@homie-rocks/studio 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +85 -2
  2. package/README.md +34 -9
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +13 -4
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/standalone-cli.mjs +30 -2
  21. package/lib/standalone-tools.mjs +20 -2
  22. package/lib/standalone.mjs +215 -9
  23. package/lib/studio.mjs +21 -1
  24. package/lib/template-history.json +90 -0
  25. package/lib/upgrade.mjs +19 -15
  26. package/lib/version.mjs +2 -7
  27. package/netplay/NETPLAY.md +249 -4
  28. package/netplay/netplay.ts +55 -13
  29. package/package.json +23 -1
  30. package/rules/core.ts +1250 -0
  31. package/rules/guard.ts +419 -0
  32. package/rules/host.ts +707 -0
  33. package/rules/math.ts +266 -0
  34. package/rules/pack.ts +292 -0
  35. package/rules/rules.ts +544 -0
  36. package/rules/view.ts +447 -0
  37. package/shop/SELLING.md +14 -16
  38. package/shop/SHOP.md +119 -30
  39. package/shop/shop.ts +6 -8
  40. package/site/SITE.md +119 -2
  41. package/standalone/STANDALONE.md +61 -6
  42. package/starters/coin-dash/game.json +11 -0
  43. package/starters/coin-dash/index.html +17 -0
  44. package/starters/coin-dash/map/main.json +16 -0
  45. package/starters/coin-dash/src/move.ts +27 -0
  46. package/starters/coin-dash/src/rules.ts +86 -0
  47. package/starters/coin-dash/src/view.ts +106 -0
  48. package/starters/coin-dash/tunables.json +5 -0
  49. package/starters/ember-vale/index.html +11 -0
  50. package/worker/brain.mjs +8 -7
  51. package/worker/chat-page.mjs +1 -1
  52. package/worker/embed.mjs +135 -0
  53. package/worker/hosted.mjs +56 -0
  54. package/worker/index.mjs +413 -22
  55. package/worker/office-page.mjs +1 -0
  56. package/worker/office.mjs +24 -7
  57. package/worker/pages.mjs +189 -43
  58. package/worker/players.mjs +10 -6
  59. package/worker/referrals.mjs +192 -57
  60. package/worker/room-store.mjs +54 -0
  61. package/worker/room.mjs +294 -119
  62. package/worker/saves-shell.mjs +7 -1
  63. package/worker/schema.mjs +4 -3
  64. package/worker/shop-page.mjs +103 -37
  65. package/worker/shop-rules.mjs +156 -117
  66. package/worker/shop-store.mjs +70 -9
  67. package/worker/shop.mjs +275 -143
  68. package/worker/site.mjs +26 -5
  69. package/worker/stripe.mjs +9 -6
  70. package/worker/version.mjs +1 -1
package/worker/shop.mjs CHANGED
@@ -3,15 +3,15 @@
3
3
  * The studio is the seller; homie.rocks never sees, holds or moves its money, and Homie takes no cut.
4
4
  *
5
5
  * GET /api/shop the items, each with how THIS player may get it (shop-rules.mjs wayFor); on a kids
6
- * server or a kids studio, nothing; until set up, { open: false, missing: [...] }
6
+ * server or studio, the studio policy applies; until set up, { open: false, missing: [...] }
7
7
  * POST /api/shop/age the neutral age question, once per account: a year of birth, kept only as a band
8
- * POST /api/shop/buy an adult account buys one item: an order row, then Stripe's own hosted Checkout page
9
- * POST /api/shop/parent 13-17: a one-time link a parent opens on their own device (/shop/parent/<token>)
10
- * POST /api/shop/hook Stripe's webhook, signature checked; the ONLY writer of paid, refunded, disputed
8
+ * POST /api/shop/buy a permitted buyer buys one item: an order row, then Stripe's own hosted Checkout page
9
+ * POST /api/shop/parent studio parent policy: a one-time link a parent opens on their own device (/shop/parent/<token>)
10
+ * POST /api/shop/hook Stripe's webhook, signature checked; verified Stripe events record paid, refunded, disputed
11
11
  * POST /api/shop/used the game says an item was used (equipped, played): it leaves the self-serve refund
12
12
  * POST /api/shop/refund the player's own refund of an unused item within the studio's refund days
13
13
  * GET /api/player/owns what the signed-in player owns (the keys the game reads; a beginner or kids server
14
- * hides anything with a play advantage)
14
+ * follows the studio advantage settings)
15
15
  * GET /api/shop/mine the player's orders and what they own (the account page)
16
16
  * GET /shop/ /shop/thanks /shop/refunds/ /shop/parent/<token> the pages (worker/shop-page.mjs)
17
17
  * POST /api/referrals/statement another studio's signed referral statement to this one (worker/referrals.mjs)
@@ -33,24 +33,24 @@
33
33
  */
34
34
  import { players } from './players.mjs';
35
35
  import { serversOf } from './servers.mjs';
