@homie-rocks/studio 0.33.0 → 0.34.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)
@@ -22,7 +22,7 @@
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
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;
25
+ * STRIPE_KEY (a secret key or a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read;
26
26
  * from 0.24.3 also Webhook Endpoints write, which only the connect page on the owner's computer uses) and
27
27
  * STRIPE_WEBHOOK_SECRET. Never in a file, a chat, a log or a page.
28
28
  *
@@ -33,12 +33,12 @@
33
33
  */
34
34
  import { players } from './players.mjs';
35
35
  import { serversOf } from './servers.mjs';
36
- import { ENTITLEMENT_KEY, checkoutMessage, amountError, amountStep, parseAmount, currencyScale, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
36
+ import { ENTITLEMENT_KEY, referralShare, checkoutMessage, amountError, amountStep, parseAmount, currencyScale, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
37
37
  import {
38
- bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
38
+ orderLines, lineView, bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
39
39
  setBand, shopDataOf, spentThisMonth,
40
40
  } from './shop-store.mjs';
41
- import { KEY_SHAPE, stripeCall, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, modeOf, productIdOf, verifyWebhook } from './stripe.mjs';
41
+ import { redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, modeOf, productIdOf, verifyWebhook } from './stripe.mjs';
42
42
  import { pendingStatementPeriod, statementFailures, arrivalOf, booksOf, linesOf, statementPage, lineFor, receiveStatement, receivedPages, referrerOk, sendStatements, settle, statementsIn, voidLineStatements } from './referrals.mjs';
43
43
  import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
44
44
  import { notFoundPage } from './site.mjs';
@@ -83,10 +83,24 @@ function sameOrigin(request, url) {
83
83
  if (origin && origin !== 'null') return origin === url.origin;
84
84
  return request.headers.get('sec-fetch-site') === 'same-origin';
85
85
  }
86
+ async function boundedText(request, max) {
87
+ const reader = request.body?.getReader();
88
+ if (!reader) return '';
89
+ const decoder = new TextDecoder();
90
+ let text = '', bytes = 0;
91
+ while (true) {
92
+ const { done, value } = await reader.read();
93
+ if (done) break;
94
+ bytes += value.byteLength;
95
+ if (bytes > max) { await reader.cancel(); return null; }
96
+ text += decoder.decode(value, { stream: true });
97
+ }
98
+ return text + decoder.decode();
99
+ }
86
100
  async function bodyOf(request, max = 4096) {
87
101
  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') };
102
+ const text = await boundedText(request, max);
103
+ if (text === null) return { error: fail(413, 'too-large', 'that request is too large') };
90
104
  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
105
  }
92
106
 
@@ -94,8 +108,9 @@ async function bodyOf(request, max = 4096) {
94
108
 
95
109
  const sizes = new WeakMap();
96
110
  function requestSize(shop) {
111
+ if (shop.requestBytes !== null) return shop.requestBytes;
97
112
  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;
113
+ 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
114
  sizes.set(shop, size);
100
115
  return size;
101
116
  }
@@ -122,11 +137,12 @@ export async function readiness(env, shop) {
122
137
  else if (shop.till === 'off') missing.push('till-off');
123
138
  else if (!shop.items.length) missing.push('items');
124
139
  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');
140
+ if (!KEY_SHAPE.test(key)) missing.push('stripe-key');
141
+ // Reliable fulfillment requires the webhook as well as the redirect.
142
+ // https://docs.stripe.com/checkout/fulfillment
127
143
  if (!WEBHOOK_SECRET_SHAPE.test(String(env?.STRIPE_WEBHOOK_SECRET ?? ''))) missing.push('webhook-secret');
128
144
  let migration = null;
129
- if (!env?.DB || env.HOMIE_PREVIEW === '1') missing.push('database');
145
+ if (!env?.DB) missing.push('database');
130
146
  else if ((migration = await migrationNeeded(env))) missing.push('migration');
131
147
  return { ready: missing.length === 0, missing, migration, mode: KEY_SHAPE.test(key) ? modeOf(key) : null };
132
148
  }
@@ -137,11 +153,10 @@ export const MISSING_WORDS = Object.freeze({
137
153
  'shop.json-refused': 'shop.json conflicts with the studio settings or payment requirements (homie-studio shop check says which), so nothing is sold.',
138
154
  'till-off': 'shop.json says "till": "off".',
139
155
  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).',
156
+ 'stripe-key': 'The Worker has no Stripe key yet (homie-studio shop connect: the owner pastes a Stripe key into a page on their own computer).',
142
157
  '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.',
158
+ database: 'This Worker has no database.',
159
+ 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
160
  });
146
161
 
147
162
  /** The server a request names (the play shell says which), with its kids and beginner flags. */
@@ -177,7 +192,7 @@ async function itemsView(env, shop, { game, kids, beginner, player, open, cursor
177
192
  const items = page.map((it) => {
178
193
  const owned = it.gives.length > 0 && it.gives.every((k) => owns.some((o) => o.key === k && o.item === it.id));
179
194
  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 }) } : {}) };
195
+ 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
196
  }).filter((i) => i.way !== 'beginner' && i.way !== 'kids');
182
197
  return { band, owns, spent, items, nextCursor: !item && cursor + 100 < selected.length ? String(cursor + 100) : null };
183
198
  }
@@ -186,6 +201,7 @@ async function itemsView(env, shop, { game, kids, beginner, player, open, cursor
186
201
 
187
202
  /** The shop's paths, or null for any other. */
188
203
  export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
204
+ const requestAt = Date.now();
189
205
  const path = url.pathname;
190
206
  const read = request.method === 'GET' || request.method === 'HEAD';
191
207
  if (path === '/api/referrals/statement') return receiveStatement(request, env, url);
@@ -195,7 +211,8 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
195
211
  if (path === '/shop') return Response.redirect(`${url.origin}/shop/${url.search}`, 301);
196
212
  const cat = await catalogueOf();
197
213
  const shop = shopOf(cat);
198
- // The studio's protective policy closes kids studios.
214
+ if (path === '/api/shop/hook') return hook(request, env, cat, shop ?? checkShop({}).shop);
215
+ // Only the studio's chosen policy closes a kids studio.
199
216
  if (!shop || shop.audience === 'kids') {
200
217
  if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
201
218
  if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
@@ -203,15 +220,25 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
203
220
  return shop ? notFoundPage('This studio sells nothing.', cat) : null;
204
221
  }
205
222
  const ready = await readiness(env, shop);
223
+ if (read && url.searchParams.get('cancelled') !== '1' && ctx?.waitUntil && !ready.migration && KEY_SHAPE.test(String(env.STRIPE_KEY ?? ''))) {
224
+ ctx.waitUntil((async () => {
225
+ const player = await players.of(request, env);
226
+ if (player) await reconcileOrders(env, shop, player.id);
227
+ })().catch((error) => logOrderError(env, null, error)));
228
+ }
206
229
  try {
230
+ if (path === '/shop/' && read && url.searchParams.get('cancelled') === '1' && /^ord_[A-Za-z0-9]{20}$/.test(url.searchParams.get('order') ?? '') && !ready.migration) {
231
+ const player = await players.of(request, env);
232
+ if (player) await reconcileOrders(env, shop, player.id, { expireOpen: true, order: url.searchParams.get('order') });
233
+ }
207
234
  if (path === '/api/shop/hook') return await hook(request, env, cat, shop, ready);
208
235
  if (!read && !sameOrigin(request, url)) return fail(403, 'origin', 'only this site\'s own pages may do that');
209
236
  if (path === '/api/shop' && read) return await listRoute(request, env, url, cat, shop, ready);
210
237
  if (path === '/api/player/owns' && read) return await ownsRoute(request, env, url, cat, shop);
211
238
  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);
239
+ if (path === '/api/shop/order' && read) return await orderRoute(request, env, url, shop);
213
240
  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);
241
+ if (path === '/api/shop/buy' && request.method === 'POST') return await buyRoute(request, env, url, cat, shop, ready, ctx, requestAt);
215
242
  if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
216
243
  if (path === '/api/shop/used' && request.method === 'POST') return await usedRoute(request, env, shop);
217
244
  if (path === '/api/shop/refund' && request.method === 'POST') return await selfRefundRoute(request, env, shop, ready);
@@ -251,7 +278,7 @@ async function ownsRoute(request, env, url, cat, shop) {
251
278
  const facts = await serverFacts(env, cat, game, url.searchParams.get('server'));
252
279
  const advantage = new Set((shop.items ?? []).filter((i) => i.advantage).map((i) => i.id));
253
280
  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 })) });
