Vibes DIY
Vibes DIY / Docs / Reference
creator docs · reference · generated

Stripe subscriptions — a paid membership that follows Stripe's state

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/stripe-subscriptions.md on every deploy, so it can't drift from what the platform actually does. Read it from your terminal any time with npx vibes-diy skills.

When someone asks for recurring money — a paid newsletter, a members-only club, a monthly coaching community, a subscription tier — build it on Stripe Checkout in subscription mode from backend.js, with the secret key the owner supplies, and keep the member's access a mirror of what Stripe says about their subscription. This is a backend.js feature (see the backend docs for the general handler contract) and a sibling of the stripe skill, which takes a one-time payment; this page is the recipe for money that keeps moving, where the question is never "did they pay" but "are they entitled right now".

This skill comes in only when the person asks for a subscription. A feed with a "members" label, a club page, a course — each of those is free until the person says people pay monthly, yearly, or "subscribe". What you may do unasked is offer it: on an app with members-only content, the closing row's capability option is ▸ [pay] Charge a monthly membership with Stripe, gold when nothing else fits better, and a tap on it is the ask. Words that are the ask: "subscription", "subscribe", "monthly", "per month", "yearly", "membership fee", "paid tier", "paywall", "recurring". A one-time charge — dues once a year, a ticket, a deposit — is the stripe skill, not this one.

One merchant, many members. Every member pays the owner's Stripe account, whose key is on the vibe. A member is a signed-in person whose membership is a record between them and the owner, and the members-only content is a channel that record grants. An app where each visitor sells their own subscriptions is a platform (Stripe Connect) and a different thing — say so and build the one-merchant half.

1. Bring your own Stripe key, turn on the portal, and explain how

The owner sets up Stripe once:

  1. Open the Stripe dashboard and sign in (or create a Stripe account). Turn on Test mode until the app is working.
  2. Go to Developers, then API keys, and reveal the Secret key. Set it on the vibe as STRIPE_SECRET_KEY — the vibe's settings page, or vibes-diy secrets set STRIPE_SECRET_KEY <the key>.
  3. Go to Settings, then Billing, then Customer portal, and save it with Cancel at end of billing period on. The app sends members there to cancel or change a card, and the portal does not exist until it is saved once.
  4. To go live, repeat steps 2 and 3 with Test mode off.

The app carries those steps in its own UI, because the person reading them is the person who has to do them.

When the key is missing, the app shows the owner the setup steps for a Stripe secret key and the Customer Portal instead of an error.

The backend answers { ok: false, reason: "missing-key" } when ctx.secrets.STRIPE_SECRET_KEY is absent, and the client renders the numbered steps, naming the Stripe dashboard, STRIPE_SECRET_KEY and the Customer Portal in so many words. A visitor who is not the owner sees a calmer sentence — memberships are not open yet — never the steps. A portal that was never saved answers a Stripe error whose message says so; keep that sentence (§ 7) and the owner reads step 3 off it.

2. The key never reaches the browser, and neither does Stripe.js

Every Stripe request is made in backend.js with ctx.fetch and ctx.secrets.STRIPE_SECRET_KEY; App.jsx never calls api.stripe.com and never loads Stripe.js.

The client talks to the app's own /_api/... routes, and the backend is the only place that knows the key exists. Stripe Checkout and the Customer Portal are pages Stripe hosts: the backend mints a URL, the page opens it, Stripe does the card, the retries, the invoices and the cancellation screen. Nothing about a card ever touches the app.

Checkout opens in a new tab with window.open, because the app runs inside a frame that cannot navigate the page around it.

The same goes for the portal. Open the tab inside the click handler, before any await, and check it for null before asking the backend for anything; hold the button busy until the tab is on its way. Say in the button's copy that Stripe opens in a new tab.

3. One membership, then one Checkout Session

