@homie-rocks/studio 0.34.0 → 0.35.1

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/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)
@@ -55,13 +55,13 @@ export function formEncode(params, prefix = '', out = new URLSearchParams()) {
55
55
  export function redactStripe(value, env = {}) {
56
56
  let text = String(value ?? '');
57
57
  for (const secret of [env.STRIPE_KEY, env.STRIPE_WEBHOOK_SECRET]) if (secret) text = text.replaceAll(String(secret), '[redacted]');
58
- return text.replace(/(?:[rs]k_(?:test|live)_[A-Za-z0-9]+|whsec_[A-Za-z0-9+/=_-]+|[A-Za-z0-9_]+_secret_[A-Za-z0-9_-]+)/g, '[redacted]');
58
+ return text.replace(/(?:(?:sk|rk|pk|whsec|oak)_[A-Za-z0-9+/=_-]+|[A-Za-z0-9_]+_secret_[A-Za-z0-9_-]+)/g, '[redacted]');
59
59
  }
60
60
 
61
61
  export class StripeError extends Error {
62
62
  constructor(status, body) {
63
63
  const e = body?.error ?? {};
64
- super(e.message ? redactStripe(e.message).slice(0, 300) : `Stripe answered ${status}`);
64
+ super(e.message ? redactStripe(e.message).slice(0, 400) : `Stripe answered ${status}`);
65
65
  this.status = status;
66
66
  this.code = redactStripe(e.code ?? e.type ?? 'stripe');
67
67
  this.type = e.type ? redactStripe(e.type) : null;
@@ -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.1';