281
+ 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
282
  }
256
283
 
257
284
  async function mineRoute(request, env, shop) {
@@ -259,16 +286,35 @@ async function mineRoute(request, env, shop) {
259
286
  if (!p) return json({ ok: true, player: null, orders: [], owns: [] });
260
287
  const data = await shopDataOf(env, p.id);
261
288
  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)) });
289
+ const orders = [];
290
+ for (const o of data?.orders ?? []) {
291
+ for (const line of o.lines) {
292
+ 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; }
293
+ const used = await lineWasUsed(env, o.id, line);
294
+ line.refundable = refundableNow({ ...line, paidAt: o.paidAt }, used ? [{ item: line.item, used: true }] : [], shop);
295
+ line.shown = money(line.amount, o.currency);
296
+ }
297
+ 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) });
298
+ }
299
+ 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
300
  }
264
301
 
265
302
  /** 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) {
303
+ async function orderRoute(request, env, url, shop) {
267
304
  const p = await players.of(request, env);
268
305
  const session = url.searchParams.get('session') ?? '';
269
306
  if (!p || !/^cs_[A-Za-z0-9_]{1,200}$/.test(session)) return json({ ok: true, status: null });
270
307
  const o = await orderBySession(env, session);
271
308
  if (!o || o.player !== p.id) return json({ ok: true, status: null });
309
+ if (o.paid_at === null && KEY_SHAPE.test(String(env.STRIPE_KEY ?? '')) && !purchaseLimited(request, shop, p.id, 'verify')) {
310
+ try {
311
+ const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(o.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
312
+ if (session.id === o.session && (session.livemode === undefined || session.livemode === (o.mode === 'live')) && ['paid', 'no_payment_required'].includes(session.payment_status)) {
313
+ await paid(env, shop, session, (session.payment_intent?.latest_charge?.created ?? session.created ?? Math.floor(Date.now() / 1000)) * 1000);
314
+ Object.assign(o, await orderById(env, o.id));
315
+ }
316
+ } catch (error) { logOrderError(env, o.id, error); }
317
+ }
272
318
  return json({ ok: true, status: playerOrderStatus(o), item: o.item, game: o.game ?? null });
273
319
  }
274
320
 
@@ -295,41 +341,45 @@ async function ageRoute(request, env, shop) {
295
341
  return response;
296
342
  }
297
343
 
298
- export function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio, product = null }) {
344
+ export function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio, product = null, lines = null }) {
299
345
  const managed = shop.till === 'stripe-managed';
300
346
  // Managed Payments needs an eligible digital-goods tax code on every product (video games, downloaded).
301
- const taxCode = item.taxCode ?? (managed ? defaultTaxCode(item) : undefined);
347
+ const cart = lines ?? [{ item, amount, quantity: 1, product }];
302
348
  const back = game ? `&game=${encodeURIComponent(game)}` : '';
303
349
  return {
304
350
  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 } } }),
351
+ // Stripe payment Checkout allows up to 100 lines, each with a quantity:
352
+ // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-line_items
353
+ line_items: cart.map((line) => ({
354
+ quantity: line.quantity,
355
+ price_data: { currency: shop.currency, unit_amount: line.amount, tax_behavior: 'exclusive',
356
+ ...(line.product ? { product: line.product } : { product_data: {
357
+ name: line.item.name, ...(line.item.blurb ? { description: line.item.blurb } : {}),
358
+ ...(line.item.taxCode || managed ? { tax_code: line.item.taxCode ?? defaultTaxCode(line.item) } : {}),
359
+ metadata: { item: line.item.id.length <= 500 ? line.item.id : undefined, line: line.id },
360
+ } }),
311
361
  },
312
- }],
362
+ })),
313
363
  client_reference_id: player,
314
364
  // Stripe metadata values allow 500 characters; the order holds the complete item id.
315
365
  // 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 } },
366
+ 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' } : {}) },
367
+ payment_intent_data: { metadata: { homie: 'shop-v1', origin, order: order.id, item: item.id.length <= 500 ? item.id : undefined } },
318
368
  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.
369
+ cancel_url: `${origin}/shop/?cancelled=1&order=${order.id}${back}`,
370
+ // Keep Stripe’s default 24-hour Checkout lifetime: https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-expires_at
371
+ expires_at: order.expiresAt ?? Math.floor((order.createdAt ?? Date.now()) / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60,
372
+ // The studio can opt into immediate-delivery acknowledgement beside the pay button.
323
373
  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 } }),
374
+ // The studio can enable Stripe Tax, or choose Managed Payments (Stripe is the seller of record
375
+ // and computes tax itself). Disable adaptive pricing to preserve the file price.
376
+ ...(managed ? { managed_payments: { enabled: true } } : { ...(shop.automaticTax ? { automatic_tax: { enabled: true } } : {}), adaptive_pricing: { enabled: false } }),
327
377
  };
328
378
  }
329
379
 
330
380
  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 });
381
+ const reason = redactStripe(error?.message ?? error, env);
382
+ console.warn('Shop reconciliation', { order, reason, status: error?.status ?? null, code: redactStripe(error?.code, env) });
333
383
  }
334
384
 
335
385
  /** At most three reads in parallel, claimed before I/O. Old unresolved rows back off up to an hour.
@@ -337,19 +387,49 @@ function logOrderError(env, order, error) {
337
387
  * Missing sessions: https://docs.stripe.com/error-codes#resource-missing
338
388
  * Payment time: https://docs.stripe.com/api/charges/object#charge_object-created
339
389
  */
