@homie-rocks/studio 0.50.4 → 0.50.6

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 (46) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -2
  3. package/bin/homie-studio.mjs +7 -7
  4. package/functions/FUNCTIONS.md +69 -13
  5. package/guides/office/SKILL.md +17 -0
  6. package/guides/servers/SKILL.md +17 -0
  7. package/guides/shop/SKILL.md +4 -3
  8. package/lib/build.mjs +3 -4
  9. package/lib/cloudflare.mjs +8 -0
  10. package/lib/functions-build.mjs +10 -3
  11. package/lib/functions-cli.mjs +9 -3
  12. package/lib/parts-build.mjs +1 -1
  13. package/lib/parts.mjs +1 -1
  14. package/lib/purchase-links.mjs +1 -1
  15. package/lib/scaffold.mjs +18 -12
  16. package/lib/shop.mjs +2 -30
  17. package/lib/template-history.json +88 -1
  18. package/lib/worker-config.mjs +20 -3
  19. package/package.json +1 -1
  20. package/shop/SELLING.md +1 -1
  21. package/shop/SHOP.md +11 -12
  22. package/worker/build-id.mjs +1 -1
  23. package/worker/canonical-json.mjs +6 -0
  24. package/worker/customer-mcp.mjs +1 -1
  25. package/worker/customer-resources.mjs +1 -1
  26. package/worker/function-schedule.mjs +68 -0
  27. package/worker/functions-schema.mjs +13 -0
  28. package/worker/functions.mjs +45 -9
  29. package/worker/index.mjs +1 -14
  30. package/worker/mcp-tools.mjs +7 -3
  31. package/worker/office-page.mjs +1 -1
  32. package/worker/office.mjs +7 -14
  33. package/worker/parts-sale.mjs +1 -1
  34. package/worker/purchase-core.mjs +1 -1
  35. package/worker/purchase-crypto.mjs +1 -1
  36. package/worker/purchase-keys.mjs +1 -1
  37. package/worker/purchase-links.mjs +1 -1
  38. package/worker/purchase-owner.mjs +1 -1
  39. package/worker/purchase-routes.mjs +1 -1
  40. package/worker/purchase-store.mjs +1 -1
  41. package/worker/shop-page.mjs +5 -69
  42. package/worker/shop-rules.mjs +5 -33
  43. package/worker/shop-store.mjs +76 -56
  44. package/worker/shop.mjs +22 -78
  45. package/worker/version.mjs +1 -1
  46. package/worker/referrals.mjs +0 -460
package/worker/shop.mjs CHANGED
@@ -16,10 +16,9 @@ import { selling } from './extensions.mjs';
16
16
  * follows the studio advantage settings)
17
17
  * GET /api/shop/mine the player's orders and what they own (the account page)
18
18
  * GET /shop/ /shop/thanks /shop/refunds/ /shop/parent/<token> the pages (worker/shop-page.mjs)
19
- * POST /api/referrals/statement another studio's signed referral statement to this one (worker/referrals.mjs)
20
19
  *
21
20
  * THE OFFICE (worker/office.mjs routes them): sales, refunds, disputes and payouts as links to the studio's own Stripe
22
- * Dashboard, a CSV for the accountant, the referral books and signed statements. A refund is the owner's one tap; the
21
+ * Dashboard, a CSV for the accountant, order records. A refund is the owner's one tap; the
23
22
  * owner's AI (an office key) can only ASK for one, which the owner confirms with one tap. A dispute never touches the
24
23
  * player's account: it changes nothing until it is decided, and a lost one is a refund of that one item.
25
24
  *
@@ -37,17 +36,16 @@ import { selling } from './extensions.mjs';
37
36
  import { hasStripeKey, linkConfig, linkSettings, shopMode, fullerReason, paymentLinkURL, rememberMoneyEvent, rememberedMoney } from './shop-links.mjs';
38
37
  import { players } from './players.mjs';
