@homie-rocks/studio 0.50.7 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/shop/shop.ts CHANGED
@@ -44,10 +44,19 @@ export interface ShopOptions {
44
44
 
45
45
  export interface CartLine { item: string; quantity: number; amount?: number }
46
46
 
47
+ export interface ShopCatalog {
48
+ open: boolean;
49
+ currency: string;
50
+ currencyScale: number;
51
+ items: Array<{ id: string; kind: string; name: string; interval?: 'month'; price: number | 'choose'; min?: number; max?: number | null; shown: string; way: string }>;
52
+ }
53
+
47
54
  export interface Shop {
48
55
  /** Resolves once the shell answered (or after `timeoutMs` with no shell: an empty, closed shop). */
49
56
  readonly ready: Promise<ShopState>;
50
57
  readonly state: ShopState;
58
+ /** Current gift/item options and payment readiness, without exposing account details. */
59
+ catalog(): Promise<ShopCatalog>;
51
60
  /** Whether the player owns this entitlement key now ("skin:ember", "badge:supporter"). Synchronous, for a render loop. */
52
61
  has(key: string): boolean;
53
62
  /** Every key the player owns now. */
@@ -61,6 +70,8 @@ export interface Shop {
61
70
  add(item: string, quantity?: number, amount?: number): void;
62
71
  cart(): CartLine[];
63
72
  clear(): void;
73
+ /** Opens Checkout: keyless bookings are pending until paid; explicit session checkout holds capacity. The returned ID tracks the public booking record. */
74
+ book(item: string, booking: { collection: string; slot: string }, quantity?: number): Promise<{ ok: boolean; booking?: string; error?: string }>;
64
75
  checkout(options?: { reference?: string }): Promise<boolean>;
65
76
  buy(item: string, quantity?: number, amount?: number, options?: { reference?: string }): Promise<boolean>;
66
77
  /** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
@@ -121,6 +132,20 @@ export function createShop(opts: ShopOptions = {}): Shop {
121
132
  let cart: CartLine[] = [];
122
133
  const shop: Shop = {
123
134
  ready,
135
+ catalog: async () => {
136
+ const result: ShopCatalog = { open: false, currency: 'usd', currencyScale: 100, items: [] };
137
+ let cursor: string | undefined;
138
+ do {
139
+ const m = await send({ op: 'catalog', cursor });
140
+ if (!m.ok) return result;
141
+ result.open = Boolean(m.open);
142
+ result.currency = String(m.currency ?? 'usd');
143
+ result.currencyScale = Number(m.currencyScale) || 100;
144
+ if (Array.isArray(m.items)) result.items.push(...m.items as ShopCatalog['items']);
145
+ cursor = typeof m.nextCursor === 'string' ? m.nextCursor : undefined;
146
+ } while (cursor);
147
+ return result;
148
+ },
124
149
  get state() { return { ...state }; },
125
150
  has: (key) => owns.includes(String(key)),
126
151
  entitlements: () => owns.slice(),
@@ -129,6 +154,10 @@ export function createShop(opts: ShopOptions = {}): Shop {
129
154
  add: (item, quantity = 1, amount) => { cart.push({ item, quantity, ...(amount === undefined ? {} : { amount }) }); },
130
155
  cart: () => cart.map((line) => ({ ...line })),
131
156
  clear: () => { cart = []; },
157
+ book: async (item, booking, quantity = 1) => {
158
+ const r = await send({ op: 'checkout', booking, lines: [{ item, quantity }] });
159
+ return { ok: Boolean(r.ok), ...(typeof r.booking === 'string' ? { booking: r.booking } : {}), ...(typeof r.message === 'string' ? { error: r.message } : {}) };
160
+ },
132
161
  checkout: async (options = {}) => { const ok = Boolean((await send({ op: 'checkout', lines: cart, reference: options.reference })).ok); if (ok) cart = []; return ok; },
133
162
  buy: async (item, quantity = 1, amount, options = {}) => Boolean((await send({ op: 'checkout', reference: options.reference, lines: [{ item, quantity, ...(amount === undefined ? {} : { amount }) }] })).ok),
134
163
  close: () => { void send({ op: 'close' }); },
@@ -20,6 +20,12 @@ export function appProblems(meta) {
20
20
  if (!key.test(id) || !object(c) || !object(c.fields)) { bad.push(`collection ${id}: declare fields`); continue; }
21
21
  for (const [field, spec] of Object.entries(c.fields)) if (!/^[a-z][a-z0-9_]*$/.test(field) || !object(spec) || !['string', 'number', 'boolean'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field: names start with a lowercase letter and use lowercase letters, digits and underscore; declare a string, number or boolean type`);
22
22
  }
23
+ for (const [id, c] of Object.entries(collections)) if (c.booking) {
24
+ const spec = c.booking;
25
+ if (!object(spec) || !key.test(spec.slots ?? '') || !collections[spec.slots] || typeof spec.item !== 'string' || !spec.item.trim()) bad.push(`collection ${id}: booking needs slots collection and shop item`);
26
+ if (!meta.records.persist) bad.push(`collection ${id}: bookings need persistent records`);
27
+ for (const role of Object.values(roles)) if (!role.signIn && (role.can ?? []).some(cap => ['create','update','delete'].some(op => cap === `${op}:${id}` || cap === `${op}:${spec.slots}`))) bad.push(`collection ${id}: booking and slot writes require a signed-in staff role`);
28
+ }
23
29
  for (const r of Object.values(roles)) for (const cap of Array.isArray(r?.can) ? r.can : []) if (!Object.hasOwn(meta.records?.collections ?? {}, String(cap).split(':')[1])) bad.push(`unknown collection in ${cap}`);
24
30
  for (const [k, v] of Object.entries(meta.words ?? {})) if (!/^[a-z][a-zA-Z0-9-]*$/.test(k) || typeof v !== 'string' || v.length > 500) bad.push(`words.${k}: use a short label`);
25
31
  return bad;
@@ -1,4 +1,6 @@
1
+ import { linkConfig } from './shop-links.mjs';
1
2
  import {localSession} from './local-test.mjs';
3
+ import { moveBooking } from './booking-store.mjs';
2
4
  import { toolIdentity } from './tool-identity.mjs';
3
5
  /** Lasting app records in the studio's own D1. Room hosts never authorize writes. */
4
6
  import { appRole, recordProblem } from './app-format.mjs';
@@ -96,10 +98,29 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
96
98
  if (op === 'read') {
97
99
  const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(recordApp, collection, id).first()].filter(Boolean)
98
100
  : (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id LIMIT 1000').bind(recordApp, collection).all()).results;
99
- return json({ ok: true, records: rows.map((r) => ({ id: r.id, data: JSON.parse(r.value), version: r.version, updatedAt: r.updated_at })) });
101
+ const records=[];
102
+ for(const r of rows) {
103
+ const record={id:r.id,data:JSON.parse(r.value),version:r.version,updatedAt:r.updated_at};
104
+ const link=linkConfig(env)?.slots?.find(l=>l.app===meta.id&&l.slots===collection&&l.slot===r.id);
105
+ if(link) {
106
+ const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
107
+ const observed=saved?JSON.parse(saved.value):null;
108
+ const status=observed && observed.capacity===(link.limit??link.capacity) ? observed : link;
109
+ const count=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(recordApp,collection,r.id).first();
110
+ record.booking={full:!status.active||status.count>=(link.limit??link.capacity)||count.n>=link.capacity,available:status.active?Math.max(0,Math.min(link.capacity-count.n,(link.limit??link.capacity)-status.count)):0,synced:link.capacity===record.data.capacity};
111
+ }
112
+ records.push(record);
113
+ }
114
+ return json({ok:true,records});
100
115
  }
101
116
  if (!id) return fail(400, 'record id required');
102
117
  const body = await bodyOf(request);
118
+ if (meta.records.collections[collection].booking) {
119
+ if (op !== 'update' || !meta.roles[access.role].signIn) return fail(403, 'bookings are created and released through checkout');
120
+ if (!Number.isSafeInteger(body.version) || !ID.test(body.data?.slot ?? '') || Object.keys(body.data ?? {}).some(k => k !== 'slot')) return fail(400, 'move requires slot and current version');
121
+ const moved = await moveBooking(env, { app: recordApp, collection, id, slot: body.data.slot, version: body.version });
122
+ return moved ? json({ ok: true, id, version: body.version + 1 }) : fail(409, 'booking changed or is released; reload');
123
+ }
103
124
  if (op !== 'delete') { const bad = recordProblem(meta.records.collections[collection], body.data, { create: op === 'create' }); if (bad) return fail(400, bad); }
104
125
  if (op !== 'create' && (!Number.isSafeInteger(body.version) || body.version < 1)) return fail(400, 'record version required');
105
126
  const stmt = op === 'create'
@@ -109,6 +130,7 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
109
130
  const result = await stmt.run();
110
131
  return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed, already exists, or collection full; reload');
111
132
  } catch (error) {
133
+ if (/booking capacity|capacity below|slot has active/.test(error.message)) return fail(409, error.message);
112
134
  if (error instanceof SyntaxError || error.message === 'body too large') return fail(400, error.message);
113
135
  return fail(503, 'app database unavailable; apply studio migrations');
114
136
  }
@@ -0,0 +1,79 @@
1
+ import { linkConfig, hasStripeKey } from './shop-links.mjs';
2
+ /** Capacity belongs to the studio database. Checkout expiry is a provider fact,
3
+ * never an inference from a browser return or the local clock. */
4
+ export const BOOKINGS_MIGRATION_FILE = '0025_bookings.sql';
5
+ export const BOOKINGS_MIGRATION = `
6
+ CREATE TABLE shop_bookings (
7
+ id TEXT PRIMARY KEY, app TEXT NOT NULL, collection TEXT NOT NULL,
8
+ slots TEXT NOT NULL, slot TEXT NOT NULL, quantity INTEGER NOT NULL CHECK(quantity > 0),
9
+ order_id TEXT NOT NULL UNIQUE, state TEXT NOT NULL DEFAULT 'held'
10
+ CHECK(state IN ('pending','held','confirmed','released')),
11
+ version INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL
12
+ );
13
+ CREATE INDEX shop_bookings_capacity ON shop_bookings(app, slots, slot, state);
14
+ CREATE TRIGGER booking_capacity_insert BEFORE INSERT ON shop_bookings WHEN NEW.state != 'pending' BEGIN
15
+ SELECT RAISE(ABORT,'booking capacity unavailable') WHERE NOT EXISTS (
16
+ SELECT 1 FROM app_records s WHERE s.app=NEW.app AND s.collection=NEW.slots AND s.id=NEW.slot
17
+ AND json_type(s.value,'$.capacity')='integer' AND json_extract(s.value,'$.capacity') >= NEW.quantity +
18
+ (SELECT COALESCE(SUM(quantity),0) FROM shop_bookings WHERE app=NEW.app AND slots=NEW.slots AND slot=NEW.slot AND state IN ('held','confirmed'))
19
+ );
20
+ END;
21
+ CREATE TRIGGER booking_capacity_move BEFORE UPDATE OF slot ON shop_bookings
22
+ WHEN NEW.state IN ('held','confirmed') BEGIN
23
+ SELECT RAISE(ABORT,'booking capacity unavailable') WHERE NOT EXISTS (
24
+ SELECT 1 FROM app_records s WHERE s.app=NEW.app AND s.collection=NEW.slots AND s.id=NEW.slot
25
+ AND json_type(s.value,'$.capacity')='integer' AND json_extract(s.value,'$.capacity') >= NEW.quantity +
26
+ (SELECT COALESCE(SUM(quantity),0) FROM shop_bookings WHERE app=NEW.app AND slots=NEW.slots AND slot=NEW.slot AND id!=NEW.id AND state IN ('held','confirmed'))
27
+ );
28
+ END;
29
+ CREATE TRIGGER booking_slot_update BEFORE UPDATE OF value ON app_records
30
+ WHEN EXISTS (SELECT 1 FROM shop_bookings WHERE app=OLD.app AND slots=OLD.collection AND slot=OLD.id AND state IN ('held','confirmed')) BEGIN
31
+ SELECT RAISE(ABORT,'capacity below existing bookings') WHERE json_type(NEW.value,'$.capacity') IS NOT 'integer' OR json_extract(NEW.value,'$.capacity') <
32
+ (SELECT SUM(quantity) FROM shop_bookings WHERE app=OLD.app AND slots=OLD.collection AND slot=OLD.id AND state IN ('held','confirmed'))
33
+ ;
34
+ END;
35
+ CREATE TRIGGER booking_slot_delete BEFORE DELETE ON app_records
36
+ WHEN EXISTS (SELECT 1 FROM shop_bookings WHERE app=OLD.app AND slots=OLD.collection AND slot=OLD.id AND state IN ('held','confirmed')) BEGIN
37
+ SELECT RAISE(ABORT,'slot has active bookings');
38
+ END;
39
+ CREATE TRIGGER booking_order_state AFTER UPDATE OF status ON shop_orders BEGIN
40
+ UPDATE shop_bookings SET state=CASE WHEN NEW.status='paid' THEN 'confirmed' ELSE 'released' END,
41
+ version=version+1, updated_at=NEW.updated_at
42
+ WHERE order_id=NEW.id AND ((state IN ('pending','held') AND NEW.status IN ('paid','expired','failed')) OR (state IN ('pending','held','confirmed') AND NEW.status IN ('refunded','lost')));
43
+ END;
44
+ ${['INSERT', 'UPDATE'].map(op => `CREATE TRIGGER booking_record_${op.toLowerCase()} AFTER ${op} ON shop_bookings BEGIN
45
+ INSERT INTO app_records(app,collection,id,value,version,updated_at)
46
+ VALUES(NEW.app,NEW.collection,NEW.id,json_object('slot',NEW.slot,'quantity',NEW.quantity,'state',NEW.state),NEW.version,NEW.updated_at)
47
+ ON CONFLICT(app,collection,id) DO UPDATE SET value=excluded.value,version=excluded.version,updated_at=excluded.updated_at;
48
+ INSERT OR IGNORE INTO studio_events(id,type,data,at)
49
+ SELECT 'booking:' || NEW.id || ':' || NEW.version,'booking.' || ${op === 'INSERT' ? "NEW.state" : "CASE WHEN OLD.slot!=NEW.slot THEN 'moved' ELSE NEW.state END"},
50
+ json_object('booking',NEW.id,'app',NEW.app,'collection',NEW.collection,'slot',NEW.slot,'state',NEW.state,'quantity',NEW.quantity,'order',NEW.order_id,'version',NEW.version),NEW.updated_at
51
+ WHERE EXISTS(SELECT 1 FROM function_subscriptions WHERE type='booking.' || ${op === 'INSERT' ? "NEW.state" : "CASE WHEN OLD.slot!=NEW.slot THEN 'moved' ELSE NEW.state END"});
52
+ END;`).join('\n')}
53
+ DROP TRIGGER IF EXISTS shop_orders_function_paid;
54
+ CREATE TRIGGER shop_orders_function_paid AFTER UPDATE ON shop_orders
55
+ WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.paid')
56
+ AND OLD.paid_at IS NULL AND NEW.paid_at IS NOT NULL
57
+ AND NOT EXISTS (SELECT 1 FROM purchase_orders WHERE id=NEW.id) BEGIN
58
+ INSERT OR IGNORE INTO studio_events(id,type,data,at)
59
+ VALUES('shop_orders:' || NEW.id || ':paid','order.paid',
60
+ json_object('order',NEW.id,'source','shop_orders','amount',NEW.amount,'currency',NEW.currency,'mode',NEW.mode,
61
+ 'app',NEW.game,'reference',(SELECT value FROM meta WHERE key='shop-reference:' || NEW.id),
62
+ 'lines',json((SELECT json_group_array(json_object('item',item,'quantity',quantity,'amount',amount,'total',total)) FROM shop_order_lines WHERE order_id=NEW.id))),NEW.paid_at);
63
+ END;
64
+ `;
65
+
66
+ export function bookingStatement(env, { id, app, collection, slots, slot, quantity, order, pending = false, now = Date.now() }) {
67
+ return env.DB.prepare(`INSERT INTO shop_bookings(id,app,collection,slots,slot,quantity,order_id,updated_at,state)
68
+ SELECT ?1,?2,?3,?4,?5,?6,?7,?8,?9 WHERE EXISTS(SELECT 1 FROM shop_orders WHERE id=?7)`).bind(id, app, collection, slots, slot, quantity, order, now, pending ? 'pending' : 'held');
69
+ }
70
+
71
+ /** Staff movement uses the same version contract as app records. */
72
+ export async function moveBooking(env, { app, collection, id, slot, version }) {
73
+ const target=linkConfig(env)?.slots?.find(l=>l.app===app&&l.collection===collection&&l.slot===slot);
74
+ if(!hasStripeKey(env) && target && !target.paused)throw new Error('booking capacity: ask the owner’s AI to pause the target slot and expire open links before moving; then sync confirmed seat counts');
75
+ const result = await env.DB.prepare(`UPDATE shop_bookings SET slot=?4,version=version+1,updated_at=?6
76
+ WHERE app=?1 AND collection=?2 AND id=?3 AND version=?5 AND state IN ('held','confirmed')`)
77
+ .bind(app, collection, id, slot, version, Date.now()).run();
78
+ return result.meta.changes > 0;
79
+ }
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.50.7","sha256":"94d707956333f370dc0a5a65762e08418b6474d19302096c765b96090d9d4845"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.51.0","sha256":"5a8e0df9f204fbe4978d618b507fe5ae0c5005ef4f106ac4b78dfac5f6c02b7b"});
package/worker/index.mjs CHANGED
@@ -185,7 +185,10 @@ async function sellingShop(env, cat) {
185
185
  }
