Skip to content

Gift subscriptions

Generated from a canonical source

This page is a read-only projection of docs/handoff-corpus/gift-subscriptions.md. Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.

What gift subscriptions is for

A gift recipient's subscription must never acquire a charges row or a non-NULL next_charge_at while it is gift-delivery-driven. That is the one invariant this domain must not break — a recipient who never provided a payment method and never consented to being charged must never be billed or dunned for a gift they didn't pay for. onTokenClaim sets next_charge_at = NULL and payment_method_id = NULL explicitly at creation — not copied from the giver's record. The only sanctioned exit is the explicit AC4 convert path, itself compare-and-set gated so it can only fire once (extensions/gift.ts:162-176; extensions/gift-delivery.ts:128-179; proven by gift-delivery-sweep.scenario.ts's full-cron-tick zero-charge assertion).

The domain breaks into five features, each anchored to its build story (US-6.1) for traceability:

  • Buy a subscription as a gift — a logged-in gifter pays upfront for N cycles of a plan and sends a recipient a reveal email; no PDP toggle exists yet, so the entry is the portal-authenticated checkout route (US-6.1)
  • Claim your gift — the recipient opens the reveal link, clicks "Activate your gift," and gets an active subscription in their own BC customer account with no card required (US-6.1)
  • Free gifted deliveries, N cycles, no dunning — every gifted delivery ships at $0 with no backing charge, and an exhausted-but-unconverted gift produces zero payment-retry emails because the recipient never owed money (US-6.1 AC5)
  • "One delivery left — want to keep it going?" — the recipient is notified before their gift runs out and can add a card to continue on paid billing (US-6.1 AC3)
  • Opt-in conversion to paid billing — an explicit consent action (not merely adding a card) converts an exhausted gift to a normal paid subscription; silence always lapses the subscription, never auto-charges (US-6.1 AC4)

The decisions that carry the most weight

1. Delivery path, not charge path — structural, not incidental. The recipient subscription never touches charges by construction, not by convention. next_charge_at = NULL on claim, and every renewal/OOS/bundle scan filters next_charge_at IS NOT NULL, so the row is permanently invisible to the billing scheduler. Deliveries flow through a dedicated fulfillment-sweep branch anchored on current_period_end, counting fulfilled delivery_instances rows instead of a mutable counter (docs/architecture/process-flows.md §5). No ADR was filed for this mechanism — the AC3/AC4/AC5 build shipped via Hive [Spec] #1797 / synthesis

1798 / PRs #1804 + #1812, not a decision record. That absence is itself a

gap this page flags, not fills.

2. Shared subscription_extensions substrate, not a bespoke gift table. gift and gift_delivery are two of the polymorphic extension types riding the JSONB substrate + per-type service-layer convention this domain reuses rather than reinvents (ADR-0033).

