from fastapi import APIRouter, HTTPException from app.core.database import execute_query from typing import List, Optional, Dict, Any from pydantic import BaseModel from datetime import datetime, date from decimal import Decimal, ROUND_CEILING import logging logger = logging.getLogger(__name__) router = APIRouter() # Pydantic Models class PrepaidCard(BaseModel): id: Optional[int] = None card_number: str customer_id: int purchased_hours: float used_hours: float remaining_hours: float price_per_hour: float total_amount: float status: str purchased_at: Optional[datetime] = None expires_at: Optional[datetime] = None economic_invoice_number: Optional[str] = None economic_product_number: Optional[str] = None notes: Optional[str] = None rounding_minutes: Optional[int] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None class PrepaidCardCreate(BaseModel): customer_id: int purchased_hours: float price_per_hour: float expires_at: Optional[date] = None notes: Optional[str] = None rounding_minutes: Optional[int] = None class PrepaidCardRoundingUpdate(BaseModel): rounding_minutes: int def _normalize_rounding_minutes(value: Optional[int]) -> int: if value is None: return 0 return int(value) def _apply_rounding(hours: float, rounding_minutes: int) -> float: if rounding_minutes <= 0: return float(hours) interval = Decimal(rounding_minutes) / Decimal(60) raw = Decimal(str(hours)) rounded = (raw / interval).to_integral_value(rounding=ROUND_CEILING) * interval return float(rounded) @router.get("/prepaid-cards", response_model=List[Dict[str, Any]]) async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[int] = None): """ Get all prepaid cards with customer information """ try: query = """ SELECT pc.*, c.name as customer_name, c.email as customer_email, (SELECT COUNT(*) FROM tticket_prepaid_transactions WHERE card_id = pc.id) as transaction_count FROM tticket_prepaid_cards pc LEFT JOIN customers c ON pc.customer_id = c.id WHERE 1=1 """ params = [] if status: query += " AND pc.status = %s" params.append(status) if customer_id: query += " AND pc.customer_id = %s" params.append(customer_id) query += " ORDER BY pc.created_at DESC" cards = execute_query(query, tuple(params) if params else None) # Recalculate used_hours and remaining_hours from actual timelogs for each card for card in cards or []: card_id = card['id'] rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes')) # Get sag timelogs sag_logs = execute_query(""" SELECT original_hours, approved_hours FROM tmodule_times WHERE prepaid_card_id = %s """, (card_id,)) # Get ticket timelogs ticket_logs = execute_query(""" SELECT hours, rounded_hours FROM tticket_worklog WHERE prepaid_card_id = %s """, (card_id,)) # Calculate total rounded hours total_rounded = 0.0 for log in sag_logs or []: actual = float(log['original_hours']) if log.get('approved_hours'): total_rounded += float(log['approved_hours']) else: total_rounded += _apply_rounding(actual, rounding_minutes) for log in ticket_logs or []: if log.get('rounded_hours'): total_rounded += float(log['rounded_hours']) else: total_rounded += float(log['hours']) # Override with calculated values purchased = float(card['purchased_hours']) card['used_hours'] = total_rounded card['remaining_hours'] = purchased - total_rounded logger.info(f"✅ Retrieved {len(cards) if cards else 0} prepaid cards") return cards or [] except Exception as e: logger.error(f"❌ Error fetching prepaid cards: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/prepaid-cards/{card_id}", response_model=Dict[str, Any]) async def get_prepaid_card(card_id: int): """ Get a specific prepaid card with transactions """ try: result = execute_query(""" SELECT pc.*, c.name as customer_name, c.email as customer_email FROM tticket_prepaid_cards pc LEFT JOIN customers c ON pc.customer_id = c.id WHERE pc.id = %s """, (card_id,)) if not result or len(result) == 0: raise HTTPException(status_code=404, detail="Prepaid card not found") card = result[0] rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes')) # Get transactions transactions = execute_query(""" SELECT pt.*, w.ticket_id, t.subject as ticket_title FROM tticket_prepaid_transactions pt LEFT JOIN tticket_worklog w ON pt.worklog_id = w.id LEFT JOIN tticket_tickets t ON w.ticket_id = t.id WHERE pt.card_id = %s ORDER BY pt.created_at DESC """, (card_id,)) card['transactions'] = transactions or [] # Timelogs from Sag + Ticket worklog (all entries tied to this prepaid card) sag_logs = execute_query(""" SELECT tm.id, tm.sag_id, tm.worked_date, tm.description, tm.original_hours, tm.approved_hours, tm.created_at, tm.user_name, s.titel AS sag_title FROM tmodule_times tm LEFT JOIN sag_sager s ON tm.sag_id = s.id WHERE tm.prepaid_card_id = %s """, (card_id,)) ticket_logs = execute_query(""" SELECT w.id, w.ticket_id, w.work_date, w.description, w.hours, w.rounded_hours, w.created_at, t.subject AS ticket_title, t.ticket_number FROM tticket_worklog w LEFT JOIN tticket_tickets t ON w.ticket_id = t.id WHERE w.prepaid_card_id = %s """, (card_id,)) timelogs = [] for log in sag_logs or []: actual_hours = float(log['original_hours']) # Use stored approved_hours if available, else calculate rounded_hours = float(log.get('approved_hours') or 0) if log.get('approved_hours') else _apply_rounding(actual_hours, rounding_minutes) timelogs.append({ "source": "sag", "source_id": log.get("sag_id"), "source_title": log.get("sag_title"), "worked_date": log.get("worked_date"), "created_at": log.get("created_at"), "description": log.get("description"), "user_name": log.get("user_name"), "actual_hours": actual_hours, "rounded_hours": rounded_hours }) for log in ticket_logs or []: actual_hours = float(log['hours']) # Use stored rounded_hours if available, else use actual rounded_hours = float(log.get('rounded_hours') or 0) if log.get('rounded_hours') else actual_hours ticket_label = log.get("ticket_number") or log.get("ticket_id") timelogs.append({ "source": "ticket", "source_id": log.get("ticket_id"), "source_title": log.get("ticket_title") or "Ticket", "ticket_number": ticket_label, "worked_date": log.get("work_date"), "created_at": log.get("created_at"), "description": log.get("description"), "user_name": None, "actual_hours": actual_hours, "rounded_hours": rounded_hours }) timelogs.sort( key=lambda item: ( item.get("worked_date") or date.min, item.get("created_at") or datetime.min ), reverse=True ) # Calculate actual totals from timelogs (sum of rounded hours) total_rounded_hours = sum(log['rounded_hours'] for log in timelogs) purchased_hours = float(card['purchased_hours']) # Override DB values with calculated values based on actual timelogs card['used_hours'] = total_rounded_hours card['remaining_hours'] = purchased_hours - total_rounded_hours card['timelogs'] = timelogs card['rounding_minutes'] = rounding_minutes return card except HTTPException: raise except Exception as e: logger.error(f"❌ Error fetching prepaid card {card_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/prepaid-cards", response_model=Dict[str, Any]) async def create_prepaid_card(card: PrepaidCardCreate): """ Create a new prepaid card Note: As of migration 065, customers can have multiple active cards simultaneously. """ try: rounding_minutes = _normalize_rounding_minutes(card.rounding_minutes) if rounding_minutes not in (0, 15, 30, 60): raise HTTPException(status_code=400, detail="Invalid rounding minutes") # Calculate total amount total_amount = card.purchased_hours * card.price_per_hour # Create card (need to use fetch=False for INSERT RETURNING) conn = None try: from app.core.database import get_db_connection, release_db_connection from psycopg2.extras import RealDictCursor conn = get_db_connection() with conn.cursor(cursor_factory=RealDictCursor) as cursor: cursor.execute(""" INSERT INTO tticket_prepaid_cards (customer_id, purchased_hours, price_per_hour, total_amount, expires_at, notes, rounding_minutes) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING * """, ( card.customer_id, card.purchased_hours, card.price_per_hour, total_amount, card.expires_at, card.notes , rounding_minutes )) conn.commit() result = cursor.fetchone() logger.info(f"✅ Created prepaid card: {result['card_number']}") return result finally: if conn: release_db_connection(conn) except HTTPException: raise except Exception as e: logger.error(f"❌ Error creating prepaid card: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put("/prepaid-cards/{card_id}/status") async def update_card_status(card_id: int, status: str): """ Update prepaid card status (cancel, reactivate) """ try: if status not in ['active', 'cancelled']: raise HTTPException(status_code=400, detail="Invalid status") conn = None try: from app.core.database import get_db_connection, release_db_connection from psycopg2.extras import RealDictCursor conn = get_db_connection() with conn.cursor(cursor_factory=RealDictCursor) as cursor: cursor.execute(""" UPDATE tticket_prepaid_cards SET status = %s WHERE id = %s RETURNING * """, (status, card_id)) conn.commit() result = cursor.fetchone() if not result: raise HTTPException(status_code=404, detail="Card not found") logger.info(f"✅ Updated card {card_id} status to {status}") return result finally: if conn: release_db_connection(conn) except HTTPException: raise except Exception as e: logger.error(f"❌ Error updating card status: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put("/prepaid-cards/{card_id}/rounding", response_model=Dict[str, Any]) async def update_card_rounding(card_id: int, payload: PrepaidCardRoundingUpdate): """ Update rounding interval for a prepaid card (minutes) """ try: rounding_minutes = _normalize_rounding_minutes(payload.rounding_minutes) if rounding_minutes not in (0, 15, 30, 60): raise HTTPException(status_code=400, detail="Invalid rounding minutes") result = execute_query( """ UPDATE tticket_prepaid_cards SET rounding_minutes = %s WHERE id = %s RETURNING * """, (rounding_minutes, card_id) ) if not result: raise HTTPException(status_code=404, detail="Card not found") return result[0] except HTTPException: raise except Exception as e: logger.error(f"❌ Error updating rounding for card {card_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.delete("/prepaid-cards/{card_id}") async def delete_prepaid_card(card_id: int): """ Delete a prepaid card (only if no transactions) """ try: # Check for transactions transactions = execute_query(""" SELECT COUNT(*) as count FROM tticket_prepaid_transactions WHERE card_id = %s """, (card_id,)) if transactions and len(transactions) > 0 and transactions[0]['count'] > 0: raise HTTPException( status_code=400, detail="Cannot delete card with transactions" ) execute_query("DELETE FROM tticket_prepaid_cards WHERE id = %s", (card_id,), fetch=False) logger.info(f"✅ Deleted prepaid card {card_id}") return {"message": "Card deleted successfully"} except HTTPException: raise except Exception as e: logger.error(f"❌ Error deleting card: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/prepaid-cards/stats/summary", response_model=Dict[str, Any]) async def get_prepaid_stats(): """ Get prepaid cards statistics (calculated from actual timelogs) """ try: # Get all cards cards = execute_query(""" SELECT id, status, purchased_hours, total_amount, rounding_minutes FROM tticket_prepaid_cards """) stats = { 'active_count': 0, 'depleted_count': 0, 'expired_count': 0, 'cancelled_count': 0, 'total_remaining_hours': 0.0, 'total_used_hours': 0.0, 'total_purchased_hours': 0.0, 'total_revenue': 0.0 } for card in cards or []: card_id = card['id'] status = card['status'] purchased = float(card['purchased_hours']) rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes')) # Count by status if status == 'active': stats['active_count'] += 1 elif status == 'depleted': stats['depleted_count'] += 1 elif status == 'expired': stats['expired_count'] += 1 elif status == 'cancelled': stats['cancelled_count'] += 1 # Calculate actual used hours from timelogs sag_logs = execute_query(""" SELECT original_hours, approved_hours FROM tmodule_times WHERE prepaid_card_id = %s """, (card_id,)) ticket_logs = execute_query(""" SELECT hours, rounded_hours FROM tticket_worklog WHERE prepaid_card_id = %s """, (card_id,)) used = 0.0 for log in sag_logs or []: if log.get('approved_hours'): used += float(log['approved_hours']) else: used += _apply_rounding(float(log['original_hours']), rounding_minutes) for log in ticket_logs or []: if log.get('rounded_hours'): used += float(log['rounded_hours']) else: used += float(log['hours']) remaining = purchased - used stats['total_purchased_hours'] += purchased stats['total_used_hours'] += used stats['total_revenue'] += float(card['total_amount']) if status == 'active': stats['total_remaining_hours'] += remaining return stats except Exception as e: logger.error(f"❌ Error fetching prepaid stats: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e))