Some checks failed
- Fix "Enrolled at: Unknown" by resolving enrolled_at_store_name from the store service and adding it to CardDetailResponse schema. - Add clipboard-copy buttons next to card number, customer name, email, and phone fields using the shared Utils.copyToClipboard() utility with toast feedback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
209 lines
5.1 KiB
Python
209 lines
5.1 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
|
|
enrolled_at_store_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
|