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

@@ -228,7 +228,10 @@ R2_BACKUP_BUCKET=orion-backups
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
# Get Issuer ID from https://pay.google.com/business/console
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
# Production convention: ~/apps/orion/google-wallet-sa.json (app user, mode 600).
# Path is validated at startup — file must exist and be readable, otherwise
# the app fails fast at import time.
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=~/apps/orion/google-wallet-sa.json
# LOYALTY_GOOGLE_WALLET_ORIGINS=["https://yourdomain.com"]
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png

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,

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

View File

@@ -0,0 +1,330 @@
# Loyalty Module — Production Launch Plan
**Created:** 2026-04-09
**Owner:** Samir
**Status:** Planning locked, awaiting Phase 1 kickoff
This is the active execution plan for taking the Loyalty module to production. It complements the earlier [Production Readiness review](production-readiness.md) by sequencing the work into phases and recording the decisions made.
---
## Locked Decisions
| # | Topic | Decision |
|---|---|---|
| 1 | Apple Wallet | **Deferred to Phase 9 (post-launch)**. Google Wallet only at launch. |
| 2 | Secrets path | Google service account JSON lives at `~/apps/orion/google-wallet-sa.json` in production (app user, mode 600). Different from Caddy TLS certs at `/etc/caddy/certs/`. |
| 3 | Notification channels | **Email only.** No SMS at launch. |
| 4 | Notification scope | Enrollment, welcome bonus, points-expiring (14d warning), points-expired, reward-ready, **birthday**, **re-engagement** (inactive). 7 templates × 4 locales = 28 seed rows. |
| 5 | T&C strategy | **CMS integration scoped to the program's owning store.** New `terms_cms_page_slug` column on `loyalty_programs`. Legacy `terms_text` kept one release as fallback. |
| 6 | Coverage target | **80%** on `app/modules/loyalty/*` (enforced in CI for loyalty job only). |
| 7 | GDPR deletion | **Anonymize** (null `customer_id`, scrub PII fields, keep aggregate transactions). |
| 8 | Bulk actions | Primary endpoints on **merchant router**; admin "act on behalf of" endpoints under admin router accept `merchant_id` and stamp `acting_admin_id` in audit log. |
---
## Verified Reuse (no new infra)
| Need | Existing component |
|---|---|
| Email | `app/modules/messaging/services/email_service.py` — multi-provider, DB templates, 4 locales, EmailLog. SMTP enabled. |
| Background tasks | **Celery** (`@shared_task`, beat schedule in `app/modules/loyalty/definition.py`). |
| Rate limiting | `middleware/decorators.py @rate_limit` decorator (already used in `routes/api/storefront.py`). |
| Migrations | Alembic loyalty branch (`loyalty_001`, `loyalty_002`). |
| pytest-cov | Wired in `pyproject.toml`; currently `--cov-fail-under=0` (disabled). |
---
## Migration Chain
```
loyalty_002 (existing)
loyalty_003 — CHECK constraints (points_balance >= 0, stamp_count >= 0, etc.)
loyalty_004 — seed 28 notification email templates
loyalty_005 — add columns: last_expiration_warning_at, last_reengagement_at on cards;
acting_admin_id on transactions
loyalty_006 — terms_cms_page_slug on programs
loyalty_007 — birth_date on customers (P0 — Phase 1.4 fix, dropped data bug)
```
---
## Phases
### Phase 0 — Decisions & prerequisites *(done)*
All 8 decisions locked. No external blockers.
### Phase 1 — Config, Secrets & Security Hardening *(✅ DONE 2026-04-09)*
#### 1.1 Google Wallet config cleanup
- Move `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` from dev path to `~/apps/orion/google-wallet-sa.json` in prod `.env`.
- Add Pydantic `@field_validator` in `app/modules/loyalty/config.py:39-43` that checks file exists and is readable when `google_issuer_id` is set. Fail fast at import time if not.
- **Validation:** unit test using `tmp_path`; manual: missing file → clear startup error.
#### 1.2 Apple Wallet timing-safe token compare *(pull-forward from Phase 9)*
- Replace `!=` with `hmac.compare_digest()` at `app/modules/loyalty/services/apple_wallet_service.py:98`.
- 15 min, code is safe whenever Apple ships.
- **Validation:** unit test with wrong token.
#### 1.3 Rate-limit store mutating endpoints
- Apply `@rate_limit` to `routes/api/store.py:600-795`:
- `POST /stamp` 60/min
- `POST /stamp/redeem` 30/min
- `POST /stamp/void` 20/min
- `POST /points/earn` 60/min
- `POST /points/redeem` 30/min
- `POST /points/void` 20/min
- `POST /cards/{card_id}/points/adjust` 20/min
- `POST /pins/{pin_id}/unlock` 10/min
- Caps confirmed.
- **Validation:** httpx loop exceeds limit → 429.
#### 1.4 Fix dropped birthday data (P0 bug)
**Background:** The enrollment form (`enroll.html:87`) collects a birthday, the schema accepts `customer_birthday` (`schemas/card.py:34`), and `card_service.resolve_customer_id` has the parameter (`card_service.py:172`) — but the call to `customer_service.create_customer_for_enrollment` at `card_service.py:215-222` does not pass it, and the `Customer` model has no `birth_date` column at all. Every enrollment silently loses the birthday. Not live yet, so no backfill needed.
- New migration `loyalty_007_add_customer_birth_date.py` (or place under customers module if that's the convention) adds `birth_date: Date | None` to `customers`.
- Update `customer_service.create_customer_for_enrollment` to accept and persist `birth_date`.
- Update `card_service.py:215-222` to pass `customer_birthday` through, with `date.fromisoformat()` parsing and validation (must be a real past date, sensible age range).
- Update `customer_service.update_customer` to allow backfill.
- Add unit + integration tests for enrollment-with-birthday and enrollment-without-birthday paths.
- **Validation:** enroll with birthday → row in `customers` has `birth_date` set; enrollment without birthday still works.
#### 1.5 DB CHECK constraints migration `loyalty_003`
- Add: `loyalty_cards.points_balance >= 0`, `loyalty_cards.stamp_count >= 0`, `loyalty_programs.minimum_purchase_cents >= 0`, `loyalty_programs.points_per_euro >= 0`, `loyalty_programs.stamps_target >= 1`, `loyalty_programs.welcome_bonus_points >= 0`.
- Mirror as `CheckConstraint` in models.
- Pre-flight: scan dev DB for existing violations; include data fix in `upgrade()` if found.
- **Validation:** migration runs clean; direct UPDATE to negative value fails.
---
### Phase 2 — Notifications Infrastructure *(4d)*
#### 2.1 `LoyaltyNotificationService`
- New `app/modules/loyalty/services/notification_service.py` with methods:
- `send_enrollment_confirmation(db, card)`
- `send_welcome_bonus(db, card, points)` *(may merge into enrollment)*
- `send_points_expiration_warning(db, card, days_before, expiring_points, expiration_date)`
- `send_points_expired(db, card, expired_points)`
- `send_reward_available(db, card, reward_description)`
- `send_birthday_message(db, card)`
- `send_reengagement(db, card, days_inactive)`
- All methods enqueue via Celery (2.2), never call EmailService inline.
#### 2.2 Celery dispatch task
- New `app/modules/loyalty/tasks/notifications.py` with `@shared_task(name="loyalty.send_notification_email", bind=True, max_retries=3, default_retry_delay=60)`.
- Opens fresh `SessionLocal`, calls `EmailService(db).send_template(...)`, retries on SMTP errors.
#### 2.3 Seed templates `loyalty_004`
- 7 templates × 4 locales (en, fr, de, lb) = **28 rows** in `email_templates`.
- Template codes: `loyalty_enrollment`, `loyalty_welcome_bonus`, `loyalty_points_expiring`, `loyalty_points_expired`, `loyalty_reward_ready`, `loyalty_birthday`, `loyalty_reengagement`.
- **Copywriting needs sign-off** before applying to prod.
#### 2.4 Wire enrollment confirmation
- In `card_service.enroll_customer_for_store` (~lines 480-540), call notification service **after** `db.commit()`.
#### 2.5 Wire expiration warning into expiration task
- Migration `loyalty_005` adds `last_expiration_warning_at` to prevent duplicates.
- In rewritten `tasks/point_expiration.py` (see 3.1), find cards 14 days from expiry, fire warning, stamp timestamp.
- **Validation:** time-mocked test — fires once at 14-day mark.
#### 2.6 Wire reward-ready notification
- In `services/stamp_service.add_stamp`, after commit, if `card.stamp_count == program.stamps_target`, dispatch.
#### 2.7 Birthday Celery beat task
- New `@shared_task loyalty.send_birthday_emails`, daily `0 8 * * *` in `definition.py`.
- Queries cards where `customer.birth_date` matches today (month/day), respects timezone.
- Depends on Phase 1.4 (column + persistence already in place).
#### 2.8 Re-engagement Celery beat task
- Weekly schedule. Finds cards inactive > N days (default 60, configurable).
- Throttled via `last_reengagement_at` (added in `loyalty_005`) — once per quarter per card.
---
### Phase 3 — Task Reliability *(1.5d)*
#### 3.1 Batched point expiration
- Rewrite `tasks/point_expiration.py:154-185` from per-card loop to set-based SQL:
- Per program, chunk card IDs with `LIMIT 500 FOR UPDATE SKIP LOCKED`
- Build expiration `loyalty_transactions` rows in bulk per chunk
- `UPDATE loyalty_cards SET points_balance=0, total_points_voided=total_points_voided+points_balance WHERE id = ANY(...)`
- Commit per chunk inside savepoint
- Done same pass as 2.5 (warning logic).
- **Validation:** seed 10k cards, time the run, verify flat memory and chunked commits.
#### 3.2 Wallet sync exponential backoff
- Replace `time.sleep(2)` retry at `tasks/wallet_sync.py:71-95` with exponential backoff `[1s, 4s, 16s]`.
- Per-card try/except (one bad card doesn't kill the batch).
- Log `failed_card_ids` to observability.
- Use `tenacity` if available, otherwise small helper.
---
### Phase 4 — Accessibility & T&C *(2d)*
#### 4.1 T&C via store CMS integration
- Migration `loyalty_006`: add `terms_cms_page_slug: str | None` to `loyalty_programs`.
- Update `schemas/program.py` (`ProgramCreate`, `ProgramUpdate`).
- `program-form.html:251` — CMS page picker scoped to the program's owning store.
- `enroll.html:99-160` — resolve slug to CMS page URL/content; legacy `terms_text` fallback.
- Service layer: `program_service.py:491,948`.
- JS: `loyalty-program-form.js:34,75,106`.
- Pre-flight: confirm CMS module exposes "get pages by store" API.
#### 4.2 Accessibility audit
- All loyalty templates: icon-only buttons → `aria-label`; modals → `role="dialog"` + `aria-modal="true"` + `aria-labelledby` + focus trap + Escape key + return-focus-on-close.
- PIN entry modal: `inputmode="numeric"`, `autocomplete="one-time-code"`, `aria-live="polite"` for errors.
- Pattern reference: existing Escape handler in `enroll.html:147`.
- **Validation:** axe-core on the 5 main loyalty pages, zero critical violations.
---
### Phase 5 — Google Wallet Production Hardening *(1d)*
#### 5.1 Cert deployment
- Place service account JSON at `~/apps/orion/google-wallet-sa.json`, app user, mode 600.
- `.env` line 196 updated.
- Startup validators from 1.1 confirm load.
#### 5.2 Conditional UI flag
- Storefront API returns `google_wallet_enabled: bool` (derived from `config.is_google_wallet_enabled`).
- `enroll.html:99-125`: hide Google button when disabled.
- Apple Wallet button removed entirely (or hidden behind feature flag for Phase 9 reintroduction).
#### 5.3 End-to-end real-device test
- Android device → enroll → "Add to Google Wallet" → verify pass renders → trigger stamp → verify pass auto-updates within ~60s.
---
### Phase 6 — Admin UX, GDPR, Bulk *(3d)*
#### 6.1 Admin trash UI
- Trash tab on programs list and cards list, calling existing `?only_deleted=true` API.
- Restore button per row + hard-delete destructive action.
- Templates: `templates/loyalty/admin/programs.html`, `cards.html` + Alpine JS.
#### 6.2 Cascade restore
- `program_service.restore_for_merchant(db, merchant_id)` — restores soft-deleted programs and cards under a merchant.
- Hooked into tenancy module's restore flow via event subscriber or direct call.
- **Validation:** soft-delete merchant + 2 programs + 50 cards; restore merchant; all 52 rows un-deleted.
#### 6.3 Customer GDPR delete (anonymize)
- `DELETE /api/v1/loyalty/cards/customer/{customer_id}` on admin router.
- New service method `card_service.anonymize_cards_for_customer`:
- `card.customer_id = NULL`
- Scrub PII fields, scrub `notes` if PII suspected
- Keep transaction rows (aggregate data)
- Audit log entry with admin ID
#### 6.4 Bulk endpoints
- **Merchant router** (`routes/api/merchant.py`):
- `POST /merchants/loyalty/cards/bulk/deactivate``{card_ids, reason}`
- `POST /merchants/loyalty/pins/bulk/assign``{store_id, pins}`
- `POST /merchants/loyalty/cards/bulk/bonus``{card_ids | filter, points, reason}`, fires notifications
- **Admin "act on behalf"** (`routes/api/admin.py`):
- `POST /admin/loyalty/merchants/{merchant_id}/cards/bulk/deactivate` (etc.)
- Shared service layer; route stamps `acting_admin_id` in audit log
- New `loyalty_transactions.acting_admin_id` column in `loyalty_005`.
#### 6.5 Manual override: restore expired points
- `POST /admin/loyalty/cards/{card_id}/restore_points` with `{points, reason}`.
- Creates `ADMIN_ADJUSTMENT` transaction via existing `points_service.adjust_points`.
- UI button on admin card detail page.
---
### Phase 7 — Advanced Analytics *(2.5d)*
#### 7.1 Cohort retention
- New `services/analytics_service.py` (or extend `program_service`).
- Group cards by enrollment month, track % active (transaction in last 30d) per subsequent month.
- Endpoint `/merchants/loyalty/analytics/cohorts` returns matrix.
- Chart.js heatmap on `analytics.html`.
#### 7.2 Simple churn rule
- "At risk" = inactive > 2× normal inter-transaction interval.
- Expose count + list.
#### 7.3 Revenue attribution
- Join `loyalty_transactions` (where `points_earned.order_reference` set) with orders module.
- Loyalty-customer revenue vs non-loyalty per store per month.
- **Cross-module rule:** use orders module's query helper, do not reach into its tables directly.
---
### Phase 8 — Tests, Docs, Observability *(2d)*
#### 8.1 Coverage enforcement
- Loyalty CI job: `pytest app/modules/loyalty/tests --cov=app/modules/loyalty --cov-fail-under=80`.
- Audit current with `--cov-report=term-missing`, fill biggest gaps (notifications, batched expiration, rate-limited endpoints).
#### 8.2 OpenAPI polish
- Audit all loyalty routes for `summary`, `description`, `response_model`, example payloads, `tags=["Loyalty - Store"]` etc.
- Export static `loyalty-openapi.json` under `app/modules/loyalty/docs/`.
#### 8.3 Runbooks
Three new docs under `docs/modules/loyalty/`:
- `runbook-wallet-certs.md` — obtain, rotate, install, expiry monitoring, rollback.
- `runbook-expiration-task.md` — re-run for specific merchant, partial failure handling, link to 6.5 manual restore.
- `runbook-wallet-sync.md` — interpreting `failed_card_ids`, manual re-sync, common failures.
#### 8.4 Monitoring & alerting
- Celery `loyalty.expire_points` not succeeded in 26h → page
- `loyalty.sync_wallet_passes` failure rate > 5% → warn
- Loyalty email failure rate > 1% → warn
- Google Wallet service account JSON cert expiry < 30d → warn
- Rate-limit 429s per store > 100/min → investigate
- Wire into `app/core/observability.py` (or whatever stack is present).
---
### Phase 9 — Apple Wallet *(post-launch, 3d)*
Tracked separately, not blocking launch.
- 9.1 Apple Developer account + Pass Type ID + WWDR cert
- 9.2 Apple config wiring + all-or-nothing validators in `config.py:33-37`
- 9.3 Cert deployment under `~/apps/orion/apple-wallet/` (matching the Google secrets convention)
- 9.4 *(already done in 1.2 — timing-safe compare)*
- 9.5 UI feature flag flip + Apple button re-enabled
- 9.6 iOS Simulator + real device E2E test
---
## Critical Path
```
Phase 0 (done) ──┬─► Phase 1 ──┬─► Phase 3 ──┐
├─► Phase 2 ──┤ ├─► Phase 8 ──► LAUNCH
└─► Phase 5 ──┘ │
Phase 4, 6, 7 (parallelizable) ───────────┘
Phase 9 — post-launch
```
Phases 4, 6, 7 can run in parallel with 2/3/5 if multiple developers are available.
## Effort Summary
| Phase | Days |
|---|---|
| 0 — Decisions | done |
| 1 — Config & security | 2 |
| 2 — Notifications | 4 |
| 3 — Task reliability | 1.5 |
| 4 — A11y + CMS T&C | 2 |
| 5 — Google Wallet hardening | 1 |
| 6 — Admin / GDPR / bulk | 3 |
| 7 — Analytics | 2.5 |
| 8 — Tests / docs / observability | 2 |
| **Launch total** | **~18 days sequential, ~10 with 2 parallel tracks** |
| 9 — Apple Wallet (post-launch) | 3 |
---
## Open Items Needing Sign-off
1. ~~**Rate limit caps**~~ — confirmed.
2. **Email copywriting** for the 7 templates × 4 locales (Phase 2.3) — flow: I draft EN, Samir reviews, then translate.
3. ~~**`birth_date` column**~~ — confirmed missing; addressed in Phase 1.4. No backfill needed (not yet live).

View File

@@ -0,0 +1,58 @@
"""loyalty 003 - add CHECK constraints on balances and program limits
Defends against direct SQL writes that would create impossible loyalty
state (negative balances, zero stamps target, etc). The application
service layer already clamps these values, but a database-level guard
catches anything that bypasses the service (manual fixes, scripts, ORM
shortcuts) before it can corrupt downstream reports and exports.
Pre-flight check on the dev database showed zero existing violations
(see Phase 1.5 of the loyalty production launch plan), so no data
remediation is needed in this upgrade path.
Revision ID: loyalty_003
Revises: loyalty_002
Create Date: 2026-04-09
"""
from alembic import op
revision = "loyalty_003"
down_revision = "loyalty_002"
branch_labels = None
depends_on = None
CONSTRAINTS = [
("loyalty_cards", "ck_loyalty_cards_points_balance_nonneg", "points_balance >= 0"),
("loyalty_cards", "ck_loyalty_cards_stamp_count_nonneg", "stamp_count >= 0"),
(
"loyalty_programs",
"ck_loyalty_programs_min_purchase_nonneg",
"minimum_purchase_cents >= 0",
),
(
"loyalty_programs",
"ck_loyalty_programs_points_per_euro_nonneg",
"points_per_euro >= 0",
),
(
"loyalty_programs",
"ck_loyalty_programs_stamps_target_positive",
"stamps_target >= 1",
),
(
"loyalty_programs",
"ck_loyalty_programs_welcome_bonus_nonneg",
"welcome_bonus_points >= 0",
),
]
def upgrade() -> None:
for table, name, expression in CONSTRAINTS:
op.create_check_constraint(name, table, expression)
def downgrade() -> None:
for table, name, _expression in reversed(CONSTRAINTS):
op.drop_constraint(name, table, type_="check")

View File

@@ -19,6 +19,7 @@ from datetime import UTC, datetime
from sqlalchemy import (
Boolean,
CheckConstraint,
Column,
DateTime,
ForeignKey,
@@ -259,6 +260,10 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
Index("idx_loyalty_card_merchant_customer", "merchant_id", "customer_id", unique=True),
Index("idx_loyalty_card_merchant_active", "merchant_id", "is_active"),
Index("idx_loyalty_card_customer_program", "customer_id", "program_id", unique=True),
# Balances must never go negative — guards against direct SQL writes
# bypassing the service layer's clamping logic.
CheckConstraint("points_balance >= 0", name="ck_loyalty_cards_points_balance_nonneg"),
CheckConstraint("stamp_count >= 0", name="ck_loyalty_cards_stamp_count_nonneg"),
)
def __repr__(self) -> str:

View File

@@ -21,6 +21,7 @@ from datetime import UTC, datetime
from sqlalchemy import (
Boolean,
CheckConstraint,
Column,
DateTime,
ForeignKey,
@@ -264,9 +265,25 @@ class LoyaltyProgram(Base, TimestampMixin, SoftDeleteMixin):
cascade="all, delete-orphan",
)
# Indexes
# Indexes & integrity constraints
__table_args__ = (
Index("idx_loyalty_program_merchant_active", "merchant_id", "is_active"),
CheckConstraint(
"minimum_purchase_cents >= 0",
name="ck_loyalty_programs_min_purchase_nonneg",
),
CheckConstraint(
"points_per_euro >= 0",
name="ck_loyalty_programs_points_per_euro_nonneg",
),
CheckConstraint(
"stamps_target >= 1",
name="ck_loyalty_programs_stamps_target_positive",
),
CheckConstraint(
"welcome_bonus_points >= 0",
name="ck_loyalty_programs_welcome_bonus_nonneg",
),
)
def __repr__(self) -> str:

View File

@@ -62,6 +62,7 @@ from app.modules.loyalty.services import (
stamp_service,
)
from app.modules.tenancy.models import User # API-007
from middleware.decorators import rate_limit
logger = logging.getLogger(__name__)
@@ -267,7 +268,9 @@ def delete_pin(
@router.post("/pins/{pin_id}/unlock", response_model=PinResponse)
@rate_limit(max_requests=10, window_seconds=60)
def unlock_pin(
request: Request,
pin_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
@@ -598,6 +601,7 @@ def get_card_transactions(
@router.post("/stamp", response_model=StampResponse)
@rate_limit(max_requests=60, window_seconds=60)
def add_stamp(
request: Request,
data: StampRequest,
@@ -624,6 +628,7 @@ def add_stamp(
@router.post("/stamp/redeem", response_model=StampRedeemResponse)
@rate_limit(max_requests=30, window_seconds=60)
def redeem_stamps(
request: Request,
data: StampRedeemRequest,
@@ -650,6 +655,7 @@ def redeem_stamps(
@router.post("/stamp/void", response_model=StampVoidResponse)
@rate_limit(max_requests=20, window_seconds=60)
def void_stamps(
request: Request,
data: StampVoidRequest,
@@ -683,6 +689,7 @@ def void_stamps(
@router.post("/points/earn", response_model=PointsEarnResponse)
@rate_limit(max_requests=60, window_seconds=60)
def earn_points(
request: Request,
data: PointsEarnRequest,
@@ -711,6 +718,7 @@ def earn_points(
@router.post("/points/redeem", response_model=PointsRedeemResponse)
@rate_limit(max_requests=30, window_seconds=60)
def redeem_points(
request: Request,
data: PointsRedeemRequest,
@@ -738,6 +746,7 @@ def redeem_points(
@router.post("/points/void", response_model=PointsVoidResponse)
@rate_limit(max_requests=20, window_seconds=60)
def void_points(
request: Request,
data: PointsVoidRequest,
@@ -767,6 +776,7 @@ def void_points(
@router.post("/cards/{card_id}/points/adjust", response_model=PointsAdjustResponse)
@rate_limit(max_requests=20, window_seconds=60)
def adjust_points(
request: Request,
data: PointsAdjustRequest,

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)."""

View File

@@ -9,6 +9,7 @@ Handles Apple Wallet integration including:
"""
import hashlib
import hmac
import io
import json
import logging
@@ -95,7 +96,12 @@ class AppleWalletService:
if authorization and authorization.startswith("ApplePass "):
auth_token = authorization.split(" ", 1)[1]
if not auth_token or auth_token != card.apple_auth_token:
# Constant-time compare to avoid leaking the token via timing.
if (
not auth_token
or not card.apple_auth_token
or not hmac.compare_digest(auth_token, card.apple_auth_token)
):
raise InvalidAppleAuthTokenException()
def generate_pass_safe(self, db: Session, card: LoyaltyCard) -> bytes:

View File

@@ -14,7 +14,7 @@ Handles card operations including:
"""
import logging
from datetime import UTC, datetime
from datetime import UTC, date, datetime
from sqlalchemy.orm import Session, joinedload
@@ -169,7 +169,7 @@ class CardService:
create_if_missing: bool = False,
customer_name: str | None = None,
customer_phone: str | None = None,
customer_birthday: str | None = None,
customer_birthday: date | None = None,
) -> int:
"""
Resolve a customer ID from either a direct ID or email lookup.
@@ -183,7 +183,7 @@ class CardService:
(used for self-enrollment)
customer_name: Full name for customer creation
customer_phone: Phone for customer creation
customer_birthday: Birthday (YYYY-MM-DD) for customer creation
customer_birthday: Date of birth for customer creation
Returns:
Resolved customer ID
@@ -202,6 +202,12 @@ class CardService:
customer = customer_service.get_customer_by_email(db, store_id, email)
if customer:
# Backfill birthday on existing customer if they didn't have
# one before — keeps the enrollment form useful for returning
# customers who never previously provided a birthday.
if customer_birthday and not customer.birth_date:
customer.birth_date = customer_birthday
db.flush()
return customer.id
if create_if_missing:
@@ -220,6 +226,7 @@ class CardService:
first_name=first_name,
last_name=last_name,
phone=customer_phone,
birth_date=customer_birthday,
)
logger.info(
f"Created customer {customer.id} ({email}) "

View File

@@ -463,6 +463,34 @@ class TestStampEarnRedeem:
assert data["success"] is True
assert data["stamp_count"] == 0
def test_stamp_endpoint_is_rate_limited(
self, client, stamp_store_headers, stamp_store_setup
):
"""POST /stamp returns 429 once the per-IP cap is exceeded."""
from middleware.decorators import rate_limiter
# Reset the in-memory limiter so prior tests don't bleed in
rate_limiter.clients.clear()
card = stamp_store_setup["card"]
# Cap is 60 per minute. Hit it 60 times and expect any 200/4xx but not
# a 429, then the 61st should be 429.
for _ in range(60):
client.post(
f"{BASE}/stamp",
json={"card_id": card.id},
headers=stamp_store_headers,
)
response = client.post(
f"{BASE}/stamp",
json={"card_id": card.id},
headers=stamp_store_headers,
)
assert response.status_code == 429
rate_limiter.clients.clear()
# ============================================================================
# Item 2: PIN Ownership Checks

View File

@@ -2,9 +2,17 @@
import pytest
from app.modules.loyalty.exceptions import InvalidAppleAuthTokenException
from app.modules.loyalty.services.apple_wallet_service import AppleWalletService
class _FakeCard:
"""Lightweight card stub for verify_auth_token tests."""
def __init__(self, token: str | None) -> None:
self.apple_auth_token = token
@pytest.mark.unit
@pytest.mark.loyalty
class TestAppleWalletService:
@@ -16,3 +24,38 @@ class TestAppleWalletService:
def test_service_instantiation(self):
"""Service can be instantiated."""
assert self.service is not None
@pytest.mark.unit
@pytest.mark.loyalty
class TestVerifyAuthToken:
"""verify_auth_token must be constant-time and handle missing tokens."""
def setup_method(self):
self.service = AppleWalletService()
def test_correct_token_passes(self):
card = _FakeCard("correct-token-abc123")
# Should not raise
self.service.verify_auth_token(card, "ApplePass correct-token-abc123")
def test_wrong_token_raises(self):
card = _FakeCard("correct-token-abc123")
with pytest.raises(InvalidAppleAuthTokenException):
self.service.verify_auth_token(card, "ApplePass wrong-token-xyz")
def test_missing_authorization_header_raises(self):
card = _FakeCard("correct-token-abc123")
with pytest.raises(InvalidAppleAuthTokenException):
self.service.verify_auth_token(card, None)
def test_malformed_authorization_header_raises(self):
card = _FakeCard("correct-token-abc123")
with pytest.raises(InvalidAppleAuthTokenException):
self.service.verify_auth_token(card, "Bearer correct-token-abc123")
def test_card_with_no_stored_token_raises(self):
"""A card that never enrolled in Apple Wallet has no token to compare."""
card = _FakeCard(None)
with pytest.raises(InvalidAppleAuthTokenException):
self.service.verify_auth_token(card, "ApplePass anything")

View File

@@ -233,6 +233,80 @@ class TestResolveCustomerId:
db, customer_id=None, email=None, store_id=test_store.id
)
def test_resolve_persists_birthday_on_create(self, db, test_store):
"""Birthday provided at self-enrollment is saved on the new customer."""
from datetime import date
email = f"birthday-create-{uuid.uuid4().hex[:8]}@test.com"
result = self.service.resolve_customer_id(
db,
customer_id=None,
email=email,
store_id=test_store.id,
create_if_missing=True,
customer_name="Anna Test",
customer_birthday=date(1992, 6, 15),
)
customer = db.query(Customer).filter(Customer.id == result).first()
assert customer.birth_date == date(1992, 6, 15)
def test_resolve_backfills_birthday_on_existing_customer(
self, db, test_store
):
"""An existing customer without a birthday is backfilled from form."""
from datetime import date
email = f"backfill-{uuid.uuid4().hex[:8]}@test.com"
# Pre-create a customer with no birth_date
existing = self.service.resolve_customer_id(
db,
customer_id=None,
email=email,
store_id=test_store.id,
create_if_missing=True,
customer_name="No Birthday",
)
customer = db.query(Customer).filter(Customer.id == existing).first()
assert customer.birth_date is None
# Re-enroll with birthday — should backfill
self.service.resolve_customer_id(
db,
customer_id=None,
email=email,
store_id=test_store.id,
create_if_missing=True,
customer_birthday=date(1985, 3, 22),
)
db.refresh(customer)
assert customer.birth_date == date(1985, 3, 22)
def test_resolve_does_not_overwrite_existing_birthday(self, db, test_store):
"""Birthday already on the customer is not overwritten by form input."""
from datetime import date
email = f"no-overwrite-{uuid.uuid4().hex[:8]}@test.com"
# Create with original birthday
cid = self.service.resolve_customer_id(
db,
customer_id=None,
email=email,
store_id=test_store.id,
create_if_missing=True,
customer_birthday=date(1990, 1, 1),
)
# Re-enroll with a different birthday
self.service.resolve_customer_id(
db,
customer_id=None,
email=email,
store_id=test_store.id,
create_if_missing=True,
customer_birthday=date(2000, 1, 1),
)
customer = db.query(Customer).filter(Customer.id == cid).first()
assert customer.birth_date == date(1990, 1, 1)
@pytest.mark.unit
@pytest.mark.loyalty

View File

@@ -0,0 +1,99 @@
"""Tests for loyalty module configuration validators."""
import os
import pytest
from app.modules.loyalty.config import ModuleConfig
@pytest.mark.unit
@pytest.mark.loyalty
class TestGoogleWalletConfigValidator:
"""Validator must fail fast when the SA path is set but unreachable."""
def test_unset_path_is_allowed(self, monkeypatch):
"""Google Wallet config is optional — None passes."""
monkeypatch.delenv("LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON", raising=False)
monkeypatch.delenv("LOYALTY_GOOGLE_ISSUER_ID", raising=False)
cfg = ModuleConfig(_env_file=None)
assert cfg.google_service_account_json is None
assert cfg.is_google_wallet_enabled is False
def test_existing_file_passes(self, tmp_path, monkeypatch):
"""A real file path passes and is returned expanded."""
sa_file = tmp_path / "fake-sa.json"
sa_file.write_text("{}")
monkeypatch.setenv("LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON", str(sa_file))
monkeypatch.setenv("LOYALTY_GOOGLE_ISSUER_ID", "1234567890")
cfg = ModuleConfig(_env_file=None)
assert cfg.google_service_account_json == str(sa_file)
assert cfg.is_google_wallet_enabled is True
def test_missing_file_raises(self, tmp_path, monkeypatch):
"""A path that does not exist raises a clear error at import time."""
bogus = tmp_path / "does-not-exist.json"
monkeypatch.setenv("LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON", str(bogus))
with pytest.raises(ValueError, match="no file exists"):
ModuleConfig(_env_file=None)
def test_unreadable_file_raises(self, tmp_path, monkeypatch):
"""A path that exists but is not readable raises a clear error."""
sa_file = tmp_path / "no-read.json"
sa_file.write_text("{}")
os.chmod(sa_file, 0o000)
try:
monkeypatch.setenv(
"LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON", str(sa_file)
)
# Skip if running as root (root bypasses unix file perms)
if os.geteuid() == 0:
pytest.skip("Cannot test unreadable file as root")
with pytest.raises(ValueError, match="not readable"):
ModuleConfig(_env_file=None)
finally:
os.chmod(sa_file, 0o600)
def test_tilde_path_is_expanded(self, tmp_path, monkeypatch):
"""Leading ~ is expanded so deployments can use ~/apps/orion/..."""
# Plant the file at $HOME/.../fake-sa.json then reference via ~
home = tmp_path / "home"
home.mkdir()
sa_dir = home / "apps" / "orion"
sa_dir.mkdir(parents=True)
sa_file = sa_dir / "fake-sa.json"
sa_file.write_text("{}")
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv(
"LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON",
"~/apps/orion/fake-sa.json",
)
cfg = ModuleConfig(_env_file=None)
assert cfg.google_service_account_json == str(sa_file)
@pytest.mark.unit
@pytest.mark.loyalty
class TestAppleWalletEnabledFlag:
"""is_apple_wallet_enabled is a derived flag for the storefront UI."""
def test_disabled_when_unset(self, monkeypatch):
for var in (
"LOYALTY_APPLE_PASS_TYPE_ID",
"LOYALTY_APPLE_TEAM_ID",
"LOYALTY_APPLE_WWDR_CERT_PATH",
"LOYALTY_APPLE_SIGNER_CERT_PATH",
"LOYALTY_APPLE_SIGNER_KEY_PATH",
):
monkeypatch.delenv(var, raising=False)
cfg = ModuleConfig(_env_file=None)
assert cfg.is_apple_wallet_enabled is False
def test_enabled_when_all_set(self, monkeypatch):
monkeypatch.setenv("LOYALTY_APPLE_PASS_TYPE_ID", "pass.com.x.y")
monkeypatch.setenv("LOYALTY_APPLE_TEAM_ID", "ABCD1234")
monkeypatch.setenv("LOYALTY_APPLE_WWDR_CERT_PATH", "/tmp/wwdr.pem")
monkeypatch.setenv("LOYALTY_APPLE_SIGNER_CERT_PATH", "/tmp/signer.pem")
monkeypatch.setenv("LOYALTY_APPLE_SIGNER_KEY_PATH", "/tmp/signer.key")
cfg = ModuleConfig(_env_file=None)
assert cfg.is_apple_wallet_enabled is True

View File

@@ -32,6 +32,8 @@ class TestCardEnrollRequest:
def test_self_enrollment_fields(self):
"""Self-enrollment accepts name, phone, birthday."""
from datetime import date
req = CardEnrollRequest(
email="self@example.com",
customer_name="Jane Doe",
@@ -40,7 +42,36 @@ class TestCardEnrollRequest:
)
assert req.customer_name == "Jane Doe"
assert req.customer_phone == "+352123456"
assert req.customer_birthday == "1990-01-15"
assert req.customer_birthday == date(1990, 1, 15)
def test_birthday_in_future_rejected(self):
"""Future birthdays are rejected."""
from datetime import date, timedelta
import pytest
from pydantic import ValidationError
with pytest.raises(ValidationError, match="must be in the past"):
CardEnrollRequest(
email="self@example.com",
customer_birthday=(date.today() + timedelta(days=1)).isoformat(),
)
def test_birthday_implausible_age_rejected(self):
"""Birthdays implying impossible ages are rejected."""
import pytest
from pydantic import ValidationError
with pytest.raises(ValidationError, match="implausible age"):
CardEnrollRequest(
email="self@example.com",
customer_birthday="1800-01-01",
)
def test_birthday_omitted_is_valid(self):
"""Birthday is optional."""
req = CardEnrollRequest(email="self@example.com")
assert req.customer_birthday is None
@pytest.mark.unit

View File

@@ -219,6 +219,7 @@ nav:
- User Journeys: modules/loyalty/user-journeys.md
- Program Analysis: modules/loyalty/program-analysis.md
- UI Design: modules/loyalty/ui-design.md
- Production Launch Plan: modules/loyalty/production-launch-plan.md
- Marketplace:
- Overview: modules/marketplace/index.md
- Data Model: modules/marketplace/data-model.md