@vibes.diy/prompts 14.3.178 → 14.3.184

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.
package/llms/stripe.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export const stripeConfig = {
2
2
  name: "stripe",
3
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",
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 into the owner's own Stripe account through ctx.stripe, connected once with Connect Stripe (a monthly or yearly subscription is the stripe-subscriptions skill): the Connect Stripe setup item read from ctx.stripe.status(), test mode first with the order's mode passed on every call and live once the owner takes this app live, every Stripe call 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
5
  cues: [
6
6
  "stripe",
7
7
  "take payments",
@@ -1 +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"}
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,07BAA07B;IAC57B,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"}
package/llms/stripe.md CHANGED
@@ -1,11 +1,13 @@
1
- # Stripe payments — take real money with the owner's own Stripe account
1
+ # Stripe payments — take real money into the owner's own Stripe account
2
2
 
3
3
  When someone asks to **take money** — charge for a booking, collect dues, sell
4
4
  tickets, take a deposit, sell a download — build it on **Stripe
5
- Checkout** from `backend.js`, with the secret key the owner supplies. This is a
6
- `backend.js` feature (see the backend docs for the general handler contract);
7
- this page is the recipe that keeps the key on the server, keeps "paid" a fact
8
- only Stripe can establish, and keeps every order honest about where it is.
5
+ Checkout** from `backend.js`, through `ctx.stripe` — the owner connects their
6
+ Stripe account once with **Connect Stripe**, and the platform holds the
7
+ credential. This is a `backend.js` feature (see the backend docs for the
8
+ general handler contract); this page is the recipe that keeps every Stripe call
9
+ on the server, keeps "paid" a fact only Stripe can establish, and keeps every
10
+ order honest about where it is.
9
11
 
10
12
  **This skill comes in only when the person asks for payment.** A storefront that
11
13
  records orders, a plans page that records requests, a booking page that holds a
@@ -30,66 +32,112 @@ subscription state into the app and takes the person's cancellations and failed
30
32
  renewals into account.
31
33
 
32
34
  **The money is the owner's, and so is the account.** Every buyer pays the same
33
- Stripe account, the one whose key is on the vibe; the app is a merchant's page,
35
+ Stripe account, the one the owner connected; the app is a merchant's page,
34
36
  and a buyer is a signed-in person whose order is a record between them and the
35
37
  merchant. An app where *each visitor* collects money into *their own* Stripe
36
- account is a platform (Stripe Connect) and is a different thing this recipe
37
- does not build — say so plainly and build the one-merchant half.
38
+ account is a marketplace, a bigger thing than this recipe — say so plainly and
39
+ build the one-merchant half.
38
40
 
39
- ## 1. The setup item: the owner's Stripe secret key
41
+ ## 1. The setup item: Connect Stripe
40
42
 
41
- The owner gets a key from Stripe, once. The Setup surface it lands on — the
43
+ The owner connects Stripe, once. The Setup surface it lands on — the
42
44
  owner-only entry in the app's navigation, the verdict from `/setup`, the
43
45
  re-read on focus that unlocks the Pay buttons in place — is the use-vibe
44
46
  docs' *Some apps need setup before they work*; this recipe contributes one
45
- item to it. The Stripe item is the secret key: the backend answers missing-key while ctx.secrets.STRIPE_SECRET_KEY is absent, and its steps link Stripe's API keys page by URL and name STRIPE_SECRET_KEY in so many words.
47
+ item to it. The Stripe item is Connect Stripe: the backend reads ctx.stripe.status(), the item is done when the mode the app charges in reads ready, and its steps link this vibe's Settings tab, where the owner presses Connect Stripe under Payments.
46
48
  The steps are written for someone who has never opened Stripe, so each one is
47
49
  a link to the exact page and uses the words that page uses:
48
50
 
49
- 1. Open Stripe's **API keys** page in test mode:
50
- <https://dashboard.stripe.com/test/apikeys> (sign in, or create a Stripe
51
- account when it asks). Under **Standard keys**, press **Reveal test key**
52
- beside **Secret key** and copy it. It starts with `sk_test_`.
53
- 2. Open this vibe's **Settings** tab on vibes.diy:
51
+ 1. Open this vibe's **Settings** tab on vibes.diy:
54
52
  `https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings` (that is
55
- **My Vibes** → this vibe → **Settings**). Under **Secrets**, enter the key
56
- `STRIPE_SECRET_KEY` and paste the value, then save. From a terminal the
57
- same thing is `npx vibes-diy secrets set STRIPE_SECRET_KEY --vibe <ownerHandle>/<appSlug>`.
58
- 3. Come back to the app. The Pay buttons work the moment the key is saved; no
59
- redeploy. In test mode the card `4242 4242 4242 4242` with any future date
60
- pays.
61
- 4. Go live. When a test payment goes through, switch to your live key to take
62
- real payments: open Stripe's **API keys** page in live mode,
63
- <https://dashboard.stripe.com/apikeys>, press **Reveal live key** beside
64
- **Secret key** (it starts with `sk_live_`), and set `STRIPE_SECRET_KEY` to
65
- it the same way; setting an existing key replaces it. This step stays on
66
- the Setup screen after the item is done, with a badge that reads *Test
67
- mode* until the live key is in.
53
+ **My Vibes** → this vibe → **Settings**), and press **Connect Stripe** under
54
+ **Payments**. Stripe opens its own setup pages: sign in with a Stripe login
55
+ or create one there, and answer Stripe's questions about the business. The
56
+ Stripe account belongs to the owner's Vibes DIY account and serves every
57
+ app they make, so it is connected once; an app that takes money for a
58
+ friend lives on the friend's own account, where they connect their Stripe.
59
+ 2. Come back to the app. The Pay buttons work in test mode the moment Stripe
60
+ says the account is ready, with no redeploy; the card
61
+ `4242 4242 4242 4242` with any future date pays.
62
+ 3. Go live, one app at a time. Once a test payment goes through and live
63
+ payments are open on vibes.diy, press **Go live with Stripe** under
64
+ **Payments** on the Settings tab and finish Stripe's live setup (once, for
65
+ every app). Then press **Take live payments in this app** on this app's
66
+ Setup screen. This step stays on the Setup screen after the item is done,
67
+ with a badge that reads *Test mode* until this app takes live payments.
68
68
 
