@homie-rocks/studio 0.22.0 → 0.24.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 (45) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +59 -0
  3. package/bin/homie-studio.mjs +68 -2
  4. package/chat/CHAT.md +101 -0
  5. package/chat/OWNERS.md +78 -0
  6. package/lib/build.mjs +20 -2
  7. package/lib/chat-cli.mjs +89 -0
  8. package/lib/cloudflare.mjs +5 -2
  9. package/lib/scaffold.mjs +59 -2
  10. package/lib/shop.mjs +270 -0
  11. package/lib/template-history.json +77 -0
  12. package/netplay/NETPLAY.md +194 -4
  13. package/netplay/netplay.ts +156 -3
  14. package/package.json +7 -1
  15. package/port/index.ts +3 -2
  16. package/port/view.ts +238 -0
  17. package/shop/SELLING.md +83 -0
  18. package/shop/SHOP.md +154 -0
  19. package/shop/shop.ts +130 -0
  20. package/site/SITE.md +22 -1
  21. package/starters/ember-vale/src/main.ts +22 -2
  22. package/starters/gem-rush/src/main.ts +21 -1
  23. package/starters/gem-rush-3d/src/main.ts +22 -1
  24. package/worker/account-page.mjs +14 -2
  25. package/worker/agents.mjs +3 -3
  26. package/worker/chat-page.mjs +556 -0
  27. package/worker/chat-store.mjs +161 -0
  28. package/worker/chat-words.mjs +46 -0
  29. package/worker/chat.mjs +419 -0
  30. package/worker/index.mjs +189 -7
  31. package/worker/office-page.mjs +120 -5
  32. package/worker/office.mjs +173 -6
  33. package/worker/pages.mjs +25 -8
  34. package/worker/players.mjs +8 -0
  35. package/worker/referrals.mjs +328 -0
  36. package/worker/room.mjs +291 -11
  37. package/worker/servers.mjs +5 -1
  38. package/worker/shop-page.mjs +532 -0
  39. package/worker/shop-rules.mjs +277 -0
  40. package/worker/shop-store.mjs +217 -0
  41. package/worker/shop.mjs +686 -0
  42. package/worker/site.mjs +4 -1
  43. package/worker/stats.mjs +4 -1
  44. package/worker/stripe.mjs +149 -0
  45. package/worker/version.mjs +1 -1
