@homie-rocks/studio 0.50.7 → 0.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/worker/shop.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import { localSession } from './local-test.mjs';
2
+ import { bindSubscription, subscriptionEvent, subscriptionPortal } from './shop-subscriptions.mjs';
3
+ import { bookingStatement } from './booking-store.mjs';
1
4
  import { paymentRefunds } from './shop-refunds.mjs';
2
5
  import { selling } from './extensions.mjs';
3
6
  /**
@@ -41,7 +44,7 @@ import {
41
44
  orderLines, lineView, bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
42
45
  setBand, shopDataOf, spentThisMonth,
43
46
  } from './shop-store.mjs';
44
- import { redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
47
+ import { apiBase, STRIPE_API, redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
45
48
  import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
46
49
  import { notFoundPage } from './site.mjs';
47
50
 
@@ -138,11 +141,11 @@ export async function readiness(env, shop) {
138
141
  else if (shop.broken) missing.push('shop.json-refused');
139
142
  else if (shop.till === 'off') missing.push('till-off');
140
143
  else if (!shop.items.length) missing.push('items');
141
- const paidItems = shop?.items.some((i) => i.price !== 0);
144
+ const paidItems = shop?.items.some((i) => i.price !== 0 || i.interval);
142
145
  const links = linkConfig(env);
143
146
  if (paidItems && !hasStripeKey(env)) {
144
147
  if (fullerReason(shop)) missing.push('fuller-checkout');
145
- else if (!links || links.settings !== linkSettings(shop) || shop.items.some((i) => i.price !== 0 && !links.items.some((l) => l.item === i.id && paymentLinkURL(l.url)))) missing.push('payment-links');
148
+ else if (!links || links.settings !== linkSettings(shop) || shop.items.some((i) => (i.price !== 0 || i.interval) && !links.items.some((l) => l.item === i.id && paymentLinkURL(l.url)))) missing.push('payment-links');
146
149
  }
147
150
  // Reliable fulfillment requires the webhook as well as the redirect.
148
151
  // https://docs.stripe.com/checkout/fulfillment
@@ -199,8 +202,8 @@ async function itemsView(env, shop, { game, kids, beginner, player, open, cursor
199
202
  const page = item ? selected : selected.slice(cursor, cursor + 100);
200
203
  const items = page.map((it) => {
201
204
  const owned = it.gives.length > 0 && it.gives.every((k) => owns.some((o) => o.key === k && o.item === it.id));
202
- const way = wayFor(it, { open: open || it.price === 0 && !shop.broken && shop.till !== 'off', kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
203
- return { id: it.id, kind: it.kind, name: it.name, blurb: it.blurb ?? null, price: it.price, ...(it.price === 'choose' ? { min: it.min, max: it.max } : {}), shown: it.price === 'choose' ? `${money(it.min, shop.currency)} or more` : money(it.price, shop.currency), gives: it.gives, ...(it.badge ? { badge: it.badge } : {}), ...(it.days ? { days: it.days } : {}), ...(it.ends ? { ends: it.ends } : {}), ...(it.game ? { game: it.game } : {}), way, ...(way === 'cap' ? { retryWay: wayFor(it, { open, kids, beginner, player, band, owned, cap: null, policy: shop.policy }) } : {}) };
205
+ const way = wayFor(it, { open: open || it.price === 0 && !it.interval && !shop.broken && shop.till !== 'off', kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
206
+ return { id: it.id, kind: it.kind, ...(it.interval ? { interval: it.interval } : {}), name: it.name, blurb: it.blurb ?? null, price: it.price, ...(it.price === 'choose' ? { min: it.min, max: it.max } : {}), shown: it.price === 'choose' ? `${money(it.min, shop.currency)} or more` : money(it.price, shop.currency), gives: it.gives, ...(it.badge ? { badge: it.badge } : {}), ...(it.days ? { days: it.days } : {}), ...(it.ends ? { ends: it.ends } : {}), ...(it.game ? { game: it.game } : {}), way, ...(way === 'cap' ? { retryWay: wayFor(it, { open, kids, beginner, player, band, owned, cap: null, policy: shop.policy }) } : {}) };
204
207
  }).filter((i) => i.way !== 'beginner' && i.way !== 'kids');
205
208
  return { band, owns, spent, items, nextCursor: !item && cursor + 100 < selected.length ? String(cursor + 100) : null };
206
209
  }
@@ -246,6 +249,21 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
246
249
  if (path === '/api/player/owns' && read) return await ownsRoute(request, env, url, cat, shop);
247
250
  if (path === '/api/shop/mine' && read) return await mineRoute(request, env, shop);
248
251
  if (path === '/api/shop/order' && read) return await orderRoute(request, env, url, shop);
252
+ if (path === '/api/shop/subscriptions' && read) {
253
+ const player = await accountOf(request, env);
254
+ if (!player) return json({ ok: true, subscriptions: [] });
255
+ const subscriptions = (await env.DB.prepare(`SELECT s.id,s.state,o.item,o.currency,o.amount FROM shop_subscriptions s
256
+ JOIN shop_orders o ON o.id=s.order_id WHERE o.player=?1`).bind(player.id).all()).results ?? [];
257
+ return json({ ok: true, subscriptions });
258
+ }
259
+ if (path === '/api/shop/portal' && request.method === 'POST') {
260
+ const player = await accountOf(request, env);
261
+ if (!player) return fail(401, 'account', 'Use the account that made this gift.');
262
+ const b = await bodyOf(request); if (b.error) return b.error;
263
+ if (typeof b.body.subscription !== 'string') return fail(400, 'subscription', 'Name your subscription.');
264
+ const portal = await subscriptionPortal(env,player.id,b.body.subscription,url.origin);
265
+ return portal ? json({ ok: true, url: portal }) : fail(404,'subscription','No subscription for this account.');
266
+ }
249
267
  if (path === '/api/shop/age' && request.method === 'POST') return await ageRoute(request, env, shop);
250
268
  if (path === '/api/shop/buy' && request.method === 'POST') return await buyRoute(request, env, url, cat, shop, ready, ctx, requestAt);
251
269
  if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
@@ -276,6 +294,8 @@ async function listRoute(request, env, url, cat, shop, ready) {
276
294
  currency: shop.currency, currencyScale: currencyScale(shop.currency), amountStep: amountStep(shop.currency), policy: shop.policy, till: shop.till, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, spent: v.spent,
277
295
  player: player ? { signedIn: !player.guest, guest: player.guest, name: player.name, band: v.band } : null,
278
296
  owns: v.owns.map((o) => o.key), items: v.items, nextCursor: v.nextCursor,
297
+ portalLogin: !hasStripeKey(env) && /^https:\/\/billing\.stripe\.com\/p\/login\//.test(linkConfig(env)?.portal??'') ? linkConfig(env).portal : null,
298
+ menus: !hasStripeKey(env) && ready.ready ? (linkConfig(env)?.menus??[]).filter(m=>m.items.every(entry=>v.items.some(i=>i.id===entry.item&&i.way==='checkout'))).map(m=>({items:m.items.map(i=>i.item)})) : [],
279
299
  });
280
300
  }
281
301
 
@@ -324,7 +344,8 @@ async function orderRoute(request, env, url, shop) {
324
344
  }
325
345
  } catch (error) { logOrderError(env, o.id, error); }
326
346
  }
327
- return json({ ok: true, status: playerOrderStatus(o), item: o.item, game: o.game ?? null });
347
+ const reference = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-reference:${o.id}`).first();
348
+ 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 });
328
349
  }
329
350
 
330
351
  /** The neutral age question: a year of birth, once. Nothing hints which answer opens anything; no default. */
