@homie-rocks/studio 0.33.1 → 0.35.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.
package/worker/shop.mjs CHANGED
@@ -5,11 +5,11 @@
5
5
  * GET /api/shop the items, each with how THIS player may get it (shop-rules.mjs wayFor); on a kids
6
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 a permitted buyer buys one item: an order row, then Stripe's own hosted Checkout page
8
+ * POST /api/shop/buy a buyer checks out a cart: an order and its lines, then Stripe's own hosted Checkout page
9
9
  * POST /api/shop/parent studio parent policy: a one-time link a parent opens on their own device (/shop/parent/<token>)
10
10
  * POST /api/shop/hook Stripe's webhook, signature checked; verified Stripe events record 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
11
+ * POST /api/shop/used the game says an item was used (equipped, played): the optional used-item policy applies
12
+ * POST /api/shop/refund the player's own refund of an item line 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
14
  * follows the studio advantage settings)
15
15
  * GET /api/shop/mine the player's orders and what they own (the account page)
@@ -21,8 +21,9 @@
21
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
22
  * player's account: it changes nothing until it is decided, and a lost one is a refund of that one item.
23
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;
24
+ * SECRETS (Worker secrets, put there by `homie-studio shop connect` on the owner's own computer):
25
+ * Default: STRIPE_SHOP_LINKS and STRIPE_WEBHOOK_SECRET; no API key or API requests at purchase time.
26
+ * Optional fuller checkout: STRIPE_KEY (a secret key or a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read;
26
27
  * from 0.24.3 also Webhook Endpoints write, which only the connect page on the owner's computer uses) and
27
28
  * STRIPE_WEBHOOK_SECRET. Never in a file, a chat, a log or a page.
28
29
  *
@@ -31,14 +32,15 @@
31
32
  * by product. The price is always shop.json's (the rules checked it), never one read from Stripe. A Product missing
32
33
  * in this mode falls back to the item described inline, so a sale never fails over the catalog.
33
34
  */
35
+ import { hasStripeKey, linkConfig, linkSettings, shopMode, fullerReason, paymentLinkURL, rememberMoneyEvent, rememberedMoney } from './shop-links.mjs';
34
36
  import { players } from './players.mjs';
35
37
  import { serversOf } from './servers.mjs';
36
- import { ENTITLEMENT_KEY, checkoutMessage, amountError, amountStep, parseAmount, currencyScale, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
38
+ import { ENTITLEMENT_KEY, referralShare, checkoutMessage, amountError, amountStep, parseAmount, currencyScale, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
37
39
  import {
38
- bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
40
+ orderLines, lineView, bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
39
41
  setBand, shopDataOf, spentThisMonth,
40
42
  } from './shop-store.mjs';
41
- import { KEY_SHAPE, stripeCall, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, modeOf, productIdOf, verifyWebhook } from './stripe.mjs';
43
+ import { redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
42
44
  import { pendingStatementPeriod, statementFailures, arrivalOf, booksOf, linesOf, statementPage, lineFor, receiveStatement, receivedPages, referrerOk, sendStatements, settle, statementsIn, voidLineStatements } from './referrals.mjs';
43
45
  import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
44
46
  import { notFoundPage } from './site.mjs';
@@ -83,10 +85,24 @@ function sameOrigin(request, url) {
83
85
  if (origin && origin !== 'null') return origin === url.origin;
84
86
  return request.headers.get('sec-fetch-site') === 'same-origin';
85
87
  }
88
+ async function boundedText(request, max) {
89
+ const reader = request.body?.getReader();
90
+ if (!reader) return '';
91
+ const decoder = new TextDecoder();
92
+ let text = '', bytes = 0;
93
+ while (true) {
94
+ const { done, value } = await reader.read();
95
+ if (done) break;
96
+ bytes += value.byteLength;
97
+ if (bytes > max) { await reader.cancel(); return null; }
98
+ text += decoder.decode(value, { stream: true });
99
+ }
100
+ return text + decoder.decode();
101
+ }
86
102
  async function bodyOf(request, max = 4096) {
87
103
  if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return { error: fail(415, 'json', 'send JSON (content-type: application/json)') };
88
- const text = await request.text();
89
- if (text.length > max) return { error: fail(413, 'too-large', 'that request is too large') };
104
+ const text = await boundedText(request, max);
105
+ if (text === null) return { error: fail(413, 'too-large', 'that request is too large') };
90
106
  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') }; }
91
107
  }
92
108
 
@@ -94,8 +110,9 @@ async function bodyOf(request, max = 4096) {
94
110
 
95
111
  const sizes = new WeakMap();
96
112
  function requestSize(shop) {
113
+ if (shop.requestBytes !== null) return shop.requestBytes;
97
114
  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;
115
+ 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) * 600 + 4096;
99
116
  sizes.set(shop, size);
100
117
  return size;
101
118
  }
@@ -121,14 +138,19 @@ export async function readiness(env, shop) {
121
138
  else if (shop.broken) missing.push('shop.json-refused');
122
139
  else if (shop.till === 'off') missing.push('till-off');
123
140
  else if (!shop.items.length) missing.push('items');
124
- const key = String(env?.STRIPE_KEY ?? '');
125
- if (/^sk_live_/.test(key)) missing.push('restricted-key');
126
- else if (!KEY_SHAPE.test(key)) missing.push('stripe-key');
127
- if (!WEBHOOK_SECRET_SHAPE.test(String(env?.STRIPE_WEBHOOK_SECRET ?? ''))) missing.push('webhook-secret');
141
+ const paidItems = shop?.items.some((i) => i.price !== 0);
142
+ const links = linkConfig(env);
143
+ if (paidItems && !hasStripeKey(env)) {
144
+ if (fullerReason(shop)) missing.push('fuller-checkout');
145
+ else if (!links || links.settings !== linkSettings(shop) || shop.items.some((i) => i.price !== 0 && !links.items.some((l) => l.item === i.id && paymentLinkURL(l.url)))) missing.push('payment-links');
146
+ }
147
+ // Reliable fulfillment requires the webhook as well as the redirect.
148
+ // https://docs.stripe.com/checkout/fulfillment
149
+ if (paidItems && !WEBHOOK_SECRET_SHAPE.test(String(env?.STRIPE_WEBHOOK_SECRET ?? ''))) missing.push('webhook-secret');
128
150
  let migration = null;
129
- if (!env?.DB || env.HOMIE_PREVIEW === '1') missing.push('database');
151
+ if (!env?.DB) missing.push('database');
130
152
  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 };
153
+ return { ready: missing.length === 0, missing, next: missing.length ? MISSING_WORDS[missing[0]] : null, migration, mode: paidItems ? shopMode(env) : null };
132
154
  }
133
155
 
134
156
  export const MISSING_WORDS = Object.freeze({
@@ -137,11 +159,12 @@ export const MISSING_WORDS = Object.freeze({
137
159
  'shop.json-refused': 'shop.json conflicts with the studio settings or payment requirements (homie-studio shop check says which), so nothing is sold.',
138
160
  'till-off': 'shop.json says "till": "off".',
139
161
  items: 'shop.json has no items.',
140
- 'restricted-key': 'The Worker has a full secret key: the shop takes only a restricted key (homie-studio shop connect).',
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).',
142
- 'webhook-secret': 'The Worker has no webhook signing secret yet (the same page takes it).',
143
- database: 'This Worker has no database (a Preview never sells).',
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.',
162
+ 'payment-links': 'Run homie-studio shop connect to sync your items and connect Stripe; approve its browser page if asked.',
163
+ 'fuller-checkout': 'Your spending allowance or checkout expiry needs the fuller checkout connection. Ask your AI to connect it.',
164
+ 'stripe-key': 'The Worker has no Stripe key yet (homie-studio shop connect: approve Stripe in your browser; the command names any remaining Worker credential step).',
165
+ 'webhook-secret': 'The Worker has no webhook signing secret yet (run homie-studio shop connect to create and store it automatically).',
166
+ database: 'This Worker has no database.',
167
+ migration: 'The shop\'s tables are not in the studio\'s D1 yet: npm run deploy applies migrations 0008_studio_shop.sql, 0010_shop_reservations.sql and 0011_shop_statements.sql and 0012_shop_lines.sql.',
145
168
  });
146
169
 
147
170
  /** The server a request names (the play shell says which), with its kids and beginner flags. */
@@ -176,8 +199,8 @@ async function itemsView(env, shop, { game, kids, beginner, player, open, cursor
176
199
  const page = item ? selected : selected.slice(cursor, cursor + 100);
177
200
  const items = page.map((it) => {
178
201
  const owned = it.gives.length > 0 && it.gives.every((k) => owns.some((o) => o.key === k && o.item === it.id));
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 }) } : {}) };
202
+ const way = wayFor(it, { open: open || it.price === 0 && !shop.broken && shop.till !== 'off', kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
203
+ return { id: it.id, kind: it.kind, name: it.name, blurb: it.blurb ?? null, price: it.price, ...(it.price === 'choose' ? { min: it.min, max: it.max } : {}), shown: it.price === 'choose' ? `${money(it.min, shop.currency)} or more` : money(it.price, shop.currency), gives: it.gives, ...(it.badge ? { badge: it.badge } : {}), ...(it.days ? { days: it.days } : {}), ...(it.ends ? { ends: it.ends } : {}), ...(it.game ? { game: it.game } : {}), way, ...(way === 'cap' ? { retryWay: wayFor(it, { open, kids, beginner, player, band, owned, cap: null, policy: shop.policy }) } : {}) };
181
204
  }).filter((i) => i.way !== 'beginner' && i.way !== 'kids');
182
205
  return { band, owns, spent, items, nextCursor: !item && cursor + 100 < selected.length ? String(cursor + 100) : null };
183
206
  }
@@ -186,6 +209,7 @@ async function itemsView(env, shop, { game, kids, beginner, player, open, cursor
186
209
 
187
210
  /** The shop's paths, or null for any other. */
188
211
  export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
212
+ const requestAt = Date.now();
189
213
  const path = url.pathname;
190
214
  const read = request.method === 'GET' || request.method === 'HEAD';
191
215
  if (path === '/api/referrals/statement') return receiveStatement(request, env, url);
@@ -195,7 +219,8 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
195
219
  if (path === '/shop') return Response.redirect(`${url.origin}/shop/${url.search}`, 301);
196
220
  const cat = await catalogueOf();
197
221
  const shop = shopOf(cat);
198
- // The studio's protective policy closes kids studios.
222
+ if (path === '/api/shop/hook') return hook(request, env, cat, shop ?? checkShop({}).shop);
223
+ // Only the studio's chosen policy closes a kids studio.
199
224
  if (!shop || shop.audience === 'kids') {
200
225
  if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
201
226
  if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
@@ -203,15 +228,25 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
203
228
  return shop ? notFoundPage('This studio sells nothing.', cat) : null;
204
229
  }
205
230
  const ready = await readiness(env, shop);
231
+ if (read && url.searchParams.get('cancelled') !== '1' && ctx?.waitUntil && !ready.migration && KEY_SHAPE.test(String(env.STRIPE_KEY ?? ''))) {
232
+ ctx.waitUntil((async () => {
233
+ const player = await players.of(request, env);
234
+ if (player) await reconcileOrders(env, shop, player.id);
235
+ })().catch((error) => logOrderError(env, null, error)));
236
+ }
206
237
  try {
238
+ if (path === '/shop/' && read && url.searchParams.get('cancelled') === '1' && /^ord_[A-Za-z0-9]{20}$/.test(url.searchParams.get('order') ?? '') && !ready.migration) {
239
+ const player = await players.of(request, env);
240
+ if (player) await reconcileOrders(env, shop, player.id, { expireOpen: true, order: url.searchParams.get('order') });
241
+ }
207
242
  if (path === '/api/shop/hook') return await hook(request, env, cat, shop, ready);
208
243
  if (!read && !sameOrigin(request, url)) return fail(403, 'origin', 'only this site\'s own pages may do that');
209
244
  if (path === '/api/shop' && read) return await listRoute(request, env, url, cat, shop, ready);
210
245
  if (path === '/api/player/owns' && read) return await ownsRoute(request, env, url, cat, shop);
211
246
  if (path === '/api/shop/mine' && read) return await mineRoute(request, env, shop);
212
- if (path === '/api/shop/order' && read) return await orderRoute(request, env, url);
247
+ if (path === '/api/shop/order' && read) return await orderRoute(request, env, url, shop);
213
248
  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);
249
+ if (path === '/api/shop/buy' && request.method === 'POST') return await buyRoute(request, env, url, cat, shop, ready, ctx, requestAt);
215
250
  if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
216
251
  if (path === '/api/shop/used' && request.method === 'POST') return await usedRoute(request, env, shop);
217
252
  if (path === '/api/shop/refund' && request.method === 'POST') return await selfRefundRoute(request, env, shop, ready);
@@ -236,7 +271,7 @@ async function listRoute(request, env, url, cat, shop, ready) {
236
271
  // The studio decides which known age bands see no shop.
237
272
  if ((v.band === 'child' && shop.policy.children === 'deny') || (v.band === 'teen' && shop.policy.teens === 'deny')) return json({ ok: true, open: false, kids: true, items: [] });
238
273
  return json({
239
- ok: true, open: ready.ready, ...(ready.ready ? {} : { missing: ready.missing, migration: ready.migration }), mode: ready.mode,
274
+ ok: true, keyless: !hasStripeKey(env), open: ready.ready || !ready.migration && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)) && shop.items.some((i) => i.price === 0), ...(ready.ready ? {} : { missing: ready.missing, migration: ready.migration }), mode: ready.mode,
240
275
  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,
241
276
  player: player ? { signedIn: !player.guest, guest: player.guest, name: player.name, band: v.band } : null,
242
277
  owns: v.owns.map((o) => o.key), items: v.items, nextCursor: v.nextCursor,
@@ -251,7 +286,7 @@ async function ownsRoute(request, env, url, cat, shop) {
251
286
  const facts = await serverFacts(env, cat, game, url.searchParams.get('server'));
252
287
  const advantage = new Set((shop.items ?? []).filter((i) => i.advantage).map((i) => i.id));
253
288
  const owns = (await ownsOf(env, p.id)).filter((o) => !(((facts.kids && !shop.policy.kidsAdvantages) || (facts.beginner && !shop.policy.beginnerAdvantages)) && advantage.has(o.item)));
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 })) });
289
+ 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, quantity: o.quantity })) });
255
290
  }
