@fantastic.dev/better-stripe 0.25.1 → 0.27.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 (94) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +11 -0
  3. package/README.md +10 -1
  4. package/dist/client/connect/payoutExecution.d.ts.map +1 -1
  5. package/dist/client/connect/payoutExecution.js +8 -83
  6. package/dist/client/connect/payoutExecution.js.map +1 -1
  7. package/dist/client/connect/payoutExecutionTestHelpers.d.ts +2 -10
  8. package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
  9. package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
  10. package/dist/client/connect/payoutPreview.d.ts +20 -0
  11. package/dist/client/connect/payoutPreview.d.ts.map +1 -0
  12. package/dist/client/connect/payoutPreview.js +44 -0
  13. package/dist/client/connect/payoutPreview.js.map +1 -0
  14. package/dist/client/connect/payoutQualification.d.ts +30 -0
  15. package/dist/client/connect/payoutQualification.d.ts.map +1 -0
  16. package/dist/client/connect/payoutQualification.js +98 -0
  17. package/dist/client/connect/payoutQualification.js.map +1 -0
  18. package/dist/client/payoutPolicy.d.ts +4 -0
  19. package/dist/client/payoutPolicy.d.ts.map +1 -1
  20. package/dist/client/payoutPolicy.js +6 -1
  21. package/dist/client/payoutPolicy.js.map +1 -1
  22. package/dist/component/_generated/component.d.ts +39 -0
  23. package/dist/component/_generated/component.d.ts.map +1 -1
  24. package/dist/component/affiliateRecovery/accounting.d.ts +2 -2
  25. package/dist/component/affiliateRecovery/accounting.d.ts.map +1 -1
  26. package/dist/component/affiliateRecovery/accounting.js.map +1 -1
  27. package/dist/component/payoutPolicy/helpers.d.ts +1 -1
  28. package/dist/component/payoutPolicy/helpers.d.ts.map +1 -1
  29. package/dist/component/payoutPolicy/helpers.js +3 -1
  30. package/dist/component/payoutPolicy/helpers.js.map +1 -1
  31. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
  32. package/dist/component/payoutPolicy/mutations.js +1 -1
  33. package/dist/component/payoutPolicy/mutations.js.map +1 -1
  34. package/dist/component/payoutRelease/helpers.d.ts +4 -4
  35. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  36. package/dist/component/payoutRelease/helpers.js.map +1 -1
  37. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  38. package/dist/component/payoutRelease/mutations.js +2 -29
  39. package/dist/component/payoutRelease/mutations.js.map +1 -1
  40. package/dist/component/payoutRelease/planning.d.ts +27 -0
  41. package/dist/component/payoutRelease/planning.d.ts.map +1 -0
  42. package/dist/component/payoutRelease/planning.js +35 -0
  43. package/dist/component/payoutRelease/planning.js.map +1 -0
  44. package/dist/component/payoutRelease/preview.d.ts +117 -0
  45. package/dist/component/payoutRelease/preview.d.ts.map +1 -0
  46. package/dist/component/payoutRelease/preview.js +82 -0
  47. package/dist/component/payoutRelease/preview.js.map +1 -0
  48. package/dist/component/payoutRelease/queries.d.ts +39 -0
  49. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  50. package/dist/component/payoutRelease/queries.js +6 -0
  51. package/dist/component/payoutRelease/queries.js.map +1 -1
  52. package/dist/react/workflows/PayoutProgress.d.ts +5 -0
  53. package/dist/react/workflows/PayoutProgress.d.ts.map +1 -0
  54. package/dist/react/workflows/PayoutProgress.js +23 -0
  55. package/dist/react/workflows/PayoutProgress.js.map +1 -0
  56. package/dist/react/workflows/PayoutSettings.d.ts +44 -0
  57. package/dist/react/workflows/PayoutSettings.d.ts.map +1 -0
  58. package/dist/react/workflows/PayoutSettings.js +84 -0
  59. package/dist/react/workflows/PayoutSettings.js.map +1 -0
  60. package/dist/react/workflows/index.d.ts +4 -0
  61. package/dist/react/workflows/index.d.ts.map +1 -1
  62. package/dist/react/workflows/index.js +2 -0
  63. package/dist/react/workflows/index.js.map +1 -1
  64. package/dist/react/workflows/payout-progress-state.d.ts +43 -0
  65. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -0
  66. package/dist/react/workflows/payout-progress-state.js +93 -0
  67. package/dist/react/workflows/payout-progress-state.js.map +1 -0
  68. package/docs/react-workflows-audit.md +1 -1
  69. package/docs/react-workflows.md +81 -0
  70. package/docs/settlement.md +57 -0
  71. package/package.json +1 -1
  72. package/src/client/connect/payoutExecution.ts +12 -99
  73. package/src/client/connect/payoutExecutionTestHelpers.ts +15 -13
  74. package/src/client/connect/payoutPreview.test.ts +269 -0
  75. package/src/client/connect/payoutPreview.ts +77 -0
  76. package/src/client/connect/payoutQualification.ts +145 -0
  77. package/src/client/payoutPolicy.test.ts +13 -0
  78. package/src/client/payoutPolicy.ts +19 -1
  79. package/src/component/_generated/api.ts +4 -0
  80. package/src/component/_generated/component.ts +42 -0
  81. package/src/component/affiliateRecovery/accounting.ts +2 -2
  82. package/src/component/payoutPolicy/helpers.ts +3 -1
  83. package/src/component/payoutPolicy/mutations.ts +4 -1
  84. package/src/component/payoutRelease/helpers.ts +7 -4
  85. package/src/component/payoutRelease/mutations.ts +2 -37
  86. package/src/component/payoutRelease/planning.ts +55 -0
  87. package/src/component/payoutRelease/preview.ts +118 -0
  88. package/src/component/payoutRelease/queries.ts +6 -0
  89. package/src/react/workflows/PayoutProgress.test.tsx +169 -0
  90. package/src/react/workflows/PayoutProgress.tsx +94 -0
  91. package/src/react/workflows/PayoutSettings.test.tsx +139 -0
  92. package/src/react/workflows/PayoutSettings.tsx +209 -0
  93. package/src/react/workflows/index.ts +13 -0
  94. package/src/react/workflows/payout-progress-state.ts +152 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fantastic.dev/better-stripe",
