Files
orion/app/modules/loyalty/services/card_service.py
Samir Boulahtit 228163d920 feat(arch): add API-007 rule to enforce layered architecture
Add architecture rule that detects when API routes import database
models directly, enforcing Routes → Services → Models pattern.

Changes:
- Add API-007 rule to .architecture-rules/api.yaml
- Add _check_no_model_imports() validation to validator script
- Update customer imports to use canonical module location
- Add storefront module restructure implementation plan

The validator now detects 81 violations across 67 API files where
database models are imported directly instead of going through
services. This is Phase 1 of the storefront restructure plan.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:23:00 +01:00

349 lines
10 KiB
Python

# app/modules/loyalty/services/card_service.py
"""
Loyalty card service.
Handles card operations including:
- Customer enrollment
- Card lookup (by ID, QR code, card number)
- Card management (activation, deactivation)
"""
import logging
from datetime import UTC, datetime
from sqlalchemy.orm import Session, joinedload
from app.modules.loyalty.exceptions import (
LoyaltyCardAlreadyExistsException,
LoyaltyCardNotFoundException,
LoyaltyProgramInactiveException,
LoyaltyProgramNotFoundException,
)
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction, TransactionType
logger = logging.getLogger(__name__)
class CardService:
"""Service for loyalty card operations."""
# =========================================================================
# Read Operations
# =========================================================================
def get_card(self, db: Session, card_id: int) -> LoyaltyCard | None:
"""Get a loyalty card by ID."""
return (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program))
.filter(LoyaltyCard.id == card_id)
.first()
)
def get_card_by_qr_code(self, db: Session, qr_code: str) -> LoyaltyCard | None:
"""Get a loyalty card by QR code data."""
return (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program))
.filter(LoyaltyCard.qr_code_data == qr_code)
.first()
)
def get_card_by_number(self, db: Session, card_number: str) -> LoyaltyCard | None:
"""Get a loyalty card by card number."""
return (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program))
.filter(LoyaltyCard.card_number == card_number)
.first()
)
def get_card_by_customer_and_program(
self,
db: Session,
customer_id: int,
program_id: int,
) -> LoyaltyCard | None:
"""Get a customer's card for a specific program."""
return (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program))
.filter(
LoyaltyCard.customer_id == customer_id,
LoyaltyCard.program_id == program_id,
)
.first()
)
def require_card(self, db: Session, card_id: int) -> LoyaltyCard:
"""Get a card or raise exception if not found."""
card = self.get_card(db, card_id)
if not card:
raise LoyaltyCardNotFoundException(str(card_id))
return card
def lookup_card(
self,
db: Session,
*,
card_id: int | None = None,
qr_code: str | None = None,
card_number: str | None = None,
) -> LoyaltyCard:
"""
Look up a card by any identifier.
Args:
db: Database session
card_id: Card ID
qr_code: QR code data
card_number: Card number
Returns:
Found card
Raises:
LoyaltyCardNotFoundException: If no card found
"""
card = None
if card_id:
card = self.get_card(db, card_id)
elif qr_code:
card = self.get_card_by_qr_code(db, qr_code)
elif card_number:
card = self.get_card_by_number(db, card_number)
if not card:
identifier = card_id or qr_code or card_number or "unknown"
raise LoyaltyCardNotFoundException(str(identifier))
return card
def list_cards(
self,
db: Session,
vendor_id: int,
*,
skip: int = 0,
limit: int = 50,
is_active: bool | None = None,
search: str | None = None,
) -> tuple[list[LoyaltyCard], int]:
"""
List loyalty cards for a vendor.
Args:
db: Database session
vendor_id: Vendor ID
skip: Pagination offset
limit: Pagination limit
is_active: Filter by active status
search: Search by card number or customer email
Returns:
(cards, total_count)
"""
from app.modules.customers.models.customer import Customer
query = (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.customer))
.filter(LoyaltyCard.vendor_id == vendor_id)
)
if is_active is not None:
query = query.filter(LoyaltyCard.is_active == is_active)
if search:
query = query.join(Customer).filter(
(LoyaltyCard.card_number.ilike(f"%{search}%"))
| (Customer.email.ilike(f"%{search}%"))
| (Customer.first_name.ilike(f"%{search}%"))
| (Customer.last_name.ilike(f"%{search}%"))
)
total = query.count()
cards = (
query.order_by(LoyaltyCard.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return cards, total
def list_customer_cards(
self,
db: Session,
customer_id: int,
) -> list[LoyaltyCard]:
"""List all loyalty cards for a customer."""
return (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program))
.filter(LoyaltyCard.customer_id == customer_id)
.all()
)
# =========================================================================
# Write Operations
# =========================================================================
def enroll_customer(
self,
db: Session,
customer_id: int,
vendor_id: int,
*,
program_id: int | None = None,
) -> LoyaltyCard:
"""
Enroll a customer in a loyalty program.
Args:
db: Database session
customer_id: Customer ID
vendor_id: Vendor ID
program_id: Optional program ID (defaults to vendor's program)
Returns:
Created loyalty card
Raises:
LoyaltyProgramNotFoundException: If no program exists
LoyaltyProgramInactiveException: If program is inactive
LoyaltyCardAlreadyExistsException: If customer already enrolled
"""
# Get the program
if program_id:
program = (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.id == program_id)
.first()
)
else:
program = (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.vendor_id == vendor_id)
.first()
)
if not program:
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}")
if not program.is_active:
raise LoyaltyProgramInactiveException(program.id)
# Check if customer already has a card
existing = self.get_card_by_customer_and_program(db, customer_id, program.id)
if existing:
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
# Create the card
card = LoyaltyCard(
customer_id=customer_id,
program_id=program.id,
vendor_id=vendor_id,
)
db.add(card)
db.flush() # Get the card ID
# Create enrollment transaction
transaction = LoyaltyTransaction(
card_id=card.id,
vendor_id=vendor_id,
transaction_type=TransactionType.CARD_CREATED.value,
transaction_at=datetime.now(UTC),
)
db.add(transaction)
db.commit()
db.refresh(card)
logger.info(
f"Enrolled customer {customer_id} in loyalty program {program.id} "
f"(card: {card.card_number})"
)
return card
def deactivate_card(self, db: Session, card_id: int) -> LoyaltyCard:
"""Deactivate a loyalty card."""
card = self.require_card(db, card_id)
card.is_active = False
# Create deactivation transaction
transaction = LoyaltyTransaction(
card_id=card.id,
vendor_id=card.vendor_id,
transaction_type=TransactionType.CARD_DEACTIVATED.value,
transaction_at=datetime.now(UTC),
)
db.add(transaction)
db.commit()
db.refresh(card)
logger.info(f"Deactivated loyalty card {card_id}")
return card
def reactivate_card(self, db: Session, card_id: int) -> LoyaltyCard:
"""Reactivate a deactivated loyalty card."""
card = self.require_card(db, card_id)
card.is_active = True
db.commit()
db.refresh(card)
logger.info(f"Reactivated loyalty card {card_id}")
return card
# =========================================================================
# Helpers
# =========================================================================
def get_stamps_today(self, db: Session, card_id: int) -> int:
"""Get number of stamps earned today for a card."""
from sqlalchemy import func
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
count = (
db.query(func.count(LoyaltyTransaction.id))
.filter(
LoyaltyTransaction.card_id == card_id,
LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value,
LoyaltyTransaction.transaction_at >= today_start,
)
.scalar()
)
return count or 0
def get_card_transactions(
self,
db: Session,
card_id: int,
*,
skip: int = 0,
limit: int = 50,
) -> tuple[list[LoyaltyTransaction], int]:
"""Get transaction history for a card."""
query = (
db.query(LoyaltyTransaction)
.filter(LoyaltyTransaction.card_id == card_id)
.order_by(LoyaltyTransaction.transaction_at.desc())
)
total = query.count()
transactions = query.offset(skip).limit(limit).all()
return transactions, total
# Singleton instance
card_service = CardService()