@fantastic.dev/better-stripe 0.9.0 → 0.10.1

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 (160) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +26 -0
  3. package/README.md +38 -1
  4. package/dist/client/apiVersion.d.ts +6 -0
  5. package/dist/client/apiVersion.d.ts.map +1 -0
  6. package/dist/client/apiVersion.js +13 -0
  7. package/dist/client/apiVersion.js.map +1 -0
  8. package/dist/client/billing/checkout.d.ts +1 -0
  9. package/dist/client/billing/checkout.d.ts.map +1 -1
  10. package/dist/client/billing/checkout.js +5 -1
  11. package/dist/client/billing/checkout.js.map +1 -1
  12. package/dist/client/billing/providerReads.d.ts +18 -0
  13. package/dist/client/billing/providerReads.d.ts.map +1 -0
  14. package/dist/client/billing/providerReads.js +15 -0
  15. package/dist/client/billing/providerReads.js.map +1 -0
  16. package/dist/client/billing/subscriptions.d.ts +1 -1
  17. package/dist/client/billing/subscriptions.d.ts.map +1 -1
  18. package/dist/client/billing/subscriptions.js.map +1 -1
  19. package/dist/client/connect/payoutSnapshot.d.ts +28 -0
  20. package/dist/client/connect/payoutSnapshot.d.ts.map +1 -0
  21. package/dist/client/connect/payoutSnapshot.js +81 -0
  22. package/dist/client/connect/payoutSnapshot.js.map +1 -0
  23. package/dist/client/connect/transfers.d.ts.map +1 -1
  24. package/dist/client/connect/transfers.js +13 -32
  25. package/dist/client/connect/transfers.js.map +1 -1
  26. package/dist/client/core/accounts.d.ts +12 -23
  27. package/dist/client/core/accounts.d.ts.map +1 -1
  28. package/dist/client/core/accounts.js +5 -1
  29. package/dist/client/core/accounts.js.map +1 -1
  30. package/dist/client/core/idempotency.d.ts +5 -0
  31. package/dist/client/core/idempotency.d.ts.map +1 -0
  32. package/dist/client/core/idempotency.js +10 -0
  33. package/dist/client/core/idempotency.js.map +1 -0
  34. package/dist/client/index.d.ts +39 -37
  35. package/dist/client/index.d.ts.map +1 -1
  36. package/dist/client/index.js +47 -56
  37. package/dist/client/index.js.map +1 -1
  38. package/dist/client/previewRoutes.d.ts +9 -0
  39. package/dist/client/previewRoutes.d.ts.map +1 -0
  40. package/dist/client/previewRoutes.js +15 -0
  41. package/dist/client/previewRoutes.js.map +1 -0
  42. package/dist/client/settlement/allocation.d.ts +30 -0
  43. package/dist/client/settlement/allocation.d.ts.map +1 -0
  44. package/dist/client/settlement/allocation.js +71 -0
  45. package/dist/client/settlement/allocation.js.map +1 -0
  46. package/dist/client/settlement/commissions.d.ts +27 -0
  47. package/dist/client/settlement/commissions.d.ts.map +1 -0
  48. package/dist/client/settlement/commissions.js +69 -0
  49. package/dist/client/settlement/commissions.js.map +1 -0
  50. package/dist/client/settlement/execution.d.ts +27 -0
  51. package/dist/client/settlement/execution.d.ts.map +1 -0
  52. package/dist/client/settlement/execution.js +70 -0
  53. package/dist/client/settlement/execution.js.map +1 -0
  54. package/dist/client/settlement/reconciliation.d.ts +10 -0
  55. package/dist/client/settlement/reconciliation.d.ts.map +1 -0
  56. package/dist/client/settlement/reconciliation.js +73 -0
  57. package/dist/client/settlement/reconciliation.js.map +1 -0
  58. package/dist/client/settlement/settleSale.d.ts +21 -0
  59. package/dist/client/settlement/settleSale.d.ts.map +1 -0
  60. package/dist/client/settlement/settleSale.js +27 -0
  61. package/dist/client/settlement/settleSale.js.map +1 -0
  62. package/dist/client/settlement/verifiedSale.d.ts +25 -0
  63. package/dist/client/settlement/verifiedSale.d.ts.map +1 -0
  64. package/dist/client/settlement/verifiedSale.js +100 -0
  65. package/dist/client/settlement/verifiedSale.js.map +1 -0
  66. package/dist/client/types/options.d.ts +3 -0
  67. package/dist/client/types/options.d.ts.map +1 -1
  68. package/dist/client/utils/webhookEndpoints.d.ts +12 -8
  69. package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
  70. package/dist/client/utils/webhookEndpoints.js +41 -2
  71. package/dist/client/utils/webhookEndpoints.js.map +1 -1
  72. package/dist/client/webhooks/asyncHooks.d.ts +5 -0
  73. package/dist/client/webhooks/asyncHooks.d.ts.map +1 -0
  74. package/dist/client/webhooks/asyncHooks.js +20 -0
  75. package/dist/client/webhooks/asyncHooks.js.map +1 -0
  76. package/dist/client/webhooks/v2.d.ts.map +1 -1
  77. package/dist/client/webhooks/v2.js +10 -7
  78. package/dist/client/webhooks/v2.js.map +1 -1
  79. package/dist/component/_generated/component.d.ts +80 -0
  80. package/dist/component/_generated/component.d.ts.map +1 -1
  81. package/dist/component/lib/stripeFactory.d.ts +3 -2
  82. package/dist/component/lib/stripeFactory.d.ts.map +1 -1
  83. package/dist/component/lib/stripeFactory.js +6 -5
  84. package/dist/component/lib/stripeFactory.js.map +1 -1
  85. package/dist/component/schema.d.ts +67 -0
  86. package/dist/component/schema.js +4 -0
  87. package/dist/component/schema.js.map +1 -1
  88. package/dist/component/settlement/mutations.d.ts +47 -0
  89. package/dist/component/settlement/mutations.d.ts.map +1 -0
  90. package/dist/component/settlement/mutations.js +104 -0
  91. package/dist/component/settlement/mutations.js.map +1 -0
  92. package/dist/component/settlement/queries.d.ts +29 -0
  93. package/dist/component/settlement/queries.d.ts.map +1 -0
  94. package/dist/component/settlement/queries.js +35 -0
  95. package/dist/component/settlement/queries.js.map +1 -0
  96. package/dist/component/settlement/schema.d.ts +99 -0
  97. package/dist/component/settlement/schema.d.ts.map +1 -0
  98. package/dist/component/settlement/schema.js +35 -0
  99. package/dist/component/settlement/schema.js.map +1 -0
  100. package/dist/react/workflows/Checkout.d.ts +3 -1
  101. package/dist/react/workflows/Checkout.d.ts.map +1 -1
  102. package/dist/react/workflows/Checkout.js +3 -2
  103. package/dist/react/workflows/Checkout.js.map +1 -1
  104. package/dist/react/workflows/Payouts.d.ts +3 -18
  105. package/dist/react/workflows/Payouts.d.ts.map +1 -1
  106. package/dist/react/workflows/Payouts.js +2 -2
  107. package/dist/react/workflows/Payouts.js.map +1 -1
  108. package/docs/accounts-v2-preview.md +132 -0
  109. package/docs/react-workflows.md +5 -2
  110. package/docs/settlement.md +158 -0
  111. package/package.json +3 -1
  112. package/src/client/apiVersion.ts +20 -0
  113. package/src/client/billing/checkout-idempotency.test.ts +82 -0
  114. package/src/client/billing/checkout.ts +6 -1
  115. package/src/client/billing/providerReads.test.ts +60 -0
  116. package/src/client/billing/providerReads.ts +30 -0
  117. package/src/client/billing/subscriptions.ts +1 -8
  118. package/src/client/connect/payoutSnapshot.test.ts +91 -0
  119. package/src/client/connect/payoutSnapshot.ts +130 -0
  120. package/src/client/connect/transfers.test.ts +15 -4
  121. package/src/client/connect/transfers.ts +13 -42
  122. package/src/client/core/account-idempotency.test.ts +78 -0
  123. package/src/client/core/accounts.ts +18 -25
  124. package/src/client/core/idempotency.test.ts +10 -0
  125. package/src/client/core/idempotency.ts +11 -0
  126. package/src/client/customer-preview-thin.test.ts +41 -0
  127. package/src/client/customer-preview-webhooks.test.ts +150 -0
  128. package/src/client/customer-preview.test.ts +185 -0
  129. package/src/client/index.ts +115 -119
  130. package/src/client/previewRoutes.ts +26 -0
  131. package/src/client/settlement/allocation.test.ts +98 -0
  132. package/src/client/settlement/allocation.ts +110 -0
  133. package/src/client/settlement/commissions.test.ts +103 -0
  134. package/src/client/settlement/commissions.ts +125 -0
  135. package/src/client/settlement/execution.test.ts +186 -0
  136. package/src/client/settlement/execution.ts +134 -0
  137. package/src/client/settlement/reconciliation.ts +119 -0
  138. package/src/client/settlement/settleSale.ts +59 -0
  139. package/src/client/settlement/verifiedSale.test.ts +139 -0
  140. package/src/client/settlement/verifiedSale.ts +159 -0
  141. package/src/client/stripe-version.test.ts +10 -4
  142. package/src/client/types/options.ts +3 -0
  143. package/src/client/utils/webhookEndpoints.ts +70 -15
  144. package/src/client/webhooks/asyncHooks.ts +38 -0
  145. package/src/client/webhooks/processors.test.ts +9 -9
  146. package/src/client/webhooks/v2.ts +14 -10
  147. package/src/component/_generated/api.ts +4 -0
  148. package/src/component/_generated/component.ts +100 -0
  149. package/src/component/lib/stripeFactory.ts +13 -5
  150. package/src/component/schema.ts +8 -0
  151. package/src/component/settlement/mutations.ts +119 -0
  152. package/src/component/settlement/queries.ts +38 -0
  153. package/src/component/settlement/schema.ts +45 -0
  154. package/src/component/settlement-claims.test.ts +77 -0
  155. package/src/react/components/EmbeddedDisputes.render.test.tsx +2 -2
  156. package/src/react/index.payment-element.test.ts +22 -37
  157. package/src/react/workflows/Checkout.test.tsx +30 -0
  158. package/src/react/workflows/Checkout.tsx +10 -3
  159. package/src/react/workflows/Payouts.test.tsx +33 -0
  160. package/src/react/workflows/Payouts.tsx +21 -15