3
- "version": "0.25.1",
3
+ "version": "0.27.0",
4
4
  "description": "A Convex component for Stripe V2 Accounts API integration.",
5
5
  "keywords": [
6
6
  "stripe",
@@ -4,17 +4,9 @@ import type { Component, RunCtx } from "../helpers.js";
4
4
  import { runMutationOrThrow } from "../helpers.js";
5
5
  import { componentRef } from "../webhooks/helpers.js";
6
6
  import type { claimDoc } from "../../component/payoutRelease/schema.js";
7
- import type { transferDocValidator } from "../../component/connect/validators.js";
8
7
  import type { policyDoc } from "../../component/payoutPolicy/schema.js";
9
- import {
10
- transferFingerprint,
11
- validateIdentity,
12
- } from "../../component/payoutRelease/helpers.js";
13
- import {
14
- getTransferFundingReceipt,
15
- type TransferReceiptTransaction,
16
- } from "./transferReceipt.js";
17
- import { verifyPayoutHistory } from "./payoutHistory.js";
8
+ import { validateIdentity } from "../../component/payoutRelease/helpers.js";
9
+ import { collectPayoutQualification } from "./payoutQualification.js";
18
10
  import {
19
11
  reconcileRecipientPayout,
20
12
  type PayoutReconciliation,
@@ -32,13 +24,6 @@ export type PayoutReleaseOptions = {
32
24
  };
33
25
  type Held = { status: "held"; reason: string };
34
26
  export type PayoutReleaseResult = PayoutReconciliation | Held;
35
- type Snapshot =
36
- | {
37
- status: "ready";
38
- transfers: Infer<typeof transferDocValidator>[];
39
- claims: Claim[];
40
- }
41
- | Held;
42
27
  /**
43
28
  * Complete receipt qualification followed by an atomic claim and one immutable
44
29
  * provider request. Host authorizes the recipient, policy, operation and known
@@ -94,86 +79,14 @@ export async function releaseRecipientPayout(
94
79
  const controls = await verify();
95
80
  if (controls.status !== "verified" || !("policy" in controls))
96
81
  return { status: "held", reason: controls.status };
97
- const snapshot = (await ctx.runQuery(
98
- componentRef(component, "payoutRelease/queries/snapshot"),
99
- { accountId: opts.accountId, ownerKey: opts.ownerKey },
100
- )) as Snapshot;
101
- if (snapshot.status !== "ready") return snapshot;
102
- const banks: string[] = [];
103
- let bankCursor: string | undefined;
104
- for (let page = 0; ; page++) {
105
- if (page >= maxPages)
106
- return { status: "held", reason: "bank_history_incomplete" };
107
- const batch = await stripe.accounts.listExternalAccounts(opts.accountId, {
108
- object: "bank_account",
109
- limit: 100,
110
- ...(bankCursor ? { starting_after: bankCursor } : {}),
111
- });
112
- for (const bank of batch.data)
113
- if (
114
- bank.object === "bank_account" &&
115
- bank.currency === opts.currency &&
116
- bank.default_for_currency &&
117
- bank.status !== "errored"
118
- )
119
- banks.push(bank.id);
120
- if (!batch.has_more) break;
121
- const next = batch.data[batch.data.length - 1]?.id;
122
- if (!next || next === bankCursor)
123
- return { status: "held", reason: "bank_history_incomplete" };
124
- bankCursor = next;
125
- }
126
- if (banks.length !== 1 || !/^ba_\w+$/.test(banks[0]))
127
- return { status: "held", reason: "bank_destination_unavailable" };
128
- const destinationId = banks[0];
129
- const transactions: TransferReceiptTransaction[] = [];
130
- for (const claim of snapshot.claims) {
131
- if (
132
- claim.currency !== opts.currency ||
133
- claim.status === "canceled" ||
134
- claim.status === "rejected"
135
- )
136
- continue;
137
- if (claim.attemptedAt === undefined)
138
- return { status: "held", reason: "pending_claim" };
139
- const result = await reconcileRecipientPayout(stripe, component, ctx, {
140
- accountId: opts.accountId,
141
- ownerKey: opts.ownerKey,
142
- operationKey: claim.operationKey,
143
- maxPages,
144
- });
145
- if (result.status !== "updated") return result;
146
- for (const t of [result.receipt.debit, result.receipt.credit])
147
- if (t)
148
- transactions.push({ ...t, sourceId: result.receipt.stripePayoutId });
149
- }
150
- const observations = [];
151
- for (const row of snapshot.transfers) {
152
- const receipt = await getTransferFundingReceipt(stripe, component, ctx, {
153
- accountId: opts.accountId,
154
- stripeTransferId: row.stripeTransferId,
155
- });
156
- if (receipt.status !== "ready")
157
- return { status: "held", reason: receipt.reason };
158
- observations.push({
159
- stripeTransferId: row.stripeTransferId,
160
- currency: receipt.settlementCurrency,
161
- matureNet: receipt.matureNet,
162
- pendingCredits: receipt.pendingCredits,
163
- ledgerFingerprint: transferFingerprint(row),
164
- observedAt: receipt.observedAt,
165
- transactionIds: receipt.transactions.map((t) => t.id),
166
- });
167
- if (receipt.settlementCurrency === opts.currency)
168
- transactions.push(...receipt.transactions);
169
- }
170
- const history = await verifyPayoutHistory(stripe, {
171
- accountId: opts.accountId,
172
- currency: opts.currency,
173
- transactions,
82
+ const proof = await collectPayoutQualification(
83
+ stripe,
84
+ component,
85
+ ctx,
86
+ opts,
174
87
  maxPages,
175
- });
176
- if (history.status !== "verified") return history;
88
+ );
89
+ if (proof.status !== "verified") return proof;
177
90
  const claim = (await runMutationOrThrow(
178
91
  ctx,
179
92
  componentRef(component, "payoutRelease/mutations/claim"),
@@ -181,10 +94,10 @@ export async function releaseRecipientPayout(
181
94
  ...identity,
182
95
  expectedRevision: controls.policy.revision,
183
96
  currency: opts.currency,
184
- destinationId,
185
- providerAvailable: history.providerAvailable,
97
+ destinationId: proof.destinationId,
98
+ providerAvailable: proof.providerAvailable,
186
99
  independentLiabilities: opts.independentLiabilities,
187
- observations,
100
+ observations: proof.observations,
188
101
  },
189
102
  )) as Claim | Held;
190
103
  if (claim.status === "held") return claim;
@@ -122,22 +122,24 @@ export async function setup(amounts = [600, 400]) {
122
122
  ),
123
123
  },
124
124
  balance: {
125
- retrieve: vi.fn(async () => ({
126
- available: [
127
- {
128
- currency: "usd",
129
- amount: transactions
130
- .filter((t) => t.status === "available")
131
- .reduce((a, t) => a + t.net, 0),
132
- source_types: {
133
- card: transactions
125
+ retrieve: vi.fn(
126
+ async (): Promise<Pick<Stripe.Balance, "available" | "pending">> => ({
127
+ available: [
128
+ {
129
+ currency: "usd",
130
+ amount: transactions
134
131
  .filter((t) => t.status === "available")
135
132
  .reduce((a, t) => a + t.net, 0),
133
+ source_types: {
134
+ card: transactions
135
+ .filter((t) => t.status === "available")
136
+ .reduce((a, t) => a + t.net, 0),
137
+ },
136
138
  },
137
- },
138
- ],
139
- pending: [],
140
- })),
139
+ ],
140
+ pending: [],
141
+ }),
142
+ ),
141
143
  },
142
144
  payouts: {
143
145
  list: vi.fn(async () => ({
@@ -0,0 +1,269 @@
1
+ // @vitest-environment edge-runtime
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import type Stripe from "stripe";
4
+ import { setup, scope, component } from "./payoutExecutionTestHelpers.js";
5
+ import { previewRecipientPayout } from "./payoutPreview.js";
6
+ import { changeBalance } from "../../component/affiliateRecovery/accounting.js";
7
+ afterEach(() => vi.restoreAllMocks());
8
+ async function preview(amounts?: number[]) {
9
+ const s = await setup(amounts);
10
+ const run = (extra = {}) =>
11
+ previewRecipientPayout(
12
+ s.provider as unknown as Stripe,
13
+ component,
14
+ s.ctx,
15
+ { ...scope, ...extra },
16
+ s.verify,
17
+ );
18
+ return { ...s, preview: run };
19
+ }
20
+ it("previews combined eligibility and source categories without creating or reserving a payout", async () => {
21
+ const s = await preview();
22
+ expect(await s.preview()).toMatchObject({
23
+ status: "qualified",
24
+ amount: 1000,
25
+ matureUnpaid: 1000,
26
+ pendingCredits: 0,
27
+ threshold: 1000,
28
+ providerMinimum: null,
29
+ sources: [
30
+ { role: "store", matureUnpaid: 600, pendingCredits: 0 },
31
+ { role: "affiliate", matureUnpaid: 400, pendingCredits: 0 },
32
+ { role: "other", matureUnpaid: 0, pendingCredits: 0 },
33
+ ],
34
+ });
35
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
36
+ expect(
37
+ await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
38
+ ).toEqual([]);
39
+ expect(
40
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
41
+ ).toEqual([]);
42
+ });
43
+ it("shows below-threshold carry forward and independent liabilities", async () => {
44
+ const s = await preview([599, 400]);
45
+ expect(await s.preview({ independentLiabilities: 100 })).toMatchObject({
46
+ status: "held",
47
+ reason: "below_threshold",
48
+ matureUnpaid: 899,
49
+ amount: 0,
50
+ independentLiabilities: 100,
51
+ });
52
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
53
+ });
54
+ it("reports the shared affiliate recovery hold without treating the debt as payable", async () => {
55
+ const s = await preview();
56
+ await s.t.run((ctx) =>
57
+ changeBalance(ctx, scope.accountId, "usd", { outstandingDebt: 100 }),
58
+ );
59
+ expect(await s.preview()).toMatchObject({
60
+ status: "held",
61
+ reason: "affiliate_recovery_pending",
62
+ amount: 0,
63
+ });
64
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
65
+ });
66
+ it("subtracts a previously paid claim while verifying its current provider receipt", async () => {
67
+ const s = await preview();
68
+ await s.run();
69
+ expect(await s.preview()).toMatchObject({
70
+ status: "held",
71
+ reason: "below_threshold",
72
+ matureUnpaid: 0,
73
+ amount: 0,
74
+ });
75
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
76
+ });
77
+ it("rejects an expired provider balance observation even when there are no transfer observations", async () => {
78
+ const s = await preview([]);
79
+ const policy = await s.verify();
80
+ const now = Date.now();
81
+ const clock = vi.spyOn(Date, "now").mockReturnValue(now);
82
+ s.verify.mockResolvedValueOnce(policy).mockImplementationOnce(async () => {
83
+ clock.mockReturnValue(now + 31000);
84
+ return policy;
85
+ });
86
+ expect(await s.preview()).toMatchObject({
87
+ status: "held",
88
+ reason: "observations_stale",
89
+ amount: 0,
90
+ });
91
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
92
+ });
93
+ it("keeps pending credits out of the qualifying combined balance", async () => {
94
+ const s = await preview();
95
+ s.setTransactions(
96
+ s.transactions().map((row, index) => ({
97
+ ...row,
98
+ status: index === 0 ? "pending" : "available",
99
+ })),
100
+ );
101
+ s.provider.balance.retrieve.mockResolvedValue({
102
+ available: [{ currency: "usd", amount: 400, source_types: { card: 400 } }],
103
+ pending: [{ currency: "usd", amount: 600 }],
104
+ });
105
+ expect(await s.preview()).toMatchObject({
106
+ status: "held",
107
+ reason: "below_threshold",
108
+ amount: 0,
109
+ matureUnpaid: 400,
110
+ pendingCredits: 600,
111
+ sources: [
112
+ { role: "store", matureUnpaid: 0, pendingCredits: 600 },
113
+ { role: "affiliate", matureUnpaid: 400, pendingCredits: 0 },
114
+ { role: "other", matureUnpaid: 0, pendingCredits: 0 },
115
+ ],
116
+ });
117
+ });
118
+ it("distinguishes qualified earnings from insufficient funds on the supported payout rail", async () => {
119
+ const s = await preview();
120
+ s.provider.balance.retrieve.mockResolvedValue({
121
+ available: [{ currency: "usd", amount: 1000, source_types: { card: 500 } }],
122
+ pending: [],
123
+ });
124
+ expect(await s.preview()).toMatchObject({
125
+ status: "held",
126
+ reason: "insufficient_available",
127
+ matureUnpaid: 1000,
128
+ providerAvailable: 500,
129
+ amount: 0,
130
+ });
131
+ });
132
+ it("deducts confirmed payout costs without attributing shared costs to a source category", async () => {
133
+ const s = await preview([700, 400]);
134
+ await s.t.run((ctx) =>
135
+ ctx.db.insert("payoutFeeBalances", {
136
+ accountId: scope.accountId,
137
+ ownerKey: scope.ownerKey,
138
+ currency: "usd",
139
+ amount: 150,
140
+ revision: 1,
141
+ }),
142
+ );
143
+ expect(await s.preview()).toMatchObject({
144
+ status: "held",
145
+ reason: "below_threshold",
146
+ matureUnpaid: 950,
147
+ confirmedFees: 150,
148
+ sources: [
149
+ { role: "store", matureUnpaid: 700 },
150
+ { role: "affiliate", matureUnpaid: 400 },
151
+ { role: "other", matureUnpaid: 0 },
152
+ ],
153
+ });
154
+ });
155
+ it("does not show unverifiable history as a zero balance", async () => {
156
+ const s = await preview();
157
+ s.setTransactions([
158
+ ...s.transactions(),
159
+ {
160
+ ...s.transactions()[0],
161
+ id: "txn_unknown",
162
+ source: "po_unknown",
163
+ type: "payout",
164
+ amount: -100,
165
+ net: -100,
166
+ },
167
+ ]);
168
+ expect(await s.preview()).toEqual({
169
+ status: "held",
170
+ reason: "unallocated_history",
171
+ amount: 0,
172
+ });
173
+ });
174
+ it("holds if controls change during provider qualification", async () => {
175
+ const s = await preview();
176
+ const controls = await s.verify();
177
+ s.verify.mockResolvedValueOnce(controls).mockResolvedValueOnce({
178
+ ...controls,
179
+ policy: { ...controls.policy, revision: controls.policy.revision + 1 },
180
+ });
181
+ expect(await s.preview()).toEqual({
182
+ status: "held",
183
+ reason: "policy_changed",
184
+ amount: 0,
185
+ });
186
+ });
187
+ it("rejects ledger changes between provider reads and the final plan", async () => {
188
+ const s = await preview();
189
+ const controls = await s.verify();
190
+ s.verify.mockResolvedValueOnce(controls).mockImplementationOnce(async () => {
191
+ await s.t.run(async (ctx) => {
192
+ const transfer = await ctx.db.query("transfers").first();
193
+ await ctx.db.patch("transfers", transfer!._id, { reversedAmount: 100 });
194
+ });
195
+ return controls;
196
+ });
197
+ await expect(s.preview()).rejects.toThrow("snapshot changed");
198
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
199
+ });
200
+ it("preserves negative affiliate entitlement when a paid commission is refunded", async () => {
201
+ const s = await preview();
202
+ await s.run();
203
+ await s.t.run(async (ctx) => {
204
+ const row = await ctx.db
205
+ .query("transfers")
206
+ .withIndex("by_stripe_transfer_id", (q) =>
207
+ q.eq("stripeTransferId", "tr_1"),
208
+ )
209
+ .unique();
210
+ await ctx.db.patch("transfers", row!._id, { reversedAmount: 100 });
211
+ });
212
+ const original = s.provider.transfers.retrieve.getMockImplementation()!;
213
+ s.provider.transfers.retrieve.mockImplementation(async (id) => ({
214
+ ...(await original(id)),
215
+ amount_reversed: id === "tr_1" ? 100 : 0,
216
+ }));
217
+ Object.assign(s.provider.transfers, {
218
+ listReversals: vi.fn(async (id: string) => ({
219
+ data:
220
+ id === "tr_1"
221
+ ? [
222
+ {
223
+ id: "trr_one",
224
+ transfer: id,
225
+ amount: 100,
226
+ currency: "usd",
227
+ destination_payment_refund: "pyr_one",
228
+ },
229
+ ]
230
+ : [],
231
+ has_more: false,
232
+ })),
233
+ });
234
+ Object.assign(s.provider, {
235
+ refunds: {
236
+ retrieve: vi.fn(async () => ({
237
+ id: "pyr_one",
238
+ charge: "py_1",
239
+ amount: 100,
240
+ currency: "usd",
241
+ status: "succeeded",
242
+ balance_transaction: "txn_refund",
243
+ })),
244
+ },
245
+ });
246
+ s.setTransactions([
247
+ ...s.transactions(),
248
+ {
249
+ ...s.transactions()[0],
250
+ id: "txn_refund",
251
+ source: "pyr_one",
252
+ amount: -100,
253
+ net: -100,
254
+ type: "payment_refund",
255
+ },
256
+ ]);
257
+ expect(await s.preview()).toMatchObject({
258
+ status: "held",
259
+ reason: "below_threshold",
260
+ amount: 0,
261
+ matureUnpaid: -100,
262
+ sources: [
263
+ { role: "store", matureUnpaid: 0 },
264
+ { role: "affiliate", matureUnpaid: -100 },
265
+ { role: "other", matureUnpaid: 0 },
266
+ ],
267
+ });
268
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
269
+ });
@@ -0,0 +1,77 @@
1
+ import type Stripe from "stripe";
2
+ import type { Infer } from "convex/values";
3
+ import type { Component, RunCtx } from "../helpers.js";
4
+ import { componentRef } from "../webhooks/helpers.js";
5
+ import { validateScope } from "../../component/payoutPolicy/helpers.js";
6
+ import type { previewResult } from "../../component/payoutRelease/preview.js";
7
+ import {
8
+ collectPayoutQualification,
9
+ type PayoutControlVerification,
10
+ } from "./payoutQualification.js";
11
+ export type PayoutPreviewOptions = {
12
+ accountId: string;
13
+ ownerKey: string;
14
+ currency: string;
15
+ independentLiabilities: number;
16
+ maxPages?: number;
17
+ };
18
+ export type PayoutPreview =
19
+ Infer<typeof previewResult> | { status: "held"; reason: string; amount: 0 };
20
+ /** Informational assessment only. Release always repeats qualification and atomically reserves funds. */
21
+ export async function previewRecipientPayout(
22
+ stripe: Stripe,
23
+ component: Component,
24
+ ctx: RunCtx,
25
+ opts: PayoutPreviewOptions,
26
+ verify: PayoutControlVerification,
27
+ ): Promise<PayoutPreview> {
28
+ validateScope(opts);
29
+ const maxPages = opts.maxPages ?? 10;
30
+ if (
31
+ !/^[a-z]{3}$/.test(opts.currency) ||
32
+ !Number.isSafeInteger(opts.independentLiabilities) ||
33
+ opts.independentLiabilities < 0 ||
34
+ !Number.isInteger(maxPages) ||
35
+ maxPages < 1 ||
36
+ maxPages > 100
37
+ )
38
+ throw new Error("Invalid payout preview scope or budget.");
39
+ const held = (reason: string) => ({
40
+ status: "held" as const,
41
+ reason,
42
+ amount: 0 as const,
43
+ });
44
+ const controls = await verify();
45
+ if (controls.status !== "verified" || !("policy" in controls))
46
+ return held(controls.status);
47
+ const proof = await collectPayoutQualification(
48
+ stripe,
49
+ component,
50
+ ctx,
51
+ opts,
52
+ maxPages,
53
+ );
54
+ if (proof.status !== "verified") return held(proof.reason);
55
+ const latest = await verify();
56
+ if (latest.status !== "verified" || !("policy" in latest))
57
+ return held(latest.status);
58
+ if (latest.policy.revision !== controls.policy.revision)
59
+ return held("policy_changed");
60
+ if (
61
+ proof.observedAt < Date.now() - 30000 ||
62
+ proof.observations.some((o) => o.observedAt < Date.now() - 30000)
63
+ )
64
+ return held("observations_stale");
65
+ return ctx.runQuery(
66
+ componentRef(component, "payoutRelease/queries/preview"),
67
+ {
68
+ accountId: opts.accountId,
69
+ ownerKey: opts.ownerKey,
70
+ currency: opts.currency,
71
+ expectedRevision: controls.policy.revision,
72
+ observations: proof.observations,
73
+ providerAvailable: proof.providerAvailable,
74
+ independentLiabilities: opts.independentLiabilities,
75
+ },
76
+ );
77
+ }
@@ -0,0 +1,145 @@
1
+ import type Stripe from "stripe";
2
+ import type { Infer } from "convex/values";
3
+ import type { Component, RunCtx } from "../helpers.js";
4
+ import { componentRef } from "../webhooks/helpers.js";
5
+ import type {
6
+ claimDoc,
7
+ observation,
8
+ } from "../../component/payoutRelease/schema.js";
9
+ import type { transferDocValidator } from "../../component/connect/validators.js";
10
+ import type { policyDoc } from "../../component/payoutPolicy/schema.js";
11
+ import { transferFingerprint } from "../../component/payoutRelease/helpers.js";
12
+ import {
13
+ getTransferFundingReceipt,
14
+ type TransferReceiptTransaction,
15
+ } from "./transferReceipt.js";
16
+ import { verifyPayoutHistory } from "./payoutHistory.js";
17
+ import { reconcileRecipientPayout } from "./payoutReconciliation.js";
18
+ type Held = { status: "held"; reason: string };
19
+ type Snapshot =
20
+ | {
21
+ status: "ready";
22
+ transfers: Infer<typeof transferDocValidator>[];
23
+ claims: Infer<typeof claimDoc>[];
24
+ }
25
+ | Held;
26
+ export type PayoutControlVerification = () => Promise<
27
+ { status: "verified"; policy: Infer<typeof policyDoc> } | { status: string }
28
+ >;
29
+ export type PayoutProofScope = {
30
+ accountId: string;
31
+ ownerKey: string;
32
+ currency: string;
33
+ };
34
+ /** Shared provider qualification. It can reconcile existing receipts, but never creates a bank payout. */
35
+ export async function collectPayoutQualification(
36
+ stripe: Stripe,
37
+ component: Component,
38
+ ctx: RunCtx,
39
+ opts: PayoutProofScope,
40
+ maxPages: number,
41
+ ): Promise<
42
+ | Held
43
+ | {
44
+ status: "verified";
45
+ observations: Infer<typeof observation>[];
46
+ destinationId: string;
47
+ providerAvailable: number;
48
+ observedAt: number;
49
+ }
50
+ > {
51
+ const snapshot = (await ctx.runQuery(
52
+ componentRef(component, "payoutRelease/queries/snapshot"),
53
+ { accountId: opts.accountId, ownerKey: opts.ownerKey },
54
+ )) as Snapshot;
55
+ if (snapshot.status !== "ready") return snapshot;
56
+ const banks: string[] = [];
57
+ let bankCursor: string | undefined;
58
+ for (let page = 0; ; page++) {
59
+ if (page >= maxPages)
60
+ return { status: "held", reason: "bank_history_incomplete" };
61
+ const batch = await stripe.accounts.listExternalAccounts(opts.accountId, {
62
+ object: "bank_account",
63
+ limit: 100,
64
+ ...(bankCursor ? { starting_after: bankCursor } : {}),
65
+ });
66
+ for (const bank of batch.data)
67
+ if (
68
+ bank.object === "bank_account" &&
69
+ bank.currency === opts.currency &&
70
+ bank.default_for_currency &&
71
+ bank.status !== "errored"
72
+ )
73
+ banks.push(bank.id);
74
+ if (!batch.has_more) break;
75
+ const next = batch.data[batch.data.length - 1]?.id;
76
+ if (!next || next === bankCursor)
77
+ return { status: "held", reason: "bank_history_incomplete" };
78
+ bankCursor = next;
79
+ }
80
+ if (banks.length !== 1 || !/^ba_\w+$/.test(banks[0]))
81
+ return { status: "held", reason: "bank_destination_unavailable" };
82
+ const destinationId = banks[0];
83
+ const transactions: TransferReceiptTransaction[] = [];
84
+ for (const claim of snapshot.claims) {
85
+ if (
86
+ claim.currency !== opts.currency ||
87
+ claim.status === "canceled" ||
88
+ claim.status === "rejected"
89
+ )
90
+ continue;
91
+ if (claim.attemptedAt === undefined)
92
+ return { status: "held", reason: "pending_claim" };
93
+ const result = await reconcileRecipientPayout(stripe, component, ctx, {
94
+ accountId: opts.accountId,
95
+ ownerKey: opts.ownerKey,
96
+ operationKey: claim.operationKey,
97
+ maxPages,
98
+ });
99
+ if (result.status !== "updated")
100
+ return {
101
+ status: "held" as const,
102
+ reason:
103
+ result.status === "held"
104
+ ? result.reason
105
+ : "historical_claim_terminal",
106
+ };
107
+ for (const t of [result.receipt.debit, result.receipt.credit])
108
+ if (t)
109
+ transactions.push({ ...t, sourceId: result.receipt.stripePayoutId });
110
+ }
111
+ const observations = [];
112
+ for (const row of snapshot.transfers) {
113
+ const receipt = await getTransferFundingReceipt(stripe, component, ctx, {
114
+ accountId: opts.accountId,
115
+ stripeTransferId: row.stripeTransferId,
116
+ });
117
+ if (receipt.status !== "ready")
118
+ return { status: "held", reason: receipt.reason };
119
+ observations.push({
120
+ stripeTransferId: row.stripeTransferId,
121
+ currency: receipt.settlementCurrency,
122
+ matureNet: receipt.matureNet,
123
+ pendingCredits: receipt.pendingCredits,
124
+ ledgerFingerprint: transferFingerprint(row),
125
+ observedAt: receipt.observedAt,
126
+ transactionIds: receipt.transactions.map((t) => t.id),
127
+ });
128
+ if (receipt.settlementCurrency === opts.currency)
129
+ transactions.push(...receipt.transactions);
130
+ }
131
+ const history = await verifyPayoutHistory(stripe, {
132
+ accountId: opts.accountId,
133
+ currency: opts.currency,
134
+ transactions,
135
+ maxPages,
136
+ });
137
+ if (history.status !== "verified") return history;
138
+ return {
139
+ status: "verified",
140
+ observations,
141
+ destinationId,
142
+ providerAvailable: history.providerAvailable,
143
+ observedAt: history.observedAt,
144
+ };
145
+ }