36
- import { CAP_CEILING, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
36
+ import { ENTITLEMENT_KEY, checkoutMessage, amountError, amountStep, parseAmount, currencyScale, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
37
37
  import {
38
- bandOfPlayer, forgetPlayerShop, grantStatements, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, ownsOf, restoreStatement, revokeStatement,
38
+ bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
39
39
  setBand, shopDataOf, spentThisMonth,
40
40
  } from './shop-store.mjs';
41
- import { KEY_SHAPE, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, dashboardLink, isApprovalRequired, isMissingProduct, modeOf, productIdOf, verifyWebhook } from './stripe.mjs';
42
- import { arrivalOf, booksOf, lineFor, receiveStatement, referrerOk, sendStatements, settle, statementFor, statementsIn, voidLineStatements } from './referrals.mjs';
41
+ import { KEY_SHAPE, stripeCall, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, modeOf, productIdOf, verifyWebhook } from './stripe.mjs';
42
+ import { pendingStatementPeriod, statementFailures, arrivalOf, booksOf, linesOf, statementPage, lineFor, receiveStatement, receivedPages, referrerOk, sendStatements, settle, statementsIn, voidLineStatements } from './referrals.mjs';
43
43
  import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
44
44
  import { notFoundPage } from './site.mjs';
45
45
 
46
- export { forgetPlayerShop, officeShopPage, shopDataOf };
46
+ export { linesOf, forgetPlayerShop, officeShopPage, receivedPages, shopDataOf };
47
47
 
48
48
  const DAY = 86_400_000;
49
49
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
50
50
  const SERVER_ID = /^(?:public|[a-z0-9][a-z0-9-]{1,19})$/;
51
51
  const TOKEN = /^[A-Za-z0-9_-]{43}$/;
52
- /** In-memory, per Worker instance: buys and parent links a minute per player. Never stored. */
53
- const BUYS_PER_MINUTE = 6;
52
+ /** In-memory, per Worker instance: purchase attempts a minute per account and address. Never stored. */
53
+ const addressOf = (request) => request.headers.get('cf-connecting-ip') || 'local';
54
54
 
55
55
  const json = (body, status = 200, headers = {}) => new Response(`${JSON.stringify(body)}\n`, {
56
56
  status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store, private', 'x-content-type-options': 'nosniff', ...headers },
@@ -58,16 +58,18 @@ const json = (body, status = 200, headers = {}) => new Response(`${JSON.stringif
58
58
  const fail = (status, error, message, extra = {}) => json({ ok: false, error, message, ...extra }, status);
59
59
 
60
60
  const buckets = new Map();
61
+ const accountBuckets = new Map();
61
62
  function limited(key, max, windowMs, now = Date.now()) {
62
- const list = (buckets.get(key) ?? []).filter((t) => t > now - windowMs);
63
- if (list.length >= max) { buckets.set(key, list); return true; }
63
+ const counts = key.includes(':account:') ? accountBuckets : buckets;
64
+ const list = (counts.get(key) ?? []).filter((t) => t > now - windowMs);
65
+ if (list.length >= max) { counts.set(key, list); return true; }
64
66
  list.push(now);
65
- buckets.set(key, list);
66
- if (buckets.size > 10_000) buckets.clear();
67
+ counts.set(key, list);
68
+ if (counts.size > 10_000) counts.delete(counts.keys().next().value);
67
69
  return false;
68
70
  }
69
71
  /** For tests: forget every count. */
70
- export function resetShopLimits() { buckets.clear(); }
72
+ export function resetShopLimits() { buckets.clear(); accountBuckets.clear(); }
71
73
 
72
74
  const sha256Hex = async (text) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))].map((b) => b.toString(16).padStart(2, '0')).join('');
73
75
  function randomToken() {
@@ -90,16 +92,22 @@ async function bodyOf(request, max = 4096) {
90
92
 
91
93
  /* ------------------------------------------------------------------ the shop and whether it is open */
92
94
 
95
+ const sizes = new WeakMap();
96
+ function requestSize(shop) {
97
+ if (sizes.has(shop)) return sizes.get(shop);
98
+ const size = (shop?.items ?? []).reduce((size, item) => (item.gives ?? []).reduce((n, key) => Math.max(n, JSON.stringify(key).length), Math.max(size, JSON.stringify(item.id).length)), 0) * 6 + 4096;
99
+ sizes.set(shop, size);
100
+ return size;
101
+ }
93
102
  const shopCache = new WeakMap();
94
103
  /**
95
- * The studio's shop, as the build wrote it into games.json and checked again here (the kids rules are in the code,
96
- * so a hand-edited build cannot sell what the kit refuses). null when the studio has no shop.json.
104
+ * The studio's shop, checked against its chosen settings and provider requirements at build and request time. null when the studio has no shop.json.
97
105
  */
98
106
  export function shopOf(cat) {
99
107
  if (!cat?.shop) return null;
100
108
  if (shopCache.has(cat)) return shopCache.get(cat);
101
109
  const games = (cat.games ?? []).map((g) => g.id);
102
- const r = checkShop(cat.shop, { games: games.length ? games : null, audience: audienceOf(cat.studio) });
110
+ const r = checkShop(cat.shop, { games: games.length ? games : null, audience: audienceOf(cat.studio), studioName: cat.studio?.name });
103
111
  const shop = r.ok ? r.shop : { ...r.shop, open: false, broken: r.errors.slice(0, 5) };
104
112
  shopCache.set(cat, shop);
105
113
  return shop;
@@ -117,22 +125,23 @@ export async function readiness(env, shop) {
117
125
  if (/^sk_live_/.test(key)) missing.push('restricted-key');
118
126
  else if (!KEY_SHAPE.test(key)) missing.push('stripe-key');
119
127
  if (!WEBHOOK_SECRET_SHAPE.test(String(env?.STRIPE_WEBHOOK_SECRET ?? ''))) missing.push('webhook-secret');
128
+ let migration = null;
120
129
  if (!env?.DB || env.HOMIE_PREVIEW === '1') missing.push('database');
121
- else if (!(await migrated(env))) missing.push('migration');
122
- return { ready: missing.length === 0, missing, mode: KEY_SHAPE.test(key) ? modeOf(key) : null };
130
+ else if ((migration = await migrationNeeded(env))) missing.push('migration');
131
+ return { ready: missing.length === 0, missing, migration, mode: KEY_SHAPE.test(key) ? modeOf(key) : null };
123
132
  }
124
133
 
125
134
  export const MISSING_WORDS = Object.freeze({
126
135
  'shop.json': 'This studio has no shop.json: nothing is for sale (homie-studio shop init writes one).',
127
- 'kids-studio': 'This studio is made for children (studio.json "audience": "kids"): it sells nothing in its games.',
128
- 'shop.json-refused': 'shop.json breaks the kit\'s rules (homie-studio shop check says which), so nothing is sold.',
136
+ 'kids-studio': 'This studio is made for children (studio.json "audience": "kids"): its policy.kidsStudio setting closes the shop.',
137
+ 'shop.json-refused': 'shop.json conflicts with the studio settings or payment requirements (homie-studio shop check says which), so nothing is sold.',
129
138
  'till-off': 'shop.json says "till": "off".',
130
139
  items: 'shop.json has no items.',
131
140
  'restricted-key': 'The Worker has a full secret key: the shop takes only a restricted key (homie-studio shop connect).',
132
141
  'stripe-key': 'The Worker has no Stripe key yet (homie-studio shop connect: the owner pastes a restricted key into a page on their own computer).',
133
142
  'webhook-secret': 'The Worker has no webhook signing secret yet (the same page takes it).',
134
143
  database: 'This Worker has no database (a Preview never sells).',
135
- migration: 'The shop\'s tables are not in the studio\'s D1 yet: npm run deploy applies migration 0008_studio_shop.sql.',
144
+ 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.',
136
145
  });
137
146
 
138
147
  /** The server a request names (the play shell says which), with its kids and beginner flags. */
@@ -159,16 +168,18 @@ async function accountOf(request, env) {
159
168
  }
160
169
 
161
170
  /** Each item with how this player may get it, and the words for it. */
162
- async function itemsView(env, shop, { game, kids, beginner, player, open }) {
163
- const band = player && !player.guest ? await bandOfPlayer(env, player.id) : null;
171
+ async function itemsView(env, shop, { game, kids, beginner, player, open, cursor = 0, item = null, purchasing = false }) {
172
+ const band = player ? await bandOfPlayer(env, player.id) : null;
164
173
  const owns = player ? await ownsOf(env, player.id) : [];
165
- const spent = player && !player.guest ? await spentThisMonth(env, player.id) : 0;
166
- const items = itemsFor(shop, game).map((it) => {
174
+ const spent = !purchasing && player && open && shop.capPerPlayerMonth !== null ? await spentThisMonth(env, player.id) : 0;
175
+ const selected = item ? [item] : itemsFor(shop, game);
176
+ const page = item ? selected : selected.slice(cursor, cursor + 100);
177
+ const items = page.map((it) => {
167
178
  const owned = it.gives.length > 0 && it.gives.every((k) => owns.some((o) => o.key === k && o.item === it.id));
168
- const way = wayFor(it, { open, kids, beginner, player, band, owned, spent, cap: shop.capPerPlayerMonth ?? CAP_CEILING });
169
- return { id: it.id, kind: it.kind, name: it.name, blurb: it.blurb ?? null, price: it.price, ...(it.kind === 'tip' ? { min: it.min, max: it.max } : {}), shown: it.kind === 'tip' ? `${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 };
179
+ const way = wayFor(it, { open, kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
180
+ return { id: it.id, kind: it.kind, name: it.name, blurb: it.blurb ?? null, price: it.price, ...(it.kind === 'tip' ? { min: it.min, max: it.max } : {}), shown: it.kind === 'tip' ? `${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 }) } : {}) };
170
181
  }).filter((i) => i.way !== 'beginner' && i.way !== 'kids');
171
- return { band, owns, spent, items };
182
+ return { band, owns, spent, items, nextCursor: !item && cursor + 100 < selected.length ? String(cursor + 100) : null };
172
183
  }
173
184
 
174
185
  /* ------------------------------------------------------------------ routes */
@@ -184,11 +195,11 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
184
195
  if (path === '/shop') return Response.redirect(`${url.origin}/shop/${url.search}`, 301);
185
196
  const cat = await catalogueOf();
186
197
  const shop = shopOf(cat);
187
- // A kids studio has no shop at all: no page, no list, nothing to buy.
198
+ // The studio's protective policy closes kids studios.
188
199
  if (!shop || shop.audience === 'kids') {
189
200
  if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
190
201
  if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
191
- // No shop.json: /shop/ is the studio's own to use (a page in site/pages, say). A kids studio's is never a shop.
202
+ // No shop.json: /shop/ is the studio's own to use (a page in site/pages, say). The policy can close a kids studio's shop.
192
203
  return shop ? notFoundPage('This studio sells nothing.', cat) : null;
193
204
  }
194
205
  const ready = await readiness(env, shop);
@@ -199,18 +210,18 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
199
210
  if (path === '/api/player/owns' && read) return await ownsRoute(request, env, url, cat, shop);
200
211
  if (path === '/api/shop/mine' && read) return await mineRoute(request, env, shop);
201
212
  if (path === '/api/shop/order' && read) return await orderRoute(request, env, url);
202
- if (path === '/api/shop/age' && request.method === 'POST') return await ageRoute(request, env);
203
- if (path === '/api/shop/buy' && request.method === 'POST') return await buyRoute(request, env, url, cat, shop, ready);
213
+ if (path === '/api/shop/age' && request.method === 'POST') return await ageRoute(request, env, shop);
214
+ if (path === '/api/shop/buy' && request.method === 'POST') return await buyRoute(request, env, url, cat, shop, ready, ctx);
204
215
  if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
205
- if (path === '/api/shop/used' && request.method === 'POST') return await usedRoute(request, env);
216
+ if (path === '/api/shop/used' && request.method === 'POST') return await usedRoute(request, env, shop);
206
217
  if (path === '/api/shop/refund' && request.method === 'POST') return await selfRefundRoute(request, env, shop, ready);
207
218
  if (path === '/shop/' && read) return shopPage(cat, shop, { origin: url.origin, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null, item: url.searchParams.get('item'), open: ready.ready, mode: ready.mode });
208
219
  if (path === '/shop/thanks' && read) return thanksPage(cat, shop, { session: /^cs_[A-Za-z0-9_]{1,200}$/.test(url.searchParams.get('session_id') ?? '') ? url.searchParams.get('session_id') : null, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null });
209
220
  if ((path === '/shop/refunds/' || path === '/shop/refunds') && read) return refundsPage(cat, shop);
210
221
  const parent = /^\/shop\/parent\/([A-Za-z0-9_-]{43})$/.exec(path);
211
- if (parent) return await parentRoute(request, env, url, cat, shop, ready, parent[1]);
222
+ if (parent) return await parentRoute(request, env, url, cat, shop, ready, parent[1], ctx);
212
223
  } catch (error) {
213
- if (/no such table/i.test(String(error?.message))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
224
+ if (/no such table|no such column|has no column/i.test(String(error?.message))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
214
225
  throw error;
215
226
  }
216
227
  return path.startsWith('/api/') ? fail(404, 'not-found', 'no such shop route') : notFoundPage('Nothing here.', cat);
@@ -219,16 +230,16 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
219
230
  async function listRoute(request, env, url, cat, shop, ready) {
220
231
  const game = GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null;
221
232
  const facts = await serverFacts(env, cat, game, url.searchParams.get('server'));
222
- if (facts.kids) return json({ ok: true, open: false, kids: true, items: [] });
233
+ if (facts.kids && !shop.policy.kidsServer) return json({ ok: true, open: false, kids: true, items: [] });
223
234
  const player = await accountOf(request, env);
224
- const v = await itemsView(env, shop, { game, ...facts, player, open: ready.ready });
225
- // An account that said it is under 13 is a kids account from then on: no shop, nothing listed.
226
- if (v.band === 'child') return json({ ok: true, open: false, kids: true, items: [] });
235
+ const v = await itemsView(env, shop, { game, ...facts, player, open: ready.ready, item: shop.items.find((i) => i.id === url.searchParams.get('item') && (!game || !i.game || i.game === game)) ?? null, cursor: /^\d+$/.test(url.searchParams.get('cursor') ?? '') ? Number(url.searchParams.get('cursor')) : 0 });
236
+ // The studio decides which known age bands see no shop.
237
+ if ((v.band === 'child' && shop.policy.children === 'deny') || (v.band === 'teen' && shop.policy.teens === 'deny')) return json({ ok: true, open: false, kids: true, items: [] });
227
238
  return json({
228
- ok: true, open: ready.ready, ...(ready.ready ? {} : { missing: ready.missing }), mode: ready.mode,
229
- currency: shop.currency, till: shop.till, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, spent: v.spent,
239
+ ok: true, open: ready.ready, ...(ready.ready ? {} : { missing: ready.missing, migration: ready.migration }), mode: ready.mode,
240
+ 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,
230
241
  player: player ? { signedIn: !player.guest, guest: player.guest, name: player.name, band: v.band } : null,
231
- owns: v.owns.map((o) => o.key), items: v.items,
242
+ owns: v.owns.map((o) => o.key), items: v.items, nextCursor: v.nextCursor,
232
243
  });
233
244
  }
234
245
 
@@ -239,7 +250,7 @@ async function ownsRoute(request, env, url, cat, shop) {
239
250
  const game = GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null;
240
251
  const facts = await serverFacts(env, cat, game, url.searchParams.get('server'));
241
252
  const advantage = new Set((shop.items ?? []).filter((i) => i.advantage).map((i) => i.id));
242
- const owns = (await ownsOf(env, p.id)).filter((o) => !((facts.kids || facts.beginner) && advantage.has(o.item)));
253
+ const owns = (await ownsOf(env, p.id)).filter((o) => !(((facts.kids && !shop.policy.kidsAdvantages) || (facts.beginner && !shop.policy.beginnerAdvantages)) && advantage.has(o.item)));
243
254
  return json({ ok: true, open: Boolean(shop.open), owns: owns.map((o) => o.key), detail: owns.map((o) => ({ key: o.key, item: o.item, until: o.until })) });
244
255
  }
245
256
 
@@ -248,7 +259,7 @@ async function mineRoute(request, env, shop) {
248
259
  if (!p) return json({ ok: true, player: null, orders: [], owns: [] });
249
260
  const data = await shopDataOf(env, p.id);
250
261
  const names = new Map((shop.items ?? []).map((i) => [i.id, i.name]));
251
- return json({ ok: true, currency: shop.currency, refundDays: shop.refundDays, ageBand: data?.ageBand ?? null, orders: (data?.orders ?? []).map((o) => ({ ...o, name: names.get(o.item) ?? o.item, shown: money(o.amount, o.currency), refundable: refundableNow(o, data?.owns ?? [], shop) })).reverse(), owns: (data?.owns ?? []).map((o) => ({ key: o.key, item: o.item, until: o.until, used: o.used })), badges: (data?.owns ?? []).filter((o) => o.key.startsWith('badge:')).map((o) => (shop.items ?? []).find((i) => i.id === o.item)?.badge ?? o.key.slice(6)) });
262
+ return json({ ok: true, currency: shop.currency, refundDays: shop.refundDays, refundUsedItems: shop.policy.refundUsedItems, ageBand: data?.ageBand ?? null, orders: (data?.orders ?? []).map((o) => ({ ...o, name: names.get(o.item) ?? o.item, shown: money(o.amount, o.currency), refundable: refundableNow(o, data?.owns ?? [], shop) })).reverse(), owns: (data?.owns ?? []).map((o) => ({ key: o.key, item: o.item, until: o.until, used: o.used })), badges: (data?.owns ?? []).filter((o) => o.key.startsWith('badge:')).map((o) => (shop.items ?? []).find((i) => i.id === o.item)?.badge ?? o.key.slice(6)) });
252
263
  }
253
264
 
254
265
  /** The thanks page asks: is my order (of this session) paid yet? Only the buyer's own account sees it. */
@@ -258,22 +269,30 @@ async function orderRoute(request, env, url) {
258
269
  if (!p || !/^cs_[A-Za-z0-9_]{1,200}$/.test(session)) return json({ ok: true, status: null });
259
270
  const o = await orderBySession(env, session);
260
271
  if (!o || o.player !== p.id) return json({ ok: true, status: null });
261
- return json({ ok: true, status: o.status, item: o.item, game: o.game ?? null });
272
+ return json({ ok: true, status: playerOrderStatus(o), item: o.item, game: o.game ?? null });
262
273
  }
263
274
 
264
275
  /** The neutral age question: a year of birth, once. Nothing hints which answer opens anything; no default. */
265
- async function ageRoute(request, env) {
266
- const b = await bodyOf(request);
276
+ async function ageRoute(request, env, shop) {
277
+ const b = await bodyOf(request, requestSize(shop));
267
278
  if (b.error) return b.error;
268
- const p = await players.of(request, env);
269
- if (!p || p.guest) return fail(401, 'account', 'make an account first (a passkey)');
279
+ let p = await players.of(request, env);
280
+ let cookies = [];
281
+ const answer = bandOf(b.body.year, new Date(), shop.policy);
282
+ if (!answer) return fail(400, 'year', 'pick the year you were born');
283
+ if (!p && !shop.policy.requireAccount) {
284
+ const guest = await players.shopGuest(request, env, new URL(request.url), shop.guestBuyersPerAddressPerHour);
285
+ if (guest.error) return guest.error;
286
+ p = guest.session.player; cookies = guest.session.cookies;
287
+ }
288
+ if (!p || (p.guest && shop.policy.requireAccount)) return fail(401, 'account', 'The studio requires an account before its age question.');
270
289
  const before = await bandOfPlayer(env, p.id);
271
290
  if (before) return json({ ok: true, asked: true });
272
- const band = bandOf(b.body.year);
273
- if (!band) return fail(400, 'year', 'pick the year you were born');
274
- await setBand(env, p.id, band);
291
+ await setBand(env, p.id, answer);
275
292
  // The answer itself is never said back: the shop's list shows what this account may do.
276
- return json({ ok: true, asked: true });
293
+ const response = json({ ok: true, asked: true });
294
+ for (const cookie of cookies) response.headers.append('set-cookie', cookie);
295
+ return response;
277
296
  }
278
297
 
279
298
  export function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio, product = null }) {
@@ -288,34 +307,90 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
288
307
  price_data: {
289
308
  currency: shop.currency, unit_amount: amount, tax_behavior: 'exclusive',
290
309
  // The catalog's Product (its name and tax code are Stripe's copy of shop.json's), or the item described inline.
291
- ...(product ? { product } : { product_data: { name: item.name.slice(0, 120), ...(item.blurb ? { description: item.blurb.slice(0, 300) } : {}), ...(taxCode ? { tax_code: taxCode } : {}), metadata: { item: item.id } } }),
310
+ ...(product ? { product } : { product_data: { name: item.name, ...(item.blurb ? { description: item.blurb } : {}), ...(taxCode ? { tax_code: taxCode } : {}), metadata: { item: item.id.length <= 500 ? item.id : undefined } } }),
292
311
  },
293
312
  }],
294
313
  client_reference_id: player,
295
- metadata: { homie: 'shop-v1', studio: String(studio ?? '').slice(0, 60), order: order.id, item: item.id, ...(game ? { game } : {}), ...(order.via ? { via: order.via } : {}), ...(parent ? { parent: '1' } : {}) },
296
- payment_intent_data: { metadata: { order: order.id, item: item.id } },
314
+ // Stripe metadata values allow 500 characters; the order holds the complete item id.
315
+ // https://docs.stripe.com/metadata (fetched 2026-10-08).
316
+ metadata: { homie: 'shop-v1', 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' } : {}) },
317
+ payment_intent_data: { metadata: { order: order.id, item: item.id.length <= 500 ? item.id : undefined } },
297
318
  success_url: `${origin}/shop/thanks?session_id={CHECKOUT_SESSION_ID}${back}`,
298
319
  cancel_url: `${origin}/shop/?cancelled=1${back}`,
299
- expires_at: Math.floor(Date.now() / 1000) + 31 * 60,
320
+ // Stripe permits 30 minutes to 24 hours: https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-expires_at
321
+ expires_at: order.expiresAt ?? Math.floor((order.createdAt ?? Date.now()) / 1000) + 31 * 60,
300
322
  // The EU and UK immediate-delivery acknowledgement (Consumer Rights Directive Art 16(m)), beside the pay button.
301
- custom_text: { submit: { message: `${parent ? 'This is for your child\'s account. ' : ''}Delivered at once to the player's account on ${String(studio ?? 'this studio').slice(0, 60)}. By paying you ask for it now, which ends the 14-day withdrawal right; an unused item can still be refunded within ${shop.refundDays} days.`.slice(0, 1200) } },
323
+ custom_text: { submit: { message: checkoutMessage(shop, String(studio ?? 'this studio'), parent) } },
302
324
  // The kit's own tills: Stripe Tax on (the studio is the seller), or Managed Payments (Stripe is the seller of record
303
325
  // and computes the tax itself, so automatic_tax and adaptive pricing must not be sent).
304
326
  ...(managed ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: true }, adaptive_pricing: { enabled: false } }),
305
327
  };
306
328
  }
307
329
 
330
+ function logOrderError(env, order, error) {
331
+ const reason = String(error?.message ?? error).replaceAll(String(env.STRIPE_KEY || '\0'), '[redacted]');
332
+ console.warn('Shop reconciliation', { order, reason, status: error?.status ?? null, code: error?.code ?? null });
333
+ }
334
+
335
+ /** At most three reads in parallel, claimed before I/O. Old unresolved rows back off up to an hour.
336
+ * Stripe truth: https://docs.stripe.com/api/checkout/sessions/retrieve
337
+ * Missing sessions: https://docs.stripe.com/error-codes#resource-missing
338
+ * Payment time: https://docs.stripe.com/api/charges/object#charge_object-created
339
+ */
340
+ export async function reconcileOrders(env, shop, player) {
341
+ const now = Date.now();
342
+ const rows = (await env.DB.prepare("SELECT * FROM shop_orders WHERE player = ?1 AND mode = ?2 AND session IS NOT NULL AND status IN ('started', 'processing') AND (expires_at IS NULL OR expires_at <= ?3 OR status = 'processing') AND updated_at <= ?3 - MIN(3600000, MAX(CASE WHEN status = 'processing' THEN 300000 ELSE 60000 END, CAST((?3 - created_at) / 24 AS INTEGER))) ORDER BY updated_at, id LIMIT 3")
343
+ .bind(player, modeOf(env.STRIPE_KEY), now).all()).results ?? [];
344
+ await Promise.all(rows.map(async (row) => {
345
+ const claim = await env.DB.prepare("UPDATE shop_orders SET updated_at = ?3 WHERE id = ?1 AND updated_at = ?2 AND status IN ('started', 'processing')").bind(row.id, row.updated_at, now).run();
346
+ if (!claim.meta?.changes) return;
347
+ try {
348
+ const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
349
+ if (!session || session.id !== row.session || (typeof session.livemode === 'boolean' && session.livemode !== (row.mode === 'live'))) throw new Error('Stripe session identity or mode mismatch');
350
+ const payment = session.payment_intent;
351
+ if (session.payment_status === 'paid' || session.payment_status === 'no_payment_required' || payment?.status === 'succeeded') {
352
+ const time = session.payment_status === 'no_payment_required' ? session.created : payment?.latest_charge?.created;
353
+ // Missing payment time remains unresolved; never charge an old payment to this month's allowance.
354
+ if (Number.isSafeInteger(time) && time > 0 && time * 1000 <= now) await paid(env, shop, session, time * 1000);
355
+ } else if (session.status === 'expired' || payment?.status === 'canceled' || (session.status === 'complete' && payment?.status === 'requires_payment_method' && payment.last_payment_error)) {
356
+ await env.DB.prepare("UPDATE shop_orders SET status = ?2, updated_at = ?3 WHERE id = ?1 AND status IN ('started', 'processing')").bind(row.id, session.status === 'expired' ? 'expired' : 'failed', now).run();
357
+ }
358
+ } catch (error) {
359
+ logOrderError(env, row.id, error);
360
+ // This key's account cannot see the session; a later verified payment still grants it.
361
+ if (error instanceof StripeError && error.status === 404 && error.code === 'resource_missing') {
362
+ await env.DB.prepare("UPDATE shop_orders SET status = 'missing', note = 'Stripe account has no such session; previous status: ' || status, updated_at = ?2 WHERE id = ?1 AND status IN ('started', 'processing')").bind(row.id, now).run();
363
+ } else {
364
+ // Keep the atomic claim, but retry failures after roughly a minute, regardless of row age.
365
+ const interval = Math.min(3600000, Math.max(row.status === 'processing' ? 300000 : 60000, Math.trunc((now - row.created_at) / 24)));
366
+ await env.DB.prepare("UPDATE shop_orders SET updated_at = ?3 WHERE id = ?1 AND updated_at = ?2 AND status IN ('started', 'processing')").bind(row.id, now, now - interval + 60000).run();
367
+ }
368
+ }
369
+ }).map((work, i) => work.catch((error) => logOrderError(env, rows[i].id, error))));
370
+ }
371
+
308
372
  /** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
309
373
  async function startCheckout(env, url, cat, shop, item, { player, game, via, amount, parent = false }) {
374
+ if (via && !Number.isSafeInteger(Math.round(amount * shop.referrals.rate))) return { error: fail(400, 'arithmetic', 'The referral share exceeds safe integer arithmetic; change the studio rate or amount.') };
310
375
  const order = { id: newOrderId(), player: player.id, via: via ?? null, amount, currency: shop.currency };
311
376
  const now = Date.now();
312
- await env.DB.prepare("INSERT INTO shop_orders (id, player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'started', ?9, ?10, ?11, ?11)")
313
- .bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, modeOf(env.STRIPE_KEY), parent ? 1 : 0, order.via, now).run();
377
+ order.createdAt = now;
378
+ order.expiresAt = Math.floor(now / 1000) + 31 * 60;
379
+ // One SQL statement reserves the amount: concurrent requests cannot both spend the remaining cap.
380
+ const reserve = () => 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), 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))))")
381
+ .bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, modeOf(env.STRIPE_KEY), parent ? 1 : 0, order.via, now, shop.capPerPlayerMonth, Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1), order.expiresAt * 1000).run();
382
+ let reserved = await reserve();
383
+ const reconciled = !reserved.meta?.changes;
384
+ if (!reserved.meta?.changes) {
385
+ await reconcileOrders(env, shop, player.id);
386
+ reserved = await reserve();
387
+ }
388
+ if (!reserved.meta?.changes) return { error: fail(403, 'cap', WAY_WORDS.cap, { way: 'cap' }) };
314
389
  let session = null;
315
390
  const mode = modeOf(env.STRIPE_KEY);
316
- const product = Array.isArray(shop.catalog) && shop.catalog.includes(mode) ? productIdOf(cat.studio?.slug, item.id) : null;
317
- const params = (p) => sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, product: p });
318
391
  try {
392
+ const product = Array.isArray(shop.catalog) && shop.catalog.includes(mode) ? await productIdOf(cat.studio?.slug, item.id) : null;
393
+ const params = (p) => sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, product: p });
319
394
  try {
320
395
  session = await createCheckoutSession(env, params(product), { idempotencyKey: `checkout-${order.id}` });
321
396
  } catch (error) {
@@ -324,16 +399,17 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
324
399
  if (!product || !isMissingProduct(error)) throw error;
325
400
  session = await createCheckoutSession(env, params(null), { idempotencyKey: `checkout-${order.id}-inline` });
326
401
  }
402
+ if (typeof session?.id !== 'string' || typeof session?.url !== 'string' || !/^https:\/\//.test(session.url) && !/^http:\/\/(127\.0\.0\.1|localhost)/.test(session.url)) {
403
+ await env.DB.prepare("UPDATE shop_orders SET status = 'failed', updated_at = ?2 WHERE id = ?1").bind(order.id, Date.now()).run();
404
+ return { error: fail(502, 'stripe', 'Stripe answered without a checkout page. Nothing was charged.') };
405
+ }
406
+ await env.DB.prepare('UPDATE shop_orders SET session = ?2, updated_at = ?3 WHERE id = ?1').bind(order.id, session.id, Date.now()).run();
327
407
  } catch (error) {
328
408
  await env.DB.prepare("UPDATE shop_orders SET status = 'failed', note = ?2, updated_at = ?3 WHERE id = ?1").bind(order.id, String(error?.code ?? 'stripe').slice(0, 60), Date.now()).run();
329
409
  const why = error instanceof StripeError ? error.message : 'Stripe did not answer';
330
410
  return { error: fail(502, 'stripe', `Stripe could not open the checkout (${why}). Nothing was charged.`) };
331
411
  }
332
- if (typeof session?.id !== 'string' || typeof session?.url !== 'string' || !/^https:\/\//.test(session.url) && !/^http:\/\/(127\.0\.0\.1|localhost)/.test(session.url)) {
333
- return { error: fail(502, 'stripe', 'Stripe answered without a checkout page. Nothing was charged.') };
334
- }
335
- await env.DB.prepare('UPDATE shop_orders SET session = ?2, updated_at = ?3 WHERE id = ?1').bind(order.id, session.id, Date.now()).run();
336
- return { order, url: session.url, session: session.id };
412
+ return { order, url: session.url, session: session.id, reconciled };
337
413
  }
338
414
 
339
415
  /** The referrer this buyer brought, if it still counts: their arrival, a new account since it, in the window, proven. */
@@ -341,60 +417,74 @@ async function viaFor(request, env, url, shop, player) {
341
417
  if (!shop.referrals || !(shop.referrals.rate > 0)) return null;
342
418
  const a = await arrivalOf(request, env);
343
419
  if (!a || a.via === url.hostname.replace(/^www\./, '')) return null;
344
- if (Date.now() - a.at > shop.referrals.windowDays * DAY) return null;
420
+ if (shop.referrals.windowDays !== null && Date.now() - a.at > Math.round(shop.referrals.windowDays * DAY)) return null;
345
421
  // New players only: an account made before the arrival was already this studio's player.
346
422
  if (player.since !== null && player.since < a.at - 60_000) return null;
347
423
  return (await referrerOk(a.via)) ? a.via : null;
348
424
  }
349
425
 
350
426
  const WAY_WORDS = Object.freeze({
351
- closed: 'The shop is not open yet.', kids: 'Nothing is sold on a kids server.', beginner: 'Not sold on a beginner server.',
427
+ 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.',
352
428
  owned: 'You have it already.', 'make-an-account': 'Make an account first (a passkey), so what you buy stays yours on every device.',
353
429
  'age-question': 'One question first: the year you were born.', no: 'This shop sells nothing to this account.',
354
- 'ask-a-parent': 'A parent or guardian buys this for you: send them the link.', cap: 'That is this month\'s limit for this account here.',
430
+ 'ask-a-parent': 'A parent or guardian buys this for you: send them the link.', cap: 'That reaches the studio capPerPlayerMonth setting for this account.',
355
431
  'not-yet': 'Not on sale yet.', over: 'No longer on sale.',
356
432
  });
357
433
 
358
- async function buyRoute(request, env, url, cat, shop, ready) {
359
- const b = await bodyOf(request);
434
+ function purchaseLimited(request, shop, player, kind) {
435
+ const account = player && limited(`${kind}:account:${player}`, shop.purchaseAttemptsPerMinute, 60_000);
436
+ const address = limited(`${kind}:address:${addressOf(request)}`, shop.purchaseAttemptsPerAddressPerMinute, 60_000);
437
+ return account || address;
438
+ }
439
+
440
+ async function buyRoute(request, env, url, cat, shop, ready, ctx) {
441
+ const b = await bodyOf(request, requestSize(shop));
360
442
  if (b.error) return b.error;
361
- if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing });
443
+ if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
362
444
  const item = shop.items.find((i) => i.id === b.body.item);
363
445
  if (!item) return fail(404, 'item', 'no such item here');
364
446
  const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
365
447
  if (item.game && game && item.game !== game) return fail(400, 'item', 'that item belongs to another game');
366
448
  const facts = await serverFacts(env, cat, game, b.body.server);
367
- const player = await accountOf(request, env);
368
- if (player && limited(`buy:${player.id}`, BUYS_PER_MINUTE, 60_000)) return fail(429, 'rate', 'one moment: too many tries');
369
- const v = await itemsView(env, shop, { game, ...facts, player, open: true });
449
+ let player = await accountOf(request, env);
450
+ if (purchaseLimited(request, shop, player?.id, 'buy')) return fail(429, 'rate', 'The studio purchase attempt rate limit was reached; try again in a minute.');
451
+ const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
370
452
  const row = v.items.find((i) => i.id === item.id);
371
- const way = facts.kids ? 'kids' : row?.way ?? (item.advantage && facts.beginner ? 'beginner' : 'closed');
453
+ const way = facts.kids && !shop.policy.kidsServer ? 'kids' : row?.way ?? (item.advantage && facts.beginner ? 'beginner' : 'closed');
372
454
  if (way !== 'checkout') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
373
455
  let amount = item.price;
374
456
  if (item.kind === 'tip') {
375
- amount = Math.floor(Number(b.body.amount));
376
- if (!(amount >= item.min && amount <= item.max)) return fail(400, 'amount', `a tip is ${money(item.min, shop.currency)} to ${money(item.max, shop.currency)}`);
377
- if (v.spent + amount > shop.capPerPlayerMonth) return fail(403, 'cap', WAY_WORDS.cap, { way: 'cap' });
457
+ amount = typeof b.body.amount === 'number' ? b.body.amount : NaN;
458
+ if (amountError(amount, shop.currency) || !(amount >= item.min && (item.max === null || amount <= item.max))) return fail(400, 'amount', amountError(amount, shop.currency) ?? `The studio tip range starts at ${money(item.min, shop.currency)}${item.max === null ? '' : ` and ends at ${money(item.max, shop.currency)}`}.`);
459
+ }
460
+ let cookies = [];
461
+ if (!player) {
462
+ const guest = await players.shopGuest(request, env, url, shop.guestBuyersPerAddressPerHour);
463
+ if (guest.error) return guest.error;
464
+ player = guest.session.player;
465
+ cookies = guest.session.cookies;
378
466
  }
379
467
  const via = await viaFor(request, env, url, shop, player);
380
468
  const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount });
381
- if (r.error) return r.error;
382
- return json({ ok: true, order: r.order.id, url: r.url });
469
+ const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url });
470
+ if (!r.error && !r.reconciled && shop.capPerPlayerMonth !== null && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
471
+ for (const cookie of cookies) response.headers.append('set-cookie', cookie);
472
+ return response;
383
473
  }
