@fantastic.dev/better-stripe 0.8.1 → 0.10.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 (175) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +12 -0
  3. package/README.md +46 -2
  4. package/dist/client/billing/checkout.d.ts +1 -0
  5. package/dist/client/billing/checkout.d.ts.map +1 -1
  6. package/dist/client/billing/checkout.js +5 -1
  7. package/dist/client/billing/checkout.js.map +1 -1
  8. package/dist/client/billing/providerReads.d.ts +18 -0
  9. package/dist/client/billing/providerReads.d.ts.map +1 -0
  10. package/dist/client/billing/providerReads.js +15 -0
  11. package/dist/client/billing/providerReads.js.map +1 -0
  12. package/dist/client/billing/subscriptions.d.ts +1 -1
  13. package/dist/client/billing/subscriptions.d.ts.map +1 -1
  14. package/dist/client/billing/subscriptions.js.map +1 -1
  15. package/dist/client/connect/payoutSnapshot.d.ts +28 -0
  16. package/dist/client/connect/payoutSnapshot.d.ts.map +1 -0
  17. package/dist/client/connect/payoutSnapshot.js +81 -0
  18. package/dist/client/connect/payoutSnapshot.js.map +1 -0
  19. package/dist/client/connect/transfers.d.ts.map +1 -1
  20. package/dist/client/connect/transfers.js +13 -32
  21. package/dist/client/connect/transfers.js.map +1 -1
  22. package/dist/client/core/accounts.d.ts +12 -23
  23. package/dist/client/core/accounts.d.ts.map +1 -1
  24. package/dist/client/core/accounts.js +5 -1
  25. package/dist/client/core/accounts.js.map +1 -1
  26. package/dist/client/core/idempotency.d.ts +5 -0
  27. package/dist/client/core/idempotency.d.ts.map +1 -0
  28. package/dist/client/core/idempotency.js +10 -0
  29. package/dist/client/core/idempotency.js.map +1 -0
  30. package/dist/client/index.d.ts +31 -25
  31. package/dist/client/index.d.ts.map +1 -1
  32. package/dist/client/index.js +29 -52
  33. package/dist/client/index.js.map +1 -1
  34. package/dist/client/settlement/allocation.d.ts +30 -0
  35. package/dist/client/settlement/allocation.d.ts.map +1 -0
  36. package/dist/client/settlement/allocation.js +71 -0
  37. package/dist/client/settlement/allocation.js.map +1 -0
  38. package/dist/client/settlement/commissions.d.ts +27 -0
  39. package/dist/client/settlement/commissions.d.ts.map +1 -0
  40. package/dist/client/settlement/commissions.js +69 -0
  41. package/dist/client/settlement/commissions.js.map +1 -0
  42. package/dist/client/settlement/execution.d.ts +27 -0
  43. package/dist/client/settlement/execution.d.ts.map +1 -0
  44. package/dist/client/settlement/execution.js +70 -0
  45. package/dist/client/settlement/execution.js.map +1 -0
  46. package/dist/client/settlement/reconciliation.d.ts +10 -0
  47. package/dist/client/settlement/reconciliation.d.ts.map +1 -0
  48. package/dist/client/settlement/reconciliation.js +73 -0
  49. package/dist/client/settlement/reconciliation.js.map +1 -0
  50. package/dist/client/settlement/settleSale.d.ts +21 -0
  51. package/dist/client/settlement/settleSale.d.ts.map +1 -0
  52. package/dist/client/settlement/settleSale.js +27 -0
  53. package/dist/client/settlement/settleSale.js.map +1 -0
  54. package/dist/client/settlement/verifiedSale.d.ts +25 -0
  55. package/dist/client/settlement/verifiedSale.d.ts.map +1 -0
  56. package/dist/client/settlement/verifiedSale.js +100 -0
  57. package/dist/client/settlement/verifiedSale.js.map +1 -0
  58. package/dist/client/webhooks/asyncHooks.d.ts +5 -0
  59. package/dist/client/webhooks/asyncHooks.d.ts.map +1 -0
  60. package/dist/client/webhooks/asyncHooks.js +20 -0
  61. package/dist/client/webhooks/asyncHooks.js.map +1 -0
  62. package/dist/component/_generated/component.d.ts +115 -0
  63. package/dist/component/_generated/component.d.ts.map +1 -1
  64. package/dist/component/connect/queries.d.ts +30 -1
  65. package/dist/component/connect/queries.d.ts.map +1 -1
  66. package/dist/component/connect/queries.js +19 -2
  67. package/dist/component/connect/queries.js.map +1 -1
  68. package/dist/component/schema.d.ts +67 -0
  69. package/dist/component/schema.js +4 -0
  70. package/dist/component/schema.js.map +1 -1
  71. package/dist/component/settlement/mutations.d.ts +47 -0
  72. package/dist/component/settlement/mutations.d.ts.map +1 -0
  73. package/dist/component/settlement/mutations.js +104 -0
  74. package/dist/component/settlement/mutations.js.map +1 -0
  75. package/dist/component/settlement/queries.d.ts +29 -0
  76. package/dist/component/settlement/queries.d.ts.map +1 -0
  77. package/dist/component/settlement/queries.js +35 -0
  78. package/dist/component/settlement/queries.js.map +1 -0
  79. package/dist/component/settlement/schema.d.ts +99 -0
  80. package/dist/component/settlement/schema.d.ts.map +1 -0
  81. package/dist/component/settlement/schema.js +35 -0
  82. package/dist/component/settlement/schema.js.map +1 -0
  83. package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
  84. package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
  85. package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
  86. package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
  87. package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
  88. package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
  89. package/dist/react/workflows/AffiliateRateEditor.js +49 -0
  90. package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
  91. package/dist/react/workflows/Checkout.d.ts +3 -1
  92. package/dist/react/workflows/Checkout.d.ts.map +1 -1
  93. package/dist/react/workflows/Checkout.js +3 -2
  94. package/dist/react/workflows/Checkout.js.map +1 -1
  95. package/dist/react/workflows/CheckoutForms.d.ts +2 -0
  96. package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
  97. package/dist/react/workflows/CheckoutForms.js +22 -16
  98. package/dist/react/workflows/CheckoutForms.js.map +1 -1
  99. package/dist/react/workflows/IntegrationSwitch.js +2 -2
  100. package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
  101. package/dist/react/workflows/Pagination.d.ts +32 -0
  102. package/dist/react/workflows/Pagination.d.ts.map +1 -0
  103. package/dist/react/workflows/Pagination.js +49 -0
  104. package/dist/react/workflows/Pagination.js.map +1 -0
  105. package/dist/react/workflows/Payouts.d.ts +3 -18
  106. package/dist/react/workflows/Payouts.d.ts.map +1 -1
  107. package/dist/react/workflows/Payouts.js +2 -2
  108. package/dist/react/workflows/Payouts.js.map +1 -1
  109. package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
  110. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
  111. package/dist/react/workflows/PlanPricingEditor.js +64 -0
  112. package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
  113. package/dist/react/workflows/index.d.ts +8 -0
  114. package/dist/react/workflows/index.d.ts.map +1 -1
  115. package/dist/react/workflows/index.js +4 -0
  116. package/dist/react/workflows/index.js.map +1 -1
  117. package/docs/react-workflows-audit.md +19 -0
  118. package/docs/react-workflows.md +94 -3
  119. package/docs/settlement.md +158 -0
  120. package/package.json +6 -4
  121. package/src/client/billing/checkout-idempotency.test.ts +82 -0
  122. package/src/client/billing/checkout.ts +6 -1
  123. package/src/client/billing/providerReads.test.ts +60 -0
  124. package/src/client/billing/providerReads.ts +30 -0
  125. package/src/client/billing/subscriptions.ts +1 -8
  126. package/src/client/connect/payoutSnapshot.test.ts +91 -0
  127. package/src/client/connect/payoutSnapshot.ts +130 -0
  128. package/src/client/connect/transfers.test.ts +15 -4
  129. package/src/client/connect/transfers.ts +13 -42
  130. package/src/client/core/account-idempotency.test.ts +78 -0
  131. package/src/client/core/accounts.ts +18 -25
  132. package/src/client/core/idempotency.test.ts +10 -0
  133. package/src/client/core/idempotency.ts +11 -0
  134. package/src/client/index.ts +84 -103
  135. package/src/client/settlement/allocation.test.ts +98 -0
  136. package/src/client/settlement/allocation.ts +110 -0
  137. package/src/client/settlement/commissions.test.ts +103 -0
  138. package/src/client/settlement/commissions.ts +125 -0
  139. package/src/client/settlement/execution.test.ts +186 -0
  140. package/src/client/settlement/execution.ts +134 -0
  141. package/src/client/settlement/reconciliation.ts +119 -0
  142. package/src/client/settlement/settleSale.ts +59 -0
  143. package/src/client/settlement/verifiedSale.test.ts +139 -0
  144. package/src/client/settlement/verifiedSale.ts +159 -0
  145. package/src/client/webhooks/asyncHooks.ts +38 -0
  146. package/src/client/webhooks/processors.test.ts +9 -9
  147. package/src/component/_generated/api.ts +4 -0
  148. package/src/component/_generated/component.ts +141 -0
  149. package/src/component/connect/queries.ts +26 -2
  150. package/src/component/earnings-totals.test.ts +13 -0
  151. package/src/component/schema.ts +8 -0
  152. package/src/component/settlement/mutations.ts +119 -0
  153. package/src/component/settlement/queries.ts +38 -0
  154. package/src/component/settlement/schema.ts +45 -0
  155. package/src/component/settlement-claims.test.ts +77 -0
  156. package/src/component/transfers.test.ts +48 -0
  157. package/src/react/components/EmbeddedDisputes.render.test.tsx +2 -2
  158. package/src/react/index.payment-element.test.ts +22 -37
  159. package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
  160. package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
  161. package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
  162. package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
  163. package/src/react/workflows/Checkout.test.tsx +30 -0
  164. package/src/react/workflows/Checkout.tsx +10 -3
  165. package/src/react/workflows/CheckoutForms.test.tsx +40 -0
  166. package/src/react/workflows/CheckoutForms.tsx +37 -16
  167. package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
  168. package/src/react/workflows/IntegrationSwitch.tsx +2 -2
  169. package/src/react/workflows/Pagination.test.tsx +53 -0
  170. package/src/react/workflows/Pagination.tsx +93 -0
  171. package/src/react/workflows/Payouts.test.tsx +33 -0
  172. package/src/react/workflows/Payouts.tsx +21 -15
  173. package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
  174. package/src/react/workflows/PlanPricingEditor.tsx +156 -0
  175. package/src/react/workflows/index.ts +22 -0