39
38
  import { serversOf } from './servers.mjs';
40
- import { ENTITLEMENT_KEY, referralShare, checkoutMessage, amountError, amountStep, parseAmount, currencyScale, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
39
+ import { ENTITLEMENT_KEY, checkoutMessage, amountError, amountStep, parseAmount, currencyScale, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
41
40
  import {
42
41
  orderLines, lineView, bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
43
42
  setBand, shopDataOf, spentThisMonth,
44
43
  } from './shop-store.mjs';
45
44
  import { redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
46
- import { pendingStatementPeriod, statementFailures, arrivalOf, booksOf, linesOf, statementPage, lineFor, receiveStatement, receivedPages, referrerOk, sendStatements, settle, statementsIn, voidLineStatements } from './referrals.mjs';
47
45
  import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
48
46
  import { notFoundPage } from './site.mjs';
49
47
 
50
- export { linesOf, forgetPlayerShop, officeShopPage, receivedPages, shopDataOf };
48
+ export { forgetPlayerShop, officeShopPage, shopDataOf };
51
49
 
52
50
  const DAY = 86_400_000;
53
51
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
@@ -166,7 +164,7 @@ export const MISSING_WORDS = Object.freeze({
166
164
  'stripe-key': 'The Worker has no Stripe key yet (homie-studio shop connect: approve Stripe in your browser; the command names any remaining Worker credential step).',
167
165
  'webhook-secret': 'The Worker has no webhook signing secret yet (run homie-studio shop connect to create and store it automatically).',
168
166
  database: 'This Worker has no database.',
169
- migration: 'The shop\'s tables are not in the studio\'s D1 yet: npm run deploy applies migrations 0008_studio_shop.sql, 0010_shop_reservations.sql and 0011_shop_statements.sql and 0012_shop_lines.sql.',
167
+ migration: 'The shop\'s tables are not in the studio\'s D1 yet: npm run deploy applies migrations 0008_studio_shop.sql, 0010_shop_reservations.sql and 0012_shop_lines.sql.',
170
168
  });
171
169
 
172
170
  /** The server a request names (the play shell says which), with its kids and beginner flags. */
@@ -183,7 +181,7 @@ async function serverFacts(env, cat, game, server) {
183
181
  /** The items a game's shop shows (its own and the studio's), in shop.json's order. */
184
182
  const itemsFor = (shop, game) => (shop?.items ?? []).filter((i) => !i.game || !game || i.game === game);
185
183
 
186
- /** The account a request is signed in as, with when it was made (a referral counts only for a new account). */
184
+ /** The account a request is signed in as, with when it was made. */
187
185
  async function accountOf(request, env) {
188
186
  const p = await players.of(request, env);
189
187
  if (!p) return null;
@@ -214,7 +212,6 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
214
212
  const requestAt = Date.now();
215
213
  const path = url.pathname;
216
214
  const read = request.method === 'GET' || request.method === 'HEAD';
217
- if (path === '/api/referrals/statement') return receiveStatement(request, env, url);
218
215
  if (path === '/_homie/shop.js') return new Response(SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
219
216
  const shopPath = path === '/shop' || path === '/shop/' || path.startsWith('/shop/') || path === '/api/shop' || path.startsWith('/api/shop/') || path === '/api/player/owns';
220
217
  if (selling.enabled && (path.startsWith('/api/purchases/') || path.startsWith('/purchases/'))) return selling.purchaseRoutes(request, env, url, await catalogueOf());
@@ -374,7 +371,7 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
374
371
  client_reference_id: player,
375
372
  // Stripe metadata values allow 500 characters; the order holds the complete item id.
376
373
  // https://docs.stripe.com/metadata (fetched 2026-10-08).
377
- 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 } : {}), ...(order.via ? { via: order.via } : {}), ...(parent ? { parent: '1' } : {}) },
374
+ 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' } : {}) },
378
375
  payment_intent_data: { metadata: { homie: 'shop-v1', origin, order: order.id, item: item.id.length <= 500 ? item.id : undefined } },
379
376
  success_url: `${origin}/shop/thanks?session_id={CHECKOUT_SESSION_ID}${back}`,
380
377
  cancel_url: `${origin}/shop/?cancelled=1&order=${order.id}${back}`,
@@ -461,7 +458,7 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
461
458
  }
462
459
 
463
460
  /** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
464
- async function startCheckout(env, url, cat, shop, item, { player, game, via, amount, parent = false, lines = null, reference = null, requestAt = Date.now() }) {
461
+ async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, requestAt = Date.now() }) {
465
462
  lines ??= [{ item, amount, quantity: 1 }];
466
463
  const keyless = amount > 0 && !hasStripeKey(env);
467
464
  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.') };
@@ -469,21 +466,20 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
469
466
  if (keyless && (!link || !paymentLinkURL(link.url))) return { error: fail(503, 'payment-links', MISSING_WORDS['payment-links']) };
470
467
  if (keyless && item.price === 'choose' && lines[0].quantity !== 1) return { error: fail(400, 'quantity', 'A tip is one chosen amount; choose it on Stripe.') };
471
468
  if (keyless) lines = lines.map((l) => ({ ...l, item: { ...l.item, paymentLink: link } }));
472
- try { if (via) referralShare(amount, shop.referrals.rate); } catch { return { error: fail(400, 'arithmetic', 'The referral share exceeds safe integer arithmetic.') }; }
473
- const order = { id: newOrderId(), player: player.id, via: via ?? null, amount, currency: shop.currency };
469
+ const order = { id: newOrderId(), player: player.id, amount, currency: shop.currency };
474
470
  const now = Date.now();
475
471
  order.createdAt = now;
476
472
  order.expiresAt = Math.floor(now / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
477
473
  // One SQL statement reserves the amount: concurrent requests cannot both spend the remaining cap.
478
- const reservation = () => env.DB.prepare("INSERT INTO shop_orders (id, player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at, expires_at) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'started', ?9, ?10, ?11, ?11, ?14 WHERE ?5 = 0 OR ?12 IS NULL OR ?5 <= ?12 - (SELECT COALESCE(SUM(amount - refunded_net), 0) FROM shop_orders WHERE player = ?2 AND mode = ?8 AND ((status IN ('paid', 'disputed') AND paid_at >= ?13) OR (status IN ('started', 'processing') AND (session IS NOT NULL OR COALESCE(expires_at, created_at + 1860000) + 60000 > ?11))))")
479
- .bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, shopMode(env), parent ? 1 : 0, order.via, now, shop.capPerPlayerMonth, Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1), order.expiresAt * 1000);
474
+ const reservation = () => env.DB.prepare("INSERT INTO shop_orders (id, player, item, game, amount, currency, till, mode, status, parent, created_at, updated_at, expires_at) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'started', ?9, ?10, ?10, ?13 WHERE ?5 = 0 OR ?11 IS NULL OR ?5 <= ?11 - (SELECT COALESCE(SUM(amount - refunded_net), 0) FROM shop_orders WHERE player = ?2 AND mode = ?8 AND ((status IN ('paid', 'disputed') AND paid_at >= ?12) OR (status IN ('started', 'processing') AND (session IS NOT NULL OR COALESCE(expires_at, created_at + 1860000) + 60000 > ?10))))")
475
+ .bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, shopMode(env), parent ? 1 : 0, now, shop.capPerPlayerMonth, Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1), order.expiresAt * 1000);
480
476
  lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
481
477
  const reserve = async () => {
482
478
  const results = await env.DB.batch([reservation(),
483
479
  ...(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)]),
484
480
  ...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)")
485
481
  .bind(line.id, order.id, i, line.item.id, line.quantity, line.amount, line.amount * line.quantity, JSON.stringify(line.item))),
486
- env.DB.prepare('UPDATE shop_orders SET referral_terms = ?2, checkout_player = player WHERE id = ?1').bind(order.id, shop.referrals ? JSON.stringify(shop.referrals) : null),
482
+ env.DB.prepare('UPDATE shop_orders SET checkout_player = player WHERE id = ?1').bind(order.id),
487
483
  ]);
488
484
  return results[0];
489
485
  };
@@ -537,17 +533,6 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
537
533
  return { order, url: session.url, session: session.id, reconciled };
538
534
  }
539
535
 
540
- /** The referrer this buyer brought, if it still counts: their arrival, a new account since it, in the window, proven. */
541
- async function viaFor(request, env, url, shop, player) {
542
- if (!shop.referrals || !(shop.referrals.rate > 0)) return null;
543
- const a = await arrivalOf(request, env);
544
- if (!a || a.via === url.hostname.replace(/^www\./, '')) return null;
545
- if (shop.referrals.windowDays !== null && Date.now() - a.at > Math.round(shop.referrals.windowDays * DAY)) return null;
546
- // New players only: an account made before the arrival was already this studio's player.
547
- if (shop.referralNewPlayersOnly && player.since !== null && player.since < a.at - 60_000) return null;
548
- return (await referrerOk(a.via)) ? a.via : null;
549
- }
550
-
551
536
  const WAY_WORDS = Object.freeze({
552
537
  closed: 'The shop is not open yet.', kids: 'The studio policy.kidsServer setting closes this shop on kids servers.', beginner: 'The studio server advantage settings hide this item here.',
553
538
  owned: 'You have it already.', 'make-an-account': 'Make an account first (a passkey), so what you buy stays yours on every device.',
@@ -605,8 +590,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
605
590
  player = guest.session.player;
606
591
  cookies = guest.session.cookies;
607
592
  }
608
- const via = await viaFor(request, env, url, shop, player);
609
- const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount, lines, reference, requestAt });
593
+ const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, requestAt });
610
594
  const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url });
