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