@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +34 -1
  3. package/dist/client/billing/checkoutOptions.d.ts +27 -0
  4. package/dist/client/billing/checkoutOptions.d.ts.map +1 -0
  5. package/dist/client/billing/checkoutOptions.js +2 -0
  6. package/dist/client/billing/checkoutOptions.js.map +1 -0
  7. package/dist/client/billing/intents.d.ts +32 -0
  8. package/dist/client/billing/intents.d.ts.map +1 -0
  9. package/dist/client/billing/intents.js +71 -0
  10. package/dist/client/billing/intents.js.map +1 -0
  11. package/dist/client/core/fees.d.ts +2 -0
  12. package/dist/client/core/fees.d.ts.map +1 -1
  13. package/dist/client/core/fees.js +7 -0
  14. package/dist/client/core/fees.js.map +1 -1
  15. package/dist/client/index.d.ts +15 -23
  16. package/dist/client/index.d.ts.map +1 -1
  17. package/dist/client/index.js +15 -13
  18. package/dist/client/index.js.map +1 -1
  19. package/dist/react/hooks/useConfirmPayment.d.ts +1 -1
  20. package/dist/react/hooks/useConfirmPayment.d.ts.map +1 -1
  21. package/dist/react/hooks/useConfirmPayment.js +13 -2
  22. package/dist/react/hooks/useConfirmPayment.js.map +1 -1
  23. package/dist/react/index.d.ts +2 -0
  24. package/dist/react/index.d.ts.map +1 -1
  25. package/dist/react/index.js +1 -0
  26. package/dist/react/index.js.map +1 -1
  27. package/dist/react/workflows/SaveCard.d.ts +27 -0
  28. package/dist/react/workflows/SaveCard.d.ts.map +1 -0
  29. package/dist/react/workflows/SaveCard.js +56 -0
  30. package/dist/react/workflows/SaveCard.js.map +1 -0
  31. package/docs/react-workflows.md +41 -0
  32. package/package.json +1 -1
  33. package/src/client/billing/checkoutOptions.ts +27 -0
  34. package/src/client/billing/intents.test.ts +216 -0
  35. package/src/client/billing/intents.ts +120 -0
  36. package/src/client/billing/intentsTransport.test.ts +94 -0
  37. package/src/client/core/fees.ts +8 -0
  38. package/src/client/index.ts +36 -39
  39. package/src/react/hooks/useConfirmPayment.test.tsx +47 -0
  40. package/src/react/hooks/useConfirmPayment.ts +16 -2
  41. package/src/react/index.ts +3 -0
  42. package/src/react/workflows/SaveCard.test.tsx +127 -0
  43. 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
+ );
@@ -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
+ }
@@ -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 { resolveFeeConfig, validatePlatformFee } from "./core/fees.js";
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
- // CHECKOUT METHODS
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
- async createCheckoutSession(
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 (returnUrl?: string): Promise<StripeConfirmResult> => {
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 { error } = await stripe.confirmSetup({
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],
@@ -290,3 +290,6 @@ export {
290
290
  formatChargeAmount,
291
291
  type ChargeCurrencyMetadata,
292
292
  } from "../client/charge-currency.js";
293
+
294
+ export { SaveCard } from "./workflows/SaveCard.js";
295
+ export type { SaveCardProps, SaveCardFormState } from "./workflows/SaveCard.js";