@fantastic.dev/better-stripe 0.27.0 → 0.28.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/MIGRATION.md +6 -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/payoutRelease/allocationTotals.d.ts +8 -0
  7. package/dist/component/payoutRelease/allocationTotals.d.ts.map +1 -0
  8. package/dist/component/payoutRelease/allocationTotals.js +43 -0
  9. package/dist/component/payoutRelease/allocationTotals.js.map +1 -0
  10. package/dist/component/payoutRelease/planning.d.ts.map +1 -1
  11. package/dist/component/payoutRelease/planning.js +2 -0
  12. package/dist/component/payoutRelease/planning.js.map +1 -1
  13. package/dist/component/payoutRelease/preview.d.ts +35 -2
  14. package/dist/component/payoutRelease/preview.d.ts.map +1 -1
  15. package/dist/component/payoutRelease/preview.js +6 -11
  16. package/dist/component/payoutRelease/preview.js.map +1 -1
  17. package/dist/component/payoutRelease/progress.d.ts +29 -0
  18. package/dist/component/payoutRelease/progress.d.ts.map +1 -0
  19. package/dist/component/payoutRelease/progress.js +40 -0
  20. package/dist/component/payoutRelease/progress.js.map +1 -0
  21. package/dist/component/payoutRelease/queries.d.ts +8 -0
  22. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  23. package/dist/react/workflows/PayoutProgress.d.ts.map +1 -1
  24. package/dist/react/workflows/PayoutProgress.js +2 -2
  25. package/dist/react/workflows/PayoutProgress.js.map +1 -1
  26. package/dist/react/workflows/payout-progress-state.d.ts +8 -1
  27. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
  28. package/dist/react/workflows/payout-progress-state.js +31 -0
  29. package/dist/react/workflows/payout-progress-state.js.map +1 -1
  30. package/docs/react-workflows.md +8 -1
  31. package/docs/settlement.md +23 -0
  32. package/package.json +1 -1
  33. package/src/client/connect/payoutPreview.test.ts +145 -0
  34. package/src/component/_generated/api.ts +4 -0
  35. package/src/component/_generated/component.ts +8 -0
  36. package/src/component/payoutRelease/allocationTotals.ts +56 -0
  37. package/src/component/payoutRelease/planning.ts +2 -0
  38. package/src/component/payoutRelease/preview.ts +10 -20
  39. package/src/component/payoutRelease/progress.test.ts +63 -0
  40. package/src/component/payoutRelease/progress.ts +56 -0
  41. package/src/react/workflows/PayoutProgress.test.tsx +39 -0
  42. package/src/react/workflows/PayoutProgress.tsx +14 -0
  43. package/src/react/workflows/payout-progress-state.ts +48 -1
@@ -26,6 +26,12 @@ it("previews combined eligibility and source categories without creating or rese
26
26
  pendingCredits: 0,
27
27
  threshold: 1000,
28
28
  providerMinimum: null,
29
+ accounting: {
30
+ carriedForwardAmount: 0,
31
+ withheldAmount: 0,
32
+ reservedAmount: 0,
33
+ releasedAmount: 0,
34
+ },
29
35
  sources: [
30
36
  { role: "store", matureUnpaid: 600, pendingCredits: 0 },
31
37
  { role: "affiliate", matureUnpaid: 400, pendingCredits: 0 },
@@ -48,6 +54,12 @@ it("shows below-threshold carry forward and independent liabilities", async () =
48
54
  matureUnpaid: 899,
49
55
  amount: 0,
50
56
  independentLiabilities: 100,
57
+ accounting: {
58
+ carriedForwardAmount: 899,
59
+ withheldAmount: 100,
60
+ reservedAmount: 0,
61
+ releasedAmount: 0,
62
+ },
51
63
  });
52
64
  expect(s.provider.payouts.create).not.toHaveBeenCalled();
53
65
  });
@@ -71,6 +83,17 @@ it("subtracts a previously paid claim while verifying its current provider recei
71
83
  reason: "below_threshold",
72
84
  matureUnpaid: 0,
73
85
  amount: 0,
86
+ accounting: {
87
+ carriedForwardAmount: 0,
88
+ withheldAmount: 0,
89
+ reservedAmount: 0,
90
+ releasedAmount: 1000,
91
+ },
92
+ sources: [
93
+ { role: "store", releasedAmount: 600, reservedAmount: 0 },
94
+ { role: "affiliate", releasedAmount: 400, reservedAmount: 0 },
95
+ { role: "other", releasedAmount: 0, reservedAmount: 0 },
96
+ ],
74
97
  });
