@homie-rocks/studio 0.37.1 → 0.38.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bin/homie-studio.mjs +7 -6
  3. package/lib/cloudflare.mjs +28 -3
  4. package/lib/dev.mjs +1 -1
  5. package/lib/parts-build.mjs +23 -4
  6. package/lib/parts-cli.mjs +54 -3
  7. package/lib/parts-purchase.mjs +174 -0
  8. package/lib/parts-store.mjs +50 -19
  9. package/lib/parts-tools.mjs +15 -12
  10. package/lib/parts-upload.mjs +43 -0
  11. package/lib/parts.mjs +49 -5
  12. package/lib/perf.mjs +17 -10
  13. package/lib/port-check.mjs +77 -18
  14. package/lib/purchase-links.mjs +42 -0
  15. package/lib/purchase-wallet.mjs +114 -0
  16. package/lib/rules-check-worker.mjs +15 -2
  17. package/lib/scaffold.mjs +25 -5
  18. package/lib/shoot.mjs +12 -6
  19. package/lib/shop-links.mjs +15 -6
  20. package/lib/shop.mjs +91 -21
  21. package/lib/stripe-connect.mjs +2 -2
  22. package/lib/template-history.json +85 -0
  23. package/netplay/NETPLAY.md +20 -6
  24. package/netplay/netplay.ts +3 -1
  25. package/package.json +12 -2
  26. package/parts/COMMERCIAL-LICENCE.md +21 -0
  27. package/parts/PARTS.md +238 -10
  28. package/port/probe.ts +2 -1
  29. package/rules/core.ts +3 -1
  30. package/rules/host.ts +3 -1
  31. package/rules/pack.ts +30 -3
  32. package/rules/view.ts +232 -40
  33. package/starters/coin-dash/lab.json +17 -0
  34. package/starters/coin-dash/src/rules.ts +3 -3
  35. package/starters/gem-rush/game.json +19 -4
  36. package/starters/gem-rush/map/main.json +50 -0
  37. package/starters/gem-rush/src/move.ts +23 -0
  38. package/starters/gem-rush/src/rules.ts +118 -0
  39. package/starters/gem-rush/src/view.ts +429 -0
  40. package/starters/gem-rush/tunables.json +98 -11
  41. package/worker/extensions.mjs +13 -0
  42. package/worker/index.mjs +3 -2
  43. package/worker/machine-payments.mjs +72 -0
  44. package/worker/office.mjs +4 -1
  45. package/worker/parts-protected.mjs +111 -0
  46. package/worker/parts-purchase-catalogue.mjs +34 -0
  47. package/worker/parts-resource.mjs +32 -0
  48. package/worker/parts-sale.mjs +59 -0
  49. package/worker/parts.mjs +24 -11
  50. package/worker/payment-capabilities.mjs +70 -0
  51. package/worker/payment-dry-exchange.mjs +30 -0
  52. package/worker/payment-manual-refund.mjs +40 -0
  53. package/worker/payment-policy.mjs +40 -0
  54. package/worker/payment-recovery.mjs +163 -0
  55. package/worker/payment-test-exchange.mjs +19 -0
  56. package/worker/purchase-core.mjs +140 -0
  57. package/worker/purchase-crypto.mjs +17 -0
  58. package/worker/purchase-delivery.mjs +108 -0
  59. package/worker/purchase-discovery.mjs +26 -0
  60. package/worker/purchase-hosted-readiness.mjs +16 -0
  61. package/worker/purchase-keys.mjs +20 -0
  62. package/worker/purchase-links.mjs +9 -0
  63. package/worker/purchase-machine.mjs +279 -0
  64. package/worker/purchase-mcp.mjs +42 -0
  65. package/worker/purchase-office-routes.mjs +17 -0
  66. package/worker/purchase-office-script.mjs +38 -0
  67. package/worker/purchase-office.mjs +35 -0
  68. package/worker/purchase-owner.mjs +243 -0
  69. package/worker/purchase-pricing.mjs +8 -0
  70. package/worker/purchase-problem.mjs +4 -0
  71. package/worker/purchase-reconciliation.mjs +582 -0
  72. package/worker/purchase-response.mjs +82 -0
  73. package/worker/purchase-routes.mjs +611 -0
  74. package/worker/purchase-schema.mjs +50 -0
  75. package/worker/purchase-store.mjs +148 -0
  76. package/worker/purchases.mjs +6 -0
  77. package/worker/resource-kinds.mjs +20 -0
  78. package/worker/selling/index.mjs +8 -0
  79. package/worker/selling/runtime.mjs +42 -0
  80. package/worker/settlement-journal.mjs +138 -0
  81. package/worker/shop-page.mjs +7 -4
  82. package/worker/shop-refunds.mjs +22 -0
  83. package/worker/shop-rules.mjs +1 -1
  84. package/worker/shop.mjs +18 -18
  85. package/worker/stripe.mjs +36 -3
  86. package/worker/version.mjs +1 -1
  87. package/starters/gem-rush/src/main.ts +0 -1087
