@homie-rocks/studio 0.32.1 → 0.33.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 +57 -1
- package/README.md +24 -6
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +3 -3
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/studio.mjs +21 -1
- package/lib/template-history.json +50 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/worker/shop.mjs
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
* The studio is the seller; homie.rocks never sees, holds or moves its money, and Homie takes no cut.
|
|
4
4
|
*
|
|
5
5
|
* GET /api/shop the items, each with how THIS player may get it (shop-rules.mjs wayFor); on a kids
|
|
6
|
-
* server or
|
|
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
|
|
9
|
-
* POST /api/shop/parent
|
|
10
|
-
* POST /api/shop/hook Stripe's webhook, signature checked;
|
|
8
|
+
* POST /api/shop/buy a permitted buyer buys one item: an order row, then Stripe's own hosted Checkout page
|
|
9
|
+
* POST /api/shop/parent studio parent policy: a one-time link a parent opens on their own device (/shop/parent/<token>)
|
|
10
|
+
* POST /api/shop/hook Stripe's webhook, signature checked; verified Stripe events record paid, refunded, disputed
|
|
11
11
|
* POST /api/shop/used the game says an item was used (equipped, played): it leaves the self-serve refund
|
|
12
12
|
* POST /api/shop/refund the player's own refund of an unused item 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)
|
|
16
16
|
* GET /shop/ /shop/thanks /shop/refunds/ /shop/parent/<token> the pages (worker/shop-page.mjs)
|
|
17
17
|
* POST /api/referrals/statement another studio's signed referral statement to this one (worker/referrals.mjs)
|
|
@@ -33,24 +33,24 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { players } from './players.mjs';
|
|
35
35
|
import { serversOf } from './servers.mjs';
|
|
36
|
-
import {
|
|
36
|
+
import { ENTITLEMENT_KEY, checkoutMessage, amountError, amountStep, parseAmount, currencyScale, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
|
|
37
37
|
import {
|
|
38
|
-
bandOfPlayer, forgetPlayerShop, grantStatements, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, ownsOf, restoreStatement, revokeStatement,
|
|
38
|
+
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, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, dashboardLink, isApprovalRequired, isMissingProduct, modeOf, productIdOf, verifyWebhook } from './stripe.mjs';
|
|
42
|
-
import { arrivalOf, booksOf, lineFor, receiveStatement, referrerOk, sendStatements, settle,
|
|
41
|
+
import { KEY_SHAPE, stripeCall, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, modeOf, productIdOf, verifyWebhook } from './stripe.mjs';
|
|
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';
|
|
45
45
|
|
|
46
|
-
export { forgetPlayerShop, officeShopPage, shopDataOf };
|
|
46
|
+
export { linesOf, forgetPlayerShop, officeShopPage, receivedPages, shopDataOf };
|
|
47
47
|
|
|
48
48
|
const DAY = 86_400_000;
|
|
49
49
|
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
50
50
|
const SERVER_ID = /^(?:public|[a-z0-9][a-z0-9-]{1,19})$/;
|
|
51
51
|
const TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
52
|
-
/** In-memory, per Worker instance:
|
|
53
|
-
const
|
|
52
|
+
/** In-memory, per Worker instance: purchase attempts a minute per account and address. Never stored. */
|
|
53
|
+
const addressOf = (request) => request.headers.get('cf-connecting-ip') || 'local';
|
|
54
54
|
|
|
55
55
|
const json = (body, status = 200, headers = {}) => new Response(`${JSON.stringify(body)}\n`, {
|
|
56
56
|
status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store, private', 'x-content-type-options': 'nosniff', ...headers },
|
|
@@ -58,16 +58,18 @@ const json = (body, status = 200, headers = {}) => new Response(`${JSON.stringif
|
|
|
58
58
|
const fail = (status, error, message, extra = {}) => json({ ok: false, error, message, ...extra }, status);
|
|
59
59
|
|
|
60
60
|
const buckets = new Map();
|
|
61
|
+
const accountBuckets = new Map();
|
|
61
62
|
function limited(key, max, windowMs, now = Date.now()) {
|
|
62
|
-
const
|
|
63
|
-
|
|
63
|
+
const counts = key.includes(':account:') ? accountBuckets : buckets;
|
|
64
|
+
const list = (counts.get(key) ?? []).filter((t) => t > now - windowMs);
|
|
65
|
+
if (list.length >= max) { counts.set(key, list); return true; }
|
|
64
66
|
list.push(now);
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
+
counts.set(key, list);
|
|
68
|
+
if (counts.size > 10_000) counts.delete(counts.keys().next().value);
|
|
67
69
|
return false;
|
|
68
70
|
}
|
|
69
71
|
/** For tests: forget every count. */
|
|
70
|
-
export function resetShopLimits() { buckets.clear(); }
|
|
72
|
+
export function resetShopLimits() { buckets.clear(); accountBuckets.clear(); }
|
|
71
73
|
|
|
72
74
|
const sha256Hex = async (text) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
73
75
|
function randomToken() {
|
|
@@ -90,16 +92,22 @@ async function bodyOf(request, max = 4096) {
|
|
|
90
92
|
|
|
91
93
|
/* ------------------------------------------------------------------ the shop and whether it is open */
|
|
92
94
|
|
|
95
|
+
const sizes = new WeakMap();
|
|
96
|
+
function requestSize(shop) {
|
|
97
|
+
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;
|
|
99
|
+
sizes.set(shop, size);
|
|
100
|
+
return size;
|
|
101
|
+
}
|
|
93
102
|
const shopCache = new WeakMap();
|
|
94
103
|
/**
|
|
95
|
-
* The studio's shop,
|
|
96
|
-
* so a hand-edited build cannot sell what the kit refuses). null when the studio has no shop.json.
|
|
104
|
+
* The studio's shop, checked against its chosen settings and provider requirements at build and request time. null when the studio has no shop.json.
|
|
97
105
|
*/
|
|
98
106
|
export function shopOf(cat) {
|
|
99
107
|
if (!cat?.shop) return null;
|
|
100
108
|
if (shopCache.has(cat)) return shopCache.get(cat);
|
|
101
109
|
const games = (cat.games ?? []).map((g) => g.id);
|
|
102
|
-
const r = checkShop(cat.shop, { games: games.length ? games : null, audience: audienceOf(cat.studio) });
|
|
110
|
+
const r = checkShop(cat.shop, { games: games.length ? games : null, audience: audienceOf(cat.studio), studioName: cat.studio?.name });
|
|
103
111
|
const shop = r.ok ? r.shop : { ...r.shop, open: false, broken: r.errors.slice(0, 5) };
|
|
104
112
|
shopCache.set(cat, shop);
|
|
105
113
|
return shop;
|
|
@@ -117,22 +125,23 @@ export async function readiness(env, shop) {
|
|
|
117
125
|
if (/^sk_live_/.test(key)) missing.push('restricted-key');
|
|
118
126
|
else if (!KEY_SHAPE.test(key)) missing.push('stripe-key');
|
|
119
127
|
if (!WEBHOOK_SECRET_SHAPE.test(String(env?.STRIPE_WEBHOOK_SECRET ?? ''))) missing.push('webhook-secret');
|
|
128
|
+
let migration = null;
|
|
120
129
|
if (!env?.DB || env.HOMIE_PREVIEW === '1') missing.push('database');
|
|
121
|
-
else if (
|
|
122
|
-
return { ready: missing.length === 0, missing, mode: KEY_SHAPE.test(key) ? modeOf(key) : null };
|
|
130
|
+
else if ((migration = await migrationNeeded(env))) missing.push('migration');
|
|
131
|
+
return { ready: missing.length === 0, missing, migration, mode: KEY_SHAPE.test(key) ? modeOf(key) : null };
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
export const MISSING_WORDS = Object.freeze({
|
|
126
135
|
'shop.json': 'This studio has no shop.json: nothing is for sale (homie-studio shop init writes one).',
|
|
127
|
-
'kids-studio': 'This studio is made for children (studio.json "audience": "kids"):
|
|
128
|
-
'shop.json-refused': 'shop.json
|
|
136
|
+
'kids-studio': 'This studio is made for children (studio.json "audience": "kids"): its policy.kidsStudio setting closes the shop.',
|
|
137
|
+
'shop.json-refused': 'shop.json conflicts with the studio settings or payment requirements (homie-studio shop check says which), so nothing is sold.',
|
|
129
138
|
'till-off': 'shop.json says "till": "off".',
|
|
130
139
|
items: 'shop.json has no items.',
|
|
131
140
|
'restricted-key': 'The Worker has a full secret key: the shop takes only a restricted key (homie-studio shop connect).',
|
|
132
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).',
|
|
133
142
|
'webhook-secret': 'The Worker has no webhook signing secret yet (the same page takes it).',
|
|
134
143
|
database: 'This Worker has no database (a Preview never sells).',
|
|
135
|
-
migration: 'The shop\'s tables are not in the studio\'s D1 yet: npm run deploy applies
|
|
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.',
|
|
136
145
|
});
|
|
137
146
|
|
|
138
147
|
/** The server a request names (the play shell says which), with its kids and beginner flags. */
|
|
@@ -159,16 +168,18 @@ async function accountOf(request, env) {
|
|
|
159
168
|
}
|
|
160
169
|
|
|
161
170
|
/** Each item with how this player may get it, and the words for it. */
|
|
162
|
-
async function itemsView(env, shop, { game, kids, beginner, player, open }) {
|
|
163
|
-
const band = player
|
|
171
|
+
async function itemsView(env, shop, { game, kids, beginner, player, open, cursor = 0, item = null, purchasing = false }) {
|
|
172
|
+
const band = player ? await bandOfPlayer(env, player.id) : null;
|
|
164
173
|
const owns = player ? await ownsOf(env, player.id) : [];
|
|
165
|
-
const spent = player &&
|
|
166
|
-
const
|
|
174
|
+
const spent = !purchasing && player && open && shop.capPerPlayerMonth !== null ? await spentThisMonth(env, player.id) : 0;
|
|
175
|
+
const selected = item ? [item] : itemsFor(shop, game);
|
|
176
|
+
const page = item ? selected : selected.slice(cursor, cursor + 100);
|
|
177
|
+
const items = page.map((it) => {
|
|
167
178
|
const owned = it.gives.length > 0 && it.gives.every((k) => owns.some((o) => o.key === k && o.item === it.id));
|
|
168
|
-
const way = wayFor(it, { open, kids, beginner, player, band, owned, spent, cap: shop.capPerPlayerMonth
|
|
169
|
-
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 };
|
|
179
|
+
const way = wayFor(it, { open, kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
|
|
180
|
+
return { id: it.id, kind: it.kind, name: it.name, blurb: it.blurb ?? null, price: it.price, ...(it.kind === 'tip' ? { min: it.min, max: it.max } : {}), shown: it.kind === 'tip' ? `${money(it.min, shop.currency)} or more` : money(it.price, shop.currency), gives: it.gives, ...(it.badge ? { badge: it.badge } : {}), ...(it.days ? { days: it.days } : {}), ...(it.ends ? { ends: it.ends } : {}), ...(it.game ? { game: it.game } : {}), way, ...(way === 'cap' ? { retryWay: wayFor(it, { open, kids, beginner, player, band, owned, cap: null, policy: shop.policy }) } : {}) };
|
|
170
181
|
}).filter((i) => i.way !== 'beginner' && i.way !== 'kids');
|
|
171
|
-
return { band, owns, spent, items };
|
|
182
|
+
return { band, owns, spent, items, nextCursor: !item && cursor + 100 < selected.length ? String(cursor + 100) : null };
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
/* ------------------------------------------------------------------ routes */
|
|
@@ -184,11 +195,11 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
184
195
|
if (path === '/shop') return Response.redirect(`${url.origin}/shop/${url.search}`, 301);
|
|
185
196
|
const cat = await catalogueOf();
|
|
186
197
|
const shop = shopOf(cat);
|
|
187
|
-
//
|
|
198
|
+
// The studio's protective policy closes kids studios.
|
|
188
199
|
if (!shop || shop.audience === 'kids') {
|
|
189
200
|
if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
|
|
190
201
|
if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
|
|
191
|
-
// No shop.json: /shop/ is the studio's own to use (a page in site/pages, say).
|
|
202
|
+
// No shop.json: /shop/ is the studio's own to use (a page in site/pages, say). The policy can close a kids studio's shop.
|
|
192
203
|
return shop ? notFoundPage('This studio sells nothing.', cat) : null;
|
|
193
204
|
}
|
|
194
205
|
const ready = await readiness(env, shop);
|
|
@@ -199,18 +210,18 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
199
210
|
if (path === '/api/player/owns' && read) return await ownsRoute(request, env, url, cat, shop);
|
|
200
211
|
if (path === '/api/shop/mine' && read) return await mineRoute(request, env, shop);
|
|
201
212
|
if (path === '/api/shop/order' && read) return await orderRoute(request, env, url);
|
|
202
|
-
if (path === '/api/shop/age' && request.method === 'POST') return await ageRoute(request, env);
|
|
203
|
-
if (path === '/api/shop/buy' && request.method === 'POST') return await buyRoute(request, env, url, cat, shop, ready);
|
|
213
|
+
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);
|
|
204
215
|
if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
|
|
205
|
-
if (path === '/api/shop/used' && request.method === 'POST') return await usedRoute(request, env);
|
|
216
|
+
if (path === '/api/shop/used' && request.method === 'POST') return await usedRoute(request, env, shop);
|
|
206
217
|
if (path === '/api/shop/refund' && request.method === 'POST') return await selfRefundRoute(request, env, shop, ready);
|
|
207
218
|
if (path === '/shop/' && read) return shopPage(cat, shop, { origin: url.origin, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null, item: url.searchParams.get('item'), open: ready.ready, mode: ready.mode });
|
|
208
219
|
if (path === '/shop/thanks' && read) return thanksPage(cat, shop, { session: /^cs_[A-Za-z0-9_]{1,200}$/.test(url.searchParams.get('session_id') ?? '') ? url.searchParams.get('session_id') : null, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null });
|
|
209
220
|
if ((path === '/shop/refunds/' || path === '/shop/refunds') && read) return refundsPage(cat, shop);
|
|
210
221
|
const parent = /^\/shop\/parent\/([A-Za-z0-9_-]{43})$/.exec(path);
|
|
211
|
-
if (parent) return await parentRoute(request, env, url, cat, shop, ready, parent[1]);
|
|
222
|
+
if (parent) return await parentRoute(request, env, url, cat, shop, ready, parent[1], ctx);
|
|
212
223
|
} catch (error) {
|
|
213
|
-
if (/no such table/i.test(String(error?.message))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
|
|
224
|
+
if (/no such table|no such column|has no column/i.test(String(error?.message))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
|
|
214
225
|
throw error;
|
|
215
226
|
}
|
|
216
227
|
return path.startsWith('/api/') ? fail(404, 'not-found', 'no such shop route') : notFoundPage('Nothing here.', cat);
|
|
@@ -219,16 +230,16 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
219
230
|
async function listRoute(request, env, url, cat, shop, ready) {
|
|
220
231
|
const game = GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null;
|
|
221
232
|
const facts = await serverFacts(env, cat, game, url.searchParams.get('server'));
|
|
222
|
-
if (facts.kids) return json({ ok: true, open: false, kids: true, items: [] });
|
|
233
|
+
if (facts.kids && !shop.policy.kidsServer) return json({ ok: true, open: false, kids: true, items: [] });
|
|
223
234
|
const player = await accountOf(request, env);
|
|
224
|
-
const v = await itemsView(env, shop, { game, ...facts, player, open: ready.ready });
|
|
225
|
-
//
|
|
226
|
-
if (v.band === 'child') return json({ ok: true, open: false, kids: true, items: [] });
|
|
235
|
+
const v = await itemsView(env, shop, { game, ...facts, player, open: ready.ready, item: shop.items.find((i) => i.id === url.searchParams.get('item') && (!game || !i.game || i.game === game)) ?? null, cursor: /^\d+$/.test(url.searchParams.get('cursor') ?? '') ? Number(url.searchParams.get('cursor')) : 0 });
|
|
236
|
+
// The studio decides which known age bands see no shop.
|
|
237
|
+
if ((v.band === 'child' && shop.policy.children === 'deny') || (v.band === 'teen' && shop.policy.teens === 'deny')) return json({ ok: true, open: false, kids: true, items: [] });
|
|
227
238
|
return json({
|
|
228
|
-
ok: true, open: ready.ready, ...(ready.ready ? {} : { missing: ready.missing }), mode: ready.mode,
|
|
229
|
-
currency: shop.currency, till: shop.till, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, spent: v.spent,
|
|
239
|
+
ok: true, open: ready.ready, ...(ready.ready ? {} : { missing: ready.missing, migration: ready.migration }), mode: ready.mode,
|
|
240
|
+
currency: shop.currency, currencyScale: currencyScale(shop.currency), amountStep: amountStep(shop.currency), policy: shop.policy, till: shop.till, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, spent: v.spent,
|
|
230
241
|
player: player ? { signedIn: !player.guest, guest: player.guest, name: player.name, band: v.band } : null,
|
|
231
|
-
owns: v.owns.map((o) => o.key), items: v.items,
|
|
242
|
+
owns: v.owns.map((o) => o.key), items: v.items, nextCursor: v.nextCursor,
|
|
232
243
|
});
|
|
233
244
|
}
|
|
234
245
|
|
|
@@ -239,7 +250,7 @@ async function ownsRoute(request, env, url, cat, shop) {
|
|
|
239
250
|
const game = GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null;
|
|
240
251
|
const facts = await serverFacts(env, cat, game, url.searchParams.get('server'));
|
|
241
252
|
const advantage = new Set((shop.items ?? []).filter((i) => i.advantage).map((i) => i.id));
|
|
242
|
-
const owns = (await ownsOf(env, p.id)).filter((o) => !((facts.kids || facts.beginner) && advantage.has(o.item)));
|
|
253
|
+
const owns = (await ownsOf(env, p.id)).filter((o) => !(((facts.kids && !shop.policy.kidsAdvantages) || (facts.beginner && !shop.policy.beginnerAdvantages)) && advantage.has(o.item)));
|
|
243
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 })) });
|
|
244
255
|
}
|
|
245
256
|
|
|
@@ -248,7 +259,7 @@ async function mineRoute(request, env, shop) {
|
|
|
248
259
|
if (!p) return json({ ok: true, player: null, orders: [], owns: [] });
|
|
249
260
|
const data = await shopDataOf(env, p.id);
|
|
250
261
|
const names = new Map((shop.items ?? []).map((i) => [i.id, i.name]));
|
|
251
|
-
return json({ ok: true, currency: shop.currency, refundDays: shop.refundDays, ageBand: data?.ageBand ?? null, orders: (data?.orders ?? []).map((o) => ({ ...o, name: names.get(o.item) ?? o.item, shown: money(o.amount, o.currency), refundable: refundableNow(o, data?.owns ?? [], shop) })).reverse(), owns: (data?.owns ?? []).map((o) => ({ key: o.key, item: o.item, until: o.until, used: o.used })), badges: (data?.owns ?? []).filter((o) => o.key.startsWith('badge:')).map((o) => (shop.items ?? []).find((i) => i.id === o.item)?.badge ?? o.key.slice(6)) });
|
|
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)) });
|
|
252
263
|
}
|
|
253
264
|
|
|
254
265
|
/** The thanks page asks: is my order (of this session) paid yet? Only the buyer's own account sees it. */
|
|
@@ -258,22 +269,30 @@ async function orderRoute(request, env, url) {
|
|
|
258
269
|
if (!p || !/^cs_[A-Za-z0-9_]{1,200}$/.test(session)) return json({ ok: true, status: null });
|
|
259
270
|
const o = await orderBySession(env, session);
|
|
260
271
|
if (!o || o.player !== p.id) return json({ ok: true, status: null });
|
|
261
|
-
return json({ ok: true, status: o
|
|
272
|
+
return json({ ok: true, status: playerOrderStatus(o), item: o.item, game: o.game ?? null });
|
|
262
273
|
}
|
|
263
274
|
|
|
264
275
|
/** The neutral age question: a year of birth, once. Nothing hints which answer opens anything; no default. */
|
|
265
|
-
async function ageRoute(request, env) {
|
|
266
|
-
const b = await bodyOf(request);
|
|
276
|
+
async function ageRoute(request, env, shop) {
|
|
277
|
+
const b = await bodyOf(request, requestSize(shop));
|
|
267
278
|
if (b.error) return b.error;
|
|
268
|
-
|
|
269
|
-
|
|
279
|
+
let p = await players.of(request, env);
|
|
280
|
+
let cookies = [];
|
|
281
|
+
const answer = bandOf(b.body.year, new Date(), shop.policy);
|
|
282
|
+
if (!answer) return fail(400, 'year', 'pick the year you were born');
|
|
283
|
+
if (!p && !shop.policy.requireAccount) {
|
|
284
|
+
const guest = await players.shopGuest(request, env, new URL(request.url), shop.guestBuyersPerAddressPerHour);
|
|
285
|
+
if (guest.error) return guest.error;
|
|
286
|
+
p = guest.session.player; cookies = guest.session.cookies;
|
|
287
|
+
}
|
|
288
|
+
if (!p || (p.guest && shop.policy.requireAccount)) return fail(401, 'account', 'The studio requires an account before its age question.');
|
|
270
289
|
const before = await bandOfPlayer(env, p.id);
|
|
271
290
|
if (before) return json({ ok: true, asked: true });
|
|
272
|
-
|
|
273
|
-
if (!band) return fail(400, 'year', 'pick the year you were born');
|
|
274
|
-
await setBand(env, p.id, band);
|
|
291
|
+
await setBand(env, p.id, answer);
|
|
275
292
|
// The answer itself is never said back: the shop's list shows what this account may do.
|
|
276
|
-
|
|
293
|
+
const response = json({ ok: true, asked: true });
|
|
294
|
+
for (const cookie of cookies) response.headers.append('set-cookie', cookie);
|
|
295
|
+
return response;
|
|
277
296
|
}
|
|
278
297
|
|
|
279
298
|
export function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio, product = null }) {
|
|
@@ -288,34 +307,90 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
|
|
|
288
307
|
price_data: {
|
|
289
308
|
currency: shop.currency, unit_amount: amount, tax_behavior: 'exclusive',
|
|
290
309
|
// The catalog's Product (its name and tax code are Stripe's copy of shop.json's), or the item described inline.
|
|
291
|
-
...(product ? { product } : { product_data: { name: item.name
|
|
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 } } }),
|
|
292
311
|
},
|
|
293
312
|
}],
|
|
294
313
|
client_reference_id: player,
|
|
295
|
-
metadata
|
|
296
|
-
|
|
314
|
+
// Stripe metadata values allow 500 characters; the order holds the complete item id.
|
|
315
|
+
// 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 } },
|
|
297
318
|
success_url: `${origin}/shop/thanks?session_id={CHECKOUT_SESSION_ID}${back}`,
|
|
298
319
|
cancel_url: `${origin}/shop/?cancelled=1${back}`,
|
|
299
|
-
|
|
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,
|
|
300
322
|
// The EU and UK immediate-delivery acknowledgement (Consumer Rights Directive Art 16(m)), beside the pay button.
|
|
301
|
-
custom_text: { submit: { message:
|
|
323
|
+
custom_text: { submit: { message: checkoutMessage(shop, String(studio ?? 'this studio'), parent) } },
|
|
302
324
|
// The kit's own tills: Stripe Tax on (the studio is the seller), or Managed Payments (Stripe is the seller of record
|
|
303
325
|
// and computes the tax itself, so automatic_tax and adaptive pricing must not be sent).
|
|
304
326
|
...(managed ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: true }, adaptive_pricing: { enabled: false } }),
|
|
305
327
|
};
|
|
306
328
|
}
|
|
307
329
|
|
|
330
|
+
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 });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** At most three reads in parallel, claimed before I/O. Old unresolved rows back off up to an hour.
|
|
336
|
+
* Stripe truth: https://docs.stripe.com/api/checkout/sessions/retrieve
|
|
337
|
+
* Missing sessions: https://docs.stripe.com/error-codes#resource-missing
|
|
338
|
+
* Payment time: https://docs.stripe.com/api/charges/object#charge_object-created
|
|
339
|
+
*/
|
|
340
|
+
export async function reconcileOrders(env, shop, player) {
|
|
341
|
+
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 ?? [];
|
|
344
|
+
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;
|
|
347
|
+
try {
|
|
348
|
+
const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
|
|
349
|
+
if (!session || session.id !== row.session || (typeof session.livemode === 'boolean' && session.livemode !== (row.mode === 'live'))) throw new Error('Stripe session identity or mode mismatch');
|
|
350
|
+
const payment = session.payment_intent;
|
|
351
|
+
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;
|
|
353
|
+
// Missing payment time remains unresolved; never charge an old payment to this month's allowance.
|
|
354
|
+
if (Number.isSafeInteger(time) && time > 0 && time * 1000 <= now) await paid(env, shop, session, time * 1000);
|
|
355
|
+
} else if (session.status === 'expired' || payment?.status === 'canceled' || (session.status === 'complete' && payment?.status === 'requires_payment_method' && payment.last_payment_error)) {
|
|
356
|
+
await env.DB.prepare("UPDATE shop_orders SET status = ?2, updated_at = ?3 WHERE id = ?1 AND status IN ('started', 'processing')").bind(row.id, session.status === 'expired' ? 'expired' : 'failed', now).run();
|
|
357
|
+
}
|
|
358
|
+
} catch (error) {
|
|
359
|
+
logOrderError(env, row.id, error);
|
|
360
|
+
// This key's account cannot see the session; a later verified payment still grants it.
|
|
361
|
+
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();
|
|
363
|
+
} else {
|
|
364
|
+
// Keep the atomic claim, but retry failures after roughly a minute, regardless of row age.
|
|
365
|
+
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();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}).map((work, i) => work.catch((error) => logOrderError(env, rows[i].id, error))));
|
|
370
|
+
}
|
|
371
|
+
|
|
308
372
|
/** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
|
|
309
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.') };
|
|
310
375
|
const order = { id: newOrderId(), player: player.id, via: via ?? null, amount, currency: shop.currency };
|
|
311
376
|
const now = Date.now();
|
|
312
|
-
|
|
313
|
-
|
|
377
|
+
order.createdAt = now;
|
|
378
|
+
order.expiresAt = Math.floor(now / 1000) + 31 * 60;
|
|
379
|
+
// 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();
|
|
382
|
+
let reserved = await reserve();
|
|
383
|
+
const reconciled = !reserved.meta?.changes;
|
|
384
|
+
if (!reserved.meta?.changes) {
|
|
385
|
+
await reconcileOrders(env, shop, player.id);
|
|
386
|
+
reserved = await reserve();
|
|
387
|
+
}
|
|
388
|
+
if (!reserved.meta?.changes) return { error: fail(403, 'cap', WAY_WORDS.cap, { way: 'cap' }) };
|
|
314
389
|
let session = null;
|
|
315
390
|
const mode = modeOf(env.STRIPE_KEY);
|
|
316
|
-
const product = Array.isArray(shop.catalog) && shop.catalog.includes(mode) ? productIdOf(cat.studio?.slug, item.id) : null;
|
|
317
|
-
const params = (p) => sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, product: p });
|
|
318
391
|
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 });
|
|
319
394
|
try {
|
|
320
395
|
session = await createCheckoutSession(env, params(product), { idempotencyKey: `checkout-${order.id}` });
|
|
321
396
|
} catch (error) {
|
|
@@ -324,16 +399,17 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
|
|
|
324
399
|
if (!product || !isMissingProduct(error)) throw error;
|
|
325
400
|
session = await createCheckoutSession(env, params(null), { idempotencyKey: `checkout-${order.id}-inline` });
|
|
326
401
|
}
|
|
402
|
+
if (typeof session?.id !== 'string' || typeof session?.url !== 'string' || !/^https:\/\//.test(session.url) && !/^http:\/\/(127\.0\.0\.1|localhost)/.test(session.url)) {
|
|
403
|
+
await env.DB.prepare("UPDATE shop_orders SET status = 'failed', updated_at = ?2 WHERE id = ?1").bind(order.id, Date.now()).run();
|
|
404
|
+
return { error: fail(502, 'stripe', 'Stripe answered without a checkout page. Nothing was charged.') };
|
|
405
|
+
}
|
|
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();
|
|
327
407
|
} catch (error) {
|
|
328
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();
|
|
329
409
|
const why = error instanceof StripeError ? error.message : 'Stripe did not answer';
|
|
330
410
|
return { error: fail(502, 'stripe', `Stripe could not open the checkout (${why}). Nothing was charged.`) };
|
|
331
411
|
}
|
|
332
|
-
|
|
333
|
-
return { error: fail(502, 'stripe', 'Stripe answered without a checkout page. Nothing was charged.') };
|
|
334
|
-
}
|
|
335
|
-
await env.DB.prepare('UPDATE shop_orders SET session = ?2, updated_at = ?3 WHERE id = ?1').bind(order.id, session.id, Date.now()).run();
|
|
336
|
-
return { order, url: session.url, session: session.id };
|
|
412
|
+
return { order, url: session.url, session: session.id, reconciled };
|
|
337
413
|
}
|
|
338
414
|
|
|
339
415
|
/** The referrer this buyer brought, if it still counts: their arrival, a new account since it, in the window, proven. */
|
|
@@ -341,60 +417,74 @@ async function viaFor(request, env, url, shop, player) {
|
|
|
341
417
|
if (!shop.referrals || !(shop.referrals.rate > 0)) return null;
|
|
342
418
|
const a = await arrivalOf(request, env);
|
|
343
419
|
if (!a || a.via === url.hostname.replace(/^www\./, '')) return null;
|
|
344
|
-
if (Date.now() - a.at > shop.referrals.windowDays * DAY) return null;
|
|
420
|
+
if (shop.referrals.windowDays !== null && Date.now() - a.at > Math.round(shop.referrals.windowDays * DAY)) return null;
|
|
345
421
|
// New players only: an account made before the arrival was already this studio's player.
|
|
346
422
|
if (player.since !== null && player.since < a.at - 60_000) return null;
|
|
347
423
|
return (await referrerOk(a.via)) ? a.via : null;
|
|
348
424
|
}
|
|
349
425
|
|
|
350
426
|
const WAY_WORDS = Object.freeze({
|
|
351
|
-
closed: 'The shop is not open yet.', kids: '
|
|
427
|
+
closed: 'The shop is not open yet.', kids: 'The studio policy.kidsServer setting closes this shop on kids servers.', beginner: 'The studio server advantage settings hide this item here.',
|
|
352
428
|
owned: 'You have it already.', 'make-an-account': 'Make an account first (a passkey), so what you buy stays yours on every device.',
|
|
353
429
|
'age-question': 'One question first: the year you were born.', no: 'This shop sells nothing to this account.',
|
|
354
|
-
'ask-a-parent': 'A parent or guardian buys this for you: send them the link.', cap: 'That
|
|
430
|
+
'ask-a-parent': 'A parent or guardian buys this for you: send them the link.', cap: 'That reaches the studio capPerPlayerMonth setting for this account.',
|
|
355
431
|
'not-yet': 'Not on sale yet.', over: 'No longer on sale.',
|
|
356
432
|
});
|
|
357
433
|
|
|
358
|
-
|
|
359
|
-
const
|
|
434
|
+
function purchaseLimited(request, shop, player, kind) {
|
|
435
|
+
const account = player && limited(`${kind}:account:${player}`, shop.purchaseAttemptsPerMinute, 60_000);
|
|
436
|
+
const address = limited(`${kind}:address:${addressOf(request)}`, shop.purchaseAttemptsPerAddressPerMinute, 60_000);
|
|
437
|
+
return account || address;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function buyRoute(request, env, url, cat, shop, ready, ctx) {
|
|
441
|
+
const b = await bodyOf(request, requestSize(shop));
|
|
360
442
|
if (b.error) return b.error;
|
|
361
|
-
if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing });
|
|
443
|
+
if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
|
|
362
444
|
const item = shop.items.find((i) => i.id === b.body.item);
|
|
363
445
|
if (!item) return fail(404, 'item', 'no such item here');
|
|
364
446
|
const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
|
|
365
447
|
if (item.game && game && item.game !== game) return fail(400, 'item', 'that item belongs to another game');
|
|
366
448
|
const facts = await serverFacts(env, cat, game, b.body.server);
|
|
367
|
-
|
|
368
|
-
if (
|
|
369
|
-
const v = await itemsView(env, shop, { game, ...facts, player, open: true });
|
|
449
|
+
let player = await accountOf(request, env);
|
|
450
|
+
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 });
|
|
370
452
|
const row = v.items.find((i) => i.id === item.id);
|
|
371
|
-
const way = facts.kids ? 'kids' : row?.way ?? (item.advantage && facts.beginner ? 'beginner' : 'closed');
|
|
453
|
+
const way = facts.kids && !shop.policy.kidsServer ? 'kids' : row?.way ?? (item.advantage && facts.beginner ? 'beginner' : 'closed');
|
|
372
454
|
if (way !== 'checkout') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
|
|
373
455
|
let amount = item.price;
|
|
374
456
|
if (item.kind === 'tip') {
|
|
375
|
-
amount =
|
|
376
|
-
if (!(amount >= item.min && amount <= item.max)) return fail(400, 'amount', `
|
|
377
|
-
|
|
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)}`}.`);
|
|
459
|
+
}
|
|
460
|
+
let cookies = [];
|
|
461
|
+
if (!player) {
|
|
462
|
+
const guest = await players.shopGuest(request, env, url, shop.guestBuyersPerAddressPerHour);
|
|
463
|
+
if (guest.error) return guest.error;
|
|
464
|
+
player = guest.session.player;
|
|
465
|
+
cookies = guest.session.cookies;
|
|
378
466
|
}
|
|
379
467
|
const via = await viaFor(request, env, url, shop, player);
|
|
380
468
|
const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount });
|
|
381
|
-
|
|
382
|
-
|
|
469
|
+
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)));
|
|
471
|
+
for (const cookie of cookies) response.headers.append('set-cookie', cookie);
|
|
472
|
+
return response;
|
|
383
473
|
}
|
|
384
474
|
|
|
385
475
|
/** 13-17: a one-time link for a parent (7 days). The teen passes it on; nothing here pressures anyone. */
|
|
386
476
|
async function parentLinkRoute(request, env, url, cat, shop, ready) {
|
|
387
|
-
const b = await bodyOf(request);
|
|
477
|
+
const b = await bodyOf(request, requestSize(shop));
|
|
388
478
|
if (b.error) return b.error;
|
|
389
|
-
if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing });
|
|
479
|
+
if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
|
|
390
480
|
const item = shop.items.find((i) => i.id === b.body.item);
|
|
391
|
-
if (!item
|
|
481
|
+
if (!item) return fail(404, 'item', 'no such item here');
|
|
392
482
|
const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
|
|
393
483
|
const facts = await serverFacts(env, cat, game, b.body.server);
|
|
394
484
|
const player = await accountOf(request, env);
|
|
395
|
-
if (
|
|
396
|
-
const v = await itemsView(env, shop, { game, ...facts, player, open: true });
|
|
397
|
-
const way = facts.kids ? 'kids' : v.items.find((i) => i.id === item.id)?.way ?? 'closed';
|
|
485
|
+
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';
|
|
398
488
|
if (way !== 'ask-a-parent') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
|
|
399
489
|
const token = randomToken();
|
|
400
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();
|
|
@@ -402,7 +492,7 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
|
|
|
402
492
|
}
|
|
403
493
|
|
|
404
494
|
/** /shop/parent/<token>: what, for whom, the price; the parent pays in their own name on Stripe's page. */
|
|
405
|
-
async function parentRoute(request, env, url, cat, shop, ready, token) {
|
|
495
|
+
async function parentRoute(request, env, url, cat, shop, ready, token, ctx) {
|
|
406
496
|
if (!TOKEN.test(token)) return notFoundPage('This link does not work.', cat);
|
|
407
497
|
const row = await env.DB.prepare('SELECT * FROM shop_parent_links WHERE hash = ?1').bind(await sha256Hex(token)).first();
|
|
408
498
|
const item = row ? shop.items.find((i) => i.id === row.item) : null;
|
|
@@ -417,51 +507,56 @@ async function parentRoute(request, env, url, cat, shop, ready, token) {
|
|
|
417
507
|
try { form = await request.formData(); } catch { form = null; }
|
|
418
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.' });
|
|
419
509
|
if (!ready.ready) return parentPage(cat, shop, { item, name: kid.name, token, open: false });
|
|
420
|
-
if (
|
|
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.' });
|
|
421
511
|
const band = await bandOfPlayer(env, row.player);
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
512
|
+
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 });
|
|
426
520
|
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)));
|
|
427
522
|
return Response.redirect(r.url, 303);
|
|
428
523
|
}
|
|
429
524
|
|
|
430
525
|
/** The game says the player used something (equipped a skin, played the pass): it is no longer refundable by the player. */
|
|
431
|
-
async function usedRoute(request, env) {
|
|
432
|
-
const b = await bodyOf(request);
|
|
526
|
+
async function usedRoute(request, env, shop) {
|
|
527
|
+
const b = await bodyOf(request, requestSize(shop));
|
|
433
528
|
if (b.error) return b.error;
|
|
434
529
|
const p = await players.of(request, env);
|
|
435
530
|
if (!p) return json({ ok: true, used: 0 });
|
|
436
531
|
const key = String(b.body.key ?? '');
|
|
437
|
-
if (
|
|
532
|
+
if (!ENTITLEMENT_KEY.test(key)) return fail(400, 'key', 'name the key the game read');
|
|
438
533
|
const r = await env.DB.prepare("UPDATE entitlements SET used_at = ?3 WHERE player = ?1 AND key = ?2 AND used_at IS NULL AND state = 'active'").bind(p.id, key, Date.now()).run();
|
|
439
534
|
return json({ ok: true, used: Number(r?.meta?.changes ?? 0) });
|
|
440
535
|
}
|
|
441
536
|
|
|
442
537
|
function refundableNow(order, owns, shop, now = Date.now()) {
|
|
443
|
-
if (order.status !== 'paid' || order.paidAt === null) return false;
|
|
444
|
-
if (now - order.paidAt > shop.refundDays * DAY) return false;
|
|
445
|
-
return !owns.some((o) => o.item === order.item && o.used);
|
|
538
|
+
if (shop.refundDays === null || order.status !== 'paid' || order.paidAt === null) return false;
|
|
539
|
+
if (now - order.paidAt > Math.round(shop.refundDays * DAY)) return false;
|
|
540
|
+
return shop.policy.refundUsedItems || !owns.some((o) => o.item === order.item && o.used);
|
|
446
541
|
}
|
|
447
542
|
|
|
448
543
|
/** A player's own refund: an unused item, within the studio's refund days (fewer disputes, and the respected norm). */
|
|
449
544
|
async function selfRefundRoute(request, env, shop, ready) {
|
|
450
|
-
const b = await bodyOf(request);
|
|
545
|
+
const b = await bodyOf(request, requestSize(shop));
|
|
451
546
|
if (b.error) return b.error;
|
|
452
547
|
const p = await players.of(request, env);
|
|
453
|
-
if (!p
|
|
548
|
+
if (!p) return fail(401, 'account', 'sign in first');
|
|
454
549
|
const o = await orderById(env, b.body.order);
|
|
455
550
|
if (!o || o.player !== p.id) return fail(404, 'order', 'no such order on this account');
|
|
456
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();
|
|
457
|
-
if (!refundableNow(orderView(o), Number(used?.n) ? [{ item: o.item, used: true }] : [], shop)) return fail(403, 'not-refundable', `
|
|
458
|
-
if (!KEY_SHAPE.test(String(env.STRIPE_KEY ?? ''))) return fail(503, 'closed', MISSING_WORDS['stripe-key']);
|
|
552
|
+
if (!refundableNow(orderView(o), Number(used?.n) ? [{ item: o.item, used: true }] : [], shop)) return fail(403, 'not-refundable', shop.refundDays === null ? 'Ask the studio for a refund; it has not set a self-service refund window.' : `The studio allows self-service refunds for ${shop.policy.refundUsedItems ? 'items' : 'unused items'} within ${shop.refundDays} days. Ask the studio for anything else.`);
|
|
553
|
+
if (Number(o.amount) !== 0 && !KEY_SHAPE.test(String(env.STRIPE_KEY ?? ''))) return fail(503, 'closed', MISSING_WORDS['stripe-key']);
|
|
459
554
|
const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player' });
|
|
460
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);
|
|
461
556
|
return json(r, r.ok ? 200 : 502);
|
|
462
557
|
}
|
|
463
558
|
|
|
464
|
-
/* ------------------------------------------------------------------
|
|
559
|
+
/* ------------------------------------------------------------------ Stripe events and reconciliation share idempotent money transitions */
|
|
465
560
|
|
|
466
561
|
async function hook(request, env, cat, shop, ready) {
|
|
467
562
|
if (request.method !== 'POST') return fail(405, 'method', 'POST only');
|
|
@@ -479,11 +574,15 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
479
574
|
switch (ev.type) {
|
|
480
575
|
case 'checkout.session.completed':
|
|
481
576
|
case 'checkout.session.async_payment_succeeded':
|
|
482
|
-
|
|
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
|
+
}
|
|
483
582
|
break;
|
|
484
583
|
case 'checkout.session.async_payment_failed':
|
|
485
584
|
case 'checkout.session.expired':
|
|
486
|
-
await env.DB.prepare('UPDATE shop_orders SET status = ?2, updated_at = ?3 WHERE session = ?1 AND status
|
|
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();
|
|
487
586
|
did = 'closed';
|
|
488
587
|
break;
|
|
489
588
|
case 'charge.refunded':
|
|
@@ -491,7 +590,11 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
491
590
|
break;
|
|
492
591
|
case 'refund.created':
|
|
493
592
|
case 'refund.updated':
|
|
494
|
-
|
|
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 ?? ''));
|
|
597
|
+
}
|
|
495
598
|
break;
|
|
496
599
|
case 'charge.dispute.created':
|
|
497
600
|
did = await disputed(env, String(obj.payment_intent ?? ''), String(obj.id ?? ''));
|
|
@@ -508,17 +611,17 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
508
611
|
}
|
|
509
612
|
|
|
510
613
|
/** A paid checkout: the order is paid, its entitlements granted, a referral line written. Idempotent. */
|
|
511
|
-
async function paid(env, shop, session) {
|
|
614
|
+
async function paid(env, shop, session, paidAt = Date.now()) {
|
|
512
615
|
const o = await orderBySession(env, session.id);
|
|
513
616
|
if (!o) return 'unknown-order';
|
|
514
617
|
// The session must be the one this order opened, for this player and this price.
|
|
515
618
|
if (session.metadata?.order !== o.id || (session.client_reference_id && session.client_reference_id !== o.player)) return 'mismatch';
|
|
516
|
-
if (session.currency
|
|
517
|
-
if (o.
|
|
619
|
+
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';
|
|
518
621
|
const item = shop.items.find((i) => i.id === o.item);
|
|
519
622
|
const now = Date.now();
|
|
520
|
-
const res = await env.DB.prepare("UPDATE shop_orders SET status = 'paid', payment = ?2, tax = ?3, total = ?4, paid_at = ?5, updated_at = ?
|
|
521
|
-
.bind(o.id, typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id ?? null, Number.isFinite(Number(session.total_details?.amount_tax)) ? Number(session.total_details.amount_tax) : null, Number.isFinite(Number(session.amount_total)) ? Number(session.amount_total) : null, now).run();
|
|
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();
|
|
522
625
|
if (!Number(res?.meta?.changes ?? 1)) return 'already';
|
|
523
626
|
const writes = [];
|
|
524
627
|
if (o.player && item) writes.push(...grantStatements(env, o, item, now));
|
|
@@ -542,7 +645,7 @@ async function refunded(env, payment, refundId) {
|
|
|
542
645
|
return 'refunded';
|
|
543
646
|
}
|
|
544
647
|
|
|
545
|
-
/** A dispute changes nothing until it is decided: the player keeps the item and their account
|
|
648
|
+
/** A dispute changes nothing until it is decided: the player keeps the item and their account. */
|
|
546
649
|
async function disputed(env, payment, disputeId) {
|
|
547
650
|
const o = payment ? await orderByPayment(env, payment) : null;
|
|
548
651
|
if (!o) return 'unknown-order';
|
|
@@ -576,6 +679,11 @@ export const REFUND_HELD = 'Stripe is holding this refund until a person approve
|
|
|
576
679
|
/** Refund one order in full with Stripe, then take back that one item. The webhook confirms the same (idempotent). */
|
|
577
680
|
export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner' } = {}) {
|
|
578
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
|
+
}
|
|
579
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.' };
|
|
580
688
|
let refund = null;
|
|
581
689
|
try {
|
|
@@ -604,9 +712,31 @@ export function checkRefund(body) {
|
|
|
604
712
|
const note = String(body.note ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim().slice(0, 140) || null;
|
|
605
713
|
return { ok: true, action: { op: 'refund', order: body.order, reason, note } };
|
|
606
714
|
}
|
|
715
|
+
/** Explicit owner override of an unresolved reservation; the existing note records its actor and time. */
|
|
716
|
+
export function checkRelease(body) {
|
|
717
|
+
const checked = checkRefund(body);
|
|
718
|
+
return checked.ok ? { ok: true, action: { op: 'shop-release', order: body.order } } : checked;
|
|
719
|
+
}
|
|
720
|
+
export async function performRelease(env, a) {
|
|
721
|
+
const row = await orderById(env, a.order);
|
|
722
|
+
if (!row || !['started', 'processing'].includes(row.status)) return { ok: false, error: 'state', message: 'Only an unresolved order can be released.' };
|
|
723
|
+
// https://docs.stripe.com/api/checkout/sessions/expire — only open sessions can expire.
|
|
724
|
+
if (row.session) {
|
|
725
|
+
try {
|
|
726
|
+
const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(row.session)}`, null, { timeout: 2000 });
|
|
727
|
+
if (session?.id === row.session && session.status === 'open') await expireCheckoutSession(env, row.session, { timeout: 2000 });
|
|
728
|
+
} catch (error) { logOrderError(env, row.id, error); }
|
|
729
|
+
}
|
|
730
|
+
const at = Date.now();
|
|
731
|
+
const note = `Reservation released by ${a.by} at ${new Date(at).toISOString()}`;
|
|
732
|
+
const result = await env.DB.prepare("UPDATE shop_orders SET status = 'released', note = ?2 || '; previous status: ' || status, updated_at = ?3 WHERE id = ?1 AND status IN ('started', 'processing')").bind(a.order, note, at).run();
|
|
733
|
+
return result.meta?.changes ? { ok: true, order: a.order, by: a.by, at } : { ok: false, error: 'state', message: 'Only an unresolved order can be released.' };
|
|
734
|
+
}
|
|
735
|
+
|
|
607
736
|
export function checkSettle(body) {
|
|
608
|
-
if (!/^[a-z0-9.-]{3,
|
|
609
|
-
return { ok:
|
|
737
|
+
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
|
+
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) } };
|
|
610
740
|
}
|
|
611
741
|
|
|
612
742
|
/** The refund in plain words, for the owner's confirm page and the AI's card. */
|
|
@@ -625,13 +755,13 @@ export async function performRefund(env, cat, a) {
|
|
|
625
755
|
}
|
|
626
756
|
|
|
627
757
|
/** Everything the office's shop page shows. Money is read from this studio's D1; Stripe's own pages are linked. */
|
|
628
|
-
export async function shopOffice(env, cat, origin) {
|
|
758
|
+
export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0, orderCursor = 0) {
|
|
629
759
|
const shop = shopOf(cat);
|
|
630
760
|
const ready = await readiness(env, shop);
|
|
631
761
|
const mode = ready.mode ?? 'test';
|
|
632
762
|
const out = {
|
|
633
|
-
ok: true, ready: ready.ready, missing: ready.missing.map((m) => ({ id: m, words: MISSING_WORDS[m] ?? m })), mode: ready.mode,
|
|
634
|
-
shop: shop ? { till: shop.till, currency: shop.currency, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, items: shop.items.map((i) => ({ id: i.id, kind: i.kind, name: i.name, price: i.price, shown: i.kind === 'tip' ? `${money(i.min, shop.currency)}+` : money(i.price, shop.currency), gives: i.gives, game: i.game ?? null })), referrals: shop.referrals, ...(shop.broken ? { broken: shop.broken } : {}) } : null,
|
|
763
|
+
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,
|
|
635
765
|
stripe: {
|
|
636
766
|
payments: dashboardLink(mode, 'payments'), refunds: dashboardLink(mode, 'refunds'), disputes: dashboardLink(mode, 'disputes'), payouts: dashboardLink(mode, 'payouts'),
|
|
637
767
|
balance: dashboardLink(mode, 'balance'), tax: dashboardLink(mode, 'tax'), keys: dashboardLink(mode, 'keys'), webhooks: dashboardLink(mode, 'webhooks'),
|
|
@@ -644,14 +774,18 @@ export async function shopOffice(env, cat, origin) {
|
|
|
644
774
|
const since = Date.now() - 30 * DAY;
|
|
645
775
|
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();
|
|
646
776
|
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 };
|
|
647
|
-
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
|
|
777
|
+
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 ?? [];
|
|
778
|
+
out.nextOrdersCursor = rows.length > 100 ? String(orderCursor + 100) : null;
|
|
779
|
+
if (rows.length > 100) rows.pop();
|
|
648
780
|
const names = new Map((shop?.items ?? []).map((i) => [i.id, i.name]));
|
|
649
781
|
out.orders = rows.map((r) => ({
|
|
650
|
-
...orderView(r), name: names.get(r.item) ?? r.item, shown: money(r.amount, r.currency), player: r.player ? { id: r.player, name: r.player_name ?? null } : null,
|
|
782
|
+
...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,
|
|
651
783
|
stripe: r.payment ? dashboardLink(r.mode, 'payment', r.payment) : null, dispute: r.dispute ? dashboardLink(r.mode, 'dispute', r.dispute) : null,
|
|
652
|
-
|
|
784
|
+
releasable: ['started', 'processing'].includes(r.status),
|
|
785
|
+
refundable: r.status === 'paid' && (Number(r.amount) === 0 || Boolean(r.payment)),
|
|
653
786
|
}));
|
|
654
|
-
|
|
787
|
+
const { books, nextCursor } = await booksOf(env, { cursor });
|
|
788
|
+
out.referrals = { nextCursor, owe: books, failures: await statementFailures(env), owedToUs: await statementsIn(env), invoice: dashboardLink(mode, 'invoices') };
|
|
655
789
|
return out;
|
|
656
790
|
}
|
|
657
791
|
|
|
@@ -661,25 +795,23 @@ export async function ordersCsv(env) {
|
|
|
661
795
|
const cell = (v) => { const s = v === null || v === undefined ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
|
|
662
796
|
const head = ['order', 'created', 'paid', 'refunded', 'status', 'item', 'game', 'amount', 'tax', 'total', 'currency', 'till', 'mode', 'parent', 'via', 'stripe_payment', 'stripe_refund', 'stripe_dispute'];
|
|
663
797
|
const iso = (t) => (t ? new Date(Number(t)).toISOString() : '');
|
|
664
|
-
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) /
|
|
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(','));
|
|
665
799
|
return `${head.join(',')}\n${lines.join('\n')}${lines.length ? '\n' : ''}`;
|
|
666
800
|
}
|
|
667
801
|
|
|
668
802
|
/** The signed statements for a period (YYYY-MM, default last month): one per referrer this studio owes. */
|
|
669
|
-
export async function statementsOut(env, cat, origin, period) {
|
|
803
|
+
export async function statementsOut(env, cat, origin, period, cursor = '') {
|
|
670
804
|
const shop = shopOf(cat);
|
|
671
805
|
const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : lastMonth();
|
|
672
|
-
|
|
673
|
-
const out = [];
|
|
674
|
-
for (const b of books) out.push(await statementFor(env, origin, b, p, shop?.referrals));
|
|
675
|
-
return { ok: true, period: p, statements: out };
|
|
806
|
+
return { ok: true, period: p, ...(await statementPage(env, origin, shop?.referrals, p, cursor, { snapshot: false })) };
|
|
676
807
|
}
|
|
677
|
-
export async function statementsSend(env, cat, origin, period) {
|
|
808
|
+
export async function statementsSend(env, cat, origin, period, cursor = '') {
|
|
678
809
|
const shop = shopOf(cat);
|
|
679
|
-
const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : lastMonth();
|
|
680
|
-
|
|
810
|
+
const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : (await pendingStatementPeriod(env, origin)) ?? lastMonth();
|
|
811
|
+
const { sent, nextCursor, retryAfter, failures } = await sendStatements(env, origin, shop, p, { cursor });
|
|
812
|
+
return { ok: true, period: p, nextCursor, sent, retryAfter, failures };
|
|
681
813
|
}
|
|
682
|
-
export const settleReferrer = (env, a) => settle(env, a.via, a.ref);
|
|
814
|
+
export const settleReferrer = (env, a) => settle(env, a.via, a.ref, { currency: a.currency });
|
|
683
815
|
|
|
684
816
|
function lastMonth(now = new Date()) {
|
|
685
817
|
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
|
@@ -688,22 +820,22 @@ function lastMonth(now = new Date()) {
|
|
|
688
820
|
|
|
689
821
|
/* ------------------------------------------------------------------ for the rest of the Worker */
|
|
690
822
|
|
|
691
|
-
/** The play shell's boot facts: is there a shop to show
|
|
823
|
+
/** The play shell's boot facts: is there a shop to show under this studio's policy. */
|
|
692
824
|
export function shellShop(cat, game, { kids = false } = {}) {
|
|
693
825
|
const shop = shopOf(cat);
|
|
694
|
-
if (!shop || shop.audience === 'kids' || kids || shop.till === 'off') return null;
|
|
826
|
+
if (!shop || shop.audience === 'kids' || (kids && !shop.policy.kidsServer) || shop.till === 'off') return null;
|
|
695
827
|
const items = itemsFor(shop, game);
|
|
696
|
-
return items.length ? { items: items.length, currency: shop.currency } : null;
|
|
828
|
+
return items.length ? { items: items.length, currency: shop.currency, policy: shop.policy } : null;
|
|
697
829
|
}
|
|
698
830
|
|
|
699
|
-
/** The badge (a supporter's) a signed-in player shows in rooms;
|
|
831
|
+
/** The badge (a supporter's) a signed-in player shows in rooms; kids visibility follows studio policy. Verified here, never by a hello. */
|
|
700
832
|
export async function roomBadge(env, cat, player, { kids = false } = {}) {
|
|
701
833
|
const shop = shopOf(cat);
|
|
702
|
-
if (!shop || kids || !/^pl_[A-Za-z0-9_-]{22}$/.test(String(player ?? ''))) return null;
|
|
834
|
+
if (!shop || (kids && !shop.policy.kidsServer) || !/^pl_[A-Za-z0-9_-]{22}$/.test(String(player ?? ''))) return null;
|
|
703
835
|
try {
|
|
704
836
|
const owns = await ownsOf(env, player);
|
|
705
837
|
const b = owns.find((o) => o.key.startsWith('badge:'));
|
|
706
838
|
if (!b) return null;
|
|
707
|
-
return shop.items.find((i) => i.id === b.item)?.badge ?? b.key.slice(6
|
|
839
|
+
return shop.items.find((i) => i.id === b.item)?.badge ?? b.key.slice(6);
|
|
708
840
|
} catch { return null; }
|
|
709
841
|
}
|