# Hearts Pass Entitlement — Backend Spec

Slug: `tier-gated-unlimited-hearts` · Track A, Step 1 · 2026-06-06

> **STATUS (2026-06-11): IMPLEMENTED.** The backend below now ships:
> `services/hearts/hearts-pass.service.ts`, `controllers/hearts-pass.controller.ts`,
> routes `GET /me/entitlements`, `POST /me/hearts-pass/checkout`,
> `POST /api/v1/webhooks/stripe`, and the `heartsPass*` config flags — all
> inert (503) until `HEARTS_PASS_ENABLED=true`. Remaining go-live steps: §"6) Go-live order".
> Historical note below kept for context.
>
> ~~SPEC ONLY — no executable TypeScript ships in this iteration.~~ The frontend
> (`js/entitlements.js`, `js/hearts.js`) is built and verified now; the backend
> below is a contract a backend engineer can implement without re-reading the
> SDLC artifacts. Full design: `.claude/sdlc/tier-gated-unlimited-hearts/design.md`.

> Mirrors the `/token/spend` spec style in `docs/TOKEN_ECONOMY_FLOWS.md`.
> **Client reflects, server decides.** The client may cache the server's answer
> for up to 5 minutes (configurable); the server response is always
> authoritative. A stale or forged client cache is treated as **inactive**.

---

## 1. `GET /api/v1/me/entitlements` — contract

**Auth:** `Authorization: Bearer <jwt>` (the same dashboard JWT used by every
`/api/v1/me/*` call). Unauthenticated → `401`. The client treats any non-200 as
`active:false`.

**Request:** no body, no query params. The user is resolved from the JWT subject
(`resolveAuthContext`); **no wallet address is sent or returned** (account-scoped).

**Response 200 (`application/json`):**
```json
{ "unlimitedHearts": true, "reason": "pass", "expiresAt": "2026-12-31T23:59:59Z" }
```
| Field | Type | Notes |
|---|---|---|
| `unlimitedHearts` | boolean | `true` iff the user has an active, unexpired pass. |
| `reason` | `"pass"` \| `null` | Only `"pass"` here — tier is resolved client-side; staff is client-side. |
| `expiresAt` | ISO-8601 \| `null` | End of the current paid window; `null` when inactive. |

**No PII** in the response: no email, no Stripe IDs, no wallet. The client caches
only `{ active, expiresAt, source }` (three fields) keyed per-user.

**Error cases:** `401` (no/invalid JWT), `503` when `heartsPassEnabled=false`
(endpoint inert, mirrors `vendingEnabled`-gated routes). Client maps all of these
to `active:false`.

**Resolution logic (server):** select the user's most-recent
`hearts_pass_entitlements` row; `unlimitedHearts = (expires_at > now())`;
`expiresAt = expires_at`. No write on GET.

---

## 2. Stripe Checkout → webhook → entitlement (mirrors `/token/spend`)

```
User taps "Buy Unlimited-Hearts Pass"
   │
   ▼
1. POST /api/v1/me/hearts-pass/checkout  (auth)         → creates a Stripe Checkout
   Session (mode=payment, one line item, price = HEARTS_PASS_PRICE_CENTS),
   client_reference_id = users.id, metadata.weeks = HEARTS_PASS_WEEKS_PER_TOPUP.
   Returns { url } → browser redirects to Stripe-hosted checkout.
   │
   ▼
2. User pays on Stripe (licensed processor; we never touch card data).
   │
   ▼
3. Stripe → POST /api/v1/webhooks/stripe   (Stripe-Signature header)
   - verify signature with STRIPE_WEBHOOK_SECRET  (reject → 400)   [webhook security]
   - allowlist event type == 'checkout.session.completed'  (else 200 ignore)
   - upsert hearts_pass_entitlements keyed on stripe_checkout_session_id (UNIQUE)
     ON CONFLICT DO NOTHING/UPDATE → no double-credit on webhook retry  [idempotency]
   - expires_at = GREATEST(now(), existing.expires_at) + (weeks * 7 days)
     (multiple top-ups STACK by extending the window)
   │
   ▼
4. Next GET /api/v1/me/entitlements returns { unlimitedHearts:true, expiresAt }.
   Client caches it (≤5 min) and the pill shows ∞ PASS.
```

---

## 3. Prepaid top-up economics

One Stripe charge funds N cheap weeks — chosen specifically to clear the
$0.30 + 2.9% per-charge floor (no recurring sub).

