@homie-rocks/studio 0.37.0 → 0.37.2

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/homie-studio.mjs +10 -3
  3. package/lib/cloudflare.mjs +40 -6
  4. package/lib/dev.mjs +1 -1
  5. package/lib/doctor.mjs +7 -7
  6. package/lib/domain.mjs +43 -0
  7. package/lib/jobs.mjs +17 -7
  8. package/lib/mcp-tools.mjs +49 -18
  9. package/lib/mcp.mjs +6 -11
  10. package/lib/parts-build.mjs +23 -4
  11. package/lib/parts-cli.mjs +54 -3
  12. package/lib/parts-purchase.mjs +174 -0
  13. package/lib/parts-store.mjs +50 -19
  14. package/lib/parts-tools.mjs +15 -12
  15. package/lib/parts-upload.mjs +43 -0
  16. package/lib/parts.mjs +49 -5
  17. package/lib/prepare.mjs +47 -0
  18. package/lib/purchase-links.mjs +42 -0
  19. package/lib/purchase-wallet.mjs +114 -0
  20. package/lib/scaffold.mjs +35 -5
  21. package/lib/shoot.mjs +1 -1
  22. package/lib/shop-links.mjs +15 -6
  23. package/lib/shop.mjs +91 -21
  24. package/lib/stripe-connect.mjs +2 -2
  25. package/lib/template-history.json +82 -0
  26. package/package.json +12 -2
  27. package/parts/COMMERCIAL-LICENCE.md +21 -0
  28. package/parts/PARTS.md +238 -10
  29. package/worker/extensions.mjs +13 -0
  30. package/worker/index.mjs +3 -2
  31. package/worker/machine-payments.mjs +72 -0
  32. package/worker/office.mjs +4 -1
  33. package/worker/parts-protected.mjs +111 -0
  34. package/worker/parts-purchase-catalogue.mjs +34 -0
  35. package/worker/parts-resource.mjs +32 -0
  36. package/worker/parts-sale.mjs +59 -0
  37. package/worker/parts.mjs +24 -11
  38. package/worker/payment-capabilities.mjs +70 -0
  39. package/worker/payment-dry-exchange.mjs +30 -0
  40. package/worker/payment-manual-refund.mjs +40 -0
  41. package/worker/payment-policy.mjs +40 -0
  42. package/worker/payment-recovery.mjs +163 -0
  43. package/worker/payment-test-exchange.mjs +19 -0
  44. package/worker/purchase-core.mjs +140 -0
  45. package/worker/purchase-crypto.mjs +17 -0
  46. package/worker/purchase-delivery.mjs +108 -0
  47. package/worker/purchase-discovery.mjs +26 -0
  48. package/worker/purchase-hosted-readiness.mjs +16 -0
  49. package/worker/purchase-keys.mjs +20 -0
  50. package/worker/purchase-links.mjs +9 -0
  51. package/worker/purchase-machine.mjs +279 -0
  52. package/worker/purchase-mcp.mjs +42 -0
  53. package/worker/purchase-office-routes.mjs +17 -0
  54. package/worker/purchase-office-script.mjs +38 -0
  55. package/worker/purchase-office.mjs +35 -0
  56. package/worker/purchase-owner.mjs +243 -0
  57. package/worker/purchase-pricing.mjs +8 -0
  58. package/worker/purchase-problem.mjs +4 -0
  59. package/worker/purchase-reconciliation.mjs +582 -0
  60. package/worker/purchase-response.mjs +82 -0
  61. package/worker/purchase-routes.mjs +611 -0
  62. package/worker/purchase-schema.mjs +50 -0
  63. package/worker/purchase-store.mjs +148 -0
  64. package/worker/purchases.mjs +6 -0
  65. package/worker/resource-kinds.mjs +20 -0
  66. package/worker/selling/index.mjs +8 -0
  67. package/worker/selling/runtime.mjs +42 -0
  68. package/worker/settlement-journal.mjs +138 -0
  69. package/worker/shop-page.mjs +7 -4
  70. package/worker/shop-refunds.mjs +22 -0
  71. package/worker/shop-rules.mjs +1 -1
  72. package/worker/shop.mjs +18 -18
  73. package/worker/stripe.mjs +36 -3
  74. package/worker/version.mjs +1 -1
