feat(loyalty): Phase 1 production launch hardening
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

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>
This commit is contained in:
2026-04-09 23:36:34 +02:00
parent 27ac7f3e28
commit 4b56eb7ab1
20 changed files with 848 additions and 12 deletions

View File

@@ -8,9 +8,9 @@ Merchant-based cards:
- Can be used at any store within the merchant
"""
from datetime import datetime
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
class CardEnrollRequest(BaseModel):
@@ -31,10 +31,24 @@ class CardEnrollRequest(BaseModel):
customer_phone: str | None = Field(
None, description="Phone number for self-enrollment"
)
customer_birthday: str | None = Field(
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)."""