Offer Claim + Redeem Flow - Design Spec โ
Date: 2026-07-24 Status: approved (operator, 2026-07-24); living draft Issue: #704Rollout: docs/planning/plans/2026-07-24-offer-claim-redeem.mdPredecessor: ad network data layer (docs/planning/plans/2026-07-22-ad-network-data-layer.md, A4 attribution contract, decisions D13-D20); docs/economics/AD_PLACEMENT_ECONOMICS.md Parts 5-7.
1. Problem โ
The ad network data layer instruments the top of the funnel (fill -> impression -> click) and the durable analytics.ad_delivery_daily rollup already reserves claims / redemptions columns behind a fail-closed server_authoritative gate. But the bottom of the funnel is empty: offer_claimed / offer_redeemed are registered events with no emitter, because there is no claim/redeem UX or backend. Merchants cannot see conversion, and the two-tier accountability story (Part 5) has no redemption seed.
This spec designs the end-to-end flow: a user claims and redeems an offer in-venue, and the two billing-adjacent events are emitted server-side with honest attribution.
2. Goals / non-goals โ
Goals
- A user physically at a venue can claim and redeem an offer in-app, in one visit.
offer_claimed/offer_redeemedemitted server-side (Forge),attributionSource='server_authoritative', withuser_idand the attribution contract (offerId,sourcePlacement,sourceSurface,merchantId), so the existing rollup and merchant dashboard light up with no downstream changes.- Enforce
per_user_limit(max redemptions per user per offer; default 1). - Anti-fraud by construction, not by heuristics.
Non-goals (v1)
- No "save for later" / wallet / claimed-offers surface. Claim and redeem happen in the same visit. (Saves are a deliberate later add; see ยง7.)
- No QR codes, scanners, or merchant-side confirmation action. The lit lantern + live on-screen confirmation is the mechanism.
- No per-redemption billing. Billing stays the flat placement fee ($150/mo); claim/redeem counts feed attribution and the dashboard, not the billing unit (econ doc Part 6 Q4 defers per-redemption pricing).
- No rotation/pacing changes.
3. The model: here-and-now, lantern-gated, no save โ
A lit lantern is Lantern's proof-of-presence primitive: venue-anchored, geofenced at light time, 48h TTL. Gating claim/redeem on "you have a lantern lit at this venue, right now" makes the redemption self-authenticating without any scanning hardware, and makes fraud expensive (each fake claim needs a geofenced lit lantern). This is our differentiator versus Yelp/Groupon, whose check-in is a spoofable GPS ping and whose coupon is a screenshot-able image.
Funnel:
impression -> click -> [LANTERN LIT @ venue] redeem (one tap = claim + redeem)- See. Offer renders front-and-center wherever it is served: home hero, inline pill, and the venue detail page (offer selection is already geofenced, so users only see offers near them). The card already shows the offer's info (title + description).
- Redeem (one tap). User taps "Redeem offer." Server verifies an active lantern at
offer.venueId, records the claim (emitsoffer_claimed) AND redeems it (emitsoffer_redeemed), enforcingper_user_limit. A full-screen, live, timestamped, animated confirmation (the Yelp anti-screenshot trick) is shown to venue staff.
The claim and redeem are collapsed into a single tap (decision E3): because the offer card already shows everything, a separate "claim" step would reveal nothing and be pure friction. Both events are still emitted so the rollup's claims and redemptions columns both populate (they will be equal, which is the honest shape of a one-tap flow). Redemption is user-initiated and honor-system on the merchant side; integrity comes from the lit-lantern gate and the un-screenshottable live confirmation, which is sufficient because redemption is not the billing unit.
4. Key decisions โ
| # | Decision | Rationale |
|---|---|---|
| E1 | Lit lantern at the offer's venue gates BOTH claim and redeem | One presence model, reused. Every claim becomes a real in-venue signal (not a spoofable save-from-home), so claim counts are billing-honest. Re-checked at redeem (the redeem call re-verifies the lantern) because a lantern can expire (48h TTL); see E3 for why claim + redeem are one tap. |
| E2 | No save / wallet in v1 | Operator call (2026-07-24). Claim and redeem are one visit; "grab now, use next visit" is a deliberate later feature, not silently introduced. Keeps the surface area small and on-brand (everything in Lantern is present-tense). |
| E3 | One tap = claim + redeem (both events, not two taps) | Revised after the live phone walkthrough (operator, 2026-07-24): the offer card already shows the offer's info, so a separate "claim" tap reveals nothing and is pure friction. The single "Redeem offer" tap fires BOTH offer_claimed and offer_redeemed server-side, so the rollup's two columns stay populated (claims == redemptions, the honest shape of a one-tap flow). The two-tap claim->redeem is reintroduced only if offers gain a detail view (terms / expiry / photos) worth a "view" step. The claim endpoint stays available for that future path. |
| E4 | Endpoints on merchants-api, not lanterns-api or a Cloud Function | merchants-api owns offers and already resolves merchant identity. It reads the shared lanterns collection directly via the Admin SDK for the presence check (no cross-service call). API-first (AGENTS.md rule 9). |
| E5 | Durable claim record: opaque userId + TTL, server-only | A claim links userId -> offer -> venue -> time, the edge the privacy axiom minimizes. Stored in a server-only offerClaims collection (Firestore rules deny all client access), TTL-purged (offer expiry / 90 days) to match the raw-event retention posture. Merchants NEVER read per-user rows; they only ever get k-anon aggregate counts via the rollup. The opaque userId is consistent with existing login_events; HMAC-hashing it is a one-line hardening deferred to ยง7. |
| E6 | Server stamps attributionSource='server_authoritative' + user_id; client forwards attribution only | The rollup fail-closes: claim/redeem rows count only with that stamp (plan D18). The browser cannot be trusted to self-certify a billing-adjacent event, so the stamp and user_id are set server-side from the verified token; the client only forwards sourcePlacement / sourceSurface / merchantId from the ad view that led to the claim. |
| E7 | per_user_limit = max REDEMPTIONS per user per offer | The offer field is documented as "max redemptions per user." A user may re-claim (idempotent) but may not redeem beyond the limit. Enforced transactionally at redeem. |
| E8 | Redeem is honor-system on the merchant side (no merchant confirm action) | Redemption is not the billing unit (flat fee), so scanner/merchant-verification infrastructure is unjustified for v1. The lit lantern + live confirmation is the integrity anchor. A merchant-confirm step is a future option if a per-redemption SKU ever ships. |
5. Data model โ
New top-level collection offerClaims. One doc per (user, offer) claim; redemption is a state transition on the same doc (not a second collection).
offerClaims/{claimId} // claimId = deterministic `${uid}_${offerId}` (idempotent claim, natural per-user key)
userId: string // opaque Firebase uid
offerId: string
merchantId: string // minted merchants/{id}, denormalized for server queries
venueId: string // the lantern-gated venue
status: 'claimed' | 'redeemed'
sourcePlacement: string // attribution snapshot at claim time (hero|inline|feed|unknown)
sourceSurface: string // e.g. 'places-nearby', 'venue-detail'
claimedAt: Timestamp
redeemedAt: Timestamp | null
expiresAt: Timestamp // = offer.expiresAt (or claimedAt + 90d cap), drives TTL purge- Deterministic id
${uid}_${offerId}makes claim idempotent (re-tapping "Get this offer" is a no-op upsert) and makes the per-user-limit check a single get, not a query. (per_user_limit > 1is out of v1's practical scope since default is 1; the doc carries aredemptionCountonly if we later raise it - for v1,statussuffices.) - firestore.rules:
match /offerClaims/{id} { allow read, write: if false; }- server-only, mirroringoffers. Merchants and users never touch it directly. - TTL: a Firestore TTL policy on
expiresAt(documented for manual/IaC creation, like the analytics purge). Aligns durable retention with the 90-day raw-event posture.
6. API (merchants-api) โ
Mounted under the existing merchant-scoped, auth+appcheck-guarded router (/merchants/:merchantId). Merchant scope in the path is odd for a user action, but keeps one auth surface; the claim routes use the caller's own uid, and :merchantId must equal offer.merchantId (validated), not requireMerchantAccess (which is for the owning merchant). Decision refinement: claim/redeem are USER actions, so they mount on a separate path that requires only a verified token, not merchant ownership. See plan ยงPhase 1 for the exact mount.
POST /offers/:offerId/claim- body{ sourcePlacement, sourceSurface }. Auth: any verified user (+ App Check). Steps: load offer (404/active check) -> verify active lantern atoffer.venueIdforreq.user.uid(403NO_LANTERNotherwise) -> upsertofferClaims/{uid_offerId}(statusclaimed) -> emitoffer_claimed-> return{ status, offer }.POST /offers/:offerId/redeem- body{ sourcePlacement, sourceSurface }. Auth: any verified user. Steps: load offer -> verify active lantern -> transactionally load the claim, reject if alreadyredeemedand limit reached (409 ALREADY_REDEEMED), else setredeemed+redeemedAt-> emitoffer_redeemed-> return the live-confirmation payload{ status, offer, venue, redeemedAt }.- Both emit via
@lantern/forgeforge.track({ eventName, userId: req.user.uid, entityId: offerId, entityType: 'offer', serviceId: 'merchants-api', metadata: { attributionSource: 'server_authoritative', offerId, sourcePlacement, sourceSurface, merchantId } }). - Validation: zod bodies; the service maps zod errors to 400 (merchants-api's global handler currently 500s ZodErrors - the new routes catch/normalize).
- Rate limiting: claim/redeem are billing-adjacent writes; apply the shared sliding-window limiter (the existing
offers_browsepattern) with a dedicated bucket.
7. Deferred / open โ
- Saves / wallet (E2): a "My Offers" surface enabling claim-now-redeem-later. Adds a claimed-offers screen and drops the redeem-time lantern re-check to claim-time only. Deliberately out of v1.
- HMAC-hashed
userIdinofferClaims(E5): hardening if the collection itself is judged a leak surface. One-line change. per_user_limit > 1: v1 assumes the default 1; aredemptionCountcounter generalizes it.- Merchant-confirm redemption (E8): only if a per-redemption SKU ships (econ Part 6 Q4).
- k-anon floor stays 3 (plan D3); merchant-visible claim/redeem counts inherit the existing gate with no new work.
8. Verification โ
Per test-plan and the verify skill: unit tests for the presence check, per-user-limit transaction, and Forge emit shape; route specs (claim happy path, no-lantern 403, redeem idempotency/limit 409, unknown offer 404); a live emulator run (create a merchant + offer + a signed-in user with a lit lantern) asserting a server_authoritative row lands in analytics.events with user_id; and confirming the rollup MERGE then counts it (the ad-network flip-on runbook, plan ยง4b).