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

@@ -14,6 +14,9 @@ Usage:
from app.modules.loyalty.config import config
cooldown = config.default_cooldown_minutes
"""
import os
from pydantic import field_validator
from pydantic_settings import BaseSettings
@@ -37,6 +40,8 @@ class ModuleConfig(BaseSettings):
apple_signer_key_path: str | None = None # Pass signing key
# Google Wallet
# In production the service account JSON lives at
# ~/apps/orion/google-wallet-sa.json (app user, mode 600).
google_issuer_id: str | None = None
google_service_account_json: str | None = None # Path to service account JSON
google_wallet_origins: list[str] = [] # Allowed origins for save-to-wallet JWT
@@ -47,6 +52,54 @@ class ModuleConfig(BaseSettings):
model_config = {"env_prefix": "LOYALTY_", "env_file": ".env", "extra": "ignore"}
@field_validator("google_service_account_json")
@classmethod
def google_sa_path_must_exist(cls, v: str | None) -> str | None:
"""
When a Google Wallet service account JSON path is configured, it must
point to a file that exists and is readable. Fails fast at import time
rather than letting the first wallet API call blow up at runtime.
A leading ``~`` is expanded so deployments can use ``~/apps/orion/...``
without hardcoding the home directory.
"""
if v is None or v == "":
return v
expanded = os.path.expanduser(v)
if not os.path.isfile(expanded):
raise ValueError(
f"LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON points to "
f"'{expanded}' but no file exists at that path"
)
if not os.access(expanded, os.R_OK):
raise ValueError(
f"LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON file at "
f"'{expanded}' is not readable by the current process"
)
return expanded
@property
def is_google_wallet_enabled(self) -> bool:
"""True when both an issuer ID and a readable service account file are set."""
return bool(self.google_issuer_id and self.google_service_account_json)
@property
def is_apple_wallet_enabled(self) -> bool:
"""True when all Apple Wallet credentials are configured.
Phase 9 will add file-existence validators for the Apple cert paths;
for now this just checks that all five env vars are populated, which
gates the storefront UI from offering Apple Wallet when the platform
cannot actually generate passes.
"""
return bool(
self.apple_pass_type_id
and self.apple_team_id
and self.apple_wwdr_cert_path
and self.apple_signer_cert_path
and self.apple_signer_key_path
)
# Export for auto-discovery
config_class = ModuleConfig