256
291
 
257
292
  async function mineRoute(request, env, shop) {
@@ -259,16 +294,35 @@ async function mineRoute(request, env, shop) {
259
294
  if (!p) return json({ ok: true, player: null, orders: [], owns: [] });
260
295
  const data = await shopDataOf(env, p.id);
261
296
  const names = new Map((shop.items ?? []).map((i) => [i.id, i.name]));
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)) });
297
+ const orders = [];
298
+ for (const o of data?.orders ?? []) {
299
+ for (const line of o.lines) {
300
+ if (line.kind === null) { const item = shop.items.find((i) => i.id === line.item); line.name = item?.name ?? line.name; line.kind = item?.kind ?? null; }
301
+ const used = await lineWasUsed(env, o.id, line);
302
+ line.refundable = (o.amount === 0 || hasStripeKey(env)) && refundableNow({ ...line, paidAt: o.paidAt }, used ? [{ item: line.item, used: true }] : [], shop);
303
+ line.shown = money(line.amount, o.currency);
304
+ }
305
+ orders.push({ ...o, name: o.lines.map((l) => `${l.name} × ${l.quantity}`).join(', '), shown: money(o.amount, o.currency), refundable: o.lines.length > 0 && o.lines.every((l) => l.refundable) });
306
+ }
307
+ return json({ ok: true, currency: shop.currency, refundDays: shop.refundDays, refundUsedItems: shop.policy.refundUsedItems, ageBand: data?.ageBand ?? null, orders: orders.reverse(), owns: data?.owns ?? [], badges: (data?.owns ?? []).filter((o) => o.key.startsWith('badge:')).map((o) => names.get(o.item) ?? o.key.slice(6)) });
263
308
  }
264
309
 
265
310
  /** The thanks page asks: is my order (of this session) paid yet? Only the buyer's own account sees it. */
266
- async function orderRoute(request, env, url) {
311
+ async function orderRoute(request, env, url, shop) {
267
312
  const p = await players.of(request, env);
268
313
  const session = url.searchParams.get('session') ?? '';
269
314
  if (!p || !/^cs_[A-Za-z0-9_]{1,200}$/.test(session)) return json({ ok: true, status: null });
270
315
  const o = await orderBySession(env, session);
271
316
  if (!o || o.player !== p.id) return json({ ok: true, status: null });
317
+ if (o.paid_at === null && KEY_SHAPE.test(String(env.STRIPE_KEY ?? '')) && !purchaseLimited(request, shop, p.id, 'verify')) {
318
+ try {
319
+ const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(o.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
320
+ if (session.id === o.session && (session.livemode === undefined || session.livemode === (o.mode === 'live')) && ['paid', 'no_payment_required'].includes(session.payment_status)) {
321
+ await paid(env, shop, session, (session.payment_intent?.latest_charge?.created ?? session.created ?? Math.floor(Date.now() / 1000)) * 1000);
322
+ Object.assign(o, await orderById(env, o.id));
323
+ }
324
+ } catch (error) { logOrderError(env, o.id, error); }
325
+ }
272
326
  return json({ ok: true, status: playerOrderStatus(o), item: o.item, game: o.game ?? null });
273
327
  }
274
328
 
@@ -295,41 +349,45 @@ async function ageRoute(request, env, shop) {
295
349
  return response;
296
350
  }
297
351
 
298
- export function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio, product = null }) {
352
+ export function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio, product = null, lines = null }) {
299
353
  const managed = shop.till === 'stripe-managed';
300
354
  // Managed Payments needs an eligible digital-goods tax code on every product (video games, downloaded).
301
- const taxCode = item.taxCode ?? (managed ? defaultTaxCode(item) : undefined);
355
+ const cart = lines ?? [{ item, amount, quantity: 1, product }];
302
356
  const back = game ? `&game=${encodeURIComponent(game)}` : '';
303
357
  return {
304
358
  mode: 'payment',
305
- line_items: [{
306
- quantity: 1,
307
- price_data: {
308
- currency: shop.currency, unit_amount: amount, tax_behavior: 'exclusive',
309
- // The catalog's Product (its name and tax code are Stripe's copy of shop.json's), or the item described inline.
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 } } }),
359
+ // Stripe payment Checkout allows up to 100 lines, each with a quantity:
360
+ // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-line_items
361
+ line_items: cart.map((line) => ({
362
+ quantity: line.quantity,
363
+ price_data: { currency: shop.currency, unit_amount: line.amount, tax_behavior: 'exclusive',
364
+ ...(line.product ? { product: line.product } : { product_data: {
365
+ name: line.item.name, ...(line.item.blurb ? { description: line.item.blurb } : {}),
366
+ ...(line.item.taxCode || managed ? { tax_code: line.item.taxCode ?? defaultTaxCode(line.item) } : {}),
367
+ metadata: { item: line.item.id.length <= 500 ? line.item.id : undefined, line: line.id },
368
+ } }),
311
369
  },
312
- }],
370
+ })),
313
371
  client_reference_id: player,
314
372
  // Stripe metadata values allow 500 characters; the order holds the complete item id.
315
373
  // 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 } },
374
+ metadata: { homie: 'shop-v1', origin, studio: String(studio ?? '').length <= 500 ? String(studio ?? '') : undefined, order: order.id, item: item.id.length <= 500 ? item.id : undefined, ...(game ? { game } : {}), ...(order.via ? { via: order.via } : {}), ...(parent ? { parent: '1' } : {}) },
375
+ payment_intent_data: { metadata: { homie: 'shop-v1', origin, order: order.id, item: item.id.length <= 500 ? item.id : undefined } },
318
376
  success_url: `${origin}/shop/thanks?session_id={CHECKOUT_SESSION_ID}${back}`,
319
- cancel_url: `${origin}/shop/?cancelled=1${back}`,
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,
322
- // The EU and UK immediate-delivery acknowledgement (Consumer Rights Directive Art 16(m)), beside the pay button.
377
+ cancel_url: `${origin}/shop/?cancelled=1&order=${order.id}${back}`,
378
+ // Keep Stripe’s default 24-hour Checkout lifetime: https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-expires_at
379
+ expires_at: order.expiresAt ?? Math.floor((order.createdAt ?? Date.now()) / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60,
380
+ // The studio can opt into immediate-delivery acknowledgement beside the pay button.
323
381
  custom_text: { submit: { message: checkoutMessage(shop, String(studio ?? 'this studio'), parent) } },
324
- // The kit's own tills: Stripe Tax on (the studio is the seller), or Managed Payments (Stripe is the seller of record
325
- // and computes the tax itself, so automatic_tax and adaptive pricing must not be sent).
326
- ...(managed ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: true }, adaptive_pricing: { enabled: false } }),
382
+ // The studio can enable Stripe Tax, or choose Managed Payments (Stripe is the seller of record
383
+ // and computes tax itself). Disable adaptive pricing to preserve the file price.
384
+ ...(managed ? { managed_payments: { enabled: true } } : { ...(shop.automaticTax ? { automatic_tax: { enabled: true } } : {}), adaptive_pricing: { enabled: false } }),
327
385
  };
328
386
  }
329
387
 
330
388
  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 });
389
+ const reason = redactStripe(error?.message ?? error, env);
390
+ console.warn('Shop reconciliation', { order, reason, status: error?.status ?? null, code: redactStripe(error?.code, env) });
333
391
  }
334
392
 
335
393
  /** At most three reads in parallel, claimed before I/O. Old unresolved rows back off up to an hour.
@@ -337,19 +395,49 @@ function logOrderError(env, order, error) {
337
395
  * Missing sessions: https://docs.stripe.com/error-codes#resource-missing
338
396
  * Payment time: https://docs.stripe.com/api/charges/object#charge_object-created
339
397
  */
