@homie-rocks/studio 0.53.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.
@@ -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.')};
@@ -649,6 +648,7 @@ 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
653
  if(!hasStripeKey(env) && ['supporter','deposit'].includes(item.kind) && quantity!==1)return fail(400,'quantity','This payment link has quantity fixed at one.');
654
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.');
@@ -716,6 +716,7 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
716
716
  const item = shop.items.find((i) => i.id === entry?.item);
717
717
  if (!item) return fail(404, 'item', 'no such item here');
718
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.');
719
720
  if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
720
721
  if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
721
722
  seen.add(item.id);
@@ -877,7 +878,7 @@ async function hook(request, env, cat, shop, ready) {
877
878
  if (subscriptionResult !== null) return json({ ok: true, did: subscriptionResult });
878
879
  const sessionEvent = ev.type.startsWith('checkout.session.');
879
880
  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' });
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'});
881
882
  if (sessionEvent && obj.metadata?.order && !obj.payment_link) {
882
883
  await env.DB.prepare(`UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL AND mode=?3
883
884
  AND EXISTS(SELECT 1 FROM meta WHERE key='booking-checkout:' || shop_orders.id)`)
@@ -950,20 +951,64 @@ async function hook(request, env, cat, shop, ready) {
950
951
  default:
951
952
  break;
952
953
  }
954
+ if(did==='mismatch'&&obj.payment_link&&await reviewLinkPayment(env,obj,ev.livemode?'live':'test'))did='review';
953
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.');
954
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.');
955
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();
956
958
  if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
957
959
  return json({ ok: true, did });
958
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'});
959
962
  logOrderError(env, null, error);
960
963
  return fail(503, 'stripe-retry', 'Payment reconciliation is temporarily unavailable; retry this event.');
961
964
  }
962
965
  }
963
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
+
964
1008
  /** Payment Links can be reopened. Each signed Session becomes its own order; the URL reference
965
1009
  * selects a frozen local intent, never an entitlement or a buyer-supplied price. */
966
1010
  async function bindLinkSession(env, session, mode) {
1011
+ if(session.id&&(await orderBySession(env,session.id))?.status==='review')return false;
967
1012
  if (!/^ord_[A-Za-z0-9]{20}$/.test(session.client_reference_id ?? '') || !/^cs_[A-Za-z0-9_]+$/.test(session.id ?? '')) return false;
968
1013
  const source = await orderById(env, session.client_reference_id);
969
1014
  if (!source || source.mode !== mode) return false;
@@ -982,7 +1027,7 @@ async function bindLinkSession(env, session, mode) {
982
1027
  if(item.kind==='deposit' && amount!==source.amount)return false;
983
1028
  const quantity = item.price === 'choose' ? 1 : amount / item.price;
984
1029
  if(['supporter','deposit'].includes(item.kind) && quantity!==1)return false;
985
- if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > 999999) return false;
1030
+ if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > (item.maxQuantity??link.maxQuantity??999999)) return false;
986
1031
  if (item.price === 'choose' && (amount < item.min || item.max !== null && amount > item.max)) return false;
987
1032
  // Claim the first session atomically. A repeated URL creates a separate paid order, never loses a payment.
988
1033
  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 +1039,14 @@ async function bindLinkSession(env, session, mode) {
994
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()),
995
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),
996
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),
997
- ...(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})] : []),
998
1043
  ]);
999
1044
  order = await orderBySession(env, session.id);
1000
1045
 
1001
1046
  }
1002
1047
  if (!order) return false;
1003
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)]:[]),
1004
1050
  env.DB.prepare('UPDATE shop_orders SET amount = ?2 WHERE id = ?1 AND paid_at IS NULL').bind(order.id, amount),
1005
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),
1006
1052
  ]);
@@ -1033,7 +1079,7 @@ async function paidBasket(env,shop,o,lines,session,paidAt) {
1033
1079
  if(session.payment_status!=='paid' || session.currency!==o.currency || !Number.isSafeInteger(total) || total<=0 || session.amount_subtotal!==total)return 'mismatch';
1034
1080
  if(o.paid_at!==null)return 'already';
1035
1081
  const expected=lines.reduce((n,l)=>n+Number(l.amount),0);
1036
- if(!Number.isSafeInteger(expected)||expected<=0)return 'mismatch';
1082
+ if(!Number.isSafeInteger(expected)||expected<=0||total!==expected)return 'mismatch';
1037
1083
  const writes=[];let cumulative=0,allocated=0;
1038
1084
  for(const l of lines) {
1039
1085
  cumulative+=Number(l.amount);
@@ -1065,6 +1111,7 @@ async function bindMenuSession(env,session,source,lines) {
1065
1111
  for(const l of invoice.lines?.data??[]) {
1066
1112
  const line=lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price));
1067
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;
1068
1115
  paidLines.push({...line,quantity:l.quantity,amount:l.amount});
1069
1116
  }
1070
1117
  if(!paidLines.length || paidLines.reduce((n,l)=>n+l.amount,0)!==session.amount_subtotal)throw new Error('Invoice subtotal mismatch');
@@ -1089,6 +1136,7 @@ async function bindMenuSession(env,session,source,lines) {
1089
1136
  export async function paid(env, shop, session, paidAt = Date.now()) {
1090
1137
  const o = await orderBySession(env, session.id);
1091
1138
  if (!o) return 'unknown-order';
1139
+ if(o.status==='review')return 'review';
1092
1140
  // The session must be the one this order opened, for this player and this price.
1093
1141
  const savedLines=await orderLines(env,o.id);
1094
1142
  if(savedLines[0]?.snapshot && JSON.parse(savedLines[0].snapshot).paymentLink?.basket)return paidBasket(env,shop,o,savedLines,session,paidAt);
@@ -1194,6 +1242,11 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
1194
1242
  }
1195
1243
  const gross = Number(o.total ?? o.amount);
1196
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
+ }
1197
1250
  const moneyFull = amount === gross && gross > 0;
1198
1251
  const whole = moneyFull && [...refunds.values()].some((r) => r.status === 'succeeded' && !r.metadata?.line);
1199
1252
  const taggedGross = [...totals.values()].reduce((n, value) => n + value, 0);
@@ -1232,6 +1285,7 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
1232
1285
  async function disputed(env, payment, disputeId) {
1233
1286
  const o = payment ? await orderByPayment(env, payment) : null;
1234
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';}
1235
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();
1236
1290
  return 'disputed';
1237
1291
  }
@@ -1240,6 +1294,7 @@ async function disputed(env, payment, disputeId) {
1240
1294
  async function disputeClosed(env, payment, status) {
1241
1295
  const o = payment ? await orderByPayment(env, payment) : null;
1242
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';}
1243
1298
  const now = Date.now();
1244
1299
  if (!['paid', 'disputed'].includes(o.status)) return 'already';
1245
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.53.0';
2
+ export const STUDIO_VERSION_TAG = '0.54.0';