@homie-rocks/studio 0.52.0 → 0.54.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 (61) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/apps/apps.ts +17 -0
  3. package/bin/homie-studio.mjs +9 -3
  4. package/docs/shop-migration.md +9 -0
  5. package/docs/toolkit-batch.md +49 -0
  6. package/functions/FUNCTIONS.md +15 -1
  7. package/guides/app/PLACES.md +23 -0
  8. package/guides/app/SKILL.md +2 -2
  9. package/guides/shop/SKILL.md +6 -0
  10. package/lib/app-needs.mjs +27 -11
  11. package/lib/app-ready.mjs +1 -1
  12. package/lib/build.mjs +4 -3
  13. package/lib/check.mjs +47 -15
  14. package/lib/dev.mjs +9 -1
  15. package/lib/fork.mjs +47 -0
  16. package/lib/jobs.mjs +1 -0
  17. package/lib/mcp-tools.mjs +1 -1
  18. package/lib/needs-headless.mjs +4 -3
  19. package/lib/scaffold.mjs +27 -1
  20. package/lib/shop-links.mjs +39 -21
  21. package/lib/shop-mcp.mjs +13 -1
  22. package/lib/stripe-connect.mjs +37 -2
  23. package/lib/template-history.json +84 -0
  24. package/lib/tools-cli.mjs +25 -3
  25. package/lib/worker-config.mjs +1 -0
  26. package/package.json +1 -1
  27. package/rules/rules.ts +1 -1
  28. package/rules/view.ts +1 -1
  29. package/shop/SHOP.md +13 -9
  30. package/shop/shop.ts +3 -3
  31. package/tools/tools.ts +3 -2
  32. package/worker/app-format.mjs +6 -3
  33. package/worker/app-records.mjs +20 -6
  34. package/worker/app-shell.mjs +10 -5
  35. package/worker/app-streams.mjs +26 -9
  36. package/worker/app-tools.mjs +2 -1
  37. package/worker/app-web.mjs +23 -0
  38. package/worker/booking-calendar.mjs +26 -0
  39. package/worker/booking-holds.mjs +18 -5
  40. package/worker/booking-options.mjs +23 -0
  41. package/worker/booking-resources.mjs +55 -0
  42. package/worker/build-id.mjs +1 -1
  43. package/worker/chat-store.mjs +5 -4
  44. package/worker/customer-mcp.mjs +1 -1
  45. package/worker/event-resources.mjs +6 -1
  46. package/worker/index.mjs +74 -27
  47. package/worker/local-test.mjs +10 -0
  48. package/worker/mcp-events.mjs +2 -2
  49. package/worker/mcp-tools.mjs +20 -2
  50. package/worker/office.mjs +108 -6
  51. package/worker/operational-schema.mjs +30 -0
  52. package/worker/pages.mjs +7 -1
  53. package/worker/records-page.mjs +2 -2
  54. package/worker/request-db.mjs +53 -0
  55. package/worker/servers.mjs +5 -4
  56. package/worker/shop-links.mjs +1 -1
  57. package/worker/shop-page.mjs +1 -0
  58. package/worker/shop-rules.mjs +3 -2
  59. package/worker/shop-store.mjs +4 -3
  60. package/worker/shop.mjs +68 -8
  61. package/worker/version.mjs +1 -1
@@ -1,3 +1,4 @@
1
+ import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
1
2
  /**
2
3
  * SERVERS (@homie-rocks/studio 0.16.0; NETPLAY.md section 17): a named, lasting pool of rooms for one game, with its
3
4
  * own policy, its own door and its own Lobby pool. Strangers are matched only inside one server, never across.
@@ -171,8 +172,8 @@ const serverCache = new WeakMap();
171
172
  /** Every server row of every game (D1), read at most every 5 s per Worker instance; an empty map before migration 0006. */
