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, through ctx.stripe on the
Stripe account the owner connects with Connect Stripe, 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 Stripe account the owner connected. 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 marketplace, a bigger thing than this recipe — say so and build the one-merchant half.
1. The setup items: Connect Stripe, and the Customer portal
The owner sets up Stripe once. The Setup surface the two items land on — the
owner-only entry in the app's navigation, the verdict from /setup, the
re-read on focus that unlocks the Join buttons in place — is the use-vibe
docs' Some apps need setup before they work; this recipe contributes two
items to it. The Stripe items are Connect Stripe and the Customer portal: the backend reads ctx.stripe.status(), reads whether a portal configuration has been saved, and its steps link this vibe's Settings tab, where the owner presses Connect Stripe under Payments, and Stripe's Customer portal settings by URL.
The steps are written for someone who has never opened Stripe, so each one is
a link to the exact page and uses the words that page uses:
- Open this vibe's Settings tab on vibes.diy:
https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings(that is My Vibes → this vibe → Settings), and press Connect Stripe under Payments. Stripe opens its own setup pages: sign in with a Stripe login or create one there, and answer Stripe's questions about the business. The Stripe account belongs to the owner's Vibes DIY account and serves every app they make, so it is connected once. - Open Stripe's Customer portal settings in test mode, signed in to the Stripe account you just connected: https://dashboard.stripe.com/test/settings/billing/portal. Under Cancellations, turn on Cancel subscriptions and choose At end of billing period, then press Save. The app sends members there to cancel or change a card, and the portal exists once it is saved.
- Come back to the app. The Join buttons work in test mode the moment both
items are done; the card
4242 4242 4242 4242with any future date pays. - Go live, one app at a time. Once a test payment goes through, press Go live with Stripe under Payments on the Settings tab and finish Stripe's live setup (once, for every app). Then press Take live payments in this app on this app's Setup screen, and save the Customer portal once more in live mode at https://dashboard.stripe.com/settings/billing/portal — test-mode settings stay in test mode, and the Setup screen links the portal page for the mode the app is in. This step stays on the Setup screen after both items are done, with a badge that reads Test mode until this app takes live payments.
Those steps are the items' copy on the Setup surface, with the vibe's real
handle and slug filled in from the ownerHandle and appSlug the /setup
route hands over: https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings
for Connect Stripe, and https://dashboard.stripe.com/test/settings/billing/portal
for the portal. "Open the Stripe dashboard" and "the Settings page" are
directions to search for, and a person who has never seen either has to go
looking. The portal item is checked with one read
of GET /v1/billing_portal/configurations?is_default=true&active=true — the
active default is the configuration /portal opens, and a portal that was
never saved has none — made only on the owner's /setup call, in the mode
the app charges in, and kept in a setup document per mode the backend
writes, so the owner sees the item turn to Done the moment they press Save
and a visitor's read costs Stripe nothing. A refused read (an account Stripe
is still reviewing, a platform answer, Stripe down) keeps the last verdict and
rides the item as problem, which the Setup screen shows the owner in its own
words; only a successful empty answer means not saved. A portal that was
skipped answers a Stripe error whose message says so (§ 8). In the consumer
flow, a Join button whose items are not done reads Memberships are not open
yet; the owner's Setup entry is where the steps are, and later it is where
test turns into live.
The owner pastes nothing: the platform holds the credential and attaches it to
every call. An app made before Connect Stripe, whose backend.js already
reads ctx.secrets.STRIPE_SECRET_KEY, keeps that helper as it is: an edit to
it changes what was asked and leaves its memberships where they are (a
subscription lives on the account that created it), and it moves to
ctx.stripe only when the owner asks to switch to Connect Stripe. Every new
membership app is built on ctx.stripe.
Test mode first, live when it opens
ctx.stripe.status() answers { test: { available, state }, live: { available, state } }: available is whether vibes.diy takes payments in that mode on
this deployment, and state is the owner's account in it (absent,
onboarding, ready, disconnected). The Stripe account serves every app
the owner makes, so going live is the owner's choice per app:
An app charges in live only after its owner chooses live for this app, and from then on live is its only account: a live account that is not ready closes checkout until the owner reconnects.
The Join request carries the mode the member was shown, and the backend opens Checkout only when it matches the mode the app charges in now.
The choice is a setup:live document the backend writes when the owner
presses Take live payments in this app, offered once live.state reads
ready; the mode comes from that document alone. /setup hands the mode to
everyone and the Join request carries the mode the member was shown, so a
page that still shows the other mode refreshes before any Checkout opens.
While the mode is test, the Join button sits beside a Test mode badge, the
owner sees the test card to rehearse with, and members read that test
memberships end when the app goes live. The owner's Go-live step reads
live.available (handed over as liveOpen) and live.state (as
liveReady) to name the next button. The stripe skill's Test mode first
section is the same rule in more words.
2. Stripe stays on the server, and so does Stripe.js
Every Stripe request is made in backend.js with ctx.stripe.fetch(path, { method, body, headers, mode }); 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 holds no
Stripe credential at all: ctx.stripe.fetch sends the call to the platform,
which attaches the key and the owner's account and hands back Stripe's own
Response. The calls this recipe makes — a subscription-mode Checkout
Session, reading it and the subscription back, the portal-configuration read
and the portal session — are the calls ctx.stripe carries. 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 per mode, membership-<mode>-<handle>, rewritten in place by every
attempt, renewal and lapse in that mode. /subscribe runs as the signed-in member (the page calls it with
apiFetch), claims that record in pending with the plan and the mode — 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:
Every membership records the mode it was made in, and every ctx.stripe.fetch call for that membership passes the membership's own mode.
- The membership's
modeis the app's current mode — test until the owner takes this app live, live after — and the session, the subscription read-back, the sweep and the portal all use the record's ownmode. A test membership is read from the test account for its whole life, and the record for each mode is its own, so a test session still open when the app goes live settles or expires on its own record. Only a record in the app's current mode is entitled: after going live, the sweep writes every test membershipentitled: false, and the member joins fresh in live. (Thismodeis the optionctx.stripe.fetchtakes; the form's ownmode=subscriptionis Stripe's Checkout field.) - The whole payment lands in the owner's own Stripe account: the session carries the line items, the record's ids and the return URLs, and Stripe pays the merchant directly every period.
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-<mode>-<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 2), 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 through ctx.stripe, in the membership's
own mode.
- 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.
Settling is idempotent: the backend reads the membership first, and a membership that is no longer pending comes back as it is, with ok.
The return can arrive more than once for one checkout, sometimes at the same
moment: the page reads the hash on mount and again on hashchange, a reload
repeats it, and the sweep may get there first. So /confirm answers from the
membership it reads. When two calls write at once, the second meets
conflict (or access-denied, when the access rule refuses that change); the
backend reads the membership again, and a membership bound to this same
checkout is the answer it gives.
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 through ctx.stripe 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 own answers arrive as a Response whose JSON carries
error.message; return it as { ok: false, reason: "stripe", message } so
the page can show why. The portal's "No configuration provided" message is
the one that means step 2 of setup is still to do. A platform answer is thrown
as an Error with e.reason, and each reason names the owner's next step:
not_connected and not_ready are the Connect Stripe item,
account_disconnected is Connect Stripe again from the same Settings tab,
and not_available means payments in that mode are paused on vibes.diy for
now. Log it with ctx.log and hand the reason to the page, which reads it as
Memberships are not open yet.
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-<mode>-<caller> only.
fetch: { unfilteredReads: { dbs: ["club"], why: "the /subscribe, /confirm, /refresh and /portal routes read the signed-in member's own membership record in the app's current mode, membership-<mode>-<their handle>, and nothing else" } },
};
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 per mode, named for them: every attempt, renewal and lapse in that mode
// rewrites it in place, and a test record never shares a document with a live one.
function membershipId(handle, mode) {
return `membership-${mode}-${handle}`;
}
// The owner's choice to take THIS app live. The Stripe account is the owner's and serves all of
// their apps, so its live account turning ready never moves an app by itself.
const LIVE_CHOICE = "setup:live";
// Which of the owner's two Stripe accounts this app charges in, decided by the owner's choice
// alone; then whether that account takes card payments right now. A live app whose live account
// is not ready stays live with checkout closed until the owner reconnects.
async function checkoutMode(ctx) {
const mode = (await ctx.db.get(LIVE_CHOICE, { db: DB })) ? "live" : "test";
let st;
try {
st = await ctx.stripe.status();
} catch (e) {
ctx.log("warn", "stripe status unavailable", { message: e.message });
return { mode, open: false, state: "unavailable", liveOpen: false, liveReady: false };
}
return { mode, open: st[mode].state === "ready", state: st[mode].state, liveOpen: st.live.available, liveReady: st.live.state === "ready" };
}
// One call to Stripe on the owner's account, in the membership's own mode. Form-encoded in,
// Stripe's JSON out, the refusal's own sentence kept.
async function stripe(ctx, mode, method, path, form, headers = {}) {
let res;
try {
res = await ctx.stripe.fetch(`/v1${path}`, { method, body: form, headers, mode });
} catch (e) {
// The platform's answer, not Stripe's: a Setup step for the owner, or payments paused.
ctx.log("warn", "stripe call refused", { reason: e.reason });
return { ok: false, reason: e.reason ?? "not_available" };
}
const body = await res.json().catch(() => null);
if (!res.ok) return { ok: false, reason: "stripe", message: body?.error?.message ?? `HTTP ${res.status}` };
return { ok: true, body };
}
// Has the owner saved the Customer portal in this mode? Stripe is asked only on the OWNER's call,
// and the answer is kept in a document per mode the backend writes, so a visitor's /setup read never
// reaches Stripe and a blip at Stripe keeps the last verdict. The /portal route opens the ACTIVE
// DEFAULT configuration, so that is the one asked for; a refused read is a problem shown to the
// owner, never "not saved". Test-mode portal settings stay in test mode, so live asks again.
const portalState = (mode) => `setup:stripe-portal:${mode}`;
async function portalSaved(ctx, owner, mode, connected) {
const kept = await ctx.db.get(portalState(mode), { db: DB });
const known = kept?.saved === true;
if (!owner || !connected) return { done: known };
const configurations = await stripe(ctx, mode, "GET", "/billing_portal/configurations?is_default=true&active=true&limit=1");
if (!configurations.ok) return { done: known, problem: configurations.message ?? "Stripe could not be reached; the last verdict stands." };
const saved = (configurations.body.data?.length ?? 0) > 0;
if (known !== saved) await ctx.db.put({ _id: portalState(mode), type: "setup", mode, saved, checkedAt: Date.now() }, { db: DB, id: portalState(mode), ifRev: kept?._rev });
return { done: saved };
}
// 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;
}
// Write a membership against the revision it was read at. A conflict or refusal means another call
// (a second /confirm, the sweep) wrote it first: answer from what is there now when it is the same checkout.
async function write(ctx, next, m) {
try {
await ctx.db.put(next, { db: DB, id: m._id, ifRev: m._rev });
return next;
} catch (err) {
if (err.code !== "conflict" && err.code !== "access-denied") throw err;
// Another call wrote it first: that is this checkout's answer when it holds the same session.
const stored = await ctx.db.get(m._id, { db: DB });
if (stored && stored.sessionId === m.sessionId) return stored;
throw err;
}
}
// 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, and a record whose mode the
// app has moved past (`appMode`), which grants nothing however Stripe sees it.
async function refresh(ctx, m, appMode) {
if (!m.subscriptionId) return m;
const sub = await stripe(ctx, m.mode, "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) && m.mode === appMode;
const changed = ["status", "currentPeriodStart", "currentPeriodEnd", "cancelAtPeriodEnd", "entitled"].some((k) => next[k] !== m[k]);
if (!changed) return m;
return write(ctx, next, m);
}
// 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, appMode) {
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, m.mode, "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 = await write(ctx, { ...m, subscriptionId: s.subscription, customerId: s.customer }, m);
if (bound.subscriptionId !== s.subscription) return bound;
return refresh(ctx, await ctx.db.get(m._id, { db: DB }), appMode);
}
export async function fetch(request, ctx) {
const url = new URL(request.url);
const me = ctx.userInfo?.userHandle;
// The setup verdict: ready for everyone, the items (and the handle + slug the Setup screen
// links with) for the owner alone.
if (url.pathname === "/setup" && request.method === "GET") {
const owner = me === ctx.appInfo.ownerHandle;
const { mode, open, state, liveOpen, liveReady } = await checkoutMode(ctx);
const connect = { id: "stripe-connect", label: "Connect Stripe", done: open, state };
if (mode === "live" && !liveOpen) connect.problem = "Vibes DIY has paused live payments for now; memberships reopen here by themselves when they return.";
const items = [connect, { id: "stripe-portal", label: "Customer portal", ...(await portalSaved(ctx, owner, mode, open)) }];
const ready = items.every((item) => item.done);
// `mode` is everyone's: the Join button wears a Test mode badge while the app takes test payments.
if (!owner) return json({ ok: true, ready, owner, mode });
// The Go-live step: `liveOpen` (vibes.diy takes live payments) and `liveReady` (the owner's live
// Stripe account is ready, so this app can be taken live).
return json({ ok: true, ready, owner, mode, liveOpen, liveReady, items, graceDays: GRACE_DAYS, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
}
// The owner takes this app live: one document, written for the owner alone, once their live
// Stripe account is ready. From here on the app charges in live only.
if (url.pathname === "/go-live" && request.method === "POST") {
if (me !== ctx.appInfo.ownerHandle) return json({ ok: false, reason: "owner-only" }, 403);
const { liveReady } = await checkoutMode(ctx);
if (!liveReady) return json({ ok: false, reason: "not_ready" }, 409);
const chosen = await ctx.db.get(LIVE_CHOICE, { db: DB });
if (!chosen) await ctx.db.put({ _id: LIVE_CHOICE, type: "setup", mode: "live", chosenAt: Date.now() }, { db: DB, id: LIVE_CHOICE });
return json({ ok: true, mode: "live" });
}
if (!me) return json({ ok: false, reason: "sign-in" }, 401);
const { mode, open, state } = await checkoutMode(ctx);
const id = membershipId(me, mode); // the member's record in the mode the app charges in now
const current = await ctx.db.get(id, { db: DB }); // null before the first attempt in this mode
if (url.pathname === "/subscribe" && request.method === "POST") {
const { plan, mode: shown } = await request.json();
// The member joins in the mode their page showed them, or not at all: a page that still shows
// the other mode refreshes first.
if (shown !== mode) return json({ ok: false, reason: "mode-changed", mode }, 409);
if (!open) return json({ ok: false, reason: state === "disconnected" ? "account_disconnected" : "not_ready" }, 503);
// The portal is where a member cancels, so nobody joins before it exists: the KEPT verdict for
// this mode, never a Stripe read per press.
if ((await ctx.db.get(portalState(mode), { db: DB }))?.saved !== true) return json({ ok: false, reason: "setup-incomplete" }, 503);
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,
mode, // the account this membership is made in; every later call for it passes the same mode
status: "pending",
entitled: false,
attempt,
customerId: current?.customerId, // a returning member keeps their Stripe customer in this mode
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, mode, "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, mode);
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, mode);
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, current.mode, "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) {
const { mode } = await checkoutMode(ctx); // records in any other mode are re-read and grant nothing
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, mode) : await refresh(ctx, m, mode);
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.mode}-${doc.memberHandle}`) throw { forbidden: "a membership is named for its mode and 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 || doc.mode !== oldDoc.mode) throw { forbidden: "a membership keeps its member and its mode" };
} 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"] } };
}
// The setup verdict the backend keeps (the portal's saved state): written by backend.js alone,
// read on the owner's channel.
if (doc.type === "setup") {
ctx.requireBackend();
return { channels: ["memberships"] };
}
throw { forbidden: "unknown document type" };
}
App.jsx sketch
App.jsx
jsximport React, { useEffect, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { apiFetch, useViewer } from "use-vibes";
import { Badge, Button, Heading, Row, Stack, Text } from "@vibes.diy/pouf";
// The setup verdict, read on mount and again whenever the app comes back into view: the owner
// connects Stripe or saves the portal in another tab and returns, and the Join buttons unlock
// without a reload.
function useSetup() {
const [setup, setSetup] = useState(null); // { ready, owner, mode, liveOpen?, items?, graceDays?, ownerHandle?, appSlug? }
const latest = useRef(0);
useEffect(() => {
// Mount, focus and visibility reads overlap; only the latest one may land, or a slow stale
// answer overwrites a fresh one.
const load = () => {
const n = ++latest.current;
apiFetch("/setup").then((r) => r.json()).catch(() => ({ ready: false, owner: false })).then((s) => { if (n === latest.current) setSetup(s); });
};
load();
const onVisible = () => { if (document.visibilityState === "visible") load(); };
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("focus", load);
window.addEventListener("setup-changed", load); // the Setup screen's own Take live button
return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); window.removeEventListener("setup-changed", load); };
}, []);
return setup;
}
// The button the Settings tab shows for each state of the owner's Stripe account, in its own words.
const STRIPE_BUTTON = { absent: "Connect Stripe", onboarding: "Finish Stripe setup", disconnected: "Connect Stripe again" };
// The Setup surface, the owner's alone: two items, every step a link to the exact page in that
// page's own words, with this vibe's real handle and slug filled in.
function SetupScreen({ setup }) {
const vibe = `${setup.ownerHandle}/${setup.appSlug}`;
const settingsUrl = `https://vibes.diy/vibes/mine/${vibe}/settings`;
const live = setup.mode === "live";
// The portal page for the mode this app charges in: test-mode settings stay in test mode.
const portalUrl = live ? "https://dashboard.stripe.com/settings/billing/portal" : "https://dashboard.stripe.com/test/settings/billing/portal";
const connect = setup.items.find((i) => i.id === "stripe-connect");
const takeLive = () => apiFetch("/go-live", { method: "POST" }).then(() => window.dispatchEvent(new Event("setup-changed")));
return (
<Stack>
<Heading>Setup</Heading>
{setup.items.map((item) => (
<Row key={item.id} gap={2}>
<Text>{item.label}</Text>
<Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : item.state === "onboarding" ? "Stripe is finishing up" : "To do"}</Badge>
{item.problem && <Text muted>{item.problem}</Text>}
</Row>
))}
<ol>
<li>
Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings)
and press <b>{STRIPE_BUTTON[connect.state] ?? "Connect Stripe"}</b> under <b>Payments</b>.
Stripe opens its own setup pages: use your Stripe account, or create one there. Your Stripe account serves every app you make.
</li>
<li>
Signed in to that Stripe account, open its <a href={portalUrl} target="_blank" rel="noreferrer">Customer portal settings ({live ? "live" : "test"} mode)</a>:
under <b>Cancellations</b> turn on <b>Cancel subscriptions</b>, choose <b>At end of billing period</b>, and press <b>Save</b>.
Members use that page to cancel or change a card.
</li>
<li>
Come back here: the Join buttons work as soon as both items are done. In test mode the card 4242 4242 4242 4242 pays.
</li>
<li>
{/* The Go-live step stays here once the items are done; its badge says which account this app charges in. */}
<Badge tone={live ? "up" : "info"}>{live ? "Live" : "Test mode"}</Badge>{" "}
{live ? (
<>
Real memberships bill into your Stripe account; refunds, invoices and payouts are in your Stripe Dashboard. Keep the{" "}
<a href={portalUrl} target="_blank" rel="noreferrer">Customer portal settings (live mode)</a> saved.
</>
) : setup.liveReady ? (
<>
Your live Stripe account is ready. Each app goes live on its own, so this one rehearses until you press{" "}
<Button onClick={takeLive}>Take live payments in this app</Button>, then save the{" "}
<a href="https://dashboard.stripe.com/settings/billing/portal" target="_blank" rel="noreferrer">Customer portal settings (live mode)</a> once more.
</>
) : setup.liveOpen ? (
<>
When a test payment goes through, press <b>Go live with Stripe</b> under <b>Payments</b> on{" "}
<a href={settingsUrl} target="_blank" rel="noreferrer">the same Settings tab</a> and finish Stripe's live setup, once for all your apps; then come back here to take this app live.
</>
) : (
<>Test mode runs the whole flow end to end. The <b>Go live with Stripe</b> button appears under <b>Payments</b> on the same Settings tab when vibes.diy opens live payments.</>
)}
</li>
</ol>
</Stack>
);
}
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 setup = useSetup();
const [screen, setScreen] = useState("club");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// 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."
: data.reason === "mode-changed" ? `Memberships here just moved to ${data.mode} mode. Refresh the page and press Join again.`
: "Memberships are not open yet."
);
return;
}
tab.location.href = data.url;
} finally {
setBusy(false);
}
};
const ready = setup?.ready === true;
// The member's record in the mode the app charges in now: a record from the other mode is a rehearsal.
const mine = viewer && setup ? memberships.find((m) => m._id === `membership-${setup.mode}-${viewer.userHandle}`) : undefined;
const entitled = mine?.entitled === true;
const ends = mine?.currentPeriodEnd ? new Date(mine.currentPeriodEnd).toLocaleDateString() : null;
return (
<Stack>
<Row gap={2}>
<Button variant="quiet" onClick={() => setScreen("club")}>The club</Button>
{/* The Setup entry: the owner's alone, highlighted while an item is still to do, quiet once done. */}
{setup?.owner && (
<Button variant={ready ? "quiet" : "solid"} onClick={() => setScreen("setup")}>
Setup{ready ? "" : " · to do"}
</Button>
)}
</Row>
{screen === "setup" && setup?.owner ? (
<SetupScreen setup={setup} />
) : (
<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>
) : ready ? (
<Stack>
{/* Test mode is a rehearsal: the owner gets the test card, members learn it ends at go-live. */}
{setup.mode === "test" && (
<Row gap={2}>
<Badge tone="info">Test mode</Badge>
<Text muted>{setup.owner ? "Join with the test card 4242 4242 4242 4242 and any future date." : "This is a rehearsal: test memberships end when the app goes live."}</Text>
</Row>
)}
<Button onClick={() => openStripe("/subscribe", { plan: "monthly", mode: setup.mode })} disabled={busy} loading={busy}>
Join — Stripe opens in a new tab
</Button>
</Stack>
) : (
<Text muted>Memberships are not open yet.</Text>
)}
</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>
{setup?.owner && (
<ul>
{memberships.map((m) => (
<li key={m._id}>@{m.memberHandle} · {m.mode} · {m.plan} · {m.status}{m.entitled ? " · entitled" : ""}{m.currentPeriodEnd ? ` · until ${new Date(m.currentPeriodEnd).toLocaleDateString()}` : ""}</li>
))}
</ul>
)}
</Stack>
)}
</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.