186
186
 
187
187
  async function availableShellShop(env, cat, game, options) {
188
- return await sellingShop(env, cat) ? shellShop(cat, game, options) : null;
188
+ // The shell can show a disabled catalog before payment setup is complete.
189
+ // Checkout readiness is enforced by the shop API, separately from visibility.
190
+ const preview = (cat.games ?? []).find(g => g.id === game)?.screen?.shopPreview === true;
191
+ return (preview && !shopOf(cat)?.broken || await sellingShop(env, cat)) ? shellShop(cat, game, options) : null;
189
192
  }
190
193
 
191
194
  async function postsOf(env, origin) {
@@ -930,7 +933,7 @@ async function route(request, env, ctx) {
930
933
  if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
931
934
  return json({ ok: false, error: a.error }, a.status);
932
935
  }
933
- if(!meta.room)return recordsPage(meta,url,cat);
936
+ if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game));
934
937
  }
935
938
  }
936
939
  const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
@@ -1,12 +1,13 @@
1
1
  import {playerProperties} from './embed.mjs';
2
2
  import {appRole} from './app-format.mjs';
3
3
  import {esc} from './site.mjs';
4
+ import {SHOP_SHELL_JS, SHOP_SHELL_CSS} from './shop-page.mjs';
4
5
  import {APP_SHELL_JS} from './app-shell.mjs';
