""" Klippekort (Prepaid Time Card) Service ======================================= Business logic for prepaid time cards: purchase, balance, deduction. CONSTRAINT: Only 1 active card per customer (enforced by database UNIQUE index). """ 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 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 CONSTRAINT: Only 1 active card allowed per customer. This will fail if customer already has an active card. Args: card_data: Card purchase data user_id: User making purchase Returns: Created card dict Raises: ValueError: If customer already has active card """ from psycopg2.extras import Json # Check if customer already has an active card existing = execute_query( """ SELECT id, card_number FROM tticket_prepaid_cards WHERE customer_id = %s AND status = 'active' """, (card_data.customer_id,), fetchone=True ) if existing: raise ValueError( f"Customer {card_data.customer_id} already has an active card: {existing['card_number']}. " "Please deactivate or deplete the existing card before purchasing a new one." ) 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( "SELECT * FROM tticket_prepaid_cards WHERE id = %s", (card_id,), fetchone=True ) 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( "SELECT * FROM tticket_prepaid_cards WHERE id = %s", (card_id,), fetchone=True ) @staticmethod def get_card_with_stats(card_id: int) -> Optional[Dict[str, Any]]: """Get prepaid card with usage statistics""" return execute_query( "SELECT * FROM tticket_prepaid_balances WHERE id = %s", (card_id,), fetchone=True ) @staticmethod def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]: """ Get active prepaid card for customer Returns None if no active card exists. """ return execute_query( """ SELECT * FROM tticket_prepaid_cards WHERE customer_id = %s AND status = 'active' """, (customer_id,), fetchone=True ) @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 """ # Check if deduction is possible can_deduct, error = KlippekortService.can_deduct(customer_id, hours) if not can_deduct: raise ValueError(error) # Get active card card = KlippekortService.get_active_card_for_customer(customer_id) logger.info(f"⏱️ Deducting {hours}h from card {card['card_number']} for worklog {worklog_id}") # Update card usage new_used = Decimal(str(card['used_hours'])) + 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'])) - 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', -hours, # Negative for deduction new_balance, description or f"Worklog #{worklog_id}: {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( "SELECT * FROM tticket_prepaid_transactions WHERE id = %s", (transaction_id,), fetchone=True ) 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( "SELECT * FROM tticket_prepaid_transactions WHERE id = %s", (transaction_id,), fetchone=True ) 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( """ 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,), fetchone=True ) logger.info(f"✅ Cancelled card {card['card_number']}") return updated