From 4b56eb7ab1ade5a7c7b4940d7ed62724d72b0ad6 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Thu, 9 Apr 2026 23:36:34 +0200 Subject: [PATCH] feat(loyalty): Phase 1 production launch hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 5 +- .../versions/customers_003_add_birth_date.py | 31 ++ app/modules/customers/models/customer.py | 2 + app/modules/customers/schemas/customer.py | 23 +- .../customers/services/customer_service.py | 5 +- app/modules/loyalty/config.py | 53 +++ .../loyalty/docs/production-launch-plan.md | 330 ++++++++++++++++++ .../loyalty_003_add_check_constraints.py | 58 +++ app/modules/loyalty/models/loyalty_card.py | 5 + app/modules/loyalty/models/loyalty_program.py | 19 +- app/modules/loyalty/routes/api/store.py | 10 + app/modules/loyalty/schemas/card.py | 20 +- .../loyalty/services/apple_wallet_service.py | 8 +- app/modules/loyalty/services/card_service.py | 13 +- .../tests/integration/test_store_api.py | 28 ++ .../tests/unit/test_apple_wallet_service.py | 43 +++ .../loyalty/tests/unit/test_card_service.py | 74 ++++ app/modules/loyalty/tests/unit/test_config.py | 99 ++++++ .../loyalty/tests/unit/test_schemas.py | 33 +- mkdocs.yml | 1 + 20 files changed, 848 insertions(+), 12 deletions(-) create mode 100644 app/modules/customers/migrations/versions/customers_003_add_birth_date.py create mode 100644 app/modules/loyalty/docs/production-launch-plan.md create mode 100644 app/modules/loyalty/migrations/versions/loyalty_003_add_check_constraints.py create mode 100644 app/modules/loyalty/tests/unit/test_config.py diff --git a/.env.example b/.env.example index 5ea14c4c..e47e461a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/modules/customers/migrations/versions/customers_003_add_birth_date.py b/app/modules/customers/migrations/versions/customers_003_add_birth_date.py new file mode 100644 index 00000000..0f6ec935 --- /dev/null +++ b/app/modules/customers/migrations/versions/customers_003_add_birth_date.py @@ -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") diff --git a/app/modules/customers/models/customer.py b/app/modules/customers/models/customer.py index c8c77f30..d098a8c9 100644 --- a/app/modules/customers/models/customer.py +++ b/app/modules/customers/models/customer.py @@ -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 diff --git a/app/modules/customers/schemas/customer.py b/app/modules/customers/schemas/customer.py index 7bf610ba..84f046b3 100644 --- a/app/modules/customers/schemas/customer.py +++ b/app/modules/customers/schemas/customer.py @@ -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 diff --git a/app/modules/customers/services/customer_service.py b/app/modules/customers/services/customer_service.py index fc229604..54110a55 100644 --- a/app/modules/customers/services/customer_service.py +++ b/app/modules/customers/services/customer_service.py @@ -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, diff --git a/app/modules/loyalty/config.py b/app/modules/loyalty/config.py index 9f88b87e..b1c4603b 100644 --- a/app/modules/loyalty/config.py +++ b/app/modules/loyalty/config.py @@ -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 diff --git a/app/modules/loyalty/docs/production-launch-plan.md b/app/modules/loyalty/docs/production-launch-plan.md new file mode 100644 index 00000000..bb184907 --- /dev/null +++ b/app/modules/loyalty/docs/production-launch-plan.md @@ -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). diff --git a/app/modules/loyalty/migrations/versions/loyalty_003_add_check_constraints.py b/app/modules/loyalty/migrations/versions/loyalty_003_add_check_constraints.py new file mode 100644 index 00000000..8b36fc99 --- /dev/null +++ b/app/modules/loyalty/migrations/versions/loyalty_003_add_check_constraints.py @@ -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") diff --git a/app/modules/loyalty/models/loyalty_card.py b/app/modules/loyalty/models/loyalty_card.py index f9a93e92..a077cb11 100644 --- a/app/modules/loyalty/models/loyalty_card.py +++ b/app/modules/loyalty/models/loyalty_card.py @@ -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: diff --git a/app/modules/loyalty/models/loyalty_program.py b/app/modules/loyalty/models/loyalty_program.py index ca6a29f7..8108247b 100644 --- a/app/modules/loyalty/models/loyalty_program.py +++ b/app/modules/loyalty/models/loyalty_program.py @@ -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: diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index 2af10e23..a40a28f3 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -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, diff --git a/app/modules/loyalty/schemas/card.py b/app/modules/loyalty/schemas/card.py index 6bfb51e4..0a21a590 100644 --- a/app/modules/loyalty/schemas/card.py +++ b/app/modules/loyalty/schemas/card.py @@ -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).""" diff --git a/app/modules/loyalty/services/apple_wallet_service.py b/app/modules/loyalty/services/apple_wallet_service.py index 0489c32e..651ba9cd 100644 --- a/app/modules/loyalty/services/apple_wallet_service.py +++ b/app/modules/loyalty/services/apple_wallet_service.py @@ -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: diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index b5c35626..38b70741 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -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}) " diff --git a/app/modules/loyalty/tests/integration/test_store_api.py b/app/modules/loyalty/tests/integration/test_store_api.py index 59b742ff..82910cb5 100644 --- a/app/modules/loyalty/tests/integration/test_store_api.py +++ b/app/modules/loyalty/tests/integration/test_store_api.py @@ -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 diff --git a/app/modules/loyalty/tests/unit/test_apple_wallet_service.py b/app/modules/loyalty/tests/unit/test_apple_wallet_service.py index 7365b808..a64feecc 100644 --- a/app/modules/loyalty/tests/unit/test_apple_wallet_service.py +++ b/app/modules/loyalty/tests/unit/test_apple_wallet_service.py @@ -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") diff --git a/app/modules/loyalty/tests/unit/test_card_service.py b/app/modules/loyalty/tests/unit/test_card_service.py index 2f3e82e2..3653a516 100644 --- a/app/modules/loyalty/tests/unit/test_card_service.py +++ b/app/modules/loyalty/tests/unit/test_card_service.py @@ -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 diff --git a/app/modules/loyalty/tests/unit/test_config.py b/app/modules/loyalty/tests/unit/test_config.py new file mode 100644 index 00000000..a64b8c82 --- /dev/null +++ b/app/modules/loyalty/tests/unit/test_config.py @@ -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 diff --git a/app/modules/loyalty/tests/unit/test_schemas.py b/app/modules/loyalty/tests/unit/test_schemas.py index dba928f3..26a41d63 100644 --- a/app/modules/loyalty/tests/unit/test_schemas.py +++ b/app/modules/loyalty/tests/unit/test_schemas.py @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index 500bad9f..20e76828 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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