The membership exists before the money moves, and there is one record per member, membership-<handle>, rewritten in place by every attempt, renewal and lapse. /subscribe runs as the signed-in member (the page calls it with apiFetch), claims that record in pending with the plan — against the revision it just read, so a concurrent press conflicts and is handed the attempt that won — and asks Stripe for a session for exactly that record:

  • mode=subscription, with an inline recurring price: line_items[0][price_data][recurring][interval]=month (or year), unit_amount in the smallest unit, a product name. The owner never creates products in the dashboard first.
  • client_reference_id carries the record's _id and metadata[attempt] its attempt number.
  • success_url and cancel_url are the app's public address with a fragment, #subscribed and #cancelled; the record is the caller's own, so no id rides the URL.
  • Idempotency-Key is <record id>:<attempt>, so two first presses that both got past the claim (a record that does not exist yet has no revision to claim) mint one session at Stripe; and a second press on an open attempt reuses its session for the day rather than minting another.

Amounts are integers in the currency's smallest unit: $5 a month is unit_amount 500, never 5.0.

The price lives in the app's own plan list on the server; the browser sends a plan name, never an amount. One subscription per person: /subscribe refuses while the member's subscription is live at Stripe — active, trialing, past_due, unpaid or paused, entitled or not — because a lapsed member whose card Stripe is still retrying must not be sold a second subscription that bills beside the first once it recovers. Those members are sent to the portal (§ 7) to fix the card or cancel.

The routes read the club database, which access.js binds, so backend.js declares config.fetch.unfilteredReads for it — and owes the obligation that comes with it: every route reads exactly one record, membership-<caller>, and no route lists memberships for anyone. The owner's roster is the page's own live query, scoped by the access function's memberships channel to the owner role.

4. Entitlement is Stripe's status plus one number, the grace period

A membership is entitled while Stripe's status is active or trialing, and while it is past_due until current_period_start plus GRACE_DAYS; canceled, unpaid, paused and incomplete lock it at once.

GRACE_DAYS is the recipe's one knob. Stripe retries a failed renewal for a few days and most retries succeed, so a short grace (five days is the default) keeps a member whose card bounced once from being locked out while Stripe is still collecting. Set it to zero and a failed renewal locks the moment it fails. Nothing else about entitlement is configurable, on purpose:

  • active and trialing are entitled. A member who cancelled at period end stays active with cancel_at_period_end: true until the date, so they keep what they paid for; the page shows "ends on …".
  • past_due is entitled until current_period_start + GRACE_DAYS, then locked. The anchor is the period start, not the end: when a renewal fails Stripe has already opened the new period, so current_period_end is a month or a year away and would hand a lapsed member the whole interval. current_period_start is the moment the renewal was due, which is the moment the grace clock should start.
  • canceled, unpaid, paused, incomplete and incomplete_expired are locked at once. With the portal set to cancel at period end (§ 1 step 3), a cancellation never cuts into paid time, because Stripe keeps the status active until the period closes.

The backend computes entitled and writes it on the membership document; nothing else does, and a value computed only for a response is not entitlement. Read current_period_start and current_period_end as sub.current_period_start ?? sub.items?.data?.[0]?.current_period_start (and the same for the end) — newer Stripe API versions carry them on the subscription item, older ones on the subscription — and they are Unix seconds, so multiply by 1000 once.

5. Members-only content is a grant the backend writes

Members-only content is a grant the backend writes from Stripe's subscription, never a flag a browser sets.

Memberships and the posts they unlock live in one database. Grants are reduced per database — a channel granted by a record in one database opens nothing in another — so the club database holds both the membership records and the post records, and one access function handles both types. A members-only feed built with its posts in a second database would grant a paying member a channel that no post is on.

The access state is the union of every current document's grants, so the membership document is where entitlement becomes structural: when doc.entitled is true the access function grants the member the members channel, and when the backend rewrites the document with entitled: false the grant is gone on the next sync. Members-only posts route to members, nothing reads them without the grant, and a browser can neither set entitled nor move status, because every update of an existing membership requires ctx.requireBackend(). The page gates its "members only" view on the live membership record and on can.* for anything it writes; the server enforces the same rule whatever the page shows.

6. Two things keep the mirror true: the return, and the sweep

Both are the backend reading Stripe with the key.

  • The return. When the app opens with #subscribed=<id> in its address, the page calls /confirm; the route reads the Checkout Session back, takes its subscription and customer ids, reads the subscription, and writes status, period end, cancel_at_period_end and entitled onto the membership. Read the hash on mount and on hashchange: the wrapper hands the app its fragment just after first render.

