Files
orion/app/modules/loyalty/schemas/card.py
Samir Boulahtit 4b56eb7ab1
Some checks failed
CI / ruff (push) Successful in 18s
CI / pytest (push) Failing after 2h37m39s
CI / validate (push) Successful in 30s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
feat(loyalty): Phase 1 production launch hardening
Phase 1 of the loyalty production launch plan: config & security
hardening, dropped-data fix, DB integrity guards, rate limiting, and
constant-time auth compare. 362 tests pass.

- 1.4 Persist customer birth_date (new column + migration). Enrollment
  form was collecting it but the value was silently dropped because
  create_customer_for_enrollment never received it. Backfills existing
  customers without overwriting.
- 1.1 LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON validated at startup (file
  must exist and be readable; ~ expanded). Adds is_google_wallet_enabled
  and is_apple_wallet_enabled derived flags. Prod path documented as
  ~/apps/orion/google-wallet-sa.json.
- 1.5 CHECK constraints on loyalty_cards (points_balance, stamp_count
  non-negative) and loyalty_programs (min_purchase, points_per_euro,
  welcome_bonus non-negative; stamps_target >= 1). Mirrored as
  CheckConstraint in models. Pre-flight scan showed zero violations.
- 1.3 @rate_limit on store mutating endpoints: stamp 60/min,
  redeem/points-earn 30-60/min, void/adjust 20/min, pin unlock 10/min.
- 1.2 Constant-time hmac.compare_digest for Apple Wallet auth token
  (pulled forward from Phase 9 — code is safe whenever Apple ships).

See app/modules/loyalty/docs/production-launch-plan.md for the full
launch plan and remaining phases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:36:34 +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
card_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