@@ -0,0 +1,60 @@
1
+ import type Stripe from "stripe";
2
+ import { expect, it, vi } from "vitest";
3
+ import {
4
+ getCheckoutSessionFromStripe,
5
+ getSubscriptionFromStripe,
6
+ getCouponFromStripe,
7
+ } from "./providerReads.js";
8
+ it("retrieves authoritative expanded provider objects without consulting the component cache", async () => {
9
+ const raw = {
10
+ checkout: {
11
+ sessions: {
12
+ retrieve: vi
13
+ .fn()
14
+ .mockResolvedValue({ id: "cs_1", payment_status: "paid" }),
15
+ },
16
+ },
17
+ subscriptions: {
18
+ retrieve: vi.fn().mockResolvedValue({ id: "sub_1", status: "active" }),
19
+ },
20
+ coupons: {
21
+ retrieve: vi.fn().mockResolvedValue({ id: "coupon_1", valid: true }),
22
+ },
23
+ };
24
+ const stripe = raw as unknown as Stripe;
25
+ expect(
26
+ await getCheckoutSessionFromStripe(stripe, {
27
+ stripeSessionId: "cs_1",
28
+ expand: ["subscription", "payment_intent"],
29
+ }),
30
+ ).toMatchObject({ payment_status: "paid" });
31
+ expect(raw.checkout.sessions.retrieve).toHaveBeenCalledWith("cs_1", {
32
+ expand: ["subscription", "payment_intent"],
33
+ });
34
+ await getSubscriptionFromStripe(stripe, {
35
+ stripeSubscriptionId: "sub_1",
36
+ expand: ["latest_invoice"],
37
+ });
38
+ expect(raw.subscriptions.retrieve).toHaveBeenCalledWith("sub_1", {
39
+ expand: ["latest_invoice"],
40
+ });
41
+ await getCouponFromStripe(stripe, { stripeCouponId: "coupon_1" });
42
+ expect(raw.coupons.retrieve).toHaveBeenCalledWith("coupon_1", {
43
+ expand: undefined,
44
+ });
45
+ });
46
+
47
+ it("preserves Stripe error identity and codes for server reconciliation", async () => {
48
+ const failure = Object.assign(new Error("No such Checkout Session"), {
49
+ code: "resource_missing",
50
+ statusCode: 404,
51
+ });
52
+ const raw = {
53
+ checkout: { sessions: { retrieve: vi.fn().mockRejectedValue(failure) } },
54
+ };
55
+ await expect(
56
+ getCheckoutSessionFromStripe(raw as unknown as Stripe, {
57
+ stripeSessionId: "cs_missing",
58
+ }),
59
+ ).rejects.toBe(failure);
60
+ });
@@ -0,0 +1,30 @@
1
+ import type Stripe from "stripe";
2
+ export type CheckoutSessionReadOptions = {
3
+ stripeSessionId: string;
4
+ expand?: string[];
5
+ };
6
+ export type SubscriptionReadOptions = {
7
+ stripeSubscriptionId: string;
8
+ expand?: string[];
9
+ };
10
+ export type CouponReadOptions = { stripeCouponId: string; expand?: string[] };
11
+ /** Authoritative Stripe objects for authorized server fulfillment/reconciliation. */
12
+ export function getCheckoutSessionFromStripe(
13
+ stripe: Stripe,
14
+ opts: CheckoutSessionReadOptions,
15
+ ) {
16
+ return stripe.checkout.sessions.retrieve(opts.stripeSessionId, {
17
+ expand: opts.expand,
18
+ });
19
+ }
20
+ export function getSubscriptionFromStripe(
21
+ stripe: Stripe,
22
+ opts: SubscriptionReadOptions,
23
+ ) {
24
+ return stripe.subscriptions.retrieve(opts.stripeSubscriptionId, {
25
+ expand: opts.expand,
26
+ });
27
+ }
28
+ export function getCouponFromStripe(stripe: Stripe, opts: CouponReadOptions) {
29
+ return stripe.coupons.retrieve(opts.stripeCouponId, { expand: opts.expand });
30
+ }
@@ -511,14 +511,7 @@ export async function upsertSubscription(
511
511
  accountId?: string;
512
512
  userId: string;
513
513
  orgId?: string;
514
- status:
515
- | "active"
516
- | "trialing"
517
- | "past_due"
518
- | "canceled"
519
- | "incomplete"
520
- | "unpaid"
521
- | "paused";
514
+ status: SubscriptionStatus;
522
515
  priceId?: string;
523
516
  quantity?: number;
524
517
  currentPeriodStart?: string;
@@ -0,0 +1,91 @@
1
+ import type Stripe from "stripe";
2
+ import { expect, it, vi } from "vitest";
3
+ import { getPayoutSnapshot } from "./payoutSnapshot.js";
4
+ const payout = (id: string, status = "paid") => ({
5
+ id,
6
+ status,
7
+ amount: 1000,
8
+ currency: "usd",
9
+ arrival_date: 0,
10
+ created: 1,
11
+ });
12
+ it("scopes every read and uses a real cursor for history while completing upcoming pages", async () => {
13
+ const list = vi.fn(
14
+ async (
15
+ args: { status?: string; starting_after?: string },
16
+ _options: { stripeAccount: string },
17
+ ) => {
18
+ void _options;
19
+ if (args.status === "pending")
20
+ return args.starting_after
21
+ ? { data: [payout("po_2", "pending")], has_more: false }
22
+ : { data: [payout("po_1", "pending")], has_more: true };
23
+ if (args.status === "in_transit")
24
+ return { data: [payout("po_1", "pending")], has_more: false };
25
+ return { data: [payout("po_history")], has_more: true };
26
+ },
27
+ );
28
+ const raw = {
29
+ balance: {
30
+ retrieve: vi.fn().mockResolvedValue({
31
+ available: [{ currency: "usd", amount: 10 }],
32
+ pending: [{ currency: "eur", amount: 20 }],
33
+ }),
34
+ },
35
+ payouts: { list },
36
+ };
37
+ const result = await getPayoutSnapshot(raw as unknown as Stripe, {
38
+ stripeAccountId: "acct_a",
39
+ cursor: "po_before",
40
+ });
41
+ expect(result).toMatchObject({
42
+ status: "ready",
43
+ balances: [
44
+ { currency: "usd", available: 10, pending: 0 },
45
+ { currency: "eur", available: 0, pending: 20 },
46
+ ],
47
+ nextCursor: "po_history",
48
+ upcomingComplete: true,
49
+ });
50
+ expect(result.upcoming.map((r) => r.id)).toEqual(["po_1", "po_2"]);
51
+ expect(result.upcoming[0].arrivalDate).toBeNull();
52
+ expect(list).toHaveBeenCalledWith(
53
+ expect.objectContaining({ starting_after: "po_before" }),
54
+ { stripeAccount: "acct_a" },
55
+ );
56
+ expect(
57
+ list.mock.calls.every(
58
+ (call) =>
59
+ (call[1] as { stripeAccount: string }).stripeAccount === "acct_a",
60
+ ),
61
+ ).toBe(true);
62
+ });
63
+ it("reports bounded upcoming previews and not-ready accounts explicitly", async () => {
64
+ const raw = {
65
+ balance: {
66
+ retrieve: vi.fn().mockResolvedValue({ available: [], pending: [] }),
67
+ },
68
+ payouts: {
69
+ list: vi.fn().mockResolvedValue({
70
+ data: [payout("po_1", "pending")],
71
+ has_more: true,
72
+ }),
73
+ },
74
+ };
75
+ expect(
76
+ await getPayoutSnapshot(raw as unknown as Stripe, {
77
+ stripeAccountId: "acct_a",
78
+ maxUpcomingPages: 1,
79
+ }),
80
+ ).toMatchObject({ upcomingComplete: false });
81
+ raw.balance.retrieve.mockRejectedValue({ code: "account_invalid" });
82
+ expect(
83
+ await getPayoutSnapshot(raw as unknown as Stripe, {
84
+ stripeAccountId: "acct_a",
85
+ }),
86
+ ).toMatchObject({
87
+ status: "not_ready",
88
+ balances: [],
89
+ reason: "account_unavailable",
90
+ });
91
+ });
@@ -0,0 +1,130 @@
1
+ import type Stripe from "stripe";
2
+ import { throwStripeError } from "../errors.js";
3
+ import { getAccountBalance, type AccountBalance } from "./payouts.js";
4
+ export type RecipientPayoutRow = {
5
+ id: string;
6
+ amount: number;
7
+ currency: string;
8
+ status: string;
9
+ arrivalDate: number | null;
10
+ created: number;
11
+ };
12
+ export type RecipientPayoutSnapshot = {
13
+ balances: AccountBalance[];
14
+ payouts: RecipientPayoutRow[];
15
+ upcoming: RecipientPayoutRow[];
16
+ nextCursor: string | null;
17
+ status: "ready" | "not_ready";
18
+ reason?: string;
19
+ upcomingComplete: boolean;
20
+ };
21
+ export type PayoutSnapshotOptions = {
22
+ stripeAccountId: string;
23
+ cursor?: string;
24
+ limit?: number;
25
+ maxUpcomingPages?: number;
26
+ };
27
+ const row = (p: Stripe.Payout): RecipientPayoutRow => ({
28
+ id: p.id,
29
+ amount: p.amount,
30
+ currency: p.currency,
31
+ status: p.status,
32
+ arrivalDate: p.arrival_date ? p.arrival_date * 1000 : null,
33
+ created: p.created * 1000,
34
+ });
35
+ /** Account-scoped live balance and cursor history. Upcoming truncation is explicit. */
36
+ export async function getPayoutSnapshot(
37
+ stripe: Stripe,
38
+ opts: PayoutSnapshotOptions,
39
+ ): Promise<RecipientPayoutSnapshot> {
40
+ const limit = opts.limit ?? 100,
41
+ maxPages = opts.maxUpcomingPages ?? 10;
42
+ if (
43
+ !opts.stripeAccountId ||
44
+ !Number.isInteger(limit) ||
45
+ limit < 1 ||
46
+ limit > 100 ||
47
+ !Number.isInteger(maxPages) ||
48
+ maxPages < 1 ||
49
+ maxPages > 100
50
+ )
51
+ throwStripeError(
52
+ "INVALID_CONFIGURATION",
53
+ "Invalid payout account or page bounds.",
54
+ );
55
+ const options = { stripeAccount: opts.stripeAccountId };
56
+ async function upcoming(status: "pending" | "in_transit") {
57
+ const rows: Stripe.Payout[] = [];
58
+ let cursor: string | undefined;
59
+ for (let page = 0; page < maxPages; page++) {
60
+ const batch = await stripe.payouts.list(
61
+ { status, limit: 100, starting_after: cursor },
62
+ options,
63
+ );
64
+ rows.push(...batch.data);
65
+ if (!batch.has_more) return { rows, complete: true };
66
+ const next = batch.data[batch.data.length - 1]?.id;
67
+ if (!next || next === cursor)
68
+ throwStripeError(
69
+ "STRIPE_API_ERROR",
70
+ "Stripe payout pagination did not advance.",
71
+ );
72
+ cursor = next;
73
+ }
74
+ return { rows, complete: false };
75
+ }
76
+ try {
77
+ const [balances, history, pending, transit] = await Promise.all([
78
+ getAccountBalance(stripe, opts),
79
+ stripe.payouts.list({ limit, starting_after: opts.cursor }, options),
80
+ upcoming("pending"),
81
+ upcoming("in_transit"),
82
+ ]);
83
+ const rows = [
84
+ ...new Map(
85
+ [...pending.rows, ...transit.rows]
86
+ .filter((p) => p.status === "pending" || p.status === "in_transit")
87
+ .map((p) => [p.id, row(p)]),
88
+ ).values(),
89
+ ].sort(
90
+ (a, b) =>
91
+ (a.arrivalDate ?? Infinity) - (b.arrivalDate ?? Infinity) ||
92
+ a.created - b.created,
93
+ );
94
+ if (history.has_more && !history.data.length)
95
+ throwStripeError(
96
+ "STRIPE_API_ERROR",
97
+ "Stripe payout history did not advance.",
98
+ );
99
+ return {
100
+ status: "ready",
101
+ balances,
102
+ payouts: history.data.map(row),
103
+ upcoming: rows,
104
+ nextCursor: history.has_more
105
+ ? history.data[history.data.length - 1]!.id
106
+ : null,
107
+ upcomingComplete: pending.complete && transit.complete,
108
+ };
109
+ } catch (error) {
110
+ if (
111
+ error &&
112
+ typeof error === "object" &&
113
+ "code" in error &&
114
+ ["account_invalid", "resource_missing"].includes(String(error.code))
115
+ )
116
+ return {
117
+ status: "not_ready",
118
+ reason: "account_unavailable",
119
+ balances: [],
120
+ payouts: [],
121
+ upcoming: [],
122
+ nextCursor: null,
123
+ upcomingComplete: true,
124
+ };
125
+ throwStripeError(
126
+ "STRIPE_API_ERROR",
127
+ "Could not retrieve recipient payout snapshot.",
128
+ );
129
+ }
130
+ }
@@ -28,7 +28,11 @@ function makeComponent(): Component {
28
28
  function plainCtx() {
29
29
  return {
30
30
  runQuery: vi.fn().mockResolvedValue(null),
31
- runMutation: vi.fn().mockResolvedValue(undefined),
31
+ runMutation: vi.fn(async (ref: Record<symbol, string>, args: unknown) =>
32
+ ref[TO_REF].endsWith("settlement/mutations/reserveSettlementAllocation")
33
+ ? args
34
+ : undefined,
35
+ ),
32
36
  } as unknown as RunCtx & {
33
37
  runQuery: ReturnType<typeof vi.fn>;
34
38
  runMutation: ReturnType<typeof vi.fn>;
@@ -47,6 +51,8 @@ function makeCtx(transfers: unknown[]) {
47
51
  const runMutation = vi.fn(
48
52
  async (refObj: Record<symbol, string>, args: Record<string, unknown>) => {
49
53
  const path = refObj[TO_REF].replace(/^betterStripe\//, "");
54
+ if (path === "settlement/mutations/reserveSettlementAllocation")
55
+ return args;
50
56
  if (path !== "connect/mutations/claimReversalSlices") return undefined;
51
57
  const legs = transfers as {
52
58
  stripeTransferId: string;
@@ -88,7 +94,9 @@ function makeCtx(transfers: unknown[]) {
88
94
  let trSeq = 0;
89
95
  function makeStripe() {
90
96
  return {
97
+ charges: { retrieve: vi.fn().mockResolvedValue({ created: 1 }) },
91
98
  transfers: {
99
+ list: vi.fn().mockResolvedValue({ data: [], has_more: false }),
92
100
  createReversal: vi.fn().mockResolvedValue({ id: "trr_1" }),
93
101
  create: vi.fn(async (_params: unknown) => ({ id: `tr_new_${++trSeq}` })),
94
102
  },
@@ -342,8 +350,8 @@ describe("createSplitTransfers engine (BTS-22)", () => {
342
350
  destination: "acct_aff",
343
351
  });
344
352
  // ledger rows
345
- expect(ctx.runMutation).toHaveBeenCalledTimes(2);
346
- expect(ctx.runMutation.mock.calls[0][1]).toMatchObject({
353
+ expect(ctx.runMutation).toHaveBeenCalledTimes(3);
354
+ expect(ctx.runMutation.mock.calls[1][1]).toMatchObject({
347
355
  sourceChargeId: "ch_1",
348
356
  destinationAccountId: "acct_store",
349
357
  amount: 8000,
@@ -372,7 +380,7 @@ describe("createSplitTransfers engine (BTS-22)", () => {
372
380
  });
373
381
 
374
382
  expect(stripe.transfers.create).toHaveBeenCalledTimes(3);
375
- expect(ctx.runMutation).toHaveBeenCalledTimes(3);
383
+ expect(ctx.runMutation).toHaveBeenCalledTimes(4);
376
384
  });
377
385
 
378
386
  it("skips zero-amount transfers", async () => {
@@ -403,6 +411,7 @@ describe("createSplitTransfers idempotency (BTS-24)", () => {
403
411
  destinationAccountId: "acct_store",
404
412
  role: "store",
405
413
  amount: 8000,
414
+ currency: "usd",
406
415
  },
407
416
  ]);
408
417
 
@@ -431,12 +440,14 @@ describe("createSplitTransfers idempotency (BTS-24)", () => {
431
440
  destinationAccountId: "acct_store",
432
441
  role: "store",
433
442
  amount: 8000,
443
+ currency: "usd",
434
444
  },
435
445
  {
436
446
  stripeTransferId: "tr_aff",
437
447
  destinationAccountId: "acct_aff",
438
448
  role: "affiliate",
439
449
  amount: 1000,
450
+ currency: "usd",
440
451
  },
441
452
  ]);
442
453
 
@@ -1,3 +1,5 @@
1
+ import { recoverSettlementTransfers } from "../settlement/reconciliation.js";
2
+ import { executeResolvedTransfers } from "../settlement/execution.js";
1
3
  import type Stripe from "stripe";
2
4
 
3
5
  import type { SplitRecipient } from "../../component/lib/fees.js";
@@ -303,6 +305,7 @@ export async function reverseTransfers(
303
305
  throw new Error("reverseTransfers: amount must be a non-negative integer");
304
306
  }
305
307
 
308
+ await recoverSettlementTransfers(stripe, component, ctx, opts.sourceChargeId);
306
309
  if (opts.operationId) {
307
310
  const claim = (await runMutationOrThrow(
308
311
  ctx,
@@ -421,6 +424,7 @@ export async function reverseTransfersForRefund(
421
424
  return { reversals: [] };
422
425
  }
423
426
 
427
+ await recoverSettlementTransfers(stripe, component, ctx, opts.sourceChargeId);
424
428
  const claim = (await runMutationOrThrow(
425
429
  ctx,
426
430
  componentRef(component, "connect/mutations/claimReversalSlices"),
@@ -460,48 +464,15 @@ export async function createSplitTransfers(
460
464
  ): Promise<SplitResult> {
461
465
  const result = computeSplit(opts.amount, opts.feeConfig, opts.split);
462
466
 
463
- // Ledger-level idempotency (BTS-24): skip any (charge, destination, role) leg
464
- // already recorded, so a webhook retry — even after a partial run — never
465
- // double-transfers. Complements the deterministic Stripe idempotency key.
466
- const existing = ((await ctx.runQuery(
467
- componentRef(component, "connect/queries/listTransfersByCharge"),
468
- { sourceChargeId: opts.sourceChargeId },
469
- )) ?? []) as LedgerTransfer[];
470
- const done = new Set(
471
- existing.map((t) => `${t.destinationAccountId}:${t.role ?? ""}`),
472
- );
473
-
474
- for (const t of result.transfers) {
475
- if (t.amount <= 0) continue;
476
- if (done.has(`${t.destinationAccountId}:${t.role}`)) continue;
477
- const transfer = await stripe.transfers.create(
478
- {
479
- amount: t.amount,
480
- currency: opts.currency,
481
- destination: t.destinationAccountId,
482
- source_transaction: opts.sourceChargeId,
483
- metadata: { bsRole: t.role, bsSourceCharge: opts.sourceChargeId },
484
- },
485
- // Deterministic key so a webhook retry can't double-transfer (BTS-24).
486
- {
487
- idempotencyKey: `bs_split_${opts.sourceChargeId}_${t.destinationAccountId}_${t.role}`,
488
- },
489
- );
490
- await runMutationOrThrow(
491
- ctx,
492
- componentRef(component, "connect/mutations/upsertTransfer"),
493
- {
494
- stripeTransferId: transfer.id,
495
- sourceChargeId: opts.sourceChargeId,
496
- destinationAccountId: t.destinationAccountId,
497
- amount: t.amount,
498
- currency: opts.currency,
499
- role: t.role,
500
- status: "paid" as const,
501
- paymentId: opts.paymentId,
502
- },
503
- );
504
- }
467
+ await executeResolvedTransfers(stripe, component, ctx, {
468
+ sourceChargeId: opts.sourceChargeId,
469
+ gross: opts.amount,
470
+ currency: opts.currency,
471
+ processingFee: 0,
472
+ platformNet: result.platformRetained,
473
+ transfers: result.transfers,
474
+ paymentId: opts.paymentId,
475
+ });
505
476
 
506
477
  return result;
507
478
  }
@@ -0,0 +1,78 @@
1
+ // @vitest-environment edge-runtime
2
+ import type Stripe from "stripe";
3
+ import { convexTest } from "convex-test";
4
+ import { componentsGeneric, defineSchema } from "convex/server";
5
+ import { expect, it, vi } from "vitest";
6
+ import componentSchema from "../../component/schema.js";
7
+ import type { Component, RunCtx } from "../helpers.js";
8
+ import { createAccount, getOrCreateAccount } from "./accounts.js";
9
+ const modules = import.meta.glob("../../component/**/*.*s");
10
+ it("reuses one provider account for concurrent calls and recovers a persistence failure", async () => {
11
+ const t = convexTest(defineSchema({}), modules);
12
+ t.registerComponent("betterStripe", componentSchema, modules);
13
+ const component = componentsGeneric().betterStripe as unknown as Component;
14
+ const provider = new Map<string, { params: string; id: string }>();
15
+ let created = 0;
16
+ const sdk = {
17
+ v2: {
18
+ core: {
19
+ accounts: {
20
+ create: vi.fn(
21
+ async (params: unknown, options?: { idempotencyKey?: string }) => {
22
+ const key = options?.idempotencyKey ?? `unkeyed-${++created}`,
23
+ existing = provider.get(key),
24
+ fingerprint = JSON.stringify(params);
25
+ if (existing && existing.params !== fingerprint)
26
+ throw new Error("idempotency_error");
27
+ if (existing) return { id: existing.id };
28
+ const value = { params: fingerprint, id: `acct_${++created}` };
29
+ provider.set(key, value);
30
+ return { id: value.id };
31
+ },
32
+ ),
33
+ },
34
+ },
35
+ },
36
+ };
37
+ let fail = true;
38
+ const ctx: RunCtx = {
39
+ runQuery: t.query as RunCtx["runQuery"],
40
+ runMutation: (async (
41
+ ref: Parameters<typeof t.mutation>[0],
42
+ args: Record<string, unknown>,
43
+ ) => {
44
+ if (fail) {
45
+ fail = false;
46
+ throw new Error("persistence unavailable");
47
+ }
48
+ return t.mutation(ref, args);
49
+ }) as RunCtx["runMutation"],
50
+ };
51
+ const opts = {
52
+ userId: "buyer",
53
+ email: "buyer@example.com",
54
+ idempotencyKey: "account:buyer:v1",
55
+ };
56
+ await expect(
57
+ createAccount(sdk as unknown as Stripe, component, ctx, opts),
58
+ ).rejects.toThrow("persistence unavailable");
59
+ const [a, b] = await Promise.all([
60
+ getOrCreateAccount(sdk as unknown as Stripe, component, ctx, opts),
61
+ getOrCreateAccount(sdk as unknown as Stripe, component, ctx, opts),
62
+ ]);
63
+ expect(a.stripeAccountId).toBe(b.stripeAccountId);
64
+ expect(provider.size).toBe(1);
65
+ expect(sdk.v2.core.accounts.create).toHaveBeenCalledWith(expect.anything(), {
66
+ idempotencyKey: opts.idempotencyKey,
67
+ });
68
+ await expect(
69
+ createAccount(sdk as unknown as Stripe, component, ctx, {
70
+ ...opts,
71
+ email: "changed@example.com",
72
+ }),
73
+ ).rejects.toThrow("idempotency_error");
74
+ const row = await t.query(component.core.queries.getAccountByUserId, {
75
+ userId: "buyer",
76
+ });
77
+ expect(row?.stripeAccountId).toBe(a.stripeAccountId);
78
+ });
@@ -1,3 +1,14 @@
1
+ import { idempotencyOptions } from "./idempotency.js";
2
+ export type CreateAccountOptions = {
3
+ userId: string;
4
+ email?: string;
5
+ name?: string;
6
+ country?: string;
7
+ orgId?: string;
8
+ metadata?: Record<string, string>;
9
+ /** Stable account-provisioning key; use a different key for a different account. */
10
+ idempotencyKey?: string;
11
+ };
1
12
  import type Stripe from "stripe";
2
13
 
3
14
  import type { Component, RunCtx } from "../helpers.js";
@@ -31,14 +42,7 @@ export async function createAccount(
31
42
  stripe: Stripe,
32
43
  component: Component,
33
44
  ctx: RunCtx,
34
- opts: {
35
- userId: string;
36
- email?: string;
37
- name?: string;
38
- country?: string;
39
- orgId?: string;
40
- metadata?: Record<string, string>;
41
- },
45
+ opts: CreateAccountOptions,
42
46
  ) {
43
47
  const metadata = {
44
48
  ...(opts.metadata ?? {}),
@@ -56,7 +60,10 @@ export async function createAccount(
56
60
  };
57
61
  }
58
62
 
59
- const account = await stripe.v2.core.accounts.create(createParams);
63
+ const requestOptions = idempotencyOptions(opts.idempotencyKey);
64
+ const account = requestOptions
65
+ ? await stripe.v2.core.accounts.create(createParams, requestOptions)
66
+ : await stripe.v2.core.accounts.create(createParams);
60
67
 
61
68
  await runMutationOrThrow(
62
69
  ctx,
@@ -191,14 +198,7 @@ export async function getOrCreateAccount(
191
198
  stripe: Stripe,
192
199
  component: Component,
193
200
  ctx: RunCtx,
194
- opts: {
195
- userId: string;
196
- email?: string;
197
- name?: string;
198
- country?: string;
199
- orgId?: string;
200
- metadata?: Record<string, string>;
201
- },
201
+ opts: CreateAccountOptions,
202
202
  ) {
203
203
  const existing = opts.orgId
204
204
  ? await ctx.runQuery(
@@ -238,14 +238,7 @@ export async function ensureCustomerAccount(
238
238
  stripe: Stripe,
239
239
  component: Component,
240
240
  ctx: RunCtx,
241
- opts: {
242
- userId: string;
243
- email?: string;
244
- name?: string;
245
- country?: string;
246
- orgId?: string;
247
- metadata?: Record<string, string>;
248
- },
241
+ opts: CreateAccountOptions,
249
242
  ): Promise<{
250
243
  stripeAccountId: string;
251
244
  accountId: string | null;
@@ -0,0 +1,10 @@
1
+ import { expect, it } from "vitest";
2
+ import { idempotencyOptions } from "./idempotency.js";
3
+ it("preserves omitted options and validates explicit request identities", () => {
4
+ expect(idempotencyOptions(undefined)).toBeUndefined();
5
+ expect(idempotencyOptions("logical-order:123")).toEqual({
6
+ idempotencyKey: "logical-order:123",
7
+ });
8
+ for (const key of ["", " ", "bad\nkey", "x".repeat(256)])
9
+ expect(() => idempotencyOptions(key)).toThrow();
10
+ });
@@ -0,0 +1,11 @@
1
+ import { throwStripeError } from "../errors.js";
2
+ /** Explicit request identity. Omission preserves the SDK's existing behavior. */
3
+ export function idempotencyOptions(key: string | undefined) {
4
+ if (key === undefined) return undefined;
5
+ if (!key.trim() || key.length > 255 || /[\r\n]/.test(key))
6
+ throwStripeError(
7
+ "INVALID_CONFIGURATION",
8
+ "Idempotency keys must be nonempty, at most 255 characters, and contain no line breaks.",
9
+ );
10
+ return { idempotencyKey: key };
11
+ }