172
173
  async function rowsOf(env, { fresh = false } = {}) {
173
174
  if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
174
- const hit = serverCache.get(env.DB);
175
- if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
175
+ const hit = serverCache.get(databaseBinding(env.DB));
176
+ if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
176
177
  let map = new Map();
177
178
  try {
178
179
  const { results } = await env.DB.prepare('SELECT * FROM servers LIMIT 2000').all();
@@ -182,10 +183,10 @@ async function rowsOf(env, { fresh = false } = {}) {
182
183
  map.set(r.game, list);
183
184
  }
184
185
  } catch { map = new Map(); }
185
- serverCache.set(env.DB, { at: Date.now(), map });
186
+ serverCache.set(databaseBinding(env.DB), { at: Date.now(), map });
186
187
  return map;
187
188
  }
188
- export const forgetServers = (env) => { if (env?.DB) serverCache.delete(env.DB); };
189
+ export const forgetServers = (env) => { if (env?.DB) serverCache.delete(databaseBinding(env.DB)); };
189
190
 
190
191
  /**
191
192
  * A game's servers: `public` first, then the rest by when they were made. The game.json seeds (the catalogue's
@@ -55,5 +55,5 @@ export async function loadShopLinks(env, origin='https://studio.invalid') {
55
55
  const response=await env.ASSETS.fetch(new Request(new URL(env.STRIPE_SHOP_LINKS_ASSET,origin)));
56
56
  if(response.ok)value=await response.json();
57
57
  } catch {}
58
- return {...env,STRIPE_SHOP_LINKS:JSON.stringify(value?.config??null),STRIPE_SHOP_SETUP:value?.next??'Ask your AI to finish stripe_shop_plan with verify:true.'};
58
+ return {...env,STRIPE_SHOP_LINKS:JSON.stringify(value?.config??null),...(value?.purchases?{PURCHASE_LINKS:JSON.stringify(value.purchases)}:{}),STRIPE_SHOP_SETUP:value?.next??'Ask your AI to finish stripe_shop_plan with verify:true.'};
59
59
  }
@@ -298,6 +298,7 @@ export const SHOP_JS = String.raw`(function () {
298
298
  (function poll() {
299
299
  tries += 1;
300
300
  api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
301
+ if (r.status === 'review') { $('[data-thanks-title]').textContent='Payment received'; $('[data-thanks-line]').textContent=r.note || 'Staff are reviewing your payment and will arrange fulfillment or a refund.'; return; }
301
302
  if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; if (r.simulated) { $('[data-thanks-title]').textContent = 'Simulation confirmed'; $('[data-thanks-line]').textContent = 'This was an offline test. No money moved and no Stripe receipt was sent.'; } return; }
302
303
  if (tries < 40) setTimeout(poll, 1500);
303
304
  else $('[data-thanks-line]').textContent = boot.giving ? 'Stripe has not confirmed your gift yet. Check your email for its receipt.' : 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
@@ -183,10 +183,11 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
183
183
  else if (Array.isArray(games) && !games.includes(String(it.game))) err(`${at}.game`, `there is no game "${it.game}" in games/`);
184
184
  }
185
185
  if (it.taxCode !== undefined && !TAX_CODE.test(String(it.taxCode))) err(`${at}.taxCode`, 'taxCode is a Stripe product tax code such as "txcd_10000000"');
186
+ if(it.maxQuantity!==undefined && (!Number.isSafeInteger(it.maxQuantity)||it.maxQuantity<1||it.maxQuantity>999999))err(`${at}.maxQuantity`,'use a whole number from 1 to 999999');
186
187
  const advantage = it.advantage === true;
187
188
  const badge = gives.find((g) => g.startsWith('badge:'));
188
189
  for (const key of Object.keys(it)) {
189
- if (!['id', 'kind', 'name', 'blurb', 'description', 'interval', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
190
+ if (!['id', 'kind', 'name', 'blurb', 'description', 'interval', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage', 'maxQuantity'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
190
191
  }
191
192
  items.push({
192
193
  id, kind, name, ...(it.interval === 'month' ? { interval: 'month' } : {}), ...(blurb ? { blurb } : {}),
@@ -194,7 +195,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
194
195
  gives, ...(badge ? { badge: badgeWord(badge, it) } : {}),
195
196
  ...(days !== null ? { days } : {}), ...(Number.isFinite(starts) ? { starts: new Date(starts).toISOString() } : {}), ...(Number.isFinite(ends) ? { ends: new Date(ends).toISOString() } : {}),
196
197
  ...(it.game ? { game: String(it.game) } : {}), ...(it.taxCode ? { taxCode: String(it.taxCode) } : {}),
197
- advantage,
198
+ advantage, ...(it.maxQuantity!==undefined?{maxQuantity:it.maxQuantity}:{}),
198
199
  });
199
200
  });
200
201
 
@@ -1,3 +1,4 @@
1
+ import {databaseBinding} from './request-db.mjs';
1
2
  /**
2
3
  * The shop's rows in the studio's own D1 (@homie-rocks/studio 0.24.0, migration 0008_studio_shop.sql). Kept apart
3
4
  * from worker/shop.mjs so player accounts (worker/players.mjs) can export and forget a player's purchases without
@@ -137,7 +138,7 @@ const completeSchemas = new WeakSet();
137
138
  /** Name the first missing schema migration, including a present table with an older shape. */