384
474
 
385
475
  /** 13-17: a one-time link for a parent (7 days). The teen passes it on; nothing here pressures anyone. */
386
476
  async function parentLinkRoute(request, env, url, cat, shop, ready) {
387
- const b = await bodyOf(request);
477
+ const b = await bodyOf(request, requestSize(shop));
388
478
  if (b.error) return b.error;
389
- if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing });
479
+ if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
390
480
  const item = shop.items.find((i) => i.id === b.body.item);
391
- if (!item || item.kind === 'tip') return fail(404, 'item', 'no such item here');
481
+ if (!item) return fail(404, 'item', 'no such item here');
392
482
  const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
393
483
  const facts = await serverFacts(env, cat, game, b.body.server);
394
484
  const player = await accountOf(request, env);
395
- if (player && limited(`parent:${player.id}`, BUYS_PER_MINUTE, 60_000)) return fail(429, 'rate', 'one moment: too many tries');
396
- const v = await itemsView(env, shop, { game, ...facts, player, open: true });
397
- const way = facts.kids ? 'kids' : v.items.find((i) => i.id === item.id)?.way ?? 'closed';
485
+ if (purchaseLimited(request, shop, player?.id, 'parent')) return fail(429, 'rate', 'The studio purchase attempt rate limit was reached; try again in a minute.');
486
+ const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
487
+ const way = facts.kids && !shop.policy.kidsServer ? 'kids' : v.items.find((i) => i.id === item.id)?.way ?? 'closed';
398
488
  if (way !== 'ask-a-parent') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