611
595
  if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
612
596
  for (const cookie of cookies) response.headers.append('set-cookie', cookie);
@@ -638,7 +622,6 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
638
622
  cart.push({ item: item.id, quantity });
639
623
  }
640
624
  const item = shop.items.find((i) => i.id === cart[0].item);
641
- cart[0].via = await viaFor(request, env, url, shop, player);
642
625
  const token = randomToken();
643
626
  await env.DB.prepare('INSERT INTO shop_parent_links (hash, player, item, game, order_id, expires_at, cart) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?6)').bind(await sha256Hex(token), player.id, item.id, game, Date.now() + 7 * DAY, JSON.stringify(cart)).run();
644
627
  return json({ ok: true, link: `${url.origin}/shop/parent/${token}`, expiresInDays: 7 });
@@ -678,7 +661,7 @@ async function parentRoute(request, env, url, cat, shop, ready, token, ctx) {
678
661
  if (total > BigInt(Number.MAX_SAFE_INTEGER)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
679
662
  amount = Number(total);
680
663
  }
681
- const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game, via: cart[0]?.via ?? null, amount, parent: true, lines });
664
+ const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game, amount, parent: true, lines });
682
665
  if (r.error) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
683
666
  await env.DB.prepare('UPDATE shop_parent_links SET order_id = ?2 WHERE hash = ?1').bind(await sha256Hex(token), r.order.id).run();