5
6
  /** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
6
- export function recordsPage(meta,url,cat={}) {
7
+ export function recordsPage(meta,url,cat={},shop=null) {
7
8
  const params=new URLSearchParams(url.search);params.delete('access');params.set('role',appRole(meta,url));if(!params.has('surface'))params.set('surface',url.pathname.endsWith('/tv')?'wall':'phone');
8
9
  const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
9
- const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv')};
10
+ const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
10
11
  const source=`/${meta.id}/__game/?${params}`;
11
- return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups" allow="autoplay *; fullscreen *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
12
+ return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups" allow="autoplay *; fullscreen *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
12
13
  }
@@ -2,9 +2,9 @@
2
2
  import { KEY_SHAPE, modeOf } from './stripe.mjs';
3
3
  // Current documented Payment Link fields, isolated from the existing keyed transport version.
4
4
  export const LINKS_STRIPE_VERSION = '2026-09-30.endive';
5
- export const hasStripeKey = (env) => KEY_SHAPE.test(String(env?.STRIPE_KEY ?? ''));
5
+ export const hasStripeKey = (env) => env?.STRIPE_CHECKOUT_MODE === 'sessions' && KEY_SHAPE.test(String(env?.STRIPE_KEY ?? ''));
6
6
  export function linkConfig(env) {
7
- try { const c = JSON.parse(env?.STRIPE_SHOP_LINKS ?? 'null'); return c?.v === 1 && ['test', 'live'].includes(c.mode) && Array.isArray(c.items) ? c : null; } catch { return null; }
7
+ try { const raw=env?.STRIPE_SHOP_LINKS??'null'; const c = JSON.parse(raw.startsWith('base64:') ? new TextDecoder().decode(Uint8Array.from(atob(raw.slice(7)),c=>c.charCodeAt(0))) : raw); return c?.v === 1 && ['test', 'live'].includes(c.mode) && Array.isArray(c.items) ? c : null; } catch { return null; }
8
8
  }
9
9
  export const shopMode = (env) => hasStripeKey(env) ? modeOf(env.STRIPE_KEY) : linkConfig(env)?.mode ?? 'test';
10
10
  export function linkSettings(shop) {
@@ -12,6 +12,7 @@ export function linkSettings(shop) {
12
12
  cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items });
13
13
  }
14
14
  export function fullerReason(shop) {
15
+ if (shop.items.some(item => item.interval && item.price === 'choose')) return 'A giver-chosen monthly amount needs a Stripe Checkout connection.';
15
16
  if (shop.capPerPlayerMonth !== null) return 'Reserving a spending allowance before payment needs the fuller checkout connection.';
16
17
  if (shop.checkoutMinutes !== 1440) return 'Choosing when abandoned checkouts expire needs the fuller checkout connection.';
17
18
  return null;
@@ -207,6 +207,14 @@ export const SHOP_JS = String.raw`(function () {
207
207
  age.appendChild(sel); age.appendChild(ok);
208
208
  }
209
209
  }
210
+ (s.menus || []).forEach(function (menu, index) {
211
+ var button = el('button', {type:'button',class:'buy'}, s.menus.length > 1 ? 'Choose items on Stripe · Menu ' + (index+1) : 'Choose several items on Stripe');
212
+ button.onclick = function () {
213
+ button.disabled=true;
214
+ api('POST','/api/shop/buy',{lines:menu.items.map(function(item){return {item:item,quantity:1};}),game:boot.game||undefined}).then(function(r){if(!r.ok||!r.url)throw new Error(r.message||'Could not open menu');location.href=r.url;}).catch(function(e){button.disabled=false;say(e.message,true);});
215
+ };
216
+ list.appendChild(button);
217
+ });
210
218
  s.items.forEach(function (i) {
211
219
  var card = el('article', { class: 'item', id: 'item-' + i.id });
212
220
  card.appendChild(el('h2', null, i.name));
@@ -236,7 +244,7 @@ export const SHOP_JS = String.raw`(function () {
236
244
  var add = el('button', { type: 'button', class: 'plain' }, 'Add to cart');
237
245
  add.onclick = function () { cart.push({ item: i.id, name: i.name, quantity: Number(quantity.value), amount: amount ? units(amount.value, s.currencyScale) : undefined }); drawCart(); };
238
246
  if (!s.keyless) { act.appendChild(quantity); act.appendChild(add); }
239
- else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : boot.giving ? 'Confirm your gift on Stripe.' : 'Confirm quantity on Stripe. Buy different items separately.'));
247
+ else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : boot.giving ? 'Confirm your gift on Stripe.' : 'Confirm quantity on Stripe, or use a menu above for several items.'));
240
248
  } else if (i.way === 'owned') {
241
249
  act.appendChild(el('span', { class: 'owned' }, 'Yours ✓'));
242
250
  } else if (i.way === 'make-an-account') {
@@ -261,6 +269,14 @@ export const SHOP_JS = String.raw`(function () {
261
269
  list.appendChild(card);
262
270
  });
263
271
  if (s.nextCursor) { var next = el('button', { type: 'button' }, 'Next items'); next.onclick = function () { load(s.nextCursor); }; list.appendChild(next); }
272
+ if(s.portalLogin)list.appendChild(el('a',{href:s.portalLogin,target:'_blank',rel:'noopener',class:'plain'},'Manage monthly giving or membership on Stripe'));
273
+ if (!s.portalLogin && s.items.some(function(i) { return i.interval; })) api('GET', '/api/shop/subscriptions').then(function(r) {
274
+ (r.subscriptions || []).forEach(function(sub) {
275
+ var manage = el('button', { type: 'button', class: 'plain' }, 'Manage subscription on Stripe');
276
+ manage.onclick = function() { var tab = window.open('', '_blank'); api('POST', '/api/shop/portal', { subscription: sub.id }).then(function(result) { if (!result.ok || !result.url) throw new Error(result.message || 'Portal unavailable'); if (tab) tab.location.href = result.url; else location.href = result.url; }).catch(function(e) { if (tab) tab.close(); say(e.message, true); }); };
277
+ list.appendChild(manage);
278
+ });
279
+ }).catch(function() {});
264
280
  if (!s.items.length) list.appendChild(el('p', null, boot.giving ? 'No gift options are available right now.' : 'Nothing for sale here right now.'));
265
281
  if (boot.item) { var at = document.getElementById('item-' + boot.item); if (at && at.scrollIntoView) at.scrollIntoView({ block: 'center' }); }
266
282
  }
@@ -282,7 +298,7 @@ export const SHOP_JS = String.raw`(function () {
282
298
  (function poll() {
283
299
  tries += 1;
284
300
  api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
285
- 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. Your receipt comes from Stripe by email.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; return; }
301
+ 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. Your receipt comes from Stripe by email.' : '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; }
286
302
  if (tries < 40) setTimeout(poll, 1500);
287
303
  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.';
288
304
  }).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
@@ -485,13 +501,19 @@ export const SHOP_SHELL_JS = String.raw`(function () {
485
501
  if (!cfg || kids) return reply({ ok: true, open: false, kids: kids, screen: screen, owns: [] });
486
502
  return refresh().then(function () { reply({ ok: true, open: true, kids: false, screen: screen, owns: owns }); });
487
503
  }
504
+ if (m.op === 'catalog') {
505
+ if (kids) return reply({ ok: true, open: false, items: [] });
506
+ return api('GET', '/api/shop?game=' + encodeURIComponent(GAME) + '&server=' + encodeURIComponent(SERVER) + '&cursor=' + encodeURIComponent(typeof m.cursor === 'string' ? m.cursor : '')).then(function (r) {
507
+ reply({ ok: r.ok, open: r.open, currency: r.currency, currencyScale: r.currencyScale, items: r.items, nextCursor: r.nextCursor });
508
+ }).catch(function () { reply({ ok: false }); });
509
+ }
488
510
  if (m.op === 'owns') return refresh().then(function () { reply({ ok: true, owns: owns }); });
489
511
  if (m.op === 'open') { open(typeof m.item === 'string' ? m.item : null); return reply({ ok: true, shown: true }); }
490
512
  if (m.op === 'close') { close(); return reply({ ok: true }); }
491
513
  if (m.op === 'checkout') {
492
514
  if (!cfg || kids || screen) return reply({ ok: false, error: 'policy' });
493
515
  var checkoutTab = window.open('', '_blank');
494
- return api('POST', '/api/shop/buy', { lines: m.lines, reference: m.reference, game: GAME, server: SERVER }).then(function (r) {
516
+ return api('POST', '/api/shop/buy', { lines: m.lines, reference: m.reference, booking: m.booking, game: GAME, server: SERVER }).then(function (r) {
495
517
  if (r.ok && r.url) { leaveForCheckout(checkoutTab, r); watch(); } else if (checkoutTab) checkoutTab.close();
496
518
  reply(r);
497
519
  }).catch(function () { if (checkoutTab) checkoutTab.close(); reply({ ok: false }); });
@@ -49,6 +49,7 @@ export function amountError(amount, currency) {
49
49
 
50
50
  /** Checkout wording is shared by validation and the request path. */
