@homie-rocks/studio 0.50.4 → 0.50.5

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/office.mjs CHANGED
@@ -58,8 +58,8 @@ import { toolIdentity } from './tool-identity.mjs';
58
58
  * taking a message down, happens at once.
59
59
  *
60
60
  * THE SHOP (0.24.0, worker/shop.mjs): /_studio/office/shop and /_studio/api/shop (sales, refunds, disputes, payouts as
61
- * links to the studio's own Stripe, a CSV, the referral books and signed statements). A refund is the owner's one tap;
62
- * from an office key it is ALWAYS an ask (the AI may only propose one), and so is marking a referrer paid.
61
+ * links to the studio's own Stripe, a CSV, order records). A refund is the owner's one tap;
62
+ * from an office key it is ALWAYS an ask (the AI may only propose one).
63
63
  */
64
64
  import { seatsOf } from './seats.mjs';
65
65
  import { diagnosticsOf, gatePlayers, roomLayout } from './gate.mjs';
@@ -71,7 +71,7 @@ import { esc, layout, notFoundPage } from './site.mjs';
71
71
  import { confirmPage, lockedPage, officePage } from './office-page.mjs';
72
72
  import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
73
73
  import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
74
- import { linesOf, checkRelease, performRelease, checkRefund, checkSettle, describeRefund, officeShopPage, ordersCsv, performRefund, receivedPages, settleReferrer, shopOffice, statementsOut, statementsSend } from './shop.mjs';
74
+ import { checkRelease, performRelease, checkRefund, describeRefund, officeShopPage, ordersCsv, performRefund, shopOffice } from './shop.mjs';
75
75
  import { orderById } from './shop-store.mjs';
76
76
  import { checkLoungeAction, describeLounge, loungeNeedsAsk, loungeOffice, performLounge } from './lounge.mjs';
77
77
 
@@ -766,7 +766,6 @@ function checkAction(cat, op, body) {
766
766
  }
767
767
  case 'shop-release': return checkRelease(body);
768
768
  case 'refund': return checkRefund(body);
769
- case 'shop-settle': return checkSettle(body);
770
769
  case 'agents-brain': {
771
770
  if (!meta) return bad('game is one of this studio\'s game ids');
772
771
  if (typeof body.server !== 'string' || !(body.server === 'public' || /^[a-z0-9][a-z0-9-]{1,19}$/.test(body.server))) return bad('server is the server\'s id');
@@ -868,7 +867,6 @@ export function describe(cat, a) {
868
867
  case 'room-level': return `Set the AI in ${where} to ${levelName(a.level)} (level ${a.level}).`;
869
868
  case 'shop-release': return `Release reservation ${a.order}: it stops counting toward the cap. We ask Stripe to expire an open Checkout Session. This does not refund a payment. A payment that still arrives grants the item, counts toward spending and can be refunded from the office.`;
870
869
  case 'refund': return describeRefund(cat, a, a.item ? { item: a.item, amount: a.amount, currency: a.currency } : null);
871
- case 'shop-settle': return `Mark what this studio owes ${a.via} for referrals in ${a.currency.toUpperCase()} as paid${a.ref ? ` (${a.ref})` : ''}: its owed lines are settled in the books.`;
872
870
  case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
873
871
  ? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ? `this studio's own Workers AI (free allowance${a.budget !== undefined ? `; at most ${Math.round(a.budget).toLocaleString('en-US')} neurons a day` : ''})` : `your own AI provider key (claude-haiku-4-5, your money${a.budget !== undefined ? `, at most $${Number(a.budget).toFixed(2)} a day` : ', capped daily'})`}.`
874
872
  : `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
@@ -901,7 +899,7 @@ export async function needsAsk(env, cat, a) {
901
899
  if (String(a.op).startsWith('lounge-')) return loungeNeedsAsk(env, cat, a, chatOpensUp);
902
900
  if (DESTRUCTIVE.has(a.op)) return true;
903
901
  // Money: the owner's AI may only propose a refund or a settlement; the owner says yes.
904
- if (a.op === 'refund' || a.op === 'shop-settle' || a.op === 'shop-release') return true;
902
+ if (a.op === 'refund' || a.op === 'shop-release') return true;
905
903
  if (a.op === 'server-close') return !a.reopen;
906
904
  if (a.op === 'member') return a.remove === true;
907
905
  if (a.op === 'server-set') {
@@ -1134,7 +1132,6 @@ export async function perform(env, cat, a) {
1134
1132
  }
1135
1133
  case 'shop-release': return performRelease(env, a, cat);
1136
1134
  case 'refund': return performRefund(env, cat, a);
1137
- case 'shop-settle': return settleReferrer(env, a);
1138
1135
  default: return { ok: false, error: 'op' };
1139
1136
  }
1140
1137
  }
@@ -1284,24 +1281,20 @@ async function api(request, env, url, cat) {
1284
1281
  const lounge = await loungeOffice(env, cat, url.origin);
1285
1282
  return lounge ? json({ ok: true, lounge }) : json({ ok: false, error: 'no-lounge', message: 'This studio has no Lounge: add "lounge": true to studio.json and deploy.' }, 404);
1286
1283
  }
1287
- // The shop (0.24.0): the owner's view, the accountant's CSV, the referral statements.
1284
+ // The shop (0.24.0): the owner's view, the accountant's CSV, order records.
1288
1285
  if (selling.enabled) { const result = await selling.officeRoutes(request, env, path, body); if (result) return result; }
1289
- if (path === '/_studio/api/shop' && request.method === 'GET') return json(await shopOffice(env, cat, url.origin, url.searchParams.get('cursor') ?? '', /^\d+$/.test(url.searchParams.get('itemCursor') ?? '') ? Number(url.searchParams.get('itemCursor')) : 0, /^\d+$/.test(url.searchParams.get('orderCursor') ?? '') ? Math.min(Number(url.searchParams.get('orderCursor')), Number.MAX_SAFE_INTEGER) : 0));
1286
+ if (path === '/_studio/api/shop' && request.method === 'GET') return json(await shopOffice(env, cat, url.origin, /^\d+$/.test(url.searchParams.get('itemCursor') ?? '') ? Number(url.searchParams.get('itemCursor')) : 0, /^\d+$/.test(url.searchParams.get('orderCursor') ?? '') ? Math.min(Number(url.searchParams.get('orderCursor')), Number.MAX_SAFE_INTEGER) : 0));
1290
1287
  if (path === '/_studio/api/shop/orders.csv' && request.method === 'GET') {
1291
1288
  try {
1292
1289
  return new Response(await ordersCsv(env), { headers: { 'content-type': 'text/csv; charset=utf-8', 'cache-control': 'no-store, private', 'content-disposition': `attachment; filename="orders-${today()}.csv"` } });
1293
1290
  } catch { return json({ ok: false, error: 'not-migrated', message: 'The shop needs migration 0008_studio_shop.sql (npm run deploy).' }, 503); }
1294
1291
  }
1295
- if (path === '/_studio/api/shop/lines' && request.method === 'GET') return json({ ok: true, ...(await linesOf(env, { via: url.searchParams.get('via'), currency: url.searchParams.get('currency'), cursor: url.searchParams.get('cursor') ?? '' })) });
1296
- if (path === '/_studio/api/shop/received' && request.method === 'GET') return json(await receivedPages(env, url.searchParams.get('seller') ?? '', url.searchParams.get('period') ?? '', url.searchParams.get('cursor') ?? ''));
1297
- if (path === '/_studio/api/shop/statements' && request.method === 'GET') return json(await statementsOut(env, cat, url.origin, url.searchParams.get('period'), url.searchParams.get('cursor') ?? ''));
1298
- if (path === '/_studio/api/shop/statements/send' && request.method === 'POST') return json(await statementsSend(env, cat, url.origin, body.period, body.cursor ?? ''));
1299
1292
  const OPS = {
1300
1293
  '/_studio/api/chat/rules': 'chat-rules', '/_studio/api/chat/remove': 'chat-remove', '/_studio/api/chat/report': 'chat-report', '/_studio/api/chat/budget': 'chat-budget',
1301
1294
  '/_studio/api/kick': 'kick', '/_studio/api/mute': 'mute', '/_studio/api/close': 'close', '/_studio/api/announce': 'announce', '/_studio/api/game': 'game',
1302
1295
  '/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
1303
1296
  '/_studio/api/agents/pass': 'pass', '/_studio/api/room-level': 'room-level', '/_studio/api/agents/brain': 'agents-brain',
1304
- '/_studio/api/shop/release': 'shop-release', '/_studio/api/shop/refund': 'refund', '/_studio/api/shop/settle': 'shop-settle',
1297
+ '/_studio/api/shop/release': 'shop-release', '/_studio/api/shop/refund': 'refund',
1305
1298
  '/_studio/api/lounge/rules': 'lounge-rules', '/_studio/api/lounge/night': 'lounge-night', '/_studio/api/lounge/mod': 'lounge-mod',
1306
1299
  '/_studio/api/lounge/remove': 'lounge-remove', '/_studio/api/lounge/hold': 'lounge-hold',
1307
1300
  };
@@ -1,7 +1,7 @@
1
1
  import { amountError } from './shop-rules.mjs';
2
2
  import { digest, manifestHash } from "./purchase-crypto.mjs";
3
3
  /** Paid-part metadata shared by the builder, buyer and Worker. No payment policy ceilings. */
4
- import { canonicalJson } from './referrals.mjs';
4
+ import { canonicalJson } from './canonical-json.mjs';
5
5
 
6
6
  export const quoteHash = async (part, quantity = 1, game = null, offer = part.sale) => digest(canonicalJson({ manifest: await manifestHash(part), part: part.id, release: part.version, scope: offer.scope, quantity, game, interval: offer.billing, currency: offer.currency, amount: offer.amount * quantity, offer }));
7
7
  export const legalTerms = (p) => ({ license: p.license, terms: p.files?.find((f) => f.path === p.licenseTerms)?.sha256, scope: p.sale?.scope, source: p.sale?.source, commercialUse: p.sale?.commercialUse, transferable: p.sale?.transferable, onRefund: p.sale?.onRefund, onExpiry: p.sale?.onExpiry, refund: p.sale?.refund, updates: p.sale?.updates });
@@ -1,5 +1,5 @@
1
1
  /** Provider facts drive orders. Offers and entitlements are immutable purchase inputs. */
2
- import { canonicalJson } from "./referrals.mjs";
2
+ import { canonicalJson } from "./canonical-json.mjs";
3
3
  import { digest } from "./purchase-crypto.mjs";
4
4
 
5
5
 
@@ -1,4 +1,4 @@
1
- import { canonicalJson } from "./referrals.mjs";
1
+ import { canonicalJson } from "./canonical-json.mjs";
2
2
  export const GRANT_TYPE = 'homie-purchase-grant+jwt';
3
3
  export const PROOF_TYPE = 'homie-purchase-proof+jwt';
4
4
  export const PROOF_AUDIENCE = 'homie-purchase-evidence';
@@ -1,4 +1,4 @@
1
- /** The licence key ring is a Worker secret, independent of referral statements and D1. */
1
+ /** The licence key ring is a Worker secret, independent of D1. */
2
2
 
3
3
  export async function purchaseKeys(env) {
4
4
  let keys;
@@ -1,6 +1,6 @@
1
1
  /** Public Payment Link configuration, installed by the owner's existing CLI authorization. */
2
2
  import { paymentLinkURL, linkConfig } from './shop-links.mjs';
3
- import { canonicalJson } from './referrals.mjs';
3
+ import { canonicalJson } from './canonical-json.mjs';
4
4
  export function purchaseLinks(env) {
5
5
  try { const c = JSON.parse(env.PURCHASE_LINKS ?? 'null'); return c?.v === 1 && c.mode === linkConfig(env)?.mode && Array.isArray(c.offers) ? c : null; } catch { return null; }
6
6
  }
@@ -5,7 +5,7 @@ import { orderFact, recordOrderState } from './purchase-core.mjs';
5
5
  import { machineCapabilities } from './payment-capabilities.mjs';
6
6
  /** Studio licences use the shop's Stripe, order book, webhook and refund office. */
7
7
 
8
- import { canonicalJson } from './referrals.mjs';
8
+ import { canonicalJson } from './canonical-json.mjs';
9
9
 
10
10
  import {
11
11
  cancelSubscription,
@@ -4,7 +4,7 @@ import { hostedReadiness } from './purchase-hosted-readiness.mjs';
4
4
  import { orderFact, orderState, recordOrderState, offerVersion } from './purchase-core.mjs';
5
5
  /** Studio licences use the shop's Stripe, order book, webhook and refund office. */
6
6
 
7
- import { canonicalJson } from './referrals.mjs';
7
+ import { canonicalJson } from './canonical-json.mjs';
8
8
  import { signingKey, publicKeys } from './purchase-keys.mjs';
9
9
  import {
10
10
  createCheckoutSession,
@@ -3,7 +3,7 @@ import { problem } from './purchase-problem.mjs';
3
3
  /** Resource purchases share the studio's Stripe webhook and refund office. */
4
4
  import { offerVersion } from "./purchase-core.mjs";
5
5
 
6
- import { canonicalJson } from "./referrals.mjs";
6
+ import { canonicalJson } from "./canonical-json.mjs";
7
7
 
8
8
  import { modeOf } from "./stripe.mjs";
9
9
 
@@ -522,8 +522,8 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
522
522
  function cents(n, c) { var scale = zeroDecimal.indexOf(String(c || 'usd').toLowerCase()) >= 0 ? 1 : ['bhd', 'jod', 'kwd', 'omr', 'tnd'].indexOf(String(c).toLowerCase()) >= 0 ? 1000 : 100; try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: String(c || 'usd').toUpperCase(), minimumFractionDigits: scale === 1 || ['isk', 'ugx'].indexOf(c) >= 0 ? 0 : Math.log10(scale), maximumFractionDigits: scale === 1 || ['isk', 'ugx'].indexOf(c) >= 0 ? 0 : Math.log10(scale) }).format(n / scale); } catch (e) { return (n / scale).toFixed(scale === 1 ? 0 : Math.log10(scale)); } }
523
523
  function toast(t) { var x = $('#toast'); x.textContent = t; x.hidden = false; clearTimeout(toast.t); toast.t = setTimeout(function () { x.hidden = true; }, 4000); }
524
524
  function link(href, text) { var a = el('a', { href: href, target: '_blank', rel: 'noopener' }, text); return a; }
525
- function load(cursor, orderCursor) {
526
- api('GET', '/_studio/api/shop?cursor=' + encodeURIComponent(cursor || '') + '&orderCursor=' + encodeURIComponent(orderCursor || '')).then(function (s) {
525
+ function load(orderCursor) {
526
+ api('GET', '/_studio/api/shop?orderCursor=' + encodeURIComponent(orderCursor || '')).then(function (s) {
527
527
  var top = $('#state'); top.textContent = '';
528
528
  if (!s.ok) { top.appendChild(el('p', { class: 'empty' }, s.message || 'The shop did not answer.')); return; }
529
529
  top.appendChild(el('p', null, s.ready ? ('Open' + (s.mode === 'test' ? ' in TEST MODE (no real money)' : ', live') + (s.shop ? ', till: ' + s.shop.till : '')) : 'Not selling yet.'));
@@ -542,7 +542,7 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
542
542
  var row = el('div', { class: 'row' });
543
543
  row.appendChild(el('span', null, new Date(o.createdAt).toISOString().slice(0, 16).replace('T', ' ')));
544
544
  row.appendChild(el('b', null, o.name + ' · ' + o.shown));
545
- row.appendChild(el('span', null, (o.player && o.player.name ? o.player.name : o.resourceKind ? 'a studio purchase' : 'a deleted account') + (o.parent ? ' (a parent paid)' : '') + (o.via ? ' · sent by ' + o.via : '')));
545
+ row.appendChild(el('span', null, (o.player && o.player.name ? o.player.name : o.resourceKind ? 'a studio purchase' : 'a deleted account') + (o.parent ? ' (a parent paid)' : '')));
546
546
  row.appendChild(el('span', { class: 'st st-' + o.status }, o.status + (o.mode === 'test' ? ' · test' : '') + (o.note ? ' · ' + o.note : '')));
547
547
  if (o.stripe) row.appendChild(link(o.stripe, 'In Stripe'));
548
548
  if (o.dispute) row.appendChild(link(o.dispute, 'The dispute'));
@@ -578,70 +578,7 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
578
578
  });
579
579
  ord.appendChild(row);
580
580
  });
581
- if (s.nextOrdersCursor) { var nextOrders = el('button', { type: 'button' }, 'Next orders'); nextOrders.onclick = function () { load('', s.nextOrdersCursor); }; ord.appendChild(nextOrders); }
582
- var ref = $('#referrals'); ref.textContent = '';
583
- if (s.referrals.nextCursor) { var next = el('button', { type: 'button' }, 'Next referrers'); next.onclick = function () { load(s.referrals.nextCursor); }; ref.appendChild(next); }
584
- (s.referrals.failures || []).forEach(function (x) { ref.appendChild(el('p', null, 'Statement failed for ' + x.via + ': ' + (x.why || x.status) + '. Start again after completion to retry.')); });
585
- var owe = s.referrals.owe || [];
586
- ref.appendChild(el('h3', null, 'You owe referrers'));
587
- if (!owe.length) ref.appendChild(el('p', { class: 'dim' }, 'Nothing: no sale came from another site\'s link yet.'));
588
- owe.forEach(function (b) {
589
- var detail = el('details'); detail.appendChild(el('summary', null, 'Sales'));
590
- var sales = el('div'); detail.appendChild(sales);
591
- var more = el('button', { type: 'button' }, 'Show sales'); detail.appendChild(more);
592
- var lineCursor = '';
593
- more.onclick = function () {
594
- more.disabled = true;
595
- api('GET', '/_studio/api/shop/lines?via=' + encodeURIComponent(b.via) + '&currency=' + b.currency + '&cursor=' + encodeURIComponent(lineCursor)).then(function (page) {
596
- sales.textContent = '';
597
- page.lines.forEach(function (line) { sales.appendChild(el('p', null, line.date + ' · ' + line.item + ' · ' + cents(line.share, b.currency) + ' · ' + line.state)); });
598
- lineCursor = page.nextCursor; more.hidden = !lineCursor; more.disabled = false; more.textContent = 'Next sales';
599
- }).catch(function () { more.disabled = false; toast('Sales did not answer.'); });
600
- };
601
- var row = el('div', { class: 'row' });
602
- row.appendChild(el('b', null, b.via));
603
- row.appendChild(el('span', null, 'due ' + cents(b.due, b.currency) + ' · held ' + cents(b.pending, b.currency) + ' · ' + b.lineCount + ' sale(s)'));
604
- if (b.due > 0) {
605
- var m = el('button', { type: 'button', class: 'ghost small' }, 'Mark paid');
606
- m.addEventListener('click', function () {
607
- var ref0 = prompt('What paid it (an invoice number, a PayPal reference)?'); if (ref0 === null) return;
608
- api('POST', '/_studio/api/shop/settle', { via: b.via, currency: b.currency, ref: ref0 }).then(function (r) { if (!r.ok) throw new Error(r.message); toast('Marked paid.'); load(); }).catch(function (e) { toast(e.message); });
609
- });
610
- row.appendChild(m);
611
- }
612
- ref.appendChild(row); ref.appendChild(detail);
613
- });
614
- var send = el('button', { type: 'button', class: 'ghost small' }, 'Send last month\'s signed statements');
615
- send.addEventListener('click', async function () {
616
- send.disabled = true; send.textContent = 'Sending; keep this tab open. Close it and press again to resume safely.';
617
- var cursor = '', period = null, sent = 0, failed = [];
618
- try {
619
- do {
620
- var r = await api('POST', '/_studio/api/shop/statements/send', { cursor: cursor, period: period });
621
- if (!r.ok) throw new Error(r.message || 'Not sent.');
622
- sent += r.sent.filter(function (x) { return x.ok; }).length;
623
- failed = failed.concat(r.sent.filter(function (x) { return !x.ok; }).map(function (x) { return x.via + ': ' + (x.why || x.status); }));
624
- send.textContent = 'Sent ' + sent + ' pages. Keep open; press again after closing to resume safely.';
625
- cursor = r.nextCursor; period = r.period;
626
- if (r.retryAfter) { send.textContent = 'Retrying with backoff; keep open, or press again later to resume safely.'; await new Promise(function (resolve) { setTimeout(resolve, r.retryAfter * 1000); }); }
627
- } while (cursor !== null);
628
- if (r.failures) failed = r.failures.map(function (x) { return x.via + ': ' + (x.why || x.status); });
629
- toast('Sent ' + sent + ' statement pages; ' + failed.length + ' failed.' + (failed.length ? ' ' + failed.join('; ') : ''));
630
- } catch (e) { toast(e.message); }
631
- send.disabled = false; send.textContent = 'Send last month’s signed statements';
632
- });
633
- ref.appendChild(send);
634
- ref.appendChild(el('h3', null, 'Owed to you'));
635
- var inn = s.referrals.owedToUs || [];
636
- if (!inn.length) ref.appendChild(el('p', { class: 'dim' }, 'No statements yet. When another studio sells to a player you sent, it sends a signed statement here.'));
637
- inn.forEach(function (x) {
638
- var row = el('div', { class: 'row' });
639
- row.appendChild(el('b', null, x.seller.replace(/^https:\/\//, '')));
640
- row.appendChild(el('span', null, x.period + ': due ' + cents(x.due, x.currency) + ', held ' + cents(x.pending, x.currency)));
641
- row.appendChild(link('/_studio/api/shop/received?seller=' + encodeURIComponent(x.seller) + '&period=' + encodeURIComponent(x.period), 'Signed statement pages'));
642
- if (x.due > 0) row.appendChild(link(s.referrals.invoice, 'Invoice them in Stripe'));
643
- ref.appendChild(row);
644
- });
581
+ if (s.nextOrdersCursor) { var nextOrders = el('button', { type: 'button' }, 'Next orders'); nextOrders.onclick = function () { load(s.nextOrdersCursor); }; ord.appendChild(nextOrders); }
645
582
  }).catch(function () { $('#state').textContent = 'The shop did not answer.'; });
646
583
  }
647
584
  load();
@@ -661,9 +598,8 @@ export async function officeShopPage(cat) {
661
598
  <style>.row{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;padding:10px 0;border-top:1px solid var(--line)}.row b{font-weight:700}.st{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--line2)}.st-refunded,.st-lost{opacity:.7}.st-disputed{border-color:#ff8a80;color:#ff8a80}h3{margin:22px 0 6px;font-size:15px}.links a{margin-right:2px}</style>
662
599
  <div class="game" style="padding:14px 16px" id="state"><p class="empty">Reading the shop…</p></div>
663
600
  <div class="game" style="padding:6px 16px 12px"><h3>Orders</h3><div id="orders"></div></div>
664
- <div class="game" style="padding:6px 16px 12px" id="referrals"></div>
665
601
  <div class="toast" id="toast" role="status" hidden></div>
666
- <p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing. Refunds are handled in Stripe or by your connected AI; shops with the fuller checkout connection also offer Refund here. A card dispute never touches the player's account: answer it in Stripe; if you lose it, that one item is taken back. Tax: when enabled, Stripe Tax works out and collects tax where you told Stripe you are registered${cat.shop?.till === 'stripe-managed' ? '; with Managed Payments Stripe is the seller of record and files it for you' : ''}. Payouts, the balance and receipts are Stripe's own pages, linked above. Referrals: you pay referrers yourself (they invoice you); nothing moves through Homie. This is not legal or tax advice.</p>`;
602
+ <p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing. Refunds are handled in Stripe or by your connected AI; shops with the fuller checkout connection also offer Refund here. A card dispute never touches the player's account: answer it in Stripe; if you lose it, that one item is taken back. Tax: when enabled, Stripe Tax works out and collects tax where you told Stripe you are registered${cat.shop?.till === 'stripe-managed' ? '; with Managed Payments Stripe is the seller of record and files it for you' : ''}. Payouts, the balance and receipts are Stripe's own pages, linked above. This is not legal or tax advice.</p>`;
667
603
  return new Response(shell(cat, `Shop · ${name}`, body, { script }), {
668
604
  headers: { ...PRIVATE, 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src '${officeHash}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'` },
669
605
  });
@@ -47,18 +47,6 @@ export function amountError(amount, currency) {
47
47
  return null;
48
48
  }
49
49
 
50
- /** Round a decimal rate times integer money exactly, including exponent notation. */
51
- export function referralShare(amount, rate) {
52
- const [digits, exponent = '0'] = String(rate).toLowerCase().split('e');
53
- const [whole, fraction = ''] = digits.split('.');
54
- const scale = fraction.length - Number(exponent);
55
- let numerator = BigInt(whole + fraction), denominator = 1n;
56
- if (scale > 0) denominator = 10n ** BigInt(scale); else numerator *= 10n ** BigInt(-scale);
57
- const share = Number((2n * BigInt(amount) * numerator + denominator) / (2n * denominator));
58
- if (!Number.isSafeInteger(share)) throw new RangeError('Referral share exceeds safe integer arithmetic');
59
- return share;
60
- }
61
-
62
50
  /** Checkout wording is shared by validation and the request path. */
63
51
  export function checkoutMessage(shop, studio = 'this studio', parent = false) {
64
52
  return `${parent ? "This is for your child's account. " : ''}Delivered at once to the player's account on ${studio}. ${shop.policy.withdrawalAcknowledgement ? 'By paying you ask for it now, which ends the 14-day withdrawal right;' : 'Your statutory rights still apply.'} ${shop.refundDays === null ? (shop.policy.withdrawalAcknowledgement ? 'ask the studio about refunds.' : 'Ask the studio about refunds.') : `${shop.policy.refundUsedItems ? 'an item' : 'an unused item'} can still be refunded within ${shop.refundDays} days.`}`;
@@ -138,7 +126,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
138
126
  const purchaseAttemptsPerMinute = ratePolicy('purchaseAttemptsPerMinute');
139
127
  const purchaseAttemptsPerAddressPerMinute = ratePolicy('purchaseAttemptsPerAddressPerMinute');
140
128
 
141
- for (const key of ['automaticTax', 'referralNewPlayersOnly']) if (raw[key] !== undefined && typeof raw[key] !== 'boolean') err(key, 'use a boolean');
129
+ if (raw.automaticTax !== undefined && typeof raw.automaticTax !== 'boolean') err('automaticTax', 'use a boolean');
142
130
  const items = [];
143
131
  const list = Array.isArray(raw.items) ? raw.items : [];
144
132
  if (raw.items !== undefined && !Array.isArray(raw.items)) err('items', 'items is a list');
@@ -209,24 +197,8 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
209
197
  });
210
198
  });
211
199
 
212
- let referrals = null;
213
- if (raw.referrals !== undefined && raw.referrals !== null && raw.referrals !== 0 && raw.referrals !== false) {
214
- const r = raw.referrals;
215
- if (!isObj(r)) err('referrals', 'referrals is { "rate": 0.10, "windowDays": 30, "capPerPlayer": 1000, "holdDays": 30, "minimumInvoice": 2500 } (or leave it out: nobody is paid for sending players)');
216
- else {
217
- const rate = optionalNumber(r.rate ?? 0, 'referrals.rate', { integer: false });
218
- const windowDays = optionalNumber(r.windowDays, 'referrals.windowDays');
219
- const capPerPlayer = optionalNumber(r.capPerPlayer, 'referrals.capPerPlayer');
220
- const holdDays = optionalNumber(r.holdDays, 'referrals.holdDays');
221
- const minimumInvoice = optionalNumber(r.minimumInvoice, 'referrals.minimumInvoice');
222
- const accept = Array.isArray(r.accept) ? r.accept.map(String) : [];
223
- const billing = r.billingEmail === undefined ? null : String(r.billingEmail);
224
- if (billing !== null && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(billing)) err('referrals.billingEmail', 'billingEmail is where referrers send their invoices (an address the studio reads)');
225
- referrals = { rate, windowDays, capPerPlayer, holdDays, minimumInvoice, basis: 'pre-tax', accept, ...(billing ? { billingEmail: billing } : {}) };
226
- }
227
- }
228
-
229
- // Where `homie-studio shop catalog` made the items' Products in Stripe (0.24.3): the Worker's checkouts then name them.
200
+ for (const key of ['referrals', 'referralNewPlayersOnly']) if (Object.hasOwn(raw, key)) warnings.push({ at: key, message: 'ignored; Homie has no referral system.' });
201
+ // Where `homie-studio shop catalog` made the items' Products in Stripe: checkouts then name them.
230
202
  let catalog = [];
231
203
  if (raw.catalog !== undefined && raw.catalog !== null) {
232
204
  if (!Array.isArray(raw.catalog) || raw.catalog.some((m) => m !== 'test' && m !== 'live')) err('catalog', 'catalog lists where the items are Products in Stripe: ["test"], ["test", "live"] (homie-studio shop catalog writes it)');
@@ -237,7 +209,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
237
209
  const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
238
210
  if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off under policy.kidsStudio).' });
239
211
  const shop = {
240
- v: 1, ...(isObj(raw.giving) ? { giving: { receiptText: String(raw.giving.receiptText ?? '') } } : {}), policy, purchasesTill: TILLS.includes(raw.purchasesTill ?? till) ? (raw.purchasesTill ?? till) : 'off', ...(/^https:\/\/billing\.stripe\.com\/p\/login\/[A-Za-z0-9_]+$/.test(raw.purchasesPortalLogin ?? '') ? { purchasesPortalLogin: raw.purchasesPortalLogin } : {}), checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, referralNewPlayersOnly: raw.referralNewPlayersOnly === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
212
+ v: 1, ...(isObj(raw.giving) ? { giving: { receiptText: String(raw.giving.receiptText ?? '') } } : {}), policy, purchasesTill: TILLS.includes(raw.purchasesTill ?? till) ? (raw.purchasesTill ?? till) : 'off', ...(/^https:\/\/billing\.stripe\.com\/p\/login\/[A-Za-z0-9_]+$/.test(raw.purchasesPortalLogin ?? '') ? { purchasesPortalLogin: raw.purchasesPortalLogin } : {}), checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items,
241
213
  open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
242
214
  };
243
215
  // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-custom_text-submit-message
@@ -305,7 +277,7 @@ export function wayFor(item, { open = true, kids = false, beginner = false, play
305
277
  /** The public face of a shop (for the directory manifest and the shop page): never a secret, never a person. */
306
278
  export function publicShop(shop) {
307
279
  if (!shop) return null;
308
- return { policy: shop.policy, till: shop.till, currency: shop.currency, refundDays: shop.refundDays, capPerPlayerMonth: shop.capPerPlayerMonth, open: Boolean(shop.open), items: shop.items.length, ...(shop.referrals ? { referrals: shop.referrals } : {}) };
280
+ return { policy: shop.policy, till: shop.till, currency: shop.currency, refundDays: shop.refundDays, capPerPlayerMonth: shop.capPerPlayerMonth, open: Boolean(shop.open), items: shop.items.length };
309
281
  }
310
282
 
311
283
  /** A price for people: 500 usd → "$5.00", 300 eur → "€3.00", 1250 cad → "CA$12.50". */
@@ -3,10 +3,9 @@
3
3
  * from worker/shop.mjs so player accounts (worker/players.mjs) can export and forget a player's purchases without
4
4
  * importing the routes.
5
5
  *
6
- * WHAT IS STORED: orders (the item, the price, the state, Stripe's ids, the referrer's host), entitlements (what a
6
+ * WHAT IS STORED: orders (the item, the price, the state, Stripe's ids), entitlements (what a
7
7
  * player owns, by the keys the game reads), the age question's answer as a BAND only (adult, teen or child; never a
8
- * date), one-time links a teenager hands a parent, the webhook events already handled (ids only), referral lines
9
- * (the seller's books) and the statements other studios sent this one. NO card, NO address, NO email, NO name:
8
+ * date), one-time links a teenager hands a parent, the webhook events already handled (ids only). NO card, NO address, NO email, NO name:
10
9
  * Stripe keeps the buyer's details and sends the receipt.
11
10
  */
12
11
 
@@ -31,7 +30,6 @@ CREATE TABLE IF NOT EXISTS shop_orders (
31
30
  refund TEXT,
32
31
  dispute TEXT,
33
32
  parent INTEGER NOT NULL DEFAULT 0,
34
- via TEXT,
35
33
  note TEXT,
36
34
  created_at INTEGER NOT NULL,
37
35
  paid_at INTEGER,
@@ -71,30 +69,6 @@ CREATE TABLE IF NOT EXISTS shop_parent_links (
71
69
  order_id TEXT,
72
70
  expires_at INTEGER NOT NULL
73
71
  ) WITHOUT ROWID;
74
- CREATE TABLE IF NOT EXISTS referral_lines (
75
- order_id TEXT PRIMARY KEY,
76
- via TEXT NOT NULL,
77
- net INTEGER NOT NULL,
78
- rate REAL NOT NULL,
79
- share INTEGER NOT NULL,
80
- currency TEXT NOT NULL,
81
- state TEXT NOT NULL,
82
- period TEXT NOT NULL,
83
- hold_until INTEGER NOT NULL,
84
- settled_ref TEXT,
85
- created_at INTEGER NOT NULL
86
- ) WITHOUT ROWID;
87
- CREATE INDEX IF NOT EXISTS referral_lines_via ON referral_lines (via, period);
88
- CREATE TABLE IF NOT EXISTS referral_statements (
89
- seller TEXT NOT NULL,
90
- period TEXT NOT NULL,
91
- owed INTEGER NOT NULL,
92
- pending INTEGER NOT NULL,
93
- currency TEXT NOT NULL,
94
- body TEXT NOT NULL,
95
- received_at INTEGER NOT NULL,
96
- PRIMARY KEY (seller, period)
97
- ) WITHOUT ROWID;
98
72
  `;
99
73
 
100
74
  export const SHOP_RESERVATIONS_FILE = '0010_shop_reservations.sql';
@@ -102,26 +76,6 @@ export const SHOP_RESERVATIONS = `-- Checkout expiry in milliseconds; only sessi
102
76
  ALTER TABLE shop_orders ADD COLUMN expires_at INTEGER;
103
77
  -- Previously opened sessions used the same explicit 31-minute lifetime.
104
78
  UPDATE shop_orders SET expires_at = (CAST(created_at / 1000 AS INTEGER) + 31 * 60) * 1000 WHERE status IN ('started', 'processing');
105
- CREATE INDEX IF NOT EXISTS referral_lines_book ON referral_lines (via, currency, order_id);
106
- `;
107
-
108
- export const SHOP_STATEMENTS_FILE = '0011_shop_statements.sql';
109
- export const SHOP_STATEMENTS = `-- Statements retain independent currencies and an immutable edition while sending.
110
- ALTER TABLE referral_statements RENAME TO referral_statements_old;
111
- CREATE TABLE referral_statements (
112
- seller TEXT NOT NULL, period TEXT NOT NULL, owed INTEGER NOT NULL, pending INTEGER NOT NULL,
113
- currency TEXT NOT NULL, body TEXT NOT NULL, received_at INTEGER NOT NULL,
114
- PRIMARY KEY (seller, period, currency)
115
- ) WITHOUT ROWID;
116
- INSERT INTO referral_statements SELECT * FROM referral_statements_old;
117
- DROP TABLE referral_statements_old;
118
- CREATE TABLE referral_editions (id TEXT PRIMARY KEY, period TEXT NOT NULL, issued INTEGER NOT NULL) WITHOUT ROWID;
119
- CREATE TABLE referral_edition_lines (
120
- edition TEXT NOT NULL, order_id TEXT NOT NULL, via TEXT NOT NULL, currency TEXT NOT NULL,
121
- net INTEGER NOT NULL, rate REAL NOT NULL, share INTEGER NOT NULL, state TEXT NOT NULL,
122
- period TEXT NOT NULL, hold_until INTEGER NOT NULL, created_at INTEGER NOT NULL, item TEXT, paid_at INTEGER,
123
- PRIMARY KEY (edition, via, currency, order_id)
124
- ) WITHOUT ROWID;
125
79
  `;
126
80
 
127
81
  export const SHOP_LINES_FILE = '0012_shop_lines.sql';
@@ -150,7 +104,6 @@ END;
150
104
  UPDATE shop_order_lines SET refunded_amount = COALESCE(total, amount), refunded_net = amount WHERE status = 'refunded';
151
105
  ALTER TABLE shop_parent_links ADD COLUMN cart TEXT;
152
106
  ALTER TABLE shop_orders ADD COLUMN whole_refund TEXT;
153
- ALTER TABLE shop_orders ADD COLUMN referral_terms TEXT;
154
107
  ALTER TABLE shop_orders ADD COLUMN checkout_player TEXT;
155
108
  UPDATE shop_orders SET checkout_player = player;
156
109
  CREATE TRIGGER shop_checkout_player_insert AFTER INSERT ON shop_orders BEGIN
@@ -159,8 +112,6 @@ END;
159
112
  CREATE TRIGGER shop_forget_checkout_player AFTER UPDATE OF player ON shop_orders WHEN NEW.player IS NULL BEGIN
160
113
  UPDATE shop_orders SET checkout_player = NULL WHERE id = NEW.id;
161
114
  END;
162
- ALTER TABLE referral_lines ADD COLUMN original_share INTEGER;
163
- UPDATE referral_lines SET original_share = share;
164
115
  ALTER TABLE shop_orders ADD COLUMN attention INTEGER NOT NULL DEFAULT 1;
165
116
  UPDATE shop_orders SET attention = 0 WHERE status = 'paid' AND EXISTS (SELECT 1 FROM entitlements WHERE order_id = shop_orders.id);
166
117
  ALTER TABLE shop_orders ADD COLUMN refunded_amount INTEGER NOT NULL DEFAULT 0;
@@ -190,13 +141,10 @@ export async function migrationNeeded(env) {
190
141
  for (const [file, query] of [
191
142
  [SHOP_MIGRATION_FILE, 'SELECT 1 FROM shop_orders LIMIT 1'],
192
143
  [SHOP_RESERVATIONS_FILE, 'SELECT expires_at FROM shop_orders LIMIT 1'],
193
- [SHOP_STATEMENTS_FILE, 'SELECT issued FROM referral_editions LIMIT 1'],
194
- [SHOP_STATEMENTS_FILE, 'SELECT edition FROM referral_edition_lines LIMIT 1'],
195
144
  [SHOP_LINES_FILE, 'SELECT snapshot, total, refunded_amount FROM shop_order_lines LIMIT 1'],
196
145
  [SHOP_LINES_FILE, 'SELECT line_id, quantity FROM shop_entitlement_lines LIMIT 1'],
197
- [SHOP_LINES_FILE, 'SELECT referral_terms, checkout_player, attention, refunded_amount, refund_revision FROM shop_orders LIMIT 1'],
146
+ [SHOP_LINES_FILE, 'SELECT checkout_player, attention, refunded_amount, refund_revision FROM shop_orders LIMIT 1'],
198
147
  [SHOP_LINES_FILE, 'SELECT cart FROM shop_parent_links LIMIT 1'],
199
- [SHOP_LINES_FILE, 'SELECT original_share FROM referral_lines LIMIT 1'],
200
148
  ]) {
201
149
  try { await env.DB.prepare(query).first(); } catch { return file; }
202
150
  }
@@ -296,7 +244,7 @@ export const orderByPayment = (env, payment) => env.DB.prepare('SELECT * FROM sh
296
244
  export function orderView(r) {
297
245
  return {
298
246
  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),
299
- status: r.status, till: r.till, mode: r.mode, parent: Number(r.parent) === 1, via: r.via ?? null,
247
+ status: r.status, till: r.till, mode: r.mode, parent: Number(r.parent) === 1,
300
248
  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),
301
249
  };
302
250
  }
@@ -368,3 +316,75 @@ export async function adoptShopStatements(env, guest, player) {
368
316
  env.DB.prepare('DELETE FROM player_age WHERE player = ?1').bind(guest),
369
317
  ];
370
318
  }
319
+
320
+ export const SHOP_REMOVE_REFERRALS_FILE = '0022_remove_referrals.sql';
321
+ export const SHOP_REMOVE_REFERRALS = `-- Remove referral bookkeeping; no studio we run has data in these tables.
322
+ DROP TABLE IF EXISTS referral_lines;
323
+ DROP TABLE IF EXISTS referral_statements;
324
+ DROP TABLE IF EXISTS referral_statements_old;
325
+ DROP TABLE IF EXISTS referral_editions;
326
+ DROP TABLE IF EXISTS referral_edition_lines;
327
+ DELETE FROM meta WHERE key IN ('referral_cookie_key', 'referral_signing_key') OR key GLOB 'referral-*';
328
+ UPDATE shop_parent_links SET cart = json_remove(cart, '$[0].via') WHERE json_valid(cart);
329
+ -- Rebuild orders to remove sales attribution, on both old and fresh schemas.
330
+ CREATE TABLE shop_orders_clean (
331
+ id TEXT PRIMARY KEY,
332
+ player TEXT,
333
+ item TEXT NOT NULL,
334
+ game TEXT,
335
+ amount INTEGER NOT NULL,
336
+ currency TEXT NOT NULL,
337
+ tax INTEGER,
338
+ total INTEGER,
339
+ till TEXT NOT NULL,
340
+ mode TEXT NOT NULL,
341
+ status TEXT NOT NULL,
342
+ session TEXT UNIQUE,
343
+ payment TEXT,
344
+ refund TEXT,
345
+ dispute TEXT,
346
+ parent INTEGER NOT NULL DEFAULT 0,
347
+ note TEXT,
348
+ created_at INTEGER NOT NULL,
349
+ paid_at INTEGER,
350
+ refunded_at INTEGER,
351
+ updated_at INTEGER NOT NULL,
352
+ expires_at INTEGER,
353
+ whole_refund TEXT,
354
+ checkout_player TEXT,
355
+ attention INTEGER NOT NULL DEFAULT 1,
356
+ refunded_amount INTEGER NOT NULL DEFAULT 0,
357
+ refunded_net INTEGER NOT NULL DEFAULT 0,
358
+ refund_revision INTEGER NOT NULL DEFAULT 0
359
+ );
360
+ INSERT INTO shop_orders_clean (id, player, item, game, amount, currency, tax, total, till, mode, status, session, payment, refund, dispute, parent, note, created_at, paid_at, refunded_at, updated_at, expires_at, whole_refund, checkout_player, attention, refunded_amount, refunded_net, refund_revision) SELECT id, player, item, game, amount, currency, tax, total, till, mode, status, session, payment, refund, dispute, parent, note, created_at, paid_at, refunded_at, updated_at, expires_at, whole_refund, checkout_player, attention, refunded_amount, refunded_net, refund_revision FROM shop_orders;
361
+ DROP TABLE shop_orders;
362
+ ALTER TABLE shop_orders_clean RENAME TO shop_orders;
363
+ CREATE INDEX IF NOT EXISTS shop_orders_player ON shop_orders (player, created_at);
364
+ CREATE INDEX IF NOT EXISTS shop_orders_status ON shop_orders (status, created_at);
365
+ CREATE INDEX IF NOT EXISTS shop_orders_payment ON shop_orders (payment);
366
+ CREATE INDEX shop_orders_attention_player ON shop_orders (mode, player, status, updated_at, id) WHERE attention = 1;
367
+ CREATE INDEX shop_orders_attention ON shop_orders (mode, status, updated_at, id) WHERE attention = 1;
368
+ CREATE TRIGGER shop_checkout_player_insert AFTER INSERT ON shop_orders BEGIN
369
+ UPDATE shop_orders SET checkout_player = NEW.player WHERE id = NEW.id;
370
+ END;
371
+ CREATE TRIGGER shop_forget_checkout_player AFTER UPDATE OF player ON shop_orders WHEN NEW.player IS NULL BEGIN
372
+ UPDATE shop_orders SET checkout_player = NULL WHERE id = NEW.id;
373
+ END;
374
+ -- Restore durable paid and refunded events after rebuilding the order table.
375
+
376
+ DROP TRIGGER IF EXISTS shop_orders_function_paid;
377
+ CREATE TRIGGER shop_orders_function_paid AFTER UPDATE ON shop_orders
378
+ WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.paid')
379
+ AND OLD.paid_at IS NULL AND NEW.paid_at IS NOT NULL
380
+ AND NOT EXISTS (SELECT 1 FROM purchase_orders WHERE id=NEW.id) BEGIN
381
+ INSERT OR IGNORE INTO studio_events(id,type,data,at)
382
+ VALUES('shop_orders:' || NEW.id || ':paid','order.paid',
383
+ json_object('order',NEW.id,'source','shop_orders','amount',NEW.amount,'currency',NEW.currency,'mode',NEW.mode,
384
+ 'app',NEW.game,'reference',(SELECT value FROM meta WHERE key='shop-reference:' || NEW.id)),NEW.paid_at);
385
+ END;
386
+
387
+ CREATE TRIGGER shop_orders_function_refund AFTER UPDATE ON shop_orders WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.refunded') AND OLD.status != 'refunded' AND NEW.status = 'refunded' AND NOT EXISTS (SELECT 1 FROM purchase_orders WHERE id=NEW.id) BEGIN
388
+ INSERT OR IGNORE INTO studio_events(id,type,data,at) VALUES('shop_orders:' || NEW.id || ':refund:' || COALESCE(NEW.refund,''),'order.refunded',json_object('order',NEW.id,'source','shop_orders','refund',NEW.refund,'mode',NEW.mode),NEW.updated_at);
389
+ END;
390
+ `;