138
139
  export async function migrationNeeded(env) {
139
140
  if (!env?.DB) return SHOP_MIGRATION_FILE;
140
- if (completeSchemas.has(env.DB)) return null;
141
+ if (completeSchemas.has(databaseBinding(env.DB))) return null;
141
142
  for (const [file, query] of [
142
143
  [SHOP_MIGRATION_FILE, 'SELECT 1 FROM shop_orders LIMIT 1'],
143
144
  [SHOP_RESERVATIONS_FILE, 'SELECT expires_at FROM shop_orders LIMIT 1'],
@@ -148,7 +149,7 @@ export async function migrationNeeded(env) {
148
149
  ]) {
149
150
  try { await env.DB.prepare(query).first(); } catch { return file; }
150
151
  }
151
- completeSchemas.add(env.DB);
152
+ completeSchemas.add(databaseBinding(env.DB));
152
153
  return null;
153
154
  }
154
155
 
@@ -244,7 +245,7 @@ export const orderByPayment = (env, payment) => env.DB.prepare('SELECT * FROM sh
244
245
  export function orderView(r) {
245
246
  return {
246
247
  id: r.id, refundedAmount: Number(r.refunded_amount ?? 0), item: r.item, game: r.game ?? null, amount: Number(r.amount), currency: r.currency, tax: r.tax === null ? null : Number(r.tax), total: r.total === null ? null : Number(r.total),
247
- status: r.status, till: r.till, mode: r.mode, parent: Number(r.parent) === 1,
248
+ status: r.status, ...(r.status==='review'?{note:'Payment received. Staff will reconcile the payment or refund it.'}:{}), till: r.till, mode: r.mode, parent: Number(r.parent) === 1,
248
249
  createdAt: Number(r.created_at), paidAt: r.paid_at === null ? null : Number(r.paid_at), refundedAt: r.refunded_at === null ? null : Number(r.refunded_at),
249
250
  };
250
251
  }
package/worker/shop.mjs CHANGED
@@ -347,7 +347,7 @@ async function orderRoute(request, env, url, shop) {
347
347
  } catch (error) { logOrderError(env, o.id, error); }
348
348
  }
349
349
  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 });
350
+ 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
351
  }
352
352
 
353
353
  /** The neutral age question: a year of birth, once. Nothing hints which answer opens anything; no default. */
@@ -524,14 +524,13 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
524
524
  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
525
  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
526
  if(keyless && booking) {
527
- if(booking.quantity!==1)return {error:fail(400,'quantity','Book one seat per checkout; Stripe counts completed sessions.')};
528
527
  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
528
  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
529
  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
530
  const update=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
532
531
  const observed=update?JSON.parse(update.value):null;
533
532
  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.')};
533
+ 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
534
  }
536
535
  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
536
  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.')};
@@ -577,7 +576,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
577
576
  }
578
577
  if (keyless) {
579
578
  const checkout = new URL(link.url); checkout.searchParams.set('client_reference_id', order.id);
580
- if(menu?.basket)checkout.searchParams.set('prefilled_amount',String(amount));
579
+ if(menu?.basket || item.kind==='deposit')checkout.searchParams.set('prefilled_amount',String(amount));
581
580
  return { order, url: checkout.href, reconciled };
582
581
  }
583
582
  let session = null;
