@homie-rocks/studio 0.53.0 → 0.55.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/bin/homie-studio.mjs +1 -1
  3. package/guides/app/SKILL.md +2 -2
  4. package/guides/shop/SKILL.md +6 -0
  5. package/lib/app-needs.mjs +8 -1
  6. package/lib/background-queue.mjs +31 -0
  7. package/lib/build.mjs +5 -2
  8. package/lib/check.mjs +10 -2
  9. package/lib/cloudflare.mjs +16 -1
  10. package/lib/dev.mjs +8 -1
  11. package/lib/read-replication.mjs +26 -0
  12. package/lib/scaffold.mjs +46 -9
  13. package/lib/shop-links.mjs +37 -19
  14. package/lib/shop-mcp.mjs +7 -1
  15. package/lib/shop.mjs +7 -0
  16. package/lib/static-assets.mjs +27 -0
  17. package/lib/template-history.json +84 -0
  18. package/lib/worker-build.mjs +13 -4
  19. package/lib/worker-config.mjs +3 -1
  20. package/package.json +1 -1
  21. package/rules/view.ts +1 -1
  22. package/shop/SHOP.md +13 -9
  23. package/worker/analytics.mjs +84 -0
  24. package/worker/app-frame.mjs +1 -1
  25. package/worker/app-graphics.mjs +1 -1
  26. package/worker/app-records.mjs +4 -2
  27. package/worker/app-shell.mjs +9 -4
  28. package/worker/app-streams.mjs +27 -10
  29. package/worker/app-tools.mjs +1 -0
  30. package/worker/asset-paths.mjs +2 -0
  31. package/worker/background-queue.mjs +30 -0
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/built-metadata.mjs +2 -0
  34. package/worker/catalogue.mjs +18 -0
  35. package/worker/chat-page.mjs +1 -1
  36. package/worker/chat-store.mjs +5 -4
  37. package/worker/embed.mjs +1 -1
  38. package/worker/event-resources.mjs +29 -12
  39. package/worker/function-registry.mjs +4 -0
  40. package/worker/functions.mjs +7 -5
  41. package/worker/gate-routing.mjs +61 -0
  42. package/worker/gate.mjs +12 -68
  43. package/worker/hosted-registry.mjs +11 -0
  44. package/worker/hosted.mjs +3 -17
  45. package/worker/index.mjs +58 -2772
  46. package/worker/lazy-objects.mjs +40 -0
  47. package/worker/lobby-limits.mjs +2 -0
  48. package/worker/lobby.mjs +251 -0
  49. package/worker/lounge.mjs +3 -3
  50. package/worker/mcp-event-streams.mjs +3 -3
  51. package/worker/mcp-events.mjs +4 -4
  52. package/worker/mcp-store.mjs +12 -1
  53. package/worker/mcp.mjs +5 -1
  54. package/worker/office.mjs +105 -34
  55. package/worker/operational-schema.mjs +44 -0
  56. package/worker/page-policy.mjs +29 -0
  57. package/worker/pages.mjs +15 -29
  58. package/worker/parts.mjs +2 -1
  59. package/worker/payment-recovery.mjs +33 -19
  60. package/worker/player-registry.mjs +9 -0
  61. package/worker/players.mjs +8 -7
  62. package/worker/purchase-machine.mjs +5 -3
  63. package/worker/reconcile-guard.mjs +22 -0
  64. package/worker/request-db.mjs +78 -0
  65. package/worker/responses.mjs +18 -0
  66. package/worker/room-chat.mjs +216 -0
  67. package/worker/room-controls.mjs +296 -0
  68. package/worker/room-lifecycle.mjs +10 -0
  69. package/worker/room-protocol.mjs +197 -0
  70. package/worker/room.mjs +38 -731
  71. package/worker/rooms-cache.mjs +19 -0
  72. package/worker/routes.mjs +667 -0
  73. package/worker/runtime-support.mjs +445 -0
  74. package/worker/seats.mjs +1 -1
  75. package/worker/selling/index.mjs +1 -0
  76. package/worker/selling/runtime.mjs +7 -5
  77. package/worker/servers.mjs +5 -4
  78. package/worker/service-run.mjs +1 -1
  79. package/worker/shop-links.mjs +2 -2
  80. package/worker/shop-page.mjs +2 -0
  81. package/worker/shop-rules.mjs +3 -2
  82. package/worker/shop-store.mjs +4 -3
  83. package/worker/shop.mjs +89 -27
  84. package/worker/stats.mjs +7 -6
  85. package/worker/stream-objects.mjs +18 -0
  86. package/worker/stripe-intake.mjs +79 -0
  87. package/worker/stripe.mjs +3 -0
  88. package/worker/table.mjs +1212 -0
  89. package/worker/tool-registry.mjs +10 -0
  90. package/worker/version.mjs +1 -1
package/worker/shop.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ const officeShopPage = async (...args) => (await import('./shop-page.mjs')).officeShopPage(...args);
1
2
  import {claimHoldStatement,ownedHold} from './booking-holds.mjs';
2
3
  import { stripeEventOwner } from './stripe-ownership.mjs';
3
4
  import { localSession } from './local-test.mjs';
@@ -47,7 +48,7 @@ import {
47
48
  setBand, shopDataOf, spentThisMonth,
48
49
  } from './shop-store.mjs';
