@fantastic.dev/better-stripe 0.14.0 → 0.15.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 +6 -0
- package/README.md +34 -1
- package/dist/client/billing/checkoutOptions.d.ts +27 -0
- package/dist/client/billing/checkoutOptions.d.ts.map +1 -0
- package/dist/client/billing/checkoutOptions.js +2 -0
- package/dist/client/billing/checkoutOptions.js.map +1 -0
- package/dist/client/billing/intents.d.ts +32 -0
- package/dist/client/billing/intents.d.ts.map +1 -0
- package/dist/client/billing/intents.js +71 -0
- package/dist/client/billing/intents.js.map +1 -0
- package/dist/client/core/fees.d.ts +2 -0
- package/dist/client/core/fees.d.ts.map +1 -1
- package/dist/client/core/fees.js +7 -0
- package/dist/client/core/fees.js.map +1 -1
- package/dist/client/index.d.ts +15 -23
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +15 -13
- package/dist/client/index.js.map +1 -1
- package/dist/react/hooks/useConfirmPayment.d.ts +1 -1
- package/dist/react/hooks/useConfirmPayment.d.ts.map +1 -1
- package/dist/react/hooks/useConfirmPayment.js +13 -2
- package/dist/react/hooks/useConfirmPayment.js.map +1 -1
- package/dist/react/index.d.ts +2 -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/workflows/SaveCard.d.ts +27 -0
- package/dist/react/workflows/SaveCard.d.ts.map +1 -0
- package/dist/react/workflows/SaveCard.js +56 -0
- package/dist/react/workflows/SaveCard.js.map +1 -0
- package/docs/react-workflows.md +41 -0
- package/package.json +1 -1
- package/src/client/billing/checkoutOptions.ts +27 -0
- package/src/client/billing/intents.test.ts +216 -0
- package/src/client/billing/intents.ts +120 -0
- package/src/client/billing/intentsTransport.test.ts +94 -0
- package/src/client/core/fees.ts +8 -0
- package/src/client/index.ts +36 -39
- package/src/react/hooks/useConfirmPayment.test.tsx +47 -0
- package/src/react/hooks/useConfirmPayment.ts +16 -2
- package/src/react/index.ts +3 -0
- package/src/react/workflows/SaveCard.test.tsx +127 -0
- package/src/react/workflows/SaveCard.tsx +117 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { expect, it, vi } from "vitest";
|
|
2
|
+
import { BetterStripe } from "../index.js";
|
|
3
|
+
import { getStripeClient, type Component, type RunCtx } from "../helpers.js";
|
|
4
|
+
|
|
5
|
+
function fixture() {
|
|
6
|
+
const ref = { [Symbol.for("toReferencePath")]: "account" };
|
|
7
|
+
const component = {
|
|
8
|
+
core: { queries: { getAccountByStripeId: ref } },
|
|
9
|
+
} as unknown as Component;
|
|
10
|
+
const key = `sk_test_intents_${crypto.randomUUID()}`;
|
|
11
|
+
const client = new BetterStripe(component, { STRIPE_SECRET_KEY: key });
|
|
12
|
+
const sdk = getStripeClient(key);
|
|
13
|
+
const ctx = {
|
|
14
|
+
runQuery: vi
|
|
15
|
+
.fn()
|
|
16
|
+
.mockResolvedValue({ userId: "buyer", stripeAccountId: "acct_buyer" }),
|
|
17
|
+
} as unknown as RunCtx;
|
|
18
|
+
const price = vi.spyOn(sdk.prices, "retrieve").mockResolvedValue({
|
|
19
|
+
id: "price_one",
|
|
20
|
+
active: true,
|
|
21
|
+
type: "one_time",
|
|
22
|
+
unit_amount: 1200,
|
|
23
|
+
currency: "usd",
|
|
24
|
+
product: { id: "prod_one", active: true },
|
|
25
|
+
} as never);
|
|
26
|
+
const payment = vi.spyOn(sdk.paymentIntents, "create").mockResolvedValue({
|
|
27
|
+
id: "pi_one",
|
|
28
|
+
client_secret: "secret",
|
|
29
|
+
amount: 1200,
|
|
30
|
+
} as never);
|
|
31
|
+
const setup = vi.spyOn(sdk.setupIntents, "create").mockResolvedValue({
|
|
32
|
+
id: "seti_one",
|
|
33
|
+
client_secret: "setup_secret",
|
|
34
|
+
} as never);
|
|
35
|
+
const opts = {
|
|
36
|
+
userId: "buyer",
|
|
37
|
+
customerAccount: "acct_buyer",
|
|
38
|
+
idempotencyKey: "order:one",
|
|
39
|
+
};
|
|
40
|
+
return { client, ctx, price, payment, setup, opts };
|
|
41
|
+
}
|
|
42
|
+
it("creates a card-only intent with provider price economics and protected ownership metadata", async () => {
|
|
43
|
+
const { client, ctx, payment, opts } = fixture();
|
|
44
|
+
const args = {
|
|
45
|
+
...opts,
|
|
46
|
+
stripePriceId: "price_one",
|
|
47
|
+
metadata: { userId: "forged", bsSplit: "forged", planId: "plan_one" },
|
|
48
|
+
};
|
|
49
|
+
expect(await client.createPaymentIntent(ctx, args)).toEqual({
|
|
50
|
+
paymentIntentId: "pi_one",
|
|
51
|
+
clientSecret: "secret",
|
|
52
|
+
});
|
|
53
|
+
await client.createPaymentIntent(ctx, args);
|
|
54
|
+
expect(payment).toHaveBeenNthCalledWith(
|
|
55
|
+
2,
|
|
56
|
+
{
|
|
57
|
+
amount: 1200,
|
|
58
|
+
currency: "usd",
|
|
59
|
+
customer_account: "acct_buyer",
|
|
60
|
+
receipt_email: undefined,
|
|
61
|
+
allowed_payment_method_types: ["card"],
|
|
62
|
+
metadata: {
|
|
63
|
+
userId: "buyer",
|
|
64
|
+
planId: "plan_one",
|
|
65
|
+
stripePriceId: "price_one",
|
|
66
|
+
stripeProductId: "prod_one",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{ idempotencyKey: "order:one" },
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
it.each([
|
|
73
|
+
{ active: false },
|
|
74
|
+
{ type: "recurring" },
|
|
75
|
+
{ unit_amount: null },
|
|
76
|
+
{ unit_amount: 0 },
|
|
77
|
+
{ product: { id: "prod_one", active: false } },
|
|
78
|
+
])("rejects unsupported price economics %j", async (override) => {
|
|
79
|
+
const { client, ctx, price, payment, opts } = fixture();
|
|
80
|
+
price.mockResolvedValue({
|
|
81
|
+
active: true,
|
|
82
|
+
type: "one_time",
|
|
83
|
+
unit_amount: 1200,
|
|
84
|
+
currency: "usd",
|
|
85
|
+
product: { id: "prod_one", active: true },
|
|
86
|
+
...override,
|
|
87
|
+
} as never);
|
|
88
|
+
await expect(
|
|
89
|
+
client.createPaymentIntent(ctx, { ...opts, stripePriceId: "price_one" }),
|
|
90
|
+
).rejects.toThrow();
|
|
91
|
+
expect(payment).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
it("rejects another customer's account before provider writes", async () => {
|
|
94
|
+
const { client, ctx, payment, setup, opts } = fixture();
|
|
95
|
+
await expect(
|
|
96
|
+
client.createPaymentIntent(ctx, {
|
|
97
|
+
...opts,
|
|
98
|
+
userId: "attacker",
|
|
99
|
+
stripePriceId: "price_one",
|
|
100
|
+
}),
|
|
101
|
+
).rejects.toThrow(/belong/);
|
|
102
|
+
await expect(
|
|
103
|
+
client.createSetupIntent(ctx, {
|
|
104
|
+
...opts,
|
|
105
|
+
userId: "attacker",
|
|
106
|
+
usage: "off_session",
|
|
107
|
+
consent: true,
|
|
108
|
+
}),
|
|
109
|
+
).rejects.toThrow(/belong/);
|
|
110
|
+
expect(payment).not.toHaveBeenCalled();
|
|
111
|
+
expect(setup).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
it("provisions consented card setup, preserves retry key after transport failure, and returns minimal secrets", async () => {
|
|
114
|
+
const { client, ctx, setup, opts } = fixture();
|
|
115
|
+
const args = {
|
|
116
|
+
...opts,
|
|
117
|
+
usage: "off_session" as const,
|
|
118
|
+
consent: true as const,
|
|
119
|
+
};
|
|
120
|
+
setup.mockRejectedValueOnce(new Error("connection lost"));
|
|
121
|
+
await expect(client.createSetupIntent(ctx, args)).rejects.toThrow(
|
|
122
|
+
"connection lost",
|
|
123
|
+
);
|
|
124
|
+
expect(await client.createSetupIntent(ctx, args)).toEqual({
|
|
125
|
+
setupIntentId: "seti_one",
|
|
126
|
+
clientSecret: "setup_secret",
|
|
127
|
+
});
|
|
128
|
+
expect(setup).toHaveBeenNthCalledWith(
|
|
129
|
+
2,
|
|
130
|
+
{
|
|
131
|
+
customer_account: "acct_buyer",
|
|
132
|
+
usage: "off_session",
|
|
133
|
+
allowed_payment_method_types: ["card"],
|
|
134
|
+
metadata: { userId: "buyer" },
|
|
135
|
+
},
|
|
136
|
+
{ idempotencyKey: "order:one" },
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
it.each([undefined, "", "\n", "x".repeat(256)])(
|
|
140
|
+
"requires a valid explicit retry key %j",
|
|
141
|
+
async (key) => {
|
|
142
|
+
const { client, ctx, setup, payment, opts } = fixture();
|
|
143
|
+
const args = { ...opts, idempotencyKey: key as string };
|
|
144
|
+
await expect(
|
|
145
|
+
client.createSetupIntent(ctx, {
|
|
146
|
+
...args,
|
|
147
|
+
usage: "on_session",
|
|
148
|
+
consent: true,
|
|
149
|
+
}),
|
|
150
|
+
).rejects.toThrow();
|
|
151
|
+
await expect(
|
|
152
|
+
client.createPaymentIntent(ctx, { ...args, stripePriceId: "price_one" }),
|
|
153
|
+
).rejects.toThrow();
|
|
154
|
+
expect(setup).not.toHaveBeenCalled();
|
|
155
|
+
expect(payment).not.toHaveBeenCalled();
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
it("requires setup consent and a supported usage", async () => {
|
|
159
|
+
const { client, ctx, setup, opts } = fixture();
|
|
160
|
+
await expect(
|
|
161
|
+
client.createSetupIntent(ctx, {
|
|
162
|
+
...opts,
|
|
163
|
+
usage: "off_session",
|
|
164
|
+
consent: false as unknown as true,
|
|
165
|
+
}),
|
|
166
|
+
).rejects.toThrow(/consent/);
|
|
167
|
+
await expect(
|
|
168
|
+
client.createSetupIntent(ctx, {
|
|
169
|
+
...opts,
|
|
170
|
+
usage: "invalid" as "on_session",
|
|
171
|
+
consent: true,
|
|
172
|
+
}),
|
|
173
|
+
).rejects.toThrow(/usage/);
|
|
174
|
+
expect(setup).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
it("rejects missing client secrets", async () => {
|
|
177
|
+
const { client, ctx, setup, payment, opts } = fixture();
|
|
178
|
+
setup.mockResolvedValue({ id: "seti_one", client_secret: null } as never);
|
|
179
|
+
payment.mockResolvedValue({ id: "pi_one", client_secret: null } as never);
|
|
180
|
+
await expect(
|
|
181
|
+
client.createSetupIntent(ctx, {
|
|
182
|
+
...opts,
|
|
183
|
+
usage: "on_session",
|
|
184
|
+
consent: true,
|
|
185
|
+
}),
|
|
186
|
+
).rejects.toThrow(/secret/);
|
|
187
|
+
await expect(
|
|
188
|
+
client.createPaymentIntent(ctx, { ...opts, stripePriceId: "price_one" }),
|
|
189
|
+
).rejects.toThrow(/secret/);
|
|
190
|
+
});
|
|
191
|
+
it("preserves host-authorized guest purchases without inventing a customer account", async () => {
|
|
192
|
+
const { client, ctx, payment } = fixture();
|
|
193
|
+
await client.createPaymentIntent(ctx, {
|
|
194
|
+
userId: "",
|
|
195
|
+
stripePriceId: "price_one",
|
|
196
|
+
idempotencyKey: "guest:one",
|
|
197
|
+
});
|
|
198
|
+
expect(payment).toHaveBeenCalledWith(
|
|
199
|
+
expect.objectContaining({
|
|
200
|
+
customer_account: undefined,
|
|
201
|
+
metadata: expect.objectContaining({ userId: "" }),
|
|
202
|
+
}),
|
|
203
|
+
{ idempotencyKey: "guest:one" },
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
it("preserves automatic-method checkout when the host selects it", async () => {
|
|
207
|
+
const { client, ctx, payment, opts } = fixture();
|
|
208
|
+
await client.createPaymentIntent(ctx, {
|
|
209
|
+
...opts,
|
|
210
|
+
stripePriceId: "price_one",
|
|
211
|
+
paymentMethods: "automatic",
|
|
212
|
+
});
|
|
213
|
+
const request = payment.mock.calls[0][0];
|
|
214
|
+
expect(request.automatic_payment_methods).toEqual({ enabled: true });
|
|
215
|
+
expect(request.allowed_payment_method_types).toBeUndefined();
|
|
216
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
3
|
+
import { stripReservedMetadata } from "../helpers.js";
|
|
4
|
+
import { idempotencyOptions } from "../core/idempotency.js";
|
|
5
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
6
|
+
|
|
7
|
+
/** Host must derive userId from authentication and authorize the purchase. */
|
|
8
|
+
export type IntentScope = {
|
|
9
|
+
userId: string;
|
|
10
|
+
customerAccount?: string;
|
|
11
|
+
/** Persist the logical request and its immutable inputs before calling. */
|
|
12
|
+
idempotencyKey: string;
|
|
13
|
+
/** Server-selected attribution only. Never forward arbitrary browser metadata. */
|
|
14
|
+
metadata?: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
export type CreatePaymentIntentOptions = IntentScope & {
|
|
17
|
+
stripePriceId: string;
|
|
18
|
+
receiptEmail?: string;
|
|
19
|
+
/** Card-only by default; automatic preserves host-selected local payment methods. */
|
|
20
|
+
paymentMethods?: "card" | "automatic";
|
|
21
|
+
};
|
|
22
|
+
export type CreateSetupIntentOptions = IntentScope & {
|
|
23
|
+
customerAccount: string;
|
|
24
|
+
usage: "on_session" | "off_session";
|
|
25
|
+
/** Host records consent for the selected usage before provisioning. */
|
|
26
|
+
consent: true;
|
|
27
|
+
};
|
|
28
|
+
async function authorize(component: Component, ctx: RunCtx, opts: IntentScope) {
|
|
29
|
+
if (typeof opts.idempotencyKey !== "string")
|
|
30
|
+
throw new Error("An explicit idempotency key is required.");
|
|
31
|
+
const request = idempotencyOptions(opts.idempotencyKey)!;
|
|
32
|
+
if (opts.customerAccount === undefined) return request;
|
|
33
|
+
if (!opts.userId || !/^acct_[a-zA-Z0-9]+$/.test(opts.customerAccount))
|
|
34
|
+
throw new Error(
|
|
35
|
+
"An authenticated user and V2 customer account are required.",
|
|
36
|
+
);
|
|
37
|
+
const account = await ctx.runQuery(
|
|
38
|
+
componentRef(component, "core/queries/getAccountByStripeId"),
|
|
39
|
+
{ stripeAccountId: opts.customerAccount },
|
|
40
|
+
);
|
|
41
|
+
if (!account || account.userId !== opts.userId)
|
|
42
|
+
throw new Error(
|
|
43
|
+
"Customer account does not belong to the authenticated user.",
|
|
44
|
+
);
|
|
45
|
+
return request;
|
|
46
|
+
}
|
|
47
|
+
export async function createPaymentIntent(
|
|
48
|
+
stripe: Stripe,
|
|
49
|
+
component: Component,
|
|
50
|
+
ctx: RunCtx,
|
|
51
|
+
opts: CreatePaymentIntentOptions,
|
|
52
|
+
) {
|
|
53
|
+
const request = await authorize(component, ctx, opts);
|
|
54
|
+
const price = await stripe.prices.retrieve(opts.stripePriceId, {
|
|
55
|
+
expand: ["product"],
|
|
56
|
+
});
|
|
57
|
+
const product = price.product;
|
|
58
|
+
if (
|
|
59
|
+
!price.active ||
|
|
60
|
+
price.type !== "one_time" ||
|
|
61
|
+
!Number.isSafeInteger(price.unit_amount) ||
|
|
62
|
+
price.unit_amount! <= 0 ||
|
|
63
|
+
typeof product === "string" ||
|
|
64
|
+
product.deleted ||
|
|
65
|
+
!product.active
|
|
66
|
+
)
|
|
67
|
+
throw new Error(
|
|
68
|
+
"Payment Intents require an active product and a positive fixed one-time price.",
|
|
69
|
+
);
|
|
70
|
+
const intent = await stripe.paymentIntents.create(
|
|
71
|
+
{
|
|
72
|
+
amount: price.unit_amount!,
|
|
73
|
+
currency: price.currency,
|
|
74
|
+
customer_account: opts.customerAccount,
|
|
75
|
+
receipt_email: opts.receiptEmail,
|
|
76
|
+
...(opts.paymentMethods === "automatic"
|
|
77
|
+
? { automatic_payment_methods: { enabled: true } }
|
|
78
|
+
: { allowed_payment_method_types: ["card" as const] }),
|
|
79
|
+
metadata: {
|
|
80
|
+
...stripReservedMetadata(opts.metadata),
|
|
81
|
+
userId: opts.userId,
|
|
82
|
+
stripePriceId: price.id,
|
|
83
|
+
stripeProductId: product.id,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
request,
|
|
87
|
+
);
|
|
88
|
+
if (!intent.client_secret)
|
|
89
|
+
throw new Error("Stripe did not return a payment secret.");
|
|
90
|
+
return { paymentIntentId: intent.id, clientSecret: intent.client_secret };
|
|
91
|
+
}
|
|
92
|
+
export async function createSetupIntent(
|
|
93
|
+
stripe: Stripe,
|
|
94
|
+
component: Component,
|
|
95
|
+
ctx: RunCtx,
|
|
96
|
+
opts: CreateSetupIntentOptions,
|
|
97
|
+
) {
|
|
98
|
+
if (!opts.customerAccount)
|
|
99
|
+
throw new Error("A customer account is required for setup.");
|
|
100
|
+
const request = await authorize(component, ctx, opts);
|
|
101
|
+
if (opts.consent !== true)
|
|
102
|
+
throw new Error("Explicit saved-card consent is required.");
|
|
103
|
+
if (opts.usage !== "on_session" && opts.usage !== "off_session")
|
|
104
|
+
throw new Error("Unsupported SetupIntent usage.");
|
|
105
|
+
const intent = await stripe.setupIntents.create(
|
|
106
|
+
{
|
|
107
|
+
customer_account: opts.customerAccount,
|
|
108
|
+
usage: opts.usage,
|
|
109
|
+
allowed_payment_method_types: ["card"],
|
|
110
|
+
metadata: {
|
|
111
|
+
...stripReservedMetadata(opts.metadata),
|
|
112
|
+
userId: opts.userId,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
request,
|
|
116
|
+
);
|
|
117
|
+
if (!intent.client_secret)
|
|
118
|
+
throw new Error("Stripe did not return a setup secret.");
|
|
119
|
+
return { setupIntentId: intent.id, clientSecret: intent.client_secret };
|
|
120
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
it.each([undefined, "2026-09-30.preview"] as const)(
|
|
7
|
+
"sends customer-bound intents using configured Stripe version %s",
|
|
8
|
+
async (version) => {
|
|
9
|
+
const key = `sk_test_intent_transport_${crypto.randomUUID()}`;
|
|
10
|
+
const client = new BetterStripe(
|
|
11
|
+
componentsGeneric().betterStripe as unknown as Component,
|
|
12
|
+
{ STRIPE_SECRET_KEY: key, stripeApiVersion: version },
|
|
13
|
+
);
|
|
14
|
+
const sdk = getStripeClient(key, version);
|
|
15
|
+
const calls: {
|
|
16
|
+
path: string;
|
|
17
|
+
version: string;
|
|
18
|
+
key?: string;
|
|
19
|
+
body: string;
|
|
20
|
+
}[] = [];
|
|
21
|
+
(
|
|
22
|
+
sdk as unknown as { _setApiField: (name: string, value: unknown) => void }
|
|
23
|
+
)._setApiField("httpClient", {
|
|
24
|
+
getClientName: () => "intent-contract-test",
|
|
25
|
+
makeRequest: async (
|
|
26
|
+
...[, , path, , headers, body]: Parameters<
|
|
27
|
+
ReturnType<typeof Stripe.createNodeHttpClient>["makeRequest"]
|
|
28
|
+
>
|
|
29
|
+
) => {
|
|
30
|
+
calls.push({
|
|
31
|
+
path,
|
|
32
|
+
version: String(headers["Stripe-Version"]),
|
|
33
|
+
key: headers["Idempotency-Key"] as string | undefined,
|
|
34
|
+
body,
|
|
35
|
+
});
|
|
36
|
+
const data = path.startsWith("/v1/prices/")
|
|
37
|
+
? {
|
|
38
|
+
id: "price_one",
|
|
39
|
+
active: true,
|
|
40
|
+
type: "one_time",
|
|
41
|
+
unit_amount: 100,
|
|
42
|
+
currency: "usd",
|
|
43
|
+
product: { id: "prod_one", active: true },
|
|
44
|
+
}
|
|
45
|
+
: {
|
|
46
|
+
id: path.includes("setup_intents") ? "seti_one" : "pi_one",
|
|
47
|
+
client_secret: "secret",
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
getStatusCode: () => 200,
|
|
51
|
+
getHeaders: () => ({}),
|
|
52
|
+
getRawResponse: () => ({}),
|
|
53
|
+
toJSON: async () => data,
|
|
54
|
+
toStream: () => {
|
|
55
|
+
throw new Error("Unexpected stream");
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const ctx = {
|
|
61
|
+
runQuery: vi.fn().mockResolvedValue({ userId: "buyer" }),
|
|
62
|
+
} as unknown as RunCtx;
|
|
63
|
+
const scope = {
|
|
64
|
+
userId: "buyer",
|
|
65
|
+
customerAccount: "acct_buyer",
|
|
66
|
+
idempotencyKey: "save-one",
|
|
67
|
+
};
|
|
68
|
+
await client.createSetupIntent(ctx, {
|
|
69
|
+
...scope,
|
|
70
|
+
usage: "off_session",
|
|
71
|
+
consent: true,
|
|
72
|
+
});
|
|
73
|
+
await client.createPaymentIntent(ctx, {
|
|
74
|
+
...scope,
|
|
75
|
+
idempotencyKey: "pay-one",
|
|
76
|
+
stripePriceId: "price_one",
|
|
77
|
+
});
|
|
78
|
+
expect(calls.map((c) => c.version)).toEqual(
|
|
79
|
+
Array(3).fill(version ?? "2026-09-30.endive"),
|
|
80
|
+
);
|
|
81
|
+
const writes = calls.filter((c) => c.key);
|
|
82
|
+
expect(writes.map((c) => c.key)).toEqual(["save-one", "pay-one"]);
|
|
83
|
+
for (const call of writes) {
|
|
84
|
+
const params = new URLSearchParams(call.body);
|
|
85
|
+
expect(params.get("customer_account")).toBe("acct_buyer");
|
|
86
|
+
expect(params.get("allowed_payment_method_types[0]")).toBe("card");
|
|
87
|
+
expect(params.has("customer")).toBe(false);
|
|
88
|
+
}
|
|
89
|
+
expect(new URLSearchParams(writes[0].body).get("usage")).toBe(
|
|
90
|
+
"off_session",
|
|
91
|
+
);
|
|
92
|
+
expect(new URLSearchParams(writes[1].body).get("amount")).toBe("100");
|
|
93
|
+
},
|
|
94
|
+
);
|
package/src/client/core/fees.ts
CHANGED
|
@@ -273,3 +273,11 @@ export function validateSplit(recipients: SplitRecipient[]): void {
|
|
|
273
273
|
throw new Error(`split percents sum to ${percentSum}, which exceeds 100`);
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
|
+
|
|
277
|
+
/** Deep-copy a platform fee config so stored/returned values can't be mutated. */
|
|
278
|
+
export function clonePlatformFee(config: PlatformFeeConfig): PlatformFeeConfig {
|
|
279
|
+
return {
|
|
280
|
+
...config,
|
|
281
|
+
tiers: config.tiers?.map((tier) => ({ ...tier })),
|
|
282
|
+
};
|
|
283
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
|
|
2
|
+
export type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
|
|
3
|
+
import * as intentsImpl from "./billing/intents.js";
|
|
4
|
+
export type * from "./billing/intents.js";
|
|
1
5
|
import { CostRecoveryClient } from "./costRecovery.js";
|
|
2
6
|
export * from "./charge-currency.js";
|
|
3
7
|
export * from "./costRecovery.js";
|
|
@@ -88,7 +92,7 @@ import * as transfersImpl from "./connect/transfers.js";
|
|
|
88
92
|
import * as accountLinksImpl from "./core/accountLinks.js";
|
|
89
93
|
import * as accountsImpl from "./core/accounts.js";
|
|
90
94
|
import * as configImpl from "./core/config.js";
|
|
91
|
-
import
|
|
95
|
+
import * as fees from "./core/fees.js";
|
|
92
96
|
import { validateStatementDescriptorSuffix } from "./core/descriptors.js";
|
|
93
97
|
export { computeFee, computeSplit } from "./core/fees.js";
|
|
94
98
|
export type { FeeBreakdown, SplitResult, SplitTransfer } from "./core/fees.js";
|
|
@@ -204,14 +208,6 @@ export type {
|
|
|
204
208
|
|
|
205
209
|
export type BetterStripeComponent = ComponentApi;
|
|
206
210
|
|
|
207
|
-
/** Deep-copy a platform fee config so stored/returned values can't be mutated. */
|
|
208
|
-
function clonePlatformFee(config: PlatformFeeConfig): PlatformFeeConfig {
|
|
209
|
-
return {
|
|
210
|
-
...config,
|
|
211
|
-
tiers: config.tiers?.map((tier) => ({ ...tier })),
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
211
|
// BETTER STRIPE CLIENT
|
|
216
212
|
|
|
217
213
|
export class BetterStripe {
|
|
@@ -230,10 +226,10 @@ export class BetterStripe {
|
|
|
230
226
|
this._triggers = options?.triggers;
|
|
231
227
|
this._hooks = options?.hooks;
|
|
232
228
|
if (options?.platformFee !== undefined) {
|
|
233
|
-
validatePlatformFee(options.platformFee);
|
|
229
|
+
fees.validatePlatformFee(options.platformFee);
|
|
234
230
|
// Store a defensive copy so later mutation of the caller's object can't
|
|
235
231
|
// silently bypass validation or change runtime behavior.
|
|
236
|
-
this._platformFee = clonePlatformFee(options.platformFee);
|
|
232
|
+
this._platformFee = fees.clonePlatformFee(options.platformFee);
|
|
237
233
|
}
|
|
238
234
|
if (options?.statementDescriptorSuffix !== undefined) {
|
|
239
235
|
this._statementDescriptorSuffix = validateStatementDescriptorSuffix(
|
|
@@ -252,7 +248,7 @@ export class BetterStripe {
|
|
|
252
248
|
|
|
253
249
|
/** The configured default platform fee (the platform's take), if any. */
|
|
254
250
|
get platformFee(): PlatformFeeConfig | undefined {
|
|
255
|
-
return this._platformFee && clonePlatformFee(this._platformFee);
|
|
251
|
+
return this._platformFee && fees.clonePlatformFee(this._platformFee);
|
|
256
252
|
}
|
|
257
253
|
|
|
258
254
|
/**
|
|
@@ -260,8 +256,8 @@ export class BetterStripe {
|
|
|
260
256
|
* default. The fee math itself is applied at charge time (M2).
|
|
261
257
|
*/
|
|
262
258
|
resolveFee(override?: FeeOverride): PlatformFeeConfig | undefined {
|
|
263
|
-
const resolved = resolveFeeConfig(this._platformFee, override);
|
|
264
|
-
return resolved && clonePlatformFee(resolved);
|
|
259
|
+
const resolved = fees.resolveFeeConfig(this._platformFee, override);
|
|
260
|
+
return resolved && fees.clonePlatformFee(resolved);
|
|
265
261
|
}
|
|
266
262
|
|
|
267
263
|
get apiKey(): string {
|
|
@@ -657,34 +653,35 @@ export class BetterStripe {
|
|
|
657
653
|
return pricesImpl.listStripePrices(this.stripe(), ctx, opts);
|
|
658
654
|
}
|
|
659
655
|
|
|
660
|
-
|
|
656
|
+
/** Card-only one-time checkout. Host authorizes price and authenticated customer. */
|
|
657
|
+
async createPaymentIntent(
|
|
658
|
+
ctx: RunCtx,
|
|
659
|
+
opts: intentsImpl.CreatePaymentIntentOptions,
|
|
660
|
+
) {
|
|
661
|
+
return intentsImpl.createPaymentIntent(
|
|
662
|
+
this.stripe(),
|
|
663
|
+
this.component,
|
|
664
|
+
ctx,
|
|
665
|
+
opts,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
661
668
|
|
|
662
|
-
|
|
669
|
+
/** Provision consented card setup; confirmation and authentication happen in Stripe.js. */
|
|
670
|
+
async createSetupIntent(
|
|
663
671
|
ctx: RunCtx,
|
|
664
|
-
opts:
|
|
665
|
-
idempotencyKey?: string;
|
|
666
|
-
userId: string;
|
|
667
|
-
orgId?: string;
|
|
668
|
-
stripePriceId: string;
|
|
669
|
-
mode: "payment" | "subscription" | "setup";
|
|
670
|
-
uiMode?: "embedded" | "redirect" | "custom" | "form";
|
|
671
|
-
quantity?: number;
|
|
672
|
-
returnUrl: string;
|
|
673
|
-
trialDays?: number;
|
|
674
|
-
accountId?: string;
|
|
675
|
-
customerEmail?: string;
|
|
676
|
-
/** Seller/recipient connected account to route funds to (destination charge). */
|
|
677
|
-
destinationAccountId?: string;
|
|
678
|
-
/** Multiple recipients (store + affiliate[s]); >1 routes via separate charges & transfers. */
|
|
679
|
-
split?: SplitRecipient[];
|
|
680
|
-
/** Per-call platform fee override; falls back to the configured default. */
|
|
681
|
-
fee?: FeeOverride;
|
|
682
|
-
/** Charge total (minor units) for one-time payment fee computation. */
|
|
683
|
-
amount?: number;
|
|
684
|
-
metadata?: Record<string, string>;
|
|
685
|
-
sessionOverrides?: Record<string, unknown>;
|
|
686
|
-
},
|
|
672
|
+
opts: intentsImpl.CreateSetupIntentOptions,
|
|
687
673
|
) {
|
|
674
|
+
return intentsImpl.createSetupIntent(
|
|
675
|
+
this.stripe(),
|
|
676
|
+
this.component,
|
|
677
|
+
ctx,
|
|
678
|
+
opts,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// CHECKOUT METHODS
|
|
683
|
+
|
|
684
|
+
async createCheckoutSession(ctx: RunCtx, opts: CreateCheckoutSessionOptions) {
|
|
688
685
|
const { fee, ...rest } = opts;
|
|
689
686
|
return checkoutImpl.createCheckoutSession(
|
|
690
687
|
this.stripe(),
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { renderHook } from "@testing-library/react";
|
|
3
|
+
import { expect, it, vi } from "vitest";
|
|
4
|
+
const f = vi.hoisted(() => ({ confirmSetup: vi.fn(), submit: vi.fn() }));
|
|
5
|
+
vi.mock("@stripe/react-stripe-js", () => ({
|
|
6
|
+
useStripe: () => ({ confirmSetup: f.confirmSetup }),
|
|
7
|
+
useElements: () => ({ submit: f.submit }),
|
|
8
|
+
}));
|
|
9
|
+
import { useConfirmPayment } from "./useConfirmPayment.js";
|
|
10
|
+
it("submits Elements and confirms explicit redisplay permission only on succeeded setup", async () => {
|
|
11
|
+
f.submit.mockResolvedValue({});
|
|
12
|
+
f.confirmSetup.mockResolvedValue({ setupIntent: { status: "succeeded" } });
|
|
13
|
+
const { result } = renderHook(useConfirmPayment);
|
|
14
|
+
expect(
|
|
15
|
+
await result.current.confirmSetup("https://app.test", "always"),
|
|
16
|
+
).toEqual({ success: true });
|
|
17
|
+
expect(f.submit).toHaveBeenCalled();
|
|
18
|
+
expect(f.confirmSetup).toHaveBeenCalledWith({
|
|
19
|
+
elements: expect.anything(),
|
|
20
|
+
confirmParams: {
|
|
21
|
+
return_url: "https://app.test",
|
|
22
|
+
payment_method_data: { allow_redisplay: "always" },
|
|
23
|
+
},
|
|
24
|
+
redirect: "if_required",
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
it("does not turn processing or incomplete authentication into saved-card success", async () => {
|
|
28
|
+
f.submit.mockResolvedValue({});
|
|
29
|
+
f.confirmSetup.mockResolvedValue({
|
|
30
|
+
setupIntent: { status: "requires_action" },
|
|
31
|
+
});
|
|
32
|
+
const { result } = renderHook(useConfirmPayment);
|
|
33
|
+
expect(await result.current.confirmSetup()).toEqual({
|
|
34
|
+
success: false,
|
|
35
|
+
error: "Card setup is not complete.",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
it("reports validation errors before confirmation", async () => {
|
|
39
|
+
f.confirmSetup.mockClear();
|
|
40
|
+
f.submit.mockResolvedValue({ error: { message: "Invalid card" } });
|
|
41
|
+
const { result } = renderHook(useConfirmPayment);
|
|
42
|
+
expect(await result.current.confirmSetup()).toEqual({
|
|
43
|
+
success: false,
|
|
44
|
+
error: "Invalid card",
|
|
45
|
+
});
|
|
46
|
+
expect(f.confirmSetup).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
@@ -77,15 +77,27 @@ export function useConfirmPayment() {
|
|
|
77
77
|
* @param returnUrl - URL to redirect to after 3D Secure. Defaults to current page.
|
|
78
78
|
*/
|
|
79
79
|
const confirmSetup = useCallback(
|
|
80
|
-
async (
|
|
80
|
+
async (
|
|
81
|
+
returnUrl?: string,
|
|
82
|
+
allowRedisplay?: "always" | "limited" | "unspecified",
|
|
83
|
+
): Promise<StripeConfirmResult> => {
|
|
81
84
|
if (!stripe || !elements) {
|
|
82
85
|
return { success: false, error: "Stripe not initialized" };
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
const
|
|
88
|
+
const validation = await elements.submit();
|
|
89
|
+
if (validation.error)
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
error: validation.error.message ?? "Check your card details.",
|
|
93
|
+
};
|
|
94
|
+
const { error, setupIntent } = await stripe.confirmSetup({
|
|
86
95
|
elements,
|
|
87
96
|
confirmParams: {
|
|
88
97
|
return_url: returnUrl ?? window.location.href,
|
|
98
|
+
...(allowRedisplay
|
|
99
|
+
? { payment_method_data: { allow_redisplay: allowRedisplay } }
|
|
100
|
+
: {}),
|
|
89
101
|
},
|
|
90
102
|
redirect: "if_required",
|
|
91
103
|
});
|
|
@@ -96,6 +108,8 @@ export function useConfirmPayment() {
|
|
|
96
108
|
error: error.message ?? "Failed to confirm setup",
|
|
97
109
|
};
|
|
98
110
|
}
|
|
111
|
+
if (setupIntent?.status !== "succeeded")
|
|
112
|
+
return { success: false, error: "Card setup is not complete." };
|
|
99
113
|
return { success: true };
|
|
100
114
|
},
|
|
101
115
|
[stripe, elements],
|
package/src/react/index.ts
CHANGED