| Variable | Env | Default | Meaning |
|---|---|---|---|
| Price | `HEARTS_PASS_PRICE_CENTS` | `299` | One-time charge ($2.99). |
| Weeks | `HEARTS_PASS_WEEKS_PER_TOPUP` | `52` | Weeks credited per top-up. |

```
Gross per top-up          = $2.99
Stripe fee (2.9% + $0.30) = $0.30 + $0.0867 = ~$0.387
Net per top-up            = ~$2.60
Effective rate            = $2.99 / 52 wks  ≈ $0.057/week  ("from ~$0.05/week")
```
`expires_at` extension arithmetic: `expires_at = GREATEST(now(), expires_at) +
HEARTS_PASS_WEEKS_PER_TOPUP * interval '7 days'`. The owner retunes price/bundle
via env only — no code change. (A literal $0.30/week recurring charge would net
≈ $0.30 − $0.31 = **negative**; the prepaid bundle is mandatory.)

---

## 4. Env flag `heartsPassEnabled` (mirror `vendingEnabled`)

Add to `AppConfig` and `loadConfig()` in `backend/src/app/config.ts` (described
here; **not** committed in this iteration):
```ts
// in AppConfig:
heartsPassEnabled: boolean;
heartsPassPriceCents: number;
heartsPassWeeksPerTopup: number;
// in loadConfig(), mirroring vendingEnabled:
const heartsPassEnabledRaw = process.env.HEARTS_PASS_ENABLED;
const heartsPassEnabled =
  heartsPassEnabledRaw === undefined ? false               // default OFF (stricter than vending)
  : heartsPassEnabledRaw.toLowerCase() === "true";
const heartsPassPriceCents = Number(process.env.HEARTS_PASS_PRICE_CENTS ?? 299);
const heartsPassWeeksPerTopup = Number(process.env.HEARTS_PASS_WEEKS_PER_TOPUP ?? 52);
// when heartsPassEnabled === false: GET /me/entitlements → 503; checkout → 503.
```

The frontend has a matching switch: `js/app-config.js → BITHEREUM_CONFIG.entitlements.passEnabled`
(default `false` in-module). Both flags must flip together when the endpoint ships.

---

## 5. Client reflects, server decides

- The client is permitted to cache the server's answer for **up to 5 minutes**
  (configurable via `BVEntitlements.configure({ passCacheTtlMs })`, clamped to
  60–300 s). The cache is bounded by `expiresAt` and discarded once expired.
- The server response is **always authoritative**. A stale or forged client
  cache is treated as **inactive**.
- The pass path is structurally inert until `HEARTS_PASS_ENABLED=true` AND
  `js/app-config.js → entitlements.passEnabled=true`. While inert the client never
  reads the localStorage cache and never fetches the endpoint, so a value written
  directly to `bv_hearts_pass_cache` (or any plausible key) yields `active:false`.

---

## 6. Webhook security & idempotency

- **Signature:** verify `Stripe-Signature` against `STRIPE_WEBHOOK_SECRET`
  (already in `config.ts`) **before** parsing the event. Unverified → **HTTP 400**.
- **Event allowlist:** process **only** `checkout.session.completed`. Any other
  event type → `200` and ignored (never grants a pass).
- **Idempotent upsert:** `hearts_pass_entitlements.stripe_checkout_session_id` is
  `UNIQUE`; the webhook upsert is `ON CONFLICT (stripe_checkout_session_id) DO
  NOTHING` (or `DO UPDATE` to set `stripe_payment_intent_id`). A duplicate
  delivery of the same `checkout.session.completed` therefore **cannot
  double-credit** `expires_at`.

---

## 7. Security model — accepted residual & "client reflects, server decides"

- **Tier source (client-side, accepted residual):** the on-chain tier read trusts
  the locally-stored `bv_wallet_addr`. A user could point it at a whale's address
  to fake a tier. **Low impact** (free hearts only, no money/tokens). Closed later
  when Privy/Web3Auth binds the wallet address to the authenticated account.
- **Pass source (server-authoritative):** `unlimitedHearts:true` originates ONLY
  from a verified Stripe payment recorded in `hearts_pass_entitlements`. The
  client never computes a paid unlock; it reflects/caches the server answer (≤5
  min) and treats any stale/forged cache as inactive. The pass path is inert until
  `HEARTS_PASS_ENABLED=true` AND `js/app-config.js → entitlements.passEnabled=true`.

---

## 8. Migration `0052_create_hearts_pass_entitlements.sql`

