Some checks failed
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>
107 lines
3.7 KiB
Python
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()
|