fix(loyalty): cross-store enrollment, card scoping, i18n flicker
Some checks failed
Some checks failed
Fix duplicate card creation when the same email enrolls at different stores under the same merchant, and implement cross-location-aware enrollment behavior. - Cross-location enabled (default): one card per customer per merchant. Re-enrolling at another store returns the existing card with a "works at all our locations" message + store list. - Cross-location disabled: one card per customer per store. Enrolling at a different store creates a separate card for that store. Changes: - Migration loyalty_004: replace (merchant_id, customer_id) unique index with (enrolled_at_store_id, customer_id). Per-merchant uniqueness enforced at application layer when cross-location enabled. - card_service.resolve_customer_id: cross-store email lookup via merchant_id param to find existing cardholders at other stores. - card_service.enroll_customer: branch duplicate check on allow_cross_location_redemption setting. - card_service.search_card_for_store: cross-store email search when cross-location enabled so staff at store2 can find cards from store1. - card_service.get_card_by_customer_and_store: new service method. - storefront enrollment: catch LoyaltyCardAlreadyExistsException, return existing card with already_enrolled flag, locations, and cross-location context. Server-rendered i18n via Jinja2 tojson. - enroll-success.html: conditional cross-store/single-store messaging, server-rendered translations and context, i18n_modules block added. - dashboard.html, history.html: replace $t() with server-side _() to fix i18n flicker across all storefront templates. - Fix device-mobile icon → phone icon. - 4 new i18n keys in 4 locales (en, fr, de, lb). - Docs: updated data-model, business-logic, production-launch-plan, user-journeys with cross-location behavior and E2E test checklist. - 12 new unit tests + 3 new integration tests (334 total pass). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,11 +40,14 @@ This is the active execution plan for taking the Loyalty module to production. I
|
||||
```
|
||||
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;
|
||||
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_006 — terms_cms_page_slug on programs
|
||||
loyalty_007 — birth_date on customers (P0 — Phase 1.4 fix, dropped data bug)
|
||||
loyalty_007 — terms_cms_page_slug on programs
|
||||
|
||||
customers_003 — birth_date on customers (Phase 1.4 fix, dropped data bug)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -82,7 +85,7 @@ All 8 decisions locked. No external blockers.
|
||||
#### 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`.
|
||||
- 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.
|
||||
@@ -114,7 +117,7 @@ All 8 decisions locked. No external blockers.
|
||||
- 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`
|
||||
#### 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.
|
||||
@@ -123,7 +126,7 @@ All 8 decisions locked. No external blockers.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -137,7 +140,7 @@ All 8 decisions locked. No external blockers.
|
||||
|
||||
#### 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.
|
||||
- Throttled via `last_reengagement_at` (added in `loyalty_006`) — once per quarter per card.
|
||||
|
||||
---
|
||||
|
||||
@@ -163,7 +166,7 @@ All 8 decisions locked. No external blockers.
|
||||
### 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`.
|
||||
- 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.
|
||||
@@ -224,7 +227,7 @@ All 8 decisions locked. No external blockers.
|
||||
- **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`.
|
||||
- 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}`.
|
||||
|
||||
Reference in New Issue
Block a user