@@ -0,0 +1,42 @@
1
+ import { purchaseResponse } from './purchase-response.mjs';
2
+ import { machineCapabilities } from './payment-capabilities.mjs';
3
+ /** The SDK owns protocol negotiation, JSON-RPC errors and stateless HTTP transport. */
4
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
6
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
7
+ import { machineResource } from './purchase-machine.mjs';
8
+ export const purchaseSchema = {
9
+ type: 'object', required: ['kind', 'resource', 'version', 'buyer', 'claim', 'offerVersion'],
10
+ properties: {
11
+ kind: { type: 'string' }, resource: { type: 'string' }, version: { type: 'string' },
12
+ buyer: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'A random private buyer identifier.' },
13
+ claim: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'A new private random 32-byte hex secret for this purchase. Keep it to retrieve this order.' },
14
+ offerVersion: { type: 'string', description: 'The published immutable offer version.' },
15
+ quantity: { type: 'integer', minimum: 1, default: 1 }, game: { type: 'string' },
16
+ },
17
+ };
18
+ export async function purchaseMcp(request, env, url, cat) {
19
+ let delivery;
20
+ const capability = await machineCapabilities(env);
21
+ const methods = Object.fromEntries([...(capability.machine.card ? ['stripe'] : []), ...(capability.machine.tempo ? ['tempo'] : [])].map((method) => [method, { intents: ['charge'] }]));
22
+ const server = new Server({ name: 'Studio purchases', version: '1' }, {
23
+ capabilities: { tools: {}, experimental: { payment: { methods } } },
24
+ });
25
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{
26
+ name: 'purchase', description: 'Pay for the published offer and receive files, receipt and proof. Unpaid challenges expire after five minutes.', inputSchema: purchaseSchema,
27
+ }] }));
28
+ server.setRequestHandler(CallToolRequestSchema, async (input, extra) => {
29
+ if (input.params.name !== 'purchase') return { isError: true, content: [{ type: 'text', text: 'Unknown tool' }] };
30
+ const result = await machineResource(new Request(request.url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input.params.arguments) }), env, url, cat, {
31
+ mcpInput: input.params.arguments, setDelivery: (data) => { delivery = data; }, extra: { ...extra, _meta: input.params._meta },
32
+ });
33
+ if (result instanceof Response) return { isError: true, content: [{ type: 'text', text: await result.text() }] };
34
+ return result;
35
+ });
36
+ const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
37
+ await server.connect(transport);
38
+ const response = await transport.handleRequest(request);
39
+ if (!delivery) return response;
40
+ const envelope = await response.json();
41
+ return purchaseResponse(delivery, env, { mcp: true, envelope });
42
+ }
@@ -0,0 +1,17 @@
1
+ import { selling } from './extensions.mjs';
2
+ const json = (v,status=200) => Response.json(v,{status});
3
+ export async function officeRoutes(request, env, path, body) {
4
+ if (path === '/_studio/api/shop/readiness' && request.method === 'POST') return json(await selling.testPaymentExchange(env));
5
+ if (['/_studio/api/shop/retire', '/_studio/api/shop/reissue', '/_studio/api/shop/test-access', '/_studio/api/shop/sync-offers', '/_studio/api/shop/reconcile-payments'].includes(path) && request.method === 'POST') {
6
+ try { if (path.endsWith('/reconcile-payments')) { return json({ ok: true, ...await selling.reconcileRecordings(env, body.order ?? null, { payment: body.payment ?? null, cancel: body.cancel === true }) }); }
7
+ if (path.endsWith('/test-access')) return json(await selling.authorizePurchaseTest(env, body.order));
8
+ if (path.endsWith('/sync-offers')) return json(await selling.expirePurchaseCheckouts(env));
9
+ return json(path.endsWith('/retire') ? await selling.retireResource(env, body.kind, body.resource) : await selling.reissuePurchaseClaim(env, body)); }
10
+ catch (error) {
11
+ if (error.status === 403 || error.status === 401) return json({ ok: false, message: 'Reconnect the shop with Checkout Sessions and Subscriptions Write permissions, then retry. New sales remain closed if retirement began.' }, 503);
12
+ if (/no such table/.test(error.message)) return json({ ok: false, message: 'Deploy the purchase migrations first' }, 503);
13
+ throw error;
14
+ }
15
+ }
16
+ return null;
17
+ }
@@ -0,0 +1,38 @@
1
+ export const officeScript = String.raw` if (s.purchases) {
2
+ top.appendChild(el('h3', null, 'Machine payment readiness'));
3
+ (s.purchases.missing || []).forEach(function (reason) { top.appendChild(el('p', null, reason)); });
4
+ var exchange = s.purchases.exchange;
5
+ if (exchange) top.appendChild(el('p', null, exchange.verified ? exchange.scope : exchange.reason));
6
+ }
7
+ var recoveries = (s.settlementRecovery || []).concat(s.paymentRecovery || []);
8
+ if (recoveries.length) {
9
+ top.appendChild(el('h3', null, 'Payment recovery'));
10
+ recoveries.forEach(function (job) {
11
+ top.appendChild(el('p', null, job.order_id + ' · ' + (job.network || 'Stripe') + ' · ' + job.state + (job.request_key ? ' · request ' + job.request_key : '') + (job.payment ? ' · payment ' + job.payment : '') + (job.started_at ? ' · since ' + new Date(job.started_at).toISOString() : '') + (job.error ? ' · ' + job.error : '')));
12
+ if (job.request_key && job.payment) {
13
+ var cancel = el('button', { type: 'button' }, 'Cancel if unpaid');
14
+ cancel.addEventListener('click', function () {
15
+ if (!confirm('Read this payment from Stripe and cancel it only if it has not succeeded? A paid order stays paid and can be refunded separately.')) return;
16
+ cancel.disabled = true;
17
+ api('POST', '/_studio/api/shop/reconcile-payments', { order: job.order_id, cancel: true }).then(function (result) { if (!result.ok) throw new Error(result.message || 'Reconciliation failed'); load(); }).catch(function (error) { cancel.disabled = false; toast(error.message); });
18
+ });
19
+ top.appendChild(cancel);
20
+ }
21
+ if (job.request_key && !job.payment) {
22
+ var payment = el('input', { placeholder: 'Existing Stripe PaymentIntent id', 'aria-label': 'Existing Stripe PaymentIntent id' });
23
+ var read = el('button', { type: 'button' }, 'Read this payment');
24
+ read.addEventListener('click', function () {
25
+ read.disabled = true;
26
+ api('POST', '/_studio/api/shop/reconcile-payments', { order: job.order_id, payment: payment.value.trim() }).then(function (result) { if (!result.ok) throw new Error(result.message || 'Read failed'); load(); }).catch(function (error) { read.disabled = false; toast(error.message); });
27
+ });
28
+ top.appendChild(payment); top.appendChild(read);
29
+ }
30
+ });
31
+ var reconcile = el('button', { type: 'button' }, 'Reconcile payments now');
32
+ reconcile.addEventListener('click', function () {
33
+ reconcile.disabled = true;
34
+ api('POST', '/_studio/api/shop/reconcile-payments', {}).then(function (result) { if (!result.ok) throw new Error(result.message || 'Reconciliation failed'); load(); }).catch(function (error) { reconcile.disabled = false; toast(error.message); });
35
+ });
36
+ top.appendChild(reconcile);
37
+ }
38
+ `;
@@ -0,0 +1,35 @@
1
+ /** Optional resource rows in the existing shop office. */
2
+ import { hasStripeKey, shopMode } from './shop-links.mjs';
3
+ import { dashboardLink, refundPayment, cancelSubscription, redactStripe } from './stripe.mjs';
4
+ import { currentPurchaseOrder, purchaseReadiness } from './purchase-owner.mjs';
5
+ import { reconcileOrder } from './purchase-reconciliation.mjs';
6
+ import { termsOf, byId } from './purchase-store.mjs';
7
+ import { manualRefundDetails } from './payment-manual-refund.mjs';
8
+ import { jobsSQL } from './payment-policy.mjs';
9
+ import { money } from './shop-rules.mjs';
10
+ export async function refundOrder(env, order, { reason = 'requested_by_customer', by = 'owner' } = {}) {
11
+ if (order.mode !== shopMode(env)) return { ok: false, error: 'mode', message: 'Reconnect the payment mode of this order.' };
12
+ if (!hasStripeKey(env)) return { ok: false, error: 'stripe-dashboard', message: 'Refund in Stripe or ask your connected AI. Its signed refund event updates this purchase.', url: dashboardLink(order.mode, 'payment', order.payment) };
13
+ let o;
14
+ try { o = await currentPurchaseOrder(env, order.id) ?? order; } catch (error) { return {ok:false,error:'stripe-retry',message:redactStripe(error.message,env)}; }
15
+ if (o.status === 'refunded') return { ok: true, order: o.id, status: 'succeeded', already: true };
16
+ if (!['paid','fulfilled'].includes(o.status)) return { ok: false, error: 'state', message: `This order is ${o.status}.` };
17
+ if (!o.payment) { const manual = await manualRefundDetails(env, o); return { ok: false, error: manual ? 'manual-refund-required' : 'no-payment', ...manual }; }
18
+ try {
19
+ const refund = await refundPayment(env, o.payment, { reason, metadata: { order: o.id, by } });
20
+ await reconcileOrder(env, o);
21
+ if (refund.status !== 'succeeded') return { ok: true, pending: true, order: o.id, status: refund.status };
22
+ if (o.subscription && termsOf(o).refundEndsSubscription === true) await cancelSubscription(env, o.subscription);
23
+ return { ok: true, order: o.id, refund: refund.id, status: refund.status, amount: refund.amount ?? o.amount, currency: o.currency };
24
+ } catch (error) { return { ok: false, error: 'stripe', message: redactStripe(error.message, env) }; }
25
+ }
26
+ export async function extendOffice(env, cat, origin, out) {
27
+ try {
28
+ out.purchases = await purchaseReadiness(env, origin, cat);
29
+ const rows = (await env.DB.prepare('SELECT o.*, r.manifest AS manifest FROM purchase_orders o JOIN resource_snapshots r ON r.hash=o.manifest_hash ORDER BY o.created_at DESC LIMIT 100').all()).results ?? [];
30
+ for (const r of rows) out.orders.push({ id: r.id, status: r.status, resourceKind: r.resource_kind, resource: r.resource_id, name: JSON.parse(r.manifest).name, shown: money(r.amount,r.currency), amount:r.amount, currency:r.currency, createdAt:r.created_at, mode:r.mode, player:{name:r.buyer_label}, stripe:r.payment ? dashboardLink(r.mode,'payment',r.payment):null, refundable:hasStripeKey(env) && ['paid','fulfilled'].includes(r.status), manualRefund:await manualRefundDetails(env,r) });
31
+ out.orders.sort((a,b)=>b.createdAt-a.createdAt);
32
+ out.paymentRecovery = (await env.DB.prepare(`SELECT id AS request_key, order_id, state, payment, error, attempts, updated_at FROM purchase_jobs WHERE ${jobsSQL('visible')} ORDER BY updated_at`).all()).results ?? [];
33
+ out.settlementRecovery = (await env.DB.prepare('SELECT order_id, network, state, reference, error, created_at, updated_at FROM purchase_settlements ORDER BY created_at').all()).results ?? [];
34
+ } catch (error) { if (!/no such table/.test(error.message)) throw error; out.purchases = {ready:false,missing:['Deploy the purchase migrations']}; }
35
+ }
@@ -0,0 +1,243 @@
1
+ import { hasStripeKey, shopMode } from './shop-links.mjs';
2
+ import { hostedReadiness } from './purchase-hosted-readiness.mjs';
3
+ import { listOffers } from './resource-kinds.mjs';
4
+ import { orderFact, recordOrderState } from './purchase-core.mjs';
5
+ import { machineCapabilities } from './payment-capabilities.mjs';
6
+ /** Studio licences use the shop's Stripe, order book, webhook and refund office. */
7
+
8
+ import { canonicalJson } from './referrals.mjs';
9
+
10
+ import {
11
+ cancelSubscription,
12
+ closeCheckoutSession,
13
+ retrieveCheckoutSession,
14
+ stripeCall,
15
+ modeOf,
16
+ } from "./stripe.mjs";
17
+
18
+ import {
19
+ json,
20
+ byId,
21
+ idOf,
22
+ safeId,
23
+ validBuyer,
24
+ termsOf,
25
+ purchasedResource,
26
+ listedRelease,
27
+ recurringReady,
28
+ expireUnpaidMachineOrders,
29
+ } from "./purchase-store.mjs";
30
+ import {
31
+ reconcilePurchaseSession,
32
+ reconcileSubscription,
33
+ reconcileOrder,
34
+ } from "./purchase-reconciliation.mjs";
35
+ /** Owner-authenticated office calls this after matching the payer's Stripe receipt. */
36
+ export async function reissuePurchaseClaim(env, body) {
37
+ if (
38
+ !validBuyer(body.claimHash) ||
39
+ !/^ord_[A-Za-z0-9]{20}$/.test(body.order ?? "") ||
40
+ body.receiptVerified !== true
41
+ )
42
+ return {
43
+ ok: false,
44
+ message:
45
+ "Verify the Stripe receipt, then supply order, claimHash and receiptVerified: true",
46
+ };
47
+ const o = await byId(env, body.order);
48
+ if (o && o.mode !== shopMode(env))
49
+ return {
50
+ ok: false,
51
+ message: "Reconnect the purchase payment mode before reissue",
52
+ };
53
+ if (!o?.session && !o?.payment)
54
+ return { ok: false, message: "No provider payment to verify" };
55
+ const receipt = o.session
56
+ ? await retrieveCheckoutSession(env, o.session)
57
+ : await stripeCall(
58
+ env,
59
+ "GET",
60
+ `/v1/payment_intents/${encodeURIComponent(o.payment)}`,
61
+ );
62
+ if (
63
+ receipt.metadata?.order !== o.id ||
64
+ receipt.metadata?.homie !== "purchase-v1" ||
65
+ (o.session && (idOf(receipt.customer) ?? null) !== o.customer)
66
+ )
67
+ return {
68
+ ok: false,
69
+ message: "Stripe receipt does not match this purchase",
70
+ };
71
+ if (body.buyer && o.subscription)
72
+ return {
73
+ ok: false,
74
+ message:
75
+ "A subscription cannot move its payer through claim reissue. Cancel billing in Stripe and have the new studio approve its own Checkout.",
76
+ };
77
+ if (body.buyer && (!termsOf(o).transferable || !validBuyer(body.buyer)))
78
+ return {
79
+ ok: false,
80
+ message: "These licence terms do not permit this transfer",
81
+ };
82
+ await orderFact(env, o.id, 'details', {claim_hash: body.claimHash, buyer: body.buyer ?? o.buyer})
83
+ .run();
84
+ return {
85
+ ok: true,
86
+ order: o.id,
87
+ message:
88
+ "Claim reissued. Earlier download credentials and claims are revoked. The buyer retries the purchase.",
89
+ };
90
+ }
91
+
92
+ export async function refundPurchasePayment(env, order) {
93
+ const o = await byId(env, order.id);
94
+ if (!o?.subscription) return false;
95
+ await env.DB.prepare(
96
+ "UPDATE purchase_payment_facts SET state = 'refunded' WHERE payment = ?1 AND order_id = ?2",
97
+ )
98
+ .bind(o.payment, o.id)
99
+ .run();
100
+ await reconcileSubscription(env, o);
101
+ return true;
102
+ }
103
+
104
+ /** Owner-only status probe; player readiness never depends on resource permissions. */
105
+ export async function purchaseReadiness(env, origin, catalogue = {}) {
106
+ const missing = [];
107
+ const recurring = (await listOffers(env,origin)).some((r)=>r.offer.billing !== 'one-time');
108
+ if (recurring && !(await recurringReady(env)))
109
+ missing.push(
110
+ "Reconnect the shop to verify subscription permissions and webhook events",
111
+ );
112
+ if (!env.PURCHASE_RATE_LIMITER)
113
+ missing.push("Deploy the Cloudflare purchase rate limiting binding");
114
+ if (!env.PURCHASE_SIGNING_KEYS)
115
+ missing.push("Create the separate purchase signing key");
116
+ const machine = await machineCapabilities(env);
117
+ if (!machine.ready) missing.push(...Object.values(machine.reasons));
118
+ const { dryPaymentExchange } = await import('./payment-dry-exchange.mjs');
119
+ let exchange = machine.ready ? await dryPaymentExchange(env, origin, catalogue) : { verified: false, reason: 'Configure machine payments before running the dry exchange.' };
120
+ if (shopMode(env) === 'test') {
121
+ const cached = await env.DB.prepare("SELECT value FROM meta WHERE key = 'purchase-test-exchange'").first();
122
+ const sandbox = cached ? JSON.parse(cached.value) : null;
123
+ if (sandbox && sandbox.nextCheckAt > Date.now()) exchange.sandbox = sandbox;
124
+ // Readiness is the current read-only exchange, not a permanent historical probe result.
125
+ }
126
+ if (machine.ready && !exchange.verified) missing.push(exchange.reason);
127
+ const fallback = await hostedReadiness(env, catalogue, recurring);
128
+ return { ready: !missing.length, missing, machine: { ...machine, ready: !missing.length }, fallback, exchange };
129
+ }
130
+
131
+ /** Close new sales immediately, then cancel every recurring contract; retries resume safely. */
132
+ export async function retireResource(env, kind, resource) {
133
+ if (!safeId(resource)) return { ok: false, message: "Name the resource to retire" };
134
+ await env.DB.prepare(
135
+ "INSERT OR IGNORE INTO resource_retirements (resource,retired_at) VALUES (?1,?2)",
136
+ )
137
+ .bind(`${shopMode(env)}:${kind}:${resource}`, Date.now())
138
+ .run();
139
+ await expirePurchaseCheckouts(env, kind, resource);
140
+ let after = "";
141
+ let canceled = 0;
142
+ const pending = [];
143
+ for (;;) {
144
+ const rows =
145
+ (
146
+ await env.DB.prepare(
147
+ "SELECT id AS order_id, subscription FROM purchase_orders WHERE item = ?1 AND mode = ?3 AND subscription IS NOT NULL AND id > ?2 ORDER BY id LIMIT 100",
148
+ )
149
+ .bind(`${kind}:${resource}`, after, shopMode(env))
150
+ .all()
151
+ ).results ?? [];
152
+ if (!rows.length) break;
153
+ for (const row of rows) {
154
+ if (hasStripeKey(env)) { await cancelSubscription(env, row.subscription); canceled++; }
155
+ else {
156
+ const saved = await env.DB.prepare('SELECT value FROM meta WHERE key = ?1').bind(`purchase-subscription:${row.subscription}`).first();
157
+ if (saved && JSON.parse(saved.value).status === 'canceled') canceled++;
158
+ else pending.push(row.subscription);
159
+ }
160
+ after = row.order_id;
161
+ }
162
+ }
163
+ if (pending.length) return {ok:false,needs:'stripe-cancellation',newSalesClosed:true,subscriptions:pending,
164
+ message:'Ask the connected AI to cancel these subscriptions in your Stripe account, resend their signed subscription events if needed, then retry retirement. Paid-period downloads remain available.',
165
+ next:pending.map(id=>({method:'DELETE',path:`/v1/subscriptions/${id}`}))};
166
+ return {
167
+ ok: true,
168
+ canceled,
169
+ message:
170
+ "New sales are closed and subscriptions canceled. Keep paid-period downloads available and publish the withdrawal.",
171
+ };
172
+ }
173
+
174
+ /** Owner approval binds a test delivery to the secret claim, not a public order identifier. */
175
+ export async function authorizePurchaseTest(env, order) {
176
+ const o = await byId(env, order);
177
+ if (!o || o.mode !== "test" || shopMode(env) !== "test")
178
+ return { ok: false, message: "Only a current test order can be approved" };
179
+ await env.DB.prepare("INSERT OR REPLACE INTO purchase_test_approvals (order_id,value) VALUES (?1,?2)")
180
+ .bind(o.id, o.claim_hash)
181
+ .run();
182
+ return {
183
+ ok: true,
184
+ message:
185
+ "This owner-approved test claim can download while the shop stays in test mode",
186
+ };
187
+ }
188
+ /** Called on retirement and after deployment, including withdrawal, supersession and price changes. */
189
+ export async function expirePurchaseCheckouts(env, kind = null, resource = null) {
190
+ let after = "";
191
+ let expired = 0;
192
+ for (;;) {
193
+ const rows =
194
+ (
195
+ await env.DB.prepare(
196
+ "SELECT id, session FROM purchase_orders WHERE status = 'started' AND mode = ?1 AND id > ?2 ORDER BY id LIMIT 100",
197
+ )
198
+ .bind(shopMode(env), after)
199
+ .all()
200
+ ).results ?? [];
201
+ if (!rows.length) break;
202
+ for (const row of rows) {
203
+ after = row.id;
204
+ const o = await byId(env, row.id);
205
+ const p = purchasedResource(o);
206
+ if (resource && (resource !== p.id || kind !== o.resource_kind)) continue;
207
+ const listed = resource
208
+ ? null
209
+ : await listedRelease(env, "https://studio.invalid", o.resource_kind, p.id, p.version);
210
+ if (
211
+ Date.now() < o.created_at + (o.session ? 86400000 : 300000) &&
212
+ listed &&
213
+ canonicalJson(listed.offer) === canonicalJson(JSON.parse(o.offer))
214
+ )
215
+ continue;
216
+ // A keyless completion can precede its invoice. Wait for signed facts; no provider read.
217
+ if (row.session && !hasStripeKey(env)) continue;
218
+ const session = row.session ? await closeCheckoutSession(env, row.session) : { status: "expired" };
219
+ if (session.status === "complete")
220
+ await reconcilePurchaseSession(env, session);
221
+ else {
222
+ await orderFact(env, o.id, 'expired', {})
223
+ .run();
224
+
225
+ expired++;
226
+ }
227
+ }
228
+ }
229
+ const cleaned = await expireUnpaidMachineOrders(env);
230
+ return { ok: true, expired, cleaned };
231
+ }
232
+
233
+ /** Owner actions refresh recurring state just as a buyer grant does. */
234
+ export async function currentPurchaseOrder(env, id) {
235
+ const { reconcileRecordings } = await import('./payment-recovery.mjs');
236
+ await reconcileRecordings(env, id);
237
+ let o = await byId(env, id);
238
+ if (o && o.mode === shopMode(env)) {
239
+ await reconcileOrder(env, o);
240
+ o = await byId(env, id);
241
+ }
242
+ return o;
243
+ }
@@ -0,0 +1,8 @@
1
+ import { currencyScale } from './shop-rules.mjs';
2
+ export function priceWords(s, quantity = 1) {
3
+ if (!s) return 'Free';
4
+ const currency = String(s.currency).toUpperCase();
5
+ const digits = Math.log10(currencyScale(s.currency));
6
+ const shown = new Intl.NumberFormat('en', { style: 'currency', currency, currencyDisplay: 'code' }).format(s.amount * quantity / 10 ** digits);
7
+ return `${shown}${s.billing === 'one-time' ? ' once' : ` per ${s.billing}`} ${s.taxBehavior === 'inclusive' ? 'including tax' : (s.automaticTax || s.taxBehavior === 'exclusive') ? 'before final tax calculation' : 'total'} · per ${s.scope}${quantity > 1 ? ` · ${quantity} units` : ''}`;
8
+ }
@@ -0,0 +1,4 @@
1
+ const titles = { 400: 'Bad Request', 401: 'Unauthorized', 402: 'Payment Required', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', 409: 'Conflict', 410: 'Gone', 413: 'Content Too Large', 429: 'Too Many Requests', 500: 'Internal Server Error', 503: 'Service Unavailable' };
2
+ export function problem(status, detail, extra = {}) {
3
+ return Response.json({ type: 'about:blank', title: titles[status] ?? 'Error', status, detail, ok: false, message: detail, ...extra }, { status, headers: { 'content-type': 'application/problem+json', 'cache-control': 'no-store' } });
4
+ }