@@ -649,7 +648,10 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
649
648
  const item = shop.items.find((i) => i.id === entry?.item);
650
649
  if (!item) return fail(404, 'item', 'no such item here');
651
650
  const quantity = entry.quantity ?? 1;
651
+ if (item.maxQuantity!==undefined&&quantity>item.maxQuantity)return fail(400,'quantity','This quantity exceeds the studio item maximum.');
652
652
  if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
653
+ if(!hasStripeKey(env) && ['supporter','deposit'].includes(item.kind) && quantity!==1)return fail(400,'quantity','This payment link has quantity fixed at one.');
654
+ 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.');
653
655
  if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
654
656
  seen.add(item.id);
655
657
  const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
@@ -679,6 +681,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
679
681
  const collection = requestedBooking?.collection;
680
682
  const spec = app?.records?.collections?.[collection]?.booking;
681
683
  if (!spec || !/^[A-Za-z0-9_-]{1,64}$/.test(requestedBooking?.slot ?? '') || lines.length !== 1 || item.id !== spec.item || item.interval) return fail(400, 'booking', 'Choose a declared booking slot and its service item.');
684
+ if(spec.resources&&requestedBooking.hold===undefined)return fail(409,'booking','Reserve the resource through the booking tool and pass its hold before checkout.');
682
685
  const local = await localSession(request, env);
683
686
  booking = { app: local?.app === game && local.scope ? `${game}:check:${local.scope}` : game, collection, slots: spec.slots, slot: requestedBooking.slot, quantity: lines[0].quantity };
684
687
  if(requestedBooking.hold!==undefined){
@@ -713,6 +716,7 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
713
716
  const item = shop.items.find((i) => i.id === entry?.item);
714
717
  if (!item) return fail(404, 'item', 'no such item here');
715
718
  const quantity = entry.quantity ?? 1;
719
+ if (item.maxQuantity!==undefined&&quantity>item.maxQuantity)return fail(400,'quantity','This quantity exceeds the studio item maximum.');
716
720
  if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
717
721
  if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
718
722
  seen.add(item.id);
@@ -874,7 +878,7 @@ async function hook(request, env, cat, shop, ready) {
874
878
  if (subscriptionResult !== null) return json({ ok: true, did: subscriptionResult });
875
879
  const sessionEvent = ev.type.startsWith('checkout.session.');
876
880
  if (!sessionEvent) await rememberMoneyEvent(env, ev);
877
- if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({ ok: true, did: 'mismatch' });
881
+ 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'});
878
882
  if (sessionEvent && obj.metadata?.order && !obj.payment_link) {
879
883
  await env.DB.prepare(`UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL AND mode=?3
880
884
  AND EXISTS(SELECT 1 FROM meta WHERE key='booking-checkout:' || shop_orders.id)`)
@@ -947,20 +951,64 @@ async function hook(request, env, cat, shop, ready) {
947
951
  default:
948
952
  break;
949
953
  }
954
+ if(did==='mismatch'&&obj.payment_link&&await reviewLinkPayment(env,obj,ev.livemode?'live':'test'))did='review';
950
955
  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.');
951
956
  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.');
952
957
  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();
953
958
  if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
954
959
  return json({ ok: true, did });
955
960
  } catch (error) {
961
+ 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'});
956
962
  logOrderError(env, null, error);
957
963
  return fail(503, 'stripe-retry', 'Payment reconciliation is temporarily unavailable; retry this event.');
958
964
  }
959
965
  }
960
966
 
967
+ /** Retain a verified payment even when its frozen order cannot be fulfilled. No grants. */
968
+ async function reviewLinkPayment(env,session,mode,reason='The paid amount differs from the order. Staff will reconcile the payment or refund it.') {
969
+ if(session.payment_status!=='paid'||!/^cs_[A-Za-z0-9_]+$/.test(session.id??'')||!Number.isSafeInteger(session.amount_total)||session.amount_total<0)return false;
970
+ if((await orderBySession(env,session.id))?.status==='review')return true;
971
+ const source=await orderById(env,session.client_reference_id);
972
+ const lines=source?await orderLines(env,source.id):[],link=lines[0]?.snapshot&&JSON.parse(lines[0].snapshot).paymentLink;
973
+ const linkId=session.payment_link?.id??session.payment_link,config=linkConfig(env);
974
+ 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());
975
+ if(!owned)return false;
976
+ if(!source||source.mode!==mode||session.currency!==source.currency||!link||link.id!==linkId||session.metadata?.revision!==link.revision){
977
+ 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;
978
+ const note='Payment received; missing or mismatched order context. Staff must reconcile or refund it.';
979
+ await env.DB.batch([
980
+ 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),
981
+ 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})),
982
+ 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),
983
+ ]);
984
+ return true;
985
+ }
986
+ await env.DB.prepare('UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL').bind(source.id,session.id).run();
987
+ let order=await orderBySession(env,session.id);
988
+ if(!order) {
989
+ const id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`;
990
+ await env.DB.batch([
991
+ 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()),
992
+ 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}`),
993
+ ...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)),
994
+ ]);order=await orderBySession(env,session.id);
995
+ }
996
+ if(order.status==='review')return true;
997
+ if(order.paid_at!==null)return false;
998
+ const payment=typeof session.payment_intent==='string'?session.payment_intent:session.payment_intent?.id;
999
+ const note='Payment received. '+reason,now=Date.now();
1000
+ await env.DB.batch([
1001
+ 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),
1002
+ 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})),
1003
+ 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),
1004
+ ]);
1005
+ return true;
1006
+ }
1007
+
961
1008
  /** Payment Links can be reopened. Each signed Session becomes its own order; the URL reference
962
1009
  * selects a frozen local intent, never an entitlement or a buyer-supplied price. */