340
- export async function reconcileOrders(env, shop, player) {
398
+ export async function reconcileOrders(env, shop, player = null, { expireOpen = false, order = null, before = Infinity } = {}) {
399
+ if (!shop || !KEY_SHAPE.test(String(env?.STRIPE_KEY ?? '')) || !(await migrated(env))) return;
341
400
  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 ?? [];
401
+ // Filter eligibility before LIMIT. Unrecorded payments precede old paid rows needing repair.
402
+ const params = [shopMode(env), now, Number.isFinite(before) ? before : Number.MAX_SAFE_INTEGER, ...(player ? [player] : []), ...(order ? [order] : [])];
403
+ const query = (status, limit) => env.DB.prepare(`SELECT * FROM shop_orders ${order ? '' : `INDEXED BY ${player ? 'shop_orders_attention_player' : 'shop_orders_attention'}`} WHERE attention = 1 AND mode = ?1
404
+ AND status = '${status}'
405
+ AND (created_at < ?3 ${expireOpen ? 'OR expires_at <= ?2' : ''}) ${player ? 'AND player = ?4' : ''} ${order ? `AND id = ?${player ? 5 : 4}` : ''}
406
+ ${expireOpen ? '' : "AND (created_at <= ?2 - 60000 OR expires_at <= ?2 OR status = 'processing') AND updated_at <= ?2 - MIN(3600000, MAX(60000, CAST((?2 - created_at) / 24 AS INTEGER)))"}
407
+ ORDER BY updated_at, id LIMIT ${limit}`).bind(...params).all();
408
+ let rows = (await query('started', 3)).results ?? [];
409
+ if (!expireOpen) {
410
+ rows.push(...((await query('processing', 3)).results ?? []));
411
+ rows.sort((a, b) => a.updated_at - b.updated_at || a.id.localeCompare(b.id));
412
+ rows = rows.slice(0, 3);
413
+ if (rows.length < 3) rows.push(...((await query('paid', 3 - rows.length)).results ?? []));
414
+ }
344
415
  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;
416
+ if (!row.session) {
417
+ if (row.status === 'paid' || (row.expires_at ?? row.created_at + 1860000) + 60000 < now) await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1 AND session IS NULL').bind(row.id).run();
418
+ return;
419
+ }
420
+ if (expireOpen && row.status !== 'started') return;
421
+ if (row.status === 'paid' && !row.player) { await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(row.id).run(); return; }
422
+ const claimedAt = Math.max(now, Number(row.updated_at) + 1);
423
+ 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', 'paid')").bind(row.id, row.updated_at, claimedAt).run();
424
+ if (!claim.meta?.changes && !(expireOpen && order)) return;
347
425
  try {
348
- const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
426
+ let session;
427
+ if (expireOpen) {
428
+ try { session = await expireCheckoutSession(env, row.session, { timeout: 2000 }); }
429
+ catch (error) {
430
+ if (!(error instanceof StripeError) || error.status !== 400) throw error;
431
+ // Already complete or expired: read Stripe's outcome instead of freeing an unconfirmed payment.
432
+ session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
433
+ }
434
+ } else session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
349
435
  if (!session || session.id !== row.session || (typeof session.livemode === 'boolean' && session.livemode !== (row.mode === 'live'))) throw new Error('Stripe session identity or mode mismatch');
436
+ // A buyer abandoning their own open session releases only on Stripe's confirmation.
437
+ // https://docs.stripe.com/api/checkout/sessions/expire
350
438
  const payment = session.payment_intent;
351
439
  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;
440
+ const time = session.payment_status === 'no_payment_required' ? session.created : payment?.latest_charge?.created ?? session.created;
353
441
  // Missing payment time remains unresolved; never charge an old payment to this month's allowance.
354
442
  if (Number.isSafeInteger(time) && time > 0 && time * 1000 <= now) await paid(env, shop, session, time * 1000);
355
443
  } else if (session.status === 'expired' || payment?.status === 'canceled' || (session.status === 'complete' && payment?.status === 'requires_payment_method' && payment.last_payment_error)) {
@@ -359,26 +447,43 @@ export async function reconcileOrders(env, shop, player) {
359
447
  logOrderError(env, row.id, error);
360
448
  // This key's account cannot see the session; a later verified payment still grants it.
361
449
  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();
450
+ await env.DB.prepare("UPDATE shop_orders SET attention = 0, status = CASE WHEN status = 'paid' THEN status ELSE 'missing' END, note = 'Stripe account has no such session; previous status: ' || status, updated_at = ?2 WHERE id = ?1 AND status IN ('started', 'processing', 'paid')").bind(row.id, now).run();
363
451
  } else {
364
452
  // Keep the atomic claim, but retry failures after roughly a minute, regardless of row age.
365
453
  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();
454
+ await env.DB.prepare("UPDATE shop_orders SET updated_at = ?3 WHERE id = ?1 AND updated_at = ?2 AND status IN ('started', 'processing', 'paid')").bind(row.id, claimedAt, now - interval + 60000).run();
367
455
  }
368
456
  }
369
457
  }).map((work, i) => work.catch((error) => logOrderError(env, rows[i].id, error))));
370
458
  }
371
459
 
