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

@@ -0,0 +1,31 @@
"""customers 003 - add birth_date column
Adds an optional birth_date column to the customers table so that
self-enrollment flows (e.g. loyalty) can persist the customer's birthday
collected on the enrollment form. Previously the field was collected by
the UI and accepted by the loyalty service signature, but never written
anywhere — see Phase 1.4 of the loyalty production launch plan.
Revision ID: customers_003
Revises: customers_002
Create Date: 2026-04-09
"""
import sqlalchemy as sa
from alembic import op
revision = "customers_003"
down_revision = "customers_002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"customers",
sa.Column("birth_date", sa.Date(), nullable=True),
)
def downgrade() -> None:
op.drop_column("customers", "birth_date")

View File

@@ -10,6 +10,7 @@ from sqlalchemy import (
JSON,
Boolean,
Column,
Date,
ForeignKey,
Integer,
String,
@@ -34,6 +35,7 @@ class Customer(Base, TimestampMixin, SoftDeleteMixin):
first_name = Column(String(100))
last_name = Column(String(100))
phone = Column(String(50))
birth_date = Column(Date, nullable=True)
customer_number = Column(
String(100), nullable=False, index=True
) # Store-specific ID

View File

@@ -9,7 +9,7 @@ Provides schemas for:
- Admin customer management
"""
from datetime import datetime
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, EmailStr, Field, field_validator
@@ -60,6 +60,9 @@ class CustomerUpdate(BaseModel):
first_name: str | None = Field(None, min_length=1, max_length=100)
last_name: str | None = Field(None, min_length=1, max_length=100)
phone: str | None = Field(None, max_length=50)
birth_date: date | None = Field(
None, description="Date of birth (YYYY-MM-DD)"
)
marketing_consent: bool | None = None
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
@@ -71,6 +74,21 @@ class CustomerUpdate(BaseModel):
"""Convert email to lowercase."""
return v.lower() if v else None
@field_validator("birth_date")
@classmethod
def birth_date_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("birth_date must be in the past")
# Plausible human age range — guards against typos like 0001-01-01
years = (today - v).days / 365.25
if years < 13 or years > 120:
raise ValueError("birth_date implies an implausible age")
return v
class CustomerPasswordChange(BaseModel):
"""Schema for customer password change."""
@@ -108,6 +126,7 @@ class CustomerResponse(BaseModel):
first_name: str | None
last_name: str | None
phone: str | None
birth_date: date | None = None
customer_number: str
marketing_consent: bool
preferred_language: str | None
@@ -253,6 +272,7 @@ class CustomerDetailResponse(BaseModel):
first_name: str | None = None
last_name: str | None = None
phone: str | None = None
birth_date: date | None = None
customer_number: str | None = None
marketing_consent: bool | None = None
preferred_language: str | None = None
@@ -304,6 +324,7 @@ class AdminCustomerItem(BaseModel):
first_name: str | None = None
last_name: str | None = None
phone: str | None = None
birth_date: date | None = None
customer_number: str
marketing_consent: bool = False
preferred_language: str | None = None

View File

@@ -7,7 +7,7 @@ with complete store isolation.
"""
import logging
from datetime import UTC, datetime, timedelta
from datetime import UTC, date, datetime, timedelta
from typing import Any
from sqlalchemy import and_
@@ -567,6 +567,7 @@ class CustomerService:
first_name: str = "",
last_name: str = "",
phone: str | None = None,
birth_date: date | None = None,
) -> Customer:
"""
Create a customer for loyalty/external enrollment.
@@ -580,6 +581,7 @@ class CustomerService:
first_name: First name
last_name: Last name
phone: Phone number
birth_date: Date of birth (optional)
Returns:
Created Customer object
@@ -603,6 +605,7 @@ class CustomerService:
first_name=first_name,
last_name=last_name,
phone=phone,
birth_date=birth_date,
hashed_password=unusable_hash,
customer_number=cust_number,
store_id=store_id,