@homie-rocks/studio 0.51.5 → 0.51.7
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 +33 -0
- package/apps/APPS.md +41 -0
- package/bin/homie-studio.mjs +1 -1
- package/functions/FUNCTIONS.md +2 -2
- package/guides/app/NEEDS.md +160 -0
- package/guides/app/SKILL.md +45 -3
- package/guides/office/SKILL.md +27 -1
- package/guides/servers/SKILL.md +27 -1
- package/guides/shop/SKILL.md +38 -15
- package/lib/app-needs.mjs +93 -19
- package/lib/app-ready.mjs +21 -0
- package/lib/check.mjs +16 -7
- package/lib/cloudflare.mjs +4 -4
- package/lib/codex.mjs +7 -5
- package/lib/mcp-tools.mjs +1 -1
- package/lib/needs-headless.mjs +34 -0
- package/lib/shop-links.mjs +91 -27
- package/lib/shop-mcp.mjs +37 -10
- package/lib/stripe-connect.mjs +4 -4
- package/lib/template-history.json +84 -0
- package/lib/worker-config.mjs +3 -0
- package/package.json +1 -1
- package/shop/SHOP.md +82 -15
- package/worker/app-records.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +9 -3
- package/worker/local-test.mjs +4 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +31 -4
- package/worker/mcp.mjs +1 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-rules.mjs +3 -0
- package/worker/shop.mjs +53 -5
- package/worker/version.mjs +1 -1
package/worker/shop.mjs
CHANGED
|
@@ -517,7 +517,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
517
517
|
const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env);
|
|
518
518
|
const config=linkConfig(env);
|
|
519
519
|
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.')};
|
|
520
|
-
const menu=keyless && !booking && lines.length>
|
|
520
|
+
const menu=keyless && !booking && lines.length>0 ? config?.menus?.find(m=>(m.basket || lines.length>1)&&lines.every(l=>m.items.some(i=>i.item===l.item.id))) : null;
|
|
521
521
|
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;
|
|
522
522
|
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.')};
|
|
523
523
|
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.')};
|
|
@@ -533,7 +533,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
533
533
|
}
|
|
534
534
|
if(keyless && item.price==='choose' && lines[0].quantity!==1)return {error:fail(400,'quantity','A tip is one chosen amount; choose it on Stripe.')};
|
|
535
535
|
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.')};
|
|
536
|
-
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(menu && !menu.basket)lines=menu.items.map(entry=>{const i=shop.items.find(i=>i.id===entry.item);return {item:i,quantity:1,amount:i.price};});
|
|
537
537
|
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}}));
|
|
538
538
|
const order = { id: newOrderId(), player: player.id, amount, currency: shop.currency };
|
|
539
539
|
if (booking && reference === null) reference = order.id;
|
|
@@ -575,6 +575,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
575
575
|
}
|
|
576
576
|
if (keyless) {
|
|
577
577
|
const checkout = new URL(link.url); checkout.searchParams.set('client_reference_id', order.id);
|
|
578
|
+
if(menu?.basket)checkout.searchParams.set('prefilled_amount',String(amount));
|
|
578
579
|
return { order, url: checkout.href, reconciled };
|
|
579
580
|
}
|
|
580
581
|
let session = null;
|
|
@@ -631,7 +632,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
631
632
|
const reference = b.body.reference ?? null;
|
|
632
633
|
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');
|
|
633
634
|
const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0 && !i.interval));
|
|
634
|
-
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 });
|
|
635
|
+
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, next: ready.next, migration: ready.migration });
|
|
635
636
|
const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
|
|
636
637
|
// Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
|
|
637
638
|
if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
|
|
@@ -690,7 +691,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
690
691
|
async function parentLinkRoute(request, env, url, cat, shop, ready) {
|
|
691
692
|
const b = await bodyOf(request, requestSize(shop));
|
|
692
693
|
if (b.error) return b.error;
|
|
693
|
-
if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
|
|
694
|
+
if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, next: ready.next, migration: ready.migration });
|
|
694
695
|
const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1 }];
|
|
695
696
|
if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
|
|
696
697
|
const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
|
|
@@ -955,6 +956,7 @@ async function bindLinkSession(env, session, mode) {
|
|
|
955
956
|
if (!source || source.mode !== mode) return false;
|
|
956
957
|
const lines = await orderLines(env, source.id);
|
|
957
958
|
if (!lines.length || !lines[0].snapshot) return false;
|
|
959
|
+
if(JSON.parse(lines[0].snapshot).paymentLink?.basket) return bindBasketSession(env,session,source,lines);
|
|
958
960
|
if(JSON.parse(lines[0].snapshot).paymentLink?.items) {
|
|
959
961
|
const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-menu:${source.id}`).first();
|
|
960
962
|
const original=saved?JSON.parse(saved.value).map(l=>({item:l.item.id,snapshot:JSON.stringify(l.item),unit_amount:l.amount})):lines;
|
|
@@ -990,6 +992,50 @@ async function bindLinkSession(env, session, mode) {
|
|
|
990
992
|
return true;
|
|
991
993
|
}
|
|
992
994
|
|
|
995
|
+
/** A saved basket is immutable. A reopened link buys that same basket again. */
|
|
996
|
+
async function bindBasketSession(env,session,source,lines) {
|
|
997
|
+
const link=JSON.parse(lines[0].snapshot).paymentLink;
|
|
998
|
+
if((session.payment_link?.id??session.payment_link)!==link.id || session.metadata?.revision!==link.revision || session.currency!==source.currency)return false;
|
|
999
|
+
if(session.payment_status!=='paid' || !Number.isSafeInteger(session.amount_total) || session.amount_total<=0 || session.amount_subtotal!==session.amount_total)return false;
|
|
1000
|
+
await env.DB.prepare('UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL').bind(source.id,session.id).run();
|
|
1001
|
+
let order=await orderBySession(env,session.id);
|
|
1002
|
+
if(!order) {
|
|
1003
|
+
const id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`;
|
|
1004
|
+
await env.DB.batch([
|
|
1005
|
+
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()),
|
|
1006
|
+
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}`),
|
|
1007
|
+
...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)),
|
|
1008
|
+
]);
|
|
1009
|
+
order=await orderBySession(env,session.id);
|
|
1010
|
+
}
|
|
1011
|
+
return Boolean(order);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Keep the actual payment in financial totals and the ordered amounts in lines.
|
|
1015
|
+
// Aggregate refunds are allocated proportionally; they do not change quantities.
|
|
1016
|
+
async function paidBasket(env,shop,o,lines,session,paidAt) {
|
|
1017
|
+
const total=session.amount_total;
|
|
1018
|
+
if(session.payment_status!=='paid' || session.currency!==o.currency || !Number.isSafeInteger(total) || total<=0 || session.amount_subtotal!==total)return 'mismatch';
|
|
1019
|
+
if(o.paid_at!==null)return 'already';
|
|
1020
|
+
const expected=lines.reduce((n,l)=>n+Number(l.amount),0);
|
|
1021
|
+
if(!Number.isSafeInteger(expected)||expected<=0)return 'mismatch';
|
|
1022
|
+
const writes=[];let cumulative=0,allocated=0;
|
|
1023
|
+
for(const l of lines) {
|
|
1024
|
+
cumulative+=Number(l.amount);
|
|
1025
|
+
const next=Number(BigInt(total)*BigInt(cumulative)/BigInt(expected));
|
|
1026
|
+
const gross=next-allocated;allocated=next;
|
|
1027
|
+
if(total>=expected && o.player)writes.push(...grantStatements(env,o,JSON.parse(l.snapshot),paidAt,l));
|
|
1028
|
+
writes.push(env.DB.prepare("UPDATE shop_order_lines SET status='paid',total=?2 WHERE id=?1 AND EXISTS(SELECT 1 FROM shop_orders WHERE id=?3 AND paid_at IS NULL)").bind(l.id,gross,o.id));
|
|
1029
|
+
}
|
|
1030
|
+
const payment=typeof session.payment_intent==='string'?session.payment_intent:session.payment_intent?.id;
|
|
1031
|
+
writes.push(env.DB.prepare("UPDATE shop_orders SET status='paid',amount=?2,total=?2,tax=0,payment=?3,paid_at=?4,updated_at=?4,attention=0,note=?5 WHERE id=?1 AND paid_at IS NULL").bind(o.id,total,payment??null,paidAt,total<expected?'Settle the difference at the bar':''));
|
|
1032
|
+
// Enrich the trigger's durable event in this same transaction, before dispatch.
|
|
1033
|
+
writes.push(env.DB.prepare("UPDATE studio_events SET data=json_set(data,'$.basketTotal',?2,'$.settlement',?3) WHERE id=?1").bind(`shop_orders:${o.id}:paid`,expected,total<expected?'short':'paid'));
|
|
1034
|
+
const result=await env.DB.batch(writes);
|
|
1035
|
+
if(payment)await syncRefunds(env,payment);
|
|
1036
|
+
return result.at(-2)?.meta?.changes?'paid':'already';
|
|
1037
|
+
}
|
|
1038
|
+
|
|
993
1039
|
/** Menu totals are never decoded into quantities. Only signed invoice lines are authoritative. */
|
|
994
1040
|
async function bindMenuSession(env,session,source,lines) {
|
|
995
1041
|
const first=JSON.parse(lines[0].snapshot),link=first.paymentLink;
|
|
@@ -1029,7 +1075,9 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
1029
1075
|
const o = await orderBySession(env, session.id);
|
|
1030
1076
|
if (!o) return 'unknown-order';
|
|
1031
1077
|
// The session must be the one this order opened, for this player and this price.
|
|
1032
|
-
const
|
|
1078
|
+
const savedLines=await orderLines(env,o.id);
|
|
1079
|
+
if(savedLines[0]?.snapshot && JSON.parse(savedLines[0].snapshot).paymentLink?.basket)return paidBasket(env,shop,o,savedLines,session,paidAt);
|
|
1080
|
+
const linkOrder = savedLines.some((l) => l.snapshot && JSON.parse(l.snapshot).paymentLink);
|
|
1033
1081
|
if (!linkOrder && (session.metadata?.order !== o.id || ((o.checkout_player ?? o.player) && session.client_reference_id && session.client_reference_id !== (o.checkout_player ?? o.player)))) return 'mismatch';
|
|
1034
1082
|
if (session.currency !== o.currency || !Number.isSafeInteger(session.amount_subtotal) || session.amount_subtotal !== Number(o.amount)) return 'mismatch';
|
|
1035
1083
|
if (['refunded', 'lost', 'disputed'].includes(o.status)) {
|
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.51.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.51.7';
|