@homie-rocks/studio 0.33.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -1
- package/README.md +18 -14
- package/bin/homie-studio.mjs +4 -4
- package/lib/build.mjs +3 -3
- package/lib/cloudflare.mjs +3 -2
- package/lib/doctor.mjs +11 -11
- package/lib/mcp-tools.mjs +15 -0
- package/lib/mcp.mjs +1 -0
- package/lib/scaffold.mjs +45 -39
- package/lib/shop-catalog.mjs +4 -4
- package/lib/shop-links.mjs +91 -0
- package/lib/shop.mjs +28 -20
- package/lib/stripe-connect.mjs +135 -0
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/shop/SELLING.md +15 -91
- package/shop/SHOP.md +185 -274
- package/shop/shop.ts +20 -5
- package/worker/index.mjs +7 -1
- package/worker/office.mjs +1 -1
- package/worker/players.mjs +1 -1
- package/worker/referrals.mjs +9 -12
- package/worker/shop-links.mjs +46 -0
- package/worker/shop-page.mjs +93 -31
- package/worker/shop-rules.mjs +39 -58
- package/worker/shop-store.mjs +101 -9
- package/worker/shop.mjs +638 -223
- package/worker/stripe.mjs +22 -13
- package/worker/version.mjs +1 -1
package/worker/shop-rules.mjs
CHANGED
|
@@ -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:
|
|
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 ?? '
|
|
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.
|
|
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 <
|
|
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
|
-
|
|
178
|
-
|
|
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 =
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
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) : [
|
|
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
|
|
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.
|
|
284
|
-
const y =
|
|
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 >=
|
|
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'
|
|
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.
|
|
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.
|
|
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';
|
package/worker/shop-store.mjs
CHANGED
|
@@ -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
|
-
|
|
178
|
-
|
|
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 ?? []).
|
|
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) {
|