399
489
  const token = randomToken();
400
490
  await env.DB.prepare('INSERT INTO shop_parent_links (hash, player, item, game, order_id, expires_at) VALUES (?1, ?2, ?3, ?4, NULL, ?5)').bind(await sha256Hex(token), player.id, item.id, game, Date.now() + 7 * DAY).run();
@@ -402,7 +492,7 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
402
492
  }
403
493
 
404
494
  /** /shop/parent/<token>: what, for whom, the price; the parent pays in their own name on Stripe's page. */
405
- async function parentRoute(request, env, url, cat, shop, ready, token) {
495
+ async function parentRoute(request, env, url, cat, shop, ready, token, ctx) {
406
496
  if (!TOKEN.test(token)) return notFoundPage('This link does not work.', cat);
407
497
  const row = await env.DB.prepare('SELECT * FROM shop_parent_links WHERE hash = ?1').bind(await sha256Hex(token)).first();
408
498
  const item = row ? shop.items.find((i) => i.id === row.item) : null;
@@ -417,51 +507,56 @@ async function parentRoute(request, env, url, cat, shop, ready, token) {
417
507
  try { form = await request.formData(); } catch { form = null; }
418
508
  if (String(form?.get('grownup') ?? '') !== 'yes') return parentPage(cat, shop, { item, name: kid.name, token, open: ready.ready, said: 'Tick the box to say you are their parent or guardian and an adult.' });
419
509
  if (!ready.ready) return parentPage(cat, shop, { item, name: kid.name, token, open: false });
420
- if (limited(`parentpay:${row.player}`, BUYS_PER_MINUTE, 60_000)) return parentPage(cat, shop, { item, name: kid.name, token, open: true, said: 'One moment: too many tries.' });
510
+ if (purchaseLimited(request, shop, row.player, 'parentpay')) return parentPage(cat, shop, { item, name: kid.name, token, open: true, said: 'One moment: too many tries.' });
421
511
  const band = await bandOfPlayer(env, row.player);
422
- const spent = await spentThisMonth(env, row.player);
423
- if (band !== 'teen' || spent + item.price > shop.capPerPlayerMonth) return parentPage(cat, shop, { gone: true });
424
- const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game, via: null, amount: item.price, parent: true });
425
- if (r.error) return parentPage(cat, shop, { item, name: kid.name, token, open: true, said: 'Stripe could not open the checkout just now. Nothing was charged; try again in a minute.' });
512
+ const owns = await ownsOf(env, row.player);
513
+ const owned = item.gives.length > 0 && item.gives.every((key) => owns.some((o) => o.key === key && o.item === item.id));
514
+ if (wayFor(item, { player: kid, band, owned, spent: 0, cap: null, policy: shop.policy }) !== 'ask-a-parent') return parentPage(cat, shop, { gone: true });
515
+ const amount = item.kind === 'tip' ? parseAmount(form?.get('amount'), currencyScale(shop.currency)) : item.price;
516
+ const invalidAmount = amountError(amount, shop.currency);
517
+ if (invalidAmount || (item.kind === 'tip' && (amount < item.min || (item.max !== null && amount > item.max)))) return parentPage(cat, shop, { item, name: kid.name, token, said: invalidAmount ?? 'This amount exceeds the studio tip range or monthly cap.' });
518
+ const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game, via: null, amount, parent: true });
519
+ if (r.error) return parentPage(cat, shop, { item, name: kid.name, token, open: true, said: (await r.error.json()).message });
426
520
  await env.DB.prepare('UPDATE shop_parent_links SET order_id = ?2 WHERE hash = ?1').bind(await sha256Hex(token), r.order.id).run();
