feat(loyalty): Phase 1 production launch hardening
Some checks failed
Some checks failed
Phase 1 of the loyalty production launch plan: config & security hardening, dropped-data fix, DB integrity guards, rate limiting, and constant-time auth compare. 362 tests pass. - 1.4 Persist customer birth_date (new column + migration). Enrollment form was collecting it but the value was silently dropped because create_customer_for_enrollment never received it. Backfills existing customers without overwriting. - 1.1 LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON validated at startup (file must exist and be readable; ~ expanded). Adds is_google_wallet_enabled and is_apple_wallet_enabled derived flags. Prod path documented as ~/apps/orion/google-wallet-sa.json. - 1.5 CHECK constraints on loyalty_cards (points_balance, stamp_count non-negative) and loyalty_programs (min_purchase, points_per_euro, welcome_bonus non-negative; stamps_target >= 1). Mirrored as CheckConstraint in models. Pre-flight scan showed zero violations. - 1.3 @rate_limit on store mutating endpoints: stamp 60/min, redeem/points-earn 30-60/min, void/adjust 20/min, pin unlock 10/min. - 1.2 Constant-time hmac.compare_digest for Apple Wallet auth token (pulled forward from Phase 9 — code is safe whenever Apple ships). See app/modules/loyalty/docs/production-launch-plan.md for the full launch plan and remaining phases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -228,7 +228,10 @@ R2_BACKUP_BUCKET=orion-backups
|
|||||||
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
||||||
# Get Issuer ID from https://pay.google.com/business/console
|
# Get Issuer ID from https://pay.google.com/business/console
|
||||||
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
# 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_GOOGLE_WALLET_ORIGINS=["https://yourdomain.com"]
|
||||||
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png
|
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -10,6 +10,7 @@ from sqlalchemy import (
|
|||||||
JSON,
|
JSON,
|
||||||
Boolean,
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
|
Date,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
@@ -34,6 +35,7 @@ class Customer(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
first_name = Column(String(100))
|
first_name = Column(String(100))
|
||||||
last_name = Column(String(100))
|
last_name = Column(String(100))
|
||||||
phone = Column(String(50))
|
phone = Column(String(50))
|
||||||
|
birth_date = Column(Date, nullable=True)
|
||||||
customer_number = Column(
|
customer_number = Column(
|
||||||
String(100), nullable=False, index=True
|
String(100), nullable=False, index=True
|
||||||
) # Store-specific ID
|
) # Store-specific ID
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Provides schemas for:
|
|||||||
- Admin customer management
|
- Admin customer management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
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)
|
first_name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
last_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)
|
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
|
marketing_consent: bool | None = None
|
||||||
preferred_language: str | None = Field(
|
preferred_language: str | None = Field(
|
||||||
None, description="Preferred language (en, fr, de, lb)"
|
None, description="Preferred language (en, fr, de, lb)"
|
||||||
@@ -71,6 +74,21 @@ class CustomerUpdate(BaseModel):
|
|||||||
"""Convert email to lowercase."""
|
"""Convert email to lowercase."""
|
||||||
return v.lower() if v else None
|
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):
|
class CustomerPasswordChange(BaseModel):
|
||||||
"""Schema for customer password change."""
|
"""Schema for customer password change."""
|
||||||
@@ -108,6 +126,7 @@ class CustomerResponse(BaseModel):
|
|||||||
first_name: str | None
|
first_name: str | None
|
||||||
last_name: str | None
|
last_name: str | None
|
||||||
phone: str | None
|
phone: str | None
|
||||||
|
birth_date: date | None = None
|
||||||
customer_number: str
|
customer_number: str
|
||||||
marketing_consent: bool
|
marketing_consent: bool
|
||||||
preferred_language: str | None
|
preferred_language: str | None
|
||||||
@@ -253,6 +272,7 @@ class CustomerDetailResponse(BaseModel):
|
|||||||
first_name: str | None = None
|
first_name: str | None = None
|
||||||
last_name: str | None = None
|
last_name: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
|
birth_date: date | None = None
|
||||||
customer_number: str | None = None
|
customer_number: str | None = None
|
||||||
marketing_consent: bool | None = None
|
marketing_consent: bool | None = None
|
||||||
preferred_language: str | None = None
|
preferred_language: str | None = None
|
||||||
@@ -304,6 +324,7 @@ class AdminCustomerItem(BaseModel):
|
|||||||
first_name: str | None = None
|
first_name: str | None = None
|
||||||
last_name: str | None = None
|
last_name: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
|
birth_date: date | None = None
|
||||||
customer_number: str
|
customer_number: str
|
||||||
marketing_consent: bool = False
|
marketing_consent: bool = False
|
||||||
preferred_language: str | None = None
|
preferred_language: str | None = None
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ with complete store isolation.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
@@ -567,6 +567,7 @@ class CustomerService:
|
|||||||
first_name: str = "",
|
first_name: str = "",
|
||||||
last_name: str = "",
|
last_name: str = "",
|
||||||
phone: str | None = None,
|
phone: str | None = None,
|
||||||
|
birth_date: date | None = None,
|
||||||
) -> Customer:
|
) -> Customer:
|
||||||
"""
|
"""
|
||||||
Create a customer for loyalty/external enrollment.
|
Create a customer for loyalty/external enrollment.
|
||||||
@@ -580,6 +581,7 @@ class CustomerService:
|
|||||||
first_name: First name
|
first_name: First name
|
||||||
last_name: Last name
|
last_name: Last name
|
||||||
phone: Phone number
|
phone: Phone number
|
||||||
|
birth_date: Date of birth (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created Customer object
|
Created Customer object
|
||||||
@@ -603,6 +605,7 @@ class CustomerService:
|
|||||||
first_name=first_name,
|
first_name=first_name,
|
||||||
last_name=last_name,
|
last_name=last_name,
|
||||||
phone=phone,
|
phone=phone,
|
||||||
|
birth_date=birth_date,
|
||||||
hashed_password=unusable_hash,
|
hashed_password=unusable_hash,
|
||||||
customer_number=cust_number,
|
customer_number=cust_number,
|
||||||
store_id=store_id,
|
store_id=store_id,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ Usage:
|
|||||||
from app.modules.loyalty.config import config
|
from app.modules.loyalty.config import config
|
||||||
cooldown = config.default_cooldown_minutes
|
cooldown = config.default_cooldown_minutes
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pydantic import field_validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +40,8 @@ class ModuleConfig(BaseSettings):
|
|||||||
apple_signer_key_path: str | None = None # Pass signing key
|
apple_signer_key_path: str | None = None # Pass signing key
|
||||||
|
|
||||||
# Google Wallet
|
# 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_issuer_id: str | None = None
|
||||||
google_service_account_json: str | None = None # Path to service account JSON
|
google_service_account_json: str | None = None # Path to service account JSON
|
||||||
google_wallet_origins: list[str] = [] # Allowed origins for save-to-wallet JWT
|
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"}
|
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
|
# Export for auto-discovery
|
||||||
config_class = ModuleConfig
|
config_class = ModuleConfig
|
||||||
|
|||||||
330
app/modules/loyalty/docs/production-launch-plan.md
Normal file
330
app/modules/loyalty/docs/production-launch-plan.md
Normal 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).
|
||||||
@@ -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")
|
||||||
@@ -19,6 +19,7 @@ from datetime import UTC, datetime
|
|||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
Column,
|
Column,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
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_customer", "merchant_id", "customer_id", unique=True),
|
||||||
Index("idx_loyalty_card_merchant_active", "merchant_id", "is_active"),
|
Index("idx_loyalty_card_merchant_active", "merchant_id", "is_active"),
|
||||||
Index("idx_loyalty_card_customer_program", "customer_id", "program_id", unique=True),
|
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:
|
def __repr__(self) -> str:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from datetime import UTC, datetime
|
|||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
Column,
|
Column,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
@@ -264,9 +265,25 @@ class LoyaltyProgram(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Indexes
|
# Indexes & integrity constraints
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_loyalty_program_merchant_active", "merchant_id", "is_active"),
|
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:
|
def __repr__(self) -> str:
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ from app.modules.loyalty.services import (
|
|||||||
stamp_service,
|
stamp_service,
|
||||||
)
|
)
|
||||||
from app.modules.tenancy.models import User # API-007
|
from app.modules.tenancy.models import User # API-007
|
||||||
|
from middleware.decorators import rate_limit
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -267,7 +268,9 @@ def delete_pin(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/pins/{pin_id}/unlock", response_model=PinResponse)
|
@router.post("/pins/{pin_id}/unlock", response_model=PinResponse)
|
||||||
|
@rate_limit(max_requests=10, window_seconds=60)
|
||||||
def unlock_pin(
|
def unlock_pin(
|
||||||
|
request: Request,
|
||||||
pin_id: int = Path(..., gt=0),
|
pin_id: int = Path(..., gt=0),
|
||||||
current_user: User = Depends(get_current_store_api),
|
current_user: User = Depends(get_current_store_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -598,6 +601,7 @@ def get_card_transactions(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/stamp", response_model=StampResponse)
|
@router.post("/stamp", response_model=StampResponse)
|
||||||
|
@rate_limit(max_requests=60, window_seconds=60)
|
||||||
def add_stamp(
|
def add_stamp(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: StampRequest,
|
data: StampRequest,
|
||||||
@@ -624,6 +628,7 @@ def add_stamp(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/stamp/redeem", response_model=StampRedeemResponse)
|
@router.post("/stamp/redeem", response_model=StampRedeemResponse)
|
||||||
|
@rate_limit(max_requests=30, window_seconds=60)
|
||||||
def redeem_stamps(
|
def redeem_stamps(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: StampRedeemRequest,
|
data: StampRedeemRequest,
|
||||||
@@ -650,6 +655,7 @@ def redeem_stamps(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/stamp/void", response_model=StampVoidResponse)
|
@router.post("/stamp/void", response_model=StampVoidResponse)
|
||||||
|
@rate_limit(max_requests=20, window_seconds=60)
|
||||||
def void_stamps(
|
def void_stamps(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: StampVoidRequest,
|
data: StampVoidRequest,
|
||||||
@@ -683,6 +689,7 @@ def void_stamps(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/points/earn", response_model=PointsEarnResponse)
|
@router.post("/points/earn", response_model=PointsEarnResponse)
|
||||||
|
@rate_limit(max_requests=60, window_seconds=60)
|
||||||
def earn_points(
|
def earn_points(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: PointsEarnRequest,
|
data: PointsEarnRequest,
|
||||||
@@ -711,6 +718,7 @@ def earn_points(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/points/redeem", response_model=PointsRedeemResponse)
|
@router.post("/points/redeem", response_model=PointsRedeemResponse)
|
||||||
|
@rate_limit(max_requests=30, window_seconds=60)
|
||||||
def redeem_points(
|
def redeem_points(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: PointsRedeemRequest,
|
data: PointsRedeemRequest,
|
||||||
@@ -738,6 +746,7 @@ def redeem_points(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/points/void", response_model=PointsVoidResponse)
|
@router.post("/points/void", response_model=PointsVoidResponse)
|
||||||
|
@rate_limit(max_requests=20, window_seconds=60)
|
||||||
def void_points(
|
def void_points(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: PointsVoidRequest,
|
data: PointsVoidRequest,
|
||||||
@@ -767,6 +776,7 @@ def void_points(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/cards/{card_id}/points/adjust", response_model=PointsAdjustResponse)
|
@router.post("/cards/{card_id}/points/adjust", response_model=PointsAdjustResponse)
|
||||||
|
@rate_limit(max_requests=20, window_seconds=60)
|
||||||
def adjust_points(
|
def adjust_points(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: PointsAdjustRequest,
|
data: PointsAdjustRequest,
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ Merchant-based cards:
|
|||||||
- Can be used at any store within the merchant
|
- 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):
|
class CardEnrollRequest(BaseModel):
|
||||||
@@ -31,10 +31,24 @@ class CardEnrollRequest(BaseModel):
|
|||||||
customer_phone: str | None = Field(
|
customer_phone: str | None = Field(
|
||||||
None, description="Phone number for self-enrollment"
|
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"
|
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):
|
class CardResponse(BaseModel):
|
||||||
"""Schema for loyalty card response (summary)."""
|
"""Schema for loyalty card response (summary)."""
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Handles Apple Wallet integration including:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -95,7 +96,12 @@ class AppleWalletService:
|
|||||||
if authorization and authorization.startswith("ApplePass "):
|
if authorization and authorization.startswith("ApplePass "):
|
||||||
auth_token = authorization.split(" ", 1)[1]
|
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()
|
raise InvalidAppleAuthTokenException()
|
||||||
|
|
||||||
def generate_pass_safe(self, db: Session, card: LoyaltyCard) -> bytes:
|
def generate_pass_safe(self, db: Session, card: LoyaltyCard) -> bytes:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Handles card operations including:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, date, datetime
|
||||||
|
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ class CardService:
|
|||||||
create_if_missing: bool = False,
|
create_if_missing: bool = False,
|
||||||
customer_name: str | None = None,
|
customer_name: str | None = None,
|
||||||
customer_phone: str | None = None,
|
customer_phone: str | None = None,
|
||||||
customer_birthday: str | None = None,
|
customer_birthday: date | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Resolve a customer ID from either a direct ID or email lookup.
|
Resolve a customer ID from either a direct ID or email lookup.
|
||||||
@@ -183,7 +183,7 @@ class CardService:
|
|||||||
(used for self-enrollment)
|
(used for self-enrollment)
|
||||||
customer_name: Full name for customer creation
|
customer_name: Full name for customer creation
|
||||||
customer_phone: Phone 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:
|
Returns:
|
||||||
Resolved customer ID
|
Resolved customer ID
|
||||||
@@ -202,6 +202,12 @@ class CardService:
|
|||||||
|
|
||||||
customer = customer_service.get_customer_by_email(db, store_id, email)
|
customer = customer_service.get_customer_by_email(db, store_id, email)
|
||||||
if customer:
|
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
|
return customer.id
|
||||||
|
|
||||||
if create_if_missing:
|
if create_if_missing:
|
||||||
@@ -220,6 +226,7 @@ class CardService:
|
|||||||
first_name=first_name,
|
first_name=first_name,
|
||||||
last_name=last_name,
|
last_name=last_name,
|
||||||
phone=customer_phone,
|
phone=customer_phone,
|
||||||
|
birth_date=customer_birthday,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created customer {customer.id} ({email}) "
|
f"Created customer {customer.id} ({email}) "
|
||||||
|
|||||||
@@ -463,6 +463,34 @@ class TestStampEarnRedeem:
|
|||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert data["stamp_count"] == 0
|
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
|
# Item 2: PIN Ownership Checks
|
||||||
|
|||||||
@@ -2,9 +2,17 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.loyalty.exceptions import InvalidAppleAuthTokenException
|
||||||
from app.modules.loyalty.services.apple_wallet_service import AppleWalletService
|
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.unit
|
||||||
@pytest.mark.loyalty
|
@pytest.mark.loyalty
|
||||||
class TestAppleWalletService:
|
class TestAppleWalletService:
|
||||||
@@ -16,3 +24,38 @@ class TestAppleWalletService:
|
|||||||
def test_service_instantiation(self):
|
def test_service_instantiation(self):
|
||||||
"""Service can be instantiated."""
|
"""Service can be instantiated."""
|
||||||
assert self.service is not None
|
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")
|
||||||
|
|||||||
@@ -233,6 +233,80 @@ class TestResolveCustomerId:
|
|||||||
db, customer_id=None, email=None, store_id=test_store.id
|
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.unit
|
||||||
@pytest.mark.loyalty
|
@pytest.mark.loyalty
|
||||||
|
|||||||
99
app/modules/loyalty/tests/unit/test_config.py
Normal file
99
app/modules/loyalty/tests/unit/test_config.py
Normal 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
|
||||||
@@ -32,6 +32,8 @@ class TestCardEnrollRequest:
|
|||||||
|
|
||||||
def test_self_enrollment_fields(self):
|
def test_self_enrollment_fields(self):
|
||||||
"""Self-enrollment accepts name, phone, birthday."""
|
"""Self-enrollment accepts name, phone, birthday."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
req = CardEnrollRequest(
|
req = CardEnrollRequest(
|
||||||
email="self@example.com",
|
email="self@example.com",
|
||||||
customer_name="Jane Doe",
|
customer_name="Jane Doe",
|
||||||
@@ -40,7 +42,36 @@ class TestCardEnrollRequest:
|
|||||||
)
|
)
|
||||||
assert req.customer_name == "Jane Doe"
|
assert req.customer_name == "Jane Doe"
|
||||||
assert req.customer_phone == "+352123456"
|
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
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ nav:
|
|||||||
- User Journeys: modules/loyalty/user-journeys.md
|
- User Journeys: modules/loyalty/user-journeys.md
|
||||||
- Program Analysis: modules/loyalty/program-analysis.md
|
- Program Analysis: modules/loyalty/program-analysis.md
|
||||||
- UI Design: modules/loyalty/ui-design.md
|
- UI Design: modules/loyalty/ui-design.md
|
||||||
|
- Production Launch Plan: modules/loyalty/production-launch-plan.md
|
||||||
- Marketplace:
|
- Marketplace:
|
||||||
- Overview: modules/marketplace/index.md
|
- Overview: modules/marketplace/index.md
|
||||||
- Data Model: modules/marketplace/data-model.md
|
- Data Model: modules/marketplace/data-model.md
|
||||||
|
|||||||
Reference in New Issue
Block a user