372
460
  /** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
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.') };
461
+ async function startCheckout(env, url, cat, shop, item, { player, game, via, amount, parent = false, lines = null, requestAt = Date.now() }) {
462
+ lines ??= [{ item, amount, quantity: 1 }];
463
+ const keyless = amount > 0 && !hasStripeKey(env);
464
+ if (keyless && (lines.length !== 1 || fullerReason(shop))) return { error: fail(409, 'one-item', fullerReason(shop) ?? 'Buy one item at a time. A combined cart needs the fuller checkout connection.') };
465
+ const link = keyless ? linkConfig(env)?.items.find((l) => l.item === item.id) : null;
466
+ if (keyless && (!link || !paymentLinkURL(link.url))) return { error: fail(503, 'payment-links', MISSING_WORDS['payment-links']) };
467
+ if (keyless && item.price === 'choose' && lines[0].quantity !== 1) return { error: fail(400, 'quantity', 'A tip is one chosen amount; choose it on Stripe.') };
468
+ if (keyless) lines = lines.map((l) => ({ ...l, item: { ...l.item, paymentLink: link } }));
469
+ try { if (via) referralShare(amount, shop.referrals.rate); } catch { return { error: fail(400, 'arithmetic', 'The referral share exceeds safe integer arithmetic.') }; }
375
470
  const order = { id: newOrderId(), player: player.id, via: via ?? null, amount, currency: shop.currency };
376
471
  const now = Date.now();
377
472
  order.createdAt = now;
378
- order.expiresAt = Math.floor(now / 1000) + 31 * 60;
473
+ order.expiresAt = Math.floor(now / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
379
474
  // 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();
475
+ const reservation = () => env.DB.prepare("INSERT INTO shop_orders (id, player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at, expires_at) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'started', ?9, ?10, ?11, ?11, ?14 WHERE ?5 = 0 OR ?12 IS NULL OR ?5 <= ?12 - (SELECT COALESCE(SUM(amount - refunded_net), 0) FROM shop_orders WHERE player = ?2 AND mode = ?8 AND ((status IN ('paid', 'disputed') AND paid_at >= ?13) OR (status IN ('started', 'processing') AND (session IS NOT NULL OR COALESCE(expires_at, created_at + 1860000) + 60000 > ?11))))")
476
+ .bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, shopMode(env), parent ? 1 : 0, order.via, now, shop.capPerPlayerMonth, Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1), order.expiresAt * 1000);
477
+ lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
478
+ const reserve = async () => {
479
+ const results = await env.DB.batch([reservation(),
480
+ ...lines.map((line, i) => env.DB.prepare("INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id = ?2)")
481
+ .bind(line.id, order.id, i, line.item.id, line.quantity, line.amount, line.amount * line.quantity, JSON.stringify(line.item))),
482
+ env.DB.prepare('UPDATE shop_orders SET referral_terms = ?2, checkout_player = player WHERE id = ?1').bind(order.id, shop.referrals ? JSON.stringify(shop.referrals) : null),
483
+ ]);
484
+ return results[0];
485
+ };
486
+ await reconcileOrders(env, shop, player.id, { expireOpen: true, before: Math.min(requestAt, now - 60000) });
382
487
  let reserved = await reserve();
383
488
  const reconciled = !reserved.meta?.changes;
384
489
  if (!reserved.meta?.changes) {
@@ -386,27 +491,43 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
386
491
  reserved = await reserve();
387
492
  }
388
493
  if (!reserved.meta?.changes) return { error: fail(403, 'cap', WAY_WORDS.cap, { way: 'cap' }) };
494
+ if (amount === 0) {
495
+ const id = `cs_free_${order.id}`;
496
+ await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1').bind(order.id, id).run();
497
+ await paid(env, shop, { id, metadata: { order: order.id }, client_reference_id: player.id, currency: shop.currency, amount_subtotal: 0, amount_total: 0, payment_status: 'no_payment_required' });
498
+ return { order, url: `${url.origin}/shop/thanks?session_id=${id}`, session: id, reconciled };
499
+ }
500
+ if (keyless) {
501
+ const checkout = new URL(link.url); checkout.searchParams.set('client_reference_id', order.id);
502
+ return { order, url: checkout.href, reconciled };
503
+ }
389
504
  let session = null;
390
- const mode = modeOf(env.STRIPE_KEY);
505
+ const mode = shopMode(env);
391
506
  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 });
507
+ const useCatalog = Array.isArray(shop.catalog) && shop.catalog.includes(mode);
508
+ for (const line of lines) line.product = useCatalog ? await productIdOf(cat.studio?.slug, line.item.id) : null;
509
+ await env.DB.batch(lines.map((line) => env.DB.prepare('UPDATE shop_order_lines SET snapshot = ?2 WHERE id = ?1').bind(line.id, JSON.stringify({ ...line.item, stripeProduct: line.product }))));
510
+ // Start the provider lifetime at session creation, after reservation/expiry I/O. At Stripe's
511
+ // lower bound the timestamp is computed immediately before the HTTP call.
512
+ const params = () => {
513
+ order.expiresAt = Math.floor(Date.now() / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
514
+ return sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, lines });
515
+ };
394
516
  try {
395
- session = await createCheckoutSession(env, params(product), { idempotencyKey: `checkout-${order.id}` });
517
+ session = await createCheckoutSession(env, params(), { idempotencyKey: `checkout-${order.id}` });
396
518
  } catch (error) {
397
- // shop.json says the catalog is made in this mode, but Stripe has no such Product (made in the sandbox only, or
398
- // archived by hand): the item described inline still sells, and `homie-studio shop catalog` says what to make.
399
- if (!product || !isMissingProduct(error)) throw error;
400
- session = await createCheckoutSession(env, params(null), { idempotencyKey: `checkout-${order.id}-inline` });
519
+ if (!useCatalog || !isMissingProduct(error)) throw error;
520
+ for (const line of lines) line.product = null;
521
+ session = await createCheckoutSession(env, params(), { idempotencyKey: `checkout-${order.id}-inline` });
401
522
  }
402
523
  if (typeof session?.id !== 'string' || typeof session?.url !== 'string' || !/^https:\/\//.test(session.url) && !/^http:\/\/(127\.0\.0\.1|localhost)/.test(session.url)) {
403
524
  await env.DB.prepare("UPDATE shop_orders SET status = 'failed', updated_at = ?2 WHERE id = ?1").bind(order.id, Date.now()).run();
404
525
  return { error: fail(502, 'stripe', 'Stripe answered without a checkout page. Nothing was charged.') };
405
526
  }
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();
527
+ await env.DB.prepare('UPDATE shop_orders SET session = ?2, updated_at = ?3, expires_at = ?4 WHERE id = ?1').bind(order.id, session.id, Date.now(), (session.expires_at ?? order.expiresAt) * 1000).run();
407
528
  } catch (error) {
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();
409
- const why = error instanceof StripeError ? error.message : 'Stripe did not answer';
529
+ await env.DB.prepare("UPDATE shop_orders SET status = 'failed', note = ?2, updated_at = ?3 WHERE id = ?1").bind(order.id, redactStripe(error?.code ?? 'stripe', env).slice(0, 60), Date.now()).run();
530
+ const why = error instanceof StripeError ? redactStripe(error.message, env) : 'Stripe did not answer';
410
531
  return { error: fail(502, 'stripe', `Stripe could not open the checkout (${why}). Nothing was charged.`) };
411
532
  }
412
533
  return { order, url: session.url, session: session.id, reconciled };
@@ -419,7 +540,7 @@ async function viaFor(request, env, url, shop, player) {
419
540
  if (!a || a.via === url.hostname.replace(/^www\./, '')) return null;
420
541
  if (shop.referrals.windowDays !== null && Date.now() - a.at > Math.round(shop.referrals.windowDays * DAY)) return null;
421
542
  // New players only: an account made before the arrival was already this studio's player.
422
- if (player.since !== null && player.since < a.at - 60_000) return null;
543
+ if (shop.referralNewPlayersOnly && player.since !== null && player.since < a.at - 60_000) return null;
423
544
  return (await referrerOk(a.via)) ? a.via : null;
424
545
  }
425
546
 
@@ -437,26 +558,40 @@ function purchaseLimited(request, shop, player, kind) {
437
558
  return account || address;
438
559
  }
439
560
 
440
- async function buyRoute(request, env, url, cat, shop, ready, ctx) {
561
+ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
441
562
  const b = await bodyOf(request, requestSize(shop));
442
563
  if (b.error) return b.error;
443
- if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
444
- const item = shop.items.find((i) => i.id === b.body.item);
445
- if (!item) return fail(404, 'item', 'no such item here');
564
+ const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0));
565
+ if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
566
+ const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
567
+ // Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
568
+ if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
446
569
  const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
447
- if (item.game && game && item.game !== game) return fail(400, 'item', 'that item belongs to another game');
448
570
  const facts = await serverFacts(env, cat, game, b.body.server);
449
571
  let player = await accountOf(request, env);
450
572
  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 });
452
- const row = v.items.find((i) => i.id === item.id);
453
- const way = facts.kids && !shop.policy.kidsServer ? 'kids' : row?.way ?? (item.advantage && facts.beginner ? 'beginner' : 'closed');
454
- if (way !== 'checkout') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
455
- let amount = item.price;
456
- if (item.kind === 'tip') {
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)}`}.`);
573
+ const lines = [];
574
+ let amount = 0;
575
+ const seen = new Set();
576
+ for (const entry of requested) {
577
+ const item = shop.items.find((i) => i.id === entry?.item);
578
+ if (!item) return fail(404, 'item', 'no such item here');
579
+ const quantity = entry.quantity ?? 1;
580
+ if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
581
+ if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
582
+ seen.add(item.id);
583
+ const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
584
+ const way = facts.kids && !shop.policy.kidsServer ? 'kids' : v.items[0]?.way ?? 'beginner';
585
+ if (way !== 'checkout') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
586
+ const unit = item.price === 'choose' ? (entry.amount ?? (!hasStripeKey(env) ? item.min : undefined)) : item.price;
587
+ if (amountError(unit, shop.currency)) return fail(400, 'amount', amountError(unit, shop.currency));
588
+ if (item.price === 'choose' && (unit < item.min || (item.max !== null && unit > item.max))) return fail(400, 'amount', 'This amount is outside the studio tip range.');
589
+ const total = BigInt(unit) * BigInt(quantity) + BigInt(amount);
590
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) return fail(400, 'arithmetic', 'The order total exceeds safe integer arithmetic.');
591
+ amount = Number(total);
592
+ lines.push({ item, amount: unit, quantity });
459
593
  }
594
+ const item = lines[0].item;
460
595
  let cookies = [];
461
596
  if (!player) {
462
597
  const guest = await players.shopGuest(request, env, url, shop.guestBuyersPerAddressPerHour);
@@ -465,9 +600,9 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx) {
465
600
  cookies = guest.session.cookies;
466
601
  }
467
602
  const via = await viaFor(request, env, url, shop, player);
468
- const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount });
603
+ const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount, lines, requestAt });
469
604
  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)));
605
+ if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
471
606
  for (const cookie of cookies) response.headers.append('set-cookie', cookie);
472
607
  return response;
473
608
  }
@@ -477,17 +612,29 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
477
612
  const b = await bodyOf(request, requestSize(shop));
478
613
  if (b.error) return b.error;
479
614
  if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
480
- const item = shop.items.find((i) => i.id === b.body.item);
481
- if (!item) return fail(404, 'item', 'no such item here');
615
+ const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1 }];
616
+ if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
482
617
  const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
483
618
  const facts = await serverFacts(env, cat, game, b.body.server);
484
619
  const player = await accountOf(request, env);
485
620
  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';
488
- if (way !== 'ask-a-parent') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
621
+ const cart = [], seen = new Set();
622
+ for (const entry of requested) {
623
+ const item = shop.items.find((i) => i.id === entry?.item);
624
+ if (!item) return fail(404, 'item', 'no such item here');
625
+ const quantity = entry.quantity ?? 1;
626
+ if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
627
+ if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
628
+ seen.add(item.id);
629
+ const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
630
+ const way = facts.kids && !shop.policy.kidsServer ? 'kids' : v.items[0]?.way ?? 'closed';
631
+ if (way !== 'ask-a-parent') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
632
+ cart.push({ item: item.id, quantity });
633
+ }
634
+ const item = shop.items.find((i) => i.id === cart[0].item);
635
+ cart[0].via = await viaFor(request, env, url, shop, player);
489
636
  const token = randomToken();
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();
637
+ await env.DB.prepare('INSERT INTO shop_parent_links (hash, player, item, game, order_id, expires_at, cart) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?6)').bind(await sha256Hex(token), player.id, item.id, game, Date.now() + 7 * DAY, JSON.stringify(cart)).run();
491
638
  return json({ ok: true, link: `${url.origin}/shop/parent/${token}`, expiresInDays: 7 });
492
639
  }
493
640
 
@@ -495,34 +642,44 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
495
642
  async function parentRoute(request, env, url, cat, shop, ready, token, ctx) {
496
643
  if (!TOKEN.test(token)) return notFoundPage('This link does not work.', cat);
497
644
  const row = await env.DB.prepare('SELECT * FROM shop_parent_links WHERE hash = ?1').bind(await sha256Hex(token)).first();
498
- const item = row ? shop.items.find((i) => i.id === row.item) : null;
645
+ const cart = row ? row.cart ? JSON.parse(row.cart) : [{ item: row.item, quantity: 1 }] : [];
646
+ const lines = cart.map((entry) => ({ item: shop.items.find((i) => i.id === entry.item), quantity: entry.quantity }));
647
+ const item = lines[0]?.item;
648
+ if (lines.some((line) => !line.item)) return parentPage(cat, shop, { gone: true });
499
649
  if (!row || !item || Number(row.expires_at) < Date.now()) return parentPage(cat, shop, { gone: true });
500
650
  const kid = await players.get(env, row.player);
501
651
  if (!kid) return parentPage(cat, shop, { gone: true });
502
652
  const done = row.order_id ? await orderById(env, row.order_id) : null;
503
- if (done?.status === 'paid') return parentPage(cat, shop, { item, name: kid.name, paid: true });
504
- if (request.method === 'GET' || request.method === 'HEAD') return parentPage(cat, shop, { item, name: kid.name, token, open: ready.ready });
653
+ if (done?.status === 'paid') return parentPage(cat, shop, { item, lines, name: kid.name, paid: true });
654
+ if (request.method === 'GET' || request.method === 'HEAD') return parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready });
505
655
  if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403 });
506
656
  let form = null;
507
657
  try { form = await request.formData(); } catch { form = null; }
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.' });
509
- if (!ready.ready) return parentPage(cat, shop, { item, name: kid.name, token, open: false });
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.' });
658
+ if (String(form?.get('grownup') ?? '') !== 'yes') return parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready, said: 'Tick the box to say you are their parent or guardian and an adult.' });
659
+ if (!ready.ready) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: false });
660
+ if (purchaseLimited(request, shop, row.player, 'parentpay')) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: 'One moment: too many tries.' });
511
661
  const band = await bandOfPlayer(env, row.player);
512
662
  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 });
663
+ let amount = 0;
664
+ for (const [index, line] of lines.entries()) {
665
+ const it = line.item;
666
+ const owned = it.gives.length > 0 && it.gives.every((key) => owns.some((o) => o.key === key && o.item === it.id));
667
+ if (wayFor(it, { player: kid, band, owned, spent: 0, cap: null, policy: shop.policy }) !== 'ask-a-parent') return parentPage(cat, shop, { gone: true });
668
+ line.amount = it.price === 'choose' ? parseAmount(form?.get(lines.length === 1 ? 'amount' : `amount_${index}`), currencyScale(shop.currency)) : it.price;
669
+ const invalid = amountError(line.amount, shop.currency);
670
+ if (invalid || it.price === 'choose' && (line.amount < it.min || it.max !== null && line.amount > it.max)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: invalid ?? 'This amount is outside the studio range.' });
671
+ const total = BigInt(amount) + BigInt(line.amount) * BigInt(line.quantity);
672
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
673
+ amount = Number(total);
674
+ }
675
+ const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game, via: cart[0]?.via ?? null, amount, parent: true, lines });
676
+ if (r.error) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
520
677
  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)));
678
+ if (!r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, row.player).catch((error) => logOrderError(env, r.order.id, error)));
522
679
  return Response.redirect(r.url, 303);
523
680
  }
524
681
 
525
- /** The game says the player used something (equipped a skin, played the pass): it is no longer refundable by the player. */
682
+ /** Mark use for studios that choose to exclude used items from player refunds. */
526
683
  async function usedRoute(request, env, shop) {
527
684
  const b = await bodyOf(request, requestSize(shop));
528
685
  if (b.error) return b.error;
@@ -534,13 +691,20 @@ async function usedRoute(request, env, shop) {
534
691
  return json({ ok: true, used: Number(r?.meta?.changes ?? 0) });
535
692
  }
536
693
 
694
+ async function lineWasUsed(env, order, line) {
695
+ if (await migrated(env)) return env.DB.prepare('SELECT 1 FROM entitlements e WHERE order_id = ?1 AND used_at IS NOT NULL AND (item = ?2 OR key IN (SELECT key FROM shop_entitlement_lines WHERE line_id = ?3)) LIMIT 1').bind(order, line.item, line.id).first();
696
+ return env.DB.prepare('SELECT 1 FROM entitlements WHERE order_id = ?1 AND item = ?2 AND used_at IS NOT NULL LIMIT 1').bind(order, line.item).first();
697
+ }
698
+
537
699
  function refundableNow(order, owns, shop, now = Date.now()) {
538
700
  if (shop.refundDays === null || order.status !== 'paid' || order.paidAt === null) return false;
701
+ // A tip is a gift, not an item: a player never takes one back by themselves. The studio can still refund any order from its office.
702
+ if (!(order.kind ?? shop.items.find((it) => it.id === order.item)?.kind) || (order.kind ?? shop.items.find((it) => it.id === order.item)?.kind) === 'tip') return false;
539
703
  if (now - order.paidAt > Math.round(shop.refundDays * DAY)) return false;
540
704
  return shop.policy.refundUsedItems || !owns.some((o) => o.item === order.item && o.used);
541
705
  }
542
706
 
543
- /** A player's own refund: an unused item, within the studio's refund days (fewer disputes, and the respected norm). */
707
+ /** A player's own refund follows the studio window and used-item setting, excluding tips. */
544
708
  async function selfRefundRoute(request, env, shop, ready) {
545
709
  const b = await bodyOf(request, requestSize(shop));
546
710
  if (b.error) return b.error;
@@ -548,11 +712,15 @@ async function selfRefundRoute(request, env, shop, ready) {
548
712
  if (!p) return fail(401, 'account', 'sign in first');
549
713
  const o = await orderById(env, b.body.order);
550
714
  if (!o || o.player !== p.id) return fail(404, 'order', 'no such order on this account');
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();
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']);
554
- const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player' });
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);
715
+ const lines = (await orderLines(env, o.id)).filter((l) => !b.body.line || l.id === b.body.line);
716
+ if (!lines.length) return fail(404, 'line', 'no such line on this order');
717
+ for (const line of lines) {
718
+ const used = await lineWasUsed(env, o.id, line);
719
+ if (!refundableNow({ ...lineView(line), paidAt: o.paid_at }, used ? [{ item: line.item, used: true }] : [], shop)) return fail(403, 'not-refundable', 'Ask the studio for this refund. Tips are not refundable by the player; items follow the studio refund settings.');
720
+ }
721
+ if (Number(o.amount) !== 0 && !hasStripeKey(env)) return fail(409, 'ask-studio', 'Ask the studio to refund this payment in Stripe.');
722
+ const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player', line: b.body.line });
723
+ if (r.held) return json({ ...r, error: r.error === 'pending' ? 'pending' : 'held', message: r.error === 'pending' ? r.message : 'The studio approves this refund in Stripe first. Your money comes back once they do, and the item leaves your account then.' }, 202);
556
724
  return json(r, r.ok ? 200 : 502);
557
725
  }
558
726
 
@@ -560,54 +728,131 @@ async function selfRefundRoute(request, env, shop, ready) {
560
728
 
561
729
  async function hook(request, env, cat, shop, ready) {
562
730
  if (request.method !== 'POST') return fail(405, 'method', 'POST only');
563
- if (Number(request.headers.get('content-length') ?? 0) > 512 * 1024) return fail(413, 'too-large', 'too large');
564
- const payload = await request.text();
731
+ const maxBytes = Number(env.SHOP_WEBHOOK_BYTES ?? 512 * 1024);
732
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) return fail(503, 'setting', 'SHOP_WEBHOOK_BYTES is a positive whole number.');
733
+ const payload = await boundedText(request, maxBytes);
734
+ if (payload === null) return fail(413, 'too-large', 'The studio webhook byte limit was reached.');
565
735
  const v = await verifyWebhook(payload, request.headers.get('stripe-signature'), env.STRIPE_WEBHOOK_SECRET);
566
736
  if (!v.ok) return fail(v.why === 'no-secret' ? 503 : 400, v.why, 'not a webhook from this studio\'s Stripe');
567
- if (!(await migrated(env))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
568
737
  const ev = v.event;
569
- // A test event never touches a live shop's books, nor a live one a test shop's.
570
- 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' });
571
- 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 });
572
738
  const obj = ev.data?.object ?? {};
573
- let did = 'ignored';
574
- switch (ev.type) {
575
- case 'checkout.session.completed':
576
- case 'checkout.session.async_payment_succeeded':
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
- }
582
- break;
583
- case 'checkout.session.async_payment_failed':
584
- case 'checkout.session.expired':
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();
586
- did = 'closed';
587
- break;
588
- case 'charge.refunded':
589
- if (obj.refunded === true || Number(obj.amount_refunded) >= Number(obj.amount)) did = await refunded(env, String(obj.payment_intent ?? ''), null);
590
- break;
591
- case 'refund.created':
592
- case 'refund.updated':
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 ?? ''));
739
+ const needed = ['checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired', 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed'];
740
+ if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
741
+ if (typeof ev.livemode === 'boolean' && (hasStripeKey(env) || linkConfig(env)) && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
742
+ try {
743
+ const sessionEvent = ev.type.startsWith('checkout.session.');
744
+ if (!sessionEvent) await rememberMoneyEvent(env, ev);
745
+ if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({ ok: true, did: 'mismatch' });
746
+ let known = sessionEvent ? await orderBySession(env, obj.id) : await orderByPayment(env, String(obj.payment_intent ?? ''));
747
+ if (!known) {
748
+ let metadata = obj.metadata;
749
+ let named = metadata?.order ? await orderById(env, metadata.order) : null;
750
+ // Charge metadata identifies foreign sales without a provider read. Untagged refunds/disputes
751
+ // need their PaymentIntent only when this database actually has an unrecorded payment.
752
+ if (hasStripeKey(env) && !named && !sessionEvent && obj.payment_intent && !metadata?.order &&
753
+ (!metadata?.homie || metadata.homie === 'shop-v1') && (!metadata?.origin || metadata.origin === new URL(request.url).origin) &&
754
+ await env.DB.prepare('SELECT 1 FROM shop_orders WHERE payment IS NULL AND paid_at IS NULL AND mode = ?1 LIMIT 1').bind(shopMode(env)).first()) {
755
+ let payment;
756
+ try { payment = await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(obj.payment_intent)}`); }
757
+ catch (error) {
758
+ if (error instanceof StripeError && error.status === 404 && error.code === 'resource_missing') return json({ ok: true, did: 'ignored' });
759
+ throw error;
760
+ }
761
+ metadata = payment.metadata;
762
+ named = metadata?.order ? await orderById(env, metadata.order) : null;
597
763
  }
598
- break;
599
- case 'charge.dispute.created':
600
- did = await disputed(env, String(obj.payment_intent ?? ''), String(obj.id ?? ''));
601
- break;
602
- case 'charge.dispute.closed':
603
- did = await disputeClosed(env, String(obj.payment_intent ?? ''), String(obj.status ?? ''));
604
- break;
605
- default:
606
- break;
764
+ if (!named) return json({ ok: true, did: !sessionEvent && !hasStripeKey(env) ? 'remembered' : 'ignored' });
765
+ if (!hasStripeKey(env) && !sessionEvent) return json({ ok: true, did: 'remembered' });
766
+ if (sessionEvent) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
767
+ if (!(await migrated(env))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
768
+ if (named.payment || named.mode !== shopMode(env)) return json({ ok: true, did: 'ignored' });
769
+ if (!named.session) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
770
+ const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(named.session)}`, { expand: ['payment_intent.latest_charge'] });
771
+ if (session.id !== named.session || !['paid', 'no_payment_required'].includes(session.payment_status)) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
772
+ if ((session.payment_intent?.id ?? session.payment_intent) !== obj.payment_intent) return json({ ok: true, did: 'ignored' });
773
+ await paid(env, shop, session, (session.payment_intent?.latest_charge?.created ?? session.created) * 1000);
774
+ known = await orderByPayment(env, obj.payment_intent);
775
+ if (!known) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
776
+ }
777
+ if (!(await migrated(env))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
778
+ if (await env.DB.prepare('SELECT 1 AS x FROM shop_events WHERE id = ?1').bind(ev.id).first()) {
779
+ if (['checkout.session.completed', 'checkout.session.async_payment_succeeded'].includes(ev.type) && ['paid', 'no_payment_required'].includes(ev.data?.object?.payment_status)) await paid(env, shop, ev.data.object);
780
+ return json({ ok: true, duplicate: true });
781
+ }
782
+ let did = 'ignored';
783
+ switch (ev.type) {
784
+ case 'checkout.session.completed':
785
+ case 'checkout.session.async_payment_succeeded':
786
+ if (ev.type === 'checkout.session.async_payment_succeeded' || obj.payment_status === 'paid' || obj.payment_status === 'no_payment_required') did = await paid(env, shop, obj);
787
+ else {
788
+ 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();
789
+ did = 'waiting';
790
+ }
791
+ break;
792
+ case 'checkout.session.async_payment_failed':
793
+ case 'checkout.session.expired':
794
+ 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();
795
+ did = 'closed';
796
+ break;
797
+ case 'charge.refunded':
798
+ case 'refund.created':
799
+ case 'refund.updated':
800
+ case 'refund.failed':
801
+ did = await syncRefunds(env, String(obj.payment_intent ?? ''));
802
+ break;
803
+ case 'charge.dispute.created':
804
+ did = await disputed(env, String(obj.payment_intent ?? ''), String(obj.id ?? ''));
805
+ if (!hasStripeKey(env)) for (const d of await rememberedMoney(env, obj.payment_intent, known.mode)) if (d.id === obj.id && ['lost', 'won', 'warning_closed'].includes(d.status)) did = await disputeClosed(env, obj.payment_intent, d.status);
806
+ break;
807
+ case 'charge.dispute.closed':
808
+ did = await disputeClosed(env, String(obj.payment_intent ?? ''), String(obj.status ?? ''));
809
+ break;
810
+ default:
811
+ break;
812
+ }
813
+ if (did === 'needs-keyed-recovery') return fail(503, did, 'This earlier multi-item checkout needs its original connection to recover tax per line. Reconnect it, then resend this event.');
814
+ if (did === 'missing-item' || did === 'unknown-order') return fail(503, did, did === 'missing-item' ? 'Restore this order item in shop.json to grant it; the office can refund its payment.' : 'Payment order is not recorded yet; retry this event.');
815
+ 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();
816
+ if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
817
+ return json({ ok: true, did });
818
+ } catch (error) {
819
+ logOrderError(env, null, error);
820
+ return fail(503, 'stripe-retry', 'Payment reconciliation is temporarily unavailable; retry this event.');
607
821
  }
608
- 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();
609
- if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
610
- return json({ ok: true, did });
822
+ }
823
+
824
+ /** Payment Links can be reopened. Each signed Session becomes its own order; the URL reference
825
+ * selects a frozen local intent, never an entitlement or a buyer-supplied price. */
826
+ async function bindLinkSession(env, session, mode) {
827
+ if (!/^ord_[A-Za-z0-9]{20}$/.test(session.client_reference_id ?? '') || !/^cs_[A-Za-z0-9_]+$/.test(session.id ?? '')) return false;
828
+ const source = await orderById(env, session.client_reference_id);
829
+ if (!source || source.mode !== mode) return false;
830
+ const lines = await orderLines(env, source.id);
831
+ if (lines.length !== 1 || !lines[0].snapshot) return false;
832
+ const item = JSON.parse(lines[0].snapshot), link = item.paymentLink;
833
+ if (!link || (session.payment_link?.id ?? session.payment_link) !== link.id || session.metadata?.revision !== link.revision || session.currency !== source.currency) return false;
834
+ const amount = session.amount_subtotal;
835
+ if (!Number.isSafeInteger(amount) || amount <= 0) return false;
836
+ const quantity = item.price === 'choose' ? 1 : amount / item.price;
837
+ if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > 999999) return false;
838
+ if (item.price === 'choose' && (amount < item.min || item.max !== null && amount > item.max)) return false;
839
+ // Claim the first session atomically. A repeated URL creates a separate paid order, never loses a payment.
840
+ await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1 AND session IS NULL').bind(source.id, session.id).run();
841
+ let order = await orderBySession(env, session.id);
842
+ if (!order) {
843
+ const id = `ord_${(await sha256Hex(session.id)).slice(0, 20)}`;
844
+ await env.DB.batch([
845
+ env.DB.prepare("INSERT INTO shop_orders (id, player, checkout_player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at, session, referral_terms) SELECT ?2, player, checkout_player, item, game, ?4, currency, till, mode, 'started', parent, via, ?5, ?5, ?3, referral_terms FROM shop_orders WHERE id = ?1 ON CONFLICT DO NOTHING").bind(source.id, id, session.id, amount, Date.now()),
846
+ env.DB.prepare('INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) VALUES (?1, ?2, 0, ?3, ?4, ?5, ?6, ?7) ON CONFLICT DO NOTHING').bind(`${id}_0`, id, item.id, quantity, amount / quantity, amount, lines[0].snapshot),
847
+ ]);
848
+ order = await orderBySession(env, session.id);
849
+ }
850
+ if (!order) return false;
851
+ if (order.paid_at === null) await env.DB.batch([
852
+ env.DB.prepare('UPDATE shop_orders SET amount = ?2 WHERE id = ?1 AND paid_at IS NULL').bind(order.id, amount),
853
+ env.DB.prepare('UPDATE shop_order_lines SET quantity = ?2, unit_amount = ?3, amount = ?4 WHERE order_id = ?1 AND EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND paid_at IS NULL)').bind(order.id, quantity, amount / quantity, amount),
854
+ ]);
855
+ return true;
611
856
  }
612
857
 
613
858
  /** A paid checkout: the order is paid, its entitlements granted, a referral line written. Idempotent. */
@@ -615,34 +860,159 @@ async function paid(env, shop, session, paidAt = Date.now()) {
615
860
  const o = await orderBySession(env, session.id);
616
861
  if (!o) return 'unknown-order';
617
862
  // The session must be the one this order opened, for this player and this price.
618
- if (session.metadata?.order !== o.id || (session.client_reference_id && session.client_reference_id !== o.player)) return 'mismatch';
863
+ const linkOrder = (await orderLines(env, o.id)).some((l) => l.snapshot && JSON.parse(l.snapshot).paymentLink);
864
+ if (!linkOrder && (session.metadata?.order !== o.id || ((o.checkout_player ?? o.player) && session.client_reference_id && session.client_reference_id !== (o.checkout_player ?? o.player)))) return 'mismatch';
619
865
  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';
621
- const item = shop.items.find((i) => i.id === o.item);
866
+ if (['refunded', 'lost', 'disputed'].includes(o.status)) {
867
+ if (o.payment) await syncRefunds(env, o.payment);
868
+ await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run();
869
+ return 'already';
870
+ }
871
+ const payment = typeof session.payment_intent === 'string'
872
+ ? !hasStripeKey(env) ? { id: session.payment_intent } : await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(session.payment_intent)}`, { expand: ['latest_charge'] }) : session.payment_intent;
873
+ let dispute = null;
874
+ if (hasStripeKey(env) && payment?.latest_charge?.disputed) {
875
+ const list = await stripeCall(env, 'GET', '/v1/disputes', { payment_intent: payment.id, limit: 100 });
876
+ if (!Array.isArray(list.data) || !list.data.length || list.has_more) throw new Error('Stripe dispute state is unavailable');
877
+ dispute = list.data.find((d) => d.status === 'lost') ?? list.data[0];
878
+ }
879
+ const lines = await orderLines(env, o.id);
880
+ if (!lines.length) throw new Error('Order has no lines');
622
881
  const now = Date.now();
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();
625
- if (!Number(res?.meta?.changes ?? 1)) return 'already';
882
+ const total = session.amount_total ?? Number(o.amount);
883
+ const tax = session.total_details?.amount_tax ?? total - Number(o.amount);
884
+ if (!Number.isSafeInteger(total) || !Number.isSafeInteger(tax) || total < 0 || tax < 0 || total !== Number(o.amount) + tax) return 'mismatch';
885
+ let stripeLines = null;
886
+ if (lines.length > 1 && tax) {
887
+ if (!hasStripeKey(env)) return 'needs-keyed-recovery';
888
+ // Use Stripe's actual tax per line, never spread tax across products with different tax codes.
889
+ // https://docs.stripe.com/api/checkout/sessions/line_items
890
+ const response = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(session.id)}/line_items`, { limit: 100, expand: ['data.price.product'] });
891
+ if (response.has_more || response.data?.length !== lines.length) throw new Error('Stripe line totals are incomplete');
892
+ stripeLines = [...response.data];
893
+ }
626
894
  const writes = [];
