@homie-rocks/studio 0.34.0 → 0.35.0
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/CHANGELOG.md +13 -1
- package/README.md +12 -8
- package/bin/homie-studio.mjs +4 -4
- package/lib/doctor.mjs +11 -11
- package/lib/mcp-tools.mjs +15 -0
- package/lib/mcp.mjs +1 -0
- package/lib/scaffold.mjs +12 -5
- package/lib/shop-links.mjs +91 -0
- package/lib/shop.mjs +17 -8
- package/lib/stripe-connect.mjs +135 -0
- package/lib/template-history.json +45 -1
- package/package.json +1 -1
- package/shop/SELLING.md +4 -2
- package/shop/SHOP.md +55 -17
- package/worker/shop-links.mjs +46 -0
- package/worker/shop-page.mjs +5 -3
- package/worker/shop.mjs +106 -31
- package/worker/stripe.mjs +1 -1
- package/worker/version.mjs +1 -1
package/shop/SELLING.md
CHANGED
|
@@ -10,10 +10,12 @@ question by default. Guests can buy repeatedly and keep their purchases when sig
|
|
|
10
10
|
You can choose `policy.preset: "protective"` or `"adults-only"`, or set individual options.
|
|
11
11
|
Without a preset the shop is open. SHOP.md describes each setting and the game cart API.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
With the fuller checkout connection, a cart is one payment with several lines and quantities. Your office can refund a whole order or a line.
|
|
14
14
|
A player can refund items within a window you set; a tip stays given unless you refund it.
|
|
15
15
|
Stripe handles payments, receipts, payment limits, held refunds and disputes. Stripe Tax is optional;
|
|
16
16
|
Managed Payments is another selectable integration. Your office links Stripe's own pages.
|
|
17
17
|
|
|
18
|
-
`homie-studio shop connect`
|
|
18
|
+
`homie-studio shop connect` opens Stripe's approval page if needed, then your AI syncs your products, prices, payment links and webhook. You never handle a key. The shop sends buyers to Stripe, where they confirm quantity or choose a tip; your Worker holds only the webhook signing secret and link settings. Buy different items separately. Refund in Stripe or ask your connected AI; signed events update your shop. A missing payment stays pending until Stripe resends its event. Free orders need no Stripe.
|
|
19
|
+
|
|
20
|
+
The fuller connection adds combined carts, spending limits, custom checkout expiry, automatic recovery and refunds in the office. An existing Worker key selects it; your AI explains it only if you ask for those features. The optional manual setup remains available, but is not the default.
|
|
19
21
|
The scaffold has no policy, cap or refund window. Test mode is available before choosing live mode.
|
package/shop/SHOP.md
CHANGED
|
@@ -104,21 +104,59 @@ One order stores every line and its item snapshot. `/api/shop/mine` lists the li
|
|
|
104
104
|
|
|
105
105
|
`homie-studio shop init --supporter` writes an example item and `SELLING.md`, without a policy, cap or refund
|
|
106
106
|
window. `shop check` validates settings; the build uses the same validation.
|
|
107
|
-
`shop connect`
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
107
|
+
`shop connect` (MCP `stripe_login`) starts Stripe's official browser approval, sandbox/test first.
|
|
108
|
+
If Stripe CLI is missing, the result gives `npm install -g @stripe/cli@latest` for the AI to run.
|
|
109
|
+
The person never supplies a key to the AI. A studio-specific CLI profile avoids borrowing another studio's login.
|
|
110
|
+
If the studio is not deployed yet, approval is retained: deploy with `studio_deploy` (and
|
|
111
|
+
`cloudflare_login` first if needed), then rerun connect. Webhooks require the deployed HTTPS address.
|
|
112
|
+
|
|
113
|
+
**The default is keyless.** Connect syncs one Product and Price for each shop.json item and one
|
|
114
|
+
Payment Link for each paid item. Tip Prices use custom amounts; free orders stay local. It creates
|
|
115
|
+
an account webhook at `<site>/api/shop/hook`, captures its signing secret without displaying it,
|
|
116
|
+
and installs `STRIPE_WEBHOOK_SECRET` and `STRIPE_SHOP_LINKS` through Wrangler stdin. No Stripe
|
|
117
|
+
API key goes into the Worker. Repeat connect after editing shop.json: changed prices get new
|
|
118
|
+
Price versions, changed links get new versions, and removed objects are archived. Old paid orders
|
|
119
|
+
keep their frozen item definitions. Receipts contain no credentials. Unchanged runs create nothing.
|
|
120
|
+
|
|
121
|
+
With an existing account and deployed studio, the person approves Stripe CLI once; an already
|
|
122
|
+
connected profile needs zero new approvals. An MCP-only grant is separate and is never extracted.
|
|
123
|
+
The AI installs tooling, creates/syncs objects and stores the secret. New account verification,
|
|
124
|
+
activation, administrator access settings and Cloudflare authorization are separate prerequisites.
|
|
125
|
+
`shop connect --live` requires the owner's go-live request. `--renew` retries browser authorization.
|
|
126
|
+
Never request a key from the person for the default flow.
|
|
127
|
+
|
|
128
|
+
At purchase time, the Worker saves an opaque order reference and sends it as the link's
|
|
129
|
+
`client_reference_id`. The buyer confirms quantity or tip amount on Stripe. A signed snapshot
|
|
130
|
+
session must match the frozen link, revision, currency and allowed amount before granting. Reusing
|
|
131
|
+
a link creates a distinct order for each Session. Returns without a webhook never grant goods.
|
|
132
|
+
Refunds and disputes use durable financial snapshots in the existing meta table, without buyer/card
|
|
133
|
+
data. They tolerate retries and arrivals before the payment event. Fully successful refunds revoke
|
|
134
|
+
items; failed refunds restore the financial booking. A lost dispute revokes goods, never the account.
|
|
135
|
+
|
|
136
|
+
| Need | Keyless behavior / fuller connection |
|
|
137
|
+
|---|---|
|
|
138
|
+
| Several different items in one payment | Buy separately. Stripe supports static multi-item links (up to 20 fixed-price lines), but they are not an arbitrary buyer cart. Dynamic carts use the keyed path. |
|
|
139
|
+
| Reserve an optional spending allowance | Requires keyed Checkout; a reusable public link cannot atomically reserve this studio's local cap. No silent weakening of a configured cap. |
|
|
140
|
+
| Expire an abandoned session | Stripe controls the lifetime; deactivating a link prevents new sessions, not existing sessions. Custom expiry/cancellation requires the keyed path. |
|
|
141
|
+
| Refund in one tap / player self-refund | Owner uses Stripe Dashboard or asks their Stripe-connected AI. Signed events update books. Office shows guidance instead of a paid-order refund button. Free refunds remain local. |
|
|
142
|
+
| Lost webhook | Remains pending. Stripe retries; the AI can use `stripe events resend <event> --webhook-endpoint <endpoint>` (within Stripe's resend window). No grant from a browser return. Automatic provider-read reconciliation needs a key. |
|
|
143
|
+
| Free orders | Local, including free carts; no Stripe account, API key or webhook secret required. |
|
|
144
|
+
| Exact local eligibility/sale/repeat enforcement | Local checks run before redirect, but a public link can be reused. Sync deactivates removed links; strict pre-payment enforcement of previously issued links requires keyed sessions. |
|
|
145
|
+
| Managed Payments | Payment Links support managed_payments.enabled; connect uses it when the studio selects Stripe as seller, subject to Stripe eligibility and terms. |
|
|
146
|
+
| Currency display | Payment Links always enable Stripe Adaptive Pricing. Bookkeeping uses integration currency; presentment details describe what the customer saw. |
|
|
147
|
+
|
|
148
|
+
An existing `STRIPE_KEY` selects the fuller path automatically. Only if requested, explain:
|
|
149
|
+
“The fuller connection adds combined carts, spending limits, automatic recovery and refunds here.”
|
|
150
|
+
The explicit `shop connect --manual` path remains available, including `--live`, but requires the
|
|
151
|
+
owner's existing Dashboard key step; it does not satisfy one approval. Never choose it automatically.
|
|
152
|
+
Running default connect again saves links, removes the API key, and keeps signed-event fulfillment.
|
|
153
|
+
When switching to spending restrictions, the AI must deactivate previously issued Payment Links through
|
|
154
|
+
the approved CLI and resolve in-flight checkouts before relying on those restrictions; switching credentials
|
|
155
|
+
does not cancel old Stripe sessions. The runtime OAuth finding and sources are in `docs/stripe-connect-research.md` in the source checkout.
|
|
156
|
+
|
|
157
|
+
`shop`, `shop orders`, and `/_studio/office/shop` show readiness and sales. Stripe's optional MCP
|
|
158
|
+
is useful for owner-requested refunds and analytics; honor its approval links. Do not create a
|
|
159
|
+
secret-returning webhook through a conversation tool: the private connect process owns that call.
|
|
122
160
|
|
|
123
161
|
## Payment guarantees and schema
|
|
124
162
|
|
|
@@ -145,7 +183,7 @@ an untagged Dashboard refund revokes items only when the whole order is refunded
|
|
|
145
183
|
Unneeded signed events are acknowledged without recording. A refund or dispute naming an unrecorded payment
|
|
146
184
|
of this database recovers its Checkout Session first, regardless of event age.
|
|
147
185
|
|
|
148
|
-
|
|
186
|
+
**With an API key**, sessions reserve the optional cap until Stripe confirms an outcome. With the default lifetime, a sessionless
|
|
149
187
|
reservation ages out after 24 hours plus a one-minute margin; your `checkoutMinutes` changes that window.
|
|
150
188
|
[Stripe session lifetime](https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-expires_at)
|
|
151
189
|
and [expiring open sessions](https://docs.stripe.com/api/checkout/sessions/expire) define these provider bounds.
|
|
@@ -153,7 +191,7 @@ Replacing an open checkout expires only reservations at least a minute old; a na
|
|
|
153
191
|
Unresolved orders become eligible for reconciliation after one minute, even with no cap. A buyer's next shop
|
|
154
192
|
or owned-items request schedules up to three reads off the response path, with atomic claims and backoff up
|
|
155
193
|
to an hour; an optional Worker cron invokes the same reconciliation across buyers. No cron is installed for you. To run it every five minutes, add `"triggers": { "crons": ["*/5 * * * *"] }` to the studio’s root `wrangler.jsonc`; deploy preserves its triggers.
|
|
156
|
-
|
|
194
|
+
With an API key, every recorded payment checks Stripe’s refunds and dispute state, including payments recovered by an early refund or dispute event. The thanks page can verify a payment directly with the studio's key. Webhook events require Stripe's signature.
|
|
157
195
|
Test and live books stay separate. Owner release attempts to expire an open session, records who released it
|
|
158
196
|
and when, and never prevents a later verified payment grant.
|
|
159
197
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Shared setup/runtime contract. Payment Links are public; credentials never enter this value. */
|
|
2
|
+
import { KEY_SHAPE, modeOf } from './stripe.mjs';
|
|
3
|
+
// Current documented Payment Link fields, isolated from the existing keyed transport version.
|
|
4
|
+
export const LINKS_STRIPE_VERSION = '2026-09-30.endive';
|
|
5
|
+
export const hasStripeKey = (env) => KEY_SHAPE.test(String(env?.STRIPE_KEY ?? ''));
|
|
6
|
+
export function linkConfig(env) {
|
|
7
|
+
try { const c = JSON.parse(env?.STRIPE_SHOP_LINKS ?? 'null'); return c?.v === 1 && ['test', 'live'].includes(c.mode) && Array.isArray(c.items) ? c : null; } catch { return null; }
|
|
8
|
+
}
|
|
9
|
+
export const shopMode = (env) => hasStripeKey(env) ? modeOf(env.STRIPE_KEY) : linkConfig(env)?.mode ?? 'test';
|
|
10
|
+
export function linkSettings(shop) {
|
|
11
|
+
return JSON.stringify({ currency: shop.currency, till: shop.till, automaticTax: shop.automaticTax, policy: shop.policy,
|
|
12
|
+
cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items });
|
|
13
|
+
}
|
|
14
|
+
export function fullerReason(shop) {
|
|
15
|
+
if (shop.capPerPlayerMonth !== null) return 'Reserving a spending allowance before payment needs the fuller checkout connection.';
|
|
16
|
+
if (shop.checkoutMinutes !== 1440) return 'Choosing when abandoned checkouts expire needs the fuller checkout connection.';
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export function paymentLinkURL(value) {
|
|
20
|
+
try { const u = new URL(value); return u.protocol === 'https:' && u.hostname === 'buy.stripe.com' && !u.username && !u.password && !u.port; } catch { return false; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Only signed snapshot events call this. Store financial fields, never customer/card data.
|
|
24
|
+
* Existing meta is a durable KV table. One row per provider object avoids read/modify/write races.
|
|
25
|
+
* Terminal failed/canceled refunds beat stale succeeded snapshots (including same-second events).
|
|
26
|
+
*/
|
|
27
|
+
export async function rememberMoneyEvent(env, event) {
|
|
28
|
+
const o = event.data.object, mode = event.livemode ? 'live' : 'test';
|
|
29
|
+
const put = async (kind, value, rank) => {
|
|
30
|
+
if (!/^pi_\w+$/.test(value.payment_intent ?? '') || !/^(re|dp)_\w+$/.test(value.id ?? '')) return;
|
|
31
|
+
const key = `shop-money:${mode}:${value.payment_intent}:${kind}:${value.id}`;
|
|
32
|
+
const record = JSON.stringify({ ...value, at: event.created ?? 0, rank });
|
|
33
|
+
await env.DB.prepare(`INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
34
|
+
WHERE json_extract(excluded.value, '$.rank') > json_extract(meta.value, '$.rank') OR
|
|
35
|
+
(json_extract(excluded.value, '$.rank') = json_extract(meta.value, '$.rank') AND json_extract(excluded.value, '$.at') >= json_extract(meta.value, '$.at'))`).bind(key, record).run();
|
|
36
|
+
};
|
|
37
|
+
const refund = (r) => put('refund', { id: r.id, payment_intent: r.payment_intent ?? o.payment_intent, amount: r.amount, currency: r.currency ?? o.currency, status: r.status, metadata: r.metadata?.line ? { line: r.metadata.line } : {} }, ['failed', 'canceled'].includes(r.status) ? 3 : r.status === 'succeeded' ? 2 : 1);
|
|
38
|
+
if (event.type.startsWith('refund.')) await refund(o);
|
|
39
|
+
if (event.type === 'charge.refunded') for (const r of o.refunds?.data ?? []) await refund(r);
|
|
40
|
+
if (event.type.startsWith('charge.dispute.')) await put('dispute', { id: o.id, payment_intent: o.payment_intent, status: o.status }, event.type.endsWith('.closed') ? 2 : 1);
|
|
41
|
+
}
|
|
42
|
+
export async function rememberedMoney(env, payment, mode) {
|
|
43
|
+
const prefix = `shop-money:${mode}:${payment}:`;
|
|
44
|
+
const rows = (await env.DB.prepare('SELECT value FROM meta WHERE key >= ?1 AND key < ?2').bind(prefix, prefix + '\uffff').all()).results ?? [];
|
|
45
|
+
return rows.map((r) => JSON.parse(r.value));
|
|
46
|
+
}
|
package/worker/shop-page.mjs
CHANGED
|
@@ -211,7 +211,7 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
211
211
|
if (i.way === 'cap' && i.retryWay) { act.appendChild(el('span', { class: 'why' }, WAY.cap)); i.way = i.retryWay; }
|
|
212
212
|
if (i.way === 'checkout') {
|
|
213
213
|
var amount = null;
|
|
214
|
-
if (i.price === 'choose') {
|
|
214
|
+
if (i.price === 'choose' && !s.keyless) {
|
|
215
215
|
amount = el('input', { type: 'number', 'aria-label': 'How much in ' + s.currency.toUpperCase(), min: String(i.min / s.currencyScale), step: String(s.amountStep / s.currencyScale), value: String(i.min / s.currencyScale) });
|
|
216
216
|
if (i.max !== null) amount.setAttribute('max', String(i.max / s.currencyScale));
|
|
217
217
|
act.appendChild(amount);
|
|
@@ -228,7 +228,8 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
228
228
|
var quantity = el('input', { type: 'number', min: '1', step: '1', value: '1', 'aria-label': 'Quantity' });
|
|
229
229
|
var add = el('button', { type: 'button', class: 'plain' }, 'Add to cart');
|
|
230
230
|
add.onclick = function () { cart.push({ item: i.id, name: i.name, quantity: Number(quantity.value), amount: amount ? units(amount.value, s.currencyScale) : undefined }); drawCart(); };
|
|
231
|
-
act.appendChild(quantity); act.appendChild(add);
|
|
231
|
+
if (!s.keyless) { act.appendChild(quantity); act.appendChild(add); }
|
|
232
|
+
else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : 'Confirm quantity on Stripe. Buy different items separately.'));
|
|
232
233
|
} else if (i.way === 'owned') {
|
|
233
234
|
act.appendChild(el('span', { class: 'owned' }, 'Yours ✓'));
|
|
234
235
|
} else if (i.way === 'make-an-account') {
|
|
@@ -524,6 +525,7 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
|
|
|
524
525
|
[['Payments', s.stripe.payments], ['Refunds', s.stripe.refunds], ['Disputes', s.stripe.disputes], ['Payouts', s.stripe.payouts], ['Balance', s.stripe.balance], ['Tax', s.stripe.tax]].concat(s.stripe.managedPayments ? [['Managed Payments', s.stripe.managedPayments]] : []).forEach(function (l) { links.appendChild(link(l[1], l[0])); });
|
|
525
526
|
links.appendChild(link('/_studio/api/shop/orders.csv', 'CSV for your accountant'));
|
|
526
527
|
top.appendChild(links);
|
|
528
|
+
if (s.keyless) top.appendChild(el('p', { class: 'note' }, 'Refund in Stripe or ask your AI connected to Stripe. If a payment is missing here, ask your AI to resend its Stripe webhook.'));
|
|
527
529
|
if (s.totals) top.appendChild(el('p', { class: 'dim' }, 'Last 30 days: ' + s.totals.sales + ' sales, ' + cents(s.totals.paid, s.totals.currency) + ' before tax and fees; ' + s.totals.refunds + ' refunded (' + cents(s.totals.refunded, s.totals.currency) + '); ' + s.totals.disputes + ' disputed now, ' + s.totals.lost + ' lost.'));
|
|
528
530
|
var ord = $('#orders'); ord.textContent = '';
|
|
529
531
|
if (!s.orders.length) ord.appendChild(el('p', { class: 'empty' }, 'No orders yet.'));
|
|
@@ -651,7 +653,7 @@ export async function officeShopPage(cat) {
|
|
|
651
653
|
<div class="game" style="padding:6px 16px 12px"><h3>Orders</h3><div id="orders"></div></div>
|
|
652
654
|
<div class="game" style="padding:6px 16px 12px" id="referrals"></div>
|
|
653
655
|
<div class="toast" id="toast" role="status" hidden></div>
|
|
654
|
-
<p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing.
|
|
656
|
+
<p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing. Refunds are handled in Stripe or by your connected AI; shops with the fuller checkout connection also offer Refund here. A card dispute never touches the player's account: answer it in Stripe; if you lose it, that one item is taken back. Tax: when enabled, Stripe Tax works out and collects tax where you told Stripe you are registered${cat.shop?.till === 'stripe-managed' ? '; with Managed Payments Stripe is the seller of record and files it for you' : ''}. Payouts, the balance and receipts are Stripe's own pages, linked above. Referrals: you pay referrers yourself (they invoice you); nothing moves through Homie. This is not legal or tax advice.</p>`;
|
|
655
657
|
return new Response(shell(cat, `Shop · ${name}`, body, { script: OFFICE_SHOP_SCRIPT }), {
|
|
656
658
|
headers: { ...PRIVATE, 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src '${officeHash}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'` },
|
|
657
659
|
});
|
package/worker/shop.mjs
CHANGED
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
* owner's AI (an office key) can only ASK for one, which the owner confirms with one tap. A dispute never touches the
|
|
22
22
|
* player's account: it changes nothing until it is decided, and a lost one is a refund of that one item.
|
|
23
23
|
*
|
|
24
|
-
* SECRETS (Worker secrets, put there by `homie-studio shop connect`
|
|
25
|
-
*
|
|
24
|
+
* SECRETS (Worker secrets, put there by `homie-studio shop connect` on the owner's own computer):
|
|
25
|
+
* Default: STRIPE_SHOP_LINKS and STRIPE_WEBHOOK_SECRET; no API key or API requests at purchase time.
|
|
26
|
+
* Optional fuller checkout: STRIPE_KEY (a secret key or a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read;
|
|
26
27
|
* from 0.24.3 also Webhook Endpoints write, which only the connect page on the owner's computer uses) and
|
|
27
28
|
* STRIPE_WEBHOOK_SECRET. Never in a file, a chat, a log or a page.
|
|
28
29
|
*
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
* by product. The price is always shop.json's (the rules checked it), never one read from Stripe. A Product missing
|
|
32
33
|
* in this mode falls back to the item described inline, so a sale never fails over the catalog.
|
|
33
34
|
*/
|
|
35
|
+
import { hasStripeKey, linkConfig, linkSettings, shopMode, fullerReason, paymentLinkURL, rememberMoneyEvent, rememberedMoney } from './shop-links.mjs';
|
|
34
36
|
import { players } from './players.mjs';
|
|
35
37
|
import { serversOf } from './servers.mjs';
|
|
36
38
|
import { ENTITLEMENT_KEY, referralShare, checkoutMessage, amountError, amountStep, parseAmount, currencyScale, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
|
|
@@ -38,7 +40,7 @@ import {
|
|
|
38
40
|
orderLines, lineView, bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
|
|
39
41
|
setBand, shopDataOf, spentThisMonth,
|
|
40
42
|
} from './shop-store.mjs';
|
|
41
|
-
import { redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct,
|
|
43
|
+
import { redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
|
|
42
44
|
import { pendingStatementPeriod, statementFailures, arrivalOf, booksOf, linesOf, statementPage, lineFor, receiveStatement, receivedPages, referrerOk, sendStatements, settle, statementsIn, voidLineStatements } from './referrals.mjs';
|
|
43
45
|
import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
|
|
44
46
|
import { notFoundPage } from './site.mjs';
|
|
@@ -136,15 +138,19 @@ export async function readiness(env, shop) {
|
|
|
136
138
|
else if (shop.broken) missing.push('shop.json-refused');
|
|
137
139
|
else if (shop.till === 'off') missing.push('till-off');
|
|
138
140
|
else if (!shop.items.length) missing.push('items');
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
+
const paidItems = shop?.items.some((i) => i.price !== 0);
|
|
142
|
+
const links = linkConfig(env);
|
|
143
|
+
if (paidItems && !hasStripeKey(env)) {
|
|
144
|
+
if (fullerReason(shop)) missing.push('fuller-checkout');
|
|
145
|
+
else if (!links || links.settings !== linkSettings(shop) || shop.items.some((i) => i.price !== 0 && !links.items.some((l) => l.item === i.id && paymentLinkURL(l.url)))) missing.push('payment-links');
|
|
146
|
+
}
|
|
141
147
|
// Reliable fulfillment requires the webhook as well as the redirect.
|
|
142
148
|
// https://docs.stripe.com/checkout/fulfillment
|
|
143
|
-
if (!WEBHOOK_SECRET_SHAPE.test(String(env?.STRIPE_WEBHOOK_SECRET ?? ''))) missing.push('webhook-secret');
|
|
149
|
+
if (paidItems && !WEBHOOK_SECRET_SHAPE.test(String(env?.STRIPE_WEBHOOK_SECRET ?? ''))) missing.push('webhook-secret');
|
|
144
150
|
let migration = null;
|
|
145
151
|
if (!env?.DB) missing.push('database');
|
|
146
152
|
else if ((migration = await migrationNeeded(env))) missing.push('migration');
|
|
147
|
-
return { ready: missing.length === 0, missing, migration, mode:
|
|
153
|
+
return { ready: missing.length === 0, missing, next: missing.length ? MISSING_WORDS[missing[0]] : null, migration, mode: paidItems ? shopMode(env) : null };
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
export const MISSING_WORDS = Object.freeze({
|
|
@@ -153,8 +159,10 @@ export const MISSING_WORDS = Object.freeze({
|
|
|
153
159
|
'shop.json-refused': 'shop.json conflicts with the studio settings or payment requirements (homie-studio shop check says which), so nothing is sold.',
|
|
154
160
|
'till-off': 'shop.json says "till": "off".',
|
|
155
161
|
items: 'shop.json has no items.',
|
|
156
|
-
'
|
|
157
|
-
'
|
|
162
|
+
'payment-links': 'Run homie-studio shop connect to sync your items and connect Stripe; approve its browser page if asked.',
|
|
163
|
+
'fuller-checkout': 'Your spending allowance or checkout expiry needs the fuller checkout connection. Ask your AI to connect it.',
|
|
164
|
+
'stripe-key': 'The Worker has no Stripe key yet (homie-studio shop connect: approve Stripe in your browser; the command names any remaining Worker credential step).',
|
|
165
|
+
'webhook-secret': 'The Worker has no webhook signing secret yet (run homie-studio shop connect to create and store it automatically).',
|
|
158
166
|
database: 'This Worker has no database.',
|
|
159
167
|
migration: 'The shop\'s tables are not in the studio\'s D1 yet: npm run deploy applies migrations 0008_studio_shop.sql, 0010_shop_reservations.sql and 0011_shop_statements.sql and 0012_shop_lines.sql.',
|
|
160
168
|
});
|
|
@@ -191,7 +199,7 @@ async function itemsView(env, shop, { game, kids, beginner, player, open, cursor
|
|
|
191
199
|
const page = item ? selected : selected.slice(cursor, cursor + 100);
|
|
192
200
|
const items = page.map((it) => {
|
|
193
201
|
const owned = it.gives.length > 0 && it.gives.every((k) => owns.some((o) => o.key === k && o.item === it.id));
|
|
194
|
-
const way = wayFor(it, { open, kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
|
|
202
|
+
const way = wayFor(it, { open: open || it.price === 0 && !shop.broken && shop.till !== 'off', kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
|
|
195
203
|
return { id: it.id, kind: it.kind, name: it.name, blurb: it.blurb ?? null, price: it.price, ...(it.price === 'choose' ? { min: it.min, max: it.max } : {}), shown: it.price === 'choose' ? `${money(it.min, shop.currency)} or more` : money(it.price, shop.currency), gives: it.gives, ...(it.badge ? { badge: it.badge } : {}), ...(it.days ? { days: it.days } : {}), ...(it.ends ? { ends: it.ends } : {}), ...(it.game ? { game: it.game } : {}), way, ...(way === 'cap' ? { retryWay: wayFor(it, { open, kids, beginner, player, band, owned, cap: null, policy: shop.policy }) } : {}) };
|
|
196
204
|
}).filter((i) => i.way !== 'beginner' && i.way !== 'kids');
|
|
197
205
|
return { band, owns, spent, items, nextCursor: !item && cursor + 100 < selected.length ? String(cursor + 100) : null };
|
|
@@ -263,7 +271,7 @@ async function listRoute(request, env, url, cat, shop, ready) {
|
|
|
263
271
|
// The studio decides which known age bands see no shop.
|
|
264
272
|
if ((v.band === 'child' && shop.policy.children === 'deny') || (v.band === 'teen' && shop.policy.teens === 'deny')) return json({ ok: true, open: false, kids: true, items: [] });
|
|
265
273
|
return json({
|
|
266
|
-
ok: true, open: ready.ready, ...(ready.ready ? {} : { missing: ready.missing, migration: ready.migration }), mode: ready.mode,
|
|
274
|
+
ok: true, keyless: !hasStripeKey(env), open: ready.ready || !ready.migration && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)) && shop.items.some((i) => i.price === 0), ...(ready.ready ? {} : { missing: ready.missing, migration: ready.migration }), mode: ready.mode,
|
|
267
275
|
currency: shop.currency, currencyScale: currencyScale(shop.currency), amountStep: amountStep(shop.currency), policy: shop.policy, till: shop.till, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, spent: v.spent,
|
|
268
276
|
player: player ? { signedIn: !player.guest, guest: player.guest, name: player.name, band: v.band } : null,
|
|
269
277
|
owns: v.owns.map((o) => o.key), items: v.items, nextCursor: v.nextCursor,
|
|
@@ -291,7 +299,7 @@ async function mineRoute(request, env, shop) {
|
|
|
291
299
|
for (const line of o.lines) {
|
|
292
300
|
if (line.kind === null) { const item = shop.items.find((i) => i.id === line.item); line.name = item?.name ?? line.name; line.kind = item?.kind ?? null; }
|
|
293
301
|
const used = await lineWasUsed(env, o.id, line);
|
|
294
|
-
line.refundable = refundableNow({ ...line, paidAt: o.paidAt }, used ? [{ item: line.item, used: true }] : [], shop);
|
|
302
|
+
line.refundable = (o.amount === 0 || hasStripeKey(env)) && refundableNow({ ...line, paidAt: o.paidAt }, used ? [{ item: line.item, used: true }] : [], shop);
|
|
295
303
|
line.shown = money(line.amount, o.currency);
|
|
296
304
|
}
|
|
297
305
|
orders.push({ ...o, name: o.lines.map((l) => `${l.name} × ${l.quantity}`).join(', '), shown: money(o.amount, o.currency), refundable: o.lines.length > 0 && o.lines.every((l) => l.refundable) });
|
|
@@ -391,7 +399,7 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
|
|
|
391
399
|
if (!shop || !KEY_SHAPE.test(String(env?.STRIPE_KEY ?? '')) || !(await migrated(env))) return;
|
|
392
400
|
const now = Date.now();
|
|
393
401
|
// Filter eligibility before LIMIT. Unrecorded payments precede old paid rows needing repair.
|
|
394
|
-
const params = [
|
|
402
|
+
const params = [shopMode(env), now, Number.isFinite(before) ? before : Number.MAX_SAFE_INTEGER, ...(player ? [player] : []), ...(order ? [order] : [])];
|
|
395
403
|
const query = (status, limit) => env.DB.prepare(`SELECT * FROM shop_orders ${order ? '' : `INDEXED BY ${player ? 'shop_orders_attention_player' : 'shop_orders_attention'}`} WHERE attention = 1 AND mode = ?1
|
|
396
404
|
AND status = '${status}'
|
|
397
405
|
AND (created_at < ?3 ${expireOpen ? 'OR expires_at <= ?2' : ''}) ${player ? 'AND player = ?4' : ''} ${order ? `AND id = ?${player ? 5 : 4}` : ''}
|
|
@@ -452,6 +460,12 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
|
|
|
452
460
|
/** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
|
|
453
461
|
async function startCheckout(env, url, cat, shop, item, { player, game, via, amount, parent = false, lines = null, requestAt = Date.now() }) {
|
|
454
462
|
lines ??= [{ item, amount, quantity: 1 }];
|
|
463
|
+
const keyless = amount > 0 && !hasStripeKey(env);
|
|
464
|
+
if (keyless && (lines.length !== 1 || fullerReason(shop))) return { error: fail(409, 'one-item', fullerReason(shop) ?? 'Buy one item at a time. A combined cart needs the fuller checkout connection.') };
|
|
465
|
+
const link = keyless ? linkConfig(env)?.items.find((l) => l.item === item.id) : null;
|
|
466
|
+
if (keyless && (!link || !paymentLinkURL(link.url))) return { error: fail(503, 'payment-links', MISSING_WORDS['payment-links']) };
|
|
467
|
+
if (keyless && item.price === 'choose' && lines[0].quantity !== 1) return { error: fail(400, 'quantity', 'A tip is one chosen amount; choose it on Stripe.') };
|
|
468
|
+
if (keyless) lines = lines.map((l) => ({ ...l, item: { ...l.item, paymentLink: link } }));
|
|
455
469
|
try { if (via) referralShare(amount, shop.referrals.rate); } catch { return { error: fail(400, 'arithmetic', 'The referral share exceeds safe integer arithmetic.') }; }
|
|
456
470
|
const order = { id: newOrderId(), player: player.id, via: via ?? null, amount, currency: shop.currency };
|
|
457
471
|
const now = Date.now();
|
|
@@ -459,7 +473,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
|
|
|
459
473
|
order.expiresAt = Math.floor(now / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
|
|
460
474
|
// One SQL statement reserves the amount: concurrent requests cannot both spend the remaining cap.
|
|
461
475
|
const reservation = () => env.DB.prepare("INSERT INTO shop_orders (id, player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at, expires_at) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'started', ?9, ?10, ?11, ?11, ?14 WHERE ?5 = 0 OR ?12 IS NULL OR ?5 <= ?12 - (SELECT COALESCE(SUM(amount - refunded_net), 0) FROM shop_orders WHERE player = ?2 AND mode = ?8 AND ((status IN ('paid', 'disputed') AND paid_at >= ?13) OR (status IN ('started', 'processing') AND (session IS NOT NULL OR COALESCE(expires_at, created_at + 1860000) + 60000 > ?11))))")
|
|
462
|
-
.bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till,
|
|
476
|
+
.bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, shopMode(env), parent ? 1 : 0, order.via, now, shop.capPerPlayerMonth, Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1), order.expiresAt * 1000);
|
|
463
477
|
lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
|
|
464
478
|
const reserve = async () => {
|
|
465
479
|
const results = await env.DB.batch([reservation(),
|
|
@@ -483,8 +497,12 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
|
|
|
483
497
|
await paid(env, shop, { id, metadata: { order: order.id }, client_reference_id: player.id, currency: shop.currency, amount_subtotal: 0, amount_total: 0, payment_status: 'no_payment_required' });
|
|
484
498
|
return { order, url: `${url.origin}/shop/thanks?session_id=${id}`, session: id, reconciled };
|
|
485
499
|
}
|
|
500
|
+
if (keyless) {
|
|
501
|
+
const checkout = new URL(link.url); checkout.searchParams.set('client_reference_id', order.id);
|
|
502
|
+
return { order, url: checkout.href, reconciled };
|
|
503
|
+
}
|
|
486
504
|
let session = null;
|
|
487
|
-
const mode =
|
|
505
|
+
const mode = shopMode(env);
|
|
488
506
|
try {
|
|
489
507
|
const useCatalog = Array.isArray(shop.catalog) && shop.catalog.includes(mode);
|
|
490
508
|
for (const line of lines) line.product = useCatalog ? await productIdOf(cat.studio?.slug, line.item.id) : null;
|
|
@@ -543,7 +561,8 @@ function purchaseLimited(request, shop, player, kind) {
|
|
|
543
561
|
async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
544
562
|
const b = await bodyOf(request, requestSize(shop));
|
|
545
563
|
if (b.error) return b.error;
|
|
546
|
-
|
|
564
|
+
const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0));
|
|
565
|
+
if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
|
|
547
566
|
const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
|
|
548
567
|
// Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
|
|
549
568
|
if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
|
|
@@ -564,7 +583,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
564
583
|
const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
|
|
565
584
|
const way = facts.kids && !shop.policy.kidsServer ? 'kids' : v.items[0]?.way ?? 'beginner';
|
|
566
585
|
if (way !== 'checkout') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
|
|
567
|
-
const unit = item.price === 'choose' ? entry.amount : item.price;
|
|
586
|
+
const unit = item.price === 'choose' ? (entry.amount ?? (!hasStripeKey(env) ? item.min : undefined)) : item.price;
|
|
568
587
|
if (amountError(unit, shop.currency)) return fail(400, 'amount', amountError(unit, shop.currency));
|
|
569
588
|
if (item.price === 'choose' && (unit < item.min || (item.max !== null && unit > item.max))) return fail(400, 'amount', 'This amount is outside the studio tip range.');
|
|
570
589
|
const total = BigInt(unit) * BigInt(quantity) + BigInt(amount);
|
|
@@ -699,7 +718,7 @@ async function selfRefundRoute(request, env, shop, ready) {
|
|
|
699
718
|
const used = await lineWasUsed(env, o.id, line);
|
|
700
719
|
if (!refundableNow({ ...lineView(line), paidAt: o.paid_at }, used ? [{ item: line.item, used: true }] : [], shop)) return fail(403, 'not-refundable', 'Ask the studio for this refund. Tips are not refundable by the player; items follow the studio refund settings.');
|
|
701
720
|
}
|
|
702
|
-
if (Number(o.amount) !== 0 && !
|
|
721
|
+
if (Number(o.amount) !== 0 && !hasStripeKey(env)) return fail(409, 'ask-studio', 'Ask the studio to refund this payment in Stripe.');
|
|
703
722
|
const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player', line: b.body.line });
|
|
704
723
|
if (r.held) return json({ ...r, error: r.error === 'pending' ? 'pending' : 'held', message: r.error === 'pending' ? r.message : 'The studio approves this refund in Stripe first. Your money comes back once they do, and the item leaves your account then.' }, 202);
|
|
705
724
|
return json(r, r.ok ? 200 : 502);
|
|
@@ -719,18 +738,20 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
719
738
|
const obj = ev.data?.object ?? {};
|
|
720
739
|
const needed = ['checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired', 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed'];
|
|
721
740
|
if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
|
|
722
|
-
if (typeof ev.livemode === 'boolean' &&
|
|
741
|
+
if (typeof ev.livemode === 'boolean' && (hasStripeKey(env) || linkConfig(env)) && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
|
|
723
742
|
try {
|
|
724
743
|
const sessionEvent = ev.type.startsWith('checkout.session.');
|
|
744
|
+
if (!sessionEvent) await rememberMoneyEvent(env, ev);
|
|
745
|
+
if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({ ok: true, did: 'mismatch' });
|
|
725
746
|
let known = sessionEvent ? await orderBySession(env, obj.id) : await orderByPayment(env, String(obj.payment_intent ?? ''));
|
|
726
747
|
if (!known) {
|
|
727
748
|
let metadata = obj.metadata;
|
|
728
749
|
let named = metadata?.order ? await orderById(env, metadata.order) : null;
|
|
729
750
|
// Charge metadata identifies foreign sales without a provider read. Untagged refunds/disputes
|
|
730
751
|
// need their PaymentIntent only when this database actually has an unrecorded payment.
|
|
731
|
-
if (!named && !sessionEvent && obj.payment_intent && !metadata?.order &&
|
|
752
|
+
if (hasStripeKey(env) && !named && !sessionEvent && obj.payment_intent && !metadata?.order &&
|
|
732
753
|
(!metadata?.homie || metadata.homie === 'shop-v1') && (!metadata?.origin || metadata.origin === new URL(request.url).origin) &&
|
|
733
|
-
await env.DB.prepare('SELECT 1 FROM shop_orders WHERE payment IS NULL AND paid_at IS NULL AND mode = ?1 LIMIT 1').bind(
|
|
754
|
+
await env.DB.prepare('SELECT 1 FROM shop_orders WHERE payment IS NULL AND paid_at IS NULL AND mode = ?1 LIMIT 1').bind(shopMode(env)).first()) {
|
|
734
755
|
let payment;
|
|
735
756
|
try { payment = await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(obj.payment_intent)}`); }
|
|
736
757
|
catch (error) {
|
|
@@ -740,10 +761,11 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
740
761
|
metadata = payment.metadata;
|
|
741
762
|
named = metadata?.order ? await orderById(env, metadata.order) : null;
|
|
742
763
|
}
|
|
743
|
-
if (!named) return json({ ok: true, did: 'ignored' });
|
|
764
|
+
if (!named) return json({ ok: true, did: !sessionEvent && !hasStripeKey(env) ? 'remembered' : 'ignored' });
|
|
765
|
+
if (!hasStripeKey(env) && !sessionEvent) return json({ ok: true, did: 'remembered' });
|
|
744
766
|
if (sessionEvent) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
|
|
745
767
|
if (!(await migrated(env))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
|
|
746
|
-
if (named.payment || named.mode !==
|
|
768
|
+
if (named.payment || named.mode !== shopMode(env)) return json({ ok: true, did: 'ignored' });
|
|
747
769
|
if (!named.session) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
|
|
748
770
|
const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(named.session)}`, { expand: ['payment_intent.latest_charge'] });
|
|
749
771
|
if (session.id !== named.session || !['paid', 'no_payment_required'].includes(session.payment_status)) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
|
|
@@ -780,6 +802,7 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
780
802
|
break;
|
|
781
803
|
case 'charge.dispute.created':
|
|
782
804
|
did = await disputed(env, String(obj.payment_intent ?? ''), String(obj.id ?? ''));
|
|
805
|
+
if (!hasStripeKey(env)) for (const d of await rememberedMoney(env, obj.payment_intent, known.mode)) if (d.id === obj.id && ['lost', 'won', 'warning_closed'].includes(d.status)) did = await disputeClosed(env, obj.payment_intent, d.status);
|
|
783
806
|
break;
|
|
784
807
|
case 'charge.dispute.closed':
|
|
785
808
|
did = await disputeClosed(env, String(obj.payment_intent ?? ''), String(obj.status ?? ''));
|
|
@@ -787,6 +810,7 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
787
810
|
default:
|
|
788
811
|
break;
|
|
789
812
|
}
|
|
813
|
+
if (did === 'needs-keyed-recovery') return fail(503, did, 'This earlier multi-item checkout needs its original connection to recover tax per line. Reconnect it, then resend this event.');
|
|
790
814
|
if (did === 'missing-item' || did === 'unknown-order') return fail(503, did, did === 'missing-item' ? 'Restore this order item in shop.json to grant it; the office can refund its payment.' : 'Payment order is not recorded yet; retry this event.');
|
|
791
815
|
await env.DB.prepare('INSERT INTO shop_events (id, type, at) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING').bind(ev.id, ev.type, Date.now()).run();
|
|
792
816
|
if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
|
|
@@ -797,12 +821,47 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
797
821
|
}
|
|
798
822
|
}
|
|
799
823
|
|
|
824
|
+
/** Payment Links can be reopened. Each signed Session becomes its own order; the URL reference
|
|
825
|
+
* selects a frozen local intent, never an entitlement or a buyer-supplied price. */
|
|
826
|
+
async function bindLinkSession(env, session, mode) {
|
|
827
|
+
if (!/^ord_[A-Za-z0-9]{20}$/.test(session.client_reference_id ?? '') || !/^cs_[A-Za-z0-9_]+$/.test(session.id ?? '')) return false;
|
|
828
|
+
const source = await orderById(env, session.client_reference_id);
|
|
829
|
+
if (!source || source.mode !== mode) return false;
|
|
830
|
+
const lines = await orderLines(env, source.id);
|
|
831
|
+
if (lines.length !== 1 || !lines[0].snapshot) return false;
|
|
832
|
+
const item = JSON.parse(lines[0].snapshot), link = item.paymentLink;
|
|
833
|
+
if (!link || (session.payment_link?.id ?? session.payment_link) !== link.id || session.metadata?.revision !== link.revision || session.currency !== source.currency) return false;
|
|
834
|
+
const amount = session.amount_subtotal;
|
|
835
|
+
if (!Number.isSafeInteger(amount) || amount <= 0) return false;
|
|
836
|
+
const quantity = item.price === 'choose' ? 1 : amount / item.price;
|
|
837
|
+
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > 999999) return false;
|
|
838
|
+
if (item.price === 'choose' && (amount < item.min || item.max !== null && amount > item.max)) return false;
|
|
839
|
+
// Claim the first session atomically. A repeated URL creates a separate paid order, never loses a payment.
|
|
840
|
+
await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1 AND session IS NULL').bind(source.id, session.id).run();
|
|
841
|
+
let order = await orderBySession(env, session.id);
|
|
842
|
+
if (!order) {
|
|
843
|
+
const id = `ord_${(await sha256Hex(session.id)).slice(0, 20)}`;
|
|
844
|
+
await env.DB.batch([
|
|
845
|
+
env.DB.prepare("INSERT INTO shop_orders (id, player, checkout_player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at, session, referral_terms) SELECT ?2, player, checkout_player, item, game, ?4, currency, till, mode, 'started', parent, via, ?5, ?5, ?3, referral_terms FROM shop_orders WHERE id = ?1 ON CONFLICT DO NOTHING").bind(source.id, id, session.id, amount, Date.now()),
|
|
846
|
+
env.DB.prepare('INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) VALUES (?1, ?2, 0, ?3, ?4, ?5, ?6, ?7) ON CONFLICT DO NOTHING').bind(`${id}_0`, id, item.id, quantity, amount / quantity, amount, lines[0].snapshot),
|
|
847
|
+
]);
|
|
848
|
+
order = await orderBySession(env, session.id);
|
|
849
|
+
}
|
|
850
|
+
if (!order) return false;
|
|
851
|
+
if (order.paid_at === null) await env.DB.batch([
|
|
852
|
+
env.DB.prepare('UPDATE shop_orders SET amount = ?2 WHERE id = ?1 AND paid_at IS NULL').bind(order.id, amount),
|
|
853
|
+
env.DB.prepare('UPDATE shop_order_lines SET quantity = ?2, unit_amount = ?3, amount = ?4 WHERE order_id = ?1 AND EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND paid_at IS NULL)').bind(order.id, quantity, amount / quantity, amount),
|
|
854
|
+
]);
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
|
|
800
858
|
/** A paid checkout: the order is paid, its entitlements granted, a referral line written. Idempotent. */
|
|
801
859
|
async function paid(env, shop, session, paidAt = Date.now()) {
|
|
802
860
|
const o = await orderBySession(env, session.id);
|
|
803
861
|
if (!o) return 'unknown-order';
|
|
804
862
|
// The session must be the one this order opened, for this player and this price.
|
|
805
|
-
|
|
863
|
+
const linkOrder = (await orderLines(env, o.id)).some((l) => l.snapshot && JSON.parse(l.snapshot).paymentLink);
|
|
864
|
+
if (!linkOrder && (session.metadata?.order !== o.id || ((o.checkout_player ?? o.player) && session.client_reference_id && session.client_reference_id !== (o.checkout_player ?? o.player)))) return 'mismatch';
|
|
806
865
|
if (session.currency !== o.currency || !Number.isSafeInteger(session.amount_subtotal) || session.amount_subtotal !== Number(o.amount)) return 'mismatch';
|
|
807
866
|
if (['refunded', 'lost', 'disputed'].includes(o.status)) {
|
|
808
867
|
if (o.payment) await syncRefunds(env, o.payment);
|
|
@@ -810,9 +869,9 @@ async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
810
869
|
return 'already';
|
|
811
870
|
}
|
|
812
871
|
const payment = typeof session.payment_intent === 'string'
|
|
813
|
-
? await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(session.payment_intent)}`, { expand: ['latest_charge'] }) : session.payment_intent;
|
|
872
|
+
? !hasStripeKey(env) ? { id: session.payment_intent } : await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(session.payment_intent)}`, { expand: ['latest_charge'] }) : session.payment_intent;
|
|
814
873
|
let dispute = null;
|
|
815
|
-
if (payment?.latest_charge?.disputed) {
|
|
874
|
+
if (hasStripeKey(env) && payment?.latest_charge?.disputed) {
|
|
816
875
|
const list = await stripeCall(env, 'GET', '/v1/disputes', { payment_intent: payment.id, limit: 100 });
|
|
817
876
|
if (!Array.isArray(list.data) || !list.data.length || list.has_more) throw new Error('Stripe dispute state is unavailable');
|
|
818
877
|
dispute = list.data.find((d) => d.status === 'lost') ?? list.data[0];
|
|
@@ -825,6 +884,7 @@ async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
825
884
|
if (!Number.isSafeInteger(total) || !Number.isSafeInteger(tax) || total < 0 || tax < 0 || total !== Number(o.amount) + tax) return 'mismatch';
|
|
826
885
|
let stripeLines = null;
|
|
827
886
|
if (lines.length > 1 && tax) {
|
|
887
|
+
if (!hasStripeKey(env)) return 'needs-keyed-recovery';
|
|
828
888
|
// Use Stripe's actual tax per line, never spread tax across products with different tax codes.
|
|
829
889
|
// https://docs.stripe.com/api/checkout/sessions/line_items
|
|
830
890
|
const response = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(session.id)}/line_items`, { limit: 100, expand: ['data.price.product'] });
|
|
@@ -866,7 +926,13 @@ async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
866
926
|
await disputed(env, paymentId, dispute.id);
|
|
867
927
|
if (['lost', 'won', 'warning_closed'].includes(dispute.status)) await disputeClosed(env, paymentId, dispute.status);
|
|
868
928
|
}
|
|
869
|
-
if (paymentId)
|
|
929
|
+
if (paymentId) {
|
|
930
|
+
await syncRefunds(env, paymentId);
|
|
931
|
+
if (!hasStripeKey(env)) for (const d of await rememberedMoney(env, paymentId, o.mode)) if (d.id.startsWith('dp_')) {
|
|
932
|
+
await disputed(env, paymentId, d.id);
|
|
933
|
+
if (['lost', 'won', 'warning_closed'].includes(d.status)) await disputeClosed(env, paymentId, d.status);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
870
936
|
await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run();
|
|
871
937
|
return result.at(-1)?.meta?.changes ? 'paid' : 'already';
|
|
872
938
|
}
|
|
@@ -882,7 +948,13 @@ async function syncRefunds(env, payment, freeOrder = null, freeLines = []) {
|
|
|
882
948
|
const all = await orderLines(env, o.id);
|
|
883
949
|
const refunds = new Map();
|
|
884
950
|
let cursor;
|
|
885
|
-
if (payment)
|
|
951
|
+
if (payment && !hasStripeKey(env)) {
|
|
952
|
+
for (const r of await rememberedMoney(env, payment, o.mode)) if (r.id.startsWith('re_')) {
|
|
953
|
+
if (r.currency !== o.currency) throw new Error('Refund currency mismatch');
|
|
954
|
+
refunds.set(r.id, r);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (payment && hasStripeKey(env)) do {
|
|
886
958
|
const page = await stripeCall(env, 'GET', '/v1/refunds', { payment_intent: payment, limit: 100, ...(cursor ? { starting_after: cursor } : {}) });
|
|
887
959
|
if (!Array.isArray(page.data)) throw new Error('Stripe refund list is unavailable');
|
|
888
960
|
for (const refund of page.data) refunds.set(refund.id, refund);
|
|
@@ -991,6 +1063,7 @@ export async function refundOrder(env, o, { reason = 'requested_by_customer', by
|
|
|
991
1063
|
const amount = selected.reduce((sum, l) => sum + Number(l.total ?? l.amount) - Number(l.refunded_amount ?? 0), 0);
|
|
992
1064
|
let refund = null;
|
|
993
1065
|
if (amount) {
|
|
1066
|
+
if (!hasStripeKey(env)) return { ok: false, error: 'stripe-dashboard', message: 'Refund in your Stripe Dashboard or ask your AI connected to Stripe. The signed refund event updates this shop.', url: dashboardLink(o.mode, 'payment', o.payment) };
|
|
994
1067
|
if (!o.payment) return { ok: false, error: 'no-payment', message: 'Stripe has not confirmed the payment yet.' };
|
|
995
1068
|
try {
|
|
996
1069
|
const prior = lineId ? selected[0].refund : legacy ? o.refund : o.whole_refund;
|
|
@@ -1040,6 +1113,7 @@ export function checkRelease(body) {
|
|
|
1040
1113
|
return checked.ok ? { ok: true, action: { op: 'shop-release', order: body.order } } : checked;
|
|
1041
1114
|
}
|
|
1042
1115
|
export async function performRelease(env, a, cat) {
|
|
1116
|
+
if (!hasStripeKey(env)) return { ok: false, error: 'stripe-dashboard', message: 'Stripe controls this checkout’s lifetime. Ask your connected AI to expire it in Stripe if needed.' };
|
|
1043
1117
|
const row = await orderById(env, a.order);
|
|
1044
1118
|
if (!row || !['started', 'processing'].includes(row.status)) return { ok: false, error: 'state', message: 'Only an unresolved order can be released.' };
|
|
1045
1119
|
// https://docs.stripe.com/api/checkout/sessions/expire — only open sessions can expire.
|
|
@@ -1079,7 +1153,7 @@ export function describeRefund(cat, a, order = null) {
|
|
|
1079
1153
|
export async function performRefund(env, cat, a) {
|
|
1080
1154
|
let o = await orderById(env, a.order);
|
|
1081
1155
|
if (!o) return { ok: false, error: 'order', message: 'No such order.' };
|
|
1082
|
-
if (!o.payment && o.session && o.paid_at === null && !(await migrationNeeded(env))) {
|
|
1156
|
+
if (hasStripeKey(env) && !o.payment && o.session && o.paid_at === null && !(await migrationNeeded(env))) {
|
|
1083
1157
|
try {
|
|
1084
1158
|
const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(o.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
|
|
1085
1159
|
if (session?.id === o.session && (session.livemode === undefined || session.livemode === (o.mode === 'live')) && ['paid', 'no_payment_required'].includes(session.payment_status)) {
|
|
@@ -1104,6 +1178,7 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
|
|
|
1104
1178
|
balance: dashboardLink(mode, 'balance'), tax: dashboardLink(mode, 'tax'), keys: dashboardLink(mode, 'keys'), webhooks: dashboardLink(mode, 'webhooks'),
|
|
1105
1179
|
...(shop?.till === 'stripe-managed' ? { managedPayments: dashboardLink(mode, 'managed-payments') } : {}),
|
|
1106
1180
|
},
|
|
1181
|
+
keyless: !hasStripeKey(env),
|
|
1107
1182
|
hook: `${origin}/api/shop/hook`,
|
|
1108
1183
|
totals: null, orders: [], referrals: { owe: [], owedToUs: [] },
|
|
1109
1184
|
};
|
|
@@ -1121,8 +1196,8 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
|
|
|
1121
1196
|
lines: (await orderLines(env, r.id)).map((line) => { const view = lineView(line); return { ...view, name: line.snapshot ? view.name : names.get(line.item) ?? view.name }; }),
|
|
1122
1197
|
...orderView(r), note: r.note ?? null, name: names.get(r.item) ?? r.item, shown: money(r.amount, r.currency), player: r.player ? { id: r.player, name: r.player_name ?? null } : null,
|
|
1123
1198
|
stripe: r.payment ? dashboardLink(r.mode, 'payment', r.payment) : null, dispute: r.dispute ? dashboardLink(r.mode, 'dispute', r.dispute) : null,
|
|
1124
|
-
releasable: ['started', 'processing'].includes(r.status),
|
|
1125
|
-
refundable: ['paid', 'started', 'processing', 'missing', 'released', 'expired', 'failed'].includes(r.status) && (Number(r.amount) === 0 || Boolean(r.payment) || Boolean(r.session)),
|
|
1199
|
+
releasable: hasStripeKey(env) && ['started', 'processing'].includes(r.status),
|
|
1200
|
+
refundable: (Number(r.amount) === 0 || hasStripeKey(env)) && ['paid', 'started', 'processing', 'missing', 'released', 'expired', 'failed'].includes(r.status) && (Number(r.amount) === 0 || Boolean(r.payment) || Boolean(r.session)),
|
|
1126
1201
|
})));
|
|
1127
1202
|
const { books, nextCursor } = await booksOf(env, { cursor });
|
|
1128
1203
|
out.referrals = { nextCursor, owe: books, failures: await statementFailures(env), owedToUs: await statementsIn(env), invoice: dashboardLink(mode, 'invoices') };
|
package/worker/stripe.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* createCheckoutSession(env, params) POST /v1/checkout/sessions (Stripe's hosted page)
|
|
6
6
|
* createRefund(env, params) POST /v1/refunds (only ever from the owner's tap, or a confirmed ask)
|
|
7
|
-
* webhook endpoints list, create and turn off:
|
|
7
|
+
* webhook endpoints list, create and turn off: the manual `homie-studio shop connect --manual` fallback calls these, on the
|
|
8
8
|
* owner's computer with the key the owner pasted there (0.24.3), so a new
|
|
9
9
|
* endpoint's signing secret goes straight to the Worker secret
|
|
10
10
|
* productIdOf(slug, item) the catalog Product a shop item is (`homie-studio shop catalog`, 0.24.3)
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.35.0';
|