@@ -356,12 +377,13 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
356
377
  const cart = lines ?? [{ item, amount, quantity: 1, product }];
357
378
  const back = game ? `&game=${encodeURIComponent(game)}` : '';
358
379
  return {
359
- mode: 'payment',
380
+ mode: cart.some(line => line.item.interval) ? 'subscription' : 'payment',
360
381
  // Stripe payment Checkout allows up to 100 lines, each with a quantity:
361
382
  // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-line_items
362
383
  line_items: cart.map((line) => ({
363
384
  quantity: line.quantity,
364
385
  price_data: { currency: shop.currency, unit_amount: line.amount, tax_behavior: 'exclusive',
386
+ ...(line.item.interval ? { recurring: { interval: line.item.interval } } : {}),
365
387
  ...(line.product ? { product: line.product } : { product_data: {
366
388
  name: line.item.name, ...(line.item.blurb ? { description: line.item.blurb } : {}),
367
389
  ...(line.item.taxCode || managed ? { tax_code: line.item.taxCode ?? defaultTaxCode(line.item) } : {}),
@@ -373,7 +395,7 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
373
395
  // Stripe metadata values allow 500 characters; the order holds the complete item id.
374
396
  // https://docs.stripe.com/metadata (fetched 2026-10-08).
375
397
  metadata: { homie: 'shop-v1', origin, studio: String(studio ?? '').length <= 500 ? String(studio ?? '') : undefined, order: order.id, item: item.id.length <= 500 ? item.id : undefined, ...(game ? { game } : {}), ...(parent ? { parent: '1' } : {}) },
376
- payment_intent_data: { metadata: { homie: 'shop-v1', origin, order: order.id, item: item.id.length <= 500 ? item.id : undefined } },
398
+ ...(cart.some(line => line.item.interval) ? { subscription_data: { metadata: { homie: 'shop-v1', origin, order: order.id } } } : { payment_intent_data: { metadata: { homie: 'shop-v1', origin, order: order.id, item: item.id.length <= 500 ? item.id : undefined } } }),
377
399
  success_url: `${origin}/shop/thanks?session_id={CHECKOUT_SESSION_ID}${back}`,
378
400
  cancel_url: `${origin}/shop/?cancelled=1&order=${order.id}${back}`,
379
401
  // Keep Stripe’s default 24-hour Checkout lifetime: https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-expires_at
@@ -415,8 +437,38 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
415
437
  }
416
438
  await Promise.all(rows.map(async (row) => {
417
439
  if (!row.session) {
418
- if (row.status === 'paid' || (row.expires_at ?? row.created_at + 1860000) + 60000 < now) await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1 AND session IS NULL').bind(row.id).run();
419
- return;
440
+ const saved = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`booking-checkout:${row.id}`).first();
441
+ if (saved) {
442
+ try {
443
+ let recovered;
444
+ if (now - row.created_at < 23 * 60 * 60 * 1000) recovered = await createCheckoutSession(env, JSON.parse(saved.value), { idempotencyKey: `checkout-${row.id}` });
445
+ else {
446
+ // After idempotency retention, discover the original session instead of
447
+ // creating another. A complete provider list can establish no creation.
448
+ let cursor;
449
+ do {
450
+ const page = await stripeCall(env, 'GET', '/v1/checkout/sessions', { limit: 100, created: { gte: Math.floor(row.created_at / 1000) - 60 }, ...(cursor ? { starting_after: cursor } : {}) });
451
+ if (!Array.isArray(page.data)) throw new Error('Stripe session list unavailable');
452
+ recovered = page.data.find(session => session.metadata?.order === row.id);
453
+ if (recovered) break;
454
+ const next = page.has_more ? page.data.at(-1)?.id : null;
455
+ if (page.has_more && (!next || next === cursor)) throw new Error('Stripe session list incomplete');
456
+ cursor = next;
457
+ } while (cursor);
458
+ if (!recovered) {
459
+ await env.DB.prepare("UPDATE shop_orders SET status='failed',updated_at=?2 WHERE id=?1 AND session IS NULL AND paid_at IS NULL").bind(row.id,now).run();
460
+ return;
461
+ }
462
+ }
463
+ if (recovered?.id) {
464
+ await env.DB.prepare('UPDATE shop_orders SET session=?2,expires_at=?3 WHERE id=?1 AND session IS NULL').bind(row.id,recovered.id,recovered.expires_at*1000).run();
465
+ row.session = recovered.id;
466
+ }
467
+ } catch (error) { logOrderError(env,row.id,error); return; }
468
+ if (!row.session) return;
469
+ }
470
+ if (!row.session && (row.status === 'paid' || (row.expires_at ?? row.created_at + 1860000) + 60000 < now)) await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1 AND session IS NULL').bind(row.id).run();
471
+ if (!row.session) return;
420
472
  }
421
473
  if (expireOpen && row.status !== 'started') return;
422
474
  if (row.status === 'paid' && !row.player) { await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(row.id).run(); return; }
@@ -459,15 +511,31 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
459
511
  }
460
512
 
461
513
  /** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
462
- async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, requestAt = Date.now() }) {
514
+ async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, booking = null, requestAt = Date.now() }) {
463
515
  lines ??= [{ item, amount, quantity: 1 }];
464
- const keyless = amount > 0 && !hasStripeKey(env);
465
- if (keyless && (lines.length !== 1 || fullerReason(shop))) return { error: fail(409, 'one-item', fullerReason(shop) ?? 'Buy one item at a time. A combined cart needs the fuller checkout connection.') };
466
- const link = keyless ? linkConfig(env)?.items.find((l) => l.item === item.id) : null;
467
- if (keyless && (!link || !paymentLinkURL(link.url))) return { error: fail(503, 'payment-links', MISSING_WORDS['payment-links']) };
468
- if (keyless && item.price === 'choose' && lines[0].quantity !== 1) return { error: fail(400, 'quantity', 'A tip is one chosen amount; choose it on Stripe.') };
469
- if (keyless) lines = lines.map((l) => ({ ...l, item: { ...l.item, paymentLink: link } }));
516
+ const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env);
517
+ const config=linkConfig(env);
518
+ 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.')};
519
+ 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 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;
521
+ 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.')};
522
+ 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.')};
523
+ if(keyless && booking) {
524
+ if(booking.quantity!==1)return {error:fail(400,'quantity','Book one seat per checkout; Stripe counts completed sessions.')};
525
+ 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();
526
+ 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.')};
527
+ 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();
528
+ const update=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
529
+ const observed=update?JSON.parse(update.value):null;
530
+ const status=observed && observed.capacity===(link.limit??link.capacity) ? observed : link;
531
+ 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.')};
532
+ }
533
+ if(keyless && item.price==='choose' && lines[0].quantity!==1)return {error:fail(400,'quantity','A tip is one chosen amount; choose it on Stripe.')};
534
+ 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.')};
535
+ 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(keyless)lines=lines.map(l=>({...l,item:{...l.item,paymentLink:link,stripePrice:menu?.items.find(i=>i.item===l.item.id)?.price??link.price}}));
470
537
  const order = { id: newOrderId(), player: player.id, amount, currency: shop.currency };
538
+ if (booking && reference === null) reference = order.id;
471
539
  const now = Date.now();
472
540
  order.createdAt = now;
473
541
  order.expiresAt = Math.floor(now / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
@@ -477,7 +545,9 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
477
545
  lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
478
546
  const reserve = async () => {
479
547
  const results = await env.DB.batch([reservation(),
548
+ ...(booking ? [bookingStatement(env, { ...booking, pending: keyless, id: order.id, app: booking.app ?? game, order: order.id })] : []),
480
549
  ...(reference === null ? [] : [env.DB.prepare("INSERT INTO meta(key,value) SELECT ?1,?2 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id=?3)").bind(`shop-reference:${order.id}`, reference, order.id)]),
550
+ ...(menu ? [env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2)').bind(`shop-menu:${order.id}`,JSON.stringify(lines))] : []),
481
551
  ...lines.map((line, i) => env.DB.prepare("INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id = ?2)")
482
552
  .bind(line.id, order.id, i, line.item.id, line.quantity, line.amount, line.amount * line.quantity, JSON.stringify(line.item))),
483
553
  env.DB.prepare('UPDATE shop_orders SET checkout_player = player WHERE id = ?1').bind(order.id),
@@ -485,14 +555,18 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
485
555
  return results[0];
486
556
  };
487
557
  await reconcileOrders(env, shop, player.id, { expireOpen: true, before: Math.min(requestAt, now - 60000) });
488
- let reserved = await reserve();
558
+ let reserved;
559
+ try { reserved = await reserve(); } catch (error) {
560
+ if (/booking capacity unavailable/.test(error.message)) return { error: fail(409, 'capacity', 'That slot is full. Choose another slot.') };
561
+ throw error;
562
+ }
489
563
  const reconciled = !reserved.meta?.changes;
490
564
  if (!reserved.meta?.changes) {
491
565
  await reconcileOrders(env, shop, player.id);
492
566
  reserved = await reserve();
493
567
  }
494
568
  if (!reserved.meta?.changes) return { error: fail(403, 'cap', WAY_WORDS.cap, { way: 'cap' }) };
495
- if (amount === 0) {
569
+ if (amount === 0 && !lines.some(l => l.item.interval)) {
496
570
  const id = `cs_free_${order.id}`;
497
571
  await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1').bind(order.id, id).run();
498
572
  await paid(env, shop, { id, metadata: { order: order.id }, client_reference_id: player.id, currency: shop.currency, amount_subtotal: 0, amount_total: 0, payment_status: 'no_payment_required' });
@@ -505,7 +579,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
505
579
  let session = null;
506
580
  const mode = shopMode(env);
507
581
  try {
508
- const useCatalog = Array.isArray(shop.catalog) && shop.catalog.includes(mode);
582
+ const useCatalog = !booking && Array.isArray(shop.catalog) && shop.catalog.includes(mode);
509
583
  for (const line of lines) line.product = useCatalog ? await productIdOf(cat.studio?.slug, line.item.id) : null;
510
584
  await env.DB.batch(lines.map((line) => env.DB.prepare('UPDATE shop_order_lines SET snapshot = ?2 WHERE id = ?1').bind(line.id, JSON.stringify({ ...line.item, stripeProduct: line.product }))));
511
585
  // Start the provider lifetime at session creation, after reservation/expiry I/O. At Stripe's
@@ -515,7 +589,9 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
515
589
  return sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, lines });
516
590
  };
517
591
  try {
518
- session = await createCheckoutSession(env, params(), { idempotencyKey: `checkout-${order.id}` });
592
+ const checkoutParams = params();
593
+ if (booking) await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2)').bind(`booking-checkout:${order.id}`, JSON.stringify(checkoutParams)).run();
594
+ session = await createCheckoutSession(env, checkoutParams, { idempotencyKey: `checkout-${order.id}` });
519
595
  } catch (error) {
520
596
  if (!useCatalog || !isMissingProduct(error)) throw error;
521
597
  for (const line of lines) line.product = null;
@@ -527,7 +603,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
527
603
  }
528
604
  await env.DB.prepare('UPDATE shop_orders SET session = ?2, updated_at = ?3, expires_at = ?4 WHERE id = ?1').bind(order.id, session.id, Date.now(), (session.expires_at ?? order.expiresAt) * 1000).run();
529
605
  } catch (error) {
530
- await env.DB.prepare("UPDATE shop_orders SET status = 'failed', note = ?2, updated_at = ?3 WHERE id = ?1").bind(order.id, redactStripe(error?.code ?? 'stripe', env).slice(0, 60), Date.now()).run();
606
+ await env.DB.prepare("UPDATE shop_orders SET status = ?4, note = ?2, updated_at = ?3 WHERE id = ?1 AND paid_at IS NULL").bind(order.id, redactStripe(error?.code ?? 'stripe', env).slice(0, 60), Date.now(), booking ? 'started' : 'failed').run();
531
607
  const why = error instanceof StripeError ? redactStripe(error.message, env) : 'Stripe did not answer';
532
608
  return { error: fail(502, 'stripe', `Stripe could not open the checkout (${why}). Nothing was charged.`) };
533
609
  }
@@ -553,7 +629,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
553
629
  if (b.error) return b.error;
554
630
  const reference = b.body.reference ?? null;
555
631
  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');
556
- const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0));
632
+ const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0 && !i.interval));
557
633
  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 });
558
634
  const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
559
635
  // Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
@@ -584,6 +660,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
584
660
  lines.push({ item, amount: unit, quantity });
585
661
  }
586
662
  const item = lines[0].item;
663
+ if (lines.some(l => l.item.interval) && (lines.filter(l => l.item.interval).length > 20 || lines.filter(l => !l.item.interval).length > 20)) return fail(400, 'stripe-lines', 'Stripe subscription Checkout allows 20 recurring and 20 one-time lines.');
587
664
  let cookies = [];
588
665
  if (!player) {
589
666
  const guest = await players.shopGuest(request, env, url, shop.guestBuyersPerAddressPerHour);
@@ -591,8 +668,18 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
591
668
  player = guest.session.player;
592
669
  cookies = guest.session.cookies;
593
670
  }
594
- const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, requestAt });
595
- const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url });
671
+ let booking = null;
672
+ if (b.body.booking !== undefined) {
673
+ const requestedBooking = b.body.booking;
674
+ const app = cat.games?.find(g => g.id === game && g.kind === 'app');
675
+ const collection = requestedBooking?.collection;
676
+ const spec = app?.records?.collections?.[collection]?.booking;
677
+ 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.');
678
+ const local = await localSession(request, env);
679
+ booking = { app: local?.app === game && local.scope ? `${game}:check:${local.scope}` : game, collection, slots: spec.slots, slot: requestedBooking.slot, quantity: lines[0].quantity };
680
+ }
681
+ const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, booking, requestAt });
682
+ const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url, ...(booking ? { booking: r.order.id } : {}) });
596
683
  if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
597
684
  for (const cookie of cookies) response.headers.append('set-cookie', cookie);
598
685
  return response;
@@ -745,12 +832,41 @@ async function hook(request, env, cat, shop, ready) {
745
832
  if (payment) await syncRefunds(env, payment);
746
833
  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(); return json({ ok: true, did: purchase }); }
747
834
  }
835
+ needed.push('payment_link.updated','invoice.paid','invoice.payment_failed','customer.subscription.updated','customer.subscription.deleted');
748
836
  if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
749
837
  if (typeof ev.livemode === 'boolean' && (hasStripeKey(env) || linkConfig(env)) && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
750
838
  try {
839
+ if(ev.type==='payment_link.updated') {
840
+ if(linkConfig(env)?.slots?.some(l=>l.id===obj.id))await env.DB.prepare(`INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value
841
+ WHERE json_extract(excluded.value,'$.count') >= json_extract(meta.value,'$.count') AND
842
+ (json_extract(excluded.value,'$.at') > json_extract(meta.value,'$.at') OR
843
+ (json_extract(excluded.value,'$.at') = json_extract(meta.value,'$.at') AND json_extract(excluded.value,'$.active')=0))`)
844
+ .bind(`shop-slot-link:${obj.id}`,JSON.stringify({active:obj.active,count:obj.restrictions?.completed_sessions?.count??0,capacity:obj.restrictions?.completed_sessions?.limit,at:ev.created})).run();
845
+ return json({ok:true,did:'slot-updated'});
846
+ }
847
+ if(ev.type==='invoice.paid' && obj.status==='paid' && (obj.metadata?.homie==='shop-links-v1' || obj.parent?.subscription_details?.metadata?.homie==='shop-links-v1' || obj.subscription_details?.metadata?.homie==='shop-links-v1')) {
848
+ const invoice={id:obj.id,status:obj.status,livemode:ev.livemode,currency:obj.currency,total:obj.total,lines:obj.lines ? {has_more:obj.lines.has_more,data:obj.lines.data.map(l=>({quantity:l.quantity,amount:l.amount,price:l.price?{id:l.price.id}:undefined,pricing:l.pricing,period:l.period,taxes:l.taxes,tax_amounts:l.tax_amounts}))} : null};
849
+ await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(`shop-invoice:${obj.id}`,JSON.stringify(invoice)).run();
850
+ const pending=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-link-session:${obj.id}`).first();
851
+ if(pending) {
852
+ const session=JSON.parse(pending.value);
853
+ if(await bindLinkSession(env,session,ev.livemode?'live':'test'))await paid(env,shop,session);
854
+ }
855
+ }
856
+ if(ev.type.startsWith('checkout.session.') && obj.payment_link && obj.invoice && ['paid','no_payment_required'].includes(obj.payment_status)) {
857
+ const session=Object.fromEntries(['id','client_reference_id','payment_link','metadata','currency','amount_subtotal','amount_total','total_details','payment_status','payment_intent','subscription','customer','invoice'].map(k=>[k,obj[k]]));
858
+ await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(`shop-link-session:${obj.invoice}`,JSON.stringify(session)).run();
859
+ }
860
+ const subscriptionResult = await subscriptionEvent(env, ev, shop);
861
+ if (subscriptionResult !== null) return json({ ok: true, did: subscriptionResult });
751
862
  const sessionEvent = ev.type.startsWith('checkout.session.');
752
863
  if (!sessionEvent) await rememberMoneyEvent(env, ev);
753
864
  if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({ ok: true, did: 'mismatch' });
865
+ if (sessionEvent && obj.metadata?.order && !obj.payment_link) {
866
+ await env.DB.prepare(`UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL AND mode=?3
867
+ AND EXISTS(SELECT 1 FROM meta WHERE key='booking-checkout:' || shop_orders.id)`)
868
+ .bind(obj.metadata.order, obj.id, ev.livemode ? 'live' : 'test').run();
869
+ }
754
870
  let known = sessionEvent ? await orderBySession(env, obj.id) : await orderByPayment(env, String(obj.payment_intent ?? ''));
755
871
  if (!known) {
756
872
  let metadata = obj.metadata;
@@ -836,7 +952,12 @@ async function bindLinkSession(env, session, mode) {
836
952
  const source = await orderById(env, session.client_reference_id);
837
953
  if (!source || source.mode !== mode) return false;
838
954
  const lines = await orderLines(env, source.id);
839
- if (lines.length !== 1 || !lines[0].snapshot) return false;
955
+ if (!lines.length || !lines[0].snapshot) return false;
956
+ if(JSON.parse(lines[0].snapshot).paymentLink?.items) {
957
+ const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-menu:${source.id}`).first();
958
+ const original=saved?JSON.parse(saved.value).map(l=>({item:l.item.id,snapshot:JSON.stringify(l.item),unit_amount:l.amount})):lines;
959
+ return bindMenuSession(env,session,source,original);
960
+ }
840
961
  const item = JSON.parse(lines[0].snapshot), link = item.paymentLink;
841
962
  if (!link || (session.payment_link?.id ?? session.payment_link) !== link.id || session.metadata?.revision !== link.revision || session.currency !== source.currency) return false;
842
963
  const amount = session.amount_subtotal;
@@ -849,12 +970,15 @@ async function bindLinkSession(env, session, mode) {
849
970
  let order = await orderBySession(env, session.id);
850
971
  if (!order) {
851
972
  const id = `ord_${(await sha256Hex(session.id)).slice(0, 20)}`;
973
+ const booking=link.slot ? await env.DB.prepare('SELECT * FROM shop_bookings WHERE order_id=?1').bind(source.id).first() : null;
852
974
  await env.DB.batch([
853
975
  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()),
854
- 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}`),
976
+ 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),
855
977
  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),
978
+ ...(booking ? [bookingStatement(env,{...booking,slot:link.slot,slots:link.slots,id,order:id,pending:true})] : []),
856
979
  ]);
857
980
  order = await orderBySession(env, session.id);
981
+
858
982
  }
859
983
  if (!order) return false;
860
984
  if (order.paid_at === null) await env.DB.batch([
@@ -864,6 +988,40 @@ async function bindLinkSession(env, session, mode) {
864
988
  return true;
865
989
  }
866
990
 
991
+ /** Menu totals are never decoded into quantities. Only signed invoice lines are authoritative. */
992
+ async function bindMenuSession(env,session,source,lines) {
993
+ const first=JSON.parse(lines[0].snapshot),link=first.paymentLink;
994
+ if((session.payment_link?.id??session.payment_link)!==link.id || session.metadata?.revision!==link.revision || session.currency!==source.currency)return false;
995
+ if(!['paid','no_payment_required'].includes(session.payment_status))return false;
996
+ const invoiceId=typeof session.invoice==='string'?session.invoice:session.invoice?.id;
997
+ const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-invoice:${invoiceId}`).first();
998
+ if(!saved)throw new Error('Waiting for signed paid invoice');
999
+ const invoice=JSON.parse(saved.value);
1000
+ if(invoice.livemode!==(source.mode==='live') || invoice.currency!==source.currency || invoice.total!==session.amount_total || invoice.lines?.has_more)throw new Error('Incomplete or mismatched signed invoice');
1001
+ const paidLines=[];
1002
+ for(const l of invoice.lines?.data??[]) {
1003
+ const line=lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price));
1004
+ if(!line || !Number.isSafeInteger(l.quantity) || l.quantity<1 || l.amount!==JSON.parse(line.snapshot).price*l.quantity)throw new Error('Unknown invoice line');
1005
+ paidLines.push({...line,quantity:l.quantity,amount:l.amount});
1006
+ }
1007
+ if(!paidLines.length || paidLines.reduce((n,l)=>n+l.amount,0)!==session.amount_subtotal)throw new Error('Invoice subtotal mismatch');
1008
+ await env.DB.prepare('UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL').bind(source.id,session.id).run();
1009
+ let order=await orderBySession(env,session.id);
1010
+ if(!order) {
1011
+ const id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`;
1012
+ await env.DB.batch([
1013
+ 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,?4,currency,till,mode,'started',parent,?5,?5,?3 FROM shop_orders WHERE id=?1").bind(source.id,id,session.id,session.amount_subtotal,Date.now()),
1014
+ 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}`),
1015
+ ]);order=await orderBySession(env,session.id);
1016
+ }
1017
+ if(order.paid_at===null)await env.DB.batch([
1018
+ env.DB.prepare('DELETE FROM shop_order_lines WHERE order_id=?1 AND EXISTS(SELECT 1 FROM shop_orders WHERE id=?1 AND paid_at IS NULL)').bind(order.id),
1019
+ ...paidLines.map((l,i)=>env.DB.prepare('INSERT INTO shop_order_lines(id,order_id,position,item,quantity,unit_amount,amount,snapshot) SELECT ?1,?2,?3,?4,?5,?6,?7,?8 WHERE EXISTS(SELECT 1 FROM shop_orders WHERE id=?2 AND paid_at IS NULL) ON CONFLICT DO NOTHING').bind(`${order.id}_${i}`,order.id,i,l.item,l.quantity,l.unit_amount,l.amount,l.snapshot)),
1020
+ env.DB.prepare('UPDATE shop_orders SET amount=?2 WHERE id=?1 AND paid_at IS NULL').bind(order.id,session.amount_subtotal),
1021
+ ]);
1022
+ return true;
1023
+ }
1024
+
867
1025
  /** A paid checkout: the order is paid, its entitlements granted, its payment recorded. Idempotent. */
868
1026
  export async function paid(env, shop, session, paidAt = Date.now()) {
869
1027
  const o = await orderBySession(env, session.id);
@@ -893,12 +1051,18 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
893
1051
  if (!Number.isSafeInteger(total) || !Number.isSafeInteger(tax) || total < 0 || tax < 0 || total !== Number(o.amount) + tax) return 'mismatch';
894
1052
  let stripeLines = null;
895
1053
  if (lines.length > 1 && tax) {
896
- if (!hasStripeKey(env)) return 'needs-keyed-recovery';
1054
+ if (!hasStripeKey(env)) {
1055
+ const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-invoice:${typeof session.invoice==='string'?session.invoice:session.invoice?.id}`).first();
1056
+ const invoice=saved&&JSON.parse(saved.value);
1057
+ if(!invoice || invoice.lines?.has_more)throw new Error('Waiting for complete signed invoice');
1058
+ stripeLines=invoice.lines.data.map(l=>({quantity:l.quantity,amount_subtotal:l.amount,amount_total:l.amount+(l.taxes??l.tax_amounts??[]).reduce((n,t)=>n+t.amount,0),price:{id:l.price?.id??l.pricing?.price_details?.price,product:JSON.parse(lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price))?.snapshot??'{}').stripeProduct}}));
1059
+ } else {
897
1060
  // Use Stripe's actual tax per line, never spread tax across products with different tax codes.
898
1061
  // https://docs.stripe.com/api/checkout/sessions/line_items
899
1062
  const response = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(session.id)}/line_items`, { limit: 100, expand: ['data.price.product'] });
900
1063
  if (response.has_more || response.data?.length !== lines.length) throw new Error('Stripe line totals are incomplete');
901
1064
  stripeLines = [...response.data];
1065
+ }
902
1066
  }
903
1067
  const writes = [];
904
1068
  let lineTotal = 0;
@@ -913,7 +1077,7 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
913
1077
  let gross = lines.length === 1 ? total : Number(line.amount);
914
1078
  if (stripeLines) {
915
1079
  const index = stripeLines.findIndex((l) => l.quantity === line.quantity && l.amount_subtotal === line.amount &&
916
- (l.price?.product?.metadata?.line === line.id || item.stripeProduct && (l.price?.product?.id ?? l.price?.product) === item.stripeProduct || l.price?.product?.metadata?.homie_item === line.item || l.price?.product?.metadata?.item === line.item));
1080
+ (l.price?.id && l.price.id === item.stripePrice || l.price?.product?.metadata?.line === line.id || item.stripeProduct && (l.price?.product?.id ?? l.price?.product) === item.stripeProduct || l.price?.product?.metadata?.homie_item === line.item || l.price?.product?.metadata?.item === line.item));
917
1081
  if (index < 0) throw new Error('Stripe line identity mismatch');
918
1082
  gross = stripeLines.splice(index, 1)[0].amount_total;
919
1083
  }
@@ -940,6 +1104,7 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
940
1104
  if (['lost', 'won', 'warning_closed'].includes(d.status)) await disputeClosed(env, paymentId, d.status);
941
1105
  }
942
1106
  }
1107
+ if (lines.some(l => l.snapshot && JSON.parse(l.snapshot).interval)) await bindSubscription(env, await orderById(env,o.id), session, shop);
943
1108
  await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run();
944
1109
  return result.at(-1)?.meta?.changes ? 'paid' : 'already';
945
1110
  }
@@ -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.50.7';
2
+ export const STUDIO_VERSION_TAG = '0.51.1';