@homie-rocks/studio 0.37.1 → 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 +28 -0
- package/bin/homie-studio.mjs +4 -3
- package/lib/cloudflare.mjs +28 -3
- package/lib/dev.mjs +1 -1
- 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/purchase-links.mjs +42 -0
- package/lib/purchase-wallet.mjs +114 -0
- package/lib/scaffold.mjs +25 -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 +41 -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,140 @@
|
|
|
1
|
+
/** Provider facts drive orders. Offers and entitlements are immutable purchase inputs. */
|
|
2
|
+
import { canonicalJson } from "./referrals.mjs";
|
|
3
|
+
import { digest } from "./purchase-crypto.mjs";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export const states = Object.freeze(['started','paid','fulfilled','refunded','disputed','lapsed','expired','failed','lost']);
|
|
7
|
+
export const transitions = Object.freeze({
|
|
8
|
+
started: ['paid','refunded','expired','failed','lapsed'],
|
|
9
|
+
paid: ['fulfilled','refunded','disputed','lapsed','lost'],
|
|
10
|
+
fulfilled: ['refunded','disputed','lapsed','lost'],
|
|
11
|
+
refunded: [],
|
|
12
|
+
disputed: ['paid','fulfilled','refunded','lapsed','lost'],
|
|
13
|
+
lapsed: ['paid','refunded','disputed'],
|
|
14
|
+
expired: ['paid','refunded'], failed: ['paid','refunded'], lost: ['paid','refunded'],
|
|
15
|
+
});
|
|
16
|
+
export function transition(from, to, { renewal = false, refundReversal = false } = {}) {
|
|
17
|
+
if ((renewal || refundReversal) && from === "refunded" && to === "paid") return to;
|
|
18
|
+
if (
|
|
19
|
+
!states.includes(from) ||
|
|
20
|
+
!states.includes(to) ||
|
|
21
|
+
(from !== to && !transitions[from].includes(to))
|
|
22
|
+
)
|
|
23
|
+
throw new Error(`Illegal purchase transition: ${from} -> ${to}`);
|
|
24
|
+
return to;
|
|
25
|
+
}
|
|
26
|
+
export async function offerVersion(resource, terms) {
|
|
27
|
+
const offer = { resource, terms };
|
|
28
|
+
return { ...offer, version: await digest(canonicalJson(offer)) };
|
|
29
|
+
}
|
|
30
|
+
export function entitlement(order, offer) {
|
|
31
|
+
if (order.offerVersion !== offer.version)
|
|
32
|
+
throw new Error("Order and offer version differ");
|
|
33
|
+
return {
|
|
34
|
+
order: order.id,
|
|
35
|
+
buyer: order.buyer,
|
|
36
|
+
resource: offer.resource,
|
|
37
|
+
terms: structuredClone(offer.terms),
|
|
38
|
+
active: ["paid", "fulfilled", "disputed"].includes(order.state),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** D1 compare-and-swap gives mppx its required shared atomic replay store. */
|
|
43
|
+
export function paymentStore(db, mode) {
|
|
44
|
+
const keyOf = (key) => `payment:${mode}:${key}`;
|
|
45
|
+
return {
|
|
46
|
+
async get(key) {
|
|
47
|
+
const row = await db
|
|
48
|
+
.prepare("SELECT value FROM meta WHERE key = ?1")
|
|
49
|
+
.bind(keyOf(key))
|
|
50
|
+
.first();
|
|
51
|
+
return row ? JSON.parse(row.value).value : null;
|
|
52
|
+
},
|
|
53
|
+
async put(key, value) {
|
|
54
|
+
await this.update(key, () => ({ op: "set", value, result: undefined }));
|
|
55
|
+
},
|
|
56
|
+
async delete(key) {
|
|
57
|
+
await this.update(key, () => ({ op: "delete", result: undefined }));
|
|
58
|
+
},
|
|
59
|
+
async update(key, fn) {
|
|
60
|
+
const k = keyOf(key);
|
|
61
|
+
for (let attempt = 0; attempt < 32; attempt++) {
|
|
62
|
+
const row = await db
|
|
63
|
+
.prepare("SELECT value FROM meta WHERE key = ?1")
|
|
64
|
+
.bind(k)
|
|
65
|
+
.first();
|
|
66
|
+
const change = fn(row ? JSON.parse(row.value).value : null);
|
|
67
|
+
if (change.op === "noop") return change.result;
|
|
68
|
+
// Tombstones retain a unique revision, avoiding an ABA race on deletion.
|
|
69
|
+
const value = JSON.stringify({
|
|
70
|
+
revision: crypto.randomUUID(),
|
|
71
|
+
value: change.op === "delete" ? null : change.value,
|
|
72
|
+
});
|
|
73
|
+
const result = row
|
|
74
|
+
? await db
|
|
75
|
+
.prepare(
|
|
76
|
+
"UPDATE meta SET value = ?2 WHERE key = ?1 AND value = ?3",
|
|
77
|
+
)
|
|
78
|
+
.bind(k, value, row.value)
|
|
79
|
+
.run()
|
|
80
|
+
: await db
|
|
81
|
+
.prepare("INSERT OR IGNORE INTO meta (key,value) VALUES (?1,?2)")
|
|
82
|
+
.bind(k, value)
|
|
83
|
+
.run();
|
|
84
|
+
if (result.meta.changes === 1) return change.result;
|
|
85
|
+
}
|
|
86
|
+
throw new Error("Payment state busy; retry the same request");
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The purchase table is the sole lifecycle record. Provider facts use this writer only. */
|
|
92
|
+
export async function orderState(env, id) {
|
|
93
|
+
const order = await env.DB.prepare("SELECT * FROM purchase_orders WHERE id = ?1").bind(id).first();
|
|
94
|
+
return order && {...order, offerVersion:order.offer_version, state:order.status};
|
|
95
|
+
}
|
|
96
|
+
const mutable = new Set(['customer','subscription','paid_until','quantity','session','checkout_url','claim_hash','buyer','payment','tax','total','paid_at','refunded_at','refund','dispute','fulfilled_at','note']);
|
|
97
|
+
export function orderFact(env, id, fact, fields = {}, { renewal = false, refundReversal = false } = {}) {
|
|
98
|
+
const target = fact === 'payment' ? 'paid' : fact;
|
|
99
|
+
if (fact !== 'details' && !states.includes(target)) throw new Error(`Unknown purchase fact: ${fact}`);
|
|
100
|
+
const allowed = fact === 'details' ? [] : states.filter((from) => {
|
|
101
|
+
try { transition(from,target,{renewal,refundReversal}); return true; } catch { return false; }
|
|
102
|
+
});
|
|
103
|
+
// Expiry/failure cannot revoke money already accepted. Re-reading the same
|
|
104
|
+
// payment preserves delivery and disputes; only reconciliation resolves them.
|
|
105
|
+
const sources = factSources(fact, allowed);
|
|
106
|
+
const status = fact === 'details' ? 'status' : `CASE ${target === 'paid' ? "WHEN status = 'fulfilled' THEN status " : ''}${target === 'fulfilled' ? "WHEN status = 'disputed' THEN status " : ''}WHEN status IN (${sources.map((v)=>`'${v}'`).join(',')}) THEN '${target}' ELSE status END`;
|
|
107
|
+
const values = [id, Date.now()];
|
|
108
|
+
const sets = [`status = ${status}`, 'updated_at = ?2'];
|
|
109
|
+
if (target === 'paid' && fields.paid_at === undefined) fields = {...fields,paid_at:Date.now()};
|
|
110
|
+
if (target === 'fulfilled') fields = {...fields,fulfilled_at:Date.now()};
|
|
111
|
+
if (target === 'refunded' && fields.refunded_at === undefined) fields = {...fields,refunded_at:Date.now()};
|
|
112
|
+
for (const [key,value] of Object.entries(fields)) {
|
|
113
|
+
if (!mutable.has(key)) throw new Error(`Immutable purchase field: ${key}`);
|
|
114
|
+
values.push(value ?? null);
|
|
115
|
+
const parameter = `?${values.length}`;
|
|
116
|
+
sets.push(`${key} = ${['paid_at','fulfilled_at'].includes(key) || key === 'payment' && !renewal ? `COALESCE(${key},${parameter})` : parameter}`);
|
|
117
|
+
}
|
|
118
|
+
return env.DB.prepare(`UPDATE purchase_orders SET ${sets.join(', ')} WHERE id = ?1`).bind(...values);
|
|
119
|
+
}
|
|
120
|
+
export async function recordOrderState(env, order, fact, fields, options) {
|
|
121
|
+
await orderFact(env,order.id,fact,fields,options).run();
|
|
122
|
+
const current = await orderState(env,order.id);
|
|
123
|
+
return current && { ...current, factResult: { fact, applied: fact === 'details' || current.status === (fact === 'payment' ? 'paid' : fact), state: current.status } };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function factSources(fact, allowed) {
|
|
127
|
+
return fact === 'payment' ? ['started','expired','failed'] : ['expired','failed'].includes(fact) ? ['started'] : allowed;
|
|
128
|
+
}
|
|
129
|
+
export function factState(from, fact, options = {}) {
|
|
130
|
+
if (fact === 'details') return from;
|
|
131
|
+
const target = fact === 'payment' ? 'paid' : fact;
|
|
132
|
+
const allowed = states.filter((state) => { try { transition(state, target, options); return true; } catch { return false; } });
|
|
133
|
+
if (target === 'paid' && from === 'fulfilled' || target === 'fulfilled' && from === 'disputed') return from;
|
|
134
|
+
return factSources(fact, allowed).includes(from) ? target : from;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const releaseClaim = (env, id) => env.DB.prepare("UPDATE purchase_orders SET claim_hash = ?2 WHERE id = ?1 AND payment IS NULL AND paid_at IS NULL").bind(id, `released:${id}`);
|
|
138
|
+
|
|
139
|
+
/** Atomically reserve the first hosted session before processing its payment. */
|
|
140
|
+
export const bindHostedSession = (env,id,session) => env.DB.prepare('UPDATE purchase_orders SET session = ?2 WHERE id = ?1 AND (session IS NULL OR session = ?2)').bind(id,session).run();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { canonicalJson } from "./referrals.mjs";
|
|
2
|
+
export const GRANT_TYPE = 'homie-purchase-grant+jwt';
|
|
3
|
+
export const PROOF_TYPE = 'homie-purchase-proof+jwt';
|
|
4
|
+
export const PROOF_AUDIENCE = 'homie-purchase-evidence';
|
|
5
|
+
export const GRANT_AUDIENCE = 'homie-purchases';
|
|
6
|
+
export const digest = async (value) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', typeof value === 'string' ? new TextEncoder().encode(value) : value))].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
7
|
+
export const manifestHash = (resource) => digest(canonicalJson(resource));
|
|
8
|
+
export async function signGrant(key, claims, proof = false) {
|
|
9
|
+
const { calculateJwkThumbprint, SignJWT } = await import('jose');
|
|
10
|
+
return new SignJWT(claims).setProtectedHeader({ alg: 'Ed25519', typ: proof ? PROOF_TYPE : GRANT_TYPE, kid: await calculateJwkThumbprint(key.publicJwk) }).sign(key.privateKey);
|
|
11
|
+
}
|
|
12
|
+
export async function verifyGrant(token, jwk, { issuer, subject, resource, kind, version, proof = false, audience = proof ? PROOF_AUDIENCE : GRANT_AUDIENCE } = {}) {
|
|
13
|
+
const { calculateJwkThumbprint, importJWK, jwtVerify } = await import('jose');
|
|
14
|
+
const { payload, protectedHeader } = await jwtVerify(token, await importJWK(jwk, 'Ed25519'), { algorithms: ['Ed25519'], typ: proof ? PROOF_TYPE : GRANT_TYPE, issuer, audience, ...(subject ? { subject } : {}), requiredClaims: ['iss', 'aud', 'sub', 'iat', 'jti', 'resource', 'kind', 'version', ...(proof && audience !== PROOF_AUDIENCE ? [] : ['manifest']), 'mode', ...(proof ? [] : ['exp'])] });
|
|
15
|
+
if (protectedHeader.kid !== await calculateJwkThumbprint(jwk) || (resource && payload.resource !== resource) || (kind && payload.kind !== kind) || (version && payload.version !== version)) throw new Error('The purchase proof is for another key or release');
|
|
16
|
+
return payload;
|
|
17
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Studio licences use the shop's Stripe, order book, webhook and refund office. */
|
|
2
|
+
import {
|
|
3
|
+
orderState,
|
|
4
|
+
recordOrderState,
|
|
5
|
+
entitlement,
|
|
6
|
+
} from "./purchase-core.mjs";
|
|
7
|
+
|
|
8
|
+
import { signingKey, publicKeys } from "./purchase-keys.mjs";
|
|
9
|
+
|
|
10
|
+
import { GRANT_AUDIENCE, PROOF_AUDIENCE, manifestHash, signGrant, verifyGrant } from "./purchase-crypto.mjs";
|
|
11
|
+
import { resourceKind } from "./resource-kinds.mjs";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
json,
|
|
15
|
+
fail,
|
|
16
|
+
byId,
|
|
17
|
+
bearer,
|
|
18
|
+
purchasedResource,
|
|
19
|
+
active,
|
|
20
|
+
privateRelease,
|
|
21
|
+
coveredPurchase,
|
|
22
|
+
deliveryAllowed,
|
|
23
|
+
resourceAvailable,
|
|
24
|
+
} from "./purchase-store.mjs";
|
|
25
|
+
export async function grantPurchase(env, origin, o, b = {}) {
|
|
26
|
+
const bought = purchasedResource(o);
|
|
27
|
+
const version = b.version ?? bought.version;
|
|
28
|
+
const p =
|
|
29
|
+
version === bought.version
|
|
30
|
+
? JSON.parse(o.manifest)
|
|
31
|
+
: await privateRelease(env, o.resource_kind, bought.id, version);
|
|
32
|
+
if (!p)
|
|
33
|
+
return fail(503, "Release temporarily unavailable; retry this purchase");
|
|
34
|
+
if (!(await coveredPurchase(env, origin, o, p)))
|
|
35
|
+
return fail(
|
|
36
|
+
403,
|
|
37
|
+
"This version needs a new licence and a fresh price approval",
|
|
38
|
+
);
|
|
39
|
+
if (!(await resourceAvailable(env, o.resource_kind, p)))
|
|
40
|
+
return fail(503, o.payment ? 'Paid file bytes are temporarily unavailable. The seller must restore storage. Do not pay again.' : 'Your chain payment settled but Stripe has not recorded it. Files are temporarily unavailable. Contact the seller for restoration or a manual refund to your original payer address. Do not pay again.');
|
|
41
|
+
if (!active(o)) return fail(402, `Purchase is ${o.status}; delivery is unavailable`);
|
|
42
|
+
await env.DB.prepare(
|
|
43
|
+
"INSERT OR IGNORE INTO purchase_grants (order_id,version,manifest_hash) VALUES (?1,?2,?3)",
|
|
44
|
+
)
|
|
45
|
+
.bind(o.id, p.version, await manifestHash(p))
|
|
46
|
+
.run();
|
|
47
|
+
const coreOrder = await recordOrderState(env, o, "fulfilled");
|
|
48
|
+
const offerRow =
|
|
49
|
+
coreOrder.offerVersion &&
|
|
50
|
+
(await env.DB.prepare("SELECT value FROM purchase_offers WHERE version = ?1")
|
|
51
|
+
.bind(coreOrder.offerVersion)
|
|
52
|
+
.first());
|
|
53
|
+
const rights = offerRow
|
|
54
|
+
? entitlement(coreOrder, JSON.parse(offerRow.value))
|
|
55
|
+
: null;
|
|
56
|
+
if (!rights || !rights.active)
|
|
57
|
+
return fail(402, `Purchase is ${coreOrder.state}; delivery is unavailable`);
|
|
58
|
+
const key = await signingKey(env);
|
|
59
|
+
const now = Math.floor(Date.now() / 1000);
|
|
60
|
+
const claims = {
|
|
61
|
+
iss: origin,
|
|
62
|
+
aud: GRANT_AUDIENCE,
|
|
63
|
+
sub: o.buyer,
|
|
64
|
+
studio: o.buyer_label,
|
|
65
|
+
offer: o.offer ? JSON.parse(o.offer) : bought.sale,
|
|
66
|
+
jti: o.id,
|
|
67
|
+
iat: now,
|
|
68
|
+
claimVersion: o.claim_hash,
|
|
69
|
+
resource: p.id,
|
|
70
|
+
kind: o.resource_kind,
|
|
71
|
+
version: p.version,
|
|
72
|
+
manifest: await manifestHash(p),
|
|
73
|
+
terms: resourceKind(o.resource_kind).terms(bought),
|
|
74
|
+
quantity: o.quantity,
|
|
75
|
+
game: o.game,
|
|
76
|
+
mode: o.mode,
|
|
77
|
+
paidUntil: o.paid_until,
|
|
78
|
+
billing: bought.sale.billing,
|
|
79
|
+
onRefund: bought.sale.onRefund,
|
|
80
|
+
onExpiry: bought.sale.onExpiry,
|
|
81
|
+
};
|
|
82
|
+
return json({
|
|
83
|
+
ok: true,
|
|
84
|
+
entitlement: rights,
|
|
85
|
+
token: await signGrant(key, { ...claims, exp: now + 300 }),
|
|
86
|
+
proof: await signGrant(key, { ...claims, aud: PROOF_AUDIENCE }, true),
|
|
87
|
+
hubProof:
|
|
88
|
+
b.audience && /^https:\/\/[^/]+$/.test(b.audience)
|
|
89
|
+
? await signGrant(
|
|
90
|
+
key,
|
|
91
|
+
{
|
|
92
|
+
iss: claims.iss,
|
|
93
|
+
aud: b.audience,
|
|
94
|
+
sub: claims.sub,
|
|
95
|
+
jti: claims.jti,
|
|
96
|
+
iat: claims.iat,
|
|
97
|
+
resource: claims.resource,
|
|
98
|
+
kind: claims.kind,
|
|
99
|
+
version: claims.version,
|
|
100
|
+
mode: claims.mode,
|
|
101
|
+
},
|
|
102
|
+
true,
|
|
103
|
+
)
|
|
104
|
+
: undefined,
|
|
105
|
+
manifest: p,
|
|
106
|
+
paidUntil: o.paid_until,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { machineCapabilities } from './payment-capabilities.mjs';
|
|
2
|
+
import { listOffers } from './resource-kinds.mjs';
|
|
3
|
+
export async function purchaseDiscovery(env, origin, catalogue = {}) {
|
|
4
|
+
const { generateProxy } = await import('mppx/discovery');
|
|
5
|
+
const capabilities = await machineCapabilities(env);
|
|
6
|
+
const routes = [];
|
|
7
|
+
for (const resource of await listOffers(env,origin)) {
|
|
8
|
+
const offer = resource.offer;
|
|
9
|
+
const eligible = (catalogue.shop?.purchasesTill ?? catalogue.shop?.till) !== 'stripe-managed' && offer.amount > 0 && offer.billing === 'one-time' && offer.currency === 'usd' && !offer.automaticTax && offer.taxBehavior !== 'exclusive';
|
|
10
|
+
const offers = eligible ? Object.entries(capabilities.machine).filter(([, enabled]) => enabled).map(([method]) => ({ method: method === 'card' ? 'stripe' : method === 'base' ? 'evm' : method, intent: 'charge', amount: String(method === 'card' ? offer.amount : offer.amount * 10000), currency: 'usd' })) : [];
|
|
11
|
+
routes.push({ method: 'POST', path: `/api/purchases/resource/${resource.kind}/${resource.id}/${resource.version}`, summary: `Buy ${resource.name} ${resource.version}. Challenges expire after five minutes. A new purchase requires a fresh private claim.`, payment: offers.length ? { offers } : null,
|
|
12
|
+
requestBody: { required: true, content: { 'application/json': { schema: {
|
|
13
|
+
type: 'object', required: ['kind','resource','version','buyer','claim','offerVersion'],
|
|
14
|
+
properties: {
|
|
15
|
+
kind: {type:'string',const:resource.kind}, resource: {type:'string',const:resource.id}, version: {type:'string',const:resource.version},
|
|
16
|
+
buyer:{type:'string',pattern:'^[a-f0-9]{64}$',description:'Private random buyer identifier (32 bytes, hex).'},
|
|
17
|
+
claim:{type:'string',pattern:'^[a-f0-9]{64}$',description:'Private random secret for this purchase (32 bytes, hex). Keep it for retries and downloads.'},
|
|
18
|
+
offerVersion:{type:'string',const:resource.offerVersion},quantity:{type:'integer',minimum:1,default:1},game:{type:'string',description:'Game identifier required for a game-scoped offer.'},
|
|
19
|
+
},
|
|
20
|
+
} } } },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const doc=generateProxy({info:{title:'Studio purchases',version:'1'},routes});doc.servers=[{url:origin}];
|
|
24
|
+
doc.paths['/api/purchases/intent'] = { post: { summary: 'Create a hosted Checkout fallback; open the returned seller URL to accept terms and complete Turnstile.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['kind','resource','version','buyer','claimHash','offerVersion'], properties: { kind: {type:'string'}, resource: {type:'string'}, version: {type:'string'}, buyer: {type:'string',pattern:'^[a-f0-9]{64}$'}, claimHash: {type:'string', description:'SHA-256 of the private random claim, hex'}, offerVersion: {type:'string'}, quantity: {type:'integer',minimum:1}, game: {type:'string'} } } } } }, responses: { '200': {description:'Order and hosted checkout URL'}, '409': {description:'Offer changed'} } } };
|
|
25
|
+
return Response.json(doc,{headers:{'cache-control':'no-store'}});
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { hasStripeKey } from './shop-links.mjs';
|
|
2
|
+
import { purchaseLinks } from './purchase-links.mjs';
|
|
3
|
+
import { KEY_SHAPE, WEBHOOK_SECRET_SHAPE } from './stripe.mjs';
|
|
4
|
+
import { signingKey } from './purchase-keys.mjs';
|
|
5
|
+
import { recurringReady } from './purchase-store.mjs';
|
|
6
|
+
|
|
7
|
+
/** The hosted route and its office report use the same local configuration checks. */
|
|
8
|
+
export async function hostedReadiness(env, catalogue, recurring = false) {
|
|
9
|
+
const missing = [];
|
|
10
|
+
if (!env.DB || env.HOMIE_PREVIEW === '1') missing.push('Studio purchases need the deployed database');
|
|
11
|
+
if (!env.PURCHASE_RATE_LIMITER) missing.push('Deploy the purchase rate limiting binding');
|
|
12
|
+
try { await signingKey(env); } catch { missing.push('Create a valid purchase signing key'); }
|
|
13
|
+
if ((!hasStripeKey(env) && !purchaseLinks(env)) || !WEBHOOK_SECRET_SHAPE.test(env.STRIPE_WEBHOOK_SECRET ?? '') || !['stripe', 'stripe-managed'].includes(catalogue.shop?.purchasesTill ?? catalogue.shop?.till)) missing.push('Connect this studio shop to Stripe first');
|
|
14
|
+
if (recurring && hasStripeKey(env) && !(await recurringReady(env))) missing.push('Reconnect the shop to verify subscription permissions and webhook events before selling recurring resources');
|
|
15
|
+
return { ready: missing.length === 0, missing };
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The licence key ring is a Worker secret, independent of referral statements and D1. */
|
|
2
|
+
|
|
3
|
+
export async function purchaseKeys(env) {
|
|
4
|
+
let keys;
|
|
5
|
+
try { keys = JSON.parse(env.PURCHASE_SIGNING_KEYS); } catch { throw new Error('Configure purchase signing keys before selling'); }
|
|
6
|
+
if (!Array.isArray(keys) || !keys.length || !keys[0].d) throw new Error('Configure purchase signing keys before selling');
|
|
7
|
+
return keys;
|
|
8
|
+
}
|
|
9
|
+
export async function signingKey(env) {
|
|
10
|
+
const { importJWK } = await import('jose');
|
|
11
|
+
const [jwk] = await purchaseKeys(env);
|
|
12
|
+
return { privateKey: await importJWK(jwk, 'Ed25519'), publicJwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x } };
|
|
13
|
+
}
|
|
14
|
+
export async function publicKeys(env) {
|
|
15
|
+
const { calculateJwkThumbprint } = await import('jose');
|
|
16
|
+
return Promise.all((await purchaseKeys(env)).map(async ({ kty, crv, x }) => {
|
|
17
|
+
const jwk = { kty, crv, x };
|
|
18
|
+
return { ...jwk, kid: await calculateJwkThumbprint(jwk), alg: 'Ed25519', use: 'sig' };
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Public Payment Link configuration, installed by the owner's existing CLI authorization. */
|
|
2
|
+
import { paymentLinkURL, linkConfig } from './shop-links.mjs';
|
|
3
|
+
import { canonicalJson } from './referrals.mjs';
|
|
4
|
+
export function purchaseLinks(env) {
|
|
5
|
+
try { const c = JSON.parse(env.PURCHASE_LINKS ?? 'null'); return c?.v === 1 && c.mode === linkConfig(env)?.mode && Array.isArray(c.offers) ? c : null; } catch { return null; }
|
|
6
|
+
}
|
|
7
|
+
export function purchaseLink(env, o) {
|
|
8
|
+
return purchaseLinks(env)?.offers.find((link) => (link.quantity ?? 1) === o.quantity && link.kind === o.resource_kind && link.resource === o.resource_id && link.version === JSON.parse(o.manifest).version && link.offer === canonicalJson(JSON.parse(o.offer)) && paymentLinkURL(link.url));
|
|
9
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { definitiveRefusal, blockingJobsSQL } from './payment-policy.mjs';
|
|
2
|
+
import { orderFact, offerVersion, orderState } from './purchase-core.mjs';
|
|
3
|
+
import { problem } from './purchase-problem.mjs';
|
|
4
|
+
import { saveRecording, recordProviderResult, recordingFailure, reconcileRecordings } from './payment-recovery.mjs';
|
|
5
|
+
import { machineCapabilities, notePaymentRefusal } from './payment-capabilities.mjs';
|
|
6
|
+
import { purchaseResponse } from './purchase-response.mjs';
|
|
7
|
+
/** One durable purchase, regardless of transport. No seller browser page is involved. */
|
|
8
|
+
import { Receipt } from 'mppx';
|
|
9
|
+
import { signingKey } from './purchase-keys.mjs';
|
|
10
|
+
import {
|
|
11
|
+
byClaim,
|
|
12
|
+
expireUnpaidMachineOrders,
|
|
13
|
+
byId,
|
|
14
|
+
createPurchase,
|
|
15
|
+
currentPurchaseOrder,
|
|
16
|
+
deliveryAllowed,
|
|
17
|
+
grantPurchase,
|
|
18
|
+
listedRelease,
|
|
19
|
+
limitedText,
|
|
20
|
+
resourceAvailable,
|
|
21
|
+
} from "./purchases.mjs";
|
|
22
|
+
import { paymentProtocol, stripeClient } from './machine-payments.mjs';
|
|
23
|
+
import { digest, manifestHash } from './purchase-crypto.mjs';
|
|
24
|
+
import { resourceKind, hasResourceKind } from './resource-kinds.mjs';
|
|
25
|
+
import { modeOf } from './stripe.mjs';
|
|
26
|
+
import { newOrderId } from './shop-store.mjs';
|
|
27
|
+
|
|
28
|
+
export async function machineResource(
|
|
29
|
+
request,
|
|
30
|
+
env,
|
|
31
|
+
url,
|
|
32
|
+
cat,
|
|
33
|
+
dependencies = {},
|
|
34
|
+
) {
|
|
35
|
+
const hostedFallback = (detail) => problem(409, detail, { checkout: { url: `${url.origin}/api/purchases/intent`, method: 'POST', description: 'Hosted Checkout fallback. Submit the published offerVersion, buyer and private claimHash.' } });
|
|
36
|
+
const mcp = Boolean(dependencies.mcpInput);
|
|
37
|
+
if (dependencies.dryRun && (mcp || request.headers.has('authorization') || request.headers.has('payment-signature'))) throw new Error('Dry exchanges cannot carry payment credentials');
|
|
38
|
+
if (request.method !== "POST")
|
|
39
|
+
return problem(405, "POST the resource, buyer, claim and approved quote");
|
|
40
|
+
const b = await machineInput(request, dependencies.mcpInput);
|
|
41
|
+
if (b instanceof Response) return b;
|
|
42
|
+
if (
|
|
43
|
+
!b ||
|
|
44
|
+
!/^[a-f0-9]{64}$/.test(b.claim ?? "") ||
|
|
45
|
+
!/^[a-f0-9]{64}$/.test(b.buyer ?? "")
|
|
46
|
+
)
|
|
47
|
+
return problem(400, "Supply a private random claim and buyer identity");
|
|
48
|
+
if (!hasResourceKind(b.kind)) return problem(400,"Name a supported resource kind");
|
|
49
|
+
const adapter = resourceKind(b.kind);
|
|
50
|
+
const quoteHash = adapter.quote;
|
|
51
|
+
const claimHash = await digest(b.claim);
|
|
52
|
+
const resourcePath = `/api/purchases/resource/${encodeURIComponent(b.kind)}/${encodeURIComponent(b.resource)}/${encodeURIComponent(b.version)}/${claimHash}`;
|
|
53
|
+
if (!mcp && url.pathname !== '/api/purchases/resource' && url.pathname !== resourcePath.slice(0, resourcePath.lastIndexOf('/')) && url.pathname !== resourcePath)
|
|
54
|
+
return problem(409, "Resource URL differs from this purchase");
|
|
55
|
+
if (!mcp) request = new Request(new URL(resourcePath, url.origin), request);
|
|
56
|
+
const approved = async (resource, quantity, game, offer) => b.quote === await quoteHash(resource, quantity, game, offer) || b.offerVersion === (await offerVersion({ kind: b.kind, id: resource.id, release: resource.version, manifest: await manifestHash(resource) }, offer)).version;
|
|
57
|
+
await expireUnpaidMachineOrders(env);
|
|
58
|
+
let o = await byClaim(env, claimHash);
|
|
59
|
+
if (!o) {
|
|
60
|
+
if ((cat.shop?.purchasesTill ?? cat.shop?.till) === 'stripe-managed') return hostedFallback('This seller uses Stripe Managed Payments.');
|
|
61
|
+
const listed = await listedRelease(env, url.origin, b.kind, b.resource, b.version);
|
|
62
|
+
if (!listed) return problem(404, "Offer unavailable");
|
|
63
|
+
const { resource, offer } = listed;
|
|
64
|
+
const quantity = b.quantity ?? 1;
|
|
65
|
+
const game = b.game ?? null;
|
|
66
|
+
if (offer.billing !== "one-time")
|
|
67
|
+
return hostedFallback("Recurring purchases use Stripe Checkout");
|
|
68
|
+
if (offer.amount === 0) return hostedFallback("A zero-price offer uses hosted Checkout.");
|
|
69
|
+
if (offer.currency !== "usd") return hostedFallback("This currency requires hosted Checkout; machine payments currently support USD.");
|
|
70
|
+
if (
|
|
71
|
+
offer.automaticTax ||
|
|
72
|
+
offer.taxBehavior === "exclusive"
|
|
73
|
+
)
|
|
74
|
+
return hostedFallback("This offer needs Checkout to calculate its final total");
|
|
75
|
+
const validation = adapter.validate({ ...resource, sale: offer }, { quantity, game });
|
|
76
|
+
if (validation.length) return problem(400, validation.join('; '));
|
|
77
|
+
if (!(await approved(resource, quantity, game, offer)))
|
|
78
|
+
return problem(409, "Price or terms changed; approve the fresh quote");
|
|
79
|
+
// Check readiness and bytes before an agent can spend anything.
|
|
80
|
+
if (!env.PURCHASE_SIGNING_KEYS || !(await machineCapabilities(env)).ready)
|
|
81
|
+
return hostedFallback("Seller machine payments are not configured. Use Hosted Checkout.");
|
|
82
|
+
await signingKey(env);
|
|
83
|
+
if (!(await resourceAvailable(env, b.kind, resource)))
|
|
84
|
+
return problem(503, "Paid files unavailable; no payment requested");
|
|
85
|
+
const pending = {
|
|
86
|
+
id: newOrderId(),
|
|
87
|
+
resource_kind: b.kind,
|
|
88
|
+
item: `${b.kind}:${resource.id}`,
|
|
89
|
+
game,
|
|
90
|
+
amount: quantity * offer.amount,
|
|
91
|
+
currency: offer.currency,
|
|
92
|
+
till: "stripe",
|
|
93
|
+
mode: modeOf(env.STRIPE_KEY),
|
|
94
|
+
claim_hash: claimHash,
|
|
95
|
+
buyer: b.buyer,
|
|
96
|
+
buyer_label: "Buyer",
|
|
97
|
+
quantity,
|
|
98
|
+
transport: "machine",
|
|
99
|
+
manifest: JSON.stringify(resource),
|
|
100
|
+
manifest_hash: await manifestHash(resource),
|
|
101
|
+
offer: JSON.stringify(offer),
|
|
102
|
+
};
|
|
103
|
+
await createPurchase(env, pending);
|
|
104
|
+
o = await byId(env, pending.id);
|
|
105
|
+
}
|
|
106
|
+
const hasCredential = request.headers.has('authorization') || request.headers.has('payment-signature') || Boolean(dependencies.extra?._meta);
|
|
107
|
+
await reconcileRecordings(env, o.id, { ...(dependencies.chainRpc ? { rpc: dependencies.chainRpc } : {}), facilitator: dependencies.facilitator });
|
|
108
|
+
o = await byId(env, o.id);
|
|
109
|
+
const pending = await env.DB.prepare(`SELECT 1 FROM purchase_jobs WHERE order_id = ?1 AND ${blockingJobsSQL} UNION ALL SELECT 1 FROM purchase_settlements WHERE order_id = ?1 AND state NOT IN ('complete','unpaid') LIMIT 1`).bind(o.id).first();
|
|
110
|
+
if (pending && !['paid','fulfilled','disputed'].includes(o.status)) return problem(503, 'Payment outcome is being reconciled. Retry this purchase; do not pay again.');
|
|
111
|
+
if (o.status === "started") {
|
|
112
|
+
const listed = await listedRelease(env, url.origin, b.kind, b.resource, b.version);
|
|
113
|
+
if (Date.now() >= o.created_at + 300000 || !listed ||
|
|
114
|
+
await quoteHash(listed.resource, o.quantity, o.game, listed.offer) !== await quoteHash(JSON.parse(o.manifest), o.quantity, o.game, JSON.parse(o.offer))) {
|
|
115
|
+
await orderFact(env, o.id, 'expired', {}).run();
|
|
116
|
+
return problem(409, "This five-minute purchase has expired or its offer changed. Start a new purchase with the current offer.");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const core = await orderState(env, o.id);
|
|
120
|
+
if (o.status === "started" && core?.transport !== "machine")
|
|
121
|
+
return problem(
|
|
122
|
+
409,
|
|
123
|
+
"This order uses Checkout; do not pay it through a wallet as well",
|
|
124
|
+
);
|
|
125
|
+
if (o.payment && hasCredential) o = await currentPurchaseOrder(env, o.id);
|
|
126
|
+
const resource = JSON.parse(o.manifest);
|
|
127
|
+
if (
|
|
128
|
+
o.resource_kind !== b.kind ||
|
|
129
|
+
o.buyer !== b.buyer ||
|
|
130
|
+
o.mode !== modeOf(env.STRIPE_KEY) ||
|
|
131
|
+
resource.id !== b.resource ||
|
|
132
|
+
resource.version !== b.version ||
|
|
133
|
+
!(await approved(resource, o.quantity, o.game, JSON.parse(o.offer)))
|
|
134
|
+
)
|
|
135
|
+
return problem(409, "Request differs from this purchase");
|
|
136
|
+
const response = async () => {
|
|
137
|
+
if (!(await deliveryAllowed(env, o)))
|
|
138
|
+
throw new Error(
|
|
139
|
+
"Test downloads need owner approval and never work in live mode",
|
|
140
|
+
);
|
|
141
|
+
const grant = await grantPurchase(env, url.origin, o, {
|
|
142
|
+
version: b.version,
|
|
143
|
+
});
|
|
144
|
+
if (!grant.ok) return grant;
|
|
145
|
+
const result = await grant.json();
|
|
146
|
+
return { ...result, order: o.id };
|
|
147
|
+
};
|
|
148
|
+
if (!dependencies.dryRun && o.mode === 'test' && !(await deliveryAllowed(env, o)))
|
|
149
|
+
return problem(403, `Test downloads need owner approval for order ${o.id}. No payment requested.`, { order: o.id });
|
|
150
|
+
const client = recordingClient(env, o, dependencies.client ?? stripeClient(env));
|
|
151
|
+
const receiptKey = o.id;
|
|
152
|
+
const stored = await env.DB.prepare("SELECT value FROM purchase_receipts WHERE order_id = ?1")
|
|
153
|
+
.bind(receiptKey)
|
|
154
|
+
.first();
|
|
155
|
+
o = await byId(env, o.id);
|
|
156
|
+
if (["paid", "fulfilled", "disputed"].includes(o.status)) {
|
|
157
|
+
const recorded = stored ? null : await env.DB.prepare("SELECT payload FROM purchase_jobs WHERE order_id = ?1 ORDER BY updated_at DESC LIMIT 1").bind(o.id).first();
|
|
158
|
+
const recording = recorded && JSON.parse(recorded.payload);
|
|
159
|
+
const challengeId = recording?.params?.metadata?.mpp_challenge_id;
|
|
160
|
+
if (!stored && !challengeId) return problem(409, "This purchase has no machine-payment challenge receipt. Retrieve the paid files using the purchase grant endpoint.");
|
|
161
|
+
const receipt = stored ? JSON.parse(stored.value) : {
|
|
162
|
+
method: recording?.params?.metadata?.mpp_method ?? "stripe", status: "success", reference: recording?.chain?.transaction_hash ?? o.payment,
|
|
163
|
+
timestamp: new Date(o.paid_at).toISOString(), challengeId,
|
|
164
|
+
};
|
|
165
|
+
const result = await response();
|
|
166
|
+
if (result instanceof Response) return result;
|
|
167
|
+
if (mcp) return machineMcpDelivery(result, dependencies, receipt);
|
|
168
|
+
const delivered = purchaseResponse(result, env);
|
|
169
|
+
if (!mcp) {
|
|
170
|
+
delivered.headers.set("Payment-Receipt", Receipt.serialize(receipt));
|
|
171
|
+
const headers = await env.DB.prepare(
|
|
172
|
+
"SELECT headers AS value FROM purchase_receipts WHERE order_id = ?1 AND headers IS NOT NULL",
|
|
173
|
+
)
|
|
174
|
+
.bind(o.id)
|
|
175
|
+
.first();
|
|
176
|
+
if (!headers && recording?.chain?.network === 'base') delivered.headers.set('payment-response', btoa(JSON.stringify({ success: true, transaction: recording.chain.transaction_hash, network: o.mode === 'live' ? 'eip155:8453' : 'eip155:84532' })));
|
|
177
|
+
if (headers)
|
|
178
|
+
for (const [key, value] of Object.entries(JSON.parse(headers.value)))
|
|
179
|
+
delivered.headers.set(key, value);
|
|
180
|
+
}
|
|
181
|
+
return mcp ? (await delivered.json()).result : delivered;
|
|
182
|
+
}
|
|
183
|
+
if (o.status !== "started")
|
|
184
|
+
return problem(402, `Purchase is ${o.status}; do not pay this order again`);
|
|
185
|
+
const readiness = await machineCapabilities(env);
|
|
186
|
+
if (!readiness.ready) return problem(503, Object.values(readiness.reasons).join(' '));
|
|
187
|
+
const protocol = await paymentProtocol(env, o, {
|
|
188
|
+
...dependencies,
|
|
189
|
+
client,
|
|
190
|
+
mcp,
|
|
191
|
+
realm: url.hostname,
|
|
192
|
+
capabilities: readiness,
|
|
193
|
+
});
|
|
194
|
+
protocol.onPaymentSuccess(async ({ receipt, challenge }) => {
|
|
195
|
+
receipt = { ...receipt, challengeId: challenge.id };
|
|
196
|
+
await env.DB.prepare(
|
|
197
|
+
"INSERT INTO purchase_receipts (order_id,value) VALUES (?1,?2) ON CONFLICT(order_id) DO UPDATE SET value = excluded.value",
|
|
198
|
+
)
|
|
199
|
+
.bind(receiptKey, JSON.stringify(receipt))
|
|
200
|
+
.run();
|
|
201
|
+
});
|
|
202
|
+
const paid = await protocol.charge({
|
|
203
|
+
expires: new Date(o.created_at + 300000).toISOString(),
|
|
204
|
+
amount: (o.amount / 100).toFixed(2),
|
|
205
|
+
description: `${resource.name} ${resource.version}`,
|
|
206
|
+
scope: o.id,
|
|
207
|
+
meta: { order: o.id, quote: await quoteHash(resource, o.quantity, o.game, JSON.parse(o.offer)) },
|
|
208
|
+
paymentIntentOptions: { metadata: { homie: "purchase-v1", order: o.id } },
|
|
209
|
+
})(mcp ? dependencies.extra : request);
|
|
210
|
+
if (paid.status === 402) {
|
|
211
|
+
const uncertain = await env.DB.prepare(`SELECT 1 FROM purchase_jobs WHERE order_id = ?1 AND ${blockingJobsSQL} UNION ALL SELECT 1 FROM purchase_settlements WHERE order_id = ?1 AND state NOT IN ('complete','unpaid') LIMIT 1`).bind(o.id).first();
|
|
212
|
+
if (uncertain) return problem(503, 'The payment outcome is being reconciled. Retry this purchase; do not pay again.');
|
|
213
|
+
if (mcp) throw paid.challenge;
|
|
214
|
+
return paid.challenge;
|
|
215
|
+
}
|
|
216
|
+
if (!mcp) {
|
|
217
|
+
const receiptResponse = paid.withReceipt(new Response());
|
|
218
|
+
const headers = Object.fromEntries(
|
|
219
|
+
["payment-receipt", "payment-response"]
|
|
220
|
+
.filter((key) => receiptResponse.headers.has(key))
|
|
221
|
+
.map((key) => [key, receiptResponse.headers.get(key)]),
|
|
222
|
+
);
|
|
223
|
+
await env.DB.prepare(
|
|
224
|
+
"INSERT INTO purchase_receipts (order_id,headers) VALUES (?1,?2) ON CONFLICT(order_id) DO UPDATE SET headers = excluded.headers",
|
|
225
|
+
)
|
|
226
|
+
.bind(o.id, JSON.stringify(headers))
|
|
227
|
+
.run();
|
|
228
|
+
}
|
|
229
|
+
o = await byId(env, o.id);
|
|
230
|
+
if (!["paid","fulfilled"].includes(o.status))
|
|
231
|
+
return problem(
|
|
232
|
+
503,
|
|
233
|
+
"Settlement is being recorded. Retry this order; do not pay again",
|
|
234
|
+
);
|
|
235
|
+
const result = await response();
|
|
236
|
+
if (result instanceof Response) return result;
|
|
237
|
+
if (mcp) return paid.withReceipt(machineMcpDelivery(result, dependencies));
|
|
238
|
+
return paid.withReceipt(purchaseResponse(result, env));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function machineMcpDelivery(result, dependencies, receipt) {
|
|
242
|
+
dependencies.setDelivery?.(result);
|
|
243
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(receipt ? { _meta: { 'org.paymentauth/receipt': receipt } } : {}) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function machineInput(request, mcpInput) {
|
|
247
|
+
let text;
|
|
248
|
+
try { text = await limitedText(request.clone()); } catch { return problem(413, 'Purchase request too large'); }
|
|
249
|
+
if (new TextEncoder().encode(text).length > 16384) return problem(413, 'Purchase request too large');
|
|
250
|
+
let input;
|
|
251
|
+
try { input = JSON.parse(text); } catch { return problem(400, 'Invalid JSON request'); }
|
|
252
|
+
if (!input || typeof input !== 'object') return problem(400, 'Expected a purchase request');
|
|
253
|
+
return mcpInput ?? input;
|
|
254
|
+
}
|
|
255
|
+
function recordingClient(env, o, providerClient) {
|
|
256
|
+
|
|
257
|
+
const create = providerClient.paymentIntents.create.bind(
|
|
258
|
+
providerClient.paymentIntents,
|
|
259
|
+
);
|
|
260
|
+
const client = Object.create(providerClient);
|
|
261
|
+
client.paymentIntents = Object.create(providerClient.paymentIntents);
|
|
262
|
+
client.paymentIntents.create = async (params, options) => {
|
|
263
|
+
params = { ...params, metadata: { ...params.metadata, homie: "purchase-v1", order: o.id } };
|
|
264
|
+
const id = await saveRecording(env, o, params, options);
|
|
265
|
+
try {
|
|
266
|
+
const saved = await env.DB.prepare('SELECT state,payload FROM purchase_jobs WHERE id = ?1').bind(id).first();
|
|
267
|
+
if (saved.state === 'manual_review') throw new Error('Stripe recording was refused; the seller must handle any refund manually.');
|
|
268
|
+
const original = JSON.parse(saved.payload);
|
|
269
|
+
let pi = await create(original.params, original.options);
|
|
270
|
+
if (pi.status === 'processing') pi = await providerClient.paymentIntents.retrieve(pi.id);
|
|
271
|
+
if (pi.status === 'requires_action') pi = await providerClient.paymentIntents.cancel(pi.id);
|
|
272
|
+
await recordProviderResult(env, o, id, pi);
|
|
273
|
+
return pi;
|
|
274
|
+
} catch (error) { await recordingFailure(env, id, error);
|
|
275
|
+
if (((error.statusCode ?? error.status) === 403 || /not available|not enabled|permission|country|eligib/i.test(error.message))) await notePaymentRefusal(env, params.payment_method_options?.crypto ? 'base' : 'card', error);
|
|
276
|
+
throw error; }
|
|
277
|
+
};
|
|
278
|
+
return client;
|
|
279
|
+
}
|