> **Migration-number collision note:** `docs/TOKEN_ECONOMY_FLOWS.md` proposes a
> migration for the *gated, unbuilt* token-spend flow. The migration runner
> applies files in lexical filename order and tracks applied filenames in
> `schema_migrations`; two different `0052_*.sql` files would both attempt to
> apply. Current max on disk is `0051`, so **this hearts-pass migration takes
> `0052`**; the token-spend migration has been renumbered to `0053` in
> `docs/TOKEN_ECONOMY_FLOWS.md`. This is called out so the next author doesn't
> reuse `0052`.

The committed SQL lives at
`backend/src/db/migrations/0052_create_hearts_pass_entitlements.sql`. It is
idempotent DDL (`create table if not exists`, `create index if not exists`) with
self-contained down-migration commentary, mirroring `0050`'s conventions.

Columns: `id` (uuid pk), `user_id` (uuid FK → users on delete cascade),
`stripe_checkout_session_id` (text unique — idempotency key),
`stripe_payment_intent_id` (text unique nullable), `amount_paid_cents` (bigint),
`currency` (text default `usd`), `prepaid_weeks` (integer), `expires_at`
(timestamptz — current entitlement window end), `created_at`, `updated_at`. No PII
beyond the `user_id` FK.

---

## Implementation skeleton (drop-in — build in a TypeScript env)

> ⚠️ **Why this lives in the doc, not in `backend/src`:** it can't be `tsc`-verified
> in the agent sandbox (no `npm install`, network-blocked), and a type error in
> `backend/src` would block **all** backend deploys. Drop these files in, run
> `npm run check`, fix any type nits against the installed `stripe`/`pg` types,
> apply migration `0052`, set env, then flip the flags. Mirrors the existing vending
> Stripe integration (`services/vending/stripe-client.ts`, the webhook middleware,
> `controllers/vending.controller.ts`). Auth/JSON helpers: use the SAME ones your
> other controllers use (`resolveAuthContext`, `sendJson`, `readJsonBody`).

### 1) `app/config.ts` — add fields (mirror `vendingEnabled`)
```ts
// in AppConfig type:
heartsPassEnabled: boolean;
heartsPassPriceCents: number;
heartsPassWeeksPerTopup: number;
// reuse existing: stripeSecretKey, stripeWebhookSecret, stripePublishableKey

// in loadConfig():
const heartsPassEnabled = (process.env.HEARTS_PASS_ENABLED ?? "false").toLowerCase() === "true";
const heartsPassPriceCents = Number(process.env.HEARTS_PASS_PRICE_CENTS ?? 299);
const heartsPassWeeksPerTopup = Number(process.env.HEARTS_PASS_WEEKS_PER_TOPUP ?? 52);
// ...add the three to the returned config object.
```

### 2) `services/hearts/hearts-pass.service.ts`
```ts
import { dbClient } from "../../db/client.js";
import { loadConfig } from "../../app/config.js";
import { getStripe } from "../vending/stripe-client.js"; // reuse the existing wrapper

const WEEK_MS = 7 * 24 * 60 * 60 * 1000;

export async function getEntitlement(userId: string): Promise<{ unlimitedHearts: boolean; reason: string | null; expiresAt: string | null; }> {
  const r = await dbClient.query(
    `SELECT expires_at FROM hearts_pass_entitlements WHERE user_id = $1 AND expires_at > now() LIMIT 1`,
    [userId]
  );
  if (r.rows[0]) return { unlimitedHearts: true, reason: "pass", expiresAt: new Date(r.rows[0].expires_at).toISOString() };
  return { unlimitedHearts: false, reason: null, expiresAt: null };
}

// Create a Stripe Checkout session for a one-time prepaid top-up.
export async function createTopupCheckout(userId: string, origin: string): Promise<{ url: string }> {
  const cfg = loadConfig();
  if (!cfg.heartsPassEnabled) throw new Error("hearts pass disabled");
  const stripe: any = await getStripe(); // checkout.sessions is on the real SDK; add to the typed surface if you prefer
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    success_url: `${origin}/membership.html?pass=success`,
    cancel_url: `${origin}/membership.html?pass=cancel`,
    line_items: [{
      price_data: {
        currency: "usd",
        unit_amount: cfg.heartsPassPriceCents,
        product_data: { name: `Bithereum007 unlimited-hearts pass (${cfg.heartsPassWeeksPerTopup} weeks)` }
      },
      quantity: 1
    }],
    // CRITICAL: bind the purchase to the user so the webhook can credit them.
    client_reference_id: userId,
    metadata: { kind: "hearts_pass", userId, weeks: String(cfg.heartsPassWeeksPerTopup) }
  }, { idempotencyKey: `hp_${userId}_${Date.now()}` });
  return { url: session.url as string };
}

// Called from the Stripe webhook on checkout.session.completed (kind=hearts_pass).
// Idempotent: the unique stripe_session_id prevents double-credit on retries.
export async function applyTopupFromWebhook(session: { id: string; client_reference_id?: string | null; metadata?: Record<string,string> | null; }): Promise<void> {
  const userId = session.client_reference_id || session.metadata?.userId;
  const weeks = Number(session.metadata?.weeks ?? 0);
  if (!userId || !weeks) return;
  const addMs = weeks * WEEK_MS;
  await dbClient.query(
    `INSERT INTO hearts_pass_entitlements (user_id, stripe_session_id, expires_at)
     VALUES ($1, $2, now() + ($3::bigint || ' milliseconds')::interval)
     ON CONFLICT (stripe_session_id) DO NOTHING
     -- stacking: if the row is new but the user already has a later expiry, extend from the greater of (now, current expiry)
     `,
    [userId, session.id, addMs]
  );
  // For true "stack on existing", prefer an upsert that sets
  // expires_at = GREATEST(now(), existing.expires_at) + interval — see migration notes.
}
```