@@ -52,8 +52,11 @@ function Purchase({ publishableKey, priceId, createSession }) {
52
52
 
53
53
  The callback returns `{ stripeSessionId, clientSecret?, url? }`. It must return
54
54
  `url` for `redirect` and `clientSecret` for the other session modes. Use the
55
- request ID for server idempotency. A user-triggered retry creates a new request
56
- ID; also enforce application-level duplicate-order protection on the server.
55
+ request ID for server idempotency. Retries and rerenders preserve the same ID.
56
+ Changing `orderKey` or checkout mode starts a new logical request. Pass a persisted
57
+ `requestId` prop to preserve identity across unmounts or page reloads. Forward it
58
+ to your authenticated server and Stripe request options; also enforce durable
59
+ application-level duplicate-order protection.
57
60
 
58
61
  | Mode | Stripe integration | Tradeoff |
59
62
  | ---------------- | ------------------------------------- | ---------------------------------------------------------------------- |
@@ -0,0 +1,158 @@
1
+ # Verified settlement and recipient payouts
2
+
3
+ BetterStripe owns provider reads, immutable settlement claims, transfer execution,
4
+ and recovery. Your server authenticates the caller, resolves recipient accounts,
5
+ selects rates, and authorizes the allocation. Never accept these values directly
6
+ from a browser. Amounts below are integer minor units in one currency.
7
+
8
+ ## Read, allocate, settle
9
+
10
+ ```ts
11
+ import { allocateSale } from "@fantastic.dev/better-stripe";
12
+
13
+ const sale = await stripe.getVerifiedSale(ctx, {
14
+ paymentIntentId,
15
+ invoiceId, // Required for a subscription payment
16
+ subscriptionId,
17
+ });
18
+ if (sale.status !== "ready") {
19
+ // Only retry when sale.retryable is true. Missing processing fees are not zero.
20
+ return sale;
21
+ }
22
+ const allocation = allocateSale({
23
+ gross: sale.gross,
24
+ processingFee: sale.processingFee,
25
+ feeBasisPoints: 900,
26
+ creatorAccountId,
27
+ affiliate: { destinationAccountId: affiliateAccountId, basisPoints: 2000 },
28
+ collaborators: [
29
+ { destinationAccountId: collaboratorAccountId, basisPoints: 2500 },
30
+ ],
31
+ });
32
+ return stripe.settleSale(ctx, {
33
+ paymentIntentId,
34
+ invoiceId,
35
+ subscriptionId,
36
+ allocation: {
37
+ gross: sale.gross,
38
+ currency: sale.currency,
39
+ processingFee: sale.processingFee,
40
+ platformNet: allocation.platformNet,
41
+ transfers: allocation.transfers,
42
+ },
43
+ });
44
+ ```
45
+
46
+ `allocateSale` is pure. The inclusive sale fee is the greater of actual Stripe
47
+ processing and the selected gross percentage. The platform keeps the difference.
48
+ An affiliate receives a percentage of the amount after that fee, or a
49
+ `fixedAmount` in minor units, capped at the amount available. Collaborators
50
+ receive their percentages of the creator remainder. Rounding residue stays with
51
+ the creator. Omit the affiliate for zero commission; no application rate is built in.
52
+
53
+ `getVerifiedSale` expands the charge's actual balance transaction, verifies
54
+ currency and paid invoice linkage, and reads the subscription's current price.
55
+ It refuses refunded/disputed charges, converted settlement currencies, partial
56
+ invoice payments, and multi-price subscriptions rather than guessing allocations.
57
+ A `pending` result carries a retryable reason; `not_payable` must not trigger a transfer.
58
+
59
+ `settleSale` verifies the provider data again, then pins the original allocation
60
+ by charge. Retries use that original allocation even if rates have changed. The
61
+ shared transfer engine records legs in the existing component transfer ledger;
62
+ there is no required application `storeSales` table. Optional signed
63
+ `adjustmentAmount` accounts for an authorized application credit or recovered
64
+ cost; it must reconcile with all other amounts. An optional `onSettled` callback
65
+ runs after execution and can run again on retries: make application writes
66
+ idempotent by source charge.
67
+
68
+ Provider transfers carry the source charge and recipient role. If Stripe succeeds
69
+ and persistence fails, retries reconcile provider records before creating missing
70
+ legs, including legacy ungrouped transfers. Incomplete provider history fails
71
+ closed. Existing refund/dispute reversal APIs recover these original legs too.
72
+ Stripe API idempotency is an additional safeguard, not the only recovery record.
73
+ Do not delete financial component records while payments can be retried or reversed.
74
+
75
+ The Vite example exposes a [fee-first allocation preview](http://localhost:5173/demo/allocation)
76
+ from Admin → Testing Utilities. Its illustrative inputs use the public helper
77
+ and the package React `SplitBreakdown`; they do not alter store settings.
78
+
79
+ ## Subscription commission terms
80
+
81
+ ```ts
82
+ const commission = await stripe.selectCommissionInvoice(ctx, {
83
+ subscriptionId,
84
+ invoiceId,
85
+ affiliateAccountId, // Server-validated identity
86
+ term: "one_time", // Default: "forever"
87
+ });
88
+ ```
89
+
90
+ This selects eligibility, not a rate or a money movement. `one_time` selects the
91
+ earliest paid nonzero invoice from complete provider history, even if a renewal
92
+ arrives first. Failed invoices and zero-value trials do not consume eligibility.
93
+ The component pins the term and affiliate identity. Refunds do not reset the
94
+ first invoice. A replay returns the same `claimKey` and eligibility so interrupted
95
+ settlement can finish; deduplicate actual payment execution with `settleSale`.
96
+ If historical pagination exceeds the configured bound, selection refuses to guess.
97
+
98
+ The Vite example migrates its old subscription claims with
99
+ `pnpm exec convex run commissionMigration:run '{}'` from `example/`. Repeat until
100
+ it returns `0`. Run this after deploying the new component and before replaying
101
+ historical subscription events. The migration preserves terms and first invoices;
102
+ its batch deletes are transactional with component writes.
103
+
104
+ ## Payout snapshots
105
+
106
+ ```ts
107
+ const snapshot = await stripe.getPayoutSnapshot(ctx, {
108
+ stripeAccountId,
109
+ cursor,
110
+ limit: 25,
111
+ });
112
+ ```
113
+
114
+ The snapshot contains per-currency `balances`, cursor-paginated `payouts`,
115
+ deduplicated pending/in-transit `upcoming`, and `nextCursor`. Arrival dates are
116
+ provider estimates, nullable, in milliseconds. `upcomingComplete: false` means
117
+ only a bounded preview was retrieved. An unavailable account returns
118
+ `status: "not_ready"` with empty data and a reason; it does not imply a zero balance.
119
+ The React `PayoutSnapshot` contract accepts this shape directly.
120
+
121
+ A transfer moves money to a connected account; a payout moves that account's
122
+ available balance to its external bank. These boundaries have different controls.
123
+
124
+ ## Combined payout minimums: supported boundary
125
+
126
+ A single user-level recipient account naturally combines creator and affiliate
127
+ funds across communities. Keep currency balances separate. Only available funds
128
+ are mature; Stripe's available balance already reflects provider debits and known
129
+ reversals. A platform source ledger is still necessary to explain which earnings
130
+ remain owed and distinguish unrelated funds in an already-mixed account.
131
+
132
+ The supported default is Stripe-managed automatic payouts with no additional
133
+ application minimum. Stripe enforces its applicable currency/method floor. The
134
+ Balance Settings API does not expose a numeric payout-floor field; this package
135
+ does not invent one. `minimum_balance_by_currency` is a retained reserve after a
136
+ payout, **not a minimum payout trigger**. [Stripe reserve documentation](https://docs.stripe.com/payouts/minimum-balances-for-automatic-payouts).
137
+
138
+ Custom application thresholds need a separately controlled release boundary:
139
+
140
+ - Before transfer: retain funds on the platform and maintain a durable payable
141
+ ledger, maturity, adjustments, and release claims. This changes settlement timing
142
+ and must account for existing transferred balances.
143
+ - Before bank payout: use a manual payout schedule where the platform has
144
+ permission, and run an automatic application job. Otherwise provider automatic
145
+ sweeps can bypass the application threshold. A retained reserve cannot fix that.
146
+
147
+ Manual holding limits depend on country: Thailand 10 days, US 2 years, other
148
+ countries generally 90 days. Schedule permissions depend on account controls and
149
+ liability. Negative balances, refunds, and in-flight payouts must reduce any
150
+ release amount; source-category thresholds and currency netting are unsafe.
151
+ See [payout schedule permissions](https://docs.stripe.com/connect/manage-payout-schedule)
152
+ and [manual payout restrictions](https://docs.stripe.com/connect/manual-payouts).
153
+
154
+ Custom threshold enforcement is not provided by the snapshot or settlement APIs.
155
+ It requires a selected retention boundary and migration policy. Applications must
156
+ not describe a displayed threshold as enforced while Stripe controls automatic
157
+ payouts. A numeric below/exact/above provider-floor demonstration is unavailable
158
+ without a supported provider floor read; surface this capability limitation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fantastic.dev/better-stripe",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "A Convex component for Stripe V2 Accounts API integration.",
5
5
  "keywords": [
6
6
  "stripe",
@@ -58,6 +58,8 @@
58
58
  "dist",
59
59
  "src",
60
60
  "docs/react-workflows.md",
61
+ "docs/settlement.md",
62
+ "docs/accounts-v2-preview.md",
61
63
  "docs/react-workflows-audit.md",
62
64
  "CHANGELOG.md",
63
65
  "MIGRATION.md"
@@ -0,0 +1,20 @@
1
+ import { STRIPE_API_VERSION } from "./constants.js";
2
+ import { throwStripeError } from "./errors.js";
3
+ /** Explicit opt-in for Stripe's customer-only Accounts v2 public preview. */
4
+ export const STRIPE_CUSTOMER_PREVIEW_API_VERSION = "2026-09-30.preview";
5
+ export type SupportedStripeApiVersion =
6
+ typeof STRIPE_API_VERSION | typeof STRIPE_CUSTOMER_PREVIEW_API_VERSION;
7
+ export function resolveStripeApiVersion(
8
+ version?: string,
9
+ ): SupportedStripeApiVersion {
10
+ if (version === undefined) return STRIPE_API_VERSION;
11
+ if (
12
+ version === STRIPE_API_VERSION ||
13
+ version === STRIPE_CUSTOMER_PREVIEW_API_VERSION
14
+ )
15
+ return version;
16
+ throwStripeError(
17
+ "INVALID_CONFIGURATION",
18
+ `Unsupported Stripe API version. Use ${STRIPE_API_VERSION} or ${STRIPE_CUSTOMER_PREVIEW_API_VERSION}.`,
19
+ );
20
+ }
@@ -0,0 +1,82 @@
1
+ import { expect, it, vi } from "vitest";
2
+ import type Stripe from "stripe";
3
+ import { componentsGeneric } from "convex/server";
4
+ import type { Component, RunCtx } from "../helpers.js";
5
+ import { createCheckoutSession } from "./checkout.js";
6
+ function makeStripe() {
7
+ return {
8
+ prices: {
9
+ retrieve: vi.fn().mockRejectedValue({ code: "resource_missing" }),
10
+ },
11
+ checkout: { sessions: { create: vi.fn() } },
12
+ };
13
+ }
14
+ function makeCtx() {
15
+ return {
16
+ runQuery: vi.fn().mockResolvedValue(null),
17
+ runMutation: vi.fn().mockResolvedValue(undefined),
18
+ } as unknown as RunCtx & { runMutation: ReturnType<typeof vi.fn> };
19
+ }
20
+ const makeComponent = () =>
21
+ componentsGeneric().betterStripe as unknown as Component;
22
+ const asStripe = (value: ReturnType<typeof makeStripe>) =>
23
+ value as unknown as Stripe;
24
+ function sessionResponse(overrides: Record<string, unknown> = {}) {
25
+ return {
26
+ id: "cs_test",
27
+ status: "open",
28
+ client_secret: "secret",
29
+ url: null,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ it("forwards an order key as request options and recovers persistence without another session", async () => {
35
+ const stripe = makeStripe(),
36
+ ctx = makeCtx();
37
+ const cache = new Map<
38
+ string,
39
+ { params: string; session: ReturnType<typeof sessionResponse> }
40
+ >();
41
+ stripe.checkout.sessions.create.mockImplementation(
42
+ async (params, options?: { idempotencyKey?: string }) => {
43
+ const key = options?.idempotencyKey ?? `new-${cache.size}`,
44
+ prior = cache.get(key),
45
+ serialized = JSON.stringify(params);
46
+ if (prior && prior.params !== serialized)
47
+ throw new Error("idempotency_error");
48
+ if (prior) return prior.session;
49
+ const session = sessionResponse({ id: `cs_${cache.size}` });
50
+ cache.set(key, { params: serialized, session });
51
+ return session;
52
+ },
53
+ );
54
+ const opts = {
55
+ userId: "buyer",
56
+ stripePriceId: "price_1",
57
+ mode: "payment" as const,
58
+ returnUrl: "https://app.test/receipt",
59
+ idempotencyKey: "order:one",
60
+ };
61
+ ctx.runMutation.mockRejectedValueOnce(new Error("persistence unavailable"));
62
+ await expect(
63
+ createCheckoutSession(asStripe(stripe), makeComponent(), ctx, opts),
64
+ ).rejects.toThrow("persistence unavailable");
65
+ await createCheckoutSession(asStripe(stripe), makeComponent(), ctx, opts);
66
+ expect(cache.size).toBe(1);
67
+ expect(stripe.checkout.sessions.create).toHaveBeenLastCalledWith(
68
+ expect.anything(),
69
+ { idempotencyKey: "order:one" },
70
+ );
71
+ await createCheckoutSession(asStripe(stripe), makeComponent(), ctx, {
72
+ ...opts,
73
+ idempotencyKey: "order:two",
74
+ });
75
+ expect(cache.size).toBe(2);
76
+ await expect(
77
+ createCheckoutSession(asStripe(stripe), makeComponent(), ctx, {
78
+ ...opts,
79
+ stripePriceId: "price_other",
80
+ }),
81
+ ).rejects.toThrow("idempotency_error");
82
+ });
@@ -1,3 +1,4 @@
1
+ import { idempotencyOptions } from "../core/idempotency.js";
1
2
  import type Stripe from "stripe";
2
3
 
3
4
  import type { SplitRecipient } from "../../component/lib/fees.js";
@@ -110,6 +111,7 @@ export async function createCheckoutSession(
110
111
  component: Component,
111
112
  ctx: RunCtx,
112
113
  opts: {
114
+ idempotencyKey?: string;
113
115
  userId: string;
114
116
  orgId?: string;
115
117
  stripePriceId: string;
@@ -338,7 +340,10 @@ export async function createCheckoutSession(
338
340
  await assertPlatformPrice(stripe, component, ctx, finalPriceId);
339
341
  }
340
342
 
341
- const session = await stripe.checkout.sessions.create(finalSessionParams);
343
+ const requestOptions = idempotencyOptions(opts.idempotencyKey);
344
+ const session = requestOptions
345
+ ? await stripe.checkout.sessions.create(finalSessionParams, requestOptions)
346
+ : await stripe.checkout.sessions.create(finalSessionParams);
342
347
 
343
348
  await runMutationOrThrow(
344
349
  ctx,
@@ -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
+ }