@homie-rocks/studio 0.50.6 → 0.51.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 +62 -0
- package/app-starters/giving/README.md +25 -2
- package/app-starters/giving/app.json +16 -49
- package/app-starters/giving/index.html +1 -1
- package/app-starters/giving/src/rules.ts +1 -1
- package/app-starters/giving/src/view.ts +57 -59
- package/apps/APPS.md +70 -2
- package/apps/apps.ts +1 -1
- package/bin/homie-studio.mjs +8 -4
- package/functions/FUNCTIONS.md +39 -2
- package/guides/app/SKILL.md +61 -5
- package/guides/functions/SKILL.md +30 -0
- package/guides/office/SKILL.md +20 -0
- package/guides/servers/SKILL.md +18 -0
- package/guides/shop/SKILL.md +41 -20
- package/lib/app-needs.mjs +31 -11
- package/lib/app-ready.mjs +49 -0
- package/lib/check.mjs +51 -35
- package/lib/dev-stripe-fixture.mjs +37 -0
- package/lib/dev-stripe-sandbox.mjs +80 -0
- package/lib/dev.mjs +19 -17
- package/lib/events-cli.mjs +5 -1
- package/lib/local-test.mjs +18 -0
- package/lib/mcp-tools.mjs +15 -1
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +22 -1
- package/lib/shop-links.mjs +79 -7
- package/lib/shop.mjs +4 -2
- package/lib/stripe-connect.mjs +1 -0
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +3 -0
- package/package.json +1 -1
- package/shop/SELLING.md +2 -2
- package/shop/SHOP.md +120 -5
- package/shop/shop.ts +29 -0
- package/tools/tools.ts +8 -1
- package/worker/app-format.mjs +8 -1
- package/worker/app-frame.mjs +9 -0
- package/worker/app-records.mjs +39 -10
- package/worker/booking-store.mjs +79 -0
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +30 -3
- package/worker/functions.mjs +5 -2
- package/worker/index.mjs +17 -3
- package/worker/local-test.mjs +19 -0
- package/worker/mcp-tools.mjs +19 -8
- package/worker/mcp.mjs +23 -11
- package/worker/records-page.mjs +13 -0
- package/worker/shop-links.mjs +3 -2
- package/worker/shop-page.mjs +27 -5
- package/worker/shop-rules.mjs +2 -1
- package/worker/shop-store.mjs +1 -1
- package/worker/shop-subscriptions.mjs +198 -0
- package/worker/shop.mjs +195 -29
- package/worker/tool-database.mjs +36 -0
- package/worker/version.mjs +1 -1
package/worker/shop.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { localSession } from './local-test.mjs';
|
|
2
|
+
import { bindSubscription, subscriptionEvent, subscriptionPortal } from './shop-subscriptions.mjs';
|
|
3
|
+
import { bookingStatement } from './booking-store.mjs';
|
|
1
4
|
import { paymentRefunds } from './shop-refunds.mjs';
|
|
2
5
|
import { selling } from './extensions.mjs';
|
|
3
6
|
/**
|
|
@@ -41,7 +44,7 @@ import {
|
|
|
41
44
|
orderLines, lineView, bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
|
|
42
45
|
setBand, shopDataOf, spentThisMonth,
|
|
43
46
|
} from './shop-store.mjs';
|
|
44
|
-
import { redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
|
|
47
|
+
import { apiBase, STRIPE_API, redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
|
|
45
48
|
import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
|
|
46
49
|
import { notFoundPage } from './site.mjs';
|
|
47
50
|
|
|
@@ -138,11 +141,11 @@ export async function readiness(env, shop) {
|
|
|
138
141
|
else if (shop.broken) missing.push('shop.json-refused');
|
|
139
142
|
else if (shop.till === 'off') missing.push('till-off');
|
|
140
143
|
else if (!shop.items.length) missing.push('items');
|
|
141
|
-
const paidItems = shop?.items.some((i) => i.price !== 0);
|
|
144
|
+
const paidItems = shop?.items.some((i) => i.price !== 0 || i.interval);
|
|
142
145
|
const links = linkConfig(env);
|
|
143
146
|
if (paidItems && !hasStripeKey(env)) {
|
|
144
147
|
if (fullerReason(shop)) missing.push('fuller-checkout');
|
|
145
|
-
else if (!links || links.settings !== linkSettings(shop) || shop.items.some((i) => i.price !== 0 && !links.items.some((l) => l.item === i.id && paymentLinkURL(l.url)))) missing.push('payment-links');
|
|
148
|
+
else if (!links || links.settings !== linkSettings(shop) || shop.items.some((i) => (i.price !== 0 || i.interval) && !links.items.some((l) => l.item === i.id && paymentLinkURL(l.url)))) missing.push('payment-links');
|
|
146
149
|
}
|
|
147
150
|
// Reliable fulfillment requires the webhook as well as the redirect.
|
|
148
151
|
// https://docs.stripe.com/checkout/fulfillment
|
|
@@ -199,8 +202,8 @@ async function itemsView(env, shop, { game, kids, beginner, player, open, cursor
|
|
|
199
202
|
const page = item ? selected : selected.slice(cursor, cursor + 100);
|
|
200
203
|
const items = page.map((it) => {
|
|
201
204
|
const owned = it.gives.length > 0 && it.gives.every((k) => owns.some((o) => o.key === k && o.item === it.id));
|
|
202
|
-
const way = wayFor(it, { open: open || it.price === 0 && !shop.broken && shop.till !== 'off', kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
|
|
203
|
-
return { id: it.id, kind: it.kind, name: it.name, blurb: it.blurb ?? null, price: it.price, ...(it.price === 'choose' ? { min: it.min, max: it.max } : {}), shown: it.price === 'choose' ? `${money(it.min, shop.currency)} or more` : money(it.price, shop.currency), gives: it.gives, ...(it.badge ? { badge: it.badge } : {}), ...(it.days ? { days: it.days } : {}), ...(it.ends ? { ends: it.ends } : {}), ...(it.game ? { game: it.game } : {}), way, ...(way === 'cap' ? { retryWay: wayFor(it, { open, kids, beginner, player, band, owned, cap: null, policy: shop.policy }) } : {}) };
|
|
205
|
+
const way = wayFor(it, { open: open || it.price === 0 && !it.interval && !shop.broken && shop.till !== 'off', kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
|
|
206
|
+
return { id: it.id, kind: it.kind, ...(it.interval ? { interval: it.interval } : {}), 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 }) } : {}) };
|
|
204
207
|
}).filter((i) => i.way !== 'beginner' && i.way !== 'kids');
|
|
205
208
|
return { band, owns, spent, items, nextCursor: !item && cursor + 100 < selected.length ? String(cursor + 100) : null };
|
|
206
209
|
}
|
|
@@ -225,6 +228,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
225
228
|
if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
|
|
226
229
|
if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
|
|
227
230
|
// 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.
|
|
231
|
+
if(!shop && path==='/shop/' && read && !cat.site?.pages?.includes('/shop/')) return shopPage(cat,checkShop({}).shop,{origin:url.origin,open:false});
|
|
228
232
|
return shop ? notFoundPage('This studio sells nothing.', cat) : null;
|
|
229
233
|
}
|
|
230
234
|
const ready = await readiness(env, shop);
|
|
@@ -245,6 +249,21 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
245
249
|
if (path === '/api/player/owns' && read) return await ownsRoute(request, env, url, cat, shop);
|
|
246
250
|
if (path === '/api/shop/mine' && read) return await mineRoute(request, env, shop);
|
|
247
251
|
if (path === '/api/shop/order' && read) return await orderRoute(request, env, url, shop);
|
|
252
|
+
if (path === '/api/shop/subscriptions' && read) {
|
|
253
|
+
const player = await accountOf(request, env);
|
|
254
|
+
if (!player) return json({ ok: true, subscriptions: [] });
|
|
255
|
+
const subscriptions = (await env.DB.prepare(`SELECT s.id,s.state,o.item,o.currency,o.amount FROM shop_subscriptions s
|
|
256
|
+
JOIN shop_orders o ON o.id=s.order_id WHERE o.player=?1`).bind(player.id).all()).results ?? [];
|
|
257
|
+
return json({ ok: true, subscriptions });
|
|
258
|
+
}
|
|
259
|
+
if (path === '/api/shop/portal' && request.method === 'POST') {
|
|
260
|
+
const player = await accountOf(request, env);
|
|
261
|
+
if (!player) return fail(401, 'account', 'Use the account that made this gift.');
|
|
262
|
+
const b = await bodyOf(request); if (b.error) return b.error;
|
|
263
|
+
if (typeof b.body.subscription !== 'string') return fail(400, 'subscription', 'Name your subscription.');
|
|
264
|
+
const portal = await subscriptionPortal(env,player.id,b.body.subscription,url.origin);
|
|
265
|
+
return portal ? json({ ok: true, url: portal }) : fail(404,'subscription','No subscription for this account.');
|
|
266
|
+
}
|
|
248
267
|
if (path === '/api/shop/age' && request.method === 'POST') return await ageRoute(request, env, shop);
|
|
249
268
|
if (path === '/api/shop/buy' && request.method === 'POST') return await buyRoute(request, env, url, cat, shop, ready, ctx, requestAt);
|
|
250
269
|
if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
|
|
@@ -275,6 +294,8 @@ async function listRoute(request, env, url, cat, shop, ready) {
|
|
|
275
294
|
currency: shop.currency, currencyScale: currencyScale(shop.currency), amountStep: amountStep(shop.currency), policy: shop.policy, till: shop.till, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, spent: v.spent,
|
|
276
295
|
player: player ? { signedIn: !player.guest, guest: player.guest, name: player.name, band: v.band } : null,
|
|
277
296
|
owns: v.owns.map((o) => o.key), items: v.items, nextCursor: v.nextCursor,
|
|
297
|
+
portalLogin: !hasStripeKey(env) && /^https:\/\/billing\.stripe\.com\/p\/login\//.test(linkConfig(env)?.portal??'') ? linkConfig(env).portal : null,
|
|
298
|
+
menus: !hasStripeKey(env) && ready.ready ? (linkConfig(env)?.menus??[]).filter(m=>m.items.every(entry=>v.items.some(i=>i.id===entry.item&&i.way==='checkout'))).map(m=>({items:m.items.map(i=>i.item)})) : [],
|
|
278
299
|
});
|
|
279
300
|
}
|
|
280
301
|
|
|
@@ -323,7 +344,8 @@ async function orderRoute(request, env, url, shop) {
|
|
|
323
344
|
}
|
|
324
345
|
} catch (error) { logOrderError(env, o.id, error); }
|
|
325
346
|
}
|
|
326
|
-
|
|
347
|
+
const reference = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-reference:${o.id}`).first();
|
|
348
|
+
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 });
|
|
327
349
|
}
|
|
328
350
|
|
|
329
351
|
/** The neutral age question: a year of birth, once. Nothing hints which answer opens anything; no default. */
|
|
@@ -355,12 +377,13 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
|
|
|
355
377
|
const cart = lines ?? [{ item, amount, quantity: 1, product }];
|
|
356
378
|
const back = game ? `&game=${encodeURIComponent(game)}` : '';
|
|
357
379
|
return {
|
|
358
|
-
mode: 'payment',
|
|
380
|
+
mode: cart.some(line => line.item.interval) ? 'subscription' : 'payment',
|
|
359
381
|
// Stripe payment Checkout allows up to 100 lines, each with a quantity:
|
|
360
382
|
// https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-line_items
|
|
361
383
|
line_items: cart.map((line) => ({
|
|
362
384
|
quantity: line.quantity,
|
|
363
385
|
price_data: { currency: shop.currency, unit_amount: line.amount, tax_behavior: 'exclusive',
|
|
386
|
+
...(line.item.interval ? { recurring: { interval: line.item.interval } } : {}),
|
|
364
387
|
...(line.product ? { product: line.product } : { product_data: {
|
|
365
388
|
name: line.item.name, ...(line.item.blurb ? { description: line.item.blurb } : {}),
|
|
366
389
|
...(line.item.taxCode || managed ? { tax_code: line.item.taxCode ?? defaultTaxCode(line.item) } : {}),
|
|
@@ -372,7 +395,7 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
|
|
|
372
395
|
// Stripe metadata values allow 500 characters; the order holds the complete item id.
|
|
373
396
|
// https://docs.stripe.com/metadata (fetched 2026-10-08).
|
|
374
397
|
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 } : {}), ...(parent ? { parent: '1' } : {}) },
|
|
375
|
-
payment_intent_data: { metadata: { homie: 'shop-v1', origin, order: order.id, item: item.id.length <= 500 ? item.id : undefined } },
|
|
398
|
+
...(cart.some(line => line.item.interval) ? { subscription_data: { metadata: { homie: 'shop-v1', origin, order: order.id } } } : { payment_intent_data: { metadata: { homie: 'shop-v1', origin, order: order.id, item: item.id.length <= 500 ? item.id : undefined } } }),
|
|
376
399
|
success_url: `${origin}/shop/thanks?session_id={CHECKOUT_SESSION_ID}${back}`,
|
|
377
400
|
cancel_url: `${origin}/shop/?cancelled=1&order=${order.id}${back}`,
|
|
378
401
|
// Keep Stripe’s default 24-hour Checkout lifetime: https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-expires_at
|
|
@@ -414,8 +437,38 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
|
|
|
414
437
|
}
|
|
415
438
|
await Promise.all(rows.map(async (row) => {
|
|
416
439
|
if (!row.session) {
|
|
417
|
-
|
|
418
|
-
|
|
440
|
+
const saved = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`booking-checkout:${row.id}`).first();
|
|
441
|
+
if (saved) {
|
|
442
|
+
try {
|
|
443
|
+
let recovered;
|
|
444
|
+
if (now - row.created_at < 23 * 60 * 60 * 1000) recovered = await createCheckoutSession(env, JSON.parse(saved.value), { idempotencyKey: `checkout-${row.id}` });
|
|
445
|
+
else {
|
|
446
|
+
// After idempotency retention, discover the original session instead of
|
|
447
|
+
// creating another. A complete provider list can establish no creation.
|
|
448
|
+
let cursor;
|
|
449
|
+
do {
|
|
450
|
+
const page = await stripeCall(env, 'GET', '/v1/checkout/sessions', { limit: 100, created: { gte: Math.floor(row.created_at / 1000) - 60 }, ...(cursor ? { starting_after: cursor } : {}) });
|
|
451
|
+
if (!Array.isArray(page.data)) throw new Error('Stripe session list unavailable');
|
|
452
|
+
recovered = page.data.find(session => session.metadata?.order === row.id);
|
|
453
|
+
if (recovered) break;
|
|
454
|
+
const next = page.has_more ? page.data.at(-1)?.id : null;
|
|
455
|
+
if (page.has_more && (!next || next === cursor)) throw new Error('Stripe session list incomplete');
|
|
456
|
+
cursor = next;
|
|
457
|
+
} while (cursor);
|
|
458
|
+
if (!recovered) {
|
|
459
|
+
await env.DB.prepare("UPDATE shop_orders SET status='failed',updated_at=?2 WHERE id=?1 AND session IS NULL AND paid_at IS NULL").bind(row.id,now).run();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (recovered?.id) {
|
|
464
|
+
await env.DB.prepare('UPDATE shop_orders SET session=?2,expires_at=?3 WHERE id=?1 AND session IS NULL').bind(row.id,recovered.id,recovered.expires_at*1000).run();
|
|
465
|
+
row.session = recovered.id;
|
|
466
|
+
}
|
|
467
|
+
} catch (error) { logOrderError(env,row.id,error); return; }
|
|
468
|
+
if (!row.session) return;
|
|
469
|
+
}
|
|
470
|
+
if (!row.session && (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();
|
|
471
|
+
if (!row.session) return;
|
|
419
472
|
}
|
|
420
473
|
if (expireOpen && row.status !== 'started') return;
|
|
421
474
|
if (row.status === 'paid' && !row.player) { await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(row.id).run(); return; }
|
|
@@ -458,15 +511,31 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
|
|
|
458
511
|
}
|
|
459
512
|
|
|
460
513
|
/** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
|
|
461
|
-
async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, requestAt = Date.now() }) {
|
|
514
|
+
async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, booking = null, requestAt = Date.now() }) {
|
|
462
515
|
lines ??= [{ item, amount, quantity: 1 }];
|
|
463
|
-
const keyless = amount > 0 && !hasStripeKey(env);
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if
|
|
516
|
+
const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env);
|
|
517
|
+
const config=linkConfig(env);
|
|
518
|
+
if(booking && amount===0 && !hasStripeKey(env) && config?.slots?.some(l=>l.app===game&&l.slots===booking.slots&&l.slot===booking.slot))return {error:fail(409,'booking','Publish a separate local slot for a free booking; it cannot share a paid Stripe capacity budget.')};
|
|
519
|
+
const menu=keyless && !booking && lines.length>1 ? config?.menus?.find(m=>lines.every(l=>m.items.some(i=>i.item===l.item.id))) : null;
|
|
520
|
+
const link=keyless ? booking ? config?.slots?.find(l=>l.app===game&&l.collection===booking.collection&&l.slots===booking.slots&&l.slot===booking.slot&&l.item===item.id) : menu ?? config?.items.find(l=>l.item===item.id) : null;
|
|
521
|
+
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.')};
|
|
522
|
+
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.')};
|
|
523
|
+
if(keyless && booking) {
|
|
524
|
+
if(booking.quantity!==1)return {error:fail(400,'quantity','Book one seat per checkout; Stripe counts completed sessions.')};
|
|
525
|
+
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();
|
|
526
|
+
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.')};
|
|
527
|
+
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();
|
|
528
|
+
const update=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
|
|
529
|
+
const observed=update?JSON.parse(update.value):null;
|
|
530
|
+
const status=observed && observed.capacity===(link.limit??link.capacity) ? observed : link;
|
|
531
|
+
if(!status.active || status.count>=(link.limit??link.capacity) || confirmed.n>=link.capacity)return {error:fail(409,'capacity','That slot is full. Choose another slot.')};
|
|
532
|
+
}
|
|
533
|
+
if(keyless && item.price==='choose' && lines[0].quantity!==1)return {error:fail(400,'quantity','A tip is one chosen amount; choose it on Stripe.')};
|
|
534
|
+
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.')};
|
|
535
|
+
if(menu)lines=menu.items.map(entry=>{const i=shop.items.find(i=>i.id===entry.item);return {item:i,quantity:1,amount:i.price};});
|
|
536
|
+
if(keyless)lines=lines.map(l=>({...l,item:{...l.item,paymentLink:link,stripePrice:menu?.items.find(i=>i.item===l.item.id)?.price??link.price}}));
|
|
469
537
|
const order = { id: newOrderId(), player: player.id, amount, currency: shop.currency };
|
|
538
|
+
if (booking && reference === null) reference = order.id;
|
|
470
539
|
const now = Date.now();
|
|
471
540
|
order.createdAt = now;
|
|
472
541
|
order.expiresAt = Math.floor(now / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
|
|
@@ -476,7 +545,9 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
476
545
|
lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
|
|
477
546
|
const reserve = async () => {
|
|
478
547
|
const results = await env.DB.batch([reservation(),
|
|
548
|
+
...(booking ? [bookingStatement(env, { ...booking, pending: keyless, id: order.id, app: booking.app ?? game, order: order.id })] : []),
|
|
479
549
|
...(reference === null ? [] : [env.DB.prepare("INSERT INTO meta(key,value) SELECT ?1,?2 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id=?3)").bind(`shop-reference:${order.id}`, reference, order.id)]),
|
|
550
|
+
...(menu ? [env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2)').bind(`shop-menu:${order.id}`,JSON.stringify(lines))] : []),
|
|
480
551
|
...lines.map((line, i) => env.DB.prepare("INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id = ?2)")
|
|
481
552
|
.bind(line.id, order.id, i, line.item.id, line.quantity, line.amount, line.amount * line.quantity, JSON.stringify(line.item))),
|
|
482
553
|
env.DB.prepare('UPDATE shop_orders SET checkout_player = player WHERE id = ?1').bind(order.id),
|
|
@@ -484,14 +555,18 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
484
555
|
return results[0];
|
|
485
556
|
};
|
|
486
557
|
await reconcileOrders(env, shop, player.id, { expireOpen: true, before: Math.min(requestAt, now - 60000) });
|
|
487
|
-
let reserved
|
|
558
|
+
let reserved;
|
|
559
|
+
try { reserved = await reserve(); } catch (error) {
|
|
560
|
+
if (/booking capacity unavailable/.test(error.message)) return { error: fail(409, 'capacity', 'That slot is full. Choose another slot.') };
|
|
561
|
+
throw error;
|
|
562
|
+
}
|
|
488
563
|
const reconciled = !reserved.meta?.changes;
|
|
489
564
|
if (!reserved.meta?.changes) {
|
|
490
565
|
await reconcileOrders(env, shop, player.id);
|
|
491
566
|
reserved = await reserve();
|
|
492
567
|
}
|
|
493
568
|
if (!reserved.meta?.changes) return { error: fail(403, 'cap', WAY_WORDS.cap, { way: 'cap' }) };
|
|
494
|
-
if (amount === 0) {
|
|
569
|
+
if (amount === 0 && !lines.some(l => l.item.interval)) {
|
|
495
570
|
const id = `cs_free_${order.id}`;
|
|
496
571
|
await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1').bind(order.id, id).run();
|
|
497
572
|
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' });
|
|
@@ -504,7 +579,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
504
579
|
let session = null;
|
|
505
580
|
const mode = shopMode(env);
|
|
506
581
|
try {
|
|
507
|
-
const useCatalog = Array.isArray(shop.catalog) && shop.catalog.includes(mode);
|
|
582
|
+
const useCatalog = !booking && Array.isArray(shop.catalog) && shop.catalog.includes(mode);
|
|
508
583
|
for (const line of lines) line.product = useCatalog ? await productIdOf(cat.studio?.slug, line.item.id) : null;
|
|
509
584
|
await env.DB.batch(lines.map((line) => env.DB.prepare('UPDATE shop_order_lines SET snapshot = ?2 WHERE id = ?1').bind(line.id, JSON.stringify({ ...line.item, stripeProduct: line.product }))));
|
|
510
585
|
// Start the provider lifetime at session creation, after reservation/expiry I/O. At Stripe's
|
|
@@ -514,7 +589,9 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
514
589
|
return sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, lines });
|
|
515
590
|
};
|
|
516
591
|
try {
|
|
517
|
-
|
|
592
|
+
const checkoutParams = params();
|
|
593
|
+
if (booking) await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2)').bind(`booking-checkout:${order.id}`, JSON.stringify(checkoutParams)).run();
|
|
594
|
+
session = await createCheckoutSession(env, checkoutParams, { idempotencyKey: `checkout-${order.id}` });
|
|
518
595
|
} catch (error) {
|
|
519
596
|
if (!useCatalog || !isMissingProduct(error)) throw error;
|
|
520
597
|
for (const line of lines) line.product = null;
|
|
@@ -526,7 +603,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
526
603
|
}
|
|
527
604
|
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();
|
|
528
605
|
} catch (error) {
|
|
529
|
-
await env.DB.prepare("UPDATE shop_orders SET status =
|
|
606
|
+
await env.DB.prepare("UPDATE shop_orders SET status = ?4, note = ?2, updated_at = ?3 WHERE id = ?1 AND paid_at IS NULL").bind(order.id, redactStripe(error?.code ?? 'stripe', env).slice(0, 60), Date.now(), booking ? 'started' : 'failed').run();
|
|
530
607
|
const why = error instanceof StripeError ? redactStripe(error.message, env) : 'Stripe did not answer';
|
|
531
608
|
return { error: fail(502, 'stripe', `Stripe could not open the checkout (${why}). Nothing was charged.`) };
|
|
532
609
|
}
|
|
@@ -552,7 +629,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
552
629
|
if (b.error) return b.error;
|
|
553
630
|
const reference = b.body.reference ?? null;
|
|
554
631
|
if (reference !== null && (typeof reference !== 'string' || !reference.trim() || /[\u0000-\u001f\u007f]/.test(reference))) return fail(400, 'reference', 'reference must be a nonempty string without control characters');
|
|
555
|
-
const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0));
|
|
632
|
+
const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0 && !i.interval));
|
|
556
633
|
if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
|
|
557
634
|
const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
|
|
558
635
|
// Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
|
|
@@ -583,6 +660,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
583
660
|
lines.push({ item, amount: unit, quantity });
|
|
584
661
|
}
|
|
585
662
|
const item = lines[0].item;
|
|
663
|
+
if (lines.some(l => l.item.interval) && (lines.filter(l => l.item.interval).length > 20 || lines.filter(l => !l.item.interval).length > 20)) return fail(400, 'stripe-lines', 'Stripe subscription Checkout allows 20 recurring and 20 one-time lines.');
|
|
586
664
|
let cookies = [];
|
|
587
665
|
if (!player) {
|
|
588
666
|
const guest = await players.shopGuest(request, env, url, shop.guestBuyersPerAddressPerHour);
|
|
@@ -590,8 +668,18 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
590
668
|
player = guest.session.player;
|
|
591
669
|
cookies = guest.session.cookies;
|
|
592
670
|
}
|
|
593
|
-
|
|
594
|
-
|
|
671
|
+
let booking = null;
|
|
672
|
+
if (b.body.booking !== undefined) {
|
|
673
|
+
const requestedBooking = b.body.booking;
|
|
674
|
+
const app = cat.games?.find(g => g.id === game && g.kind === 'app');
|
|
675
|
+
const collection = requestedBooking?.collection;
|
|
676
|
+
const spec = app?.records?.collections?.[collection]?.booking;
|
|
677
|
+
if (!spec || !/^[A-Za-z0-9_-]{1,64}$/.test(requestedBooking?.slot ?? '') || lines.length !== 1 || item.id !== spec.item || item.interval) return fail(400, 'booking', 'Choose a declared booking slot and its service item.');
|
|
678
|
+
const local = await localSession(request, env);
|
|
679
|
+
booking = { app: local?.app === game && local.scope ? `${game}:check:${local.scope}` : game, collection, slots: spec.slots, slot: requestedBooking.slot, quantity: lines[0].quantity };
|
|
680
|
+
}
|
|
681
|
+
const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, booking, requestAt });
|
|
682
|
+
const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url, ...(booking ? { booking: r.order.id } : {}) });
|
|
595
683
|
if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
|
|
596
684
|
for (const cookie of cookies) response.headers.append('set-cookie', cookie);
|
|
597
685
|
return response;
|
|
@@ -744,12 +832,41 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
744
832
|
if (payment) await syncRefunds(env, payment);
|
|
745
833
|
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(); return json({ ok: true, did: purchase }); }
|
|
746
834
|
}
|
|
835
|
+
needed.push('payment_link.updated','invoice.paid','invoice.payment_failed','customer.subscription.updated','customer.subscription.deleted');
|
|
747
836
|
if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
|
|
748
837
|
if (typeof ev.livemode === 'boolean' && (hasStripeKey(env) || linkConfig(env)) && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
|
|
749
838
|
try {
|
|
839
|
+
if(ev.type==='payment_link.updated') {
|
|
840
|
+
if(linkConfig(env)?.slots?.some(l=>l.id===obj.id))await env.DB.prepare(`INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value
|
|
841
|
+
WHERE json_extract(excluded.value,'$.count') >= json_extract(meta.value,'$.count') AND
|
|
842
|
+
(json_extract(excluded.value,'$.at') > json_extract(meta.value,'$.at') OR
|
|
843
|
+
(json_extract(excluded.value,'$.at') = json_extract(meta.value,'$.at') AND json_extract(excluded.value,'$.active')=0))`)
|
|
844
|
+
.bind(`shop-slot-link:${obj.id}`,JSON.stringify({active:obj.active,count:obj.restrictions?.completed_sessions?.count??0,capacity:obj.restrictions?.completed_sessions?.limit,at:ev.created})).run();
|
|
845
|
+
return json({ok:true,did:'slot-updated'});
|
|
846
|
+
}
|
|
847
|
+
if(ev.type==='invoice.paid' && obj.status==='paid' && (obj.metadata?.homie==='shop-links-v1' || obj.parent?.subscription_details?.metadata?.homie==='shop-links-v1' || obj.subscription_details?.metadata?.homie==='shop-links-v1')) {
|
|
848
|
+
const invoice={id:obj.id,status:obj.status,livemode:ev.livemode,currency:obj.currency,total:obj.total,lines:obj.lines ? {has_more:obj.lines.has_more,data:obj.lines.data.map(l=>({quantity:l.quantity,amount:l.amount,price:l.price?{id:l.price.id}:undefined,pricing:l.pricing,period:l.period,taxes:l.taxes,tax_amounts:l.tax_amounts}))} : null};
|
|
849
|
+
await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(`shop-invoice:${obj.id}`,JSON.stringify(invoice)).run();
|
|
850
|
+
const pending=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-link-session:${obj.id}`).first();
|
|
851
|
+
if(pending) {
|
|
852
|
+
const session=JSON.parse(pending.value);
|
|
853
|
+
if(await bindLinkSession(env,session,ev.livemode?'live':'test'))await paid(env,shop,session);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if(ev.type.startsWith('checkout.session.') && obj.payment_link && obj.invoice && ['paid','no_payment_required'].includes(obj.payment_status)) {
|
|
857
|
+
const session=Object.fromEntries(['id','client_reference_id','payment_link','metadata','currency','amount_subtotal','amount_total','total_details','payment_status','payment_intent','subscription','customer','invoice'].map(k=>[k,obj[k]]));
|
|
858
|
+
await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(`shop-link-session:${obj.invoice}`,JSON.stringify(session)).run();
|
|
859
|
+
}
|
|
860
|
+
const subscriptionResult = await subscriptionEvent(env, ev, shop);
|
|
861
|
+
if (subscriptionResult !== null) return json({ ok: true, did: subscriptionResult });
|
|
750
862
|
const sessionEvent = ev.type.startsWith('checkout.session.');
|
|
751
863
|
if (!sessionEvent) await rememberMoneyEvent(env, ev);
|
|
752
864
|
if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({ ok: true, did: 'mismatch' });
|
|
865
|
+
if (sessionEvent && obj.metadata?.order && !obj.payment_link) {
|
|
866
|
+
await env.DB.prepare(`UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL AND mode=?3
|
|
867
|
+
AND EXISTS(SELECT 1 FROM meta WHERE key='booking-checkout:' || shop_orders.id)`)
|
|
868
|
+
.bind(obj.metadata.order, obj.id, ev.livemode ? 'live' : 'test').run();
|
|
869
|
+
}
|
|
753
870
|
let known = sessionEvent ? await orderBySession(env, obj.id) : await orderByPayment(env, String(obj.payment_intent ?? ''));
|
|
754
871
|
if (!known) {
|
|
755
872
|
let metadata = obj.metadata;
|
|
@@ -835,7 +952,12 @@ async function bindLinkSession(env, session, mode) {
|
|
|
835
952
|
const source = await orderById(env, session.client_reference_id);
|
|
836
953
|
if (!source || source.mode !== mode) return false;
|
|
837
954
|
const lines = await orderLines(env, source.id);
|
|
838
|
-
if (lines.length
|
|
955
|
+
if (!lines.length || !lines[0].snapshot) return false;
|
|
956
|
+
if(JSON.parse(lines[0].snapshot).paymentLink?.items) {
|
|
957
|
+
const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-menu:${source.id}`).first();
|
|
958
|
+
const original=saved?JSON.parse(saved.value).map(l=>({item:l.item.id,snapshot:JSON.stringify(l.item),unit_amount:l.amount})):lines;
|
|
959
|
+
return bindMenuSession(env,session,source,original);
|
|
960
|
+
}
|
|
839
961
|
const item = JSON.parse(lines[0].snapshot), link = item.paymentLink;
|
|
840
962
|
if (!link || (session.payment_link?.id ?? session.payment_link) !== link.id || session.metadata?.revision !== link.revision || session.currency !== source.currency) return false;
|
|
841
963
|
const amount = session.amount_subtotal;
|
|
@@ -848,12 +970,15 @@ async function bindLinkSession(env, session, mode) {
|
|
|
848
970
|
let order = await orderBySession(env, session.id);
|
|
849
971
|
if (!order) {
|
|
850
972
|
const id = `ord_${(await sha256Hex(session.id)).slice(0, 20)}`;
|
|
973
|
+
const booking=link.slot ? await env.DB.prepare('SELECT * FROM shop_bookings WHERE order_id=?1').bind(source.id).first() : null;
|
|
851
974
|
await env.DB.batch([
|
|
852
975
|
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()),
|
|
853
|
-
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}
|
|
976
|
+
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),
|
|
854
977
|
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),
|
|
978
|
+
...(booking ? [bookingStatement(env,{...booking,slot:link.slot,slots:link.slots,id,order:id,pending:true})] : []),
|
|
855
979
|
]);
|
|
856
980
|
order = await orderBySession(env, session.id);
|
|
981
|
+
|
|
857
982
|
}
|
|
858
983
|
if (!order) return false;
|
|
859
984
|
if (order.paid_at === null) await env.DB.batch([
|
|
@@ -863,6 +988,40 @@ async function bindLinkSession(env, session, mode) {
|
|
|
863
988
|
return true;
|
|
864
989
|
}
|
|
865
990
|
|
|
991
|
+
/** Menu totals are never decoded into quantities. Only signed invoice lines are authoritative. */
|
|
992
|
+
async function bindMenuSession(env,session,source,lines) {
|
|
993
|
+
const first=JSON.parse(lines[0].snapshot),link=first.paymentLink;
|
|
994
|
+
if((session.payment_link?.id??session.payment_link)!==link.id || session.metadata?.revision!==link.revision || session.currency!==source.currency)return false;
|
|
995
|
+
if(!['paid','no_payment_required'].includes(session.payment_status))return false;
|
|
996
|
+
const invoiceId=typeof session.invoice==='string'?session.invoice:session.invoice?.id;
|
|
997
|
+
const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-invoice:${invoiceId}`).first();
|
|
998
|
+
if(!saved)throw new Error('Waiting for signed paid invoice');
|
|
999
|
+
const invoice=JSON.parse(saved.value);
|
|
1000
|
+
if(invoice.livemode!==(source.mode==='live') || invoice.currency!==source.currency || invoice.total!==session.amount_total || invoice.lines?.has_more)throw new Error('Incomplete or mismatched signed invoice');
|
|
1001
|
+
const paidLines=[];
|
|
1002
|
+
for(const l of invoice.lines?.data??[]) {
|
|
1003
|
+
const line=lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price));
|
|
1004
|
+
if(!line || !Number.isSafeInteger(l.quantity) || l.quantity<1 || l.amount!==JSON.parse(line.snapshot).price*l.quantity)throw new Error('Unknown invoice line');
|
|
1005
|
+
paidLines.push({...line,quantity:l.quantity,amount:l.amount});
|
|
1006
|
+
}
|
|
1007
|
+
if(!paidLines.length || paidLines.reduce((n,l)=>n+l.amount,0)!==session.amount_subtotal)throw new Error('Invoice subtotal mismatch');
|
|
1008
|
+
await env.DB.prepare('UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL').bind(source.id,session.id).run();
|
|
1009
|
+
let order=await orderBySession(env,session.id);
|
|
1010
|
+
if(!order) {
|
|
1011
|
+
const id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`;
|
|
1012
|
+
await env.DB.batch([
|
|
1013
|
+
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,?4,currency,till,mode,'started',parent,?5,?5,?3 FROM shop_orders WHERE id=?1").bind(source.id,id,session.id,session.amount_subtotal,Date.now()),
|
|
1014
|
+
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}`),
|
|
1015
|
+
]);order=await orderBySession(env,session.id);
|
|
1016
|
+
}
|
|
1017
|
+
if(order.paid_at===null)await env.DB.batch([
|
|
1018
|
+
env.DB.prepare('DELETE FROM shop_order_lines WHERE order_id=?1 AND EXISTS(SELECT 1 FROM shop_orders WHERE id=?1 AND paid_at IS NULL)').bind(order.id),
|
|
1019
|
+
...paidLines.map((l,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 AND paid_at IS NULL) ON CONFLICT DO NOTHING').bind(`${order.id}_${i}`,order.id,i,l.item,l.quantity,l.unit_amount,l.amount,l.snapshot)),
|
|
1020
|
+
env.DB.prepare('UPDATE shop_orders SET amount=?2 WHERE id=?1 AND paid_at IS NULL').bind(order.id,session.amount_subtotal),
|
|
1021
|
+
]);
|
|
1022
|
+
return true;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
866
1025
|
/** A paid checkout: the order is paid, its entitlements granted, its payment recorded. Idempotent. */
|
|
867
1026
|
export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
868
1027
|
const o = await orderBySession(env, session.id);
|
|
@@ -892,12 +1051,18 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
892
1051
|
if (!Number.isSafeInteger(total) || !Number.isSafeInteger(tax) || total < 0 || tax < 0 || total !== Number(o.amount) + tax) return 'mismatch';
|
|
893
1052
|
let stripeLines = null;
|
|
894
1053
|
if (lines.length > 1 && tax) {
|
|
895
|
-
if (!hasStripeKey(env))
|
|
1054
|
+
if (!hasStripeKey(env)) {
|
|
1055
|
+
const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-invoice:${typeof session.invoice==='string'?session.invoice:session.invoice?.id}`).first();
|
|
1056
|
+
const invoice=saved&&JSON.parse(saved.value);
|
|
1057
|
+
if(!invoice || invoice.lines?.has_more)throw new Error('Waiting for complete signed invoice');
|
|
1058
|
+
stripeLines=invoice.lines.data.map(l=>({quantity:l.quantity,amount_subtotal:l.amount,amount_total:l.amount+(l.taxes??l.tax_amounts??[]).reduce((n,t)=>n+t.amount,0),price:{id:l.price?.id??l.pricing?.price_details?.price,product:JSON.parse(lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price))?.snapshot??'{}').stripeProduct}}));
|
|
1059
|
+
} else {
|
|
896
1060
|
// Use Stripe's actual tax per line, never spread tax across products with different tax codes.
|
|
897
1061
|
// https://docs.stripe.com/api/checkout/sessions/line_items
|
|
898
1062
|
const response = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(session.id)}/line_items`, { limit: 100, expand: ['data.price.product'] });
|
|
899
1063
|
if (response.has_more || response.data?.length !== lines.length) throw new Error('Stripe line totals are incomplete');
|
|
900
1064
|
stripeLines = [...response.data];
|
|
1065
|
+
}
|
|
901
1066
|
}
|
|
902
1067
|
const writes = [];
|
|
903
1068
|
let lineTotal = 0;
|
|
@@ -912,7 +1077,7 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
912
1077
|
let gross = lines.length === 1 ? total : Number(line.amount);
|
|
913
1078
|
if (stripeLines) {
|
|
914
1079
|
const index = stripeLines.findIndex((l) => l.quantity === line.quantity && l.amount_subtotal === line.amount &&
|
|
915
|
-
(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));
|
|
1080
|
+
(l.price?.id && l.price.id === item.stripePrice || 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));
|
|
916
1081
|
if (index < 0) throw new Error('Stripe line identity mismatch');
|
|
917
1082
|
gross = stripeLines.splice(index, 1)[0].amount_total;
|
|
918
1083
|
}
|
|
@@ -939,6 +1104,7 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
939
1104
|
if (['lost', 'won', 'warning_closed'].includes(d.status)) await disputeClosed(env, paymentId, d.status);
|
|
940
1105
|
}
|
|
941
1106
|
}
|
|
1107
|
+
if (lines.some(l => l.snapshot && JSON.parse(l.snapshot).interval)) await bindSubscription(env, await orderById(env,o.id), session, shop);
|
|
942
1108
|
await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run();
|
|
943
1109
|
return result.at(-1)?.meta?.changes ? 'paid' : 'already';
|
|
944
1110
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const TOOL_DATABASE_MIGRATION_FILE='0024_tool_data_versions.sql';
|
|
2
|
+
export const TOOL_DATABASE_MIGRATION='ALTER TABLE tool_data ADD COLUMN version INTEGER NOT NULL DEFAULT 1;\n';
|
|
3
|
+
/** Each mutation is one SQLite statement; callers never hold a JS lock across Workers. */
|
|
4
|
+
export function toolDatabase(DB,namespace) {
|
|
5
|
+
const key=k=>{if(typeof k!=='string'||!k.length||k.length>128)throw new Error('Key must contain 1–128 characters');return k;};
|
|
6
|
+
const decode=r=>r?{value:JSON.parse(r.value),version:r.version}:null;
|
|
7
|
+
const getVersioned=async k=>decode(await DB.prepare('SELECT value,version FROM tool_data WHERE namespace=?1 AND key=?2').bind(namespace,key(k)).first());
|
|
8
|
+
return Object.freeze({
|
|
9
|
+
async get(k){return (await getVersioned(k))?.value??null;},
|
|
10
|
+
getVersioned,
|
|
11
|
+
async put(k,value,{version}={}) {
|
|
12
|
+
const text=JSON.stringify(value);if(text===undefined)throw new Error('Value must be JSON');
|
|
13
|
+
if(version!==undefined&&(!Number.isSafeInteger(version)||version<0))throw new Error('Version must be a nonnegative integer (0 creates)');
|
|
14
|
+
const stmt=version===undefined
|
|
15
|
+
? DB.prepare('INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,?3) ON CONFLICT(namespace,key) DO UPDATE SET value=excluded.value,version=tool_data.version+1 RETURNING version').bind(namespace,key(k),text)
|
|
16
|
+
: version===0 ? DB.prepare('INSERT OR IGNORE INTO tool_data(namespace,key,value) VALUES(?1,?2,?3) RETURNING version').bind(namespace,key(k),text)
|
|
17
|
+
: DB.prepare('UPDATE tool_data SET value=?3,version=version+1 WHERE namespace=?1 AND key=?2 AND version=?4 RETURNING version').bind(namespace,key(k),text,version);
|
|
18
|
+
const row=await stmt.first();return row?{ok:true,version:row.version}:{ok:false,conflict:true};
|
|
19
|
+
},
|
|
20
|
+
async increment(k,amount=1) {
|
|
21
|
+
if(!Number.isFinite(amount))throw new Error('Increment must be finite');
|
|
22
|
+
const row=await DB.prepare(`INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,json(?3)) ON CONFLICT(namespace,key) DO UPDATE SET value=json(json_extract(tool_data.value,'$')+?3),version=tool_data.version+1 WHERE json_type(tool_data.value) IN ('integer','real') RETURNING value,version`).bind(namespace,key(k),amount).first();
|
|
23
|
+
if(!row)throw new Error('Increment requires a numeric value');return decode(row);
|
|
24
|
+
},
|
|
25
|
+
async append(k,value) {
|
|
26
|
+
const text=JSON.stringify(value);if(text===undefined)throw new Error('Append requires JSON');
|
|
27
|
+
const row=await DB.prepare(`INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,json_array(json(?3))) ON CONFLICT(namespace,key) DO UPDATE SET value=json_insert(tool_data.value,'$[#]',json(?3)),version=tool_data.version+1 WHERE json_type(tool_data.value)='array' RETURNING value,version`).bind(namespace,key(k),text).first();
|
|
28
|
+
if(!row)throw new Error('Append requires an array value');return decode(row);
|
|
29
|
+
},
|
|
30
|
+
async list({prefix='',after='',limit=100}={}) {
|
|
31
|
+
if(typeof prefix!=='string'||typeof after!=='string'||!Number.isSafeInteger(limit)||limit<1)throw new Error('List requires a string prefix/cursor and positive page size');
|
|
32
|
+
const rows=(await DB.prepare('SELECT key,value,version FROM tool_data WHERE namespace=?1 AND substr(key,1,?2)=?3 AND key>?4 ORDER BY key LIMIT ?5').bind(namespace,prefix.length,prefix,after,limit+1).all()).results;
|
|
33
|
+
return {items:rows.slice(0,limit).map(r=>({key:r.key,...decode(r)})),next:rows.length>limit?rows[limit-1].key:null};
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.51.0';
|