@fantastic.dev/better-stripe 0.26.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.
- package/CHANGELOG.md +12 -0
- package/MIGRATION.md +6 -0
- package/README.md +7 -1
- package/dist/client/payoutPolicy.d.ts +1 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +2 -1
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +8 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/payoutPolicy/helpers.d.ts +1 -1
- package/dist/component/payoutPolicy/helpers.d.ts.map +1 -1
- package/dist/component/payoutPolicy/helpers.js +3 -1
- package/dist/component/payoutPolicy/helpers.js.map +1 -1
- package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
- package/dist/component/payoutPolicy/mutations.js +1 -1
- package/dist/component/payoutPolicy/mutations.js.map +1 -1
- package/dist/component/payoutRelease/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/react/workflows/PayoutProgress.d.ts +5 -0
- package/dist/react/workflows/PayoutProgress.d.ts.map +1 -0
- package/dist/react/workflows/PayoutProgress.js +23 -0
- package/dist/react/workflows/PayoutProgress.js.map +1 -0
- package/dist/react/workflows/PayoutSettings.d.ts +44 -0
- package/dist/react/workflows/PayoutSettings.d.ts.map +1 -0
- package/dist/react/workflows/PayoutSettings.js +84 -0
- package/dist/react/workflows/PayoutSettings.js.map +1 -0
- package/dist/react/workflows/index.d.ts +4 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +2 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/payout-progress-state.d.ts +50 -0
- package/dist/react/workflows/payout-progress-state.d.ts.map +1 -0
- package/dist/react/workflows/payout-progress-state.js +124 -0
- package/dist/react/workflows/payout-progress-state.js.map +1 -0
- package/docs/react-workflows-audit.md +1 -1
- package/docs/react-workflows.md +88 -0
- package/docs/settlement.md +23 -0
- package/package.json +1 -1
- package/src/client/connect/payoutPreview.test.ts +145 -0
- package/src/client/payoutPolicy.test.ts +13 -0
- package/src/client/payoutPolicy.ts +5 -1
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +8 -0
- package/src/component/payoutPolicy/helpers.ts +3 -1
- package/src/component/payoutPolicy/mutations.ts +4 -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/react/workflows/PayoutProgress.test.tsx +208 -0
- package/src/react/workflows/PayoutProgress.tsx +108 -0
- package/src/react/workflows/PayoutSettings.test.tsx +139 -0
- package/src/react/workflows/PayoutSettings.tsx +209 -0
- package/src/react/workflows/index.ts +13 -0
- package/src/react/workflows/payout-progress-state.ts +199 -0
|
@@ -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";
|
|
@@ -55,7 +55,9 @@ export function canonicalThresholds(
|
|
|
55
55
|
)
|
|
56
56
|
)
|
|
57
57
|
throw new Error("Invalid custom payout thresholds.");
|
|
58
|
-
return
|
|
58
|
+
return values
|
|
59
|
+
.map(({ currency, amount }) => ({ currency, amount }))
|
|
60
|
+
.sort((a, b) => a.currency.localeCompare(b.currency));
|
|
59
61
|
}
|
|
60
62
|
export function validateScope(scope: { accountId: string; ownerKey: string }) {
|
|
61
63
|
if (
|
|
@@ -52,7 +52,10 @@ export const prepare = mutation({
|
|
|
52
52
|
old.country !== args.country ||
|
|
53
53
|
old.observedAt !== args.observedAt ||
|
|
54
54
|
Boolean(old.adoption) !== Boolean(args.adoption) ||
|
|
55
|
-
!same(
|
|
55
|
+
!same(
|
|
56
|
+
canonicalThresholds(old.thresholds, old.mode === "custom"),
|
|
57
|
+
args.thresholds,
|
|
58
|
+
) ||
|
|
56
59
|
!same(old.observedSchedule, args.observedSchedule)
|
|
57
60
|
)
|
|
58
61
|
throw new Error("Payout policy operation identity cannot be reused.");
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, expect, it } from "vitest";
|
|
4
|
+
import type { PayoutPreview } from "../../client/payoutPolicy.js";
|
|
5
|
+
import { PayoutProgress } from "./PayoutProgress.js";
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
const policy = {
|
|
8
|
+
mode: "custom" as const,
|
|
9
|
+
status: "ready" as const,
|
|
10
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
11
|
+
holdDeadline: 1900000000000,
|
|
12
|
+
};
|
|
13
|
+
const preview: PayoutPreview = {
|
|
14
|
+
status: "held",
|
|
15
|
+
reason: "below_threshold",
|
|
16
|
+
amount: 0,
|
|
17
|
+
currency: "usd",
|
|
18
|
+
threshold: 1000,
|
|
19
|
+
providerMinimum: null,
|
|
20
|
+
matureUnpaid: 999,
|
|
21
|
+
pendingCredits: 200,
|
|
22
|
+
confirmedFees: 0,
|
|
23
|
+
independentLiabilities: 0,
|
|
24
|
+
providerAvailable: 999,
|
|
25
|
+
observedAt: 1800000000000,
|
|
26
|
+
sources: [
|
|
27
|
+
{ role: "store", matureUnpaid: 599, pendingCredits: 200 },
|
|
28
|
+
{ role: "affiliate", matureUnpaid: 400, pendingCredits: 0 },
|
|
29
|
+
{ role: "other", matureUnpaid: 0, pendingCredits: 0 },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
it("shows one combined minimum, pending settlement and source contributions", () => {
|
|
33
|
+
render(<PayoutProgress policy={policy} preview={preview} currency="usd" />);
|
|
34
|
+
expect(screen.getByRole("progressbar").getAttribute("max")).toBe("1000");
|
|
35
|
+
expect(screen.getByRole("progressbar").getAttribute("value")).toBe("999");
|
|
36
|
+
expect(screen.getByText("$9.99")).toBeTruthy();
|
|
37
|
+
expect(screen.getByText("$5.99")).toBeTruthy();
|
|
38
|
+
expect(screen.getByText("$4.00")).toBeTruthy();
|
|
39
|
+
expect(screen.getByText(/Below-minimum funds remain owed/)).toBeTruthy();
|
|
40
|
+
expect(
|
|
41
|
+
screen.queryByRole("button", { name: /withdraw|pay out/i }),
|
|
42
|
+
).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
it("does not invent zero balances or progress for an unavailable assessment", () => {
|
|
45
|
+
render(
|
|
46
|
+
<PayoutProgress
|
|
47
|
+
policy={policy}
|
|
48
|
+
currency="usd"
|
|
49
|
+
preview={{
|
|
50
|
+
status: "held",
|
|
51
|
+
reason: "unallocated_history",
|
|
52
|
+
amount: 0,
|
|
53
|
+
}}
|
|
54
|
+
/>,
|
|
55
|
+
);
|
|
56
|
+
expect(screen.getByRole("status").textContent).toContain("reconciliation");
|
|
57
|
+
expect(screen.queryByRole("progressbar")).toBeNull();
|
|
58
|
+
expect(screen.queryByText("$0.00")).toBeNull();
|
|
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
|
+
});
|
|
99
|
+
it("keeps Stripe-managed defaults distinct from a custom application minimum", () => {
|
|
100
|
+
render(<PayoutProgress policy={null} currency="usd" />);
|
|
101
|
+
expect(screen.getByRole("status").textContent).toContain("Stripe manages");
|
|
102
|
+
expect(screen.getByText(/numeric amount is unavailable/)).toBeTruthy();
|
|
103
|
+
expect(screen.queryByRole("progressbar")).toBeNull();
|
|
104
|
+
expect(screen.queryByText("$1.00")).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
it("does not reuse a prior assessment while the schedule change is pending", () => {
|
|
107
|
+
render(
|
|
108
|
+
<PayoutProgress
|
|
109
|
+
policy={{ ...policy, status: "changing" }}
|
|
110
|
+
preview={preview}
|
|
111
|
+
currency="usd"
|
|
112
|
+
/>,
|
|
113
|
+
);
|
|
114
|
+
expect(screen.getByRole("status").textContent).toContain("being verified");
|
|
115
|
+
expect(screen.queryByRole("progressbar")).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
it("preserves negative entitlement while clamping only the visual progress", () => {
|
|
118
|
+
render(
|
|
119
|
+
<PayoutProgress
|
|
120
|
+
policy={policy}
|
|
121
|
+
preview={{ ...preview, matureUnpaid: -100 }}
|
|
122
|
+
currency="usd"
|
|
123
|
+
/>,
|
|
124
|
+
);
|
|
125
|
+
expect(screen.getByText("-$1.00")).toBeTruthy();
|
|
126
|
+
expect(screen.getByRole("progressbar").getAttribute("value")).toBe("0");
|
|
127
|
+
});
|
|
128
|
+
it("distinguishes the next worker attempt from a bank arrival date", () => {
|
|
129
|
+
render(
|
|
130
|
+
<PayoutProgress
|
|
131
|
+
policy={policy}
|
|
132
|
+
preview={preview}
|
|
133
|
+
currency="usd"
|
|
134
|
+
nextAttemptAt={1800000000000}
|
|
135
|
+
timeZone="UTC"
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
expect(screen.getByText(/Next automatic attempt:/).textContent).toContain(
|
|
139
|
+
"not a bank arrival date",
|
|
140
|
+
);
|
|
141
|
+
expect(screen.getByText(/Custom holding deadline:/)).toBeTruthy();
|
|
142
|
+
});
|
|
143
|
+
it("rejects another currency or changed threshold instead of displaying mixed progress", () => {
|
|
144
|
+
expect(() =>
|
|
145
|
+
render(<PayoutProgress policy={policy} preview={preview} currency="eur" />),
|
|
146
|
+
).toThrow("does not match");
|
|
147
|
+
expect(() =>
|
|
148
|
+
render(
|
|
149
|
+
<PayoutProgress
|
|
150
|
+
policy={{ ...policy, thresholds: [{ currency: "usd", amount: 2000 }] }}
|
|
151
|
+
preview={preview}
|
|
152
|
+
currency="usd"
|
|
153
|
+
/>,
|
|
154
|
+
),
|
|
155
|
+
).toThrow("does not match");
|
|
156
|
+
});
|
|
157
|
+
it("exposes the same financial state for translated host cards", () => {
|
|
158
|
+
render(
|
|
159
|
+
<PayoutProgress policy={policy} preview={preview} currency="usd">
|
|
160
|
+
{(state) => (
|
|
161
|
+
<article data-testid="custom">
|
|
162
|
+
{state.status}{" "}
|
|
163
|
+
{
|
|
164
|
+
state.metrics.find((metric) => metric.key === "qualifying")
|
|
165
|
+
?.formattedValue
|
|
166
|
+
}
|
|
167
|
+
</article>
|
|
168
|
+
)}
|
|
169
|
+
</PayoutProgress>,
|
|
170
|
+
);
|
|
171
|
+
expect(screen.getByTestId("custom").textContent).toBe("assessed $9.99");
|
|
172
|
+
expect(screen.queryByText("Source contributions")).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
it.each([1000, 1100])(
|
|
175
|
+
"shows a qualifying combined assessment at %i without separate category minimums",
|
|
176
|
+
(total) => {
|
|
177
|
+
render(
|
|
178
|
+
<PayoutProgress
|
|
179
|
+
policy={policy}
|
|
180
|
+
currency="usd"
|
|
181
|
+
preview={{
|
|
182
|
+
...preview,
|
|
183
|
+
status: "qualified",
|
|
184
|
+
reason: undefined,
|
|
185
|
+
amount: total,
|
|
186
|
+
matureUnpaid: total,
|
|
187
|
+
sources: [
|
|
188
|
+
{ role: "store", matureUnpaid: total - 400, pendingCredits: 0 },
|
|
189
|
+
{ role: "affiliate", matureUnpaid: 400, pendingCredits: 0 },
|
|
190
|
+
],
|
|
191
|
+
}}
|
|
192
|
+
/>,
|
|
193
|
+
);
|
|
194
|
+
expect(screen.getByRole("status").textContent).toContain("qualif");
|
|
195
|
+
expect(screen.getAllByRole("progressbar")).toHaveLength(1);
|
|
196
|
+
expect(screen.getByRole("progressbar").getAttribute("value")).toBe("1000");
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
it("does not display a former custom holding deadline for Stripe-managed payouts", () => {
|
|
200
|
+
render(
|
|
201
|
+
<PayoutProgress
|
|
202
|
+
policy={{ ...policy, mode: "stripe_managed" }}
|
|
203
|
+
currency="usd"
|
|
204
|
+
preview={preview}
|
|
205
|
+
/>,
|
|
206
|
+
);
|
|
207
|
+
expect(screen.queryByText(/Custom holding deadline:/)).toBeNull();
|
|
208
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import {
|
|
4
|
+
payoutProgressState,
|
|
5
|
+
type PayoutProgressProps,
|
|
6
|
+
} from "./payout-progress-state.js";
|
|
7
|
+
export type {
|
|
8
|
+
PayoutProgressMetric,
|
|
9
|
+
PayoutProgressProps,
|
|
10
|
+
PayoutProgressState,
|
|
11
|
+
} from "./payout-progress-state.js";
|
|
12
|
+
/** Unstyled, read-only progress. A preview never authorizes or requests a payout. */
|
|
13
|
+
export function PayoutProgress(props: PayoutProgressProps) {
|
|
14
|
+
const { currency, locale = "en-US", timeZone, className, children } = props;
|
|
15
|
+
const state = payoutProgressState(props);
|
|
16
|
+
const formatter = useMemo(
|
|
17
|
+
() =>
|
|
18
|
+
new Intl.DateTimeFormat(locale, {
|
|
19
|
+
dateStyle: "medium",
|
|
20
|
+
timeStyle: "short",
|
|
21
|
+
timeZone,
|
|
22
|
+
}),
|
|
23
|
+
[locale, timeZone],
|
|
24
|
+
);
|
|
25
|
+
if (children) return <>{children(state)}</>;
|
|
26
|
+
const date = (value: number) => formatter.format(value);
|
|
27
|
+
return (
|
|
28
|
+
<section className={className} data-better-stripe="payout-progress">
|
|
29
|
+
<h2>Automatic payout progress ({currency.toUpperCase()})</h2>
|
|
30
|
+
<p role="status">{state.message}</p>
|
|
31
|
+
<p>Stripe enforces its payout floor; a numeric amount is unavailable.</p>
|
|
32
|
+
{state.progress && (
|
|
33
|
+
<progress
|
|
34
|
+
aria-label="Combined payout minimum"
|
|
35
|
+
value={state.progress.value}
|
|
36
|
+
max={state.progress.max}
|
|
37
|
+
/>
|
|
38
|
+
)}
|
|
39
|
+
{state.metrics.length > 0 && (
|
|
40
|
+
<dl>
|
|
41
|
+
{state.metrics.map((metric) => (
|
|
42
|
+
<div key={metric.key}>
|
|
43
|
+
<dt>{metric.label}</dt>
|
|
44
|
+
<dd>{metric.formattedValue}</dd>
|
|
45
|
+
</div>
|
|
46
|
+
))}
|
|
47
|
+
</dl>
|
|
48
|
+
)}
|
|
49
|
+
{state.sources.length > 0 && (
|
|
50
|
+
<>
|
|
51
|
+
<p>
|
|
52
|
+
All categories contribute to one minimum. Source amounts are before
|
|
53
|
+
shared costs and obligations. Pending funds do not qualify.
|
|
54
|
+
</p>
|
|
55
|
+
<table>
|
|
56
|
+
<caption>Source contributions</caption>
|
|
57
|
+
<thead>
|
|
58
|
+
<tr>
|
|
59
|
+
<th scope="col">Source</th>
|
|
60
|
+
<th scope="col">Mature unpaid</th>
|
|
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>}
|
|
64
|
+
</tr>
|
|
65
|
+
</thead>
|
|
66
|
+
<tbody>
|
|
67
|
+
{state.sources.map((source) => (
|
|
68
|
+
<tr key={source.role}>
|
|
69
|
+
<th scope="row">
|
|
70
|
+
{source.role === "store"
|
|
71
|
+
? "Store"
|
|
72
|
+
: source.role === "affiliate"
|
|
73
|
+
? "Affiliate"
|
|
74
|
+
: "Other"}
|
|
75
|
+
</th>
|
|
76
|
+
<td>{source.matureLabel}</td>
|
|
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
|
+
)}
|
|
84
|
+
</tr>
|
|
85
|
+
))}
|
|
86
|
+
</tbody>
|
|
87
|
+
</table>
|
|
88
|
+
</>
|
|
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
|
+
)}
|
|
96
|
+
{state.nextAttemptAt !== null && (
|
|
97
|
+
<p>
|
|
98
|
+
Next automatic attempt: {date(state.nextAttemptAt)}. This is not a
|
|
99
|
+
bank arrival date.
|
|
100
|
+
</p>
|
|
101
|
+
)}
|
|
102
|
+
{state.holdDeadline !== null && (
|
|
103
|
+
<p>Custom holding deadline: {date(state.holdDeadline)}</p>
|
|
104
|
+
)}
|
|
105
|
+
{state.observedAt !== null && <p>Assessed: {date(state.observedAt)}</p>}
|
|
106
|
+
</section>
|
|
107
|
+
);
|
|
108
|
+
}
|