@fantastic.dev/better-stripe 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +267 -2
- package/dist/client/billing/balanceFunding.d.ts +36 -0
- package/dist/client/billing/balanceFunding.d.ts.map +1 -0
- package/dist/client/billing/balanceFunding.js +107 -0
- package/dist/client/billing/balanceFunding.js.map +1 -0
- package/dist/client/billing/invoicePreview.d.ts +28 -0
- package/dist/client/billing/invoicePreview.d.ts.map +1 -0
- package/dist/client/billing/invoicePreview.js +56 -0
- package/dist/client/billing/invoicePreview.js.map +1 -0
- package/dist/client/charge-currency-format.d.ts +3 -0
- package/dist/client/charge-currency-format.d.ts.map +1 -0
- package/dist/client/charge-currency-format.js +22 -0
- package/dist/client/charge-currency-format.js.map +1 -0
- package/dist/client/charge-currency.d.ts +14 -0
- package/dist/client/charge-currency.d.ts.map +1 -0
- package/dist/client/charge-currency.js +85 -0
- package/dist/client/charge-currency.js.map +1 -0
- package/dist/client/costRecovery-invoices.d.ts +38 -0
- package/dist/client/costRecovery-invoices.d.ts.map +1 -0
- package/dist/client/costRecovery-invoices.js +105 -0
- package/dist/client/costRecovery-invoices.js.map +1 -0
- package/dist/client/costRecovery.d.ts +156 -0
- package/dist/client/costRecovery.d.ts.map +1 -0
- package/dist/client/costRecovery.js +117 -0
- package/dist/client/costRecovery.js.map +1 -0
- package/dist/client/index.d.ts +28 -66
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +25 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/products/prices.d.ts +3 -2
- package/dist/client/products/prices.d.ts.map +1 -1
- package/dist/client/products/prices.js +0 -3
- package/dist/client/products/prices.js.map +1 -1
- package/dist/client/products/products.d.ts +3 -2
- package/dist/client/products/products.d.ts.map +1 -1
- package/dist/client/products/products.js +0 -3
- package/dist/client/products/products.js.map +1 -1
- package/dist/client/settlement/processingCosts.d.ts +56 -0
- package/dist/client/settlement/processingCosts.d.ts.map +1 -0
- package/dist/client/settlement/processingCosts.js +173 -0
- package/dist/client/settlement/processingCosts.js.map +1 -0
- package/dist/client/utils/webhookEndpoints.d.ts +1 -1
- package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.js +1 -0
- package/dist/client/utils/webhookEndpoints.js.map +1 -1
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +1 -0
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +322 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts +68 -0
- package/dist/component/costRecovery/claims.d.ts.map +1 -0
- package/dist/component/costRecovery/claims.js +92 -0
- package/dist/component/costRecovery/claims.js.map +1 -0
- package/dist/component/costRecovery/completion.d.ts +39 -0
- package/dist/component/costRecovery/completion.d.ts.map +1 -0
- package/dist/component/costRecovery/completion.js +69 -0
- package/dist/component/costRecovery/completion.js.map +1 -0
- package/dist/component/costRecovery/helpers.d.ts +68 -0
- package/dist/component/costRecovery/helpers.d.ts.map +1 -0
- package/dist/component/costRecovery/helpers.js +71 -0
- package/dist/component/costRecovery/helpers.js.map +1 -0
- package/dist/component/costRecovery/mutations.d.ts +210 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -0
- package/dist/component/costRecovery/mutations.js +93 -0
- package/dist/component/costRecovery/mutations.js.map +1 -0
- package/dist/component/costRecovery/queries.d.ts +108 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -0
- package/dist/component/costRecovery/queries.js +73 -0
- package/dist/component/costRecovery/queries.js.map +1 -0
- package/dist/component/costRecovery/reservations.d.ts +95 -0
- package/dist/component/costRecovery/reservations.d.ts.map +1 -0
- package/dist/component/costRecovery/reservations.js +134 -0
- package/dist/component/costRecovery/reservations.js.map +1 -0
- package/dist/component/costRecovery/schema.d.ts +382 -0
- package/dist/component/costRecovery/schema.d.ts.map +1 -0
- package/dist/component/costRecovery/schema.js +115 -0
- package/dist/component/costRecovery/schema.js.map +1 -0
- package/dist/component/schema.d.ts +135 -0
- package/dist/component/schema.js +5 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/components/PriceCard.d.ts +3 -1
- package/dist/react/components/PriceCard.d.ts.map +1 -1
- package/dist/react/components/PriceCard.js +2 -2
- package/dist/react/components/PriceCard.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/price-helpers.d.ts +4 -6
- package/dist/react/lib/price-helpers.d.ts.map +1 -1
- package/dist/react/lib/price-helpers.js +16 -9
- package/dist/react/lib/price-helpers.js.map +1 -1
- package/dist/react/workflows/Collections.d.ts.map +1 -1
- package/dist/react/workflows/Collections.js +2 -1
- package/dist/react/workflows/Collections.js.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.d.ts +4 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.js +12 -9
- package/dist/react/workflows/PlanPricingEditor.js.map +1 -1
- package/dist/react/workflows/Refunds.d.ts +14 -3
- package/dist/react/workflows/Refunds.d.ts.map +1 -1
- package/dist/react/workflows/Refunds.js +58 -16
- package/dist/react/workflows/Refunds.js.map +1 -1
- package/dist/react/workflows/index.d.ts +3 -1
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +2 -1
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/refund-preview.d.ts +16 -0
- package/dist/react/workflows/refund-preview.d.ts.map +1 -0
- package/dist/react/workflows/refund-preview.js +38 -0
- package/dist/react/workflows/refund-preview.js.map +1 -0
- package/docs/react-workflows.md +167 -1
- package/package.json +2 -2
- package/src/client/billing/balanceFunding.test.ts +257 -0
- package/src/client/billing/balanceFunding.ts +183 -0
- package/src/client/billing/balanceFundingTransport.test.ts +89 -0
- package/src/client/billing/invoicePreview.test.ts +123 -0
- package/src/client/billing/invoicePreview.ts +94 -0
- package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
- package/src/client/charge-currency-format.ts +29 -0
- package/src/client/charge-currency.test.ts +113 -0
- package/src/client/charge-currency.ts +119 -0
- package/src/client/costRecovery-invoices.test.ts +401 -0
- package/src/client/costRecovery-invoices.transport.test.ts +107 -0
- package/src/client/costRecovery-invoices.ts +144 -0
- package/src/client/costRecovery.test.ts +76 -0
- package/src/client/costRecovery.ts +254 -0
- package/src/client/index.ts +76 -75
- package/src/client/products/prices.ts +13 -11
- package/src/client/products/products.ts +10 -10
- package/src/client/settlement/processingCosts.test.ts +349 -0
- package/src/client/settlement/processingCosts.ts +315 -0
- package/src/client/settlement/processingCostsTransport.test.ts +135 -0
- package/src/client/utils/webhookEndpoints.test.ts +5 -0
- package/src/client/utils/webhookEndpoints.ts +1 -0
- package/src/client/webhooks/hooks.test.ts +9 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
- package/src/client/webhooks/processors.ts +1 -0
- package/src/component/_generated/api.ts +12 -0
- package/src/component/_generated/component.ts +407 -0
- package/src/component/cost-recovery.test.ts +344 -0
- package/src/component/costRecovery/claims.ts +119 -0
- package/src/component/costRecovery/completion.ts +92 -0
- package/src/component/costRecovery/helpers.ts +83 -0
- package/src/component/costRecovery/mutations.ts +115 -0
- package/src/component/costRecovery/queries.ts +88 -0
- package/src/component/costRecovery/reservations.ts +164 -0
- package/src/component/costRecovery/schema.ts +135 -0
- package/src/component/schema.ts +10 -0
- package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
- package/src/react/components/PriceCard.render.test.tsx +37 -0
- package/src/react/components/PriceCard.tsx +9 -1
- package/src/react/index.test.ts +5 -0
- package/src/react/index.ts +7 -0
- package/src/react/lib/price-helpers.test.ts +15 -2
- package/src/react/lib/price-helpers.ts +25 -8
- package/src/react/workflows/Billing.test.tsx +1 -1
- package/src/react/workflows/Collections.test.tsx +36 -0
- package/src/react/workflows/Collections.tsx +3 -1
- package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
- package/src/react/workflows/PlanPricingEditor.tsx +26 -10
- package/src/react/workflows/Refunds.currency.test.tsx +290 -0
- package/src/react/workflows/Refunds.tsx +115 -25
- package/src/react/workflows/index.ts +8 -1
- package/src/react/workflows/refund-preview.test.ts +65 -0
- package/src/react/workflows/refund-preview.ts +56 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
getBalanceFundingCapability,
|
|
5
|
+
prepareBalanceFunding,
|
|
6
|
+
setBalanceFunding,
|
|
7
|
+
type BalanceFundingPreparation,
|
|
8
|
+
} from "./balanceFunding.js";
|
|
9
|
+
|
|
10
|
+
const version = "2026-09-30.preview";
|
|
11
|
+
const scope = {
|
|
12
|
+
customerAccount: "acct_store",
|
|
13
|
+
stripeSubscriptionId: "sub_store",
|
|
14
|
+
};
|
|
15
|
+
const preparation: BalanceFundingPreparation = {
|
|
16
|
+
...scope,
|
|
17
|
+
paymentMethodId: "pm_balance",
|
|
18
|
+
previousPaymentMethodId: "pm_card",
|
|
19
|
+
previousPaymentMethodTypes: ["card"],
|
|
20
|
+
};
|
|
21
|
+
const raw = {
|
|
22
|
+
subscriptions: { retrieve: vi.fn(), update: vi.fn() },
|
|
23
|
+
paymentMethods: { create: vi.fn(), attach: vi.fn(), retrieve: vi.fn() },
|
|
24
|
+
};
|
|
25
|
+
const stripe = raw as unknown as Stripe;
|
|
26
|
+
function sub(
|
|
27
|
+
method: string | null = "pm_card",
|
|
28
|
+
types: string[] | null = ["card"],
|
|
29
|
+
) {
|
|
30
|
+
return {
|
|
31
|
+
id: "sub_store",
|
|
32
|
+
customer_account: "acct_store",
|
|
33
|
+
status: "active",
|
|
34
|
+
default_payment_method: method,
|
|
35
|
+
payment_settings: { payment_method_types: types },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.resetAllMocks();
|
|
40
|
+
raw.subscriptions.retrieve.mockResolvedValue(sub());
|
|
41
|
+
raw.subscriptions.update.mockResolvedValue({ id: "sub_store" });
|
|
42
|
+
raw.paymentMethods.create.mockResolvedValue({
|
|
43
|
+
id: "pm_balance",
|
|
44
|
+
type: "stripe_balance",
|
|
45
|
+
});
|
|
46
|
+
raw.paymentMethods.retrieve.mockImplementation(async (id: string) => ({
|
|
47
|
+
id,
|
|
48
|
+
type: id === "pm_balance" ? "stripe_balance" : "card",
|
|
49
|
+
customer_account: id === "pm_balance" ? null : "acct_store",
|
|
50
|
+
}));
|
|
51
|
+
raw.paymentMethods.attach.mockResolvedValue({ id: "pm_balance" });
|
|
52
|
+
});
|
|
53
|
+
const enable = (enabled = true) =>
|
|
54
|
+
setBalanceFunding(stripe, version, {
|
|
55
|
+
preparation,
|
|
56
|
+
enabled,
|
|
57
|
+
consent: enabled ? true : undefined,
|
|
58
|
+
idempotencyKey: "request-1",
|
|
59
|
+
});
|
|
60
|
+
describe("explicit balance funding", () => {
|
|
61
|
+
it("reports a required explicit preview without claiming provider eligibility", () => {
|
|
62
|
+
expect(getBalanceFundingCapability("2026-09-30.endive")).toMatchObject({
|
|
63
|
+
configured: false,
|
|
64
|
+
});
|
|
65
|
+
expect(getBalanceFundingCapability(version)).toMatchObject({
|
|
66
|
+
configured: true,
|
|
67
|
+
requiresConnectedAccount: true,
|
|
68
|
+
eligibility: "provider_validates_on_prepare",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
it("rejects GA calls and missing consent before provider requests", async () => {
|
|
72
|
+
await expect(
|
|
73
|
+
prepareBalanceFunding(stripe, "2026-09-30.endive", {
|
|
74
|
+
...scope,
|
|
75
|
+
consent: true,
|
|
76
|
+
idempotencyKey: "request-1",
|
|
77
|
+
}),
|
|
78
|
+
).rejects.toThrow(/preview/);
|
|
79
|
+
await expect(
|
|
80
|
+
setBalanceFunding(stripe, version, {
|
|
81
|
+
preparation,
|
|
82
|
+
enabled: true,
|
|
83
|
+
idempotencyKey: "request-1",
|
|
84
|
+
}),
|
|
85
|
+
).rejects.toThrow(/consent/);
|
|
86
|
+
expect(raw.subscriptions.retrieve).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
it("prepares without changing the subscription and uses stable phase keys", async () => {
|
|
89
|
+
expect(
|
|
90
|
+
await prepareBalanceFunding(stripe, version, {
|
|
91
|
+
...scope,
|
|
92
|
+
consent: true,
|
|
93
|
+
idempotencyKey: "request-1",
|
|
94
|
+
}),
|
|
95
|
+
).toEqual(preparation);
|
|
96
|
+
expect(raw.paymentMethods.create).toHaveBeenCalledWith(
|
|
97
|
+
{ type: "stripe_balance" },
|
|
98
|
+
{ idempotencyKey: "request-1:create" },
|
|
99
|
+
);
|
|
100
|
+
expect(raw.paymentMethods.attach).toHaveBeenCalledWith(
|
|
101
|
+
"pm_balance",
|
|
102
|
+
{
|
|
103
|
+
customer_account: "acct_store",
|
|
104
|
+
},
|
|
105
|
+
{ idempotencyKey: "request-1:attach" },
|
|
106
|
+
);
|
|
107
|
+
expect(raw.subscriptions.update).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
it("rejects mismatched subscription ownership before preparing or applying", async () => {
|
|
110
|
+
raw.subscriptions.retrieve.mockResolvedValue({
|
|
111
|
+
...sub(),
|
|
112
|
+
customer_account: "acct_other",
|
|
113
|
+
});
|
|
114
|
+
await expect(
|
|
115
|
+
prepareBalanceFunding(stripe, version, {
|
|
116
|
+
...scope,
|
|
117
|
+
consent: true,
|
|
118
|
+
idempotencyKey: "request-1",
|
|
119
|
+
}),
|
|
120
|
+
).rejects.toThrow(/does not belong/);
|
|
121
|
+
await expect(enable()).rejects.toThrow(/does not belong/);
|
|
122
|
+
expect(raw.paymentMethods.create).not.toHaveBeenCalled();
|
|
123
|
+
expect(raw.subscriptions.update).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
it("enables and restores the exact previous settings on opt-out", async () => {
|
|
126
|
+
raw.paymentMethods.retrieve.mockResolvedValue({
|
|
127
|
+
id: "pm_balance",
|
|
128
|
+
type: "stripe_balance",
|
|
129
|
+
customer_account: "acct_store",
|
|
130
|
+
});
|
|
131
|
+
await enable();
|
|
132
|
+
expect(raw.subscriptions.update).toHaveBeenLastCalledWith(
|
|
133
|
+
"sub_store",
|
|
134
|
+
{
|
|
135
|
+
default_payment_method: "pm_balance",
|
|
136
|
+
payment_settings: { payment_method_types: ["stripe_balance"] },
|
|
137
|
+
},
|
|
138
|
+
{ idempotencyKey: "request-1:enable" },
|
|
139
|
+
);
|
|
140
|
+
raw.subscriptions.retrieve.mockResolvedValue(
|
|
141
|
+
sub("pm_balance", ["stripe_balance"]),
|
|
142
|
+
);
|
|
143
|
+
await enable(false);
|
|
144
|
+
expect(raw.subscriptions.update).toHaveBeenLastCalledWith(
|
|
145
|
+
"sub_store",
|
|
146
|
+
{
|
|
147
|
+
default_payment_method: "pm_card",
|
|
148
|
+
payment_settings: { payment_method_types: ["card"] },
|
|
149
|
+
},
|
|
150
|
+
{ idempotencyKey: "request-1:disable" },
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
it("clears the prior default and payment types when they originally inherited customer settings", async () => {
|
|
154
|
+
raw.paymentMethods.retrieve.mockResolvedValue({
|
|
155
|
+
id: "pm_balance",
|
|
156
|
+
type: "stripe_balance",
|
|
157
|
+
customer_account: "acct_store",
|
|
158
|
+
});
|
|
159
|
+
raw.subscriptions.retrieve.mockResolvedValue(
|
|
160
|
+
sub("pm_balance", ["stripe_balance"]),
|
|
161
|
+
);
|
|
162
|
+
await setBalanceFunding(stripe, version, {
|
|
163
|
+
preparation: {
|
|
164
|
+
...preparation,
|
|
165
|
+
previousPaymentMethodId: null,
|
|
166
|
+
previousPaymentMethodTypes: null,
|
|
167
|
+
},
|
|
168
|
+
enabled: false,
|
|
169
|
+
idempotencyKey: "request-2",
|
|
170
|
+
});
|
|
171
|
+
expect(raw.subscriptions.update).toHaveBeenLastCalledWith(
|
|
172
|
+
"sub_store",
|
|
173
|
+
{
|
|
174
|
+
default_payment_method: "",
|
|
175
|
+
payment_settings: { payment_method_types: "" },
|
|
176
|
+
},
|
|
177
|
+
{ idempotencyKey: "request-2:disable" },
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
it("recovers an applied provider update after host persistence fails without overwriting the original snapshot", async () => {
|
|
181
|
+
raw.paymentMethods.retrieve.mockResolvedValue({
|
|
182
|
+
id: "pm_balance",
|
|
183
|
+
type: "stripe_balance",
|
|
184
|
+
customer_account: "acct_store",
|
|
185
|
+
});
|
|
186
|
+
raw.subscriptions.retrieve.mockResolvedValue(
|
|
187
|
+
sub("pm_balance", ["stripe_balance"]),
|
|
188
|
+
);
|
|
189
|
+
expect(await enable()).toEqual({ enabled: true });
|
|
190
|
+
expect(raw.subscriptions.update).not.toHaveBeenCalled();
|
|
191
|
+
await expect(
|
|
192
|
+
prepareBalanceFunding(stripe, version, {
|
|
193
|
+
...scope,
|
|
194
|
+
consent: true,
|
|
195
|
+
idempotencyKey: "new-prepare",
|
|
196
|
+
}),
|
|
197
|
+
).rejects.toThrow(/restoration record/);
|
|
198
|
+
});
|
|
199
|
+
it("does not overwrite external payment-source changes with a stale preparation", async () => {
|
|
200
|
+
raw.paymentMethods.retrieve.mockResolvedValue({
|
|
201
|
+
id: "pm_balance",
|
|
202
|
+
type: "stripe_balance",
|
|
203
|
+
customer_account: "acct_store",
|
|
204
|
+
});
|
|
205
|
+
raw.subscriptions.retrieve.mockResolvedValue(
|
|
206
|
+
sub("pm_external", ["card", "link"]),
|
|
207
|
+
);
|
|
208
|
+
await expect(enable()).rejects.toThrow(/changed/);
|
|
209
|
+
await expect(enable(false)).rejects.toThrow(/changed/);
|
|
210
|
+
expect(raw.subscriptions.update).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
it("rejects an unrelated balance payment method", async () => {
|
|
213
|
+
raw.paymentMethods.retrieve.mockResolvedValue({
|
|
214
|
+
id: "pm_balance",
|
|
215
|
+
type: "stripe_balance",
|
|
216
|
+
customer_account: "acct_other",
|
|
217
|
+
});
|
|
218
|
+
await expect(enable()).rejects.toThrow(/payment method/);
|
|
219
|
+
});
|
|
220
|
+
it("preserves provider preview and update errors for retry decisions", async () => {
|
|
221
|
+
const failure = Object.assign(new Error("Preview access required"), {
|
|
222
|
+
code: "parameter_invalid",
|
|
223
|
+
});
|
|
224
|
+
raw.paymentMethods.create.mockRejectedValue(failure);
|
|
225
|
+
await expect(
|
|
226
|
+
prepareBalanceFunding(stripe, version, {
|
|
227
|
+
...scope,
|
|
228
|
+
consent: true,
|
|
229
|
+
idempotencyKey: "request-1",
|
|
230
|
+
}),
|
|
231
|
+
).rejects.toBe(failure);
|
|
232
|
+
raw.paymentMethods.retrieve.mockResolvedValue({
|
|
233
|
+
id: "pm_balance",
|
|
234
|
+
type: "stripe_balance",
|
|
235
|
+
customer_account: "acct_store",
|
|
236
|
+
});
|
|
237
|
+
raw.subscriptions.update.mockRejectedValue(failure);
|
|
238
|
+
await expect(enable()).rejects.toBe(failure);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
it("requires an explicit runtime request identity and desired state for JavaScript callers", async () => {
|
|
242
|
+
await expect(
|
|
243
|
+
prepareBalanceFunding(stripe, version, {
|
|
244
|
+
...scope,
|
|
245
|
+
consent: true,
|
|
246
|
+
idempotencyKey: undefined as unknown as string,
|
|
247
|
+
}),
|
|
248
|
+
).rejects.toThrow(/required/);
|
|
249
|
+
await expect(
|
|
250
|
+
setBalanceFunding(stripe, version, {
|
|
251
|
+
preparation,
|
|
252
|
+
enabled: undefined as unknown as boolean,
|
|
253
|
+
idempotencyKey: "request-1",
|
|
254
|
+
}),
|
|
255
|
+
).rejects.toThrow(/required/);
|
|
256
|
+
expect(raw.subscriptions.retrieve).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { STRIPE_CUSTOMER_PREVIEW_API_VERSION } from "../apiVersion.js";
|
|
3
|
+
import { idempotencyOptions } from "../core/idempotency.js";
|
|
4
|
+
import { resolveOwnerAccount } from "../utils/owner.js";
|
|
5
|
+
|
|
6
|
+
export type BalanceFundingCapability = {
|
|
7
|
+
configured: boolean;
|
|
8
|
+
requiredApiVersion: typeof STRIPE_CUSTOMER_PREVIEW_API_VERSION;
|
|
9
|
+
requiresConnectedAccount: true;
|
|
10
|
+
eligibility: "provider_validates_on_prepare";
|
|
11
|
+
};
|
|
12
|
+
export function getBalanceFundingCapability(
|
|
13
|
+
version: string,
|
|
14
|
+
): BalanceFundingCapability {
|
|
15
|
+
return {
|
|
16
|
+
configured: version === STRIPE_CUSTOMER_PREVIEW_API_VERSION,
|
|
17
|
+
requiredApiVersion: STRIPE_CUSTOMER_PREVIEW_API_VERSION,
|
|
18
|
+
requiresConnectedAccount: true,
|
|
19
|
+
eligibility: "provider_validates_on_prepare",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export type BalanceFundingScope = {
|
|
23
|
+
customerAccount: string;
|
|
24
|
+
stripeSubscriptionId: string;
|
|
25
|
+
};
|
|
26
|
+
/** Persist server-side before applying; never accept this record from a browser. */
|
|
27
|
+
export type BalanceFundingPreparation = BalanceFundingScope & {
|
|
28
|
+
paymentMethodId: string;
|
|
29
|
+
previousPaymentMethodId: string | null;
|
|
30
|
+
previousPaymentMethodTypes: string[] | null;
|
|
31
|
+
};
|
|
32
|
+
export type PrepareBalanceFundingOptions = BalanceFundingScope & {
|
|
33
|
+
consent: true;
|
|
34
|
+
idempotencyKey: string;
|
|
35
|
+
};
|
|
36
|
+
export type SetBalanceFundingOptions = {
|
|
37
|
+
preparation: BalanceFundingPreparation;
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
consent?: true;
|
|
40
|
+
idempotencyKey: string;
|
|
41
|
+
};
|
|
42
|
+
function request(version: string, key: string, phase: string) {
|
|
43
|
+
if (!getBalanceFundingCapability(version).configured)
|
|
44
|
+
throw new Error(
|
|
45
|
+
"Balance funding requires explicit Stripe preview API configuration.",
|
|
46
|
+
);
|
|
47
|
+
if (typeof key !== "string")
|
|
48
|
+
throw new Error(
|
|
49
|
+
"An explicit idempotency key is required for balance funding.",
|
|
50
|
+
);
|
|
51
|
+
idempotencyOptions(key);
|
|
52
|
+
return idempotencyOptions(`${key}:${phase}`)!;
|
|
53
|
+
}
|
|
54
|
+
async function subscription(stripe: Stripe, scope: BalanceFundingScope) {
|
|
55
|
+
if (!scope.customerAccount.startsWith("acct_") || !scope.stripeSubscriptionId)
|
|
56
|
+
throw new Error("A customer account and subscription are required.");
|
|
57
|
+
const sub = await stripe.subscriptions.retrieve(scope.stripeSubscriptionId);
|
|
58
|
+
if (resolveOwnerAccount(sub) !== scope.customerAccount)
|
|
59
|
+
throw new Error("Subscription does not belong to the customer account.");
|
|
60
|
+
if (!["active", "past_due", "trialing"].includes(sub.status))
|
|
61
|
+
throw new Error("This subscription cannot change payment sources.");
|
|
62
|
+
return sub;
|
|
63
|
+
}
|
|
64
|
+
function methodId(sub: Stripe.Subscription) {
|
|
65
|
+
const pm = sub.default_payment_method;
|
|
66
|
+
return typeof pm === "string" ? pm : (pm?.id ?? null);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Capture restoration settings, provision and attach; does not enable billing. */
|
|
70
|
+
export async function prepareBalanceFunding(
|
|
71
|
+
stripe: Stripe,
|
|
72
|
+
version: string,
|
|
73
|
+
opts: PrepareBalanceFundingOptions,
|
|
74
|
+
): Promise<BalanceFundingPreparation> {
|
|
75
|
+
const createOptions = request(version, opts.idempotencyKey, "create");
|
|
76
|
+
const attachOptions = request(version, opts.idempotencyKey, "attach");
|
|
77
|
+
if (opts.consent !== true)
|
|
78
|
+
throw new Error("Explicit consent is required for balance funding.");
|
|
79
|
+
const sub = await subscription(stripe, opts);
|
|
80
|
+
const previous = methodId(sub);
|
|
81
|
+
const types = sub.payment_settings?.payment_method_types ?? null;
|
|
82
|
+
if (
|
|
83
|
+
types?.includes("stripe_balance") ||
|
|
84
|
+
(previous &&
|
|
85
|
+
(await stripe.paymentMethods.retrieve(previous)).type ===
|
|
86
|
+
"stripe_balance")
|
|
87
|
+
)
|
|
88
|
+
throw new Error(
|
|
89
|
+
"Balance funding is already configured; reuse the persisted restoration record.",
|
|
90
|
+
);
|
|
91
|
+
const pm = await stripe.paymentMethods.create(
|
|
92
|
+
{ type: "stripe_balance" },
|
|
93
|
+
createOptions,
|
|
94
|
+
);
|
|
95
|
+
if (pm.type !== "stripe_balance")
|
|
96
|
+
throw new Error("Stripe did not return a balance payment method.");
|
|
97
|
+
const current = await stripe.paymentMethods.retrieve(pm.id);
|
|
98
|
+
if (resolveOwnerAccount(current) === null) {
|
|
99
|
+
await stripe.paymentMethods.attach(
|
|
100
|
+
pm.id,
|
|
101
|
+
{ customer_account: opts.customerAccount },
|
|
102
|
+
attachOptions,
|
|
103
|
+
);
|
|
104
|
+
} else if (resolveOwnerAccount(current) !== opts.customerAccount) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"Balance payment method belongs to a different customer account.",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
customerAccount: opts.customerAccount,
|
|
111
|
+
stripeSubscriptionId: opts.stripeSubscriptionId,
|
|
112
|
+
paymentMethodId: pm.id,
|
|
113
|
+
previousPaymentMethodId: previous,
|
|
114
|
+
previousPaymentMethodTypes: types,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function sameTypes(a: string[] | null, b: string[] | null) {
|
|
118
|
+
return a === null || b === null
|
|
119
|
+
? a === b
|
|
120
|
+
: a.length === b.length &&
|
|
121
|
+
[...a].sort().every((type, i) => type === [...b].sort()[i]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Apply a host-persisted preparation. Host serializes changes per subscription. */
|
|
125
|
+
export async function setBalanceFunding(
|
|
126
|
+
stripe: Stripe,
|
|
127
|
+
version: string,
|
|
128
|
+
opts: SetBalanceFundingOptions,
|
|
129
|
+
): Promise<{ enabled: boolean }> {
|
|
130
|
+
if (typeof opts.enabled !== "boolean")
|
|
131
|
+
throw new Error(
|
|
132
|
+
"An explicit enabled state is required for balance funding.",
|
|
133
|
+
);
|
|
134
|
+
const requestOptions = request(
|
|
135
|
+
version,
|
|
136
|
+
opts.idempotencyKey,
|
|
137
|
+
opts.enabled ? "enable" : "disable",
|
|
138
|
+
);
|
|
139
|
+
if (opts.enabled && opts.consent !== true)
|
|
140
|
+
throw new Error("Explicit consent is required for balance funding.");
|
|
141
|
+
const prep = opts.preparation;
|
|
142
|
+
const sub = await subscription(stripe, prep);
|
|
143
|
+
const pm = await stripe.paymentMethods.retrieve(prep.paymentMethodId);
|
|
144
|
+
if (
|
|
145
|
+
pm.type !== "stripe_balance" ||
|
|
146
|
+
resolveOwnerAccount(pm) !== prep.customerAccount
|
|
147
|
+
)
|
|
148
|
+
throw new Error(
|
|
149
|
+
"Balance payment method does not belong to the customer account.",
|
|
150
|
+
);
|
|
151
|
+
const original =
|
|
152
|
+
methodId(sub) === prep.previousPaymentMethodId &&
|
|
153
|
+
sameTypes(
|
|
154
|
+
sub.payment_settings?.payment_method_types ?? null,
|
|
155
|
+
prep.previousPaymentMethodTypes,
|
|
156
|
+
);
|
|
157
|
+
const balance =
|
|
158
|
+
methodId(sub) === prep.paymentMethodId &&
|
|
159
|
+
sameTypes(sub.payment_settings?.payment_method_types ?? null, [
|
|
160
|
+
"stripe_balance",
|
|
161
|
+
]);
|
|
162
|
+
if (!original && !balance)
|
|
163
|
+
throw new Error(
|
|
164
|
+
"Subscription payment settings changed; reconcile before applying this restoration record.",
|
|
165
|
+
);
|
|
166
|
+
if ((opts.enabled && balance) || (!opts.enabled && original))
|
|
167
|
+
return { enabled: opts.enabled };
|
|
168
|
+
await stripe.subscriptions.update(
|
|
169
|
+
sub.id,
|
|
170
|
+
{
|
|
171
|
+
default_payment_method: opts.enabled
|
|
172
|
+
? prep.paymentMethodId
|
|
173
|
+
: (prep.previousPaymentMethodId ?? ""),
|
|
174
|
+
payment_settings: {
|
|
175
|
+
payment_method_types: opts.enabled
|
|
176
|
+
? ["stripe_balance"]
|
|
177
|
+
: (prep.previousPaymentMethodTypes ?? ""),
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
requestOptions,
|
|
181
|
+
);
|
|
182
|
+
return { enabled: opts.enabled };
|
|
183
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { componentsGeneric } from "convex/server";
|
|
3
|
+
import { expect, it, vi } from "vitest";
|
|
4
|
+
import { BetterStripe } from "../index.js";
|
|
5
|
+
import { getStripeClient, type Component, type RunCtx } from "../helpers.js";
|
|
6
|
+
|
|
7
|
+
it("keeps balance operations on the explicitly selected preview and serializes phase keys", async () => {
|
|
8
|
+
const key = `sk_test_balance_transport_${crypto.randomUUID()}`;
|
|
9
|
+
const version = "2026-09-30.preview";
|
|
10
|
+
const bs = new BetterStripe(
|
|
11
|
+
componentsGeneric().betterStripe as unknown as Component,
|
|
12
|
+
{
|
|
13
|
+
STRIPE_SECRET_KEY: key,
|
|
14
|
+
stripeApiVersion: version,
|
|
15
|
+
},
|
|
16
|
+
);
|
|
17
|
+
const calls: {
|
|
18
|
+
path: string;
|
|
19
|
+
version: string;
|
|
20
|
+
body: string;
|
|
21
|
+
idempotency?: string;
|
|
22
|
+
}[] = [];
|
|
23
|
+
let attached = false;
|
|
24
|
+
const sdk = getStripeClient(key, version);
|
|
25
|
+
(
|
|
26
|
+
sdk as unknown as { _setApiField: (name: string, value: unknown) => void }
|
|
27
|
+
)._setApiField("httpClient", {
|
|
28
|
+
getClientName: () => "balance-contract-test",
|
|
29
|
+
makeRequest: async (
|
|
30
|
+
...[, , path, , headers, body]: Parameters<
|
|
31
|
+
ReturnType<typeof Stripe.createNodeHttpClient>["makeRequest"]
|
|
32
|
+
>
|
|
33
|
+
) => {
|
|
34
|
+
calls.push({
|
|
35
|
+
path,
|
|
36
|
+
version: String(headers["Stripe-Version"]),
|
|
37
|
+
body: String(body),
|
|
38
|
+
idempotency: headers["Idempotency-Key"] as string | undefined,
|
|
39
|
+
});
|
|
40
|
+
let data: object;
|
|
41
|
+
if (path.includes("/subscriptions/"))
|
|
42
|
+
data = {
|
|
43
|
+
id: "sub_test",
|
|
44
|
+
status: "active",
|
|
45
|
+
customer_account: "acct_store",
|
|
46
|
+
default_payment_method: null,
|
|
47
|
+
payment_settings: { payment_method_types: null },
|
|
48
|
+
};
|
|
49
|
+
else {
|
|
50
|
+
if (path.endsWith("/attach")) attached = true;
|
|
51
|
+
data = {
|
|
52
|
+
id: "pm_balance",
|
|
53
|
+
type: "stripe_balance",
|
|
54
|
+
customer_account: attached ? "acct_store" : null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
getStatusCode: () => 200,
|
|
59
|
+
getHeaders: () => ({}),
|
|
60
|
+
getRawResponse: () => ({}),
|
|
61
|
+
toJSON: async () => data,
|
|
62
|
+
toStream: () => {
|
|
63
|
+
throw new Error("Unexpected stream");
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const ctx = { runQuery: vi.fn(), runMutation: vi.fn() } as unknown as RunCtx;
|
|
69
|
+
const prep = await bs.prepareBalanceFunding(ctx, {
|
|
70
|
+
customerAccount: "acct_store",
|
|
71
|
+
stripeSubscriptionId: "sub_test",
|
|
72
|
+
consent: true,
|
|
73
|
+
idempotencyKey: "prepare-1",
|
|
74
|
+
});
|
|
75
|
+
await bs.setBalanceFunding(ctx, {
|
|
76
|
+
preparation: prep,
|
|
77
|
+
enabled: true,
|
|
78
|
+
consent: true,
|
|
79
|
+
idempotencyKey: "enable-1",
|
|
80
|
+
});
|
|
81
|
+
expect(bs.getBalanceFundingCapability().configured).toBe(true);
|
|
82
|
+
expect(calls.every((call) => call.version === version)).toBe(true);
|
|
83
|
+
expect(
|
|
84
|
+
calls.filter((call) => call.idempotency).map((call) => call.idempotency),
|
|
85
|
+
).toEqual(["prepare-1:create", "prepare-1:attach", "enable-1:enable"]);
|
|
86
|
+
expect(calls[calls.length - 1]?.body).toContain(
|
|
87
|
+
"payment_settings[payment_method_types][0]=stripe_balance",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { getUpcomingInvoicePreview } from "./invoicePreview.js";
|
|
4
|
+
|
|
5
|
+
const opts = {
|
|
6
|
+
customerAccount: "acct_buyer",
|
|
7
|
+
stripeSubscriptionId: "sub_test",
|
|
8
|
+
};
|
|
9
|
+
const line = (id: string, amount = 100) => ({
|
|
10
|
+
id,
|
|
11
|
+
amount,
|
|
12
|
+
currency: "usd",
|
|
13
|
+
description: "Subscription",
|
|
14
|
+
period: { start: 1800000000, end: 1802592000 },
|
|
15
|
+
});
|
|
16
|
+
function fixture(hasMore = false) {
|
|
17
|
+
const raw = {
|
|
18
|
+
subscriptions: {
|
|
19
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
20
|
+
id: opts.stripeSubscriptionId,
|
|
21
|
+
customer_account: opts.customerAccount,
|
|
22
|
+
items: { data: [{ current_period_end: 1800000000 }] },
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
invoices: {
|
|
26
|
+
createPreview: vi.fn().mockResolvedValue({
|
|
27
|
+
id: "in_preview",
|
|
28
|
+
currency: "usd",
|
|
29
|
+
total: 275,
|
|
30
|
+
subtotal: 300,
|
|
31
|
+
amount_due: 275,
|
|
32
|
+
period_start: 1800000000,
|
|
33
|
+
period_end: 1802592000,
|
|
34
|
+
lines: { data: [line("il_first")], has_more: hasMore },
|
|
35
|
+
}),
|
|
36
|
+
listLineItems: vi.fn().mockImplementation(async function* () {
|
|
37
|
+
yield line("il_first");
|
|
38
|
+
yield line("il_second", 200);
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
return { raw, stripe: raw as unknown as Stripe };
|
|
43
|
+
}
|
|
44
|
+
describe("authoritative upcoming invoice preview", () => {
|
|
45
|
+
it("keeps provider totals and converts dates to milliseconds", async () => {
|
|
46
|
+
const { raw, stripe } = fixture();
|
|
47
|
+
const preview = await getUpcomingInvoicePreview(stripe, opts);
|
|
48
|
+
expect(preview).toMatchObject({
|
|
49
|
+
total: 275,
|
|
50
|
+
subtotal: 300,
|
|
51
|
+
amountDue: 275,
|
|
52
|
+
currency: "usd",
|
|
53
|
+
nextBillingAt: 1800000000000,
|
|
54
|
+
linesComplete: true,
|
|
55
|
+
periodStart: 1800000000000,
|
|
56
|
+
periodEnd: 1802592000000,
|
|
57
|
+
lines: [{ id: "il_first", amount: 100, periodStart: 1800000000000 }],
|
|
58
|
+
});
|
|
59
|
+
expect(raw.invoices.createPreview).toHaveBeenCalledWith({
|
|
60
|
+
customer_account: "acct_buyer",
|
|
61
|
+
subscription: "sub_test",
|
|
62
|
+
});
|
|
63
|
+
expect(raw.invoices.listLineItems).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
it("loads every provider line when the initial embedded page is truncated", async () => {
|
|
66
|
+
const { raw, stripe } = fixture(true);
|
|
67
|
+
expect(
|
|
68
|
+
(await getUpcomingInvoicePreview(stripe, opts)).lines.map((l) => l.id),
|
|
69
|
+
).toEqual(["il_first", "il_second"]);
|
|
70
|
+
expect(raw.invoices.listLineItems).toHaveBeenCalledWith("in_preview", {
|
|
71
|
+
limit: 100,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
it("rejects a subscription owned by a different account before creating a preview", async () => {
|
|
75
|
+
const { raw, stripe } = fixture();
|
|
76
|
+
raw.subscriptions.retrieve.mockResolvedValueOnce({
|
|
77
|
+
customer_account: "acct_other",
|
|
78
|
+
});
|
|
79
|
+
await expect(getUpcomingInvoicePreview(stripe, opts)).rejects.toThrow(
|
|
80
|
+
"does not belong",
|
|
81
|
+
);
|
|
82
|
+
expect(raw.invoices.createPreview).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
it("preserves unavailable preview errors and does not return partial pagination", async () => {
|
|
85
|
+
const { raw, stripe } = fixture(true);
|
|
86
|
+
const unavailable = Object.assign(new Error("No upcoming invoice"), {
|
|
87
|
+
code: "invoice_upcoming_none",
|
|
88
|
+
});
|
|
89
|
+
raw.invoices.createPreview.mockRejectedValueOnce(unavailable);
|
|
90
|
+
await expect(getUpcomingInvoicePreview(stripe, opts)).rejects.toBe(
|
|
91
|
+
unavailable,
|
|
92
|
+
);
|
|
93
|
+
const failedPage = new Error("Provider page unavailable");
|
|
94
|
+
raw.invoices.listLineItems.mockImplementationOnce(async function* () {
|
|
95
|
+
yield line("il_first");
|
|
96
|
+
throw failedPage;
|
|
97
|
+
});
|
|
98
|
+
await expect(getUpcomingInvoicePreview(stripe, opts)).rejects.toBe(
|
|
99
|
+
failedPage,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
it("rejects inconsistent currency or fractional provider amounts", async () => {
|
|
103
|
+
const { raw, stripe } = fixture();
|
|
104
|
+
const preview = await raw.invoices.createPreview();
|
|
105
|
+
raw.invoices.createPreview.mockResolvedValueOnce({
|
|
106
|
+
...preview,
|
|
107
|
+
total: 1.5,
|
|
108
|
+
});
|
|
109
|
+
await expect(getUpcomingInvoicePreview(stripe, opts)).rejects.toThrow(
|
|
110
|
+
"integer",
|
|
111
|
+
);
|
|
112
|
+
raw.invoices.createPreview.mockResolvedValueOnce({
|
|
113
|
+
...preview,
|
|
114
|
+
lines: {
|
|
115
|
+
data: [{ ...line("il_bad"), currency: "eur" }],
|
|
116
|
+
has_more: false,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
await expect(getUpcomingInvoicePreview(stripe, opts)).rejects.toThrow(
|
|
120
|
+
"currency",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|