@fantastic.dev/better-stripe 0.14.0 → 0.16.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 (138) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +6 -0
  3. package/README.md +40 -1
  4. package/dist/client/billing/checkoutOptions.d.ts +27 -0
  5. package/dist/client/billing/checkoutOptions.d.ts.map +1 -0
  6. package/dist/client/billing/checkoutOptions.js +2 -0
  7. package/dist/client/billing/checkoutOptions.js.map +1 -0
  8. package/dist/client/billing/intents.d.ts +32 -0
  9. package/dist/client/billing/intents.d.ts.map +1 -0
  10. package/dist/client/billing/intents.js +71 -0
  11. package/dist/client/billing/intents.js.map +1 -0
  12. package/dist/client/connect/payoutSnapshot.d.ts +2 -0
  13. package/dist/client/connect/payoutSnapshot.d.ts.map +1 -1
  14. package/dist/client/connect/payoutSnapshot.js +2 -0
  15. package/dist/client/connect/payoutSnapshot.js.map +1 -1
  16. package/dist/client/core/fees.d.ts +2 -0
  17. package/dist/client/core/fees.d.ts.map +1 -1
  18. package/dist/client/core/fees.js +7 -0
  19. package/dist/client/core/fees.js.map +1 -1
  20. package/dist/client/index.d.ts +18 -26
  21. package/dist/client/index.d.ts.map +1 -1
  22. package/dist/client/index.js +18 -16
  23. package/dist/client/index.js.map +1 -1
  24. package/dist/client/readContracts.d.ts +50 -0
  25. package/dist/client/readContracts.d.ts.map +1 -0
  26. package/dist/client/readContracts.js +24 -0
  27. package/dist/client/readContracts.js.map +1 -0
  28. package/dist/client/types/documents.d.ts +2 -0
  29. package/dist/client/types/documents.d.ts.map +1 -1
  30. package/dist/client/webhooks/processors.js +2 -0
  31. package/dist/client/webhooks/processors.js.map +1 -1
  32. package/dist/component/_generated/component.d.ts +177 -0
  33. package/dist/component/_generated/component.d.ts.map +1 -1
  34. package/dist/component/connect/mutations.d.ts +2 -0
  35. package/dist/component/connect/mutations.d.ts.map +1 -1
  36. package/dist/component/connect/mutations.js +8 -8
  37. package/dist/component/connect/mutations.js.map +1 -1
  38. package/dist/component/connect/queries.d.ts +102 -2
  39. package/dist/component/connect/queries.d.ts.map +1 -1
  40. package/dist/component/connect/queries.js +88 -45
  41. package/dist/component/connect/queries.js.map +1 -1
  42. package/dist/component/connect/schema.d.ts +12 -2
  43. package/dist/component/connect/schema.d.ts.map +1 -1
  44. package/dist/component/connect/schema.js +10 -1
  45. package/dist/component/connect/schema.js.map +1 -1
  46. package/dist/component/connect/validators.d.ts +13 -2
  47. package/dist/component/connect/validators.d.ts.map +1 -1
  48. package/dist/component/connect/validators.js +4 -0
  49. package/dist/component/connect/validators.js.map +1 -1
  50. package/dist/component/earnings/legacyReads.d.ts +51 -0
  51. package/dist/component/earnings/legacyReads.d.ts.map +1 -0
  52. package/dist/component/earnings/legacyReads.js +43 -0
  53. package/dist/component/earnings/legacyReads.js.map +1 -0
  54. package/dist/component/earnings/mutations.d.ts +9 -0
  55. package/dist/component/earnings/mutations.d.ts.map +1 -0
  56. package/dist/component/earnings/mutations.js +23 -0
  57. package/dist/component/earnings/mutations.js.map +1 -0
  58. package/dist/component/earnings/reads.d.ts +14 -0
  59. package/dist/component/earnings/reads.d.ts.map +1 -0
  60. package/dist/component/earnings/reads.js +28 -0
  61. package/dist/component/earnings/reads.js.map +1 -0
  62. package/dist/component/earnings/schema.d.ts +20 -0
  63. package/dist/component/earnings/schema.d.ts.map +1 -0
  64. package/dist/component/earnings/schema.js +12 -0
  65. package/dist/component/earnings/schema.js.map +1 -0
  66. package/dist/component/earnings/triggers.d.ts +1290 -0
  67. package/dist/component/earnings/triggers.d.ts.map +1 -0
  68. package/dist/component/earnings/triggers.js +68 -0
  69. package/dist/component/earnings/triggers.js.map +1 -0
  70. package/dist/component/products/queries.d.ts +47 -0
  71. package/dist/component/products/queries.d.ts.map +1 -1
  72. package/dist/component/products/queries.js +59 -0
  73. package/dist/component/products/queries.js.map +1 -1
  74. package/dist/component/schema.d.ts +31 -2
  75. package/dist/component/schema.js +2 -0
  76. package/dist/component/schema.js.map +1 -1
  77. package/dist/react/components/EarningsSummary.d.ts +3 -1
  78. package/dist/react/components/EarningsSummary.d.ts.map +1 -1
  79. package/dist/react/components/EarningsSummary.js +3 -3
  80. package/dist/react/components/EarningsSummary.js.map +1 -1
  81. package/dist/react/hooks/useConfirmPayment.d.ts +1 -1
  82. package/dist/react/hooks/useConfirmPayment.d.ts.map +1 -1
  83. package/dist/react/hooks/useConfirmPayment.js +13 -2
  84. package/dist/react/hooks/useConfirmPayment.js.map +1 -1
  85. package/dist/react/hooks/useCurrencyEarnings.d.ts +31 -0
  86. package/dist/react/hooks/useCurrencyEarnings.d.ts.map +1 -0
  87. package/dist/react/hooks/useCurrencyEarnings.js +30 -0
  88. package/dist/react/hooks/useCurrencyEarnings.js.map +1 -0
  89. package/dist/react/index.d.ts +5 -0
  90. package/dist/react/index.d.ts.map +1 -1
  91. package/dist/react/index.js +4 -0
  92. package/dist/react/index.js.map +1 -1
  93. package/dist/react/workflows/SaveCard.d.ts +27 -0
  94. package/dist/react/workflows/SaveCard.d.ts.map +1 -0
  95. package/dist/react/workflows/SaveCard.js +56 -0
  96. package/dist/react/workflows/SaveCard.js.map +1 -0
  97. package/docs/react-workflows.md +47 -0
  98. package/docs/read-contracts.md +136 -0
  99. package/package.json +3 -2
  100. package/src/client/billing/checkoutOptions.ts +27 -0
  101. package/src/client/billing/intents.test.ts +216 -0
  102. package/src/client/billing/intents.ts +120 -0
  103. package/src/client/billing/intentsTransport.test.ts +94 -0
  104. package/src/client/connect/payoutSnapshot.test.ts +27 -0
  105. package/src/client/connect/payoutSnapshot.ts +4 -0
  106. package/src/client/core/fees.ts +8 -0
  107. package/src/client/index.ts +40 -43
  108. package/src/client/readContracts.ts +90 -0
  109. package/src/client/readPagination.test.ts +62 -0
  110. package/src/client/types/documents.ts +2 -0
  111. package/src/client/webhooks/payout-canceled.integration.test.ts +25 -0
  112. package/src/client/webhooks/processors.ts +2 -0
  113. package/src/component/_generated/api.ts +8 -0
  114. package/src/component/_generated/component.ts +201 -0
  115. package/src/component/connect/mutations.ts +8 -8
  116. package/src/component/connect/queries.ts +110 -48
  117. package/src/component/connect/schema.ts +10 -1
  118. package/src/component/connect/validators.ts +4 -0
  119. package/src/component/currency-migration.test.ts +119 -0
  120. package/src/component/currency-totals.test.ts +95 -0
  121. package/src/component/earnings/legacyReads.ts +59 -0
  122. package/src/component/earnings/mutations.ts +21 -0
  123. package/src/component/earnings/reads.ts +41 -0
  124. package/src/component/earnings/schema.ts +11 -0
  125. package/src/component/earnings/triggers.ts +91 -0
  126. package/src/component/products/queries.ts +74 -0
  127. package/src/component/public.test.ts +1 -1
  128. package/src/component/read-pagination.test.ts +193 -0
  129. package/src/component/schema.ts +2 -0
  130. package/src/react/components/EarningsSummary.tsx +5 -2
  131. package/src/react/hooks/useConfirmPayment.test.tsx +47 -0
  132. package/src/react/hooks/useConfirmPayment.ts +16 -2
  133. package/src/react/hooks/useCurrencyEarnings.test.ts +59 -0
  134. package/src/react/hooks/useCurrencyEarnings.ts +73 -0
  135. package/src/react/index.ts +15 -0
  136. package/src/react/read-contracts.render.test.tsx +20 -0
  137. package/src/react/workflows/SaveCard.test.tsx +127 -0
  138. package/src/react/workflows/SaveCard.tsx +117 -0
