@homie-rocks/studio 0.52.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/apps/apps.ts +17 -0
- package/bin/homie-studio.mjs +9 -3
- package/docs/shop-migration.md +9 -0
- package/docs/toolkit-batch.md +49 -0
- package/functions/FUNCTIONS.md +15 -1
- package/guides/app/PLACES.md +23 -0
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +27 -11
- package/lib/app-ready.mjs +1 -1
- package/lib/build.mjs +4 -3
- package/lib/check.mjs +47 -15
- package/lib/dev.mjs +9 -1
- package/lib/fork.mjs +47 -0
- package/lib/jobs.mjs +1 -0
- package/lib/mcp-tools.mjs +1 -1
- package/lib/needs-headless.mjs +4 -3
- package/lib/scaffold.mjs +27 -1
- package/lib/shop-links.mjs +39 -21
- package/lib/shop-mcp.mjs +13 -1
- package/lib/stripe-connect.mjs +37 -2
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +25 -3
- package/lib/worker-config.mjs +1 -0
- package/package.json +1 -1
- package/rules/rules.ts +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/shop/shop.ts +3 -3
- package/tools/tools.ts +3 -2
- package/worker/app-format.mjs +6 -3
- package/worker/app-records.mjs +20 -6
- package/worker/app-shell.mjs +10 -5
- package/worker/app-streams.mjs +26 -9
- package/worker/app-tools.mjs +2 -1
- package/worker/app-web.mjs +23 -0
- package/worker/booking-calendar.mjs +26 -0
- package/worker/booking-holds.mjs +18 -5
- package/worker/booking-options.mjs +23 -0
- package/worker/booking-resources.mjs +55 -0
- package/worker/build-id.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/customer-mcp.mjs +1 -1
- package/worker/event-resources.mjs +6 -1
- package/worker/index.mjs +74 -27
- package/worker/local-test.mjs +10 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +20 -2
- package/worker/office.mjs +108 -6
- package/worker/operational-schema.mjs +30 -0
- package/worker/pages.mjs +7 -1
- package/worker/records-page.mjs +2 -2
- package/worker/request-db.mjs +53 -0
- package/worker/servers.mjs +5 -4
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +68 -8
- package/worker/version.mjs +1 -1
package/worker/servers.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* SERVERS (@homie-rocks/studio 0.16.0; NETPLAY.md section 17): a named, lasting pool of rooms for one game, with its
|
|
3
4
|
* own policy, its own door and its own Lobby pool. Strangers are matched only inside one server, never across.
|
|
@@ -171,8 +172,8 @@ const serverCache = new WeakMap();
|
|
|
171
172
|
/** Every server row of every game (D1), read at most every 5 s per Worker instance; an empty map before migration 0006. */
|
|
172
173
|
async function rowsOf(env, { fresh = false } = {}) {
|
|
173
174
|
if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
|
|
174
|
-
const hit = serverCache.get(env.DB);
|
|
175
|
-
if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
175
|
+
const hit = serverCache.get(databaseBinding(env.DB));
|
|
176
|
+
if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
176
177
|
let map = new Map();
|
|
177
178
|
try {
|
|
178
179
|
const { results } = await env.DB.prepare('SELECT * FROM servers LIMIT 2000').all();
|
|
@@ -182,10 +183,10 @@ async function rowsOf(env, { fresh = false } = {}) {
|
|
|
182
183
|
map.set(r.game, list);
|
|
183
184
|
}
|
|
184
185
|
} catch { map = new Map(); }
|
|
185
|
-
serverCache.set(env.DB, { at: Date.now(), map });
|
|
186
|
+
serverCache.set(databaseBinding(env.DB), { at: Date.now(), map });
|
|
186
187
|
return map;
|
|
187
188
|
}
|
|
188
|
-
export const forgetServers = (env) => { if (env?.DB) serverCache.delete(env.DB); };
|
|
189
|
+
export const forgetServers = (env) => { if (env?.DB) serverCache.delete(databaseBinding(env.DB)); };
|
|
189
190
|
|
|
190
191
|
/**
|
|
191
192
|
* A game's servers: `public` first, then the rest by when they were made. The game.json seeds (the catalogue's
|
package/worker/shop-links.mjs
CHANGED
|
@@ -55,5 +55,5 @@ export async function loadShopLinks(env, origin='https://studio.invalid') {
|
|
|
55
55
|
const response=await env.ASSETS.fetch(new Request(new URL(env.STRIPE_SHOP_LINKS_ASSET,origin)));
|
|
56
56
|
if(response.ok)value=await response.json();
|
|
57
57
|
} catch {}
|
|
58
|
-
return {...env,STRIPE_SHOP_LINKS:JSON.stringify(value?.config??null),STRIPE_SHOP_SETUP:value?.next??'Ask your AI to finish stripe_shop_plan with verify:true.'};
|
|
58
|
+
return {...env,STRIPE_SHOP_LINKS:JSON.stringify(value?.config??null),...(value?.purchases?{PURCHASE_LINKS:JSON.stringify(value.purchases)}:{}),STRIPE_SHOP_SETUP:value?.next??'Ask your AI to finish stripe_shop_plan with verify:true.'};
|
|
59
59
|
}
|
package/worker/shop-page.mjs
CHANGED
|
@@ -298,6 +298,7 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
298
298
|
(function poll() {
|
|
299
299
|
tries += 1;
|
|
300
300
|
api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
|
|
301
|
+
if (r.status === 'review') { $('[data-thanks-title]').textContent='Payment received'; $('[data-thanks-line]').textContent=r.note || 'Staff are reviewing your payment and will arrange fulfillment or a refund.'; return; }
|
|
301
302
|
if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; if (r.simulated) { $('[data-thanks-title]').textContent = 'Simulation confirmed'; $('[data-thanks-line]').textContent = 'This was an offline test. No money moved and no Stripe receipt was sent.'; } return; }
|
|
302
303
|
if (tries < 40) setTimeout(poll, 1500);
|
|
303
304
|
else $('[data-thanks-line]').textContent = boot.giving ? 'Stripe has not confirmed your gift yet. Check your email for its receipt.' : 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
|
package/worker/shop-rules.mjs
CHANGED
|
@@ -183,10 +183,11 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
|
|
|
183
183
|
else if (Array.isArray(games) && !games.includes(String(it.game))) err(`${at}.game`, `there is no game "${it.game}" in games/`);
|
|
184
184
|
}
|
|
185
185
|
if (it.taxCode !== undefined && !TAX_CODE.test(String(it.taxCode))) err(`${at}.taxCode`, 'taxCode is a Stripe product tax code such as "txcd_10000000"');
|
|
186
|
+
if(it.maxQuantity!==undefined && (!Number.isSafeInteger(it.maxQuantity)||it.maxQuantity<1||it.maxQuantity>999999))err(`${at}.maxQuantity`,'use a whole number from 1 to 999999');
|
|
186
187
|
const advantage = it.advantage === true;
|
|
187
188
|
const badge = gives.find((g) => g.startsWith('badge:'));
|
|
188
189
|
for (const key of Object.keys(it)) {
|
|
189
|
-
if (!['id', 'kind', 'name', 'blurb', 'description', 'interval', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
|
|
190
|
+
if (!['id', 'kind', 'name', 'blurb', 'description', 'interval', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage', 'maxQuantity'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
|
|
190
191
|
}
|
|
191
192
|
items.push({
|
|
192
193
|
id, kind, name, ...(it.interval === 'month' ? { interval: 'month' } : {}), ...(blurb ? { blurb } : {}),
|
|
@@ -194,7 +195,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
|
|
|
194
195
|
gives, ...(badge ? { badge: badgeWord(badge, it) } : {}),
|
|
195
196
|
...(days !== null ? { days } : {}), ...(Number.isFinite(starts) ? { starts: new Date(starts).toISOString() } : {}), ...(Number.isFinite(ends) ? { ends: new Date(ends).toISOString() } : {}),
|
|
196
197
|
...(it.game ? { game: String(it.game) } : {}), ...(it.taxCode ? { taxCode: String(it.taxCode) } : {}),
|
|
197
|
-
advantage,
|
|
198
|
+
advantage, ...(it.maxQuantity!==undefined?{maxQuantity:it.maxQuantity}:{}),
|
|
198
199
|
});
|
|
199
200
|
});
|
|
200
201
|
|
package/worker/shop-store.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {databaseBinding} from './request-db.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* The shop's rows in the studio's own D1 (@homie-rocks/studio 0.24.0, migration 0008_studio_shop.sql). Kept apart
|
|
3
4
|
* from worker/shop.mjs so player accounts (worker/players.mjs) can export and forget a player's purchases without
|
|
@@ -137,7 +138,7 @@ const completeSchemas = new WeakSet();
|
|
|
137
138
|
/** Name the first missing schema migration, including a present table with an older shape. */
|
|
138
139
|
export async function migrationNeeded(env) {
|
|
139
140
|
if (!env?.DB) return SHOP_MIGRATION_FILE;
|
|
140
|
-
if (completeSchemas.has(env.DB)) return null;
|
|
141
|
+
if (completeSchemas.has(databaseBinding(env.DB))) return null;
|
|
141
142
|
for (const [file, query] of [
|
|
142
143
|
[SHOP_MIGRATION_FILE, 'SELECT 1 FROM shop_orders LIMIT 1'],
|
|
143
144
|
[SHOP_RESERVATIONS_FILE, 'SELECT expires_at FROM shop_orders LIMIT 1'],
|
|
@@ -148,7 +149,7 @@ export async function migrationNeeded(env) {
|
|
|
148
149
|
]) {
|
|
149
150
|
try { await env.DB.prepare(query).first(); } catch { return file; }
|
|
150
151
|
}
|
|
151
|
-
completeSchemas.add(env.DB);
|
|
152
|
+
completeSchemas.add(databaseBinding(env.DB));
|
|
152
153
|
return null;
|
|
153
154
|
}
|
|
154
155
|
|
|
@@ -244,7 +245,7 @@ export const orderByPayment = (env, payment) => env.DB.prepare('SELECT * FROM sh
|
|
|
244
245
|
export function orderView(r) {
|
|
245
246
|
return {
|
|
246
247
|
id: r.id, refundedAmount: Number(r.refunded_amount ?? 0), item: r.item, game: r.game ?? null, amount: Number(r.amount), currency: r.currency, tax: r.tax === null ? null : Number(r.tax), total: r.total === null ? null : Number(r.total),
|
|
247
|
-
status: r.status, till: r.till, mode: r.mode, parent: Number(r.parent) === 1,
|
|
248
|
+
status: r.status, ...(r.status==='review'?{note:'Payment received. Staff will reconcile the payment or refund it.'}:{}), till: r.till, mode: r.mode, parent: Number(r.parent) === 1,
|
|
248
249
|
createdAt: Number(r.created_at), paidAt: r.paid_at === null ? null : Number(r.paid_at), refundedAt: r.refunded_at === null ? null : Number(r.refunded_at),
|
|
249
250
|
};
|
|
250
251
|
}
|
package/worker/shop.mjs
CHANGED
|
@@ -347,7 +347,7 @@ async function orderRoute(request, env, url, shop) {
|
|
|
347
347
|
} catch (error) { logOrderError(env, o.id, error); }
|
|
348
348
|
}
|
|
349
349
|
const reference = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-reference:${o.id}`).first();
|
|
350
|
-
return json({ ok: true, order: o.id, simulated: apiBase(env) !== STRIPE_API, status: playerOrderStatus(o), item: o.item, game: o.game ?? null, reference: reference?.value ?? null });
|
|
350
|
+
return json({ ok: true, order: o.id, simulated: apiBase(env) !== STRIPE_API, status: playerOrderStatus(o), ...(o.status==='review'?{note:'Payment received. Staff will reconcile the payment or refund it.'}:{}), item: o.item, game: o.game ?? null, reference: reference?.value ?? null });
|
|
351
351
|
}
|
|
352
352
|
|
|
353
353
|
/** The neutral age question: a year of birth, once. Nothing hints which answer opens anything; no default. */
|
|
@@ -524,14 +524,13 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
524
524
|
if(keyless && lines.length>1 && !menu)return {error:fail(409,'menu','These items need separate Stripe menus. Custom amounts and monthly gifts use their own links.')};
|
|
525
525
|
if(keyless && (!link || !paymentLinkURL(link.url)))return {error:fail(503,'payment-links','Ask the owner’s AI to sync the shop and published booking slots.')};
|
|
526
526
|
if(keyless && booking) {
|
|
527
|
-
if(booking.quantity!==1)return {error:fail(400,'quantity','Book one seat per checkout; Stripe counts completed sessions.')};
|
|
528
527
|
const slot=await env.DB.prepare('SELECT value FROM app_records WHERE app=?1 AND collection=?2 AND id=?3').bind(booking.app,booking.slots,booking.slot).first();
|
|
529
528
|
if(!slot || JSON.parse(slot.value).capacity!==link.capacity)return {error:fail(409,'slot-sync','This slot changed; ask the owner’s AI to sync its Stripe link.')};
|
|
530
529
|
const confirmed=await env.DB.prepare("SELECT COALESCE(SUM(quantity),0) n FROM shop_bookings WHERE app=?1 AND slots=?2 AND slot=?3 AND state='confirmed'").bind(booking.app,booking.slots,booking.slot).first();
|
|
531
530
|
const update=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
|
|
532
531
|
const observed=update?JSON.parse(update.value):null;
|
|
533
532
|
const status=observed && observed.capacity===(link.limit??link.capacity) ? observed : link;
|
|
534
|
-
if(!status.active || status.count>=(link.limit??link.capacity) || confirmed.n
|
|
533
|
+
if(!status.active || status.count>=(link.limit??link.capacity) || confirmed.n+booking.quantity>link.capacity)return {error:fail(409,'capacity','That slot is full. Choose another slot.')};
|
|
535
534
|
}
|
|
536
535
|
if(keyless && item.price==='choose' && lines[0].quantity!==1)return {error:fail(400,'quantity','A tip is one chosen amount; choose it on Stripe.')};
|
|
537
536
|
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.')};
|
|
@@ -577,7 +576,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
577
576
|
}
|
|
578
577
|
if (keyless) {
|
|
579
578
|
const checkout = new URL(link.url); checkout.searchParams.set('client_reference_id', order.id);
|
|
580
|
-
if(menu?.basket)checkout.searchParams.set('prefilled_amount',String(amount));
|
|
579
|
+
if(menu?.basket || item.kind==='deposit')checkout.searchParams.set('prefilled_amount',String(amount));
|
|
581
580
|
return { order, url: checkout.href, reconciled };
|
|
582
581
|
}
|
|
583
582
|
let session = null;
|
|
@@ -649,7 +648,10 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
649
648
|
const item = shop.items.find((i) => i.id === entry?.item);
|
|
650
649
|
if (!item) return fail(404, 'item', 'no such item here');
|
|
651
650
|
const quantity = entry.quantity ?? 1;
|
|
651
|
+
if (item.maxQuantity!==undefined&&quantity>item.maxQuantity)return fail(400,'quantity','This quantity exceeds the studio item maximum.');
|
|
652
652
|
if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
|
|
653
|
+
if(!hasStripeKey(env) && ['supporter','deposit'].includes(item.kind) && quantity!==1)return fail(400,'quantity','This payment link has quantity fixed at one.');
|
|
654
|
+
if(item.kind==='deposit' && (!reference || !Number.isSafeInteger(entry.amount??item.price) || (entry.amount??item.price)<=0))return fail(400,'deposit','A deposit requires the app reference and its saved positive amount.');
|
|
653
655
|
if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
|
|
654
656
|
seen.add(item.id);
|
|
655
657
|
const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
|
|
@@ -679,6 +681,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
679
681
|
const collection = requestedBooking?.collection;
|
|
680
682
|
const spec = app?.records?.collections?.[collection]?.booking;
|
|
681
683
|
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.');
|
|
684
|
+
if(spec.resources&&requestedBooking.hold===undefined)return fail(409,'booking','Reserve the resource through the booking tool and pass its hold before checkout.');
|
|
682
685
|
const local = await localSession(request, env);
|
|
683
686
|
booking = { app: local?.app === game && local.scope ? `${game}:check:${local.scope}` : game, collection, slots: spec.slots, slot: requestedBooking.slot, quantity: lines[0].quantity };
|
|
684
687
|
if(requestedBooking.hold!==undefined){
|
|
@@ -713,6 +716,7 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
|
|
|
713
716
|
const item = shop.items.find((i) => i.id === entry?.item);
|
|
714
717
|
if (!item) return fail(404, 'item', 'no such item here');
|
|
715
718
|
const quantity = entry.quantity ?? 1;
|
|
719
|
+
if (item.maxQuantity!==undefined&&quantity>item.maxQuantity)return fail(400,'quantity','This quantity exceeds the studio item maximum.');
|
|
716
720
|
if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
|
|
717
721
|
if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
|
|
718
722
|
seen.add(item.id);
|
|
@@ -874,7 +878,7 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
874
878
|
if (subscriptionResult !== null) return json({ ok: true, did: subscriptionResult });
|
|
875
879
|
const sessionEvent = ev.type.startsWith('checkout.session.');
|
|
876
880
|
if (!sessionEvent) await rememberMoneyEvent(env, ev);
|
|
877
|
-
if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({
|
|
881
|
+
if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({ok:true,did:await reviewLinkPayment(env,obj,ev.livemode?'live':'test')?'review':'mismatch'});
|
|
878
882
|
if (sessionEvent && obj.metadata?.order && !obj.payment_link) {
|
|
879
883
|
await env.DB.prepare(`UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL AND mode=?3
|
|
880
884
|
AND EXISTS(SELECT 1 FROM meta WHERE key='booking-checkout:' || shop_orders.id)`)
|
|
@@ -947,20 +951,64 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
947
951
|
default:
|
|
948
952
|
break;
|
|
949
953
|
}
|
|
954
|
+
if(did==='mismatch'&&obj.payment_link&&await reviewLinkPayment(env,obj,ev.livemode?'live':'test'))did='review';
|
|
950
955
|
if (did === 'needs-keyed-recovery') return fail(503, did, 'This earlier multi-item checkout needs its original connection to recover tax per line. Reconnect it, then resend this event.');
|
|
951
956
|
if (did === 'missing-item' || did === 'unknown-order') return fail(503, did, did === 'missing-item' ? 'Restore this order item in shop.json to grant it; the office can refund its payment.' : 'Payment order is not recorded yet; retry this event.');
|
|
952
957
|
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();
|
|
953
958
|
if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
|
|
954
959
|
return json({ ok: true, did });
|
|
955
960
|
} catch (error) {
|
|
961
|
+
if(/(?:booking|resource) capacity unavailable/.test(error.message)&&obj.payment_link&&await reviewLinkPayment(env,obj,ev.livemode?'live':'test','The paid group exceeds remaining capacity. Staff will arrange a replacement or refund.'))return json({ok:true,did:'review'});
|
|
956
962
|
logOrderError(env, null, error);
|
|
957
963
|
return fail(503, 'stripe-retry', 'Payment reconciliation is temporarily unavailable; retry this event.');
|
|
958
964
|
}
|
|
959
965
|
}
|
|
960
966
|
|
|
967
|
+
/** Retain a verified payment even when its frozen order cannot be fulfilled. No grants. */
|
|
968
|
+
async function reviewLinkPayment(env,session,mode,reason='The paid amount differs from the order. Staff will reconcile the payment or refund it.') {
|
|
969
|
+
if(session.payment_status!=='paid'||!/^cs_[A-Za-z0-9_]+$/.test(session.id??'')||!Number.isSafeInteger(session.amount_total)||session.amount_total<0)return false;
|
|
970
|
+
if((await orderBySession(env,session.id))?.status==='review')return true;
|
|
971
|
+
const source=await orderById(env,session.client_reference_id);
|
|
972
|
+
const lines=source?await orderLines(env,source.id):[],link=lines[0]?.snapshot&&JSON.parse(lines[0].snapshot).paymentLink;
|
|
973
|
+
const linkId=session.payment_link?.id??session.payment_link,config=linkConfig(env);
|
|
974
|
+
const owned=[...(config?.items??[]),...(config?.menus??[]),...(config?.slots??[])].some(l=>l.id===linkId) || Boolean(await env.DB.prepare("SELECT 1 FROM shop_order_lines WHERE json_extract(snapshot,'$.paymentLink.id')=?1 LIMIT 1").bind(linkId).first());
|
|
975
|
+
if(!owned)return false;
|
|
976
|
+
if(!source||source.mode!==mode||session.currency!==source.currency||!link||link.id!==linkId||session.metadata?.revision!==link.revision){
|
|
977
|
+
const now=Date.now(),id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`,payment=typeof session.payment_intent==='string'?session.payment_intent:session.payment_intent?.id;
|
|
978
|
+
const note='Payment received; missing or mismatched order context. Staff must reconcile or refund it.';
|
|
979
|
+
await env.DB.batch([
|
|
980
|
+
env.DB.prepare("INSERT OR IGNORE INTO shop_orders(id,item,amount,total,currency,till,mode,status,session,payment,created_at,updated_at,attention,note) VALUES(?1,'payment-review',?2,?2,?3,'stripe',?4,'review',?5,?6,?7,?7,1,?8)").bind(id,session.amount_total,session.currency,mode,session.id,payment??null,now,note),
|
|
981
|
+
env.DB.prepare('INSERT OR IGNORE INTO meta(key,value) VALUES(?1,?2)').bind('payment-review:'+session.id,JSON.stringify({session:session.id,receivedAt:now,payment,amount:session.amount_total,currency:session.currency,reference:session.client_reference_id,link:linkId,reason:note})),
|
|
982
|
+
env.DB.prepare("INSERT OR IGNORE INTO studio_events(id,type,data,at) VALUES(?1,'payment.review',?2,?3)").bind('payment-review:'+session.id,JSON.stringify({order:id,payment,amount:session.amount_total,currency:session.currency,note}),now),
|
|
983
|
+
]);
|
|
984
|
+
return true;
|
|
985
|
+
}
|
|
986
|
+
await env.DB.prepare('UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL').bind(source.id,session.id).run();
|
|
987
|
+
let order=await orderBySession(env,session.id);
|
|
988
|
+
if(!order) {
|
|
989
|
+
const id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`;
|
|
990
|
+
await env.DB.batch([
|
|
991
|
+
env.DB.prepare("INSERT OR IGNORE INTO shop_orders(id,player,checkout_player,item,game,amount,currency,till,mode,status,parent,created_at,updated_at,session) SELECT ?2,player,checkout_player,item,game,amount,currency,till,mode,'started',parent,?4,?4,?3 FROM shop_orders WHERE id=?1").bind(source.id,id,session.id,Date.now()),
|
|
992
|
+
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}`),
|
|
993
|
+
...lines.map((l,i)=>env.DB.prepare('INSERT OR IGNORE INTO shop_order_lines(id,order_id,position,item,quantity,unit_amount,amount,snapshot) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)').bind(`${id}_${i}`,id,i,l.item,l.quantity,l.unit_amount,l.amount,l.snapshot)),
|
|
994
|
+
]);order=await orderBySession(env,session.id);
|
|
995
|
+
}
|
|
996
|
+
if(order.status==='review')return true;
|
|
997
|
+
if(order.paid_at!==null)return false;
|
|
998
|
+
const payment=typeof session.payment_intent==='string'?session.payment_intent:session.payment_intent?.id;
|
|
999
|
+
const note='Payment received. '+reason,now=Date.now();
|
|
1000
|
+
await env.DB.batch([
|
|
1001
|
+
env.DB.prepare("UPDATE shop_orders SET status='review',payment=?2,total=?3,updated_at=?4,attention=1,note=?5 WHERE id=?1 AND paid_at IS NULL").bind(order.id,payment??null,session.amount_total,now,note),
|
|
1002
|
+
env.DB.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES(?1,?2)").bind('payment-review:'+session.id,JSON.stringify({session:session.id,receivedAt:now,payment,amount:session.amount_total,currency:session.currency,expected:source.amount,reason})),
|
|
1003
|
+
env.DB.prepare("INSERT OR IGNORE INTO studio_events(id,type,data,at) VALUES(?1,'payment.review',?2,?3)").bind('payment-review:'+session.id,JSON.stringify({order:order.id,payment,amount:session.amount_total,currency:session.currency,note}),now),
|
|
1004
|
+
]);
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
961
1008
|
/** Payment Links can be reopened. Each signed Session becomes its own order; the URL reference
|
|
962
1009
|
* selects a frozen local intent, never an entitlement or a buyer-supplied price. */
|
|
963
1010
|
async function bindLinkSession(env, session, mode) {
|
|
1011
|
+
if(session.id&&(await orderBySession(env,session.id))?.status==='review')return false;
|
|
964
1012
|
if (!/^ord_[A-Za-z0-9]{20}$/.test(session.client_reference_id ?? '') || !/^cs_[A-Za-z0-9_]+$/.test(session.id ?? '')) return false;
|
|
965
1013
|
const source = await orderById(env, session.client_reference_id);
|
|
966
1014
|
if (!source || source.mode !== mode) return false;
|
|
@@ -976,8 +1024,10 @@ async function bindLinkSession(env, session, mode) {
|
|
|
976
1024
|
if (!link || (session.payment_link?.id ?? session.payment_link) !== link.id || session.metadata?.revision !== link.revision || session.currency !== source.currency) return false;
|
|
977
1025
|
const amount = session.amount_subtotal;
|
|
978
1026
|
if (!Number.isSafeInteger(amount) || amount <= 0) return false;
|
|
1027
|
+
if(item.kind==='deposit' && amount!==source.amount)return false;
|
|
979
1028
|
const quantity = item.price === 'choose' ? 1 : amount / item.price;
|
|
980
|
-
if
|
|
1029
|
+
if(['supporter','deposit'].includes(item.kind) && quantity!==1)return false;
|
|
1030
|
+
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > (item.maxQuantity??link.maxQuantity??999999)) return false;
|
|
981
1031
|
if (item.price === 'choose' && (amount < item.min || item.max !== null && amount > item.max)) return false;
|
|
982
1032
|
// Claim the first session atomically. A repeated URL creates a separate paid order, never loses a payment.
|
|
983
1033
|
await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1 AND session IS NULL').bind(source.id, session.id).run();
|
|
@@ -989,13 +1039,14 @@ async function bindLinkSession(env, session, mode) {
|
|
|
989
1039
|
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()),
|
|
990
1040
|
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),
|
|
991
1041
|
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),
|
|
992
|
-
...(booking ? [bookingStatement(env,{...booking,slot:link.slot,slots:link.slots,id,order:id,pending:true})] : []),
|
|
1042
|
+
...(booking ? [bookingStatement(env,{...booking,quantity,slot:link.slot,slots:link.slots,id,order:id,pending:true})] : []),
|
|
993
1043
|
]);
|
|
994
1044
|
order = await orderBySession(env, session.id);
|
|
995
1045
|
|
|
996
1046
|
}
|
|
997
1047
|
if (!order) return false;
|
|
998
1048
|
if (order.paid_at === null) await env.DB.batch([
|
|
1049
|
+
...(link.slot?[env.DB.prepare("UPDATE shop_bookings SET quantity=?2 WHERE order_id=?1 AND state='pending'").bind(order.id,quantity)]:[]),
|
|
999
1050
|
env.DB.prepare('UPDATE shop_orders SET amount = ?2 WHERE id = ?1 AND paid_at IS NULL').bind(order.id, amount),
|
|
1000
1051
|
env.DB.prepare('UPDATE shop_order_lines SET quantity = ?2, unit_amount = ?3, amount = ?4 WHERE order_id = ?1 AND EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND paid_at IS NULL)').bind(order.id, quantity, amount / quantity, amount),
|
|
1001
1052
|
]);
|
|
@@ -1028,7 +1079,7 @@ async function paidBasket(env,shop,o,lines,session,paidAt) {
|
|
|
1028
1079
|
if(session.payment_status!=='paid' || session.currency!==o.currency || !Number.isSafeInteger(total) || total<=0 || session.amount_subtotal!==total)return 'mismatch';
|
|
1029
1080
|
if(o.paid_at!==null)return 'already';
|
|
1030
1081
|
const expected=lines.reduce((n,l)=>n+Number(l.amount),0);
|
|
1031
|
-
if(!Number.isSafeInteger(expected)||expected<=0)return 'mismatch';
|
|
1082
|
+
if(!Number.isSafeInteger(expected)||expected<=0||total!==expected)return 'mismatch';
|
|
1032
1083
|
const writes=[];let cumulative=0,allocated=0;
|
|
1033
1084
|
for(const l of lines) {
|
|
1034
1085
|
cumulative+=Number(l.amount);
|
|
@@ -1060,6 +1111,7 @@ async function bindMenuSession(env,session,source,lines) {
|
|
|
1060
1111
|
for(const l of invoice.lines?.data??[]) {
|
|
1061
1112
|
const line=lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price));
|
|
1062
1113
|
if(!line || !Number.isSafeInteger(l.quantity) || l.quantity<1 || l.amount!==JSON.parse(line.snapshot).price*l.quantity)throw new Error('Unknown invoice line');
|
|
1114
|
+
if(l.quantity>(JSON.parse(line.snapshot).maxQuantity??link.items?.find(i=>i.item===line.item)?.maxQuantity??999999))return false;
|
|
1063
1115
|
paidLines.push({...line,quantity:l.quantity,amount:l.amount});
|
|
1064
1116
|
}
|
|
1065
1117
|
if(!paidLines.length || paidLines.reduce((n,l)=>n+l.amount,0)!==session.amount_subtotal)throw new Error('Invoice subtotal mismatch');
|
|
@@ -1084,6 +1136,7 @@ async function bindMenuSession(env,session,source,lines) {
|
|
|
1084
1136
|
export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
1085
1137
|
const o = await orderBySession(env, session.id);
|
|
1086
1138
|
if (!o) return 'unknown-order';
|
|
1139
|
+
if(o.status==='review')return 'review';
|
|
1087
1140
|
// The session must be the one this order opened, for this player and this price.
|
|
1088
1141
|
const savedLines=await orderLines(env,o.id);
|
|
1089
1142
|
if(savedLines[0]?.snapshot && JSON.parse(savedLines[0].snapshot).paymentLink?.basket)return paidBasket(env,shop,o,savedLines,session,paidAt);
|
|
@@ -1189,6 +1242,11 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
|
|
|
1189
1242
|
}
|
|
1190
1243
|
const gross = Number(o.total ?? o.amount);
|
|
1191
1244
|
if (!Number.isSafeInteger(amount) || amount > gross) throw new Error('Stripe refund total exceeds payment');
|
|
1245
|
+
if(o.status==='review') {
|
|
1246
|
+
// Review never grants anything, even when a later refund is reversed.
|
|
1247
|
+
await env.DB.prepare("UPDATE shop_orders SET refunded_amount=?2,refunded_net=?2,updated_at=?3,note=?4 WHERE id=?1 AND status='review'").bind(o.id,amount,Date.now(),`Payment received; staff review required. Refunded ${amount} of ${gross} ${o.currency} minor units.`).run();
|
|
1248
|
+
return 'review';
|
|
1249
|
+
}
|
|
1192
1250
|
const moneyFull = amount === gross && gross > 0;
|
|
1193
1251
|
const whole = moneyFull && [...refunds.values()].some((r) => r.status === 'succeeded' && !r.metadata?.line);
|
|
1194
1252
|
const taggedGross = [...totals.values()].reduce((n, value) => n + value, 0);
|
|
@@ -1227,6 +1285,7 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
|
|
|
1227
1285
|
async function disputed(env, payment, disputeId) {
|
|
1228
1286
|
const o = payment ? await orderByPayment(env, payment) : null;
|
|
1229
1287
|
if (!o) return 'unknown-order';
|
|
1288
|
+
if(o.status==='review'){await env.DB.prepare("UPDATE shop_orders SET dispute=?2,note='Payment received; disputed payment requires staff review.',updated_at=?3 WHERE id=?1").bind(o.id,disputeId,Date.now()).run();return 'review';}
|
|
1230
1289
|
await env.DB.prepare("UPDATE shop_orders SET status = 'disputed', dispute = ?2, updated_at = ?3 WHERE id = ?1 AND status = 'paid'").bind(o.id, disputeId, Date.now()).run();
|
|
1231
1290
|
return 'disputed';
|
|
1232
1291
|
}
|
|
@@ -1235,6 +1294,7 @@ async function disputed(env, payment, disputeId) {
|
|
|
1235
1294
|
async function disputeClosed(env, payment, status) {
|
|
1236
1295
|
const o = payment ? await orderByPayment(env, payment) : null;
|
|
1237
1296
|
if (!o) return 'unknown-order';
|
|
1297
|
+
if(o.status==='review'){await env.DB.prepare("UPDATE shop_orders SET note=?2,updated_at=?3 WHERE id=?1").bind(o.id,`Payment received; dispute ${status}. Staff review required.`,Date.now()).run();return 'review';}
|
|
1238
1298
|
const now = Date.now();
|
|
1239
1299
|
if (!['paid', 'disputed'].includes(o.status)) return 'already';
|
|
1240
1300
|
if (status === 'lost') {
|
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.54.0';
|