@@ -0,0 +1,686 @@
1
+ /**
2
+ * THE SHOP (@homie-rocks/studio 0.24.0): a studio sells with ITS OWN Stripe account (shop/SHOP.md is the guide).
3
+ * The studio is the seller; homie.rocks never sees, holds or moves its money, and Homie takes no cut.
4
+ *
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: [...] }
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
11
+ * POST /api/shop/used the game says an item was used (equipped, played): it leaves the self-serve refund
12
+ * POST /api/shop/refund the player's own refund of an unused item within the studio's refund days
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)
15
+ * GET /api/shop/mine the player's orders and what they own (the account page)
16
+ * GET /shop/ /shop/thanks /shop/refunds/ /shop/parent/<token> the pages (worker/shop-page.mjs)
17
+ * POST /api/referrals/statement another studio's signed referral statement to this one (worker/referrals.mjs)
18
+ *
19
+ * THE OFFICE (worker/office.mjs routes them): sales, refunds, disputes and payouts as links to the studio's own Stripe
20
+ * Dashboard, a CSV for the accountant, the referral books and signed statements. A refund is the owner's one tap; the
21
+ * owner's AI (an office key) can only ASK for one, which the owner confirms with one tap. A dispute never touches the
22
+ * player's account: it changes nothing until it is decided, and a lost one is a refund of that one item.
23
+ *
24
+ * SECRETS (Worker secrets, put there by `homie-studio shop connect` from a page on the owner's own computer):
25
+ * STRIPE_KEY (a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read)
26
+ * and STRIPE_WEBHOOK_SECRET. Never in a file, a chat, a log or a page.
27
+ */
28
+ import { players } from './players.mjs';
29
+ import { serversOf } from './servers.mjs';
30
+ import { CAP_CEILING, audienceOf, bandOf, checkShop, money, wayFor } from './shop-rules.mjs';
31
+ import {
32
+ bandOfPlayer, forgetPlayerShop, grantStatements, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, ownsOf, restoreStatement, revokeStatement,
33
+ setBand, shopDataOf, spentThisMonth,
34
+ } from './shop-store.mjs';
35
+ import { KEY_SHAPE, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, dashboardLink, modeOf, verifyWebhook } from './stripe.mjs';
36
+ import { arrivalOf, booksOf, lineFor, receiveStatement, referrerOk, sendStatements, settle, statementFor, statementsIn, voidLineStatements } from './referrals.mjs';
37
+ import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
38
+ import { notFoundPage } from './site.mjs';
39
+
40
+ export { forgetPlayerShop, officeShopPage, shopDataOf };
41
+
42
+ const DAY = 86_400_000;
43
+ const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
44
+ const SERVER_ID = /^(?:public|[a-z0-9][a-z0-9-]{1,19})$/;
45
+ const TOKEN = /^[A-Za-z0-9_-]{43}$/;
46
+ /** In-memory, per Worker instance: buys and parent links a minute per player. Never stored. */
47
+ const BUYS_PER_MINUTE = 6;
48
+
49
+ const json = (body, status = 200, headers = {}) => new Response(`${JSON.stringify(body)}\n`, {
50
+ status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store, private', 'x-content-type-options': 'nosniff', ...headers },
51
+ });
52
+ const fail = (status, error, message, extra = {}) => json({ ok: false, error, message, ...extra }, status);
53
+
54
+ const buckets = new Map();
55
+ function limited(key, max, windowMs, now = Date.now()) {
56
+ const list = (buckets.get(key) ?? []).filter((t) => t > now - windowMs);
57
+ if (list.length >= max) { buckets.set(key, list); return true; }
58
+ list.push(now);
59
+ buckets.set(key, list);
60
+ if (buckets.size > 10_000) buckets.clear();
61
+ return false;
62
+ }
63
+ /** For tests: forget every count. */
64
+ export function resetShopLimits() { buckets.clear(); }
65
+
66
+ 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('');
67
+ function randomToken() {
68
+ const raw = new Uint8Array(32);
69
+ crypto.getRandomValues(raw);
70
+ return btoa(String.fromCharCode(...raw)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
71
+ }
72
+
73
+ function sameOrigin(request, url) {
74
+ const origin = request.headers.get('origin');
75
+ if (origin && origin !== 'null') return origin === url.origin;
76
+ return request.headers.get('sec-fetch-site') === 'same-origin';
77
+ }
78
+ async function bodyOf(request, max = 4096) {
79
+ if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return { error: fail(415, 'json', 'send JSON (content-type: application/json)') };
80
+ const text = await request.text();
81
+ if (text.length > max) return { error: fail(413, 'too-large', 'that request is too large') };
82
+ try { const b = text ? JSON.parse(text) : {}; return b && typeof b === 'object' && !Array.isArray(b) ? { body: b } : { error: fail(400, 'json', 'send a JSON object') }; } catch { return { error: fail(400, 'json', 'that is not JSON') }; }
83
+ }
84
+
85
+ /* ------------------------------------------------------------------ the shop and whether it is open */
86
+
87
+ const shopCache = new WeakMap();
88
+ /**
89
+ * The studio's shop, as the build wrote it into games.json and checked again here (the kids rules are in the code,
90
+ * so a hand-edited build cannot sell what the kit refuses). null when the studio has no shop.json.
91
+ */
92
+ export function shopOf(cat) {
93
+ if (!cat?.shop) return null;
94
+ if (shopCache.has(cat)) return shopCache.get(cat);
95
+ const games = (cat.games ?? []).map((g) => g.id);
96
+ const r = checkShop(cat.shop, { games: games.length ? games : null, audience: audienceOf(cat.studio) });
97
+ const shop = r.ok ? r.shop : { ...r.shop, open: false, broken: r.errors.slice(0, 5) };
98
+ shopCache.set(cat, shop);
99
+ return shop;
100
+ }
101
+
102
+ /** What is missing before anything is sold, in order, each with what to do. Nothing secret is read out. */
103
+ export async function readiness(env, shop) {
104
+ const missing = [];
105
+ if (!shop) missing.push('shop.json');
106
+ else if (shop.audience === 'kids') missing.push('kids-studio');
107
+ else if (shop.broken) missing.push('shop.json-refused');
108
+ else if (shop.till === 'off') missing.push('till-off');
109
+ else if (!shop.items.length) missing.push('items');
110
+ const key = String(env?.STRIPE_KEY ?? '');
111
+ if (/^sk_live_/.test(key)) missing.push('restricted-key');
112
+ else if (!KEY_SHAPE.test(key)) missing.push('stripe-key');
113
+ if (!WEBHOOK_SECRET_SHAPE.test(String(env?.STRIPE_WEBHOOK_SECRET ?? ''))) missing.push('webhook-secret');
114
+ if (!env?.DB || env.HOMIE_PREVIEW === '1') missing.push('database');
115
+ else if (!(await migrated(env))) missing.push('migration');
116
+ return { ready: missing.length === 0, missing, mode: KEY_SHAPE.test(key) ? modeOf(key) : null };
117
+ }
118
+
119
+ export const MISSING_WORDS = Object.freeze({
120
+ 'shop.json': 'This studio has no shop.json: nothing is for sale (homie-studio shop init writes one).',
121
+ 'kids-studio': 'This studio is made for children (studio.json "audience": "kids"): it sells nothing in its games.',
122
+ 'shop.json-refused': 'shop.json breaks the kit\'s rules (homie-studio shop check says which), so nothing is sold.',
123
+ 'till-off': 'shop.json says "till": "off".',
124
+ items: 'shop.json has no items.',
125
+ 'restricted-key': 'The Worker has a full secret key: the shop takes only a restricted key (homie-studio shop connect).',
126
+ '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).',
127
+ 'webhook-secret': 'The Worker has no webhook signing secret yet (the same page takes it).',
128
+ database: 'This Worker has no database (a Preview never sells).',
129
+ migration: 'The shop\'s tables are not in the studio\'s D1 yet: npm run deploy applies migration 0008_studio_shop.sql.',
130
+ });
131
+
132
+ /** The server a request names (the play shell says which), with its kids and beginner flags. */
133
+ async function serverFacts(env, cat, game, server) {
134
+ if (!game || !GAME_ID.test(game) || !server || !SERVER_ID.test(server)) return { kids: false, beginner: false };
135
+ const meta = (cat.games ?? []).find((g) => g.id === game);
136
+ if (!meta) return { kids: false, beginner: false };
137
+ try {
138
+ const s = (await serversOf(env, meta)).find((x) => x.id === server);
139
+ return { kids: Boolean(s?.kids), beginner: s?.policy === 'beginner' };
140
+ } catch { return { kids: false, beginner: false }; }
141
+ }
142
+
143
+ /** The items a game's shop shows (its own and the studio's), in shop.json's order. */
144
+ const itemsFor = (shop, game) => (shop?.items ?? []).filter((i) => !i.game || !game || i.game === game);
145
+
146
+ /** The account a request is signed in as, with when it was made (a referral counts only for a new account). */
147
+ async function accountOf(request, env) {
148
+ const p = await players.of(request, env);
149
+ if (!p) return null;
150
+ let since = null;
151
+ try { since = (await players.get(env, p.id))?.createdAt ?? null; } catch { since = null; }
152
+ return { ...p, since };
153
+ }
154
+
155
+ /** Each item with how this player may get it, and the words for it. */
156
+ async function itemsView(env, shop, { game, kids, beginner, player, open }) {
157
+ const band = player && !player.guest ? await bandOfPlayer(env, player.id) : null;
158
+ const owns = player ? await ownsOf(env, player.id) : [];
159
+ const spent = player && !player.guest ? await spentThisMonth(env, player.id) : 0;
160
+ const items = itemsFor(shop, game).map((it) => {
161
+ const owned = it.gives.length > 0 && it.gives.every((k) => owns.some((o) => o.key === k && o.item === it.id));
162
+ const way = wayFor(it, { open, kids, beginner, player, band, owned, spent, cap: shop.capPerPlayerMonth ?? CAP_CEILING });
163
+ 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 };
164
+ }).filter((i) => i.way !== 'beginner' && i.way !== 'kids');
165
+ return { band, owns, spent, items };
166
+ }
167
+
168
+ /* ------------------------------------------------------------------ routes */
169
+
170
+ /** The shop's paths, or null for any other. */
171
+ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
172
+ const path = url.pathname;
173
+ const read = request.method === 'GET' || request.method === 'HEAD';
174
+ if (path === '/api/referrals/statement') return receiveStatement(request, env, url);
175
+ if (path === '/_homie/shop.js') return new Response(SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
176
+ const shopPath = path === '/shop' || path === '/shop/' || path.startsWith('/shop/') || path === '/api/shop' || path.startsWith('/api/shop/') || path === '/api/player/owns';
177
+ if (!shopPath) return null;
178
+ if (path === '/shop') return Response.redirect(`${url.origin}/shop/${url.search}`, 301);
179
+ const cat = await catalogueOf();
180
+ const shop = shopOf(cat);
181
+ // A kids studio has no shop at all: no page, no list, nothing to buy.
182
+ if (!shop || shop.audience === 'kids') {
183
+ if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
184
+ if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
185
+ // 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.
186
+ return shop ? notFoundPage('This studio sells nothing.', cat) : null;
187
+ }
188
+ const ready = await readiness(env, shop);
189
+ try {
190
+ if (path === '/api/shop/hook') return await hook(request, env, cat, shop, ready);
191
+ if (!read && !sameOrigin(request, url)) return fail(403, 'origin', 'only this site\'s own pages may do that');
192
+ if (path === '/api/shop' && read) return await listRoute(request, env, url, cat, shop, ready);
193
+ if (path === '/api/player/owns' && read) return await ownsRoute(request, env, url, cat, shop);
194
+ if (path === '/api/shop/mine' && read) return await mineRoute(request, env, shop);
195
+ if (path === '/api/shop/order' && read) return await orderRoute(request, env, url);
196
+ if (path === '/api/shop/age' && request.method === 'POST') return await ageRoute(request, env);
197
+ if (path === '/api/shop/buy' && request.method === 'POST') return await buyRoute(request, env, url, cat, shop, ready);
198
+ if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
199
+ if (path === '/api/shop/used' && request.method === 'POST') return await usedRoute(request, env);
200
+ if (path === '/api/shop/refund' && request.method === 'POST') return await selfRefundRoute(request, env, shop, ready);
201
+ 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 });
202
+ 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 });
203
+ if ((path === '/shop/refunds/' || path === '/shop/refunds') && read) return refundsPage(cat, shop);
204
+ const parent = /^\/shop\/parent\/([A-Za-z0-9_-]{43})$/.exec(path);
205
+ if (parent) return await parentRoute(request, env, url, cat, shop, ready, parent[1]);
206
+ } catch (error) {
207
+ if (/no such table/i.test(String(error?.message))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
208
+ throw error;
209
+ }
210
+ return path.startsWith('/api/') ? fail(404, 'not-found', 'no such shop route') : notFoundPage('Nothing here.', cat);
211
+ }
212
+
213
+ async function listRoute(request, env, url, cat, shop, ready) {
214
+ const game = GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null;
215
+ const facts = await serverFacts(env, cat, game, url.searchParams.get('server'));
216
+ if (facts.kids) return json({ ok: true, open: false, kids: true, items: [] });
217
+ const player = await accountOf(request, env);
218
+ const v = await itemsView(env, shop, { game, ...facts, player, open: ready.ready });
219
+ // An account that said it is under 13 is a kids account from then on: no shop, nothing listed.
220
+ if (v.band === 'child') return json({ ok: true, open: false, kids: true, items: [] });
221
+ return json({
222
+ ok: true, open: ready.ready, ...(ready.ready ? {} : { missing: ready.missing }), mode: ready.mode,
223
+ currency: shop.currency, till: shop.till, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, spent: v.spent,
224
+ player: player ? { signedIn: !player.guest, guest: player.guest, name: player.name, band: v.band } : null,
225
+ owns: v.owns.map((o) => o.key), items: v.items,
226
+ });
227
+ }
228
+
229
+ /** What the player owns: the keys a game reads. On a beginner or kids server, nothing with a play advantage counts. */
230
+ async function ownsRoute(request, env, url, cat, shop) {
231
+ const p = await players.of(request, env);
232
+ if (!p) return json({ ok: true, owns: [], open: Boolean(shop.open) });
233
+ const game = GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null;
234
+ const facts = await serverFacts(env, cat, game, url.searchParams.get('server'));
235
+ const advantage = new Set((shop.items ?? []).filter((i) => i.advantage).map((i) => i.id));
236
+ const owns = (await ownsOf(env, p.id)).filter((o) => !((facts.kids || facts.beginner) && advantage.has(o.item)));
237
+ 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 })) });
238
+ }
239
+
240
+ async function mineRoute(request, env, shop) {
241
+ const p = await players.of(request, env);
242
+ if (!p) return json({ ok: true, player: null, orders: [], owns: [] });
243
+ const data = await shopDataOf(env, p.id);
244
+ const names = new Map((shop.items ?? []).map((i) => [i.id, i.name]));
245
+ 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)) });
246
+ }
247
+
248
+ /** The thanks page asks: is my order (of this session) paid yet? Only the buyer's own account sees it. */
249
+ async function orderRoute(request, env, url) {
250
+ const p = await players.of(request, env);
251
+ const session = url.searchParams.get('session') ?? '';
252
+ if (!p || !/^cs_[A-Za-z0-9_]{1,200}$/.test(session)) return json({ ok: true, status: null });
253
+ const o = await orderBySession(env, session);
254
+ if (!o || o.player !== p.id) return json({ ok: true, status: null });
255
+ return json({ ok: true, status: o.status, item: o.item, game: o.game ?? null });
256
+ }
257
+
258
+ /** The neutral age question: a year of birth, once. Nothing hints which answer opens anything; no default. */
259
+ async function ageRoute(request, env) {
260
+ const b = await bodyOf(request);
261
+ if (b.error) return b.error;
262
+ const p = await players.of(request, env);
263
+ if (!p || p.guest) return fail(401, 'account', 'make an account first (a passkey)');
264
+ const before = await bandOfPlayer(env, p.id);
265
+ if (before) return json({ ok: true, asked: true });
266
+ const band = bandOf(b.body.year);
267
+ if (!band) return fail(400, 'year', 'pick the year you were born');
268
+ await setBand(env, p.id, band);
269
+ // The answer itself is never said back: the shop's list shows what this account may do.
270
+ return json({ ok: true, asked: true });
271
+ }
272
+
273
+ function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio }) {
274
+ const managed = shop.till === 'stripe-managed';
275
+ const limited = Boolean(item.days || item.ends);
276
+ // Managed Payments needs an eligible digital-goods tax code on every product (video games, downloaded).
277
+ const taxCode = item.taxCode ?? (managed ? (limited ? 'txcd_10201001' : 'txcd_10201000') : undefined);
278
+ const back = game ? `&game=${encodeURIComponent(game)}` : '';
279
+ return {
280
+ mode: 'payment',
281
+ line_items: [{
282
+ quantity: 1,
283
+ price_data: {
284
+ currency: shop.currency, unit_amount: amount, tax_behavior: 'exclusive',
285
+ product_data: { name: item.name.slice(0, 120), ...(item.blurb ? { description: item.blurb.slice(0, 300) } : {}), ...(taxCode ? { tax_code: taxCode } : {}), metadata: { item: item.id } },
286
+ },
287
+ }],
288
+ client_reference_id: player,
289
+ 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' } : {}) },
290
+ payment_intent_data: { metadata: { order: order.id, item: item.id } },
291
+ success_url: `${origin}/shop/thanks?session_id={CHECKOUT_SESSION_ID}${back}`,
292
+ cancel_url: `${origin}/shop/?cancelled=1${back}`,
293
+ expires_at: Math.floor(Date.now() / 1000) + 31 * 60,
294
+ // The EU and UK immediate-delivery acknowledgement (Consumer Rights Directive Art 16(m)), beside the pay button.
295
+ 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) } },
296
+ // The kit's own tills: Stripe Tax on (the studio is the seller), or Managed Payments (Stripe is the seller of record
297
+ // and computes the tax itself, so automatic_tax and adaptive pricing must not be sent).
298
+ ...(managed ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: true }, adaptive_pricing: { enabled: false } }),
299
+ };
300
+ }
301
+
302
+ /** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
303
+ async function startCheckout(env, url, cat, shop, item, { player, game, via, amount, parent = false }) {
304
+ const order = { id: newOrderId(), player: player.id, via: via ?? null, amount, currency: shop.currency };
305
+ const now = Date.now();
306
+ 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)")
307
+ .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();
308
+ let session = null;
309
+ try {
310
+ session = await createCheckoutSession(env, sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name }), { idempotencyKey: `checkout-${order.id}` });
311
+ } catch (error) {
312
+ 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();
313
+ const why = error instanceof StripeError ? error.message : 'Stripe did not answer';
314
+ return { error: fail(502, 'stripe', `Stripe could not open the checkout (${why}). Nothing was charged.`) };
315
+ }
316
+ if (typeof session?.id !== 'string' || typeof session?.url !== 'string' || !/^https:\/\//.test(session.url) && !/^http:\/\/(127\.0\.0\.1|localhost)/.test(session.url)) {
317
+ return { error: fail(502, 'stripe', 'Stripe answered without a checkout page. Nothing was charged.') };
318
+ }
319
+ await env.DB.prepare('UPDATE shop_orders SET session = ?2, updated_at = ?3 WHERE id = ?1').bind(order.id, session.id, Date.now()).run();
320
+ return { order, url: session.url, session: session.id };
321
+ }
322
+
323
+ /** The referrer this buyer brought, if it still counts: their arrival, a new account since it, in the window, proven. */
324
+ async function viaFor(request, env, url, shop, player) {
325
+ if (!shop.referrals || !(shop.referrals.rate > 0)) return null;
326
+ const a = await arrivalOf(request, env);
327
+ if (!a || a.via === url.hostname.replace(/^www\./, '')) return null;
328
+ if (Date.now() - a.at > shop.referrals.windowDays * DAY) return null;
329
+ // New players only: an account made before the arrival was already this studio's player.
330
+ if (player.since !== null && player.since < a.at - 60_000) return null;
331
+ return (await referrerOk(a.via)) ? a.via : null;
332
+ }
333
+
334
+ const WAY_WORDS = Object.freeze({
335
+ closed: 'The shop is not open yet.', kids: 'Nothing is sold on a kids server.', beginner: 'Not sold on a beginner server.',
336
+ owned: 'You have it already.', 'make-an-account': 'Make an account first (a passkey), so what you buy stays yours on every device.',
337
+ 'age-question': 'One question first: the year you were born.', no: 'This shop sells nothing to this account.',
338
+ '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.',
339
+ 'not-yet': 'Not on sale yet.', over: 'No longer on sale.',
340
+ });
341
+
342
+ async function buyRoute(request, env, url, cat, shop, ready) {
343
+ const b = await bodyOf(request);
344
+ if (b.error) return b.error;
345
+ if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing });
346
+ const item = shop.items.find((i) => i.id === b.body.item);
347
+ if (!item) return fail(404, 'item', 'no such item here');
348
+ const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
349
+ if (item.game && game && item.game !== game) return fail(400, 'item', 'that item belongs to another game');
350
+ const facts = await serverFacts(env, cat, game, b.body.server);
351
+ const player = await accountOf(request, env);
352
+ if (player && limited(`buy:${player.id}`, BUYS_PER_MINUTE, 60_000)) return fail(429, 'rate', 'one moment: too many tries');
353
+ const v = await itemsView(env, shop, { game, ...facts, player, open: true });
354
+ const row = v.items.find((i) => i.id === item.id);
355
+ const way = facts.kids ? 'kids' : row?.way ?? (item.advantage && facts.beginner ? 'beginner' : 'closed');
356
+ if (way !== 'checkout') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
357
+ let amount = item.price;
358
+ if (item.kind === 'tip') {
359
+ amount = Math.floor(Number(b.body.amount));
360
+ 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)}`);
361
+ if (v.spent + amount > shop.capPerPlayerMonth) return fail(403, 'cap', WAY_WORDS.cap, { way: 'cap' });
362
+ }
363
+ const via = await viaFor(request, env, url, shop, player);
364
+ const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount });
365
+ if (r.error) return r.error;
366
+ return json({ ok: true, order: r.order.id, url: r.url });
367
+ }
368
+
369
+ /** 13-17: a one-time link for a parent (7 days). The teen passes it on; nothing here pressures anyone. */
370
+ async function parentLinkRoute(request, env, url, cat, shop, ready) {
371
+ const b = await bodyOf(request);
372
+ if (b.error) return b.error;
373
+ if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing });
374
+ const item = shop.items.find((i) => i.id === b.body.item);
375
+ if (!item || item.kind === 'tip') return fail(404, 'item', 'no such item here');
376
+ const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
377
+ const facts = await serverFacts(env, cat, game, b.body.server);
378
+ const player = await accountOf(request, env);
379
+ if (player && limited(`parent:${player.id}`, BUYS_PER_MINUTE, 60_000)) return fail(429, 'rate', 'one moment: too many tries');
380
+ const v = await itemsView(env, shop, { game, ...facts, player, open: true });
381
+ const way = facts.kids ? 'kids' : v.items.find((i) => i.id === item.id)?.way ?? 'closed';
382
+ if (way !== 'ask-a-parent') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
383
+ const token = randomToken();
384
+ 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();
385
+ return json({ ok: true, link: `${url.origin}/shop/parent/${token}`, expiresInDays: 7 });
386
+ }
387
+
388
+ /** /shop/parent/<token>: what, for whom, the price; the parent pays in their own name on Stripe's page. */
389
+ async function parentRoute(request, env, url, cat, shop, ready, token) {
390
+ if (!TOKEN.test(token)) return notFoundPage('This link does not work.', cat);
391
+ const row = await env.DB.prepare('SELECT * FROM shop_parent_links WHERE hash = ?1').bind(await sha256Hex(token)).first();
392
+ const item = row ? shop.items.find((i) => i.id === row.item) : null;
393
+ if (!row || !item || Number(row.expires_at) < Date.now()) return parentPage(cat, shop, { gone: true });
394
+ const kid = await players.get(env, row.player);
395
+ if (!kid) return parentPage(cat, shop, { gone: true });
396
+ const done = row.order_id ? await orderById(env, row.order_id) : null;
397
+ if (done?.status === 'paid') return parentPage(cat, shop, { item, name: kid.name, paid: true });
398
+ if (request.method === 'GET' || request.method === 'HEAD') return parentPage(cat, shop, { item, name: kid.name, token, open: ready.ready });
399
+ if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403 });
400
+ let form = null;
401
+ try { form = await request.formData(); } catch { form = null; }
402
+ 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.' });
403
+ if (!ready.ready) return parentPage(cat, shop, { item, name: kid.name, token, open: false });
404
+ 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.' });
405
+ const band = await bandOfPlayer(env, row.player);
406
+ const spent = await spentThisMonth(env, row.player);
407
+ if (band !== 'teen' || spent + item.price > shop.capPerPlayerMonth) return parentPage(cat, shop, { gone: true });
408
+ const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game, via: null, amount: item.price, parent: true });
409
+ 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.' });
410
+ await env.DB.prepare('UPDATE shop_parent_links SET order_id = ?2 WHERE hash = ?1').bind(await sha256Hex(token), r.order.id).run();
411
+ return Response.redirect(r.url, 303);
412
+ }
413
+
414
+ /** The game says the player used something (equipped a skin, played the pass): it is no longer refundable by the player. */
415
+ async function usedRoute(request, env) {
416
+ const b = await bodyOf(request);
417
+ if (b.error) return b.error;
418
+ const p = await players.of(request, env);
419
+ if (!p) return json({ ok: true, used: 0 });
420
+ const key = String(b.body.key ?? '');
421
+ if (!/^[a-z0-9][a-z0-9_.:-]{0,63}$/.test(key)) return fail(400, 'key', 'name the key the game read');
422
+ 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();
423
+ return json({ ok: true, used: Number(r?.meta?.changes ?? 0) });
424
+ }
425
+
426
+ function refundableNow(order, owns, shop, now = Date.now()) {
427
+ if (order.status !== 'paid' || order.paidAt === null) return false;
428
+ if (now - order.paidAt > shop.refundDays * DAY) return false;
429
+ return !owns.some((o) => o.item === order.item && o.used);
430
+ }
431
+
432
+ /** A player's own refund: an unused item, within the studio's refund days (fewer disputes, and the respected norm). */
433
+ async function selfRefundRoute(request, env, shop, ready) {
434
+ const b = await bodyOf(request);
435
+ if (b.error) return b.error;
436
+ const p = await players.of(request, env);
437
+ if (!p || p.guest) return fail(401, 'account', 'sign in first');
438
+ const o = await orderById(env, b.body.order);
439
+ if (!o || o.player !== p.id) return fail(404, 'order', 'no such order on this account');
440
+ 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();
441
+ 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.`);
442
+ if (!KEY_SHAPE.test(String(env.STRIPE_KEY ?? ''))) return fail(503, 'closed', MISSING_WORDS['stripe-key']);
443
+ const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player' });
444
+ return json(r, r.ok ? 200 : 502);
445
+ }
446
+
447
+ /* ------------------------------------------------------------------ the webhook: the only writer of money states */
448
+
449
+ async function hook(request, env, cat, shop, ready) {
450
+ if (request.method !== 'POST') return fail(405, 'method', 'POST only');
451
+ if (Number(request.headers.get('content-length') ?? 0) > 512 * 1024) return fail(413, 'too-large', 'too large');
452
+ const payload = await request.text();
453
+ const v = await verifyWebhook(payload, request.headers.get('stripe-signature'), env.STRIPE_WEBHOOK_SECRET);
454
+ if (!v.ok) return fail(v.why === 'no-secret' ? 503 : 400, v.why, 'not a webhook from this studio\'s Stripe');
455
+ if (!(await migrated(env))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
456
+ const ev = v.event;
457
+ // A test event never touches a live shop's books, nor a live one a test shop's.
458
+ if (typeof ev.livemode === 'boolean' && KEY_SHAPE.test(String(env.STRIPE_KEY ?? '')) && ev.livemode !== (modeOf(env.STRIPE_KEY) === 'live')) return json({ ok: true, ignored: 'mode' });
459
+ if (await env.DB.prepare('SELECT 1 AS x FROM shop_events WHERE id = ?1').bind(ev.id).first()) return json({ ok: true, duplicate: true });
460
+ const obj = ev.data?.object ?? {};
461
+ let did = 'ignored';
462
+ switch (ev.type) {
463
+ case 'checkout.session.completed':
464
+ case 'checkout.session.async_payment_succeeded':
465
+ did = obj.payment_status === 'paid' || obj.payment_status === 'no_payment_required' ? await paid(env, shop, obj) : 'waiting';
466
+ break;
467
+ case 'checkout.session.async_payment_failed':
468
+ case 'checkout.session.expired':
469
+ 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();
470
+ did = 'closed';
471
+ break;
472
+ case 'charge.refunded':
473
+ if (obj.refunded === true || Number(obj.amount_refunded) >= Number(obj.amount)) did = await refunded(env, String(obj.payment_intent ?? ''), null);
474
+ break;
475
+ case 'refund.created':
476
+ case 'refund.updated':
477
+ if (obj.status === 'succeeded') did = await refunded(env, String(obj.payment_intent ?? ''), String(obj.id ?? ''));
478
+ break;
479
+ case 'charge.dispute.created':
480
+ did = await disputed(env, String(obj.payment_intent ?? ''), String(obj.id ?? ''));
481
+ break;
482
+ case 'charge.dispute.closed':
483
+ did = await disputeClosed(env, String(obj.payment_intent ?? ''), String(obj.status ?? ''));
484
+ break;
485
+ default:
486
+ break;
487
+ }
488
+ await env.DB.prepare('INSERT INTO shop_events (id, type, at) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING').bind(ev.id, ev.type, Date.now()).run();
489
+ if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
490
+ return json({ ok: true, did });
491
+ }
492
+
493
+ /** A paid checkout: the order is paid, its entitlements granted, a referral line written. Idempotent. */
494
+ async function paid(env, shop, session) {
495
+ const o = await orderBySession(env, session.id);
496
+ if (!o) return 'unknown-order';
497
+ // The session must be the one this order opened, for this player and this price.
498
+ if (session.metadata?.order !== o.id || (session.client_reference_id && session.client_reference_id !== o.player)) return 'mismatch';
499
+ if (session.currency && session.currency === o.currency && Number.isFinite(Number(session.amount_subtotal)) && Number(session.amount_subtotal) !== Number(o.amount)) return 'mismatch';
500
+ if (o.status === 'paid' || o.status === 'refunded' || o.status === 'disputed') return 'already';
501
+ const item = shop.items.find((i) => i.id === o.item);
502
+ const now = Date.now();
503
+ 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')")
504
+ .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();
505
+ if (!Number(res?.meta?.changes ?? 1)) return 'already';
506
+ const writes = [];
507
+ if (o.player && item) writes.push(...grantStatements(env, o, item, now));
508
+ // An item that is no longer in shop.json still reaches its buyer: the keys it gave when it was sold are not known
509
+ // here, so the order is paid and the office shows it for the owner to settle by hand.
510
+ if (o.via) writes.push(...(await lineFor(env, o, shop, { now })));
511
+ if (writes.length) await env.DB.batch(writes);
512
+ return item ? 'paid' : 'paid-unknown-item';
513
+ }
514
+
515
+ async function refunded(env, payment, refundId) {
516
+ const o = payment ? await orderByPayment(env, payment) : null;
517
+ if (!o) return 'unknown-order';
518
+ if (o.status === 'refunded') return 'already';
519
+ const now = Date.now();
520
+ await env.DB.batch([
521
+ env.DB.prepare("UPDATE shop_orders SET status = 'refunded', refund = COALESCE(?2, refund), refunded_at = ?3, updated_at = ?3 WHERE id = ?1").bind(o.id, refundId, now),
522
+ revokeStatement(env, o.id),
523
+ ...voidLineStatements(env, o.id),
524
+ ]);
525
+ return 'refunded';
526
+ }
527
+
528
+ /** A dispute changes nothing until it is decided: the player keeps the item and their account (FTC v. Epic). */
529
+ async function disputed(env, payment, disputeId) {
530
+ const o = payment ? await orderByPayment(env, payment) : null;
531
+ if (!o) return 'unknown-order';
532
+ await env.DB.prepare("UPDATE shop_orders SET status = 'disputed', dispute = ?2, updated_at = ?3 WHERE id = ?1 AND status = 'paid'").bind(o.id, disputeId, Date.now()).run();
533
+ return 'disputed';
534
+ }
535
+
536
+ /** Lost: that one item goes, as a refund would take it. Won: back to paid. Never a ban, never a locked account. */
537
+ async function disputeClosed(env, payment, status) {
538
+ const o = payment ? await orderByPayment(env, payment) : null;
539
+ if (!o) return 'unknown-order';
540
+ const now = Date.now();
541
+ if (status === 'lost') {
542
+ await env.DB.batch([
543
+ env.DB.prepare("UPDATE shop_orders SET status = 'lost', updated_at = ?2 WHERE id = ?1").bind(o.id, now),
544
+ revokeStatement(env, o.id),
545
+ ...voidLineStatements(env, o.id),
546
+ ]);
547
+ return 'lost';
548
+ }
549
+ await env.DB.batch([
550
+ env.DB.prepare("UPDATE shop_orders SET status = 'paid', updated_at = ?2 WHERE id = ?1 AND status = 'disputed'").bind(o.id, now),
551
+ restoreStatement(env, o.id),
552
+ ]);
553
+ return 'kept';
554
+ }
555
+
556
+ /** Refund one order in full with Stripe, then take back that one item. The webhook confirms the same (idempotent). */
557
+ export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner' } = {}) {
558
+ 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.` };
559
+ if (!o.payment) return { ok: false, error: 'no-payment', message: 'Stripe has not said which payment this was yet; try again in a minute.' };
560
+ let refund = null;
561
+ try {
562
+ refund = await createRefund(env, { payment_intent: o.payment, reason: ['duplicate', 'fraudulent', 'requested_by_customer'].includes(reason) ? reason : 'requested_by_customer', metadata: { order: o.id, by } }, { idempotencyKey: `refund-${o.id}` });
563
+ } catch (error) {
564
+ return { ok: false, error: 'stripe', message: `Stripe did not refund it (${error instanceof StripeError ? error.message : 'no answer'}). Nothing changed.` };
565
+ }
566
+ const now = Date.now();
567
+ await env.DB.batch([
568
+ env.DB.prepare("UPDATE shop_orders SET status = 'refunded', refund = ?2, refunded_at = ?3, updated_at = ?3 WHERE id = ?1 AND status = 'paid'").bind(o.id, String(refund?.id ?? ''), now),
569
+ revokeStatement(env, o.id),
570
+ ...voidLineStatements(env, o.id),
571
+ ]);
572
+ return { ok: true, order: o.id, refund: refund?.id ?? null, status: refund?.status ?? null, amount: Number(o.amount), currency: o.currency };
573
+ }
574
+
575
+ /* ------------------------------------------------------------------ the office (worker/office.mjs calls these) */
576
+
577
+ /** office checkAction for `refund`: { ok, action } or a refusal. */
578
+ export function checkRefund(body) {
579
+ if (!/^ord_[A-Za-z0-9]{20}$/.test(String(body.order ?? ''))) return { ok: false, error: 'bad-request', message: 'order is the order\'s id (ord_…, from the office\'s shop page or `homie-studio shop orders`)' };
580
+ const reason = ['duplicate', 'fraudulent', 'requested_by_customer'].includes(body.reason) ? body.reason : 'requested_by_customer';
581
+ const note = String(body.note ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim().slice(0, 140) || null;
582
+ return { ok: true, action: { op: 'refund', order: body.order, reason, note } };
583
+ }
584
+ export function checkSettle(body) {
585
+ 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)' };
586
+ return { ok: true, action: { op: 'shop-settle', via: String(body.via), ref: String(body.ref ?? '').slice(0, 80) } };
587
+ }
588
+
589
+ /** The refund in plain words, for the owner's confirm page and the AI's card. */
590
+ export function describeRefund(cat, a, order = null) {
591
+ const shop = shopOf(cat);
592
+ const name = shop?.items.find((i) => i.id === order?.item)?.name ?? order?.item ?? 'an item';
593
+ const what = order ? `${name}, ${money(order.amount, order.currency)}` : a.order;
594
+ return `Refund order ${a.order} (${what}) in full with Stripe: the buyer gets their money back in 5 to 10 days and the item leaves their account. ${a.note ? `Why: "${a.note}".` : ''}`.trim();
595
+ }
596
+
597
+ /** Do the refund (the owner's tap, or the owner's yes to the AI's ask). */
598
+ export async function performRefund(env, cat, a) {
599
+ const o = await orderById(env, a.order);
600
+ if (!o) return { ok: false, error: 'order', message: 'No such order.' };
601
+ return refundOrder(env, o, { reason: a.reason, by: 'owner' });
602
+ }
603
+
604
+ /** Everything the office's shop page shows. Money is read from this studio's D1; Stripe's own pages are linked. */
605
+ export async function shopOffice(env, cat, origin) {
606
+ const shop = shopOf(cat);
607
+ const ready = await readiness(env, shop);
608
+ const mode = ready.mode ?? 'test';
609
+ const out = {
610
+ ok: true, ready: ready.ready, missing: ready.missing.map((m) => ({ id: m, words: MISSING_WORDS[m] ?? m })), mode: ready.mode,
611
+ 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,
612
+ stripe: {
613
+ payments: dashboardLink(mode, 'payments'), refunds: dashboardLink(mode, 'refunds'), disputes: dashboardLink(mode, 'disputes'), payouts: dashboardLink(mode, 'payouts'),
614
+ balance: dashboardLink(mode, 'balance'), tax: dashboardLink(mode, 'tax'), keys: dashboardLink(mode, 'keys'), webhooks: dashboardLink(mode, 'webhooks'),
615
+ ...(shop?.till === 'stripe-managed' ? { managedPayments: dashboardLink(mode, 'managed-payments') } : {}),
616
+ },
617
+ hook: `${origin}/api/shop/hook`,
618
+ totals: null, orders: [], referrals: { owe: [], owedToUs: [] },
619
+ };
620
+ if (!(await migrated(env))) return out;
621
+ const since = Date.now() - 30 * DAY;
622
+ 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();
623
+ 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 };
624
+ 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 ?? [];
625
+ const names = new Map((shop?.items ?? []).map((i) => [i.id, i.name]));
626
+ out.orders = rows.map((r) => ({
627
+ ...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,
628
+ stripe: r.payment ? dashboardLink(r.mode, 'payment', r.payment) : null, dispute: r.dispute ? dashboardLink(r.mode, 'dispute', r.dispute) : null,
629
+ refundable: r.status === 'paid' && Boolean(r.payment),
630
+ }));
631
+ out.referrals = { owe: await booksOf(env), owedToUs: await statementsIn(env), invoice: dashboardLink(mode, 'invoices') };
632
+ return out;
633
+ }
634
+
635
+ /** The orders as CSV for the studio's accountant: no names, no emails (Stripe has the buyer's details). */
636
+ export async function ordersCsv(env) {
637
+ const rows = (await env.DB.prepare("SELECT * FROM shop_orders WHERE status != 'started' ORDER BY created_at").all()).results ?? [];
638
+ const cell = (v) => { const s = v === null || v === undefined ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
639
+ const head = ['order', 'created', 'paid', 'refunded', 'status', 'item', 'game', 'amount', 'tax', 'total', 'currency', 'till', 'mode', 'parent', 'via', 'stripe_payment', 'stripe_refund', 'stripe_dispute'];
640
+ const iso = (t) => (t ? new Date(Number(t)).toISOString() : '');
641
+ 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(','));
642
+ return `${head.join(',')}\n${lines.join('\n')}${lines.length ? '\n' : ''}`;
643
+ }
644
+
645
+ /** The signed statements for a period (YYYY-MM, default last month): one per referrer this studio owes. */
646
+ export async function statementsOut(env, cat, origin, period) {
647
+ const shop = shopOf(cat);
648
+ const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : lastMonth();
649
+ const books = await booksOf(env, { period: p });
650
+ const out = [];
651
+ for (const b of books) out.push(await statementFor(env, origin, b, p, shop?.referrals));
652
+ return { ok: true, period: p, statements: out };
653
+ }
654
+ export async function statementsSend(env, cat, origin, period) {
655
+ const shop = shopOf(cat);
656
+ const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : lastMonth();
657
+ return { ok: true, period: p, sent: await sendStatements(env, origin, shop, p) };
658
+ }
659
+ export const settleReferrer = (env, a) => settle(env, a.via, a.ref);
660
+
661
+ function lastMonth(now = new Date()) {
662
+ const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
663
+ return d.toISOString().slice(0, 7);
664
+ }
665
+
666
+ /* ------------------------------------------------------------------ for the rest of the Worker */
667
+
668
+ /** The play shell's boot facts: is there a shop to show in this game (never on a kids server or a kids studio). */
669
+ export function shellShop(cat, game, { kids = false } = {}) {
670
+ const shop = shopOf(cat);
671
+ if (!shop || shop.audience === 'kids' || kids || shop.till === 'off') return null;
672
+ const items = itemsFor(shop, game);
673
+ return items.length ? { items: items.length, currency: shop.currency } : null;
674
+ }
675
+
676
+ /** The badge (a supporter's) a signed-in player shows in rooms; none on a kids server. Verified here, never by a hello. */
677
+ export async function roomBadge(env, cat, player, { kids = false } = {}) {
678
+ const shop = shopOf(cat);
679
+ if (!shop || kids || !/^pl_[A-Za-z0-9_-]{22}$/.test(String(player ?? ''))) return null;
680
+ try {
681
+ const owns = await ownsOf(env, player);
682
+ const b = owns.find((o) => o.key.startsWith('badge:'));
683
+ if (!b) return null;
684
+ return shop.items.find((i) => i.id === b.item)?.badge ?? b.key.slice(6, 22);
685
+ } catch { return null; }
686
+ }