Files
orion/app/modules/loyalty/schemas/card.py
Samir Boulahtit 64fe58c171
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
fix(loyalty): normalize card id field, fix terminal redeem bug
The terminal redeem failed with "card not found: unknown" because
CardLookupResponse used card_id while CardDetailResponse (from
refreshCard) used id. After refresh, selectedCard.card_id was
undefined.

Fix: standardize on 'id' everywhere (the universal convention):
- CardLookupResponse: card_id → id
- _build_card_lookup_response: card_id= → id=
- loyalty-terminal.js: selectedCard.card_id → selectedCard.id (5 refs)
- Removed the card_id/model_validator approach as unnecessary

Also fixes Chart.js recursion error on analytics page (inline CDN
script instead of optional-libs.html include which caused infinite
template recursion in test context).

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:01:26 +02:00

208 lines
5.0 KiB
Python

# app/modules/loyalty/schemas/card.py
"""
Pydantic schemas for loyalty card operations.
Merchant-based cards:
- Cards belong to a merchant's loyalty program
- One card per customer per merchant
- Can be used at any store within the merchant
"""
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
class CardEnrollRequest(BaseModel):
"""Schema for enrolling a customer in a loyalty program."""
customer_id: int | None = Field(
None,
description="Customer ID (required for store API, optional for public enrollment)",
)
email: str | None = Field(
None,
description="Customer email (for public enrollment without customer_id)",
)
# Self-enrollment fields (used to create customer if not found)
customer_name: str | None = Field(
None, description="Full name for self-enrollment"
)
customer_phone: str | None = Field(
None, description="Phone number for self-enrollment"
)
customer_birthday: date | None = Field(
None, description="Birthday (YYYY-MM-DD) for self-enrollment"
)
@field_validator("customer_birthday")
@classmethod
def birthday_sane(cls, v: date | None) -> date | None:
"""Birthday must be in the past and within a plausible age range."""
if v is None:
return v
today = date.today()
if v >= today:
raise ValueError("customer_birthday must be in the past")
years = (today - v).days / 365.25
if years < 13 or years > 120:
raise ValueError("customer_birthday implies an implausible age")
return v
class CardResponse(BaseModel):
"""Schema for loyalty card response (summary)."""
model_config = ConfigDict(from_attributes=True)
id: int
card_number: str
customer_id: int
merchant_id: int
program_id: int
enrolled_at_store_id: int | None = None
# Customer info (for list views)
customer_name: str | None = None
customer_email: str | None = None
# Stamps
stamp_count: int
stamps_target: int # From program
stamps_until_reward: int
total_stamps_earned: int
stamps_redeemed: int
# Points
points_balance: int
total_points_earned: int
points_redeemed: int
# Status
is_active: bool
created_at: datetime
# Wallet
has_google_wallet: bool = False
has_apple_wallet: bool = False
class CardDetailResponse(CardResponse):
"""Schema for detailed loyalty card response."""
# QR code
qr_code_data: str
qr_code_url: str | None = None # Generated QR code image URL
# Customer info
customer_name: str | None = None
customer_email: str | None = None
# Merchant info
merchant_name: str | None = None
# Program info
program_name: str
program_type: str
reward_description: str | None = None
# Activity
last_stamp_at: datetime | None = None
last_points_at: datetime | None = None
last_redemption_at: datetime | None = None
last_activity_at: datetime | None = None
# Wallet URLs
google_wallet_url: str | None = None
apple_wallet_url: str | None = None
class CardListResponse(BaseModel):
"""Schema for listing loyalty cards."""
cards: list[CardResponse]
total: int
class CardLookupResponse(BaseModel):
"""Schema for card lookup by QR code or card number."""
# Card info
id: int
card_number: str
# Customer
customer_id: int
customer_name: str | None = None
customer_email: str
# Merchant context
merchant_id: int
merchant_name: str | None = None
# Current balances
stamp_count: int
stamps_target: int
stamps_until_reward: int
points_balance: int
# Can redeem?
can_redeem_stamps: bool = False
stamp_reward_description: str | None = None
# Available points rewards
available_rewards: list[dict] = []
# Cooldown status
can_stamp: bool = True
cooldown_ends_at: datetime | None = None
# Today's activity
stamps_today: int = 0
max_daily_stamps: int = 5
can_earn_more_stamps: bool = True
class TransactionResponse(BaseModel):
"""Schema for a loyalty transaction."""
model_config = ConfigDict(from_attributes=True)
id: int
card_id: int
store_id: int | None = None
store_name: str | None = None
transaction_type: str
# Deltas
stamps_delta: int = 0
points_delta: int = 0
# Balances after
stamps_balance_after: int | None = None
points_balance_after: int | None = None
# Context
purchase_amount_cents: int | None = None
order_reference: str | None = None
reward_id: str | None = None
reward_description: str | None = None
notes: str | None = None
# Customer
customer_name: str | None = None
# Staff
staff_name: str | None = None
# Timestamps
transaction_at: datetime
created_at: datetime
class TransactionListResponse(BaseModel):
"""Schema for listing transactions."""
transactions: list[TransactionResponse]
total: int