51
51
  export function checkoutMessage(shop, studio = 'this studio', parent = false) {
52
+ if (shop.giving?.receiptText) return shop.giving.receiptText;
52
53
  return `${parent ? "This is for your child's account. " : ''}Delivered at once to the player's account on ${studio}. ${shop.policy.withdrawalAcknowledgement ? 'By paying you ask for it now, which ends the 14-day withdrawal right;' : 'Your statutory rights still apply.'} ${shop.refundDays === null ? (shop.policy.withdrawalAcknowledgement ? 'ask the studio about refunds.' : 'Ask the studio about refunds.') : `${shop.policy.refundUsedItems ? 'an item' : 'an unused item'} can still be refunded within ${shop.refundDays} days.`}`;
53
54
  }
54
55
 
@@ -157,7 +158,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
157
158
  if (why) err(`${at}.price`, why);
158
159
  else price = numeric(it.price);
159
160
  }
160
- if (it.interval !== undefined && (it.interval !== 'month' || chosen)) err(`${at}.interval`, 'monthly gifts need a fixed suggested amount');
161
+ if (it.interval !== undefined && it.interval !== 'month') err(`${at}.interval`, 'interval must be month');
161
162
  const charge = chosen ? min : price;
162
163
  if (currencyScale(currency) === 1000 && charge % 10) warnings.push({ at: `${at}.price`, message: 'This three-decimal amount uses the smallest minor unit. Confirm support with your Stripe payment method; Stripe decides acceptance (https://docs.stripe.com/currencies).' });