49
50
  import { apiBase, STRIPE_API, redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
50
- import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
51
+
51
52
  import { notFoundPage } from './site.mjs';
52
53
 
53
54
  export { forgetPlayerShop, officeShopPage, shopDataOf };
@@ -217,7 +218,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
217
218
  const requestAt = Date.now();
218
219
  const path = url.pathname;
219
220
  const read = request.method === 'GET' || request.method === 'HEAD';
220
- if (path === '/_homie/shop.js') return new Response(SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
221
+ if (path === '/_homie/shop.js') return new Response((await import('./shop-page.mjs')).SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
221
222
  const shopPath = path === '/shop' || path === '/shop/' || path.startsWith('/shop/') || path === '/api/shop' || path.startsWith('/api/shop/') || path === '/api/player/owns';
222
223
  if (selling.enabled && (path.startsWith('/api/purchases/') || path.startsWith('/purchases/'))) return selling.purchaseRoutes(request, env, url, await catalogueOf());
223
224
  if (!shopPath) return null;
@@ -230,7 +231,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
230
231
  if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
231
232
  if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
232
233
  // No shop.json: /shop/ is the studio's own to use (a page in site/pages, say). The policy can close a kids studio's shop.
233
- if(!shop && path==='/shop/' && read && !cat.site?.pages?.includes('/shop/')) return shopPage(cat,checkShop({}).shop,{origin:url.origin,open:false});
234
+ if(!shop && path==='/shop/' && read && !cat.site?.pages?.includes('/shop/')) return (await import('./shop-page.mjs')).shopPage(cat,checkShop({}).shop,{origin:url.origin,open:false});
234
235
  return shop ? notFoundPage('This studio sells nothing.', cat) : null;
235
236
  }
236
237
  const ready = await readiness(env, shop);
@@ -271,9 +272,9 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
271
272
  if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
272
273
  if (path === '/api/shop/used' && request.method === 'POST') return await usedRoute(request, env, shop);
273
274
  if (path === '/api/shop/refund' && request.method === 'POST') return await selfRefundRoute(request, env, shop, ready);
274
- if (path === '/shop/' && read) return shopPage(cat, shop, { origin: url.origin, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null, item: url.searchParams.get('item'), open: ready.ready, mode: ready.mode });
275
- if (path === '/shop/thanks' && read) return thanksPage(cat, shop, { session: /^cs_[A-Za-z0-9_]{1,200}$/.test(url.searchParams.get('session_id') ?? '') ? url.searchParams.get('session_id') : null, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null });
276
- if ((path === '/shop/refunds/' || path === '/shop/refunds') && read) return refundsPage(cat, shop);
275
+ if (path === '/shop/' && read) return (await import('./shop-page.mjs')).shopPage(cat, shop, { origin: url.origin, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null, item: url.searchParams.get('item'), open: ready.ready, mode: ready.mode });
276
+ if (path === '/shop/thanks' && read) return (await import('./shop-page.mjs')).thanksPage(cat, shop, { session: /^cs_[A-Za-z0-9_]{1,200}$/.test(url.searchParams.get('session_id') ?? '') ? url.searchParams.get('session_id') : null, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null });
277
+ if ((path === '/shop/refunds/' || path === '/shop/refunds') && read) return (await import('./shop-page.mjs')).refundsPage(cat, shop);
277
278
  const parent = /^\/shop\/parent\/([A-Za-z0-9_-]{43})$/.exec(path);
278
279
  if (parent) return await parentRoute(request, env, url, cat, shop, ready, parent[1], ctx);
279
280
  } catch (error) {
@@ -347,7 +348,7 @@ async function orderRoute(request, env, url, shop) {
347
348
  } catch (error) { logOrderError(env, o.id, error); }
348
349
  }
349
350
  const reference = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-reference:${o.id}`).first();
350
- return json({ ok: true, order: o.id, simulated: apiBase(env) !== STRIPE_API, status: playerOrderStatus(o), item: o.item, game: o.game ?? null, reference: reference?.value ?? null });
351
+ return json({ ok: true, order: o.id, simulated: apiBase(env) !== STRIPE_API, status: playerOrderStatus(o), ...(o.status==='review'?{note:'Payment received. Staff will reconcile the payment or refund it.'}:{}), item: o.item, game: o.game ?? null, reference: reference?.value ?? null });
351
352
  }
352
353
 
353
354
  /** The neutral age question: a year of birth, once. Nothing hints which answer opens anything; no default. */
@@ -524,14 +525,13 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
524
525
  if(keyless && lines.length>1 && !menu)return {error:fail(409,'menu','These items need separate Stripe menus. Custom amounts and monthly gifts use their own links.')};
525
526
  if(keyless && (!link || !paymentLinkURL(link.url)))return {error:fail(503,'payment-links','Ask the owner’s AI to sync the shop and published booking slots.')};
526
527
  if(keyless && booking) {
527
- if(booking.quantity!==1)return {error:fail(400,'quantity','Book one seat per checkout; Stripe counts completed sessions.')};
528
528
  const slot=await env.DB.prepare('SELECT value FROM app_records WHERE app=?1 AND collection=?2 AND id=?3').bind(booking.app,booking.slots,booking.slot).first();
529
529
  if(!slot || JSON.parse(slot.value).capacity!==link.capacity)return {error:fail(409,'slot-sync','This slot changed; ask the owner’s AI to sync its Stripe link.')};
530
530
  const confirmed=await env.DB.prepare("SELECT COALESCE(SUM(quantity),0) n FROM shop_bookings WHERE app=?1 AND slots=?2 AND slot=?3 AND state='confirmed'").bind(booking.app,booking.slots,booking.slot).first();
531
531
  const update=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
532
532
  const observed=update?JSON.parse(update.value):null;
533
533
  const status=observed && observed.capacity===(link.limit??link.capacity) ? observed : link;
534
- if(!status.active || status.count>=(link.limit??link.capacity) || confirmed.n>=link.capacity)return {error:fail(409,'capacity','That slot is full. Choose another slot.')};
534
+ if(!status.active || status.count>=(link.limit??link.capacity) || confirmed.n+booking.quantity>link.capacity)return {error:fail(409,'capacity','That slot is full. Choose another slot.')};
535
535
  }
536
536
  if(keyless && item.price==='choose' && lines[0].quantity!==1)return {error:fail(400,'quantity','A tip is one chosen amount; choose it on Stripe.')};
537
537
  if(menu && menu.items.some(e=>{const i=shop.items.find(i=>i.id===e.item);return !i || i.game && i.game!==game;}))return {error:fail(409,'menu','This menu includes items from another app; sync separate menus for each app.')};
@@ -649,6 +649,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
649
649
  const item = shop.items.find((i) => i.id === entry?.item);
650
650
  if (!item) return fail(404, 'item', 'no such item here');
651
651
  const quantity = entry.quantity ?? 1;
652
+ if (item.maxQuantity!==undefined&&quantity>item.maxQuantity)return fail(400,'quantity','This quantity exceeds the studio item maximum.');
652
653
  if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
653
654
  if(!hasStripeKey(env) && ['supporter','deposit'].includes(item.kind) && quantity!==1)return fail(400,'quantity','This payment link has quantity fixed at one.');
654
655
  if(item.kind==='deposit' && (!reference || !Number.isSafeInteger(entry.amount??item.price) || (entry.amount??item.price)<=0))return fail(400,'deposit','A deposit requires the app reference and its saved positive amount.');
@@ -716,6 +717,7 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
716
717
  const item = shop.items.find((i) => i.id === entry?.item);
717
718
  if (!item) return fail(404, 'item', 'no such item here');
718
719
  const quantity = entry.quantity ?? 1;
720
+ if (item.maxQuantity!==undefined&&quantity>item.maxQuantity)return fail(400,'quantity','This quantity exceeds the studio item maximum.');
719
721
  if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
720
722
  if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
721
723
  seen.add(item.id);
@@ -737,35 +739,35 @@ async function parentRoute(request, env, url, cat, shop, ready, token, ctx) {
737
739
  const cart = row ? row.cart ? JSON.parse(row.cart) : [{ item: row.item, quantity: 1 }] : [];
738
740
  const lines = cart.map((entry) => ({ item: shop.items.find((i) => i.id === entry.item), quantity: entry.quantity }));
739
741
  const item = lines[0]?.item;
740
- if (lines.some((line) => !line.item)) return parentPage(cat, shop, { gone: true });
741
- if (!row || !item || Number(row.expires_at) < Date.now()) return parentPage(cat, shop, { gone: true });
742
+ if (lines.some((line) => !line.item)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
743
+ if (!row || !item || Number(row.expires_at) < Date.now()) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
742
744
  const kid = await players.get(env, row.player);
743
- if (!kid) return parentPage(cat, shop, { gone: true });
745
+ if (!kid) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
744
746
  const done = row.order_id ? await orderById(env, row.order_id) : null;
745
- if (done?.status === 'paid') return parentPage(cat, shop, { item, lines, name: kid.name, paid: true });
746
- if (request.method === 'GET' || request.method === 'HEAD') return parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready });
747
+ if (done?.status === 'paid') return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, paid: true });
748
+ if (request.method === 'GET' || request.method === 'HEAD') return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready });
747
749
  if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403 });
748
750
  let form = null;
749
751
  try { form = await request.formData(); } catch { form = null; }
750
- if (String(form?.get('grownup') ?? '') !== 'yes') return parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready, said: 'Tick the box to say you are their parent or guardian and an adult.' });
751
- if (!ready.ready) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: false });
752
- if (purchaseLimited(request, shop, row.player, 'parentpay')) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: 'One moment: too many tries.' });
752
+ if (String(form?.get('grownup') ?? '') !== 'yes') return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready, said: 'Tick the box to say you are their parent or guardian and an adult.' });
753
+ if (!ready.ready) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: false });
754
+ if (purchaseLimited(request, shop, row.player, 'parentpay')) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: 'One moment: too many tries.' });
753
755
  const band = await bandOfPlayer(env, row.player);
754
756
  const owns = await ownsOf(env, row.player);
755
757
  let amount = 0;
756
758
  for (const [index, line] of lines.entries()) {
757
759
  const it = line.item;
758
760
  const owned = it.gives.length > 0 && it.gives.every((key) => owns.some((o) => o.key === key && o.item === it.id));
759
- if (wayFor(it, { player: kid, band, owned, spent: 0, cap: null, policy: shop.policy }) !== 'ask-a-parent') return parentPage(cat, shop, { gone: true });
761
+ if (wayFor(it, { player: kid, band, owned, spent: 0, cap: null, policy: shop.policy }) !== 'ask-a-parent') return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
760
762
  line.amount = it.price === 'choose' ? parseAmount(form?.get(lines.length === 1 ? 'amount' : `amount_${index}`), currencyScale(shop.currency)) : it.price;
761
763
  const invalid = amountError(line.amount, shop.currency);
762
- if (invalid || it.price === 'choose' && (line.amount < it.min || it.max !== null && line.amount > it.max)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: invalid ?? 'This amount is outside the studio range.' });
764
+ if (invalid || it.price === 'choose' && (line.amount < it.min || it.max !== null && line.amount > it.max)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, said: invalid ?? 'This amount is outside the studio range.' });
763
765
  const total = BigInt(amount) + BigInt(line.amount) * BigInt(line.quantity);
764
- if (total > BigInt(Number.MAX_SAFE_INTEGER)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
766
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
765
767
  amount = Number(total);
766
768
  }
767
769
  const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game, amount, parent: true, lines });
768
- if (r.error) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
770
+ if (r.error) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
769
771
  await env.DB.prepare('UPDATE shop_parent_links SET order_id = ?2 WHERE hash = ?1').bind(await sha256Hex(token), r.order.id).run();
770
772
  if (!r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, row.player).catch((error) => logOrderError(env, r.order.id, error)));
771
773
  return Response.redirect(r.url, 303);
@@ -835,7 +837,13 @@ async function hook(request, env, cat, shop, ready) {
835
837
  if (payload === null) return fail(413, 'too-large', 'The studio webhook byte limit was reached.');
836
838
  const v = await verifyWebhook(payload, request.headers.get('stripe-signature'), env.STRIPE_WEBHOOK_SECRET);
837
839
  if (!v.ok) return fail(v.why === 'no-secret' ? 503 : 400, v.why, 'not a webhook from this studio\'s Stripe');
838
- const ev = v.event;
840
+ if(env.BACKGROUND)return (await import('./stripe-intake.mjs')).acceptStripeEvent(env,v.event,new URL(request.url).origin);
841
+ return reconcileShopEvent(request,env,shop,v.event);
842
+ }
843
+
844
+ /** Internal verified-event seam shared by direct legacy intake and the queue consumer. */
845
+ export async function reconcileShopEvent(request,env,shop,ev) {
846
+ shop??=checkShop({}).shop;
839
847
  const obj = ev.data?.object ?? {};
840
848
  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'];
841
849
  if (selling.enabled) {
@@ -877,7 +885,7 @@ async function hook(request, env, cat, shop, ready) {
877
885
  if (subscriptionResult !== null) return json({ ok: true, did: subscriptionResult });
878
886
  const sessionEvent = ev.type.startsWith('checkout.session.');
879
887
  if (!sessionEvent) await rememberMoneyEvent(env, ev);
880
- if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({ ok: true, did: 'mismatch' });
888
+ if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({ok:true,did:await reviewLinkPayment(env,obj,ev.livemode?'live':'test')?'review':'mismatch'});
881
889
  if (sessionEvent && obj.metadata?.order && !obj.payment_link) {
882
890
  await env.DB.prepare(`UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL AND mode=?3
883
891
  AND EXISTS(SELECT 1 FROM meta WHERE key='booking-checkout:' || shop_orders.id)`)
@@ -950,20 +958,63 @@ async function hook(request, env, cat, shop, ready) {
950
958
  default:
951
959
  break;
952
960
  }
961
+ if(did==='mismatch'&&obj.payment_link&&await reviewLinkPayment(env,obj,ev.livemode?'live':'test'))did='review';
953
962
  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.');
954
963
  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.');
955
964
  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();
956
- if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
957
965
  return json({ ok: true, did });
958
966
  } catch (error) {
967
+ if(/(?:booking|resource) capacity unavailable/.test(error.message)&&obj.payment_link&&await reviewLinkPayment(env,obj,ev.livemode?'live':'test','The paid group exceeds remaining capacity. Staff will arrange a replacement or refund.'))return json({ok:true,did:'review'});
959
968
  logOrderError(env, null, error);
960
969
  return fail(503, 'stripe-retry', 'Payment reconciliation is temporarily unavailable; retry this event.');
961
970
  }
962
971
  }
963
972
 
973
+ /** Retain a verified payment even when its frozen order cannot be fulfilled. No grants. */
974
+ async function reviewLinkPayment(env,session,mode,reason='The paid amount differs from the order. Staff will reconcile the payment or refund it.') {
975
+ if(session.payment_status!=='paid'||!/^cs_[A-Za-z0-9_]+$/.test(session.id??'')||!Number.isSafeInteger(session.amount_total)||session.amount_total<0)return false;
976
+ if((await orderBySession(env,session.id))?.status==='review')return true;
977
+ const source=await orderById(env,session.client_reference_id);
978
+ const lines=source?await orderLines(env,source.id):[],link=lines[0]?.snapshot&&JSON.parse(lines[0].snapshot).paymentLink;
979
+ const linkId=session.payment_link?.id??session.payment_link,config=linkConfig(env);
980
+ const owned=[...(config?.items??[]),...(config?.menus??[]),...(config?.slots??[])].some(l=>l.id===linkId) || Boolean(await env.DB.prepare("SELECT 1 FROM shop_order_lines WHERE json_extract(snapshot,'$.paymentLink.id')=?1 LIMIT 1").bind(linkId).first());
981
+ if(!owned)return false;
982
+ if(!source||source.mode!==mode||session.currency!==source.currency||!link||link.id!==linkId||session.metadata?.revision!==link.revision){
983
+ const now=Date.now(),id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`,payment=typeof session.payment_intent==='string'?session.payment_intent:session.payment_intent?.id;
984
+ const note='Payment received; missing or mismatched order context. Staff must reconcile or refund it.';
985
+ await env.DB.batch([
986
+ env.DB.prepare("INSERT OR IGNORE INTO shop_orders(id,item,amount,total,currency,till,mode,status,session,payment,created_at,updated_at,attention,note) VALUES(?1,'payment-review',?2,?2,?3,'stripe',?4,'review',?5,?6,?7,?7,1,?8)").bind(id,session.amount_total,session.currency,mode,session.id,payment??null,now,note),
987
+ env.DB.prepare('INSERT OR IGNORE INTO meta(key,value) VALUES(?1,?2)').bind('payment-review:'+session.id,JSON.stringify({session:session.id,receivedAt:now,payment,amount:session.amount_total,currency:session.currency,reference:session.client_reference_id,link:linkId,reason:note})),
988
+ env.DB.prepare("INSERT OR IGNORE INTO studio_events(id,type,data,at) VALUES(?1,'payment.review',?2,?3)").bind('payment-review:'+session.id,JSON.stringify({order:id,payment,amount:session.amount_total,currency:session.currency,note}),now),
989
+ ]);
990
+ return true;
991
+ }
992
+ await env.DB.prepare('UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL').bind(source.id,session.id).run();
993
+ let order=await orderBySession(env,session.id);
994
+ if(!order) {
995
+ const id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`;
996
+ await env.DB.batch([
997
+ env.DB.prepare("INSERT OR IGNORE INTO shop_orders(id,player,checkout_player,item,game,amount,currency,till,mode,status,parent,created_at,updated_at,session) SELECT ?2,player,checkout_player,item,game,amount,currency,till,mode,'started',parent,?4,?4,?3 FROM shop_orders WHERE id=?1").bind(source.id,id,session.id,Date.now()),
998
+ env.DB.prepare('INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2').bind(`shop-reference:${id}`,`shop-reference:${source.id}`),
999
+ ...lines.map((l,i)=>env.DB.prepare('INSERT OR IGNORE INTO shop_order_lines(id,order_id,position,item,quantity,unit_amount,amount,snapshot) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)').bind(`${id}_${i}`,id,i,l.item,l.quantity,l.unit_amount,l.amount,l.snapshot)),
1000
+ ]);order=await orderBySession(env,session.id);
1001
+ }
1002
+ if(order.status==='review')return true;
1003
+ if(order.paid_at!==null)return false;
1004
+ const payment=typeof session.payment_intent==='string'?session.payment_intent:session.payment_intent?.id;
1005
+ const note='Payment received. '+reason,now=Date.now();
1006
+ await env.DB.batch([
1007
+ env.DB.prepare("UPDATE shop_orders SET status='review',payment=?2,total=?3,updated_at=?4,attention=1,note=?5 WHERE id=?1 AND paid_at IS NULL").bind(order.id,payment??null,session.amount_total,now,note),
1008
+ env.DB.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES(?1,?2)").bind('payment-review:'+session.id,JSON.stringify({session:session.id,receivedAt:now,payment,amount:session.amount_total,currency:session.currency,expected:source.amount,reason})),
1009
+ env.DB.prepare("INSERT OR IGNORE INTO studio_events(id,type,data,at) VALUES(?1,'payment.review',?2,?3)").bind('payment-review:'+session.id,JSON.stringify({order:order.id,payment,amount:session.amount_total,currency:session.currency,note}),now),
1010
+ ]);
1011
+ return true;
1012
+ }
1013
+
964
1014
  /** Payment Links can be reopened. Each signed Session becomes its own order; the URL reference
965
1015
  * selects a frozen local intent, never an entitlement or a buyer-supplied price. */
966
1016
  async function bindLinkSession(env, session, mode) {
1017
+ if(session.id&&(await orderBySession(env,session.id))?.status==='review')return false;
967
1018
  if (!/^ord_[A-Za-z0-9]{20}$/.test(session.client_reference_id ?? '') || !/^cs_[A-Za-z0-9_]+$/.test(session.id ?? '')) return false;
968
1019
  const source = await orderById(env, session.client_reference_id);
969
1020
  if (!source || source.mode !== mode) return false;
@@ -982,7 +1033,7 @@ async function bindLinkSession(env, session, mode) {
982
1033
  if(item.kind==='deposit' && amount!==source.amount)return false;
983
1034
  const quantity = item.price === 'choose' ? 1 : amount / item.price;
984
1035
  if(['supporter','deposit'].includes(item.kind) && quantity!==1)return false;
985
- if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > 999999) return false;
1036
+ if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > (item.maxQuantity??link.maxQuantity??999999)) return false;
986
1037
  if (item.price === 'choose' && (amount < item.min || item.max !== null && amount > item.max)) return false;
987
1038
  // Claim the first session atomically. A repeated URL creates a separate paid order, never loses a payment.
988
1039
  await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1 AND session IS NULL').bind(source.id, session.id).run();
@@ -994,13 +1045,14 @@ async function bindLinkSession(env, session, mode) {
994
1045
  env.DB.prepare("INSERT INTO shop_orders (id, player, checkout_player, item, game, amount, currency, till, mode, status, parent, created_at, updated_at, session) SELECT ?2, player, checkout_player, item, game, ?4, currency, till, mode, 'started', parent, ?5, ?5, ?3 FROM shop_orders WHERE id = ?1 ON CONFLICT DO NOTHING").bind(source.id, id, session.id, amount, Date.now()),
995
1046
  env.DB.prepare("INSERT OR IGNORE INTO meta(key,value) SELECT ?1,CASE WHEN value=?3 THEN ?4 ELSE value END FROM meta WHERE key=?2").bind(`shop-reference:${id}`, `shop-reference:${source.id}`,source.id,id),
996
1047
  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),
997
- ...(booking ? [bookingStatement(env,{...booking,slot:link.slot,slots:link.slots,id,order:id,pending:true})] : []),
1048
+ ...(booking ? [bookingStatement(env,{...booking,quantity,slot:link.slot,slots:link.slots,id,order:id,pending:true})] : []),
998
1049
  ]);
999
1050
  order = await orderBySession(env, session.id);
1000
1051
 
1001
1052
  }
1002
1053
  if (!order) return false;
1003
1054
  if (order.paid_at === null) await env.DB.batch([
1055
+ ...(link.slot?[env.DB.prepare("UPDATE shop_bookings SET quantity=?2 WHERE order_id=?1 AND state='pending'").bind(order.id,quantity)]:[]),
1004
1056
  env.DB.prepare('UPDATE shop_orders SET amount = ?2 WHERE id = ?1 AND paid_at IS NULL').bind(order.id, amount),
1005
1057
  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),
1006
1058
  ]);
@@ -1033,7 +1085,7 @@ async function paidBasket(env,shop,o,lines,session,paidAt) {
1033
1085
  if(session.payment_status!=='paid' || session.currency!==o.currency || !Number.isSafeInteger(total) || total<=0 || session.amount_subtotal!==total)return 'mismatch';
1034
1086
  if(o.paid_at!==null)return 'already';
1035
1087
  const expected=lines.reduce((n,l)=>n+Number(l.amount),0);
1036
- if(!Number.isSafeInteger(expected)||expected<=0)return 'mismatch';
1088
+ if(!Number.isSafeInteger(expected)||expected<=0||total!==expected)return 'mismatch';
1037
1089
  const writes=[];let cumulative=0,allocated=0;
1038
1090
  for(const l of lines) {
1039
1091
  cumulative+=Number(l.amount);
@@ -1065,6 +1117,7 @@ async function bindMenuSession(env,session,source,lines) {
1065
1117
  for(const l of invoice.lines?.data??[]) {
1066
1118
  const line=lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price));
1067
1119
  if(!line || !Number.isSafeInteger(l.quantity) || l.quantity<1 || l.amount!==JSON.parse(line.snapshot).price*l.quantity)throw new Error('Unknown invoice line');
1120
+ if(l.quantity>(JSON.parse(line.snapshot).maxQuantity??link.items?.find(i=>i.item===line.item)?.maxQuantity??999999))return false;
1068
1121
  paidLines.push({...line,quantity:l.quantity,amount:l.amount});
1069
1122
  }
1070
1123
  if(!paidLines.length || paidLines.reduce((n,l)=>n+l.amount,0)!==session.amount_subtotal)throw new Error('Invoice subtotal mismatch');
@@ -1089,6 +1142,7 @@ async function bindMenuSession(env,session,source,lines) {
1089
1142
  export async function paid(env, shop, session, paidAt = Date.now()) {
1090
1143
  const o = await orderBySession(env, session.id);
1091
1144
  if (!o) return 'unknown-order';
1145
+ if(o.status==='review')return 'review';
1092
1146
  // The session must be the one this order opened, for this player and this price.
1093
1147
  const savedLines=await orderLines(env,o.id);
1094
1148
  if(savedLines[0]?.snapshot && JSON.parse(savedLines[0].snapshot).paymentLink?.basket)return paidBasket(env,shop,o,savedLines,session,paidAt);
@@ -1194,6 +1248,11 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
1194
1248
  }
1195
1249
  const gross = Number(o.total ?? o.amount);
1196
1250
  if (!Number.isSafeInteger(amount) || amount > gross) throw new Error('Stripe refund total exceeds payment');
1251
+ if(o.status==='review') {
1252
+ // Review never grants anything, even when a later refund is reversed.
1253
+ await env.DB.prepare("UPDATE shop_orders SET refunded_amount=?2,refunded_net=?2,updated_at=?3,note=?4 WHERE id=?1 AND status='review'").bind(o.id,amount,Date.now(),`Payment received; staff review required. Refunded ${amount} of ${gross} ${o.currency} minor units.`).run();
1254
+ return 'review';
1255
+ }
1197
1256
  const moneyFull = amount === gross && gross > 0;
1198
1257
  const whole = moneyFull && [...refunds.values()].some((r) => r.status === 'succeeded' && !r.metadata?.line);
1199
1258
  const taggedGross = [...totals.values()].reduce((n, value) => n + value, 0);
@@ -1232,6 +1291,7 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
1232
1291
  async function disputed(env, payment, disputeId) {
1233
1292
  const o = payment ? await orderByPayment(env, payment) : null;
1234
1293
  if (!o) return 'unknown-order';
1294
+ if(o.status==='review'){await env.DB.prepare("UPDATE shop_orders SET dispute=?2,note='Payment received; disputed payment requires staff review.',updated_at=?3 WHERE id=?1").bind(o.id,disputeId,Date.now()).run();return 'review';}
1235
1295
  await env.DB.prepare("UPDATE shop_orders SET status = 'disputed', dispute = ?2, updated_at = ?3 WHERE id = ?1 AND status = 'paid'").bind(o.id, disputeId, Date.now()).run();
1236
1296
  return 'disputed';
1237
1297
  }
@@ -1240,6 +1300,7 @@ async function disputed(env, payment, disputeId) {
1240
1300
  async function disputeClosed(env, payment, status) {
1241
1301
  const o = payment ? await orderByPayment(env, payment) : null;
1242
1302
  if (!o) return 'unknown-order';
1303
+ if(o.status==='review'){await env.DB.prepare("UPDATE shop_orders SET note=?2,updated_at=?3 WHERE id=?1").bind(o.id,`Payment received; dispute ${status}. Staff review required.`,Date.now()).run();return 'review';}
1243
1304
  const now = Date.now();
1244
1305
  if (!['paid', 'disputed'].includes(o.status)) return 'already';
1245
1306
  if (status === 'lost') {
@@ -1388,6 +1449,7 @@ export async function shopOffice(env, cat, origin, itemCursor = 0, orderCursor =
1388
1449
  },
1389
1450
  keyless: !hasStripeKey(env),
1390
1451
  hook: `${origin}/api/shop/hook`,
1452
+ ...(env.BACKGROUND ? {delivery:await (await import('./stripe-intake.mjs')).stripeIntakeStatus(env)} : {}),
1391
1453
  totals: null, orders: [],
1392
1454
  };
1393
1455
  if (!(await migrated(env, { complete: false }))) return out;
package/worker/stats.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {writePageMetric,analyticsStatus} from './analytics.mjs';
1
2
  /**
2
3
  * STUDIO METRICS: count, don't track (@homie-rocks/studio 0.6.0).
3
4
  *
@@ -5,7 +6,7 @@
5
6
  * party, no cookie is set on a visitor, and no visitor is identified: a count is a number on a day, never a row
6
7
  * about a person. Referrers are kept as a host name only (never a path, a query or an address).
7
8
  *
8
- * WHAT IS COUNTED, one D1 row write each (an UPSERT on a daily counter; never per frame):
9
+ * WHAT IS COUNTED (page metrics go to Analytics Engine; daily rollups preserve D1 history):
9
10
  * visit a page opened (home, a game's page, /music/, a song's page, /videos/, a video's page)
10
11
  * play a game's play page opened: somebody pressed Play (or followed a Play link or QR code)
11
12
  * screen a big screen opened (/<game>/tv)
@@ -36,9 +37,8 @@
36
37
  * (HttpOnly, SameSite=Lax; from 0.13.0 at / so the owner is recognised in their own games, never readable by a
37
38
  * page or a game). There is no other way in.
38
39
  *
39
- * FREE PLAN: D1 allows 100,000 row writes and 5 million row reads a day. A visit, a play, a room and a
40
- * song or video start are one write each; a finished round is three (its row in `rounds`, the round and the
41
- * humans counters). Past the day's writes, counting stops until 00:00 UTC and the site keeps working.
40
+ * Page totals refresh hourly and can be sampled at high traffic. Older wrappers without the analytics
41
+ * binding retain D1 counting. Game, payment and budget facts remain in durable storage.
42
42
  */
43
43
 
44
44
  export const STATS_MIGRATION_FILE = '0002_studio_stats.sql';
@@ -97,9 +97,10 @@ export function counter(env, { metric, subject = '', source = '', n = 1, day = t
97
97
 
98
98
  /** Run a counter write after the response (ctx.waitUntil), or before it when the caller gave no ctx. Never throws. */
99
99
  export async function count(env, ctx, row) {
100
+ if (writePageMetric(env, row)) return;
100
101
  const stmt = counter(env, row);
101
102
  if (!stmt) return;
102
- const p = stmt.run().catch(() => {}); // before migration 0002, or past the free plan's daily writes: not counted
103
+ const p = stmt.run().catch(error => console.warn("Studio counter dropped:",error.message)); // before migration 0002, or past the free plan's daily writes: not counted
103
104
  if (ctx?.waitUntil) ctx.waitUntil(p); else await p;
104
105
  }
105
106
 
@@ -339,7 +340,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
339
340
  // The studio's own pages: its home, and the /music/ and /videos/ lists (a catalogue's road).
340
341
  pages: Object.fromEntries(['home', 'games', 'rooms', 'posts', 'music', 'videos'].map((p) => [p, sum((r) => r.metric === 'visit' && r.subject === p)])),
341
342
  games: perGame, songs: media(songs, 'song', 'music'), videos: media(videos, 'video', 'videos'), referrers, days,
342
- note: 'Counts, never people: a visit is a page a browser opened, a play is a Play press, a round is a finished round. Prefetches, crawlers and house QA are not counted. Times are UTC days.',
343
+ note: 'Counts, never people: a visit is a page a browser opened, a play is a Play press, a round is a finished round. Prefetches, crawlers and house QA are not counted. Times are UTC days.' + (env.STUDIO_ANALYTICS && env.STUDIO_ANALYTICS_DATASET ? ' Page visits, plays, screens and watches show the last hourly rollup; recent activity appears after ingestion and the next rollup. These counters may be sampled at high traffic.' + await analyticsStatus(env).then(s=>s.delayed ? ' Counts are delayed: '+(s.error??'awaiting a successful rollup')+'.' : '') : ''),
343
344
  };
344
345
  }
345
346
 
@@ -0,0 +1,18 @@
1
+ /** Streams use independent namespaces so subscribers never warm matchmaking state. */
2
+ export class AppRecordHub {
3
+ constructor(ctx, env) { this.ctx=ctx; this.env=env; }
4
+ async fetch(request) {
5
+ const {AppRecordStreams}=await import('./app-streams.mjs');
6
+ this.streams??=new AppRecordStreams(this.env,this.ctx);
7
+ const input=await request.json();
8
+ return input.op==='open'?this.streams.open(input):this.streams.wake(input.meta);
9
+ }
10
+ }
11
+ export class EventHub {
12
+ constructor(ctx, env) { this.ctx=ctx; this.env=env; }
13
+ async fetch(request) {
14
+ const {EventStreams}=await import('./event-resources.mjs');
15
+ this.streams??=new EventStreams(this.env,this.ctx);
16
+ return this.streams.fetch(request);
17
+ }
18
+ }
@@ -0,0 +1,79 @@
1
+ import {RECONCILE_GUARD,guardedDatabase} from './reconcile-guard.mjs';
2
+ /** Called only after raw-body signature verification. D1 owns the work; queues
3
+ * are wakeups and may be unavailable or exhaust their daily allowance. */
4
+ export async function acceptStripeEvent(env,event,origin) {
5
+ await env.DB.prepare('INSERT INTO stripe_intake(id,payload,origin,received) VALUES(?1,?2,?3,?4) ON CONFLICT(id) DO NOTHING')
6
+ .bind(event.id,JSON.stringify(event),origin,Date.now()).run();
7
+ try { await env.BACKGROUND.send({v:1,type:'stripe.event',id:event.id}); }
8
+ catch(error) { console.warn('Stripe delivery deferred to the scheduled D1 sweep:',error.message); }
9
+ return Response.json({ok:true,queued:true});
10
+ }
11
+ async function reconcile(env,row,event,cat) {
12
+ const {reconcileShopEvent,shopOf}=await import('./shop.mjs');
13
+ return reconcileShopEvent(new Request(new URL('/api/shop/hook',row.origin)),env,shopOf(cat),event);
14
+ }
15
+ export async function reconcileStripeEvent(env,id,cat,{leaseMs=300000,reconcile:deliver=reconcile}={}) {
16
+ const now=Date.now();let lease=now+leaseMs;
17
+ const row=await env.DB.prepare('UPDATE stripe_intake SET lease=?2 WHERE id=?1 AND done IS NULL AND lease<?3 RETURNING *').bind(id,lease,now).first();
18
+ if(!row) {
19
+ const pending=await env.DB.prepare('SELECT done,lease FROM stripe_intake WHERE id=?1').bind(id).first();
20
+ if(pending?.done)return;
21
+ const error=new Error('Stripe event is missing or already being reconciled');
22
+ if(pending?.lease>=now)error.retryAfterSeconds=Math.max(1,Math.min(301,Math.ceil((pending.lease-now)/1000)+1));
23
+ throw error;
24
+ }
25
+ let renewal=Promise.resolve(),lost=null;
26
+ const timer=setInterval(()=>{
27
+ renewal=renewal.then(async()=>{
28
+ if(lost)return;
29
+ const next=Date.now()+leaseMs;
30
+ const renewed=await env.DB.prepare('UPDATE stripe_intake SET lease=?3 WHERE id=?1 AND lease=?2 AND done IS NULL RETURNING id').bind(id,lease,next).first();
31
+ if(!renewed)throw new Error('Stripe reconciliation lease was lost');
32
+ lease=next;
33
+ }).catch(error=>{lost=error;});
34
+ },Math.max(1,Math.floor(leaseMs/3)));
35
+ const check=async()=>{
36
+ await renewal;
37
+ if(lost)throw lost;
38
+ if(Date.now()>=lease)throw new Error('Stripe reconciliation lease expired');
39
+ };
40
+ const guarded={...env,DB:guardedDatabase(env.DB,check),[RECONCILE_GUARD]:check};
41
+ try {
42
+ await check();
43
+ const event=JSON.parse(row.payload);
44
+ if(event.id!==id)throw new Error('Stored Stripe event id differs');
45
+ const response=await deliver(guarded,row,event,cat);
46
+ if(!response.ok)throw new Error('Stripe reconciliation needs retry ('+response.status+')');
47
+ clearInterval(timer);await renewal;
48
+ if(lost)throw lost;
49
+ const done=await env.DB.prepare('UPDATE stripe_intake SET done=?2,lease=0 WHERE id=?1 AND lease=?3 AND lease>?2 RETURNING id').bind(id,Date.now(),lease).first();
50
+ if(!done)throw new Error('Stripe reconciliation lease lost before completion');
51
+ } catch(error) {
52
+ clearInterval(timer);await renewal;
53
+ await env.DB.prepare('UPDATE stripe_intake SET lease=?3 WHERE id=?1 AND lease=?2').bind(id,lease,Date.now()+60000).run();
54
+ throw error;
55
+ } finally {clearInterval(timer);}
56
+ }
57
+ export async function stripeIntakeStatus(env) {
58
+ if(!env.DB)return {pending:0,stuck:0};
59
+ const row=await env.DB.prepare('SELECT COUNT(*) AS pending,COALESCE(SUM(received<?1),0) AS stuck,MIN(received) AS oldest FROM stripe_intake WHERE done IS NULL').bind(Date.now()-3600000).first();
60
+ return {...row,message:row?.stuck?'Stripe deliveries are delayed. Check Worker logs and Stripe key permissions; scheduled recovery retries unfinished events.':null};
61
+ }
62
+ export async function sweepStripeEvents(env,cat) {
63
+ if(!env.DB)return;
64
+ const rows=(await env.DB.prepare('SELECT id FROM stripe_intake WHERE done IS NULL AND lease<?1 ORDER BY lease,received,id LIMIT 100').bind(Date.now()).all()).results??[];
65
+ for(const row of rows) {
66
+ try {await reconcileStripeEvent(env,row.id,cat);}
67
+ catch(error) {console.error('Stripe intake recovery failed',row.id,error.message);}
68
+ }
69
+ const status=await stripeIntakeStatus(env);
70
+ if(status.stuck)console.error('Stripe intake needs attention',status);
71
+ return status;
72
+ }
73
+ export async function pruneStripeEvents(env) {
74
+ const cutoff=Date.now()-60*86400000;
75
+ await env.DB.batch([
76
+ ...(env.BACKGROUND?[env.DB.prepare('DELETE FROM stripe_intake WHERE id IN (SELECT id FROM stripe_intake WHERE done<?1 ORDER BY done LIMIT 1000)').bind(cutoff)]:[]),
77
+ env.DB.prepare('DELETE FROM shop_events WHERE id IN (SELECT id FROM shop_events WHERE at<?1 ORDER BY at LIMIT 1000)').bind(cutoff),
78
+ ]);
79
+ }
package/worker/stripe.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {RECONCILE_GUARD} from './reconcile-guard.mjs';
1
2
  /**
2
3
  * THE STUDIO'S OWN STRIPE, from its Worker (@homie-rocks/studio 0.24.0). No SDK: a few form-encoded calls with the
3
4
  * studio's key (a Worker secret), and Stripe's documented webhook signature check with WebCrypto.
@@ -95,6 +96,7 @@ export async function productIdOf(slug, item) {
95
96
 
96
97
  /** One call with the studio's key. `idempotencyKey` makes a retried POST do the work once (Stripe keeps it 24 h). */
97
98
  export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch, version = STRIPE_VERSION, timeout = 15_000 } = {}) {
99
+ await env?.[RECONCILE_GUARD]?.();
98
100
  const key = String(env?.STRIPE_KEY ?? '');
99
101
  if (!KEY_SHAPE.test(key)) throw new StripeError(0, { error: { message: 'no Stripe key on this Worker', code: 'no-key' } });
100
102
  const body = params && method !== 'GET' ? formEncode(params).toString() : null;
@@ -119,6 +121,7 @@ export async function stripeCall(env, method, path, params = null, { idempotency
119
121
  error.idempotentReplayed = res.headers.get('idempotent-replayed') === 'true';
120
122
  throw error;
121
123
  }
124
+ await env?.[RECONCILE_GUARD]?.();
122
125
  return json;
123
126
  }
124
127