@homie-rocks/studio 0.51.5 → 0.51.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/worker/shop.mjs CHANGED
@@ -517,7 +517,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
517
517
  const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env);
518
518
  const config=linkConfig(env);
519
519
  if(booking && amount===0 && !hasStripeKey(env) && config?.slots?.some(l=>l.app===game&&l.slots===booking.slots&&l.slot===booking.slot))return {error:fail(409,'booking','Publish a separate local slot for a free booking; it cannot share a paid Stripe capacity budget.')};
520
- const menu=keyless && !booking && lines.length>1 ? config?.menus?.find(m=>lines.every(l=>m.items.some(i=>i.item===l.item.id))) : null;
520
+ const menu=keyless && !booking && lines.length>0 ? config?.menus?.find(m=>(m.basket || lines.length>1)&&lines.every(l=>m.items.some(i=>i.item===l.item.id))) : null;
521
521
  const link=keyless ? booking ? config?.slots?.find(l=>l.app===game&&l.collection===booking.collection&&l.slots===booking.slots&&l.slot===booking.slot&&l.item===item.id) : menu ?? config?.items.find(l=>l.item===item.id) : null;
522
522
  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.')};
523
523
  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.')};
@@ -533,7 +533,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
533
533
  }
534
534
  if(keyless && item.price==='choose' && lines[0].quantity!==1)return {error:fail(400,'quantity','A tip is one chosen amount; choose it on Stripe.')};
535
535
  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.')};
536
- if(menu)lines=menu.items.map(entry=>{const i=shop.items.find(i=>i.id===entry.item);return {item:i,quantity:1,amount:i.price};});
536
+ if(menu && !menu.basket)lines=menu.items.map(entry=>{const i=shop.items.find(i=>i.id===entry.item);return {item:i,quantity:1,amount:i.price};});
537
537
  if(keyless)lines=lines.map(l=>({...l,item:{...l.item,paymentLink:link,stripePrice:menu?.items.find(i=>i.item===l.item.id)?.price??link.price}}));
538
538
  const order = { id: newOrderId(), player: player.id, amount, currency: shop.currency };
539
539
  if (booking && reference === null) reference = order.id;
@@ -575,6 +575,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
575
575
  }
576
576
  if (keyless) {
577
577
  const checkout = new URL(link.url); checkout.searchParams.set('client_reference_id', order.id);
578
+ if(menu?.basket)checkout.searchParams.set('prefilled_amount',String(amount));
578
579
  return { order, url: checkout.href, reconciled };
579
580
  }
580
581
  let session = null;
@@ -631,7 +632,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
631
632
  const reference = b.body.reference ?? null;
632
633
  if (reference !== null && (typeof reference !== 'string' || !reference.trim() || /[\u0000-\u001f\u007f]/.test(reference))) return fail(400, 'reference', 'reference must be a nonempty string without control characters');
633
634
  const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0 && !i.interval));
634
- if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
635
+ if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, next: ready.next, migration: ready.migration });
635
636
  const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
636
637
  // Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
637
638
  if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
@@ -690,7 +691,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
690
691
  async function parentLinkRoute(request, env, url, cat, shop, ready) {
691
692
  const b = await bodyOf(request, requestSize(shop));
692
693
  if (b.error) return b.error;
693
- if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
694
+ if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, next: ready.next, migration: ready.migration });
694
695
  const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1 }];
695
696
  if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
696
697
  const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