69
69
  Those steps are the item's copy on the Setup surface, with the vibe's real
70
70
  handle and slug filled in from the `ownerHandle` and `appSlug` the `/setup`
71
- route hands over. `https://dashboard.stripe.com/test/apikeys` for the key,
72
- `https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings` for where it
73
- goes, and `STRIPE_SECRET_KEY` in so many words: "Open the Stripe dashboard"
74
- and "the Settings page" are directions to search for, and a person who has
75
- never seen either has to go looking. In the consumer flow, a Pay button whose
76
- key is not set reads *Checkout is not open yet*; the owner's Setup entry is
77
- where the steps are.
78
-
79
- Only the owner can set or rotate a secret, and handlers see the current value on
80
- every invocation — so the pay button starts working the moment the key lands,
81
- with no redeploy. There is no publishable key, no webhook signing secret and no
82
- second secret of any kind in this recipe: one key, held by the server. Later,
83
- the same Setup entry is where the owner swaps the test key for the live one.
84
-
85
- ## 2. The key never reaches the browser, and neither does Stripe.js
86
-
87
- 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.
88
-
89
- The client talks to the app's own `/_api/...` routes, and the backend is the only
90
- place that knows the key exists. A secret key in a React component ships to
91
- every visitor who views source, and anyone who reads it can refund, pay out and
92
- read every customer on the owner's account until it is rotated.
71
+ route hands over: `https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings`
72
+ for where the button is, and **Connect Stripe** and **Payments** in so many
73
+ words. "The Settings page" is a direction to search for, and a person who has
74
+ never seen it has to go looking. In the consumer flow, a Pay button whose item
75
+ is not done reads *Checkout is not open yet*; the owner's Setup entry is where
76
+ the steps are.
77
+
78
+ The owner pastes nothing: the platform holds the credential and attaches it
79
+ to every call, and the backend names only which of the owner's two accounts —
80
+ test or live — a call is for.
81
+ An app made before Connect Stripe, whose `backend.js` already reads
82
+ `ctx.secrets.STRIPE_SECRET_KEY`, keeps that helper as it is: an edit to it
83
+ changes what was asked and leaves its payments where they are, and it moves to
84
+ `ctx.stripe` only when the owner asks to switch to Connect Stripe. Every new
85
+ payment app is built on `ctx.stripe`.
86
+
87
+ ### Test mode first, live one app at a time
88
+
89
+ `ctx.stripe.status()` answers `{ test: { available, state }, live: { available,
90
+ state } }`. `available` says whether vibes.diy takes payments in that mode on
91
+ this deployment; `state` is the owner's account in that mode: `absent` (not
92
+ connected yet), `onboarding` (Stripe is still collecting the owner's details —
93
+ the Settings button reads **Finish Stripe setup**), `ready` (card payments on)
94
+ or `disconnected` (the button reads **Connect Stripe again**).
95
+
96
+ The Stripe account is the owner's and serves every app they make, so the live
97
+ account turning `ready` says the owner can take real money, not that **this**
98
+ app is done rehearsing. Going live is the owner's choice per app:
99
+
100
+ 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.
101
+
102
+ The Pay request carries the mode the buyer was shown, and the backend opens Checkout only when it matches the mode the app charges in now.
103
+
104
+ - The choice is a `setup:live` document the backend writes when the owner
105
+ presses **Take live payments in this app** on the Setup screen, offered once
106
+ `live.state` reads `ready`. Without it the app charges in **test**; with it,
107
+ in **live**. The mode comes from that document alone, so a slow or failed
108
+ status read never moves an app between accounts.
109
+ - A live app stays live. A live account that reads `onboarding` or
110
+ `disconnected` closes checkout, and the Setup item names the button that
111
+ brings it back; the test account keeps its rehearsal role.
112
+ - `/setup` hands the `mode` to everyone, and the Pay request carries the mode
113
+ the buyer was shown. The backend compares it with the mode it charges in
114
+ now, and a page that still shows the other mode is asked to refresh before
115
+ any Checkout opens.
116
+ - While the mode is test, the Pay button sits beside a *Test mode* badge. The
117
+ owner sees the test card `4242 4242 4242 4242` to rehearse with; buyers read
118
+ that test purchases end when the app goes live.
119
+ - Every order keeps the mode it was made in and settles in that mode, and the
120
+ app counts only orders made in its current mode: after going live, a paid
121
+ test order reads as a rehearsal and unlocks nothing.
122
+ - The Go-live step reads `live.available`, which `/setup` hands the owner as
123
+ `liveOpen`. While it is true, the step names the **Go live with Stripe**
124
+ button; until then, the step says test mode runs the whole flow end to end
125
+ and that the Go live button appears on the same Settings tab when vibes.diy
126
+ opens live payments. A live app whose `liveOpen` turns false tells its owner
127
+ that vibes.diy has paused live payments, in its own words.
128
+
129
+ ## 2. Stripe stays on the server, and so does Stripe.js
130
+
131
+ 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.
132
+
133
+ The client talks to the app's own `/_api/...` routes, and the backend holds no
134
+ Stripe credential at all: `ctx.stripe.fetch` sends the call to the platform,
135
+ which attaches the key and the owner's account and hands back Stripe's own
136
+ `Response`. `path` is the Stripe API path from `/v1` (`/v1/checkout/sessions`),
137
+ `body` is the form as a `URLSearchParams`, `headers` carries the
138
+ `Idempotency-Key`, and `mode` is `"test"` or `"live"`. The calls this recipe
139
+ makes — create a Checkout Session and read it back — are calls `ctx.stripe`
140
+ carries, along with expiring an open session.
93
141
 
