@vibes.diy/prompts 14.3.181 → 14.3.186

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.
@@ -2,8 +2,8 @@
2
2
 
3
3
  When someone asks for **recurring money** — a paid newsletter, a members-only
4
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
5
+ Checkout in subscription mode** from `backend.js`, through `ctx.stripe` on the
6
+ Stripe account the owner connects with **Connect Stripe**, and keep the member's access a mirror of what Stripe says about their
7
7
  subscription. This is a `backend.js` feature (see the backend docs for the
8
8
  general handler contract) and a sibling of the `stripe` skill, which takes a
9
9
  one-time payment; this page is the recipe for money that keeps moving, where the
@@ -19,76 +19,115 @@ ask: "subscription", "subscribe", "monthly", "per month", "yearly", "membership
19
19
  fee", "paid tier", "paywall", "recurring". A one-time charge — dues once a year,
20
20
  a ticket, a deposit — is the `stripe` skill, not this one.
21
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
22
+ **One merchant, many members.** Every member pays the Stripe account the owner
23
+ connected. A member is a signed-in person whose membership is a record between
24
+ them and the owner, and the members-only content is a channel that record
25
+ grants. An app where each visitor sells their own subscriptions is a
26
+ marketplace, a bigger thing than this recipe — say so and build the
27
27
  one-merchant half.
28
28
 
29
- ## 1. The setup items: the owner's Stripe secret key, and the Customer portal
29
+ ## 1. The setup items: Connect Stripe, and the Customer portal
30
30
 
31
31
  The owner sets up Stripe once. The Setup surface the two items land on — the
32
32
  owner-only entry in the app's navigation, the verdict from `/setup`, the
33
33
  re-read on focus that unlocks the Join buttons in place — is the use-vibe
34
34
  docs' *Some apps need setup before they work*; this recipe contributes two
35
- items to it. The Stripe items are the secret key and the Customer portal: the backend answers missing-key while ctx.secrets.STRIPE_SECRET_KEY is absent, reads whether a portal configuration has been saved, and its steps link Stripe's API keys page and Stripe's Customer portal settings by URL and name STRIPE_SECRET_KEY in so many words.
35
+ 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.
36
36
  The steps are written for someone who has never opened Stripe, so each one is
37
37
  a link to the exact page and uses the words that page uses:
38
38
 
39
- 1. Open Stripe's **API keys** page in test mode:
40
- <https://dashboard.stripe.com/test/apikeys> (sign in, or create a Stripe
41
- account when it asks). Under **Standard keys**, press **Reveal test key**
42
- beside **Secret key** and copy it. It starts with `sk_test_`.
43
- 2. Open this vibe's **Settings** tab on vibes.diy:
39
+ 1. Open this vibe's **Settings** tab on vibes.diy:
44
40
  `https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings` (that is
45
- **My Vibes** → this vibe → **Settings**). Under **Secrets**, enter the key
46
- `STRIPE_SECRET_KEY` and paste the value, then save. From a terminal the
47
- same thing is `npx vibes-diy secrets set STRIPE_SECRET_KEY --vibe <ownerHandle>/<appSlug>`.
48
- 3. Open Stripe's **Customer portal** settings in test mode:
41
+ **My Vibes** → this vibe → **Settings**), and press **Connect Stripe** under
42
+ **Payments**. Stripe opens its own setup pages: sign in with a Stripe login
43
+ or create one there, and answer Stripe's questions about the business. The
44
+ Stripe account belongs to the owner's Vibes DIY account and serves every
45
+ app they make, so it is connected once.
46
+ 2. Open Stripe's **Customer portal** settings in test mode, signed in to the
47
+ Stripe account you just connected:
49
48
  <https://dashboard.stripe.com/test/settings/billing/portal>. Under
50
49
  **Cancellations**, turn on **Cancel subscriptions** and choose **At end of
51
50
  billing period**, then press **Save**. The app sends members there to cancel
52
- or change a card, and the portal does not exist until it is saved once.
53
- 4. Go live. When a test payment goes through, switch to your live key to take
54
- real payments: open Stripe's **API keys** page in live mode,
55
- <https://dashboard.stripe.com/apikeys>, press **Reveal live key** beside
56
- **Secret key** (it starts with `sk_live_`), set `STRIPE_SECRET_KEY` to it
57
- the same way, and save the **Customer portal** once more in live mode at
51
+ or change a card, and the portal exists once it is saved.
52
+ 3. Come back to the app. The Join buttons work in test mode the moment both
53
+ items are done; the card `4242 4242 4242 4242` with any future date pays.
54
+ 4. Go live, one app at a time. Once a test payment goes through and live
55
+ payments are open on vibes.diy, press **Go live with Stripe** under
56
+ **Payments** on the Settings tab and finish Stripe's live setup (once, for
57
+ every app). Then press **Take live payments in this app** on this app's
58
+ Setup screen, and save the **Customer portal** once more in live mode at
58
59
  <https://dashboard.stripe.com/settings/billing/portal> — test-mode settings
59
- stay in test mode. This step stays on the Setup screen after both items
60
- are done, with a badge that reads *Test mode* until the live key is in.
60
+ stay in test mode, and the Setup screen links the portal page for the mode
61
+ the app is in. This step stays on the Setup screen after both items are
62
+ done, with a badge that reads *Test mode* until this app takes live
63
+ payments.
61
64
 
62
65
  Those steps are the items' copy on the Setup surface, with the vibe's real
63
66
  handle and slug filled in from the `ownerHandle` and `appSlug` the `/setup`
