@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/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
- A cart is one payment with several lines and quantities. Your office can refund a whole order or a line.
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` provides a local page for the key and webhook signing secret; both are part of payment setup, credentials stay in Worker secrets.
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` opens a local page for the studio's Stripe key and webhook secret; `--live` selects live mode.
108
- Both restricted (`rk_`) and full secret (`sk_`) keys are accepted. A restricted key is useful: Checkout Sessions Write, Charges Write, PaymentIntents Read, Disputes Read;
109
- Webhook Endpoints Write lets the connect page create the webhook. The key and webhook signing secret are both
110
- part of working payment setup; without either, readiness names the missing step and opens no checkout.
111
- [Stripe fulfillment](https://docs.stripe.com/checkout/fulfillment) uses webhooks as well as the redirect.
112
- Credentials stay in Worker secrets. A preview normally has no database and cannot sell. A preview that you
113
- configure with a database, key and signing secret sells into that database.
114
- Stripe's agent tools can populate Products with `shop catalog` and read sales; secret-returning endpoint
115
- creation belongs on the connect page. Stripe approval links are handled by the owner.
116
-
117
- `shop`, `shop orders`, and `/_studio/office/shop` show readiness and sales. Office refunds are owner actions;
118
- `shop refund <order>` asks the owner to confirm. Stripe controls pending or held refunds.
119
- The office links Stripe's disputes and provides CSV exports. A dispute does not lock a player's account;
120
- a lost dispute revokes the order's entitlements. Referral statements record what the studio owes;
121
- the studio pays referrers directly and marks payment in its office.
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
- Sessions reserve the optional cap until Stripe confirms an outcome. With the default lifetime, a sessionless
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
- 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.
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
+ }
@@ -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. Refund is one tap here (your AI can only ask, and you confirm). 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>`;
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` from a page on the owner's own computer):
25
- * STRIPE_KEY (a secret key or a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read;
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, modeOf, productIdOf, verifyWebhook } from './stripe.mjs';
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 key = String(env?.STRIPE_KEY ?? '');
140
- if (!KEY_SHAPE.test(key)) missing.push('stripe-key');
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: KEY_SHAPE.test(key) ? modeOf(key) : null };
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
- 'stripe-key': 'The Worker has no Stripe key yet (homie-studio shop connect: the owner pastes a Stripe key into a page on their own computer).',
157
- 'webhook-secret': 'The Worker has no webhook signing secret yet (the same page takes it).',
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 = [modeOf(env.STRIPE_KEY), now, Number.isFinite(before) ? before : Number.MAX_SAFE_INTEGER, ...(player ? [player] : []), ...(order ? [order] : [])];
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, modeOf(env.STRIPE_KEY), parent ? 1 : 0, order.via, now, shop.capPerPlayerMonth, Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1), order.expiresAt * 1000);
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 = modeOf(env.STRIPE_KEY);
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
- if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
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 && !KEY_SHAPE.test(String(env.STRIPE_KEY ?? ''))) return fail(503, 'closed', MISSING_WORDS['stripe-key']);
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' && KEY_SHAPE.test(String(env.STRIPE_KEY ?? '')) && ev.livemode !== (modeOf(env.STRIPE_KEY) === 'live')) return json({ ok: true, ignored: 'mode' });
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(modeOf(env.STRIPE_KEY)).first()) {
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 !== modeOf(env.STRIPE_KEY)) return json({ ok: true, did: 'ignored' });
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
- if (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';
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) await syncRefunds(env, 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) do {
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: only `homie-studio shop connect` calls these, on the
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)
@@ -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.34.0';
2
+ export const STUDIO_VERSION_TAG = '0.35.0';