@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/backend.js +2 -2
- package/llms/backend.js.map +1 -1
- package/llms/backend.md +166 -2
- package/llms/connections.js +6 -1
- package/llms/connections.js.map +1 -1
- package/llms/connections.md +16 -4
- package/llms/stripe-subscriptions.js +1 -1
- package/llms/stripe-subscriptions.js.map +1 -1
- package/llms/stripe-subscriptions.md +280 -154
- package/llms/stripe.js +1 -1
- package/llms/stripe.js.map +1 -1
- package/llms/stripe.md +250 -130
- package/llms/use-vibe.md +5 -4
- package/llms/web-audio.js +1 -1
- package/llms/web-audio.js.map +1 -1
- package/llms/web-audio.md +509 -1
- package/package.json +5 -5
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
|
|
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",
|
package/llms/stripe.js.map
CHANGED
|
@@ -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,
|
|
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
|
|
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`,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
37
|
-
|
|
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:
|
|
41
|
+
## 1. The setup item: Connect Stripe
|
|
40
42
|
|
|
41
|
-
The owner
|
|
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
|
|
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
|
|
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**)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
119
|
-
the
|
|
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>`
|
|
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
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
315
|
-
const
|
|
316
|
-
if (!
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
335
|
-
if (
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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>{
|
|
465
|
-
<Badge tone={
|
|
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
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
<
|
|
562
|
-
|
|
563
|
-
|
|
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
|
)}
|