3. Consent-marker discriminator for AC4 vs AC5, not payment-method presence. convert_consent_at in extension_data is the sole gate for the convert path. The generic stored-instrument PM-select endpoint can attach a card without AC4 intent, so keying off PM presence would silently convert recipients who just wanted to update a card — an FTC Negative Option Rule violation (routes/portal/gift-convert.ts header, synthesis #1798).

4. Atomic compare-and-set claim, not read-then-write. The single-use claim token is consumed via UPDATE ... WHERE claimed_at IS NULL before any subscription is created, closing a replay/race window a read-then-check pattern would leave open (Hive #1754, extensions/gift.ts:134-154).

How it actually works

Read the deciding record before the code, not after. The canonical-framing attestation on this domain (_input-b/gift-subscriptions.md) found that BRD.md's own §US-6.1 data-contract prose still describes this mechanism as reusing "the prepaid extension mechanism (cycles_remaining driven by the US-6.2 skip_advance + cycles_remaining-- machinery)." That was the original synthesis #1798 plan. A 2026-06-27 grounding pass disproved it before build — the shipped mechanism below is what actually runs. If you read the BRD prose first, you will derive the wrong mechanism.

Purchase. handleGiftPurchase (routes/storefront/checkout/gift.ts) charges the giver once, upfront, for plan.amount_cents * cycles through the normal adapter.charge() path (order-first, ADR-0025), then creates the gift subscription in status='paused' (a Phase-1 proxy for pending_claim; migration 0019) with gift_from_customer_id set. A gift extension row records the recipient email, cycles_total, and a 32-byte hex claim_token with a 30-day expiry. sendGiftRevealEmail fires best-effort — its failure does not roll back the charge.

Claim. handleGiftClaim (routes/portal/gifts/[token]/claim.ts) takes no auth beyond the token itself and calls giftService.onTokenClaim (extensions/gift.ts:115-227). That function:

  1. Looks up the extension row by scanning claim_token in the JSONB (json_extract, LIKE-compatible with Workers D1).
  2. Consumes the token atomically: UPDATE subscription_extensions SET claimed_at = ... WHERE claimed_at IS NULL. Zero rows changed means a race loser or a replay — both are rejected with Gift already claimed.
  3. Creates the recipient subscription with status='active', payment_method_id = NULL, next_charge_at = NULL — both explicitly not copied from the giver's row — and gift_origin_subscription_id pointing back to the giver's subscription for audit trail.
  4. Attaches a gift_delivery extension carrying cycles_total.
  5. Seeds up to cycles_total delivery-instance rows up front via fillForSubscription, since gifts have no charge-success event to drive the normal event-driven refill.

Delivery. extensions/gift-delivery.ts is read/validate-only on the extension-registry contract — it has no charge-path hooks, because gifts never reach the scheduler. Two counting functions are the actual state:

  • countFulfilledDeliveriesCOUNT(*) over delivery_instances WHERE status='fulfilled'. This is cycles-remaining, and it is a count, never a mutable counter — a shared JSON counter would race under concurrent sweeps and lose a decrement (an extra free shipment); counting rows is race-free.
  • maybeEndGiftSubscription — once fulfilled >= cycles_total, branches on whether convert_consent_at is set in the extension data. AC4 convert: CAS UPDATE subscriptions SET next_charge_at = ... WHERE status='active' AND next_charge_at IS NULL — the only place a gift-delivery sub's next_charge_at is ever set to non-NULL, and it can fire exactly once. AC5 lapse: CAS UPDATE subscriptions SET status='cancelled', cancel_reason='gift_exhausted' WHERE status='active'. Both paths delete any surplus pending delivery_instances so nothing can ship after the transition.

processGiftEndingSoon runs each scheduler tick (wired before findDueCharges) and, for active gift subs where fulfilled >= cycles_total - 1 with no convert_consent_at yet, emits gift.ending_soon once (an events-table anti-join, not a CAS column — a deliberate downgrade since a notification's TOCTOU window is annoying, not dangerous) and sends the "add a card" email.

Convert. handlePortalGiftConvertOptIn (routes/portal/gift-convert.ts) is the only path that can set convert_consent_at. It requires an authenticated portal session owning the subscription, an active gift recipient sub, an existing payment_method_id, and a { consent: true, disclosed_amount_cents } body — disclosed_amount_cents is mandatory for audit-trail completeness. Calling it twice is idempotent (already_consented: true, 200) rather than erroring.

flowchart TD
    A[Giver: POST /api/v1/storefront/checkout/gift<br/>plan_id, cycles, recipient_email, payment_method_id] --> B[handleGiftPurchase]
    B --> C[Giver charged ONCE, upfront, for N cycles<br/>via normal adapter.charge path — ADR-0025 test-mode/synthetic in CI]
    C --> D[createIncompleteOrderForGift +<br/>gift subscription created, status='paused',<br/>gift_from_customer_id set]
    D --> E[subscription_extensions row, extension_type='gift'<br/>claim_token + claim_expires_at + cycles]
    E --> F[Recipient emailed reveal link — out of scope here]
    F --> G[Recipient: POST /api/v1/portal/gifts/:token/claim — no auth, token is the credential]
    G --> H[handleGiftClaim → giftService.onTokenClaim]
    H --> I{Token valid, unclaimed,<br/>within claim window?}
    I -- no --> I1[404 / 409 conflict / 412 precondition_failed]
    I -- yes --> J[ATOMIC claim: UPDATE subscription_extensions<br/>SET claimed_at WHERE claimed_at IS NULL<br/>— compare-and-set, race-safe]
    J --> K[Create RECIPIENT subscription:<br/>status='active', next_charge_at=NULL,<br/>gift_origin_subscription_id set]
    K --> L[Attach gift_delivery extension:<br/>seed N delivery instances, one per prepaid cycle]
    L --> M[200: recipient subscription_id]
    M --> N[runFulfillmentSweep sweeps gift_delivery instances<br/>on schedule — ships each cycle as a $0/no-charge BC order]
    N --> O{cycles exhausted?}
    O -- no --> N
    O -- yes --> P[processGiftEndingSoon fires at N-1<br/>notifies recipient; gift-convert opt-in offered]
    P --> Q{recipient opts in?<br/>convert_consent_at set}
    Q -- yes --> Q1["convert to paid billing<br/>(separate flow — sets next_charge_at, exits delivery-only path)"]
    Q -- no / lapses --> R[subscription ends — NO charge, NO dunning, ever]

Diagram provenance. Transcluded verbatim from the canonical, code-sourced docs/architecture/process-flows.md §5 "Gift — DELIVERY path, NOT a charge path" (derives_from: routes/storefront/checkout/gift.ts, routes/portal/gifts/[token]/claim.ts, extensions/gift.ts, extensions/gift-delivery.ts, cron/fulfillment-sweep.ts, plus the three gift scenario files). It carries sign_off: pending — accurate to the code, not yet human-attested, so read it as the current mechanism, not a ratified contract. In the handoff pipeline this is a build-time include of that one source, never a hand-copied fork.

The data model backing this flow — the polymorphic extension row and the two gift-specific columns on subscriptions:

erDiagram
    subscription_extensions {
        TEXT subscription_id PK
        TEXT store_hash
        TEXT extension_type PK
        TEXT extension_data
        INTEGER extension_version
        TIMESTAMP created_at
        TIMESTAMP updated_at
    }
    subscriptions {
        TEXT gift_from_customer_id
        TEXT gift_origin_subscription_id
    }
    subscriptions ||--o{ subscription_extensions : "subscription_id"

Diagram provenance. Excerpt of the canonical, code-sourced docs/architecture/data-model-erd.md (2 of ~85 tables — the subscription_extensions table this domain's two extension types live in, plus the two gift-specific columns on subscriptions; every other column and every other table is omitted). Unlike process-flows.md, this source carries no sign_off field; its own staleness marker is as_of_commit: 80fc35f4, staleness_threshold_days: infinite. Same rule applies: this is one transcluded source, not a hand-drawn fork.

Where intent and reality diverge

The derived coverage matrix (_coverage-matrix.json) reports US-6.1 at terminal_gate: "G4", g4_status: "pass", dod_bucket: "tested". That is true, and — as with every domain in this corpus — it is not the whole truth. Four honest deltas, each typed:

1. Superseded-framing residue — the BRD's own data-contract prose is wrong. BRD.md §US-6.1 (surfaced in docs/audits/derived/brd-epics/epic-06-advanced-subscription-types.md) still describes the mechanism as reusing "the prepaid extension mechanism (cycles_remaining driven by the US-6.2 skip_advance + cycles_remaining-- machinery)." That was the original synthesis #1798 plan; a 2026-06-27 grounding pass disproved it before build — gifts never touch the charge path at all, and the shipped code counts fulfilled delivery_instances rows, never a mutable counter. The BRD prose was not corrected after the pivot. This reconciliation is tracked as Hive issue

1887; **this page's Input-B canonical-framing attestation is the authority

for the mechanism, not the BRD prose** — do not re-import the BRD's cycles_remaining description into new work.

2. Named-deferred — no storefront purchase-initiation UI exists. A real "gift this subscription" form is filed as a standalone gap (Hive #1753) and not built. The only storefront gift surfaces today are GiftClaimView.svelte (recipient claim) and GiftPanel.svelte (giver-side, read-only status — it cannot initiate a purchase). gift-preview/+page.svelte is an explicitly dev-only mock harness (if (!dev) error(404, ...)) for visually QA'ing GiftClaimView states — it does not exercise the real purchase route. BRD itself documents the honest current entry as the portal-authenticated checkout route, with a PDP toggle named as a separate future slice (synthesis #1798).

3. Contract-verified, not live-verified — the entire domain is G4-tier only. Purchase, claim, AC3 ending-soon, AC4 convert, AC5 lapse, and the full-cron-tick zero-charge guarantee are all proven against real D1 via applySchema and dispatched through worker.fetch (gtm-gift.scenario.ts) — but no live BC sandbox or Stripe network call is exercised. test_mode_enabled=1 bypasses the real charge call even in the one scenario that drives handleGiftPurchase through the real route. No G5 (live-sandbox) run exists for gift purchase or claim, unlike dunning's cit-to-mit-sandbox.spec.ts decline variant or the canonical-charge-rail domain's ADR-0082 live executions. p3-jordan-gifting.scenario.ts calls handlers directly and does not, on its own, prove HTTP reachability — that proof is gtm-gift.scenario.ts's alone, and it is the scenario that found and drove the fix for the reveal-link 404 bug below.

4. Built-but-untrodden — the reveal-link email never traverses its real delivery path in test. sendGiftRevealEmailEVENTS_QUEUE outbox → email-consumer Worker → provider is real code with a regression-guarded claim-URL shape (gift-reveal-email.test.ts, added after a real bug: the reveal link was missing a path segment and 404'd on every real link, found by gtm-gift.scenario.ts driving the real route before it shipped). In every test environment env.EVENTS_QUEUE is unbound, so the code takes the console.log-only branch — live delivery through the full queue → consumer → provider path for this template is not exercised by any test in this domain. This mirrors dunning's identical gap for a different email template.

Verified-but-incomplete, named for completeness: AC3's near-end notify (processGiftEndingSoon) and AC4's convert opt-in endpoint are both built and scenario-proven (gift-conversion.test.ts), and wired into runScheduledTick — but there is no storefront/portal UI surfacing the "add a card to keep them coming" prompt or a convert-consent affordance. The endpoint exists; the recipient-facing consent UI to reach it does not (no file under apps/storefront-svelte implements a gift-convert form as of this trace).

How to operate & extend

  • Change the number of cycles a gift covers: cycles in the purchase request body (handleGiftPurchase); the recipient's gift_delivery extension stores it as cycles_total and every downstream count derives from delivery_instances, never a stored remaining-count.
  • The invariant you must not break: next_charge_at and payment_method_id stay NULL on a gift-delivery recipient sub until the single CAS-gated AC4 convert write. Any code path that sets next_charge_at on a still-gift-delivery sub, or mints a charges row for a delivery, puts a recipient who never consented to a charge into the billing/dunning path — both a money-path bug and a negative-option-billing violation.
  • Recipient claiming fails unexpectedly? Start at giftService.onTokenClaim (extensions/gift.ts:115) — it returns Invalid claim token, Claim window expired, or Gift already claimed as distinct thrown errors that the route maps to 404 / 412 / 409 respectively.
  • Deliveries not shipping or not ending? runFulfillmentSweep (cron/fulfillment-sweep.ts) is what ships gift deliveries; reconcileGiftDeliveries (extensions/gift-delivery.ts) is the belt-and-suspenders reconciliation pass that recovers a failed maybeEndGiftSubscription call or tops up under-seeded gifts (the 60/call horizon clamp on fillForSubscription means a long gift converges over several ticks, not one).
  • Extension seams: the gift / gift_delivery extension-type pair on subscription_extensions is the pattern to follow for a new polymorphic subscription behavior — see ADR-0033 for the substrate convention. The AC4-vs-AC5 discriminator (convert_consent_at in extension_data, not payment-method presence) is the pattern to reuse for any future consent-gated state transition.

Confidence notes

  • The mechanism-correction has no ADR. The switch from the original "reuse the prepaid extension" plan to the shipped delivery-count mechanism shipped via Hive [Spec] #1797 / synthesis #1798 / PRs #1804 + #1812, not a decision record. Both this page's Input-B and docs/architecture/process-flows.md note the same gap independently. If you are the one to close it, the deciding record should also carry the fix for delta #1 above (correcting the BRD's cycles_remaining prose).
  • No live-sandbox (G5) proof exists for this domain. Unlike dunning and the canonical-charge-rail domain, there is no cit-to-mit-sandbox-style live run for gift purchase or claim in this repo as of this trace (a targeted grep for e2e/*gift* and any apps/storefront-svelte e2e gift specs returned zero results). Treat "gift purchase actually charges a giver's real card" as unproven beyond the G4 stub-mode scenario.
  • docs/audits/route-orphan-audit-2026-06-23.md documents a historical finding — gift routes were HTTP-unrouted until commit 7d7ee6d1 (2026-06-23) wired them. Both gift routes are present in the current worker.ts dispatch table (confirmed by direct read: lines 859-895 for claim + the gift-convert route, lines 1554-1567 for purchase) — the audit document should be read as history, not current state.