75
98
  expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
76
99
  });
@@ -145,6 +168,12 @@ it("deducts confirmed payout costs without attributing shared costs to a source
145
168
  reason: "below_threshold",
146
169
  matureUnpaid: 950,
147
170
  confirmedFees: 150,
171
+ accounting: {
172
+ carriedForwardAmount: 950,
173
+ withheldAmount: 150,
174
+ reservedAmount: 0,
175
+ releasedAmount: 0,
176
+ },
148
177
  sources: [
149
178
  { role: "store", matureUnpaid: 700 },
150
179
  { role: "affiliate", matureUnpaid: 400 },
@@ -259,6 +288,12 @@ it("preserves negative affiliate entitlement when a paid commission is refunded"
259
288
  reason: "below_threshold",
260
289
  amount: 0,
261
290
  matureUnpaid: -100,
291
+ accounting: {
292
+ carriedForwardAmount: 0,
293
+ withheldAmount: 0,
294
+ reservedAmount: 0,
295
+ releasedAmount: 1000,
296
+ },
262
297
  sources: [
263
298
  { role: "store", matureUnpaid: 0 },
264
299
  { role: "affiliate", matureUnpaid: -100 },
@@ -267,3 +302,113 @@ it("preserves negative affiliate entitlement when a paid commission is refunded"
267
302
  });
268
303
  expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
269
304
  });
305
+ it("caps actual withholding at mature unpaid funds when obligations exceed them", async () => {
306
+ const s = await preview([60, 40]);
307
+ expect(await s.preview({ independentLiabilities: 1000 })).toMatchObject({
308
+ matureUnpaid: -900,
309
+ accounting: {
310
+ carriedForwardAmount: 0,
311
+ withheldAmount: 100,
312
+ reservedAmount: 0,
313
+ releasedAmount: 0,
314
+ },
315
+ });
316
+ });
317
+ it("reports unpaid funds outside the supported-rail cap as carried forward", async () => {
318
+ const s = await preview([1000, 500]);
319
+ s.provider.balance.retrieve.mockResolvedValue({
320
+ available: [
321
+ { currency: "usd", amount: 1500, source_types: { card: 1000 } },
322
+ ],
323
+ pending: [],
324
+ });
325
+ expect(await s.preview()).toMatchObject({
326
+ status: "qualified",
327
+ amount: 1000,
328
+ matureUnpaid: 1500,
329
+ accounting: {
330
+ carriedForwardAmount: 500,
331
+ withheldAmount: 0,
332
+ reservedAmount: 0,
333
+ releasedAmount: 0,
334
+ },
335
+ });
336
+ });
337
+ it("refuses paid allocation totals without corresponding payout claims", async () => {
338
+ const s = await preview();
339
+ await s.t.run((ctx) =>
340
+ ctx.db.insert("payoutSourceAllocations", {
341
+ accountId: scope.accountId,
342
+ ownerKey: scope.ownerKey,
343
+ currency: "usd",
344
+ stripeTransferId: "tr_0",
345
+ paidAmount: 600,
346
+ reservedAmount: 0,
347
+ }),
348
+ );
349
+ await expect(s.preview()).rejects.toThrow(/allocation totals/i);
350
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
351
+ });
352
+ it("keeps a submitted payout reserved until Stripe verifies it as paid", async () => {
353
+ const s = await preview();
354
+ const create = s.provider.payouts.create.getMockImplementation()!;
355
+ s.provider.payouts.create.mockImplementation(async (args) => {
356
+ const payout = await create(args);
357
+ payout.status = "pending";
358
+ return payout;
359
+ });
360
+ expect(await s.run()).toMatchObject({
361
+ status: "updated",
362
+ claim: { status: "submitted" },
363
+ });
364
+ expect(await s.preview()).toMatchObject({
365
+ matureUnpaid: 0,
366
+ accounting: {
367
+ releasedAmount: 0,
368
+ reservedAmount: 1000,
369
+ carriedForwardAmount: 0,
370
+ withheldAmount: 0,
371
+ },
372
+ sources: [
373
+ { role: "store", releasedAmount: 0, reservedAmount: 600 },
374
+ { role: "affiliate", releasedAmount: 0, reservedAmount: 400 },
375
+ { role: "other", releasedAmount: 0, reservedAmount: 0 },
376
+ ],
377
+ });
378
+ (await s.provider.payouts.retrieve())!.status = "paid";
379
+ expect(await s.preview()).toMatchObject({
380
+ accounting: { releasedAmount: 1000, reservedAmount: 0 },
381
+ });
382
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
383
+ });
384
+ it("removes a verified returned payout from released totals without forfeiting the funds", async () => {
385
+ const s = await preview();
386
+ await s.run();
387
+ Object.assign((await s.provider.payouts.retrieve())!, {
388
+ status: "failed",
389
+ failure_balance_transaction: "txn_return",
390
+ });
391
+ s.setTransactions([
392
+ {
393
+ ...s.transactions()[0],
394
+ id: "txn_return",
395
+ source: "po_one",
396
+ amount: 1000,
397
+ net: 1000,
398
+ type: "payout_failure",
399
+ },
400
+ ...s.transactions(),
401
+ ]);
402
+ expect(await s.preview()).toMatchObject({
403
+ status: "qualified",
404
+ amount: 1000,
405
+ matureUnpaid: 1000,
406
+ accounting: {
407
+ releasedAmount: 0,
408
+ reservedAmount: 0,
409
+ carriedForwardAmount: 0,
410
+ withheldAmount: 0,
411
+ },
412
+ });
413
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
414
+ });
@@ -52,12 +52,14 @@ import type * as payoutPolicy_mutations from "../payoutPolicy/mutations.js";
52
52
  import type * as payoutPolicy_queries from "../payoutPolicy/queries.js";
53
53
  import type * as payoutRelease_adoption from "../payoutRelease/adoption.js";
54
54
  import type * as payoutRelease_allocation from "../payoutRelease/allocation.js";
55
+ import type * as payoutRelease_allocationTotals from "../payoutRelease/allocationTotals.js";
55
56
  import type * as payoutRelease_fees from "../payoutRelease/fees.js";
56
57
  import type * as payoutRelease_helpers from "../payoutRelease/helpers.js";
57
58
  import type * as payoutRelease_mutations from "../payoutRelease/mutations.js";
58
59
  import type * as payoutRelease_observation from "../payoutRelease/observation.js";
59
60
  import type * as payoutRelease_planning from "../payoutRelease/planning.js";
60
61
  import type * as payoutRelease_preview from "../payoutRelease/preview.js";
62
+ import type * as payoutRelease_progress from "../payoutRelease/progress.js";
61
63
  import type * as payoutRelease_queries from "../payoutRelease/queries.js";
62
64
  import type * as payoutSnapshots_localState from "../payoutSnapshots/localState.js";
63
65
  import type * as payoutSnapshots_mutations from "../payoutSnapshots/mutations.js";
@@ -134,12 +136,14 @@ const fullApi: ApiFromModules<{
134
136
  "payoutPolicy/queries": typeof payoutPolicy_queries;
135
137
  "payoutRelease/adoption": typeof payoutRelease_adoption;
136
138
  "payoutRelease/allocation": typeof payoutRelease_allocation;
139
+ "payoutRelease/allocationTotals": typeof payoutRelease_allocationTotals;
137
140
  "payoutRelease/fees": typeof payoutRelease_fees;
138
141
  "payoutRelease/helpers": typeof payoutRelease_helpers;
139
142
  "payoutRelease/mutations": typeof payoutRelease_mutations;
140
143
  "payoutRelease/observation": typeof payoutRelease_observation;
141
144
  "payoutRelease/planning": typeof payoutRelease_planning;
142
145
  "payoutRelease/preview": typeof payoutRelease_preview;
146
+ "payoutRelease/progress": typeof payoutRelease_progress;
143
147
  "payoutRelease/queries": typeof payoutRelease_queries;
144
148
  "payoutSnapshots/localState": typeof payoutSnapshots_localState;
145
149
  "payoutSnapshots/mutations": typeof payoutSnapshots_mutations;
@@ -3630,6 +3630,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
3630
3630
  },
3631
3631
  | { amount: 0; reason: string; status: "held" }
3632
3632
  | {
3633
+ accounting?: {
3634
+ carriedForwardAmount: number;
3635
+ releasedAmount: number;
3636
+ reservedAmount: number;
3637
+ withheldAmount: number;
3638
+ };
3633
3639
  amount: number;
3634
3640
  confirmedFees: number;
3635
3641
  currency: string;
@@ -3643,6 +3649,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
3643
3649
  sources: Array<{
3644
3650
  matureUnpaid: number;
3645
3651
  pendingCredits: number;
3652
+ releasedAmount?: number;
3653
+ reservedAmount?: number;
3646
3654
  role: "store" | "affiliate" | "other";
3647
3655
  }>;
3648
3656
  status: "held" | "qualified";
@@ -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
+ }
@@ -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
@@ -3,7 +3,16 @@ import type { PayoutPolicy, PayoutPreview } from "../../client/payoutPolicy.js";
3
3
  import { formatPrice } from "../lib/price-helpers.js";
4
4
  export type PayoutProgressMetric = {
5
5
  key:
6
- "qualifying" | "pending" | "minimum" | "eligible" | "fees" | "liabilities";
6
+ | "qualifying"
7
+ | "pending"
8
+ | "minimum"
9
+ | "eligible"
10
+ | "fees"
11
+ | "liabilities"
12
+ | "carried"
13
+ | "withheld"
14
+ | "reserved"
15
+ | "released";
7
16
  label: string;
8
17
  amount: number;
9
18
  formattedValue: string;
@@ -15,12 +24,19 @@ export type PayoutProgressState = {
15
24
  reason: string | null;
16
25
  message: string;
17
26
  metrics: PayoutProgressMetric[];
27
+ accounting?: NonNullable<
28
+ Extract<PayoutPreview, { currency: string }>["accounting"]
29
+ >;
18
30
  sources: {
19
31
  role: "store" | "affiliate" | "other";
20
32
  matureUnpaid: number;
21
33
  pendingCredits: number;
22
34
  matureLabel: string;
23
35
  pendingLabel: string;
36
+ releasedAmount?: number;
37
+ reservedAmount?: number;
38
+ releasedLabel?: string;
39
+ reservedLabel?: string;
24
40
  }[];
25
41
  progress: { value: number; max: number } | null;
26
42
  nextAttemptAt: number | null;
@@ -118,6 +134,7 @@ export function payoutProgressState({
118
134
  : (reasons[preview.reason ?? ""] ??
119
135
  "Automatic payout is on hold. Refresh or contact support.");
120
136
  state.observedAt = preview.observedAt;
137
+ state.accounting = preview.accounting;
121
138
  state.progress = {
122
139
  value: Math.max(0, Math.min(preview.threshold, preview.matureUnpaid)),
123
140
  max: preview.threshold,
@@ -127,6 +144,30 @@ export function payoutProgressState({
127
144
  ["pending", "Pending settlement", preview.pendingCredits],
128
145
  ["minimum", "Combined payout minimum", preview.threshold],
129
146
  ["eligible", "Eligible at assessment", preview.amount],
147
+ ...(preview.accounting
148
+ ? ([
149
+ [
150
+ "carried",
151
+ "Carried forward",
152
+ preview.accounting.carriedForwardAmount,
153
+ ],
154
+ [
155
+ "withheld",
156
+ "Withheld for costs",
157
+ preview.accounting.withheldAmount,
158
+ ],
159
+ [
160
+ "reserved",
161
+ "Payouts in progress",
162
+ preview.accounting.reservedAmount,
163
+ ],
164
+ [
165
+ "released",
166
+ "Paid out, net of returns",
167
+ preview.accounting.releasedAmount,
168
+ ],
169
+ ] as const)
170
+ : []),
130
171
  ["fees", "Confirmed payout costs", preview.confirmedFees],
131
172
  [
132
173
  "liabilities",
@@ -144,6 +185,12 @@ export function payoutProgressState({
144
185
  ...source,
145
186
  matureLabel: amount(source.matureUnpaid),
146
187
  pendingLabel: amount(source.pendingCredits),
188
+ ...(source.releasedAmount === undefined
189
+ ? {}
190
+ : { releasedLabel: amount(source.releasedAmount) }),
191
+ ...(source.reservedAmount === undefined
192
+ ? {}
193
+ : { reservedLabel: amount(source.reservedAmount) }),
147
194
  }));
148
195
  }
149
196
  }