94
142
  Stripe.js is not needed either: **Stripe Checkout is a page Stripe hosts.** The
95
143
  backend creates a Checkout Session and gets back a URL; the browser opens that
@@ -115,9 +163,20 @@ start two purchases.
115
163
 
116
164
  The order exists before the money moves. The `/checkout` route runs as the
117
165
  signed-in buyer (the page calls it with `apiFetch`, so `ctx.userInfo` is set),
118
- writes an `order` document in `pending` with the buyer's handle, the title and
119
- the amount, and only then asks Stripe for a session for exactly that order:
120
-
166
+ writes an `order` document in `pending` with the buyer's handle, the title, the
167
+ amount and the mode it is made in, and only then asks Stripe for a session for
168
+ exactly that order:
169
+
170
+ Every order records the mode it was made in, and every ctx.stripe.fetch call for that order passes the order's own mode.
171
+
172
+ - The order's `mode` is the app's current mode — test until the owner takes
173
+ this app live, live after — and the session, the read-back and the sweep
174
+ all use the order's own `mode`. A test order is a test-mode session on the
175
+ owner's test account and stays one, so after the app goes live a pending
176
+ test order still settles (or expires) where it was made. (This `mode` is the option
177
+ `ctx.stripe.fetch` takes; the form's own `mode=payment` is Stripe's Checkout
178
+ field, and both are always there.)
179
+ - The whole payment lands in the owner's own Stripe account: the session carries the line items, the order's ids and the return URLs, and Stripe pays the merchant directly.
121
180
  - `mode=payment`, always: one charge, one order (see *One-time charges only*).
122
181
  - The price is inline `price_data` — currency, `unit_amount`, a product name —
123
182
  so the owner never has to create products in the dashboard first.
@@ -163,7 +222,7 @@ document field is only as trustworthy as the rules that guard it — so the
163
222
  read-back checks Stripe's copy, never the order's.
164
223
 
165
224
  Two things settle an order, and both are the backend reading
166
- `GET /v1/checkout/sessions/<id>` with the key:
225
+ `GET /v1/checkout/sessions/<id>` through `ctx.stripe`, in the order's own mode:
167
226
 
168
227
  - **The return.** When the app opens with `#paid=<orderId>` in its address, the
