@homie-rocks/studio 0.53.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/bin/homie-studio.mjs +1 -1
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +8 -1
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +5 -2
- package/lib/check.mjs +10 -2
- package/lib/cloudflare.mjs +16 -1
- package/lib/dev.mjs +8 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +46 -9
- package/lib/shop-links.mjs +37 -19
- package/lib/shop-mcp.mjs +7 -1
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +84 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-records.mjs +4 -2
- package/worker/app-shell.mjs +9 -4
- package/worker/app-streams.mjs +27 -10
- package/worker/app-tools.mjs +1 -0
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +30 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +29 -12
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +58 -2772
- package/worker/lazy-objects.mjs +40 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby.mjs +251 -0
- package/worker/lounge.mjs +3 -3
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +105 -34
- package/worker/operational-schema.mjs +44 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +15 -29
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/request-db.mjs +78 -0
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +216 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room.mjs +38 -731
- package/worker/rooms-cache.mjs +19 -0
- package/worker/routes.mjs +667 -0
- package/worker/runtime-support.mjs +445 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/servers.mjs +5 -4
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +2 -2
- package/worker/shop-page.mjs +2 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +89 -27
- package/worker/stats.mjs +7 -6
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1212 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
package/worker/shop.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const officeShopPage = async (...args) => (await import('./shop-page.mjs')).officeShopPage(...args);
|
|
1
2
|
import {claimHoldStatement,ownedHold} from './booking-holds.mjs';
|
|
2
3
|
import { stripeEventOwner } from './stripe-ownership.mjs';
|
|
3
4
|
import { localSession } from './local-test.mjs';
|
|
@@ -47,7 +48,7 @@ import {
|
|
|
47
48
|
setBand, shopDataOf, spentThisMonth,
|
|
48
49
|
} from './shop-store.mjs';
|
|
49
50
|
import { apiBase, STRIPE_API, redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
|
|
50
|
-
|
|
51
|
+
|
|
51
52
|
import { notFoundPage } from './site.mjs';
|
|
52
53
|
|
|
53
54
|
export { forgetPlayerShop, officeShopPage, shopDataOf };
|
|
@@ -217,7 +218,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
217
218
|
const requestAt = Date.now();
|
|
218
219
|
const path = url.pathname;
|
|
219
220
|
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
220
|
-
if (path === '/_homie/shop.js') return new Response(SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
221
|
+
if (path === '/_homie/shop.js') return new Response((await import('./shop-page.mjs')).SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
221
222
|
const shopPath = path === '/shop' || path === '/shop/' || path.startsWith('/shop/') || path === '/api/shop' || path.startsWith('/api/shop/') || path === '/api/player/owns';
|
|
222
223
|
if (selling.enabled && (path.startsWith('/api/purchases/') || path.startsWith('/purchases/'))) return selling.purchaseRoutes(request, env, url, await catalogueOf());
|
|
223
224
|
if (!shopPath) return null;
|
|
@@ -230,7 +231,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
230
231
|
if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
|
|
231
232
|
if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
|
|
232
233
|
// No shop.json: /shop/ is the studio's own to use (a page in site/pages, say). The policy can close a kids studio's shop.
|
|
233
|
-
if(!shop && path==='/shop/' && read && !cat.site?.pages?.includes('/shop/')) return shopPage(cat,checkShop({}).shop,{origin:url.origin,open:false});
|
|
234
|
+
if(!shop && path==='/shop/' && read && !cat.site?.pages?.includes('/shop/')) return (await import('./shop-page.mjs')).shopPage(cat,checkShop({}).shop,{origin:url.origin,open:false});
|
|
234
235
|
return shop ? notFoundPage('This studio sells nothing.', cat) : null;
|
|
235
236
|
}
|
|
236
237
|
const ready = await readiness(env, shop);
|
|
@@ -271,9 +272,9 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
271
272
|
if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
|
|
272
273
|
if (path === '/api/shop/used' && request.method === 'POST') return await usedRoute(request, env, shop);
|
|
273
274
|
if (path === '/api/shop/refund' && request.method === 'POST') return await selfRefundRoute(request, env, shop, ready);
|
|
274
|
-
if (path === '/shop/' && read) return shopPage(cat, shop, { origin: url.origin, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null, item: url.searchParams.get('item'), open: ready.ready, mode: ready.mode });
|
|
275
|
-
if (path === '/shop/thanks' && read) return thanksPage(cat, shop, { session: /^cs_[A-Za-z0-9_]{1,200}$/.test(url.searchParams.get('session_id') ?? '') ? url.searchParams.get('session_id') : null, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null });
|
|
276
|
-
if ((path === '/shop/refunds/' || path === '/shop/refunds') && read) return refundsPage(cat, shop);
|
|
275
|
+
if (path === '/shop/' && read) return (await import('./shop-page.mjs')).shopPage(cat, shop, { origin: url.origin, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null, item: url.searchParams.get('item'), open: ready.ready, mode: ready.mode });
|
|
276
|
+
if (path === '/shop/thanks' && read) return (await import('./shop-page.mjs')).thanksPage(cat, shop, { session: /^cs_[A-Za-z0-9_]{1,200}$/.test(url.searchParams.get('session_id') ?? '') ? url.searchParams.get('session_id') : null, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null });
|
|
277
|
+
if ((path === '/shop/refunds/' || path === '/shop/refunds') && read) return (await import('./shop-page.mjs')).refundsPage(cat, shop);
|
|
277
278
|
const parent = /^\/shop\/parent\/([A-Za-z0-9_-]{43})$/.exec(path);
|
|
278
279
|
if (parent) return await parentRoute(request, env, url, cat, shop, ready, parent[1], ctx);
|
|
279
280
|
} catch (error) {
|
|
@@ -347,7 +348,7 @@ async function orderRoute(request, env, url, shop) {
|
|
|
347
348
|
} catch (error) { logOrderError(env, o.id, error); }
|
|
348
349
|
}
|
|
349
350
|
const reference = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-reference:${o.id}`).first();
|
|
350
|
-
return json({ ok: true, order: o.id, simulated: apiBase(env) !== STRIPE_API, status: playerOrderStatus(o), item: o.item, game: o.game ?? null, reference: reference?.value ?? null });
|
|
351
|
+
return json({ ok: true, order: o.id, simulated: apiBase(env) !== STRIPE_API, status: playerOrderStatus(o), ...(o.status==='review'?{note:'Payment received. Staff will reconcile the payment or refund it.'}:{}), item: o.item, game: o.game ?? null, reference: reference?.value ?? null });
|
|
351
352
|
}
|
|
352
353
|
|
|
353
354
|
/** The neutral age question: a year of birth, once. Nothing hints which answer opens anything; no default. */
|
|
@@ -524,14 +525,13 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
524
525
|
if(keyless && lines.length>1 && !menu)return {error:fail(409,'menu','These items need separate Stripe menus. Custom amounts and monthly gifts use their own links.')};
|
|
525
526
|
if(keyless && (!link || !paymentLinkURL(link.url)))return {error:fail(503,'payment-links','Ask the owner’s AI to sync the shop and published booking slots.')};
|
|
526
527
|
if(keyless && booking) {
|
|
527
|
-
if(booking.quantity!==1)return {error:fail(400,'quantity','Book one seat per checkout; Stripe counts completed sessions.')};
|
|
528
528
|
const slot=await env.DB.prepare('SELECT value FROM app_records WHERE app=?1 AND collection=?2 AND id=?3').bind(booking.app,booking.slots,booking.slot).first();
|
|
529
529
|
if(!slot || JSON.parse(slot.value).capacity!==link.capacity)return {error:fail(409,'slot-sync','This slot changed; ask the owner’s AI to sync its Stripe link.')};
|
|
530
530
|
const confirmed=await env.DB.prepare("SELECT COALESCE(SUM(quantity),0) n FROM shop_bookings WHERE app=?1 AND slots=?2 AND slot=?3 AND state='confirmed'").bind(booking.app,booking.slots,booking.slot).first();
|
|
531
531
|
const update=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
|
|
532
532
|
const observed=update?JSON.parse(update.value):null;
|
|
533
533
|
const status=observed && observed.capacity===(link.limit??link.capacity) ? observed : link;
|
|
534
|
-
if(!status.active || status.count>=(link.limit??link.capacity) || confirmed.n
|
|
534
|
+
if(!status.active || status.count>=(link.limit??link.capacity) || confirmed.n+booking.quantity>link.capacity)return {error:fail(409,'capacity','That slot is full. Choose another slot.')};
|
|
535
535
|
}
|
|
536
536
|
if(keyless && item.price==='choose' && lines[0].quantity!==1)return {error:fail(400,'quantity','A tip is one chosen amount; choose it on Stripe.')};
|
|
537
537
|
if(menu && menu.items.some(e=>{const i=shop.items.find(i=>i.id===e.item);return !i || i.game && i.game!==game;}))return {error:fail(409,'menu','This menu includes items from another app; sync separate menus for each app.')};
|
|
@@ -649,6 +649,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
649
649
|
const item = shop.items.find((i) => i.id === entry?.item);
|
|
650
650
|
if (!item) return fail(404, 'item', 'no such item here');
|
|
651
651
|
const quantity = entry.quantity ?? 1;
|
|
652
|
+
if (item.maxQuantity!==undefined&&quantity>item.maxQuantity)return fail(400,'quantity','This quantity exceeds the studio item maximum.');
|
|
652
653
|
if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
|
|
653
654
|
if(!hasStripeKey(env) && ['supporter','deposit'].includes(item.kind) && quantity!==1)return fail(400,'quantity','This payment link has quantity fixed at one.');
|
|
654
655
|
if(item.kind==='deposit' && (!reference || !Number.isSafeInteger(entry.amount??item.price) || (entry.amount??item.price)<=0))return fail(400,'deposit','A deposit requires the app reference and its saved positive amount.');
|
|
@@ -716,6 +717,7 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
|
|
|
716
717
|
const item = shop.items.find((i) => i.id === entry?.item);
|
|
717
718
|
if (!item) return fail(404, 'item', 'no such item here');
|
|
718
719
|
const quantity = entry.quantity ?? 1;
|
|
720
|
+
if (item.maxQuantity!==undefined&&quantity>item.maxQuantity)return fail(400,'quantity','This quantity exceeds the studio item maximum.');
|
|
719
721
|
if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
|
|
720
722
|
if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
|
|
721
723
|
seen.add(item.id);
|
|
@@ -737,35 +739,35 @@ async function parentRoute(request, env, url, cat, shop, ready, token, ctx) {
|
|
|
737
739
|
const cart = row ? row.cart ? JSON.parse(row.cart) : [{ item: row.item, quantity: 1 }] : [];
|
|
738
740
|
const lines = cart.map((entry) => ({ item: shop.items.find((i) => i.id === entry.item), quantity: entry.quantity }));
|
|
739
741
|
const item = lines[0]?.item;
|
|
740
|
-
if (lines.some((line) => !line.item)) return parentPage(cat, shop, { gone: true });
|
|
741
|
-
if (!row || !item || Number(row.expires_at) < Date.now()) return parentPage(cat, shop, { gone: true });
|
|
742
|
+
if (lines.some((line) => !line.item)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
|
|
743
|
+
if (!row || !item || Number(row.expires_at) < Date.now()) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
|
|
742
744
|
const kid = await players.get(env, row.player);
|
|
743
|
-
if (!kid) return parentPage(cat, shop, { gone: true });
|
|
745
|
+
if (!kid) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
|
|
744
746
|
const done = row.order_id ? await orderById(env, row.order_id) : null;
|
|
745
|
-
if (done?.status === 'paid') return parentPage(cat, shop, { item, lines, name: kid.name, paid: true });
|
|
746
|
-
if (request.method === 'GET' || request.method === 'HEAD') return parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready });
|
|
747
|
+
if (done?.status === 'paid') return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, paid: true });
|
|
748
|
+
if (request.method === 'GET' || request.method === 'HEAD') return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready });
|
|
747
749
|
if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403 });
|
|
748
750
|
let form = null;
|
|
749
751
|
try { form = await request.formData(); } catch { form = null; }
|
|
750
|
-
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.' });
|
|
751
|
-
if (!ready.ready) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: false });
|
|
752
|
-
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.' });
|
|
752
|
+
if (String(form?.get('grownup') ?? '') !== 'yes') return (await import('./shop-page.mjs')).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.' });
|
|
753
|
+
if (!ready.ready) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: false });
|
|
754
|
+
if (purchaseLimited(request, shop, row.player, 'parentpay')) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: 'One moment: too many tries.' });
|
|
753
755
|
const band = await bandOfPlayer(env, row.player);
|
|
754
756
|
const owns = await ownsOf(env, row.player);
|
|
755
757
|
let amount = 0;
|
|
756
758
|
for (const [index, line] of lines.entries()) {
|
|
757
759
|
const it = line.item;
|
|
758
760
|
const owned = it.gives.length > 0 && it.gives.every((key) => owns.some((o) => o.key === key && o.item === it.id));
|
|
759
|
-
if (wayFor(it, { player: kid, band, owned, spent: 0, cap: null, policy: shop.policy }) !== 'ask-a-parent') return parentPage(cat, shop, { gone: true });
|
|
761
|
+
if (wayFor(it, { player: kid, band, owned, spent: 0, cap: null, policy: shop.policy }) !== 'ask-a-parent') return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
|
|
760
762
|
line.amount = it.price === 'choose' ? parseAmount(form?.get(lines.length === 1 ? 'amount' : `amount_${index}`), currencyScale(shop.currency)) : it.price;
|
|
761
763
|
const invalid = amountError(line.amount, shop.currency);
|
|
762
|
-
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.' });
|
|
764
|
+
if (invalid || it.price === 'choose' && (line.amount < it.min || it.max !== null && line.amount > it.max)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, said: invalid ?? 'This amount is outside the studio range.' });
|
|
763
765
|
const total = BigInt(amount) + BigInt(line.amount) * BigInt(line.quantity);
|
|
764
|
-
if (total > BigInt(Number.MAX_SAFE_INTEGER)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
|
|
766
|
+
if (total > BigInt(Number.MAX_SAFE_INTEGER)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
|
|
765
767
|
amount = Number(total);
|
|
766
768
|
}
|
|
767
769
|
const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game, amount, parent: true, lines });
|
|
768
|
-
if (r.error) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
|
|
770
|
+
if (r.error) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
|
|
769
771
|
await env.DB.prepare('UPDATE shop_parent_links SET order_id = ?2 WHERE hash = ?1').bind(await sha256Hex(token), r.order.id).run();
|
|
770
772
|
if (!r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, row.player).catch((error) => logOrderError(env, r.order.id, error)));
|
|
771
773
|
return Response.redirect(r.url, 303);
|
|
@@ -835,7 +837,13 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
835
837
|
if (payload === null) return fail(413, 'too-large', 'The studio webhook byte limit was reached.');
|
|
836
838
|
const v = await verifyWebhook(payload, request.headers.get('stripe-signature'), env.STRIPE_WEBHOOK_SECRET);
|
|
837
839
|
if (!v.ok) return fail(v.why === 'no-secret' ? 503 : 400, v.why, 'not a webhook from this studio\'s Stripe');
|
|
838
|
-
|
|
840
|
+
if(env.BACKGROUND)return (await import('./stripe-intake.mjs')).acceptStripeEvent(env,v.event,new URL(request.url).origin);
|
|
841
|
+
return reconcileShopEvent(request,env,shop,v.event);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Internal verified-event seam shared by direct legacy intake and the queue consumer. */
|
|
845
|
+
export async function reconcileShopEvent(request,env,shop,ev) {
|
|
846
|
+
shop??=checkShop({}).shop;
|
|
839
847
|
const obj = ev.data?.object ?? {};
|
|
840
848
|
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'];
|
|
841
849
|
if (selling.enabled) {
|
|
@@ -877,7 +885,7 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
877
885
|
if (subscriptionResult !== null) return json({ ok: true, did: subscriptionResult });
|
|
878
886
|
const sessionEvent = ev.type.startsWith('checkout.session.');
|
|
879
887
|
if (!sessionEvent) await rememberMoneyEvent(env, ev);
|
|
880
|
-
if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({
|
|
888
|
+
if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({ok:true,did:await reviewLinkPayment(env,obj,ev.livemode?'live':'test')?'review':'mismatch'});
|
|
881
889
|
if (sessionEvent && obj.metadata?.order && !obj.payment_link) {
|
|
882
890
|
await env.DB.prepare(`UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL AND mode=?3
|
|
883
891
|
AND EXISTS(SELECT 1 FROM meta WHERE key='booking-checkout:' || shop_orders.id)`)
|
|
@@ -950,20 +958,63 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
950
958
|
default:
|
|
951
959
|
break;
|
|
952
960
|
}
|
|
961
|
+
if(did==='mismatch'&&obj.payment_link&&await reviewLinkPayment(env,obj,ev.livemode?'live':'test'))did='review';
|
|
953
962
|
if (did === 'needs-keyed-recovery') return fail(503, did, 'This earlier multi-item checkout needs its original connection to recover tax per line. Reconnect it, then resend this event.');
|
|
954
963
|
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.');
|
|
955
964
|
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();
|
|
956
|
-
if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
|
|
957
965
|
return json({ ok: true, did });
|
|
958
966
|
} catch (error) {
|
|
967
|
+
if(/(?:booking|resource) capacity unavailable/.test(error.message)&&obj.payment_link&&await reviewLinkPayment(env,obj,ev.livemode?'live':'test','The paid group exceeds remaining capacity. Staff will arrange a replacement or refund.'))return json({ok:true,did:'review'});
|
|
959
968
|
logOrderError(env, null, error);
|
|
960
969
|
return fail(503, 'stripe-retry', 'Payment reconciliation is temporarily unavailable; retry this event.');
|
|
961
970
|
}
|
|
962
971
|
}
|
|
963
972
|
|
|
973
|
+
/** Retain a verified payment even when its frozen order cannot be fulfilled. No grants. */
|
|
974
|
+
async function reviewLinkPayment(env,session,mode,reason='The paid amount differs from the order. Staff will reconcile the payment or refund it.') {
|
|
975
|
+
if(session.payment_status!=='paid'||!/^cs_[A-Za-z0-9_]+$/.test(session.id??'')||!Number.isSafeInteger(session.amount_total)||session.amount_total<0)return false;
|
|
976
|
+
if((await orderBySession(env,session.id))?.status==='review')return true;
|
|
977
|
+
const source=await orderById(env,session.client_reference_id);
|
|
978
|
+
const lines=source?await orderLines(env,source.id):[],link=lines[0]?.snapshot&&JSON.parse(lines[0].snapshot).paymentLink;
|
|
979
|
+
const linkId=session.payment_link?.id??session.payment_link,config=linkConfig(env);
|
|
980
|
+
const owned=[...(config?.items??[]),...(config?.menus??[]),...(config?.slots??[])].some(l=>l.id===linkId) || Boolean(await env.DB.prepare("SELECT 1 FROM shop_order_lines WHERE json_extract(snapshot,'$.paymentLink.id')=?1 LIMIT 1").bind(linkId).first());
|
|
981
|
+
if(!owned)return false;
|
|
982
|
+
if(!source||source.mode!==mode||session.currency!==source.currency||!link||link.id!==linkId||session.metadata?.revision!==link.revision){
|
|
983
|
+
const now=Date.now(),id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`,payment=typeof session.payment_intent==='string'?session.payment_intent:session.payment_intent?.id;
|
|
984
|
+
const note='Payment received; missing or mismatched order context. Staff must reconcile or refund it.';
|
|
985
|
+
await env.DB.batch([
|
|
986
|
+
env.DB.prepare("INSERT OR IGNORE INTO shop_orders(id,item,amount,total,currency,till,mode,status,session,payment,created_at,updated_at,attention,note) VALUES(?1,'payment-review',?2,?2,?3,'stripe',?4,'review',?5,?6,?7,?7,1,?8)").bind(id,session.amount_total,session.currency,mode,session.id,payment??null,now,note),
|
|
987
|
+
env.DB.prepare('INSERT OR IGNORE INTO meta(key,value) VALUES(?1,?2)').bind('payment-review:'+session.id,JSON.stringify({session:session.id,receivedAt:now,payment,amount:session.amount_total,currency:session.currency,reference:session.client_reference_id,link:linkId,reason:note})),
|
|
988
|
+
env.DB.prepare("INSERT OR IGNORE INTO studio_events(id,type,data,at) VALUES(?1,'payment.review',?2,?3)").bind('payment-review:'+session.id,JSON.stringify({order:id,payment,amount:session.amount_total,currency:session.currency,note}),now),
|
|
989
|
+
]);
|
|
990
|
+
return true;
|
|
991
|
+
}
|
|
992
|
+
await env.DB.prepare('UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL').bind(source.id,session.id).run();
|
|
993
|
+
let order=await orderBySession(env,session.id);
|
|
994
|
+
if(!order) {
|
|
995
|
+
const id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`;
|
|
996
|
+
await env.DB.batch([
|
|
997
|
+
env.DB.prepare("INSERT OR IGNORE INTO shop_orders(id,player,checkout_player,item,game,amount,currency,till,mode,status,parent,created_at,updated_at,session) SELECT ?2,player,checkout_player,item,game,amount,currency,till,mode,'started',parent,?4,?4,?3 FROM shop_orders WHERE id=?1").bind(source.id,id,session.id,Date.now()),
|
|
998
|
+
env.DB.prepare('INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2').bind(`shop-reference:${id}`,`shop-reference:${source.id}`),
|
|
999
|
+
...lines.map((l,i)=>env.DB.prepare('INSERT OR IGNORE INTO shop_order_lines(id,order_id,position,item,quantity,unit_amount,amount,snapshot) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)').bind(`${id}_${i}`,id,i,l.item,l.quantity,l.unit_amount,l.amount,l.snapshot)),
|
|
1000
|
+
]);order=await orderBySession(env,session.id);
|
|
1001
|
+
}
|
|
1002
|
+
if(order.status==='review')return true;
|
|
1003
|
+
if(order.paid_at!==null)return false;
|
|
1004
|
+
const payment=typeof session.payment_intent==='string'?session.payment_intent:session.payment_intent?.id;
|
|
1005
|
+
const note='Payment received. '+reason,now=Date.now();
|
|
1006
|
+
await env.DB.batch([
|
|
1007
|
+
env.DB.prepare("UPDATE shop_orders SET status='review',payment=?2,total=?3,updated_at=?4,attention=1,note=?5 WHERE id=?1 AND paid_at IS NULL").bind(order.id,payment??null,session.amount_total,now,note),
|
|
1008
|
+
env.DB.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES(?1,?2)").bind('payment-review:'+session.id,JSON.stringify({session:session.id,receivedAt:now,payment,amount:session.amount_total,currency:session.currency,expected:source.amount,reason})),
|
|
1009
|
+
env.DB.prepare("INSERT OR IGNORE INTO studio_events(id,type,data,at) VALUES(?1,'payment.review',?2,?3)").bind('payment-review:'+session.id,JSON.stringify({order:order.id,payment,amount:session.amount_total,currency:session.currency,note}),now),
|
|
1010
|
+
]);
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
964
1014
|
/** Payment Links can be reopened. Each signed Session becomes its own order; the URL reference
|
|
965
1015
|
* selects a frozen local intent, never an entitlement or a buyer-supplied price. */
|
|
966
1016
|
async function bindLinkSession(env, session, mode) {
|
|
1017
|
+
if(session.id&&(await orderBySession(env,session.id))?.status==='review')return false;
|
|
967
1018
|
if (!/^ord_[A-Za-z0-9]{20}$/.test(session.client_reference_id ?? '') || !/^cs_[A-Za-z0-9_]+$/.test(session.id ?? '')) return false;
|
|
968
1019
|
const source = await orderById(env, session.client_reference_id);
|
|
969
1020
|
if (!source || source.mode !== mode) return false;
|
|
@@ -982,7 +1033,7 @@ async function bindLinkSession(env, session, mode) {
|
|
|
982
1033
|
if(item.kind==='deposit' && amount!==source.amount)return false;
|
|
983
1034
|
const quantity = item.price === 'choose' ? 1 : amount / item.price;
|
|
984
1035
|
if(['supporter','deposit'].includes(item.kind) && quantity!==1)return false;
|
|
985
|
-
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > 999999) return false;
|
|
1036
|
+
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > (item.maxQuantity??link.maxQuantity??999999)) return false;
|
|
986
1037
|
if (item.price === 'choose' && (amount < item.min || item.max !== null && amount > item.max)) return false;
|
|
987
1038
|
// Claim the first session atomically. A repeated URL creates a separate paid order, never loses a payment.
|
|
988
1039
|
await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1 AND session IS NULL').bind(source.id, session.id).run();
|
|
@@ -994,13 +1045,14 @@ async function bindLinkSession(env, session, mode) {
|
|
|
994
1045
|
env.DB.prepare("INSERT INTO shop_orders (id, player, checkout_player, item, game, amount, currency, till, mode, status, parent, created_at, updated_at, session) SELECT ?2, player, checkout_player, item, game, ?4, currency, till, mode, 'started', parent, ?5, ?5, ?3 FROM shop_orders WHERE id = ?1 ON CONFLICT DO NOTHING").bind(source.id, id, session.id, amount, Date.now()),
|
|
995
1046
|
env.DB.prepare("INSERT OR IGNORE INTO meta(key,value) SELECT ?1,CASE WHEN value=?3 THEN ?4 ELSE value END FROM meta WHERE key=?2").bind(`shop-reference:${id}`, `shop-reference:${source.id}`,source.id,id),
|
|
996
1047
|
env.DB.prepare('INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) VALUES (?1, ?2, 0, ?3, ?4, ?5, ?6, ?7) ON CONFLICT DO NOTHING').bind(`${id}_0`, id, item.id, quantity, amount / quantity, amount, lines[0].snapshot),
|
|
997
|
-
...(booking ? [bookingStatement(env,{...booking,slot:link.slot,slots:link.slots,id,order:id,pending:true})] : []),
|
|
1048
|
+
...(booking ? [bookingStatement(env,{...booking,quantity,slot:link.slot,slots:link.slots,id,order:id,pending:true})] : []),
|
|
998
1049
|
]);
|
|
999
1050
|
order = await orderBySession(env, session.id);
|
|
1000
1051
|
|
|
1001
1052
|
}
|
|
1002
1053
|
if (!order) return false;
|
|
1003
1054
|
if (order.paid_at === null) await env.DB.batch([
|
|
1055
|
+
...(link.slot?[env.DB.prepare("UPDATE shop_bookings SET quantity=?2 WHERE order_id=?1 AND state='pending'").bind(order.id,quantity)]:[]),
|
|
1004
1056
|
env.DB.prepare('UPDATE shop_orders SET amount = ?2 WHERE id = ?1 AND paid_at IS NULL').bind(order.id, amount),
|
|
1005
1057
|
env.DB.prepare('UPDATE shop_order_lines SET quantity = ?2, unit_amount = ?3, amount = ?4 WHERE order_id = ?1 AND EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND paid_at IS NULL)').bind(order.id, quantity, amount / quantity, amount),
|
|
1006
1058
|
]);
|
|
@@ -1033,7 +1085,7 @@ async function paidBasket(env,shop,o,lines,session,paidAt) {
|
|
|
1033
1085
|
if(session.payment_status!=='paid' || session.currency!==o.currency || !Number.isSafeInteger(total) || total<=0 || session.amount_subtotal!==total)return 'mismatch';
|
|
1034
1086
|
if(o.paid_at!==null)return 'already';
|
|
1035
1087
|
const expected=lines.reduce((n,l)=>n+Number(l.amount),0);
|
|
1036
|
-
if(!Number.isSafeInteger(expected)||expected<=0)return 'mismatch';
|
|
1088
|
+
if(!Number.isSafeInteger(expected)||expected<=0||total!==expected)return 'mismatch';
|
|
1037
1089
|
const writes=[];let cumulative=0,allocated=0;
|
|
1038
1090
|
for(const l of lines) {
|
|
1039
1091
|
cumulative+=Number(l.amount);
|
|
@@ -1065,6 +1117,7 @@ async function bindMenuSession(env,session,source,lines) {
|
|
|
1065
1117
|
for(const l of invoice.lines?.data??[]) {
|
|
1066
1118
|
const line=lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price));
|
|
1067
1119
|
if(!line || !Number.isSafeInteger(l.quantity) || l.quantity<1 || l.amount!==JSON.parse(line.snapshot).price*l.quantity)throw new Error('Unknown invoice line');
|
|
1120
|
+
if(l.quantity>(JSON.parse(line.snapshot).maxQuantity??link.items?.find(i=>i.item===line.item)?.maxQuantity??999999))return false;
|
|
1068
1121
|
paidLines.push({...line,quantity:l.quantity,amount:l.amount});
|
|
1069
1122
|
}
|
|
1070
1123
|
if(!paidLines.length || paidLines.reduce((n,l)=>n+l.amount,0)!==session.amount_subtotal)throw new Error('Invoice subtotal mismatch');
|
|
@@ -1089,6 +1142,7 @@ async function bindMenuSession(env,session,source,lines) {
|
|
|
1089
1142
|
export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
1090
1143
|
const o = await orderBySession(env, session.id);
|
|
1091
1144
|
if (!o) return 'unknown-order';
|
|
1145
|
+
if(o.status==='review')return 'review';
|
|
1092
1146
|
// The session must be the one this order opened, for this player and this price.
|
|
1093
1147
|
const savedLines=await orderLines(env,o.id);
|
|
1094
1148
|
if(savedLines[0]?.snapshot && JSON.parse(savedLines[0].snapshot).paymentLink?.basket)return paidBasket(env,shop,o,savedLines,session,paidAt);
|
|
@@ -1194,6 +1248,11 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
|
|
|
1194
1248
|
}
|
|
1195
1249
|
const gross = Number(o.total ?? o.amount);
|
|
1196
1250
|
if (!Number.isSafeInteger(amount) || amount > gross) throw new Error('Stripe refund total exceeds payment');
|
|
1251
|
+
if(o.status==='review') {
|
|
1252
|
+
// Review never grants anything, even when a later refund is reversed.
|
|
1253
|
+
await env.DB.prepare("UPDATE shop_orders SET refunded_amount=?2,refunded_net=?2,updated_at=?3,note=?4 WHERE id=?1 AND status='review'").bind(o.id,amount,Date.now(),`Payment received; staff review required. Refunded ${amount} of ${gross} ${o.currency} minor units.`).run();
|
|
1254
|
+
return 'review';
|
|
1255
|
+
}
|
|
1197
1256
|
const moneyFull = amount === gross && gross > 0;
|
|
1198
1257
|
const whole = moneyFull && [...refunds.values()].some((r) => r.status === 'succeeded' && !r.metadata?.line);
|
|
1199
1258
|
const taggedGross = [...totals.values()].reduce((n, value) => n + value, 0);
|
|
@@ -1232,6 +1291,7 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
|
|
|
1232
1291
|
async function disputed(env, payment, disputeId) {
|
|
1233
1292
|
const o = payment ? await orderByPayment(env, payment) : null;
|
|
1234
1293
|
if (!o) return 'unknown-order';
|
|
1294
|
+
if(o.status==='review'){await env.DB.prepare("UPDATE shop_orders SET dispute=?2,note='Payment received; disputed payment requires staff review.',updated_at=?3 WHERE id=?1").bind(o.id,disputeId,Date.now()).run();return 'review';}
|
|
1235
1295
|
await env.DB.prepare("UPDATE shop_orders SET status = 'disputed', dispute = ?2, updated_at = ?3 WHERE id = ?1 AND status = 'paid'").bind(o.id, disputeId, Date.now()).run();
|
|
1236
1296
|
return 'disputed';
|
|
1237
1297
|
}
|
|
@@ -1240,6 +1300,7 @@ async function disputed(env, payment, disputeId) {
|
|
|
1240
1300
|
async function disputeClosed(env, payment, status) {
|
|
1241
1301
|
const o = payment ? await orderByPayment(env, payment) : null;
|
|
1242
1302
|
if (!o) return 'unknown-order';
|
|
1303
|
+
if(o.status==='review'){await env.DB.prepare("UPDATE shop_orders SET note=?2,updated_at=?3 WHERE id=?1").bind(o.id,`Payment received; dispute ${status}. Staff review required.`,Date.now()).run();return 'review';}
|
|
1243
1304
|
const now = Date.now();
|
|
1244
1305
|
if (!['paid', 'disputed'].includes(o.status)) return 'already';
|
|
1245
1306
|
if (status === 'lost') {
|
|
@@ -1388,6 +1449,7 @@ export async function shopOffice(env, cat, origin, itemCursor = 0, orderCursor =
|
|
|
1388
1449
|
},
|
|
1389
1450
|
keyless: !hasStripeKey(env),
|
|
1390
1451
|
hook: `${origin}/api/shop/hook`,
|
|
1452
|
+
...(env.BACKGROUND ? {delivery:await (await import('./stripe-intake.mjs')).stripeIntakeStatus(env)} : {}),
|
|
1391
1453
|
totals: null, orders: [],
|
|
1392
1454
|
};
|
|
1393
1455
|
if (!(await migrated(env, { complete: false }))) return out;
|
package/worker/stats.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {writePageMetric,analyticsStatus} from './analytics.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* STUDIO METRICS: count, don't track (@homie-rocks/studio 0.6.0).
|
|
3
4
|
*
|
|
@@ -5,7 +6,7 @@
|
|
|
5
6
|
* party, no cookie is set on a visitor, and no visitor is identified: a count is a number on a day, never a row
|
|
6
7
|
* about a person. Referrers are kept as a host name only (never a path, a query or an address).
|
|
7
8
|
*
|
|
8
|
-
* WHAT IS COUNTED
|
|
9
|
+
* WHAT IS COUNTED (page metrics go to Analytics Engine; daily rollups preserve D1 history):
|
|
9
10
|
* visit a page opened (home, a game's page, /music/, a song's page, /videos/, a video's page)
|
|
10
11
|
* play a game's play page opened: somebody pressed Play (or followed a Play link or QR code)
|
|
11
12
|
* screen a big screen opened (/<game>/tv)
|
|
@@ -36,9 +37,8 @@
|
|
|
36
37
|
* (HttpOnly, SameSite=Lax; from 0.13.0 at / so the owner is recognised in their own games, never readable by a
|
|
37
38
|
* page or a game). There is no other way in.
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* humans counters). Past the day's writes, counting stops until 00:00 UTC and the site keeps working.
|
|
40
|
+
* Page totals refresh hourly and can be sampled at high traffic. Older wrappers without the analytics
|
|
41
|
+
* binding retain D1 counting. Game, payment and budget facts remain in durable storage.
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
44
|
export const STATS_MIGRATION_FILE = '0002_studio_stats.sql';
|
|
@@ -97,9 +97,10 @@ export function counter(env, { metric, subject = '', source = '', n = 1, day = t
|
|
|
97
97
|
|
|
98
98
|
/** Run a counter write after the response (ctx.waitUntil), or before it when the caller gave no ctx. Never throws. */
|
|
99
99
|
export async function count(env, ctx, row) {
|
|
100
|
+
if (writePageMetric(env, row)) return;
|
|
100
101
|
const stmt = counter(env, row);
|
|
101
102
|
if (!stmt) return;
|
|
102
|
-
const p = stmt.run().catch(
|
|
103
|
+
const p = stmt.run().catch(error => console.warn("Studio counter dropped:",error.message)); // before migration 0002, or past the free plan's daily writes: not counted
|
|
103
104
|
if (ctx?.waitUntil) ctx.waitUntil(p); else await p;
|
|
104
105
|
}
|
|
105
106
|
|
|
@@ -339,7 +340,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
|
|
|
339
340
|
// The studio's own pages: its home, and the /music/ and /videos/ lists (a catalogue's road).
|
|
340
341
|
pages: Object.fromEntries(['home', 'games', 'rooms', 'posts', 'music', 'videos'].map((p) => [p, sum((r) => r.metric === 'visit' && r.subject === p)])),
|
|
341
342
|
games: perGame, songs: media(songs, 'song', 'music'), videos: media(videos, 'video', 'videos'), referrers, days,
|
|
342
|
-
note: 'Counts, never people: a visit is a page a browser opened, a play is a Play press, a round is a finished round. Prefetches, crawlers and house QA are not counted. Times are UTC days.',
|
|
343
|
+
note: 'Counts, never people: a visit is a page a browser opened, a play is a Play press, a round is a finished round. Prefetches, crawlers and house QA are not counted. Times are UTC days.' + (env.STUDIO_ANALYTICS && env.STUDIO_ANALYTICS_DATASET ? ' Page visits, plays, screens and watches show the last hourly rollup; recent activity appears after ingestion and the next rollup. These counters may be sampled at high traffic.' + await analyticsStatus(env).then(s=>s.delayed ? ' Counts are delayed: '+(s.error??'awaiting a successful rollup')+'.' : '') : ''),
|
|
343
344
|
};
|
|
344
345
|
}
|
|
345
346
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Streams use independent namespaces so subscribers never warm matchmaking state. */
|
|
2
|
+
export class AppRecordHub {
|
|
3
|
+
constructor(ctx, env) { this.ctx=ctx; this.env=env; }
|
|
4
|
+
async fetch(request) {
|
|
5
|
+
const {AppRecordStreams}=await import('./app-streams.mjs');
|
|
6
|
+
this.streams??=new AppRecordStreams(this.env,this.ctx);
|
|
7
|
+
const input=await request.json();
|
|
8
|
+
return input.op==='open'?this.streams.open(input):this.streams.wake(input.meta);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class EventHub {
|
|
12
|
+
constructor(ctx, env) { this.ctx=ctx; this.env=env; }
|
|
13
|
+
async fetch(request) {
|
|
14
|
+
const {EventStreams}=await import('./event-resources.mjs');
|
|
15
|
+
this.streams??=new EventStreams(this.env,this.ctx);
|
|
16
|
+
return this.streams.fetch(request);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {RECONCILE_GUARD,guardedDatabase} from './reconcile-guard.mjs';
|
|
2
|
+
/** Called only after raw-body signature verification. D1 owns the work; queues
|
|
3
|
+
* are wakeups and may be unavailable or exhaust their daily allowance. */
|
|
4
|
+
export async function acceptStripeEvent(env,event,origin) {
|
|
5
|
+
await env.DB.prepare('INSERT INTO stripe_intake(id,payload,origin,received) VALUES(?1,?2,?3,?4) ON CONFLICT(id) DO NOTHING')
|
|
6
|
+
.bind(event.id,JSON.stringify(event),origin,Date.now()).run();
|
|
7
|
+
try { await env.BACKGROUND.send({v:1,type:'stripe.event',id:event.id}); }
|
|
8
|
+
catch(error) { console.warn('Stripe delivery deferred to the scheduled D1 sweep:',error.message); }
|
|
9
|
+
return Response.json({ok:true,queued:true});
|
|
10
|
+
}
|
|
11
|
+
async function reconcile(env,row,event,cat) {
|
|
12
|
+
const {reconcileShopEvent,shopOf}=await import('./shop.mjs');
|
|
13
|
+
return reconcileShopEvent(new Request(new URL('/api/shop/hook',row.origin)),env,shopOf(cat),event);
|
|
14
|
+
}
|
|
15
|
+
export async function reconcileStripeEvent(env,id,cat,{leaseMs=300000,reconcile:deliver=reconcile}={}) {
|
|
16
|
+
const now=Date.now();let lease=now+leaseMs;
|
|
17
|
+
const row=await env.DB.prepare('UPDATE stripe_intake SET lease=?2 WHERE id=?1 AND done IS NULL AND lease<?3 RETURNING *').bind(id,lease,now).first();
|
|
18
|
+
if(!row) {
|
|
19
|
+
const pending=await env.DB.prepare('SELECT done,lease FROM stripe_intake WHERE id=?1').bind(id).first();
|
|
20
|
+
if(pending?.done)return;
|
|
21
|
+
const error=new Error('Stripe event is missing or already being reconciled');
|
|
22
|
+
if(pending?.lease>=now)error.retryAfterSeconds=Math.max(1,Math.min(301,Math.ceil((pending.lease-now)/1000)+1));
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
let renewal=Promise.resolve(),lost=null;
|
|
26
|
+
const timer=setInterval(()=>{
|
|
27
|
+
renewal=renewal.then(async()=>{
|
|
28
|
+
if(lost)return;
|
|
29
|
+
const next=Date.now()+leaseMs;
|
|
30
|
+
const renewed=await env.DB.prepare('UPDATE stripe_intake SET lease=?3 WHERE id=?1 AND lease=?2 AND done IS NULL RETURNING id').bind(id,lease,next).first();
|
|
31
|
+
if(!renewed)throw new Error('Stripe reconciliation lease was lost');
|
|
32
|
+
lease=next;
|
|
33
|
+
}).catch(error=>{lost=error;});
|
|
34
|
+
},Math.max(1,Math.floor(leaseMs/3)));
|
|
35
|
+
const check=async()=>{
|
|
36
|
+
await renewal;
|
|
37
|
+
if(lost)throw lost;
|
|
38
|
+
if(Date.now()>=lease)throw new Error('Stripe reconciliation lease expired');
|
|
39
|
+
};
|
|
40
|
+
const guarded={...env,DB:guardedDatabase(env.DB,check),[RECONCILE_GUARD]:check};
|
|
41
|
+
try {
|
|
42
|
+
await check();
|
|
43
|
+
const event=JSON.parse(row.payload);
|
|
44
|
+
if(event.id!==id)throw new Error('Stored Stripe event id differs');
|
|
45
|
+
const response=await deliver(guarded,row,event,cat);
|
|
46
|
+
if(!response.ok)throw new Error('Stripe reconciliation needs retry ('+response.status+')');
|
|
47
|
+
clearInterval(timer);await renewal;
|
|
48
|
+
if(lost)throw lost;
|
|
49
|
+
const done=await env.DB.prepare('UPDATE stripe_intake SET done=?2,lease=0 WHERE id=?1 AND lease=?3 AND lease>?2 RETURNING id').bind(id,Date.now(),lease).first();
|
|
50
|
+
if(!done)throw new Error('Stripe reconciliation lease lost before completion');
|
|
51
|
+
} catch(error) {
|
|
52
|
+
clearInterval(timer);await renewal;
|
|
53
|
+
await env.DB.prepare('UPDATE stripe_intake SET lease=?3 WHERE id=?1 AND lease=?2').bind(id,lease,Date.now()+60000).run();
|
|
54
|
+
throw error;
|
|
55
|
+
} finally {clearInterval(timer);}
|
|
56
|
+
}
|
|
57
|
+
export async function stripeIntakeStatus(env) {
|
|
58
|
+
if(!env.DB)return {pending:0,stuck:0};
|
|
59
|
+
const row=await env.DB.prepare('SELECT COUNT(*) AS pending,COALESCE(SUM(received<?1),0) AS stuck,MIN(received) AS oldest FROM stripe_intake WHERE done IS NULL').bind(Date.now()-3600000).first();
|
|
60
|
+
return {...row,message:row?.stuck?'Stripe deliveries are delayed. Check Worker logs and Stripe key permissions; scheduled recovery retries unfinished events.':null};
|
|
61
|
+
}
|
|
62
|
+
export async function sweepStripeEvents(env,cat) {
|
|
63
|
+
if(!env.DB)return;
|
|
64
|
+
const rows=(await env.DB.prepare('SELECT id FROM stripe_intake WHERE done IS NULL AND lease<?1 ORDER BY lease,received,id LIMIT 100').bind(Date.now()).all()).results??[];
|
|
65
|
+
for(const row of rows) {
|
|
66
|
+
try {await reconcileStripeEvent(env,row.id,cat);}
|
|
67
|
+
catch(error) {console.error('Stripe intake recovery failed',row.id,error.message);}
|
|
68
|
+
}
|
|
69
|
+
const status=await stripeIntakeStatus(env);
|
|
70
|
+
if(status.stuck)console.error('Stripe intake needs attention',status);
|
|
71
|
+
return status;
|
|
72
|
+
}
|
|
73
|
+
export async function pruneStripeEvents(env) {
|
|
74
|
+
const cutoff=Date.now()-60*86400000;
|
|
75
|
+
await env.DB.batch([
|
|
76
|
+
...(env.BACKGROUND?[env.DB.prepare('DELETE FROM stripe_intake WHERE id IN (SELECT id FROM stripe_intake WHERE done<?1 ORDER BY done LIMIT 1000)').bind(cutoff)]:[]),
|
|
77
|
+
env.DB.prepare('DELETE FROM shop_events WHERE id IN (SELECT id FROM shop_events WHERE at<?1 ORDER BY at LIMIT 1000)').bind(cutoff),
|
|
78
|
+
]);
|
|
79
|
+
}
|
package/worker/stripe.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {RECONCILE_GUARD} from './reconcile-guard.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* THE STUDIO'S OWN STRIPE, from its Worker (@homie-rocks/studio 0.24.0). No SDK: a few form-encoded calls with the
|
|
3
4
|
* studio's key (a Worker secret), and Stripe's documented webhook signature check with WebCrypto.
|
|
@@ -95,6 +96,7 @@ export async function productIdOf(slug, item) {
|
|
|
95
96
|
|
|
96
97
|
/** One call with the studio's key. `idempotencyKey` makes a retried POST do the work once (Stripe keeps it 24 h). */
|
|
97
98
|
export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch, version = STRIPE_VERSION, timeout = 15_000 } = {}) {
|
|
99
|
+
await env?.[RECONCILE_GUARD]?.();
|
|
98
100
|
const key = String(env?.STRIPE_KEY ?? '');
|
|
99
101
|
if (!KEY_SHAPE.test(key)) throw new StripeError(0, { error: { message: 'no Stripe key on this Worker', code: 'no-key' } });
|
|
100
102
|
const body = params && method !== 'GET' ? formEncode(params).toString() : null;
|
|
@@ -119,6 +121,7 @@ export async function stripeCall(env, method, path, params = null, { idempotency
|
|
|
119
121
|
error.idempotentReplayed = res.headers.get('idempotent-replayed') === 'true';
|
|
120
122
|
throw error;
|
|
121
123
|
}
|
|
124
|
+
await env?.[RECONCILE_GUARD]?.();
|
|
122
125
|
return json;
|
|
123
126
|
}
|
|
124
127
|
|