### 3) `controllers/hearts-pass.controller.ts`
```ts
import type { IncomingMessage, ServerResponse } from "node:http";
import { resolveAuthContext } from "../services/auth/auth.service.js"; // match your real path
import { sendJson } from "../utils/http.js";                          // match your real helper
import { getEntitlement, createTopupCheckout } from "../services/hearts/hearts-pass.service.js";

export async function getEntitlementsController(req: IncomingMessage, res: ServerResponse) {
  const auth = await resolveAuthContext(req);
  if (!auth.ok) return sendJson(res, auth.statusCode, { error: auth.error });
  const ent = await getEntitlement(auth.context.userId);
  sendJson(res, 200, ent); // { unlimitedHearts, reason, expiresAt } — matches BVEntitlements normalizeServer()
}

export async function createPassCheckoutController(req: IncomingMessage, res: ServerResponse) {
  const auth = await resolveAuthContext(req);
  if (!auth.ok) return sendJson(res, auth.statusCode, { error: auth.error });
  try {
    const origin = `https://${req.headers.host}`;
    const out = await createTopupCheckout(auth.context.userId, origin);
    sendJson(res, 200, out);
  } catch (e) {
    sendJson(res, 503, { error: "hearts pass unavailable" });
  }
}
```

### 4) `app/app.ts` — register routes (mirror the existing `if (method && pathname)` style)
```ts
if (req.method === "GET" && requestUrl.pathname === "/api/v1/me/entitlements") {
  getEntitlementsController(req, res); return;
}
if (req.method === "POST" && requestUrl.pathname === "/api/v1/me/hearts-pass/checkout") {
  createPassCheckoutController(req, res); return;
}
```

### 5) Webhook
Reuse the existing Stripe webhook route/middleware (`stripe-webhook.middleware.ts` →
`webhooks.constructEvent` with `stripeWebhookSecret`). In the event handler, add:
```ts
if (event.type === "checkout.session.completed") {
  const s = event.data.object as any;
  if (s?.metadata?.kind === "hearts_pass") await applyTopupFromWebhook(s);
}
```
Register the **same** Checkout webhook URL in the Stripe dashboard (test mode first).

### 6) Go-live order (test mode → live)
1. `npm run check` (fix type nits) → apply migration `0052`.
2. Set env: `HEARTS_PASS_ENABLED=true`, `STRIPE_SECRET_KEY=sk_test_…`, `STRIPE_WEBHOOK_SECRET=whsec_…`, (optional price/weeks overrides).
3. Add the Stripe **test** webhook endpoint; do a test purchase with card `4242 4242 4242 4242`.
4. Flip the client: `BITHEREUM_CONFIG.entitlements.passEnabled = true` (uncomment in `app-config.js`) so `BVEntitlements` starts calling `GET /me/entitlements`. Point `membership.html`'s buy button at `POST /me/hearts-pass/checkout` instead of the test provider (set `BITHEREUM_CONFIG.billing.provider = "stripe"`).
5. Verify end-to-end on staging, then swap test keys → live keys.