684
667
  if (!r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, row.player).catch((error) => logOrderError(env, r.order.id, error)));
@@ -866,7 +849,7 @@ async function bindLinkSession(env, session, mode) {
866
849
  if (!order) {
867
850
  const id = `ord_${(await sha256Hex(session.id)).slice(0, 20)}`;
868
851
  await env.DB.batch([
869
- env.DB.prepare("INSERT INTO shop_orders (id, player, checkout_player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at, session, referral_terms) SELECT ?2, player, checkout_player, item, game, ?4, currency, till, mode, 'started', parent, via, ?5, ?5, ?3, referral_terms FROM shop_orders WHERE id = ?1 ON CONFLICT DO NOTHING").bind(source.id, id, session.id, amount, Date.now()),
852
+ 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()),
870
853
  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}`),
871
854
  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),
872
855
  ]);
@@ -880,7 +863,7 @@ async function bindLinkSession(env, session, mode) {
880
863
  return true;
881
864
  }
882
865
 
883
- /** A paid checkout: the order is paid, its entitlements granted, a referral line written. Idempotent. */
866
+ /** A paid checkout: the order is paid, its entitlements granted, its payment recorded. Idempotent. */
884
867
  export async function paid(env, shop, session, paidAt = Date.now()) {
885
868
  const o = await orderBySession(env, session.id);
886
869
  if (!o) return 'unknown-order';
@@ -939,11 +922,9 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
939
922
  writes.push(env.DB.prepare("UPDATE shop_order_lines SET status = 'paid', total = ?2, snapshot = COALESCE(snapshot, ?3) WHERE id = ?1 AND status != 'refunded'").bind(line.id, gross, JSON.stringify(item)));
940
923
  }
941
924
  if (lineTotal !== total) throw new Error('Stripe line totals do not match order');
942
- if (o.via && o.paid_at === null) writes.push(...(await lineFor(env, o, o.referral_terms ? { referrals: JSON.parse(o.referral_terms) } : shop, { now })));
943
- writes.push(env.DB.prepare("UPDATE referral_lines SET original_share = share WHERE order_id = ?1 AND original_share IS NULL").bind(o.id));
944
925
  writes.push(env.DB.prepare("UPDATE shop_orders SET status = 'paid', payment = ?2, tax = ?3, total = ?4, paid_at = ?5, updated_at = ?6 WHERE id = ?1 AND paid_at IS NULL")
945
926
  .bind(o.id, typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id ?? null, tax, total, paidAt, now));
946
- // D1 batch is transactional: a failure rolls back payment, every grant and the referral together.
927
+ // D1 batch is transactional: a failure rolls back payment and every grant together.
947
928
  // https://developers.cloudflare.com/d1/worker-api/d1-database/#batch
948
929
  const result = await env.DB.batch(writes);
949
930
  const paymentId = payment?.id ?? (typeof session.payment_intent === 'string' ? session.payment_intent : null);
@@ -1007,15 +988,6 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
1007
988
  }
1008
989
  writes.push(env.DB.prepare(`UPDATE entitlements SET state = CASE WHEN ?3 OR ?4 OR EXISTS (SELECT 1 FROM shop_entitlement_lines WHERE order_id = ?1 AND key = entitlements.key) AND NOT EXISTS (SELECT 1 FROM shop_entitlement_lines WHERE order_id = ?1 AND key = entitlements.key AND state = 'active') THEN 'revoked' ELSE 'active' END WHERE order_id = ?1 AND ${guard}`).bind(o.id, revision, full ? 1 : 0, o.status === 'lost' ? 1 : 0));
1009
990
  writes.push(env.DB.prepare(`UPDATE entitlements SET starts_at = (SELECT MIN(starts_at) FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active'), ends_at = CASE WHEN EXISTS (SELECT 1 FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active' AND l.ends_at IS NULL) THEN NULL ELSE (SELECT MAX(ends_at) FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active') END WHERE order_id = ?1 AND ${guard} AND EXISTS (SELECT 1 FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active')`).bind(o.id, revision));
1010
- const referral = await env.DB.prepare('SELECT * FROM referral_lines WHERE order_id = ?1').bind(o.id).first();
1011
- if (referral && o.status !== 'lost') {
1012
- const share = Number(referral.original_share ?? referral.share);
1013
- const remaining = o.amount ? Number((2n * BigInt(share) * BigInt(Number(o.amount) - net) + BigInt(o.amount)) / (2n * BigInt(o.amount))) : 0;
1014
- changed ||= Number(referral.share) !== remaining || Number(referral.net) !== Number(o.amount) - net || (referral.state === 'void') !== (remaining === 0) && referral.state !== 'settled';
1015
- const delta = Number(referral.share) - remaining;
1016
- writes.push(env.DB.prepare(`INSERT INTO referral_lines (order_id, via, net, rate, share, original_share, currency, state, period, hold_until, created_at) SELECT ?3, via, ?4, rate, ?5, ?5, currency, 'clawback', period, hold_until, ?6 FROM referral_lines WHERE order_id = ?1 AND state = 'settled' AND ${guard} AND ?5 != 0 ON CONFLICT DO NOTHING`).bind(o.id, revision, `${o.id}_refund_${revision}`, net - Number(o.refunded_net), delta, Date.now()));
1017
- writes.push(env.DB.prepare(`UPDATE referral_lines SET share = ?3, net = ?4, state = CASE WHEN state = 'settled' THEN state WHEN ?3 = 0 THEN 'void' WHEN state = 'void' THEN 'pending' ELSE state END WHERE order_id = ?1 AND ${guard}`).bind(o.id, revision, remaining, Number(o.amount) - net));
1018
- }
1019
991
  full &&= o.status !== 'lost';
1020
992
  changed ||= (o.status === 'refunded') !== full;
1021
993
  if (!changed) return 'already';
@@ -1044,7 +1016,6 @@ async function disputeClosed(env, payment, status) {
1044
1016
  env.DB.prepare("UPDATE shop_orders SET status = 'lost', updated_at = ?2 WHERE id = ?1 AND status IN ('paid', 'disputed')").bind(o.id, now),
1045
1017
  env.DB.prepare("UPDATE shop_order_lines SET status = 'lost' WHERE order_id = ?1 AND status != 'refunded' AND EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND status = 'lost')").bind(o.id),
1046
1018
  revokeStatement(env, o.id),
1047
- ...voidLineStatements(env, o.id),
1048
1019
  ]);
1049
1020
  return 'lost';
1050
1021
  }
@@ -1101,7 +1072,7 @@ export async function refundOrder(env, o, { reason = 'requested_by_customer', by
1101
1072
  }
1102
1073
  if (legacy) {
1103
1074
  await env.DB.batch([
1104
- revokeStatement(env, o.id), ...voidLineStatements(env, o.id),
1075
+ revokeStatement(env, o.id),
1105
1076
  env.DB.prepare("UPDATE shop_orders SET status = 'refunded', refund = ?2, refunded_at = ?3, updated_at = ?3 WHERE id = ?1").bind(o.id, refund?.id ?? null, Date.now()),
1106
1077
  ]);
1107
1078
  } else if (!amount) await syncRefunds(env, o.payment, o.payment ? null : o, selected.map((line) => line.id));
@@ -1146,12 +1117,6 @@ export async function performRelease(env, a, cat) {
1146
1117
  return result.meta?.changes ? { ok: true, order: a.order, by: a.by, at } : { ok: false, error: 'state', message: 'Only an unresolved order can be released.' };
1147
1118
  }
1148
1119
 
1149
- export function checkSettle(body) {
1150
- if (!/^[a-z0-9.-]{3,253}$/.test(String(body.via ?? ''))) return { ok: false, error: 'bad-request', message: 'via is the referrer\'s host (as the shop page shows it)' };
1151
- if (!/^[a-z]{3}$/.test(body.currency ?? '')) return { ok: false, error: 'currency', message: 'Choose the currency to mark paid.' };
1152
- return { ok: true, action: { op: 'shop-settle', currency: body.currency, via: String(body.via), ref: String(body.ref ?? '') } };
1153
- }
1154
-
1155
1120
  /** The refund in plain words, for the owner's confirm page and the AI's card. */
1156
1121
  export function describeRefund(cat, a, order = null) {
1157
1122
  const shop = shopOf(cat);
@@ -1178,13 +1143,13 @@ export async function performRefund(env, cat, a) {
1178
1143
  }
1179
1144
 
1180
1145
  /** Everything the office's shop page shows. Money is read from this studio's D1; Stripe's own pages are linked. */
1181
- export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0, orderCursor = 0) {
1146
+ export async function shopOffice(env, cat, origin, itemCursor = 0, orderCursor = 0) {
1182
1147
  const shop = shopOf(cat);
1183
1148
  const ready = await readiness(env, shop);
1184
1149
  const mode = ready.mode ?? 'test';
1185
1150
  const out = {
1186
1151
  ok: true, ready: ready.ready, missing: ready.missing.map((m) => ({ id: m, words: m === 'migration' ? `The shop needs ${ready.migration}; npm run deploy applies it.` : MISSING_WORDS[m] ?? m })), mode: ready.mode,
1187
- shop: shop ? { policy: shop.policy, purchaseAttemptsPerMinute: shop.purchaseAttemptsPerMinute, till: shop.till, currency: shop.currency, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, nextItemsCursor: itemCursor + 100 < shop.items.length ? String(itemCursor + 100) : null, items: shop.items.slice(itemCursor, itemCursor + 100).map((i) => ({ id: i.id, kind: i.kind, name: i.name, price: i.price, shown: i.price === 'choose' ? `${money(i.min, shop.currency)}+` : money(i.price, shop.currency), gives: i.gives, game: i.game ?? null })), referrals: shop.referrals, ...(shop.broken ? { broken: shop.broken } : {}) } : null,
1152
+ shop: shop ? { policy: shop.policy, purchaseAttemptsPerMinute: shop.purchaseAttemptsPerMinute, till: shop.till, currency: shop.currency, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, nextItemsCursor: itemCursor + 100 < shop.items.length ? String(itemCursor + 100) : null, items: shop.items.slice(itemCursor, itemCursor + 100).map((i) => ({ id: i.id, kind: i.kind, name: i.name, price: i.price, shown: i.price === 'choose' ? `${money(i.min, shop.currency)}+` : money(i.price, shop.currency), gives: i.gives, game: i.game ?? null })), ...(shop.broken ? { broken: shop.broken } : {}) } : null,
1188
1153
  stripe: {
1189
1154
  payments: dashboardLink(mode, 'payments'), refunds: dashboardLink(mode, 'refunds'), disputes: dashboardLink(mode, 'disputes'), payouts: dashboardLink(mode, 'payouts'),
1190
1155
  balance: dashboardLink(mode, 'balance'), tax: dashboardLink(mode, 'tax'), keys: dashboardLink(mode, 'keys'), webhooks: dashboardLink(mode, 'webhooks'),
@@ -1192,7 +1157,7 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
1192
1157
  },
1193
1158
  keyless: !hasStripeKey(env),
1194
1159
  hook: `${origin}/api/shop/hook`,
1195
- totals: null, orders: [], referrals: { owe: [], owedToUs: [] },
1160
+ totals: null, orders: [],
1196
1161
  };
1197
1162
  if (!(await migrated(env, { complete: false }))) return out;
1198
1163
  const since = Date.now() - 30 * DAY;
@@ -1212,8 +1177,6 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
1212
1177
  refundable: (Number(r.amount) === 0 || hasStripeKey(env)) && ['paid', 'started', 'processing', 'missing', 'released', 'expired', 'failed'].includes(r.status) && (Number(r.amount) === 0 || Boolean(r.payment) || Boolean(r.session)),
1213
1178
  })));
1214
1179
  if (selling.enabled) await selling.extendOffice(env, cat, origin, out);
1215
- const { books, nextCursor } = await booksOf(env, { cursor });
1216
- out.referrals = { nextCursor, owe: books, failures: await statementFailures(env), owedToUs: await statementsIn(env), invoice: dashboardLink(mode, 'invoices') };
1217
1180
  return out;
1218
1181
  }
1219
1182
 
@@ -1221,31 +1184,12 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
1221
1184
  export async function ordersCsv(env) {
1222
1185
  const rows = (await env.DB.prepare("SELECT * FROM shop_orders WHERE status != 'started' ORDER BY created_at").all()).results ?? [];
1223
1186
  const cell = (v) => { const s = v === null || v === undefined ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
1224
- const head = ['order', 'created', 'paid', 'refunded', 'status', 'item', 'game', 'amount', 'tax', 'total', 'currency', 'till', 'mode', 'parent', 'via', 'stripe_payment', 'stripe_refund', 'stripe_dispute', 'lines_json', 'refunded_total', 'refunded_net'];
1187
+ const head = ['order', 'created', 'paid', 'refunded', 'status', 'item', 'game', 'amount', 'tax', 'total', 'currency', 'till', 'mode', 'parent', 'stripe_payment', 'stripe_refund', 'stripe_dispute', 'lines_json', 'refunded_total', 'refunded_net'];
1225
1188
  const iso = (t) => (t ? new Date(Number(t)).toISOString() : '');
1226
- const lines = await Promise.all(rows.map(async (r) => [r.id, iso(r.created_at), iso(r.paid_at), iso(r.refunded_at), r.status, r.item, r.game, (Number(r.amount) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.tax === null ? '' : (Number(r.tax) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.total === null ? '' : (Number(r.total) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.currency, r.till, r.mode, Number(r.parent) ? 'yes' : '', r.via, r.payment, r.refund, r.dispute, JSON.stringify((await orderLines(env, r.id)).map(lineView)), (Number(r.refunded_amount ?? 0) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), (Number(r.refunded_net ?? 0) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency)))].map(cell).join(',')));
1189
+ const lines = await Promise.all(rows.map(async (r) => [r.id, iso(r.created_at), iso(r.paid_at), iso(r.refunded_at), r.status, r.item, r.game, (Number(r.amount) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.tax === null ? '' : (Number(r.tax) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.total === null ? '' : (Number(r.total) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.currency, r.till, r.mode, Number(r.parent) ? 'yes' : '', r.payment, r.refund, r.dispute, JSON.stringify((await orderLines(env, r.id)).map(lineView)), (Number(r.refunded_amount ?? 0) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), (Number(r.refunded_net ?? 0) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency)))].map(cell).join(',')));
1227
1190
  return `${head.join(',')}\n${lines.join('\n')}${lines.length ? '\n' : ''}`;
1228
1191
  }
1229
1192
 
1230
- /** The signed statements for a period (YYYY-MM, default last month): one per referrer this studio owes. */
1231
- export async function statementsOut(env, cat, origin, period, cursor = '') {
1232
- const shop = shopOf(cat);
1233
- const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : lastMonth();
1234
- return { ok: true, period: p, ...(await statementPage(env, origin, shop?.referrals, p, cursor, { snapshot: false })) };
1235
- }
1236
- export async function statementsSend(env, cat, origin, period, cursor = '') {
1237
- const shop = shopOf(cat);
1238
- const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : (await pendingStatementPeriod(env, origin)) ?? lastMonth();
1239
- const { sent, nextCursor, retryAfter, failures } = await sendStatements(env, origin, shop, p, { cursor });
1240
- return { ok: true, period: p, nextCursor, sent, retryAfter, failures };
1241
- }
1242
- export const settleReferrer = (env, a) => settle(env, a.via, a.ref, { currency: a.currency });
1243
-
1244
- function lastMonth(now = new Date()) {
1245
- const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
1246
- return d.toISOString().slice(0, 7);
1247
- }
1248
-
1249
1193
  /* ------------------------------------------------------------------ for the rest of the Worker */
1250
1194
 
1251
1195
  /** The play shell's boot facts: is there a shop to show under this studio's policy. */
@@ -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.4';
2
+ export const STUDIO_VERSION_TAG = '0.50.6';