bmc_hub/app/ticket/backend/klippekort_service.py

488 lines
16 KiB
Python
Raw Permalink Normal View History

"""
Klippekort (Prepaid Time Card) Service
=======================================
Business logic for prepaid time cards: purchase, balance, deduction.
NOTE: As of migration 065, customers can have multiple active cards simultaneously.
When multiple active cards exist, operations default to the card with earliest expiry.
"""
import logging
from datetime import datetime
from decimal import Decimal
from typing import Optional, Dict, Any, List
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.ticket.backend.models import (
TPrepaidCard,
TPrepaidCardCreate,
TPrepaidCardUpdate,
TPrepaidCardWithStats,
TPrepaidTransaction,
TPrepaidTransactionCreate,
PrepaidCardStatus,
TransactionType
)
logger = logging.getLogger(__name__)
class KlippekortService:
"""Service for prepaid card operations"""
@staticmethod
def purchase_card(
card_data: TPrepaidCardCreate,
user_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Purchase a new prepaid card
Note: As of migration 065, customers can have multiple active cards simultaneously.
Args:
card_data: Card purchase data
user_id: User making purchase
Returns:
Created card dict
"""
from psycopg2.extras import Json
logger.info(f"💳 Purchasing prepaid card for customer {card_data.customer_id}: {card_data.purchased_hours}h")
# Insert card (trigger will auto-generate card_number if NULL)
card_id = execute_insert(
"""
INSERT INTO tticket_prepaid_cards (
card_number, customer_id, purchased_hours, price_per_hour, total_amount,
status, expires_at, notes, economic_invoice_number, economic_product_number,
created_by_user_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
card_data.card_number,
card_data.customer_id,
card_data.purchased_hours,
card_data.price_per_hour,
card_data.total_amount,
'active',
card_data.expires_at,
card_data.notes,
card_data.economic_invoice_number,
card_data.economic_product_number,
user_id or card_data.created_by_user_id
)
)
# Create initial transaction
execute_insert(
"""
INSERT INTO tticket_prepaid_transactions
(card_id, transaction_type, hours, balance_after, description, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
card_id,
'purchase',
card_data.purchased_hours,
card_data.purchased_hours,
f"Initial purchase: {card_data.purchased_hours}h @ {card_data.price_per_hour} DKK/h",
user_id
)
)
# Fetch created card
card = execute_query_single(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
(card_id,))
logger.info(f"✅ Created prepaid card {card['card_number']} (ID: {card_id})")
return card
@staticmethod
def get_card(card_id: int) -> Optional[Dict[str, Any]]:
"""Get prepaid card by ID"""
return execute_query_single(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
(card_id,))
@staticmethod
def get_card_with_stats(card_id: int) -> Optional[Dict[str, Any]]:
"""Get prepaid card with usage statistics"""
return execute_query_single(
"SELECT * FROM tticket_prepaid_balances WHERE id = %s",
(card_id,))
@staticmethod
def get_active_cards_for_customer(customer_id: int) -> List[Dict[str, Any]]:
"""
Get all active prepaid cards for customer (sorted by expiry)
Returns empty list if no active cards exist.
"""
cards = execute_query(
"""
SELECT * FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
ORDER BY expires_at ASC NULLS LAST, created_at ASC
""",
(customer_id,))
return cards or []
@staticmethod
def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]:
"""
Get active prepaid card for customer (defaults to earliest expiry if multiple)
Returns None if no active card exists.
"""
cards = KlippekortService.get_active_cards_for_customer(customer_id)
return cards[0] if cards else None
@staticmethod
def check_balance(customer_id: int) -> Dict[str, Any]:
"""
Check prepaid card balance for customer
Args:
customer_id: Customer ID
Returns:
Dict with balance info: {
"has_card": bool,
"card_id": int or None,
"card_number": str or None,
"balance_hours": Decimal,
"status": str or None,
"expires_at": datetime or None
}
"""
card = KlippekortService.get_active_card_for_customer(customer_id)
if not card:
return {
"has_card": False,
"card_id": None,
"card_number": None,
"balance_hours": Decimal('0'),
"status": None,
"expires_at": None
}
return {
"has_card": True,
"card_id": card['id'],
"card_number": card['card_number'],
"balance_hours": card['remaining_hours'],
"status": card['status'],
"expires_at": card['expires_at']
}
@staticmethod
def can_deduct(customer_id: int, hours: Decimal) -> tuple[bool, Optional[str]]:
"""
Check if customer has sufficient balance for deduction
Args:
customer_id: Customer ID
hours: Hours to deduct
Returns:
(can_deduct, error_message)
"""
balance_info = KlippekortService.check_balance(customer_id)
if not balance_info['has_card']:
return False, f"Customer {customer_id} has no active prepaid card"
if balance_info['status'] != 'active':
return False, f"Prepaid card is not active (status: {balance_info['status']})"
if balance_info['balance_hours'] < hours:
return False, (
f"Insufficient balance: {balance_info['balance_hours']}h available, "
f"{hours}h required"
)
# Check expiration
if balance_info['expires_at']:
if balance_info['expires_at'] < datetime.now():
return False, f"Prepaid card expired on {balance_info['expires_at']}"
return True, None
@staticmethod
def deduct_hours(
customer_id: int,
hours: Decimal,
worklog_id: int,
user_id: Optional[int] = None,
description: Optional[str] = None
) -> Dict[str, Any]:
"""
Deduct hours from customer's active prepaid card
Args:
customer_id: Customer ID
hours: Hours to deduct
worklog_id: Worklog entry consuming the hours
user_id: User performing deduction
description: Optional description
Returns:
Transaction dict
Raises:
ValueError: If insufficient balance or no active card
"""
# Get active card
card = KlippekortService.get_active_card_for_customer(customer_id)
rounding_minutes = int(card.get('rounding_minutes') or 0)
rounded_hours = hours
if rounding_minutes > 0:
interval = Decimal(rounding_minutes) / Decimal(60)
rounded_hours = (Decimal(str(hours)) / interval).to_integral_value(rounding=ROUND_CEILING) * interval
if Decimal(str(card['remaining_hours'])) < Decimal(str(rounded_hours)):
raise ValueError(f"Insufficient hours on prepaid card (Remaining: {card['remaining_hours']})")
logger.info(f"⏱️ Deducting {rounded_hours}h from card {card['card_number']} for worklog {worklog_id}")
# Update card usage
new_used = Decimal(str(card['used_hours'])) + rounded_hours
execute_update(
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
(new_used, card['id'])
)
# Calculate new balance
new_balance = Decimal(str(card['remaining_hours'])) - rounded_hours
# Create transaction
transaction_id = execute_insert(
"""
INSERT INTO tticket_prepaid_transactions
(card_id, worklog_id, transaction_type, hours, balance_after, description, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
card['id'],
worklog_id,
'usage',
-rounded_hours, # Negative for deduction
new_balance,
description or f"Worklog #{worklog_id}: {hours}h (rounded to {rounded_hours}h)",
user_id
)
)
# Check if card is now depleted
if new_balance <= Decimal('0'):
execute_update(
"UPDATE tticket_prepaid_cards SET status = 'depleted' WHERE id = %s",
(card['id'],)
)
logger.warning(f"💳 Card {card['card_number']} is now depleted")
# Fetch transaction
transaction = execute_query_single(
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
(transaction_id,))
logger.info(f"✅ Deducted {hours}h from card {card['card_number']}, new balance: {new_balance}h")
return transaction
@staticmethod
def top_up_card(
card_id: int,
hours: Decimal,
user_id: Optional[int] = None,
note: Optional[str] = None
) -> Dict[str, Any]:
"""
Add hours to existing prepaid card
Args:
card_id: Card ID
hours: Hours to add
user_id: User performing top-up
note: Optional note
Returns:
Transaction dict
Raises:
ValueError: If card not found or not active
"""
# Get card
card = KlippekortService.get_card(card_id)
if not card:
raise ValueError(f"Prepaid card {card_id} not found")
if card['status'] not in ['active', 'depleted']:
raise ValueError(f"Cannot top up card with status: {card['status']}")
logger.info(f"💰 Topping up card {card['card_number']} with {hours}h")
# Update purchased hours
new_purchased = Decimal(str(card['purchased_hours'])) + hours
execute_update(
"UPDATE tticket_prepaid_cards SET purchased_hours = %s, status = 'active' WHERE id = %s",
(new_purchased, card_id)
)
# Calculate new balance
new_balance = Decimal(str(card['remaining_hours'])) + hours
# Create transaction
transaction_id = execute_insert(
"""
INSERT INTO tticket_prepaid_transactions
(card_id, transaction_type, hours, balance_after, description, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
card_id,
'top_up',
hours, # Positive for addition
new_balance,
note or f"Top-up: {hours}h added",
user_id
)
)
transaction = execute_query_single(
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
(transaction_id,))
logger.info(f"✅ Topped up card {card['card_number']} with {hours}h, new balance: {new_balance}h")
return transaction
@staticmethod
def get_transactions(
card_id: int,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Get transaction history for card
Args:
card_id: Card ID
limit: Max number of transactions
Returns:
List of transaction dicts
"""
transactions = execute_query_single(
"""
SELECT * FROM tticket_prepaid_transactions
WHERE card_id = %s
ORDER BY created_at DESC
LIMIT %s
""",
(card_id, limit)
)
return transactions or []
@staticmethod
def list_cards(
customer_id: Optional[int] = None,
status: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[Dict[str, Any]]:
"""
List prepaid cards with optional filters
Args:
customer_id: Filter by customer
status: Filter by status
limit: Number of results
offset: Offset for pagination
Returns:
List of card dicts
"""
query = "SELECT * FROM tticket_prepaid_cards WHERE 1=1"
params = []
if customer_id:
query += " AND customer_id = %s"
params.append(customer_id)
if status:
query += " AND status = %s"
params.append(status)
query += " ORDER BY purchased_at DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
cards = execute_query(query, tuple(params))
return cards or []
@staticmethod
def cancel_card(
card_id: int,
user_id: Optional[int] = None,
reason: Optional[str] = None
) -> Dict[str, Any]:
"""
Cancel/deactivate a prepaid card
Args:
card_id: Card ID
user_id: User cancelling card
reason: Cancellation reason
Returns:
Updated card dict
Raises:
ValueError: If card not found or already cancelled
"""
card = KlippekortService.get_card(card_id)
if not card:
raise ValueError(f"Prepaid card {card_id} not found")
if card['status'] == 'cancelled':
raise ValueError(f"Card {card['card_number']} is already cancelled")
logger.info(f"❌ Cancelling card {card['card_number']}")
# Update status
execute_update(
"UPDATE tticket_prepaid_cards SET status = 'cancelled' WHERE id = %s",
(card_id,)
)
# Log transaction
execute_insert(
"""
INSERT INTO tticket_prepaid_transactions
(card_id, transaction_type, hours, balance_after, description, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
card_id,
'cancellation',
Decimal('0'),
card['remaining_hours'],
reason or "Card cancelled",
user_id
)
)
# Fetch updated card
updated = execute_query(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s",
(card_id,))
logger.info(f"✅ Cancelled card {card['card_number']}")
return updated