@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.
- package/CHANGELOG.md +46 -0
- package/bin/homie-studio.mjs +10 -3
- package/lib/cloudflare.mjs +40 -6
- package/lib/dev.mjs +1 -1
- package/lib/doctor.mjs +7 -7
- package/lib/domain.mjs +43 -0
- package/lib/jobs.mjs +17 -7
- package/lib/mcp-tools.mjs +49 -18
- package/lib/mcp.mjs +6 -11
- package/lib/parts-build.mjs +23 -4
- package/lib/parts-cli.mjs +54 -3
- package/lib/parts-purchase.mjs +174 -0
- package/lib/parts-store.mjs +50 -19
- package/lib/parts-tools.mjs +15 -12
- package/lib/parts-upload.mjs +43 -0
- package/lib/parts.mjs +49 -5
- package/lib/prepare.mjs +47 -0
- package/lib/purchase-links.mjs +42 -0
- package/lib/purchase-wallet.mjs +114 -0
- package/lib/scaffold.mjs +35 -5
- package/lib/shoot.mjs +1 -1
- package/lib/shop-links.mjs +15 -6
- package/lib/shop.mjs +91 -21
- package/lib/stripe-connect.mjs +2 -2
- package/lib/template-history.json +82 -0
- package/package.json +12 -2
- package/parts/COMMERCIAL-LICENCE.md +21 -0
- package/parts/PARTS.md +238 -10
- package/worker/extensions.mjs +13 -0
- package/worker/index.mjs +3 -2
- package/worker/machine-payments.mjs +72 -0
- package/worker/office.mjs +4 -1
- package/worker/parts-protected.mjs +111 -0
- package/worker/parts-purchase-catalogue.mjs +34 -0
- package/worker/parts-resource.mjs +32 -0
- package/worker/parts-sale.mjs +59 -0
- package/worker/parts.mjs +24 -11
- package/worker/payment-capabilities.mjs +70 -0
- package/worker/payment-dry-exchange.mjs +30 -0
- package/worker/payment-manual-refund.mjs +40 -0
- package/worker/payment-policy.mjs +40 -0
- package/worker/payment-recovery.mjs +163 -0
- package/worker/payment-test-exchange.mjs +19 -0
- package/worker/purchase-core.mjs +140 -0
- package/worker/purchase-crypto.mjs +17 -0
- package/worker/purchase-delivery.mjs +108 -0
- package/worker/purchase-discovery.mjs +26 -0
- package/worker/purchase-hosted-readiness.mjs +16 -0
- package/worker/purchase-keys.mjs +20 -0
- package/worker/purchase-links.mjs +9 -0
- package/worker/purchase-machine.mjs +279 -0
- package/worker/purchase-mcp.mjs +42 -0
- package/worker/purchase-office-routes.mjs +17 -0
- package/worker/purchase-office-script.mjs +38 -0
- package/worker/purchase-office.mjs +35 -0
- package/worker/purchase-owner.mjs +243 -0
- package/worker/purchase-pricing.mjs +8 -0
- package/worker/purchase-problem.mjs +4 -0
- package/worker/purchase-reconciliation.mjs +582 -0
- package/worker/purchase-response.mjs +82 -0
- package/worker/purchase-routes.mjs +611 -0
- package/worker/purchase-schema.mjs +50 -0
- package/worker/purchase-store.mjs +148 -0
- package/worker/purchases.mjs +6 -0
- package/worker/resource-kinds.mjs +20 -0
- package/worker/selling/index.mjs +8 -0
- package/worker/selling/runtime.mjs +42 -0
- package/worker/settlement-journal.mjs +138 -0
- package/worker/shop-page.mjs +7 -4
- package/worker/shop-refunds.mjs +22 -0
- package/worker/shop-rules.mjs +1 -1
- package/worker/shop.mjs +18 -18
- package/worker/stripe.mjs +36 -3
- package/worker/version.mjs +1 -1
|
@@ -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
|
+
}
|
package/worker/shop-page.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
+
}
|
package/worker/shop-rules.mjs
CHANGED
|
@@ -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 =
|
|
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':
|
|
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)
|
|
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
|
+
}
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.37.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.37.2';
|