@@ -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
+ );
@@ -89,3 +89,30 @@ it("reports bounded upcoming previews and not-ready accounts explicitly", async
89
89
  reason: "account_unavailable",
90
90
  });
91
91
  });
92
+
93
+ it("exposes optional provider failure details in history", async () => {
94
+ const stripe = {
95
+ balance: { retrieve: async () => ({ available: [], pending: [] }) },
96
+ payouts: {
97
+ list: async (args: { status?: string }) => ({
98
+ data: args.status
99
+ ? []
100
+ : [
101
+ {
102
+ ...payout("po_failed", "failed"),
103
+ failure_code: "account_closed",
104
+ failure_message: "Bank account closed",
105
+ },
106
+ ],
107
+ has_more: false,
108
+ }),
109
+ },
110
+ };
111
+ const result = await getPayoutSnapshot(stripe as unknown as Stripe, {
112
+ stripeAccountId: "acct_a",
113
+ });
114
+ expect(result.payouts[0]).toMatchObject({
115
+ failureCode: "account_closed",
116
+ failureMessage: "Bank account closed",
117
+ });
118
+ });
@@ -8,6 +8,8 @@ export type RecipientPayoutRow = {
8
8
  status: string;
9
9
  arrivalDate: number | null;
10
10
  created: number;
11
+ failureCode?: string;
12
+ failureMessage?: string;
11
13
  };
