@fantastic.dev/better-stripe 0.25.1 → 0.26.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 (60) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/MIGRATION.md +11 -0
  3. package/README.md +4 -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 +3 -0
  19. package/dist/client/payoutPolicy.d.ts.map +1 -1
  20. package/dist/client/payoutPolicy.js +4 -0
  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/payoutRelease/helpers.d.ts +4 -4
  28. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  29. package/dist/component/payoutRelease/helpers.js.map +1 -1
  30. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  31. package/dist/component/payoutRelease/mutations.js +2 -29
  32. package/dist/component/payoutRelease/mutations.js.map +1 -1
  33. package/dist/component/payoutRelease/planning.d.ts +27 -0
  34. package/dist/component/payoutRelease/planning.d.ts.map +1 -0
  35. package/dist/component/payoutRelease/planning.js +35 -0
  36. package/dist/component/payoutRelease/planning.js.map +1 -0
  37. package/dist/component/payoutRelease/preview.d.ts +117 -0
  38. package/dist/component/payoutRelease/preview.d.ts.map +1 -0
  39. package/dist/component/payoutRelease/preview.js +82 -0
  40. package/dist/component/payoutRelease/preview.js.map +1 -0
  41. package/dist/component/payoutRelease/queries.d.ts +39 -0
  42. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  43. package/dist/component/payoutRelease/queries.js +6 -0
  44. package/dist/component/payoutRelease/queries.js.map +1 -1
  45. package/docs/settlement.md +57 -0
  46. package/package.json +1 -1
  47. package/src/client/connect/payoutExecution.ts +12 -99
  48. package/src/client/connect/payoutExecutionTestHelpers.ts +15 -13
  49. package/src/client/connect/payoutPreview.test.ts +269 -0
  50. package/src/client/connect/payoutPreview.ts +77 -0
  51. package/src/client/connect/payoutQualification.ts +145 -0
  52. package/src/client/payoutPolicy.ts +14 -0
  53. package/src/component/_generated/api.ts +4 -0
  54. package/src/component/_generated/component.ts +42 -0
  55. package/src/component/affiliateRecovery/accounting.ts +2 -2
  56. package/src/component/payoutRelease/helpers.ts +7 -4
  57. package/src/component/payoutRelease/mutations.ts +2 -37
  58. package/src/component/payoutRelease/planning.ts +55 -0
  59. package/src/component/payoutRelease/preview.ts +118 -0
  60. package/src/component/payoutRelease/queries.ts +6 -0
@@ -607,6 +607,63 @@ These are server worker APIs, not manual withdrawal actions for a customer UI.
607
607
  for an authorized host to display. Bank availability, pending funds and bank
608
608
  arrival dates come from the recipient payout snapshot API.
609
609
 
610
+ ## Payout eligibility preview
611
+
612
+ An authorized server action can assess a custom policy before displaying its
613
+ progress. It uses the same provider receipt qualification and local allocation
614
+ plan as release:
615
+
616
+ ```ts
617
+ const preview = await stripe.payoutPolicy.preview(ctx, {
618
+ accountId,
619
+ ownerKey,
620
+ currency,
621
+ independentLiabilities,
622
+ });
623
+ const job = await stripe.payoutPolicy.worker.get(ctx, {
624
+ accountId,
625
+ ownerKey,
626
+ currency,
627
+ });
628
+ ```
629
+
630
+ Authorize the owner/account mapping and derive independent liabilities on the
631
+ server, exactly as for the automatic worker. The preview reads Stripe and can
632
+ reconcile existing payout receipts in the component. It never creates a bank
633
+ payout, reserves funds, or invokes the worker. The caller needs an action context
634
+ that permits component queries and reconciliation mutations.
635
+
636
+ A detailed result includes `currency`, `threshold`, `matureUnpaid`,
637
+ `pendingCredits`, `confirmedFees`, `independentLiabilities`, `providerAvailable`,
638
+ `amount`, `observedAt` and `sources`. Each source category reports its signed
639
+ mature unpaid entitlement and pending credits. Category amounts are before shared
640
+ fees and independent liabilities. Those deductions reduce the combined
641
+ `matureUnpaid` once. Negative entitlement after a refund survives the calculation;
642
+ pending credits never qualify. `amount` is the amount currently qualified for the
643
+ supported bank payout rail, or zero when held. `providerMinimum: null` means the
644
+ numeric Stripe floor is unknown, not zero.
645
+
646
+ Below-threshold funds remain owed. `insufficient_available` distinguishes enough
647
+ qualifying earnings from insufficient provider funds on the supported rail. Some
648
+ holds return only `{ status: "held", reason, amount: 0 }`, including incomplete
649
+ history, unverified controls and unresolved affiliate recovery. Such a result
650
+ does **not** establish a zero balance or zero debt. Render an unavailable
651
+ assessment with its reason, rather than inventing amounts. Ledger changes during
652
+ the final query reject the assessment and require a refresh.
653
+
654
+ The preview verifies policy controls again after reading provider data. Source
655
+ and balance observations older than 30 seconds do not qualify. Display
656
+ `observedAt` as an assessment time; release independently repeats qualification
657
+ and reserves funds atomically. A qualified preview is neither a promised payout
658
+ nor authorization to call Stripe. `job.nextAttemptAt` is the next application
659
+ attempt. Bank arrival dates remain separate provider data from
660
+ `getPayoutSnapshot`.
661
+
662
+ Stripe-managed recipients keep Stripe's default automatic schedule. This preview
663
+ requires a ready custom policy and preserves the current source/history limits,
664
+ region restrictions and supported payout rail. It does not remove the need for
665
+ provider-history continuation on larger accounts.
666
+
610
667
  ## Resumable local collections
611
668
 
612
669
  An authorized host worker can collect the local recipient ledger in bounded
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.26.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
+ }