@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +13 -0
  3. package/README.md +1 -1
  4. package/dist/component/_generated/component.d.ts +8 -0
  5. package/dist/component/_generated/component.d.ts.map +1 -1
  6. package/dist/component/affiliatePayouts/queries.d.ts.map +1 -1
  7. package/dist/component/affiliatePayouts/queries.js +3 -1
  8. package/dist/component/affiliatePayouts/queries.js.map +1 -1
  9. package/dist/component/costRecovery/queries.d.ts.map +1 -1
  10. package/dist/component/costRecovery/queries.js +3 -1
  11. package/dist/component/costRecovery/queries.js.map +1 -1
  12. package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
  13. package/dist/component/payoutPolicy/queries.js +3 -1
  14. package/dist/component/payoutPolicy/queries.js.map +1 -1
  15. package/dist/component/payoutRelease/allocationTotals.d.ts +8 -0
  16. package/dist/component/payoutRelease/allocationTotals.d.ts.map +1 -0
  17. package/dist/component/payoutRelease/allocationTotals.js +43 -0
  18. package/dist/component/payoutRelease/allocationTotals.js.map +1 -0
  19. package/dist/component/payoutRelease/planning.d.ts.map +1 -1
  20. package/dist/component/payoutRelease/planning.js +2 -0
  21. package/dist/component/payoutRelease/planning.js.map +1 -1
  22. package/dist/component/payoutRelease/preview.d.ts +35 -2
  23. package/dist/component/payoutRelease/preview.d.ts.map +1 -1
  24. package/dist/component/payoutRelease/preview.js +6 -11
  25. package/dist/component/payoutRelease/preview.js.map +1 -1
  26. package/dist/component/payoutRelease/progress.d.ts +29 -0
  27. package/dist/component/payoutRelease/progress.d.ts.map +1 -0
  28. package/dist/component/payoutRelease/progress.js +40 -0
  29. package/dist/component/payoutRelease/progress.js.map +1 -0
  30. package/dist/component/payoutRelease/queries.d.ts +8 -0
  31. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  32. package/dist/component/payoutSnapshots/mutations.d.ts.map +1 -1
  33. package/dist/component/payoutSnapshots/mutations.js +6 -4
  34. package/dist/component/payoutSnapshots/mutations.js.map +1 -1
  35. package/dist/component/payoutWorker/queries.d.ts.map +1 -1
  36. package/dist/component/payoutWorker/queries.js +5 -3
  37. package/dist/component/payoutWorker/queries.js.map +1 -1
  38. package/dist/react/workflows/PayoutProgress.d.ts.map +1 -1
  39. package/dist/react/workflows/PayoutProgress.js +2 -2
  40. package/dist/react/workflows/PayoutProgress.js.map +1 -1
  41. package/dist/react/workflows/payout-progress-state.d.ts +8 -1
  42. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
  43. package/dist/react/workflows/payout-progress-state.js +31 -0
  44. package/dist/react/workflows/payout-progress-state.js.map +1 -1
  45. package/dist/testing/component-db.d.ts +3 -0
  46. package/dist/testing/component-db.d.ts.map +1 -0
  47. package/dist/testing/component-db.js +26 -0
  48. package/dist/testing/component-db.js.map +1 -0
  49. package/docs/react-workflows.md +8 -1
  50. package/docs/settlement.md +33 -0
  51. package/package.json +1 -1
  52. package/src/client/connect/payoutPreview.test.ts +145 -0
  53. package/src/component/_generated/api.ts +4 -0
  54. package/src/component/_generated/component.ts +8 -0
  55. package/src/component/affiliate-component-pagination.test.ts +1 -25
  56. package/src/component/affiliatePayouts/queries.ts +3 -1
  57. package/src/component/costRecovery/queries.ts +3 -1
  58. package/src/component/payout-component-pagination.test.ts +212 -0
  59. package/src/component/payoutPolicy/queries.ts +3 -1
  60. package/src/component/payoutRelease/allocationTotals.ts +56 -0
  61. package/src/component/payoutRelease/planning.ts +2 -0
  62. package/src/component/payoutRelease/preview.ts +10 -20
  63. package/src/component/payoutRelease/progress.test.ts +63 -0
  64. package/src/component/payoutRelease/progress.ts +56 -0
  65. package/src/component/payoutSnapshots/mutations.ts +6 -4
  66. package/src/component/payoutWorker/queries.ts +5 -3
  67. package/src/react/workflows/PayoutProgress.test.tsx +39 -0
  68. package/src/react/workflows/PayoutProgress.tsx +14 -0
  69. package/src/react/workflows/payout-progress-state.ts +48 -1
  70. 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 sources = (["store", "affiliate", "other"] as const).map((role) => {
82
- let matureUnpaid = 0,
83
- pendingCredits = 0;
84
- for (const source of funding)
85
- if (
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
- sources,
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