@homie-rocks/studio 0.50.7 → 0.51.1
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 +54 -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 +25 -1
- package/apps/apps.ts +1 -1
- package/bin/homie-studio.mjs +5 -1
- package/guides/app/SKILL.md +13 -1
- package/guides/game/RULES.md +26 -0
- package/guides/shop/SKILL.md +41 -20
- package/lib/dev-stripe-fixture.mjs +37 -0
- package/lib/dev-stripe-sandbox.mjs +80 -0
- package/lib/mcp-tools.mjs +15 -1
- package/lib/scaffold.mjs +15 -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/package.json +1 -1
- package/rules/correction-path.ts +35 -0
- package/rules/map-index.ts +4 -4
- package/rules/view.ts +196 -28
- package/shop/SELLING.md +2 -2
- package/shop/SHOP.md +120 -5
- package/shop/shop.ts +29 -0
- package/worker/app-format.mjs +6 -0
- package/worker/app-records.mjs +23 -1
- package/worker/booking-store.mjs +79 -0
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +5 -2
- package/worker/records-page.mjs +4 -3
- package/worker/shop-links.mjs +3 -2
- package/worker/shop-page.mjs +25 -3
- 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 +194 -29
- 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
|
}
|
|
@@ -246,6 +249,21 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
246
249
|
if (path === '/api/player/owns' && read) return await ownsRoute(request, env, url, cat, shop);
|
|
247
250
|
if (path === '/api/shop/mine' && read) return await mineRoute(request, env, shop);
|
|
248
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
|
+
}
|
|
249
267
|
if (path === '/api/shop/age' && request.method === 'POST') return await ageRoute(request, env, shop);
|
|
250
268
|
if (path === '/api/shop/buy' && request.method === 'POST') return await buyRoute(request, env, url, cat, shop, ready, ctx, requestAt);
|
|
251
269
|
if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
|
|
@@ -276,6 +294,8 @@ async function listRoute(request, env, url, cat, shop, ready) {
|
|
|
276
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,
|
|
277
295
|
player: player ? { signedIn: !player.guest, guest: player.guest, name: player.name, band: v.band } : null,
|
|
278
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)})) : [],
|
|
279
299
|
});
|
|
280
300
|
}
|
|
281
301
|
|
|
@@ -324,7 +344,8 @@ async function orderRoute(request, env, url, shop) {
|
|
|
324
344
|
}
|
|
325
345
|
} catch (error) { logOrderError(env, o.id, error); }
|
|
326
346
|
}
|
|
327
|
-
|
|
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 });
|
|
328
349
|
}
|
|
329
350
|
|
|
330
351
|
/** The neutral age question: a year of birth, once. Nothing hints which answer opens anything; no default. */
|
|
@@ -356,12 +377,13 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
|
|
|
356
377
|
const cart = lines ?? [{ item, amount, quantity: 1, product }];
|
|
357
378
|
const back = game ? `&game=${encodeURIComponent(game)}` : '';
|
|
358
379
|
return {
|
|
359
|
-
mode: 'payment',
|
|
380
|
+
mode: cart.some(line => line.item.interval) ? 'subscription' : 'payment',
|
|
360
381
|
// Stripe payment Checkout allows up to 100 lines, each with a quantity:
|
|
361
382
|
// https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-line_items
|
|
362
383
|
line_items: cart.map((line) => ({
|
|
363
384
|
quantity: line.quantity,
|
|
364
385
|
price_data: { currency: shop.currency, unit_amount: line.amount, tax_behavior: 'exclusive',
|
|
386
|
+
...(line.item.interval ? { recurring: { interval: line.item.interval } } : {}),
|
|
365
387
|
...(line.product ? { product: line.product } : { product_data: {
|
|
366
388
|
name: line.item.name, ...(line.item.blurb ? { description: line.item.blurb } : {}),
|
|
367
389
|
...(line.item.taxCode || managed ? { tax_code: line.item.taxCode ?? defaultTaxCode(line.item) } : {}),
|
|
@@ -373,7 +395,7 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
|
|
|
373
395
|
// Stripe metadata values allow 500 characters; the order holds the complete item id.
|
|
374
396
|
// https://docs.stripe.com/metadata (fetched 2026-10-08).
|
|
375
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' } : {}) },
|
|
376
|
-
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 } } }),
|
|
377
399
|
success_url: `${origin}/shop/thanks?session_id={CHECKOUT_SESSION_ID}${back}`,
|
|
378
400
|
cancel_url: `${origin}/shop/?cancelled=1&order=${order.id}${back}`,
|
|
379
401
|
// Keep Stripe’s default 24-hour Checkout lifetime: https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-expires_at
|
|
@@ -415,8 +437,38 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
|
|
|
415
437
|
}
|
|
416
438
|
await Promise.all(rows.map(async (row) => {
|
|
417
439
|
if (!row.session) {
|
|
418
|
-
|
|
419
|
-
|
|
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;
|
|
420
472
|
}
|
|
421
473
|
if (expireOpen && row.status !== 'started') return;
|
|
422
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; }
|
|
@@ -459,15 +511,31 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
|
|
|
459
511
|
}
|
|
460
512
|
|
|
461
513
|
/** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
|
|
462
|
-
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() }) {
|
|
463
515
|
lines ??= [{ item, amount, quantity: 1 }];
|
|
464
|
-
const keyless = amount > 0 && !hasStripeKey(env);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
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}}));
|
|
470
537
|
const order = { id: newOrderId(), player: player.id, amount, currency: shop.currency };
|
|
538
|
+
if (booking && reference === null) reference = order.id;
|
|
471
539
|
const now = Date.now();
|
|
472
540
|
order.createdAt = now;
|
|
473
541
|
order.expiresAt = Math.floor(now / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
|
|
@@ -477,7 +545,9 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
477
545
|
lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
|
|
478
546
|
const reserve = async () => {
|
|
479
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 })] : []),
|
|
480
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))] : []),
|
|
481
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)")
|
|
482
552
|
.bind(line.id, order.id, i, line.item.id, line.quantity, line.amount, line.amount * line.quantity, JSON.stringify(line.item))),
|
|
483
553
|
env.DB.prepare('UPDATE shop_orders SET checkout_player = player WHERE id = ?1').bind(order.id),
|
|
@@ -485,14 +555,18 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
485
555
|
return results[0];
|
|
486
556
|
};
|
|
487
557
|
await reconcileOrders(env, shop, player.id, { expireOpen: true, before: Math.min(requestAt, now - 60000) });
|
|
488
|
-
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
|
+
}
|
|
489
563
|
const reconciled = !reserved.meta?.changes;
|
|
490
564
|
if (!reserved.meta?.changes) {
|
|
491
565
|
await reconcileOrders(env, shop, player.id);
|
|
492
566
|
reserved = await reserve();
|
|
493
567
|
}
|
|
494
568
|
if (!reserved.meta?.changes) return { error: fail(403, 'cap', WAY_WORDS.cap, { way: 'cap' }) };
|
|
495
|
-
if (amount === 0) {
|
|
569
|
+
if (amount === 0 && !lines.some(l => l.item.interval)) {
|
|
496
570
|
const id = `cs_free_${order.id}`;
|
|
497
571
|
await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1').bind(order.id, id).run();
|
|
498
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' });
|
|
@@ -505,7 +579,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
505
579
|
let session = null;
|
|
506
580
|
const mode = shopMode(env);
|
|
507
581
|
try {
|
|
508
|
-
const useCatalog = Array.isArray(shop.catalog) && shop.catalog.includes(mode);
|
|
582
|
+
const useCatalog = !booking && Array.isArray(shop.catalog) && shop.catalog.includes(mode);
|
|
509
583
|
for (const line of lines) line.product = useCatalog ? await productIdOf(cat.studio?.slug, line.item.id) : null;
|
|
510
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 }))));
|
|
511
585
|
// Start the provider lifetime at session creation, after reservation/expiry I/O. At Stripe's
|
|
@@ -515,7 +589,9 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
515
589
|
return sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, lines });
|
|
516
590
|
};
|
|
517
591
|
try {
|
|
518
|
-
|
|
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}` });
|
|
519
595
|
} catch (error) {
|
|
520
596
|
if (!useCatalog || !isMissingProduct(error)) throw error;
|
|
521
597
|
for (const line of lines) line.product = null;
|
|
@@ -527,7 +603,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
527
603
|
}
|
|
528
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();
|
|
529
605
|
} catch (error) {
|
|
530
|
-
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();
|
|
531
607
|
const why = error instanceof StripeError ? redactStripe(error.message, env) : 'Stripe did not answer';
|
|
532
608
|
return { error: fail(502, 'stripe', `Stripe could not open the checkout (${why}). Nothing was charged.`) };
|
|
533
609
|
}
|
|
@@ -553,7 +629,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
553
629
|
if (b.error) return b.error;
|
|
554
630
|
const reference = b.body.reference ?? null;
|
|
555
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');
|
|
556
|
-
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));
|
|
557
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 });
|
|
558
634
|
const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
|
|
559
635
|
// Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
|
|
@@ -584,6 +660,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
584
660
|
lines.push({ item, amount: unit, quantity });
|
|
585
661
|
}
|
|
586
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.');
|
|
587
664
|
let cookies = [];
|
|
588
665
|
if (!player) {
|
|
589
666
|
const guest = await players.shopGuest(request, env, url, shop.guestBuyersPerAddressPerHour);
|
|
@@ -591,8 +668,18 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
591
668
|
player = guest.session.player;
|
|
592
669
|
cookies = guest.session.cookies;
|
|
593
670
|
}
|
|
594
|
-
|
|
595
|
-
|
|
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 } : {}) });
|
|
596
683
|
if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
|
|
597
684
|
for (const cookie of cookies) response.headers.append('set-cookie', cookie);
|
|
598
685
|
return response;
|
|
@@ -745,12 +832,41 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
745
832
|
if (payment) await syncRefunds(env, payment);
|
|
746
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 }); }
|
|
747
834
|
}
|
|
835
|
+
needed.push('payment_link.updated','invoice.paid','invoice.payment_failed','customer.subscription.updated','customer.subscription.deleted');
|
|
748
836
|
if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
|
|
749
837
|
if (typeof ev.livemode === 'boolean' && (hasStripeKey(env) || linkConfig(env)) && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
|
|
750
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 });
|
|
751
862
|
const sessionEvent = ev.type.startsWith('checkout.session.');
|
|
752
863
|
if (!sessionEvent) await rememberMoneyEvent(env, ev);
|
|
753
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
|
+
}
|
|
754
870
|
let known = sessionEvent ? await orderBySession(env, obj.id) : await orderByPayment(env, String(obj.payment_intent ?? ''));
|
|
755
871
|
if (!known) {
|
|
756
872
|
let metadata = obj.metadata;
|
|
@@ -836,7 +952,12 @@ async function bindLinkSession(env, session, mode) {
|
|
|
836
952
|
const source = await orderById(env, session.client_reference_id);
|
|
837
953
|
if (!source || source.mode !== mode) return false;
|
|
838
954
|
const lines = await orderLines(env, source.id);
|
|
839
|
-
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
|
+
}
|
|
840
961
|
const item = JSON.parse(lines[0].snapshot), link = item.paymentLink;
|
|
841
962
|
if (!link || (session.payment_link?.id ?? session.payment_link) !== link.id || session.metadata?.revision !== link.revision || session.currency !== source.currency) return false;
|
|
842
963
|
const amount = session.amount_subtotal;
|
|
@@ -849,12 +970,15 @@ async function bindLinkSession(env, session, mode) {
|
|
|
849
970
|
let order = await orderBySession(env, session.id);
|
|
850
971
|
if (!order) {
|
|
851
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;
|
|
852
974
|
await env.DB.batch([
|
|
853
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()),
|
|
854
|
-
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),
|
|
855
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})] : []),
|
|
856
979
|
]);
|
|
857
980
|
order = await orderBySession(env, session.id);
|
|
981
|
+
|
|
858
982
|
}
|
|
859
983
|
if (!order) return false;
|
|
860
984
|
if (order.paid_at === null) await env.DB.batch([
|
|
@@ -864,6 +988,40 @@ async function bindLinkSession(env, session, mode) {
|
|
|
864
988
|
return true;
|
|
865
989
|
}
|
|
866
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
|
+
|
|
867
1025
|
/** A paid checkout: the order is paid, its entitlements granted, its payment recorded. Idempotent. */
|
|
868
1026
|
export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
869
1027
|
const o = await orderBySession(env, session.id);
|
|
@@ -893,12 +1051,18 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
893
1051
|
if (!Number.isSafeInteger(total) || !Number.isSafeInteger(tax) || total < 0 || tax < 0 || total !== Number(o.amount) + tax) return 'mismatch';
|
|
894
1052
|
let stripeLines = null;
|
|
895
1053
|
if (lines.length > 1 && tax) {
|
|
896
|
-
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 {
|
|
897
1060
|
// Use Stripe's actual tax per line, never spread tax across products with different tax codes.
|
|
898
1061
|
// https://docs.stripe.com/api/checkout/sessions/line_items
|
|
899
1062
|
const response = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(session.id)}/line_items`, { limit: 100, expand: ['data.price.product'] });
|
|
900
1063
|
if (response.has_more || response.data?.length !== lines.length) throw new Error('Stripe line totals are incomplete');
|
|
901
1064
|
stripeLines = [...response.data];
|
|
1065
|
+
}
|
|
902
1066
|
}
|
|
903
1067
|
const writes = [];
|
|
904
1068
|
let lineTotal = 0;
|
|
@@ -913,7 +1077,7 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
913
1077
|
let gross = lines.length === 1 ? total : Number(line.amount);
|
|
914
1078
|
if (stripeLines) {
|
|
915
1079
|
const index = stripeLines.findIndex((l) => l.quantity === line.quantity && l.amount_subtotal === line.amount &&
|
|
916
|
-
(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));
|
|
917
1081
|
if (index < 0) throw new Error('Stripe line identity mismatch');
|
|
918
1082
|
gross = stripeLines.splice(index, 1)[0].amount_total;
|
|
919
1083
|
}
|
|
@@ -940,6 +1104,7 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
940
1104
|
if (['lost', 'won', 'warning_closed'].includes(d.status)) await disputeClosed(env, paymentId, d.status);
|
|
941
1105
|
}
|
|
942
1106
|
}
|
|
1107
|
+
if (lines.some(l => l.snapshot && JSON.parse(l.snapshot).interval)) await bindSubscription(env, await orderById(env,o.id), session, shop);
|
|
943
1108
|
await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run();
|
|
944
1109
|
return result.at(-1)?.meta?.changes ? 'paid' : 'already';
|
|
945
1110
|
}
|
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.1';
|