@fantastic.dev/better-stripe 0.24.0 → 0.25.1
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 +13 -0
- package/README.md +7 -1
- package/dist/client/billing/checkout.d.ts +3 -43
- package/dist/client/billing/checkout.d.ts.map +1 -1
- package/dist/client/billing/checkout.js +5 -260
- package/dist/client/billing/checkout.js.map +1 -1
- package/dist/client/billing/checkoutRequest.d.ts +129 -0
- package/dist/client/billing/checkoutRequest.d.ts.map +1 -0
- package/dist/client/billing/checkoutRequest.js +263 -0
- package/dist/client/billing/checkoutRequest.js.map +1 -0
- package/dist/client/billing/preparedCheckout.d.ts +22 -0
- package/dist/client/billing/preparedCheckout.d.ts.map +1 -0
- package/dist/client/billing/preparedCheckout.js +86 -0
- package/dist/client/billing/preparedCheckout.js.map +1 -0
- package/dist/client/checkoutRequests.d.ts +80 -0
- package/dist/client/checkoutRequests.d.ts.map +1 -0
- package/dist/client/checkoutRequests.js +28 -0
- package/dist/client/checkoutRequests.js.map +1 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +464 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutCollection.d.ts +21 -0
- package/dist/client/connect/payoutCollection.d.ts.map +1 -0
- package/dist/client/connect/payoutCollection.js +47 -0
- package/dist/client/connect/payoutCollection.js.map +1 -0
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts +1805 -0
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -0
- package/dist/client/connect/payoutExecutionTestHelpers.js +194 -0
- package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts +232 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutWorker.d.ts +48 -0
- package/dist/client/connect/payoutWorker.d.ts.map +1 -0
- package/dist/client/connect/payoutWorker.js +78 -0
- package/dist/client/connect/payoutWorker.js.map +1 -0
- package/dist/client/index.d.ts +4 -14
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +6 -13
- package/dist/client/index.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +67 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +10 -0
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/client/readContracts.d.ts +14 -1
- package/dist/client/readContracts.d.ts.map +1 -1
- package/dist/client/readContracts.js +14 -0
- package/dist/client/readContracts.js.map +1 -1
- package/dist/client/setup.test.d.ts +18 -0
- package/dist/client/setup.test.d.ts.map +1 -0
- package/dist/client/setup.test.js +33 -0
- package/dist/client/setup.test.js.map +1 -0
- package/dist/client/types/triggers.d.ts +1 -1
- package/dist/client/types/triggers.d.ts.map +1 -1
- package/dist/client/webhooks/helpers.d.ts.map +1 -1
- package/dist/client/webhooks/helpers.js +1 -0
- package/dist/client/webhooks/helpers.js.map +1 -1
- package/dist/client/webhooks/payoutReconciliation.d.ts +8 -0
- package/dist/client/webhooks/payoutReconciliation.d.ts.map +1 -0
- package/dist/client/webhooks/payoutReconciliation.js +36 -0
- package/dist/client/webhooks/payoutReconciliation.js.map +1 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts +117 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.d.ts.map +1 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.js +105 -0
- package/dist/client/webhooks/payoutReconciliationTestHelpers.js.map +1 -0
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +1 -20
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +293 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/core/mutations.d.ts.map +1 -1
- package/dist/component/core/mutations.js +2 -0
- package/dist/component/core/mutations.js.map +1 -1
- package/dist/component/core/resetSafety.d.ts +4 -0
- package/dist/component/core/resetSafety.d.ts.map +1 -0
- package/dist/component/core/resetSafety.js +54 -0
- package/dist/component/core/resetSafety.js.map +1 -0
- package/dist/component/earnings/triggers.d.ts +418 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/earnings/triggers.js +2 -0
- package/dist/component/earnings/triggers.js.map +1 -1
- package/dist/component/payoutPolicy/schema.d.ts +2 -0
- package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.js +3 -1
- package/dist/component/payoutPolicy/schema.js.map +1 -1
- package/dist/component/payoutRelease/mutations.js +1 -1
- package/dist/component/payoutRelease/schema.d.ts +1 -0
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +1 -0
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/payoutSnapshots/localState.d.ts +89 -0
- package/dist/component/payoutSnapshots/localState.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/localState.js +186 -0
- package/dist/component/payoutSnapshots/localState.js.map +1 -0
- package/dist/component/payoutSnapshots/mutations.d.ts +108 -0
- package/dist/component/payoutSnapshots/mutations.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/mutations.js +263 -0
- package/dist/component/payoutSnapshots/mutations.js.map +1 -0
- package/dist/component/payoutSnapshots/revisions.d.ts +12 -0
- package/dist/component/payoutSnapshots/revisions.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/revisions.js +173 -0
- package/dist/component/payoutSnapshots/revisions.js.map +1 -0
- package/dist/component/payoutSnapshots/schema.d.ts +285 -0
- package/dist/component/payoutSnapshots/schema.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/schema.js +108 -0
- package/dist/component/payoutSnapshots/schema.js.map +1 -0
- package/dist/component/payoutSnapshots/scope.d.ts +7 -0
- package/dist/component/payoutSnapshots/scope.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/scope.js +8 -0
- package/dist/component/payoutSnapshots/scope.js.map +1 -0
- package/dist/component/payoutWorker/helpers.d.ts +30 -0
- package/dist/component/payoutWorker/helpers.d.ts.map +1 -0
- package/dist/component/payoutWorker/helpers.js +21 -0
- package/dist/component/payoutWorker/helpers.js.map +1 -0
- package/dist/component/payoutWorker/mutations.d.ts +68 -0
- package/dist/component/payoutWorker/mutations.d.ts.map +1 -0
- package/dist/component/payoutWorker/mutations.js +161 -0
- package/dist/component/payoutWorker/mutations.js.map +1 -0
- package/dist/component/payoutWorker/queries.d.ts +123 -0
- package/dist/component/payoutWorker/queries.d.ts.map +1 -0
- package/dist/component/payoutWorker/queries.js +61 -0
- package/dist/component/payoutWorker/queries.js.map +1 -0
- package/dist/component/payoutWorker/schema.d.ts +91 -0
- package/dist/component/payoutWorker/schema.d.ts.map +1 -0
- package/dist/component/payoutWorker/schema.js +27 -0
- package/dist/component/payoutWorker/schema.js.map +1 -0
- package/dist/component/schema.d.ts +232 -0
- package/dist/component/schema.js +10 -0
- package/dist/component/schema.js.map +1 -1
- package/docs/read-contracts.md +69 -5
- package/docs/settlement.md +128 -1
- package/package.json +1 -1
- package/src/client/billing/checkout.ts +11 -359
- package/src/client/billing/checkoutRequest.ts +362 -0
- package/src/client/billing/prepared-checkout.test.ts +430 -0
- package/src/client/billing/preparedCheckout.ts +143 -0
- package/src/client/checkoutRequests.ts +40 -0
- package/src/client/connect/payoutCollection.test.ts +74 -0
- package/src/client/connect/payoutCollection.ts +82 -0
- package/src/client/connect/payoutExecution.test.ts +1 -211
- package/src/client/connect/payoutExecutionTestHelpers.ts +213 -0
- package/src/client/connect/payoutWorker.test.ts +145 -0
- package/src/client/connect/payoutWorker.ts +144 -0
- package/src/client/index.ts +13 -27
- package/src/client/payoutPolicy.ts +26 -1
- package/src/client/readContracts.ts +28 -0
- package/src/client/types/triggers.ts +1 -0
- package/src/client/webhooks/helpers.ts +1 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +14 -1
- package/src/client/webhooks/payout-reconciliation.integration.test.ts +168 -0
- package/src/client/webhooks/payoutReconciliation.ts +72 -0
- package/src/client/webhooks/payoutReconciliationTestHelpers.ts +135 -0
- package/src/client/webhooks/processors.test.ts +44 -29
- package/src/client/webhooks/processors.ts +1 -27
- package/src/component/_generated/api.ts +16 -0
- package/src/component/_generated/component.ts +361 -0
- package/src/component/core/mutations.ts +2 -0
- package/src/component/core/resetSafety.ts +67 -0
- package/src/component/earnings/triggers.ts +2 -0
- package/src/component/payout-reset-safety.test.ts +148 -0
- package/src/component/payout-revisions.test.ts +125 -0
- package/src/component/payout-snapshots.test.ts +395 -0
- package/src/component/payout-worker.test.ts +165 -0
- package/src/component/payoutPolicy/schema.ts +3 -1
- package/src/component/payoutRelease/mutations.ts +1 -1
- package/src/component/payoutRelease/schema.ts +1 -0
- package/src/component/payoutSnapshots/localState.ts +238 -0
- package/src/component/payoutSnapshots/mutations.ts +275 -0
- package/src/component/payoutSnapshots/revisions.ts +247 -0
- package/src/component/payoutSnapshots/schema.ts +125 -0
- package/src/component/payoutSnapshots/scope.ts +7 -0
- package/src/component/payoutWorker/helpers.ts +30 -0
- package/src/component/payoutWorker/mutations.ts +181 -0
- package/src/component/payoutWorker/queries.ts +64 -0
- package/src/component/payoutWorker/schema.ts +26 -0
- package/src/component/public.test.ts +2 -2
- package/src/component/schema.ts +18 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { expect, it, vi } from "vitest";
|
|
2
|
+
import type Stripe from "stripe";
|
|
3
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
4
|
+
import type { CreateCheckoutSessionOptions } from "./checkoutOptions.js";
|
|
5
|
+
|
|
6
|
+
async function contract() {
|
|
7
|
+
const path = "./preparedCheckout.js";
|
|
8
|
+
const mod = await import(path).catch(() => ({}));
|
|
9
|
+
expect(
|
|
10
|
+
mod.prepareCheckoutSession,
|
|
11
|
+
"package-owned preparation is missing",
|
|
12
|
+
).toBeTypeOf("function");
|
|
13
|
+
expect(
|
|
14
|
+
mod.executePreparedCheckoutSession,
|
|
15
|
+
"package-owned prepared execution is missing",
|
|
16
|
+
).toBeTypeOf("function");
|
|
17
|
+
return mod;
|
|
18
|
+
}
|
|
19
|
+
function fixture() {
|
|
20
|
+
const create = vi.fn().mockResolvedValue({
|
|
21
|
+
id: "cs_one",
|
|
22
|
+
status: "open",
|
|
23
|
+
client_secret: "secret_one",
|
|
24
|
+
url: null,
|
|
25
|
+
});
|
|
26
|
+
const provider = {
|
|
27
|
+
checkout: { sessions: { create } },
|
|
28
|
+
prices: { retrieve: vi.fn().mockResolvedValue({ id: "price_one" }) },
|
|
29
|
+
} as unknown as Stripe;
|
|
30
|
+
const runMutation = vi.fn().mockResolvedValue(null);
|
|
31
|
+
const runQuery = vi.fn().mockResolvedValue(null);
|
|
32
|
+
const ctx = { runMutation, runQuery } as unknown as RunCtx;
|
|
33
|
+
const component = {
|
|
34
|
+
billing: {
|
|
35
|
+
mutations: {
|
|
36
|
+
upsertCheckoutSession: {
|
|
37
|
+
[Symbol.for("toReferencePath")]:
|
|
38
|
+
"betterStripe/billing/mutations/upsertCheckoutSession",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
} as unknown as Component;
|
|
43
|
+
const options: CreateCheckoutSessionOptions = {
|
|
44
|
+
userId: "buyer_one",
|
|
45
|
+
stripePriceId: "price_one",
|
|
46
|
+
mode: "subscription",
|
|
47
|
+
accountId: "acct_buyer",
|
|
48
|
+
returnUrl: "https://example.com/return",
|
|
49
|
+
metadata: { intentId: "intent_one", episodeId: "episode_one" },
|
|
50
|
+
trialDays: 0,
|
|
51
|
+
sessionOverrides: {
|
|
52
|
+
allowed_payment_method_types: ["card"],
|
|
53
|
+
subscription_data: {
|
|
54
|
+
metadata: {
|
|
55
|
+
userId: "buyer_one",
|
|
56
|
+
intentId: "intent_one",
|
|
57
|
+
episodeId: "episode_one",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
return { create, provider, runMutation, runQuery, ctx, component, options };
|
|
63
|
+
}
|
|
64
|
+
it("prepares the exact serializable provider request without a provider or component write", async () => {
|
|
65
|
+
const api = await contract();
|
|
66
|
+
const s = fixture();
|
|
67
|
+
const prepared = await api.prepareCheckoutSession(
|
|
68
|
+
s.provider,
|
|
69
|
+
s.component,
|
|
70
|
+
s.ctx,
|
|
71
|
+
s.options,
|
|
72
|
+
);
|
|
73
|
+
expect(prepared.request).toMatchObject({
|
|
74
|
+
mode: "subscription",
|
|
75
|
+
customer_account: "acct_buyer",
|
|
76
|
+
line_items: [{ price: "price_one", quantity: 1 }],
|
|
77
|
+
metadata: {
|
|
78
|
+
userId: "buyer_one",
|
|
79
|
+
intentId: "intent_one",
|
|
80
|
+
episodeId: "episode_one",
|
|
81
|
+
},
|
|
82
|
+
subscription_data: {
|
|
83
|
+
metadata: {
|
|
84
|
+
userId: "buyer_one",
|
|
85
|
+
intentId: "intent_one",
|
|
86
|
+
episodeId: "episode_one",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
expect(prepared.request.subscription_data.trial_period_days).toBeUndefined();
|
|
91
|
+
expect(JSON.parse(JSON.stringify(prepared))).toEqual(prepared);
|
|
92
|
+
expect(s.create).not.toHaveBeenCalled();
|
|
93
|
+
expect(s.runMutation).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
it("executes a persisted request unchanged with explicit idempotency and correlated component metadata", async () => {
|
|
96
|
+
const api = await contract();
|
|
97
|
+
const s = fixture();
|
|
98
|
+
const prepared = JSON.parse(
|
|
99
|
+
JSON.stringify(
|
|
100
|
+
await api.prepareCheckoutSession(
|
|
101
|
+
s.provider,
|
|
102
|
+
s.component,
|
|
103
|
+
s.ctx,
|
|
104
|
+
s.options,
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
s.options.metadata = { intentId: "changed" };
|
|
109
|
+
s.options.accountId = "acct_changed";
|
|
110
|
+
s.options.trialDays = 30;
|
|
111
|
+
expect(
|
|
112
|
+
await api.executePreparedCheckoutSession(s.provider, s.component, s.ctx, {
|
|
113
|
+
prepared,
|
|
114
|
+
idempotencyKey: "intent-one-checkout",
|
|
115
|
+
}),
|
|
116
|
+
).toEqual({
|
|
117
|
+
stripeSessionId: "cs_one",
|
|
118
|
+
clientSecret: "secret_one",
|
|
119
|
+
url: undefined,
|
|
120
|
+
});
|
|
121
|
+
expect(s.create).toHaveBeenCalledWith(prepared.request, {
|
|
122
|
+
idempotencyKey: "intent-one-checkout",
|
|
123
|
+
});
|
|
124
|
+
expect(s.runMutation.mock.calls[0][1]).toMatchObject({
|
|
125
|
+
userId: "buyer_one",
|
|
126
|
+
accountId: "acct_buyer",
|
|
127
|
+
priceId: "price_one",
|
|
128
|
+
metadata: { intentId: "intent_one", episodeId: "episode_one" },
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it.each([
|
|
133
|
+
{ metadata: { userId: "foreign_buyer" } },
|
|
134
|
+
{ customer_account: "acct_foreign" },
|
|
135
|
+
{ line_items: [{ price: "price_foreign", quantity: 1 }] },
|
|
136
|
+
])(
|
|
137
|
+
"rejects provider overrides that change owned correlation %j before writes",
|
|
138
|
+
async (patch) => {
|
|
139
|
+
const api = await contract();
|
|
140
|
+
const s = fixture();
|
|
141
|
+
s.options.sessionOverrides = { ...s.options.sessionOverrides, ...patch };
|
|
142
|
+
await expect(
|
|
143
|
+
api.prepareCheckoutSession(s.provider, s.component, s.ctx, s.options),
|
|
144
|
+
).rejects.toThrow("Prepared Checkout correlation does not match.");
|
|
145
|
+
expect(s.create).not.toHaveBeenCalled();
|
|
146
|
+
expect(s.runMutation).not.toHaveBeenCalled();
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
it("exposes preparation and execution on the actual BetterStripe client", async () => {
|
|
151
|
+
const { BetterStripe } = await import("../index.js");
|
|
152
|
+
const s = fixture();
|
|
153
|
+
const client = new BetterStripe(s.component as never, {
|
|
154
|
+
STRIPE_SECRET_KEY: "sk_test_fake",
|
|
155
|
+
});
|
|
156
|
+
expect(client.checkoutRequests?.prepare).toBeTypeOf("function");
|
|
157
|
+
expect(client.checkoutRequests?.execute).toBeTypeOf("function");
|
|
158
|
+
});
|
|
159
|
+
it.each([undefined, "", "bad\nkey"])(
|
|
160
|
+
"requires an explicit valid prepared execution key %j",
|
|
161
|
+
async (key) => {
|
|
162
|
+
const api = await contract();
|
|
163
|
+
const s = fixture();
|
|
164
|
+
const prepared = await api.prepareCheckoutSession(
|
|
165
|
+
s.provider,
|
|
166
|
+
s.component,
|
|
167
|
+
s.ctx,
|
|
168
|
+
s.options,
|
|
169
|
+
);
|
|
170
|
+
await expect(
|
|
171
|
+
api.executePreparedCheckoutSession(s.provider, s.component, s.ctx, {
|
|
172
|
+
prepared,
|
|
173
|
+
idempotencyKey: key,
|
|
174
|
+
}),
|
|
175
|
+
).rejects.toThrow();
|
|
176
|
+
expect(s.create).not.toHaveBeenCalled();
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
it("does not execute changed prepared parameters or component correlation", async () => {
|
|
180
|
+
const api = await contract();
|
|
181
|
+
const s = fixture();
|
|
182
|
+
const prepared = await api.prepareCheckoutSession(
|
|
183
|
+
s.provider,
|
|
184
|
+
s.component,
|
|
185
|
+
s.ctx,
|
|
186
|
+
s.options,
|
|
187
|
+
);
|
|
188
|
+
for (const changed of [
|
|
189
|
+
{
|
|
190
|
+
...prepared,
|
|
191
|
+
request: { ...prepared.request, return_url: "https://foreign.example" },
|
|
192
|
+
},
|
|
193
|
+
{ ...prepared, row: { ...prepared.row, userId: "foreign" } },
|
|
194
|
+
]) {
|
|
195
|
+
await expect(
|
|
196
|
+
api.executePreparedCheckoutSession(s.provider, s.component, s.ctx, {
|
|
197
|
+
prepared: changed,
|
|
198
|
+
idempotencyKey: "same-intent",
|
|
199
|
+
}),
|
|
200
|
+
).rejects.toThrow();
|
|
201
|
+
}
|
|
202
|
+
expect(s.create).not.toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
it("retries the exact provider request after a lost component response", async () => {
|
|
205
|
+
const api = await contract();
|
|
206
|
+
const s = fixture();
|
|
207
|
+
const prepared = await api.prepareCheckoutSession(
|
|
208
|
+
s.provider,
|
|
209
|
+
s.component,
|
|
210
|
+
s.ctx,
|
|
211
|
+
s.options,
|
|
212
|
+
);
|
|
213
|
+
s.runMutation.mockRejectedValueOnce(new Error("Lost component response"));
|
|
214
|
+
const args = { prepared, idempotencyKey: "same-intent" };
|
|
215
|
+
await expect(
|
|
216
|
+
api.executePreparedCheckoutSession(s.provider, s.component, s.ctx, args),
|
|
217
|
+
).rejects.toThrow("Lost component response");
|
|
218
|
+
await api.executePreparedCheckoutSession(
|
|
219
|
+
s.provider,
|
|
220
|
+
s.component,
|
|
221
|
+
s.ctx,
|
|
222
|
+
args,
|
|
223
|
+
);
|
|
224
|
+
expect(s.create.mock.calls).toEqual([
|
|
225
|
+
[prepared.request, { idempotencyKey: "same-intent" }],
|
|
226
|
+
[prepared.request, { idempotencyKey: "same-intent" }],
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("freezes fee and descriptor reads before execution even when configuration changes", async () => {
|
|
231
|
+
const api = await contract();
|
|
232
|
+
const s = fixture();
|
|
233
|
+
s.runQuery.mockImplementation(async (ref: Record<symbol, string>) =>
|
|
234
|
+
ref[Symbol.for("toReferencePath")].endsWith(
|
|
235
|
+
"core/queries/getAccountByStripeId",
|
|
236
|
+
)
|
|
237
|
+
? { statementDescriptor: "ORIGINAL" }
|
|
238
|
+
: null,
|
|
239
|
+
);
|
|
240
|
+
const opts = {
|
|
241
|
+
...s.options,
|
|
242
|
+
destinationAccountId: "acct_seller",
|
|
243
|
+
feeConfig: { percent: 10 },
|
|
244
|
+
defaultStatementDescriptorSuffix: "DEFAULT",
|
|
245
|
+
sessionOverrides: { allowed_payment_method_types: ["card"] },
|
|
246
|
+
};
|
|
247
|
+
const prepared = await api.prepareCheckoutSession(
|
|
248
|
+
s.provider,
|
|
249
|
+
s.component,
|
|
250
|
+
s.ctx,
|
|
251
|
+
opts,
|
|
252
|
+
);
|
|
253
|
+
expect(prepared.request.subscription_data).toMatchObject({
|
|
254
|
+
application_fee_percent: 10,
|
|
255
|
+
transfer_data: { destination: "acct_seller" },
|
|
256
|
+
metadata: { bsStatementDescriptor: "ORIGINAL" },
|
|
257
|
+
});
|
|
258
|
+
s.runQuery.mockClear();
|
|
259
|
+
s.runQuery.mockResolvedValue({ statementDescriptor: "CHANGED" });
|
|
260
|
+
opts.feeConfig.percent = 40;
|
|
261
|
+
opts.defaultStatementDescriptorSuffix = "CHANGED";
|
|
262
|
+
await api.executePreparedCheckoutSession(s.provider, s.component, s.ctx, {
|
|
263
|
+
prepared,
|
|
264
|
+
idempotencyKey: "frozen-defaults",
|
|
265
|
+
});
|
|
266
|
+
expect(s.create).toHaveBeenCalledWith(prepared.request, {
|
|
267
|
+
idempotencyKey: "frozen-defaults",
|
|
268
|
+
});
|
|
269
|
+
expect(
|
|
270
|
+
s.runQuery.mock.calls.some(([ref]) =>
|
|
271
|
+
ref[Symbol.for("toReferencePath")].endsWith(
|
|
272
|
+
"core/queries/getAccountByStripeId",
|
|
273
|
+
),
|
|
274
|
+
),
|
|
275
|
+
).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
it("rejects nested reserved split instructions injected by overrides", async () => {
|
|
278
|
+
const api = await contract();
|
|
279
|
+
const s = fixture();
|
|
280
|
+
s.options.sessionOverrides = {
|
|
281
|
+
subscription_data: {
|
|
282
|
+
metadata: {
|
|
283
|
+
userId: "buyer_one",
|
|
284
|
+
intentId: "intent_one",
|
|
285
|
+
episodeId: "episode_one",
|
|
286
|
+
bsChargeType: "separate",
|
|
287
|
+
bsSplit: "forged",
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
await expect(
|
|
292
|
+
api.prepareCheckoutSession(s.provider, s.component, s.ctx, s.options),
|
|
293
|
+
).rejects.toThrow("Prepared Checkout routing metadata does not match.");
|
|
294
|
+
expect(s.create).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
it("rejects executing a different key from an explicitly prepared key", async () => {
|
|
297
|
+
const api = await contract();
|
|
298
|
+
const s = fixture();
|
|
299
|
+
s.options.idempotencyKey = "original-key";
|
|
300
|
+
const prepared = await api.prepareCheckoutSession(
|
|
301
|
+
s.provider,
|
|
302
|
+
s.component,
|
|
303
|
+
s.ctx,
|
|
304
|
+
s.options,
|
|
305
|
+
);
|
|
306
|
+
await expect(
|
|
307
|
+
api.executePreparedCheckoutSession(s.provider, s.component, s.ctx, {
|
|
308
|
+
prepared,
|
|
309
|
+
idempotencyKey: "changed-key",
|
|
310
|
+
}),
|
|
311
|
+
).rejects.toThrow("Prepared Checkout idempotency key does not match.");
|
|
312
|
+
expect(s.create).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("runs the public client contract using resolved fee defaults and the actual provider boundary", async () => {
|
|
316
|
+
const { BetterStripe } = await import("../index.js");
|
|
317
|
+
const s = fixture();
|
|
318
|
+
const client = new BetterStripe(s.component as never, {
|
|
319
|
+
STRIPE_SECRET_KEY: "sk_test_fake",
|
|
320
|
+
platformFee: { percent: 10 },
|
|
321
|
+
statementDescriptorSuffix: "DOJO",
|
|
322
|
+
});
|
|
323
|
+
vi.spyOn(
|
|
324
|
+
client as unknown as { stripe: () => Stripe },
|
|
325
|
+
"stripe",
|
|
326
|
+
).mockReturnValue(s.provider);
|
|
327
|
+
const prepared = await client.checkoutRequests.prepare(s.ctx, {
|
|
328
|
+
...s.options,
|
|
329
|
+
destinationAccountId: "acct_seller",
|
|
330
|
+
sessionOverrides: { allowed_payment_method_types: ["card"] },
|
|
331
|
+
});
|
|
332
|
+
expect(prepared.request.subscription_data).toMatchObject({
|
|
333
|
+
application_fee_percent: 10,
|
|
334
|
+
metadata: { bsStatementDescriptor: "DOJO" },
|
|
335
|
+
});
|
|
336
|
+
await client.checkoutRequests.execute(s.ctx, {
|
|
337
|
+
prepared,
|
|
338
|
+
idempotencyKey: "public-client-intent",
|
|
339
|
+
});
|
|
340
|
+
expect(s.create).toHaveBeenCalledWith(prepared.request, {
|
|
341
|
+
idempotencyKey: "public-client-intent",
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it.each(["subscription", "payment"] as const)(
|
|
346
|
+
"preserves generated split routing through prepared %s execution",
|
|
347
|
+
async (mode) => {
|
|
348
|
+
const api = await contract();
|
|
349
|
+
const s = fixture();
|
|
350
|
+
const opts = {
|
|
351
|
+
...s.options,
|
|
352
|
+
mode,
|
|
353
|
+
sessionOverrides: { allowed_payment_method_types: ["card"] },
|
|
354
|
+
split: [
|
|
355
|
+
{
|
|
356
|
+
destinationAccountId: "acct_seller",
|
|
357
|
+
role: "store" as const,
|
|
358
|
+
percent: 80,
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
destinationAccountId: "acct_affiliate",
|
|
362
|
+
role: "affiliate" as const,
|
|
363
|
+
percent: 10,
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
feeConfig: { percent: 5 },
|
|
367
|
+
};
|
|
368
|
+
const prepared = await api.prepareCheckoutSession(
|
|
369
|
+
s.provider,
|
|
370
|
+
s.component,
|
|
371
|
+
s.ctx,
|
|
372
|
+
opts,
|
|
373
|
+
);
|
|
374
|
+
const nested =
|
|
375
|
+
mode === "subscription"
|
|
376
|
+
? prepared.request.subscription_data
|
|
377
|
+
: prepared.request.payment_intent_data;
|
|
378
|
+
expect(nested.metadata).toMatchObject({
|
|
379
|
+
bsChargeType: "separate",
|
|
380
|
+
bsSplit: JSON.stringify(prepared.options.split),
|
|
381
|
+
bsFeeConfig: JSON.stringify(prepared.options.feeConfig),
|
|
382
|
+
});
|
|
383
|
+
expect(nested.transfer_data).toBeUndefined();
|
|
384
|
+
await api.executePreparedCheckoutSession(s.provider, s.component, s.ctx, {
|
|
385
|
+
prepared,
|
|
386
|
+
idempotencyKey: "prepared-split",
|
|
387
|
+
});
|
|
388
|
+
expect(s.create).toHaveBeenCalledWith(prepared.request, {
|
|
389
|
+
idempotencyKey: "prepared-split",
|
|
390
|
+
});
|
|
391
|
+
expect(s.runMutation.mock.calls[0][1]).toMatchObject({
|
|
392
|
+
chargeType: "separate",
|
|
393
|
+
splitRecipients: opts.split,
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
it("retains the platform-price provider guard during prepared execution", async () => {
|
|
398
|
+
const api = await contract();
|
|
399
|
+
const s = fixture();
|
|
400
|
+
const opts = {
|
|
401
|
+
...s.options,
|
|
402
|
+
destinationAccountId: "acct_seller",
|
|
403
|
+
sessionOverrides: { allowed_payment_method_types: ["card"] },
|
|
404
|
+
};
|
|
405
|
+
const prepared = await api.prepareCheckoutSession(
|
|
406
|
+
s.provider,
|
|
407
|
+
s.component,
|
|
408
|
+
s.ctx,
|
|
409
|
+
opts,
|
|
410
|
+
);
|
|
411
|
+
s.runQuery.mockImplementation(async (ref: Record<symbol, string>) => {
|
|
412
|
+
const path = ref[Symbol.for("toReferencePath")];
|
|
413
|
+
if (path.endsWith("products/queries/getPriceByStripeId"))
|
|
414
|
+
return { stripeProductId: "prod_one" };
|
|
415
|
+
if (path.endsWith("products/queries/getProductByStripeId"))
|
|
416
|
+
return { accountId: "acct_seller" };
|
|
417
|
+
return null;
|
|
418
|
+
});
|
|
419
|
+
vi.mocked(s.provider.prices.retrieve).mockRejectedValueOnce({
|
|
420
|
+
code: "resource_missing",
|
|
421
|
+
});
|
|
422
|
+
await expect(
|
|
423
|
+
api.executePreparedCheckoutSession(s.provider, s.component, s.ctx, {
|
|
424
|
+
prepared,
|
|
425
|
+
idempotencyKey: "prepared-price-guard",
|
|
426
|
+
}),
|
|
427
|
+
).rejects.toThrow("unavailable on the platform");
|
|
428
|
+
expect(s.create).not.toHaveBeenCalled();
|
|
429
|
+
expect(s.runMutation).not.toHaveBeenCalled();
|
|
430
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
3
|
+
import { runMutationOrThrow } from "../helpers.js";
|
|
4
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
5
|
+
import { idempotencyOptions } from "../core/idempotency.js";
|
|
6
|
+
import { renderCheckoutSession } from "./checkoutRequest.js";
|
|
7
|
+
|
|
8
|
+
export type PreparedCheckoutSession = Awaited<
|
|
9
|
+
ReturnType<typeof renderCheckoutSession>
|
|
10
|
+
>;
|
|
11
|
+
function canonical(value: unknown): string {
|
|
12
|
+
return JSON.stringify(value, (_key, item: unknown) => {
|
|
13
|
+
if (typeof item === "number" && !Number.isFinite(item))
|
|
14
|
+
throw new Error("Checkout request must be serializable.");
|
|
15
|
+
if (["function", "symbol", "bigint"].includes(typeof item))
|
|
16
|
+
throw new Error("Checkout request must be serializable.");
|
|
17
|
+
if (item && typeof item === "object" && !Array.isArray(item))
|
|
18
|
+
return Object.fromEntries(
|
|
19
|
+
Object.entries(item).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
20
|
+
);
|
|
21
|
+
return item;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function snapshot<T>(value: T): T {
|
|
25
|
+
return JSON.parse(canonical(value)) as T;
|
|
26
|
+
}
|
|
27
|
+
function validateCorrelation(prepared: PreparedCheckoutSession) {
|
|
28
|
+
const { request, row, options } = prepared;
|
|
29
|
+
const nested =
|
|
30
|
+
request.mode === "subscription"
|
|
31
|
+
? request.subscription_data?.metadata
|
|
32
|
+
: request.mode === "payment"
|
|
33
|
+
? request.payment_intent_data?.metadata
|
|
34
|
+
: undefined;
|
|
35
|
+
if (
|
|
36
|
+
request.mode !== options.mode ||
|
|
37
|
+
request.customer_account !== options.accountId ||
|
|
38
|
+
request.customer_email !==
|
|
39
|
+
(options.accountId ? undefined : options.customerEmail) ||
|
|
40
|
+
request.line_items?.length !== 1 ||
|
|
41
|
+
request.line_items[0]?.price !== options.stripePriceId ||
|
|
42
|
+
canonical(request.metadata) !== canonical(row.metadata) ||
|
|
43
|
+
(request.mode !== "setup" &&
|
|
44
|
+
Object.entries(row.metadata).some(
|
|
45
|
+
([key, value]) => nested?.[key] !== value,
|
|
46
|
+
))
|
|
47
|
+
)
|
|
48
|
+
throw new Error("Prepared Checkout correlation does not match.");
|
|
49
|
+
const generated =
|
|
50
|
+
request.mode === "subscription"
|
|
51
|
+
? prepared.routingMetadata.subscription
|
|
52
|
+
: prepared.routingMetadata.payment;
|
|
53
|
+
const reserved = new Set(
|
|
54
|
+
[...Object.keys(nested ?? {}), ...Object.keys(generated ?? {})].filter(
|
|
55
|
+
(key) => key.startsWith("bs"),
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
if ([...reserved].some((key) => nested?.[key] !== generated?.[key]))
|
|
59
|
+
throw new Error("Prepared Checkout routing metadata does not match.");
|
|
60
|
+
}
|
|
61
|
+
/** Read-only preparation. Persist the whole snapshot under host-owned immutable authorization. */
|
|
62
|
+
export async function prepareCheckoutSession(
|
|
63
|
+
stripe: Stripe,
|
|
64
|
+
component: Component,
|
|
65
|
+
ctx: RunCtx,
|
|
66
|
+
opts: Parameters<typeof renderCheckoutSession>[3],
|
|
67
|
+
): Promise<PreparedCheckoutSession> {
|
|
68
|
+
const prepared = snapshot(
|
|
69
|
+
await renderCheckoutSession(stripe, component, ctx, snapshot(opts)),
|
|
70
|
+
);
|
|
71
|
+
validateCorrelation(prepared);
|
|
72
|
+
return prepared;
|
|
73
|
+
}
|
|
74
|
+
/** Execute only a trusted server snapshot. Ownership and durable request binding belong to the host. */
|
|
75
|
+
export async function executePreparedCheckoutSession(
|
|
76
|
+
stripe: Stripe,
|
|
77
|
+
component: Component,
|
|
78
|
+
ctx: RunCtx,
|
|
79
|
+
args: { prepared: PreparedCheckoutSession; idempotencyKey: string },
|
|
80
|
+
) {
|
|
81
|
+
const requestOptions = idempotencyOptions(args.idempotencyKey);
|
|
82
|
+
if (!requestOptions)
|
|
83
|
+
throw new Error("Prepared Checkout requires an explicit idempotency key.");
|
|
84
|
+
const prepared = snapshot(args.prepared);
|
|
85
|
+
if (prepared.version !== 1)
|
|
86
|
+
throw new Error("Unsupported prepared Checkout version.");
|
|
87
|
+
if (
|
|
88
|
+
prepared.options.idempotencyKey !== undefined &&
|
|
89
|
+
prepared.options.idempotencyKey !== args.idempotencyKey
|
|
90
|
+
)
|
|
91
|
+
throw new Error("Prepared Checkout idempotency key does not match.");
|
|
92
|
+
validateCorrelation(prepared);
|
|
93
|
+
const expected = snapshot(
|
|
94
|
+
await renderCheckoutSession(
|
|
95
|
+
stripe,
|
|
96
|
+
component,
|
|
97
|
+
ctx,
|
|
98
|
+
prepared.options,
|
|
99
|
+
prepared.descriptor,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
if (canonical(prepared) !== canonical(expected))
|
|
103
|
+
throw new Error(
|
|
104
|
+
"Prepared Checkout request does not match its bound context.",
|
|
105
|
+
);
|
|
106
|
+
return submitPreparedCheckout(
|
|
107
|
+
stripe,
|
|
108
|
+
component,
|
|
109
|
+
ctx,
|
|
110
|
+
prepared,
|
|
111
|
+
args.idempotencyKey,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
/** Shared legacy submission. Its input has already passed the maintained renderer's safeguards. */
|
|
115
|
+
export async function submitPreparedCheckout(
|
|
116
|
+
stripe: Stripe,
|
|
117
|
+
component: Component,
|
|
118
|
+
ctx: RunCtx,
|
|
119
|
+
prepared: PreparedCheckoutSession,
|
|
120
|
+
key?: string,
|
|
121
|
+
) {
|
|
122
|
+
const options = idempotencyOptions(key);
|
|
123
|
+
const session = options
|
|
124
|
+
? await stripe.checkout.sessions.create(prepared.request, options)
|
|
125
|
+
: await stripe.checkout.sessions.create(prepared.request);
|
|
126
|
+
await runMutationOrThrow(
|
|
127
|
+
ctx,
|
|
128
|
+
componentRef(component, "billing/mutations/upsertCheckoutSession"),
|
|
129
|
+
{
|
|
130
|
+
...prepared.row,
|
|
131
|
+
stripeSessionId: session.id,
|
|
132
|
+
mode: prepared.request.mode,
|
|
133
|
+
status: (session.status ?? "open") as "open" | "complete" | "expired",
|
|
134
|
+
clientSecret: session.client_secret ?? undefined,
|
|
135
|
+
url: session.url ?? undefined,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
return {
|
|
139
|
+
stripeSessionId: session.id,
|
|
140
|
+
clientSecret: session.client_secret ?? undefined,
|
|
141
|
+
url: session.url ?? undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { Component, RunCtx } from "./helpers.js";
|
|
3
|
+
import type { FeeOverride, PlatformFeeConfig } from "./types.js";
|
|
4
|
+
import type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
|
|
5
|
+
import {
|
|
6
|
+
prepareCheckoutSession,
|
|
7
|
+
executePreparedCheckoutSession,
|
|
8
|
+
} from "./billing/preparedCheckout.js";
|
|
9
|
+
|
|
10
|
+
/** Server-only request preparation and execution, without a browser-owned money API. */
|
|
11
|
+
export class CheckoutRequestClient {
|
|
12
|
+
constructor(
|
|
13
|
+
private component: Component,
|
|
14
|
+
private provider: () => Stripe,
|
|
15
|
+
private resolveFee: (fee?: FeeOverride) => PlatformFeeConfig | undefined,
|
|
16
|
+
private descriptor?: string,
|
|
17
|
+
) {}
|
|
18
|
+
prepare(ctx: RunCtx, opts: CreateCheckoutSessionOptions) {
|
|
19
|
+
const { fee, ...rest } = opts;
|
|
20
|
+
return prepareCheckoutSession(this.provider(), this.component, ctx, {
|
|
21
|
+
...rest,
|
|
22
|
+
feeConfig:
|
|
23
|
+
opts.destinationAccountId || opts.split?.length
|
|
24
|
+
? this.resolveFee(fee)
|
|
25
|
+
: undefined,
|
|
26
|
+
defaultStatementDescriptorSuffix: this.descriptor,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
execute(
|
|
30
|
+
ctx: RunCtx,
|
|
31
|
+
args: Parameters<typeof executePreparedCheckoutSession>[3],
|
|
32
|
+
) {
|
|
33
|
+
return executePreparedCheckoutSession(
|
|
34
|
+
this.provider(),
|
|
35
|
+
this.component,
|
|
36
|
+
ctx,
|
|
37
|
+
args,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { convexTest } from "convex-test";
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
import { expect, it, vi } from "vitest";
|
|
5
|
+
import schema from "../../component/schema.js";
|
|
6
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
7
|
+
import { PayoutPolicyClient } from "../payoutPolicy.js";
|
|
8
|
+
const modules = import.meta.glob("../../component/**/*.*s");
|
|
9
|
+
const symbol = Symbol.for("toReferencePath");
|
|
10
|
+
const component = {
|
|
11
|
+
payoutPolicy: {
|
|
12
|
+
queries: { get: { [symbol]: "betterStripe/payoutPolicy/queries/get" } },
|
|
13
|
+
},
|
|
14
|
+
} as unknown as Component;
|
|
15
|
+
it("collects, resumes and discards local account state through the public API without Stripe I/O", async () => {
|
|
16
|
+
const t = convexTest(schema, modules);
|
|
17
|
+
await t.run(async (ctx) => {
|
|
18
|
+
for (let i = 0; i < 205; i++)
|
|
19
|
+
await ctx.db.insert("transfers", {
|
|
20
|
+
stripeTransferId: `tr_${i}`,
|
|
21
|
+
destinationAccountId: "acct_one",
|
|
22
|
+
amount: 1000,
|
|
23
|
+
currency: "usd",
|
|
24
|
+
role: "store",
|
|
25
|
+
status: "paid",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
const path = (r: Record<symbol, string>) =>
|
|
29
|
+
r[symbol].replace("betterStripe/", "").replace(/\/(\w+)$/, ":$1");
|
|
30
|
+
const ctx = {
|
|
31
|
+
runQuery: (r: Record<symbol, string>, args: never) =>
|
|
32
|
+
t.query(makeFunctionReference<"query">(path(r)), args),
|
|
33
|
+
runMutation: (r: Record<symbol, string>, args: never) =>
|
|
34
|
+
t.mutation(makeFunctionReference<"mutation">(path(r)), args),
|
|
35
|
+
} as unknown as RunCtx;
|
|
36
|
+
const provider = vi.fn(() => {
|
|
37
|
+
throw Error("Unexpected provider I/O");
|
|
38
|
+
});
|
|
39
|
+
const client = new PayoutPolicyClient(component, provider);
|
|
40
|
+
const opts = {
|
|
41
|
+
accountId: "acct_one",
|
|
42
|
+
ownerKey: "owner",
|
|
43
|
+
operationKey: "read-only",
|
|
44
|
+
expectedRevision: -1,
|
|
45
|
+
currency: "usd",
|
|
46
|
+
};
|
|
47
|
+
let state = await client.collectSnapshot(ctx, opts);
|
|
48
|
+
for (
|
|
49
|
+
let i = 0;
|
|
50
|
+
i < 100 && state.phase !== "local_ready" && state.phase !== "invalidated";
|
|
51
|
+
i++
|
|
52
|
+
)
|
|
53
|
+
state = await client.collectSnapshot(ctx, {
|
|
54
|
+
...opts,
|
|
55
|
+
expectedStep: state.step,
|
|
56
|
+
});
|
|
57
|
+
expect(state).toMatchObject({
|
|
58
|
+
phase: "local_ready",
|
|
59
|
+
readOnly: true,
|
|
60
|
+
sourceCount: 205,
|
|
61
|
+
});
|
|
62
|
+
expect(state).not.toHaveProperty("amount");
|
|
63
|
+
let cleanup = await client.discardSnapshot(ctx, opts);
|
|
64
|
+
for (let i = 0; i < 100 && cleanup; i++)
|
|
65
|
+
cleanup = await client.discardSnapshot(ctx, opts);
|
|
66
|
+
expect(cleanup).toBeNull();
|
|
67
|
+
expect(provider).not.toHaveBeenCalled();
|
|
68
|
+
expect(
|
|
69
|
+
await t.run((ctx) => ctx.db.query("payoutSnapshotSources").collect()),
|
|
70
|
+
).toEqual([]);
|
|
71
|
+
expect(
|
|
72
|
+
await t.run((ctx) => ctx.db.query("transfers").collect()),
|
|
73
|
+
).toHaveLength(205);
|
|
74
|
+
});
|