A scheduled sweep refreshes every live subscription from Stripe, so a failed renewal or a cancellation reaches the app without a webhook.

  • The sweep. Every half hour scheduled reads each membership whose subscription is not in a terminal state, re-reads it from Stripe, recomputes entitled, and writes the document when anything changed — including when nothing at Stripe changed but the grace period ran out. A pending membership whose session was never completed is settled the same way as the one-time recipe's orders: expired after a day. The work is bounded by the number of members.

Why not a webhook: a webhook write arrives with no identity and would need allowAnonymous on the one record that grants access. Reading the subscription back with the key the app already holds is the same proof from the same source, on a route that can only write what Stripe reports.

7. Cancel, change a card, see invoices: the Customer Portal

Cancelling and changing a card happen in Stripe's Customer Portal, opened from a backend-minted billing_portal session, never in a form the app writes itself.

/portal runs as the signed-in member, reads their record, and POSTs /v1/billing_portal/sessions with customer and a return_url of the app's address; the page opens the returned url in a new tab. The portal handles cancel-at-period-end, card changes, invoices and receipts, and the next sweep or the member's return (#portal in the fragment calls /refresh) brings the new state home. The app never writes status: "canceled" itself, in the page or in a route: a Cancel button in the app is a link to the portal. The portal button shows whenever Stripe knows the member (the record carries a customerId), entitled or not — a lapsed member needs it most, to update the card or to cancel. A visitor who is not signed in gets a Sign in to join button wired to useViewer().requestLogin(), never a Join button that fails with a sign-in error.

8. Errors keep their reason

Stripe's refusals come back as JSON with error.message; return it as { ok: false, reason: "stripe", message } so the page can show the owner why. The portal's "No configuration provided" message is the one that means step 3 of setup was skipped. A refused ctx.fetch — the { vibesEgressDenied: true } shape — is a platform refusal; log it and answer { ok: false, reason: "unavailable" }. api.stripe.com is CORS-open to the app's origin, so nothing here needs the curated list.

Complete backend.js

backend.js

js// A paid membership that mirrors Stripe: hosted Checkout in subscription mode, entitlement
// recomputed from the subscription's status, members-only access as a grant the backend writes.
// ONE database holds the memberships and the posts they unlock: grants are reduced per database,
// so a channel granted in one database opens nothing in another.
export const config = {
  scheduled: { interval: "30m" },
  // The routes read the member's own record; access.js binds the database, so the fetch lane needs
  // this declaration, and every route owes the check it waives: it reads membership-<caller> only.
  fetch: { unfilteredReads: { dbs: ["club"], why: "the /subscribe, /confirm, /refresh and /portal routes read the signed-in member's own membership record, membership-<their handle>, and nothing else" } },
};

const STRIPE = "https://api.stripe.com/v1";
const DB = "club";
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
// The one knob: how long a past_due member stays in while Stripe retries the card. Zero locks
// the moment a renewal fails.
const GRACE_DAYS = 5;
const PLANS = {
  monthly: { title: "Club membership (monthly)", amount: 500, currency: "usd", interval: "month" },
  yearly: { title: "Club membership (yearly)", amount: 5000, currency: "usd", interval: "year" },
};
// Nothing at Stripe will change these again; everything else is live and re-read.
const TERMINAL = new Set(["canceled", "incomplete_expired"]);

function json(body, status = 200) {
  return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}

function appUrl(ctx, hash) {
  return `https://vibes.diy/vibe/${ctx.appInfo.ownerHandle}/${ctx.appInfo.appSlug}${hash}`;
}

// One record per member, named for them: every attempt, renewal and lapse rewrites it in place.
function membershipId(handle) {
  return `membership-${handle}`;
}

async function stripe(ctx, method, path, form, headers = {}) {
  const key = ctx.secrets.STRIPE_SECRET_KEY;
  if (!key) return { ok: false, reason: "missing-key" };
  const res = await ctx.fetch(`${STRIPE}${path}`, {
    method,
    headers: { authorization: `Bearer ${key}`, ...(form ? { "content-type": "application/x-www-form-urlencoded" } : {}), ...headers },
    body: form ? form.toString() : undefined,
  });
  const body = await res.json().catch(() => null);
  if (!res.ok) {
    if (body?.vibesEgressDenied) {
      ctx.log("warn", "stripe egress denied", { gate: body.gate });
      return { ok: false, reason: "unavailable" };
    }
    return { ok: false, reason: "stripe", message: body?.error?.message ?? `HTTP ${res.status}` };
  }
  return { ok: true, body };
}

// Stripe's answer, reduced to the facts the app keeps. The period bounds are Unix seconds, and
// newer API versions carry them on the subscription item rather than the subscription.
function mirror(sub) {
  const item = sub.items?.data?.[0];
  const periodStart = (sub.current_period_start ?? item?.current_period_start ?? 0) * 1000;
  const periodEnd = (sub.current_period_end ?? item?.current_period_end ?? 0) * 1000;
  return { status: sub.status, currentPeriodStart: periodStart, currentPeriodEnd: periodEnd, cancelAtPeriodEnd: sub.cancel_at_period_end === true, customerId: sub.customer };
}

// The entitlement rule, stated once. active/trialing in; past_due in until the grace period after
// the failed renewal — the START of the current period, which Stripe opens when it invoices the
// renewal, not its end, which is an interval away; everything else out.
function isEntitled(m, now = Date.now()) {
  if (m.status === "active" || m.status === "trialing") return true;
  if (m.status === "past_due") return now < m.currentPeriodStart + GRACE_DAYS * 24 * 60 * 60 * 1000;
  return false;
}

// Re-read the member's subscription and write the record back when anything the app keeps
// changed — including entitlement lapsing with nothing new at Stripe.
async function refresh(ctx, m) {
  if (!m.subscriptionId) return m;
  const sub = await stripe(ctx, "GET", `/subscriptions/${m.subscriptionId}`);
  if (!sub.ok) return m; // a refusal changes nothing; the next read tries again
  const next = { ...m, ...mirror(sub.body), syncedAt: Date.now() };
  next.entitled = isEntitled(next);
  const changed = ["status", "currentPeriodStart", "currentPeriodEnd", "cancelAtPeriodEnd", "entitled"].some((k) => next[k] !== m[k]);
  if (!changed) return m;
  await ctx.db.put(next, { db: DB, id: m._id, ifRev: m._rev });
  return next;
}

// A record still waiting on its Checkout Session: bind the subscription Stripe created, or expire
// the attempt after a day.
async function settlePending(ctx, m) {
  if (m.status !== "pending") return m;
  if (!m.sessionId) {
    if (Date.now() - m.sessionCreatedAt > SESSION_TTL_MS) {
      const expired = { ...m, status: "expired", entitled: false };
      await ctx.db.put(expired, { db: DB, id: m._id, ifRev: m._rev });
      return expired;
    }
    return m;
  }
  const session = await stripe(ctx, "GET", `/checkout/sessions/${m.sessionId}`);
  if (!session.ok) return m;
  const s = session.body;
  if (s.id !== m.sessionId || s.client_reference_id !== m._id) {
    ctx.log("warn", "stripe session does not match its membership", { membershipId: m._id });
    return m;
  }
  if (s.status === "expired") {
    const expired = { ...m, status: "expired", entitled: false };
    await ctx.db.put(expired, { db: DB, id: m._id, ifRev: m._rev });
    return expired;
  }
  if (!s.subscription) return m; // not completed yet
  const bound = { ...m, subscriptionId: s.subscription, customerId: s.customer };
  await ctx.db.put(bound, { db: DB, id: m._id, ifRev: m._rev });
  return refresh(ctx, await ctx.db.get(m._id, { db: DB }));
}

export async function fetch(request, ctx) {
  const url = new URL(request.url);
  const me = ctx.userInfo?.userHandle;

  if (url.pathname === "/status" && request.method === "GET") {
    return json({ ok: true, configured: Boolean(ctx.secrets.STRIPE_SECRET_KEY), owner: me === ctx.appInfo.ownerHandle, graceDays: GRACE_DAYS });
  }

  if (!me) return json({ ok: false, reason: "sign-in" }, 401);
  const id = membershipId(me);
  const current = await ctx.db.get(id, { db: DB }); // null before the first attempt

  if (url.pathname === "/subscribe" && request.method === "POST") {
    if (!ctx.secrets.STRIPE_SECRET_KEY) return json({ ok: false, reason: "missing-key" }, 503);
    const { plan } = await request.json();
    const price = PLANS[plan];
    if (!price) return json({ ok: false, reason: "unknown-plan" }, 400);
    // A subscription that is live at Stripe — entitled or not — is managed in the portal, never
    // doubled: a past_due member whose grace ran out still has a subscription Stripe is retrying.
    if (current?.subscriptionId && !TERMINAL.has(current.status)) return json({ ok: false, reason: "already-member" }, 409);
    // A second press, a retry, another tab: the open attempt is reused, never repeated.
    if (current?.status === "pending" && current.sessionUrl && Date.now() - current.sessionCreatedAt < SESSION_TTL_MS) {
      return json({ ok: true, url: current.sessionUrl });
    }

    const attempt = (current?.attempt ?? 0) + 1;
    const membership = {
      _id: id,
      type: "membership",
      memberHandle: me,
      plan,
      amount: price.amount,
      currency: price.currency,
      status: "pending",
      entitled: false,
      attempt,
      customerId: current?.customerId, // a returning member keeps their Stripe customer
      sessionId: undefined,
      sessionUrl: undefined,
      subscriptionId: undefined,
      createdAt: current?.createdAt ?? Date.now(),
      sessionCreatedAt: Date.now(),
    };
    // The record is claimed against the revision we read: a concurrent press conflicts here and
    // is handed the attempt that won instead of starting its own.
    try {
      await ctx.db.put(membership, { db: DB, id, ifRev: current?._rev });
    } catch (err) {
      if (err.code !== "conflict") throw err;
      const won = await ctx.db.get(id, { db: DB });
      return won?.sessionUrl ? json({ ok: true, url: won.sessionUrl }) : json({ ok: false, reason: "retry" }, 409);
    }

    const form = new URLSearchParams({
      mode: "subscription",
      "line_items[0][quantity]": "1",
      "line_items[0][price_data][currency]": price.currency,
      "line_items[0][price_data][unit_amount]": String(price.amount),
      "line_items[0][price_data][recurring][interval]": price.interval,
      "line_items[0][price_data][product_data][name]": price.title,
      client_reference_id: id,
      "metadata[attempt]": String(attempt),
      success_url: appUrl(ctx, "#subscribed"),
      cancel_url: appUrl(ctx, "#cancelled"),
    });
    // The idempotency key names the attempt, so two first presses that both got past the claim
    // (a record that does not exist yet has no revision to claim) mint ONE session at Stripe.
    const session = await stripe(ctx, "POST", "/checkout/sessions", form, { "idempotency-key": `${id}:${attempt}` });
    if (!session.ok) return json(session, session.reason === "stripe" ? 502 : 503);
    const claimed = await ctx.db.get(id, { db: DB });
    await ctx.db.put({ ...claimed, sessionId: session.body.id, sessionUrl: session.body.url }, { db: DB, id, ifRev: claimed._rev });
    return json({ ok: true, url: session.body.url });
  }

  // Back from Checkout: bind the subscription and mirror it.
  if (url.pathname === "/confirm" && request.method === "POST") {
    if (!current) return json({ ok: false, reason: "not-found" }, 404);
    const settled = await settlePending(ctx, current);
    return json({ ok: true, status: settled.status, entitled: settled.entitled === true });
  }

  // Back from the portal, or a "check again": re-read my subscription now.
  if (url.pathname === "/refresh" && request.method === "POST") {
    if (!current?.subscriptionId) return json({ ok: true, entitled: false });
    const next = await refresh(ctx, current);
    return json({ ok: true, status: next.status, entitled: next.entitled === true });
  }

  // Cancel, change a card, see invoices: Stripe's own portal, for this member's customer.
  if (url.pathname === "/portal" && request.method === "POST") {
    if (!current?.customerId) return json({ ok: false, reason: "no-membership" }, 404);
    const form = new URLSearchParams({ customer: current.customerId, return_url: appUrl(ctx, "#portal") });
    const portal = await stripe(ctx, "POST", "/billing_portal/sessions", form);
    if (!portal.ok) return json(portal, portal.reason === "stripe" ? 502 : 503);
    return json({ ok: true, url: portal.body.url });
  }

  return json({ ok: false, reason: "not-found" }, 404);
}

// The sweep: every live subscription re-read, entitlement recomputed, pending attempts settled.
export async function scheduled(event, ctx) {
  if (!ctx.secrets.STRIPE_SECRET_KEY) return;
  let after;
  let changed = 0;
  do {
    const page = await ctx.db.query({ db: DB, field: "type", key: "membership", limit: 100, after });
    for (const m of page) {
      if (TERMINAL.has(m.status) || m.status === "expired") continue;
      const next = m.status === "pending" ? await settlePending(ctx, m) : await refresh(ctx, m);
      if (next !== m) changed += 1;
    }
    after = page.next;
  } while (after);
  ctx.log("info", "stripe subscription sweep", { changed, at: event.scheduledTime });
}

access.js — the grant follows the record

access.js

js// One function for the one database: the membership record that carries the grant, and the posts
// the grant unlocks. Grants are reduced per database, so these two types have to share one.
export function club(doc, oldDoc, user, ctx) {
  if (!user?.userHandle) throw { forbidden: "sign in" };
  if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };

  // One membership record per member, named for them, written only by backend.js.
  if (doc.type === "membership") {
    if (doc._id !== `membership-${doc.memberHandle}`) throw { forbidden: "a membership is named for its member" };
    if (oldDoc) {
      // Only backend.js changes an existing membership — claiming an attempt, binding the session,
      // mirroring Stripe, recomputing entitlement — so neither status nor entitled is a member's to edit.
      ctx.requireBackend();
      if (doc.memberHandle !== oldDoc.memberHandle) throw { forbidden: "a membership keeps its member" };
    } else {
      if (doc.memberHandle !== user.userHandle) throw { forbidden: "a membership belongs to its member" };
      if (doc.status !== "pending" || doc.entitled) throw { forbidden: "a membership starts pending" };
      ctx.requireBackend("fetch"); // the /subscribe route writes it, so the amount is the server's
    }
    const mine = `user:${doc.memberHandle}`;
    // The grant IS the entitlement: while the backend says entitled, the member holds `members`;
    // the rewrite that says otherwise takes it away on the next sync.
    const granted = doc.entitled === true ? [mine, "members"] : [mine];
    return { channels: [mine, "memberships"], grant: { users: { [doc.memberHandle]: granted }, roles: { owner: ["memberships", "members"] } } };
  }

  // A post: the owner writes it; members read it on the channel their membership record grants.
  if (doc.type === "post") {
    if (oldDoc && doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "a post keeps its author" };
    ctx.requireRole("owner");
    return { channels: [doc.membersOnly ? "members" : "public"], grant: { public: ["public"] } };
  }

  throw { forbidden: "unknown document type" };
}

