@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/CHANGELOG.md +42 -0
- package/app-starters/giving/README.md +25 -2
- package/app-starters/giving/app.json +16 -49
- package/app-starters/giving/index.html +1 -1
- package/app-starters/giving/src/rules.ts +1 -1
- package/app-starters/giving/src/view.ts +57 -59
- package/apps/APPS.md +25 -1
- package/apps/apps.ts +1 -1
- package/bin/homie-studio.mjs +5 -1
- package/guides/app/SKILL.md +13 -1
- package/guides/shop/SKILL.md +41 -20
- package/lib/dev-stripe-fixture.mjs +37 -0
- package/lib/dev-stripe-sandbox.mjs +80 -0
- package/lib/mcp-tools.mjs +15 -1
- package/lib/scaffold.mjs +15 -1
- package/lib/shop-links.mjs +79 -7
- package/lib/shop.mjs +4 -2
- package/lib/stripe-connect.mjs +1 -0
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/shop/SELLING.md +2 -2
- package/shop/SHOP.md +120 -5
- package/shop/shop.ts +29 -0
- package/worker/app-format.mjs +6 -0
- package/worker/app-records.mjs +23 -1
- package/worker/booking-store.mjs +79 -0
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +5 -2
- package/worker/records-page.mjs +4 -3
- package/worker/shop-links.mjs +3 -2
- package/worker/shop-page.mjs +25 -3
- package/worker/shop-rules.mjs +2 -1
- package/worker/shop-store.mjs +1 -1
- package/worker/shop-subscriptions.mjs +198 -0
- package/worker/shop.mjs +194 -29
- package/worker/version.mjs +1 -1
package/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' }); },
|
package/worker/app-format.mjs
CHANGED
|
@@ -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;
|
package/worker/app-records.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/worker/build-id.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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));
|
package/worker/records-page.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|
package/worker/shop-links.mjs
CHANGED
|
@@ -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(
|
|
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;
|
package/worker/shop-page.mjs
CHANGED
|
@@ -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
|
|
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 }); });
|
package/worker/shop-rules.mjs
CHANGED
|
@@ -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 &&
|
|
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.` });
|
package/worker/shop-store.mjs
CHANGED
|
@@ -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
|
+
}
|