627
- if (o.player && item) writes.push(...grantStatements(env, o, item, now));
628
- // An item that is no longer in shop.json still reaches its buyer: the keys it gave when it was sold are not known
629
- // here, so the order is paid and the office shows it for the owner to settle by hand.
630
- if (o.via) writes.push(...(await lineFor(env, o, shop, { now })));
631
- if (writes.length) await env.DB.batch(writes);
632
- return item ? 'paid' : 'paid-unknown-item';
895
+ let lineTotal = 0;
896
+ for (const line of lines) {
897
+ // The database schema step creates lines for old orders. Only those lack a snapshot.
898
+ const item = line.snapshot ? JSON.parse(line.snapshot) : shop.items.find((i) => i.id === line.item);
899
+ if (!item && o.status === 'paid') { await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run(); return 'already'; }
900
+ if (!item) {
901
+ await env.DB.prepare("UPDATE shop_orders SET payment = COALESCE(payment, ?2), total = ?4, tax = ?5, note = 'missing-item: restore the catalog definition to grant this paid order', updated_at = ?3 WHERE id = ?1").bind(o.id, typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id ?? null, now, total, tax).run();
902
+ return 'missing-item';
903
+ }
904
+ let gross = lines.length === 1 ? total : Number(line.amount);
905
+ if (stripeLines) {
906
+ const index = stripeLines.findIndex((l) => l.quantity === line.quantity && l.amount_subtotal === line.amount &&
907
+ (l.price?.product?.metadata?.line === line.id || item.stripeProduct && (l.price?.product?.id ?? l.price?.product) === item.stripeProduct || l.price?.product?.metadata?.homie_item === line.item || l.price?.product?.metadata?.item === line.item));
908
+ if (index < 0) throw new Error('Stripe line identity mismatch');
909
+ gross = stripeLines.splice(index, 1)[0].amount_total;
910
+ }
911
+ if (!Number.isSafeInteger(gross) || gross < line.amount) throw new Error('Invalid Stripe line total');
912
+ lineTotal += gross;
913
+ if (o.player && dispute?.status !== 'lost' && line.status !== 'refunded') writes.push(...grantStatements(env, o, item, Number(o.paid_at ?? now), line));
914
+ writes.push(env.DB.prepare("UPDATE shop_order_lines SET status = 'paid', total = ?2, snapshot = COALESCE(snapshot, ?3) WHERE id = ?1 AND status != 'refunded'").bind(line.id, gross, JSON.stringify(item)));
915
+ }
916
+ if (lineTotal !== total) throw new Error('Stripe line totals do not match order');
917
+ if (o.via && o.paid_at === null) writes.push(...(await lineFor(env, o, o.referral_terms ? { referrals: JSON.parse(o.referral_terms) } : shop, { now })));
918
+ writes.push(env.DB.prepare("UPDATE referral_lines SET original_share = share WHERE order_id = ?1 AND original_share IS NULL").bind(o.id));
919
+ writes.push(env.DB.prepare("UPDATE shop_orders SET status = 'paid', payment = ?2, tax = ?3, total = ?4, paid_at = ?5, updated_at = ?6 WHERE id = ?1 AND paid_at IS NULL")
920
+ .bind(o.id, typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id ?? null, tax, total, paidAt, now));
921
+ // D1 batch is transactional: a failure rolls back payment, every grant and the referral together.
922
+ // https://developers.cloudflare.com/d1/worker-api/d1-database/#batch
923
+ const result = await env.DB.batch(writes);
924
+ const paymentId = payment?.id ?? (typeof session.payment_intent === 'string' ? session.payment_intent : null);
925
+ if (dispute) {
926
+ await disputed(env, paymentId, dispute.id);
927
+ if (['lost', 'won', 'warning_closed'].includes(dispute.status)) await disputeClosed(env, paymentId, dispute.status);
928
+ }
929
+ if (paymentId) {
930
+ await syncRefunds(env, paymentId);
931
+ if (!hasStripeKey(env)) for (const d of await rememberedMoney(env, paymentId, o.mode)) if (d.id.startsWith('dp_')) {
932
+ await disputed(env, paymentId, d.id);
933
+ if (['lost', 'won', 'warning_closed'].includes(d.status)) await disputeClosed(env, paymentId, d.status);
934
+ }
935
+ }
936
+ await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run();
937
+ return result.at(-1)?.meta?.changes ? 'paid' : 'already';
633
938
  }