@@ -955,6 +956,7 @@ async function bindLinkSession(env, session, mode) {
955
956
  if (!source || source.mode !== mode) return false;
956
957
  const lines = await orderLines(env, source.id);
957
958
  if (!lines.length || !lines[0].snapshot) return false;
959
+ if(JSON.parse(lines[0].snapshot).paymentLink?.basket) return bindBasketSession(env,session,source,lines);
958
960
  if(JSON.parse(lines[0].snapshot).paymentLink?.items) {
959
961
  const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-menu:${source.id}`).first();
960
962
  const original=saved?JSON.parse(saved.value).map(l=>({item:l.item.id,snapshot:JSON.stringify(l.item),unit_amount:l.amount})):lines;
@@ -990,6 +992,50 @@ async function bindLinkSession(env, session, mode) {
990
992
  return true;
991
993
  }
992
994
 
995
+ /** A saved basket is immutable. A reopened link buys that same basket again. */
996
+ async function bindBasketSession(env,session,source,lines) {
997
+ const link=JSON.parse(lines[0].snapshot).paymentLink;
998
+ if((session.payment_link?.id??session.payment_link)!==link.id || session.metadata?.revision!==link.revision || session.currency!==source.currency)return false;
999
+ if(session.payment_status!=='paid' || !Number.isSafeInteger(session.amount_total) || session.amount_total<=0 || session.amount_subtotal!==session.amount_total)return false;
1000
+ await env.DB.prepare('UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL').bind(source.id,session.id).run();
1001
+ let order=await orderBySession(env,session.id);
1002
+ if(!order) {
1003
+ const id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`;
1004
+ await env.DB.batch([
1005
+ 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()),
1006
+ 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}`),
1007
+ ...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)),
1008
+ ]);
1009
+ order=await orderBySession(env,session.id);
1010
+ }
1011
+ return Boolean(order);
1012
+ }
1013
+
1014
+ // Keep the actual payment in financial totals and the ordered amounts in lines.
1015
+ // Aggregate refunds are allocated proportionally; they do not change quantities.
1016
+ async function paidBasket(env,shop,o,lines,session,paidAt) {
1017
+ const total=session.amount_total;
1018
+ if(session.payment_status!=='paid' || session.currency!==o.currency || !Number.isSafeInteger(total) || total<=0 || session.amount_subtotal!==total)return 'mismatch';
1019
+ if(o.paid_at!==null)return 'already';
1020
+ const expected=lines.reduce((n,l)=>n+Number(l.amount),0);
1021
+ if(!Number.isSafeInteger(expected)||expected<=0)return 'mismatch';
1022
+ const writes=[];let cumulative=0,allocated=0;
1023
+ for(const l of lines) {
1024
+ cumulative+=Number(l.amount);
1025
+ const next=Number(BigInt(total)*BigInt(cumulative)/BigInt(expected));
1026
+ const gross=next-allocated;allocated=next;
1027
+ if(total>=expected && o.player)writes.push(...grantStatements(env,o,JSON.parse(l.snapshot),paidAt,l));
1028
+ writes.push(env.DB.prepare("UPDATE shop_order_lines SET status='paid',total=?2 WHERE id=?1 AND EXISTS(SELECT 1 FROM shop_orders WHERE id=?3 AND paid_at IS NULL)").bind(l.id,gross,o.id));
1029
+ }
1030
+ const payment=typeof session.payment_intent==='string'?session.payment_intent:session.payment_intent?.id;
1031
+ writes.push(env.DB.prepare("UPDATE shop_orders SET status='paid',amount=?2,total=?2,tax=0,payment=?3,paid_at=?4,updated_at=?4,attention=0,note=?5 WHERE id=?1 AND paid_at IS NULL").bind(o.id,total,payment??null,paidAt,total<expected?'Settle the difference at the bar':''));
1032
+ // Enrich the trigger's durable event in this same transaction, before dispatch.
1033
+ writes.push(env.DB.prepare("UPDATE studio_events SET data=json_set(data,'$.basketTotal',?2,'$.settlement',?3) WHERE id=?1").bind(`shop_orders:${o.id}:paid`,expected,total<expected?'short':'paid'));
1034
+ const result=await env.DB.batch(writes);
1035
+ if(payment)await syncRefunds(env,payment);
1036
+ return result.at(-2)?.meta?.changes?'paid':'already';
1037
+ }
1038
+
993
1039
  /** Menu totals are never decoded into quantities. Only signed invoice lines are authoritative. */
994
1040
  async function bindMenuSession(env,session,source,lines) {
995
1041
  const first=JSON.parse(lines[0].snapshot),link=first.paymentLink;
@@ -1029,7 +1075,9 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
1029
1075
  const o = await orderBySession(env, session.id);
1030
1076
  if (!o) return 'unknown-order';
1031
1077
  // The session must be the one this order opened, for this player and this price.
1032
- const linkOrder = (await orderLines(env, o.id)).some((l) => l.snapshot && JSON.parse(l.snapshot).paymentLink);
1078
+ const savedLines=await orderLines(env,o.id);
1079
+ if(savedLines[0]?.snapshot && JSON.parse(savedLines[0].snapshot).paymentLink?.basket)return paidBasket(env,shop,o,savedLines,session,paidAt);
1080
+ const linkOrder = savedLines.some((l) => l.snapshot && JSON.parse(l.snapshot).paymentLink);
1033
1081
  if (!linkOrder && (session.metadata?.order !== o.id || ((o.checkout_player ?? o.player) && session.client_reference_id && session.client_reference_id !== (o.checkout_player ?? o.player)))) return 'mismatch';
1034
1082
  if (session.currency !== o.currency || !Number.isSafeInteger(session.amount_subtotal) || session.amount_subtotal !== Number(o.amount)) return 'mismatch';
1035
1083
  if (['refunded', 'lost', 'disputed'].includes(o.status)) {
@@ -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.51.5';
2
+ export const STUDIO_VERSION_TAG = '0.51.7';