12
14
  export type RecipientPayoutSnapshot = {
13
15
  balances: AccountBalance[];
@@ -31,6 +33,8 @@ const row = (p: Stripe.Payout): RecipientPayoutRow => ({
31
33
  status: p.status,
32
34
  arrivalDate: p.arrival_date ? p.arrival_date * 1000 : null,
33
35
  created: p.created * 1000,
36
+ ...(p.failure_code != null ? { failureCode: p.failure_code } : {}),
37
+ ...(p.failure_message != null ? { failureMessage: p.failure_message } : {}),
34
38
  });
35
39
  /** Account-scoped live balance and cursor history. Upcoming truncation is explicit. */
36
40
  export async function getPayoutSnapshot(
@@ -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,8 @@
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";
5
+ import { ReadContractClient } from "./readContracts.js";
1
6
  import { CostRecoveryClient } from "./costRecovery.js";
2
7
  export * from "./charge-currency.js";
3
8
  export * from "./costRecovery.js";
@@ -88,7 +93,7 @@ import * as transfersImpl from "./connect/transfers.js";
88
93
  import * as accountLinksImpl from "./core/accountLinks.js";
89
94
  import * as accountsImpl from "./core/accounts.js";
90
95
  import * as configImpl from "./core/config.js";
91
- import { resolveFeeConfig, validatePlatformFee } from "./core/fees.js";
96
+ import * as fees from "./core/fees.js";
92
97
  import { validateStatementDescriptorSuffix } from "./core/descriptors.js";
93
98
  export { computeFee, computeSplit } from "./core/fees.js";
94
99
  export type { FeeBreakdown, SplitResult, SplitTransfer } from "./core/fees.js";
@@ -103,7 +108,7 @@ export type { RefundActor } from "./connect/refunds.js";
103
108
  import type { RefundActor } from "./connect/refunds.js";
104
109
  export type { AccountBalance } from "./connect/payouts.js";
105
110
  export type { WedgedReversalClaim } from "./connect/transfers.js";
106
- import type { Component, RunCtx } from "./helpers.js";
111
+ import type { RunCtx } from "./helpers.js";
107
112
  import { getStripeClient } from "./helpers.js";
108
113
  import type {
109
114
  V2AccountRetrieveInclude,
@@ -204,18 +209,9 @@ export type {
204
209
 
205
210
  export type BetterStripeComponent = ComponentApi;
206
211
 
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
212
  // BETTER STRIPE CLIENT
216
213
 
217
- export class BetterStripe {
218
- public component: Component;
214
+ export class BetterStripe extends ReadContractClient {
219
215
  readonly stripeApiVersion: SupportedStripeApiVersion;
220
216
  private _apiKey?: string;
221
217
  private _triggers?: SyncTriggers;
@@ -224,16 +220,16 @@ export class BetterStripe {
224
220
  private _statementDescriptorSuffix?: string;
225
221
 
226
222
  constructor(component: BetterStripeComponent, options?: BetterStripeOptions) {
227
- this.component = component;
223
+ super(component);
228
224
  this.stripeApiVersion = resolveStripeApiVersion(options?.stripeApiVersion);
229
225
  this._apiKey = options?.STRIPE_SECRET_KEY;
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,90 @@
1
+ import type { ComponentApi } from "../component/_generated/component.js";
2
+ import type { PaginationOptions, PaginationResult } from "convex/server";
3
+ import type { Component, RunCtx } from "./helpers.js";
4
+ import type { PayoutStatus } from "../component/connect/validators.js";
5
+ import type {
6
+ StripeComponentPayout,
7
+ StripeComponentProduct,
8
+ StripeComponentPrice,
9
+ } from "./types/documents.js";
10
+ import { componentRef } from "./webhooks/helpers.js";
11
+ /** Shared component readers inherited by BetterStripe. */
12
+ export class ReadContractClient {
13
+ constructor(public component: Component) {}
14
+ async paginateProducts(
15
+ ctx: RunCtx,
16
+ opts: {
17
+ accountId?: string;
18
+ active?: boolean;
19
+ paginationOpts: PaginationOptions;
20
+ },
21
+ ): Promise<PaginationResult<StripeComponentProduct>> {
22
+ return ctx.runQuery(
23
+ componentRef(this.component, "products/queries/paginateProducts"),
24
+ opts,
25
+ );
26
+ }
27
+ async paginatePrices(
28
+ ctx: RunCtx,
29
+ opts: {
30
+ productId?: string;
31
+ active?: boolean;
32
+ paginationOpts: PaginationOptions;
33
+ },
34
+ ): Promise<PaginationResult<StripeComponentPrice>> {
35
+ return ctx.runQuery(
36
+ componentRef(this.component, "products/queries/paginatePrices"),
37
+ opts,
38
+ );
39
+ }
40
+ async paginatePayouts(
41
+ ctx: RunCtx,
42
+ opts: {
43
+ accountId: string;
44
+ status?: PayoutStatus;
45
+ paginationOpts: PaginationOptions;
46
+ },
47
+ ): Promise<PaginationResult<StripeComponentPayout>> {
48
+ return ctx.runQuery(
49
+ componentRef(this.component, "connect/queries/paginatePayouts"),
50
+ opts,
51
+ );
52
+ }
53
+
54
+ async getAccountEarningsByCurrency(
55
+ ctx: RunCtx,
56
+ opts: { destinationAccountId: string; previewLimit?: number },
57
+ ): Promise<{
58
+ totals: {
59
+ currency: string;
60
+ gross: number;
61
+ reversed: number;
62
+ net: number;
63
+ transferCount: number;
64
+ }[];
65
+ transfers: ComponentApi["connect"]["queries"]["getAccountEarningsByCurrency"]["_returnType"]["transfers"];
66
+ }> {
67
+ return ctx.runQuery(
68
+ componentRef(
69
+ this.component,
70
+ "connect/queries/getAccountEarningsByCurrency",
71
+ ),
72
+ opts,
73
+ );
74
+ }
75
+ async getAccountPayoutsByCurrency(
76
+ ctx: RunCtx,
77
+ opts: { accountId: string; previewLimit?: number },
78
+ ): Promise<{
79
+ totals: { currency: string; paidOut: number; payoutCount: number }[];
80
+ payouts: StripeComponentPayout[];
81
+ }> {
82
+ return ctx.runQuery(
83
+ componentRef(
84
+ this.component,
85
+ "connect/queries/getAccountPayoutsByCurrency",
86
+ ),
87
+ opts,
88
+ );
89
+ }
90
+ }
@@ -0,0 +1,62 @@
1
+ import { expect, it, vi } from "vitest";
2
+ import { BetterStripe } from "./index.js";
3
+ import { components } from "./setup.test.js";
4
+ it("forwards typed component cursors and account scope through public readers", async () => {
5
+ const bs = new BetterStripe(components.betterStripe, {
6
+ STRIPE_SECRET_KEY: "sk_test_reader_fixture",
7
+ });
8
+ const page = { page: [], isDone: true, continueCursor: "end" };
9
+ const ctx = { runQuery: vi.fn().mockResolvedValue(page) };
10
+ const paginationOpts = { numItems: 10, cursor: "opaque" };
11
+ expect(
12
+ await bs.paginatePayouts(ctx, {
13
+ accountId: "acct_a",
14
+ status: "paid",
15
+ paginationOpts,
16
+ }),
17
+ ).toBe(page);
18
+ expect(ctx.runQuery).toHaveBeenLastCalledWith(
19
+ components.betterStripe.connect.queries.paginatePayouts,
20
+ { accountId: "acct_a", status: "paid", paginationOpts },
21
+ );
22
+ expect(
23
+ await bs.paginateProducts(ctx, {
24
+ accountId: "acct_a",
25
+ active: true,
26
+ paginationOpts,
27
+ }),
28
+ ).toBe(page);
29
+ expect(ctx.runQuery).toHaveBeenLastCalledWith(
30
+ components.betterStripe.products.queries.paginateProducts,
31
+ { accountId: "acct_a", active: true, paginationOpts },
32
+ );
33
+ expect(
34
+ await bs.paginatePrices(ctx, {
35
+ productId: "prod_a",
36
+ active: false,
37
+ paginationOpts,
38
+ }),
39
+ ).toBe(page);
40
+ expect(ctx.runQuery).toHaveBeenLastCalledWith(
41
+ components.betterStripe.products.queries.paginatePrices,
42
+ { productId: "prod_a", active: false, paginationOpts },
43
+ );
44
+ });
45
+ it("forwards exact currency summary reads through the public client", async () => {
46
+ const bs = new BetterStripe(components.betterStripe);
47
+ const ctx = { runQuery: vi.fn().mockResolvedValue({ totals: [] }) };
48
+ expect(
49
+ await bs.getAccountEarningsByCurrency(ctx, {
50
+ destinationAccountId: "acct_a",
51
+ }),
52
+ ).toEqual({ totals: [] });
53
+ expect(ctx.runQuery).toHaveBeenLastCalledWith(
54
+ components.betterStripe.connect.queries.getAccountEarningsByCurrency,
55
+ { destinationAccountId: "acct_a" },
56
+ );
57
+ await bs.getAccountPayoutsByCurrency(ctx, { accountId: "acct_a" });
58
+ expect(ctx.runQuery).toHaveBeenLastCalledWith(
59
+ components.betterStripe.connect.queries.getAccountPayoutsByCurrency,
60
+ { accountId: "acct_a" },
61
+ );
62
+ });
@@ -124,6 +124,8 @@ export type StripeComponentPayout = {
124
124
  status: PayoutStatus;
125
125
  arrivalDate?: string;
126
126
  method?: string;
127
+ failureCode?: string;
128
+ failureMessage?: string;
127
129
  metadata?: Record<string, unknown>;
128
130
  };
129
131
 
@@ -190,3 +190,28 @@ it.each([
190
190
  expect(transport).not.toHaveBeenCalled();
191
191
  },
192
192
  );
193
+
194
+ it("persists signed failure details and clears them on a later snapshot", async () => {
195
+ const f = fixture("payout.failed", "failed");
196
+ f.event.data.object.failure_code = "account_closed";
197
+ f.event.data.object.failure_message = "Bank account closed";
198
+ expect((await f.deliver()).status).toBe(200);
199
+ const failed = await f.payout();
200
+ expect(failed).toMatchObject({
201
+ failureCode: "account_closed",
202
+ failureMessage: "Bank account closed",
203
+ accountId: "acct_qa_recipient",
204
+ });
205
+ expect((await f.deliver()).body).toMatchObject({ deduplicated: true });
206
+ expect(await f.payout()).toEqual(failed);
207
+ f.event.id = "evt_failure_cleared";
208
+ f.event.type = "payout.updated";
209
+ f.event.data.object.status = "pending";
210
+ f.event.data.object.failure_code = null;
211
+ f.event.data.object.failure_message = null;
212
+ expect((await f.deliver()).status).toBe(200);
213
+ const cleared = await f.payout();
214
+ expect(cleared?._id).toBe(failed?._id);
215
+ expect(cleared).not.toHaveProperty("failureCode");
216
+ expect(cleared).not.toHaveProperty("failureMessage");
217
+ });
@@ -921,6 +921,8 @@ async function handlePayoutEvent(
921
921
  "pending" | "paid" | "failed" | "canceled" | "in_transit",
922
922
  arrivalDate: epochToIso(payout.arrival_date),
923
923
  method: payout.method ?? undefined,
924
+ failureCode: payout.failure_code ?? undefined,
925
+ failureMessage: payout.failure_message ?? undefined,
924
926
  metadata: payout.metadata ?? undefined,
925
927
  });
926
928
  }
@@ -24,6 +24,10 @@ import type * as costRecovery_imports from "../costRecovery/imports.js";
24
24
  import type * as costRecovery_mutations from "../costRecovery/mutations.js";
25
25
  import type * as costRecovery_queries from "../costRecovery/queries.js";
26
26
  import type * as costRecovery_reservations from "../costRecovery/reservations.js";
27
+ import type * as earnings_legacyReads from "../earnings/legacyReads.js";
28
+ import type * as earnings_mutations from "../earnings/mutations.js";
29
+ import type * as earnings_reads from "../earnings/reads.js";
30
+ import type * as earnings_triggers from "../earnings/triggers.js";
27
31
  import type * as lib_fees from "../lib/fees.js";
28
32
  import type * as lib_idempotency from "../lib/idempotency.js";
29
33
  import type * as lib_paymentSeller from "../lib/paymentSeller.js";
@@ -62,6 +66,10 @@ const fullApi: ApiFromModules<{
62
66
  "costRecovery/mutations": typeof costRecovery_mutations;
63
67
  "costRecovery/queries": typeof costRecovery_queries;
64
68
  "costRecovery/reservations": typeof costRecovery_reservations;
69
+ "earnings/legacyReads": typeof earnings_legacyReads;
70
+ "earnings/mutations": typeof earnings_mutations;
71
+ "earnings/reads": typeof earnings_reads;
72
+ "earnings/triggers": typeof earnings_triggers;
65
73
  "lib/fees": typeof lib_fees;
66
74
  "lib/idempotency": typeof lib_idempotency;
67
75
  "lib/paymentSeller": typeof lib_paymentSeller;