@fantastic.dev/better-stripe 0.28.6 → 0.29.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 +6 -0
- package/README.md +5 -1
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +28 -8
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutCollectedInput.d.ts +110 -0
- package/dist/client/connect/payoutCollectedInput.d.ts.map +1 -0
- package/dist/client/connect/payoutCollectedInput.js +70 -0
- package/dist/client/connect/payoutCollectedInput.js.map +1 -0
- package/dist/client/connect/payoutExecution.d.ts +1 -0
- package/dist/client/connect/payoutExecution.d.ts.map +1 -1
- package/dist/client/connect/payoutExecution.js +94 -67
- package/dist/client/connect/payoutExecution.js.map +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts +16 -4
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.js +9 -6
- package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
- package/dist/client/connect/payoutPreview.d.ts +1 -0
- package/dist/client/connect/payoutPreview.d.ts.map +1 -1
- package/dist/client/connect/payoutPreview.js +31 -19
- package/dist/client/connect/payoutPreview.js.map +1 -1
- package/dist/client/connect/payoutQualification.d.ts +7 -1
- package/dist/client/connect/payoutQualification.d.ts.map +1 -1
- package/dist/client/connect/payoutQualification.js +34 -19
- package/dist/client/connect/payoutQualification.js.map +1 -1
- package/dist/client/connect/payoutSyncTestHelpers.d.ts +14 -4
- package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutWorker.d.ts.map +1 -1
- package/dist/client/connect/payoutWorker.js +4 -1
- package/dist/client/connect/payoutWorker.js.map +1 -1
- package/dist/client/costRecovery.d.ts +5 -0
- package/dist/client/costRecovery.d.ts.map +1 -1
- package/dist/client/costRecovery.js +5 -0
- package/dist/client/costRecovery.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +4 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/component/_generated/component.d.ts +175 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/mutations.js +1 -1
- package/dist/component/costRecovery/queries.d.ts +9 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -1
- package/dist/component/costRecovery/queries.js +20 -0
- package/dist/component/costRecovery/queries.js.map +1 -1
- package/dist/component/earnings/triggers.d.ts +16 -6
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutRelease/adoption.d.ts +6 -0
- package/dist/component/payoutRelease/adoption.d.ts.map +1 -1
- package/dist/component/payoutRelease/helpers.d.ts +4 -1
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/helpers.js +17 -10
- package/dist/component/payoutRelease/helpers.js.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts +20 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +14 -2
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/planning.d.ts +4 -0
- package/dist/component/payoutRelease/planning.d.ts.map +1 -1
- package/dist/component/payoutRelease/planning.js +4 -2
- package/dist/component/payoutRelease/planning.js.map +1 -1
- package/dist/component/payoutRelease/preview.d.ts +2 -0
- package/dist/component/payoutRelease/preview.d.ts.map +1 -1
- package/dist/component/payoutRelease/preview.js +2 -0
- package/dist/component/payoutRelease/preview.js.map +1 -1
- package/dist/component/payoutRelease/queries.d.ts +11 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.d.ts +17 -2
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +3 -0
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/payoutSnapshots/financial.d.ts +35 -0
- package/dist/component/payoutSnapshots/financial.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/financial.js +89 -0
- package/dist/component/payoutSnapshots/financial.js.map +1 -0
- package/dist/component/payoutSnapshots/liabilities.d.ts +7 -0
- package/dist/component/payoutSnapshots/liabilities.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/liabilities.js +18 -0
- package/dist/component/payoutSnapshots/liabilities.js.map +1 -0
- package/dist/component/payoutSnapshots/localState.d.ts +2 -0
- package/dist/component/payoutSnapshots/localState.d.ts.map +1 -1
- package/dist/component/payoutSnapshots/localState.js +6 -3
- package/dist/component/payoutSnapshots/localState.js.map +1 -1
- package/dist/component/payoutSnapshots/mutations.d.ts +6 -0
- package/dist/component/payoutSnapshots/mutations.d.ts.map +1 -1
- package/dist/component/payoutSnapshots/mutations.js +2 -0
- package/dist/component/payoutSnapshots/mutations.js.map +1 -1
- package/dist/component/payoutSnapshots/queries.d.ts +133 -0
- package/dist/component/payoutSnapshots/queries.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/queries.js +84 -0
- package/dist/component/payoutSnapshots/queries.js.map +1 -0
- package/dist/component/payoutSnapshots/revisions.d.ts.map +1 -1
- package/dist/component/payoutSnapshots/revisions.js +19 -0
- package/dist/component/payoutSnapshots/revisions.js.map +1 -1
- package/dist/component/payoutSnapshots/schema.d.ts +15 -5
- package/dist/component/payoutSnapshots/schema.d.ts.map +1 -1
- package/dist/component/payoutSnapshots/schema.js +3 -1
- package/dist/component/payoutSnapshots/schema.js.map +1 -1
- package/dist/component/payoutWorker/mutations.d.ts.map +1 -1
- package/dist/component/payoutWorker/mutations.js +3 -1
- package/dist/component/payoutWorker/mutations.js.map +1 -1
- package/dist/component/schema.d.ts +14 -4
- package/dist/react/workflows/payout-attempt-state.d.ts.map +1 -1
- package/dist/react/workflows/payout-attempt-state.js +3 -0
- package/dist/react/workflows/payout-attempt-state.js.map +1 -1
- package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
- package/dist/react/workflows/payout-progress-state.js +3 -0
- package/dist/react/workflows/payout-progress-state.js.map +1 -1
- package/docs/settlement.md +75 -16
- package/package.json +1 -1
- package/src/client/connect/payoutCollectedInput.ts +120 -0
- package/src/client/connect/payoutExecution.ts +116 -84
- package/src/client/connect/payoutExecutionTestHelpers.ts +19 -6
- package/src/client/connect/payoutLargeHistory.test.ts +222 -0
- package/src/client/connect/payoutLiabilityVersion.test.ts +221 -0
- package/src/client/connect/payoutPreview.ts +42 -24
- package/src/client/connect/payoutQualification.ts +62 -21
- package/src/client/connect/payoutWorker.ts +4 -1
- package/src/client/costRecovery.ts +8 -0
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +203 -0
- package/src/component/costRecovery/mutations.ts +1 -1
- package/src/component/costRecovery/queries.ts +21 -0
- package/src/component/payoutRelease/helpers.ts +19 -11
- package/src/component/payoutRelease/mutations.ts +18 -1
- package/src/component/payoutRelease/planning.ts +7 -1
- package/src/component/payoutRelease/preview.ts +2 -0
- package/src/component/payoutRelease/schema.ts +3 -0
- package/src/component/payoutSnapshots/financial.ts +121 -0
- package/src/component/payoutSnapshots/liabilities.ts +25 -0
- package/src/component/payoutSnapshots/localState.ts +7 -3
- package/src/component/payoutSnapshots/mutations.ts +2 -0
- package/src/component/payoutSnapshots/queries.ts +102 -0
- package/src/component/payoutSnapshots/revisions.ts +26 -0
- package/src/component/payoutSnapshots/schema.ts +3 -0
- package/src/component/payoutWorker/mutations.ts +3 -1
- package/src/react/workflows/payout-attempt-state.ts +6 -0
- package/src/react/workflows/payout-progress-state.ts +6 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { PayoutWorkerClient } from "./payoutWorker.js";
|
|
3
|
+
import { reconcileRecipientPayout } from "./payoutReconciliation.js";
|
|
4
|
+
// @vitest-environment edge-runtime
|
|
5
|
+
import { expect, it, vi } from "vitest";
|
|
6
|
+
import { makeFunctionReference } from "convex/server";
|
|
7
|
+
import { setup, scope, component } from "./payoutExecutionTestHelpers.js";
|
|
8
|
+
async function largeHistory(amount = 5) {
|
|
9
|
+
const s = await setup(Array.from({ length: 205 }, () => amount));
|
|
10
|
+
await s.t.run(async (ctx) => {
|
|
11
|
+
for (let i = 0; i < 101; i++)
|
|
12
|
+
await ctx.db.insert("payoutReleaseClaims", {
|
|
13
|
+
accountId: "acct_recipient",
|
|
14
|
+
ownerKey: "owner",
|
|
15
|
+
operationKey: `old-${i}`,
|
|
16
|
+
expectedRevision: 0,
|
|
17
|
+
currency: "usd",
|
|
18
|
+
amount: 1,
|
|
19
|
+
allocations: [{ stripeTransferId: "tr_0", amount: 1, role: "store" }],
|
|
20
|
+
observations: [],
|
|
21
|
+
status: "canceled",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
s.provider.balanceTransactions.list.mockImplementation(async (params) => {
|
|
25
|
+
const p = params as typeof params & {
|
|
26
|
+
starting_after?: string;
|
|
27
|
+
currency?: string;
|
|
28
|
+
};
|
|
29
|
+
const all = s
|
|
30
|
+
.transactions()
|
|
31
|
+
.filter(
|
|
32
|
+
(t) =>
|
|
33
|
+
(!p.source || t.source === p.source) &&
|
|
34
|
+
(!p.currency || t.currency === p.currency),
|
|
35
|
+
);
|
|
36
|
+
const start = p.starting_after
|
|
37
|
+
? all.findIndex((t) => t.id === p.starting_after) + 1
|
|
38
|
+
: 0;
|
|
39
|
+
const data = all.slice(start, start + (p.limit ?? 100));
|
|
40
|
+
return { data, has_more: start + data.length < all.length };
|
|
41
|
+
});
|
|
42
|
+
return s;
|
|
43
|
+
}
|
|
44
|
+
it("releases all 205 verified sources despite 101 terminal historical claims", async () => {
|
|
45
|
+
const s = await largeHistory();
|
|
46
|
+
expect(await s.run()).toMatchObject({
|
|
47
|
+
status: "updated",
|
|
48
|
+
claim: { status: "paid", amount: 1025 },
|
|
49
|
+
});
|
|
50
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(
|
|
52
|
+
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
53
|
+
).toHaveLength(205);
|
|
54
|
+
await s.run();
|
|
55
|
+
expect(await s.run({ operationKey: "release-2" })).toMatchObject({
|
|
56
|
+
status: "held",
|
|
57
|
+
reason: "below_threshold",
|
|
58
|
+
matureUnpaid: 0,
|
|
59
|
+
});
|
|
60
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
61
|
+
// Two complete 205-source passes exercise allocation rollups under coverage.
|
|
62
|
+
}, 15_000);
|
|
63
|
+
|
|
64
|
+
it("preserves a financially bound collection across a crash before attempt, then cancels exactly", async () => {
|
|
65
|
+
const s = await largeHistory();
|
|
66
|
+
const mutation = s.ctx.runMutation!;
|
|
67
|
+
const fault = vi
|
|
68
|
+
.spyOn(s.ctx, "runMutation")
|
|
69
|
+
.mockImplementation(async (...args) => {
|
|
70
|
+
const [ref] = args;
|
|
71
|
+
const path = (ref as unknown as Record<symbol, string>)[
|
|
72
|
+
Symbol.for("toReferencePath")
|
|
73
|
+
];
|
|
74
|
+
if (path.endsWith("payoutRelease/mutations/beginAttempt"))
|
|
75
|
+
throw new Error("before-attempt outage");
|
|
76
|
+
return mutation(args[0], args[1] ?? {});
|
|
77
|
+
});
|
|
78
|
+
await expect(s.run()).rejects.toThrow("before-attempt outage");
|
|
79
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
80
|
+
expect(
|
|
81
|
+
await s.t.run((ctx) => ctx.db.query("payoutSnapshots").first()),
|
|
82
|
+
).toMatchObject({ readOnly: false, boundOperationKey: "release-1" });
|
|
83
|
+
fault.mockRestore();
|
|
84
|
+
expect(await s.run()).toMatchObject({
|
|
85
|
+
status: "terminal",
|
|
86
|
+
claim: { status: "canceled" },
|
|
87
|
+
});
|
|
88
|
+
const sources = await s.t.run((ctx) =>
|
|
89
|
+
ctx.db.query("payoutSourceAllocations").collect(),
|
|
90
|
+
);
|
|
91
|
+
expect(sources).toHaveLength(205);
|
|
92
|
+
expect(
|
|
93
|
+
sources.every((s) => s.reservedAmount === 0 && s.paidAmount === 0),
|
|
94
|
+
).toBe(true);
|
|
95
|
+
expect(
|
|
96
|
+
await s.t.run((ctx) => ctx.db.query("payoutSnapshots").collect()),
|
|
97
|
+
).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
it("does not replace an unknown large-history payout outcome", async () => {
|
|
100
|
+
const s = await largeHistory();
|
|
101
|
+
s.provider.payouts.create.mockRejectedValueOnce(
|
|
102
|
+
new Error("provider outcome unknown"),
|
|
103
|
+
);
|
|
104
|
+
await expect(s.run()).rejects.toThrow("provider outcome unknown");
|
|
105
|
+
expect(await s.run()).toMatchObject({
|
|
106
|
+
status: "held",
|
|
107
|
+
reason: "payout_unidentified",
|
|
108
|
+
});
|
|
109
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
110
|
+
const sources = await s.t.run((ctx) =>
|
|
111
|
+
ctx.db.query("payoutSourceAllocations").collect(),
|
|
112
|
+
);
|
|
113
|
+
expect(
|
|
114
|
+
sources.every((s) => s.reservedAmount === 5 && s.paidAmount === 0),
|
|
115
|
+
).toBe(true);
|
|
116
|
+
expect(
|
|
117
|
+
await s.t.run((ctx) => ctx.db.query("payoutSnapshots").collect()),
|
|
118
|
+
).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
it("a source reversal after reservation invalidates the financial binding before Stripe", async () => {
|
|
121
|
+
const s = await largeHistory();
|
|
122
|
+
const verified = await s.verify();
|
|
123
|
+
s.verify.mockResolvedValueOnce(verified).mockImplementationOnce(async () => {
|
|
124
|
+
await s.t.mutation(
|
|
125
|
+
makeFunctionReference<"mutation">("connect/mutations:upsertTransfer"),
|
|
126
|
+
{
|
|
127
|
+
stripeTransferId: "tr_0",
|
|
128
|
+
destinationAccountId: "acct_recipient",
|
|
129
|
+
sourceChargeId: "ch_0",
|
|
130
|
+
amount: 5,
|
|
131
|
+
currency: "usd",
|
|
132
|
+
status: "reversed",
|
|
133
|
+
reversedAmount: 1,
|
|
134
|
+
role: "store",
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
return verified;
|
|
138
|
+
});
|
|
139
|
+
await expect(s.run()).rejects.toThrow(/collection changed/);
|
|
140
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
141
|
+
expect(await s.run()).toMatchObject({
|
|
142
|
+
status: "terminal",
|
|
143
|
+
claim: { status: "canceled" },
|
|
144
|
+
});
|
|
145
|
+
const sources = await s.t.run((ctx) =>
|
|
146
|
+
ctx.db.query("payoutSourceAllocations").collect(),
|
|
147
|
+
);
|
|
148
|
+
expect(sources.every((s) => s.reservedAmount === 0)).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("resumes a bounded collection under the same recurring worker operation", async () => {
|
|
152
|
+
const s = await largeHistory();
|
|
153
|
+
const worker = new PayoutWorkerClient(
|
|
154
|
+
component,
|
|
155
|
+
(_ctx, opts) => s.run(opts),
|
|
156
|
+
(_ctx, opts) =>
|
|
157
|
+
reconcileRecipientPayout(
|
|
158
|
+
s.provider as unknown as Stripe,
|
|
159
|
+
component,
|
|
160
|
+
s.ctx,
|
|
161
|
+
opts,
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
const first = await worker.run(s.ctx, { ...scope, maxPages: 1 });
|
|
165
|
+
expect(first).toMatchObject({
|
|
166
|
+
status: "completed",
|
|
167
|
+
payout: { status: "held", reason: "collection_pending" },
|
|
168
|
+
job: { hasOperation: true },
|
|
169
|
+
});
|
|
170
|
+
if (first.status !== "completed")
|
|
171
|
+
throw new Error("Expected collection progress");
|
|
172
|
+
const clock = vi.spyOn(Date, "now").mockReturnValue(Date.now() + 300000);
|
|
173
|
+
try {
|
|
174
|
+
const next = await worker.run(s.ctx, { ...scope, maxPages: 10 });
|
|
175
|
+
expect(next).toMatchObject({
|
|
176
|
+
status: "completed",
|
|
177
|
+
payout: { claim: { amount: 1025, status: "paid" } },
|
|
178
|
+
job: { sequence: first.job.sequence, hasOperation: false },
|
|
179
|
+
});
|
|
180
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
181
|
+
} finally {
|
|
182
|
+
clock.mockRestore();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("carries a complete larger history forward below the combined minimum", async () => {
|
|
187
|
+
const s = await largeHistory(4);
|
|
188
|
+
expect(await s.run()).toMatchObject({
|
|
189
|
+
status: "held",
|
|
190
|
+
reason: "below_threshold",
|
|
191
|
+
matureUnpaid: 820,
|
|
192
|
+
});
|
|
193
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
194
|
+
expect(
|
|
195
|
+
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
196
|
+
).toEqual([]);
|
|
197
|
+
expect(
|
|
198
|
+
await s.t.run((ctx) => ctx.db.query("payoutSnapshots").collect()),
|
|
199
|
+
).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
it("deducts known refund/dispute liabilities before qualifying a larger history", async () => {
|
|
202
|
+
const s = await largeHistory();
|
|
203
|
+
expect(await s.run({ independentLiabilities: 30 })).toMatchObject({
|
|
204
|
+
status: "held",
|
|
205
|
+
reason: "below_threshold",
|
|
206
|
+
matureUnpaid: 995,
|
|
207
|
+
});
|
|
208
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
it("does not verify provider data from another owner's historical claim", async () => {
|
|
211
|
+
const s = await largeHistory();
|
|
212
|
+
await s.t.run(async (ctx) => {
|
|
213
|
+
const claim = await ctx.db.query("payoutReleaseClaims").first();
|
|
214
|
+
await ctx.db.patch(claim!._id, { ownerKey: "other-owner" });
|
|
215
|
+
});
|
|
216
|
+
expect(await s.run()).toMatchObject({
|
|
217
|
+
status: "held",
|
|
218
|
+
reason: "collection_invalidated",
|
|
219
|
+
});
|
|
220
|
+
expect(s.provider.accounts.listExternalAccounts).not.toHaveBeenCalled();
|
|
221
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
import { setup } from "./payoutExecutionTestHelpers.js";
|
|
5
|
+
it("invalidates a liability version when refund costs increase before the attempt", async () => {
|
|
6
|
+
const s = await setup([600, 401]);
|
|
7
|
+
const cost = {
|
|
8
|
+
accountId: "acct_recipient",
|
|
9
|
+
scopeKey: "store:owner",
|
|
10
|
+
currency: "usd",
|
|
11
|
+
claimKey: "refund:new",
|
|
12
|
+
sourceChargeId: "ch_refunded",
|
|
13
|
+
sourceGross: 1001,
|
|
14
|
+
sourceProcessingFee: 2,
|
|
15
|
+
amountDue: 1,
|
|
16
|
+
revision: 1,
|
|
17
|
+
};
|
|
18
|
+
await s.t.mutation(
|
|
19
|
+
makeFunctionReference<"mutation">("costRecovery/mutations:record"),
|
|
20
|
+
cost,
|
|
21
|
+
);
|
|
22
|
+
const version = await s.t.run(
|
|
23
|
+
async (ctx) =>
|
|
24
|
+
(await ctx.db.query("payoutRiskRevisions").first())?.riskRevision ?? 0,
|
|
25
|
+
);
|
|
26
|
+
const verified = await s.verify();
|
|
27
|
+
s.verify.mockResolvedValueOnce(verified).mockImplementationOnce(async () => {
|
|
28
|
+
await s.t.mutation(
|
|
29
|
+
makeFunctionReference<"mutation">("costRecovery/mutations:record"),
|
|
30
|
+
{ ...cost, amountDue: 2, revision: 2 },
|
|
31
|
+
);
|
|
32
|
+
return verified;
|
|
33
|
+
});
|
|
34
|
+
await expect(
|
|
35
|
+
s.run({ independentLiabilities: 1, liabilityRevision: version }),
|
|
36
|
+
).rejects.toThrow(/liabilit|collection changed/);
|
|
37
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it.each(["new", "increase", "decrease"])(
|
|
41
|
+
"rejects %s recovery costs before attempt and pays only after a fresh quote",
|
|
42
|
+
async (kind) => {
|
|
43
|
+
const { CostRecoveryClient } = await import("../costRecovery.js");
|
|
44
|
+
const { component } = await import("./payoutExecutionTestHelpers.js");
|
|
45
|
+
const s = await setup([600, 405]);
|
|
46
|
+
const client = new CostRecoveryClient(component);
|
|
47
|
+
const cost = {
|
|
48
|
+
accountId: "acct_recipient",
|
|
49
|
+
scopeKey: "store:owner",
|
|
50
|
+
currency: "usd",
|
|
51
|
+
claimKey: "refund:new",
|
|
52
|
+
sourceChargeId: "ch_refunded",
|
|
53
|
+
sourceGross: 1005,
|
|
54
|
+
sourceProcessingFee: 2,
|
|
55
|
+
amountDue: kind === "new" ? 0 : kind === "increase" ? 1 : 2,
|
|
56
|
+
revision: 1,
|
|
57
|
+
};
|
|
58
|
+
await s.t.mutation(
|
|
59
|
+
makeFunctionReference<"mutation">("costRecovery/mutations:record"),
|
|
60
|
+
cost,
|
|
61
|
+
);
|
|
62
|
+
const quote = await client.payoutQuote(s.ctx, cost);
|
|
63
|
+
const changed = kind === "increase" ? 2 : 1;
|
|
64
|
+
const verified = await s.verify();
|
|
65
|
+
s.verify
|
|
66
|
+
.mockResolvedValueOnce(verified)
|
|
67
|
+
.mockImplementationOnce(async () => {
|
|
68
|
+
await s.t.mutation(
|
|
69
|
+
makeFunctionReference<"mutation">("costRecovery/mutations:record"),
|
|
70
|
+
{ ...cost, amountDue: changed, revision: 2 },
|
|
71
|
+
);
|
|
72
|
+
return verified;
|
|
73
|
+
});
|
|
74
|
+
await expect(s.run(quote)).rejects.toThrow(/collection changed/);
|
|
75
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
76
|
+
expect(await s.run(quote)).toMatchObject({
|
|
77
|
+
status: "terminal",
|
|
78
|
+
claim: { status: "canceled" },
|
|
79
|
+
});
|
|
80
|
+
const fresh = await client.payoutQuote(s.ctx, cost);
|
|
81
|
+
expect(fresh.independentLiabilities).toBe(changed);
|
|
82
|
+
expect(fresh.liabilityRevision).toBeGreaterThan(quote.liabilityRevision);
|
|
83
|
+
expect(
|
|
84
|
+
await s.run({ ...fresh, operationKey: "fresh-payout" }),
|
|
85
|
+
).toMatchObject({
|
|
86
|
+
status: "updated",
|
|
87
|
+
claim: {
|
|
88
|
+
status: "paid",
|
|
89
|
+
amount: 1005 - changed,
|
|
90
|
+
liabilityRevision: fresh.liabilityRevision,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
it("accepts unchanged atomic quotes and excludes costs from another currency", async () => {
|
|
97
|
+
const { CostRecoveryClient } = await import("../costRecovery.js");
|
|
98
|
+
const { component } = await import("./payoutExecutionTestHelpers.js");
|
|
99
|
+
const s = await setup([600, 405]);
|
|
100
|
+
const client = new CostRecoveryClient(component);
|
|
101
|
+
const cost = {
|
|
102
|
+
accountId: "acct_recipient",
|
|
103
|
+
scopeKey: "store:owner",
|
|
104
|
+
currency: "eur",
|
|
105
|
+
claimKey: "refund:eur",
|
|
106
|
+
sourceChargeId: "ch_refunded",
|
|
107
|
+
sourceGross: 1005,
|
|
108
|
+
sourceProcessingFee: 2,
|
|
109
|
+
amountDue: 2,
|
|
110
|
+
revision: 1,
|
|
111
|
+
};
|
|
112
|
+
await s.t.mutation(
|
|
113
|
+
makeFunctionReference<"mutation">("costRecovery/mutations:record"),
|
|
114
|
+
cost,
|
|
115
|
+
);
|
|
116
|
+
const quote = await client.payoutQuote(s.ctx, { ...cost, currency: "usd" });
|
|
117
|
+
expect(quote.independentLiabilities).toBe(0);
|
|
118
|
+
await s.t.mutation(
|
|
119
|
+
makeFunctionReference<"mutation">("costRecovery/mutations:record"),
|
|
120
|
+
cost,
|
|
121
|
+
);
|
|
122
|
+
expect(await client.payoutQuote(s.ctx, { ...cost, currency: "usd" })).toEqual(
|
|
123
|
+
quote,
|
|
124
|
+
);
|
|
125
|
+
expect(await s.run(quote)).toMatchObject({
|
|
126
|
+
status: "updated",
|
|
127
|
+
claim: { status: "paid", amount: 1005, currency: "usd" },
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
it("rejects a quote that is already stale before qualification", async () => {
|
|
131
|
+
const { CostRecoveryClient } = await import("../costRecovery.js");
|
|
132
|
+
const { component } = await import("./payoutExecutionTestHelpers.js");
|
|
133
|
+
const s = await setup([600, 405]);
|
|
134
|
+
const client = new CostRecoveryClient(component);
|
|
135
|
+
const cost = {
|
|
136
|
+
accountId: "acct_recipient",
|
|
137
|
+
scopeKey: "store:owner",
|
|
138
|
+
currency: "usd",
|
|
139
|
+
claimKey: "refund:new",
|
|
140
|
+
sourceChargeId: "ch_refunded",
|
|
141
|
+
sourceGross: 1005,
|
|
142
|
+
sourceProcessingFee: 2,
|
|
143
|
+
amountDue: 1,
|
|
144
|
+
revision: 1,
|
|
145
|
+
};
|
|
146
|
+
await s.t.mutation(
|
|
147
|
+
makeFunctionReference<"mutation">("costRecovery/mutations:record"),
|
|
148
|
+
cost,
|
|
149
|
+
);
|
|
150
|
+
const quote = await client.payoutQuote(s.ctx, cost);
|
|
151
|
+
await s.t.mutation(
|
|
152
|
+
makeFunctionReference<"mutation">("costRecovery/mutations:record"),
|
|
153
|
+
{ ...cost, amountDue: 2, revision: 2 },
|
|
154
|
+
);
|
|
155
|
+
await expect(s.run(quote)).rejects.toThrow(/liability version changed/);
|
|
156
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
it("fails safely when a legacy unversioned caller gains recovery debt before attempt", async () => {
|
|
159
|
+
const s = await setup();
|
|
160
|
+
const verified = await s.verify();
|
|
161
|
+
s.verify.mockResolvedValueOnce(verified).mockImplementationOnce(async () => {
|
|
162
|
+
await s.t.mutation(
|
|
163
|
+
makeFunctionReference<"mutation">("costRecovery/mutations:record"),
|
|
164
|
+
{
|
|
165
|
+
accountId: "acct_recipient",
|
|
166
|
+
scopeKey: "store:owner",
|
|
167
|
+
currency: "usd",
|
|
168
|
+
claimKey: "refund:new",
|
|
169
|
+
sourceChargeId: "ch_refunded",
|
|
170
|
+
sourceGross: 1000,
|
|
171
|
+
sourceProcessingFee: 1,
|
|
172
|
+
amountDue: 1,
|
|
173
|
+
revision: 1,
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
return verified;
|
|
177
|
+
});
|
|
178
|
+
await expect(s.run()).rejects.toThrow(/versioned recovery liability quote/);
|
|
179
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("protects a versioned quote across a larger paginated collection", async () => {
|
|
183
|
+
const { CostRecoveryClient } = await import("../costRecovery.js");
|
|
184
|
+
const { component } = await import("./payoutExecutionTestHelpers.js");
|
|
185
|
+
const s = await setup(Array.from({ length: 205 }, () => 5));
|
|
186
|
+
const client = new CostRecoveryClient(component);
|
|
187
|
+
const cost = {
|
|
188
|
+
accountId: "acct_recipient",
|
|
189
|
+
scopeKey: "store:owner",
|
|
190
|
+
currency: "usd",
|
|
191
|
+
claimKey: "refund:new",
|
|
192
|
+
sourceChargeId: "ch_refunded",
|
|
193
|
+
sourceGross: 1025,
|
|
194
|
+
sourceProcessingFee: 2,
|
|
195
|
+
amountDue: 1,
|
|
196
|
+
revision: 1,
|
|
197
|
+
};
|
|
198
|
+
await s.t.mutation(
|
|
199
|
+
makeFunctionReference<"mutation">("costRecovery/mutations:record"),
|
|
200
|
+
cost,
|
|
201
|
+
);
|
|
202
|
+
const quote = await client.payoutQuote(s.ctx, cost);
|
|
203
|
+
const verified = await s.verify();
|
|
204
|
+
s.verify.mockResolvedValueOnce(verified).mockImplementationOnce(async () => {
|
|
205
|
+
await s.t.mutation(
|
|
206
|
+
makeFunctionReference<"mutation">("costRecovery/mutations:record"),
|
|
207
|
+
{ ...cost, amountDue: 2, revision: 2 },
|
|
208
|
+
);
|
|
209
|
+
return verified;
|
|
210
|
+
});
|
|
211
|
+
await expect(s.run(quote)).rejects.toThrow(/collection changed/);
|
|
212
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
213
|
+
await s.run(quote);
|
|
214
|
+
const fresh = await client.payoutQuote(s.ctx, cost);
|
|
215
|
+
expect(await s.run({ ...fresh, operationKey: "fresh-large" })).toMatchObject({
|
|
216
|
+
status: "updated",
|
|
217
|
+
claim: { status: "paid", amount: 1023, independentLiabilities: 2 },
|
|
218
|
+
});
|
|
219
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
220
|
+
// This new integration case qualifies the full ledger twice, with a cancellation between.
|
|
221
|
+
}, 15_000);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { discardQualificationInput } from "./payoutCollectedInput.js";
|
|
1
2
|
import type Stripe from "stripe";
|
|
2
3
|
import type { Infer } from "convex/values";
|
|
3
4
|
import type { Component, RunCtx } from "../helpers.js";
|
|
@@ -13,6 +14,7 @@ export type PayoutPreviewOptions = {
|
|
|
13
14
|
ownerKey: string;
|
|
14
15
|
currency: string;
|
|
15
16
|
independentLiabilities: number;
|
|
17
|
+
liabilityRevision?: number;
|
|
16
18
|
maxPages?: number;
|
|
17
19
|
};
|
|
18
20
|
export type PayoutPreview =
|
|
@@ -51,29 +53,45 @@ export async function previewRecipientPayout(
|
|
|
51
53
|
opts,
|
|
52
54
|
maxPages,
|
|
53
55
|
);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
try {
|
|
57
|
+
if (proof.status !== "verified") return held(proof.reason);
|
|
58
|
+
const latest = await verify();
|
|
59
|
+
if (latest.status !== "verified" || !("policy" in latest))
|
|
60
|
+
return held(latest.status);
|
|
61
|
+
if (latest.policy.revision !== controls.policy.revision)
|
|
62
|
+
return held("policy_changed");
|
|
63
|
+
if (
|
|
64
|
+
proof.observedAt < Date.now() - 30000 ||
|
|
65
|
+
proof.observations.some(
|
|
66
|
+
(o) =>
|
|
67
|
+
o.currency === opts.currency && o.observedAt < Date.now() - 30000,
|
|
68
|
+
)
|
|
64
69
|
)
|
|
65
|
-
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
70
|
+
return held("observations_stale");
|
|
71
|
+
return await ctx.runQuery(
|
|
72
|
+
componentRef(component, "payoutRelease/queries/preview"),
|
|
73
|
+
{
|
|
74
|
+
accountId: opts.accountId,
|
|
75
|
+
ownerKey: opts.ownerKey,
|
|
76
|
+
currency: opts.currency,
|
|
77
|
+
expectedRevision: controls.policy.revision,
|
|
78
|
+
observations: proof.observations,
|
|
79
|
+
...(opts.liabilityRevision === undefined
|
|
80
|
+
? {}
|
|
81
|
+
: { liabilityRevision: opts.liabilityRevision }),
|
|
82
|
+
...(proof.collection
|
|
83
|
+
? { snapshotId: proof.collection.snapshotId }
|
|
84
|
+
: {}),
|
|
85
|
+
providerAvailable: proof.providerAvailable,
|
|
86
|
+
independentLiabilities: opts.independentLiabilities,
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
} finally {
|
|
90
|
+
await discardQualificationInput(
|
|
91
|
+
component,
|
|
92
|
+
ctx,
|
|
93
|
+
proof.collection,
|
|
94
|
+
proof.status === "held" && proof.reason === "collection_pending",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
79
97
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collectLargePayoutInput,
|
|
3
|
+
type CollectionRef,
|
|
4
|
+
type PayoutInput,
|
|
5
|
+
} from "./payoutCollectedInput.js";
|
|
1
6
|
import type Stripe from "stripe";
|
|
2
7
|
import type { Infer } from "convex/values";
|
|
3
8
|
import type { Component, RunCtx } from "../helpers.js";
|
|
@@ -30,6 +35,8 @@ export type PayoutProofScope = {
|
|
|
30
35
|
accountId: string;
|
|
31
36
|
ownerKey: string;
|
|
32
37
|
currency: string;
|
|
38
|
+
operationKey?: string;
|
|
39
|
+
liabilityRevision?: number;
|
|
33
40
|
};
|
|
34
41
|
/** Shared provider qualification. It can reconcile existing receipts, but never creates a bank payout. */
|
|
35
42
|
export async function collectPayoutQualification(
|
|
@@ -39,20 +46,45 @@ export async function collectPayoutQualification(
|
|
|
39
46
|
opts: PayoutProofScope,
|
|
40
47
|
maxPages: number,
|
|
41
48
|
): Promise<
|
|
42
|
-
| Held
|
|
49
|
+
| (Held & { collection?: CollectionRef })
|
|
43
50
|
| {
|
|
44
51
|
status: "verified";
|
|
52
|
+
collection?: CollectionRef;
|
|
45
53
|
observations: Infer<typeof observation>[];
|
|
46
54
|
destinationId: string;
|
|
47
55
|
providerAvailable: number;
|
|
48
56
|
observedAt: number;
|
|
49
57
|
}
|
|
50
58
|
> {
|
|
51
|
-
|
|
59
|
+
let snapshot = (await ctx.runQuery(
|
|
52
60
|
componentRef(component, "payoutRelease/queries/snapshot"),
|
|
53
61
|
{ accountId: opts.accountId, ownerKey: opts.ownerKey },
|
|
54
62
|
)) as Snapshot;
|
|
55
|
-
|
|
63
|
+
let collection: CollectionRef | undefined;
|
|
64
|
+
if (snapshot.status !== "ready" || opts.liabilityRevision !== undefined) {
|
|
65
|
+
const large = await collectLargePayoutInput(component, ctx, opts, maxPages);
|
|
66
|
+
if (large.status !== "ready") return large;
|
|
67
|
+
collection = large.collection;
|
|
68
|
+
snapshot = large;
|
|
69
|
+
}
|
|
70
|
+
const result = await qualifyInput(
|
|
71
|
+
stripe,
|
|
72
|
+
component,
|
|
73
|
+
ctx,
|
|
74
|
+
opts,
|
|
75
|
+
maxPages,
|
|
76
|
+
snapshot,
|
|
77
|
+
);
|
|
78
|
+
return { ...result, ...(collection ? { collection } : {}) };
|
|
79
|
+
}
|
|
80
|
+
async function qualifyInput(
|
|
81
|
+
stripe: Stripe,
|
|
82
|
+
component: Component,
|
|
83
|
+
ctx: RunCtx,
|
|
84
|
+
opts: PayoutProofScope,
|
|
85
|
+
maxPages: number,
|
|
86
|
+
snapshot: PayoutInput,
|
|
87
|
+
): Promise<Awaited<ReturnType<typeof collectPayoutQualification>>> {
|
|
56
88
|
const banks: string[] = [];
|
|
57
89
|
let bankCursor: string | undefined;
|
|
58
90
|
for (let page = 0; ; page++) {
|
|
@@ -109,24 +141,33 @@ export async function collectPayoutQualification(
|
|
|
109
141
|
transactions.push({ ...t, sourceId: result.receipt.stripePayoutId });
|
|
110
142
|
}
|
|
111
143
|
const observations = [];
|
|
112
|
-
for (
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
for (let i = 0; i < snapshot.transfers.length; i += 5) {
|
|
145
|
+
const batch = snapshot.transfers.slice(i, i + 5);
|
|
146
|
+
const receipts = await Promise.all(
|
|
147
|
+
batch.map((row) =>
|
|
148
|
+
getTransferFundingReceipt(stripe, component, ctx, {
|
|
149
|
+
accountId: opts.accountId,
|
|
150
|
+
stripeTransferId: row.stripeTransferId,
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
for (let j = 0; j < batch.length; j++) {
|
|
155
|
+
const row = batch[j],
|
|
156
|
+
receipt = receipts[j];
|
|
157
|
+
if (receipt.status !== "ready")
|
|
158
|
+
return { status: "held", reason: receipt.reason };
|
|
159
|
+
observations.push({
|
|
160
|
+
stripeTransferId: row.stripeTransferId,
|
|
161
|
+
currency: receipt.settlementCurrency,
|
|
162
|
+
matureNet: receipt.matureNet,
|
|
163
|
+
pendingCredits: receipt.pendingCredits,
|
|
164
|
+
ledgerFingerprint: transferFingerprint(row),
|
|
165
|
+
observedAt: receipt.observedAt,
|
|
166
|
+
transactionIds: receipt.transactions.map((t) => t.id),
|
|
167
|
+
});
|
|
168
|
+
if (receipt.settlementCurrency === opts.currency)
|
|
169
|
+
transactions.push(...receipt.transactions);
|
|
170
|
+
}
|
|
130
171
|
}
|
|
131
172
|
const history = await verifyPayoutHistory(stripe, {
|
|
132
173
|
accountId: opts.accountId,
|
|
@@ -127,6 +127,9 @@ export class PayoutWorkerClient {
|
|
|
127
127
|
...scope,
|
|
128
128
|
operationKey,
|
|
129
129
|
independentLiabilities: opts.independentLiabilities,
|
|
130
|
+
...(opts.liabilityRevision === undefined
|
|
131
|
+
? {}
|
|
132
|
+
: { liabilityRevision: opts.liabilityRevision }),
|
|
130
133
|
maxPages: opts.maxPages,
|
|
131
134
|
});
|
|
132
135
|
} catch (error) {
|
|
@@ -163,7 +166,7 @@ export function payoutAttemptStatus(
|
|
|
163
166
|
: (job.lastReason ?? "pending_claim");
|
|
164
167
|
const status = running
|
|
165
168
|
? "running"
|
|
166
|
-
: job.hasOperation
|
|
169
|
+
: job.hasOperation && reason !== "collection_pending"
|
|
167
170
|
? "reconciling"
|
|
168
171
|
: ["balance_insufficient", "amount_too_small", "rejected"].includes(
|
|
169
172
|
reason,
|
|
@@ -241,6 +241,14 @@ export class CostRecoveryClient {
|
|
|
241
241
|
) {
|
|
242
242
|
return this.mutate<RecoveryClaim>(ctx, "mutations/rollback", opts);
|
|
243
243
|
}
|
|
244
|
+
/** Host authorizes the recovery scope; pass this atomic quote unchanged to payout qualification. */
|
|
245
|
+
payoutQuote(ctx: RunCtx, opts: RecoveryScope) {
|
|
246
|
+
const { accountId, scopeKey, currency } = opts;
|
|
247
|
+
return this.query<{
|
|
248
|
+
independentLiabilities: number;
|
|
249
|
+
liabilityRevision: number;
|
|
250
|
+
}>(ctx, "payoutQuote", { accountId, scopeKey, currency });
|
|
251
|
+
}
|
|
244
252
|
balance(ctx: RunCtx, opts: RecoveryScope) {
|
|
245
253
|
return this.query<RecoveryBalance>(ctx, "balance", opts);
|
|
246
254
|
}
|