App.jsx sketch

App.jsx

jsximport React, { useEffect, useState } from "react";
import { useFireproof } from "use-fireproof";
import { apiFetch, useViewer } from "use-vibes";
import { Badge, Button, Heading, Stack, Text } from "@vibes.diy/pouf";

const SETUP_STEPS = [
  "Open the Stripe dashboard, turn on Test mode, and reveal the Secret key under Developers → API keys.",
  "Set it on this vibe as STRIPE_SECRET_KEY: the Settings page, or `vibes-diy secrets set STRIPE_SECRET_KEY`.",
  "Under Settings → Billing → Customer portal, save the portal with Cancel at end of billing period on.",
];

export default function App() {
  const { viewer, requestLogin } = useViewer();
  const { useLiveQuery } = useFireproof("club"); // memberships AND posts: one database, one grant set
  const { docs: memberships } = useLiveQuery("type", { key: "membership" });
  const { docs: posts } = useLiveQuery("type", { key: "post" });
  const [status, setStatus] = useState(null); // { configured, owner, graceDays } from the backend
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    apiFetch("/status").then((r) => r.json()).then(setStatus).catch(() => setStatus({ configured: false, owner: false }));
  }, []);

  // Back from Stripe: the fragment arrives just after first render and on hashchange, so read both.
  useEffect(() => {
    const settle = () => {
      const subscribed = /#subscribed\b/.test(location.hash);
      const portal = /#portal\b/.test(location.hash);
      if (!subscribed && !portal) return;
      history.replaceState(null, "", location.pathname);
      apiFetch(subscribed ? "/confirm" : "/refresh", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
    };
    settle();
    window.addEventListener("hashchange", settle);
    return () => window.removeEventListener("hashchange", settle);
  }, []);

  // Open a Stripe page in a new tab: the tab first, inside the click, then the backend for its URL.
  const openStripe = async (path, body) => {
    setError(null);
    const tab = window.open("", "_blank");
    if (!tab) {
      setError("Your browser blocked the Stripe tab. Allow pop-ups for this page and try again.");
      return;
    }
    setBusy(true);
    try {
      const res = await apiFetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
      const data = await res.json();
      if (!data.ok) {
        tab.close();
        setError(data.reason === "stripe" ? data.message : data.reason === "already-member" ? "You already have a membership — manage it below." : "Memberships are not available right now.");
        return;
      }
      tab.location.href = data.url;
    } finally {
      setBusy(false);
    }
  };

  const configured = status?.configured === true;
  if (status && !configured && status.owner) {
    return (
      <Stack>
        <Heading>Set up memberships</Heading>
        <Text>Three steps in Stripe, once, and the Join buttons go live for everyone.</Text>
        <ol>{SETUP_STEPS.map((s) => <li key={s}>{s}</li>)}</ol>
      </Stack>
    );
  }

  const mine = viewer ? memberships.find((m) => m._id === `membership-${viewer.userHandle}`) : undefined;
  const entitled = mine?.entitled === true;
  const ends = mine?.currentPeriodEnd ? new Date(mine.currentPeriodEnd).toLocaleDateString() : null;

  return (
    <Stack>
      <Heading>The club</Heading>
      {!viewer ? (
        <Stack>
          <Text>Members read every post. Join for $5 a month, cancel any time.</Text>
          <Button onClick={() => requestLogin()}>Sign in to join</Button>
        </Stack>
      ) : entitled ? (
        <Badge tone="up">{mine.cancelAtPeriodEnd ? `Member until ${ends}` : mine.status === "past_due" ? "Payment due — please update your card" : "Member"}</Badge>
      ) : (
        <Stack>
          <Text>Members read every post. Join for $5 a month, cancel any time.</Text>
          {mine?.subscriptionId && mine.status !== "canceled" ? (
            <Text>Your last payment did not go through. Update your card below to keep reading.</Text>
          ) : (
            <Button onClick={() => openStripe("/subscribe", { plan: "monthly" })} disabled={!configured || busy} loading={busy}>
              Join — Stripe opens in a new tab
            </Button>
          )}
        </Stack>
      )}
      {/* The portal is reachable whenever Stripe knows this member, entitled or not: a lapsed
          member needs it most, to update the card or to cancel. */}
      {mine?.customerId && (
        <Button variant="quiet" onClick={() => openStripe("/portal", {})} disabled={busy} loading={busy}>
          Manage membership — Stripe opens in a new tab
        </Button>
      )}
      {error && <Text>{error}</Text>}
      <ul>
        {posts.map((p) => (
          <li key={p._id}>{p.membersOnly && <Badge tone="info">Members</Badge>} {p.title}</li>
        ))}
      </ul>
      {status?.owner && (
        <ul>
          {memberships.map((m) => (
            <li key={m._id}>@{m.memberHandle} · {m.plan} · {m.status}{m.entitled ? " · entitled" : ""}{m.currentPeriodEnd ? ` · until ${new Date(m.currentPeriodEnd).toLocaleDateString()}` : ""}</li>
          ))}
        </ul>
      )}
    </Stack>
  );
}

The posts list shows only what the viewer can read: a member sees the members-only posts because the grant on their membership record admits them to the members channel, and a lapsed member sees the public ones and the Join button. The owner's roster at the bottom is the app's mirror of Stripe; the two agree because the app only ever copies Stripe's answer.