634
939
 
635
- async function refunded(env, payment, refundId) {
636
- const o = payment ? await orderByPayment(env, payment) : null;
940
+ /** Rebuild the books from Stripe's refund IDs, never event delivery order or Charge.amount_refunded.
941
+ * Pending/failed refunds do not revoke goods. A failed refund reverses its earlier booking.
942
+ * https://docs.stripe.com/api/refunds/object#refund_object-status
943
+ * https://docs.stripe.com/api/refunds/list
944
+ */
945
+ async function syncRefunds(env, payment, freeOrder = null, freeLines = []) {
946
+ const o = freeOrder ?? await orderByPayment(env, payment);
637
947
  if (!o) return 'unknown-order';
638
- if (o.status === 'refunded') return 'already';
639
- const now = Date.now();
640
- await env.DB.batch([
641
- 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),
642
- revokeStatement(env, o.id),
643
- ...voidLineStatements(env, o.id),
644
- ]);
645
- return 'refunded';
948
+ const all = await orderLines(env, o.id);
949
+ const refunds = new Map();
950
+ let cursor;
951
+ if (payment && !hasStripeKey(env)) {
952
+ for (const r of await rememberedMoney(env, payment, o.mode)) if (r.id.startsWith('re_')) {
953
+ if (r.currency !== o.currency) throw new Error('Refund currency mismatch');
954
+ refunds.set(r.id, r);
955
+ }
956
+ }
957
+ if (payment && hasStripeKey(env)) do {
958
+ const page = await stripeCall(env, 'GET', '/v1/refunds', { payment_intent: payment, limit: 100, ...(cursor ? { starting_after: cursor } : {}) });
959
+ if (!Array.isArray(page.data)) throw new Error('Stripe refund list is unavailable');
960
+ for (const refund of page.data) refunds.set(refund.id, refund);
961
+ const next = page.has_more ? page.data.at(-1)?.id : null;
962
+ if (page.has_more && (!next || next === cursor)) throw new Error('Stripe refund list is incomplete');
963
+ cursor = next;
964
+ } while (cursor);
965
+ const totals = new Map(all.map((line) => [line.id, 0]));
966
+ let amount = 0;
967
+ for (const refund of refunds.values()) {
968
+ if (refund.status !== 'succeeded') continue;
969
+ if (!Number.isSafeInteger(refund.amount) || refund.amount < 0) throw new Error('Invalid Stripe refund amount');
970
+ amount += refund.amount;
971
+ if (refund.metadata?.line && totals.has(refund.metadata.line)) totals.set(refund.metadata.line, totals.get(refund.metadata.line) + refund.amount);
972
+ }
973
+ const gross = Number(o.total ?? o.amount);
974
+ if (!Number.isSafeInteger(amount) || amount > gross) throw new Error('Stripe refund total exceeds payment');
975
+ const moneyFull = amount === gross && gross > 0;
976
+ const whole = moneyFull && [...refunds.values()].some((r) => r.status === 'succeeded' && !r.metadata?.line);
977
+ const taggedGross = [...totals.values()].reduce((n, value) => n + value, 0);
978
+ const taggedNet = all.reduce((n, line) => n + (Number(line.total ?? line.amount) ? Number(BigInt(line.amount) * BigInt(totals.get(line.id)) / BigInt(line.total ?? line.amount)) : 0), 0);
979
+ const net = moneyFull ? Number(o.amount) : taggedNet + (gross > taggedGross ? Number(BigInt(Number(o.amount) - taggedNet) * BigInt(amount - taggedGross) / BigInt(gross - taggedGross)) : 0);
980
+ const revision = Number(o.refund_revision);
981
+ const guard = `EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND refund_revision = ?2 AND (status != 'lost' OR ${o.status === 'lost' ? 1 : 0}))`;
982
+ const writes = [];
983
+ let changed = Number(o.refunded_amount) !== amount || Number(o.refunded_net) !== net;
984
+ let full = true;
985
+ for (const line of all) {
986
+ // A completed whole-order refund (including earlier partial refunds) names every line.
987
+ // Untagged partial refunds remain solely on the order: no invented item allocation.
988
+ const target = whole ? Number(line.total ?? line.amount) : totals.get(line.id);
989
+ if (target > Number(line.total ?? line.amount)) throw new Error('Stripe line refund exceeds line payment');
990
+ const lineNet = line.total || line.amount ? Number(BigInt(line.amount) * BigInt(target) / BigInt(line.total ?? line.amount)) : 0;
991
+ const localFree = Number(line.total ?? line.amount) === 0 && (freeLines.includes(line.id) || line.refund === 'local-free');
992
+ const revoked = whole || localFree || target > 0 && target === Number(line.total ?? line.amount);
993
+ full &&= revoked;
994
+ changed ||= Number(line.refunded_amount) !== target || Number(line.refunded_net) !== lineNet || (line.status === 'refunded') !== revoked || localFree && line.refund !== 'local-free';
995
+ writes.push(env.DB.prepare(`UPDATE shop_order_lines SET refund = CASE WHEN ?8 THEN 'local-free' ELSE refund END, refunded_amount = ?4, refunded_net = ?5, status = CASE WHEN ?6 THEN 'refunded' WHEN status = 'refunded' THEN 'paid' ELSE status END, refunded_at = CASE WHEN ?6 THEN COALESCE(refunded_at, ?7) ELSE NULL END WHERE order_id = ?1 AND id = ?3 AND ${guard}`).bind(o.id, revision, line.id, target, lineNet, revoked ? 1 : 0, Date.now(), localFree ? 1 : 0));
996
+ writes.push(env.DB.prepare(`UPDATE shop_entitlement_lines SET state = ?4 WHERE order_id = ?1 AND line_id = ?3 AND ${guard}`).bind(o.id, revision, line.id, revoked || o.status === 'lost' ? 'revoked' : 'active'));
997
+ }
998
+ writes.push(env.DB.prepare(`UPDATE entitlements SET state = CASE WHEN ?3 OR ?4 OR EXISTS (SELECT 1 FROM shop_entitlement_lines WHERE order_id = ?1 AND key = entitlements.key) AND NOT EXISTS (SELECT 1 FROM shop_entitlement_lines WHERE order_id = ?1 AND key = entitlements.key AND state = 'active') THEN 'revoked' ELSE 'active' END WHERE order_id = ?1 AND ${guard}`).bind(o.id, revision, full ? 1 : 0, o.status === 'lost' ? 1 : 0));
999
+ writes.push(env.DB.prepare(`UPDATE entitlements SET starts_at = (SELECT MIN(starts_at) FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active'), ends_at = CASE WHEN EXISTS (SELECT 1 FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active' AND l.ends_at IS NULL) THEN NULL ELSE (SELECT MAX(ends_at) FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active') END WHERE order_id = ?1 AND ${guard} AND EXISTS (SELECT 1 FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active')`).bind(o.id, revision));
1000
+ const referral = await env.DB.prepare('SELECT * FROM referral_lines WHERE order_id = ?1').bind(o.id).first();
1001
+ if (referral && o.status !== 'lost') {
1002
+ const share = Number(referral.original_share ?? referral.share);
1003
+ const remaining = o.amount ? Number((2n * BigInt(share) * BigInt(Number(o.amount) - net) + BigInt(o.amount)) / (2n * BigInt(o.amount))) : 0;
1004
+ changed ||= Number(referral.share) !== remaining || Number(referral.net) !== Number(o.amount) - net || (referral.state === 'void') !== (remaining === 0) && referral.state !== 'settled';
1005
+ const delta = Number(referral.share) - remaining;
1006
+ writes.push(env.DB.prepare(`INSERT INTO referral_lines (order_id, via, net, rate, share, original_share, currency, state, period, hold_until, created_at) SELECT ?3, via, ?4, rate, ?5, ?5, currency, 'clawback', period, hold_until, ?6 FROM referral_lines WHERE order_id = ?1 AND state = 'settled' AND ${guard} AND ?5 != 0 ON CONFLICT DO NOTHING`).bind(o.id, revision, `${o.id}_refund_${revision}`, net - Number(o.refunded_net), delta, Date.now()));
1007
+ writes.push(env.DB.prepare(`UPDATE referral_lines SET share = ?3, net = ?4, state = CASE WHEN state = 'settled' THEN state WHEN ?3 = 0 THEN 'void' WHEN state = 'void' THEN 'pending' ELSE state END WHERE order_id = ?1 AND ${guard}`).bind(o.id, revision, remaining, Number(o.amount) - net));
1008
+ }
1009
+ full &&= o.status !== 'lost';
1010
+ changed ||= (o.status === 'refunded') !== full;
1011
+ if (!changed) return 'already';
1012
+ writes.push(env.DB.prepare(`UPDATE shop_orders SET refunded_amount = ?3, refunded_net = ?4, status = CASE WHEN ?5 THEN 'refunded' WHEN status = 'refunded' THEN 'paid' ELSE status END, refunded_at = CASE WHEN ?5 THEN COALESCE(refunded_at, ?6) ELSE NULL END, refund_revision = refund_revision + 1 WHERE id = ?1 AND ${guard}`).bind(o.id, revision, amount, net, full ? 1 : 0, Date.now()));
1013
+ const result = await env.DB.batch(writes);
1014
+ if (!result.at(-1)?.meta?.changes) return syncRefunds(env, payment, freeOrder ? await orderById(env, o.id) : null, freeLines);
1015
+ return o.refunded_amount === amount && o.status === (full ? 'refunded' : o.status) ? 'already' : 'refunded';
646
1016
  }
