@fantastic.dev/better-stripe 0.27.0 → 0.28.1
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 +13 -0
- package/README.md +1 -1
- package/dist/component/_generated/component.d.ts +8 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/affiliatePayouts/queries.d.ts.map +1 -1
- package/dist/component/affiliatePayouts/queries.js +3 -1
- package/dist/component/affiliatePayouts/queries.js.map +1 -1
- package/dist/component/costRecovery/queries.d.ts.map +1 -1
- package/dist/component/costRecovery/queries.js +3 -1
- package/dist/component/costRecovery/queries.js.map +1 -1
- package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
- package/dist/component/payoutPolicy/queries.js +3 -1
- package/dist/component/payoutPolicy/queries.js.map +1 -1
- package/dist/component/payoutRelease/allocationTotals.d.ts +8 -0
- package/dist/component/payoutRelease/allocationTotals.d.ts.map +1 -0
- package/dist/component/payoutRelease/allocationTotals.js +43 -0
- package/dist/component/payoutRelease/allocationTotals.js.map +1 -0
- package/dist/component/payoutRelease/planning.d.ts.map +1 -1
- package/dist/component/payoutRelease/planning.js +2 -0
- package/dist/component/payoutRelease/planning.js.map +1 -1
- package/dist/component/payoutRelease/preview.d.ts +35 -2
- package/dist/component/payoutRelease/preview.d.ts.map +1 -1
- package/dist/component/payoutRelease/preview.js +6 -11
- package/dist/component/payoutRelease/preview.js.map +1 -1
- package/dist/component/payoutRelease/progress.d.ts +29 -0
- package/dist/component/payoutRelease/progress.d.ts.map +1 -0
- package/dist/component/payoutRelease/progress.js +40 -0
- package/dist/component/payoutRelease/progress.js.map +1 -0
- package/dist/component/payoutRelease/queries.d.ts +8 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutSnapshots/mutations.d.ts.map +1 -1
- package/dist/component/payoutSnapshots/mutations.js +6 -4
- package/dist/component/payoutSnapshots/mutations.js.map +1 -1
- package/dist/component/payoutWorker/queries.d.ts.map +1 -1
- package/dist/component/payoutWorker/queries.js +5 -3
- package/dist/component/payoutWorker/queries.js.map +1 -1
- package/dist/react/workflows/PayoutProgress.d.ts.map +1 -1
- package/dist/react/workflows/PayoutProgress.js +2 -2
- package/dist/react/workflows/PayoutProgress.js.map +1 -1
- package/dist/react/workflows/payout-progress-state.d.ts +8 -1
- package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
- package/dist/react/workflows/payout-progress-state.js +31 -0
- package/dist/react/workflows/payout-progress-state.js.map +1 -1
- package/dist/testing/component-db.d.ts +3 -0
- package/dist/testing/component-db.d.ts.map +1 -0
- package/dist/testing/component-db.js +26 -0
- package/dist/testing/component-db.js.map +1 -0
- package/docs/react-workflows.md +8 -1
- package/docs/settlement.md +33 -0
- package/package.json +1 -1
- package/src/client/connect/payoutPreview.test.ts +145 -0
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +8 -0
- package/src/component/affiliate-component-pagination.test.ts +1 -25
- package/src/component/affiliatePayouts/queries.ts +3 -1
- package/src/component/costRecovery/queries.ts +3 -1
- package/src/component/payout-component-pagination.test.ts +212 -0
- package/src/component/payoutPolicy/queries.ts +3 -1
- package/src/component/payoutRelease/allocationTotals.ts +56 -0
- package/src/component/payoutRelease/planning.ts +2 -0
- package/src/component/payoutRelease/preview.ts +10 -20
- package/src/component/payoutRelease/progress.test.ts +63 -0
- package/src/component/payoutRelease/progress.ts +56 -0
- package/src/component/payoutSnapshots/mutations.ts +6 -4
- package/src/component/payoutWorker/queries.ts +5 -3
- package/src/react/workflows/PayoutProgress.test.tsx +39 -0
- package/src/react/workflows/PayoutProgress.tsx +14 -0
- package/src/react/workflows/payout-progress-state.ts +48 -1
- package/src/testing/component-db.ts +24 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { convexTest } from "convex-test";
|
|
3
|
+
import { expect, it } from "vitest";
|
|
4
|
+
import type {
|
|
5
|
+
DefaultFunctionArgs,
|
|
6
|
+
RegisteredQuery,
|
|
7
|
+
RegisteredMutation,
|
|
8
|
+
} from "convex/server";
|
|
9
|
+
import type { MutationCtx } from "./_generated/server.js";
|
|
10
|
+
import { componentDb } from "../testing/component-db.js";
|
|
11
|
+
import schema from "./schema.js";
|
|
12
|
+
import { policies, changing, recovering } from "./payoutWorker/queries.js";
|
|
13
|
+
import { due } from "./payoutPolicy/queries.js";
|
|
14
|
+
import { pendingPayouts } from "./affiliatePayouts/queries.js";
|
|
15
|
+
import { listClaims } from "./costRecovery/queries.js";
|
|
16
|
+
import { begin, advance } from "./payoutSnapshots/mutations.js";
|
|
17
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
18
|
+
function handler<Args extends DefaultFunctionArgs, Result>(
|
|
19
|
+
fn:
|
|
20
|
+
| RegisteredQuery<"public", Args, Result>
|
|
21
|
+
| RegisteredMutation<"public", Args, Result>,
|
|
22
|
+
) {
|
|
23
|
+
const run = (
|
|
24
|
+
fn as unknown as { _handler: (ctx: MutationCtx, args: Args) => Result }
|
|
25
|
+
)._handler;
|
|
26
|
+
return (ctx: MutationCtx, args: Args) =>
|
|
27
|
+
run({ ...ctx, db: componentDb(ctx.db) }, args);
|
|
28
|
+
}
|
|
29
|
+
const options = { numItems: 1, cursor: null };
|
|
30
|
+
const policy = {
|
|
31
|
+
ownerKey: "owner",
|
|
32
|
+
mode: "custom" as const,
|
|
33
|
+
status: "ready" as const,
|
|
34
|
+
revision: 0,
|
|
35
|
+
operationKey: "enable",
|
|
36
|
+
country: "US",
|
|
37
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
38
|
+
holdDeadline: Date.now() - 1,
|
|
39
|
+
};
|
|
40
|
+
it("pages ready, changing and expired policies across the deployed component boundary", async () => {
|
|
41
|
+
const t = convexTest(schema, modules);
|
|
42
|
+
await t.run(async (ctx) => {
|
|
43
|
+
for (let i = 0; i < 3; i++)
|
|
44
|
+
await ctx.db.insert("payoutPolicies", {
|
|
45
|
+
...policy,
|
|
46
|
+
accountId: `acct_${i}`,
|
|
47
|
+
});
|
|
48
|
+
await ctx.db.insert("payoutPolicies", {
|
|
49
|
+
...policy,
|
|
50
|
+
accountId: "acct_changing",
|
|
51
|
+
status: "changing",
|
|
52
|
+
});
|
|
53
|
+
await ctx.db.insert("payoutPolicies", {
|
|
54
|
+
...policy,
|
|
55
|
+
accountId: "acct_default",
|
|
56
|
+
mode: "stripe_managed",
|
|
57
|
+
holdDeadline: undefined,
|
|
58
|
+
});
|
|
59
|
+
const accounts: string[] = [];
|
|
60
|
+
let cursor: string | null = null;
|
|
61
|
+
for (let i = 0; i < 6; i++) {
|
|
62
|
+
const p = await handler(policies)(ctx, {
|
|
63
|
+
paginationOpts: { ...options, cursor },
|
|
64
|
+
});
|
|
65
|
+
accounts.push(...p.page.map((row) => row.accountId));
|
|
66
|
+
cursor = p.continueCursor;
|
|
67
|
+
if (p.isDone) break;
|
|
68
|
+
}
|
|
69
|
+
expect(accounts).toEqual(["acct_0", "acct_1", "acct_2"]);
|
|
70
|
+
const changePage = await handler(changing)(ctx, {
|
|
71
|
+
paginationOpts: options,
|
|
72
|
+
});
|
|
73
|
+
expect(changePage.page).toMatchObject([{ accountId: "acct_changing" }]);
|
|
74
|
+
expect(
|
|
75
|
+
await handler(changing)(ctx, {
|
|
76
|
+
paginationOpts: { ...options, cursor: changePage.continueCursor },
|
|
77
|
+
}),
|
|
78
|
+
).toMatchObject({ page: [], isDone: true });
|
|
79
|
+
const expired: string[] = [];
|
|
80
|
+
cursor = null;
|
|
81
|
+
for (let i = 0; i < 6; i++) {
|
|
82
|
+
const p = await handler(due)(ctx, {
|
|
83
|
+
now: Date.now(),
|
|
84
|
+
paginationOpts: { ...options, cursor },
|
|
85
|
+
});
|
|
86
|
+
expired.push(...p.page.map((row) => row.accountId));
|
|
87
|
+
cursor = p.continueCursor;
|
|
88
|
+
if (p.isDone) break;
|
|
89
|
+
}
|
|
90
|
+
expect(expired).toEqual(["acct_0", "acct_1", "acct_2", "acct_changing"]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
it("pages only due unresolved worker operations", async () => {
|
|
94
|
+
const t = convexTest(schema, modules);
|
|
95
|
+
await t.run(async (ctx) => {
|
|
96
|
+
const base = {
|
|
97
|
+
accountId: "acct_one",
|
|
98
|
+
ownerKey: "owner",
|
|
99
|
+
currency: "usd",
|
|
100
|
+
generation: 1,
|
|
101
|
+
sequence: 1,
|
|
102
|
+
cadenceMs: 60000,
|
|
103
|
+
nextAttemptAt: Date.now() - 1,
|
|
104
|
+
hasOperation: true,
|
|
105
|
+
};
|
|
106
|
+
for (let i = 0; i < 3; i++)
|
|
107
|
+
await ctx.db.insert("payoutWorkerJobs", {
|
|
108
|
+
...base,
|
|
109
|
+
accountId: `acct_${i}`,
|
|
110
|
+
});
|
|
111
|
+
await ctx.db.insert("payoutWorkerJobs", {
|
|
112
|
+
...base,
|
|
113
|
+
accountId: "acct_future",
|
|
114
|
+
nextAttemptAt: Date.now() + 86400000,
|
|
115
|
+
});
|
|
116
|
+
await ctx.db.insert("payoutWorkerJobs", {
|
|
117
|
+
...base,
|
|
118
|
+
accountId: "acct_idle",
|
|
119
|
+
hasOperation: false,
|
|
120
|
+
});
|
|
121
|
+
const accounts: string[] = [];
|
|
122
|
+
let cursor: string | null = null;
|
|
123
|
+
for (let i = 0; i < 6; i++) {
|
|
124
|
+
const p = await handler(recovering)(ctx, {
|
|
125
|
+
paginationOpts: { ...options, cursor },
|
|
126
|
+
});
|
|
127
|
+
accounts.push(...p.page.map((row) => row.accountId));
|
|
128
|
+
cursor = p.continueCursor;
|
|
129
|
+
if (p.isDone) break;
|
|
130
|
+
}
|
|
131
|
+
expect(accounts).toEqual(["acct_0", "acct_1", "acct_2"]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
it("reads affiliate reconciliation and seller cost history without native pagination", async () => {
|
|
135
|
+
const t = convexTest(schema, modules);
|
|
136
|
+
await t.run(async (ctx) => {
|
|
137
|
+
expect(
|
|
138
|
+
await handler(pendingPayouts)(ctx, {
|
|
139
|
+
state: "unknown",
|
|
140
|
+
paginationOpts: options,
|
|
141
|
+
}),
|
|
142
|
+
).toMatchObject({ page: [], isDone: true });
|
|
143
|
+
expect(
|
|
144
|
+
await handler(listClaims)(ctx, {
|
|
145
|
+
accountId: "acct_one",
|
|
146
|
+
scopeKey: "store_one",
|
|
147
|
+
currency: "usd",
|
|
148
|
+
paginationOpts: options,
|
|
149
|
+
}),
|
|
150
|
+
).toMatchObject({ page: [], isDone: true });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
it("continues every local collection phase across bounded component-compatible pages", async () => {
|
|
154
|
+
const t = convexTest(schema, modules);
|
|
155
|
+
const scope = {
|
|
156
|
+
accountId: "acct_one",
|
|
157
|
+
ownerKey: "owner",
|
|
158
|
+
operationKey: "collect",
|
|
159
|
+
};
|
|
160
|
+
await t.run(async (ctx) => {
|
|
161
|
+
for (let i = 0; i < 31; i++)
|
|
162
|
+
await ctx.db.insert("transfers", {
|
|
163
|
+
stripeTransferId: `tr_${i}`,
|
|
164
|
+
destinationAccountId: scope.accountId,
|
|
165
|
+
amount: 1000,
|
|
166
|
+
currency: "usd",
|
|
167
|
+
role: "store",
|
|
168
|
+
status: "paid",
|
|
169
|
+
});
|
|
170
|
+
await ctx.db.insert("payoutSourceAllocations", {
|
|
171
|
+
accountId: scope.accountId,
|
|
172
|
+
ownerKey: scope.ownerKey,
|
|
173
|
+
stripeTransferId: "tr_0",
|
|
174
|
+
currency: "usd",
|
|
175
|
+
paidAmount: 10,
|
|
176
|
+
reservedAmount: 0,
|
|
177
|
+
});
|
|
178
|
+
await ctx.db.insert("payoutFeeBalances", {
|
|
179
|
+
accountId: scope.accountId,
|
|
180
|
+
ownerKey: scope.ownerKey,
|
|
181
|
+
currency: "usd",
|
|
182
|
+
amount: 2,
|
|
183
|
+
revision: 0,
|
|
184
|
+
});
|
|
185
|
+
await ctx.db.insert("payoutReleaseClaims", {
|
|
186
|
+
...scope,
|
|
187
|
+
operationKey: "previous",
|
|
188
|
+
expectedRevision: 0,
|
|
189
|
+
currency: "usd",
|
|
190
|
+
amount: 10,
|
|
191
|
+
status: "paid",
|
|
192
|
+
stripePayoutId: "po_previous",
|
|
193
|
+
allocations: [{ stripeTransferId: "tr_0", amount: 10, role: "store" }],
|
|
194
|
+
observations: [],
|
|
195
|
+
});
|
|
196
|
+
let state = await handler(begin)(ctx, { ...scope, expectedRevision: -1 });
|
|
197
|
+
for (let i = 0; i < 30 && state.phase !== "local_ready"; i++)
|
|
198
|
+
state = await handler(advance)(ctx, {
|
|
199
|
+
...scope,
|
|
200
|
+
expectedStep: state.step,
|
|
201
|
+
batchSize: 5,
|
|
202
|
+
});
|
|
203
|
+
expect(state).toMatchObject({
|
|
204
|
+
phase: "local_ready",
|
|
205
|
+
sourceCount: 31,
|
|
206
|
+
allocationCount: 1,
|
|
207
|
+
claimCount: 1,
|
|
208
|
+
feeCount: 1,
|
|
209
|
+
readOnly: true,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
2
|
+
import schema from "../schema.js";
|
|
1
3
|
import { paginationOptsValidator } from "convex/server";
|
|
2
4
|
import { v } from "convex/values";
|
|
3
5
|
import { query } from "../_generated/server.js";
|
|
@@ -41,7 +43,7 @@ export const due = query({
|
|
|
41
43
|
args.paginationOpts.numItems > 100
|
|
42
44
|
)
|
|
43
45
|
throw new Error("Invalid payout policy due page.");
|
|
44
|
-
const page = await ctx.db
|
|
46
|
+
const page = await paginator(ctx.db, schema)
|
|
45
47
|
.query("payoutPolicies")
|
|
46
48
|
.withIndex("by_deadline", (q) =>
|
|
47
49
|
q.gt("holdDeadline", 0).lte("holdDeadline", args.now),
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { QueryCtx, MutationCtx } from "../_generated/server.js";
|
|
2
|
+
import type { PayoutFundingSource } from "./allocation.js";
|
|
3
|
+
import { add } from "./helpers.js";
|
|
4
|
+
/** Match the maintained source rollups to all persisted claims before using them as financial facts. */
|
|
5
|
+
export async function assertAllocationTotals(
|
|
6
|
+
ctx: QueryCtx | MutationCtx,
|
|
7
|
+
scope: { accountId: string; ownerKey: string },
|
|
8
|
+
sources: PayoutFundingSource[],
|
|
9
|
+
) {
|
|
10
|
+
const claims = await ctx.db
|
|
11
|
+
.query("payoutReleaseClaims")
|
|
12
|
+
.withIndex("by_account_status", (q) => q.eq("accountId", scope.accountId))
|
|
13
|
+
.take(101);
|
|
14
|
+
if (claims.length > 100)
|
|
15
|
+
throw new Error("Payout claim snapshot is incomplete.");
|
|
16
|
+
const totals = new Map(
|
|
17
|
+
sources.map((source) => [
|
|
18
|
+
source.stripeTransferId,
|
|
19
|
+
{ source, paid: 0, reserved: 0 },
|
|
20
|
+
]),
|
|
21
|
+
);
|
|
22
|
+
for (const claim of claims) {
|
|
23
|
+
if (claim.ownerKey !== scope.ownerKey)
|
|
24
|
+
throw new Error("Payout claim owner does not match.");
|
|
25
|
+
let allocated = 0;
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
for (const allocation of claim.allocations) {
|
|
28
|
+
const total = totals.get(allocation.stripeTransferId);
|
|
29
|
+
if (
|
|
30
|
+
!total ||
|
|
31
|
+
total.source.currency !== claim.currency ||
|
|
32
|
+
seen.has(allocation.stripeTransferId) ||
|
|
33
|
+
!Number.isSafeInteger(allocation.amount) ||
|
|
34
|
+
allocation.amount <= 0
|
|
35
|
+
)
|
|
36
|
+
throw new Error("Payout claim allocation provenance is invalid.");
|
|
37
|
+
seen.add(allocation.stripeTransferId);
|
|
38
|
+
allocated = add(allocated, allocation.amount);
|
|
39
|
+
if (claim.status === "paid")
|
|
40
|
+
total.paid = add(total.paid, allocation.amount);
|
|
41
|
+
if (claim.status === "reserved" || claim.status === "submitted")
|
|
42
|
+
total.reserved = add(total.reserved, allocation.amount);
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
!Number.isSafeInteger(claim.amount) ||
|
|
46
|
+
claim.amount <= 0 ||
|
|
47
|
+
allocated !== claim.amount
|
|
48
|
+
)
|
|
49
|
+
throw new Error("Payout claim allocation principal does not match.");
|
|
50
|
+
}
|
|
51
|
+
for (const { source, paid, reserved } of totals.values())
|
|
52
|
+
if (source.paidAmount !== paid || source.reservedAmount !== reserved)
|
|
53
|
+
throw new Error(
|
|
54
|
+
"Payout allocation totals do not match persisted claims.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { observation } from "./observation.js";
|
|
|
5
5
|
import { checkSnapshot, sourceById } from "./helpers.js";
|
|
6
6
|
import { readFees } from "./fees.js";
|
|
7
7
|
import { planRecipientPayout, type PayoutFundingSource } from "./allocation.js";
|
|
8
|
+
import { assertAllocationTotals } from "./allocationTotals.js";
|
|
8
9
|
export async function readPayoutPlan(
|
|
9
10
|
ctx: MutationCtx | QueryCtx,
|
|
10
11
|
args: {
|
|
@@ -43,6 +44,7 @@ export async function readPayoutPlan(
|
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
46
|
const fees = await readFees(ctx, args);
|
|
47
|
+
await assertAllocationTotals(ctx, args, sources);
|
|
46
48
|
const plan = planRecipientPayout({
|
|
47
49
|
currency: args.currency,
|
|
48
50
|
threshold: minimum.amount,
|
|
@@ -6,6 +6,7 @@ import { observation } from "./observation.js";
|
|
|
6
6
|
import { readyPolicy, add } from "./helpers.js";
|
|
7
7
|
import { readPayoutPlan } from "./planning.js";
|
|
8
8
|
import { balance } from "../affiliateRecovery/accounting.js";
|
|
9
|
+
import { progressAccounting, summarizePayoutProgress } from "./progress.js";
|
|
9
10
|
export const previewFields = {
|
|
10
11
|
...scopeFields,
|
|
11
12
|
expectedRevision: v.number(),
|
|
@@ -34,6 +35,7 @@ export const previewResult = v.union(
|
|
|
34
35
|
independentLiabilities: v.number(),
|
|
35
36
|
providerAvailable: v.number(),
|
|
36
37
|
observedAt: v.number(),
|
|
38
|
+
accounting: v.optional(progressAccounting),
|
|
37
39
|
sources: v.array(
|
|
38
40
|
v.object({
|
|
39
41
|
role: v.union(
|
|
@@ -43,6 +45,8 @@ export const previewResult = v.union(
|
|
|
43
45
|
),
|
|
44
46
|
matureUnpaid: v.number(),
|
|
45
47
|
pendingCredits: v.number(),
|
|
48
|
+
releasedAmount: v.optional(v.number()),
|
|
49
|
+
reservedAmount: v.optional(v.number()),
|
|
46
50
|
}),
|
|
47
51
|
),
|
|
48
52
|
}),
|
|
@@ -78,25 +82,11 @@ export async function previewPlan(
|
|
|
78
82
|
fees,
|
|
79
83
|
threshold,
|
|
80
84
|
} = await readPayoutPlan(ctx, args, policy);
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
source.currency === args.currency &&
|
|
87
|
-
(source.role ?? "other") === role
|
|
88
|
-
) {
|
|
89
|
-
matureUnpaid = add(
|
|
90
|
-
matureUnpaid,
|
|
91
|
-
add(
|
|
92
|
-
add(source.matureNet, -source.paidAmount),
|
|
93
|
-
-source.reservedAmount,
|
|
94
|
-
),
|
|
95
|
-
);
|
|
96
|
-
pendingCredits = add(pendingCredits, source.pendingCredits);
|
|
97
|
-
}
|
|
98
|
-
return { role, matureUnpaid, pendingCredits };
|
|
99
|
-
});
|
|
85
|
+
const progress = summarizePayoutProgress(
|
|
86
|
+
plan,
|
|
87
|
+
funding,
|
|
88
|
+
add(fees?.amount ?? 0, args.independentLiabilities),
|
|
89
|
+
);
|
|
100
90
|
return {
|
|
101
91
|
status: plan.status,
|
|
102
92
|
...(plan.status === "held" ? { reason: plan.reason } : {}),
|
|
@@ -113,6 +103,6 @@ export async function previewPlan(
|
|
|
113
103
|
Date.now(),
|
|
114
104
|
...args.observations.map((o) => o.observedAt),
|
|
115
105
|
),
|
|
116
|
-
|
|
106
|
+
...progress,
|
|
117
107
|
};
|
|
118
108
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { planRecipientPayout } from "./allocation.js";
|
|
3
|
+
import { summarizePayoutProgress } from "./progress.js";
|
|
4
|
+
it("keeps paid, reserved, mature and pending amounts in their own currency", () => {
|
|
5
|
+
const sources = [
|
|
6
|
+
{
|
|
7
|
+
stripeTransferId: "tr_usd",
|
|
8
|
+
currency: "usd",
|
|
9
|
+
role: "store" as const,
|
|
10
|
+
matureNet: 1000,
|
|
11
|
+
paidAmount: 0,
|
|
12
|
+
reservedAmount: 0,
|
|
13
|
+
pendingCredits: 0,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
stripeTransferId: "tr_eur",
|
|
17
|
+
currency: "eur",
|
|
18
|
+
role: "affiliate" as const,
|
|
19
|
+
matureNet: 7000,
|
|
20
|
+
paidAmount: 2000,
|
|
21
|
+
reservedAmount: 500,
|
|
22
|
+
pendingCredits: 999,
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
const plan = planRecipientPayout({
|
|
26
|
+
currency: "usd",
|
|
27
|
+
threshold: 1000,
|
|
28
|
+
providerAvailable: 1000,
|
|
29
|
+
independentLiabilities: 0,
|
|
30
|
+
sources,
|
|
31
|
+
});
|
|
32
|
+
expect(summarizePayoutProgress(plan, sources, 0)).toEqual({
|
|
33
|
+
accounting: {
|
|
34
|
+
carriedForwardAmount: 0,
|
|
35
|
+
withheldAmount: 0,
|
|
36
|
+
releasedAmount: 0,
|
|
37
|
+
reservedAmount: 0,
|
|
38
|
+
},
|
|
39
|
+
sources: [
|
|
40
|
+
{
|
|
41
|
+
role: "store",
|
|
42
|
+
matureUnpaid: 1000,
|
|
43
|
+
pendingCredits: 0,
|
|
44
|
+
releasedAmount: 0,
|
|
45
|
+
reservedAmount: 0,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
role: "affiliate",
|
|
49
|
+
matureUnpaid: 0,
|
|
50
|
+
pendingCredits: 0,
|
|
51
|
+
releasedAmount: 0,
|
|
52
|
+
reservedAmount: 0,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
role: "other",
|
|
56
|
+
matureUnpaid: 0,
|
|
57
|
+
pendingCredits: 0,
|
|
58
|
+
releasedAmount: 0,
|
|
59
|
+
reservedAmount: 0,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import type { PayoutFundingSource, RecipientPayoutPlan } from "./allocation.js";
|
|
3
|
+
import { add } from "./helpers.js";
|
|
4
|
+
export const progressAccounting = v.object({
|
|
5
|
+
carriedForwardAmount: v.number(),
|
|
6
|
+
withheldAmount: v.number(),
|
|
7
|
+
reservedAmount: v.number(),
|
|
8
|
+
releasedAmount: v.number(),
|
|
9
|
+
});
|
|
10
|
+
/** Inputs have passed allocation validation. Shared costs are never attributed to a source category. */
|
|
11
|
+
export function summarizePayoutProgress(
|
|
12
|
+
plan: RecipientPayoutPlan,
|
|
13
|
+
funding: PayoutFundingSource[],
|
|
14
|
+
costs: number,
|
|
15
|
+
) {
|
|
16
|
+
const sources = (["store", "affiliate", "other"] as const).map((role) => {
|
|
17
|
+
let matureUnpaid = 0,
|
|
18
|
+
pendingCredits = 0,
|
|
19
|
+
releasedAmount = 0,
|
|
20
|
+
reservedAmount = 0;
|
|
21
|
+
for (const source of funding)
|
|
22
|
+
if (
|
|
23
|
+
source.currency === plan.currency &&
|
|
24
|
+
(source.role ?? "other") === role
|
|
25
|
+
) {
|
|
26
|
+
matureUnpaid = add(
|
|
27
|
+
matureUnpaid,
|
|
28
|
+
add(
|
|
29
|
+
add(source.matureNet, -source.paidAmount),
|
|
30
|
+
-source.reservedAmount,
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
pendingCredits = add(pendingCredits, source.pendingCredits);
|
|
34
|
+
releasedAmount = add(releasedAmount, source.paidAmount);
|
|
35
|
+
reservedAmount = add(reservedAmount, source.reservedAmount);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
role,
|
|
39
|
+
matureUnpaid,
|
|
40
|
+
pendingCredits,
|
|
41
|
+
releasedAmount,
|
|
42
|
+
reservedAmount,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
const total = (field: "matureUnpaid" | "releasedAmount" | "reservedAmount") =>
|
|
46
|
+
sources.reduce((sum, source) => add(sum, source[field]), 0);
|
|
47
|
+
return {
|
|
48
|
+
sources,
|
|
49
|
+
accounting: {
|
|
50
|
+
carriedForwardAmount: Math.max(0, add(plan.matureUnpaid, -plan.amount)),
|
|
51
|
+
withheldAmount: Math.min(Math.max(0, total("matureUnpaid")), costs),
|
|
52
|
+
reservedAmount: total("reservedAmount"),
|
|
53
|
+
releasedAmount: total("releasedAmount"),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
2
|
+
import schema from "../schema.js";
|
|
1
3
|
import { v } from "convex/values";
|
|
2
4
|
import { mutation } from "../earnings/triggers.js";
|
|
3
5
|
import { readPolicy } from "../payoutPolicy/helpers.js";
|
|
@@ -119,7 +121,7 @@ export const advance = mutation({
|
|
|
119
121
|
const patch: Partial<Snapshot> = { step: h.step + 1 };
|
|
120
122
|
try {
|
|
121
123
|
if (h.phase === "sources") {
|
|
122
|
-
const page = await ctx.db
|
|
124
|
+
const page = await paginator(ctx.db, schema)
|
|
123
125
|
.query("transfers")
|
|
124
126
|
.withIndex("by_destination_account_id", (q) =>
|
|
125
127
|
q
|
|
@@ -132,7 +134,7 @@ export const advance = mutation({
|
|
|
132
134
|
patch.sourceCursor = page.continueCursor;
|
|
133
135
|
patch.phase = page.isDone ? "allocations" : "sources";
|
|
134
136
|
} else if (h.phase === "allocations") {
|
|
135
|
-
const page = await ctx.db
|
|
137
|
+
const page = await paginator(ctx.db, schema)
|
|
136
138
|
.query("payoutSourceAllocations")
|
|
137
139
|
.withIndex("by_account", (q) => q.eq("accountId", h!.accountId))
|
|
138
140
|
.paginate({ cursor: h.allocationCursor ?? null, numItems: batch });
|
|
@@ -141,7 +143,7 @@ export const advance = mutation({
|
|
|
141
143
|
patch.allocationCursor = page.continueCursor;
|
|
142
144
|
patch.phase = page.isDone ? "fees" : "allocations";
|
|
143
145
|
} else if (h.phase === "fees") {
|
|
144
|
-
const page = await ctx.db
|
|
146
|
+
const page = await paginator(ctx.db, schema)
|
|
145
147
|
.query("payoutFeeBalances")
|
|
146
148
|
.withIndex("by_account_currency", (q) =>
|
|
147
149
|
q.eq("accountId", h!.accountId),
|
|
@@ -152,7 +154,7 @@ export const advance = mutation({
|
|
|
152
154
|
patch.feeCursor = page.continueCursor;
|
|
153
155
|
patch.phase = page.isDone ? "claims" : "fees";
|
|
154
156
|
} else if (h.phase === "claims") {
|
|
155
|
-
const page = await ctx.db
|
|
157
|
+
const page = await paginator(ctx.db, schema)
|
|
156
158
|
.query("payoutReleaseClaims")
|
|
157
159
|
.withIndex("by_account_status", (q) =>
|
|
158
160
|
q.eq("accountId", h!.accountId),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
2
|
+
import schema from "../schema.js";
|
|
1
3
|
import { paginationOptsValidator } from "convex/server";
|
|
2
4
|
import { v } from "convex/values";
|
|
3
5
|
import { query } from "../_generated/server.js";
|
|
@@ -18,7 +20,7 @@ export const policies = query({
|
|
|
18
20
|
}),
|
|
19
21
|
handler: async (ctx, args) => {
|
|
20
22
|
pageSize(args.paginationOpts.numItems);
|
|
21
|
-
const p = await ctx.db
|
|
23
|
+
const p = await paginator(ctx.db, schema)
|
|
22
24
|
.query("payoutPolicies")
|
|
23
25
|
.withIndex("by_mode_status", (q) =>
|
|
24
26
|
q.eq("mode", "custom").eq("status", "ready"),
|
|
@@ -36,7 +38,7 @@ export const changing = query({
|
|
|
36
38
|
}),
|
|
37
39
|
handler: async (ctx, args) => {
|
|
38
40
|
pageSize(args.paginationOpts.numItems);
|
|
39
|
-
const p = await ctx.db
|
|
41
|
+
const p = await paginator(ctx.db, schema)
|
|
40
42
|
.query("payoutPolicies")
|
|
41
43
|
.withIndex("by_status", (q) => q.eq("status", "changing"))
|
|
42
44
|
.paginate(args.paginationOpts);
|
|
@@ -53,7 +55,7 @@ export const recovering = query({
|
|
|
53
55
|
}),
|
|
54
56
|
handler: async (ctx, args) => {
|
|
55
57
|
pageSize(args.paginationOpts.numItems);
|
|
56
|
-
const p = await ctx.db
|
|
58
|
+
const p = await paginator(ctx.db, schema)
|
|
57
59
|
.query("payoutWorkerJobs")
|
|
58
60
|
.withIndex("by_operation_pending", (q) =>
|
|
59
61
|
q.eq("hasOperation", true).lte("nextAttemptAt", Date.now()),
|
|
@@ -57,6 +57,45 @@ it("does not invent zero balances or progress for an unavailable assessment", ()
|
|
|
57
57
|
expect(screen.queryByRole("progressbar")).toBeNull();
|
|
58
58
|
expect(screen.queryByText("$0.00")).toBeNull();
|
|
59
59
|
});
|
|
60
|
+
it("shows verified carry forward, withholding, in-flight and paid totals separately", () => {
|
|
61
|
+
render(
|
|
62
|
+
<PayoutProgress
|
|
63
|
+
policy={policy}
|
|
64
|
+
currency="usd"
|
|
65
|
+
preview={{
|
|
66
|
+
...preview,
|
|
67
|
+
matureUnpaid: 899,
|
|
68
|
+
independentLiabilities: 100,
|
|
69
|
+
accounting: {
|
|
70
|
+
carriedForwardAmount: 899,
|
|
71
|
+
withheldAmount: 100,
|
|
72
|
+
reservedAmount: 200,
|
|
73
|
+
releasedAmount: 1000,
|
|
74
|
+
},
|
|
75
|
+
sources: [
|
|
76
|
+
{ ...preview.sources[0], reservedAmount: 100, releasedAmount: 600 },
|
|
77
|
+
{ ...preview.sources[1], reservedAmount: 100, releasedAmount: 400 },
|
|
78
|
+
{ ...preview.sources[2], reservedAmount: 0, releasedAmount: 0 },
|
|
79
|
+
],
|
|
80
|
+
}}
|
|
81
|
+
/>,
|
|
82
|
+
);
|
|
83
|
+
for (const [label, value] of [
|
|
84
|
+
["Carried forward", "$8.99"],
|
|
85
|
+
["Withheld for costs", "$1.00"],
|
|
86
|
+
["Payouts in progress", "$2.00"],
|
|
87
|
+
["Paid out, net of returns", "$10.00"],
|
|
88
|
+
])
|
|
89
|
+
expect(screen.getByText(label).nextElementSibling?.textContent).toBe(value);
|
|
90
|
+
expect(screen.getByRole("columnheader", { name: "Paid out" })).toBeTruthy();
|
|
91
|
+
expect(screen.getByRole("columnheader", { name: "In flight" })).toBeTruthy();
|
|
92
|
+
});
|
|
93
|
+
it("does not invent new accounting totals for a legacy assessment", () => {
|
|
94
|
+
render(<PayoutProgress policy={policy} preview={preview} currency="usd" />);
|
|
95
|
+
expect(screen.queryByText("Carried forward")).toBeNull();
|
|
96
|
+
expect(screen.queryByText("Paid out, net of returns")).toBeNull();
|
|
97
|
+
expect(screen.queryByRole("columnheader", { name: "Paid out" })).toBeNull();
|
|
98
|
+
});
|
|
60
99
|
it("keeps Stripe-managed defaults distinct from a custom application minimum", () => {
|
|
61
100
|
render(<PayoutProgress policy={null} currency="usd" />);
|
|
62
101
|
expect(screen.getByRole("status").textContent).toContain("Stripe manages");
|
|
@@ -59,6 +59,8 @@ export function PayoutProgress(props: PayoutProgressProps) {
|
|
|
59
59
|
<th scope="col">Source</th>
|
|
60
60
|
<th scope="col">Mature unpaid</th>
|
|
61
61
|
<th scope="col">Pending settlement</th>
|
|
62
|
+
{state.accounting && <th scope="col">In flight</th>}
|
|
63
|
+
{state.accounting && <th scope="col">Paid out</th>}
|
|
62
64
|
</tr>
|
|
63
65
|
</thead>
|
|
64
66
|
<tbody>
|
|
@@ -73,12 +75,24 @@ export function PayoutProgress(props: PayoutProgressProps) {
|
|
|
73
75
|
</th>
|
|
74
76
|
<td>{source.matureLabel}</td>
|
|
75
77
|
<td>{source.pendingLabel}</td>
|
|
78
|
+
{state.accounting && (
|
|
79
|
+
<td>{source.reservedLabel ?? "Not assessed"}</td>
|
|
80
|
+
)}
|
|
81
|
+
{state.accounting && (
|
|
82
|
+
<td>{source.releasedLabel ?? "Not assessed"}</td>
|
|
83
|
+
)}
|
|
76
84
|
</tr>
|
|
77
85
|
))}
|
|
78
86
|
</tbody>
|
|
79
87
|
</table>
|
|
80
88
|
</>
|
|
81
89
|
)}
|
|
90
|
+
{state.accounting && (
|
|
91
|
+
<p>
|
|
92
|
+
Carried-forward funds remain owed. In-flight payouts are separate from
|
|
93
|
+
completed payouts. Paid-out amounts exclude returned payouts.
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
82
96
|
{state.nextAttemptAt !== null && (
|
|
83
97
|
<p>
|
|
84
98
|
Next automatic attempt: {date(state.nextAttemptAt)}. This is not a
|