64
- route hands over. `https://dashboard.stripe.com/test/apikeys` for the key,
65
- `https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings` for where it
66
- goes, `https://dashboard.stripe.com/test/settings/billing/portal` for the
67
- portal, and `STRIPE_SECRET_KEY` in so many words: "Open the Stripe dashboard"
68
- and "the Settings page" are directions to search for, and a person who has
69
- never seen either has to go looking. The portal item is checked with one read
67
+ route hands over: `https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings`
68
+ for **Connect Stripe**, and `https://dashboard.stripe.com/test/settings/billing/portal`
69
+ for the portal. "Open the Stripe dashboard" and "the Settings page" are
70
+ directions to search for, and a person who has never seen either has to go
71
+ looking. The portal item is checked with one read
70
72
  of `GET /v1/billing_portal/configurations?is_default=true&active=true` — the
71
73
  active default is the configuration `/portal` opens, and a portal that was
72
- never saved has none — made only on the **owner's** `/setup` call and kept in
73
- a `setup` document the backend writes, so the owner sees the item turn to
74
- *Done* the moment they press Save and a visitor's read costs Stripe nothing. A
75
- refused read (a wrong key, a platform refusal, Stripe down) keeps the last
76
- verdict and rides the item as `problem`, which the Setup screen shows the
77
- owner in Stripe's own words; only a successful empty answer means not saved.
78
- A portal that was skipped answers a Stripe error whose message says so (§ 8).
79
- In the consumer flow, a Join button whose items are not done reads
80
- *Memberships are not open yet*; the owner's Setup entry is where the steps
81
- are, and later it is where the owner swaps the test key for the live one.
82
-
83
- ## 2. The key never reaches the browser, and neither does Stripe.js
84
-
85
- 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.
86
-
87
- The client talks to the app's own `/_api/...` routes, and the backend is the
88
- only place that knows the key exists. Stripe Checkout and the Customer Portal
89
- are pages Stripe hosts: the backend mints a URL, the page opens it, Stripe does
90
- the card, the retries, the invoices and the cancellation screen. Nothing about
91
- a card ever touches the app.
74
+ never saved has none — made only on the **owner's** `/setup` call, in the mode
75
+ the app charges in, and kept in a `setup` document per mode the backend
76
+ writes, so the owner sees the item turn to *Done* the moment they press Save
77
+ and a visitor's read costs Stripe nothing. A refused read (an account Stripe
78
+ is still reviewing, a platform answer, Stripe down) keeps the last verdict and
79
+ rides the item as `problem`, which the Setup screen shows the owner in its own
80
+ words; only a successful empty answer means not saved. A portal that was
81
+ skipped answers a Stripe error whose message says so (§ 8). In the consumer
82
+ flow, a Join button whose items are not done reads *Memberships are not open
83
+ yet*; the owner's Setup entry is where the steps are, and later it is where
84
+ test turns into live.
85
+
86
+ The owner pastes nothing: the platform holds the credential and attaches it to
87
+ every call. An app made before Connect Stripe, whose `backend.js` already
88
+ reads `ctx.secrets.STRIPE_SECRET_KEY`, keeps that helper as it is: an edit to
89
+ it changes what was asked and leaves its memberships where they are (a
90
+ subscription lives on the account that created it), and it moves to
91
+ `ctx.stripe` only when the owner asks to switch to Connect Stripe. Every new
92
+ membership app is built on `ctx.stripe`.
93
+
94
+ ### Test mode first, live when it opens
95
+
96
+ `ctx.stripe.status()` answers `{ test: { available, state }, live: { available,
97
+ state } }`: `available` is whether vibes.diy takes payments in that mode on
98
+ this deployment, and `state` is the owner's account in it (`absent`,
99
+ `onboarding`, `ready`, `disconnected`). The Stripe account serves every app
100
+ the owner makes, so going live is the owner's choice per app:
101
+
102
+ 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.
103
+
104
+ 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.
105
+
106
+ The choice is a `setup:live` document the backend writes when the owner
107
+ presses **Take live payments in this app**, offered once `live.state` reads
108
+ `ready`; the mode comes from that document alone. `/setup` hands the `mode` to
109
+ everyone and the Join request carries the mode the member was shown, so a
110
+ page that still shows the other mode refreshes before any Checkout opens.
111
+ While the mode is test, the Join button sits beside a *Test mode* badge, the
112
+ owner sees the test card to rehearse with, and members read that test
113
+ memberships end when the app goes live. The owner's Go-live step reads
114
+ `live.available` (handed over as `liveOpen`) and `live.state` (as
115
+ `liveReady`) to name the next button. The `stripe` skill's *Test mode first*
116
+ section is the same rule in more words.
117
+
118
+ ## 2. Stripe stays on the server, and so does Stripe.js
119
+
120
+ 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.
121
+
122
+ The client talks to the app's own `/_api/...` routes, and the backend holds no
123
+ Stripe credential at all: `ctx.stripe.fetch` sends the call to the platform,
124
+ which attaches the key and the owner's account and hands back Stripe's own
125
+ `Response`. The calls this recipe makes — a subscription-mode Checkout
126
+ Session, reading it and the subscription back, the portal-configuration read
127
+ and the portal session — are the calls `ctx.stripe` carries. Stripe Checkout
128
+ and the Customer Portal are pages Stripe hosts: the backend mints a URL, the
129
+ page opens it, Stripe does the card, the retries, the invoices and the
130
+ cancellation screen. Nothing about a card ever touches the app.
92
131
 
93
132
  Checkout opens in a new tab with window.open, because the app runs inside a frame that cannot navigate the page around it.
94
133
 
@@ -100,12 +139,25 @@ Stripe opens in a new tab.
100
139
  ## 3. One membership, then one Checkout Session
101
140
 
102
141
  The membership exists before the money moves, and there is **one record per
103
- member**, `membership-<handle>`, rewritten in place by every attempt, renewal
104
- and lapse. `/subscribe` runs as the signed-in member (the page calls it with
105
- `apiFetch`), claims that record in `pending` with the plan — against the
142
+ member per mode**, `membership-<mode>-<handle>`, rewritten in place by every
143
+ attempt, renewal and lapse in that mode. `/subscribe` runs as the signed-in member (the page calls it with
144
+ `apiFetch`), claims that record in `pending` with the plan and the mode — against the
106
145
  revision it just read, so a concurrent press conflicts and is handed the