647
1017
 
648
1018
  /** A dispute changes nothing until it is decided: the player keeps the item and their account. */
@@ -658,9 +1028,11 @@ async function disputeClosed(env, payment, status) {
658
1028
  const o = payment ? await orderByPayment(env, payment) : null;
659
1029
  if (!o) return 'unknown-order';
660
1030
  const now = Date.now();
1031
+ if (!['paid', 'disputed'].includes(o.status)) return 'already';
661
1032
  if (status === 'lost') {
662
1033
  await env.DB.batch([
663
- env.DB.prepare("UPDATE shop_orders SET status = 'lost', updated_at = ?2 WHERE id = ?1").bind(o.id, now),
1034
+ env.DB.prepare("UPDATE shop_orders SET status = 'lost', updated_at = ?2 WHERE id = ?1 AND status IN ('paid', 'disputed')").bind(o.id, now),
1035
+ env.DB.prepare("UPDATE shop_order_lines SET status = 'lost' WHERE order_id = ?1 AND status != 'refunded' AND EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND status = 'lost')").bind(o.id),
664
1036
  revokeStatement(env, o.id),
665
1037
  ...voidLineStatements(env, o.id),
666
1038
  ]);
@@ -668,6 +1040,7 @@ async function disputeClosed(env, payment, status) {
668
1040
  }
669
1041
  await env.DB.batch([
670
1042
  env.DB.prepare("UPDATE shop_orders SET status = 'paid', updated_at = ?2 WHERE id = ?1 AND status = 'disputed'").bind(o.id, now),
1043
+ env.DB.prepare("UPDATE shop_order_lines SET status = 'paid' WHERE order_id = ?1 AND status = 'disputed' AND EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND status = 'paid')").bind(o.id),
671
1044
  restoreStatement(env, o.id),
672
1045
  ]);
673
1046
  return 'kept';
@@ -676,31 +1049,53 @@ async function disputeClosed(env, payment, status) {
676
1049
  /** What the owner reads when Stripe holds a refund for approval (the shop's key is an Agent key). */
677
1050
  export const REFUND_HELD = 'Stripe is holding this refund until a person approves it in Stripe (Settings, Approvals, Requests): the shop\'s Stripe key is marked as an Agent key, and Stripe asks a person before an agent\'s refund. Approve it there; Stripe then refunds, and the item leaves the player\'s account when Stripe says so. To refund with one tap again, connect the shop with a plain restricted key (homie-studio shop connect), not one made for "Authorizing agent access".';
678
1051
 
679
- /** Refund one order in full with Stripe, then take back that one item. The webhook confirms the same (idempotent). */
680
- export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner' } = {}) {
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
- }
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.' };
1052
+ /** Refund selected lines through Stripe, revoking each only after success. */
1053
+ export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner', line: lineId = null } = {}) {
1054
+ if (o.status !== 'paid' && !(o.payment && ['started', 'processing', 'missing', 'released', 'expired', 'failed'].includes(o.status))) return { ok: false, error: 'state', message: `This order is ${o.status}.` };
1055
+ const lines = (await orderLines(env, o.id)).filter((l) => !lineId || l.id === lineId);
1056
+ if (!lines.length) return { ok: false, error: 'line', message: 'No such order line.' };
1057
+ const legacy = Boolean(await migrationNeeded(env));
1058
+ // One whole-order refund asks Stripe for the charge's remaining balance. Stripe arbitrates concurrent
1059
+ // line/whole refunds; this keeps even 100-line carts within the Worker's subrequest budget.
1060
+ // https://docs.stripe.com/api/refunds/create#create_refund-amount
1061
+ const selected = lines.filter((l) => l.status !== 'refunded');
1062
+ if (!selected.length) return { ok: true, order: o.id, line: lineId, amount: 0, currency: o.currency, status: 'succeeded' };
1063
+ const amount = selected.reduce((sum, l) => sum + Number(l.total ?? l.amount) - Number(l.refunded_amount ?? 0), 0);
688
1064
  let refund = null;
689
- try {
690
- 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}` });
691
- } catch (error) {
692
- // An Agent-tagged key: Stripe holds an agent's refund for a person's approval. Nothing happened yet; once someone
693
- // approves it in Stripe, Stripe refunds and the webhook (refund.created, charge.refunded) takes the item back.
694
- if (isApprovalRequired(error)) return { ok: false, error: 'approval', held: true, message: REFUND_HELD };
695
- return { ok: false, error: 'stripe', message: `Stripe did not refund it (${error instanceof StripeError ? error.message : 'no answer'}). Nothing changed.` };
1065
+ if (amount) {
1066
+ if (!hasStripeKey(env)) return { ok: false, error: 'stripe-dashboard', message: 'Refund in your Stripe Dashboard or ask your AI connected to Stripe. The signed refund event updates this shop.', url: dashboardLink(o.mode, 'payment', o.payment) };
1067
+ if (!o.payment) return { ok: false, error: 'no-payment', message: 'Stripe has not confirmed the payment yet.' };
1068
+ try {
1069
+ const prior = lineId ? selected[0].refund : legacy ? o.refund : o.whole_refund;
1070
+ refund = prior ? await stripeCall(env, 'GET', `/v1/refunds/${encodeURIComponent(prior)}`) : null;
1071
+ if (!refund || ['failed', 'canceled'].includes(refund.status)) {
1072
+ refund = await createRefund(env, { payment_intent: o.payment, ...(lineId ? { amount } : {}),
1073
+ reason: ['duplicate', 'fraudulent', 'requested_by_customer'].includes(reason) ? reason : 'requested_by_customer',
1074
+ metadata: { order: o.id, ...(lineId ? { line: lineId } : {}), by } },
1075
+ { idempotencyKey: `refund-${lineId ?? o.id}${lineId && selected[0].refunded_amount ? '-' + selected[0].refunded_amount : ''}${refund ? '-' + refund.id : ''}` });
1076
+ }
1077
+ if (typeof refund?.id === 'string') {
1078
+ if (lineId && !legacy) await env.DB.prepare('UPDATE shop_order_lines SET refund = ?2 WHERE id = ?1').bind(lineId, refund.id).run();
1079
+ else await env.DB.prepare(legacy ? 'UPDATE shop_orders SET refund = ?2 WHERE id = ?1' : 'UPDATE shop_orders SET whole_refund = ?2 WHERE id = ?1').bind(o.id, refund.id).run();
1080
+ }
1081
+ } catch (error) {
1082
+ if (isApprovalRequired(error)) return { ok: false, error: 'approval', held: true, message: REFUND_HELD };
1083
+ return { ok: false, error: 'stripe', message: `Stripe did not refund this order (${error instanceof StripeError ? redactStripe(error.message, env) : 'no answer'}).` };
1084
+ }
1085
+ if (!legacy) {
1086
+ try { await syncRefunds(env, o.payment); }
1087
+ catch (error) { logOrderError(env, o.id, error); return { ok: false, error: 'stripe-retry', message: 'Stripe accepted the refund; its books will be reconciled on retry.' }; }
1088
+ }
1089
+ if (refund?.status !== 'succeeded') return { ok: false, held: true, error: 'pending', message: 'Stripe has not completed this refund yet.' };
696
1090
  }
697
- const now = Date.now();
698
- await env.DB.batch([
699
- 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),
700
- revokeStatement(env, o.id),
701
- ...voidLineStatements(env, o.id),
702
- ]);
703
- return { ok: true, order: o.id, refund: refund?.id ?? null, status: refund?.status ?? null, amount: Number(o.amount), currency: o.currency };
1091
+ if (legacy) {
1092
+ await env.DB.batch([
1093
+ revokeStatement(env, o.id), ...voidLineStatements(env, o.id),
1094
+ env.DB.prepare("UPDATE shop_orders SET status = 'refunded', refund = ?2, refunded_at = ?3, updated_at = ?3 WHERE id = ?1").bind(o.id, refund?.id ?? null, Date.now()),
1095
+ ]);
1096
+ } else if (!amount) await syncRefunds(env, o.payment, o.payment ? null : o, selected.map((line) => line.id));
1097
+
1098
+ return { ok: true, order: o.id, line: lineId, amount: refund?.amount ?? amount, currency: o.currency, status: 'succeeded' };
704
1099
  }
705
1100
 
706
1101
  /* ------------------------------------------------------------------ the office (worker/office.mjs calls these) */
@@ -709,21 +1104,28 @@ export async function refundOrder(env, o, { reason = 'requested_by_customer', by
709
1104
  export function checkRefund(body) {
710
1105
  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`)' };
711
1106
  const reason = ['duplicate', 'fraudulent', 'requested_by_customer'].includes(body.reason) ? body.reason : 'requested_by_customer';
712
- const note = String(body.note ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim().slice(0, 140) || null;
713
- return { ok: true, action: { op: 'refund', order: body.order, reason, note } };
1107
+ const note = String(body.note ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim() || null;
1108
+ return { ok: true, action: { op: 'refund', order: body.order, line: typeof body.line === 'string' ? body.line : null, reason, note } };
714
1109
  }
715
1110
  /** Explicit owner override of an unresolved reservation; the existing note records its actor and time. */
716
1111
  export function checkRelease(body) {
717
1112
  const checked = checkRefund(body);
718
1113
  return checked.ok ? { ok: true, action: { op: 'shop-release', order: body.order } } : checked;
719
1114
  }
720
- export async function performRelease(env, a) {
1115
+ export async function performRelease(env, a, cat) {
1116
+ if (!hasStripeKey(env)) return { ok: false, error: 'stripe-dashboard', message: 'Stripe controls this checkout’s lifetime. Ask your connected AI to expire it in Stripe if needed.' };
721
1117
  const row = await orderById(env, a.order);
722
1118
  if (!row || !['started', 'processing'].includes(row.status)) return { ok: false, error: 'state', message: 'Only an unresolved order can be released.' };
723
1119
  // https://docs.stripe.com/api/checkout/sessions/expire — only open sessions can expire.
724
1120
  if (row.session) {
725
1121
  try {
726
- const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, null, { timeout: 2000 });
1122
+ let session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
1123
+ if (session?.id === row.session && ['paid', 'no_payment_required'].includes(session.payment_status)) {
1124
+ let result;
1125
+ try { result = await paid(env, shopOf(cat), session, (session.payment_intent?.latest_charge?.created ?? session.created ?? Math.floor(Date.now() / 1000)) * 1000); }
1126
+ catch (error) { logOrderError(env, row.id, error); return { ok: false, error: 'stripe-retry', message: 'The paid order could not be granted yet; retry before releasing it.' }; }
1127
+ return ['paid', 'already'].includes(result) ? { ok: true, order: row.id, status: 'paid' } : { ok: false, error: result, message: 'The payment must be reconciled before releasing this order.' };
1128
+ }
727
1129
  if (session?.id === row.session && session.status === 'open') await expireCheckoutSession(env, row.session, { timeout: 2000 });
728
1130
  } catch (error) { logOrderError(env, row.id, error); }
729
1131
  }
@@ -736,7 +1138,7 @@ export async function performRelease(env, a) {
736
1138
  export function checkSettle(body) {
737
1139
  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
1140
  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) } };
1141
+ return { ok: true, action: { op: 'shop-settle', currency: body.currency, via: String(body.via), ref: String(body.ref ?? '') } };
740
1142
  }
741
1143
 
742
1144
  /** The refund in plain words, for the owner's confirm page and the AI's card. */
@@ -744,14 +1146,23 @@ export function describeRefund(cat, a, order = null) {
744
1146
  const shop = shopOf(cat);
745
1147
  const name = shop?.items.find((i) => i.id === order?.item)?.name ?? order?.item ?? 'an item';
746
1148
  const what = order ? `${name}, ${money(order.amount, order.currency)}` : a.order;
747
- 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();
1149
+ return `Refund ${a.line ? `line ${a.line} of order` : 'order'} ${a.order} (${what}) 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();
748
1150
  }
749
1151
 
750
1152
  /** Do the refund (the owner's tap, or the owner's yes to the AI's ask). */
751
1153
  export async function performRefund(env, cat, a) {
752
- const o = await orderById(env, a.order);
1154
+ let o = await orderById(env, a.order);
753
1155
  if (!o) return { ok: false, error: 'order', message: 'No such order.' };
754
- return refundOrder(env, o, { reason: a.reason, by: 'owner' });
1156
+ if (hasStripeKey(env) && !o.payment && o.session && o.paid_at === null && !(await migrationNeeded(env))) {
1157
+ try {
1158
+ const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(o.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
1159
+ if (session?.id === o.session && (session.livemode === undefined || session.livemode === (o.mode === 'live')) && ['paid', 'no_payment_required'].includes(session.payment_status)) {
1160
+ await paid(env, shopOf(cat), session, (session.payment_intent?.latest_charge?.created ?? session.created ?? Math.floor(Date.now() / 1000)) * 1000);
1161
+ o = await orderById(env, a.order);
1162
+ }
1163
+ } catch (error) { return { ok: false, error: 'stripe', message: redactStripe(error.message, env) }; }
1164
+ }
1165
+ return refundOrder(env, o, { reason: a.reason, by: 'owner', line: a.line });
755
1166
  }
756
1167
 
757
1168
  /** Everything the office's shop page shows. Money is read from this studio's D1; Stripe's own pages are linked. */
@@ -761,29 +1172,33 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
761
1172
  const mode = ready.mode ?? 'test';
762
1173
  const out = {
763
1174
  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,
1175
+ shop: shop ? { policy: shop.policy, purchaseAttemptsPerMinute: shop.purchaseAttemptsPerMinute, till: shop.till, currency: shop.currency, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, nextItemsCursor: itemCursor + 100 < shop.items.length ? String(itemCursor + 100) : null, items: shop.items.slice(itemCursor, itemCursor + 100).map((i) => ({ id: i.id, kind: i.kind, name: i.name, price: i.price, shown: i.price === 'choose' ? `${money(i.min, shop.currency)}+` : money(i.price, shop.currency), gives: i.gives, game: i.game ?? null })), referrals: shop.referrals, ...(shop.broken ? { broken: shop.broken } : {}) } : null,
765
1176
  stripe: {
766
1177
  payments: dashboardLink(mode, 'payments'), refunds: dashboardLink(mode, 'refunds'), disputes: dashboardLink(mode, 'disputes'), payouts: dashboardLink(mode, 'payouts'),
767
1178
  balance: dashboardLink(mode, 'balance'), tax: dashboardLink(mode, 'tax'), keys: dashboardLink(mode, 'keys'), webhooks: dashboardLink(mode, 'webhooks'),
768
1179
  ...(shop?.till === 'stripe-managed' ? { managedPayments: dashboardLink(mode, 'managed-payments') } : {}),
769
1180
  },
1181
+ keyless: !hasStripeKey(env),
770
1182
  hook: `${origin}/api/shop/hook`,
771
1183
  totals: null, orders: [], referrals: { owe: [], owedToUs: [] },
772
1184
  };
773
- if (!(await migrated(env))) return out;
1185
+ if (!(await migrated(env, { complete: false }))) return out;
774
1186
  const since = Date.now() - 30 * DAY;
775
1187
  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();
776
1188
  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 };
777
1189
  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 ?? [];
1190
+ const partial = ready.migration ? null : await env.DB.prepare("SELECT COALESCE(SUM(refunded_net), 0) AS amount, COUNT(*) AS orders FROM shop_orders WHERE created_at >= ?1 AND status IN ('paid', 'disputed') AND refunded_net > 0").bind(since).first();
1191
+ out.totals.paid -= Number(partial?.amount ?? 0); out.totals.refunded += Number(partial?.amount ?? 0); out.totals.refunds += Number(partial?.orders ?? 0);
778
1192
  out.nextOrdersCursor = rows.length > 100 ? String(orderCursor + 100) : null;
779
1193
  if (rows.length > 100) rows.pop();
780
1194
  const names = new Map((shop?.items ?? []).map((i) => [i.id, i.name]));
781
- out.orders = rows.map((r) => ({
1195
+ out.orders = await Promise.all(rows.map(async (r) => ({
1196
+ lines: (await orderLines(env, r.id)).map((line) => { const view = lineView(line); return { ...view, name: line.snapshot ? view.name : names.get(line.item) ?? view.name }; }),
782
1197
  ...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,
783
1198
  stripe: r.payment ? dashboardLink(r.mode, 'payment', r.payment) : null, dispute: r.dispute ? dashboardLink(r.mode, 'dispute', r.dispute) : null,
784
- releasable: ['started', 'processing'].includes(r.status),
785
- refundable: r.status === 'paid' && (Number(r.amount) === 0 || Boolean(r.payment)),
786
- }));
1199
+ releasable: hasStripeKey(env) && ['started', 'processing'].includes(r.status),
1200
+ refundable: (Number(r.amount) === 0 || hasStripeKey(env)) && ['paid', 'started', 'processing', 'missing', 'released', 'expired', 'failed'].includes(r.status) && (Number(r.amount) === 0 || Boolean(r.payment) || Boolean(r.session)),
1201
+ })));
787
1202
  const { books, nextCursor } = await booksOf(env, { cursor });
788
1203
  out.referrals = { nextCursor, owe: books, failures: await statementFailures(env), owedToUs: await statementsIn(env), invoice: dashboardLink(mode, 'invoices') };
789
1204
  return out;
@@ -793,9 +1208,9 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
793
1208
  export async function ordersCsv(env) {
794
1209
  const rows = (await env.DB.prepare("SELECT * FROM shop_orders WHERE status != 'started' ORDER BY created_at").all()).results ?? [];
795
1210
  const cell = (v) => { const s = v === null || v === undefined ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
796
- const head = ['order', 'created', 'paid', 'refunded', 'status', 'item', 'game', 'amount', 'tax', 'total', 'currency', 'till', 'mode', 'parent', 'via', 'stripe_payment', 'stripe_refund', 'stripe_dispute'];
1211
+ const head = ['order', 'created', 'paid', 'refunded', 'status', 'item', 'game', 'amount', 'tax', 'total', 'currency', 'till', 'mode', 'parent', 'via', 'stripe_payment', 'stripe_refund', 'stripe_dispute', 'lines_json', 'refunded_total', 'refunded_net'];
797
1212
  const iso = (t) => (t ? new Date(Number(t)).toISOString() : '');
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(','));
1213
+ const lines = await Promise.all(rows.map(async (r) => [r.id, iso(r.created_at), iso(r.paid_at), iso(r.refunded_at), r.status, r.item, r.game, (Number(r.amount) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.tax === null ? '' : (Number(r.tax) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.total === null ? '' : (Number(r.total) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.currency, r.till, r.mode, Number(r.parent) ? 'yes' : '', r.via, r.payment, r.refund, r.dispute, JSON.stringify((await orderLines(env, r.id)).map(lineView)), (Number(r.refunded_amount ?? 0) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), (Number(r.refunded_net ?? 0) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency)))].map(cell).join(',')));
799
1214
  return `${head.join(',')}\n${lines.join('\n')}${lines.length ? '\n' : ''}`;
800
1215
  }
801
1216