169
228
  page calls `/confirm` for that order; the route reads the session back, and if
@@ -183,7 +242,7 @@ A scheduled sweep settles every order still pending by reading its session back
183
242
  Why not a Stripe webhook: a webhook arrives with no identity, and a write with
184
243
  no identity has to be opened to every anonymous visitor to be accepted — the
185
244
  wrong door for the one record that means money changed hands. Reading the
186
- session back with the key the app already holds is the same proof, from the
245
+ session back through `ctx.stripe` is the same proof, from the
187
246
  same source, on a route that can only move an order to the state Stripe
188
247
  reports. `access.js` makes that structural: an existing order changes only on
189
248
  a write from the app's own `backend.js` (`ctx.requireBackend()` on every
@@ -207,22 +266,22 @@ reading Stripe. A buyer never edits an order; they make a new one.
207
266
 
208
267
  ## 6. Errors keep their reason
209
268
 
210
- Stripe's refusals come back as JSON with `error.message`; keep that sentence and
211
- return it as `{ ok: false, reason: "stripe", message }` so the page can show
212
- the owner *why* ("Invalid API Key provided" is the whole diagnosis of a key
213
- pasted with a trailing space). A refused `ctx.fetch` — the
214
- `{ vibesEgressDenied: true }` shape — is a platform refusal, not Stripe's; log
215
- it with `ctx.log` and answer `{ ok: false, reason: "unavailable" }`.
216
- `api.stripe.com` is CORS-open to the app's own origin, so a keyed `POST` from
217
- `ctx.fetch` is admitted on the CORS lane with no platform arrangement; nothing
218
- here needs the curated list.
269
+ Stripe's own answers arrive as a `Response`: a card decline or a bad parameter
270
+ is JSON with `error.message`; keep that sentence and return it as
271
+ `{ ok: false, reason: "stripe", message }` so the page can show *why*. A
272
+ platform answer is thrown as an `Error` with `e.reason`, and each reason names
273
+ the owner's next step: `not_connected` and `not_ready` are the Setup item
274
+ (connect, or let Stripe finish its checks), `account_disconnected` is
275
+ **Connect Stripe** again from the same Settings tab, and `not_available` means
276
+ payments in that mode are paused on vibes.diy for now. Log it with `ctx.log`
277
+ and hand the reason to the page, which reads it as *Checkout is not open yet*.
219
278
 
220
279
  ## Complete backend.js
221
280
 
222
281
  ### `backend.js`
223
282
  ```js app=stripe-dues
224
- // Stripe Checkout for one merchant: the owner's key, hosted Checkout, orders settled
225
- // by reading each session back. Nothing in this file touches a card.
283
+ // Stripe Checkout for one merchant: the owner's connected Stripe account, hosted Checkout,
284
+ // orders settled by reading each session back. Nothing in this file touches a card or a key.
226
285
  export const config = {
227
286
  scheduled: { interval: "5m" },
228
287
  // The routes read a buyer's own order back; access.js binds the database, so the fetch lane
@@ -230,7 +289,6 @@ export const config = {
230
289
  fetch: { unfilteredReads: { dbs: ["orders"], why: "the /checkout and /confirm routes read the signed-in buyer's own order, checked by buyerHandle before anything is returned" } },
231
290
  };
232
291
 
233
- const STRIPE = "https://api.stripe.com/v1";
234
292
  const DB = "orders";
235
293
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // a Checkout Session expires on its own after a day
236
294
 
@@ -249,27 +307,38 @@ function appUrl(ctx, hash) {
249
307
  return `https://vibes.diy/vibe/${ctx.appInfo.ownerHandle}/${ctx.appInfo.appSlug}${hash}`;
250
308
  }
251
309
 
