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