@vibes.diy/prompts 14.3.191 → 14.3.193
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/access.md +1 -1
- package/llms/backend.md +9 -0
- package/llms/cues.js.map +1 -1
- package/llms/custom-look.js +3 -2
- package/llms/custom-look.js.map +1 -1
- package/llms/stripe-subscriptions.md +1 -2
- package/llms/stripe.js +11 -1
- package/llms/stripe.js.map +1 -1
- package/llms/stripe.md +816 -22
- package/package.json +5 -5
package/llms/access.md
CHANGED
|
@@ -30,7 +30,7 @@ writes.
|
|
|
30
30
|
|
|
31
31
|
**UserContext:** `{ userHandle: string, displayName?: string, backend?: "scheduled" | "onChange" | "fetch" }` — `userHandle` is stable unique id (use for all auth checks), `displayName` is display only (never use for identity checks), and `backend` is present only when the app's own `backend.js` issued this write, naming the handler that did (`scheduled`, `onChange` or `fetch`). Every write from a browser or the CLI arrives with `backend` absent, the app owner's own writes included — it says where a write came from, never who the writer is. Gate on `ctx.requireBackend(...)` rather than throwing on `user.backend` yourself: a bare `throw` on `user.backend` refuses the hand-repair lane admin mode exists for, and returns no descriptor for the write it refused.
|
|
32
32
|
|
|
33
|
-
**Helpers (`ctx`):** Opaque closures over the materialized grant state. They throw or pass — you cannot enumerate channels, list members, or iterate grants. They also throw when `user` is null: `ctx.requireAccess(channelId)` throws if user is not in the channel, `ctx.requireRole(roleName)` throws if user is not in the role, and `ctx.requireBackend()` throws unless the app's own `backend.js` issued this write — `ctx.requireBackend("scheduled")` narrows that to one handler. All three no-op under admin mode.
|
|
33
|
+
**Helpers (`ctx`):** Opaque closures over the materialized grant state. They throw or pass — you cannot enumerate channels, list members, or iterate grants. They also throw when `user` is null: `ctx.requireAccess(channelId)` throws if user is not in the channel, `ctx.requireRole(roleName)` throws if user is not in the role, and `ctx.requireBackend()` throws unless the app's own `backend.js` issued this write — `ctx.requireBackend("scheduled")` narrows that to one handler. `ctx.requireBackend` reads the write's origin, not its writer, so it also passes when nobody is signed in (a guest calling your `fetch` handler, or an `onChange` run a signed-out write triggered), while `user` stays null and the write still needs `allowAnonymous: true`. All three no-op under admin mode.
|
|
34
34
|
|
|
35
35
|
Express every refusal about **who may act** through the supplied validators — `ctx.requireAccess`, `ctx.requireRole`, `ctx.requireBackend` — so that admin mode passes them and the function still runs, applying your own channels and grants to the repaired document; a refusal about the document's own **shape**, like an unknown type or a missing field, stays a plain `throw` and is right to, while a write-once check sits in between and is worth naming in a comment, since a schema repair has to get past it.
|
|
36
36
|
|
package/llms/backend.md
CHANGED
|
@@ -85,6 +85,15 @@ escalation. For documents only the server should control, use `scheduled` and
|
|
|
85
85
|
gate them on the origin with `ctx.requireBackend("scheduled")`, which admits the
|
|
86
86
|
run and turns every ordinary client write away.
|
|
87
87
|
|
|
88
|
+
The origin travels with the write even when nobody is signed in, for every
|
|
89
|
+
handler. A guest's `apiFetch` into the `fetch` handler arrives with `user` null,
|
|
90
|
+
and `ctx.requireBackend("fetch")` still passes for it, so a guest-checkout
|
|
91
|
+
record can be one only the handler writes; an `onChange` run triggered by a
|
|
92
|
+
signed-out writer passes `ctx.requireBackend("onChange")` the same way. The function still opts that anonymous
|
|
93
|
+
write in with `allowAnonymous: true`, and the app's anonymous-write setting
|
|
94
|
+
still has to admit signed-out writers; `user.backend` itself is read only off a
|
|
95
|
+
signed-in user, so gate with `ctx.requireBackend`.
|
|
96
|
+
|
|
88
97
|
`scheduled` always runs in **admin mode**, the same override the owner gets
|
|
89
98
|
from the client's admin toggle: `ctx.requireAccess(...)`, `ctx.requireRole(...)`
|
|
90
99
|
and `ctx.requireBackend(...)` all no-op, and its `ctx.db.query` reads are
|
package/llms/cues.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cues.js","sourceRoot":"","sources":["../../jsr/llms/cues.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cues.js","sourceRoot":"","sources":["../../jsr/llms/cues.ts"],"names":[],"mappings":"AAKA,SAAS,YAAY,CAAC,CAAS;IAC7B,OAAO,CAAC,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;AAClD,CAAC;AAGD,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,GAAW;IAClD,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAClF,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACpC,OAAO,IAAI,MAAM,CAAC,gBAAgB,IAAI,cAAc,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACxE,CAAC;AASD,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,OAAmC;IAChF,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;AACvG,CAAC"}
|
package/llms/custom-look.js
CHANGED
|
@@ -17,9 +17,10 @@ export const customLookConfig = {
|
|
|
17
17
|
"shadcn",
|
|
18
18
|
"neo-brutalist",
|
|
19
19
|
"neobrutalism",
|
|
20
|
-
"gumroad",
|
|
20
|
+
"like gumroad",
|
|
21
21
|
"terminal style",
|
|
22
|
-
"newspaper",
|
|
22
|
+
"like a newspaper",
|
|
23
|
+
"newspaper style",
|
|
23
24
|
"linear style",
|
|
24
25
|
"like linear",
|
|
25
26
|
"ios settings",
|
package/llms/custom-look.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"custom-look.js","sourceRoot":"","sources":["../../jsr/llms/custom-look.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"custom-look.js","sourceRoot":"","sources":["../../jsr/llms/custom-look.ts"],"names":[],"mappings":"AAeA,MAAM,CAAC,MAAM,gBAAgB,GAAc;IACzC,IAAI,EAAE,aAAa;IACnB,KAAK,EAAE,aAAa;IACpB,WAAW,EACT,gXAAgX;IAClX,IAAI,EAAE;QACJ,aAAa;QACb,iBAAiB;QACjB,eAAe;QACf,eAAe;QACf,cAAc;QACd,aAAa;QACb,YAAY;QACZ,UAAU;QACV,iBAAiB;QACjB,YAAY;QACZ,cAAc;QACd,QAAQ;QACR,eAAe;QACf,cAAc;QACd,cAAc;QACd,gBAAgB;QAChB,kBAAkB;QAClB,iBAAiB;QACjB,cAAc;QACd,aAAa;QACb,cAAc;KACf;CACF,CAAC"}
|
|
@@ -51,8 +51,7 @@ a link to the exact page and uses the words that page uses:
|
|
|
51
51
|
or change a card, and the portal exists once it is saved.
|
|
52
52
|
3. Come back to the app. The Join buttons work in test mode the moment both
|
|
53
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
|
|
55
|
-
payments are open on vibes.diy, press **Go live with Stripe** under
|
|
54
|
+
4. Go live, one app at a time. Once a test payment goes through, press **Go live with Stripe** under
|
|
56
55
|
**Payments** on the Settings tab and finish Stripe's live setup (once, for
|
|
57
56
|
every app). Then press **Take live payments in this app** on this app's
|
|
58
57
|
Setup screen, and save the **Customer portal** once more in live mode at
|
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: load it whenever the person asks for money to move —
|
|
4
|
+
description: "Taking real money through Stripe Checkout: load it whenever the person asks for money to move — pay the bill, split the check, a restaurant check, pay at the table or at the counter, a tip or a tip jar, donate or a donation page, sell tickets, a charge, a deposit, dues, a checkout, pay with Stripe, or the word Stripe — 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): guest checkout by default for paying a venue (a check, a tip, a donation — no sign-in, the payment keyed to the thing paid and its session, written by backend.js for the guest) and a signed-in order where the record is the buyer's to keep (dues on a roster, my orders), one payer paying the whole check by default with splitting as a second choice and the balance reserved with a revision-checked write before any session opens, 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",
|
|
@@ -16,6 +16,16 @@ export const stripeConfig = {
|
|
|
16
16
|
"sell tickets",
|
|
17
17
|
"charge",
|
|
18
18
|
"paywall",
|
|
19
|
+
"pay the bill",
|
|
20
|
+
"split the check",
|
|
21
|
+
"restaurant check",
|
|
22
|
+
"pay at the table",
|
|
23
|
+
"pay at the counter",
|
|
24
|
+
"tip jar",
|
|
25
|
+
"donate",
|
|
26
|
+
"donation",
|
|
27
|
+
"donations",
|
|
28
|
+
"pay with stripe",
|
|
19
29
|
],
|
|
20
30
|
};
|
|
21
31
|
//# sourceMappingURL=stripe.js.map
|
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,miDAAmiD;IACriD,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;QACT,cAAc;QACd,iBAAiB;QACjB,kBAAkB;QAClB,kBAAkB;QAClB,oBAAoB;QACpB,SAAS;QACT,QAAQ;QACR,UAAU;QACV,WAAW;QACX,iBAAiB;KAClB;CACF,CAAC"}
|
package/llms/stripe.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Stripe payments — take real money into the owner's own Stripe account
|
|
2
2
|
|
|
3
|
-
When someone asks to **take money** —
|
|
4
|
-
|
|
3
|
+
When someone asks to **take money** — pay the bill at a table, split the check,
|
|
4
|
+
take tips or donations, charge for a booking, collect dues, sell tickets, take
|
|
5
|
+
a deposit, sell a download — build it on **Stripe
|
|
5
6
|
Checkout** from `backend.js`, through `ctx.stripe` — the owner connects their
|
|
6
7
|
Stripe account once with **Connect Stripe**, and the platform holds the
|
|
7
8
|
credential. This is a `backend.js` feature (see the backend docs for the
|
|
@@ -16,8 +17,9 @@ that way until the person asks to charge for them. What you may do unasked is
|
|
|
16
17
|
**offer** it: on an app that sells, books, collects dues or sells tickets, the
|
|
17
18
|
closing row's capability option is `▸ [pay] Take card payments with Stripe`,
|
|
18
19
|
gold when nothing else fits better, and a tap on it is the ask. Words that are
|
|
19
|
-
the ask: "charge", "pay", "paid", "payment", "
|
|
20
|
-
"
|
|
20
|
+
the ask: "charge", "pay", "paid", "payment", "pay the bill", "split the check",
|
|
21
|
+
"tip", "donate", "deposit", "dues", "tickets", "sell", "checkout", "Stripe".
|
|
22
|
+
Words that are not: "orders",
|
|
21
23
|
"price", "plans", "cart" — those describe records, not money moving.
|
|
22
24
|
|
|
23
25
|
**One-time charges only.** This recipe takes a payment and records that it
|
|
@@ -31,12 +33,30 @@ a monthly club — is the `stripe-subscriptions` skill, which mirrors Stripe's
|
|
|
31
33
|
subscription state into the app and takes the person's cancellations and failed
|
|
32
34
|
renewals into account.
|
|
33
35
|
|
|
34
|
-
**The money is the owner's, and so is the account.** Every
|
|
35
|
-
Stripe account, the one the owner connected; the app is a merchant's page
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
**The money is the owner's, and so is the account.** Every payer pays the same
|
|
37
|
+
Stripe account, the one the owner connected; the app is a merchant's page. An
|
|
38
|
+
app where *each visitor* collects money into *their own* Stripe account is a
|
|
39
|
+
marketplace, a bigger thing than this recipe — say so plainly and build the
|
|
40
|
+
one-merchant half.
|
|
41
|
+
|
|
42
|
+
**Who pays: a guest, unless the record is theirs to keep.**
|
|
43
|
+
|
|
44
|
+
A payment to a venue is guest checkout: the payer needs no Vibes DIY account, the payment record is keyed to the thing being paid and its Checkout Session, and backend.js writes it on the guest's behalf.
|
|
45
|
+
|
|
46
|
+
A restaurant check, a split-the-check page, a tip jar, a donation page, a
|
|
47
|
+
pay-at-the-counter page, event tickets bought on the spot — the payer is a
|
|
48
|
+
guest at the owner's place, and Stripe's own page asks for their email and
|
|
49
|
+
sends the receipt. Security rests on Stripe, not on a sign-in: the backend
|
|
50
|
+
reads every session back from Stripe before anything counts as paid.
|
|
51
|
+
*Guest checkout at a venue* at the end of this page is that recipe, with a
|
|
52
|
+
table's check as the worked example.
|
|
53
|
+
|
|
54
|
+
Sign-in is the shape when the record belongs to a person over time: a member's
|
|
55
|
+
dues on the roster, a booking under their name, a "my orders" or "my tickets"
|
|
56
|
+
page they come back to, and every subscription (the `stripe-subscriptions`
|
|
57
|
+
skill). Then the order carries the buyer's handle, and *Complete backend.js*
|
|
58
|
+
below is that recipe, with club dues as the worked example. Sections 1 to 6
|
|
59
|
+
hold for both.
|
|
40
60
|
|
|
41
61
|
## 1. The setup item: Connect Stripe
|
|
42
62
|
|
|
@@ -59,8 +79,7 @@ a link to the exact page and uses the words that page uses:
|
|
|
59
79
|
2. Come back to the app. The Pay buttons work in test mode the moment Stripe
|
|
60
80
|
says the account is ready, with no redeploy; the card
|
|
61
81
|
`4242 4242 4242 4242` with any future date pays.
|
|
62
|
-
3. Go live, one app at a time. Once a test payment goes through
|
|
63
|
-
payments are open on vibes.diy, press **Go live with Stripe** under
|
|
82
|
+
3. Go live, one app at a time. Once a test payment goes through, press **Go live with Stripe** under
|
|
64
83
|
**Payments** on the Settings tab and finish Stripe's live setup (once, for
|
|
65
84
|
every app). Then press **Take live payments in this app** on this app's
|
|
66
85
|
Setup screen. This step stays on the Setup screen after the item is done,
|
|
@@ -161,11 +180,13 @@ start two purchases.
|
|
|
161
180
|
|
|
162
181
|
## 3. One order, then one Checkout Session
|
|
163
182
|
|
|
164
|
-
The order exists before the money moves.
|
|
165
|
-
signed-in buyer (the page calls it with
|
|
166
|
-
writes an `order` document in `pending`
|
|
167
|
-
amount and the mode it is made in, and
|
|
168
|
-
exactly that order
|
|
183
|
+
The order exists before the money moves. In the signed-in recipe the
|
|
184
|
+
`/checkout` route runs as the signed-in buyer (the page calls it with
|
|
185
|
+
`apiFetch`, so `ctx.userInfo` is set), writes an `order` document in `pending`
|
|
186
|
+
with the buyer's handle, the title, the amount and the mode it is made in, and
|
|
187
|
+
only then asks Stripe for a session for exactly that order. A guest's payment
|
|
188
|
+
goes the same way once it has reserved its share of the check (*Guest
|
|
189
|
+
checkout at a venue*).
|
|
169
190
|
|
|
170
191
|
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
192
|
|
|
@@ -195,8 +216,10 @@ Every order records the mode it was made in, and every ctx.stripe.fetch call for
|
|
|
195
216
|
|
|
196
217
|
The routes read the `orders` database, which `access.js` binds, so `backend.js`
|
|
197
218
|
declares `config.fetch.unfilteredReads` for it — the obligation that comes
|
|
198
|
-
with it is yours:
|
|
199
|
-
before it returns or settles anything,
|
|
219
|
+
with it is yours: in the signed-in recipe every route checks
|
|
220
|
+
`order.buyerHandle` against the caller before it returns or settles anything,
|
|
221
|
+
on a guest page every route answers only about the check and the payment the
|
|
222
|
+
request names, and no route lists orders for anyone.
|
|
200
223
|
The owner's view of every order is the page's own live query, which the
|
|
201
224
|
access function's `orders` channel already scopes to the owner role.
|
|
202
225
|
|
|
@@ -262,9 +285,12 @@ reading Stripe. A buyer never edits an order; they make a new one.
|
|
|
262
285
|
|
|
263
286
|
## 5. What the buyer and the owner each see
|
|
264
287
|
|
|
265
|
-
- A buyer sees their own orders and each one's state — *pending*
|
|
266
|
-
"Finish paying" link that reuses the session URL while it is open,
|
|
267
|
-
with the date, *expired* with a fresh "Pay" button that makes a new
|
|
288
|
+
- A signed-in buyer sees their own orders and each one's state — *pending*
|
|
289
|
+
with a "Finish paying" link that reuses the session URL while it is open,
|
|
290
|
+
*paid* with the date, *expired* with a fresh "Pay" button that makes a new
|
|
291
|
+
order. A guest sees the thing they paid toward: the table's check moving to
|
|
292
|
+
*Paid in full*, the tip jar's total rising, and a thank-you the moment they
|
|
293
|
+
come back from Stripe.
|
|
268
294
|
- The owner sees every order: who, what, how much, when, and a total of what
|
|
269
295
|
has been paid. This is the app's own ledger; it is also in the Stripe
|
|
270
296
|
dashboard, and the two agree because the app only ever copies Stripe's
|
|
@@ -288,6 +314,9 @@ and hand the reason to the page, which reads it as *Checkout is not open yet*.
|
|
|
288
314
|
|
|
289
315
|
## Complete backend.js
|
|
290
316
|
|
|
317
|
+
The signed-in recipe, with a club's dues as the example: the order carries the
|
|
318
|
+
buyer's handle because the roster reads it for the rest of the year.
|
|
319
|
+
|
|
291
320
|
### `backend.js`
|
|
292
321
|
```js app=stripe-dues
|
|
293
322
|
// Stripe Checkout for one merchant: the owner's connected Stripe account, hosted Checkout,
|
|
@@ -723,3 +752,768 @@ per item in `PRICES` and one `pay(kind)` per button. Something that recurs is a
|
|
|
723
752
|
new order each period — next year's dues are next year's `pay("dues")` — and a
|
|
724
753
|
true subscription, where Stripe renews and retries on its own, is the
|
|
725
754
|
`stripe-subscriptions` skill.
|
|
755
|
+
|
|
756
|
+
## Guest checkout at a venue: a table's check
|
|
757
|
+
|
|
758
|
+
A restaurant check is the fullest venue page; a tip jar, a donation page or a
|
|
759
|
+
pay-at-the-counter page is the same recipe with one line and no splitting.
|
|
760
|
+
Staff open a check with its lines, its link goes on the table as a QR code,
|
|
761
|
+
and anyone at the table opens it and pays, with no account.
|
|
762
|
+
|
|
763
|
+
A check is paid by one payer by default: one primary button pays the whole remaining balance plus the tip, and splitting is a second choice the table opens.
|
|
764
|
+
|
|
765
|
+
Below the primary *Pay $X*, a quiet *Split the check* opens the other two
|
|
766
|
+
ways: the lines I had, or an even share of what is left. The page sends
|
|
767
|
+
*what* is paid (`pay: "rest"`, `"lines"` with ids, or `"share"` with how many
|
|
768
|
+
ways) and a tip percentage from its own list; the backend prices it from the
|
|
769
|
+
check.
|
|
770
|
+
|
|
771
|
+
Every record is written by `backend.js`, guest or not: `access.js` takes a
|
|
772
|
+
payment's creation only from `ctx.requireBackend("fetch")` and its settling
|
|
773
|
+
only from the backend, keeps each type at its own ids, and takes the go-live
|
|
774
|
+
record only from the owner. A page writes nothing but the owner's own checks.
|
|
775
|
+
|
|
776
|
+
A guest's write through the route is still a signed-out write: `user` is
|
|
777
|
+
null, `ctx.requireBackend("fetch")` passes, the record returns
|
|
778
|
+
`allowAnonymous: true`, and the app's anonymous-write setting has to admit
|
|
779
|
+
it. So the owner's Setup screen carries a *let guests pay* step: publishing
|
|
780
|
+
from the editor turns the setting on, and after a CLI push the owner runs
|
|
781
|
+
`vibes-diy app anon-write <handle>/<slug> on`, with the real handle and slug
|
|
782
|
+
filled in. With it off, `/checkout` answers `guests-closed` and the page
|
|
783
|
+
reads *Checkout is not open yet*.
|
|
784
|
+
|
|
785
|
+
Before a Checkout Session is created, the payment reserves its share of the check with one revision-checked write.
|
|
786
|
+
|
|
787
|
+
- **One ledger per check per mode** (`ledger:<mode>:<checkId>`) holds what is
|
|
788
|
+
paid, which lines are paid, and what each open payment holds. `/checkout`
|
|
789
|
+
reads it, prices the request against what is free, adds a hold and writes
|
|
790
|
+
it back with `ifRev`; a second payer who moved in the same instant meets
|
|
791
|
+
`conflict`, reads again, and finds the balance held — the answer is
|
|
792
|
+
*someone at the table is paying right now*. So no balance and no line is
|
|
793
|
+
ever in two open sessions.
|
|
794
|
+
- **The payer pays what they were shown.** The page sends the subtotal it
|
|
795
|
+
displayed as `quote`; when the check moved in between, the route reserves
|
|
796
|
+
nothing and answers `quote-changed` with the new amount for the page to
|
|
797
|
+
show.
|
|
798
|
+
- **An even split is fixed once chosen.** The first share writes the split
|
|
799
|
+
onto the ledger: N shares of what was free, the rounding cents on the last
|
|
800
|
+
share. Every later share pays the next open one, so two ways on $100 is $50
|
|
801
|
+
and $50 however the first one ends.
|
|
802
|
+
- **Settling moves the hold.** `/confirm` reads the session back from Stripe;
|
|
803
|
+
paid adds the hold to the ledger's paid total and lines, expired gives it
|
|
804
|
+
back, and the ledger's `settled` map makes a repeat a no-op. A failed
|
|
805
|
+
session create, a payer back from Stripe without paying (`/release` expires
|
|
806
|
+
their session) and a second press of Pay (which replaces the payer's own
|
|
807
|
+
attempt) all give the hold back the same way. A read-back Stripe cannot
|
|
808
|
+
answer right now changes nothing: the hold stays, and a payer replacing
|
|
809
|
+
their own attempt hears *try again in a moment*.
|
|
810
|
+
- **The sweep is the safety net**: every minute it settles payments whose
|
|
811
|
+
payer closed the tab, and anything still pending a day after its
|
|
812
|
+
`createdAt` is expired whatever its session says, since sessions close
|
|
813
|
+
after 30 minutes (`expires_at`).
|
|
814
|
+
- The page reads the ledger for its mode with a live query: paid and left
|
|
815
|
+
move on every phone at once, and *Paid in full* shows at zero.
|
|
816
|
+
|
|
817
|
+
Stripe's page asks the payer for their email and sends the receipt (the owner
|
|
818
|
+
turns receipts on under customer emails in their Stripe Dashboard); the app
|
|
819
|
+
keeps no email and no name. `submit_type: "pay"` makes Stripe's button read
|
|
820
|
+
*Pay*; a donation page passes `"donate"`.
|
|
821
|
+
|
|
822
|
+
### `backend.js`
|
|
823
|
+
```js app=table-check
|
|
824
|
+
// A table's check, paid by guests through Stripe Checkout into the owner's connected account.
|
|
825
|
+
// Anyone with the table's link pays; every record is written here, never by a page.
|
|
826
|
+
export const config = {
|
|
827
|
+
scheduled: { interval: "1m" },
|
|
828
|
+
// Guests have no account, so the routes read unfiltered; each answers only about the check or
|
|
829
|
+
// the payment the request names.
|
|
830
|
+
fetch: { unfilteredReads: { dbs: ["checks"], why: "guests pay a table's check without an account; /checkout, /confirm and /release read only the check, its ledger and the payment the request names" } },
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
const DB = "checks";
|
|
834
|
+
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
835
|
+
const HOLD_SECONDS = 30 * 60; // a Checkout Session closes after 30 minutes
|
|
836
|
+
const TIPS = [0, 15, 18, 20, 25]; // percent of the subtotal
|
|
837
|
+
const LIVE_CHOICE = "setup:live";
|
|
838
|
+
|
|
839
|
+
function json(body, status = 200) {
|
|
840
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function appUrl(ctx, hash) {
|
|
844
|
+
return `https://vibes.diy/vibe/${ctx.appInfo.ownerHandle}/${ctx.appInfo.appSlug}${hash}`;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function checkoutMode(ctx) {
|
|
848
|
+
const choice = await ctx.db.get(LIVE_CHOICE, { db: DB });
|
|
849
|
+
const mode = choice?.type === "setup" && choice.mode === "live" ? "live" : "test";
|
|
850
|
+
let st;
|
|
851
|
+
try {
|
|
852
|
+
st = await ctx.stripe.status();
|
|
853
|
+
} catch (e) {
|
|
854
|
+
ctx.log("warn", "stripe status unavailable", { message: e.message });
|
|
855
|
+
return { mode, open: false, state: "unavailable", liveOpen: false, liveReady: false };
|
|
856
|
+
}
|
|
857
|
+
return { mode, open: st[mode].state === "ready", state: st[mode].state, liveOpen: st.live.available, liveReady: st.live.state === "ready" };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function stripe(ctx, mode, method, path, form, headers = {}) {
|
|
861
|
+
let res;
|
|
862
|
+
try {
|
|
863
|
+
res = await ctx.stripe.fetch(`/v1${path}`, { method, body: form, headers, mode });
|
|
864
|
+
} catch (e) {
|
|
865
|
+
ctx.log("warn", "stripe call refused", { reason: e.reason });
|
|
866
|
+
return { ok: false, reason: e.reason ?? "not_available" };
|
|
867
|
+
}
|
|
868
|
+
const body = await res.json().catch(() => null);
|
|
869
|
+
if (!res.ok) return { ok: false, reason: "stripe", message: body?.error?.message ?? `HTTP ${res.status}` };
|
|
870
|
+
return { ok: true, body };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// One ledger per check per mode holds the money: what is paid, which lines are paid, and what
|
|
874
|
+
// each open payment has reserved. Every change is revision-checked, so two payers can never
|
|
875
|
+
// reserve the same balance or the same line.
|
|
876
|
+
const ledgerId = (checkId, mode) => `ledger:${mode}:${checkId}`;
|
|
877
|
+
|
|
878
|
+
async function changeLedger(ctx, checkId, mode, change) {
|
|
879
|
+
const id = ledgerId(checkId, mode);
|
|
880
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
881
|
+
const stored = await ctx.db.get(id, { db: DB });
|
|
882
|
+
const ledger = stored ?? { _id: id, type: "ledger", checkId, mode, paid: 0, linesPaid: {}, holds: {}, settled: {} };
|
|
883
|
+
const next = change(structuredClone(ledger));
|
|
884
|
+
if (!next) return ledger; // nothing to change, or the change was refused
|
|
885
|
+
try {
|
|
886
|
+
await ctx.db.put(next, { db: DB, id, ifRev: stored ? stored._rev : null });
|
|
887
|
+
return next;
|
|
888
|
+
} catch (err) {
|
|
889
|
+
if (err.code !== "conflict") throw err; // another payer moved first: read again and retry
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
throw new Error("the check is busy");
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// The sum a ledger has free to reserve, and the lines nobody has paid or reserved.
|
|
896
|
+
function freeOf(check, ledger) {
|
|
897
|
+
const total = check.lines.reduce((n, l) => n + l.price, 0);
|
|
898
|
+
const held = Object.values(ledger.holds);
|
|
899
|
+
const taken = new Set([...Object.keys(ledger.linesPaid), ...held.flatMap((h) => h.lines)]);
|
|
900
|
+
return { left: Math.max(0, total - ledger.paid), free: Math.max(0, total - ledger.paid - held.reduce((n, h) => n + h.subtotal, 0)), taken };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// An even split is fixed the first time someone picks it: N shares of what was free, the
|
|
904
|
+
// rounding cents on the last share, so a 2-way split of $100 stays $50 and $50.
|
|
905
|
+
function splitShares(free, ways) {
|
|
906
|
+
const each = Math.floor(free / ways);
|
|
907
|
+
return Array.from({ length: ways }, (_, i) => ({ amount: i === ways - 1 ? free - each * (ways - 1) : each, by: null }));
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Settle one pending payment by what Stripe says: paid moves its hold into the ledger's paid
|
|
911
|
+
// total, expired gives the hold back. /confirm, /release and the sweep all come here. A read-back
|
|
912
|
+
// Stripe could not answer keeps the hold until Stripe can, or until the day is out; `giveUp` is a
|
|
913
|
+
// payment whose session Stripe never opened.
|
|
914
|
+
async function settle(ctx, p, giveUp = false) {
|
|
915
|
+
if (p.status !== "pending") return p;
|
|
916
|
+
let outcome = null;
|
|
917
|
+
if (p.sessionId) {
|
|
918
|
+
const session = await stripe(ctx, p.mode, "GET", `/checkout/sessions/${p.sessionId}`);
|
|
919
|
+
const s = session.ok ? session.body : null;
|
|
920
|
+
const matches = s && s.client_reference_id === p._id && s.amount_total === p.amount && s.currency === p.currency;
|
|
921
|
+
if (s && !matches) ctx.log("warn", "stripe session does not match its payment", { paymentId: p._id });
|
|
922
|
+
if (matches && s.payment_status === "paid") outcome = "paid";
|
|
923
|
+
else if (matches && s.status === "expired") outcome = "expired";
|
|
924
|
+
}
|
|
925
|
+
// Sessions close after 30 minutes, so a payment still pending a day later is over, whatever it holds.
|
|
926
|
+
if (outcome === null && (giveUp || Date.now() - p.createdAt > SESSION_TTL_MS)) outcome = "expired";
|
|
927
|
+
if (outcome === null) return p;
|
|
928
|
+
await changeLedger(ctx, p.checkId, p.mode, (ledger) => {
|
|
929
|
+
if (ledger.settled[p._id]) return null; // this payment already counted
|
|
930
|
+
delete ledger.holds[p.holdId];
|
|
931
|
+
ledger.settled[p._id] = outcome;
|
|
932
|
+
const share = ledger.split?.shares[p.share];
|
|
933
|
+
if (share) share.by = outcome === "paid" ? p._id : null;
|
|
934
|
+
if (outcome === "paid") {
|
|
935
|
+
ledger.paid += p.subtotal;
|
|
936
|
+
for (const id of p.lines) ledger.linesPaid[id] = p._id;
|
|
937
|
+
}
|
|
938
|
+
return ledger;
|
|
939
|
+
});
|
|
940
|
+
const next = outcome === "paid" ? { ...p, status: "paid", paidAt: Date.now() } : { ...p, status: "expired" };
|
|
941
|
+
try {
|
|
942
|
+
await ctx.db.put(next, { db: DB, id: p._id, ifRev: p._rev });
|
|
943
|
+
} catch (err) {
|
|
944
|
+
if (err.code !== "conflict") throw err;
|
|
945
|
+
const stored = await ctx.db.get(p._id, { db: DB }); // another call settled it first
|
|
946
|
+
if (stored && stored.status !== "pending") return stored;
|
|
947
|
+
throw err;
|
|
948
|
+
}
|
|
949
|
+
return next;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// A payer who came back without paying lets go of what they reserved, straight away.
|
|
953
|
+
async function release(ctx, paymentId) {
|
|
954
|
+
const p = typeof paymentId === "string" ? await ctx.db.get(paymentId, { db: DB }) : null;
|
|
955
|
+
if (p?.type !== "payment" || p.status !== "pending") return null;
|
|
956
|
+
if (p.sessionId) await stripe(ctx, p.mode, "POST", `/checkout/sessions/${p.sessionId}/expire`);
|
|
957
|
+
return settle(ctx, p);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export async function fetch(request, ctx) {
|
|
961
|
+
const url = new URL(request.url);
|
|
962
|
+
|
|
963
|
+
if (url.pathname === "/setup" && request.method === "GET") {
|
|
964
|
+
const owner = ctx.userInfo?.userHandle === ctx.appInfo.ownerHandle;
|
|
965
|
+
const { mode, open, state, liveOpen, liveReady } = await checkoutMode(ctx);
|
|
966
|
+
const item = { id: "stripe-connect", label: "Connect Stripe", done: open, state };
|
|
967
|
+
if (mode === "live" && !liveOpen) item.problem = "Vibes DIY has paused live payments for now; checkout reopens here by itself when they return.";
|
|
968
|
+
const items = [item];
|
|
969
|
+
const ready = items.every((i) => i.done);
|
|
970
|
+
if (!owner) return json({ ok: true, ready, owner, mode });
|
|
971
|
+
return json({ ok: true, ready, owner, mode, liveOpen, liveReady, items, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// The owner, and only the owner, takes this app live.
|
|
975
|
+
if (url.pathname === "/go-live" && request.method === "POST") {
|
|
976
|
+
if (ctx.userInfo?.userHandle !== ctx.appInfo.ownerHandle) return json({ ok: false, reason: "owner-only" }, 403);
|
|
977
|
+
const { liveReady } = await checkoutMode(ctx);
|
|
978
|
+
if (!liveReady) return json({ ok: false, reason: "not_ready" }, 409);
|
|
979
|
+
const chosen = await ctx.db.get(LIVE_CHOICE, { db: DB });
|
|
980
|
+
if (!chosen) await ctx.db.put({ _id: LIVE_CHOICE, type: "setup", mode: "live", chosenAt: Date.now() }, { db: DB, id: LIVE_CHOICE });
|
|
981
|
+
return json({ ok: true, mode: "live" });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Pay toward a check. Nobody signs in: the payment is keyed to the check and its session.
|
|
985
|
+
if (url.pathname === "/checkout" && request.method === "POST") {
|
|
986
|
+
const { mode, open, state } = await checkoutMode(ctx);
|
|
987
|
+
const { checkId, pay, lineIds, ways, quote, tipPercent, replaces, mode: shown } = await request.json().catch(() => ({}));
|
|
988
|
+
if (shown !== mode) return json({ ok: false, reason: "mode-changed", mode }, 409);
|
|
989
|
+
if (!open) return json({ ok: false, reason: state === "disconnected" ? "account_disconnected" : "not_ready" }, 503);
|
|
990
|
+
const check = typeof checkId === "string" ? await ctx.db.get(checkId, { db: DB }) : null;
|
|
991
|
+
if (check?.type !== "check") return json({ ok: false, reason: "not-found" }, 404);
|
|
992
|
+
// The payer's own earlier attempt lets go first; while Stripe cannot say how it ended, it keeps its hold.
|
|
993
|
+
const earlier = await release(ctx, replaces);
|
|
994
|
+
if (earlier?.status === "pending") return json({ ok: false, reason: "try-again" }, 503);
|
|
995
|
+
|
|
996
|
+
// Reserve before Stripe hears of it: one revision-checked ledger write, priced from the check,
|
|
997
|
+
// at exactly the amount the payer was shown.
|
|
998
|
+
const id = "pay-" + crypto.randomUUID();
|
|
999
|
+
const holdId = crypto.randomUUID();
|
|
1000
|
+
let hold = null;
|
|
1001
|
+
let refusal = { reason: "being-paid" };
|
|
1002
|
+
const refused = await changeLedger(ctx, check._id, mode, (ledger) => {
|
|
1003
|
+
const { left, free, taken } = freeOf(check, ledger);
|
|
1004
|
+
let lines = [];
|
|
1005
|
+
let share;
|
|
1006
|
+
let subtotal = free; // the default: everything still free on the check
|
|
1007
|
+
if (pay === "lines") {
|
|
1008
|
+
lines = [...new Set(Array.isArray(lineIds) ? lineIds : [])].map((lid) => check.lines.find((l) => l.id === lid));
|
|
1009
|
+
if (lines.length === 0 || lines.some((l) => !l || taken.has(l.id))) {
|
|
1010
|
+
refusal = { reason: "taken" };
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
subtotal = lines.reduce((n, l) => n + l.price, 0);
|
|
1014
|
+
} else if (pay === "share") {
|
|
1015
|
+
if (!ledger.split) {
|
|
1016
|
+
const n = Math.min(20, Math.max(2, Math.round(Number(ways) || 2)));
|
|
1017
|
+
ledger.split = { ways: n, shares: splitShares(free, n) };
|
|
1018
|
+
}
|
|
1019
|
+
share = ledger.split.shares.findIndex((s) => s.by === null);
|
|
1020
|
+
subtotal = share < 0 ? 0 : ledger.split.shares[share].amount;
|
|
1021
|
+
}
|
|
1022
|
+
if (left === 0) refusal = { reason: "paid-in-full" };
|
|
1023
|
+
if (subtotal <= 0 || subtotal > free) return null;
|
|
1024
|
+
if (subtotal !== quote) {
|
|
1025
|
+
refusal = { reason: "quote-changed", quote: subtotal }; // the check moved since the page drew it
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
hold = { subtotal, lines: lines.map((l) => l.id), share: share ?? null };
|
|
1029
|
+
ledger.holds[holdId] = hold;
|
|
1030
|
+
if (share !== undefined) ledger.split.shares[share].by = holdId;
|
|
1031
|
+
return ledger;
|
|
1032
|
+
}).then(
|
|
1033
|
+
() => null,
|
|
1034
|
+
(err) => err
|
|
1035
|
+
);
|
|
1036
|
+
if (refused) {
|
|
1037
|
+
// Most often the app's anonymous-write setting is off, so a guest's payment cannot be recorded.
|
|
1038
|
+
ctx.log("warn", "check reservation refused", { code: refused.code, message: refused.message });
|
|
1039
|
+
return json({ ok: false, reason: "guests-closed" }, 503);
|
|
1040
|
+
}
|
|
1041
|
+
if (!hold) return json({ ok: false, ...refusal }, 409);
|
|
1042
|
+
|
|
1043
|
+
const tip = TIPS.includes(tipPercent) ? tipPercent : 0;
|
|
1044
|
+
const tipAmount = Math.round((hold.subtotal * tip) / 100);
|
|
1045
|
+
const payment = {
|
|
1046
|
+
_id: id,
|
|
1047
|
+
type: "payment",
|
|
1048
|
+
checkId: check._id,
|
|
1049
|
+
holdId,
|
|
1050
|
+
lines: hold.lines,
|
|
1051
|
+
share: hold.share,
|
|
1052
|
+
subtotal: hold.subtotal,
|
|
1053
|
+
tip: tipAmount,
|
|
1054
|
+
amount: hold.subtotal + tipAmount,
|
|
1055
|
+
currency: check.currency,
|
|
1056
|
+
mode,
|
|
1057
|
+
status: "pending",
|
|
1058
|
+
createdAt: Date.now(),
|
|
1059
|
+
};
|
|
1060
|
+
await ctx.db.put(payment, { db: DB, id, ifRev: null });
|
|
1061
|
+
|
|
1062
|
+
const what = pay === "lines" ? check.lines.filter((l) => hold.lines.includes(l.id)).map((l) => l.name).join(", ").slice(0, 200) : pay === "share" ? "A share of the check" : "The check";
|
|
1063
|
+
const form = new URLSearchParams({
|
|
1064
|
+
mode: "payment",
|
|
1065
|
+
submit_type: "pay",
|
|
1066
|
+
"line_items[0][quantity]": "1",
|
|
1067
|
+
"line_items[0][price_data][currency]": check.currency,
|
|
1068
|
+
"line_items[0][price_data][unit_amount]": String(hold.subtotal),
|
|
1069
|
+
"line_items[0][price_data][product_data][name]": `${check.table} · ${what}`,
|
|
1070
|
+
client_reference_id: id,
|
|
1071
|
+
"metadata[checkId]": check._id,
|
|
1072
|
+
expires_at: String(Math.floor(Date.now() / 1000) + HOLD_SECONDS),
|
|
1073
|
+
success_url: appUrl(ctx, `#check=${check._id}&paid=${id}`),
|
|
1074
|
+
cancel_url: appUrl(ctx, `#check=${check._id}&cancelled=${id}`),
|
|
1075
|
+
});
|
|
1076
|
+
if (tipAmount > 0) {
|
|
1077
|
+
form.set("line_items[1][quantity]", "1");
|
|
1078
|
+
form.set("line_items[1][price_data][currency]", check.currency);
|
|
1079
|
+
form.set("line_items[1][price_data][unit_amount]", String(tipAmount));
|
|
1080
|
+
form.set("line_items[1][price_data][product_data][name]", `Tip (${tip}%)`);
|
|
1081
|
+
}
|
|
1082
|
+
const session = await stripe(ctx, mode, "POST", "/checkout/sessions", form, { "idempotency-key": id });
|
|
1083
|
+
if (!session.ok) {
|
|
1084
|
+
await settle(ctx, await ctx.db.get(id, { db: DB }), true); // no session: give the hold back now
|
|
1085
|
+
return json(session, session.reason === "stripe" ? 502 : 503);
|
|
1086
|
+
}
|
|
1087
|
+
const stored = await ctx.db.get(id, { db: DB });
|
|
1088
|
+
await ctx.db.put({ ...stored, sessionId: session.body.id }, { db: DB, id, ifRev: stored._rev });
|
|
1089
|
+
return json({ ok: true, paymentId: id, url: session.body.url });
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// The payer is back: settle by what Stripe says, and answer from it.
|
|
1093
|
+
if (url.pathname === "/confirm" && request.method === "POST") {
|
|
1094
|
+
const { paymentId } = await request.json().catch(() => ({}));
|
|
1095
|
+
const p = typeof paymentId === "string" ? await ctx.db.get(paymentId, { db: DB }) : null;
|
|
1096
|
+
if (p?.type !== "payment") return json({ ok: false, reason: "not-found" }, 404);
|
|
1097
|
+
const settled = await settle(ctx, p);
|
|
1098
|
+
return json({ ok: true, state: settled.status });
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (url.pathname === "/release" && request.method === "POST") {
|
|
1102
|
+
const { paymentId } = await request.json().catch(() => ({}));
|
|
1103
|
+
await release(ctx, paymentId);
|
|
1104
|
+
return json({ ok: true });
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
return json({ ok: false, reason: "not-found" }, 404);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// The safety net: a payer who paid and closed the tab, or walked away from Stripe's page, is
|
|
1111
|
+
// settled within a minute, and their hold comes back to the table.
|
|
1112
|
+
export async function scheduled(event, ctx) {
|
|
1113
|
+
let after;
|
|
1114
|
+
let settled = 0;
|
|
1115
|
+
do {
|
|
1116
|
+
const page = await ctx.db.query({ db: DB, field: "status", key: "pending", limit: 100, after });
|
|
1117
|
+
for (const p of page) {
|
|
1118
|
+
if (p.type !== "payment") continue;
|
|
1119
|
+
const next = await settle(ctx, p);
|
|
1120
|
+
if (next !== p) settled += 1;
|
|
1121
|
+
}
|
|
1122
|
+
after = page.next;
|
|
1123
|
+
} while (after);
|
|
1124
|
+
ctx.log("info", "check sweep", { settled, at: event.scheduledTime });
|
|
1125
|
+
}
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
### `access.js`
|
|
1129
|
+
```js app=table-check
|
|
1130
|
+
export function checks(doc, oldDoc, user, ctx) {
|
|
1131
|
+
const table = (checkId) => {
|
|
1132
|
+
if (typeof checkId !== "string" || !/^check-[\w-]+$/.test(checkId)) throw { forbidden: "a record names its check" };
|
|
1133
|
+
return `check:${checkId}`;
|
|
1134
|
+
};
|
|
1135
|
+
// Each type owns its ids, so no record can stand in for another.
|
|
1136
|
+
const idFor = { setup: "setup:live", check: doc._id?.startsWith("check-") && doc._id, payment: doc._id?.startsWith("pay-") && doc._id, ledger: `ledger:${doc.mode}:${doc.checkId}` };
|
|
1137
|
+
if (!(doc.type in idFor) || doc._id !== idFor[doc.type]) throw { forbidden: "that id belongs to another kind of record" };
|
|
1138
|
+
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "a record keeps its type" };
|
|
1139
|
+
|
|
1140
|
+
// The owner's go-live choice: backend.js writes it on the owner's own /go-live call.
|
|
1141
|
+
if (doc.type === "setup") {
|
|
1142
|
+
ctx.requireBackend("fetch");
|
|
1143
|
+
ctx.requireRole("owner");
|
|
1144
|
+
if (doc.mode !== "live") throw { forbidden: "the setup record says live" };
|
|
1145
|
+
return { channels: ["admin"], grant: { roles: { owner: ["admin"] } } };
|
|
1146
|
+
}
|
|
1147
|
+
// A check: the owner opens it with its lines, and anyone with the table's link reads it.
|
|
1148
|
+
if (doc.type === "check") {
|
|
1149
|
+
ctx.requireRole("owner");
|
|
1150
|
+
const priced = Array.isArray(doc.lines) && doc.lines.every((l) => typeof l.id === "string" && Number.isInteger(l.price) && l.price > 0);
|
|
1151
|
+
if (!priced) throw { forbidden: "each line has an id and a whole-cent price" };
|
|
1152
|
+
if (oldDoc && oldDoc.lines.some((l) => !doc.lines.some((m) => m.id === l.id && m.price === l.price))) {
|
|
1153
|
+
throw { forbidden: "a check keeps the lines it opened with; add a new line instead" };
|
|
1154
|
+
}
|
|
1155
|
+
return { channels: [table(doc._id)], grant: { public: [table(doc._id)] } };
|
|
1156
|
+
}
|
|
1157
|
+
if (doc.mode !== "test" && doc.mode !== "live") throw { forbidden: "money records name their mode" };
|
|
1158
|
+
// A payment: /checkout writes it pending for a guest, and only backend.js settles it.
|
|
1159
|
+
if (doc.type === "payment") {
|
|
1160
|
+
if (oldDoc) {
|
|
1161
|
+
ctx.requireBackend(); // /confirm, /release or the sweep, each after reading Stripe
|
|
1162
|
+
if (doc.checkId !== oldDoc.checkId || doc.amount !== oldDoc.amount || doc.currency !== oldDoc.currency || doc.mode !== oldDoc.mode || doc.holdId !== oldDoc.holdId) {
|
|
1163
|
+
throw { forbidden: "a payment keeps its check, its amount, its mode and its hold" };
|
|
1164
|
+
}
|
|
1165
|
+
} else {
|
|
1166
|
+
ctx.requireBackend("fetch");
|
|
1167
|
+
if (doc.status !== "pending" || !Number.isInteger(doc.amount) || !Number.isInteger(doc.createdAt)) {
|
|
1168
|
+
throw { forbidden: "a payment starts pending, in whole cents, with the time it was made" };
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
table(doc.checkId);
|
|
1172
|
+
// allowAnonymous: backend.js writes it while serving a guest who has no account.
|
|
1173
|
+
return { channels: ["payments"], grant: { roles: { owner: ["payments"] } }, allowAnonymous: true };
|
|
1174
|
+
}
|
|
1175
|
+
// The check's ledger in one mode: written by backend.js alone, read by the whole table.
|
|
1176
|
+
ctx.requireBackend();
|
|
1177
|
+
if (!Number.isInteger(doc.paid) || doc.paid < 0) throw { forbidden: "a ledger counts whole cents" };
|
|
1178
|
+
return { channels: [table(doc.checkId)], allowAnonymous: true };
|
|
1179
|
+
}
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
### `App.jsx`
|
|
1183
|
+
```jsx app=table-check
|
|
1184
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
1185
|
+
import { useFireproof } from "use-fireproof";
|
|
1186
|
+
import { apiFetch } from "use-vibes";
|
|
1187
|
+
import { Badge, Button, Field, Figure, Heading, Input, Metric, Row, Segmented, Stack, Text } from "@vibes.diy/pouf";
|
|
1188
|
+
import { Checkbox } from "@vibes.diy/pouf/interactive";
|
|
1189
|
+
|
|
1190
|
+
const money = (cents) => `$${(cents / 100).toFixed(2)}`;
|
|
1191
|
+
const post = (body) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
1192
|
+
const TIPS = [
|
|
1193
|
+
{ value: "0", label: "No tip" },
|
|
1194
|
+
{ value: "15", label: "15%" },
|
|
1195
|
+
{ value: "18", label: "18%" },
|
|
1196
|
+
{ value: "20", label: "20%" },
|
|
1197
|
+
{ value: "25", label: "25%" },
|
|
1198
|
+
];
|
|
1199
|
+
|
|
1200
|
+
// Where a check stands in this mode, read off its ledger, which backend.js alone writes.
|
|
1201
|
+
function useStanding(check, checkId, mode) {
|
|
1202
|
+
const { useLiveQuery } = useFireproof("checks");
|
|
1203
|
+
const { docs } = useLiveQuery("_id", { key: `ledger:${mode}:${checkId}` });
|
|
1204
|
+
const ledger = docs[0];
|
|
1205
|
+
const total = check ? check.lines.reduce((n, l) => n + l.price, 0) : 0;
|
|
1206
|
+
const paid = Math.min(total, ledger?.paid ?? 0);
|
|
1207
|
+
const holds = Object.values(ledger?.holds ?? {});
|
|
1208
|
+
const free = Math.max(0, total - paid - holds.reduce((n, h) => n + h.subtotal, 0));
|
|
1209
|
+
const taken = new Set([...Object.keys(ledger?.linesPaid ?? {}), ...holds.flatMap((h) => h.lines)]);
|
|
1210
|
+
// An even split, once someone picks it, is fixed on the ledger: the next open share's amount.
|
|
1211
|
+
const split = ledger?.split;
|
|
1212
|
+
const share = (ways) => (split ? (split.shares.find((s) => s.by === null)?.amount ?? 0) : Math.floor(free / ways));
|
|
1213
|
+
return { total, paid, left: total - paid, free, taken, paidLines: new Set(Object.keys(ledger?.linesPaid ?? {})), busy: holds.length > 0, split, share };
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// The setup verdict, read on mount and again whenever the app comes back into view.
|
|
1217
|
+
function useSetup() {
|
|
1218
|
+
const [setup, setSetup] = useState(null);
|
|
1219
|
+
const latest = useRef(0);
|
|
1220
|
+
useEffect(() => {
|
|
1221
|
+
const load = () => {
|
|
1222
|
+
const n = ++latest.current;
|
|
1223
|
+
apiFetch("/setup").then((r) => r.json()).catch(() => ({ ready: false, owner: false })).then((s) => { if (n === latest.current) setSetup(s); });
|
|
1224
|
+
};
|
|
1225
|
+
load();
|
|
1226
|
+
const onVisible = () => { if (document.visibilityState === "visible") load(); };
|
|
1227
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
1228
|
+
window.addEventListener("focus", load);
|
|
1229
|
+
window.addEventListener("setup-changed", load);
|
|
1230
|
+
return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); window.removeEventListener("setup-changed", load); };
|
|
1231
|
+
}, []);
|
|
1232
|
+
return setup;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const STRIPE_BUTTON = { absent: "Connect Stripe", onboarding: "Finish Stripe setup", disconnected: "Connect Stripe again" };
|
|
1236
|
+
|
|
1237
|
+
// The owner's Setup surface: Connect Stripe, the go-live step, and guests' writes for this app.
|
|
1238
|
+
function SetupScreen({ setup }) {
|
|
1239
|
+
const vibe = `${setup.ownerHandle}/${setup.appSlug}`;
|
|
1240
|
+
const settingsUrl = `https://vibes.diy/vibes/mine/${vibe}/settings`;
|
|
1241
|
+
const item = setup.items.find((i) => i.id === "stripe-connect");
|
|
1242
|
+
const live = setup.mode === "live";
|
|
1243
|
+
const takeLive = () => apiFetch("/go-live", { method: "POST" }).then(() => window.dispatchEvent(new Event("setup-changed")));
|
|
1244
|
+
return (
|
|
1245
|
+
<Stack>
|
|
1246
|
+
<Heading>Setup</Heading>
|
|
1247
|
+
<Row gap={2}>
|
|
1248
|
+
<Text>{item.label}</Text>
|
|
1249
|
+
<Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : item.state === "onboarding" ? "Stripe is finishing up" : "To do"}</Badge>
|
|
1250
|
+
</Row>
|
|
1251
|
+
{item.problem && <Text muted>{item.problem}</Text>}
|
|
1252
|
+
<ol>
|
|
1253
|
+
<li>
|
|
1254
|
+
Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings)
|
|
1255
|
+
and press <b>{STRIPE_BUTTON[item.state] ?? "Connect Stripe"}</b> under <b>Payments</b>. Stripe opens its own setup pages.
|
|
1256
|
+
</li>
|
|
1257
|
+
<li>
|
|
1258
|
+
Let guests pay without an account: publishing from the editor turns this on, and after a CLI push run{" "}
|
|
1259
|
+
<b>vibes-diy app anon-write {vibe} on</b>. The page never writes for them; this app's server records each payment.
|
|
1260
|
+
</li>
|
|
1261
|
+
<li>
|
|
1262
|
+
Come back here: tables can pay as soon as Stripe says your account is ready. In test mode the card 4242 4242 4242 4242 with any future date pays.
|
|
1263
|
+
</li>
|
|
1264
|
+
<li>
|
|
1265
|
+
<Badge tone={live ? "up" : "info"}>{live ? "Live" : "Test mode"}</Badge>{" "}
|
|
1266
|
+
{live ? (
|
|
1267
|
+
<>This app takes real payments into your Stripe account; refunds and payouts are in your Stripe Dashboard.</>
|
|
1268
|
+
) : setup.liveReady ? (
|
|
1269
|
+
<>Your live Stripe account is ready. <Button onClick={takeLive}>Take live payments in this app</Button></>
|
|
1270
|
+
) : setup.liveOpen ? (
|
|
1271
|
+
<>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>, then come back here to take this app live.</>
|
|
1272
|
+
) : (
|
|
1273
|
+
<>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.</>
|
|
1274
|
+
)}
|
|
1275
|
+
</li>
|
|
1276
|
+
</ol>
|
|
1277
|
+
</Stack>
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// One open check on the owner's list: its table link as a QR code, and what is paid and left.
|
|
1282
|
+
function CheckRow({ check, setup }) {
|
|
1283
|
+
const s = useStanding(check, check._id, setup.mode);
|
|
1284
|
+
const link = `https://vibes.diy/vibe/${setup.ownerHandle}/${setup.appSlug}#check=${check._id}`;
|
|
1285
|
+
return (
|
|
1286
|
+
<Stack>
|
|
1287
|
+
<Row gap={2}>
|
|
1288
|
+
<Heading level={3}>{check.table}</Heading>
|
|
1289
|
+
{s.left === 0 ? <Badge tone="up">Paid in full</Badge> : <Badge tone="info">{money(s.left)} left</Badge>}
|
|
1290
|
+
</Row>
|
|
1291
|
+
<Text muted>{money(s.paid)} of {money(s.total)} paid · {link}</Text>
|
|
1292
|
+
<Figure src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(link)}`} alt={`QR code for ${check.table}`} width={200} height={200} />
|
|
1293
|
+
</Stack>
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// The owner opens a check: a table name and its lines, prices kept in whole cents.
|
|
1298
|
+
function Tables({ setup }) {
|
|
1299
|
+
const { database, useLiveQuery } = useFireproof("checks");
|
|
1300
|
+
const { docs: checks } = useLiveQuery("type", { key: "check" });
|
|
1301
|
+
const [table, setTable] = useState("");
|
|
1302
|
+
const [lines, setLines] = useState([]);
|
|
1303
|
+
const [name, setName] = useState("");
|
|
1304
|
+
const [price, setPrice] = useState("");
|
|
1305
|
+
const addLine = () => {
|
|
1306
|
+
const cents = Math.round(Number(price) * 100);
|
|
1307
|
+
if (!name.trim() || !(cents > 0)) return;
|
|
1308
|
+
setLines([...lines, { id: crypto.randomUUID().slice(0, 8), name: name.trim(), price: cents }]);
|
|
1309
|
+
setName("");
|
|
1310
|
+
setPrice("");
|
|
1311
|
+
};
|
|
1312
|
+
const openCheck = () => {
|
|
1313
|
+
const check = { _id: "check-" + crypto.randomUUID(), type: "check", table: table.trim(), lines, currency: "usd", openedAt: Date.now() };
|
|
1314
|
+
// The form empties as the owner presses Open; the draft comes back only if the write is refused.
|
|
1315
|
+
setTable("");
|
|
1316
|
+
setLines([]);
|
|
1317
|
+
database.put(check).catch(() => {
|
|
1318
|
+
setTable(check.table);
|
|
1319
|
+
setLines(check.lines);
|
|
1320
|
+
});
|
|
1321
|
+
};
|
|
1322
|
+
return (
|
|
1323
|
+
<Stack>
|
|
1324
|
+
<Heading>Open a check</Heading>
|
|
1325
|
+
<Field label="Table"><Input value={table} onChange={(e) => setTable(e.target.value)} placeholder="Table 4" /></Field>
|
|
1326
|
+
<Row gap={2}>
|
|
1327
|
+
<Field label="Item"><Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Margherita" /></Field>
|
|
1328
|
+
<Field label="Price"><Input value={price} onChange={(e) => setPrice(e.target.value)} placeholder="14.50" inputMode="decimal" /></Field>
|
|
1329
|
+
<Button variant="quiet" onClick={addLine}>Add item</Button>
|
|
1330
|
+
</Row>
|
|
1331
|
+
{lines.map((l) => <Text key={l.id}>{l.name} · {money(l.price)}</Text>)}
|
|
1332
|
+
<Button onClick={openCheck} disabled={!table.trim() || lines.length === 0}>Open the check</Button>
|
|
1333
|
+
{checks.map((c) => <CheckRow key={c._id} check={c} setup={setup} />)}
|
|
1334
|
+
</Stack>
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const SORRY = {
|
|
1339
|
+
"being-paid": "Someone at the table is paying right now. This page updates the moment they finish.",
|
|
1340
|
+
"paid-in-full": "This check is paid in full.",
|
|
1341
|
+
taken: "Someone is paying for one of those. Pick again from what is left.",
|
|
1342
|
+
"mode-changed": "Payments here just changed mode. Refresh the page and press Pay again.",
|
|
1343
|
+
"try-again": "Stripe did not answer just now. Press Pay again in a moment.",
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
// The table's page: one button pays everything that is left, and splitting is there for the table that wants it.
|
|
1347
|
+
function TableCheck({ checkId, setup }) {
|
|
1348
|
+
const { useLiveQuery } = useFireproof("checks");
|
|
1349
|
+
const { docs: found } = useLiveQuery("_id", { key: checkId });
|
|
1350
|
+
const check = found.find((d) => d.type === "check");
|
|
1351
|
+
const s = useStanding(check, checkId, setup?.mode);
|
|
1352
|
+
const [splitting, setSplitting] = useState(false);
|
|
1353
|
+
const [how, setHow] = useState("lines");
|
|
1354
|
+
const [picked, setPicked] = useState([]);
|
|
1355
|
+
const [ways, setWays] = useState("2");
|
|
1356
|
+
const [tip, setTip] = useState("18");
|
|
1357
|
+
const [mine, setMine] = useState(null); // this payer's latest payment: { id, state }
|
|
1358
|
+
const [busy, setBusy] = useState(false);
|
|
1359
|
+
const [error, setError] = useState(null);
|
|
1360
|
+
|
|
1361
|
+
// Back from Stripe: /confirm settles the payment by what Stripe says, and the ledger moves for everyone.
|
|
1362
|
+
useEffect(() => {
|
|
1363
|
+
const back = () => {
|
|
1364
|
+
const h = new URLSearchParams(location.hash.slice(1));
|
|
1365
|
+
const paid = h.get("paid");
|
|
1366
|
+
const cancelled = h.get("cancelled");
|
|
1367
|
+
if (!paid && !cancelled) return;
|
|
1368
|
+
history.replaceState(null, "", `${location.pathname}#check=${checkId}`);
|
|
1369
|
+
if (cancelled) {
|
|
1370
|
+
apiFetch("/release", post({ paymentId: cancelled }));
|
|
1371
|
+
setMine(null);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
setMine({ id: paid, state: "pending" });
|
|
1375
|
+
apiFetch("/confirm", post({ paymentId: paid })).then((r) => r.json()).then((d) => setMine({ id: paid, state: d.state ?? "pending" }));
|
|
1376
|
+
};
|
|
1377
|
+
back();
|
|
1378
|
+
window.addEventListener("hashchange", back);
|
|
1379
|
+
return () => window.removeEventListener("hashchange", back);
|
|
1380
|
+
}, [checkId]);
|
|
1381
|
+
|
|
1382
|
+
const withTip = (cents) => cents + Math.round((cents * Number(tip)) / 100);
|
|
1383
|
+
// `quote` is the subtotal this page shows; the backend charges exactly that or answers with the new one.
|
|
1384
|
+
const pay = async (what) => {
|
|
1385
|
+
setError(null);
|
|
1386
|
+
const tab = window.open("", "_blank"); // opened inside the click, before any await
|
|
1387
|
+
if (!tab) {
|
|
1388
|
+
setError("Your browser blocked the Stripe tab. Allow pop-ups for this page and press Pay again.");
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
setBusy(true);
|
|
1392
|
+
try {
|
|
1393
|
+
const replaces = mine?.state === "pending" ? mine.id : undefined;
|
|
1394
|
+
const res = await apiFetch("/checkout", post({ checkId, ...what, tipPercent: Number(tip), replaces, mode: setup.mode }));
|
|
1395
|
+
const data = await res.json();
|
|
1396
|
+
if (!data.ok) {
|
|
1397
|
+
tab.close();
|
|
1398
|
+
setError(
|
|
1399
|
+
data.reason === "stripe" ? data.message
|
|
1400
|
+
: data.reason === "quote-changed" ? `The check just changed: your part is now ${money(withTip(data.quote))}. Press Pay again to pay that.`
|
|
1401
|
+
: SORRY[data.reason] ?? "Checkout is not open yet."
|
|
1402
|
+
);
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
setMine({ id: data.paymentId, state: "pending" });
|
|
1406
|
+
tab.location.href = data.url;
|
|
1407
|
+
} finally {
|
|
1408
|
+
setBusy(false);
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
if (!check) return <Text muted>Finding this table's check…</Text>;
|
|
1413
|
+
const open = check.lines.filter((l) => !s.taken.has(l.id));
|
|
1414
|
+
const pickedTotal = open.filter((l) => picked.includes(l.id)).reduce((n, l) => n + l.price, 0);
|
|
1415
|
+
const share = s.share(Number(ways));
|
|
1416
|
+
return (
|
|
1417
|
+
<Stack>
|
|
1418
|
+
<Heading>{check.table}</Heading>
|
|
1419
|
+
<Row gap={2}>
|
|
1420
|
+
<Metric label="Total" value={money(s.total)} />
|
|
1421
|
+
<Metric label="Paid" value={money(s.paid)} />
|
|
1422
|
+
<Metric label="Left" value={money(s.left)} />
|
|
1423
|
+
</Row>
|
|
1424
|
+
{check.lines.map((l) => (
|
|
1425
|
+
<Row key={l.id} gap={2}>
|
|
1426
|
+
<Text>{l.name}</Text>
|
|
1427
|
+
<Text num>{money(l.price)}</Text>
|
|
1428
|
+
{s.paidLines.has(l.id) && <Badge tone="up">Paid</Badge>}
|
|
1429
|
+
</Row>
|
|
1430
|
+
))}
|
|
1431
|
+
{mine?.state === "paid" && <Badge tone="up">Paid. Thank you!</Badge>}
|
|
1432
|
+
{s.busy && mine?.state !== "pending" && <Text muted>Someone at the table is paying right now.</Text>}
|
|
1433
|
+
{s.left === 0 ? (
|
|
1434
|
+
<Badge tone="up">Paid in full</Badge>
|
|
1435
|
+
) : !setup?.ready ? (
|
|
1436
|
+
<Text muted>Checkout is not open yet.</Text>
|
|
1437
|
+
) : (
|
|
1438
|
+
<Stack>
|
|
1439
|
+
{setup.mode === "test" && (
|
|
1440
|
+
<Row gap={2}>
|
|
1441
|
+
<Badge tone="info">Test mode</Badge>
|
|
1442
|
+
<Text muted>{setup.owner ? "Pay with the test card 4242 4242 4242 4242 and any future date." : "This is a rehearsal: test payments end when the app goes live."}</Text>
|
|
1443
|
+
</Row>
|
|
1444
|
+
)}
|
|
1445
|
+
<Segmented label="Tip" value={tip} onChange={setTip} options={TIPS} />
|
|
1446
|
+
{!splitting ? (
|
|
1447
|
+
<Stack>
|
|
1448
|
+
<Button onClick={() => pay({ pay: "rest", quote: s.free })} disabled={busy || s.free === 0} loading={busy}>
|
|
1449
|
+
Pay {money(withTip(s.free))} — Stripe opens in a new tab
|
|
1450
|
+
</Button>
|
|
1451
|
+
<Button variant="quiet" onClick={() => setSplitting(true)}>Split the check</Button>
|
|
1452
|
+
</Stack>
|
|
1453
|
+
) : (
|
|
1454
|
+
<Stack>
|
|
1455
|
+
<Segmented label="Split by" value={how} onChange={setHow} options={[{ value: "lines", label: "What I had" }, { value: "share", label: "Even shares" }]} />
|
|
1456
|
+
{how === "lines" ? (
|
|
1457
|
+
<Stack>
|
|
1458
|
+
{open.map((l) => (
|
|
1459
|
+
<Checkbox key={l.id} label={`${l.name} · ${money(l.price)}`} checked={picked.includes(l.id)} onChange={(on) => setPicked(on ? [...picked, l.id] : picked.filter((id) => id !== l.id))} />
|
|
1460
|
+
))}
|
|
1461
|
+
<Button onClick={() => pay({ pay: "lines", lineIds: picked, quote: pickedTotal })} disabled={busy || pickedTotal === 0} loading={busy}>
|
|
1462
|
+
Pay {money(withTip(pickedTotal))} for what I had
|
|
1463
|
+
</Button>
|
|
1464
|
+
</Stack>
|
|
1465
|
+
) : (
|
|
1466
|
+
<Stack>
|
|
1467
|
+
{s.split ? (
|
|
1468
|
+
<Text muted>This check is split {s.split.ways} ways.</Text>
|
|
1469
|
+
) : (
|
|
1470
|
+
<Segmented label="Ways" value={ways} onChange={setWays} options={["2", "3", "4", "5", "6"].map((n) => ({ value: n, label: `${n} ways` }))} />
|
|
1471
|
+
)}
|
|
1472
|
+
<Button onClick={() => pay({ pay: "share", ways: Number(ways), quote: share })} disabled={busy || share === 0} loading={busy}>
|
|
1473
|
+
Pay my share, {money(withTip(share))}
|
|
1474
|
+
</Button>
|
|
1475
|
+
</Stack>
|
|
1476
|
+
)}
|
|
1477
|
+
<Button variant="quiet" onClick={() => setSplitting(false)}>Pay the whole check instead</Button>
|
|
1478
|
+
</Stack>
|
|
1479
|
+
)}
|
|
1480
|
+
</Stack>
|
|
1481
|
+
)}
|
|
1482
|
+
{error && <Text>{error}</Text>}
|
|
1483
|
+
</Stack>
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
export default function App() {
|
|
1488
|
+
const setup = useSetup();
|
|
1489
|
+
const [hash, setHash] = useState(() => location.hash);
|
|
1490
|
+
const [screen, setScreen] = useState("tables");
|
|
1491
|
+
useEffect(() => {
|
|
1492
|
+
const onHash = () => setHash(location.hash);
|
|
1493
|
+
window.addEventListener("hashchange", onHash);
|
|
1494
|
+
return () => window.removeEventListener("hashchange", onHash);
|
|
1495
|
+
}, []);
|
|
1496
|
+
const checkId = new URLSearchParams(hash.slice(1)).get("check");
|
|
1497
|
+
if (checkId) return <TableCheck checkId={checkId} setup={setup} />;
|
|
1498
|
+
if (!setup?.owner) return <Text muted>Scan the code on your table to see your check and pay.</Text>;
|
|
1499
|
+
return (
|
|
1500
|
+
<Stack>
|
|
1501
|
+
{/* The owner's own screens: the tables, and Setup while an item is still to do. */}
|
|
1502
|
+
<Row gap={2}>
|
|
1503
|
+
<Button variant="quiet" onClick={() => setScreen("tables")}>Tables</Button>
|
|
1504
|
+
{setup?.owner && (
|
|
1505
|
+
<Button variant={setup.ready ? "quiet" : "solid"} onClick={() => setScreen("setup")}>
|
|
1506
|
+
Setup{setup.ready ? "" : " · to do"}
|
|
1507
|
+
</Button>
|
|
1508
|
+
)}
|
|
1509
|
+
</Row>
|
|
1510
|
+
{screen === "setup" && setup?.owner ? <SetupScreen setup={setup} /> : <Tables setup={setup} />}
|
|
1511
|
+
</Stack>
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
A tip jar or a donation page keeps this shape with one standing check the
|
|
1517
|
+
owner opens once and amounts the backend prices from the page's own list, and
|
|
1518
|
+
no split. Staff who are not the owner open checks through a `staff` role, the
|
|
1519
|
+
way the access docs teach roles.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibes.diy/prompts",
|
|
3
|
-
"version": "14.3.
|
|
3
|
+
"version": "14.3.193",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"exports": {
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"license": "Apache-2.0",
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@adviser/cement": "~0.5.34",
|
|
42
|
-
"@vibes.diy/api-types": "14.3.
|
|
43
|
-
"@vibes.diy/call-ai-v2": "14.3.
|
|
44
|
-
"@vibes.diy/identity": "14.3.
|
|
45
|
-
"@vibes.diy/use-vibes-types": "14.3.
|
|
42
|
+
"@vibes.diy/api-types": "14.3.193",
|
|
43
|
+
"@vibes.diy/call-ai-v2": "14.3.193",
|
|
44
|
+
"@vibes.diy/identity": "14.3.193",
|
|
45
|
+
"@vibes.diy/use-vibes-types": "14.3.193",
|
|
46
46
|
"arktype": "~2.2.8",
|
|
47
47
|
"json-schema-faker": "~0.6.3"
|
|
48
48
|
},
|