Stripe subscriptions — a paid membership that follows Stripe's state
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:
- Open the Stripe dashboard and sign in (or create a Stripe account). Turn on Test mode until the app is working.
- 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, orvibes-diy secrets set STRIPE_SECRET_KEY <the key>. - 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.
- 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(oryear),unit_amountin the smallest unit, a product name. The owner never creates products in the dashboard first.client_reference_idcarries the record's_idandmetadata[attempt]its attempt number.success_urlandcancel_urlare the app's public address with a fragment,#subscribedand#cancelled; the record is the caller's own, so no id rides the URL.Idempotency-Keyis<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:
activeandtrialingare entitled. A member who cancelled at period end staysactivewithcancel_at_period_end: trueuntil the date, so they keep what they paid for; the page shows "ends on …".past_dueis entitled untilcurrent_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, socurrent_period_endis a month or a year away and would hand a lapsed member the whole interval.current_period_startis the moment the renewal was due, which is the moment the grace clock should start.canceled,unpaid,paused,incompleteandincomplete_expiredare 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 statusactiveuntil 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 itssubscriptionandcustomerids, reads the subscription, and writes status, period end,cancel_at_period_endandentitledonto the membership. Read the hash on mount and onhashchange: 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
scheduledreads each membership whose subscription is not in a terminal state, re-reads it from Stripe, recomputesentitled, 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.