521
+ if (!r.reconciled && shop.capPerPlayerMonth !== null && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, row.player).catch((error) => logOrderError(env, r.order.id, error)));
427
522
  return Response.redirect(r.url, 303);
428
523
  }
429
524
 
430
525
  /** The game says the player used something (equipped a skin, played the pass): it is no longer refundable by the player. */
431
- async function usedRoute(request, env) {
432
- const b = await bodyOf(request);
526
+ async function usedRoute(request, env, shop) {
527
+ const b = await bodyOf(request, requestSize(shop));
433
528
  if (b.error) return b.error;
434
529
  const p = await players.of(request, env);
435
530
  if (!p) return json({ ok: true, used: 0 });
436
531
  const key = String(b.body.key ?? '');
437
- if (!/^[a-z0-9][a-z0-9_.:-]{0,63}$/.test(key)) return fail(400, 'key', 'name the key the game read');
532
+ if (!ENTITLEMENT_KEY.test(key)) return fail(400, 'key', 'name the key the game read');
438
533
  const r = await env.DB.prepare("UPDATE entitlements SET used_at = ?3 WHERE player = ?1 AND key = ?2 AND used_at IS NULL AND state = 'active'").bind(p.id, key, Date.now()).run();
439
534
  return json({ ok: true, used: Number(r?.meta?.changes ?? 0) });
440
535
  }
441
536
 
442
537
  function refundableNow(order, owns, shop, now = Date.now()) {
443
- if (order.status !== 'paid' || order.paidAt === null) return false;
444
- if (now - order.paidAt > shop.refundDays * DAY) return false;
445
- return !owns.some((o) => o.item === order.item && o.used);
538
+ if (shop.refundDays === null || order.status !== 'paid' || order.paidAt === null) return false;
539
+ if (now - order.paidAt > Math.round(shop.refundDays * DAY)) return false;
540
+ return shop.policy.refundUsedItems || !owns.some((o) => o.item === order.item && o.used);
446
541
  }
447
542
 
448
543
  /** A player's own refund: an unused item, within the studio's refund days (fewer disputes, and the respected norm). */
449
544
  async function selfRefundRoute(request, env, shop, ready) {
450
- const b = await bodyOf(request);
545
+ const b = await bodyOf(request, requestSize(shop));
451
546
  if (b.error) return b.error;
452
547
  const p = await players.of(request, env);
453
- if (!p || p.guest) return fail(401, 'account', 'sign in first');
548
+ if (!p) return fail(401, 'account', 'sign in first');
454
549
  const o = await orderById(env, b.body.order);
455
550
  if (!o || o.player !== p.id) return fail(404, 'order', 'no such order on this account');
456
551
  const used = await env.DB.prepare('SELECT COUNT(*) AS n FROM entitlements WHERE order_id = ?1 AND used_at IS NOT NULL').bind(o.id).first();
457
- if (!refundableNow(orderView(o), Number(used?.n) ? [{ item: o.item, used: true }] : [], shop)) return fail(403, 'not-refundable', `Only an unused item, within ${shop.refundDays} days. Ask the studio for anything else.`);
458
- if (!KEY_SHAPE.test(String(env.STRIPE_KEY ?? ''))) return fail(503, 'closed', MISSING_WORDS['stripe-key']);
552
+ if (!refundableNow(orderView(o), Number(used?.n) ? [{ item: o.item, used: true }] : [], shop)) return fail(403, 'not-refundable', shop.refundDays === null ? 'Ask the studio for a refund; it has not set a self-service refund window.' : `The studio allows self-service refunds for ${shop.policy.refundUsedItems ? 'items' : 'unused items'} within ${shop.refundDays} days. Ask the studio for anything else.`);
553
+ if (Number(o.amount) !== 0 && !KEY_SHAPE.test(String(env.STRIPE_KEY ?? ''))) return fail(503, 'closed', MISSING_WORDS['stripe-key']);
459
554
  const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player' });
460
555
  if (r.held) return json({ ok: false, held: true, error: 'held', message: 'The studio approves this refund in Stripe first. Your money comes back once they do, and the item leaves your account then.' }, 202);
461
556
  return json(r, r.ok ? 200 : 502);
462
557
  }
463
558
 
464
- /* ------------------------------------------------------------------ the webhook: the only writer of money states */
559
+ /* ------------------------------------------------------------------ Stripe events and reconciliation share idempotent money transitions */
465
560
 
466
561
  async function hook(request, env, cat, shop, ready) {
467
562
  if (request.method !== 'POST') return fail(405, 'method', 'POST only');
@@ -479,11 +574,15 @@ async function hook(request, env, cat, shop, ready) {
479
574
  switch (ev.type) {
480
575
  case 'checkout.session.completed':
481
576
  case 'checkout.session.async_payment_succeeded':
482
- did = obj.payment_status === 'paid' || obj.payment_status === 'no_payment_required' ? await paid(env, shop, obj) : 'waiting';
577
+ if (ev.type === 'checkout.session.async_payment_succeeded' || obj.payment_status === 'paid' || obj.payment_status === 'no_payment_required') did = await paid(env, shop, obj);
578
+ else {
579
+ await env.DB.prepare("UPDATE shop_orders SET status = 'processing', updated_at = ?2 WHERE session = ?1 AND status = 'started'").bind(String(obj.id ?? ''), Date.now()).run();
580
+ did = 'waiting';
581
+ }
483
582
  break;
484
583
  case 'checkout.session.async_payment_failed':
485
584
  case 'checkout.session.expired':
486
- await env.DB.prepare('UPDATE shop_orders SET status = ?2, updated_at = ?3 WHERE session = ?1 AND status = \'started\'').bind(String(obj.id ?? ''), ev.type.endsWith('expired') ? 'expired' : 'failed', Date.now()).run();
585
+ await env.DB.prepare('UPDATE shop_orders SET status = ?2, updated_at = ?3 WHERE session = ?1 AND status IN (\'started\', \'processing\', \'missing\', \'released\')').bind(String(obj.id ?? ''), ev.type.endsWith('expired') ? 'expired' : 'failed', Date.now()).run();
487
586
  did = 'closed';
488
587
  break;
489
588
  case 'charge.refunded':
@@ -491,7 +590,11 @@ async function hook(request, env, cat, shop, ready) {
491
590
  break;
492
591
  case 'refund.created':
493
592
  case 'refund.updated':
494
- if (obj.status === 'succeeded') did = await refunded(env, String(obj.payment_intent ?? ''), String(obj.id ?? ''));
593
+ // Individual refunds may be partial. charge.refunded supplies the cumulative full-refund truth.
594
+ if (obj.status === 'succeeded') {
595
+ const order = await orderByPayment(env, String(obj.payment_intent ?? ''));
596
+ if (order && Number(obj.amount) >= Number(order.total ?? order.amount)) did = await refunded(env, String(obj.payment_intent ?? ''), String(obj.id ?? ''));
597
+ }
495
598
  break;
496
599
  case 'charge.dispute.created':
497
600
  did = await disputed(env, String(obj.payment_intent ?? ''), String(obj.id ?? ''));
@@ -508,17 +611,17 @@ async function hook(request, env, cat, shop, ready) {
508
611
  }
509
612
 
510
613
  /** A paid checkout: the order is paid, its entitlements granted, a referral line written. Idempotent. */
511
- async function paid(env, shop, session) {
614
+ async function paid(env, shop, session, paidAt = Date.now()) {
512
615
  const o = await orderBySession(env, session.id);
513
616
  if (!o) return 'unknown-order';
514
617
  // The session must be the one this order opened, for this player and this price.
515
618
  if (session.metadata?.order !== o.id || (session.client_reference_id && session.client_reference_id !== o.player)) return 'mismatch';
516
- if (session.currency && session.currency === o.currency && Number.isFinite(Number(session.amount_subtotal)) && Number(session.amount_subtotal) !== Number(o.amount)) return 'mismatch';
517
- if (o.status === 'paid' || o.status === 'refunded' || o.status === 'disputed') return 'already';
619
+ if (session.currency !== o.currency || !Number.isSafeInteger(session.amount_subtotal) || session.amount_subtotal !== Number(o.amount)) return 'mismatch';
620
+ if (o.paid_at !== null) return 'already';
518
621
  const item = shop.items.find((i) => i.id === o.item);
519
622
  const now = Date.now();
520
- const res = await env.DB.prepare("UPDATE shop_orders SET status = 'paid', payment = ?2, tax = ?3, total = ?4, paid_at = ?5, updated_at = ?5 WHERE id = ?1 AND status IN ('started', 'failed', 'expired')")
521
- .bind(o.id, typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id ?? null, Number.isFinite(Number(session.total_details?.amount_tax)) ? Number(session.total_details.amount_tax) : null, Number.isFinite(Number(session.amount_total)) ? Number(session.amount_total) : null, now).run();
623
+ const res = await 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")
624
+ .bind(o.id, typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id ?? null, Number.isFinite(Number(session.total_details?.amount_tax)) ? Number(session.total_details.amount_tax) : null, Number.isFinite(Number(session.amount_total)) ? Number(session.amount_total) : null, paidAt, now).run();
522
625
  if (!Number(res?.meta?.changes ?? 1)) return 'already';
523
626
  const writes = [];
524
627
  if (o.player && item) writes.push(...grantStatements(env, o, item, now));
@@ -542,7 +645,7 @@ async function refunded(env, payment, refundId) {
542
645
  return 'refunded';
543
646
  }
544
647
 
545
- /** A dispute changes nothing until it is decided: the player keeps the item and their account (FTC v. Epic). */
648
+ /** A dispute changes nothing until it is decided: the player keeps the item and their account. */
546
649
  async function disputed(env, payment, disputeId) {
547
650
  const o = payment ? await orderByPayment(env, payment) : null;
548
651
  if (!o) return 'unknown-order';
@@ -576,6 +679,11 @@ export const REFUND_HELD = 'Stripe is holding this refund until a person approve
576
679
  /** Refund one order in full with Stripe, then take back that one item. The webhook confirms the same (idempotent). */
577
680
  export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner' } = {}) {
578
681
  if (o.status !== 'paid') return { ok: false, error: 'state', message: o.status === 'disputed' ? 'This order is disputed: the card network decides it now (answer it in Stripe).' : `This order is ${o.status}, not paid.` };
682
+ if (Number(o.amount) === 0) {
683
+ const now = Date.now();
684
+ await env.DB.batch([env.DB.prepare("UPDATE shop_orders SET status = 'refunded', refunded_at = ?2, updated_at = ?2 WHERE id = ?1 AND status = 'paid'").bind(o.id, now), revokeStatement(env, o.id), ...voidLineStatements(env, o.id)]);
685
+ return { ok: true, order: o.id, amount: 0, currency: o.currency, status: 'succeeded', refund: null };
686
+ }
579
687
  if (!o.payment) return { ok: false, error: 'no-payment', message: 'Stripe has not said which payment this was yet; try again in a minute.' };
580
688
  let refund = null;
581
689
  try {
@@ -604,9 +712,31 @@ export function checkRefund(body) {
604
712
  const note = String(body.note ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim().slice(0, 140) || null;
605
713
  return { ok: true, action: { op: 'refund', order: body.order, reason, note } };
606
714
  }
715
+ /** Explicit owner override of an unresolved reservation; the existing note records its actor and time. */
716
+ export function checkRelease(body) {
717
+ const checked = checkRefund(body);
718
+ return checked.ok ? { ok: true, action: { op: 'shop-release', order: body.order } } : checked;
719
+ }
720
+ export async function performRelease(env, a) {
721
+ const row = await orderById(env, a.order);
722
+ if (!row || !['started', 'processing'].includes(row.status)) return { ok: false, error: 'state', message: 'Only an unresolved order can be released.' };
723
+ // https://docs.stripe.com/api/checkout/sessions/expire — only open sessions can expire.
724
+ if (row.session) {
725
+ try {
726
+ const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, null, { timeout: 2000 });
727
+ if (session?.id === row.session && session.status === 'open') await expireCheckoutSession(env, row.session, { timeout: 2000 });
728
+ } catch (error) { logOrderError(env, row.id, error); }
729
+ }
730
+ const at = Date.now();
731
+ const note = `Reservation released by ${a.by} at ${new Date(at).toISOString()}`;
732
+ const result = await env.DB.prepare("UPDATE shop_orders SET status = 'released', note = ?2 || '; previous status: ' || status, updated_at = ?3 WHERE id = ?1 AND status IN ('started', 'processing')").bind(a.order, note, at).run();
733
+ 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.' };
734
+ }
735
+
607
736
  export function checkSettle(body) {
608
- if (!/^[a-z0-9.-]{3,80}$/.test(String(body.via ?? ''))) return { ok: false, error: 'bad-request', message: 'via is the referrer\'s host (as the shop page shows it)' };
609
- return { ok: true, action: { op: 'shop-settle', via: String(body.via), ref: String(body.ref ?? '').slice(0, 80) } };
737
+ 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)' };
738
+ if (!/^[a-z]{3}$/.test(body.currency ?? '')) return { ok: false, error: 'currency', message: 'Choose the currency to mark paid.' };
739
+ return { ok: true, action: { op: 'shop-settle', currency: body.currency, via: String(body.via), ref: String(body.ref ?? '').slice(0, 80) } };
610
740
  }
611
741
 
612
742
  /** The refund in plain words, for the owner's confirm page and the AI's card. */
@@ -625,13 +755,13 @@ export async function performRefund(env, cat, a) {
625
755
  }
626
756
 
627
757
  /** Everything the office's shop page shows. Money is read from this studio's D1; Stripe's own pages are linked. */
628
- export async function shopOffice(env, cat, origin) {
758
+ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0, orderCursor = 0) {
629
759
  const shop = shopOf(cat);
630
760
  const ready = await readiness(env, shop);
631
761
  const mode = ready.mode ?? 'test';
632
762
  const out = {
633
- ok: true, ready: ready.ready, missing: ready.missing.map((m) => ({ id: m, words: MISSING_WORDS[m] ?? m })), mode: ready.mode,
634
- shop: shop ? { till: shop.till, currency: shop.currency, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, items: shop.items.map((i) => ({ id: i.id, kind: i.kind, name: i.name, price: i.price, shown: i.kind === 'tip' ? `${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,
763
+ 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,
764
+ 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.kind === 'tip' ? `${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,
635
765
  stripe: {
636
766
  payments: dashboardLink(mode, 'payments'), refunds: dashboardLink(mode, 'refunds'), disputes: dashboardLink(mode, 'disputes'), payouts: dashboardLink(mode, 'payouts'),
637
767
  balance: dashboardLink(mode, 'balance'), tax: dashboardLink(mode, 'tax'), keys: dashboardLink(mode, 'keys'), webhooks: dashboardLink(mode, 'webhooks'),
@@ -644,14 +774,18 @@ export async function shopOffice(env, cat, origin) {
644
774
  const since = Date.now() - 30 * DAY;
645
775
  const t = await env.DB.prepare("SELECT SUM(CASE WHEN status IN ('paid', 'disputed') THEN amount ELSE 0 END) AS paid, SUM(CASE WHEN status IN ('paid', 'disputed') THEN 1 ELSE 0 END) AS sales, SUM(CASE WHEN status = 'refunded' THEN amount ELSE 0 END) AS refunded, SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) AS refunds, SUM(CASE WHEN status = 'disputed' THEN 1 ELSE 0 END) AS disputes, SUM(CASE WHEN status = 'lost' THEN 1 ELSE 0 END) AS lost FROM shop_orders WHERE created_at >= ?1").bind(since).first();
646
776
  out.totals = { days: 30, currency: shop?.currency ?? 'usd', paid: Number(t?.paid) || 0, sales: Number(t?.sales) || 0, refunded: Number(t?.refunded) || 0, refunds: Number(t?.refunds) || 0, disputes: Number(t?.disputes) || 0, lost: Number(t?.lost) || 0 };
647
- const rows = (await env.DB.prepare("SELECT o.*, p.name AS player_name FROM shop_orders o LEFT JOIN players p ON p.id = o.player WHERE o.status != 'started' OR o.created_at >= ?1 ORDER BY o.created_at DESC LIMIT 100").bind(Date.now() - DAY).all()).results ?? [];
777
+ const rows = (await env.DB.prepare("SELECT o.*, p.name AS player_name FROM shop_orders o LEFT JOIN players p ON p.id = o.player ORDER BY CASE WHEN o.status IN ('started', 'processing') THEN 0 ELSE 1 END, o.created_at DESC, o.id LIMIT 101 OFFSET ?1").bind(orderCursor).all()).results ?? [];
778
+ out.nextOrdersCursor = rows.length > 100 ? String(orderCursor + 100) : null;
779
+ if (rows.length > 100) rows.pop();
648
780
  const names = new Map((shop?.items ?? []).map((i) => [i.id, i.name]));
649
781
  out.orders = rows.map((r) => ({
650
- ...orderView(r), name: names.get(r.item) ?? r.item, shown: money(r.amount, r.currency), player: r.player ? { id: r.player, name: r.player_name ?? null } : null,
782
+ ...orderView(r), note: r.note ?? null, name: names.get(r.item) ?? r.item, shown: money(r.amount, r.currency), player: r.player ? { id: r.player, name: r.player_name ?? null } : null,
651
783
  stripe: r.payment ? dashboardLink(r.mode, 'payment', r.payment) : null, dispute: r.dispute ? dashboardLink(r.mode, 'dispute', r.dispute) : null,
652
- refundable: r.status === 'paid' && Boolean(r.payment),
784
+ releasable: ['started', 'processing'].includes(r.status),
785
+ refundable: r.status === 'paid' && (Number(r.amount) === 0 || Boolean(r.payment)),
653
786
  }));
654
- out.referrals = { owe: await booksOf(env), owedToUs: await statementsIn(env), invoice: dashboardLink(mode, 'invoices') };
787
+ const { books, nextCursor } = await booksOf(env, { cursor });
788
+ out.referrals = { nextCursor, owe: books, failures: await statementFailures(env), owedToUs: await statementsIn(env), invoice: dashboardLink(mode, 'invoices') };
655
789
  return out;
656
790
  }
657
791
 
@@ -661,25 +795,23 @@ export async function ordersCsv(env) {
661
795
  const cell = (v) => { const s = v === null || v === undefined ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
662
796
  const head = ['order', 'created', 'paid', 'refunded', 'status', 'item', 'game', 'amount', 'tax', 'total', 'currency', 'till', 'mode', 'parent', 'via', 'stripe_payment', 'stripe_refund', 'stripe_dispute'];
663
797
  const iso = (t) => (t ? new Date(Number(t)).toISOString() : '');
664
- const lines = rows.map((r) => [r.id, iso(r.created_at), iso(r.paid_at), iso(r.refunded_at), r.status, r.item, r.game, (Number(r.amount) / 100).toFixed(2), r.tax === null ? '' : (Number(r.tax) / 100).toFixed(2), r.total === null ? '' : (Number(r.total) / 100).toFixed(2), r.currency, r.till, r.mode, Number(r.parent) ? 'yes' : '', r.via, r.payment, r.refund, r.dispute].map(cell).join(','));
798
+ const lines = rows.map((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].map(cell).join(','));
665
799
  return `${head.join(',')}\n${lines.join('\n')}${lines.length ? '\n' : ''}`;
666
800
  }
667
801
 
668
802
  /** The signed statements for a period (YYYY-MM, default last month): one per referrer this studio owes. */
669
- export async function statementsOut(env, cat, origin, period) {
803
+ export async function statementsOut(env, cat, origin, period, cursor = '') {
670
804
  const shop = shopOf(cat);
671
805
  const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : lastMonth();
672
- const books = await booksOf(env, { period: p });
673
- const out = [];
674
- for (const b of books) out.push(await statementFor(env, origin, b, p, shop?.referrals));
675
- return { ok: true, period: p, statements: out };
806
+ return { ok: true, period: p, ...(await statementPage(env, origin, shop?.referrals, p, cursor, { snapshot: false })) };
676
807
  }
677
- export async function statementsSend(env, cat, origin, period) {
808
+ export async function statementsSend(env, cat, origin, period, cursor = '') {
678
809
  const shop = shopOf(cat);
679
- const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : lastMonth();
680
- return { ok: true, period: p, sent: await sendStatements(env, origin, shop, p) };
810
+ const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : (await pendingStatementPeriod(env, origin)) ?? lastMonth();
811
+ const { sent, nextCursor, retryAfter, failures } = await sendStatements(env, origin, shop, p, { cursor });
812
+ return { ok: true, period: p, nextCursor, sent, retryAfter, failures };
681
813
  }
682
- export const settleReferrer = (env, a) => settle(env, a.via, a.ref);
814
+ export const settleReferrer = (env, a) => settle(env, a.via, a.ref, { currency: a.currency });
683
815
 
684
816
  function lastMonth(now = new Date()) {
685
817
  const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
@@ -688,22 +820,22 @@ function lastMonth(now = new Date()) {
688
820
 
689
821
  /* ------------------------------------------------------------------ for the rest of the Worker */
690
822
 
691
- /** The play shell's boot facts: is there a shop to show in this game (never on a kids server or a kids studio). */
823
+ /** The play shell's boot facts: is there a shop to show under this studio's policy. */
692
824
  export function shellShop(cat, game, { kids = false } = {}) {
693
825
  const shop = shopOf(cat);
694
- if (!shop || shop.audience === 'kids' || kids || shop.till === 'off') return null;
826
+ if (!shop || shop.audience === 'kids' || (kids && !shop.policy.kidsServer) || shop.till === 'off') return null;
695
827
  const items = itemsFor(shop, game);
696
- return items.length ? { items: items.length, currency: shop.currency } : null;
828
+ return items.length ? { items: items.length, currency: shop.currency, policy: shop.policy } : null;
697
829
  }
698
830
 
699
- /** The badge (a supporter's) a signed-in player shows in rooms; none on a kids server. Verified here, never by a hello. */
831
+ /** The badge (a supporter's) a signed-in player shows in rooms; kids visibility follows studio policy. Verified here, never by a hello. */
700
832
  export async function roomBadge(env, cat, player, { kids = false } = {}) {
701
833
  const shop = shopOf(cat);
702
- if (!shop || kids || !/^pl_[A-Za-z0-9_-]{22}$/.test(String(player ?? ''))) return null;
834
+ if (!shop || (kids && !shop.policy.kidsServer) || !/^pl_[A-Za-z0-9_-]{22}$/.test(String(player ?? ''))) return null;
703
835
  try {
704
836
  const owns = await ownsOf(env, player);
705
837
  const b = owns.find((o) => o.key.startsWith('badge:'));
706
838
  if (!b) return null;
707
- return shop.items.find((i) => i.id === b.item)?.badge ?? b.key.slice(6, 22);
839
+ return shop.items.find((i) => i.id === b.item)?.badge ?? b.key.slice(6);
708
840
  } catch { return null; }
709
841
  }