@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.
- package/llms/backend.md +4 -0
- 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/package.json +5 -5
|
@@ -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`,
|
|
6
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
a
|
|
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:
|
|
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
|
|
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
|
|
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**)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
the
|
|
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
|
|
60
|
-
|
|
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
|
|
65
|
-
`https://
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
240
|
-
`{ ok: false, reason: "stripe", message }` so
|
|
241
|
-
*why*. The portal's "No configuration provided" message is
|
|
242
|
-
step
|
|
243
|
-
|
|
244
|
-
`
|
|
245
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
return {
|
|
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,
|
|
308
|
-
// is kept in a document the backend writes, so a visitor's /setup read never
|
|
309
|
-
// blip at Stripe keeps the last verdict. The /portal route opens the ACTIVE
|
|
310
|
-
// so that is the one asked for; a refused read is a problem shown to the
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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 || !
|
|
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:
|
|
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
|
-
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
406
|
-
const
|
|
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
|
-
|
|
410
|
-
// The
|
|
411
|
-
//
|
|
412
|
-
if (
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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
|
|
632
|
-
<Badge tone={
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
<
|
|
725
|
-
|
|
726
|
-
|
|
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
|
)}
|