Offer Claim + Redeem Flow - Rollout Plan โ
Date: 2026-07-24 Branch: worktree-offer-claim-redeem (off origin/dev) Status: live plan; ยง4 is the running dashboard. Issue: #704Design: docs/planning/specs/2026-07-24-offer-claim-redeem-design.md
1. Why / what โ
Completes the ad-funnel bottom left open by the ad network data layer: emit offer_claimed / offer_redeemed server-side so the durable rollup and merchant dashboard show real conversion. Model (operator-approved): here-and-now, lantern-gated, no save. Full rationale in the design spec.
2. Phases โ
Phase 1 - merchants-api: claim + redeem endpoints (#704) โ
| Step | What | Where |
|---|---|---|
| 1a | Wire @lantern/forge: add dep to package.json, vendor it in deploy:dev (cp -r ../../../packages/forge .forge-pkg), copy the trackEvent dev-echo helper pattern from lanterns-api. | services/api/merchants/ |
| 1b | Presence check: hasActiveLanternAtVenue(uid, venueId) reading the shared lanterns collection via Admin SDK (userId + venueId + not-expired). Unit-tested. | services/api/merchants/src/lib/ |
| 1c | offerClaims service: idempotent claim upsert (${uid}_${offerId}), transactional redeem with per_user_limit enforcement, Forge emit with the server_authoritative attribution contract. Zod bodies, zod->400 normalization. | services/api/merchants/src/services/ |
| 1d | Routes POST /offers/:offerId/claim + /redeem on a user-auth (not merchant-owner) mount; rate-limited bucket. Update openapi.json. | services/api/merchants/src/routes/, openapi.json |
| 1e | firestore.rules: offerClaims server-only block; TTL policy on expiresAt documented. | firestore.rules |
Phase 2 - apps/web: claim + live redeem UX (#704) โ
| Step | What | Where |
|---|---|---|
| 2a | Offer cards accept injected onClaim / claimState (no telemetry/fetch imported in the card); Claim CTA rendered as a sibling of the tap-through button (never nested <button>). Stories updated. | packages/ui/offers/ |
| 2b | offerClaimService: claimOffer / redeemOffer via authRequest, forwarding sourcePlacement / sourceSurface / merchantId from the ad view; base URL via the /api/merchants proxy. | apps/web/src/lib/ |
| 2c | Live redeem-confirmation screen/modal: animated, timestamped, venue + offer; the "show staff" surface. Colocated story with the animation states. | apps/web/src/components/ or screens/ |
| 2d | Wire the CTA into HomeView hero + inline + the venue-detail offer card; no-lantern gating message ("Light a lantern here to claim"). | apps/web/src/screens/dashboard/HomeView.jsx, venue detail |
Phase 3 - verify + ship โ
| Step | What |
|---|---|
| 3a | Unit + route specs (presence, limit transaction, emit shape, 403/404/409 paths). |
| 3b | Live emulator run: merchant + offer + signed-in user with a lit lantern -> assert a server_authoritative offer_claimed/offer_redeemed row lands in analytics.events with user_id. |
| 3c | npm run validate (full) green locally. |
| 3d | Draft PR, base dev, Closes #704. |
3. Merchant side โ
No new merchant action UI. Claim/redeem counts reach the merchant dashboard through the existing ad_delivery_daily rollup + summarizeDeliveryByOffer join (already wired for claims/redemptions), k-anon gated. Confirmed as part of 3b that the counts appear once events flow.
4. Live status โ
| Phase | Status | Notes |
|---|---|---|
| Spec + plan + issue | done | #704 filed; design spec + this plan written 2026-07-24 |
| 1 merchants-api endpoints | done | claim/redeem routes + service + presence + offerClaims rules; 20 tests |
| 2 web claim + redeem UX | done | offerClaimService + ClaimOfferButton + RedeemConfirmation, wired into VenueView; 7 tests |
| 3 verify + ship | done | draft PR #705 (base dev); live verification below; web browser check pending |
Live verification (2026-07-24, real dev backend) โ
Ran the claim + redeem service against real dev Firestore + BigQuery (seeded a temporary offer + lit lantern, exercised claimOffer/redeemOffer directly, then cleaned up):
- Presence gate works against real Firestore: claim with a lit lantern succeeded; an offer at a venue with no lantern was correctly rejected
NO_LANTERN(403).per_user_limit=1enforced (second redeemLIMIT_REACHED409). - Both
offer_claimedandoffer_redeemedlanded inanalytics.eventswith the full trust contract:attributionSource='server_authoritative',user_idpopulated,entity_id=offerId,service_id='merchants-api',environment='development', plusofferId/merchantId/sourcePlacementmetadata. - The rollup MERGE extraction (run read-only over the test rows) produced the expected cell:
claims=1, redemptions=1, unique_users=1keyed on (offer, merchant, placement=inline, day). The funnel is confirmed end to end; the scheduled nightly MERGE will count these the same way. - Remaining: web UX in a real browser (claim states, gate, live redeem screen), and the deploy-time items (Cloud Run build of the Forge vendoring,
offerClaimsTTL policy creation, rules deploy) which land at/after merge.
5. Guardrails carried from the data layer โ
- Emit server-side only,
attributionSource='server_authoritative',user_idpopulated (else k-anon suppresses the cell forever). Plan D15/D18. entity_id = offerId, metadata{ sourcePlacement, sourceSurface, merchantId, offerId }. The MERGE readsmetadata.offerId || entity_id,metadata.sourcePlacement(-> placement),metadata.merchantId. Registry contract inpackages/shared/analytics/index.js.- Merchant identity is the minted
merchants/{id}viausers/{uid}.merchantId, never the auth uid. Plan D13. - No em dashes anywhere (AGENTS.md). Story coverage for every new component. openapi in sync.