# 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 — relax card uniqueness: replace (merchant_id, customer_id) unique index
              with (enrolled_at_store_id, customer_id) for cross-location support
loyalty_005 — seed 28 notification email templates
loyalty_006 — add columns: last_expiration_warning_at, last_reengagement_at on cards;
              acting_admin_id on transactions
loyalty_007 — terms_cms_page_slug on programs

customers_003 — birth_date on customers (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.

- Migration `customers_003_add_birth_date.py` 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 2A — Notifications Infrastructure *(✅ DONE 2026-04-11)*

#### 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_005`
- 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_006` 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_006`) — once per quarter per card.

---

### Phase 3 — Task Reliability *(✅ DONE 2026-04-11)*

#### 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 *(✅ DONE 2026-04-11)*

#### 4.1 T&C via store CMS integration
- Migration `loyalty_007`: 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 *(✅ UI done 2026-04-11, deploy is manual)*

#### 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 *(✅ DONE 2026-04-11)*

#### 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_006`.

#### 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 *(✅ DONE 2026-04-11)*

#### 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 *(✅ DONE 2026-04-11)*

#### 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

---

## Development Status (as of 2026-04-16)

**All development phases (0-8) are COMPLETE.** 342 automated tests pass.

| Phase | Status | Completed |
|---|---|---|
| Phase 0 — Decisions | ✅ Done | 2026-04-09 |
| Phase 1 — Config & Security | ✅ Done | 2026-04-09 |
| Phase 1.x — Cross-store enrollment fix | ✅ Done | 2026-04-10 |
| Phase 2A — Transactional notifications (5 templates) | ✅ Done | 2026-04-11 |
| Phase 3 — Task reliability (batched expiration + wallet backoff) | ✅ Done | 2026-04-11 |
| Phase 4.1 — T&C via CMS | ✅ Done | 2026-04-11 |
| Phase 4.2 — Accessibility audit | ✅ Done | 2026-04-11 |
| Phase 5 — Wallet UI flags | ✅ Done (already handled) | 2026-04-11 |
| Phase 6 — GDPR, bulk ops, point restore, cascade restore | ✅ Done | 2026-04-11 |
| Phase 7 — Analytics (cohort, churn, revenue + Chart.js) | ✅ Done | 2026-04-11 |
| Phase 8 — Runbooks, monitoring docs, OpenAPI tags | ✅ Done | 2026-04-11 |

**Additional bugfixes during manual testing (2026-04-15):**

- Terminal redeem: `card_id` → `id` normalization across schemas/JS
- Card detail: enrolled store name resolution, copy buttons, paginated transactions
- i18n flicker: server-rendered translations on success page
- Icon fix: `device-mobile` → `phone`

---

## Pre-Launch Checklist

Everything below must be completed before going live. Items are ordered by dependency.

> **Latest status snapshot:** see [Go-Live Readiness (2026-05-10)](../../proposals/loyalty-go-live-readiness.md)
> — only Steps 6 and 8 still pending; Step 9 is post-launch.

### Step 1: Seed email templates on prod DB ✅
- [x] SSH into prod server
- [x] Run: `python scripts/seed/seed_email_templates_loyalty.py`
- [x] Verify: 20 rows created (5 templates × 4 locales)
- [ ] Review EN email copy — adjust subject lines/body if needed via admin UI at `/admin/email-templates`

### Step 2: Google Wallet — already deployed, verify config
The Google Wallet integration is already deployed on the Hetzner server (see Step 25 of `hetzner-server-setup.md`):
- Service account JSON at `~/apps/orion/google-wallet-sa.json` ✅
- Docker volume mount in `docker-compose.yml` (`./google-wallet-sa.json:/app/google-wallet-sa.json:ro`) ✅
- Env vars set: `LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598`, `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/app/google-wallet-sa.json` ✅
- Service account linked to Issuer with Admin role ✅

Verify after deploy:
- [x] Restart app — confirm no startup error (config validator checks file exists)
- [x] `GET /api/v1/admin/loyalty/wallet-status` returns `google_configured: true`
- [x] Wallet diagnostics screen at `/admin/loyalty/wallet-debug` → Config Validation → all green (validated 2026-05-10)

### Step 3: Apply database migrations ✅
- [x] Run: `alembic upgrade heads`
- [x] Verify migrations applied: all four module heads current incl. `loyalty_011`