340
- export async function reconcileOrders(env, shop, player) {
390
+ export async function reconcileOrders(env, shop, player = null, { expireOpen = false, order = null, before = Infinity } = {}) {
391
+ if (!shop || !KEY_SHAPE.test(String(env?.STRIPE_KEY ?? '')) || !(await migrated(env))) return;
341
392
  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 ?? [];
393
+ // Filter eligibility before LIMIT. Unrecorded payments precede old paid rows needing repair.
394
+ const params = [modeOf(env.STRIPE_KEY), now, Number.isFinite(before) ? before : Number.MAX_SAFE_INTEGER, ...(player ? [player] : []), ...(order ? [order] : [])];
395
+ 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
396
+ AND status = '${status}'
397
+ AND (created_at < ?3 ${expireOpen ? 'OR expires_at <= ?2' : ''}) ${player ? 'AND player = ?4' : ''} ${order ? `AND id = ?${player ? 5 : 4}` : ''}
398
+ ${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)))"}
399
+ ORDER BY updated_at, id LIMIT ${limit}`).bind(...params).all();
400
+ let rows = (await query('started', 3)).results ?? [];
401
+ if (!expireOpen) {
402
+ rows.push(...((await query('processing', 3)).results ?? []));
403
+ rows.sort((a, b) => a.updated_at - b.updated_at || a.id.localeCompare(b.id));
404
+ rows = rows.slice(0, 3);
405
+ if (rows.length < 3) rows.push(...((await query('paid', 3 - rows.length)).results ?? []));
406
+ }
344
407
  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;
408
+ if (!row.session) {
409
+ 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();
410
+ return;
411
+ }
412
+ if (expireOpen && row.status !== 'started') return;
413
+ if (row.status === 'paid' && !row.player) { await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(row.id).run(); return; }
414
+ const claimedAt = Math.max(now, Number(row.updated_at) + 1);
415
+ 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();
416
+ if (!claim.meta?.changes && !(expireOpen && order)) return;
347
417
  try {
348
- const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
418
+ let session;
419
+ if (expireOpen) {
420
+ try { session = await expireCheckoutSession(env, row.session, { timeout: 2000 }); }
421
+ catch (error) {
422
+ if (!(error instanceof StripeError) || error.status !== 400) throw error;
423
+ // Already complete or expired: read Stripe's outcome instead of freeing an unconfirmed payment.
424
+ session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
425
+ }
426
+ } else session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
349
427
  if (!session || session.id !== row.session || (typeof session.livemode === 'boolean' && session.livemode !== (row.mode === 'live'))) throw new Error('Stripe session identity or mode mismatch');
428
+ // A buyer abandoning their own open session releases only on Stripe's confirmation.
429
+ // https://docs.stripe.com/api/checkout/sessions/expire
350
430
  const payment = session.payment_intent;
351
431
  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;
432
+ const time = session.payment_status === 'no_payment_required' ? session.created : payment?.latest_charge?.created ?? session.created;
353
433
  // Missing payment time remains unresolved; never charge an old payment to this month's allowance.
354
434
  if (Number.isSafeInteger(time) && time > 0 && time * 1000 <= now) await paid(env, shop, session, time * 1000);
355
435
  } else if (session.status === 'expired' || payment?.status === 'canceled' || (session.status === 'complete' && payment?.status === 'requires_payment_method' && payment.last_payment_error)) {
@@ -359,26 +439,37 @@ export async function reconcileOrders(env, shop, player) {
359
439
  logOrderError(env, row.id, error);
360
440
  // This key's account cannot see the session; a later verified payment still grants it.
361
441
  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();
442
+ 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
443
  } else {
364
444
  // Keep the atomic claim, but retry failures after roughly a minute, regardless of row age.
365
445
  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();
446
+ 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
447
  }
368
448
  }
369
449
  }).map((work, i) => work.catch((error) => logOrderError(env, rows[i].id, error))));
370
450
  }
371
451
 
372
452
  /** 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.') };
453
+ async function startCheckout(env, url, cat, shop, item, { player, game, via, amount, parent = false, lines = null, requestAt = Date.now() }) {
454
+ lines ??= [{ item, amount, quantity: 1 }];
455
+ try { if (via) referralShare(amount, shop.referrals.rate); } catch { return { error: fail(400, 'arithmetic', 'The referral share exceeds safe integer arithmetic.') }; }
375
456
  const order = { id: newOrderId(), player: player.id, via: via ?? null, amount, currency: shop.currency };
376
457
  const now = Date.now();
377
458
  order.createdAt = now;
378
- order.expiresAt = Math.floor(now / 1000) + 31 * 60;
459
+ order.expiresAt = Math.floor(now / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
379
460
  // 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();
461
+ 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))))")
462
+ .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);
463
+ lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
464
+ const reserve = async () => {
465
+ const results = await env.DB.batch([reservation(),
466
+ ...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)")
467
+ .bind(line.id, order.id, i, line.item.id, line.quantity, line.amount, line.amount * line.quantity, JSON.stringify(line.item))),
468
+ 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),
469
+ ]);
470
+ return results[0];
471
+ };
472
+ await reconcileOrders(env, shop, player.id, { expireOpen: true, before: Math.min(requestAt, now - 60000) });
382
473
  let reserved = await reserve();
383
474
  const reconciled = !reserved.meta?.changes;
384
475
  if (!reserved.meta?.changes) {
@@ -386,27 +477,39 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
386
477
  reserved = await reserve();
387
478
  }
388
479
  if (!reserved.meta?.changes) return { error: fail(403, 'cap', WAY_WORDS.cap, { way: 'cap' }) };
480
+ if (amount === 0) {
481
+ const id = `cs_free_${order.id}`;
482
+ await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1').bind(order.id, id).run();
483
+ 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' });
484
+ return { order, url: `${url.origin}/shop/thanks?session_id=${id}`, session: id, reconciled };
485
+ }
389
486
  let session = null;
390
487
  const mode = modeOf(env.STRIPE_KEY);
391
488
  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 });
489
+ const useCatalog = Array.isArray(shop.catalog) && shop.catalog.includes(mode);
490
+ for (const line of lines) line.product = useCatalog ? await productIdOf(cat.studio?.slug, line.item.id) : null;
491
+ 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 }))));
492
+ // Start the provider lifetime at session creation, after reservation/expiry I/O. At Stripe's
493
+ // lower bound the timestamp is computed immediately before the HTTP call.
494
+ const params = () => {
495
+ order.expiresAt = Math.floor(Date.now() / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
496
+ return sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, lines });
497
+ };
394
498
  try {
395
- session = await createCheckoutSession(env, params(product), { idempotencyKey: `checkout-${order.id}` });
499
+ session = await createCheckoutSession(env, params(), { idempotencyKey: `checkout-${order.id}` });
396
500
  } 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` });
501
+ if (!useCatalog || !isMissingProduct(error)) throw error;
502
+ for (const line of lines) line.product = null;
503
+ session = await createCheckoutSession(env, params(), { idempotencyKey: `checkout-${order.id}-inline` });
401
504
  }
402
505
  if (typeof session?.id !== 'string' || typeof session?.url !== 'string' || !/^https:\/\//.test(session.url) && !/^http:\/\/(127\.0\.0\.1|localhost)/.test(session.url)) {
403
506
  await env.DB.prepare("UPDATE shop_orders SET status = 'failed', updated_at = ?2 WHERE id = ?1").bind(order.id, Date.now()).run();
404
507
  return { error: fail(502, 'stripe', 'Stripe answered without a checkout page. Nothing was charged.') };
405
508
  }
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();
509
+ 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
510
  } 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';
511
+ 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();
512
+ const why = error instanceof StripeError ? redactStripe(error.message, env) : 'Stripe did not answer';
410
513
  return { error: fail(502, 'stripe', `Stripe could not open the checkout (${why}). Nothing was charged.`) };
