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