107
146
  attempt that won — and asks Stripe for a session for exactly that record:
108
147
 
148
+ Every membership records the mode it was made in, and every ctx.stripe.fetch call for that membership passes the membership's own mode.
149
+
150
+ - The membership's `mode` is the app's current mode — test until the owner
151
+ takes this app live, live after — and the session, the subscription
152
+ read-back, the sweep and the portal all use the record's own `mode`. A test
153
+ membership is read from the test account for its whole life, and the
154
+ record for each mode is its own, so a test session still open when the app
155
+ goes live settles or expires on its own record. Only a record in the app's
156
+ current mode is entitled: after going live, the sweep writes every test
157
+ membership `entitled: false`, and the member joins fresh in live. (This
158
+ `mode` is the option `ctx.stripe.fetch` takes; the form's own
159
+ `mode=subscription` is Stripe's Checkout field.)
160
+ - 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.
109
161
  - `mode=subscription`, with an inline recurring price:
110
162
  `line_items[0][price_data][recurring][interval]=month` (or `year`),
111
163
  `unit_amount` in the smallest unit, a product name. The owner never creates
@@ -132,7 +184,7 @@ portal (§ 7) to fix the card or cancel.
132
184
 
133
185
  The routes read the `club` database, which `access.js` binds, so `backend.js`
134
186
  declares `config.fetch.unfilteredReads` for it — and owes the obligation that
135
- comes with it: every route reads exactly one record, `membership-<caller>`,
187
+ comes with it: every route reads exactly one record, `membership-<mode>-<caller>`,
136
188
  and no route lists memberships for anyone. The owner's roster is the page's
137
189
  own live query, scoped by the access function's `memberships` channel to the
138
190
  owner role.
@@ -157,7 +209,7 @@ fails. Nothing else about entitlement is configurable, on purpose:
157
209
  `current_period_start` is the moment the renewal was due, which is the
158
210
  moment the grace clock should start.
159
211
  - `canceled`, `unpaid`, `paused`, `incomplete` and `incomplete_expired` are
160
- locked at once. With the portal set to cancel at period end (§ 1 step 3), a
212
+ locked at once. With the portal set to cancel at period end (§ 1 step 2), a
161
213
  cancellation never cuts into paid time, because Stripe keeps the status
162
214
  `active` until the period closes.
163
215
 
@@ -193,7 +245,8 @@ the same rule whatever the page shows.
193
245
 
194
246
  ## 6. Two things keep the mirror true: the return, and the sweep
195
247
 
196
- Both are the backend reading Stripe with the key.
248
+ Both are the backend reading Stripe through `ctx.stripe`, in the membership's
249
+ own mode.
197
250
 
198
251
  - **The return.** When the app opens with `#subscribed=<id>` in its address,
199
252
  the page calls `/confirm`; the route reads the Checkout Session back, takes
@@ -214,7 +267,7 @@ A scheduled sweep refreshes every live subscription from Stripe, so a failed ren
214
267
 
215
268
  Why not a webhook: a webhook write arrives with no identity and would need
216
269
  `allowAnonymous` on the one record that grants access. Reading the
217
- subscription back with the key the app already holds is the same proof from
270
+ subscription back through `ctx.stripe` is the same proof from
218
271
  the same source, on a route that can only write what Stripe reports.
219
272
 
220
273
  ## 7. Cancel, change a card, see invoices: the Customer Portal
@@ -236,13 +289,16 @@ with a sign-in error.
236
289
 
237
290
  ## 8. Errors keep their reason
238
291
 
239
- Stripe's refusals come back as JSON with `error.message`; return it as
240
- `{ ok: false, reason: "stripe", message }` so the page can show the owner
241
- *why*. The portal's "No configuration provided" message is the one that means
242
- step 3 of setup was skipped. A refused `ctx.fetch` — the
243
- `{ vibesEgressDenied: true }` shape — is a platform refusal; log it and answer
244
- `{ ok: false, reason: "unavailable" }`. `api.stripe.com` is CORS-open to the
245
- app's origin, so nothing here needs the curated list.
292
+ Stripe's own answers arrive as a `Response` whose JSON carries
293
+ `error.message`; return it as `{ ok: false, reason: "stripe", message }` so
294
+ the page can show *why*. The portal's "No configuration provided" message is
295
+ the one that means step 2 of setup is still to do. A platform answer is thrown
296
+ as an `Error` with `e.reason`, and each reason names the owner's next step:
297
+ `not_connected` and `not_ready` are the Connect Stripe item,
298
+ `account_disconnected` is **Connect Stripe** again from the same Settings tab,
299
+ and `not_available` means payments in that mode are paused on vibes.diy for
300
+ now. Log it with `ctx.log` and hand the reason to the page, which reads it as
301
+ *Memberships are not open yet*.
246
302
 
247
303
  ## Complete backend.js
248
304
 
@@ -255,11 +311,10 @@ app's origin, so nothing here needs the curated list.
255
311
  export const config = {
256
312
  scheduled: { interval: "30m" },
257
313
  // The routes read the member's own record; access.js binds the database, so the fetch lane needs
258
- // this declaration, and every route owes the check it waives: it reads membership-<caller> only.
259
- 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" } },
314
+ // this declaration, and every route owes the check it waives: it reads membership-<mode>-<caller> only.
315
+ 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" } },
260
316
  };
261
317
 
262
- const STRIPE = "https://api.stripe.com/v1";
263
318
  const DB = "club";
264
319
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
265
320
  // The one knob: how long a past_due member stays in while Stripe retries the card. Zero locks
@@ -280,43 +335,61 @@ function appUrl(ctx, hash) {
280
335
  return `https://vibes.diy/vibe/${ctx.appInfo.ownerHandle}/${ctx.appInfo.appSlug}${hash}`;
281
336
  }
282
337
 
283
- // One record per member, named for them: every attempt, renewal and lapse rewrites it in place.
284
- function membershipId(handle) {
285
- return `membership-${handle}`;
338
+ // One record per member per mode, named for them: every attempt, renewal and lapse in that mode
339
+ // rewrites it in place, and a test record never shares a document with a live one.
340
+ function membershipId(handle, mode) {
341
+ return `membership-${mode}-${handle}`;
286
342
  }
287
343
 
288
- async function stripe(ctx, method, path, form, headers = {}) {
289
- const key = ctx.secrets.STRIPE_SECRET_KEY;
290
- if (!key) return { ok: false, reason: "missing-key" };
291
- const res = await ctx.fetch(`${STRIPE}${path}`, {
292
- method,
293
- headers: { authorization: `Bearer ${key}`, ...(form ? { "content-type": "application/x-www-form-urlencoded" } : {}), ...headers },
294
- body: form ? form.toString() : undefined,
295
- });
296
- const body = await res.json().catch(() => null);
297
- if (!res.ok) {
298
- if (body?.vibesEgressDenied) {
299
- ctx.log("warn", "stripe egress denied", { gate: body.gate });
300
- return { ok: false, reason: "unavailable" };
301
- }
302
- return { ok: false, reason: "stripe", message: body?.error?.message ?? `HTTP ${res.status}` };
344
+ // The owner's choice to take THIS app live. The Stripe account is the owner's and serves all of
345
+ // their apps, so its live account turning ready never moves an app by itself.
346
+ const LIVE_CHOICE = "setup:live";
347
+
348
+ // Which of the owner's two Stripe accounts this app charges in, decided by the owner's choice
349
+ // alone; then whether that account takes card payments right now. A live app whose live account
350
+ // is not ready stays live with checkout closed until the owner reconnects.
351
+ async function checkoutMode(ctx) {
352
+ const mode = (await ctx.db.get(LIVE_CHOICE, { db: DB })) ? "live" : "test";
353
+ let st;
354
+ try {
355
+ st = await ctx.stripe.status();
356
+ } catch (e) {
357
+ ctx.log("warn", "stripe status unavailable", { message: e.message });
358
+ return { mode, open: false, state: "unavailable", liveOpen: false, liveReady: false };
303
359
  }
360
+ return { mode, open: st[mode].state === "ready", state: st[mode].state, liveOpen: st.live.available, liveReady: st.live.state === "ready" };
361
+ }
362
+
363
+ // One call to Stripe on the owner's account, in the membership's own mode. Form-encoded in,
364
+ // Stripe's JSON out, the refusal's own sentence kept.
365
+ async function stripe(ctx, mode, method, path, form, headers = {}) {
366
+ let res;
367
+ try {
368
+ res = await ctx.stripe.fetch(`/v1${path}`, { method, body: form, headers, mode });
369
+ } catch (e) {
370
+ // The platform's answer, not Stripe's: a Setup step for the owner, or payments paused.
371
+ ctx.log("warn", "stripe call refused", { reason: e.reason });
372
+ return { ok: false, reason: e.reason ?? "not_available" };
373
+ }
374
+ const body = await res.json().catch(() => null);
375
+ if (!res.ok) return { ok: false, reason: "stripe", message: body?.error?.message ?? `HTTP ${res.status}` };
304
376
  return { ok: true, body };
305
377
  }
306
378
 
307
- // Has the owner saved the Customer portal? Stripe is asked only on the OWNER's call, and the answer
308
- // is kept in a document the backend writes, so a visitor's /setup read never reaches Stripe and a
309
- // blip at Stripe keeps the last verdict. The /portal route opens the ACTIVE DEFAULT configuration,
310
- // so that is the one asked for; a refused read is a problem shown to the owner, never "not saved".
311
- const PORTAL_STATE = "setup:stripe-portal";
312
- async function portalSaved(ctx, owner) {
313
- const kept = await ctx.db.get(PORTAL_STATE, { db: DB });
379
+ // Has the owner saved the Customer portal in this mode? Stripe is asked only on the OWNER's call,
380
+ // and the answer is kept in a document per mode the backend writes, so a visitor's /setup read never
381
+ // reaches Stripe and a blip at Stripe keeps the last verdict. The /portal route opens the ACTIVE
382
+ // DEFAULT configuration, so that is the one asked for; a refused read is a problem shown to the
383
+ // owner, never "not saved". Test-mode portal settings stay in test mode, so live asks again.
384
+ const portalState = (mode) => `setup:stripe-portal:${mode}`;
385
+ async function portalSaved(ctx, owner, mode, connected) {
386
+ const kept = await ctx.db.get(portalState(mode), { db: DB });
314
387
  const known = kept?.saved === true;
315
- if (!owner || !ctx.secrets.STRIPE_SECRET_KEY) return { done: known };
316
- const configurations = await stripe(ctx, "GET", "/billing_portal/configurations?is_default=true&active=true&limit=1");
388
+ if (!owner || !connected) return { done: known };
389
+ const configurations = await stripe(ctx, mode, "GET", "/billing_portal/configurations?is_default=true&active=true&limit=1");
317
390
  if (!configurations.ok) return { done: known, problem: configurations.message ?? "Stripe could not be reached; the last verdict stands." };
318
391
  const saved = (configurations.body.data?.length ?? 0) > 0;
319
- if (known !== saved) await ctx.db.put({ _id: PORTAL_STATE, type: "setup", saved, checkedAt: Date.now() }, { db: DB, id: PORTAL_STATE, ifRev: kept?._rev });
392
+ 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 });
320
393
  return { done: saved };
321
394
  }
322
395
 
@@ -339,13 +412,14 @@ function isEntitled(m, now = Date.now()) {
339
412
  }
340
413
 
341
414
  // Re-read the member's subscription and write the record back when anything the app keeps
342
- // changed — including entitlement lapsing with nothing new at Stripe.
343
- async function refresh(ctx, m) {
415
+ // changed — including entitlement lapsing with nothing new at Stripe, and a record whose mode the
416
+ // app has moved past (`appMode`), which grants nothing however Stripe sees it.
417
+ async function refresh(ctx, m, appMode) {
344
418
  if (!m.subscriptionId) return m;
345
- const sub = await stripe(ctx, "GET", `/subscriptions/${m.subscriptionId}`);
419
+ const sub = await stripe(ctx, m.mode, "GET", `/subscriptions/${m.subscriptionId}`);
346
420
  if (!sub.ok) return m; // a refusal changes nothing; the next read tries again
347
421
  const next = { ...m, ...mirror(sub.body), syncedAt: Date.now() };
348
- next.entitled = isEntitled(next);
422
+ next.entitled = isEntitled(next) && m.mode === appMode;
349
423
  const changed = ["status", "currentPeriodStart", "currentPeriodEnd", "cancelAtPeriodEnd", "entitled"].some((k) => next[k] !== m[k]);
350
424
  if (!changed) return m;
351
425
  await ctx.db.put(next, { db: DB, id: m._id, ifRev: m._rev });
@@ -354,7 +428,7 @@ async function refresh(ctx, m) {
354
428
 
355
429
  // A record still waiting on its Checkout Session: bind the subscription Stripe created, or expire
356
430
  // the attempt after a day.
357
- async function settlePending(ctx, m) {
431
+ async function settlePending(ctx, m, appMode) {
358
432
  if (m.status !== "pending") return m;
359
433
  if (!m.sessionId) {
360
434
  if (Date.now() - m.sessionCreatedAt > SESSION_TTL_MS) {
@@ -364,7 +438,7 @@ async function settlePending(ctx, m) {
364
438
  }
365
439
  return m;
366
440
  }
367
- const session = await stripe(ctx, "GET", `/checkout/sessions/${m.sessionId}`);
441
+ const session = await stripe(ctx, m.mode, "GET", `/checkout/sessions/${m.sessionId}`);
368
442
  if (!session.ok) return m;
369
443
  const s = session.body;
370
444
  if (s.id !== m.sessionId || s.client_reference_id !== m._id) {
@@ -379,7 +453,7 @@ async function settlePending(ctx, m) {
379
453
  if (!s.subscription) return m; // not completed yet
380
454
  const bound = { ...m, subscriptionId: s.subscription, customerId: s.customer };
381
455
  await ctx.db.put(bound, { db: DB, id: m._id, ifRev: m._rev });
382
- return refresh(ctx, await ctx.db.get(m._id, { db: DB }));
456
+ return refresh(ctx, await ctx.db.get(m._id, { db: DB }), appMode);
383
457
  }
384
458
 
385
459
  export async function fetch(request, ctx) {
@@ -390,27 +464,43 @@ export async function fetch(request, ctx) {
390
464
  // links with) for the owner alone.
391
465
  if (url.pathname === "/setup" && request.method === "GET") {
392
466
  const owner = me === ctx.appInfo.ownerHandle;
393
- const items = [
394
- { id: "stripe-key", label: "Stripe secret key", done: Boolean(ctx.secrets.STRIPE_SECRET_KEY) },
395
- { id: "stripe-portal", label: "Customer portal", ...(await portalSaved(ctx, owner)) },
396
- ];
467
+ const { mode, open, state, liveOpen, liveReady } = await checkoutMode(ctx);
468
+ const connect = { id: "stripe-connect", label: "Connect Stripe", done: open, state };
469
+ if (mode === "live" && !liveOpen) connect.problem = "Vibes DIY has paused live payments for now; memberships reopen here by themselves when they return.";
470
+ const items = [connect, { id: "stripe-portal", label: "Customer portal", ...(await portalSaved(ctx, owner, mode, open)) }];
397
471
  const ready = items.every((item) => item.done);
398
- if (!owner) return json({ ok: true, ready, owner });
399
- // `live` is the Go-live step's badge: a test key takes test payments, a live key real ones.
400
- const live = (ctx.secrets.STRIPE_SECRET_KEY ?? "").startsWith("sk_live_");
401
- return json({ ok: true, ready, owner, live, items, graceDays: GRACE_DAYS, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
472
+ // `mode` is everyone's: the Join button wears a Test mode badge while the app takes test payments.
473
+ if (!owner) return json({ ok: true, ready, owner, mode });
474
+ // The Go-live step: `liveOpen` (vibes.diy takes live payments) and `liveReady` (the owner's live
475
+ // Stripe account is ready, so this app can be taken live).
476
+ return json({ ok: true, ready, owner, mode, liveOpen, liveReady, items, graceDays: GRACE_DAYS, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
477
+ }
478
+
479
+ // The owner takes this app live: one document, written for the owner alone, once their live
480
+ // Stripe account is ready. From here on the app charges in live only.
481
+ if (url.pathname === "/go-live" && request.method === "POST") {
482
+ if (me !== ctx.appInfo.ownerHandle) return json({ ok: false, reason: "owner-only" }, 403);
483
+ const { liveReady } = await checkoutMode(ctx);
484
+ if (!liveReady) return json({ ok: false, reason: "not_ready" }, 409);
485
+ const chosen = await ctx.db.get(LIVE_CHOICE, { db: DB });
486
+ if (!chosen) await ctx.db.put({ _id: LIVE_CHOICE, type: "setup", mode: "live", chosenAt: Date.now() }, { db: DB, id: LIVE_CHOICE });
487
+ return json({ ok: true, mode: "live" });
402
488
  }
403
489
 
404
490
  if (!me) return json({ ok: false, reason: "sign-in" }, 401);
405
- const id = membershipId(me);
406
- const current = await ctx.db.get(id, { db: DB }); // null before the first attempt
491
+ const { mode, open, state } = await checkoutMode(ctx);
492
+ const id = membershipId(me, mode); // the member's record in the mode the app charges in now
493
+ const current = await ctx.db.get(id, { db: DB }); // null before the first attempt in this mode
407
494
 
408
495
  if (url.pathname === "/subscribe" && request.method === "POST") {
409
- if (!ctx.secrets.STRIPE_SECRET_KEY) return json({ ok: false, reason: "missing-key" }, 503);
410
- // The portal is where a member cancels, so nobody joins before it exists: the KEPT verdict,
411
- // never a Stripe read per press.
412
- if ((await ctx.db.get(PORTAL_STATE, { db: DB }))?.saved !== true) return json({ ok: false, reason: "setup-incomplete" }, 503);
413
- const { plan } = await request.json();
496
+ const { plan, mode: shown } = await request.json();
497
+ // The member joins in the mode their page showed them, or not at all: a page that still shows
498
+ // the other mode refreshes first.
499
+ if (shown !== mode) return json({ ok: false, reason: "mode-changed", mode }, 409);
500
+ if (!open) return json({ ok: false, reason: state === "disconnected" ? "account_disconnected" : "not_ready" }, 503);
501
+ // The portal is where a member cancels, so nobody joins before it exists: the KEPT verdict for
502
+ // this mode, never a Stripe read per press.
503
+ if ((await ctx.db.get(portalState(mode), { db: DB }))?.saved !== true) return json({ ok: false, reason: "setup-incomplete" }, 503);
414
504
  const price = PLANS[plan];
415
505
  if (!price) return json({ ok: false, reason: "unknown-plan" }, 400);
416
506
  // A subscription that is live at Stripe — entitled or not — is managed in the portal, never
@@ -429,10 +519,11 @@ export async function fetch(request, ctx) {
429
519
  plan,
430
520
  amount: price.amount,
431
521
  currency: price.currency,
522
+ mode, // the account this membership is made in; every later call for it passes the same mode
432
523
  status: "pending",
433
524
  entitled: false,
434
525
  attempt,
435
- customerId: current?.customerId, // a returning member keeps their Stripe customer
526
+ customerId: current?.customerId, // a returning member keeps their Stripe customer in this mode
436
527
  sessionId: undefined,
437
528
  sessionUrl: undefined,
438
529
  subscriptionId: undefined,
@@ -463,7 +554,7 @@ export async function fetch(request, ctx) {
463
554
  });
464
555
  // The idempotency key names the attempt, so two first presses that both got past the claim
465
556
  // (a record that does not exist yet has no revision to claim) mint ONE session at Stripe.
466
- const session = await stripe(ctx, "POST", "/checkout/sessions", form, { "idempotency-key": `${id}:${attempt}` });
557
+ const session = await stripe(ctx, mode, "POST", "/checkout/sessions", form, { "idempotency-key": `${id}:${attempt}` });
467
558
  if (!session.ok) return json(session, session.reason === "stripe" ? 502 : 503);
468
559
  const claimed = await ctx.db.get(id, { db: DB });
469
560
  await ctx.db.put({ ...claimed, sessionId: session.body.id, sessionUrl: session.body.url }, { db: DB, id, ifRev: claimed._rev });
@@ -473,14 +564,14 @@ export async function fetch(request, ctx) {
473
564
  // Back from Checkout: bind the subscription and mirror it.
474
565
  if (url.pathname === "/confirm" && request.method === "POST") {
475
566
  if (!current) return json({ ok: false, reason: "not-found" }, 404);
476
- const settled = await settlePending(ctx, current);
567
+ const settled = await settlePending(ctx, current, mode);
477
568
  return json({ ok: true, status: settled.status, entitled: settled.entitled === true });
478
569
  }
479
570
 
480
571
  // Back from the portal, or a "check again": re-read my subscription now.
481
572
  if (url.pathname === "/refresh" && request.method === "POST") {
482
573
  if (!current?.subscriptionId) return json({ ok: true, entitled: false });
483
- const next = await refresh(ctx, current);
574
+ const next = await refresh(ctx, current, mode);
484
575
  return json({ ok: true, status: next.status, entitled: next.entitled === true });
485
576
  }
486
577
 
@@ -488,7 +579,7 @@ export async function fetch(request, ctx) {
488
579
  if (url.pathname === "/portal" && request.method === "POST") {
489
580
  if (!current?.customerId) return json({ ok: false, reason: "no-membership" }, 404);
490
581
  const form = new URLSearchParams({ customer: current.customerId, return_url: appUrl(ctx, "#portal") });
491
- const portal = await stripe(ctx, "POST", "/billing_portal/sessions", form);
582
+ const portal = await stripe(ctx, current.mode, "POST", "/billing_portal/sessions", form);
492
583
  if (!portal.ok) return json(portal, portal.reason === "stripe" ? 502 : 503);
493
584
  return json({ ok: true, url: portal.body.url });
494
585
  }
@@ -498,14 +589,14 @@ export async function fetch(request, ctx) {
498
589
 
499
590
  // The sweep: every live subscription re-read, entitlement recomputed, pending attempts settled.
500
591
  export async function scheduled(event, ctx) {
501
- if (!ctx.secrets.STRIPE_SECRET_KEY) return;
592
+ const { mode } = await checkoutMode(ctx); // records in any other mode are re-read and grant nothing
502
593
  let after;
503
594
  let changed = 0;
504
595
  do {
505
596
  const page = await ctx.db.query({ db: DB, field: "type", key: "membership", limit: 100, after });
506
597
  for (const m of page) {
507
598
  if (TERMINAL.has(m.status) || m.status === "expired") continue;
508
- const next = m.status === "pending" ? await settlePending(ctx, m) : await refresh(ctx, m);
599
+ const next = m.status === "pending" ? await settlePending(ctx, m, mode) : await refresh(ctx, m, mode);
509
600
  if (next !== m) changed += 1;
510
601
  }
511
602
  after = page.next;
@@ -526,12 +617,12 @@ export function club(doc, oldDoc, user, ctx) {
526
617
 
527
618
  // One membership record per member, named for them, written only by backend.js.
528
619
  if (doc.type === "membership") {
529
- if (doc._id !== `membership-${doc.memberHandle}`) throw { forbidden: "a membership is named for its member" };
620
+ if (doc._id !== `membership-${doc.mode}-${doc.memberHandle}`) throw { forbidden: "a membership is named for its mode and its member" };
530
621
  if (oldDoc) {
531
622
  // Only backend.js changes an existing membership — claiming an attempt, binding the session,
532
623
  // mirroring Stripe, recomputing entitlement — so neither status nor entitled is a member's to edit.
533
624
  ctx.requireBackend();
534
- if (doc.memberHandle !== oldDoc.memberHandle) throw { forbidden: "a membership keeps its member" };
625
+ if (doc.memberHandle !== oldDoc.memberHandle || doc.mode !== oldDoc.mode) throw { forbidden: "a membership keeps its member and its mode" };
535
626
  } else {
536
627
  if (doc.memberHandle !== user.userHandle) throw { forbidden: "a membership belongs to its member" };
537
628
  if (doc.status !== "pending" || doc.entitled) throw { forbidden: "a membership starts pending" };
@@ -572,10 +663,10 @@ import { apiFetch, useViewer } from "use-vibes";
572
663
  import { Badge, Button, Heading, Row, Stack, Text } from "@vibes.diy/pouf";
573
664
 
574
665
  // The setup verdict, read on mount and again whenever the app comes back into view: the owner
575
- // sets the key or saves the portal in another tab and returns, and the Join buttons unlock without
576
- // a reload.
666
+ // connects Stripe or saves the portal in another tab and returns, and the Join buttons unlock
667
+ // without a reload.
577
668
  function useSetup() {
578
- const [setup, setSetup] = useState(null); // { ready, owner, items?, graceDays?, ownerHandle?, appSlug? }
669
+ const [setup, setSetup] = useState(null); // { ready, owner, mode, liveOpen?, items?, graceDays?, ownerHandle?, appSlug? }
579
670
  const latest = useRef(0);
580
671
  useEffect(() => {
581
672
  // Mount, focus and visibility reads overlap; only the latest one may land, or a slow stale
@@ -588,39 +679,44 @@ function useSetup() {
588
679
  const onVisible = () => { if (document.visibilityState === "visible") load(); };
589
680
  document.addEventListener("visibilitychange", onVisible);
590
681
  window.addEventListener("focus", load);
591
- return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); };
682
+ window.addEventListener("setup-changed", load); // the Setup screen's own Take live button
683
+ return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); window.removeEventListener("setup-changed", load); };
592
684
  }, []);
593
685
  return setup;
594
686
  }
595
687
 
688
+ // The button the Settings tab shows for each state of the owner's Stripe account, in its own words.
689
+ const STRIPE_BUTTON = { absent: "Connect Stripe", onboarding: "Finish Stripe setup", disconnected: "Connect Stripe again" };
690
+
596
691
  // The Setup surface, the owner's alone: two items, every step a link to the exact page in that
597
692
  // page's own words, with this vibe's real handle and slug filled in.
693
+
598
694
  function SetupScreen({ setup }) {
599
695
  const vibe = `${setup.ownerHandle}/${setup.appSlug}`;
600
696
  const settingsUrl = `https://vibes.diy/vibes/mine/${vibe}/settings`;
697
+ const live = setup.mode === "live";
698
+ // The portal page for the mode this app charges in: test-mode settings stay in test mode.
699
+ const portalUrl = live ? "https://dashboard.stripe.com/settings/billing/portal" : "https://dashboard.stripe.com/test/settings/billing/portal";
700
+ const connect = setup.items.find((i) => i.id === "stripe-connect");
701
+ const takeLive = () => apiFetch("/go-live", { method: "POST" }).then(() => window.dispatchEvent(new Event("setup-changed")));
601
702
  return (
602
703
  <Stack>
603
704
  <Heading>Setup</Heading>
604
705
  {setup.items.map((item) => (
605
706
  <Row key={item.id} gap={2}>
606
707
  <Text>{item.label}</Text>
607
- <Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : "To do"}</Badge>
708
+ <Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : item.state === "onboarding" ? "Stripe is finishing up" : "To do"}</Badge>
608
709
  {item.problem && <Text muted>{item.problem}</Text>}
609
710
  </Row>
610
711
  ))}
611
712
  <ol>
612
713
  <li>
613
- Open Stripe's <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noreferrer">API keys page (test mode)</a>,
614
- use your Stripe account (the page offers to create one), and under <b>Standard keys</b> press <b>Reveal test key</b> beside <b>Secret key</b>.
615
- Copy it; it starts with <code>sk_test_</code>.
714
+ Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings)
715
+ and press <b>{STRIPE_BUTTON[connect.state] ?? "Connect Stripe"}</b> under <b>Payments</b>.
716
+ Stripe opens its own setup pages: use your Stripe account, or create one there. Your Stripe account serves every app you make.
616
717
  </li>
617
718
  <li>
618
- Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings).
619
- Under <b>Secrets</b>, enter the key <code>STRIPE_SECRET_KEY</code>, paste the value, and save.
620
- From a terminal instead: <code>npx vibes-diy secrets set STRIPE_SECRET_KEY --vibe {vibe}</code>.
621
- </li>
622
- <li>
623
- Open Stripe's <a href="https://dashboard.stripe.com/test/settings/billing/portal" target="_blank" rel="noreferrer">Customer portal settings</a>:
719
+ Signed in to that Stripe account, open its <a href={portalUrl} target="_blank" rel="noreferrer">Customer portal settings ({live ? "live" : "test"} mode)</a>:
624
720
  under <b>Cancellations</b> turn on <b>Cancel subscriptions</b>, choose <b>At end of billing period</b>, and press <b>Save</b>.
625
721
  Members use that page to cancel or change a card.
626
722
  </li>
@@ -628,12 +724,27 @@ function SetupScreen({ setup }) {
628
724
  Come back here: the Join buttons work as soon as both items are done. In test mode the card 4242 4242 4242 4242 pays.
629
725
  </li>
630
726
  <li>
631
- {/* The Go-live step stays here once the items are done; its badge says which key is in. */}
632
- <Badge tone={setup.live ? "up" : "info"}>{setup.live ? "Live" : "Test mode"}</Badge>{" "}
633
- When a test payment goes through, switch to your live key to take real payments: open Stripe's{" "}
634
- <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noreferrer">API keys page (live mode)</a>, press <b>Reveal live key</b> beside <b>Secret key</b>
635
- (it starts with <code>sk_live_</code>), set <code>STRIPE_SECRET_KEY</code> to it the same way, and save the{" "}
636
- <a href="https://dashboard.stripe.com/settings/billing/portal" target="_blank" rel="noreferrer">Customer portal settings (live mode)</a> once more.
727
+ {/* The Go-live step stays here once the items are done; its badge says which account this app charges in. */}
728
+ <Badge tone={live ? "up" : "info"}>{live ? "Live" : "Test mode"}</Badge>{" "}
729
+ {live ? (
730
+ <>
731
+ Real memberships bill into your Stripe account; refunds, invoices and payouts are in your Stripe Dashboard. Keep the{" "}
732
+ <a href={portalUrl} target="_blank" rel="noreferrer">Customer portal settings (live mode)</a> saved.
733
+ </>
734
+ ) : setup.liveReady ? (
735
+ <>
736
+ Your live Stripe account is ready. Each app goes live on its own, so this one rehearses until you press{" "}
737
+ <Button onClick={takeLive}>Take live payments in this app</Button>, then save the{" "}
738
+ <a href="https://dashboard.stripe.com/settings/billing/portal" target="_blank" rel="noreferrer">Customer portal settings (live mode)</a> once more.
739
+ </>
740
+ ) : setup.liveOpen ? (
741
+ <>
742
+ When a test payment goes through, press <b>Go live with Stripe</b> under <b>Payments</b> on{" "}
743
+ <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.
744
+ </>
745
+ ) : (
746
+ <>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.</>
747
+ )}
637
748
  </li>
638
749
  </ol>
639
750
  </Stack>
@@ -678,7 +789,12 @@ export default function App() {
678
789
  const data = await res.json();
679
790
  if (!data.ok) {
680
791
  tab.close();
681
- setError(data.reason === "stripe" ? data.message : data.reason === "already-member" ? "You already have a membership — manage it below." : "Memberships are not open yet.");
792
+ setError(
793
+ data.reason === "stripe" ? data.message
794
+ : data.reason === "already-member" ? "You already have a membership — manage it below."
795
+ : data.reason === "mode-changed" ? `Memberships here just moved to ${data.mode} mode. Refresh the page and press Join again.`
796
+ : "Memberships are not open yet."
797
+ );
682
798
  return;
683
799
  }
684
800
  tab.location.href = data.url;
@@ -688,7 +804,8 @@ export default function App() {
688
804
  };
689
805
 
690
806
  const ready = setup?.ready === true;
691
- const mine = viewer ? memberships.find((m) => m._id === `membership-${viewer.userHandle}`) : undefined;
807
+ // The member's record in the mode the app charges in now: a record from the other mode is a rehearsal.
808
+ const mine = viewer && setup ? memberships.find((m) => m._id === `membership-${setup.mode}-${viewer.userHandle}`) : undefined;
692
809
  const entitled = mine?.entitled === true;
693
810
  const ends = mine?.currentPeriodEnd ? new Date(mine.currentPeriodEnd).toLocaleDateString() : null;
694
811
 
@@ -721,9 +838,18 @@ export default function App() {
721
838
  {mine?.subscriptionId && mine.status !== "canceled" ? (
722
839
  <Text>Your last payment did not go through. Update your card below to keep reading.</Text>
723
840
  ) : ready ? (
724
- <Button onClick={() => openStripe("/subscribe", { plan: "monthly" })} disabled={busy} loading={busy}>
725
- Join — Stripe opens in a new tab
726
- </Button>
841
+ <Stack>
842
+ {/* Test mode is a rehearsal: the owner gets the test card, members learn it ends at go-live. */}
843
+ {setup.mode === "test" && (
844
+ <Row gap={2}>
845
+ <Badge tone="info">Test mode</Badge>
846
+ <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>
847
+ </Row>
848
+ )}
849
+ <Button onClick={() => openStripe("/subscribe", { plan: "monthly", mode: setup.mode })} disabled={busy} loading={busy}>
850
+ Join — Stripe opens in a new tab
851
+ </Button>
852
+ </Stack>
727
853
  ) : (
728
854
  <Text muted>Memberships are not open yet.</Text>
729
855
  )}
@@ -745,7 +871,7 @@ export default function App() {
745
871
  {setup?.owner && (
746
872
  <ul>
747
873
  {memberships.map((m) => (
748
- <li key={m._id}>@{m.memberHandle} · {m.plan} · {m.status}{m.entitled ? " · entitled" : ""}{m.currentPeriodEnd ? ` · until ${new Date(m.currentPeriodEnd).toLocaleDateString()}` : ""}</li>
874
+ <li key={m._id}>@{m.memberHandle} · {m.mode} · {m.plan} · {m.status}{m.entitled ? " · entitled" : ""}{m.currentPeriodEnd ? ` · until ${new Date(m.currentPeriodEnd).toLocaleDateString()}` : ""}</li>
749
875
  ))}
750
876
  </ul>
751
877
  )}