Compare commits

...

2 Commits

Author SHA1 Message Date
f804ff8442 fix(loyalty): cross-store enrollment, card scoping, i18n flicker
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-04-11 18:28:19 +02:00
d9abb275a5 feat(dev_tools): expand SQL query tool presets and fix column headers
Add 45 new preset queries covering all database tables, reorganize into
platform-aligned sections (Infrastructure, Core, OMS, Loyalty, Hosting,
Internal) with search/filter input. Fix column headers not appearing on
SELECT * queries by capturing result.keys() before fetchmany().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:28:57 +02:00
23 changed files with 1304 additions and 161 deletions

View File

@@ -99,10 +99,9 @@ def execute_query(db: Session, sql: str) -> dict:
start = time.perf_counter()
result = connection.execute(text(sql))
columns = list(result.keys()) if result.returns_rows else []
rows_raw = result.fetchmany(max_rows + 1)
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
columns = list(result.keys()) if result.returns_rows else []
truncated = len(rows_raw) > max_rows
rows_raw = rows_raw[:max_rows]

View File

@@ -40,7 +40,14 @@ function sqlQueryTool() {
// Schema explorer
showPresets: true,
expandedCategories: {},
presetQueries: [
presetSearch: '',
// Preset sections — grouped by platform
presetSections: [
// ── Infrastructure ──
{
label: 'Infrastructure',
groups: [
{
category: 'Schema',
items: [
@@ -58,6 +65,13 @@ function sqlQueryTool() {
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
]
},
]
},
// ── Core ──
{
label: 'Core',
groups: [
{
category: 'Tenancy',
items: [
@@ -65,7 +79,8 @@ function sqlQueryTool() {
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
{ name: 'Customers', sql: "SELECT id, store_id, email, first_name, last_name,\n is_active, created_at\nFROM customers\nORDER BY id\nLIMIT 50;" },
{ name: 'Merchant domains', sql: "SELECT md.id, m.name AS merchant_name,\n md.domain, md.is_primary, md.is_active,\n md.ssl_status, md.is_verified\nFROM merchant_domains md\nJOIN merchants m ON m.id = md.merchant_id\nORDER BY m.name, md.domain;" },
{ name: 'Store domains', sql: "SELECT sd.id, s.name AS store_name,\n sd.domain, sd.is_primary, sd.is_active,\n sd.ssl_status, sd.is_verified\nFROM store_domains sd\nJOIN stores s ON s.id = sd.store_id\nORDER BY s.name, sd.domain;" },
]
},
{
@@ -79,43 +94,13 @@ function sqlQueryTool() {
]
},
{
category: 'System',
category: 'Admin & Audit',
items: [
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
]
},
{
category: 'Loyalty',
items: [
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
]
},
{
category: 'Billing',
items: [
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
]
},
{
category: 'Orders',
items: [
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
]
},
{
category: 'Catalog',
items: [
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
{ name: 'Audit log', sql: "SELECT al.id, u.email AS admin_email,\n al.action, al.target_type, al.target_id,\n al.ip_address, al.created_at\nFROM admin_audit_logs al\nJOIN users u ON u.id = al.admin_user_id\nORDER BY al.created_at DESC\nLIMIT 100;" },
{ name: 'Active sessions', sql: "SELECT s.id, u.email AS admin_email,\n s.ip_address, s.login_at, s.last_activity_at,\n s.is_active, s.logout_reason\nFROM admin_sessions s\nJOIN users u ON u.id = s.admin_user_id\nORDER BY s.last_activity_at DESC\nLIMIT 50;" },
{ name: 'Admin settings', sql: "SELECT id, key, value, value_type,\n category, is_encrypted, is_public\nFROM admin_settings\nORDER BY category, key;" },
{ name: 'Platform alerts', sql: "SELECT id, alert_type, severity, title,\n is_resolved, occurrence_count,\n first_occurred_at, last_occurred_at\nFROM platform_alerts\nORDER BY last_occurred_at DESC\nLIMIT 50;" },
{ name: 'Application logs', sql: "SELECT id, timestamp, level, logger_name,\n module, message, exception_type,\n request_id\nFROM application_logs\nORDER BY timestamp DESC\nLIMIT 100;" },
]
},
{
@@ -123,13 +108,20 @@ function sqlQueryTool() {
items: [
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
{ name: 'Password reset tokens', sql: "SELECT prt.id, c.email AS customer_email,\n prt.expires_at, prt.used_at, prt.created_at\nFROM password_reset_tokens prt\nJOIN customers c ON c.id = prt.customer_id\nORDER BY prt.created_at DESC\nLIMIT 50;" },
]
},
{
category: 'Inventory',
category: 'Messaging',
items: [
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
{ name: 'Conversations', sql: "SELECT cv.id, cv.conversation_type, cv.subject,\n s.name AS store_name, cv.is_closed,\n cv.message_count, cv.last_message_at\nFROM conversations cv\nLEFT JOIN stores s ON s.id = cv.store_id\nORDER BY cv.last_message_at DESC\nLIMIT 50;" },
{ name: 'Messages', sql: "SELECT m.id, m.conversation_id,\n m.sender_type, m.sender_id,\n LEFT(m.content, 100) AS content_preview,\n m.is_system_message, m.created_at\nFROM messages m\nORDER BY m.created_at DESC\nLIMIT 100;" },
{ name: 'Message attachments', sql: "SELECT ma.id, ma.message_id,\n ma.original_filename, ma.mime_type,\n ma.file_size, ma.is_image, ma.created_at\nFROM message_attachments ma\nORDER BY ma.created_at DESC\nLIMIT 50;" },
{ name: 'Store email templates', sql: "SELECT set_.id, s.name AS store_name,\n set_.template_code, set_.language,\n set_.name, set_.is_active\nFROM store_email_templates set_\nJOIN stores s ON s.id = set_.store_id\nORDER BY s.name, set_.template_code;" },
{ name: 'Store email settings', sql: "SELECT ses.id, s.name AS store_name,\n ses.from_email, ses.from_name, ses.provider,\n ses.is_configured, ses.is_verified\nFROM store_email_settings ses\nJOIN stores s ON s.id = ses.store_id\nORDER BY s.name;" },
]
},
{
@@ -141,28 +133,179 @@ function sqlQueryTool() {
]
},
{
category: 'Messaging',
category: 'Billing',
items: [
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
{ name: 'Tier feature limits', sql: "SELECT tfl.id, st.code AS tier_code,\n st.name AS tier_name, tfl.feature_code,\n tfl.limit_value\nFROM tier_feature_limits tfl\nJOIN subscription_tiers st ON st.id = tfl.tier_id\nORDER BY st.code, tfl.feature_code;" },
{ name: 'Merchant feature overrides', sql: "SELECT mfo.id, m.name AS merchant_name,\n p.code AS platform_code, mfo.feature_code,\n mfo.limit_value, mfo.is_enabled, mfo.reason\nFROM merchant_feature_overrides mfo\nJOIN merchants m ON m.id = mfo.merchant_id\nJOIN platforms p ON p.id = mfo.platform_id\nORDER BY m.name, mfo.feature_code;" },
{ name: 'Store add-ons', sql: "SELECT sa.id, s.name AS store_name,\n ap.name AS addon_name, sa.status,\n sa.quantity, sa.domain_name,\n sa.period_start, sa.period_end\nFROM store_addons sa\nJOIN stores s ON s.id = sa.store_id\nJOIN addon_products ap ON ap.id = sa.addon_product_id\nORDER BY sa.id DESC;" },
{ name: 'Stripe webhook events', sql: "SELECT swe.id, swe.event_id, swe.event_type,\n swe.status, swe.processed_at,\n s.name AS store_name, swe.error_message\nFROM stripe_webhook_events swe\nLEFT JOIN stores s ON s.id = swe.store_id\nORDER BY swe.created_at DESC\nLIMIT 50;" },
]
},
]
},
// ── OMS ──
{
label: 'OMS',
groups: [
{
category: 'Orders',
items: [
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
{ name: 'Order item exceptions', sql: "SELECT oie.id, o.order_number,\n oie.original_product_name, oie.original_gtin,\n oie.exception_type, oie.status,\n oie.resolved_at, oie.created_at\nFROM order_item_exceptions oie\nJOIN order_items oi ON oi.id = oie.order_item_id\nJOIN orders o ON o.id = oi.order_id\nORDER BY oie.created_at DESC\nLIMIT 50;" },
{ name: 'Invoice settings', sql: "SELECT sis.id, s.name AS store_name,\n sis.merchant_name, sis.vat_number,\n sis.is_vat_registered, sis.invoice_prefix,\n sis.invoice_next_number, sis.default_vat_rate\nFROM store_invoice_settings sis\nJOIN stores s ON s.id = sis.store_id\nORDER BY s.name;" },
{ name: 'Customer order stats', sql: "SELECT cos.id, s.name AS store_name,\n c.email AS customer_email, cos.total_orders,\n cos.total_spent_cents, cos.first_order_date,\n cos.last_order_date\nFROM customer_order_stats cos\nJOIN stores s ON s.id = cos.store_id\nJOIN customers c ON c.id = cos.customer_id\nORDER BY cos.total_spent_cents DESC\nLIMIT 50;" },
]
},
{
category: 'Cart',
items: [
{ name: 'Cart items', sql: "SELECT ci.id, s.name AS store_name,\n p.store_sku, ci.session_id,\n ci.quantity, ci.price_at_add_cents,\n ci.created_at\nFROM cart_items ci\nJOIN stores s ON s.id = ci.store_id\nJOIN products p ON p.id = ci.product_id\nORDER BY ci.created_at DESC\nLIMIT 50;" },
]
},
{
category: 'Catalog',
items: [
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
]
},
{
category: 'Inventory',
items: [
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
]
},
{
category: 'Marketplace',
items: [
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
{ name: 'Import errors', sql: "SELECT mie.id, mij.marketplace,\n mie.row_number, mie.identifier,\n mie.error_type, mie.error_message\nFROM marketplace_import_errors mie\nJOIN marketplace_import_jobs mij ON mij.id = mie.import_job_id\nORDER BY mie.created_at DESC\nLIMIT 100;" },
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
{ name: 'Product translations', sql: "SELECT mpt.id, mp.gtin,\n mpt.language, mpt.title, mpt.url_slug\nFROM marketplace_product_translations mpt\nJOIN marketplace_products mp ON mp.id = mpt.marketplace_product_id\nORDER BY mpt.id DESC\nLIMIT 50;" },
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
{ name: 'Letzshop credentials', sql: "SELECT slc.id, s.name AS store_name,\n slc.api_endpoint, slc.auto_sync_enabled,\n slc.sync_interval_minutes, slc.last_sync_at,\n slc.last_sync_status\nFROM store_letzshop_credentials slc\nJOIN stores s ON s.id = slc.store_id\nORDER BY s.name;" },
{ name: 'Sync logs', sql: "SELECT sl.id, s.name AS store_name,\n sl.operation_type, sl.direction, sl.status,\n sl.records_processed, sl.records_failed,\n sl.duration_seconds, sl.triggered_by\nFROM letzshop_sync_logs sl\nJOIN stores s ON s.id = sl.store_id\nORDER BY sl.created_at DESC\nLIMIT 50;" },
{ name: 'Historical import jobs', sql: "SELECT hij.id, s.name AS store_name,\n hij.status, hij.current_phase,\n hij.orders_imported, hij.orders_skipped,\n hij.products_matched, hij.products_not_found\nFROM letzshop_historical_import_jobs hij\nJOIN stores s ON s.id = hij.store_id\nORDER BY hij.created_at DESC\nLIMIT 50;" },
]
},
]
},
// ── Loyalty ──
{
label: 'Loyalty',
groups: [
{
category: 'Loyalty',
items: [
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
{ name: 'Apple device registrations', sql: "SELECT adr.id, lc.card_number,\n adr.device_library_identifier,\n adr.push_token, adr.created_at\nFROM apple_device_registrations adr\nJOIN loyalty_cards lc ON lc.id = adr.card_id\nORDER BY adr.created_at DESC\nLIMIT 50;" },
{ name: 'Merchant loyalty settings', sql: "SELECT mls.id, m.name AS merchant_name,\n mls.staff_pin_policy,\n mls.allow_self_enrollment,\n mls.allow_void_transactions,\n mls.allow_cross_location_redemption,\n mls.require_order_reference\nFROM merchant_loyalty_settings mls\nJOIN merchants m ON m.id = mls.merchant_id\nORDER BY m.name;" },
]
},
]
},
// ── Hosting ──
{
label: 'Hosting',
groups: [
{
category: 'Hosting',
items: [
{ name: 'Hosted sites', sql: "SELECT hs.id, s.name AS store_name,\n hs.business_name, hs.status,\n hs.contact_email, hs.live_domain,\n hs.went_live_at, hs.created_at\nFROM hosted_sites hs\nLEFT JOIN stores s ON s.id = hs.store_id\nORDER BY hs.created_at DESC;" },
{ name: 'Client services', sql: "SELECT cs.id, hs.business_name,\n cs.service_type, cs.name, cs.status,\n cs.billing_period, cs.price_cents,\n cs.domain_name, cs.expires_at\nFROM client_services cs\nJOIN hosted_sites hs ON hs.id = cs.hosted_site_id\nORDER BY hs.business_name, cs.service_type;" },
]
},
{
category: 'Prospecting',
items: [
{ name: 'Prospects', sql: "SELECT id, channel, business_name,\n domain_name, status, source,\n city, country, created_at\nFROM prospects\nORDER BY created_at DESC\nLIMIT 50;" },
{ name: 'Prospect contacts', sql: "SELECT pc.id, p.business_name,\n pc.contact_type, pc.value, pc.label,\n pc.is_primary, pc.is_validated\nFROM prospect_contacts pc\nJOIN prospects p ON p.id = pc.prospect_id\nORDER BY pc.created_at DESC\nLIMIT 50;" },
{ name: 'Tech profiles', sql: "SELECT tp.id, p.business_name,\n tp.cms, tp.cms_version, tp.server,\n tp.hosting_provider, tp.ecommerce_platform,\n tp.has_valid_cert\nFROM prospect_tech_profiles tp\nJOIN prospects p ON p.id = tp.prospect_id\nORDER BY tp.created_at DESC\nLIMIT 50;" },
{ name: 'Performance profiles', sql: "SELECT pp.id, p.business_name,\n pp.performance_score, pp.accessibility_score,\n pp.seo_score, pp.is_mobile_friendly,\n pp.total_bytes, pp.total_requests\nFROM prospect_performance_profiles pp\nJOIN prospects p ON p.id = pp.prospect_id\nORDER BY pp.created_at DESC\nLIMIT 50;" },
{ name: 'Interactions', sql: "SELECT pi.id, p.business_name,\n pi.interaction_type, pi.subject,\n pi.outcome, pi.next_action,\n pi.next_action_date, pi.created_at\nFROM prospect_interactions pi\nJOIN prospects p ON p.id = pi.prospect_id\nORDER BY pi.created_at DESC\nLIMIT 50;" },
{ name: 'Scan jobs', sql: "SELECT id, job_type, status,\n total_items, processed_items, failed_items,\n started_at, completed_at\nFROM prospect_scan_jobs\nORDER BY created_at DESC\nLIMIT 50;" },
{ name: 'Security audits', sql: "SELECT psa.id, p.business_name,\n psa.score, psa.grade,\n psa.findings_count_critical,\n psa.findings_count_high,\n psa.has_https, psa.has_valid_ssl\nFROM prospect_security_audits psa\nJOIN prospects p ON p.id = psa.prospect_id\nORDER BY psa.created_at DESC\nLIMIT 50;" },
{ name: 'Prospect scores', sql: "SELECT ps.id, p.business_name,\n ps.score, ps.lead_tier,\n ps.technical_health_score,\n ps.modernity_score,\n ps.business_value_score,\n ps.engagement_score\nFROM prospect_scores ps\nJOIN prospects p ON p.id = ps.prospect_id\nORDER BY ps.score DESC\nLIMIT 50;" },
{ name: 'Campaign templates', sql: "SELECT id, name, lead_type,\n channel, language, is_active\nFROM campaign_templates\nORDER BY lead_type, channel;" },
{ name: 'Campaign sends', sql: "SELECT cs.id, ct.name AS template_name,\n p.business_name, cs.channel,\n cs.status, cs.sent_at\nFROM campaign_sends cs\nJOIN campaign_templates ct ON ct.id = cs.template_id\nJOIN prospects p ON p.id = cs.prospect_id\nORDER BY cs.created_at DESC\nLIMIT 50;" },
]
},
]
},
// ── Internal ──
{
label: 'Internal',
groups: [
{
category: 'System',
items: [
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
{ name: 'Menu configs', sql: "SELECT amc.id, amc.frontend_type,\n p.code AS platform_code, u.email,\n amc.menu_item_id, amc.is_visible\nFROM admin_menu_configs amc\nLEFT JOIN platforms p ON p.id = amc.platform_id\nLEFT JOIN users u ON u.id = amc.user_id\nORDER BY amc.frontend_type, amc.menu_item_id;" },
]
},
{
category: 'Monitoring',
items: [
{ name: 'Capacity snapshots', sql: "SELECT id, snapshot_date,\n active_stores, total_products,\n total_orders_month, total_team_members,\n db_size_mb, avg_response_ms,\n peak_cpu_percent, peak_memory_percent\nFROM capacity_snapshots\nORDER BY snapshot_date DESC\nLIMIT 30;" },
]
},
{
category: 'Dev Tools',
items: [
{ name: 'Test runs', sql: "SELECT id, timestamp, status,\n total_tests, passed, failed, errors,\n coverage_percent, duration_seconds,\n git_branch\nFROM test_runs\nORDER BY timestamp DESC\nLIMIT 30;" },
{ name: 'Architecture scans', sql: "SELECT id, timestamp, validator_type,\n status, total_files, total_violations,\n errors, warnings, duration_seconds\nFROM architecture_scans\nORDER BY timestamp DESC\nLIMIT 30;" },
{ name: 'Architecture violations', sql: "SELECT av.id, av.rule_id, av.rule_name,\n av.severity, av.file_path, av.line_number,\n av.status, av.message\nFROM architecture_violations av\nORDER BY av.created_at DESC\nLIMIT 100;" },
]
},
]
},
],
get filteredPresetSections() {
const q = this.presetSearch.toLowerCase().trim();
if (!q) return this.presetSections;
const filtered = [];
for (const section of this.presetSections) {
const groups = [];
for (const group of section.groups) {
const items = group.items.filter(
item => item.name.toLowerCase().includes(q)
|| group.category.toLowerCase().includes(q)
|| section.label.toLowerCase().includes(q)
);
if (items.length > 0) {
groups.push({ ...group, items });
}
}
if (groups.length > 0) {
filtered.push({ ...section, groups });
}
}
return filtered;
},
toggleCategory(category) {
this.expandedCategories[category] = !this.expandedCategories[category];
},
isCategoryExpanded(category) {
if (this.presetSearch.trim()) return true;
return this.expandedCategories[category] || false;
},

View File

@@ -24,7 +24,16 @@
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
</button>
<div x-show="showPresets" x-collapse class="mt-3">
<template x-for="group in presetQueries" :key="group.category">
<!-- Search filter -->
<div class="mb-2">
<input type="text" x-model="presetSearch" placeholder="Filter presets..."
class="w-full text-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1.5 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<template x-for="section in filteredPresetSections" :key="section.label">
<div class="mb-2">
<div class="text-[10px] font-bold text-indigo-500 dark:text-indigo-400 uppercase tracking-widest px-2 py-1"
x-text="section.label"></div>
<template x-for="group in section.groups" :key="group.category">
<div class="mb-1">
<button @click="toggleCategory(group.category)"
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
@@ -43,6 +52,10 @@
</div>
</template>
</div>
</template>
<div x-show="presetSearch && filteredPresetSections.length === 0"
class="text-xs text-gray-400 px-2 py-2">No matching presets.</div>
</div>
</div>
<!-- Saved Queries -->

View File

@@ -214,14 +214,25 @@ Uses PKCS#7 signed `.pkpass` files and APNs push notifications.
## Cross-Store Redemption
When `allow_cross_location_redemption` is enabled in merchant settings:
The `allow_cross_location_redemption` merchant setting controls both card scoping and enrollment behavior:
- Cards are scoped to the **merchant** (not individual stores)
### When enabled (default)
- **One card per customer per merchant** — enforced at the application layer
- Customer can earn stamps at Store A and redeem at Store B
- Each transaction records which `store_id` it occurred at
- The `enrolled_at_store_id` field tracks where the customer first enrolled
- If a customer tries to enroll at a second store, the system returns their existing card with a message showing all available locations
When disabled, stamp/point operations are restricted to the enrollment store.
### When disabled
- **One card per customer per store** — each store under the merchant issues its own card
- Stamp/point operations are restricted to the card's enrollment store
- A customer can hold separate cards at different stores under the same merchant
- Re-enrolling at the **same** store returns the existing card
- Enrolling at a **different** store creates a new card scoped to that store
**Database constraint:** Unique index on `(enrolled_at_store_id, customer_id)` prevents duplicate cards at the same store regardless of the cross-location setting.
## Enrollment Flow
@@ -229,21 +240,25 @@ When disabled, stamp/point operations are restricted to the enrollment store.
Staff enrolls customer via terminal:
1. Enter customer email (and optional name)
2. System resolves or creates customer record
3. Creates loyalty card with unique card number and QR code
4. Creates `CARD_CREATED` transaction
5. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction
6. Creates Google Wallet object and Apple Wallet serial
7. Returns card details with "Add to Wallet" URLs
2. System resolves customer — checks the current store first, then searches across all stores under the merchant for an existing cardholder with the same email
3. If the customer already has a card (per-merchant or per-store, depending on the cross-location setting), raises `LoyaltyCardAlreadyExistsException`
4. Otherwise creates loyalty card with unique card number and QR code
5. Creates `CARD_CREATED` transaction
6. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction
7. Creates Google Wallet object and Apple Wallet serial
8. Returns card details with "Add to Wallet" URLs
### Self-Enrollment (Public)
Customer enrolls via public page (if `allow_self_enrollment` enabled):
1. Customer visits `/loyalty/join` page
2. Enters email and name
3. System creates customer + card
4. Redirected to success page with card number
5. Can add to Google/Apple Wallet from success page
2. Enters email, name, and optional birthday
3. System resolves customer (cross-store lookup for existing cardholders under the same merchant)
4. If already enrolled: returns existing card with success page showing location info
- Cross-location enabled: "Your card works at all our locations" + store list
- Cross-location disabled: "Your card is registered at {original_store}"
5. If new: creates customer + card, redirected to success page with card number
6. Can add to Google/Apple Wallet from success page
## Scheduled Tasks

View File

@@ -120,14 +120,21 @@ Merchant-wide loyalty program configuration. One program per merchant, shared ac
### LoyaltyCard
Customer loyalty card linking a customer to a merchant's program. One card per customer per merchant.
Customer loyalty card linking a customer to a merchant's program.
**Card uniqueness depends on the `allow_cross_location_redemption` merchant setting:**
- **Cross-location enabled (default):** One card per customer per merchant. The application layer enforces this by checking all stores under the merchant before creating a card. Re-enrolling at another store returns the existing card.
- **Cross-location disabled:** One card per customer per store. A customer can hold separate cards at different stores under the same merchant, each scoped to its enrollment store.
**Database constraint:** Unique index on `(enrolled_at_store_id, customer_id)` — always enforced. The per-merchant uniqueness (cross-location enabled) is enforced at the application layer in `card_service.enroll_customer`.
| Field | Type | Description |
|-------|------|-------------|
| `merchant_id` | FK | Links to program's merchant |
| `customer_id` | FK | Card owner |
| `program_id` | FK | Associated program |
| `enrolled_at_store_id` | FK | Store where customer enrolled |
| `enrolled_at_store_id` | FK | Store where customer enrolled (part of unique constraint) |
| `card_number` | String (unique) | Formatted XXXX-XXXX-XXXX |
| `qr_code_data` | String (unique) | URL-safe token for QR codes |
| `stamp_count` | Integer | Current stamp count |

View File

@@ -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}`.

View File

@@ -792,3 +792,109 @@ flowchart TD
There is no feature gating on loyalty program creation — you can test them in
either order. Journey 0 is listed second because domain setup is about URL
presentation, not a functional prerequisite for the loyalty module.
---
## Pre-Launch E2E Test Checklist (Fashion Group)
Manual end-to-end checklist using Fashion Group (merchant 2: FASHIONHUB + FASHIONOUTLET).
Covers all customer-facing flows including the cross-store enrollment and redemption features
added in the Phase 1 production launch hardening.
### Pre-requisite: Program Setup (Journey 1)
If Fashion Group doesn't have a loyalty program yet:
1. Login as `jane.owner@fashiongroup.com` at `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/login`
2. Navigate to: `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/settings`
3. Create program (hybrid or points), set welcome bonus, enable self-enrollment
4. Verify Cross-Location Redemption is **enabled** in merchant settings
### Test 1: Customer Self-Enrollment (Journey 4)
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1.1 | Visit `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join` | Enrollment form loads, no console errors |
| 1.2 | Fill in: fresh email, name, **birthday** → Submit | Redirected to success page with card number |
| 1.3 | Check DB: `SELECT birth_date FROM customers WHERE email = '...'` | `birth_date` is set (not NULL) |
| 1.4 | Enroll **without** birthday (different email) | Success, `birth_date` is NULL (no crash) |
### Test 2: Cross-Store Re-Enrollment (Cross-Location Enabled)
| Step | Action | Expected Result |
|------|--------|-----------------|
| 2.1 | Visit `http://localhost:8000/platforms/loyalty/storefront/FASHIONOUTLET/loyalty/join` | Enrollment form loads |
| 2.2 | Submit with the **same email** from Test 1 | Success page shows **"You're already a member!"** |
| 2.3 | Check: store list shown | Blue box: "Your card works at all our locations:" with Fashion Hub + Fashion Outlet listed |
| 2.4 | Check: same card number as Test 1 | Card number matches (no duplicate created) |
| 2.5 | Check DB: `SELECT COUNT(*) FROM loyalty_cards WHERE customer_id = ...` | Exactly 1 card |
| 2.6 | Re-enroll at FASHIONHUB (same store as original) | Same behavior: "already a member" + locations |
| 2.7 | Refresh the success page | Message persists, no flicker, no untranslated i18n keys |
### Test 3: Staff Operations — Stamps/Points (Journeys 2 & 3)
| Step | Action | Expected Result |
|------|--------|-----------------|
| 3.1 | Login as `jane.owner@fashiongroup.com` at FASHIONHUB | Login succeeds |
| 3.2 | Open terminal: `.../store/FASHIONHUB/loyalty/terminal` | Terminal loads |
| 3.3 | Look up card by card number | Card found, balance displayed |
| 3.4 | Look up card by customer email | Card found (same result) |
| 3.5 | Add stamp (or earn points with purchase amount) | Count/balance updates |
| 3.6 | Add stamp again immediately (within cooldown) | Rejected: cooldown active |
### Test 4: Cross-Store Redemption (Journey 8)
| Step | Action | Expected Result |
|------|--------|-----------------|
| 4.1 | Staff at FASHIONHUB adds stamps/points to the card | Balance updated |
| 4.2 | Login as staff at FASHIONOUTLET (e.g., `diana.stylist@fashiongroup.com` or `jane.owner`) | Login succeeds |
| 4.3 | Open terminal: `.../store/FASHIONOUTLET/loyalty/terminal` | Terminal loads |
| 4.4 | Look up card **by email** | Card found (cross-store email search) |
| 4.5 | Look up card **by card number** | Card found |
| 4.6 | Redeem reward (if enough stamps/points) | Redemption succeeds |
| 4.7 | View card detail | Transaction history shows entries from both FASHIONHUB and FASHIONOUTLET |
### Test 5: Customer Views Status (Journey 5)
| Step | Action | Expected Result |
|------|--------|-----------------|
| 5.1 | Login as the customer at storefront | Customer dashboard loads |
| 5.2 | Dashboard: `.../storefront/FASHIONHUB/account/loyalty` | Shows balance, available rewards |
| 5.3 | History: `.../storefront/FASHIONHUB/account/loyalty/history` | Shows transactions from both stores |
### Test 6: Void/Return (Journey 7)
| Step | Action | Expected Result |
|------|--------|-----------------|
| 6.1 | Staff at FASHIONHUB opens terminal, looks up card | Card found |
| 6.2 | Void a stamp or points transaction | Balance adjusted |
| 6.3 | Check transaction history | Void transaction appears, linked to original |
### Test 7: Admin Oversight (Journey 6)
| Step | Action | Expected Result |
|------|--------|-----------------|
| 7.1 | Login as `samir.boulahtit@gmail.com` (admin) | Admin dashboard loads |
| 7.2 | Programs: `.../admin/loyalty/programs` | Fashion Group program visible |
| 7.3 | Fashion Group detail: `.../admin/loyalty/merchants/2` | Cards, transactions, stats appear correctly |
| 7.4 | Fashion Group settings: `.../admin/loyalty/merchants/2/settings` | Cross-location toggle visible and correct |
### Test 8: Cross-Location Disabled Behavior
| Step | Action | Expected Result |
|------|--------|-----------------|
| 8.1 | Admin disables Cross-Location Redemption for Fashion Group | Setting saved |
| 8.2 | Enroll a **new email** at FASHIONHUB | New card created for FASHIONHUB |
| 8.3 | Enroll **same email** at FASHIONOUTLET | **New card created** for FASHIONOUTLET (separate card) |
| 8.4 | Enroll **same email** at FASHIONHUB again | "Already a member" — shows "Your card is registered at Fashion Hub" (single store, no list) |
| 8.5 | Staff at FASHIONOUTLET searches by email | Only finds the FASHIONOUTLET card (no cross-store search) |
| 8.6 | Re-enable Cross-Location Redemption when done | Restore default state |
### Key Things to Watch
- [ ] Birthday persisted after enrollment (check DB)
- [ ] No i18n flicker or console warnings on success page
- [ ] Cross-store email search works in the terminal (cross-location enabled)
- [ ] "Already a member" message shows correct locations/store based on cross-location setting
- [ ] No duplicate cards created under same merchant (when cross-location enabled)
- [ ] Rate limiting: rapid-fire stamp calls eventually return 429

View File

@@ -135,6 +135,10 @@
"view_dashboard": "Mein Treue-Dashboard anzeigen",
"continue_shopping": "Weiter einkaufen"
},
"already_enrolled_title": "Sie sind bereits Mitglied!",
"cross_location_message": "Ihre Karte gilt an allen unseren Standorten:",
"single_location_message": "Ihre Karte ist bei {store_name} registriert",
"available_locations": "Nutzen Sie Ihre Karte an allen unseren Standorten:",
"errors": {
"load_failed": "Programminformationen konnten nicht geladen werden",
"email_exists": "Diese E-Mail ist bereits in unserem Treueprogramm registriert.",

View File

@@ -135,6 +135,10 @@
"view_dashboard": "View My Loyalty Dashboard",
"continue_shopping": "Continue Shopping"
},
"already_enrolled_title": "You're already a member!",
"cross_location_message": "Your card works at all our locations:",
"single_location_message": "Your card is registered at {store_name}",
"available_locations": "Use your card at all our locations:",
"errors": {
"load_failed": "Failed to load program information",
"email_exists": "This email is already registered in our loyalty program.",

View File

@@ -135,6 +135,10 @@
"view_dashboard": "Voir mon tableau de bord fidélité",
"continue_shopping": "Continuer mes achats"
},
"already_enrolled_title": "Vous êtes déjà membre !",
"cross_location_message": "Votre carte est valable dans tous nos points de vente :",
"single_location_message": "Votre carte est enregistrée chez {store_name}",
"available_locations": "Utilisez votre carte dans tous nos points de vente :",
"errors": {
"load_failed": "Impossible de charger les informations du programme",
"email_exists": "Cet e-mail est déjà inscrit dans notre programme de fidélité.",

View File

@@ -135,6 +135,10 @@
"view_dashboard": "Mäin Treie-Dashboard kucken",
"continue_shopping": "Weider akafen"
},
"already_enrolled_title": "Dir sidd schonn Member!",
"cross_location_message": "Är Kaart gëllt an all eise Standuerter:",
"single_location_message": "Är Kaart ass bei {store_name} registréiert",
"available_locations": "Benotzt Är Kaart an all eise Standuerter:",
"errors": {
"load_failed": "Programminformatiounen konnten net gelueden ginn",
"email_exists": "Dës E-Mail ass schonn an eisem Treieprogramm registréiert.",

View File

@@ -0,0 +1,59 @@
"""loyalty 004 - relax card uniqueness for cross-location support
Replace the (merchant_id, customer_id) and (customer_id, program_id)
unique indexes with (enrolled_at_store_id, customer_id). This allows
merchants with cross-location redemption DISABLED to issue one card per
store per customer, while merchants with it ENABLED enforce the
per-merchant constraint in the application layer.
Revision ID: loyalty_004
Revises: loyalty_003
Create Date: 2026-04-10
"""
from alembic import op
revision = "loyalty_004"
down_revision = "loyalty_003"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Drop the old per-merchant unique indexes
op.drop_index("idx_loyalty_card_merchant_customer", table_name="loyalty_cards")
op.drop_index("idx_loyalty_card_customer_program", table_name="loyalty_cards")
# Keep a non-unique index on (merchant_id, customer_id) for lookups
op.create_index(
"idx_loyalty_card_merchant_customer",
"loyalty_cards",
["merchant_id", "customer_id"],
unique=False,
)
# New unique constraint: one card per customer per store (always valid)
op.create_index(
"idx_loyalty_card_store_customer",
"loyalty_cards",
["enrolled_at_store_id", "customer_id"],
unique=True,
)
def downgrade() -> None:
op.drop_index("idx_loyalty_card_store_customer", table_name="loyalty_cards")
op.drop_index("idx_loyalty_card_merchant_customer", table_name="loyalty_cards")
# Restore original unique indexes
op.create_index(
"idx_loyalty_card_merchant_customer",
"loyalty_cards",
["merchant_id", "customer_id"],
unique=True,
)
op.create_index(
"idx_loyalty_card_customer_program",
"loyalty_cards",
["customer_id", "program_id"],
unique=True,
)

View File

@@ -255,11 +255,14 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
cascade="all, delete-orphan",
)
# Indexes - one card per customer per merchant
# Indexes
# One card per customer per store (always enforced at DB level).
# Per-merchant uniqueness (when cross-location is enabled) is enforced
# by the application layer in enroll_customer().
__table_args__ = (
Index("idx_loyalty_card_merchant_customer", "merchant_id", "customer_id", unique=True),
Index("idx_loyalty_card_store_customer", "enrolled_at_store_id", "customer_id", unique=True),
Index("idx_loyalty_card_merchant_customer", "merchant_id", "customer_id"),
Index("idx_loyalty_card_merchant_active", "merchant_id", "is_active"),
Index("idx_loyalty_card_customer_program", "customer_id", "program_id", unique=True),
# Balances must never go negative — guards against direct SQL writes
# bypassing the service layer's clamping logic.
CheckConstraint("points_balance >= 0", name="ck_loyalty_cards_points_balance_nonneg"),

View File

@@ -540,11 +540,18 @@ def enroll_customer(
"""
store_id = current_user.token_store_id
# Resolve merchant_id for cross-store customer lookup
from app.modules.tenancy.services.store_service import store_service
store = store_service.get_store_by_id_optional(db, store_id)
merchant_id = store.merchant_id if store else None
customer_id = card_service.resolve_customer_id(
db,
customer_id=data.customer_id,
email=data.email,
store_id=store_id,
merchant_id=merchant_id,
)
card = card_service.enroll_customer_for_store(db, customer_id, store_id)

View File

@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.modules.customers.schemas import CustomerContext
from app.modules.loyalty.exceptions import LoyaltyCardAlreadyExistsException
from app.modules.loyalty.schemas import (
CardEnrollRequest,
CardResponse,
@@ -88,6 +89,7 @@ def self_enroll(
customer_id=data.customer_id,
email=data.email,
store_id=store.id,
merchant_id=store.merchant_id,
create_if_missing=True,
customer_name=data.customer_name,
customer_phone=data.customer_phone,
@@ -96,10 +98,45 @@ def self_enroll(
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
# Build merchant context for the response (locations, cross-location flag)
settings = program_service.get_merchant_settings(db, store.merchant_id)
allow_cross_location = (
settings.allow_cross_location_redemption if settings else True
)
locations = program_service.get_merchant_locations(db, store.merchant_id)
location_list = [
{"id": loc.id, "name": loc.name}
for loc in locations
]
already_enrolled = False
try:
card = card_service.enroll_customer_for_store(db, customer_id, store.id)
except LoyaltyCardAlreadyExistsException:
# Customer already has a card — return it instead of erroring out.
# For cross-location=true this is the normal re-enroll-at-another-store
# path; for cross-location=false this is a same-store re-enroll.
already_enrolled = True
if allow_cross_location:
card = card_service.get_card_by_customer_and_merchant(
db, customer_id, store.merchant_id
)
else:
card = card_service.get_card_by_customer_and_store(
db, customer_id, store.id
)
program = card.program
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
# Resolve the name of the original enrollment store
enrolled_at_store_name = None
if card.enrolled_at_store_id:
for loc in locations:
if loc.id == card.enrolled_at_store_id:
enrolled_at_store_name = loc.name
break
return {
"card": CardResponse(
id=card.id,
@@ -122,6 +159,10 @@ def self_enroll(
has_apple_wallet=bool(card.apple_serial_number),
),
"wallet_urls": wallet_urls,
"already_enrolled": already_enrolled,
"allow_cross_location": allow_cross_location,
"enrolled_at_store_name": enrolled_at_store_name,
"merchant_locations": location_list,
}

View File

@@ -133,6 +133,7 @@ async def loyalty_self_enrollment(
async def loyalty_enrollment_success(
request: Request,
card: str = Query(None, description="Card number"),
already: str = Query(None, description="Already enrolled flag"),
db: Session = Depends(get_db),
):
"""
@@ -149,6 +150,27 @@ async def loyalty_enrollment_success(
context = get_storefront_context(request, db=db)
context["enrolled_card_number"] = card
# Provide merchant locations and cross-location flag server-side so
# the template doesn't depend on sessionStorage surviving refreshes.
store = getattr(request.state, "store", None)
if store:
from app.modules.loyalty.services import program_service
settings = program_service.get_merchant_settings(db, store.merchant_id)
locations = program_service.get_merchant_locations(db, store.merchant_id)
context["server_already_enrolled"] = already == "1"
context["server_allow_cross_location"] = (
settings.allow_cross_location_redemption if settings else True
)
context["server_merchant_locations"] = [
{"id": loc.id, "name": loc.name} for loc in locations
]
else:
context["server_already_enrolled"] = False
context["server_allow_cross_location"] = True
context["server_merchant_locations"] = []
return templates.TemplateResponse(
"loyalty/storefront/enroll-success.html",
context,

View File

@@ -119,6 +119,23 @@ class CardService:
.first()
)
def get_card_by_customer_and_store(
self,
db: Session,
customer_id: int,
store_id: int,
) -> LoyaltyCard | None:
"""Get a customer's card for a specific store."""
return (
db.query(LoyaltyCard)
.options(joinedload(LoyaltyCard.program))
.filter(
LoyaltyCard.customer_id == customer_id,
LoyaltyCard.enrolled_at_store_id == store_id,
)
.first()
)
def get_card_by_customer_and_program(
self,
db: Session,
@@ -166,6 +183,7 @@ class CardService:
customer_id: int | None,
email: str | None,
store_id: int,
merchant_id: int | None = None,
create_if_missing: bool = False,
customer_name: str | None = None,
customer_phone: str | None = None,
@@ -179,6 +197,7 @@ class CardService:
customer_id: Direct customer ID (used if provided)
email: Customer email to look up
store_id: Store ID for scoping the email lookup
merchant_id: Merchant ID for cross-store loyalty card lookup
create_if_missing: If True, create customer when email not found
(used for self-enrollment)
customer_name: Full name for customer creation
@@ -196,6 +215,9 @@ class CardService:
return customer_id
if email:
from app.modules.customers.models.customer import (
Customer as CustomerModel,
)
from app.modules.customers.services.customer_service import (
customer_service,
)
@@ -210,6 +232,29 @@ class CardService:
db.flush()
return customer.id
# Customers are store-scoped, but loyalty cards are merchant-scoped.
# Check if this email already has a card under the same merchant at
# a different store — if so, reuse that customer_id so the duplicate
# check in enroll_customer() fires correctly.
if merchant_id:
existing_cardholder = (
db.query(CustomerModel)
.join(
LoyaltyCard,
CustomerModel.id == LoyaltyCard.customer_id,
)
.filter(
CustomerModel.email == email.lower(),
LoyaltyCard.merchant_id == merchant_id,
)
.first()
)
if existing_cardholder:
if customer_birthday and not existing_cardholder.birth_date:
existing_cardholder.birth_date = customer_birthday
db.flush()
return existing_cardholder.id
if create_if_missing:
# Parse name into first/last
first_name = customer_name or ""
@@ -347,18 +392,45 @@ class CardService:
merchant_id = store.merchant_id
# Try card number
# Try card number — always merchant-scoped
card = self.get_card_by_number(db, query)
if card and card.merchant_id == merchant_id:
return card
# Try customer email
# Try customer email — first at this store
customer = customer_service.get_customer_by_email(db, store_id, query)
if customer:
card = self.get_card_by_customer_and_merchant(db, customer.id, merchant_id)
if card:
return card
# Cross-store email search: the customer may have enrolled at a
# different store under the same merchant. Only search when
# cross-location redemption is enabled.
from app.modules.customers.models.customer import Customer as CustomerModel
from app.modules.loyalty.services.program_service import program_service
settings = program_service.get_merchant_settings(db, merchant_id)
cross_location_enabled = (
settings.allow_cross_location_redemption if settings else True
)
if cross_location_enabled:
cross_store_customer = (
db.query(CustomerModel)
.join(LoyaltyCard, CustomerModel.id == LoyaltyCard.customer_id)
.filter(
CustomerModel.email == query.lower(),
LoyaltyCard.merchant_id == merchant_id,
)
.first()
)
if cross_store_customer:
card = self.get_card_by_customer_and_merchant(
db, cross_store_customer.id, merchant_id
)
if card:
return card
return None
def list_cards(
@@ -479,8 +551,30 @@ class CardService:
if not program.is_active:
raise LoyaltyProgramInactiveException(program.id)
# Check if customer already has a card
existing = self.get_card_by_customer_and_merchant(db, customer_id, merchant_id)
# Check for duplicate enrollment — the scope depends on whether
# cross-location redemption is enabled for this merchant.
from app.modules.loyalty.services.program_service import program_service
settings = program_service.get_merchant_settings(db, merchant_id)
if settings and not settings.allow_cross_location_redemption:
# Per-store cards: only block if the customer already has a card
# at THIS specific store. Cards at other stores are allowed.
if enrolled_at_store_id:
existing = (
db.query(LoyaltyCard)
.filter(
LoyaltyCard.customer_id == customer_id,
LoyaltyCard.enrolled_at_store_id == enrolled_at_store_id,
)
.first()
)
if existing:
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
else:
# Cross-location enabled (default): one card per merchant
existing = self.get_card_by_customer_and_merchant(
db, customer_id, merchant_id
)
if existing:
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)

View File

@@ -81,10 +81,21 @@ function customerLoyaltyEnroll() {
sessionStorage.setItem('loyalty_wallet_urls', JSON.stringify(response.wallet_urls));
}
// Redirect to success page
// Store enrollment context for the success page
sessionStorage.setItem('loyalty_enroll_context', JSON.stringify({
already_enrolled: response.already_enrolled || false,
allow_cross_location: response.allow_cross_location ?? true,
enrolled_at_store_name: response.enrolled_at_store_name || null,
merchant_locations: response.merchant_locations || [],
}));
// Redirect to success page — pass already_enrolled in the
// URL so the message survives page refreshes (sessionStorage
// is supplementary for the location list).
const currentPath = window.location.pathname;
const alreadyFlag = response.already_enrolled ? '&already=1' : '';
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
'?card=' + encodeURIComponent(cardNumber);
'?card=' + encodeURIComponent(cardNumber) + alreadyFlag;
window.location.href = successUrl;
}
} catch (error) {

View File

@@ -105,12 +105,12 @@
<template x-if="(card?.points_balance || 0) >= reward.points_required">
<span class="inline-flex items-center text-sm font-medium text-green-600">
<span x-html="$icon('check-circle', 'w-4 h-4 mr-1')"></span>
<span x-text="$t('loyalty.storefront.dashboard.ready_to_redeem')"></span>
<span>{{ _('loyalty.storefront.dashboard.ready_to_redeem') }}</span>
</span>
</template>
<template x-if="(card?.points_balance || 0) < reward.points_required">
<span class="text-sm text-gray-500"
x-text="$t('loyalty.storefront.dashboard.x_more_to_go', {count: reward.points_required - (card?.points_balance || 0)})">
x-text="'{{ _('loyalty.storefront.dashboard.x_more_to_go') }}'.replace('{count}', reward.points_required - (card?.points_balance || 0))">
</span>
</template>
</div>
@@ -208,14 +208,14 @@
<template x-if="walletUrls.apple_wallet_url">
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
{{ _('loyalty.loyalty.wallet.apple') }}
</a>
</template>
<template x-if="walletUrls.google_wallet_url">
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
{{ _('loyalty.loyalty.wallet.google') }}
</a>
</template>

View File

@@ -3,6 +3,7 @@
{% block title %}{{ _('loyalty.enrollment.success.title') }} - {{ store.name }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}customerLoyaltyEnrollSuccess(){% endblock %}
{% block content %}
@@ -16,7 +17,10 @@
</div>
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ _('loyalty.enrollment.success.title') }}</h1>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2"
x-text="enrollContext.already_enrolled ? i18nStrings.already_enrolled_title : i18nStrings.success_title">
{{ _('loyalty.enrollment.success.title') }}
</h1>
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ _('loyalty.enrollment.success.message') }}</p>
<!-- Card Number Display -->
@@ -33,14 +37,14 @@
<template x-if="walletUrls.apple_wallet_url">
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
{{ _('loyalty.loyalty.wallet.apple') }}
</a>
</template>
<template x-if="walletUrls.google_wallet_url">
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
{{ _('loyalty.loyalty.wallet.google') }}
</a>
</template>
@@ -48,6 +52,41 @@
</div>
</div>
<!-- Cross-location info -->
<template x-if="enrollContext.already_enrolled || (enrollContext.merchant_locations?.length > 1 && enrollContext.allow_cross_location)">
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-8 text-left">
<template x-if="enrollContext.already_enrolled && enrollContext.allow_cross_location">
<div>
<p class="font-medium text-blue-800 dark:text-blue-200 mb-2" x-text="i18nStrings.cross_location_message"></p>
<ul class="space-y-1">
<template x-for="loc in enrollContext.merchant_locations" :key="loc.id">
<li class="flex items-center text-sm text-blue-700 dark:text-blue-300">
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2 flex-shrink-0')"></span>
<span x-text="loc.name"></span>
</li>
</template>
</ul>
</div>
</template>
<template x-if="enrollContext.already_enrolled && !enrollContext.allow_cross_location">
<p class="text-sm text-blue-700 dark:text-blue-300" x-text="i18nStrings.single_location_message.replace('{store_name}', enrollContext.enrolled_at_store_name || '')"></p>
</template>
<template x-if="!enrollContext.already_enrolled && enrollContext.merchant_locations?.length > 1 && enrollContext.allow_cross_location">
<div>
<p class="font-medium text-blue-800 dark:text-blue-200 mb-2" x-text="i18nStrings.available_locations"></p>
<ul class="space-y-1">
<template x-for="loc in enrollContext.merchant_locations" :key="loc.id">
<li class="flex items-center text-sm text-blue-700 dark:text-blue-300">
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2 flex-shrink-0')"></span>
<span x-text="loc.name"></span>
</li>
</template>
</ul>
</div>
</template>
</div>
</template>
<!-- Next Steps -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 text-left mb-8">
<h2 class="font-semibold text-gray-900 dark:text-white mb-4">{{ _('loyalty.enrollment.success.next_steps_title') }}</h2>
@@ -89,6 +128,20 @@ function customerLoyaltyEnrollSuccess() {
return {
...storefrontLayoutData(),
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
// Server-rendered context — no flicker, survives refreshes
enrollContext: {
already_enrolled: {{ server_already_enrolled|tojson }},
allow_cross_location: {{ server_allow_cross_location|tojson }},
enrolled_at_store_name: null,
merchant_locations: {{ server_merchant_locations|tojson }},
},
i18nStrings: {
success_title: {{ _('loyalty.enrollment.success.title')|tojson }},
already_enrolled_title: {{ _('loyalty.enrollment.already_enrolled_title')|tojson }},
cross_location_message: {{ _('loyalty.enrollment.cross_location_message')|tojson }},
single_location_message: {{ _('loyalty.enrollment.single_location_message')|tojson }},
available_locations: {{ _('loyalty.enrollment.available_locations')|tojson }},
},
init() {
// Read wallet URLs saved during enrollment (no auth needed)
@@ -101,6 +154,20 @@ function customerLoyaltyEnrollSuccess() {
} catch (e) {
console.log('Could not load wallet URLs:', e.message);
}
// Merge sessionStorage context (has enrolled_at_store_name from
// the enrollment API response) into the server-rendered defaults
try {
const ctx = sessionStorage.getItem('loyalty_enroll_context');
if (ctx) {
const parsed = JSON.parse(ctx);
if (parsed.enrolled_at_store_name) {
this.enrollContext.enrolled_at_store_name = parsed.enrolled_at_store_name;
}
}
} catch (e) {
console.log('Could not load enroll context:', e.message);
}
}
};
}

View File

@@ -93,7 +93,7 @@
{{ _('loyalty.storefront.history.previous') }}
</button>
<span class="text-sm text-gray-500 dark:text-gray-400"
x-text="$t('loyalty.storefront.history.page_x_of_y', {page: pagination.page, pages: pagination.pages})">
x-text="'{{ _('loyalty.storefront.history.page_x_of_y') }}'.replace('{page}', pagination.page).replace('{pages}', pagination.pages)">
</span>
<button @click="nextPage()" :disabled="pagination.page >= pagination.pages"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50">

View File

@@ -830,3 +830,248 @@ class TestAdjustPointsRoleGate:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
# ============================================================================
# Item 4: Cross-store enrollment
# ============================================================================
@pytest.fixture
def cross_store_setup(db, loyalty_platform):
"""Setup with two stores under the same merchant for cross-store tests.
Creates: merchant → store1 + store2 (each with its own user),
program, customer at store1 with card.
"""
from app.modules.customers.models.customer import Customer
from app.modules.tenancy.models import Merchant, Store
from app.modules.tenancy.models.store import StoreUser
from app.modules.tenancy.models.store_platform import StorePlatform
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
# Owner for store1
owner1 = User(
email=f"xs1own_{uid}@test.com",
username=f"xs1own_{uid}",
hashed_password=auth.hash_password("storepass123"),
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
db.add(owner1)
db.commit()
db.refresh(owner1)
merchant = Merchant(
name=f"Cross-Store Merchant {uid}",
owner_user_id=owner1.id,
contact_email=owner1.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
# Store 1
store1 = Store(
merchant_id=merchant.id,
store_code=f"XS1_{uid.upper()}",
subdomain=f"xs1{uid}",
name=f"Cross Store 1 {uid}",
is_active=True,
is_verified=True,
)
db.add(store1)
db.commit()
db.refresh(store1)
su1 = StoreUser(store_id=store1.id, user_id=owner1.id, is_active=True)
db.add(su1)
sp1 = StorePlatform(store_id=store1.id, platform_id=loyalty_platform.id)
db.add(sp1)
db.commit()
# Separate user for store2 (login always binds to user's first store)
owner2 = User(
email=f"xs2own_{uid}@test.com",
username=f"xs2own_{uid}",
hashed_password=auth.hash_password("storepass123"),
role="merchant_owner",
is_active=True,
is_email_verified=True,
)
db.add(owner2)
db.commit()
db.refresh(owner2)
# Store 2
store2 = Store(
merchant_id=merchant.id,
store_code=f"XS2_{uid.upper()}",
subdomain=f"xs2{uid}",
name=f"Cross Store 2 {uid}",
is_active=True,
is_verified=True,
)
db.add(store2)
db.commit()
db.refresh(store2)
su2 = StoreUser(store_id=store2.id, user_id=owner2.id, is_active=True)
db.add(su2)
sp2 = StorePlatform(store_id=store2.id, platform_id=loyalty_platform.id)
db.add(sp2)
db.commit()
# Customer at store1
customer_email = f"xscust_{uid}@test.com"
customer = Customer(
email=customer_email,
first_name="Cross",
last_name="StoreCustomer",
hashed_password="!unused!", # noqa: SEC001
customer_number=f"XSC-{uid.upper()}",
store_id=store1.id,
is_active=True,
)
db.add(customer)
db.commit()
db.refresh(customer)
# Program
program = LoyaltyProgram(
merchant_id=merchant.id,
loyalty_type=LoyaltyType.POINTS.value,
points_per_euro=10,
welcome_bonus_points=0,
minimum_redemption_points=100,
minimum_purchase_cents=0,
cooldown_minutes=0,
max_daily_stamps=10,
require_staff_pin=False,
card_name="Cross Store Rewards",
card_color="#4F46E5",
is_active=True,
points_rewards=[],
)
db.add(program)
db.commit()
db.refresh(program)
# Card enrolled at store1
card = LoyaltyCard(
merchant_id=merchant.id,
program_id=program.id,
customer_id=customer.id,
enrolled_at_store_id=store1.id,
is_active=True,
last_activity_at=datetime.now(UTC),
)
db.add(card)
db.commit()
db.refresh(card)
return {
"owner1": owner1,
"owner2": owner2,
"merchant": merchant,
"store1": store1,
"store2": store2,
"customer": customer,
"customer_email": customer_email,
"program": program,
"card": card,
}
@pytest.fixture
def cross_store_headers_store2(client, cross_store_setup):
"""JWT auth headers bound to store2 (via owner2 who only belongs to store2)."""
owner2 = cross_store_setup["owner2"]
response = client.post(
"/api/v1/store/auth/login",
json={"email_or_username": owner2.username, "password": "storepass123"},
)
assert response.status_code == 200
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.loyalty
class TestCrossStoreEnrollment:
"""Integration tests for enrollment across stores under the same merchant."""
def test_enroll_same_email_at_store2_returns_409(
self, client, cross_store_headers_store2, cross_store_setup
):
"""With cross-location enabled (default), enrolling the same email
at store2 returns 409 because the customer already has a card under
this merchant."""
response = client.post(
f"{BASE}/cards/enroll",
json={"email": cross_store_setup["customer_email"]},
headers=cross_store_headers_store2,
)
assert response.status_code == 409
def test_enroll_new_customer_at_store2_succeeds(
self, client, cross_store_headers_store2, cross_store_setup, db
):
"""A fresh customer at store2 (no existing card) enrolls normally."""
from app.modules.customers.models.customer import Customer
# Pre-create a customer at store2 (store API requires existing customer)
store2 = cross_store_setup["store2"]
new_customer = Customer(
email=f"newcust_{uuid.uuid4().hex[:8]}@test.com",
first_name="New",
last_name="Customer",
hashed_password="!unused!", # noqa: SEC001
customer_number=f"NEW-{uuid.uuid4().hex[:6].upper()}",
store_id=store2.id,
is_active=True,
)
db.add(new_customer)
db.commit()
db.refresh(new_customer)
response = client.post(
f"{BASE}/cards/enroll",
json={"customer_id": new_customer.id},
headers=cross_store_headers_store2,
)
assert response.status_code == 201
data = response.json()
assert data["card_number"] is not None
def test_enroll_cross_location_disabled_allows_store2_card(
self, client, cross_store_headers_store2, cross_store_setup, db
):
"""With cross-location disabled, enrolling the same email at store2
creates a second card for that store."""
# Disable cross-location
from app.modules.loyalty.services.program_service import program_service
settings = program_service.get_or_create_merchant_settings(
db, cross_store_setup["merchant"].id
)
settings.allow_cross_location_redemption = False
db.commit()
response = client.post(
f"{BASE}/cards/enroll",
json={"email": cross_store_setup["customer_email"]},
headers=cross_store_headers_store2,
)
assert response.status_code == 201
data = response.json()
assert data["card_number"] is not None
# Different card from the original
assert data["id"] != cross_store_setup["card"].id

View File

@@ -153,6 +153,69 @@ class TestSearchCardForStore:
result = self.service.search_card_for_store(db, test_store.id, "nobody@nowhere.com")
assert result is None
def test_search_email_finds_cross_store_card(self, db, loyalty_store_setup):
"""Email search at store2 finds a card enrolled at store1 when
cross-location is enabled."""
setup = loyalty_store_setup
customer = setup["customer"]
card = setup["card"]
merchant = setup["merchant"]
from app.modules.tenancy.models import Store
store2 = Store(
merchant_id=merchant.id,
store_code=f"XSRCH_{uuid.uuid4().hex[:6].upper()}",
subdomain=f"xsrch{uuid.uuid4().hex[:6]}",
name="Cross Search Store",
is_active=True,
is_verified=True,
)
db.add(store2)
db.commit()
# Cross-location is enabled by default — should find the card
result = self.service.search_card_for_store(
db, store2.id, customer.email
)
assert result is not None
assert result.id == card.id
def test_search_email_no_cross_store_when_disabled(
self, db, loyalty_store_setup
):
"""Email search at store2 does NOT find cross-store cards when
cross-location is disabled."""
setup = loyalty_store_setup
customer = setup["customer"]
merchant = setup["merchant"]
from app.modules.loyalty.services.program_service import program_service
settings = program_service.get_or_create_merchant_settings(
db, merchant.id
)
settings.allow_cross_location_redemption = False
db.commit()
from app.modules.tenancy.models import Store
store2 = Store(
merchant_id=merchant.id,
store_code=f"NOSRCH_{uuid.uuid4().hex[:6].upper()}",
subdomain=f"nosrch{uuid.uuid4().hex[:6]}",
name="No Cross Search Store",
is_active=True,
is_verified=True,
)
db.add(store2)
db.commit()
result = self.service.search_card_for_store(
db, store2.id, customer.email
)
assert result is None
@pytest.mark.unit
@pytest.mark.loyalty
@@ -405,3 +468,232 @@ class TestReactivateCardAudit:
card = self.service.reactivate_card(db, test_loyalty_card.id)
assert card.is_active is True
@pytest.mark.unit
@pytest.mark.loyalty
class TestGetCardByCustomerAndStore:
"""Tests for the per-store card lookup."""
def setup_method(self):
self.service = CardService()
def test_finds_card_at_store(self, db, loyalty_store_setup):
"""Returns card when customer has one at the given store."""
setup = loyalty_store_setup
result = self.service.get_card_by_customer_and_store(
db, setup["customer"].id, setup["store"].id
)
assert result is not None
assert result.id == setup["card"].id
def test_returns_none_at_different_store(self, db, loyalty_store_setup):
"""Returns None when customer has no card at the given store."""
from app.modules.tenancy.models import Store
setup = loyalty_store_setup
store2 = Store(
merchant_id=setup["merchant"].id,
store_code=f"NOCARD_{uuid.uuid4().hex[:6].upper()}",
subdomain=f"nocard{uuid.uuid4().hex[:6]}",
name="No Card Store",
is_active=True,
is_verified=True,
)
db.add(store2)
db.commit()
result = self.service.get_card_by_customer_and_store(
db, setup["customer"].id, store2.id
)
assert result is None
@pytest.mark.unit
@pytest.mark.loyalty
class TestCrossStoreEnrollment:
"""
Tests for cross-store enrollment with merchant_id-aware resolution.
The customer model is store-scoped, but loyalty cards are merchant-scoped.
When a customer enrolls at store1 and then at store2 (same merchant),
resolve_customer_id should find the existing customer from store1 via
the cross-store loyalty card lookup.
"""
def setup_method(self):
self.service = CardService()
def test_resolve_finds_existing_cardholder_across_stores(
self, db, loyalty_store_setup
):
"""Same email at a different store returns the original customer_id
when merchant_id is provided and they already have a card."""
setup = loyalty_store_setup
merchant = setup["merchant"]
customer = setup["customer"] # Already has a card at store1
# Create a second store under the same merchant
from app.modules.tenancy.models import Store
store2 = Store(
merchant_id=merchant.id,
store_code=f"SECOND_{uuid.uuid4().hex[:6].upper()}",
subdomain=f"second{uuid.uuid4().hex[:6]}",
name="Second Store",
is_active=True,
is_verified=True,
)
db.add(store2)
db.commit()
db.refresh(store2)
# Resolve with the same email at store2 — should find
# the existing customer from store1 via the loyalty card join
result = self.service.resolve_customer_id(
db,
customer_id=None,
email=customer.email,
store_id=store2.id,
merchant_id=merchant.id,
create_if_missing=True,
)
assert result == customer.id
def test_resolve_without_merchant_id_creates_new_customer(
self, db, loyalty_store_setup
):
"""Without merchant_id, the cross-store lookup is skipped and
a new customer is created at the new store."""
setup = loyalty_store_setup
merchant = setup["merchant"]
customer = setup["customer"]
from app.modules.tenancy.models import Store
store2 = Store(
merchant_id=merchant.id,
store_code=f"NOMID_{uuid.uuid4().hex[:6].upper()}",
subdomain=f"nomid{uuid.uuid4().hex[:6]}",
name="No Merchant ID Store",
is_active=True,
is_verified=True,
)
db.add(store2)
db.commit()
result = self.service.resolve_customer_id(
db,
customer_id=None,
email=customer.email,
store_id=store2.id,
# No merchant_id — cross-store lookup skipped
create_if_missing=True,
customer_name="New Customer",
)
assert result != customer.id # Different customer created
def test_enroll_cross_location_enabled_rejects_duplicate(
self, db, loyalty_store_setup
):
"""With cross-location enabled (default), enrolling the same
customer_id at a different store raises AlreadyExists."""
from app.modules.loyalty.exceptions import (
LoyaltyCardAlreadyExistsException,
)
setup = loyalty_store_setup
merchant = setup["merchant"]
customer = setup["customer"] # Already has a card
from app.modules.tenancy.models import Store
store2 = Store(
merchant_id=merchant.id,
store_code=f"DUP_{uuid.uuid4().hex[:6].upper()}",
subdomain=f"dup{uuid.uuid4().hex[:6]}",
name="Dup Store",
is_active=True,
is_verified=True,
)
db.add(store2)
db.commit()
with pytest.raises(LoyaltyCardAlreadyExistsException):
self.service.enroll_customer_for_store(
db, customer.id, store2.id
)
def test_enroll_cross_location_disabled_allows_second_card(
self, db, loyalty_store_setup
):
"""With cross-location disabled, the same customer can enroll
at a different store and get a separate card."""
setup = loyalty_store_setup
merchant = setup["merchant"]
customer = setup["customer"]
# Disable cross-location
from app.modules.loyalty.services.program_service import (
program_service,
)
settings = program_service.get_or_create_merchant_settings(
db, merchant.id
)
settings.allow_cross_location_redemption = False
db.commit()
from app.modules.tenancy.models import Store
store2 = Store(
merchant_id=merchant.id,
store_code=f"XLOC_{uuid.uuid4().hex[:6].upper()}",
subdomain=f"xloc{uuid.uuid4().hex[:6]}",
name="Cross-Loc Store",
is_active=True,
is_verified=True,
)
db.add(store2)
db.commit()
db.refresh(store2)
# Should succeed — different store, cross-location disabled
card2 = self.service.enroll_customer_for_store(
db, customer.id, store2.id
)
assert card2.enrolled_at_store_id == store2.id
assert card2.merchant_id == merchant.id
assert card2.customer_id == customer.id
# Original card still exists
assert setup["card"].id != card2.id
def test_enroll_cross_location_disabled_rejects_same_store(
self, db, loyalty_store_setup
):
"""With cross-location disabled, re-enrolling at the SAME store
still raises AlreadyExists."""
from app.modules.loyalty.exceptions import (
LoyaltyCardAlreadyExistsException,
)
setup = loyalty_store_setup
merchant = setup["merchant"]
customer = setup["customer"]
from app.modules.loyalty.services.program_service import (
program_service,
)
settings = program_service.get_or_create_merchant_settings(
db, merchant.id
)
settings.allow_cross_location_redemption = False
db.commit()
with pytest.raises(LoyaltyCardAlreadyExistsException):
self.service.enroll_customer_for_store(
db, customer.id, setup["store"].id
)