@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.
- package/CHANGELOG.md +12 -0
- package/MIGRATION.md +11 -0
- package/README.md +10 -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 +4 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +6 -1
- 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/payoutPolicy/helpers.d.ts +1 -1
- package/dist/component/payoutPolicy/helpers.d.ts.map +1 -1
- package/dist/component/payoutPolicy/helpers.js +3 -1
- package/dist/component/payoutPolicy/helpers.js.map +1 -1
- package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
- package/dist/component/payoutPolicy/mutations.js +1 -1
- package/dist/component/payoutPolicy/mutations.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/dist/react/workflows/PayoutProgress.d.ts +5 -0
- package/dist/react/workflows/PayoutProgress.d.ts.map +1 -0
- package/dist/react/workflows/PayoutProgress.js +23 -0
- package/dist/react/workflows/PayoutProgress.js.map +1 -0
- package/dist/react/workflows/PayoutSettings.d.ts +44 -0
- package/dist/react/workflows/PayoutSettings.d.ts.map +1 -0
- package/dist/react/workflows/PayoutSettings.js +84 -0
- package/dist/react/workflows/PayoutSettings.js.map +1 -0
- package/dist/react/workflows/index.d.ts +4 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +2 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/payout-progress-state.d.ts +43 -0
- package/dist/react/workflows/payout-progress-state.d.ts.map +1 -0
- package/dist/react/workflows/payout-progress-state.js +93 -0
- package/dist/react/workflows/payout-progress-state.js.map +1 -0
- package/docs/react-workflows-audit.md +1 -1
- package/docs/react-workflows.md +81 -0
- 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.test.ts +13 -0
- package/src/client/payoutPolicy.ts +19 -1
- 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/payoutPolicy/helpers.ts +3 -1
- package/src/component/payoutPolicy/mutations.ts +4 -1
- 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/src/react/workflows/PayoutProgress.test.tsx +169 -0
- package/src/react/workflows/PayoutProgress.tsx +94 -0
- package/src/react/workflows/PayoutSettings.test.tsx +139 -0
- package/src/react/workflows/PayoutSettings.tsx +209 -0
- package/src/react/workflows/index.ts +13 -0
- package/src/react/workflows/payout-progress-state.ts +152 -0
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
|
+
}
|
|
@@ -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
|
+
}
|