@homie-rocks/studio 0.53.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 +30 -0
- package/bin/homie-studio.mjs +1 -1
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +8 -1
- package/lib/build.mjs +1 -1
- package/lib/check.mjs +10 -2
- package/lib/dev.mjs +8 -1
- package/lib/scaffold.mjs +20 -1
- package/lib/shop-links.mjs +37 -19
- package/lib/shop-mcp.mjs +7 -1
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/worker/app-records.mjs +4 -2
- package/worker/app-shell.mjs +9 -4
- package/worker/app-streams.mjs +26 -9
- package/worker/app-tools.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/event-resources.mjs +6 -1
- package/worker/index.mjs +42 -25
- package/worker/office.mjs +89 -14
- package/worker/operational-schema.mjs +30 -0
- package/worker/pages.mjs +7 -1
- 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 +62 -7
- package/worker/version.mjs +1 -1
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.')};
|
|
@@ -649,6 +648,7 @@ 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
653
|
if(!hasStripeKey(env) && ['supporter','deposit'].includes(item.kind) && quantity!==1)return fail(400,'quantity','This payment link has quantity fixed at one.');
|
|
654
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.');
|
|
@@ -716,6 +716,7 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
|
|
|
716
716
|
const item = shop.items.find((i) => i.id === entry?.item);
|
|
717
717
|
if (!item) return fail(404, 'item', 'no such item here');
|
|
718
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.');
|
|
719
720
|
if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
|
|
720
721
|
if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
|
|
721
722
|
seen.add(item.id);
|
|
@@ -877,7 +878,7 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
877
878
|
if (subscriptionResult !== null) return json({ ok: true, did: subscriptionResult });
|
|
878
879
|
const sessionEvent = ev.type.startsWith('checkout.session.');
|
|
879
880
|
if (!sessionEvent) await rememberMoneyEvent(env, ev);
|
|
880
|
-
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'});
|
|
881
882
|
if (sessionEvent && obj.metadata?.order && !obj.payment_link) {
|
|
882
883
|
await env.DB.prepare(`UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL AND mode=?3
|
|
883
884
|
AND EXISTS(SELECT 1 FROM meta WHERE key='booking-checkout:' || shop_orders.id)`)
|
|
@@ -950,20 +951,64 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
950
951
|
default:
|
|
951
952
|
break;
|
|
952
953
|
}
|
|
954
|
+
if(did==='mismatch'&&obj.payment_link&&await reviewLinkPayment(env,obj,ev.livemode?'live':'test'))did='review';
|
|
953
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.');
|
|
954
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.');
|
|
955
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();
|
|
956
958
|
if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
|
|
957
959
|
return json({ ok: true, did });
|
|
958
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'});
|
|
959
962
|
logOrderError(env, null, error);
|
|
960
963
|
return fail(503, 'stripe-retry', 'Payment reconciliation is temporarily unavailable; retry this event.');
|
|
961
964
|
}
|
|
962
965
|
}
|
|
963
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
|
+
|
|
964
1008
|
/** Payment Links can be reopened. Each signed Session becomes its own order; the URL reference
|
|
965
1009
|
* selects a frozen local intent, never an entitlement or a buyer-supplied price. */
|
|
966
1010
|
async function bindLinkSession(env, session, mode) {
|
|
1011
|
+
if(session.id&&(await orderBySession(env,session.id))?.status==='review')return false;
|
|
967
1012
|
if (!/^ord_[A-Za-z0-9]{20}$/.test(session.client_reference_id ?? '') || !/^cs_[A-Za-z0-9_]+$/.test(session.id ?? '')) return false;
|
|
968
1013
|
const source = await orderById(env, session.client_reference_id);
|
|
969
1014
|
if (!source || source.mode !== mode) return false;
|
|
@@ -982,7 +1027,7 @@ async function bindLinkSession(env, session, mode) {
|
|
|
982
1027
|
if(item.kind==='deposit' && amount!==source.amount)return false;
|
|
983
1028
|
const quantity = item.price === 'choose' ? 1 : amount / item.price;
|
|
984
1029
|
if(['supporter','deposit'].includes(item.kind) && quantity!==1)return false;
|
|
985
|
-
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > 999999) return false;
|
|
1030
|
+
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > (item.maxQuantity??link.maxQuantity??999999)) return false;
|
|
986
1031
|
if (item.price === 'choose' && (amount < item.min || item.max !== null && amount > item.max)) return false;
|
|
987
1032
|
// Claim the first session atomically. A repeated URL creates a separate paid order, never loses a payment.
|
|
988
1033
|
await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1 AND session IS NULL').bind(source.id, session.id).run();
|
|
@@ -994,13 +1039,14 @@ async function bindLinkSession(env, session, mode) {
|
|
|
994
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()),
|
|
995
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),
|
|
996
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),
|
|
997
|
-
...(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})] : []),
|
|
998
1043
|
]);
|
|
999
1044
|
order = await orderBySession(env, session.id);
|
|
1000
1045
|
|
|
1001
1046
|
}
|
|
1002
1047
|
if (!order) return false;
|
|
1003
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)]:[]),
|
|
1004
1050
|
env.DB.prepare('UPDATE shop_orders SET amount = ?2 WHERE id = ?1 AND paid_at IS NULL').bind(order.id, amount),
|
|
1005
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),
|
|
1006
1052
|
]);
|
|
@@ -1033,7 +1079,7 @@ async function paidBasket(env,shop,o,lines,session,paidAt) {
|
|
|
1033
1079
|
if(session.payment_status!=='paid' || session.currency!==o.currency || !Number.isSafeInteger(total) || total<=0 || session.amount_subtotal!==total)return 'mismatch';
|
|
1034
1080
|
if(o.paid_at!==null)return 'already';
|
|
1035
1081
|
const expected=lines.reduce((n,l)=>n+Number(l.amount),0);
|
|
1036
|
-
if(!Number.isSafeInteger(expected)||expected<=0)return 'mismatch';
|
|
1082
|
+
if(!Number.isSafeInteger(expected)||expected<=0||total!==expected)return 'mismatch';
|
|
1037
1083
|
const writes=[];let cumulative=0,allocated=0;
|
|
1038
1084
|
for(const l of lines) {
|
|
1039
1085
|
cumulative+=Number(l.amount);
|
|
@@ -1065,6 +1111,7 @@ async function bindMenuSession(env,session,source,lines) {
|
|
|
1065
1111
|
for(const l of invoice.lines?.data??[]) {
|
|
1066
1112
|
const line=lines.find(x=>JSON.parse(x.snapshot).stripePrice===(l.price?.id??l.pricing?.price_details?.price));
|
|
1067
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;
|
|
1068
1115
|
paidLines.push({...line,quantity:l.quantity,amount:l.amount});
|
|
1069
1116
|
}
|
|
1070
1117
|
if(!paidLines.length || paidLines.reduce((n,l)=>n+l.amount,0)!==session.amount_subtotal)throw new Error('Invoice subtotal mismatch');
|
|
@@ -1089,6 +1136,7 @@ async function bindMenuSession(env,session,source,lines) {
|
|
|
1089
1136
|
export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
1090
1137
|
const o = await orderBySession(env, session.id);
|
|
1091
1138
|
if (!o) return 'unknown-order';
|
|
1139
|
+
if(o.status==='review')return 'review';
|
|
1092
1140
|
// The session must be the one this order opened, for this player and this price.
|
|
1093
1141
|
const savedLines=await orderLines(env,o.id);
|
|
1094
1142
|
if(savedLines[0]?.snapshot && JSON.parse(savedLines[0].snapshot).paymentLink?.basket)return paidBasket(env,shop,o,savedLines,session,paidAt);
|
|
@@ -1194,6 +1242,11 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
|
|
|
1194
1242
|
}
|
|
1195
1243
|
const gross = Number(o.total ?? o.amount);
|
|
1196
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
|
+
}
|
|
1197
1250
|
const moneyFull = amount === gross && gross > 0;
|
|
1198
1251
|
const whole = moneyFull && [...refunds.values()].some((r) => r.status === 'succeeded' && !r.metadata?.line);
|
|
1199
1252
|
const taggedGross = [...totals.values()].reduce((n, value) => n + value, 0);
|
|
@@ -1232,6 +1285,7 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
|
|
|
1232
1285
|
async function disputed(env, payment, disputeId) {
|
|
1233
1286
|
const o = payment ? await orderByPayment(env, payment) : null;
|
|
1234
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';}
|
|
1235
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();
|
|
1236
1290
|
return 'disputed';
|
|
1237
1291
|
}
|
|
@@ -1240,6 +1294,7 @@ async function disputed(env, payment, disputeId) {
|
|
|
1240
1294
|
async function disputeClosed(env, payment, status) {
|
|
1241
1295
|
const o = payment ? await orderByPayment(env, payment) : null;
|
|
1242
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';}
|
|
1243
1298
|
const now = Date.now();
|
|
1244
1299
|
if (!['paid', 'disputed'].includes(o.status)) return 'already';
|
|
1245
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';
|