""" 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