Files
orion/app/modules/loyalty/config.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

107 lines
3.7 KiB
Python

# app/modules/loyalty/config.py
"""
Module configuration.
Environment-based configuration using Pydantic Settings.
Settings are loaded from environment variables with LOYALTY_ prefix.
Example:
LOYALTY_DEFAULT_COOLDOWN_MINUTES=15
LOYALTY_MAX_DAILY_STAMPS=5
LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
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
class ModuleConfig(BaseSettings):
"""Configuration for loyalty module."""
# Default anti-fraud settings
default_cooldown_minutes: int = 15
max_daily_stamps: int = 5
pin_max_failed_attempts: int = 5
pin_lockout_minutes: int = 30
# Points configuration
default_points_per_euro: int = 10 # 10 points per euro spent
# Apple Wallet
apple_pass_type_id: str | None = None
apple_team_id: str | None = None
apple_wwdr_cert_path: str | None = None # Apple WWDR certificate
apple_signer_cert_path: str | None = None # Pass signing certificate
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
default_logo_url: str = "https://rewardflow.lu/static/modules/loyalty/shared/img/default-logo-200.png"
# QR code settings
qr_code_size: int = 300 # pixels
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
config = ModuleConfig()