963
1010
  async function bindLinkSession(env, session, mode) {
1011
+ if(session.id&&(await orderBySession(env,session.id))?.status==='review')return false;
964
1012
  if (!/^ord_[A-Za-z0-9]{20}$/.test(session.client_reference_id ?? '') || !/^cs_[A-Za-z0-9_]+$/.test(session.id ?? '')) return false;
965
1013
  const source = await orderById(env, session.client_reference_id);
966
1014
  if (!source || source.mode !== mode) return false;
@@ -976,8 +1024,10 @@ async function bindLinkSession(env, session, mode) {
976
1024
  if (!link || (session.payment_link?.id ?? session.payment_link) !== link.id || session.metadata?.revision !== link.revision || session.currency !== source.currency) return false;
977
1025
  const amount = session.amount_subtotal;
978
1026
  if (!Number.isSafeInteger(amount) || amount <= 0) return false;
1027
+ if(item.kind==='deposit' && amount!==source.amount)return false;
979
1028
  const quantity = item.price === 'choose' ? 1 : amount / item.price;
980
- if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > 999999) return false;
1029
+ if(['supporter','deposit'].includes(item.kind) && quantity!==1)return false;
1030
+ if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > (item.maxQuantity??link.maxQuantity??999999)) return false;
981
1031
  if (item.price === 'choose' && (amount < item.min || item.max !== null && amount > item.max)) return false;
982
1032
  // Claim the first session atomically. A repeated URL creates a separate paid order, never loses a payment.
983
1033
  await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1 AND session IS NULL').bind(source.id, session.id).run();
@@ -989,13 +1039,14 @@ async function bindLinkSession(env, session, mode) {
989
1039
  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()),
990
1040
  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),
991
1041
  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),
992
- ...(booking ? [bookingStatement(env,{...booking,slot:link.slot,slots:link.slots,id,order:id,pending:true})] : []),
1042
+ ...(booking ? [bookingStatement(env,{...booking,quantity,slot:link.slot,slots:link.slots,id,order:id,pending:true})] : []),
993
1043
  ]);
994
1044
  order = await orderBySession(env, session.id);
995
1045
 
996
1046
  }
997
1047
  if (!order) return false;
998
1048
  if (order.paid_at === null) await env.DB.batch([
1049
+ ...(link.slot?[env.DB.prepare("UPDATE shop_bookings SET quantity=?2 WHERE order_id=?1 AND state='pending'").bind(order.id,quantity)]:[]),
999
1050
  env.DB.prepare('UPDATE shop_orders SET amount = ?2 WHERE id = ?1 AND paid_at IS NULL').bind(order.id, amount),
1000
1051
  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),
1001
1052
  ]);