411
514
  }
412
515
  return { order, url: session.url, session: session.id, reconciled };
@@ -419,7 +522,7 @@ async function viaFor(request, env, url, shop, player) {
419
522
  if (!a || a.via === url.hostname.replace(/^www\./, '')) return null;
420
523
  if (shop.referrals.windowDays !== null && Date.now() - a.at > Math.round(shop.referrals.windowDays * DAY)) return null;
421
524
  // 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;
525
+ if (shop.referralNewPlayersOnly && player.since !== null && player.since < a.at - 60_000) return null;
423
526
  return (await referrerOk(a.via)) ? a.via : null;
424
527
  }
425
528
 
@@ -437,26 +540,39 @@ function purchaseLimited(request, shop, player, kind) {
437
540
  return account || address;
438
541
  }
439
542
 
440
- async function buyRoute(request, env, url, cat, shop, ready, ctx) {
543
+ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
441
544
  const b = await bodyOf(request, requestSize(shop));
442
545
  if (b.error) return b.error;
443
546
  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');
547
+ const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
548
+ // Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
549
+ if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
446
550
  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
551
  const facts = await serverFacts(env, cat, game, b.body.server);
449
552
  let player = await accountOf(request, env);
450
553
  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)}`}.`);
554
+ const lines = [];
555
+ let amount = 0;
556
+ const seen = new Set();
557
+ for (const entry of requested) {
558
+ const item = shop.items.find((i) => i.id === entry?.item);
559
+ if (!item) return fail(404, 'item', 'no such item here');
560
+ const quantity = entry.quantity ?? 1;
561
+ if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
562
+ if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
563
+ seen.add(item.id);
564
+ const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
565
+ const way = facts.kids && !shop.policy.kidsServer ? 'kids' : v.items[0]?.way ?? 'beginner';
566
+ if (way !== 'checkout') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
567
+ const unit = item.price === 'choose' ? entry.amount : item.price;
568
+ if (amountError(unit, shop.currency)) return fail(400, 'amount', amountError(unit, shop.currency));
569
+ 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.');
570
+ const total = BigInt(unit) * BigInt(quantity) + BigInt(amount);
571
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) return fail(400, 'arithmetic', 'The order total exceeds safe integer arithmetic.');
572
+ amount = Number(total);
573
+ lines.push({ item, amount: unit, quantity });
459
574
  }
575
+ const item = lines[0].item;
460
576
  let cookies = [];
461
577
  if (!player) {
462
578
  const guest = await players.shopGuest(request, env, url, shop.guestBuyersPerAddressPerHour);
@@ -465,9 +581,9 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx) {
465
581
  cookies = guest.session.cookies;
466
582
  }
467
583
  const via = await viaFor(request, env, url, shop, player);
468
- const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount });
584
+ const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount, lines, requestAt });
469
585
  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)));
586
+ if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
471
587
  for (const cookie of cookies) response.headers.append('set-cookie', cookie);
472
588
  return response;
473
589
  }
@@ -477,17 +593,29 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
477
593
  const b = await bodyOf(request, requestSize(shop));
478
594
  if (b.error) return b.error;
479
595
  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');
596
+ const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1 }];
597
+ if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
482
598
  const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
483
599
  const facts = await serverFacts(env, cat, game, b.body.server);
484
600
  const player = await accountOf(request, env);
485
601
  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 });
602
+ const cart = [], seen = new Set();
603
+ for (const entry of requested) {
604
+ const item = shop.items.find((i) => i.id === entry?.item);
605
+ if (!item) return fail(404, 'item', 'no such item here');
606
+ const quantity = entry.quantity ?? 1;
607
+ if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
608
+ if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
609
+ seen.add(item.id);
610
+ const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
611
+ const way = facts.kids && !shop.policy.kidsServer ? 'kids' : v.items[0]?.way ?? 'closed';
612
+ if (way !== 'ask-a-parent') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
613
+ cart.push({ item: item.id, quantity });
614
+ }
615
+ const item = shop.items.find((i) => i.id === cart[0].item);
616
+ cart[0].via = await viaFor(request, env, url, shop, player);
489
617
  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();
618
+ 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
619
  return json({ ok: true, link: `${url.origin}/shop/parent/${token}`, expiresInDays: 7 });
492
620
  }
493
621
 
@@ -495,34 +623,44 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
495
623
  async function parentRoute(request, env, url, cat, shop, ready, token, ctx) {
496
624
  if (!TOKEN.test(token)) return notFoundPage('This link does not work.', cat);
497
625
  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;
626
+ const cart = row ? row.cart ? JSON.parse(row.cart) : [{ item: row.item, quantity: 1 }] : [];
627
+ const lines = cart.map((entry) => ({ item: shop.items.find((i) => i.id === entry.item), quantity: entry.quantity }));
628
+ const item = lines[0]?.item;
629
+ if (lines.some((line) => !line.item)) return parentPage(cat, shop, { gone: true });
499
630
  if (!row || !item || Number(row.expires_at) < Date.now()) return parentPage(cat, shop, { gone: true });
500
631
  const kid = await players.get(env, row.player);
501
632
  if (!kid) return parentPage(cat, shop, { gone: true });
502
633
  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 });
634
+ if (done?.status === 'paid') return parentPage(cat, shop, { item, lines, name: kid.name, paid: true });
635
+ if (request.method === 'GET' || request.method === 'HEAD') return parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready });
505
636
  if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403 });
506
637
  let form = null;
507
638
  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.' });
639
+ 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.' });
640
+ if (!ready.ready) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: false });
641
+ 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
642
  const band = await bandOfPlayer(env, row.player);
512
643
  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 });
644
+ let amount = 0;
645
+ for (const [index, line] of lines.entries()) {
646
+ const it = line.item;
647
+ const owned = it.gives.length > 0 && it.gives.every((key) => owns.some((o) => o.key === key && o.item === it.id));
648
+ if (wayFor(it, { player: kid, band, owned, spent: 0, cap: null, policy: shop.policy }) !== 'ask-a-parent') return parentPage(cat, shop, { gone: true });
649
+ line.amount = it.price === 'choose' ? parseAmount(form?.get(lines.length === 1 ? 'amount' : `amount_${index}`), currencyScale(shop.currency)) : it.price;
650
+ const invalid = amountError(line.amount, shop.currency);
651
+ 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.' });
652
+ const total = BigInt(amount) + BigInt(line.amount) * BigInt(line.quantity);
653
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
654
+ amount = Number(total);
655
+ }
656
+ 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 });
657
+ if (r.error) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
520
658
  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)));
659
+ if (!r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, row.player).catch((error) => logOrderError(env, r.order.id, error)));
522
660
  return Response.redirect(r.url, 303);
523
661
  }
524
662
 
