- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs. - Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups. - Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
505 lines
16 KiB
Python
505 lines
16 KiB
Python
"""
|
|
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
|