@vibes.diy/prompts 14.3.156 → 14.3.159

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,633 @@
1
+ # Stripe subscriptions — a paid membership that follows Stripe's state
2
+
3
+ When someone asks for **recurring money** — a paid newsletter, a members-only
4
+ club, a monthly coaching community, a subscription tier — build it on **Stripe
5
+ Checkout in subscription mode** from `backend.js`, with the secret key the owner
6
+ supplies, and keep the member's access a mirror of what Stripe says about their
7
+ subscription. This is a `backend.js` feature (see the backend docs for the
8
+ general handler contract) and a sibling of the `stripe` skill, which takes a
9
+ one-time payment; this page is the recipe for money that keeps moving, where the
10
+ question is never "did they pay" but "are they entitled right now".
11
+
12
+ **This skill comes in only when the person asks for a subscription.** A feed
13
+ with a "members" label, a club page, a course — each of those is free until the
14
+ person says people pay monthly, yearly, or "subscribe". What you may do unasked
15
+ is **offer** it: on an app with members-only content, the closing row's
16
+ capability option is `▸ [pay] Charge a monthly membership with Stripe`, gold
17
+ when nothing else fits better, and a tap on it is the ask. Words that are the
18
+ ask: "subscription", "subscribe", "monthly", "per month", "yearly", "membership
19
+ fee", "paid tier", "paywall", "recurring". A one-time charge — dues once a year,
20
+ a ticket, a deposit — is the `stripe` skill, not this one.
21
+
22
+ **One merchant, many members.** Every member pays the owner's Stripe account,
23
+ whose key is on the vibe. A member is a signed-in person whose membership is a
24
+ record between them and the owner, and the members-only content is a channel
25
+ that record grants. An app where each visitor sells their own subscriptions is
26
+ a platform (Stripe Connect) and a different thing — say so and build the
27
+ one-merchant half.
28
+
29
+ ## 1. Bring your own Stripe key, turn on the portal, and explain how
30
+
31
+ The owner sets up Stripe once:
32
+
33
+ 1. Open the Stripe dashboard and sign in (or create a Stripe account). Turn on
34
+ **Test mode** until the app is working.
35
+ 2. Go to Developers, then API keys, and reveal the **Secret key**. Set it on
36
+ the vibe as `STRIPE_SECRET_KEY` — the vibe's settings page, or
37
+ `vibes-diy secrets set STRIPE_SECRET_KEY <the key>`.
38
+ 3. Go to Settings, then Billing, then **Customer portal**, and save it with
39
+ **Cancel at end of billing period** on. The app sends members there to
40
+ cancel or change a card, and the portal does not exist until it is saved
41
+ once.
42
+ 4. To go live, repeat steps 2 and 3 with Test mode off.
43
+
44
+ The app carries those steps in its own UI, because the person reading them is
45
+ the person who has to do them.
46
+
47
+ 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.
48
+
49
+ The backend answers `{ ok: false, reason: "missing-key" }` when
50
+ `ctx.secrets.STRIPE_SECRET_KEY` is absent, and the client renders the numbered
51
+ steps, naming the Stripe dashboard, `STRIPE_SECRET_KEY` and the Customer Portal
52
+ in so many words. A visitor who is not the owner sees a calmer sentence —
53
+ memberships are not open yet — never the steps. A portal that was never saved
54
+ answers a Stripe error whose message says so; keep that sentence (§ 7) and the
55
+ owner reads step 3 off it.
56
+
57
+ ## 2. The key never reaches the browser, and neither does Stripe.js
58
+
59
+ 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.
60
+
61
+ The client talks to the app's own `/_api/...` routes, and the backend is the
62
+ only place that knows the key exists. Stripe Checkout and the Customer Portal
63
+ are pages Stripe hosts: the backend mints a URL, the page opens it, Stripe does
64
+ the card, the retries, the invoices and the cancellation screen. Nothing about
65
+ a card ever touches the app.
66
+
67
+ Checkout opens in a new tab with window.open, because the app runs inside a frame that cannot navigate the page around it.
68
+
69
+ The same goes for the portal. Open the tab inside the click handler, before any
70
+ `await`, and check it for `null` before asking the backend for anything; hold
71
+ the button busy until the tab is on its way. Say in the button's copy that
72
+ Stripe opens in a new tab.
73
+
74
+ ## 3. One membership, then one Checkout Session
75
+
76
+ The membership exists before the money moves, and there is **one record per
77
+ member**, `membership-<handle>`, rewritten in place by every attempt, renewal
78
+ and lapse. `/subscribe` runs as the signed-in member (the page calls it with
79
+ `apiFetch`), claims that record in `pending` with the plan — against the
80
+ revision it just read, so a concurrent press conflicts and is handed the
81
+ attempt that won — and asks Stripe for a session for exactly that record:
82
+
83
+ - `mode=subscription`, with an inline recurring price:
84
+ `line_items[0][price_data][recurring][interval]=month` (or `year`),
85
+ `unit_amount` in the smallest unit, a product name. The owner never creates
86
+ products in the dashboard first.
87
+ - `client_reference_id` carries the record's `_id` and `metadata[attempt]`
88
+ its attempt number.
89
+ - `success_url` and `cancel_url` are the app's public address with a fragment,
90
+ `#subscribed` and `#cancelled`; the record is the caller's own, so no id
91
+ rides the URL.
92
+ - `Idempotency-Key` is `<record id>:<attempt>`, so two first presses that both
93
+ got past the claim (a record that does not exist yet has no revision to
94
+ claim) mint one session at Stripe; and a second press on an open attempt
95
+ reuses its session for the day rather than minting another.
96
+
97
+ Amounts are integers in the currency's smallest unit: $5 a month is unit_amount 500, never 5.0.
98
+
99
+ The price lives in the app's own plan list on the server; the browser sends a
100
+ plan name, never an amount. One subscription per person: `/subscribe` refuses
101
+ while the member's subscription is **live at Stripe** — `active`, `trialing`,
102
+ `past_due`, `unpaid` or `paused`, entitled or not — because a lapsed member
103
+ whose card Stripe is still retrying must not be sold a second subscription
104
+ that bills beside the first once it recovers. Those members are sent to the
105
+ portal (§ 7) to fix the card or cancel.
106
+
107
+ The routes read the `club` database, which `access.js` binds, so `backend.js`
108
+ declares `config.fetch.unfilteredReads` for it — and owes the obligation that
109
+ comes with it: every route reads exactly one record, `membership-<caller>`,
110
+ and no route lists memberships for anyone. The owner's roster is the page's
111
+ own live query, scoped by the access function's `memberships` channel to the
112
+ owner role.
113
+
114
+ ## 4. Entitlement is Stripe's status plus one number, the grace period
115
+
116
+ 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.
117
+
118
+ `GRACE_DAYS` is the recipe's one knob. Stripe retries a failed renewal for a
119
+ few days and most retries succeed, so a short grace (five days is the default)
120
+ keeps a member whose card bounced once from being locked out while Stripe is
121
+ still collecting. Set it to zero and a failed renewal locks the moment it
122
+ fails. Nothing else about entitlement is configurable, on purpose:
123
+
124
+ - `active` and `trialing` are entitled. A member who cancelled **at period
125
+ end** stays `active` with `cancel_at_period_end: true` until the date, so
126
+ they keep what they paid for; the page shows "ends on …".
127
+ - `past_due` is entitled until `current_period_start + GRACE_DAYS`, then
128
+ locked. The anchor is the period **start**, not the end: when a renewal
129
+ fails Stripe has already opened the new period, so `current_period_end` is
130
+ a month or a year away and would hand a lapsed member the whole interval.
131
+ `current_period_start` is the moment the renewal was due, which is the
132
+ moment the grace clock should start.
133
+ - `canceled`, `unpaid`, `paused`, `incomplete` and `incomplete_expired` are
134
+ locked at once. With the portal set to cancel at period end (§ 1 step 3), a
135
+ cancellation never cuts into paid time, because Stripe keeps the status
136
+ `active` until the period closes.
137
+
138
+ The backend computes `entitled` and writes it on the membership document;
139
+ nothing else does, and a value computed only for a response is not
140
+ entitlement. Read `current_period_start` and `current_period_end` as
141
+ `sub.current_period_start ?? sub.items?.data?.[0]?.current_period_start` (and
142
+ the same for the end) — newer Stripe API versions carry them on the
143
+ subscription item, older ones on the subscription — and they are Unix
144
+ **seconds**, so multiply by 1000 once.
145
+
146
+ ## 5. Members-only content is a grant the backend writes
147
+
148
+ Members-only content is a grant the backend writes from Stripe's subscription, never a flag a browser sets.
149
+
150
+ **Memberships and the posts they unlock live in one database.** Grants are
151
+ reduced per database — a channel granted by a record in one database opens
152
+ nothing in another — so the `club` database holds both the `membership`
153
+ records and the `post` records, and one access function handles both types.
154
+ A members-only feed built with its posts in a second database would grant a
155
+ paying member a channel that no post is on.
156
+
157
+ The access state is the union of every current document's grants, so the
158
+ membership document is where entitlement becomes structural: when
159
+ `doc.entitled` is true the access function grants the member the `members`
160
+ channel, and when the backend rewrites the document with `entitled: false` the
161
+ grant is gone on the next sync. Members-only posts route to `members`, nothing
162
+ reads them without the grant, and a browser can neither set `entitled` nor
163
+ move `status`, because every update of an existing membership requires
164
+ `ctx.requireBackend()`. The page gates its "members only" view on the live
165
+ membership record and on `can.*` for anything it writes; the server enforces
166
+ the same rule whatever the page shows.
167
+
168
+ ## 6. Two things keep the mirror true: the return, and the sweep
169
+
170
+ Both are the backend reading Stripe with the key.
171
+
172
+ - **The return.** When the app opens with `#subscribed=<id>` in its address,
173
+ the page calls `/confirm`; the route reads the Checkout Session back, takes
174
+ its `subscription` and `customer` ids, reads the subscription, and writes
175
+ status, period end, `cancel_at_period_end` and `entitled` onto the
176
+ membership. Read the hash on mount **and** on `hashchange`: the wrapper hands
177
+ the app its fragment just after first render.
178
+
179
+ A scheduled sweep refreshes every live subscription from Stripe, so a failed renewal or a cancellation reaches the app without a webhook.
180
+
181
+ - **The sweep.** Every half hour `scheduled` reads each membership whose
182
+ subscription is not in a terminal state, re-reads it from Stripe, recomputes
183
+ `entitled`, and writes the document when anything changed — including when
184
+ nothing at Stripe changed but the grace period ran out. A pending membership
185
+ whose session was never completed is settled the same way as the one-time
186
+ recipe's orders: expired after a day. The work is bounded by the number of
187
+ members.
188
+
189
+ Why not a webhook: a webhook write arrives with no identity and would need
190
+ `allowAnonymous` on the one record that grants access. Reading the
191
+ subscription back with the key the app already holds is the same proof from
192
+ the same source, on a route that can only write what Stripe reports.
193
+
194
+ ## 7. Cancel, change a card, see invoices: the Customer Portal
195
+
196
+ 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.
197
+
198
+ `/portal` runs as the signed-in member, reads their record, and `POST`s
199
+ `/v1/billing_portal/sessions` with `customer` and a `return_url` of the app's
200
+ address; the page opens the returned `url` in a new tab. The portal handles
201
+ cancel-at-period-end, card changes, invoices and receipts, and the next sweep
202
+ or the member's return (`#portal` in the fragment calls `/refresh`) brings the
203
+ new state home. The app never writes `status: "canceled"` itself, in the page
204
+ or in a route: a Cancel button in the app is a link to the portal. The portal
205
+ button shows whenever Stripe knows the member (the record carries a
206
+ `customerId`), entitled or not — a lapsed member needs it most, to update the
207
+ card or to cancel. A visitor who is not signed in gets a `Sign in to join`
208
+ button wired to `useViewer().requestLogin()`, never a Join button that fails
209
+ with a sign-in error.
210
+
211
+ ## 8. Errors keep their reason
212
+
213
+ Stripe's refusals come back as JSON with `error.message`; return it as
214
+ `{ ok: false, reason: "stripe", message }` so the page can show the owner
215
+ *why*. The portal's "No configuration provided" message is the one that means
216
+ step 3 of setup was skipped. A refused `ctx.fetch` — the
217
+ `{ vibesEgressDenied: true }` shape — is a platform refusal; log it and answer
218
+ `{ ok: false, reason: "unavailable" }`. `api.stripe.com` is CORS-open to the
219
+ app's origin, so nothing here needs the curated list.
220
+
221
+ ## Complete backend.js
222
+
223
+ ### `backend.js`
224
+ ```js app=stripe-membership
225
+ // A paid membership that mirrors Stripe: hosted Checkout in subscription mode, entitlement
226
+ // recomputed from the subscription's status, members-only access as a grant the backend writes.
227
+ // ONE database holds the memberships and the posts they unlock: grants are reduced per database,
228
+ // so a channel granted in one database opens nothing in another.
229
+ export const config = {
230
+ scheduled: { interval: "30m" },
231
+ // The routes read the member's own record; access.js binds the database, so the fetch lane needs
232
+ // this declaration, and every route owes the check it waives: it reads membership-<caller> only.
233
+ 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" } },
234
+ };
235
+
236
+ const STRIPE = "https://api.stripe.com/v1";
237
+ const DB = "club";
238
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
239
+ // The one knob: how long a past_due member stays in while Stripe retries the card. Zero locks
240
+ // the moment a renewal fails.
241
+ const GRACE_DAYS = 5;
242
+ const PLANS = {
243
+ monthly: { title: "Club membership (monthly)", amount: 500, currency: "usd", interval: "month" },
244
+ yearly: { title: "Club membership (yearly)", amount: 5000, currency: "usd", interval: "year" },
245
+ };
246
+ // Nothing at Stripe will change these again; everything else is live and re-read.
247
+ const TERMINAL = new Set(["canceled", "incomplete_expired"]);
248
+
249
+ function json(body, status = 200) {
250
+ return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
251
+ }
252
+
253
+ function appUrl(ctx, hash) {
254
+ return `https://vibes.diy/vibe/${ctx.appInfo.ownerHandle}/${ctx.appInfo.appSlug}${hash}`;
255
+ }
256
+
257
+ // One record per member, named for them: every attempt, renewal and lapse rewrites it in place.
258
+ function membershipId(handle) {
259
+ return `membership-${handle}`;
260
+ }
261
+
262
+ async function stripe(ctx, method, path, form, headers = {}) {
263
+ const key = ctx.secrets.STRIPE_SECRET_KEY;
264
+ if (!key) return { ok: false, reason: "missing-key" };
265
+ const res = await ctx.fetch(`${STRIPE}${path}`, {
266
+ method,
267
+ headers: { authorization: `Bearer ${key}`, ...(form ? { "content-type": "application/x-www-form-urlencoded" } : {}), ...headers },
268
+ body: form ? form.toString() : undefined,
269
+ });
270
+ const body = await res.json().catch(() => null);
271
+ if (!res.ok) {
272
+ if (body?.vibesEgressDenied) {
273
+ ctx.log("warn", "stripe egress denied", { gate: body.gate });
274
+ return { ok: false, reason: "unavailable" };
275
+ }
276
+ return { ok: false, reason: "stripe", message: body?.error?.message ?? `HTTP ${res.status}` };
277
+ }
278
+ return { ok: true, body };
279
+ }
280
+
281
+ // Stripe's answer, reduced to the facts the app keeps. The period bounds are Unix seconds, and
282
+ // newer API versions carry them on the subscription item rather than the subscription.
283
+ function mirror(sub) {
284
+ const item = sub.items?.data?.[0];
285
+ const periodStart = (sub.current_period_start ?? item?.current_period_start ?? 0) * 1000;
286
+ const periodEnd = (sub.current_period_end ?? item?.current_period_end ?? 0) * 1000;
287
+ return { status: sub.status, currentPeriodStart: periodStart, currentPeriodEnd: periodEnd, cancelAtPeriodEnd: sub.cancel_at_period_end === true, customerId: sub.customer };
288
+ }
289
+
290
+ // The entitlement rule, stated once. active/trialing in; past_due in until the grace period after
291
+ // the failed renewal — the START of the current period, which Stripe opens when it invoices the
292
+ // renewal, not its end, which is an interval away; everything else out.
293
+ function isEntitled(m, now = Date.now()) {
294
+ if (m.status === "active" || m.status === "trialing") return true;
295
+ if (m.status === "past_due") return now < m.currentPeriodStart + GRACE_DAYS * 24 * 60 * 60 * 1000;
296
+ return false;
297
+ }
298
+
299
+ // Re-read the member's subscription and write the record back when anything the app keeps
300
+ // changed — including entitlement lapsing with nothing new at Stripe.
301
+ async function refresh(ctx, m) {
302
+ if (!m.subscriptionId) return m;
303
+ const sub = await stripe(ctx, "GET", `/subscriptions/${m.subscriptionId}`);
304
+ if (!sub.ok) return m; // a refusal changes nothing; the next read tries again
305
+ const next = { ...m, ...mirror(sub.body), syncedAt: Date.now() };
306
+ next.entitled = isEntitled(next);
307
+ const changed = ["status", "currentPeriodStart", "currentPeriodEnd", "cancelAtPeriodEnd", "entitled"].some((k) => next[k] !== m[k]);
308
+ if (!changed) return m;
309
+ await ctx.db.put(next, { db: DB, id: m._id, ifRev: m._rev });
310
+ return next;
311
+ }
312
+
313
+ // A record still waiting on its Checkout Session: bind the subscription Stripe created, or expire
314
+ // the attempt after a day.
315
+ async function settlePending(ctx, m) {
316
+ if (m.status !== "pending") return m;
317
+ if (!m.sessionId) {
318
+ if (Date.now() - m.sessionCreatedAt > SESSION_TTL_MS) {
319
+ const expired = { ...m, status: "expired", entitled: false };
320
+ await ctx.db.put(expired, { db: DB, id: m._id, ifRev: m._rev });
321
+ return expired;
322
+ }
323
+ return m;
324
+ }
325
+ const session = await stripe(ctx, "GET", `/checkout/sessions/${m.sessionId}`);
326
+ if (!session.ok) return m;
327
+ const s = session.body;
328
+ if (s.id !== m.sessionId || s.client_reference_id !== m._id) {
329
+ ctx.log("warn", "stripe session does not match its membership", { membershipId: m._id });
330
+ return m;
331
+ }
332
+ if (s.status === "expired") {
333
+ const expired = { ...m, status: "expired", entitled: false };
334
+ await ctx.db.put(expired, { db: DB, id: m._id, ifRev: m._rev });
335
+ return expired;
336
+ }
337
+ if (!s.subscription) return m; // not completed yet
338
+ const bound = { ...m, subscriptionId: s.subscription, customerId: s.customer };
339
+ await ctx.db.put(bound, { db: DB, id: m._id, ifRev: m._rev });
340
+ return refresh(ctx, await ctx.db.get(m._id, { db: DB }));
341
+ }
342
+
343
+ export async function fetch(request, ctx) {
344
+ const url = new URL(request.url);
345
+ const me = ctx.userInfo?.userHandle;
346
+
347
+ if (url.pathname === "/status" && request.method === "GET") {
348
+ return json({ ok: true, configured: Boolean(ctx.secrets.STRIPE_SECRET_KEY), owner: me === ctx.appInfo.ownerHandle, graceDays: GRACE_DAYS });
349
+ }
350
+
351
+ if (!me) return json({ ok: false, reason: "sign-in" }, 401);
352
+ const id = membershipId(me);
353
+ const current = await ctx.db.get(id, { db: DB }); // null before the first attempt
354
+
355
+ if (url.pathname === "/subscribe" && request.method === "POST") {
356
+ if (!ctx.secrets.STRIPE_SECRET_KEY) return json({ ok: false, reason: "missing-key" }, 503);
357
+ const { plan } = await request.json();
358
+ const price = PLANS[plan];
359
+ if (!price) return json({ ok: false, reason: "unknown-plan" }, 400);
360
+ // A subscription that is live at Stripe — entitled or not — is managed in the portal, never
361
+ // doubled: a past_due member whose grace ran out still has a subscription Stripe is retrying.
362
+ if (current?.subscriptionId && !TERMINAL.has(current.status)) return json({ ok: false, reason: "already-member" }, 409);
363
+ // A second press, a retry, another tab: the open attempt is reused, never repeated.
364
+ if (current?.status === "pending" && current.sessionUrl && Date.now() - current.sessionCreatedAt < SESSION_TTL_MS) {
365
+ return json({ ok: true, url: current.sessionUrl });
366
+ }
367
+
368
+ const attempt = (current?.attempt ?? 0) + 1;
369
+ const membership = {
370
+ _id: id,
371
+ type: "membership",
372
+ memberHandle: me,
373
+ plan,
374
+ amount: price.amount,
375
+ currency: price.currency,
376
+ status: "pending",
377
+ entitled: false,
378
+ attempt,
379
+ customerId: current?.customerId, // a returning member keeps their Stripe customer
380
+ sessionId: undefined,
381
+ sessionUrl: undefined,
382
+ subscriptionId: undefined,
383
+ createdAt: current?.createdAt ?? Date.now(),
384
+ sessionCreatedAt: Date.now(),
385
+ };
386
+ // The record is claimed against the revision we read: a concurrent press conflicts here and
387
+ // is handed the attempt that won instead of starting its own.
388
+ try {
389
+ await ctx.db.put(membership, { db: DB, id, ifRev: current?._rev });
390
+ } catch (err) {
391
+ if (err.code !== "conflict") throw err;
392
+ const won = await ctx.db.get(id, { db: DB });
393
+ return won?.sessionUrl ? json({ ok: true, url: won.sessionUrl }) : json({ ok: false, reason: "retry" }, 409);
394
+ }
395
+
396
+ const form = new URLSearchParams({
397
+ mode: "subscription",
398
+ "line_items[0][quantity]": "1",
399
+ "line_items[0][price_data][currency]": price.currency,
400
+ "line_items[0][price_data][unit_amount]": String(price.amount),
401
+ "line_items[0][price_data][recurring][interval]": price.interval,
402
+ "line_items[0][price_data][product_data][name]": price.title,
403
+ client_reference_id: id,
404
+ "metadata[attempt]": String(attempt),
405
+ success_url: appUrl(ctx, "#subscribed"),
406
+ cancel_url: appUrl(ctx, "#cancelled"),
407
+ });
408
+ // The idempotency key names the attempt, so two first presses that both got past the claim
409
+ // (a record that does not exist yet has no revision to claim) mint ONE session at Stripe.
410
+ const session = await stripe(ctx, "POST", "/checkout/sessions", form, { "idempotency-key": `${id}:${attempt}` });
411
+ if (!session.ok) return json(session, session.reason === "stripe" ? 502 : 503);
412
+ const claimed = await ctx.db.get(id, { db: DB });
413
+ await ctx.db.put({ ...claimed, sessionId: session.body.id, sessionUrl: session.body.url }, { db: DB, id, ifRev: claimed._rev });
414
+ return json({ ok: true, url: session.body.url });
415
+ }
416
+
417
+ // Back from Checkout: bind the subscription and mirror it.
418
+ if (url.pathname === "/confirm" && request.method === "POST") {
419
+ if (!current) return json({ ok: false, reason: "not-found" }, 404);
420
+ const settled = await settlePending(ctx, current);
421
+ return json({ ok: true, status: settled.status, entitled: settled.entitled === true });
422
+ }
423
+
424
+ // Back from the portal, or a "check again": re-read my subscription now.
425
+ if (url.pathname === "/refresh" && request.method === "POST") {
426
+ if (!current?.subscriptionId) return json({ ok: true, entitled: false });
427
+ const next = await refresh(ctx, current);
428
+ return json({ ok: true, status: next.status, entitled: next.entitled === true });
429
+ }
430
+
431
+ // Cancel, change a card, see invoices: Stripe's own portal, for this member's customer.
432
+ if (url.pathname === "/portal" && request.method === "POST") {
433
+ if (!current?.customerId) return json({ ok: false, reason: "no-membership" }, 404);
434
+ const form = new URLSearchParams({ customer: current.customerId, return_url: appUrl(ctx, "#portal") });
435
+ const portal = await stripe(ctx, "POST", "/billing_portal/sessions", form);
436
+ if (!portal.ok) return json(portal, portal.reason === "stripe" ? 502 : 503);
437
+ return json({ ok: true, url: portal.body.url });
438
+ }
439
+
440
+ return json({ ok: false, reason: "not-found" }, 404);
441
+ }
442
+
443
+ // The sweep: every live subscription re-read, entitlement recomputed, pending attempts settled.
444
+ export async function scheduled(event, ctx) {
445
+ if (!ctx.secrets.STRIPE_SECRET_KEY) return;
446
+ let after;
447
+ let changed = 0;
448
+ do {
449
+ const page = await ctx.db.query({ db: DB, field: "type", key: "membership", limit: 100, after });
450
+ for (const m of page) {
451
+ if (TERMINAL.has(m.status) || m.status === "expired") continue;
452
+ const next = m.status === "pending" ? await settlePending(ctx, m) : await refresh(ctx, m);
453
+ if (next !== m) changed += 1;
454
+ }
455
+ after = page.next;
456
+ } while (after);
457
+ ctx.log("info", "stripe subscription sweep", { changed, at: event.scheduledTime });
458
+ }
459
+ ```
460
+
461
+ ## access.js — the grant follows the record
462
+
463
+ ### `access.js`
464
+ ```js app=stripe-membership
465
+ // One function for the one database: the membership record that carries the grant, and the posts
466
+ // the grant unlocks. Grants are reduced per database, so these two types have to share one.
467
+ export function club(doc, oldDoc, user, ctx) {
468
+ if (!user?.userHandle) throw { forbidden: "sign in" };
469
+ if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
470
+
471
+ // One membership record per member, named for them, written only by backend.js.
472
+ if (doc.type === "membership") {
473
+ if (doc._id !== `membership-${doc.memberHandle}`) throw { forbidden: "a membership is named for its member" };
474
+ if (oldDoc) {
475
+ // Only backend.js changes an existing membership — claiming an attempt, binding the session,
476
+ // mirroring Stripe, recomputing entitlement — so neither status nor entitled is a member's to edit.
477
+ ctx.requireBackend();
478
+ if (doc.memberHandle !== oldDoc.memberHandle) throw { forbidden: "a membership keeps its member" };
479
+ } else {
480
+ if (doc.memberHandle !== user.userHandle) throw { forbidden: "a membership belongs to its member" };
481
+ if (doc.status !== "pending" || doc.entitled) throw { forbidden: "a membership starts pending" };
482
+ ctx.requireBackend("fetch"); // the /subscribe route writes it, so the amount is the server's
483
+ }
484
+ const mine = `user:${doc.memberHandle}`;
485
+ // The grant IS the entitlement: while the backend says entitled, the member holds `members`;
486
+ // the rewrite that says otherwise takes it away on the next sync.
487
+ const granted = doc.entitled === true ? [mine, "members"] : [mine];
488
+ return { channels: [mine, "memberships"], grant: { users: { [doc.memberHandle]: granted }, roles: { owner: ["memberships", "members"] } } };
489
+ }
490
+
491
+ // A post: the owner writes it; members read it on the channel their membership record grants.
492
+ if (doc.type === "post") {
493
+ if (oldDoc && doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "a post keeps its author" };
494
+ ctx.requireRole("owner");
495
+ return { channels: [doc.membersOnly ? "members" : "public"], grant: { public: ["public"] } };
496
+ }
497
+
498
+ throw { forbidden: "unknown document type" };
499
+ }
500
+ ```
501
+
502
+ ## App.jsx sketch
503
+
504
+ ### `App.jsx`
505
+ ```jsx app=stripe-membership
506
+ import React, { useEffect, useState } from "react";
507
+ import { useFireproof } from "use-fireproof";
508
+ import { apiFetch, useViewer } from "use-vibes";
509
+ import { Badge, Button, Heading, Stack, Text } from "@vibes.diy/pouf";
510
+
511
+ const SETUP_STEPS = [
512
+ "Open the Stripe dashboard, turn on Test mode, and reveal the Secret key under Developers → API keys.",
513
+ "Set it on this vibe as STRIPE_SECRET_KEY: the Settings page, or `vibes-diy secrets set STRIPE_SECRET_KEY`.",
514
+ "Under Settings → Billing → Customer portal, save the portal with Cancel at end of billing period on.",
515
+ ];
516
+
517
+ export default function App() {
518
+ const { viewer, requestLogin } = useViewer();
519
+ const { useLiveQuery } = useFireproof("club"); // memberships AND posts: one database, one grant set
520
+ const { docs: memberships } = useLiveQuery("type", { key: "membership" });
521
+ const { docs: posts } = useLiveQuery("type", { key: "post" });
522
+ const [status, setStatus] = useState(null); // { configured, owner, graceDays } from the backend
523
+ const [busy, setBusy] = useState(false);
524
+ const [error, setError] = useState(null);
525
+
526
+ useEffect(() => {
527
+ apiFetch("/status").then((r) => r.json()).then(setStatus).catch(() => setStatus({ configured: false, owner: false }));
528
+ }, []);
529
+
530
+ // Back from Stripe: the fragment arrives just after first render and on hashchange, so read both.
531
+ useEffect(() => {
532
+ const settle = () => {
533
+ const subscribed = /#subscribed\b/.test(location.hash);
534
+ const portal = /#portal\b/.test(location.hash);
535
+ if (!subscribed && !portal) return;
536
+ history.replaceState(null, "", location.pathname);
537
+ apiFetch(subscribed ? "/confirm" : "/refresh", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
538
+ };
539
+ settle();
540
+ window.addEventListener("hashchange", settle);
541
+ return () => window.removeEventListener("hashchange", settle);
542
+ }, []);
543
+
544
+ // Open a Stripe page in a new tab: the tab first, inside the click, then the backend for its URL.
545
+ const openStripe = async (path, body) => {
546
+ setError(null);
547
+ const tab = window.open("", "_blank");
548
+ if (!tab) {
549
+ setError("Your browser blocked the Stripe tab. Allow pop-ups for this page and try again.");
550
+ return;
551
+ }
552
+ setBusy(true);
553
+ try {
554
+ const res = await apiFetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
555
+ const data = await res.json();
556
+ if (!data.ok) {
557
+ tab.close();
558
+ setError(data.reason === "stripe" ? data.message : data.reason === "already-member" ? "You already have a membership — manage it below." : "Memberships are not available right now.");
559
+ return;
560
+ }
561
+ tab.location.href = data.url;
562
+ } finally {
563
+ setBusy(false);
564
+ }
565
+ };
566
+
567
+ const configured = status?.configured === true;
568
+ if (status && !configured && status.owner) {
569
+ return (
570
+ <Stack>
571
+ <Heading>Set up memberships</Heading>
572
+ <Text>Three steps in Stripe, once, and the Join buttons go live for everyone.</Text>
573
+ <ol>{SETUP_STEPS.map((s) => <li key={s}>{s}</li>)}</ol>
574
+ </Stack>
575
+ );
576
+ }
577
+
578
+ const mine = viewer ? memberships.find((m) => m._id === `membership-${viewer.userHandle}`) : undefined;
579
+ const entitled = mine?.entitled === true;
580
+ const ends = mine?.currentPeriodEnd ? new Date(mine.currentPeriodEnd).toLocaleDateString() : null;
581
+
582
+ return (
583
+ <Stack>
584
+ <Heading>The club</Heading>
585
+ {!viewer ? (
586
+ <Stack>
587
+ <Text>Members read every post. Join for $5 a month, cancel any time.</Text>
588
+ <Button onClick={() => requestLogin()}>Sign in to join</Button>
589
+ </Stack>
590
+ ) : entitled ? (
591
+ <Badge tone="up">{mine.cancelAtPeriodEnd ? `Member until ${ends}` : mine.status === "past_due" ? "Payment due — please update your card" : "Member"}</Badge>
592
+ ) : (
593
+ <Stack>
594
+ <Text>Members read every post. Join for $5 a month, cancel any time.</Text>
595
+ {mine?.subscriptionId && mine.status !== "canceled" ? (
596
+ <Text>Your last payment did not go through. Update your card below to keep reading.</Text>
597
+ ) : (
598
+ <Button onClick={() => openStripe("/subscribe", { plan: "monthly" })} disabled={!configured || busy} loading={busy}>
599
+ Join — Stripe opens in a new tab
600
+ </Button>
601
+ )}
602
+ </Stack>
603
+ )}
604
+ {/* The portal is reachable whenever Stripe knows this member, entitled or not: a lapsed
605
+ member needs it most, to update the card or to cancel. */}
606
+ {mine?.customerId && (
607
+ <Button variant="quiet" onClick={() => openStripe("/portal", {})} disabled={busy} loading={busy}>
608
+ Manage membership — Stripe opens in a new tab
609
+ </Button>
610
+ )}
611
+ {error && <Text>{error}</Text>}
612
+ <ul>
613
+ {posts.map((p) => (
614
+ <li key={p._id}>{p.membersOnly && <Badge tone="info">Members</Badge>} {p.title}</li>
615
+ ))}
616
+ </ul>
617
+ {status?.owner && (
618
+ <ul>
619
+ {memberships.map((m) => (
620
+ <li key={m._id}>@{m.memberHandle} · {m.plan} · {m.status}{m.entitled ? " · entitled" : ""}{m.currentPeriodEnd ? ` · until ${new Date(m.currentPeriodEnd).toLocaleDateString()}` : ""}</li>
621
+ ))}
622
+ </ul>
623
+ )}
624
+ </Stack>
625
+ );
626
+ }
627
+ ```
628
+
629
+ The posts list shows only what the viewer can read: a member sees the
630
+ members-only posts because the grant on their membership record admits them to the
631
+ `members` channel, and a lapsed member sees the public ones and the Join
632
+ button. The owner's roster at the bottom is the app's mirror of Stripe; the
633
+ two agree because the app only ever copies Stripe's answer.
@@ -0,0 +1,2 @@
1
+ import type { LlmConfig } from "./types.js";
2
+ export declare const stripeConfig: LlmConfig;
package/llms/stripe.js ADDED
@@ -0,0 +1,21 @@
1
+ export const stripeConfig = {
2
+ name: "stripe",
3
+ label: "Stripe payments",
4
+ description: "Taking real money through Stripe Checkout, ONLY when the person asks to charge, sell, take a deposit, collect dues or sell tickets — one-time charges on the owner's own STRIPE_SECRET_KEY (a monthly or yearly subscription is the stripe-subscriptions skill): the setup steps the app shows when the key is missing, the key kept in backend.js with no Stripe.js in the page, one pending order then one Checkout Session opened in a new tab, an order marked paid only after the backend reads the session back from Stripe and matches it to the order, integer amounts in cents, and a scheduled sweep that settles orders whose buyer never came back. A storefront, plans page or booking page that only records orders takes no payment and does not need this; offer it as the [pay] option instead",
5
+ cues: [
6
+ "stripe",
7
+ "take payments",
8
+ "take payment",
9
+ "accept payments",
10
+ "pay with card",
11
+ "card payment",
12
+ "credit card",
13
+ "checkout",
14
+ "deposit",
15
+ "dues",
16
+ "sell tickets",
17
+ "charge",
18
+ "paywall",
19
+ ],
20
+ };
21
+ //# sourceMappingURL=stripe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stripe.js","sourceRoot":"","sources":["../../jsr/llms/stripe.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,YAAY,GAAc;IACrC,IAAI,EAAE,QAAQ;IACd,KAAK,EAAE,iBAAiB;IACxB,WAAW,EACT,ixBAAixB;IACnxB,IAAI,EAAE;QACJ,QAAQ;QACR,eAAe;QACf,cAAc;QACd,iBAAiB;QACjB,eAAe;QACf,cAAc;QACd,aAAa;QACb,UAAU;QACV,SAAS;QACT,MAAM;QACN,cAAc;QACd,QAAQ;QACR,SAAS;KACV;CACF,CAAC"}