@@ -0,0 +1,148 @@
1
+ import { hasStripeKey, shopMode } from './shop-links.mjs';
2
+ import { problem } from './purchase-problem.mjs';
3
+ /** Resource purchases share the studio's Stripe webhook and refund office. */
4
+ import { offerVersion } from "./purchase-core.mjs";
5
+
6
+ import { canonicalJson } from "./referrals.mjs";
7
+
8
+ import { modeOf } from "./stripe.mjs";
9
+
10
+ import { digest, manifestHash } from "./purchase-crypto.mjs";
11
+ import { resourceKind } from "./resource-kinds.mjs";
12
+
13
+ export { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from './purchase-schema.mjs';
14
+ export const json = (body, status = 200) =>
15
+ status >= 400 ? problem(status, body.message ?? body.detail ?? "Purchase refused", body) : Response.json(body, {
16
+ status,
17
+ headers: {
18
+ "cache-control": "no-store, private",
19
+ "x-content-type-options": "nosniff",
20
+ },
21
+ });
22
+ export const fail = problem;
23
+ const orderQuery = "SELECT o.*, COALESCE(r.manifest,o.manifest) AS manifest FROM purchase_orders o LEFT JOIN resource_snapshots r ON r.hash = o.manifest_hash";
24
+ export const byId = (env, id) => env.DB.prepare(`${orderQuery} WHERE o.id = ?1`).bind(id).first();
25
+ export const byClaim = (env, hash) => env.DB.prepare(`${orderQuery} WHERE o.claim_hash = ?1`).bind(hash).first();
26
+ export const idOf = (v) => (typeof v === "string" ? v : v?.id);
27
+ export const safeId = (v) => /^[a-z0-9][a-z0-9-]{0,47}$/.test(v ?? "");
28
+ export const versionOf = (v) =>
29
+ /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[A-Za-z0-9.-]{1,40})?$/.test(v ?? "");
30
+ export const validBuyer = (v) => /^[a-f0-9]{64}$/.test(v ?? "");
31
+ export const bearer = (r) =>
32
+ /^Bearer ([A-Za-z0-9_.-]+)$/.exec(r.headers.get("authorization") ?? "")?.[1];
33
+ export const termsOf = (o) =>
34
+ o.offer ? JSON.parse(o.offer) : JSON.parse(o.manifest).sale;
35
+ export const purchasedResource = (o) => ({
36
+ ...JSON.parse(o.manifest),
37
+ sale: termsOf(o),
38
+ });
39
+ export const active = (o) =>
40
+ ["paid", "fulfilled", "disputed"].includes(o.status) &&
41
+ (!o.subscription || Number(o.paid_until) > Date.now());
42
+
43
+ export async function bodyOf(request) {
44
+ if (!request.headers.get("content-type")?.startsWith("application/json"))
45
+ throw new Error("Send JSON");
46
+ return JSON.parse(await limitedText(request));
47
+ }
48
+ export async function limitedText(request) {
49
+ const reader = request.body?.getReader();
50
+ if (!reader) throw new Error("Send JSON");
51
+ let size = 0;
52
+ const chunks = [];
53
+ for (;;) {
54
+ const { done, value } = await reader.read();
55
+ if (done) break;
56
+ size += value.length;
57
+ if (size > 16384) {
58
+ await reader.cancel();
59
+ throw new Error("Request too large");
60
+ }
61
+ chunks.push(value);
62
+ }
63
+ const bytes = new Uint8Array(size);
64
+ let at = 0;
65
+ for (const c of chunks) {
66
+ bytes.set(c, at);
67
+ at += c.length;
68
+ }
69
+ return new TextDecoder().decode(bytes);
70
+ }
71
+ export async function privateRelease(env, kind, id, version) {
72
+ if (!safeId(id) || !versionOf(version)) return null;
73
+ return resourceKind(kind).get(env,id,version);
74
+ }
75
+ export async function listedRelease(env, origin, kind, id, version) {
76
+ if (await env.DB.prepare("SELECT 1 FROM resource_retirements WHERE resource = ?1").bind(`${shopMode(env)}:${kind}:${id}`).first()) return null;
77
+ const adapter = resourceKind(kind);
78
+ const listed = (await adapter.list(env,origin)).find((r)=>r.id===id && r.version===version);
79
+ if (!listed) return null;
80
+ const resource = await privateRelease(env,kind,id,version);
81
+ return resource && {resource,offer:listed.offer};
82
+ }
83
+ export async function coveredPurchase(env, origin, order, wanted) {
84
+ const bought = purchasedResource(order);
85
+ if (bought.id !== wanted.id) return false;
86
+ const granted = await env.DB.prepare("SELECT manifest_hash FROM purchase_grants WHERE order_id = ?1 AND version = ?2").bind(order.id,wanted.version).first();
87
+ if (granted) return granted.manifest_hash === await manifestHash(wanted);
88
+ return resourceKind(order.resource_kind).covers(env,origin,bought,wanted);
89
+ }
90
+
91
+ export async function recurringReady(env) {
92
+ try {
93
+ const c = JSON.parse(env.PURCHASE_PAYMENT_CAPABILITIES ?? "{}");
94
+ return (
95
+ c.recurring === true &&
96
+ c.mode === shopMode(env) &&
97
+ c.key === (await digest(env.STRIPE_KEY))
98
+ );
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ export async function deliveryAllowed(env, o) {
105
+ if (o.mode !== shopMode(env)) return false;
106
+ return (
107
+ o.mode === "live" ||
108
+ (
109
+ await env.DB.prepare("SELECT value FROM purchase_test_approvals WHERE order_id = ?1")
110
+ .bind(o.id)
111
+ .first()
112
+ )?.value === o.claim_hash
113
+ );
114
+ }
115
+
116
+ /** Immutable order creation is shared by machine payments and the human fallback. */
117
+ export async function createPurchase(env, o) {
118
+ const resource = JSON.parse(o.manifest);
119
+ const kind = o.resource_kind;
120
+ const adapter = resourceKind(kind);
121
+ const offer = await offerVersion({ kind, id: resource.id, release: resource.version, manifest: o.manifest_hash }, JSON.parse(o.offer));
122
+ const now = Date.now();
123
+ await env.DB.batch([
124
+ env.DB.prepare("INSERT OR IGNORE INTO resource_snapshots (hash,manifest) VALUES (?1,?2)").bind(o.manifest_hash,o.manifest),
125
+ env.DB.prepare("INSERT INTO purchase_orders (id,resource_kind,resource_id,item,game,amount,currency,till,mode,status,created_at,updated_at,claim_hash,buyer,buyer_label,quantity,manifest,manifest_hash,offer_version,transport,accepted_at,terms_hash,quote_hash,offer,checkout_buyer) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,'started',?10,?10,?11,?12,?13,?14,'',?15,?16,?17,?10,?18,?19,?20,?12)").bind(o.id,kind,resource.id,o.item,o.game,o.amount,o.currency,o.till,o.mode,now,o.claim_hash,o.buyer,o.buyer_label,o.quantity,o.manifest_hash,offer.version,o.transport ?? 'checkout',await digest(canonicalJson(adapter.terms({...resource,sale:offer.terms}))),await adapter.quote(resource,o.quantity,o.game,offer.terms),o.offer),
126
+ env.DB.prepare("INSERT OR IGNORE INTO purchase_offers (version,value) VALUES (?1,?2)").bind(offer.version,JSON.stringify(offer)),
127
+ ]);
128
+ }
129
+
130
+ /** Verify deliverability before either payment adapter can create a charge. */
131
+ export async function resourceAvailable(env, kind, resource) {
132
+ const adapter = resourceKind(kind);
133
+ for (const file of resource.files ?? []) {
134
+ const object = await adapter.readFile(env,file,{head:true});
135
+ if (!object || object.size !== file.bytes) return false;
136
+ }
137
+ return true;
138
+ }
139
+
140
+ /** Unpaid attempts have no durable entitlement; release their claims after expiry. */
141
+ export async function expireUnpaidMachineOrders(env) {
142
+ const rows = (await env.DB.prepare(`SELECT id FROM purchase_orders WHERE transport = 'machine' AND status IN ('started','expired') AND created_at <= ?1 AND payment IS NULL AND NOT EXISTS (SELECT 1 FROM purchase_jobs j WHERE j.order_id = purchase_orders.id ) AND NOT EXISTS (SELECT 1 FROM purchase_settlements s WHERE s.order_id = purchase_orders.id) LIMIT 100`).bind(Date.now()-300000).all()).results ?? [];
143
+ for (const row of rows) await env.DB.batch([
144
+ env.DB.prepare("DELETE FROM purchase_jobs WHERE order_id = ?1 AND state = 'failed'").bind(row.id),
145
+ env.DB.prepare("DELETE FROM purchase_orders WHERE id = ?1 AND payment IS NULL AND status IN ('started','expired')").bind(row.id),
146
+ ]);
147
+ return rows.length;
148
+ }
@@ -0,0 +1,6 @@
1
+ /** Public integration points for the shop, file router and studio tools. */
2
+ export * from "./purchase-store.mjs";
3
+ export * from "./purchase-reconciliation.mjs";
4
+ export * from "./purchase-owner.mjs";
5
+ export * from "./purchase-delivery.mjs";
6
+ export * from "./purchase-routes.mjs";
@@ -0,0 +1,20 @@
1
+ /** Application composition. New resource kinds implement this interface, not payment routes. */
2
+ import { partResource } from './parts-resource.mjs';
3
+ const kinds = new Map();
4
+ export function registerResourceKind(kind, adapter) {
5
+ if (!/^[a-z][a-z0-9-]{0,47}$/.test(kind) || kinds.has(kind)) throw new Error('Invalid or duplicate resource kind');
6
+ for (const method of ['list','get','quote','terms','validate','termsUrl','readFile','covers']) if (typeof adapter[method] !== 'function') throw new Error(`Resource adapter needs ${method}`);
7
+ kinds.set(kind,Object.freeze({...adapter}));
8
+ }
9
+ export function resourceKind(kind) {
10
+ const adapter = kinds.get(kind);
11
+ if (!adapter) throw new Error(`Unknown resource kind: ${kind}`);
12
+ return adapter;
13
+ }
14
+ export const hasResourceKind = (kind) => kinds.has(kind);
15
+ export async function listOffers(env,origin) {
16
+ const offers=[];
17
+ for (const [kind,adapter] of kinds) for (const offer of await adapter.list(env,origin)) offers.push({...offer,kind});
18
+ return offers;
19
+ }
20
+ registerResourceKind('part',partResource);
@@ -0,0 +1,8 @@
1
+ /** The SDK graph is evaluated only when this selling Worker handles work. */
2
+ export * from '../index.mjs';
3
+ let loaded;
4
+ export const loadSelling = () => loaded ??= import('./runtime.mjs');
5
+ export default {
6
+ async fetch(request, env, ctx) { return (await loadSelling()).default.fetch(request, env, ctx); },
7
+ async scheduled(event, env, ctx) { return (await loadSelling()).default.scheduled(event, env, ctx); },
8
+ };
@@ -0,0 +1,42 @@
1
+ import { productDescription, entryDescription, discoveryRoutes } from '../parts-purchase-catalogue.mjs';
2
+ import { officeScript } from '../purchase-office-script.mjs';
3
+ import { officeRoutes } from '../purchase-office-routes.mjs';
4
+ import { refundOrder, extendOffice } from '../purchase-office.mjs';
5
+ import worker from '../index.mjs';
6
+ export * from '../index.mjs';
7
+ import { useSelling } from '../extensions.mjs';
8
+ import { jobsSQL } from '../payment-policy.mjs';
9
+ import { manualRefundDetails } from '../payment-manual-refund.mjs';
10
+ import { confirmManualRefund } from '../payment-manual-refund.mjs';
11
+ import { listOffers } from '../resource-kinds.mjs';
12
+ import { orderFact } from '../purchase-core.mjs';
13
+ import { byId as resourceOrder } from '../purchase-store.mjs';
14
+ import { currentPurchaseOrder } from '../purchases.mjs';
15
+ import { purchasePaymentEvent } from '../purchases.mjs';
16
+ import { refundPurchasePayment } from '../purchases.mjs';
17
+ import { purchaseReadiness } from '../purchases.mjs';
18
+ import { purchaseRoutes } from '../purchase-routes.mjs';
19
+ import { recordOrderState } from '../purchase-core.mjs';
20
+ import { authorizePurchaseTest } from '../purchases.mjs';
21
+ import { expirePurchaseCheckouts } from '../purchases.mjs';
22
+ import { reissuePurchaseClaim } from '../purchases.mjs';
23
+ import { retireResource } from '../purchases.mjs';
24
+ import { reconcileRecordings } from '../payment-recovery.mjs';
25
+ import { machineCapabilities } from '../payment-capabilities.mjs';
26
+ import { paidFile } from '../parts-protected.mjs';
27
+ import { priceWords } from '../purchase-pricing.mjs';
28
+ import { purchaseDiscovery } from '../purchase-discovery.mjs';
29
+ import { testPaymentExchange } from '../payment-test-exchange.mjs';
30
+ useSelling({ enabled: true, productDescription, entryDescription, discoveryRoutes, officeScript, officeRoutes, refundOrder, extendOffice, testPaymentExchange, jobsSQL, manualRefundDetails, confirmManualRefund, listOffers, orderFact, resourceOrder, currentPurchaseOrder, purchasePaymentEvent, refundPurchasePayment, purchaseReadiness, purchaseRoutes, recordOrderState, authorizePurchaseTest, expirePurchaseCheckouts, reissuePurchaseClaim, retireResource, reconcileRecordings, machineCapabilities, paidFile, priceWords, purchaseDiscovery });
31
+ export default { ...worker,
32
+ async scheduled(controller, env, ctx) {
33
+ await worker.scheduled(controller, env, ctx);
34
+ if (!env.PURCHASE_MACHINE_PAYMENTS || !env.DB) return;
35
+ const { refreshMachineCapabilities } = await import('../payment-capabilities.mjs');
36
+ const errors = [];
37
+ for (const step of [reconcileRecordings, refreshMachineCapabilities, expirePurchaseCheckouts]) {
38
+ try { await step(env); } catch (error) { errors.push(error); }
39
+ }
40
+ if (errors.length) throw new AggregateError(errors, 'Selling maintenance needs retry');
41
+ },
42
+ };
@@ -0,0 +1,138 @@
1
+ import { REPLAY_LIMIT_MS } from './payment-policy.mjs';
2
+ /** Write-ahead settlement journal. Only protocol-validated operations enter this module. */
3
+ import { digest } from "./purchase-crypto.mjs";
4
+
5
+ import { modeOf } from './stripe.mjs';
6
+
7
+ export { SETTLEMENT_SCHEMA } from './purchase-schema.mjs';
8
+
9
+ export async function beginSettlement(env, order, network, payload) {
10
+ const { fromBlock: cursor, ...identity } = payload;
11
+ const id = await digest(JSON.stringify({ order: order.id, network, payload: identity }));
12
+ await env.DB.prepare("INSERT OR IGNORE INTO purchase_settlements (id,order_id,mode,network,payload,state,created_at,updated_at) VALUES (?1,?2,?3,?4,?5,'settling',?6,?6)")
13
+ .bind(id, order.id, order.mode, network, JSON.stringify({ ...payload, challengeId: order.challengeId }), Date.now()).run();
14
+ return id;
15
+ }
16
+ export async function finishSettlement(env, id, reference) {
17
+ if (!/^0x[0-9a-fA-F]{64}$/.test(reference)) throw new Error('Invalid settlement reference');
18
+ await env.DB.prepare("UPDATE purchase_settlements SET reference = ?2, state = 'settled', error = NULL, updated_at = ?3 WHERE id = ?1 AND state != 'complete'")
19
+ .bind(id, reference, Date.now()).run();
20
+ }
21
+
22
+ export function journalFacilitator(env, order, facilitator, rpc) {
23
+ return {
24
+ verify: (...args) => facilitator.verify(...args),
25
+ async settle(payload, requirements) {
26
+ // Capture the scan cursor before submitting; the authorization nonce identifies
27
+ // the transfer even if the facilitator's response disappears with this isolate.
28
+ const fromBlock = await rpc('eth_blockNumber', []);
29
+ const id = await beginSettlement(env, order, 'base', { payload, requirements, fromBlock });
30
+ const result = await facilitator.settle(payload, requirements);
31
+ if (result.success) await finishSettlement(env, id, result.transaction);
32
+ return result;
33
+ },
34
+ };
35
+ }
36
+
37
+ export function rpcFor(env, network, mode = modeOf(env.STRIPE_KEY)) {
38
+ const config = JSON.parse(env.PURCHASE_MACHINE_PAYMENTS ?? '{}')[mode] ?? {};
39
+ const url = config.rpc?.[network] ?? (network === 'base'
40
+ ? mode === 'live' ? 'https://mainnet.base.org' : 'https://sepolia.base.org'
41
+ : mode === 'live' ? 'https://rpc.tempo.xyz' : 'https://rpc.moderato.tempo.xyz');
42
+ return async (method, params) => {
43
+ const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
44
+ const body = await response.json();
45
+ if (!response.ok || body.error) throw new Error(body.error?.message ?? `Chain RPC answered ${response.status}`);
46
+ return body.result;
47
+ };
48
+ }
49
+
50
+ /** The final, canonical signed transaction is durable before an RPC may broadcast it. */
51
+ export async function journalTempoClient(env, order, chainId) {
52
+ const { createClient, custom, keccak256 } = await import('viem');
53
+ const { tempo, tempoModerato } = await import('viem/tempo/chains');
54
+ const rpc = rpcFor(env, 'tempo', order.mode);
55
+ return createClient({ chain: (chainId ?? (order.mode === 'live' ? tempo.id : tempoModerato.id)) === tempo.id ? tempo : tempoModerato, transport: custom({
56
+ async request({ method, params }) {
57
+ if (!['eth_sendRawTransaction', 'eth_sendRawTransactionSync'].includes(method)) return rpc(method, params);
58
+ const raw = params[0];
59
+ const reference = keccak256(raw);
60
+ const id = await beginSettlement(env, order, 'tempo', { raw, reference });
61
+ const result = await rpc(method, params);
62
+ await finishSettlement(env, id, reference);
63
+ return result;
64
+ },
65
+ }) });
66
+ }
67
+
68
+ /** Recover a fact from chain evidence, never from an unverified client receipt. */
69
+ export async function findSettlement(env, job, { rpc = rpcFor(env, job.network, job.mode) } = {}) {
70
+ const payload = JSON.parse(job.payload);
71
+ let reference = job.reference ?? payload.reference;
72
+ if (!reference && job.network === 'base') {
73
+ const { keccak256, toHex } = await import('viem');
74
+ const auth = payload.payload.payload.authorization;
75
+ const topic = keccak256(toHex('AuthorizationUsed(address,bytes32)'));
76
+ const latest = BigInt(await rpc('eth_blockNumber', []));
77
+ const from = BigInt(payload.fromBlock);
78
+ const to = from + 1999n < latest ? from + 1999n : latest;
79
+ const logs = await rpc('eth_getLogs', [{ address: payload.requirements.asset, fromBlock: payload.fromBlock, toBlock: `0x${to.toString(16)}`, topics: [topic, `0x${auth.from.slice(2).padStart(64, '0')}`, auth.nonce] }]);
80
+ reference = logs[0]?.transactionHash;
81
+ if (!reference && to < latest) {
82
+ await env.DB.prepare('UPDATE purchase_settlements SET payload = ?2 WHERE id = ?1').bind(job.id, JSON.stringify({ ...payload, fromBlock: `0x${(to+1n).toString(16)}` })).run();
83
+ return { scanning: true };
84
+ }
85
+ }
86
+ if (!reference) return null;
87
+ const receipt = await rpc('eth_getTransactionReceipt', [reference]);
88
+ if (!receipt) return null;
89
+ if (receipt.status === '0x0') return { unpaid: true, reason: 'The settlement transaction reverted.' };
90
+ if (receipt.status !== '0x1') return null;
91
+ if (job.network === 'base') {
92
+ const { keccak256, toHex } = await import('viem');
93
+ const auth = payload.payload.payload.authorization;
94
+ const transfer = keccak256(toHex('Transfer(address,address,uint256)'));
95
+ const found = receipt.logs?.some((log) => log.address.toLowerCase() === payload.requirements.asset.toLowerCase() && log.topics[0] === transfer && log.topics[1]?.slice(-40).toLowerCase() === auth.from.slice(2).toLowerCase() && log.topics[2]?.slice(-40).toLowerCase() === auth.to.slice(2).toLowerCase() && BigInt(log.data) === BigInt(auth.value));
96
+ if (!found) throw new Error('Settlement receipt does not contain the accepted transfer');
97
+ }
98
+ // Tempo raw bytes are persisted only after mppx validates recipient, amount,
99
+ // token and challenge binding; a successful receipt proves those exact calls ran.
100
+ return reference;
101
+ }
102
+
103
+ /** Replaying the same authorization/raw transaction cannot transfer twice on chain. */
104
+ export async function resumeSettlement(env, job, dependencies = {}) {
105
+ const rpc = dependencies.rpc ?? rpcFor(env, job.network, job.mode);
106
+ const found = await findSettlement(env, job, { rpc });
107
+ if (found?.scanning) return null;
108
+ if (found) return found;
109
+ const payload = JSON.parse(job.payload);
110
+ if (job.network === 'base') {
111
+ const finalBlock = await rpc('eth_getBlockByNumber', ['finalized', false]);
112
+ if (finalBlock?.timestamp && BigInt(finalBlock.timestamp) > BigInt(payload.payload.payload.authorization.validBefore))
113
+ return { unpaid: true, reason: 'The authorization expired without a transfer on the finalized chain.' };
114
+ }
115
+ if (Date.now() - job.created_at >= REPLAY_LIMIT_MS) return { manual: true, reason: 'Settlement deadline reached. Check the recorded transaction and payer before another payment; automatic broadcasting has stopped.' };
116
+ if (job.network === 'tempo') {
117
+ try { await rpc('eth_sendRawTransaction', [payload.raw]); }
118
+ catch { /* It may already be mined; the chain receipt decides. */ }
119
+ } else if (BigInt(payload.payload.payload.authorization.validBefore) > BigInt(Math.floor(Date.now() / 1000))) {
120
+ let facilitator = dependencies.facilitator;
121
+ if (!facilitator) {
122
+ const { HTTPFacilitatorClient } = await import('@x402/core/server');
123
+ const { createFacilitatorConfig } = await import('@coinbase/x402');
124
+ facilitator = new HTTPFacilitatorClient(job.mode === 'live' ? createFacilitatorConfig(env.CDP_API_KEY_ID, env.CDP_API_KEY_SECRET) : { url: 'https://x402.org/facilitator' });
125
+ }
126
+ const result = await facilitator.settle(payload.payload, payload.requirements);
127
+ if (result.success) return result.transaction;
128
+ }
129
+ const recovered = await findSettlement(env, job, { rpc });
130
+ if (recovered?.scanning) return null;
131
+ if (recovered) return recovered;
132
+ if (job.network === 'base') {
133
+ const finalBlock = await rpc('eth_getBlockByNumber', ['finalized', false]);
134
+ if (finalBlock?.timestamp && BigInt(finalBlock.timestamp) > BigInt(payload.payload.payload.authorization.validBefore))
135
+ return { unpaid: true, reason: 'The authorization expired without a transfer on the finalized chain.' };
136
+ }
137
+ return null;
138
+ }
@@ -1,3 +1,4 @@
1
+ import { selling } from './extensions.mjs';
1
2
  /**
2
3
  * The shop's pages (@homie-rocks/studio 0.24.0; worker/shop.mjs has the rules):
3
4
  *
@@ -527,14 +528,15 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
527
528
  links.appendChild(link('/_studio/api/shop/orders.csv', 'CSV for your accountant'));
528
529
  top.appendChild(links);
529
530
  if (s.keyless) top.appendChild(el('p', { class: 'note' }, 'Refund in Stripe or ask your AI connected to Stripe. If a payment is missing here, ask your AI to resend its Stripe webhook.'));
530
- if (s.totals) top.appendChild(el('p', { class: 'dim' }, 'Last 30 days: ' + s.totals.sales + ' sales, ' + cents(s.totals.paid, s.totals.currency) + ' before tax and fees; ' + s.totals.refunds + ' refunded (' + cents(s.totals.refunded, s.totals.currency) + '); ' + s.totals.disputes + ' disputed now, ' + s.totals.lost + ' lost.'));
531
+ for (const total of s.totalsByCurrency ?? (s.totals ? [s.totals] : [])) top.appendChild(el('p', { class: 'dim' }, 'Orders in the last 30 days: ' + total.sales + ' paid orders, ' + cents(total.paid, total.currency) + ' before tax and fees (subscription renewals are in Stripe); ' + total.refunds + ' refunded (' + cents(total.refunded, total.currency) + '); ' + total.disputes + ' disputed now, ' + total.lost + ' lost.'));
532
+ /* purchase-office */
531
533
  var ord = $('#orders'); ord.textContent = '';
532
534
  if (!s.orders.length) ord.appendChild(el('p', { class: 'empty' }, 'No orders yet.'));
533
535
  s.orders.forEach(function (o) {
534
536
  var row = el('div', { class: 'row' });
535
537
  row.appendChild(el('span', null, new Date(o.createdAt).toISOString().slice(0, 16).replace('T', ' ')));
536
538
  row.appendChild(el('b', null, o.name + ' · ' + o.shown));
537
- row.appendChild(el('span', null, (o.player && o.player.name ? o.player.name : 'a deleted account') + (o.parent ? ' (a parent paid)' : '') + (o.via ? ' · sent by ' + o.via : '')));
539
+ row.appendChild(el('span', null, (o.player && o.player.name ? o.player.name : o.resourceKind ? 'a studio purchase' : 'a deleted account') + (o.parent ? ' (a parent paid)' : '') + (o.via ? ' · sent by ' + o.via : '')));
538
540
  row.appendChild(el('span', { class: 'st st-' + o.status }, o.status + (o.mode === 'test' ? ' · test' : '') + (o.note ? ' · ' + o.note : '')));
539
541
  if (o.stripe) row.appendChild(link(o.stripe, 'In Stripe'));
540
542
  if (o.dispute) row.appendChild(link(o.dispute, 'The dispute'));
@@ -644,8 +646,9 @@ let officeHash = null;
644
646
  /** /_studio/office/shop: the owner's shop. One inline script, allowed by its hash; connect only to this site. */
645
647
  export async function officeShopPage(cat) {
646
648
  const name = cat.studio?.name ?? 'Studio';
649
+ const script = OFFICE_SHOP_SCRIPT.replace('/* purchase-office */', selling.officeScript ?? '');
647
650
  if (!officeHash) {
648
- const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(OFFICE_SHOP_SCRIPT));
651
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(script));
649
652
  officeHash = `sha256-${btoa(String.fromCharCode(...new Uint8Array(digest)))}`;
650
653
  }
651
654
  const body = `<header class="top"><h1>${esc(name)} <small>Shop</small></h1><div class="links"><a href="/_studio/office">Office</a><a href="/_studio/stats">Stats</a></div></header>
@@ -655,7 +658,7 @@ export async function officeShopPage(cat) {
655
658
  <div class="game" style="padding:6px 16px 12px" id="referrals"></div>
656
659
  <div class="toast" id="toast" role="status" hidden></div>
657
660
  <p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing. Refunds are handled in Stripe or by your connected AI; shops with the fuller checkout connection also offer Refund here. A card dispute never touches the player's account: answer it in Stripe; if you lose it, that one item is taken back. Tax: when enabled, Stripe Tax works out and collects tax where you told Stripe you are registered${cat.shop?.till === 'stripe-managed' ? '; with Managed Payments Stripe is the seller of record and files it for you' : ''}. Payouts, the balance and receipts are Stripe's own pages, linked above. Referrals: you pay referrers yourself (they invoice you); nothing moves through Homie. This is not legal or tax advice.</p>`;
658
- return new Response(shell(cat, `Shop · ${name}`, body, { script: OFFICE_SHOP_SCRIPT }), {
661
+ return new Response(shell(cat, `Shop · ${name}`, body, { script }), {
659
662
  headers: { ...PRIVATE, 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src '${officeHash}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'` },
660
663
  });
661
664
  }
@@ -0,0 +1,22 @@
1
+ import { hasStripeKey, rememberedMoney } from './shop-links.mjs';
2
+ import { stripeCall } from './stripe.mjs';
3
+ /** Stripe refunds, shared by player orders and licensed resources. */
4
+ export async function paymentRefunds(env, payment, o) {
5
+ const refunds = new Map();
6
+ let cursor;
7
+ if (payment && !hasStripeKey(env)) {
8
+ for (const r of await rememberedMoney(env, payment, o.mode)) if (r.id.startsWith('re_')) {
9
+ if (r.currency !== o.currency) throw new Error('Refund currency mismatch');
10
+ refunds.set(r.id, r);
11
+ }
12
+ }
13
+ if (payment && hasStripeKey(env)) do {
14
+ const page = await stripeCall(env, 'GET', '/v1/refunds', { payment_intent: payment, limit: 100, ...(cursor ? { starting_after: cursor } : {}) });
15
+ if (!Array.isArray(page.data)) throw new Error('Stripe refund list is unavailable');
16
+ for (const refund of page.data) refunds.set(refund.id, refund);
17
+ const next = page.has_more ? page.data.at(-1)?.id : null;
18
+ if (page.has_more && (!next || next === cursor)) throw new Error('Stripe refund list is incomplete');
19
+ cursor = next;
20
+ } while (cursor);
21
+ return refunds;
22
+ }
@@ -235,7 +235,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
235
235
  const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
236
236
  if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off under policy.kidsStudio).' });
237
237
  const shop = {
238
- v: 1, policy, checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, referralNewPlayersOnly: raw.referralNewPlayersOnly === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
238
+ v: 1, policy, purchasesTill: TILLS.includes(raw.purchasesTill ?? till) ? (raw.purchasesTill ?? till) : 'off', ...(/^https:\/\/billing\.stripe\.com\/p\/login\/[A-Za-z0-9_]+$/.test(raw.purchasesPortalLogin ?? '') ? { purchasesPortalLogin: raw.purchasesPortalLogin } : {}), checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, referralNewPlayersOnly: raw.referralNewPlayersOnly === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
239
239
  open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
240
240
  };
241
241
  // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-custom_text-submit-message
package/worker/shop.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { paymentRefunds } from './shop-refunds.mjs';
2
+ import { selling } from './extensions.mjs';
1
3
  /**
2
4
  * THE SHOP (@homie-rocks/studio 0.24.0): a studio sells with ITS OWN Stripe account (shop/SHOP.md is the guide).
3
5
  * The studio is the seller; homie.rocks never sees, holds or moves its money, and Homie takes no cut.
@@ -215,6 +217,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
215
217
  if (path === '/api/referrals/statement') return receiveStatement(request, env, url);
216
218
  if (path === '/_homie/shop.js') return new Response(SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
217
219
  const shopPath = path === '/shop' || path === '/shop/' || path.startsWith('/shop/') || path === '/api/shop' || path.startsWith('/api/shop/') || path === '/api/player/owns';
220
+ if (selling.enabled && (path.startsWith('/api/purchases/') || path.startsWith('/purchases/'))) return selling.purchaseRoutes(request, env, url, await catalogueOf());
218
221
  if (!shopPath) return null;
219
222
  if (path === '/shop') return Response.redirect(`${url.origin}/shop/${url.search}`, 301);
220
223
  const cat = await catalogueOf();
@@ -737,6 +740,11 @@ async function hook(request, env, cat, shop, ready) {
737
740
  const ev = v.event;
738
741
  const obj = ev.data?.object ?? {};
739
742
  const needed = ['checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired', 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed'];
743
+ if (selling.enabled) {
744
+ if (typeof ev.livemode === 'boolean' && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
745
+ const purchase = await selling.purchasePaymentEvent(env, ev);
746
+ if (purchase !== null) { await env.DB.prepare('INSERT INTO shop_events (id, type, at) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING').bind(ev.id, ev.type, Date.now()).run(); return json({ ok: true, did: purchase }); }
747
+ }
740
748
  if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
741
749
  if (typeof ev.livemode === 'boolean' && (hasStripeKey(env) || linkConfig(env)) && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
742
750
  try {
@@ -946,22 +954,7 @@ async function syncRefunds(env, payment, freeOrder = null, freeLines = []) {
946
954
  const o = freeOrder ?? await orderByPayment(env, payment);
947
955
  if (!o) return 'unknown-order';
948
956
  const all = await orderLines(env, o.id);
949
- const refunds = new Map();
950
- let cursor;
951
- if (payment && !hasStripeKey(env)) {
952
- for (const r of await rememberedMoney(env, payment, o.mode)) if (r.id.startsWith('re_')) {
953
- if (r.currency !== o.currency) throw new Error('Refund currency mismatch');
954
- refunds.set(r.id, r);
955
- }
956
- }
957
- if (payment && hasStripeKey(env)) do {
958
- const page = await stripeCall(env, 'GET', '/v1/refunds', { payment_intent: payment, limit: 100, ...(cursor ? { starting_after: cursor } : {}) });
959
- if (!Array.isArray(page.data)) throw new Error('Stripe refund list is unavailable');
960
- for (const refund of page.data) refunds.set(refund.id, refund);
961
- const next = page.has_more ? page.data.at(-1)?.id : null;
962
- if (page.has_more && (!next || next === cursor)) throw new Error('Stripe refund list is incomplete');
963
- cursor = next;
964
- } while (cursor);
957
+ const refunds = await paymentRefunds(env, payment, o);
965
958
  const totals = new Map(all.map((line) => [line.id, 0]));
966
959
  let amount = 0;
967
960
  for (const refund of refunds.values()) {
@@ -1051,6 +1044,7 @@ export const REFUND_HELD = 'Stripe is holding this refund until a person approve
1051
1044
 
1052
1045
  /** Refund selected lines through Stripe, revoking each only after success. */
1053
1046
  export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner', line: lineId = null } = {}) {
1047
+ if (o.resource_kind) return selling.refundOrder(env, o, { reason, by });
1054
1048
  if (o.status !== 'paid' && !(o.payment && ['started', 'processing', 'missing', 'released', 'expired', 'failed'].includes(o.status))) return { ok: false, error: 'state', message: `This order is ${o.status}.` };
1055
1049
  const lines = (await orderLines(env, o.id)).filter((l) => !lineId || l.id === lineId);
1056
1050
  if (!lines.length) return { ok: false, error: 'line', message: 'No such order line.' };
@@ -1105,7 +1099,7 @@ export function checkRefund(body) {
1105
1099
  if (!/^ord_[A-Za-z0-9]{20}$/.test(String(body.order ?? ''))) return { ok: false, error: 'bad-request', message: 'order is the order\'s id (ord_…, from the office\'s shop page or `homie-studio shop orders`)' };
1106
1100
  const reason = ['duplicate', 'fraudulent', 'requested_by_customer'].includes(body.reason) ? body.reason : 'requested_by_customer';
1107
1101
  const note = String(body.note ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim() || null;
1108
- return { ok: true, action: { op: 'refund', order: body.order, line: typeof body.line === 'string' ? body.line : null, reason, note } };
1102
+ return { ok: true, action: { op: 'refund', order: body.order, line: typeof body.line === 'string' ? body.line : null, reason, note, ...(body.manualTransaction ? { manualTransaction: body.manualTransaction } : {}) } };
1109
1103
  }
1110
1104
  /** Explicit owner override of an unresolved reservation; the existing note records its actor and time. */
1111
1105
  export function checkRelease(body) {
@@ -1151,7 +1145,8 @@ export function describeRefund(cat, a, order = null) {
1151
1145
 
1152
1146
  /** Do the refund (the owner's tap, or the owner's yes to the AI's ask). */
1153
1147
  export async function performRefund(env, cat, a) {
1154
- let o = await orderById(env, a.order);
1148
+ let o = await orderById(env, a.order) ?? await purchaseOrderIfPresent(env, a.order);
1149
+ if (o?.resource_kind) return a.manualTransaction ? selling.confirmManualRefund(env, o, a.manualTransaction) : refundOrder(env, o, { reason: a.reason, by: 'owner' });
1155
1150
  if (!o) return { ok: false, error: 'order', message: 'No such order.' };
1156
1151
  if (hasStripeKey(env) && !o.payment && o.session && o.paid_at === null && !(await migrationNeeded(env))) {
1157
1152
  try {
@@ -1199,6 +1194,7 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
1199
1194
  releasable: hasStripeKey(env) && ['started', 'processing'].includes(r.status),
1200
1195
  refundable: (Number(r.amount) === 0 || hasStripeKey(env)) && ['paid', 'started', 'processing', 'missing', 'released', 'expired', 'failed'].includes(r.status) && (Number(r.amount) === 0 || Boolean(r.payment) || Boolean(r.session)),
1201
1196
  })));
1197
+ if (selling.enabled) await selling.extendOffice(env, cat, origin, out);
1202
1198
  const { books, nextCursor } = await booksOf(env, { cursor });
1203
1199
  out.referrals = { nextCursor, owe: books, failures: await statementFailures(env), owedToUs: await statementsIn(env), invoice: dashboardLink(mode, 'invoices') };
1204
1200
  return out;
@@ -1254,3 +1250,7 @@ export async function roomBadge(env, cat, player, { kids = false } = {}) {
1254
1250
  return shop.items.find((i) => i.id === b.item)?.badge ?? b.key.slice(6);
1255
1251
  } catch { return null; }
1256
1252
  }
1253
+
1254
+ export async function purchaseOrderIfPresent(env, id) {
1255
+ try { return await selling.resourceOrder(env, id); } catch (error) { if (/no such table/.test(error.message)) return null; throw error; }
1256
+ }
package/worker/stripe.mjs CHANGED
@@ -63,6 +63,7 @@ export class StripeError extends Error {
63
63
  const e = body?.error ?? {};
64
64
  super(e.message ? redactStripe(e.message).slice(0, 400) : `Stripe answered ${status}`);
65
65
  this.status = status;
66
+ this.statusCode = status;
66
67
  this.code = redactStripe(e.code ?? e.type ?? 'stripe');
67
68
  this.type = e.type ? redactStripe(e.type) : null;
68
69
  this.param = typeof e.param === 'string' ? redactStripe(e.param).slice(0, 120) : null;
@@ -93,7 +94,7 @@ export async function productIdOf(slug, item) {
93
94
  }
94
95
 
95
96
  /** One call with the studio's key. `idempotencyKey` makes a retried POST do the work once (Stripe keeps it 24 h). */
96
- export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch, timeout = 15_000 } = {}) {
97
+ export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch, version = STRIPE_VERSION, timeout = 15_000 } = {}) {
97
98
  const key = String(env?.STRIPE_KEY ?? '');
98
99
  if (!KEY_SHAPE.test(key)) throw new StripeError(0, { error: { message: 'no Stripe key on this Worker', code: 'no-key' } });
99
100
  const body = params && method !== 'GET' ? formEncode(params).toString() : null;
@@ -103,7 +104,7 @@ export async function stripeCall(env, method, path, params = null, { idempotency
103
104
  method,
104
105
  headers: {
105
106
  authorization: `Bearer ${key}`,
106
- 'stripe-version': STRIPE_VERSION,
107
+ 'stripe-version': version,
107
108
  ...(body !== null ? { 'content-type': 'application/x-www-form-urlencoded' } : {}),
108
109
  ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
109
110
  },
@@ -113,7 +114,11 @@ export async function stripeCall(env, method, path, params = null, { idempotency
113
114
  } catch (error) { throw new StripeError(502, { error: { message: redactStripe(error?.message ?? 'Stripe did not answer', env), code: 'network' } }); }
114
115
  let json = null;
115
116
  try { json = await res.json(); } catch { json = null; }
116
- if (!res.ok) throw new StripeError(res.status, JSON.parse(redactStripe(JSON.stringify(json), env)));
117
+ if (!res.ok) {
118
+ const error = new StripeError(res.status, JSON.parse(redactStripe(JSON.stringify(json), env)));
119
+ error.idempotentReplayed = res.headers.get('idempotent-replayed') === 'true';
120
+ throw error;
121
+ }
117
122
  return json;
118
123
  }
119
124
 
@@ -189,3 +194,31 @@ export function dashboardLink(mode, kind, id = '') {
189
194
  default: return base;
190
195
  }
191
196
  }
197
+
198
+ /** Stripe is authoritative; DELETE is idempotent even when a prior request already canceled it. */
199
+ export async function cancelSubscription(env, id) {
200
+ try {
201
+ const sub = await stripeCall(env, 'GET', `/v1/subscriptions/${encodeURIComponent(id)}`);
202
+ if (sub.status === 'canceled') return sub;
203
+ return await stripeCall(env, 'DELETE', `/v1/subscriptions/${encodeURIComponent(id)}`);
204
+ } catch (error) { if (error.code === 'resource_missing') return { id, status: 'canceled' }; throw error; }
205
+ }
206
+
207
+ /** Expiration races with completion. Re-read after failure and accept only a terminal session. */
208
+ export async function closeCheckoutSession(env, id) {
209
+ const session = await retrieveCheckoutSession(env, id);
210
+ if (session.status !== 'open') return session;
211
+ try { return await expireCheckoutSession(env, id, { idempotencyKey: `expire-${id}` }); }
212
+ catch (error) {
213
+ const current = await retrieveCheckoutSession(env, id);
214
+ if (['expired', 'complete'].includes(current.status)) return current;
215
+ throw error;
216
+ }
217
+ }
218
+
219
+ export async function refundPayment(env, payment, params = {}) {
220
+ const pi = await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(payment)}`, { expand: ['latest_charge'] });
221
+ if (pi.latest_charge?.refunded) return { id: null, status: 'succeeded', already: true };
222
+ try { return await createRefund(env, { payment_intent: payment }, { idempotencyKey: `purchase-refund-${payment}` }); }
223
+ catch (error) { if (error.code === 'charge_already_refunded') return { id: null, status: 'succeeded', already: true }; throw error; }
224
+ }
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.37.1';
2
+ export const STUDIO_VERSION_TAG = '0.38.0';