# MDD — BTL Offers (generic promotional-gift claim platform)

**Status:** BUILT (v1, dev) — 2026-07-11. Live at https://btl.srv1111289.hstgr.cloud/ (dev-only; not promoted to prod).
**Origin:** /webapp-reverse of the dead philipswizoffer.com (2023 Signify "WiZ Consumer Offer" microsite — domain dropped ~2024-07, host vhost gone, no archives). Structure reconstructed from the user's description + June-2023 email trail, then locked via the /new-pwa Q0 interview (2026-07-11, all answers user-confirmed).
**ADRs:** ADR-013 (single file), ADR-009 (own `btl` schema), ADR-050-style RPC-only auth surface, ADR-071 (Indian amounts), ADR-072 (proof editor, full), ADR-075 (RPC error contract), ADR-006/008 (TABLE login / #variable_conflict), ADR-076 (viewport), ADR-081 (safe-bottom), ADR-086 (isolated PWA), ADR-017/073 (Traefik), ADR-020 (offline-first), ADR-100 (theme-v2 presets).

---

## 1. Why

360DLM repeatedly runs BTL schemes for brand clients: brand offers a promotional gift against a valid purchase; channel partners submit the purchase invoice as proof; the agency verifies and approves; the gift is couriered; the brand gets MIS. The WiZ microsite did this once, hard-coded to one campaign, on outside infrastructure that evaporated. This PWA is the generic, multi-campaign rebuild on own infrastructure — a new brand campaign is configuration, not code.

## 2. Locked decisions (Q0 interview, 2026-07-11)

| Topic | Decision |
|---|---|
| Identity | `btl` / "BTL Offers", dir `/var/www/360lm/btl/` |
| Auth | Standalone id+PIN (NOT hub). Login IDs globally unique, campaign-prefixed (`WIZ-001`), so no campaign picker at login. PIN stored as sha256(login:pin); client keeps id+pin in localStorage for stateless per-RPC re-auth (ponytail ceiling; upgrade trigger = ADR-105 JWT) |
| Partners | **Per-campaign lists** (user: platform is not Signify-only; each campaign imports its own sheet) |
| Brand client | Excel/CSV MIS export only — no brand-viewer login in v1 |
| Eligibility | Min-amount threshold (lowest gift_rule) + unique invoice-no per campaign + date within window. Everything else is human review |
| Form | Single page; localStorage draft (500ms debounce); mandatory 1–3 invoice images with FULL editor (crop+rect+oval+line+pen, 5 colors, 3 widths, undo 20) + quality-slider compression ≤100KB |
| Offline | Offline-first submission queue in IndexedDB (BtlDB v1: queue/claims/cfg). Duplicate check is soft locally, hard at sync; server conflict marks the queued item "⚠ Needs fix" and reopens it in the form |
| Idempotency | `client_ref` (BTL_<ts>_<rand>) unique column; replayed sync of the same ref returns the existing claim |
| Resubmission | Query-back loop: admin `query` with mandatory note → partner fixes & resubmits. Rejected is final |
| Fulfilment | In-app: dispatch form (gift/courier/AWB/dates) inside admin claim detail; queue filtered to "approved" is the dispatch board (no separate view — ponytail) |
| Admins | harish + pramod (role='admin' rows; ⚠ seeded with placeholder PIN 0000 — MUST be rotated) |
| Theme | navy-classic app chrome; `campaigns.theme_preset` applied per-campaign after partner login (ADR-100 presets only) |
| Nav | 3 tabs partner (My Claims/New Claim/Help), 3 tabs admin (Queue/Campaigns/Partners) |
| Notifications | None v1 (💡 OpenClaw WhatsApp later) |
| Hosting | Dedicated `btl-web` httpd:alpine container (isolation per ADR-086): mounts `/var/www/360lm/btl` at root + `/shared` read-only. Traefik routers `btlweb` + `postgrest-btl` (`/db` on the subdomain → shared dev PostgREST). Also reachable at dev host `/btl/` (SW/manifest are path-agnostic) |

## 3. Status machine

`submitted → (approve|reject|query)`; `queried → resubmitted → (approve|reject|query)`; `approved → dispatched → delivered`. Client-side only: `pending_sync` (queued offline) and `conflict` (server rejected at sync; partner corrects).

## 4. Data model — schema `btl` (lm360 dev; migrate_btl_v1.sql, applied 2026-07-11)

campaigns, gift_rules, partners (per-campaign; admin rows have campaign_id NULL; CHECK enforces), claims (UNIQUE(campaign_id,invoice_no), UNIQUE(client_ref), images jsonb 1–3 data-URLs — never returned by list RPCs, lazy via btl_claim_images), dispatches (1:1 claim), claim_events (audit).

**Zero direct web_anon table grants.** 13 SECURITY DEFINER RPCs (`btl_login`, `btl_submit_claim`, `btl_resubmit_claim`, `btl_my_claims`, `btl_claim_images`, `btl_upsert_campaign`, `btl_admin_campaigns`, `btl_import_partners`, `btl_admin_partners`, `btl_set_partner`, `btl_admin_claims`, `btl_review_claim`, `btl_save_dispatch`), all re-verifying id+PIN via internal `btl._auth`/`_auth_admin` (not exposed). Errors per ADR-075 (`P0001` + user-readable message + machine `details`). `btl` appended to dev `PGRST_DB_SCHEMAS`.

## 5. Partner import / credentials

Admin picks campaign + login-ID prefix, uploads CSV/XLSX (columns name/outlet/city/phone; SheetJS CDN). Client generates `PREFIX-NNN` ids + 4-digit PINs, sends rows to `btl_import_partners` (hashes server-side, skips duplicate login_ids), then downloads a credential CSV — the only place PINs exist in plain text (mirrors the original's "User ID and Password.xlsx" workflow). Reset-PIN button generates a new PIN and shows it once.

## 6. Verification (2026-07-11)

- Full RPC lifecycle smoke-tested over the real web_anon HTTP path (not psql): bad login → `[]`, below-threshold → 400, valid submit, idempotent replay, duplicate invoice → 400, query→resubmit→approve→dispatch, my_claims joins dispatch. All passed first run.
- Playwright `tests/btl.spec.js`: 18/18 pass (login render, CACHE_VER assert, partner+admin land correctly, bad login, empty-submit validation, Indian formatting `12,34,567`, draft survives reload, offline banner). Report in test_reports/.
- 390×844 screenshots + critique (fixed hero/stat-card overlap) + VCC §V pass (contrast, aria-labels, focus rings, ≥44px targets, no 375px h-scroll, reduced-motion, labeled inputs).
- Test fixtures kept for the spec: campaign "TEST Campaign" (id 1), partner TST-001/1234, claim INV-1 (dispatched).

## 7. Out of scope v1

Consumer QR/OTP redemption; SMS/WhatsApp notifications; gift inventory accounting; brand-viewer login; multi-language; prod deployment (user decision pending).

## 8. Open items

- ⚠️ Rotate admin PINs (harish/pramod seeded `0000`).
- ⚠️ Prod promotion decision (schema on lm360_prod + prod PGRST list + prod routing) — deliberately not done.
- 💡 OCR-assisted invoice entry (ADR-062 chain) if manual entry annoys partners.
- 💡 Per-campaign custom domains (extra Traefik router per domain) if a brand insists.
