@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,582 @@
|
|
|
1
|
+
import { hasStripeKey, shopMode, rememberMoneyEvent, rememberedMoney } from './shop-links.mjs';
|
|
2
|
+
import { paymentRefunds } from './shop-refunds.mjs';
|
|
3
|
+
import { orderFact, bindHostedSession } from './purchase-core.mjs';
|
|
4
|
+
/** Resource purchases share the studio's Stripe webhook and refund office. */
|
|
5
|
+
import { recordOrderState } from "./purchase-core.mjs";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
cancelSubscription,
|
|
9
|
+
refundPayment,
|
|
10
|
+
retrieveCheckoutSession,
|
|
11
|
+
stripeCall,
|
|
12
|
+
modeOf,
|
|
13
|
+
} from "./stripe.mjs";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
byId,
|
|
17
|
+
idOf,
|
|
18
|
+
termsOf,
|
|
19
|
+
purchasedResource,
|
|
20
|
+
active,
|
|
21
|
+
} from "./purchase-store.mjs";
|
|
22
|
+
export async function reconcilePurchaseSession(env, session) {
|
|
23
|
+
if (
|
|
24
|
+
!session.metadata?.order ||
|
|
25
|
+
!(await env.DB.prepare(
|
|
26
|
+
"SELECT 1 FROM purchase_orders WHERE id = ?1",
|
|
27
|
+
)
|
|
28
|
+
.bind(session.metadata.order)
|
|
29
|
+
.first())
|
|
30
|
+
)
|
|
31
|
+
return false;
|
|
32
|
+
const o = session.metadata?.order
|
|
33
|
+
? await byId(env, session.metadata.order)
|
|
34
|
+
: null;
|
|
35
|
+
if (!o) return false;
|
|
36
|
+
if (o.session && o.session !== session.id)
|
|
37
|
+
throw new Error("Purchase checkout session mismatch");
|
|
38
|
+
if (
|
|
39
|
+
session.metadata?.homie !== "purchase-v1" ||
|
|
40
|
+
session.client_reference_id !== (o.checkout_buyer ?? o.buyer) ||
|
|
41
|
+
session.currency !== o.currency ||
|
|
42
|
+
Number(session.amount_subtotal) !== Number(o.amount) ||
|
|
43
|
+
session.livemode !== (o.mode === "live")
|
|
44
|
+
)
|
|
45
|
+
throw new Error("Purchase checkout terms mismatch");
|
|
46
|
+
if (!["paid", "no_payment_required"].includes(session.payment_status))
|
|
47
|
+
return true;
|
|
48
|
+
const payment = idOf(session.payment_intent);
|
|
49
|
+
const subscription = idOf(session.subscription);
|
|
50
|
+
const recurring = termsOf(o).billing !== "one-time";
|
|
51
|
+
if (
|
|
52
|
+
recurring !== Boolean(subscription) ||
|
|
53
|
+
(!recurring && !payment && Number(session.amount_total) !== 0)
|
|
54
|
+
)
|
|
55
|
+
throw new Error("Purchase payment kind mismatch");
|
|
56
|
+
const writes = [
|
|
57
|
+
orderFact(env, o.id, 'details', {customer: idOf(session.customer) ?? null, subscription: subscription ?? null}),
|
|
58
|
+
];
|
|
59
|
+
if (!recurring) {
|
|
60
|
+
writes.push(
|
|
61
|
+
orderFact(env, o.id, 'payment', {session: session.id, payment: payment ?? null, tax: session.total_details?.amount_tax ?? 0, total: session.amount_total, paid_at: Date.now()}),
|
|
62
|
+
);
|
|
63
|
+
if (payment)
|
|
64
|
+
writes.push(
|
|
65
|
+
env.DB.prepare(
|
|
66
|
+
"INSERT INTO purchase_payments (payment, order_id) VALUES (?1, ?2) ON CONFLICT(payment) DO NOTHING",
|
|
67
|
+
).bind(payment, o.id),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
await env.DB.batch(writes);
|
|
71
|
+
if (recurring) {
|
|
72
|
+
await reconcileSubscription(env, { ...o, subscription });
|
|
73
|
+
if (
|
|
74
|
+
await env.DB.prepare("SELECT 1 FROM resource_retirements WHERE resource = ?1")
|
|
75
|
+
.bind(`${o.mode}:${o.resource_kind}:${JSON.parse(o.manifest).id}`)
|
|
76
|
+
.first()
|
|
77
|
+
)
|
|
78
|
+
await cancelSubscription(env, subscription);
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function reconcileSubscription(env, o) {
|
|
84
|
+
const sub = await stripeCall(
|
|
85
|
+
env,
|
|
86
|
+
"GET",
|
|
87
|
+
`/v1/subscriptions/${encodeURIComponent(o.subscription)}`,
|
|
88
|
+
{ expand: ["latest_invoice"] },
|
|
89
|
+
);
|
|
90
|
+
if (
|
|
91
|
+
sub.metadata?.order !== o.id ||
|
|
92
|
+
Boolean(sub.livemode) !== (o.mode === "live")
|
|
93
|
+
)
|
|
94
|
+
throw new Error("Subscription mismatch");
|
|
95
|
+
let invoice = sub.latest_invoice;
|
|
96
|
+
if (typeof invoice === "string")
|
|
97
|
+
invoice = await stripeCall(
|
|
98
|
+
env,
|
|
99
|
+
"GET",
|
|
100
|
+
`/v1/invoices/${encodeURIComponent(invoice)}`,
|
|
101
|
+
);
|
|
102
|
+
if (!["active", "canceled", "past_due"].includes(sub.status)) {
|
|
103
|
+
await orderFact(env, o.id, 'lapsed', {paid_until: 0})
|
|
104
|
+
.run();
|
|
105
|
+
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// A proration is an adjustment, never a replacement for the base paid period.
|
|
109
|
+
// When renewal is pending, find the previous paid base invoice in Stripe, not a local flag.
|
|
110
|
+
const pending = ["draft", "open"].includes(invoice?.status);
|
|
111
|
+
const grace =
|
|
112
|
+
sub.status === "active" && pending
|
|
113
|
+
? (termsOf(o).renewalGraceMinutes ?? 0) * 60000
|
|
114
|
+
: 0;
|
|
115
|
+
if (
|
|
116
|
+
invoice?.status !== "paid" ||
|
|
117
|
+
invoice?.billing_reason === "subscription_update" ||
|
|
118
|
+
invoice?.lines?.data?.every(
|
|
119
|
+
(l) =>
|
|
120
|
+
l.parent?.subscription_item_details?.proration === true ||
|
|
121
|
+
l.proration === true,
|
|
122
|
+
)
|
|
123
|
+
) {
|
|
124
|
+
let cursor;
|
|
125
|
+
for (;;) {
|
|
126
|
+
const page = await stripeCall(env, "GET", "/v1/invoices", {
|
|
127
|
+
subscription: sub.id,
|
|
128
|
+
limit: 100,
|
|
129
|
+
...(cursor ? { starting_after: cursor } : {}),
|
|
130
|
+
});
|
|
131
|
+
const base = page.data?.find(
|
|
132
|
+
(i) =>
|
|
133
|
+
i.status === "paid" &&
|
|
134
|
+
["subscription_create", "subscription_cycle"].includes(
|
|
135
|
+
i.billing_reason,
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
if (base) {
|
|
139
|
+
invoice = base;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
if (!page.has_more || !page.data?.length) {
|
|
143
|
+
invoice = null;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
cursor = page.data.at(-1).id;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!invoice) {
|
|
150
|
+
await orderFact(env, o.id, 'lapsed', {paid_until: 0})
|
|
151
|
+
.run();
|
|
152
|
+
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const p = purchasedResource(o);
|
|
156
|
+
const line =
|
|
157
|
+
invoice.lines?.data?.find(
|
|
158
|
+
(l) =>
|
|
159
|
+
(l.parent?.subscription_item_details?.subscription ||
|
|
160
|
+
l.subscription) === sub.id,
|
|
161
|
+
) ?? invoice.lines?.data?.[0];
|
|
162
|
+
if (!line?.period?.end || invoice.currency !== o.currency)
|
|
163
|
+
throw new Error("Subscription invoice period or currency mismatch");
|
|
164
|
+
const quantity = sub.items?.data?.[0]?.quantity;
|
|
165
|
+
if (
|
|
166
|
+
!Number.isSafeInteger(quantity) ||
|
|
167
|
+
quantity < 1
|
|
168
|
+
) {
|
|
169
|
+
await orderFact(env, o.id, 'lapsed', {paid_until: 0})
|
|
170
|
+
.run();
|
|
171
|
+
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const until = line.period.end * 1000;
|
|
175
|
+
const payments = { data: [] };
|
|
176
|
+
let cursor;
|
|
177
|
+
for (;;) {
|
|
178
|
+
const page = await stripeCall(env, "GET", "/v1/invoice_payments", {
|
|
179
|
+
invoice: invoice.id,
|
|
180
|
+
limit: 100,
|
|
181
|
+
...(cursor ? { starting_after: cursor } : {}),
|
|
182
|
+
});
|
|
183
|
+
payments.data.push(...(page.data ?? []));
|
|
184
|
+
if (!page.has_more || !page.data?.length) break;
|
|
185
|
+
cursor = page.data.at(-1).id;
|
|
186
|
+
}
|
|
187
|
+
await orderFact(env, o.id, 'details', {payment: null}, {renewal:true})
|
|
188
|
+
.run();
|
|
189
|
+
let usable =
|
|
190
|
+
invoice.status === "paid" && Number(invoice.amount_remaining ?? 0) === 0;
|
|
191
|
+
let disputed = false;
|
|
192
|
+
let revoked = null;
|
|
193
|
+
for (const pay of payments.data ?? []) {
|
|
194
|
+
const payment = idOf(pay.payment?.payment_intent);
|
|
195
|
+
if (!payment || pay.status !== "paid") continue;
|
|
196
|
+
await env.DB.batch([
|
|
197
|
+
env.DB.prepare(
|
|
198
|
+
"INSERT INTO purchase_payments (payment, order_id, invoice) VALUES (?1, ?2, ?3) ON CONFLICT(payment) DO NOTHING",
|
|
199
|
+
).bind(payment, o.id, invoice.id),
|
|
200
|
+
env.DB.prepare(
|
|
201
|
+
"INSERT INTO purchase_payment_facts (payment, invoice, order_id, period_end) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(payment, invoice) DO UPDATE SET period_end = excluded.period_end",
|
|
202
|
+
).bind(payment, invoice.id, o.id, until),
|
|
203
|
+
orderFact(env, o.id, 'details', {payment: payment}, {renewal:true}),
|
|
204
|
+
]);
|
|
205
|
+
const pi = await stripeCall(
|
|
206
|
+
env,
|
|
207
|
+
"GET",
|
|
208
|
+
`/v1/payment_intents/${encodeURIComponent(payment)}`,
|
|
209
|
+
{ expand: ["latest_charge"] },
|
|
210
|
+
);
|
|
211
|
+
const charge = pi.latest_charge;
|
|
212
|
+
if (charge && typeof charge === "object") {
|
|
213
|
+
const refunds = await paymentRefunds(env,payment,o);
|
|
214
|
+
const refunded = [...refunds.values()].filter(r=>r.status === 'succeeded').reduce((n,r)=>n+r.amount,0);
|
|
215
|
+
let current = refunded > 0 && refunded >= Number(charge.amount ?? invoice.total) ? 'refunded' : 'paid';
|
|
216
|
+
let dispute = null;
|
|
217
|
+
if (charge.disputed) {
|
|
218
|
+
const disputes = await stripeCall(env, "GET", "/v1/disputes", {
|
|
219
|
+
payment_intent: payment,
|
|
220
|
+
limit: 1,
|
|
221
|
+
});
|
|
222
|
+
dispute = disputes.data?.[0];
|
|
223
|
+
current =
|
|
224
|
+
dispute?.status === "lost"
|
|
225
|
+
? "lost"
|
|
226
|
+
: ["won", "warning_closed"].includes(dispute?.status)
|
|
227
|
+
? current
|
|
228
|
+
: "disputed";
|
|
229
|
+
}
|
|
230
|
+
await env.DB.prepare(
|
|
231
|
+
"UPDATE purchase_payment_facts SET state = ?3, dispute = ?4 WHERE payment = ?1 AND invoice = ?2",
|
|
232
|
+
)
|
|
233
|
+
.bind(payment, invoice.id, current, dispute?.id ?? null)
|
|
234
|
+
.run();
|
|
235
|
+
}
|
|
236
|
+
const state = await env.DB.prepare(
|
|
237
|
+
"SELECT state FROM purchase_payment_facts WHERE payment = ?1 AND invoice = ?2",
|
|
238
|
+
)
|
|
239
|
+
.bind(payment, invoice.id)
|
|
240
|
+
.first();
|
|
241
|
+
if (state?.state === "disputed") disputed = true;
|
|
242
|
+
if (["refunded", "lost"].includes(state?.state)) revoked = state.state;
|
|
243
|
+
}
|
|
244
|
+
usable = usable && !revoked;
|
|
245
|
+
await env.DB.batch([
|
|
246
|
+
orderFact(env, o.id, 'details', {paid_until: usable ? until + grace : 0, quantity: quantity}),
|
|
247
|
+
orderFact(env, o.id, usable && until + grace > Date.now() ? (disputed ? "disputed" : "paid") : (revoked ?? "lapsed"), {total: invoice.total, paid_at: Date.now()}, {renewal:true}),
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
}
|
|
251
|
+
/** Re-read the provider so delayed or missing webhooks cannot preserve refunded access. */
|
|
252
|
+
export async function reconcileOneTime(env, o) {
|
|
253
|
+
if (!o.payment || o.mode !== shopMode(env)) return;
|
|
254
|
+
if (!hasStripeKey(env)) {
|
|
255
|
+
const refunds = await paymentRefunds(env, o.payment, o);
|
|
256
|
+
const refunded = [...refunds.values()].filter(r=>r.status === 'succeeded').reduce((n,r)=>n+r.amount,0);
|
|
257
|
+
if (!Number.isSafeInteger(refunded) || refunded < 0 || refunded > Number(o.total ?? o.amount)) throw new Error('Invalid Stripe refund total');
|
|
258
|
+
const dispute = (await rememberedMoney(env,o.payment,o.mode)).find(r=>r.id.startsWith('dp_'));
|
|
259
|
+
const state = refunded === Number(o.total ?? o.amount) && refunded > 0 ? 'refunded' : dispute?.status === 'lost' ? 'lost' : dispute && !['won','warning_closed'].includes(dispute.status) ? 'disputed' : 'paid';
|
|
260
|
+
const completed = [...refunds.values()].find(r=>r.status === 'succeeded');
|
|
261
|
+
const reversed = [...refunds.values()].some(r=>['failed','canceled'].includes(r.status));
|
|
262
|
+
await orderFact(env,o.id,state,state === 'refunded' ? {refund:completed?.id} : {},{refundReversal:Boolean(reversed)}).run();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const pi = await stripeCall(
|
|
266
|
+
env,
|
|
267
|
+
"GET",
|
|
268
|
+
`/v1/payment_intents/${encodeURIComponent(o.payment)}`,
|
|
269
|
+
{ expand: ["latest_charge"] },
|
|
270
|
+
);
|
|
271
|
+
const link = await linkOf(env,o.id);
|
|
272
|
+
if (link ? pi.metadata?.homie !== 'purchase-links-v1' || pi.metadata?.revision !== link.revision : pi.metadata?.order !== o.id || pi.metadata?.homie !== "purchase-v1")
|
|
273
|
+
throw new Error("Payment order mismatch");
|
|
274
|
+
const charge = pi.latest_charge;
|
|
275
|
+
if (!charge || typeof charge !== "object") return;
|
|
276
|
+
const refunds = await paymentRefunds(env, o.payment, o);
|
|
277
|
+
const refunded = [...refunds.values()].filter(r=>r.status === 'succeeded').reduce((n,r)=>n+r.amount,0);
|
|
278
|
+
if (!Number.isSafeInteger(refunded) || refunded < 0 || refunded > Number(o.total ?? o.amount)) throw new Error('Invalid Stripe refund total');
|
|
279
|
+
let state = refunded > 0 && refunded === Number(o.total ?? o.amount) ? 'refunded' : 'paid';
|
|
280
|
+
if (charge.disputed && state !== "refunded") {
|
|
281
|
+
const disputes = await stripeCall(env, "GET", "/v1/disputes", {
|
|
282
|
+
payment_intent: o.payment,
|
|
283
|
+
limit: 1,
|
|
284
|
+
});
|
|
285
|
+
const dispute = disputes.data?.[0];
|
|
286
|
+
state =
|
|
287
|
+
dispute?.status === "lost"
|
|
288
|
+
? "lost"
|
|
289
|
+
: ["won", "warning_closed"].includes(dispute?.status)
|
|
290
|
+
? "paid"
|
|
291
|
+
: "disputed";
|
|
292
|
+
}
|
|
293
|
+
const completed = [...refunds.values()].find(r=>r.status === 'succeeded');
|
|
294
|
+
const reversed = [...refunds.values()].some(r=>['failed','canceled'].includes(r.status));
|
|
295
|
+
await orderFact(env, o.id, state, state === 'refunded' ? {refund:completed?.id} : {}, {refundReversal:Boolean(reversed)}).run();
|
|
296
|
+
|
|
297
|
+
}
|
|
298
|
+
export async function reconcileOrder(env, o) {
|
|
299
|
+
if (o.subscription && hasStripeKey(env) && !await linkOf(env,o.id)) await reconcileSubscription(env, o);
|
|
300
|
+
else await reconcileOneTime(env, o);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Called only AFTER the existing shop webhook signature/mode check. Return null for player orders. */
|
|
304
|
+
export async function purchasePaymentEvent(env, event) {
|
|
305
|
+
// An existing player shop may receive webhooks before purchase migrations run.
|
|
306
|
+
try { await env.DB.prepare("SELECT 1 FROM purchase_orders LIMIT 0").first(); }
|
|
307
|
+
catch (error) { if (/no such table/.test(error.message)) return null; throw error; }
|
|
308
|
+
|
|
309
|
+
const obj = event.data?.object ?? {};
|
|
310
|
+
if (!event.type.startsWith('checkout.session.')) await rememberMoneyEvent(env,event);
|
|
311
|
+
if (obj.payment_link && obj.metadata?.homie === 'purchase-links-v1') return reconcileLinkSession(env,obj);
|
|
312
|
+
if (!hasStripeKey(env) && event.type.startsWith('customer.subscription.')) {
|
|
313
|
+
await env.DB.prepare("INSERT INTO meta (key,value) VALUES (?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value WHERE json_extract(meta.value,'$.status') != 'canceled'").bind(`purchase-subscription:${obj.id}`,JSON.stringify({status:obj.status})).run();
|
|
314
|
+
return 'purchase-subscription-status';
|
|
315
|
+
}
|
|
316
|
+
if (!hasStripeKey(env) || event.type === 'invoice_payment.paid' || event.type === 'invoice.paid' && (obj.parent?.subscription_details?.metadata?.homie === 'purchase-links-v1' || await linkSubscription(env,obj))) {
|
|
317
|
+
if (event.type === 'invoice_payment.paid') {
|
|
318
|
+
const payment = idOf(obj.payment?.payment_intent), invoice = idOf(obj.invoice);
|
|
319
|
+
if (!payment || !invoice || obj.status !== 'paid') return null;
|
|
320
|
+
await env.DB.prepare('INSERT OR REPLACE INTO meta (key,value) VALUES (?1,?2)').bind(`purchase-invoice-payment:${invoice}`,JSON.stringify({payment,currency:obj.currency,livemode:obj.livemode})).run();
|
|
321
|
+
const saved = await env.DB.prepare('SELECT value FROM meta WHERE key = ?1').bind(`purchase-invoice-id:${invoice}`).first();
|
|
322
|
+
return saved ? reconcileLinkInvoice(env,JSON.parse(saved.value)) : 'purchase-invoice-payment-waiting';
|
|
323
|
+
}
|
|
324
|
+
if (event.type === 'invoice.paid') return reconcileLinkInvoice(env,obj);
|
|
325
|
+
const payment = idOf(obj.payment_intent);
|
|
326
|
+
if (!payment) return null;
|
|
327
|
+
const row = await env.DB.prepare('SELECT order_id FROM purchase_payments WHERE payment = ?1').bind(payment).first();
|
|
328
|
+
if (!row) return null;
|
|
329
|
+
const order = await byId(env,row.order_id);
|
|
330
|
+
await reconcileOneTime(env,order);
|
|
331
|
+
return 'purchase-reconciled';
|
|
332
|
+
}
|
|
333
|
+
if (
|
|
334
|
+
event.type === "payment_intent.succeeded" &&
|
|
335
|
+
obj.metadata?.homie === "purchase-v1"
|
|
336
|
+
) {
|
|
337
|
+
const o = await byId(env, obj.metadata.order);
|
|
338
|
+
if (
|
|
339
|
+
!o ||
|
|
340
|
+
o.session ||
|
|
341
|
+
o.mode !== (obj.livemode ? "live" : "test") ||
|
|
342
|
+
o.amount !== obj.amount ||
|
|
343
|
+
o.currency !== obj.currency
|
|
344
|
+
)
|
|
345
|
+
return null;
|
|
346
|
+
await env.DB.batch([
|
|
347
|
+
orderFact(env, o.id, 'payment', {payment: obj.id, paid_at: Date.now()}),
|
|
348
|
+
env.DB.prepare(
|
|
349
|
+
"INSERT OR IGNORE INTO purchase_payments (payment,order_id) VALUES (?1,?2)",
|
|
350
|
+
).bind(obj.id, o.id),
|
|
351
|
+
]);
|
|
352
|
+
const current = await byId(env, o.id);
|
|
353
|
+
if (current.payment !== obj.id) {
|
|
354
|
+
await env.DB.prepare("INSERT OR IGNORE INTO purchase_jobs (id,order_id,mode,state,payload,payment,updated_at) VALUES (?1,?2,?3,'duplicate_refund',?4,?5,?6)").bind(`duplicate:${obj.id}`, o.id, o.mode, JSON.stringify({ params: {}, options: {} }), obj.id, Date.now()).run();
|
|
355
|
+
const refund = await refundPayment(env, obj.id);
|
|
356
|
+
if (refund.status === 'succeeded') await env.DB.prepare("UPDATE purchase_jobs SET state = 'duplicate_refunded' WHERE id = ?1").bind(`duplicate:${obj.id}`).run();
|
|
357
|
+
return 'duplicate-refund';
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return "purchase-payment";
|
|
361
|
+
}
|
|
362
|
+
if (
|
|
363
|
+
event.type.startsWith("checkout.session.") &&
|
|
364
|
+
obj.metadata?.homie === "purchase-v1"
|
|
365
|
+
) {
|
|
366
|
+
if (
|
|
367
|
+
!/^ord_[A-Za-z0-9]{20}$/.test(obj.metadata.order ?? "") ||
|
|
368
|
+
!(await env.DB.prepare(
|
|
369
|
+
"SELECT 1 FROM purchase_orders WHERE id = ?1",
|
|
370
|
+
)
|
|
371
|
+
.bind(obj.metadata.order)
|
|
372
|
+
.first())
|
|
373
|
+
)
|
|
374
|
+
return null;
|
|
375
|
+
if (
|
|
376
|
+
[
|
|
377
|
+
"checkout.session.completed",
|
|
378
|
+
"checkout.session.async_payment_succeeded",
|
|
379
|
+
].includes(event.type)
|
|
380
|
+
) {
|
|
381
|
+
if (!(await reconcilePurchaseSession(env, obj))) return null;
|
|
382
|
+
} else if (
|
|
383
|
+
[
|
|
384
|
+
"checkout.session.expired",
|
|
385
|
+
"checkout.session.async_payment_failed",
|
|
386
|
+
].includes(event.type)
|
|
387
|
+
) {
|
|
388
|
+
await orderFact(env, obj.metadata.order, event.type.endsWith("expired") ? "expired" : "failed", {})
|
|
389
|
+
.run();
|
|
390
|
+
}
|
|
391
|
+
return "purchase-checkout";
|
|
392
|
+
}
|
|
393
|
+
if (
|
|
394
|
+
event.type.startsWith("invoice.") ||
|
|
395
|
+
event.type.startsWith("customer.subscription.")
|
|
396
|
+
) {
|
|
397
|
+
const subscription = event.type.startsWith("customer.subscription.")
|
|
398
|
+
? obj.id
|
|
399
|
+
: idOf(
|
|
400
|
+
obj.parent?.subscription_details?.subscription ?? obj.subscription,
|
|
401
|
+
);
|
|
402
|
+
if (!subscription) return null;
|
|
403
|
+
let row;
|
|
404
|
+
try {
|
|
405
|
+
row = await env.DB.prepare(
|
|
406
|
+
"SELECT id AS order_id FROM purchase_orders WHERE subscription = ?1",
|
|
407
|
+
)
|
|
408
|
+
.bind(subscription)
|
|
409
|
+
.first();
|
|
410
|
+
} catch (e) {
|
|
411
|
+
if (/no such table/.test(e.message)) return null;
|
|
412
|
+
throw e;
|
|
413
|
+
}
|
|
414
|
+
const order =
|
|
415
|
+
row?.order_id ??
|
|
416
|
+
obj.metadata?.order ??
|
|
417
|
+
obj.parent?.subscription_details?.metadata?.order;
|
|
418
|
+
if (!order) return null;
|
|
419
|
+
if (
|
|
420
|
+
!(await env.DB.prepare(
|
|
421
|
+
"SELECT 1 FROM purchase_orders WHERE id = ?1",
|
|
422
|
+
)
|
|
423
|
+
.bind(order)
|
|
424
|
+
.first())
|
|
425
|
+
)
|
|
426
|
+
return null;
|
|
427
|
+
const o = await byId(env, order);
|
|
428
|
+
if (!o) return null;
|
|
429
|
+
await orderFact(env, o.id, 'details', {subscription: subscription})
|
|
430
|
+
.run();
|
|
431
|
+
await reconcileSubscription(env, { ...o, subscription });
|
|
432
|
+
return "purchase-subscription";
|
|
433
|
+
}
|
|
434
|
+
const payment = idOf(obj.payment_intent);
|
|
435
|
+
if (!payment) return null;
|
|
436
|
+
let row;
|
|
437
|
+
try {
|
|
438
|
+
row = await env.DB.prepare(
|
|
439
|
+
"SELECT order_id FROM purchase_payments WHERE payment = ?1",
|
|
440
|
+
)
|
|
441
|
+
.bind(payment)
|
|
442
|
+
.first();
|
|
443
|
+
} catch (e) {
|
|
444
|
+
if (/no such table/.test(e.message)) return null;
|
|
445
|
+
throw e;
|
|
446
|
+
}
|
|
447
|
+
// A refund may beat Checkout's completion. Resolve its order from Stripe's immutable metadata.
|
|
448
|
+
if (!row && /^(charge\.|refund\.)/.test(event.type)) {
|
|
449
|
+
const playerOrder = await env.DB.prepare(
|
|
450
|
+
"SELECT item FROM shop_orders WHERE payment = ?1",
|
|
451
|
+
)
|
|
452
|
+
.bind(payment)
|
|
453
|
+
.first();
|
|
454
|
+
if (playerOrder)
|
|
455
|
+
return null;
|
|
456
|
+
if (!(await env.DB.prepare("SELECT 1 FROM purchase_orders LIMIT 1").first()))
|
|
457
|
+
return null;
|
|
458
|
+
try {
|
|
459
|
+
const pi = await stripeCall(
|
|
460
|
+
env,
|
|
461
|
+
"GET",
|
|
462
|
+
`/v1/payment_intents/${encodeURIComponent(payment)}`,
|
|
463
|
+
);
|
|
464
|
+
if (pi.metadata?.homie === "purchase-v1")
|
|
465
|
+
row = { order_id: pi.metadata.order };
|
|
466
|
+
else {
|
|
467
|
+
const payments = await stripeCall(env, "GET", "/v1/invoice_payments", {
|
|
468
|
+
"payment[type]": "payment_intent",
|
|
469
|
+
"payment[payment_intent]": payment,
|
|
470
|
+
limit: 100,
|
|
471
|
+
});
|
|
472
|
+
const invoiceId = idOf(payments.data?.[0]?.invoice);
|
|
473
|
+
if (!invoiceId) return null;
|
|
474
|
+
const invoice = await stripeCall(
|
|
475
|
+
env,
|
|
476
|
+
"GET",
|
|
477
|
+
`/v1/invoices/${encodeURIComponent(invoiceId)}`,
|
|
478
|
+
);
|
|
479
|
+
const order =
|
|
480
|
+
invoice.parent?.subscription_details?.metadata?.order ??
|
|
481
|
+
invoice.subscription_details?.metadata?.order;
|
|
482
|
+
if (!order) return null;
|
|
483
|
+
row = { order_id: order };
|
|
484
|
+
}
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
} // Discovery never owns a foreign Stripe event.
|
|
488
|
+
}
|
|
489
|
+
if (
|
|
490
|
+
!row ||
|
|
491
|
+
!/^ord_[A-Za-z0-9]{20}$/.test(row.order_id ?? "") ||
|
|
492
|
+
!(await env.DB.prepare(
|
|
493
|
+
"SELECT 1 FROM purchase_orders WHERE id = ?1",
|
|
494
|
+
)
|
|
495
|
+
.bind(row.order_id)
|
|
496
|
+
.first())
|
|
497
|
+
)
|
|
498
|
+
return null;
|
|
499
|
+
const o = await byId(env, row.order_id);
|
|
500
|
+
if (!o || o.mode !== (event.livemode ? "live" : "test")) return null;
|
|
501
|
+
if (!o.subscription && termsOf(o).billing !== "one-time" && o.session) {
|
|
502
|
+
const session = await retrieveCheckoutSession(env, o.session);
|
|
503
|
+
o.subscription = idOf(session.subscription);
|
|
504
|
+
}
|
|
505
|
+
if (!o.subscription) {
|
|
506
|
+
if (!o.payment) o.payment = payment;
|
|
507
|
+
await reconcileOneTime(env, o);
|
|
508
|
+
} else {
|
|
509
|
+
await reconcileSubscription(env, o);
|
|
510
|
+
const current = await byId(env, o.id);
|
|
511
|
+
if (current.status === "refunded" && termsOf(o).refundEndsSubscription)
|
|
512
|
+
await cancelSubscription(env, o.subscription);
|
|
513
|
+
}
|
|
514
|
+
return "purchase-reconciled";
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Signed snapshots only; correlation is checked against the frozen link before granting. */
|
|
518
|
+
async function reconcileLinkSession(env, session) {
|
|
519
|
+
let o = await byId(env, session.client_reference_id);
|
|
520
|
+
if (!o) return 'purchase-unknown';
|
|
521
|
+
const saved = await env.DB.prepare('SELECT value FROM meta WHERE key = ?1').bind(`purchase-link:${o.id}`).first();
|
|
522
|
+
const link = saved && JSON.parse(saved.value);
|
|
523
|
+
if (!link || session.payment_link !== link.id || session.metadata?.revision !== link.revision || session.currency !== o.currency || session.amount_subtotal !== o.amount || session.livemode !== (o.mode === 'live')) throw new Error('Purchase Payment Link terms mismatch');
|
|
524
|
+
if (!['paid','no_payment_required'].includes(session.payment_status)) return 'purchase-waiting';
|
|
525
|
+
await bindHostedSession(env,o.id,session.id);
|
|
526
|
+
o = await byId(env,o.id);
|
|
527
|
+
if (o.session !== session.id) {
|
|
528
|
+
// Public links are reusable. Every verified payment gets its own immutable order.
|
|
529
|
+
const { createPurchase } = await import('./purchase-store.mjs');
|
|
530
|
+
const { digest } = await import('./purchase-crypto.mjs');
|
|
531
|
+
const existing = await env.DB.prepare('SELECT id FROM purchase_orders WHERE session = ?1').bind(session.id).first();
|
|
532
|
+
const next = {...o,id:`ord_${(await digest(session.id)).slice(0,20)}`,session:null,payment:null,status:'started',claim_hash:await digest(`${o.claim_hash}:${session.id}`),created_at:Date.now()};
|
|
533
|
+
if (!existing) { try { await createPurchase(env,next); } catch(error) { if (!await byId(env,next.id)) throw error; } }
|
|
534
|
+
o = await byId(env,existing?.id ?? next.id);
|
|
535
|
+
await env.DB.prepare('INSERT OR IGNORE INTO meta (key,value) VALUES (?1,?2)').bind(`purchase-link:${o.id}`,JSON.stringify(link)).run();
|
|
536
|
+
await bindHostedSession(env,o.id,session.id);
|
|
537
|
+
}
|
|
538
|
+
const payment = idOf(session.payment_intent), subscription = idOf(session.subscription);
|
|
539
|
+
if ((termsOf(o).billing !== 'one-time') !== Boolean(subscription) || !subscription && !payment && session.amount_total !== 0) throw new Error('Purchase payment kind mismatch');
|
|
540
|
+
await env.DB.batch([
|
|
541
|
+
orderFact(env,o.id,'details',{session:session.id,customer:idOf(session.customer) ?? null,subscription:subscription ?? null}),
|
|
542
|
+
...(!subscription ? [orderFact(env,o.id,'payment',{payment:payment ?? null,total:session.amount_total,tax:session.total_details?.amount_tax ?? 0,paid_at:Date.now()})] : []),
|
|
543
|
+
...(payment ? [env.DB.prepare('INSERT OR IGNORE INTO purchase_payments (payment,order_id) VALUES (?1,?2)').bind(payment,o.id)] : [])
|
|
544
|
+
]);
|
|
545
|
+
if (payment) await reconcileOneTime(env,await byId(env,o.id));
|
|
546
|
+
if (subscription) {
|
|
547
|
+
const invoice = await env.DB.prepare('SELECT value FROM meta WHERE key = ?1').bind(`purchase-invoice:${subscription}`).first();
|
|
548
|
+
if (invoice) await reconcileLinkInvoice(env,JSON.parse(invoice.value));
|
|
549
|
+
}
|
|
550
|
+
return 'purchase-checkout';
|
|
551
|
+
}
|
|
552
|
+
async function reconcileLinkInvoice(env, invoice) {
|
|
553
|
+
const subscription = idOf(invoice.parent?.subscription_details?.subscription ?? invoice.subscription);
|
|
554
|
+
if (!subscription) return null;
|
|
555
|
+
const line = invoice.lines?.data?.find(l=>!l.proration && !l.parent?.subscription_item_details?.proration);
|
|
556
|
+
if (invoice.status !== 'paid' || !line?.period?.end) return null;
|
|
557
|
+
await env.DB.prepare("INSERT INTO meta (key,value) VALUES (?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value WHERE json_extract(excluded.value,'$.period_end') >= json_extract(meta.value,'$.period_end')").bind(`purchase-invoice:${subscription}`,JSON.stringify({id:invoice.id,subscription,status:invoice.status,currency:invoice.currency,livemode:invoice.livemode,amount_remaining:invoice.amount_remaining,total:invoice.total,period_end:line.period.end,lines:{data:[{period:line.period}]}})).run();
|
|
558
|
+
await env.DB.prepare('INSERT OR REPLACE INTO meta (key,value) VALUES (?1,?2)').bind(`purchase-invoice-id:${invoice.id}`,JSON.stringify({id:invoice.id,subscription,status:invoice.status,currency:invoice.currency,livemode:invoice.livemode,amount_remaining:invoice.amount_remaining,total:invoice.total,lines:{data:[{period:line.period}]}})).run();
|
|
559
|
+
const row = await env.DB.prepare('SELECT id FROM purchase_orders WHERE subscription = ?1').bind(subscription).first();
|
|
560
|
+
if (!row) return 'purchase-invoice-waiting';
|
|
561
|
+
const o = await byId(env,row.id);
|
|
562
|
+
if (invoice.currency !== o.currency || invoice.livemode !== (o.mode === 'live') || Number(invoice.amount_remaining ?? 0) !== 0) throw new Error('Purchase invoice mismatch');
|
|
563
|
+
if (line.period.end * 1000 < Number(o.paid_until ?? 0)) return 'purchase-old-invoice';
|
|
564
|
+
const recorded = await env.DB.prepare('SELECT value FROM meta WHERE key = ?1').bind(`purchase-invoice-payment:${invoice.id}`).first();
|
|
565
|
+
const paymentFact = recorded && JSON.parse(recorded.value);
|
|
566
|
+
if (paymentFact && (paymentFact.currency !== o.currency || paymentFact.livemode !== (o.mode === 'live'))) throw new Error('Invoice payment mismatch');
|
|
567
|
+
const payment = paymentFact?.payment ?? idOf(invoice.payment_intent ?? invoice.payments?.data?.[0]?.payment?.payment_intent);
|
|
568
|
+
await env.DB.batch([orderFact(env,o.id,'paid',{paid_until:line.period.end*1000,total:invoice.total,paid_at:Date.now(),...(payment?{payment}:{})},{renewal:line.period.end*1000 > Number(o.paid_until ?? 0)}),...(payment?[env.DB.prepare('INSERT OR IGNORE INTO purchase_payments (payment,order_id,invoice) VALUES (?1,?2,?3)').bind(payment,o.id,invoice.id)]:[])]);
|
|
569
|
+
if (payment) await reconcileOneTime(env,await byId(env,o.id));
|
|
570
|
+
return 'purchase-subscription';
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function linkOf(env,id) {
|
|
574
|
+
const saved = await env.DB.prepare('SELECT value FROM meta WHERE key = ?1').bind(`purchase-link:${id}`).first();
|
|
575
|
+
return saved ? JSON.parse(saved.value) : null;
|
|
576
|
+
}
|
|
577
|
+
async function linkSubscription(env,invoice) {
|
|
578
|
+
const subscription = idOf(invoice.parent?.subscription_details?.subscription ?? invoice.subscription);
|
|
579
|
+
if (!subscription) return false;
|
|
580
|
+
const row = await env.DB.prepare('SELECT id FROM purchase_orders WHERE subscription = ?1').bind(subscription).first();
|
|
581
|
+
return row ? Boolean(await linkOf(env,row.id)) : false;
|
|
582
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Stream purchased bytes so a large resource does not have to fit in Worker memory. */
|
|
2
|
+
import { resourceKind } from "./resource-kinds.mjs";
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
async function* base64(object) {
|
|
5
|
+
const reader = object.body?.getReader?.();
|
|
6
|
+
const source = reader ? null : new Uint8Array(await object.arrayBuffer());
|
|
7
|
+
let carry = new Uint8Array();
|
|
8
|
+
let offset = 0;
|
|
9
|
+
try {
|
|
10
|
+
for (;;) {
|
|
11
|
+
const next = reader
|
|
12
|
+
? await reader.read()
|
|
13
|
+
: {
|
|
14
|
+
done: offset >= source.length,
|
|
15
|
+
value: source.subarray(offset, (offset += 49152)),
|
|
16
|
+
};
|
|
17
|
+
if (next.done) break;
|
|
18
|
+
const bytes = new Uint8Array(carry.length + next.value.length);
|
|
19
|
+
bytes.set(carry);
|
|
20
|
+
bytes.set(next.value, carry.length);
|
|
21
|
+
const end = bytes.length - (bytes.length % 3);
|
|
22
|
+
for (let at = 0; at < end; at += 49152) {
|
|
23
|
+
let binary = "";
|
|
24
|
+
for (const byte of bytes.subarray(at, Math.min(at + 49152, end)))
|
|
25
|
+
binary += String.fromCharCode(byte);
|
|
26
|
+
yield btoa(binary);
|
|
27
|
+
}
|
|
28
|
+
carry = bytes.slice(end);
|
|
29
|
+
}
|
|
30
|
+
if (carry.length) yield btoa(String.fromCharCode(...carry));
|
|
31
|
+
} finally {
|
|
32
|
+
reader?.releaseLock();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function purchaseResponse(data, env, { mcp, envelope } = {}) {
|
|
36
|
+
data = { delivery: 'If this stream is interrupted, retry the same purchase to retrieve the files without paying again.', ...data };
|
|
37
|
+
async function* chunks() {
|
|
38
|
+
if (mcp) {
|
|
39
|
+
const { content, ...rest } = envelope.result;
|
|
40
|
+
yield `{"jsonrpc":"2.0","id":${JSON.stringify(envelope.id)},"result":{${Object.keys(rest).length ? JSON.stringify(rest).slice(1, -1) + "," : ""}"content":[${content.map((c) => JSON.stringify(c)).join(",")}`;
|
|
41
|
+
} else yield JSON.stringify(data).slice(0, -1) + ',"files":[';
|
|
42
|
+
let first = true;
|
|
43
|
+
for (const file of data.manifest.files) {
|
|
44
|
+
const object = await resourceKind(data.entitlement.resource.kind).readFile(env,file);
|
|
45
|
+
if (!object)
|
|
46
|
+
throw new Error(
|
|
47
|
+
"Purchased file temporarily unavailable; retry this order",
|
|
48
|
+
);
|
|
49
|
+
if (mcp)
|
|
50
|
+
yield `,{"type":"resource","resource":{"uri":${JSON.stringify(`urn:sha256:${file.sha256}?path=${encodeURIComponent(file.path)}`)},"mimeType":"application/octet-stream","blob":"`;
|
|
51
|
+
else
|
|
52
|
+
yield `${first ? "" : ","}{"path":${JSON.stringify(file.path)},"data":"`;
|
|
53
|
+
first = false;
|
|
54
|
+
yield* base64(object);
|
|
55
|
+
yield mcp ? '"}}' : '"}';
|
|
56
|
+
}
|
|
57
|
+
yield mcp ? "]}}" : "]}";
|
|
58
|
+
}
|
|
59
|
+
const iterator = chunks();
|
|
60
|
+
return new Response(
|
|
61
|
+
new ReadableStream({
|
|
62
|
+
async pull(controller) {
|
|
63
|
+
try {
|
|
64
|
+
const { value, done } = await iterator.next();
|
|
65
|
+
if (done) controller.close();
|
|
66
|
+
else controller.enqueue(encoder.encode(value));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
controller.error(error);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
async cancel() {
|
|
72
|
+
await iterator.return();
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
{
|
|
76
|
+
headers: {
|
|
77
|
+
"content-type": "application/json",
|
|
78
|
+
"cache-control": "no-store",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
}
|