525
- /** The game says the player used something (equipped a skin, played the pass): it is no longer refundable by the player. */
663
+ /** Mark use for studios that choose to exclude used items from player refunds. */
526
664
  async function usedRoute(request, env, shop) {
527
665
  const b = await bodyOf(request, requestSize(shop));
528
666
  if (b.error) return b.error;
@@ -534,13 +672,20 @@ async function usedRoute(request, env, shop) {
534
672
  return json({ ok: true, used: Number(r?.meta?.changes ?? 0) });
535
673
  }
536
674
 
675
+ async function lineWasUsed(env, order, line) {
676
+ 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();
677
+ 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();
678
+ }
679
+
537
680
  function refundableNow(order, owns, shop, now = Date.now()) {
538
681
  if (shop.refundDays === null || order.status !== 'paid' || order.paidAt === null) return false;
682
+ // 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.
683
+ 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
684
  if (now - order.paidAt > Math.round(shop.refundDays * DAY)) return false;
540
685
  return shop.policy.refundUsedItems || !owns.some((o) => o.item === order.item && o.used);
541
686
  }
542
687
 
543
- /** A player's own refund: an unused item, within the studio's refund days (fewer disputes, and the respected norm). */
688
+ /** A player's own refund follows the studio window and used-item setting, excluding tips. */
544
689
  async function selfRefundRoute(request, env, shop, ready) {
545
690
  const b = await bodyOf(request, requestSize(shop));
546
691
  if (b.error) return b.error;
@@ -548,11 +693,15 @@ async function selfRefundRoute(request, env, shop, ready) {
548
693
  if (!p) return fail(401, 'account', 'sign in first');
549
694
  const o = await orderById(env, b.body.order);
550
695
  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.`);
696
+ const lines = (await orderLines(env, o.id)).filter((l) => !b.body.line || l.id === b.body.line);
697
+ if (!lines.length) return fail(404, 'line', 'no such line on this order');
698
+ for (const line of lines) {
699
+ const used = await lineWasUsed(env, o.id, line);
700
+ 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.');
701
+ }
553
702
  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);
703
+ const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player', line: b.body.line });
704
+ 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
705
  return json(r, r.ok ? 200 : 502);
557
706
  }
558
707
 
@@ -560,54 +709,92 @@ async function selfRefundRoute(request, env, shop, ready) {
560
709
 
561
710
  async function hook(request, env, cat, shop, ready) {
562
711
  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();
712
+ const maxBytes = Number(env.SHOP_WEBHOOK_BYTES ?? 512 * 1024);
713
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) return fail(503, 'setting', 'SHOP_WEBHOOK_BYTES is a positive whole number.');
714
+ const payload = await boundedText(request, maxBytes);
715
+ if (payload === null) return fail(413, 'too-large', 'The studio webhook byte limit was reached.');
565
716
  const v = await verifyWebhook(payload, request.headers.get('stripe-signature'), env.STRIPE_WEBHOOK_SECRET);
566
717
  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
718
  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
719
  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 ?? ''));
720
+ 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'];
721
+ if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
722
+ 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' });
723
+ try {
724
+ const sessionEvent = ev.type.startsWith('checkout.session.');
725
+ let known = sessionEvent ? await orderBySession(env, obj.id) : await orderByPayment(env, String(obj.payment_intent ?? ''));
726
+ if (!known) {
727
+ let metadata = obj.metadata;
728
+ let named = metadata?.order ? await orderById(env, metadata.order) : null;
729
+ // Charge metadata identifies foreign sales without a provider read. Untagged refunds/disputes
730
+ // need their PaymentIntent only when this database actually has an unrecorded payment.
731
+ if (!named && !sessionEvent && obj.payment_intent && !metadata?.order &&
732
+ (!metadata?.homie || metadata.homie === 'shop-v1') && (!metadata?.origin || metadata.origin === new URL(request.url).origin) &&
733
+ await env.DB.prepare('SELECT 1 FROM shop_orders WHERE payment IS NULL AND paid_at IS NULL AND mode = ?1 LIMIT 1').bind(modeOf(env.STRIPE_KEY)).first()) {
734
+ let payment;
735
+ try { payment = await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(obj.payment_intent)}`); }
736
+ catch (error) {
737
+ if (error instanceof StripeError && error.status === 404 && error.code === 'resource_missing') return json({ ok: true, did: 'ignored' });
738
+ throw error;
739
+ }
740
+ metadata = payment.metadata;
741
+ named = metadata?.order ? await orderById(env, metadata.order) : null;
597
742
  }
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;
743
+ if (!named) return json({ ok: true, did: 'ignored' });
744
+ if (sessionEvent) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
745
+ if (!(await migrated(env))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
746
+ if (named.payment || named.mode !== modeOf(env.STRIPE_KEY)) return json({ ok: true, did: 'ignored' });
747
+ if (!named.session) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
748
+ const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(named.session)}`, { expand: ['payment_intent.latest_charge'] });
749
+ 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.');
750
+ if ((session.payment_intent?.id ?? session.payment_intent) !== obj.payment_intent) return json({ ok: true, did: 'ignored' });
751
+ await paid(env, shop, session, (session.payment_intent?.latest_charge?.created ?? session.created) * 1000);
752
+ known = await orderByPayment(env, obj.payment_intent);
753
+ if (!known) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
754
+ }
755
+ if (!(await migrated(env))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
756
+ if (await env.DB.prepare('SELECT 1 AS x FROM shop_events WHERE id = ?1').bind(ev.id).first()) {
757
+ 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);
758
+ return json({ ok: true, duplicate: true });
759
+ }
760
+ let did = 'ignored';
761
+ switch (ev.type) {
762
+ case 'checkout.session.completed':
763
+ case 'checkout.session.async_payment_succeeded':
764
+ if (ev.type === 'checkout.session.async_payment_succeeded' || obj.payment_status === 'paid' || obj.payment_status === 'no_payment_required') did = await paid(env, shop, obj);
765
+ else {
766
+ 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();
767
+ did = 'waiting';
768
+ }
769
+ break;
770
+ case 'checkout.session.async_payment_failed':
771
+ case 'checkout.session.expired':
772
+ 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();
773
+ did = 'closed';
774
+ break;
775
+ case 'charge.refunded':
776
+ case 'refund.created':
777
+ case 'refund.updated':
778
+ case 'refund.failed':
779
+ did = await syncRefunds(env, String(obj.payment_intent ?? ''));
780
+ break;
781
+ case 'charge.dispute.created':
782
+ did = await disputed(env, String(obj.payment_intent ?? ''), String(obj.id ?? ''));
783
+ break;
784
+ case 'charge.dispute.closed':
785
+ did = await disputeClosed(env, String(obj.payment_intent ?? ''), String(obj.status ?? ''));
786
+ break;
787
+ default:
788
+ break;
789
+ }
790
+ 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.');
791
+ 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();
792
+ if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
793
+ return json({ ok: true, did });
794
+ } catch (error) {
795
+ logOrderError(env, null, error);
796
+ return fail(503, 'stripe-retry', 'Payment reconciliation is temporarily unavailable; retry this event.');
607
797
  }
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 });
611
798
  }
612
799
 
613
800
  /** A paid checkout: the order is paid, its entitlements granted, a referral line written. Idempotent. */
@@ -615,34 +802,145 @@ async function paid(env, shop, session, paidAt = Date.now()) {
615
802
  const o = await orderBySession(env, session.id);
616
803
  if (!o) return 'unknown-order';
617
804
  // 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';
805
+ if (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
806
  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);
807
+ if (['refunded', 'lost', 'disputed'].includes(o.status)) {
808
+ if (o.payment) await syncRefunds(env, o.payment);
809
+ await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run();
810
+ return 'already';
811
+ }
812
+ const payment = typeof session.payment_intent === 'string'
813
+ ? await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(session.payment_intent)}`, { expand: ['latest_charge'] }) : session.payment_intent;
814
+ let dispute = null;
815
+ if (payment?.latest_charge?.disputed) {
816
+ const list = await stripeCall(env, 'GET', '/v1/disputes', { payment_intent: payment.id, limit: 100 });
817
+ if (!Array.isArray(list.data) || !list.data.length || list.has_more) throw new Error('Stripe dispute state is unavailable');
818
+ dispute = list.data.find((d) => d.status === 'lost') ?? list.data[0];
819
+ }
820
+ const lines = await orderLines(env, o.id);
821
+ if (!lines.length) throw new Error('Order has no lines');
622
822
  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';