### Step 4: FR/DE/LB translations for new analytics i18n keys
- [ ] Add translations for 7 keys in `app/modules/loyalty/locales/{fr,de,lb}.json`:
  - `store.analytics.revenue_title`
  - `store.analytics.at_risk_title`
  - `store.analytics.cards_at_risk`
  - `store.analytics.no_at_risk`
  - `store.analytics.cohort_title`
  - `store.analytics.cohort_month`
  - `store.analytics.cohort_enrolled`
  - `store.analytics.no_data_yet`

### Step 5: Investigate email template menu visibility
- [ ] Check if `messaging.manage_templates` permission is assigned to `merchant_owner` role
- [ ] If not, add it to permission discovery or default role assignments
- [ ] Verify menu appears at `/store/{store_code}/email-templates`
- [ ] Verify admin menu at `/admin/email-templates` shows loyalty templates

### Step 6: Manual E2E testing (user journeys)
Follow the **Pre-Launch E2E Test Checklist** at the bottom of `user-journeys.md`:

- [ ] **Test 1:** Customer self-enrollment (with birthday)
- [ ] **Test 2:** Cross-store re-enrollment (cross-location enabled)
- [ ] **Test 3:** Staff operations — stamps/points via terminal
- [ ] **Test 4:** Cross-store redemption (earn at store1, redeem at store2)
- [ ] **Test 5:** Customer views dashboard + transaction history
- [ ] **Test 6:** Void/return flow
- [ ] **Test 7:** Admin oversight (programs, merchants, analytics)
- [ ] **Test 8:** Cross-location disabled behavior (separate cards per store)

### Step 7: Google Wallet real-device test (demo mode) ✅
Google Wallet currently works in **demo/test mode** — only your Google account and explicitly added test accounts can see passes. This is sufficient for launch testing.
- [x] Enroll a test customer on prod
- [x] Tap "Add to Google Wallet" on success page
- [x] Open Google Wallet on Android device — verify pass renders with merchant branding
- [x] Trigger a stamp/points transaction — verify pass auto-updates within 60s

### Step 8: Go live
- [ ] Remove any test data from prod DB (test customers, test cards)
- [ ] Verify Celery workers are running (`loyalty.expire_points`, `loyalty.sync_wallet_passes`)
- [ ] Verify SMTP is configured and test email sends work
- [ ] Enable the loyalty platform for production stores
- [ ] Monitor first 24h: check email logs, wallet sync, expiration task

### Step 9: Google Wallet production access (can be done post-launch)
Passes in demo mode only work for test accounts. To make passes available to **all Android users**:
- [ ] Go to [pay.google.com/business/console](https://pay.google.com/business/console) → **Google Wallet API** → **Manage**
- [ ] Click **"Request production access"**
- [ ] Fill in: business name, website URL (`rewardflow.lu`), contact info, pass type (Loyalty)
- [ ] Upload 1-2 sample pass screenshots (e.g., Fashion Hub's card with their logo/colors). Google reviews the **Issuer** (your platform), not individual merchants — once approved, all merchants on the platform can issue passes.
- [ ] Wait for Google approval (typically 1-3 business days). They check pass design complies with [brand guidelines](https://developers.google.com/wallet/loyalty/brand-guidelines).
- [ ] Once approved: **no code or infra changes needed**. Same Issuer ID and service account, passes become visible to all Android users.

---

## Post-Launch Roadmap

| Item | Priority | Effort | Notes |
|---|---|---|---|
| **Phase 9 — Apple Wallet** | P1 | 3d | Requires Apple Developer certs. See `runbook-wallet-certs.md`. |
| **Phase 2B — Marketing module** | P2 | 4d | Birthday + re-engagement emails. Cross-platform (OMS, loyalty, hosting). |
| **Coverage to 80%** | P2 | 2d | Needs Celery task mocking infrastructure for task-level tests. |
| **Admin trash UI** | P3 | 2d | Trash tab on programs/cards pages using existing `?only_deleted=true` API. The cascade restore API exists but has no UI. |
| **Bulk PIN assignment** | P3 | 1d | Batch create staff PINs. API exists for single PIN; needs bulk endpoint + UI. |
| **Cross-location enforcement** | P3 | 2d | `allow_cross_location_redemption` controls enrollment behavior but stamp/point operations don't enforce it yet. |
| **Email template menu** | P2 | 0.5d | Investigate and fix `messaging.manage_templates` permission for store owners. |