@@ -1028,7 +1079,7 @@ async function paidBasket(env,shop,o,lines,session,paidAt) {
1028
1079
  if(session.payment_status!=='paid' || session.currency!==o.currency || !Number.isSafeInteger(total) || total<=0 || session.amount_subtotal!==total)return 'mismatch';
1029
1080
  if(o.paid_at!==null)return 'already';
1030
1081
  const expected=lines.reduce((n,l)=>n+Number(l.amount),0);
1031
- if(!Number.isSafeInteger(expected)||expected<=0)return 'mismatch';
1082
+ if(!Number.isSafeInteger(expected)||expected<=0||total!==expected)return 'mismatch';
1032
1083
  const writes=[];let cumulative=0,allocated=0;
1033
1084
  for(const l of lines) {
1034
1085
  cumulative+=Number(l.amount);
@@ -1060,6 +1111,7 @@ async function bindMenuSession(env,session,source,lines) {
1060
1111
  for(const l of invoice.lines?.data??[]) {
1061
1112
  const line=lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price));
1062
1113
  if(!line || !Number.isSafeInteger(l.quantity) || l.quantity<1 || l.amount!==JSON.parse(line.snapshot).price*l.quantity)throw new Error('Unknown invoice line');
1114
+ if(l.quantity>(JSON.parse(line.snapshot).maxQuantity??link.items?.find(i=>i.item===line.item)?.maxQuantity??999999))return false;
1063
1115
  paidLines.push({...line,quantity:l.quantity,amount:l.amount});
1064
1116
  }
1065
1117
  if(!paidLines.length || paidLines.reduce((n,l)=>n+l.amount,0)!==session.amount_subtotal)throw new Error('Invoice subtotal mismatch');
@@ -1084,6 +1136,7 @@ async function bindMenuSession(env,session,source,lines) {
1084
1136
  export async function paid(env, shop, session, paidAt = Date.now()) {
1085
1137
  const o = await orderBySession(env, session.id);
1086
1138
  if (!o) return 'unknown-order';
1139
+ if(o.status==='review')return 'review';
1087
1140
  // The session must be the one this order opened, for this player and this price.
1088
1141
  const savedLines=await orderLines(env,o.id);
1089
1142
  if(savedLines[0]?.snapshot && JSON.parse(savedLines[0].snapshot).paymentLink?.basket)return paidBasket(env,shop,o,savedLines,session,paidAt);
@@ -1189,6 +1242,11 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
1189
1242
  }
1190
1243
  const gross = Number(o.total ?? o.amount);
1191
1244
  if (!Number.isSafeInteger(amount) || amount > gross) throw new Error('Stripe refund total exceeds payment');
1245
+ if(o.status==='review') {
1246
+ // Review never grants anything, even when a later refund is reversed.
1247
+ 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();
1248
+ return 'review';
1249
+ }
1192
1250
  const moneyFull = amount === gross && gross > 0;
1193
1251
  const whole = moneyFull && [...refunds.values()].some((r) => r.status === 'succeeded' && !r.metadata?.line);
1194
1252
  const taggedGross = [...totals.values()].reduce((n, value) => n + value, 0);
@@ -1227,6 +1285,7 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
1227
1285
  async function disputed(env, payment, disputeId) {
1228
1286
  const o = payment ? await orderByPayment(env, payment) : null;
1229
1287
  if (!o) return 'unknown-order';
1288
+ 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';}
1230
1289
  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();
1231
1290
  return 'disputed';
1232
1291
  }
@@ -1235,6 +1294,7 @@ async function disputed(env, payment, disputeId) {
1235
1294
  async function disputeClosed(env, payment, status) {
1236
1295
  const o = payment ? await orderByPayment(env, payment) : null;
1237
1296
  if (!o) return 'unknown-order';
1297
+ 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';}
1238
1298
  const now = Date.now();
1239
1299
  if (!['paid', 'disputed'].includes(o.status)) return 'already';
1240
1300
  if (status === 'lost') {
@@ -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.52.0';
2
+ export const STUDIO_VERSION_TAG = '0.54.0';