823
+ const total = session.amount_total ?? Number(o.amount);
824
+ const tax = session.total_details?.amount_tax ?? total - Number(o.amount);
825
+ if (!Number.isSafeInteger(total) || !Number.isSafeInteger(tax) || total < 0 || tax < 0 || total !== Number(o.amount) + tax) return 'mismatch';
826
+ let stripeLines = null;
827
+ if (lines.length > 1 && tax) {
828
+ // Use Stripe's actual tax per line, never spread tax across products with different tax codes.
829
+ // https://docs.stripe.com/api/checkout/sessions/line_items
830
+ const response = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(session.id)}/line_items`, { limit: 100, expand: ['data.price.product'] });
831
+ if (response.has_more || response.data?.length !== lines.length) throw new Error('Stripe line totals are incomplete');
832
+ stripeLines = [...response.data];
833
+ }
626
834
  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';
835
+ let lineTotal = 0;
836
+ for (const line of lines) {
837
+ // The database schema step creates lines for old orders. Only those lack a snapshot.
838
+ const item = line.snapshot ? JSON.parse(line.snapshot) : shop.items.find((i) => i.id === line.item);
839
+ if (!item && o.status === 'paid') { await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run(); return 'already'; }
840
+ if (!item) {
841
+ 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();
842
+ return 'missing-item';
843
+ }
844
+ let gross = lines.length === 1 ? total : Number(line.amount);
845
+ if (stripeLines) {
846
+ const index = stripeLines.findIndex((l) => l.quantity === line.quantity && l.amount_subtotal === line.amount &&
847
+ (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));
848
+ if (index < 0) throw new Error('Stripe line identity mismatch');
849
+ gross = stripeLines.splice(index, 1)[0].amount_total;
850
+ }
851
+ if (!Number.isSafeInteger(gross) || gross < line.amount) throw new Error('Invalid Stripe line total');
852
+ lineTotal += gross;
853
+ if (o.player && dispute?.status !== 'lost' && line.status !== 'refunded') writes.push(...grantStatements(env, o, item, Number(o.paid_at ?? now), line));
854
+ 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)));
855
+ }
856
+ if (lineTotal !== total) throw new Error('Stripe line totals do not match order');
857
+ if (o.via && o.paid_at === null) writes.push(...(await lineFor(env, o, o.referral_terms ? { referrals: JSON.parse(o.referral_terms) } : shop, { now })));
858
+ writes.push(env.DB.prepare("UPDATE referral_lines SET original_share = share WHERE order_id = ?1 AND original_share IS NULL").bind(o.id));
859
+ 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")
860
+ .bind(o.id, typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id ?? null, tax, total, paidAt, now));
861
+ // D1 batch is transactional: a failure rolls back payment, every grant and the referral together.
862
+ // https://developers.cloudflare.com/d1/worker-api/d1-database/#batch
863
+ const result = await env.DB.batch(writes);
864
+ const paymentId = payment?.id ?? (typeof session.payment_intent === 'string' ? session.payment_intent : null);
865
+ if (dispute) {
866
+ await disputed(env, paymentId, dispute.id);
867
+ if (['lost', 'won', 'warning_closed'].includes(dispute.status)) await disputeClosed(env, paymentId, dispute.status);
868
+ }
869
+ if (paymentId) await syncRefunds(env, paymentId);
870
+ await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run();
871
+ return result.at(-1)?.meta?.changes ? 'paid' : 'already';
633
872
  }
634
873
 
635
- async function refunded(env, payment, refundId) {
636
- const o = payment ? await orderByPayment(env, payment) : null;
874
+ /** Rebuild the books from Stripe's refund IDs, never event delivery order or Charge.amount_refunded.
875
+ * Pending/failed refunds do not revoke goods. A failed refund reverses its earlier booking.
876
+ * https://docs.stripe.com/api/refunds/object#refund_object-status
877
+ * https://docs.stripe.com/api/refunds/list
878
+ */
879
+ async function syncRefunds(env, payment, freeOrder = null, freeLines = []) {
880
+ const o = freeOrder ?? await orderByPayment(env, payment);
637
881
  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';
882
+ const all = await orderLines(env, o.id);
883
+ const refunds = new Map();
884
+ let cursor;
885
+ if (payment) do {
886
+ const page = await stripeCall(env, 'GET', '/v1/refunds', { payment_intent: payment, limit: 100, ...(cursor ? { starting_after: cursor } : {}) });
887
+ if (!Array.isArray(page.data)) throw new Error('Stripe refund list is unavailable');
888
+ for (const refund of page.data) refunds.set(refund.id, refund);
889
+ const next = page.has_more ? page.data.at(-1)?.id : null;
890
+ if (page.has_more && (!next || next === cursor)) throw new Error('Stripe refund list is incomplete');
891
+ cursor = next;
892
+ } while (cursor);
893
+ const totals = new Map(all.map((line) => [line.id, 0]));
894
+ let amount = 0;
895
+ for (const refund of refunds.values()) {
896
+ if (refund.status !== 'succeeded') continue;
897
+ if (!Number.isSafeInteger(refund.amount) || refund.amount < 0) throw new Error('Invalid Stripe refund amount');
898
+ amount += refund.amount;
899
+ if (refund.metadata?.line && totals.has(refund.metadata.line)) totals.set(refund.metadata.line, totals.get(refund.metadata.line) + refund.amount);
900
+ }
901
+ const gross = Number(o.total ?? o.amount);
902
+ if (!Number.isSafeInteger(amount) || amount > gross) throw new Error('Stripe refund total exceeds payment');
903
+ const moneyFull = amount === gross && gross > 0;
904
+ const whole = moneyFull && [...refunds.values()].some((r) => r.status === 'succeeded' && !r.metadata?.line);
905
+ const taggedGross = [...totals.values()].reduce((n, value) => n + value, 0);
906
+ 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);
907
+ const net = moneyFull ? Number(o.amount) : taggedNet + (gross > taggedGross ? Number(BigInt(Number(o.amount) - taggedNet) * BigInt(amount - taggedGross) / BigInt(gross - taggedGross)) : 0);
908
+ const revision = Number(o.refund_revision);
909
+ const guard = `EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND refund_revision = ?2 AND (status != 'lost' OR ${o.status === 'lost' ? 1 : 0}))`;
910
+ const writes = [];
911
+ let changed = Number(o.refunded_amount) !== amount || Number(o.refunded_net) !== net;
912
+ let full = true;
913
+ for (const line of all) {
914
+ // A completed whole-order refund (including earlier partial refunds) names every line.
915
+ // Untagged partial refunds remain solely on the order: no invented item allocation.
916
+ const target = whole ? Number(line.total ?? line.amount) : totals.get(line.id);
917
+ if (target > Number(line.total ?? line.amount)) throw new Error('Stripe line refund exceeds line payment');
918
+ const lineNet = line.total || line.amount ? Number(BigInt(line.amount) * BigInt(target) / BigInt(line.total ?? line.amount)) : 0;
919
+ const localFree = Number(line.total ?? line.amount) === 0 && (freeLines.includes(line.id) || line.refund === 'local-free');
920
+ const revoked = whole || localFree || target > 0 && target === Number(line.total ?? line.amount);
921
+ full &&= revoked;
922
+ changed ||= Number(line.refunded_amount) !== target || Number(line.refunded_net) !== lineNet || (line.status === 'refunded') !== revoked || localFree && line.refund !== 'local-free';
923
+ 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));
924
+ 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'));
925
+ }
926
+ 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));
927
+ 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));
928
+ const referral = await env.DB.prepare('SELECT * FROM referral_lines WHERE order_id = ?1').bind(o.id).first();
929
+ if (referral && o.status !== 'lost') {
930
+ const share = Number(referral.original_share ?? referral.share);
931
+ const remaining = o.amount ? Number((2n * BigInt(share) * BigInt(Number(o.amount) - net) + BigInt(o.amount)) / (2n * BigInt(o.amount))) : 0;
932
+ changed ||= Number(referral.share) !== remaining || Number(referral.net) !== Number(o.amount) - net || (referral.state === 'void') !== (remaining === 0) && referral.state !== 'settled';
933
+ const delta = Number(referral.share) - remaining;
934
+ 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()));
935
+ 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));
936
+ }
937
+ full &&= o.status !== 'lost';
938
+ changed ||= (o.status === 'refunded') !== full;
939
+ if (!changed) return 'already';
940
+ 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()));
941
+ const result = await env.DB.batch(writes);
942
+ if (!result.at(-1)?.meta?.changes) return syncRefunds(env, payment, freeOrder ? await orderById(env, o.id) : null, freeLines);
943
+ return o.refunded_amount === amount && o.status === (full ? 'refunded' : o.status) ? 'already' : 'refunded';
646
944
  }
647
945
 
648
946
  /** A dispute changes nothing until it is decided: the player keeps the item and their account. */
@@ -658,9 +956,11 @@ async function disputeClosed(env, payment, status) {
658
956
  const o = payment ? await orderByPayment(env, payment) : null;
659
957
  if (!o) return 'unknown-order';
660
958
  const now = Date.now();
959
+ if (!['paid', 'disputed'].includes(o.status)) return 'already';
661
960
  if (status === 'lost') {
662
961
  await env.DB.batch([
663
- env.DB.prepare("UPDATE shop_orders SET status = 'lost', updated_at = ?2 WHERE id = ?1").bind(o.id, now),
962
+ env.DB.prepare("UPDATE shop_orders SET status = 'lost', updated_at = ?2 WHERE id = ?1 AND status IN ('paid', 'disputed')").bind(o.id, now),
963
+ 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
964
  revokeStatement(env, o.id),
665
965
  ...voidLineStatements(env, o.id),
666
966
  ]);
@@ -668,6 +968,7 @@ async function disputeClosed(env, payment, status) {
668
968
  }
669
969
  await env.DB.batch([
670
970
  env.DB.prepare("UPDATE shop_orders SET status = 'paid', updated_at = ?2 WHERE id = ?1 AND status = 'disputed'").bind(o.id, now),
971
+ 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
972
  restoreStatement(env, o.id),
672
973
  ]);
673
974
  return 'kept';
@@ -676,31 +977,52 @@ async function disputeClosed(env, payment, status) {
676
977
  /** What the owner reads when Stripe holds a refund for approval (the shop's key is an Agent key). */
677
978
  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
979
 
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.' };
980
+ /** Refund selected lines through Stripe, revoking each only after success. */
981
+ export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner', line: lineId = null } = {}) {
982
+ 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}.` };
983
+ const lines = (await orderLines(env, o.id)).filter((l) => !lineId || l.id === lineId);
984
+ if (!lines.length) return { ok: false, error: 'line', message: 'No such order line.' };
985
+ const legacy = Boolean(await migrationNeeded(env));
986
+ // One whole-order refund asks Stripe for the charge's remaining balance. Stripe arbitrates concurrent
987
+ // line/whole refunds; this keeps even 100-line carts within the Worker's subrequest budget.
988
+ // https://docs.stripe.com/api/refunds/create#create_refund-amount
989
+ const selected = lines.filter((l) => l.status !== 'refunded');
990
+ if (!selected.length) return { ok: true, order: o.id, line: lineId, amount: 0, currency: o.currency, status: 'succeeded' };
991
+ const amount = selected.reduce((sum, l) => sum + Number(l.total ?? l.amount) - Number(l.refunded_amount ?? 0), 0);
688
992
  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.` };
993
+ if (amount) {
994
+ if (!o.payment) return { ok: false, error: 'no-payment', message: 'Stripe has not confirmed the payment yet.' };
995
+ try {
996
+ const prior = lineId ? selected[0].refund : legacy ? o.refund : o.whole_refund;
997
+ refund = prior ? await stripeCall(env, 'GET', `/v1/refunds/${encodeURIComponent(prior)}`) : null;
998
+ if (!refund || ['failed', 'canceled'].includes(refund.status)) {
999
+ refund = await createRefund(env, { payment_intent: o.payment, ...(lineId ? { amount } : {}),
1000
+ reason: ['duplicate', 'fraudulent', 'requested_by_customer'].includes(reason) ? reason : 'requested_by_customer',
1001
+ metadata: { order: o.id, ...(lineId ? { line: lineId } : {}), by } },
1002
+ { idempotencyKey: `refund-${lineId ?? o.id}${lineId && selected[0].refunded_amount ? '-' + selected[0].refunded_amount : ''}${refund ? '-' + refund.id : ''}` });
1003
+ }
1004
+ if (typeof refund?.id === 'string') {
1005
+ if (lineId && !legacy) await env.DB.prepare('UPDATE shop_order_lines SET refund = ?2 WHERE id = ?1').bind(lineId, refund.id).run();
1006
+ 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();
1007
+ }
1008
+ } catch (error) {
1009
+ if (isApprovalRequired(error)) return { ok: false, error: 'approval', held: true, message: REFUND_HELD };
1010
+ return { ok: false, error: 'stripe', message: `Stripe did not refund this order (${error instanceof StripeError ? redactStripe(error.message, env) : 'no answer'}).` };
1011
+ }
1012
+ if (!legacy) {
1013
+ try { await syncRefunds(env, o.payment); }
1014
+ 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.' }; }
1015
+ }
1016
+ if (refund?.status !== 'succeeded') return { ok: false, held: true, error: 'pending', message: 'Stripe has not completed this refund yet.' };
696
1017
  }
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 };
1018
+ if (legacy) {
1019
+ await env.DB.batch([
1020
+ revokeStatement(env, o.id), ...voidLineStatements(env, o.id),
1021
+ 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()),
1022
+ ]);
1023
+ } else if (!amount) await syncRefunds(env, o.payment, o.payment ? null : o, selected.map((line) => line.id));
1024
+
1025
+ return { ok: true, order: o.id, line: lineId, amount: refund?.amount ?? amount, currency: o.currency, status: 'succeeded' };
704
1026
  }
705
1027
 
706
1028
  /* ------------------------------------------------------------------ the office (worker/office.mjs calls these) */
@@ -709,21 +1031,27 @@ export async function refundOrder(env, o, { reason = 'requested_by_customer', by
709
1031
  export function checkRefund(body) {
710
1032
  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
1033
  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 } };
1034
+ const note = String(body.note ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim() || null;
1035
+ return { ok: true, action: { op: 'refund', order: body.order, line: typeof body.line === 'string' ? body.line : null, reason, note } };
714
1036
  }
715
1037
  /** Explicit owner override of an unresolved reservation; the existing note records its actor and time. */
716
1038
  export function checkRelease(body) {
717
1039
  const checked = checkRefund(body);
718
1040
  return checked.ok ? { ok: true, action: { op: 'shop-release', order: body.order } } : checked;
719
1041
  }
720
- export async function performRelease(env, a) {
1042
+ export async function performRelease(env, a, cat) {
721
1043
  const row = await orderById(env, a.order);
722
1044
  if (!row || !['started', 'processing'].includes(row.status)) return { ok: false, error: 'state', message: 'Only an unresolved order can be released.' };
723
1045
  // https://docs.stripe.com/api/checkout/sessions/expire — only open sessions can expire.
724
1046
  if (row.session) {
725
1047
  try {
726
- const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, null, { timeout: 2000 });
1048
+ let session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
1049
+ if (session?.id === row.session && ['paid', 'no_payment_required'].includes(session.payment_status)) {
1050
+ let result;
1051
+ try { result = await paid(env, shopOf(cat), session, (session.payment_intent?.latest_charge?.created ?? session.created ?? Math.floor(Date.now() / 1000)) * 1000); }
1052
+ 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.' }; }
1053
+ 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.' };
1054
+ }
727
1055
  if (session?.id === row.session && session.status === 'open') await expireCheckoutSession(env, row.session, { timeout: 2000 });
728
1056
  } catch (error) { logOrderError(env, row.id, error); }
729
1057
  }
@@ -736,7 +1064,7 @@ export async function performRelease(env, a) {
736
1064
  export function checkSettle(body) {
737
1065
  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
1066
  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) } };
1067
+ return { ok: true, action: { op: 'shop-settle', currency: body.currency, via: String(body.via), ref: String(body.ref ?? '') } };
740
1068
  }
741
1069
 
742
1070
  /** The refund in plain words, for the owner's confirm page and the AI's card. */
@@ -744,14 +1072,23 @@ export function describeRefund(cat, a, order = null) {
744
1072
  const shop = shopOf(cat);
745
1073
  const name = shop?.items.find((i) => i.id === order?.item)?.name ?? order?.item ?? 'an item';
746
1074
  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();
1075
+ 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
1076
  }
749
1077
 
750
1078
  /** Do the refund (the owner's tap, or the owner's yes to the AI's ask). */
751
1079
  export async function performRefund(env, cat, a) {
752
- const o = await orderById(env, a.order);
1080
+ let o = await orderById(env, a.order);
753
1081
  if (!o) return { ok: false, error: 'order', message: 'No such order.' };
754
- return refundOrder(env, o, { reason: a.reason, by: 'owner' });
1082
+ if (!o.payment && o.session && o.paid_at === null && !(await migrationNeeded(env))) {
1083
+ try {
1084
+ const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(o.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
1085
+ if (session?.id === o.session && (session.livemode === undefined || session.livemode === (o.mode === 'live')) && ['paid', 'no_payment_required'].includes(session.payment_status)) {
1086
+ await paid(env, shopOf(cat), session, (session.payment_intent?.latest_charge?.created ?? session.created ?? Math.floor(Date.now() / 1000)) * 1000);
1087
+ o = await orderById(env, a.order);
1088
+ }
1089
+ } catch (error) { return { ok: false, error: 'stripe', message: redactStripe(error.message, env) }; }
1090
+ }
1091
+ return refundOrder(env, o, { reason: a.reason, by: 'owner', line: a.line });
755
1092
  }
756
1093
 
757
1094
  /** Everything the office's shop page shows. Money is read from this studio's D1; Stripe's own pages are linked. */
@@ -761,7 +1098,7 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
761
1098
  const mode = ready.mode ?? 'test';
762
1099
  const out = {
763
1100
  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,
1101
+ 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
1102
  stripe: {
766
1103
  payments: dashboardLink(mode, 'payments'), refunds: dashboardLink(mode, 'refunds'), disputes: dashboardLink(mode, 'disputes'), payouts: dashboardLink(mode, 'payouts'),
767
1104
  balance: dashboardLink(mode, 'balance'), tax: dashboardLink(mode, 'tax'), keys: dashboardLink(mode, 'keys'), webhooks: dashboardLink(mode, 'webhooks'),
@@ -770,20 +1107,23 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
770
1107
  hook: `${origin}/api/shop/hook`,
771
1108
  totals: null, orders: [], referrals: { owe: [], owedToUs: [] },
772
1109
  };
773
- if (!(await migrated(env))) return out;
1110
+ if (!(await migrated(env, { complete: false }))) return out;
774
1111
  const since = Date.now() - 30 * DAY;
775
1112
  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
1113
  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
1114
  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 ?? [];
1115
+ 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();
1116
+ out.totals.paid -= Number(partial?.amount ?? 0); out.totals.refunded += Number(partial?.amount ?? 0); out.totals.refunds += Number(partial?.orders ?? 0);
778
1117
  out.nextOrdersCursor = rows.length > 100 ? String(orderCursor + 100) : null;
779
1118
  if (rows.length > 100) rows.pop();
780
1119
  const names = new Map((shop?.items ?? []).map((i) => [i.id, i.name]));
781
- out.orders = rows.map((r) => ({
1120
+ out.orders = await Promise.all(rows.map(async (r) => ({
1121
+ 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
1122
  ...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
1123
  stripe: r.payment ? dashboardLink(r.mode, 'payment', r.payment) : null, dispute: r.dispute ? dashboardLink(r.mode, 'dispute', r.dispute) : null,
784
1124
  releasable: ['started', 'processing'].includes(r.status),
785
- refundable: r.status === 'paid' && (Number(r.amount) === 0 || Boolean(r.payment)),
786
- }));
1125
+ refundable: ['paid', 'started', 'processing', 'missing', 'released', 'expired', 'failed'].includes(r.status) && (Number(r.amount) === 0 || Boolean(r.payment) || Boolean(r.session)),
1126
+ })));
787
1127
  const { books, nextCursor } = await booksOf(env, { cursor });
788
1128
  out.referrals = { nextCursor, owe: books, failures: await statementFailures(env), owedToUs: await statementsIn(env), invoice: dashboardLink(mode, 'invoices') };
789
1129
  return out;
@@ -793,9 +1133,9 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
793
1133
  export async function ordersCsv(env) {
794
1134
  const rows = (await env.DB.prepare("SELECT * FROM shop_orders WHERE status != 'started' ORDER BY created_at").all()).results ?? [];
795
1135
  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'];
1136
+ 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
1137
  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(','));
1138
+ 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
1139
  return `${head.join(',')}\n${lines.join('\n')}${lines.length ? '\n' : ''}`;
800
1140
  }
801
1141