@homie-rocks/studio 0.33.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,15 +5,12 @@ export const TILLS = Object.freeze(['stripe', 'stripe-managed', 'off']);
5
5
  export const POLICY_PRESETS = Object.freeze({
6
6
  protective: Object.freeze({ withdrawalAcknowledgement: true, childAge: 13, adultAge: 18, requireAccount: true, ageQuestion: true, children: 'deny', teens: 'parent',
7
7
  kidsStudio: false, kidsServer: false, beginnerAdvantages: false, kidsAdvantages: false,
8
- paidRandomRewards: false, countdownOffers: false, virtualCurrency: false, supporterAdvantages: false,
9
8
  repeatPurchases: false, refundUsedItems: false, televisionCheckout: false }),
10
9
  'adults-only': Object.freeze({ withdrawalAcknowledgement: true, childAge: 13, adultAge: 18, requireAccount: true, ageQuestion: true, children: 'deny', teens: 'deny',
11
10
  kidsStudio: false, kidsServer: false, beginnerAdvantages: false, kidsAdvantages: false,
12
- paidRandomRewards: false, countdownOffers: false, virtualCurrency: false, supporterAdvantages: false,
13
11
  repeatPurchases: false, refundUsedItems: false, televisionCheckout: false }),
14
- custom: Object.freeze({ withdrawalAcknowledgement: true, childAge: 13, adultAge: 18, requireAccount: false, ageQuestion: false, children: 'allow', teens: 'allow',
12
+ custom: Object.freeze({ withdrawalAcknowledgement: false, childAge: 13, adultAge: 18, requireAccount: false, ageQuestion: false, children: 'allow', teens: 'allow',
15
13
  kidsStudio: true, kidsServer: true, beginnerAdvantages: true, kidsAdvantages: true,
16
- paidRandomRewards: true, countdownOffers: true, virtualCurrency: true, supporterAdvantages: true,
17
14
  repeatPurchases: true, refundUsedItems: true, televisionCheckout: true }),
18
15
  });
19
16
  export const ITEM_ID = /^[^\u0000-\u001f\u007f]+$/;
@@ -50,38 +47,28 @@ export function amountError(amount, currency) {
50
47
  return null;
51
48
  }
52
49
 
50
+ /** Round a decimal rate times integer money exactly, including exponent notation. */
51
+ export function referralShare(amount, rate) {
52
+ const [digits, exponent = '0'] = String(rate).toLowerCase().split('e');
53
+ const [whole, fraction = ''] = digits.split('.');
54
+ const scale = fraction.length - Number(exponent);
55
+ let numerator = BigInt(whole + fraction), denominator = 1n;
56
+ if (scale > 0) denominator = 10n ** BigInt(scale); else numerator *= 10n ** BigInt(-scale);
57
+ const share = Number((2n * BigInt(amount) * numerator + denominator) / (2n * denominator));
58
+ if (!Number.isSafeInteger(share)) throw new RangeError('Referral share exceeds safe integer arithmetic');
59
+ return share;
60
+ }
61
+
53
62
  /** Checkout wording is shared by validation and the request path. */
54
63
  export function checkoutMessage(shop, studio = 'this studio', parent = false) {
55
64
  return `${parent ? "This is for your child's account. " : ''}Delivered at once to the player's account on ${studio}. ${shop.policy.withdrawalAcknowledgement ? 'By paying you ask for it now, which ends the 14-day withdrawal right;' : 'Your statutory rights still apply.'} ${shop.refundDays === null ? (shop.policy.withdrawalAcknowledgement ? 'ask the studio about refunds.' : 'Ask the studio about refunds.') : `${shop.policy.refundUsedItems ? 'an item' : 'an unused item'} can still be refunded within ${shop.refundDays} days.`}`;
56
65
  }
57
66
 
58
- /** Content patterns used only when the studio's paidRandomRewards policy refuses them. */
59
- const RANDOM_WORDS = /\b(?:chance|chances|odds|random|randomi[sz]ed|randomly|crates?|box|boxes|lootbox(?:es)?|loot|mystery|gacha|spins?|roll|rolls|lottery|raffle|sweepstakes?|jackpot|surprise|blind ?bag|drop ?rate|pity)\b/i;
60
- /** Fields that only exist to make an item random. */
61
- const RANDOM_FIELDS = new Set(['odds', 'chance', 'chances', 'random', 'pool', 'drops', 'droptable', 'loottable', 'weights', 'rarityweights', 'probability', 'probabilities']);
62
- /** Fields that only exist to pressure: a clock on an offer. */
63
- const PRESSURE_FIELDS = new Set(['countdown', 'timer', 'hurry', 'limitedtime', 'flash', 'flashsale', 'expiresin', 'urgency', 'scarcity', 'onlyleft']);
64
- const CURRENCY_KEYS = /^(?:gems?|coins?|currency|credits?|points|tokens|bucks|diamonds|crystals|vbucks|robux):/i;
65
-
66
67
  const numeric = (value) => typeof value === 'number' ? value : typeof value === 'string' && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value) ? Number(value) : NaN;
67
68
 
68
69
  const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
69
70
  const oneLine = (v, max = Infinity) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
70
71
 
71
- /** Every string in a value, with the key path it sits at (to name where a forbidden word is). */
72
- function strings(v, at = '', out = []) {
73
- if (typeof v === 'string') out.push([at, v]);
74
- else if (Array.isArray(v)) v.forEach((x, i) => strings(x, `${at}[${i}]`, out));
75
- else if (isObj(v)) for (const [k, x] of Object.entries(v)) { out.push([`${at}${at ? '.' : ''}${k}`, k]); strings(x, `${at}${at ? '.' : ''}${k}`, out); }
76
- return out;
77
- }
78
- /** Every key in a value, lower-cased, with its path. */
79
- function keys(v, at = '', out = []) {
80
- if (Array.isArray(v)) v.forEach((x, i) => keys(x, `${at}[${i}]`, out));
81
- else if (isObj(v)) for (const [k, x] of Object.entries(v)) { const p = `${at}${at ? '.' : ''}${k}`; out.push([p, k.toLowerCase().replace(/[^a-z]/g, '')]); keys(x, p, out); }
82
- return out;
83
- }
84
-
85
72
  /** "2026-11-01" or an ISO time: its ms, or null. */
86
73
  function dateOf(v) {
87
74
  if (v === undefined || v === null || v === '') return null;
@@ -107,15 +94,15 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
107
94
  const warnings = [];
108
95
  const err = (at, message) => errors.push({ at, message });
109
96
  if (!isObj(raw)) return { ok: false, shop: null, errors: [{ at: '', message: 'shop.json is a JSON object: { "till": "stripe", "currency": "usd", "items": [...] }' }], warnings };
110
- const preset = raw.policy?.preset ?? 'protective';
97
+ const preset = raw.policy?.preset ?? 'custom';
111
98
  if (!Object.hasOwn(POLICY_PRESETS, preset)) err('policy.preset', 'choose protective, adults-only or custom');
112
99
  if (raw.policy !== undefined && !isObj(raw.policy)) err('policy', 'policy is an object with a named preset and editable rules');
113
- const policy = { preset, ...(Object.hasOwn(POLICY_PRESETS, preset) ? POLICY_PRESETS[preset] : POLICY_PRESETS.protective) };
100
+ const policy = { preset, ...(Object.hasOwn(POLICY_PRESETS, preset) ? POLICY_PRESETS[preset] : POLICY_PRESETS.custom) };
114
101
  for (const key of Object.keys(POLICY_PRESETS.protective)) {
115
102
  if (raw.policy?.[key] === undefined) continue;
116
103
  const value = raw.policy[key];
117
104
  if (['childAge', 'adultAge'].includes(key)) {
118
- if (!Number.isSafeInteger(value) || value < 1) err(`policy.${key}`, 'use a positive whole age in years');
105
+ if (!Number.isSafeInteger(value) || value < 0) err(`policy.${key}`, 'use a nonnegative whole age in years');
119
106
  else policy[key] = value;
120
107
  continue;
121
108
  }
@@ -123,13 +110,6 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
123
110
  else policy[key] = value;
124
111
  }
125
112
  if (policy.childAge >= policy.adultAge) err('policy.adultAge', 'adultAge must be greater than childAge');
126
- for (const [at, text] of strings(raw.items)) {
127
- if (!policy.paidRandomRewards && RANDOM_WORDS.test(text)) err(`items.${at}`, `The studio's policy.paidRandomRewards setting refuses paid randomness: "${oneLine(text, 40)}".`);
128
- }
129
- for (const [at, k] of keys(raw.items)) {
130
- if (!policy.paidRandomRewards && RANDOM_FIELDS.has(k)) err(`items.${at}`, "The studio's policy.paidRandomRewards setting refuses odds, drops or a random pool.");
131
- if (!policy.countdownOffers && PRESSURE_FIELDS.has(k)) err(`items.${at}`, "The studio's policy.countdownOffers setting refuses countdown fields.");
132
- }
133
113
  const till = raw.till === undefined ? 'stripe' : String(raw.till);
134
114
  if (!TILLS.includes(till)) err('till', 'till is "stripe" (your own Stripe: you are the seller), "stripe-managed" (Stripe Managed Payments: Stripe is the seller of record and files the tax, for 3.5% more) or "off"');
135
115
  const currency = raw.currency === undefined ? 'usd' : String(raw.currency).toLowerCase();
@@ -141,6 +121,11 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
141
121
  if (at.endsWith('Days') && value !== null && (!Number.isSafeInteger(Math.round(value * 86400000)) || !Number.isSafeInteger(Math.round(value * 86400000) + Date.now()) || !Number.isFinite(new Date(Math.round(value * 86400000) + Date.now()).getTime()))) err(at, 'duration rounded to milliseconds must remain within safe arithmetic and the JavaScript timestamp range');
142
122
  return value;
143
123
  };
124
+ // Stripe permits 30 minutes to 24 hours; 24 hours is its default.
125
+ // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-expires_at
126
+ const checkoutMinutes = numeric(raw.checkoutMinutes ?? 1440);
127
+ if (!Number.isSafeInteger(checkoutMinutes * 60) || checkoutMinutes < 30 || checkoutMinutes > 1440) err('checkoutMinutes', 'Stripe accepts 30 to 1440 minutes (24 hours), in whole seconds.');
128
+ const requestBytes = optionalNumber(raw.requestBytes, 'requestBytes');
144
129
  const refundDays = optionalNumber(raw.refundDays, 'refundDays');
145
130
  const cap = optionalNumber(raw.capPerPlayerMonth, 'capPerPlayerMonth');
146
131
  // 600 new buyers per hour allows a school or venue to arrive together without disabling flood protection.
@@ -151,8 +136,8 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
151
136
  const purchaseAttemptsPerAddressPerMinute = numeric(raw.purchaseAttemptsPerAddressPerMinute ?? 600);
152
137
  if (!Number.isSafeInteger(purchaseAttemptsPerAddressPerMinute) || purchaseAttemptsPerAddressPerMinute < 1) err('purchaseAttemptsPerAddressPerMinute', 'use a positive whole number for address flood protection');
153
138
  if (!Number.isSafeInteger(purchaseAttemptsPerMinute) || purchaseAttemptsPerMinute < 1) err('purchaseAttemptsPerMinute', 'use a positive whole number for the studio account rate limit');
154
- if (raw.supportHomie !== undefined && Number(raw.supportHomie) !== 0) err('supportHomie', 'Homie takes no cut; this adapter does not transfer a share to Homie');
155
139
 
140
+ for (const key of ['automaticTax', 'referralNewPlayersOnly']) if (raw[key] !== undefined && typeof raw[key] !== 'boolean') err(key, 'use a boolean');
156
141
  const items = [];
157
142
  const list = Array.isArray(raw.items) ? raw.items : [];
158
143
  if (raw.items !== undefined && !Array.isArray(raw.items)) err('items', 'items is a list');
@@ -164,9 +149,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
164
149
  if (!id.trim() || !ITEM_ID.test(id)) err(`${at}.id`, 'id is nonempty text without control characters');
165
150
  else if (seen.has(id)) err(`${at}.id`, `two items are called "${id}"`);
166
151
  seen.add(id);
167
- const kind = String(it.kind ?? '');
168
- if (!kind.trim()) err(`${at}.kind`, 'an item needs a kind chosen by the studio');
169
- if (!policy.virtualCurrency && ['gems', 'coins', 'currency'].includes(kind)) err(`${at}.kind`, "The studio's policy.virtualCurrency setting refuses currency items.");
152
+ const kind = String(it.kind ?? 'item');
170
153
  const name = oneLine(it.name);
171
154
  if (!name) err(`${at}.name`, 'an item needs a name the buyer reads');
172
155
  const blurb = oneLine(it.blurb ?? it.description);
@@ -174,8 +157,8 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
174
157
  if ([...name].length > 5000) err(`${at}.name`, 'Stripe product names allow at most 5000 characters. Shorten the name.');
175
158
  if ([...blurb].length > 40000) err(`${at}.blurb`, 'Stripe product descriptions allow at most 40000 characters. Shorten the description.');
176
159
  let price = null; let min = null; let max = null;
177
- if (kind === 'tip') {
178
- if (it.price !== 'choose' && it.price !== undefined) err(`${at}.price`, 'a tip\'s price is "choose" (pay what you want between min and max)');
160
+ const chosen = it.price === 'choose' || kind === 'tip' && it.price === undefined;
161
+ if (chosen) {
179
162
  min = numeric(it.min ?? 0); max = optionalNumber(it.max, `${at}.max`);
180
163
  const why = amountError(min, currency);
181
164
  if (why) err(`${at}.min`, why);
@@ -185,7 +168,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
185
168
  if (why) err(`${at}.price`, why);
186
169
  else price = numeric(it.price);
187
170
  }
188
- const charge = kind === 'tip' ? min : price;
171
+ const charge = chosen ? min : price;
189
172
  if (currencyScale(currency) === 1000 && charge % 10) warnings.push({ at: `${at}.price`, message: 'This three-decimal amount uses the smallest minor unit. Confirm support with your Stripe payment method; Stripe decides acceptance (https://docs.stripe.com/currencies).' });
190
173
  if (charge > 0 && charge < minimumCharge(currency)) warnings.push({ at: `${at}.price`, message: `Stripe's minimum for settlement in ${currency.toUpperCase()} is ${money(minimumCharge(currency), currency)}. Your settlement currency and payment method determine the actual minimum at checkout.` });
191
174
  if (cap !== null && price !== null && price > cap) warnings.push({ at: `${at}.price`, message: 'This price exceeds the studio capPerPlayerMonth setting; raise or remove that setting to allow a purchase.' });
@@ -193,12 +176,11 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
193
176
  if (it.gives !== undefined && !Array.isArray(it.gives)) err(`${at}.gives`, 'gives is a list of entitlement keys the game reads: ["skin:ember"]');
194
177
  for (const g of gives) {
195
178
  if (!g.trim() || !ENTITLEMENT_KEY.test(g)) err(`${at}.gives`, `"${oneLine(g, 40)}" is not a key: use nonempty text without control characters`);
196
- else if (!policy.virtualCurrency && CURRENCY_KEYS.test(g)) err(`${at}.gives`, `"${g}" is a currency refused by the studio policy.virtualCurrency setting`);
197
179
  }
198
180
  let days = null;
199
181
  if (it.days !== undefined && it.days !== null) {
200
182
  days = numeric(it.days);
201
- if (!(days > 0 && Number.isFinite(days) && Number.isSafeInteger(Math.round(days * 86400000)) && Number.isSafeInteger(Math.round(days * 86400000) + Date.now()) && Number.isFinite(new Date(Math.round(days * 86400000) + Date.now()).getTime()))) { err(`${at}.days`, 'days is a positive duration with whole milliseconds within safe arithmetic and the JavaScript timestamp range (omit for forever)'); days = null; }
183
+ if (!(days >= 0 && Number.isFinite(days) && Number.isSafeInteger(Math.round(days * 86400000)) && Number.isSafeInteger(Math.round(days * 86400000) + Date.now()) && Number.isFinite(new Date(Math.round(days * 86400000) + Date.now()).getTime()))) { err(`${at}.days`, 'days is a nonnegative duration with whole milliseconds within safe arithmetic and the JavaScript timestamp range (omit for forever)'); days = null; }
202
184
  }
203
185
  const starts = dateOf(it.starts);
204
186
  const ends = dateOf(it.ends);
@@ -211,16 +193,15 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
211
193
  }
212
194
  if (it.taxCode !== undefined && !TAX_CODE.test(String(it.taxCode))) err(`${at}.taxCode`, 'taxCode is a Stripe product tax code such as "txcd_10000000"');
213
195
  const advantage = it.advantage === true;
214
- if (advantage && kind === 'supporter' && !policy.supporterAdvantages) err(`${at}.advantage`, 'the studio policy.supporterAdvantages setting refuses an advantage in a supporter pack');
215
196
  const badge = gives.find((g) => g.startsWith('badge:'));
216
197
  for (const key of Object.keys(it)) {
217
198
  if (!['id', 'kind', 'name', 'blurb', 'description', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
218
199
  }
219
200
  items.push({
220
201
  id, kind, name, ...(blurb ? { blurb } : {}),
221
- ...(kind === 'tip' ? { price: 'choose', min, max } : { price }),
202
+ ...(chosen ? { price: 'choose', min, max } : { price }),
222
203
  gives, ...(badge ? { badge: badgeWord(badge, it) } : {}),
223
- ...(days ? { days } : {}), ...(Number.isFinite(starts) ? { starts: new Date(starts).toISOString() } : {}), ...(Number.isFinite(ends) ? { ends: new Date(ends).toISOString() } : {}),
204
+ ...(days !== null ? { days } : {}), ...(Number.isFinite(starts) ? { starts: new Date(starts).toISOString() } : {}), ...(Number.isFinite(ends) ? { ends: new Date(ends).toISOString() } : {}),
224
205
  ...(it.game ? { game: String(it.game) } : {}), ...(it.taxCode ? { taxCode: String(it.taxCode) } : {}),
225
206
  advantage,
226
207
  });
@@ -231,14 +212,14 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
231
212
  const r = raw.referrals;
232
213
  if (!isObj(r)) err('referrals', 'referrals is { "rate": 0.10, "windowDays": 30, "capPerPlayer": 1000, "holdDays": 30, "minimumInvoice": 2500 } (or leave it out: nobody is paid for sending players)');
233
214
  else {
234
- const rate = optionalNumber(r.rate ?? 0.10, 'referrals.rate', { integer: false });
215
+ const rate = optionalNumber(r.rate ?? 0, 'referrals.rate', { integer: false });
235
216
  const windowDays = optionalNumber(r.windowDays, 'referrals.windowDays');
236
217
  const capPerPlayer = optionalNumber(r.capPerPlayer, 'referrals.capPerPlayer');
237
218
  const holdDays = optionalNumber(r.holdDays, 'referrals.holdDays');
238
219
  const minimumInvoice = optionalNumber(r.minimumInvoice, 'referrals.minimumInvoice');
239
- const accept = Array.isArray(r.accept) ? r.accept.map(String) : ['stripe-invoice'];
220
+ const accept = Array.isArray(r.accept) ? r.accept.map(String) : [];
240
221
  const billing = r.billingEmail === undefined ? null : String(r.billingEmail);
241
- if (billing !== null && !/^[^\s@<>()",;:]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/.test(billing)) err('referrals.billingEmail', 'billingEmail is where referrers send their invoices (an address the studio reads)');
222
+ if (billing !== null && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(billing)) err('referrals.billingEmail', 'billingEmail is where referrers send their invoices (an address the studio reads)');
242
223
  referrals = { rate, windowDays, capPerPlayer, holdDays, minimumInvoice, basis: 'pre-tax', accept, ...(billing ? { billingEmail: billing } : {}) };
243
224
  }
244
225
  }
@@ -254,7 +235,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
254
235
  const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
255
236
  if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off under policy.kidsStudio).' });
256
237
  const shop = {
257
- v: 1, policy, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
238
+ v: 1, policy, checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, referralNewPlayersOnly: raw.referralNewPlayersOnly === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
258
239
  open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
259
240
  };
260
241
  // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-custom_text-submit-message
@@ -280,10 +261,10 @@ export function audienceOf(studio) {
280
261
  /* ------------------------------------------------------------------ who may buy */
281
262
 
282
263
  /** The neutral age question's answer as a band (never stored as a date): child (under 13), teen (13-17), adult. */
283
- export function bandOf(year, now = new Date(), policy = POLICY_PRESETS.protective) {
284
- const y = Math.floor(Number(year));
264
+ export function bandOf(year, now = new Date(), policy = POLICY_PRESETS.custom) {
265
+ const y = (typeof year === 'number' || typeof year === 'string' && /^\d+$/.test(year)) ? Number(year) : NaN;
285
266
  const thisYear = now.getUTCFullYear();
286
- if (!(Number.isSafeInteger(y) && y >= thisYear - 120 && y <= thisYear)) return null;
267
+ if (!(Number.isSafeInteger(y) && y >= 0 && y <= thisYear)) return null;
287
268
  // The youngest a person born that year can be: a year of birth alone never makes a child look older.
288
269
  const age = thisYear - y - 1;
289
270
  return age < policy.childAge ? 'child' : age < policy.adultAge ? 'teen' : 'adult';
@@ -299,11 +280,11 @@ export function bandOf(year, now = new Date(), policy = POLICY_PRESETS.protectiv
299
280
  * 'age-question' the studio requires an age band before checkout
300
281
  * 'no' the studio denies this age band
301
282
  * 'ask-a-parent' 13-17: a link a parent opens on their own device and pays in their own name
302
- * 'checkout' an adult: Stripe's own page, one purchase at a time
283
+ * 'checkout' Stripe's own page, with the requested cart
303
284
  * 'not-yet' / 'over' a season pass before it starts or after it ends
304
285
  * 'cap' this month's cap is reached
305
286
  */
306
- export function wayFor(item, { open = true, kids = false, beginner = false, player = null, band = null, owned = false, spent = 0, cap = null, policy = POLICY_PRESETS.protective, now = Date.now() } = {}) {
287
+ export function wayFor(item, { open = true, kids = false, beginner = false, player = null, band = null, owned = false, spent = 0, cap = null, policy = POLICY_PRESETS.custom, now = Date.now() } = {}) {
307
288
  if (!open) return 'closed';
308
289
  if (kids && !policy.kidsServer) return 'kids';
309
290
  if (item.advantage && ((beginner && !policy.beginnerAdvantages) || (kids && !policy.kidsAdvantages))) return 'beginner';
@@ -313,7 +294,7 @@ export function wayFor(item, { open = true, kids = false, beginner = false, play
313
294
  if (policy.requireAccount && (!player || player.guest)) return 'make-an-account';
314
295
  if (policy.ageQuestion && !band) return 'age-question';
315
296
  if ((band === 'child' && policy.children === 'deny') || (band === 'teen' && policy.teens === 'deny')) return 'no';
316
- const price = item.kind === 'tip' ? item.min : item.price;
297
+ const price = item.price === 'choose' ? item.min : item.price;
317
298
  if (price > 0 && cap !== null && spent + price > cap) return 'cap';
318
299
  if ((band === 'teen' && policy.teens === 'parent') || (band === 'child' && policy.children === 'parent')) return 'ask-a-parent';
319
300
  return 'checkout';
@@ -124,6 +124,53 @@ CREATE TABLE referral_edition_lines (
124
124
  ) WITHOUT ROWID;
125
125
  `;
126
126
 
127
+ export const SHOP_LINES_FILE = '0012_shop_lines.sql';
128
+ export const SHOP_LINES = `-- Each checkout retains its item snapshots and independently refundable lines.
129
+ CREATE TABLE shop_order_lines (
130
+ id TEXT PRIMARY KEY, order_id TEXT NOT NULL, position INTEGER NOT NULL,
131
+ item TEXT NOT NULL, quantity INTEGER NOT NULL, unit_amount INTEGER NOT NULL,
132
+ amount INTEGER NOT NULL, total INTEGER, snapshot TEXT, status TEXT NOT NULL DEFAULT 'started',
133
+ refund TEXT, refunded_at INTEGER, refunded_amount INTEGER NOT NULL DEFAULT 0, refunded_net INTEGER NOT NULL DEFAULT 0, UNIQUE(order_id, position)
134
+ );
135
+ INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, total, status, refund, refunded_at)
136
+ SELECT id || '_0', id, 0, item, 1, amount, amount, total, status, refund, refunded_at FROM shop_orders;
137
+ -- Keep the released entitlement key and columns intact for rolling deploys and rollback.
138
+ CREATE TABLE shop_entitlement_lines (
139
+ player TEXT NOT NULL, key TEXT NOT NULL, item TEXT NOT NULL, order_id TEXT NOT NULL,
140
+ line_id TEXT NOT NULL, quantity INTEGER NOT NULL,
141
+ starts_at INTEGER NOT NULL, ends_at INTEGER, state TEXT NOT NULL,
142
+ PRIMARY KEY (player, key, order_id, line_id)
143
+ ) WITHOUT ROWID;
144
+ CREATE TRIGGER shop_entitlements_delete AFTER DELETE ON entitlements BEGIN
145
+ DELETE FROM shop_entitlement_lines WHERE player = OLD.player AND key = OLD.key AND order_id = OLD.order_id;
146
+ END;
147
+ CREATE TRIGGER shop_entitlements_adopt AFTER UPDATE OF player ON entitlements BEGIN
148
+ UPDATE shop_entitlement_lines SET player = NEW.player WHERE player = OLD.player AND key = OLD.key AND order_id = OLD.order_id;
149
+ END;
150
+ UPDATE shop_order_lines SET refunded_amount = COALESCE(total, amount), refunded_net = amount WHERE status = 'refunded';
151
+ ALTER TABLE shop_parent_links ADD COLUMN cart TEXT;
152
+ ALTER TABLE shop_orders ADD COLUMN whole_refund TEXT;
153
+ ALTER TABLE shop_orders ADD COLUMN referral_terms TEXT;
154
+ ALTER TABLE shop_orders ADD COLUMN checkout_player TEXT;
155
+ UPDATE shop_orders SET checkout_player = player;
156
+ CREATE TRIGGER shop_checkout_player_insert AFTER INSERT ON shop_orders BEGIN
157
+ UPDATE shop_orders SET checkout_player = NEW.player WHERE id = NEW.id;
158
+ END;
159
+ CREATE TRIGGER shop_forget_checkout_player AFTER UPDATE OF player ON shop_orders WHEN NEW.player IS NULL BEGIN
160
+ UPDATE shop_orders SET checkout_player = NULL WHERE id = NEW.id;
161
+ END;
162
+ ALTER TABLE referral_lines ADD COLUMN original_share INTEGER;
163
+ UPDATE referral_lines SET original_share = share;
164
+ ALTER TABLE shop_orders ADD COLUMN attention INTEGER NOT NULL DEFAULT 1;
165
+ UPDATE shop_orders SET attention = 0 WHERE status = 'paid' AND EXISTS (SELECT 1 FROM entitlements WHERE order_id = shop_orders.id);
166
+ ALTER TABLE shop_orders ADD COLUMN refunded_amount INTEGER NOT NULL DEFAULT 0;
167
+ ALTER TABLE shop_orders ADD COLUMN refunded_net INTEGER NOT NULL DEFAULT 0;
168
+ ALTER TABLE shop_orders ADD COLUMN refund_revision INTEGER NOT NULL DEFAULT 0;
169
+ UPDATE shop_orders SET refunded_amount = COALESCE(total, amount), refunded_net = amount WHERE status = 'refunded';
170
+ CREATE INDEX shop_orders_attention_player ON shop_orders (mode, player, status, updated_at, id) WHERE attention = 1;
171
+ CREATE INDEX shop_orders_attention ON shop_orders (mode, status, updated_at, id) WHERE attention = 1;
172
+ `;
173
+
127
174
  const DAY = 86_400_000;
128
175
  export const ORDER_ID = /^ord_[A-Za-z0-9]{20}$/;
129
176
  const PLAYER_ID = /^pl_[A-Za-z0-9_-]{22}$/;
@@ -145,6 +192,11 @@ export async function migrationNeeded(env) {
145
192
  [SHOP_RESERVATIONS_FILE, 'SELECT expires_at FROM shop_orders LIMIT 1'],
146
193
  [SHOP_STATEMENTS_FILE, 'SELECT issued FROM referral_editions LIMIT 1'],
147
194
  [SHOP_STATEMENTS_FILE, 'SELECT edition FROM referral_edition_lines LIMIT 1'],
195
+ [SHOP_LINES_FILE, 'SELECT snapshot, total, refunded_amount FROM shop_order_lines LIMIT 1'],
196
+ [SHOP_LINES_FILE, 'SELECT line_id, quantity FROM shop_entitlement_lines LIMIT 1'],
197
+ [SHOP_LINES_FILE, 'SELECT referral_terms, checkout_player, attention, refunded_amount, refund_revision FROM shop_orders LIMIT 1'],
198
+ [SHOP_LINES_FILE, 'SELECT cart FROM shop_parent_links LIMIT 1'],
199
+ [SHOP_LINES_FILE, 'SELECT original_share FROM referral_lines LIMIT 1'],
148
200
  ]) {
149
201
  try { await env.DB.prepare(query).first(); } catch { return file; }
150
202
  }
@@ -174,14 +226,24 @@ export async function setBand(env, player, band) {
174
226
  export async function ownsOf(env, player, now = Date.now()) {
175
227
  if (!PLAYER_ID.test(String(player ?? ''))) return [];
176
228
  try {
177
- const { results } = await env.DB.prepare("SELECT key, item, ends_at, used_at FROM entitlements WHERE player = ?1 AND state = 'active' AND starts_at <= ?2 AND (ends_at IS NULL OR ends_at > ?2) ORDER BY starts_at").bind(player, now).all();
178
- const out = new Map();
229
+ let results;
230
+ try {
231
+ ({ results } = await env.DB.prepare("SELECT l.key, l.item, l.ends_at, e.used_at, l.quantity FROM shop_entitlement_lines l JOIN entitlements e ON e.player = l.player AND e.key = l.key AND e.order_id = l.order_id WHERE l.player = ?1 AND l.state = 'active' AND e.state = 'active' AND l.starts_at <= ?2 AND (l.ends_at IS NULL OR l.ends_at > ?2) UNION ALL SELECT e.key, e.item, e.ends_at, e.used_at, 1 AS quantity FROM entitlements e WHERE e.player = ?1 AND e.state = 'active' AND e.starts_at <= ?2 AND (e.ends_at IS NULL OR e.ends_at > ?2) AND NOT EXISTS (SELECT 1 FROM shop_entitlement_lines l WHERE l.player = e.player AND l.key = e.key AND l.order_id = e.order_id)").bind(player, now).all());
232
+ } catch {
233
+ ({ results } = await env.DB.prepare("SELECT key, item, ends_at, used_at, 1 AS quantity FROM entitlements WHERE player = ?1 AND state = 'active' AND starts_at <= ?2 AND (ends_at IS NULL OR ends_at > ?2) ORDER BY starts_at").bind(player, now).all());
234
+ }
235
+ const out = new Map(), quantities = new Map();
179
236
  for (const r of results ?? []) {
237
+ quantities.set(r.key, (quantities.get(r.key) ?? 0n) + BigInt(r.quantity));
180
238
  const until = r.ends_at === null ? null : Number(r.ends_at);
181
239
  const prev = out.get(r.key);
182
240
  // Two orders of the same key: the one that lasts longest wins.
183
241
  if (!prev || (prev.until !== null && (until === null || until > prev.until))) out.set(r.key, { key: r.key, item: r.item, until, used: r.used_at !== null && r.used_at !== undefined });
184
242
  }
243
+ for (const value of out.values()) {
244
+ const quantity = quantities.get(value.key);
245
+ value.quantity = quantity <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(quantity) : String(quantity);
246
+ }
185
247
  return [...out.values()];
186
248
  } catch { return []; }
187
249
  }
@@ -191,7 +253,7 @@ export async function spentThisMonth(env, player, now = Date.now()) {
191
253
  // A Stripe session remains reserved until Stripe confirms its outcome. Test rows never consume a live cap.
192
254
  const d = new Date(now);
193
255
  const start = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
194
- const r = await env.DB.prepare("SELECT COALESCE(SUM(amount), 0) AS n FROM shop_orders WHERE player = ?1 AND mode = ?4 AND ((status IN ('paid', 'disputed') AND paid_at >= ?2) OR (status IN ('started', 'processing') AND (session IS NOT NULL OR COALESCE(expires_at, created_at + 1860000) + 60000 > ?3)))").bind(player, start, now, String(env.STRIPE_KEY).includes('_live_') ? 'live' : 'test').first();
256
+ const r = await env.DB.prepare("SELECT COALESCE(SUM(amount - refunded_net), 0) AS n FROM shop_orders WHERE player = ?1 AND mode = ?4 AND ((status IN ('paid', 'disputed') AND paid_at >= ?2) OR (status IN ('started', 'processing') AND (session IS NOT NULL OR COALESCE(expires_at, created_at + 1860000) + 60000 > ?3)))").bind(player, start, now, String(env.STRIPE_KEY).includes('_live_') ? 'live' : 'test').first();
195
257
  return Number(r?.n) || 0;
196
258
  }
197
259
 
@@ -200,13 +262,40 @@ export async function orderById(env, id) {
200
262
  try { return await env.DB.prepare('SELECT * FROM shop_orders WHERE id = ?1').bind(id).first(); } catch { return null; }
201
263
  }
202
264
 
265
+ export async function orderLines(env, id) {
266
+ const o = await orderById(env, id);
267
+ if (!o) return [];
268
+ const fallback = { id: id + '_0', order_id: id, position: 0, item: o.item, quantity: 1, unit_amount: o.amount, amount: o.amount, total: o.total, snapshot: null, status: o.status, refund: o.refund, refunded_at: o.refunded_at };
269
+ try {
270
+ let rows = (await env.DB.prepare('SELECT * FROM shop_order_lines WHERE order_id = ?1 ORDER BY position').bind(id).all()).results ?? [];
271
+ if (!rows.length) {
272
+ await env.DB.prepare('INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, total, status, refund, refunded_at) VALUES (?1, ?2, 0, ?3, 1, ?4, ?4, ?5, ?6, ?7, ?8) ON CONFLICT DO NOTHING').bind(fallback.id, id, o.item, o.amount, o.total, o.status, o.refund, o.refunded_at).run();
273
+ rows = (await env.DB.prepare('SELECT * FROM shop_order_lines WHERE order_id = ?1 ORDER BY position').bind(id).all()).results ?? [];
274
+ }
275
+ // A released Worker may have paid or refunded after the step without touching its single line.
276
+ if (rows.length === 1 && !rows[0].snapshot && o.paid_at !== null) {
277
+ await env.DB.prepare("UPDATE shop_order_lines SET status = ?2, total = ?3, refund = ?4, refunded_at = ?5, refunded_amount = CASE WHEN ?2 = 'refunded' THEN COALESCE(?3, amount) ELSE refunded_amount END, refunded_net = CASE WHEN ?2 = 'refunded' THEN amount ELSE refunded_net END WHERE id = ?1 AND snapshot IS NULL").bind(rows[0].id, o.status, o.total, o.refund, o.refunded_at).run();
278
+ rows[0] = { ...rows[0], status: o.status, total: o.total, refund: o.refund, refunded_at: o.refunded_at };
279
+ }
280
+ return rows.map((line) => ['refunded', 'lost', 'disputed'].includes(o.status) && (line.status !== 'refunded' || o.status === 'refunded') ? { ...line, status: o.status } : line);
281
+ } catch (error) {
282
+ if (!/no such table/i.test(String(error.message))) throw error;
283
+ return [fallback];
284
+ }
285
+ }
286
+
287
+ export function lineView(r) {
288
+ const item = r.snapshot ? JSON.parse(r.snapshot) : null;
289
+ return { id: r.id, refundedAmount: Number(r.refunded_amount ?? 0), item: r.item, name: item?.name ?? r.item, kind: item?.kind ?? null, quantity: Number(r.quantity), unitAmount: Number(r.unit_amount), amount: Number(r.amount), total: r.total === null ? null : Number(r.total), status: r.status, refundedAt: r.refunded_at };
290
+ }
291
+
203
292
  export const orderBySession = (env, session) => env.DB.prepare('SELECT * FROM shop_orders WHERE session = ?1').bind(String(session ?? '')).first();
204
293
  export const orderByPayment = (env, payment) => env.DB.prepare('SELECT * FROM shop_orders WHERE payment = ?1').bind(String(payment ?? '')).first();
205
294
 
206
295
  /** An order as the player, the office and the export see it (never a card, never an email). */
207
296
  export function orderView(r) {
208
297
  return {
209
- id: r.id, item: r.item, game: r.game ?? null, amount: Number(r.amount), currency: r.currency, tax: r.tax === null ? null : Number(r.tax), total: r.total === null ? null : Number(r.total),
298
+ id: r.id, refundedAmount: Number(r.refunded_amount ?? 0), item: r.item, game: r.game ?? null, amount: Number(r.amount), currency: r.currency, tax: r.tax === null ? null : Number(r.tax), total: r.total === null ? null : Number(r.total),
210
299
  status: r.status, till: r.till, mode: r.mode, parent: Number(r.parent) === 1, via: r.via ?? null,
211
300
  createdAt: Number(r.created_at), paidAt: r.paid_at === null ? null : Number(r.paid_at), refundedAt: r.refunded_at === null ? null : Number(r.refunded_at),
212
301
  };
@@ -221,6 +310,7 @@ export function playerOrderStatus(row) {
221
310
  export async function shopDataOf(env, player) {
222
311
  try {
223
312
  const orders = ((await env.DB.prepare("SELECT * FROM shop_orders WHERE player = ?1 AND status != 'started' ORDER BY created_at").bind(player).all()).results ?? []).filter((r) => playerOrderStatus(r) !== 'started').map((r) => orderView({ ...r, status: playerOrderStatus(r) }));
313
+ for (const order of orders) order.lines = (await orderLines(env, order.id)).map(lineView);
224
314
  const owns = await ownsOf(env, player);
225
315
  const band = await bandOfPlayer(env, player);
226
316
  return { orders, owns, ageBand: band };
@@ -246,15 +336,17 @@ export function forgetPlayerShop(env, player) {
246
336
  }
247
337
 
248
338
  /** Grant an order's entitlements (idempotent on player, key and order). */
249
- export function grantStatements(env, order, item, now = Date.now()) {
339
+ export function grantStatements(env, order, item, now = Date.now(), line = { id: order.id + '_0', quantity: 1 }) {
250
340
  const starts = item.starts ? Math.max(now, Date.parse(item.starts)) : now;
251
- const ends = item.ends ? Date.parse(item.ends) : item.days ? starts + Math.round(item.days * DAY) : null;
252
- return (item.gives ?? []).map((key) => env.DB.prepare("INSERT INTO entitlements (player, key, item, order_id, starts_at, ends_at, state, used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', NULL) ON CONFLICT(player, key, order_id) DO UPDATE SET state = 'active'")
253
- .bind(order.player, key, item.id, order.id, starts, ends));
341
+ const ends = item.ends ? Date.parse(item.ends) : item.days !== undefined && item.days !== null ? starts + Math.round(item.days * DAY) : null;
342
+ return (item.gives ?? []).flatMap((key) => [
343
+ env.DB.prepare("INSERT INTO entitlements (player, key, item, order_id, starts_at, ends_at, state, used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', NULL) ON CONFLICT(player, key, order_id) DO UPDATE SET starts_at = MIN(starts_at, excluded.starts_at), ends_at = CASE WHEN ends_at IS NULL OR excluded.ends_at IS NULL THEN NULL ELSE MAX(ends_at, excluded.ends_at) END").bind(order.player, key, item.id, order.id, starts, ends),
344
+ env.DB.prepare("INSERT INTO shop_entitlement_lines (player, key, item, order_id, line_id, quantity, starts_at, ends_at, state) SELECT ?1, ?2, ?3, ?4, ?7, ?8, ?5, ?6, 'active' WHERE NOT EXISTS (SELECT 1 FROM entitlements WHERE player = ?1 AND key = ?2 AND order_id = ?4 AND state != 'active') ON CONFLICT DO NOTHING").bind(order.player, key, item.id, order.id, starts, ends, line.id, line.quantity),
345
+ ]);
254
346
  }
255
347
 
256
348
  export const revokeStatement = (env, orderId) => env.DB.prepare("UPDATE entitlements SET state = 'revoked' WHERE order_id = ?1").bind(orderId);
257
- export const restoreStatement = (env, orderId) => env.DB.prepare("UPDATE entitlements SET state = 'active' WHERE order_id = ?1").bind(orderId);
349
+ export const restoreStatement = (env, orderId) => env.DB.prepare("UPDATE entitlements SET state = 'active' WHERE order_id = ?1 AND EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND status = 'paid') AND EXISTS (SELECT 1 FROM shop_order_lines WHERE order_id = ?1 AND status != 'refunded') AND (NOT EXISTS (SELECT 1 FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key) OR EXISTS (SELECT 1 FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active'))").bind(orderId);
258
350
 
259
351
  /** The badge a player shows in rooms (a supporter's), from what they own: the first live badge key, or null. */
260
352
  export async function badgeOf(env, player, shop) {