163
164
  if (charge > 0 && charge < minimumCharge(currency)) warnings.push({ at: `${at}.price`, message: `Stripe's minimum for settlement in ${currency.toUpperCase()} is ${money(minimumCharge(currency), currency)}. Your settlement currency and payment method determine the actual minimum at checkout.` });
@@ -286,7 +286,7 @@ export function forgetPlayerShop(env, player) {
286
286
  /** Grant an order's entitlements (idempotent on player, key and order). */
287
287
  export function grantStatements(env, order, item, now = Date.now(), line = { id: order.id + '_0', quantity: 1 }) {
288
288
  const starts = item.starts ? Math.max(now, Date.parse(item.starts)) : now;
289
- const ends = item.ends ? Date.parse(item.ends) : item.days !== undefined && item.days !== null ? starts + Math.round(item.days * DAY) : null;
289
+ const ends = item.interval ? starts : item.ends ? Date.parse(item.ends) : item.days !== undefined && item.days !== null ? starts + Math.round(item.days * DAY) : null;
290
290
  return (item.gives ?? []).flatMap((key) => [
291
291
  env.DB.prepare("INSERT INTO entitlements (player, key, item, order_id, starts_at, ends_at, state, used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', NULL) ON CONFLICT(player, key, order_id) DO UPDATE SET starts_at = MIN(starts_at, excluded.starts_at), ends_at = CASE WHEN ends_at IS NULL OR excluded.ends_at IS NULL THEN NULL ELSE MAX(ends_at, excluded.ends_at) END").bind(order.player, key, item.id, order.id, starts, ends),
292
292
  env.DB.prepare("INSERT INTO shop_entitlement_lines (player, key, item, order_id, line_id, quantity, starts_at, ends_at, state) SELECT ?1, ?2, ?3, ?4, ?7, ?8, ?5, ?6, 'active' WHERE NOT EXISTS (SELECT 1 FROM entitlements WHERE player = ?1 AND key = ?2 AND order_id = ?4 AND state != 'active') ON CONFLICT DO NOTHING").bind(order.player, key, item.id, order.id, starts, ends, line.id, line.quantity),
@@ -0,0 +1,198 @@
1
+ import { linkConfig, hasStripeKey } from './shop-links.mjs';
2
+ import { KEY_SHAPE, stripeCall } from './stripe.mjs';
3
+ /** Only verified shop Checkout sessions establish ownership. Provider customer IDs
4
+ * stay private, and are never accepted from a browser. */
5
+ export const SUBSCRIPTIONS_MIGRATION_FILE = '0026_shop_subscriptions.sql';
6
+ export const SUBSCRIPTIONS_MIGRATION = `
7
+ CREATE TABLE shop_subscriptions (
8
+ id TEXT PRIMARY KEY, order_id TEXT NOT NULL UNIQUE, customer TEXT NOT NULL,
9
+ state TEXT NOT NULL DEFAULT 'active', event_at INTEGER NOT NULL DEFAULT 0
10
+ );
11
+ CREATE TABLE shop_subscription_events (
12
+ id TEXT PRIMARY KEY, subscription TEXT NOT NULL, type TEXT NOT NULL,
13
+ invoice TEXT, amount INTEGER, currency TEXT, at INTEGER NOT NULL
14
+ );
15
+ CREATE TRIGGER shop_subscription_event AFTER INSERT ON shop_subscription_events BEGIN
16
+ INSERT OR IGNORE INTO studio_events(id,type,data,at)
17
+ SELECT 'subscription:' || NEW.id,NEW.type,
18
+ json_object('subscription',NEW.subscription,'order',s.order_id,'app',o.game,
19
+ 'reference',(SELECT value FROM meta WHERE key='shop-reference:' || o.id),
20
+ 'invoice',NEW.invoice,'amount',NEW.amount,'currency',NEW.currency,'mode',o.mode),NEW.at
21
+ FROM shop_subscriptions s JOIN shop_orders o ON o.id=s.order_id
22
+ WHERE s.id=NEW.subscription AND EXISTS(SELECT 1 FROM function_subscriptions WHERE type=NEW.type);
23
+ END;
24
+ `;
25
+ const idOf = x => typeof x === 'string' ? x : x?.id;
26
+ export async function bindSubscription(env, order, session, shop) {
27
+ const subscription=idOf(session.subscription), customer=idOf(session.customer);
28
+ if (!subscription || !customer || !/^sub_[A-Za-z0-9]+$/.test(subscription) || !/^cus_[A-Za-z0-9]+$/.test(customer)) throw new Error('Stripe subscription identity missing');
29
+ await env.DB.batch([
30
+ env.DB.prepare('INSERT OR IGNORE INTO shop_subscriptions(id,order_id,customer) VALUES(?1,?2,?3)').bind(subscription,order.id,customer),
31
+ env.DB.prepare(`INSERT OR IGNORE INTO shop_subscription_events(id,subscription,type,invoice,amount,currency,at)
32
+ VALUES(?1,?2,'subscription.started',NULL,?3,?4,?5)`).bind(`started:${subscription}`,subscription,order.amount,order.currency,order.paid_at),
33
+ ]);
34
+ await reconcileMembership(env, subscription, shop);
35
+ if(!hasStripeKey(env)) {
36
+ const invoice=typeof session.invoice==='string'?session.invoice:session.invoice?.id;
37
+ if(invoice) {
38
+ const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-invoice:${invoice}`).first();
39
+ if(saved)await invoiceMembership(env,subscription,JSON.parse(saved.value));
40
+ }
41
+ }
42
+ }
43
+
44
+ /** Stripe retries unknown subscriptions until their Checkout has established ownership.
45
+ * Ignore unrelated invoices, and never infer a successful renewal from a status update. */
46
+ export async function subscriptionEvent(env, event, shop) {
47
+ if (!['invoice.paid','invoice.payment_failed','customer.subscription.updated','customer.subscription.deleted'].includes(event.type)) return null;
48
+ const obj=event.data.object;
49
+ const subscription=event.type.startsWith('customer.subscription.') ? obj.id : idOf(obj.parent?.subscription_details?.subscription ?? obj.subscription);
50
+ if (!subscription) return 'ignored';
51
+ let row;
52
+ try {
53
+ row=await env.DB.prepare('SELECT s.*,o.mode FROM shop_subscriptions s JOIN shop_orders o ON o.id=s.order_id WHERE s.id=?1').bind(subscription).first();
54
+ } catch (error) {
55
+ // Existing studios have no subscription ownership until they opt in and migrate.
56
+ // Preserve retries for database failures other than an absent opt-in table.
57
+ if (!/no such table: (?:main\.)?shop_subscriptions\b/.test(String(error?.message))) throw error;
58
+ }
59
+ if (!row) {
60
+ const metadata=obj.parent?.subscription_details?.metadata ?? obj.subscription_details?.metadata ?? obj.metadata;
61
+ if (metadata?.homie === 'shop-v1' || metadata?.homie === 'shop-links-v1') throw new Error('Subscription Checkout has not arrived; retry');
62
+ return 'ignored';
63
+ }
64
+ if (event.livemode !== (row.mode === 'live')) return 'ignored';
65
+ await reconcileMembership(env, subscription, shop);
66
+ if(event.type==='invoice.paid' && !hasStripeKey(env))await invoiceMembership(env,subscription,obj);
67
+ const at=(event.created ?? 0)*1000;
68
+ let type, state;
69
+ if (event.type === 'customer.subscription.deleted') { type='subscription.cancelled'; state='cancelled'; }
70
+ else if (event.type === 'invoice.paid') { if (obj.billing_reason === 'subscription_create') return 'initial'; type='subscription.renewed'; state='active'; }
71
+ else if (event.type === 'invoice.payment_failed') { type='subscription.failed'; state='past_due'; }
72
+ else { return 'updated'; } // Scheduling cancellation is not cancellation; Stripe sends deleted when it ends.
73
+ const eventId=type === 'subscription.renewed' ? `paid:${obj.id}` : event.id;
74
+ await env.DB.batch([
75
+ env.DB.prepare(`UPDATE shop_subscriptions SET state=?2,event_at=?3 WHERE id=?1 AND state!='cancelled' AND event_at<=?3 AND ?4=0`).bind(subscription,state,at,hasStripeKey(env)?1:0),
76
+ env.DB.prepare(`INSERT OR IGNORE INTO shop_subscription_events(id,subscription,type,invoice,amount,currency,at)
77
+ VALUES(?1,?2,?3,?4,?5,?6,?7)`).bind(eventId,subscription,type,event.type.startsWith('invoice.')?obj.id:null,obj.amount_paid??obj.amount_due??null,obj.currency??null,at),
78
+ ...(state === 'cancelled' ? [env.DB.prepare(`UPDATE shop_entitlement_lines SET ends_at=MIN(COALESCE(ends_at,?2),?2)
79
+ WHERE order_id=?1 AND line_id IN (SELECT id FROM shop_order_lines WHERE order_id=?1 AND json_extract(snapshot,'$.interval') IS NOT NULL)`)
80
+ .bind(row.order_id,at)] : []),
81
+ ]);
82
+ return type;
83
+ }
84
+ export async function subscriptionPortal(env, player, subscription, origin) {
85
+ const row=await env.DB.prepare(`SELECT s.customer FROM shop_subscriptions s JOIN shop_orders o ON o.id=s.order_id WHERE s.id=?1 AND o.player=?2`).bind(subscription,player).first();
86
+ if (!row) return null;
87
+ if(!hasStripeKey(env)) {
88
+ const url=linkConfig(env)?.portal;
89
+ return typeof url==='string' && /^https:\/\/billing\.stripe\.com\/p\/login\//.test(url)?url:null;
90
+ }
91
+ const portal=await stripeCall(env,'POST','/v1/billing_portal/sessions',{customer:row.customer,return_url:`${origin}/shop/`});
92
+ if (typeof portal?.url !== 'string' || new URL(portal.url).origin !== 'https://billing.stripe.com') throw new Error('Invalid Stripe portal response');
93
+ return portal.url;
94
+ }
95
+
96
+ /** Reconcile membership access from current provider truth, not delivery order.
97
+ * A subscription item stays attached to its original order line across portal plan
98
+ * changes. Only catalogued products can supply replacement grants. Unknown plans
99
+ * grant nothing. Financial order snapshots remain immutable.
100
+ */
101
+ export async function reconcileMembership(env, subscription, shop) {
102
+ if (!hasStripeKey(env)) return;
103
+ const row = await env.DB.prepare(`SELECT s.*,o.player,o.status AS order_status,o.mode
104
+ FROM shop_subscriptions s JOIN shop_orders o ON o.id=s.order_id WHERE s.id=?1`).bind(subscription).first();
105
+ if (!row) return;
106
+ const lines = (await env.DB.prepare(`SELECT * FROM shop_order_lines WHERE order_id=?1
107
+ AND json_extract(snapshot,'$.interval') IS NOT NULL`).bind(row.order_id).all()).results;
108
+ if (!lines.length) return;
109
+ const revision = crypto.randomUUID(), syncKey = `subscription-sync:${subscription}`;
110
+ await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(syncKey,revision).run();
111
+ const current = await stripeCall(env,'GET',`/v1/subscriptions/${subscription}`,{
112
+ expand:['items.data.price.product','latest_invoice'],
113
+ });
114
+ if (current.id !== subscription || idOf(current.customer) !== row.customer || current.livemode !== (row.mode === 'live')) throw new Error('Subscription identity mismatch');
115
+ if (current.items?.has_more) current.items = await allPages(env,'/v1/subscription_items',{subscription,expand:['data.price.product']});
116
+ const invoice = current.latest_invoice;
117
+ if (invoice?.lines?.has_more) invoice.lines = await allPages(env,`/v1/invoices/${invoice.id}/lines`);
118
+ // Every mutation is conditional on the read generation. A slower request cannot
119
+ // overwrite a newer reconciliation, including when Stripe events share a second.
120
+ const guard = `EXISTS(SELECT 1 FROM meta WHERE key=?1 AND value=?2)`;
121
+ const writes = [env.DB.prepare(`UPDATE shop_subscriptions SET state=?3 WHERE id=?4 AND ${guard}`)
122
+ .bind(syncKey,revision,current.status,subscription)];
123
+ const active = ['active','trialing','past_due'].includes(current.status) && row.order_status === 'paid';
124
+ const used = new Set();
125
+ for (const item of current.items?.data ?? []) {
126
+ const mappingKey = `subscription-item:${subscription}:${item.id}`;
127
+ const saved = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(mappingKey).first();
128
+ const product = item.price?.product;
129
+ let line = lines.find(l => l.id === saved?.value);
130
+ if (!line) line = lines.find(l => !used.has(l.id) && (product?.metadata?.line === l.id ||
131
+ [product?.metadata?.item,product?.metadata?.homie_item].includes(l.item) ||
132
+ (idOf(product) && JSON.parse(l.snapshot).stripeProduct === idOf(product))));
133
+ if (!line) continue;
134
+ used.add(line.id);
135
+ writes.push(env.DB.prepare(`INSERT INTO meta(key,value) SELECT ?3,?4 WHERE ${guard} ON CONFLICT(key) DO NOTHING`).bind(syncKey,revision,mappingKey,line.id));
136
+ const original = JSON.parse(line.snapshot);
137
+ const originalProduct = product?.metadata?.item === original.id || product?.metadata?.homie_item === original.id || (idOf(product) && original.stripeProduct === idOf(product));
138
+ const plan = originalProduct ? original : shop?.items?.find(p => p.interval && ((idOf(product) && p.stripeProduct === idOf(product)) || [product?.metadata?.item,product?.metadata?.homie_item].includes(p.id)));
139
+ // A paid invoice line identifies both the price and subscription item. A
140
+ // pending invoice/portal upgrade never creates an unpaid entitlement period.
141
+ const paidLine = invoice?.status === 'paid' && invoice.lines?.data?.find(l =>
142
+ (idOf(l.subscription_item ?? l.parent?.subscription_item_details?.subscription_item) === item.id) &&
143
+ idOf(l.price ?? l.pricing?.price_details?.price) === item.price.id &&
144
+ (l.amount ?? 0) >= 0);
145
+ const paidEnd = Number(paidLine?.period?.end) * 1000;
146
+ const previous = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`subscription-period:${subscription}:${item.id}`).first();
147
+ const prior = previous ? JSON.parse(previous.value) : null;
148
+ const end = Number.isSafeInteger(paidEnd) && paidEnd > 0 ? paidEnd : prior?.price === item.price.id ? prior.end : 0;
149
+ const allowedEnd = active && plan && line.status !== 'refunded' ? Math.min(end, plan.ends ? Date.parse(plan.ends) : Infinity) : 0;
150
+ writes.push(env.DB.prepare(`INSERT INTO meta(key,value) SELECT ?3,?4 WHERE ${guard} ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
151
+ .bind(syncKey,revision,`subscription-period:${subscription}:${item.id}`,JSON.stringify({price:item.price.id,end})));
152
+ // Retain old rows with zero access: removing them would activate the legacy
153
+ // aggregate fallback used by pre-line studios.
154
+ writes.push(env.DB.prepare(`UPDATE shop_entitlement_lines SET ends_at=0 WHERE line_id=?3 AND order_id=?4 AND ${guard}`).bind(syncKey,revision,line.id,row.order_id));
155
+ for (const key of row.player ? plan?.gives ?? [] : []) {
156
+ writes.push(env.DB.prepare(`INSERT INTO entitlements(player,key,item,order_id,starts_at,ends_at,state,used_at)
157
+ SELECT ?3,?4,?5,?6,0,?7,'active',NULL WHERE ${guard}
158
+ ON CONFLICT(player,key,order_id) DO UPDATE SET state=CASE WHEN ?8 THEN 'active' ELSE entitlements.state END`)
159
+ .bind(syncKey,revision,row.player,key,plan.id,row.order_id,allowedEnd,active?1:0));
160
+ writes.push(env.DB.prepare(`INSERT INTO shop_entitlement_lines(player,key,item,order_id,line_id,quantity,starts_at,ends_at,state)
161
+ SELECT ?3,?4,?5,?6,?7,?8,0,?9,'active' WHERE ${guard}
162
+ ON CONFLICT DO UPDATE SET item=excluded.item,quantity=excluded.quantity,ends_at=excluded.ends_at`)
163
+ .bind(syncKey,revision,row.player,key,plan.id,row.order_id,line.id,item.quantity,allowedEnd));
164
+ }
165
+ }
166
+ for (const line of lines) if (!used.has(line.id)) writes.push(env.DB.prepare(`UPDATE shop_entitlement_lines SET ends_at=0 WHERE line_id=?3 AND order_id=?4 AND ${guard}`).bind(syncKey,revision,line.id,row.order_id));
167
+ await env.DB.batch(writes);
168
+ }
169
+
170
+ async function allPages(env, path, params = {}) {
171
+ const data=[];let after;
172
+ for (;;) {
173
+ const page=await stripeCall(env,'GET',path,{...params,limit:100,starting_after:after});
174
+ data.push(...page.data);
175
+ if (!page.has_more) return {data,has_more:false};
176
+ const next=page.data.at(-1)?.id;
177
+ if (!next || next===after) throw new Error('Stripe pagination did not advance');
178
+ after=next;
179
+ }
180
+ }
181
+
182
+ /** Paid signed invoices grant only their stated periods. Failure never extends access. */
183
+ async function invoiceMembership(env,subscription,invoice) {
184
+ if(invoice.status!=='paid' || invoice.lines?.has_more)throw new Error('Complete paid invoice required');
185
+ const row=await env.DB.prepare('SELECT s.*,o.player,o.status order_status FROM shop_subscriptions s JOIN shop_orders o ON o.id=s.order_id WHERE s.id=?1').bind(subscription).first();
186
+ if(!row || row.state==='cancelled' || row.order_status!=='paid')return;
187
+ const lines=(await env.DB.prepare('SELECT * FROM shop_order_lines WHERE order_id=?1').bind(row.order_id).all()).results;
188
+ const writes=[];
189
+ for(const l of lines) {
190
+ const item=JSON.parse(l.snapshot);
191
+ if(!item.interval)continue;
192
+ const paid=invoice.lines.data.find(x=>(x.price?.id??x.pricing?.price_details?.price)===item.stripePrice && x.quantity===l.quantity && x.amount>=0);
193
+ const end=Number(paid?.period?.end)*1000;
194
+ if(!Number.isSafeInteger(end)||end<=0)continue;
195
+ writes.push(env.DB.prepare('UPDATE shop_entitlement_lines SET ends_at=MAX(COALESCE(ends_at,0),?2) WHERE line_id=?1').bind(l.id,end));
196
+ }
197
+ if(writes.length)await env.DB.batch(writes);
198
+ }