252
- // One call to Stripe. Form-encoded in, JSON out, the refusal's own sentence kept.
253
- async function stripe(ctx, method, path, form, headers = {}) {
254
- const key = ctx.secrets.STRIPE_SECRET_KEY;
255
- if (!key) return { ok: false, reason: "missing-key" };
256
- const res = await ctx.fetch(`${STRIPE}${path}`, {
257
- method,
258
- headers: {
259
- authorization: `Bearer ${key}`,
260
- ...(form ? { "content-type": "application/x-www-form-urlencoded" } : {}),
261
- ...headers,
262
- },
263
- body: form ? form.toString() : undefined,
264
- });
265
- const body = await res.json().catch(() => null);
266
- if (!res.ok) {
267
- if (body?.vibesEgressDenied) {
268
- ctx.log("warn", "stripe egress denied", { gate: body.gate });
269
- return { ok: false, reason: "unavailable" };
270
- }
271
- return { ok: false, reason: "stripe", message: body?.error?.message ?? `HTTP ${res.status}` };
310
+ // The owner's choice to take THIS app live. The Stripe account is the owner's and serves all of
311
+ // their apps, so its live account turning ready never moves an app by itself.
312
+ const LIVE_CHOICE = "setup:live";
313
+
314
+ // Which of the owner's two Stripe accounts this app charges in, decided by the owner's choice
315
+ // alone; then whether that account takes card payments right now. A live app whose live account
316
+ // is not ready stays live with checkout closed until the owner reconnects.
317
+ async function checkoutMode(ctx) {
318
+ const mode = (await ctx.db.get(LIVE_CHOICE, { db: DB })) ? "live" : "test";
319
+ let st;
320
+ try {
321
+ st = await ctx.stripe.status();
322
+ } catch (e) {
323
+ ctx.log("warn", "stripe status unavailable", { message: e.message });
324
+ return { mode, open: false, state: "unavailable", liveOpen: false, liveReady: false };
325
+ }
326
+ return { mode, open: st[mode].state === "ready", state: st[mode].state, liveOpen: st.live.available, liveReady: st.live.state === "ready" };
327
+ }
328
+
329
+ // One call to Stripe on the owner's account, in the order's own mode. Form-encoded in, Stripe's
330
+ // JSON out, the refusal's own sentence kept.
331
+ async function stripe(ctx, mode, method, path, form, headers = {}) {
332
+ let res;
333
+ try {
334
+ res = await ctx.stripe.fetch(`/v1${path}`, { method, body: form, headers, mode });
335
+ } catch (e) {
336
+ // The platform's answer, not Stripe's: a Setup step for the owner, or payments paused.
337
+ ctx.log("warn", "stripe call refused", { reason: e.reason });
338
+ return { ok: false, reason: e.reason ?? "not_available" };
272
339
  }
340
+ const body = await res.json().catch(() => null);
341
+ if (!res.ok) return { ok: false, reason: "stripe", message: body?.error?.message ?? `HTTP ${res.status}` };
273
342
  return { ok: true, body };
274
343
  }
275
344
 
@@ -284,7 +353,7 @@ async function settle(ctx, order) {
284
353
  }
285
354
  return order;
286
355
  }
287
- const session = await stripe(ctx, "GET", `/checkout/sessions/${order.sessionId}`);
356
+ const session = await stripe(ctx, order.mode, "GET", `/checkout/sessions/${order.sessionId}`);
288
357
  if (!session.ok) return order; // a refusal settles nothing; the next read tries again
289
358
  const s = session.body;
290
359
  // The session must be THIS order's: Stripe's copy of the order id, amount and currency,
@@ -311,28 +380,47 @@ export async function fetch(request, ctx) {
311
380
  // links with) for the owner alone.
312
381
  if (url.pathname === "/setup" && request.method === "GET") {
313
382
  const owner = ctx.userInfo?.userHandle === ctx.appInfo.ownerHandle;
314
- const items = [{ id: "stripe-key", label: "Stripe secret key", done: Boolean(ctx.secrets.STRIPE_SECRET_KEY) }];
315
- const ready = items.every((item) => item.done);
316
- if (!owner) return json({ ok: true, ready, owner });
317
- // `live` is the Go-live step's badge: a test key takes test payments, a live key real ones.
318
- const live = (ctx.secrets.STRIPE_SECRET_KEY ?? "").startsWith("sk_live_");
319
- return json({ ok: true, ready, owner, live, items, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
383
+ const { mode, open, state, liveOpen, liveReady } = await checkoutMode(ctx);
384
+ const item = { id: "stripe-connect", label: "Connect Stripe", done: open, state };
385
+ if (mode === "live" && !liveOpen) item.problem = "Vibes DIY has paused live payments for now; checkout reopens here by itself when they return.";
386
+ const items = [item];
387
+ const ready = items.every((i) => i.done);
388
+ // `mode` is everyone's: the Pay button wears a Test mode badge while the app takes test payments.
389
+ if (!owner) return json({ ok: true, ready, owner, mode });
390
+ // The Go-live step: `liveOpen` (vibes.diy takes live payments) and `liveReady` (the owner's live
391
+ // Stripe account is ready, so this app can be taken live).
392
+ return json({ ok: true, ready, owner, mode, liveOpen, liveReady, items, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
393
+ }
394
+
395
+ // The owner takes this app live: one document, written for the owner alone, once their live
396
+ // Stripe account is ready. From here on the app charges in live only.
397
+ if (url.pathname === "/go-live" && request.method === "POST") {
398
+ if (ctx.userInfo?.userHandle !== ctx.appInfo.ownerHandle) return json({ ok: false, reason: "owner-only" }, 403);
399
+ const { liveReady } = await checkoutMode(ctx);
400
+ if (!liveReady) return json({ ok: false, reason: "not_ready" }, 409);
401
+ const chosen = await ctx.db.get(LIVE_CHOICE, { db: DB });
402
+ if (!chosen) await ctx.db.put({ _id: LIVE_CHOICE, type: "setup", mode: "live", chosenAt: Date.now() }, { db: DB, id: LIVE_CHOICE });
403
+ return json({ ok: true, mode: "live" });
320
404
  }
321
405
 
322
406
  // Start a purchase: the order first, then a Checkout Session for exactly that order.
323
407
  if (url.pathname === "/checkout" && request.method === "POST") {
324
408
  const me = ctx.userInfo?.userHandle;
325
409
  if (!me) return json({ ok: false, reason: "sign-in" }, 401);
326
- if (!ctx.secrets.STRIPE_SECRET_KEY) return json({ ok: false, reason: "missing-key" }, 503);
327
- const { kind } = await request.json();
410
+ const { mode, open, state } = await checkoutMode(ctx);
411
+ const { kind, mode: shown } = await request.json();
412
+ // The buyer pays in the mode their page showed them, or not at all: a page that still shows
413
+ // the other mode refreshes first.
414
+ if (shown !== mode) return json({ ok: false, reason: "mode-changed", mode }, 409);
415
+ if (!open) return json({ ok: false, reason: state === "disconnected" ? "account_disconnected" : "not_ready" }, 503);
328
416
  const price = PRICES[kind];
329
417
  if (!price) return json({ ok: false, reason: "unknown-item" }, 400);
330
418
 
331
419
  // One attempt per buyer per item: a second press, a retry after a lost answer, a reload,
332
420
  // all land on the session already open rather than minting another order.
333
421
  const pending = await ctx.db.query({ db: DB, field: "status", key: "pending", limit: 100 });
334
- const open = pending.find((o) => o.buyerHandle === me && o.kind === kind && o.sessionUrl && Date.now() - o.createdAt < SESSION_TTL_MS);
335
- if (open) return json({ ok: true, orderId: open._id, url: open.sessionUrl });
422
+ const attempt = pending.find((o) => o.buyerHandle === me && o.kind === kind && o.mode === mode && o.sessionUrl && Date.now() - o.createdAt < SESSION_TTL_MS);
423
+ if (attempt) return json({ ok: true, orderId: attempt._id, url: attempt.sessionUrl });
336
424
 
337
425
  const id = "order-" + crypto.randomUUID();
338
426
  const order = {
@@ -343,6 +431,7 @@ export async function fetch(request, ctx) {
343
431
  amount: price.amount,
344
432
  currency: price.currency,
345
433
  buyerHandle: me,
434
+ mode, // the account this order is made in; every later call for it passes the same mode
346
435
  status: "pending",
347
436
  createdAt: Date.now(),
348
437
  };
@@ -359,7 +448,7 @@ export async function fetch(request, ctx) {
359
448
  success_url: appUrl(ctx, `#paid=${id}`),
360
449
  cancel_url: appUrl(ctx, `#cancelled=${id}`),
361
450
  });
362
- const session = await stripe(ctx, "POST", "/checkout/sessions", form, { "idempotency-key": id });
451
+ const session = await stripe(ctx, mode, "POST", "/checkout/sessions", form, { "idempotency-key": id });
363
452
  if (!session.ok) return json(session, session.reason === "stripe" ? 502 : 503);
364
453
 
365
454
  await ctx.db.put({ ...order, sessionId: session.body.id, sessionUrl: session.body.url }, { db: DB, id });
@@ -382,7 +471,6 @@ export async function fetch(request, ctx) {
382
471
 
383
472
  // The sweep: anyone who paid and closed the tab is still marked paid within a few minutes.
384
473
  export async function scheduled(event, ctx) {
385
- if (!ctx.secrets.STRIPE_SECRET_KEY) return;
386
474
  let after;
387
475
  let settled = 0;
388
476
  do {
@@ -403,13 +491,19 @@ export async function scheduled(event, ctx) {
403
491
  ```js app=stripe-dues
404
492
  export function orders(doc, oldDoc, user, ctx) {
405
493
  if (!user?.userHandle) throw { forbidden: "sign in" };
494
+ // The app's go-live choice: written by backend.js on the owner's /go-live call alone, read on
495
+ // the owner's channel.
496
+ if (doc.type === "setup") {
497
+ ctx.requireBackend("fetch");
498
+ return { channels: ["orders"] };
499
+ }
406
500
  if (doc.type !== "order") throw { forbidden: "unknown document type" };
407
501
  if (oldDoc) {
408
502
  // Only backend.js changes an existing order — the /checkout route adding the session, the
409
503
  // /confirm route and the sweep settling it — so no field (sessionId included) is a buyer's to edit.
410
504
  ctx.requireBackend();
411
- if (doc.buyerHandle !== oldDoc.buyerHandle || doc.amount !== oldDoc.amount || doc.currency !== oldDoc.currency || doc.kind !== oldDoc.kind) {
412
- throw { forbidden: "an order keeps its buyer and its price" };
505
+ if (doc.buyerHandle !== oldDoc.buyerHandle || doc.amount !== oldDoc.amount || doc.currency !== oldDoc.currency || doc.kind !== oldDoc.kind || doc.mode !== oldDoc.mode) {
506
+ throw { forbidden: "an order keeps its buyer, its price and its mode" };
413
507
  }
414
508
  } else {
415
509
  if (doc.buyerHandle !== user.userHandle) throw { forbidden: "an order belongs to its buyer" };
@@ -431,9 +525,9 @@ import { apiFetch, useVibe } from "use-vibes";
431
525
  import { Badge, Button, Heading, Row, Stack, Text } from "@vibes.diy/pouf";
432
526
 
433
527
  // The setup verdict, read on mount and again whenever the app comes back into view: the owner
434
- // sets the key in another tab and returns, and the Pay buttons unlock without a reload.
528
+ // connects Stripe in another tab and returns, and the Pay buttons unlock without a reload.
435
529
  function useSetup() {
436
- const [setup, setSetup] = useState(null); // { ready, owner, items?, ownerHandle?, appSlug? }
530
+ const [setup, setSetup] = useState(null); // { ready, owner, mode, liveOpen?, items?, ownerHandle?, appSlug? }
437
531
  const latest = useRef(0);
438
532
  useEffect(() => {
439
533
  // Mount, focus and visibility reads overlap; only the latest one may land, or a slow stale
@@ -446,44 +540,55 @@ function useSetup() {
446
540
  const onVisible = () => { if (document.visibilityState === "visible") load(); };
447
541
  document.addEventListener("visibilitychange", onVisible);
448
542
  window.addEventListener("focus", load);
449
- return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); };
543
+ window.addEventListener("setup-changed", load); // the Setup screen's own Take live button
544
+ return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); window.removeEventListener("setup-changed", load); };
450
545
  }, []);
451
546
  return setup;
452
547
  }
453
548
 
549
+ // The button the Settings tab shows for each state of the owner's Stripe account, in its own words.
550
+ const STRIPE_BUTTON = { absent: "Connect Stripe", onboarding: "Finish Stripe setup", disconnected: "Connect Stripe again" };
551
+
454
552
  // The Setup surface, the owner's alone: one item, every step a link to the exact page in that
455
553
  // page's own words, with this vibe's real handle and slug filled in.
456
554
  function SetupScreen({ setup }) {
457
555
  const vibe = `${setup.ownerHandle}/${setup.appSlug}`;
458
556
  const settingsUrl = `https://vibes.diy/vibes/mine/${vibe}/settings`;
459
- const key = setup.items.find((item) => item.id === "stripe-key");
557
+ const item = setup.items.find((i) => i.id === "stripe-connect");
558
+ const live = setup.mode === "live";
559
+ const takeLive = () => apiFetch("/go-live", { method: "POST" }).then(() => window.dispatchEvent(new Event("setup-changed")));
460
560
  return (
461
561
  <Stack>
462
562
  <Heading>Setup</Heading>
463
563
  <Row gap={2}>
464
- <Text>{key.label}</Text>
465
- <Badge tone={key.done ? "up" : "info"}>{key.done ? "Done" : "To do"}</Badge>
564
+ <Text>{item.label}</Text>
565
+ <Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : item.state === "onboarding" ? "Stripe is finishing up" : "To do"}</Badge>
466
566
  </Row>
567
+ {item.problem && <Text muted>{item.problem}</Text>}
467
568
  <ol>
468
569
  <li>
469
- Open Stripe's <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noreferrer">API keys page (test mode)</a>,
470
- 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>.
471
- Copy it; it starts with <code>sk_test_</code>.
570
+ Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings)
571
+ and press <b>{STRIPE_BUTTON[item.state] ?? "Connect Stripe"}</b> under <b>Payments</b>.
572
+ Stripe opens its own setup pages: use your Stripe account, or create one there. Your Stripe account serves every app you make.
472
573
  </li>
473
574
  <li>
474
- Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings).
475
- Under <b>Secrets</b>, enter the key <code>STRIPE_SECRET_KEY</code>, paste the value, and save.
476
- From a terminal instead: <code>npx vibes-diy secrets set STRIPE_SECRET_KEY --vibe {vibe}</code>.
575
+ Come back here: the Pay buttons work as soon as Stripe says your account is ready. In test mode the card 4242 4242 4242 4242 with any future date pays.
477
576
  </li>
478
577
  <li>
479
- Come back here: the Pay buttons work as soon as the key is saved. In test mode the card 4242 4242 4242 4242 pays.
480
- </li>
481
- <li>
482
- {/* The Go-live step stays here once the item is done; its badge says which key is in. */}
483
- <Badge tone={setup.live ? "up" : "info"}>{setup.live ? "Live" : "Test mode"}</Badge>{" "}
484
- When a test payment goes through, switch to your live key to take real payments: open Stripe's{" "}
485
- <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>
486
- (it starts with <code>sk_live_</code>), and set <code>STRIPE_SECRET_KEY</code> to it the same way.
578
+ {/* The Go-live step stays here once the item is done; its badge says which account this app charges in. */}
579
+ <Badge tone={live ? "up" : "info"}>{live ? "Live" : "Test mode"}</Badge>{" "}
580
+ {live ? (
581
+ <>This app takes real payments into your Stripe account; refunds and payouts are in your Stripe Dashboard.</>
582
+ ) : setup.liveReady ? (
583
+ <>
584
+ Your live Stripe account is ready. Each app goes live on its own, so this one rehearses until you press{" "}
585
+ <Button onClick={takeLive}>Take live payments in this app</Button>. Buyers start fresh in live; test purchases stay rehearsals.
586
+ </>
587
+ ) : setup.liveOpen ? (
588
+ <>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.</>
589
+ ) : (
590
+ <>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.</>
591
+ )}
487
592
  </li>
488
593
  </ol>
489
594
  </Stack>
@@ -523,11 +628,16 @@ export default function App() {
523
628
  }
524
629
  setBusy(true);
525
630
  try {
526
- const res = await apiFetch("/checkout", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ kind }) });
631
+ // The mode this page shows rides the request, so the buyer pays in the mode they saw.
632
+ const res = await apiFetch("/checkout", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ kind, mode: setup.mode }) });
527
633
  const data = await res.json();
528
634
  if (!data.ok) {
529
635
  tab.close();
530
- setError(data.reason === "stripe" ? data.message : "Payments are not available right now.");
636
+ setError(
637
+ data.reason === "stripe" ? data.message
638
+ : data.reason === "mode-changed" ? `Payments here just moved to ${data.mode} mode. Refresh the page and press Pay again.`
639
+ : "Checkout is not open yet."
640
+ );
531
641
  return;
532
642
  }
533
643
  tab.location.href = data.url;
@@ -538,7 +648,8 @@ export default function App() {
538
648
 
539
649
  const ready = setup?.ready === true;
540
650
  const mine = orders.filter((o) => o.buyerHandle === me?.userHandle);
541
- const paid = mine.some((o) => o.kind === "dues" && o.status === "paid");
651
+ // Only orders made in the app's current mode count: after going live, test orders are rehearsals.
652
+ const paid = mine.some((o) => o.kind === "dues" && o.status === "paid" && o.mode === setup?.mode);
542
653
  return (
543
654
  <Stack>
544
655
  <Row gap={2}>
@@ -558,9 +669,18 @@ export default function App() {
558
669
  {paid ? (
559
670
  <Badge tone="up">Dues paid</Badge>
560
671
  ) : ready ? (
561
- <Button onClick={() => pay("dues")} disabled={busy} loading={busy}>
562
- Pay $40 dues — Stripe opens in a new tab
563
- </Button>
672
+ <Stack>
673
+ {/* Test mode is a rehearsal: the owner gets the test card, buyers learn it ends at go-live. */}
674
+ {setup.mode === "test" && (
675
+ <Row gap={2}>
676
+ <Badge tone="info">Test mode</Badge>
677
+ <Text muted>{setup.owner ? "Pay with the test card 4242 4242 4242 4242 and any future date." : "This is a rehearsal: test purchases end when the app goes live."}</Text>
678
+ </Row>
679
+ )}
680
+ <Button onClick={() => pay("dues")} disabled={busy} loading={busy}>
681
+ Pay $40 dues — Stripe opens in a new tab
682
+ </Button>
683
+ </Stack>
564
684
  ) : (
565
685
  <Text muted>Checkout is not open yet.</Text>
566
686
  )}
@@ -568,7 +688,7 @@ export default function App() {
568
688
  {setup?.owner && (
569
689
  <ul>
570
690
  {orders.map((o) => (
571
- <li key={o._id}>@{o.buyerHandle} · {o.title} · {(o.amount / 100).toFixed(2)} {o.currency.toUpperCase()} · {o.status}</li>
691
+ <li key={o._id}>@{o.buyerHandle} · {o.title} · {(o.amount / 100).toFixed(2)} {o.currency.toUpperCase()} · {o.status}{o.mode === setup.mode ? "" : ` · ${o.mode} rehearsal`}</li>
572
692
  ))}
573
693
  </ul>
574
694
  )}