@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.
- package/CHANGELOG.md +6 -0
- package/MIGRATION.md +11 -0
- package/README.md +4 -1
- package/dist/client/connect/payoutExecution.d.ts.map +1 -1
- package/dist/client/connect/payoutExecution.js +8 -83
- package/dist/client/connect/payoutExecution.js.map +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts +2 -10
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
- package/dist/client/connect/payoutPreview.d.ts +20 -0
- package/dist/client/connect/payoutPreview.d.ts.map +1 -0
- package/dist/client/connect/payoutPreview.js +44 -0
- package/dist/client/connect/payoutPreview.js.map +1 -0
- package/dist/client/connect/payoutQualification.d.ts +30 -0
- package/dist/client/connect/payoutQualification.d.ts.map +1 -0
- package/dist/client/connect/payoutQualification.js +98 -0
- package/dist/client/connect/payoutQualification.js.map +1 -0
- package/dist/client/payoutPolicy.d.ts +3 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +4 -0
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +39 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/affiliateRecovery/accounting.d.ts +2 -2
- package/dist/component/affiliateRecovery/accounting.d.ts.map +1 -1
- package/dist/component/affiliateRecovery/accounting.js.map +1 -1
- package/dist/component/payoutRelease/helpers.d.ts +4 -4
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/helpers.js.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +2 -29
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/planning.d.ts +27 -0
- package/dist/component/payoutRelease/planning.d.ts.map +1 -0
- package/dist/component/payoutRelease/planning.js +35 -0
- package/dist/component/payoutRelease/planning.js.map +1 -0
- package/dist/component/payoutRelease/preview.d.ts +117 -0
- package/dist/component/payoutRelease/preview.d.ts.map +1 -0
- package/dist/component/payoutRelease/preview.js +82 -0
- package/dist/component/payoutRelease/preview.js.map +1 -0
- package/dist/component/payoutRelease/queries.d.ts +39 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/queries.js +6 -0
- package/dist/component/payoutRelease/queries.js.map +1 -1
- package/docs/settlement.md +57 -0
- package/package.json +1 -1
- package/src/client/connect/payoutExecution.ts +12 -99
- package/src/client/connect/payoutExecutionTestHelpers.ts +15 -13
- package/src/client/connect/payoutPreview.test.ts +269 -0
- package/src/client/connect/payoutPreview.ts +77 -0
- package/src/client/connect/payoutQualification.ts +145 -0
- package/src/client/payoutPolicy.ts +14 -0
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +42 -0
- package/src/component/affiliateRecovery/accounting.ts +2 -2
- package/src/component/payoutRelease/helpers.ts +7 -4
- package/src/component/payoutRelease/mutations.ts +2 -37
- package/src/component/payoutRelease/planning.ts +55 -0
- package/src/component/payoutRelease/preview.ts +118 -0
- package/src/component/payoutRelease/queries.ts +6 -0
package/docs/settlement.md
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 (
|
|
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:
|
|
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(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
+
}
|