@fantastic.dev/better-stripe 0.20.0 → 0.21.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/README.md +28 -1
- package/dist/client/connect/payoutReceipt.d.ts +53 -0
- package/dist/client/connect/payoutReceipt.d.ts.map +1 -0
- package/dist/client/connect/payoutReceipt.js +185 -0
- package/dist/client/connect/payoutReceipt.js.map +1 -0
- package/dist/client/connect/payoutReconciliation.d.ts +38 -0
- package/dist/client/connect/payoutReconciliation.d.ts.map +1 -0
- package/dist/client/connect/payoutReconciliation.js +110 -0
- package/dist/client/connect/payoutReconciliation.js.map +1 -0
- package/dist/client/payoutPolicy.d.ts +6 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +8 -0
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +39 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +50 -2
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutRelease/allocation.d.ts +1 -0
- package/dist/component/payoutRelease/allocation.d.ts.map +1 -1
- package/dist/component/payoutRelease/allocation.js +3 -0
- package/dist/component/payoutRelease/allocation.js.map +1 -1
- package/dist/component/payoutRelease/fees.d.ts +22 -0
- package/dist/component/payoutRelease/fees.d.ts.map +1 -0
- package/dist/component/payoutRelease/fees.js +73 -0
- package/dist/component/payoutRelease/fees.js.map +1 -0
- package/dist/component/payoutRelease/helpers.d.ts +4 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts +23 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +17 -0
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/queries.d.ts +16 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/queries.js +10 -0
- package/dist/component/payoutRelease/queries.js.map +1 -1
- package/dist/component/payoutRelease/schema.d.ts +38 -2
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +11 -0
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/schema.d.ts +25 -1
- package/dist/component/schema.js +2 -1
- package/dist/component/schema.js.map +1 -1
- package/docs/accounts-v2-preview.md +39 -1
- package/docs/react-workflows.md +31 -0
- package/docs/settlement.md +74 -3
- package/package.json +1 -1
- package/src/client/connect/payoutAllocation.test.ts +36 -3
- package/src/client/connect/payoutReceipt.test.ts +312 -0
- package/src/client/connect/payoutReceipt.ts +287 -0
- package/src/client/connect/payoutReconciliation.test.ts +385 -0
- package/src/client/connect/payoutReconciliation.ts +174 -0
- package/src/client/payoutPolicy.ts +16 -0
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +38 -0
- package/src/component/payout-fees.test.ts +224 -0
- package/src/component/payoutRelease/allocation.ts +6 -0
- package/src/component/payoutRelease/fees.ts +93 -0
- package/src/component/payoutRelease/mutations.ts +17 -0
- package/src/component/payoutRelease/queries.ts +11 -0
- package/src/component/payoutRelease/schema.ts +12 -0
- package/src/component/schema.ts +2 -0
|
@@ -2492,7 +2492,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2492
2492
|
amount: number;
|
|
2493
2493
|
attemptedAt?: number;
|
|
2494
2494
|
currency: string;
|
|
2495
|
+
debitFee?: number;
|
|
2496
|
+
debitTransactionId?: string;
|
|
2495
2497
|
expectedRevision: number;
|
|
2498
|
+
feeRevision?: number;
|
|
2496
2499
|
observations: Array<{
|
|
2497
2500
|
currency: string;
|
|
2498
2501
|
ledgerFingerprint: string;
|
|
@@ -2504,6 +2507,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2504
2507
|
}>;
|
|
2505
2508
|
operationKey: string;
|
|
2506
2509
|
ownerKey: string;
|
|
2510
|
+
providerFee?: number;
|
|
2507
2511
|
rejection?: {
|
|
2508
2512
|
code: "balance_insufficient" | "amount_too_small";
|
|
2509
2513
|
requestId: string;
|
|
@@ -2536,7 +2540,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2536
2540
|
amount: number;
|
|
2537
2541
|
attemptedAt?: number;
|
|
2538
2542
|
currency: string;
|
|
2543
|
+
debitFee?: number;
|
|
2544
|
+
debitTransactionId?: string;
|
|
2539
2545
|
expectedRevision: number;
|
|
2546
|
+
feeRevision?: number;
|
|
2540
2547
|
observations: Array<{
|
|
2541
2548
|
currency: string;
|
|
2542
2549
|
ledgerFingerprint: string;
|
|
@@ -2548,6 +2555,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2548
2555
|
}>;
|
|
2549
2556
|
operationKey: string;
|
|
2550
2557
|
ownerKey: string;
|
|
2558
|
+
providerFee?: number;
|
|
2551
2559
|
rejection?: {
|
|
2552
2560
|
code: "balance_insufficient" | "amount_too_small";
|
|
2553
2561
|
requestId: string;
|
|
@@ -2597,7 +2605,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2597
2605
|
amount: number;
|
|
2598
2606
|
attemptedAt?: number;
|
|
2599
2607
|
currency: string;
|
|
2608
|
+
debitFee?: number;
|
|
2609
|
+
debitTransactionId?: string;
|
|
2600
2610
|
expectedRevision: number;
|
|
2611
|
+
feeRevision?: number;
|
|
2601
2612
|
observations: Array<{
|
|
2602
2613
|
currency: string;
|
|
2603
2614
|
ledgerFingerprint: string;
|
|
@@ -2609,6 +2620,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2609
2620
|
}>;
|
|
2610
2621
|
operationKey: string;
|
|
2611
2622
|
ownerKey: string;
|
|
2623
|
+
providerFee?: number;
|
|
2612
2624
|
rejection?: {
|
|
2613
2625
|
code: "balance_insufficient" | "amount_too_small";
|
|
2614
2626
|
requestId: string;
|
|
@@ -2642,10 +2654,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2642
2654
|
accountId: string;
|
|
2643
2655
|
amount: number;
|
|
2644
2656
|
currency: string;
|
|
2657
|
+
debitFee?: number;
|
|
2658
|
+
debitTransactionId?: string;
|
|
2645
2659
|
operationKey: string;
|
|
2646
2660
|
ownerKey: string;
|
|
2647
2661
|
providerStatus:
|
|
2648
2662
|
"pending" | "in_transit" | "paid" | "failed" | "canceled";
|
|
2663
|
+
returnFee?: number;
|
|
2649
2664
|
returnTransactionId?: string;
|
|
2650
2665
|
stripePayoutId: string;
|
|
2651
2666
|
},
|
|
@@ -2661,7 +2676,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2661
2676
|
amount: number;
|
|
2662
2677
|
attemptedAt?: number;
|
|
2663
2678
|
currency: string;
|
|
2679
|
+
debitFee?: number;
|
|
2680
|
+
debitTransactionId?: string;
|
|
2664
2681
|
expectedRevision: number;
|
|
2682
|
+
feeRevision?: number;
|
|
2665
2683
|
observations: Array<{
|
|
2666
2684
|
currency: string;
|
|
2667
2685
|
ledgerFingerprint: string;
|
|
@@ -2673,6 +2691,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2673
2691
|
}>;
|
|
2674
2692
|
operationKey: string;
|
|
2675
2693
|
ownerKey: string;
|
|
2694
|
+
providerFee?: number;
|
|
2676
2695
|
rejection?: {
|
|
2677
2696
|
code: "balance_insufficient" | "amount_too_small";
|
|
2678
2697
|
requestId: string;
|
|
@@ -2711,7 +2730,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2711
2730
|
amount: number;
|
|
2712
2731
|
attemptedAt?: number;
|
|
2713
2732
|
currency: string;
|
|
2733
|
+
debitFee?: number;
|
|
2734
|
+
debitTransactionId?: string;
|
|
2714
2735
|
expectedRevision: number;
|
|
2736
|
+
feeRevision?: number;
|
|
2715
2737
|
observations: Array<{
|
|
2716
2738
|
currency: string;
|
|
2717
2739
|
ledgerFingerprint: string;
|
|
@@ -2723,6 +2745,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2723
2745
|
}>;
|
|
2724
2746
|
operationKey: string;
|
|
2725
2747
|
ownerKey: string;
|
|
2748
|
+
providerFee?: number;
|
|
2726
2749
|
rejection?: {
|
|
2727
2750
|
code: "balance_insufficient" | "amount_too_small";
|
|
2728
2751
|
requestId: string;
|
|
@@ -2741,6 +2764,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2741
2764
|
>;
|
|
2742
2765
|
};
|
|
2743
2766
|
queries: {
|
|
2767
|
+
fees: FunctionReference<
|
|
2768
|
+
"query",
|
|
2769
|
+
"internal",
|
|
2770
|
+
{ accountId: string; currency: string; ownerKey: string },
|
|
2771
|
+
{ amount: number; revision: number },
|
|
2772
|
+
Name
|
|
2773
|
+
>;
|
|
2744
2774
|
get: FunctionReference<
|
|
2745
2775
|
"query",
|
|
2746
2776
|
"internal",
|
|
@@ -2757,7 +2787,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2757
2787
|
amount: number;
|
|
2758
2788
|
attemptedAt?: number;
|
|
2759
2789
|
currency: string;
|
|
2790
|
+
debitFee?: number;
|
|
2791
|
+
debitTransactionId?: string;
|
|
2760
2792
|
expectedRevision: number;
|
|
2793
|
+
feeRevision?: number;
|
|
2761
2794
|
observations: Array<{
|
|
2762
2795
|
currency: string;
|
|
2763
2796
|
ledgerFingerprint: string;
|
|
@@ -2769,6 +2802,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2769
2802
|
}>;
|
|
2770
2803
|
operationKey: string;
|
|
2771
2804
|
ownerKey: string;
|
|
2805
|
+
providerFee?: number;
|
|
2772
2806
|
rejection?: {
|
|
2773
2807
|
code: "balance_insufficient" | "amount_too_small";
|
|
2774
2808
|
requestId: string;
|
|
@@ -2815,7 +2849,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2815
2849
|
amount: number;
|
|
2816
2850
|
attemptedAt?: number;
|
|
2817
2851
|
currency: string;
|
|
2852
|
+
debitFee?: number;
|
|
2853
|
+
debitTransactionId?: string;
|
|
2818
2854
|
expectedRevision: number;
|
|
2855
|
+
feeRevision?: number;
|
|
2819
2856
|
observations: Array<{
|
|
2820
2857
|
currency: string;
|
|
2821
2858
|
ledgerFingerprint: string;
|
|
@@ -2827,6 +2864,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2827
2864
|
}>;
|
|
2828
2865
|
operationKey: string;
|
|
2829
2866
|
ownerKey: string;
|
|
2867
|
+
providerFee?: number;
|
|
2830
2868
|
rejection?: {
|
|
2831
2869
|
code: "balance_insufficient" | "amount_too_small";
|
|
2832
2870
|
requestId: string;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { convexTest } from "convex-test";
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import schema from "./schema.js";
|
|
6
|
+
import { transferFingerprint } from "./payoutRelease/helpers.js";
|
|
7
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
8
|
+
const m = (name: string) =>
|
|
9
|
+
makeFunctionReference<"mutation">(`payoutRelease/mutations:${name}`);
|
|
10
|
+
const scope = { accountId: "acct_recipient", ownerKey: "user_1" };
|
|
11
|
+
async function setup(amount = 1500) {
|
|
12
|
+
const t = convexTest(schema, modules);
|
|
13
|
+
await t.mutation(
|
|
14
|
+
makeFunctionReference<"mutation">("payoutPolicy/mutations:prepare"),
|
|
15
|
+
{
|
|
16
|
+
...scope,
|
|
17
|
+
operationKey: "enable",
|
|
18
|
+
expectedRevision: -1,
|
|
19
|
+
mode: "custom",
|
|
20
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
21
|
+
country: "US",
|
|
22
|
+
observedSchedule: { interval: "daily" },
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
await t.mutation(
|
|
26
|
+
makeFunctionReference<"mutation">("payoutPolicy/mutations:confirm"),
|
|
27
|
+
{
|
|
28
|
+
...scope,
|
|
29
|
+
operationKey: "enable",
|
|
30
|
+
observedSchedule: { interval: "manual" },
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
await t.mutation(
|
|
34
|
+
makeFunctionReference<"mutation">("connect/mutations:upsertTransfer"),
|
|
35
|
+
{
|
|
36
|
+
stripeTransferId: "tr_one",
|
|
37
|
+
destinationAccountId: scope.accountId,
|
|
38
|
+
amount,
|
|
39
|
+
currency: "usd",
|
|
40
|
+
role: "affiliate",
|
|
41
|
+
status: "paid",
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
const row = (await t.run((ctx) => ctx.db.query("transfers").take(1)))[0];
|
|
45
|
+
const args = {
|
|
46
|
+
...scope,
|
|
47
|
+
operationKey: "first",
|
|
48
|
+
expectedRevision: 0,
|
|
49
|
+
currency: "usd",
|
|
50
|
+
providerAvailable: 1000,
|
|
51
|
+
independentLiabilities: 0,
|
|
52
|
+
observations: [
|
|
53
|
+
{
|
|
54
|
+
stripeTransferId: "tr_one",
|
|
55
|
+
currency: "usd",
|
|
56
|
+
matureNet: amount,
|
|
57
|
+
pendingCredits: 0,
|
|
58
|
+
ledgerFingerprint: transferFingerprint(row),
|
|
59
|
+
observedAt: Date.now(),
|
|
60
|
+
transactionIds: ["txn_funding"],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
await t.mutation(m("claim"), args);
|
|
65
|
+
await t.mutation(m("beginAttempt"), {
|
|
66
|
+
...scope,
|
|
67
|
+
operationKey: args.operationKey,
|
|
68
|
+
});
|
|
69
|
+
const receipt = {
|
|
70
|
+
...scope,
|
|
71
|
+
operationKey: args.operationKey,
|
|
72
|
+
stripePayoutId: "po_one",
|
|
73
|
+
currency: "usd",
|
|
74
|
+
amount: 1000,
|
|
75
|
+
providerStatus: "paid",
|
|
76
|
+
debitTransactionId: "txn_debit",
|
|
77
|
+
debitFee: 30,
|
|
78
|
+
};
|
|
79
|
+
return { t, args, receipt };
|
|
80
|
+
}
|
|
81
|
+
it("books a verified payout fee once and keeps it out of later eligibility", async () => {
|
|
82
|
+
const { t, args, receipt } = await setup();
|
|
83
|
+
await t.mutation(m("recordOutcome"), receipt);
|
|
84
|
+
await t.mutation(m("recordOutcome"), receipt);
|
|
85
|
+
expect(
|
|
86
|
+
await t.query(
|
|
87
|
+
makeFunctionReference<"query">("payoutRelease/queries:fees"),
|
|
88
|
+
{ ...scope, currency: "usd" },
|
|
89
|
+
),
|
|
90
|
+
).toMatchObject({ amount: 30, revision: 1 });
|
|
91
|
+
expect(
|
|
92
|
+
await t.mutation(m("claim"), {
|
|
93
|
+
...args,
|
|
94
|
+
operationKey: "next",
|
|
95
|
+
providerAvailable: 5000,
|
|
96
|
+
}),
|
|
97
|
+
).toMatchObject({
|
|
98
|
+
status: "held",
|
|
99
|
+
matureUnpaid: 470,
|
|
100
|
+
reason: "below_threshold",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
it("books only the net non-refundable fee after a verified return, including a negative fee credit", async () => {
|
|
104
|
+
const { t, args, receipt } = await setup();
|
|
105
|
+
await t.mutation(m("recordOutcome"), receipt);
|
|
106
|
+
const returned = {
|
|
107
|
+
...receipt,
|
|
108
|
+
providerStatus: "failed",
|
|
109
|
+
returnTransactionId: "txn_return",
|
|
110
|
+
returnFee: -10,
|
|
111
|
+
};
|
|
112
|
+
await t.mutation(m("recordOutcome"), returned);
|
|
113
|
+
await t.mutation(m("recordOutcome"), returned);
|
|
114
|
+
expect(
|
|
115
|
+
await t.query(
|
|
116
|
+
makeFunctionReference<"query">("payoutRelease/queries:fees"),
|
|
117
|
+
{ ...scope, currency: "usd" },
|
|
118
|
+
),
|
|
119
|
+
).toMatchObject({ amount: 20, revision: 2 });
|
|
120
|
+
expect(
|
|
121
|
+
await t.mutation(m("claim"), {
|
|
122
|
+
...args,
|
|
123
|
+
operationKey: "next",
|
|
124
|
+
providerAvailable: 1500,
|
|
125
|
+
}),
|
|
126
|
+
).toMatchObject({ status: "reserved", amount: 1480 });
|
|
127
|
+
});
|
|
128
|
+
it("does not erase known fees with a stale pending receipt after payout completion", async () => {
|
|
129
|
+
const { t, receipt } = await setup();
|
|
130
|
+
await t.mutation(m("recordOutcome"), receipt);
|
|
131
|
+
await t.mutation(m("recordOutcome"), {
|
|
132
|
+
...receipt,
|
|
133
|
+
providerStatus: "pending",
|
|
134
|
+
debitFee: 0,
|
|
135
|
+
});
|
|
136
|
+
expect(
|
|
137
|
+
await t.query(
|
|
138
|
+
makeFunctionReference<"query">("payoutRelease/queries:fees"),
|
|
139
|
+
{ ...scope, currency: "usd" },
|
|
140
|
+
),
|
|
141
|
+
).toMatchObject({ amount: 30 });
|
|
142
|
+
});
|
|
143
|
+
it("rejects a changed original debit identity or fee", async () => {
|
|
144
|
+
const { t, receipt } = await setup();
|
|
145
|
+
await t.mutation(m("recordOutcome"), receipt);
|
|
146
|
+
for (const changed of [{ debitTransactionId: "txn_other" }, { debitFee: 31 }])
|
|
147
|
+
await expect(
|
|
148
|
+
t.mutation(m("recordOutcome"), { ...receipt, ...changed }),
|
|
149
|
+
).rejects.toThrow(/receipt|fee/);
|
|
150
|
+
});
|
|
151
|
+
it("invalidates an unattempted claim if a known fee changes after reservation", async () => {
|
|
152
|
+
const { t, args, receipt } = await setup(3000);
|
|
153
|
+
await t.mutation(m("recordOutcome"), {
|
|
154
|
+
...receipt,
|
|
155
|
+
providerStatus: "pending",
|
|
156
|
+
debitFee: 0,
|
|
157
|
+
});
|
|
158
|
+
await t.mutation(m("claim"), { ...args, operationKey: "second" });
|
|
159
|
+
// A verified return adds its actual processing fee.
|
|
160
|
+
await t.mutation(m("recordOutcome"), {
|
|
161
|
+
...receipt,
|
|
162
|
+
debitFee: 0,
|
|
163
|
+
providerStatus: "failed",
|
|
164
|
+
returnTransactionId: "txn_return",
|
|
165
|
+
returnFee: 30,
|
|
166
|
+
});
|
|
167
|
+
await expect(
|
|
168
|
+
t.mutation(m("beginAttempt"), { ...scope, operationKey: "second" }),
|
|
169
|
+
).rejects.toThrow(/fee|revision/);
|
|
170
|
+
});
|
|
171
|
+
it("rejects ambiguous fee pairs and over-credits instead of creating spendable money", async () => {
|
|
172
|
+
const { t, receipt } = await setup();
|
|
173
|
+
await expect(
|
|
174
|
+
t.mutation(m("recordOutcome"), {
|
|
175
|
+
...receipt,
|
|
176
|
+
debitTransactionId: undefined,
|
|
177
|
+
}),
|
|
178
|
+
).rejects.toThrow(/fee|receipt/);
|
|
179
|
+
await expect(
|
|
180
|
+
t.mutation(m("recordOutcome"), {
|
|
181
|
+
...receipt,
|
|
182
|
+
providerStatus: "failed",
|
|
183
|
+
returnTransactionId: "txn_return",
|
|
184
|
+
returnFee: -31,
|
|
185
|
+
}),
|
|
186
|
+
).rejects.toThrow(/fee/);
|
|
187
|
+
});
|
|
188
|
+
it("can add verified fee evidence to a legacy returned claim without releasing principal twice", async () => {
|
|
189
|
+
const { t, args, receipt } = await setup();
|
|
190
|
+
const legacy = {
|
|
191
|
+
...scope,
|
|
192
|
+
operationKey: args.operationKey,
|
|
193
|
+
stripePayoutId: "po_one",
|
|
194
|
+
amount: 1000,
|
|
195
|
+
currency: "usd",
|
|
196
|
+
providerStatus: "failed",
|
|
197
|
+
returnTransactionId: "txn_return",
|
|
198
|
+
};
|
|
199
|
+
await t.mutation(m("recordOutcome"), legacy);
|
|
200
|
+
const verified = {
|
|
201
|
+
...legacy,
|
|
202
|
+
debitTransactionId: receipt.debitTransactionId,
|
|
203
|
+
debitFee: 30,
|
|
204
|
+
returnFee: -10,
|
|
205
|
+
};
|
|
206
|
+
await t.mutation(m("recordOutcome"), verified);
|
|
207
|
+
await t.mutation(m("recordOutcome"), verified);
|
|
208
|
+
expect(
|
|
209
|
+
await t.query(
|
|
210
|
+
makeFunctionReference<"query">("payoutRelease/queries:fees"),
|
|
211
|
+
{ ...scope, currency: "usd" },
|
|
212
|
+
),
|
|
213
|
+
).toMatchObject({ amount: 20 });
|
|
214
|
+
await expect(
|
|
215
|
+
t.mutation(m("recordOutcome"), { ...verified, returnFee: 0 }),
|
|
216
|
+
).rejects.toThrow(/fee|receipt/);
|
|
217
|
+
expect(
|
|
218
|
+
await t.mutation(m("claim"), {
|
|
219
|
+
...args,
|
|
220
|
+
operationKey: "next",
|
|
221
|
+
providerAvailable: 1500,
|
|
222
|
+
}),
|
|
223
|
+
).toMatchObject({ status: "reserved", amount: 1480 });
|
|
224
|
+
});
|
|
@@ -12,6 +12,7 @@ export type PayoutAllocationOptions = {
|
|
|
12
12
|
threshold: number;
|
|
13
13
|
providerAvailable: number;
|
|
14
14
|
independentLiabilities: number;
|
|
15
|
+
confirmedFees?: number;
|
|
15
16
|
sources: PayoutFundingSource[];
|
|
16
17
|
};
|
|
17
18
|
export type RecipientPayoutPlan = {
|
|
@@ -66,6 +67,7 @@ export function planRecipientPayout(
|
|
|
66
67
|
if (money(opts.threshold) <= 0) throw new Error("Invalid payout threshold.");
|
|
67
68
|
money(opts.providerAvailable);
|
|
68
69
|
nonnegative(opts.independentLiabilities);
|
|
70
|
+
nonnegative(opts.confirmedFees ?? 0);
|
|
69
71
|
const seen = new Set<string>();
|
|
70
72
|
for (const source of opts.sources) {
|
|
71
73
|
if (
|
|
@@ -107,6 +109,10 @@ export function planRecipientPayout(
|
|
|
107
109
|
pendingCredits = money(pendingCredits + source.pendingCredits);
|
|
108
110
|
return { source, unpaid };
|
|
109
111
|
});
|
|
112
|
+
matureUnpaid = money(
|
|
113
|
+
money(matureUnpaid - (opts.confirmedFees ?? 0)) -
|
|
114
|
+
opts.independentLiabilities,
|
|
115
|
+
);
|
|
110
116
|
const totals = {
|
|
111
117
|
currency: opts.currency,
|
|
112
118
|
matureUnpaid,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server.js";
|
|
2
|
+
import type { Doc } from "../_generated/dataModel.js";
|
|
3
|
+
import { addSummaryAmounts as add } from "../earnings/arithmetic.js";
|
|
4
|
+
import { validateScope } from "../payoutPolicy/helpers.js";
|
|
5
|
+
export async function readFees(
|
|
6
|
+
ctx: QueryCtx | MutationCtx,
|
|
7
|
+
scope: { accountId: string; ownerKey: string; currency: string },
|
|
8
|
+
) {
|
|
9
|
+
validateScope(scope);
|
|
10
|
+
if (scope.currency.length !== 3 || !/^[a-z]{3}$/.test(scope.currency))
|
|
11
|
+
throw new Error("Invalid payout fee currency.");
|
|
12
|
+
const row = await ctx.db
|
|
13
|
+
.query("payoutFeeBalances")
|
|
14
|
+
.withIndex("by_account_currency", (q) =>
|
|
15
|
+
q.eq("accountId", scope.accountId).eq("currency", scope.currency),
|
|
16
|
+
)
|
|
17
|
+
.unique();
|
|
18
|
+
if (row && row.ownerKey !== scope.ownerKey)
|
|
19
|
+
throw new Error("Payout fee owner does not match.");
|
|
20
|
+
return row;
|
|
21
|
+
}
|
|
22
|
+
/** Atomically book verified debit/return fees along with the provider outcome. */
|
|
23
|
+
export async function recordFees(
|
|
24
|
+
ctx: MutationCtx,
|
|
25
|
+
claim: Doc<"payoutReleaseClaims">,
|
|
26
|
+
proof: { debitTransactionId?: string; debitFee?: number; returnFee?: number },
|
|
27
|
+
returned: boolean,
|
|
28
|
+
) {
|
|
29
|
+
if (
|
|
30
|
+
proof.debitTransactionId === undefined &&
|
|
31
|
+
proof.debitFee === undefined &&
|
|
32
|
+
proof.returnFee === undefined
|
|
33
|
+
)
|
|
34
|
+
return;
|
|
35
|
+
if (
|
|
36
|
+
!proof.debitTransactionId ||
|
|
37
|
+
!/^txn_\w+$/.test(proof.debitTransactionId) ||
|
|
38
|
+
proof.debitTransactionId.trim() !== proof.debitTransactionId ||
|
|
39
|
+
!Number.isSafeInteger(proof.debitFee) ||
|
|
40
|
+
(returned
|
|
41
|
+
? !Number.isSafeInteger(proof.returnFee)
|
|
42
|
+
: proof.returnFee !== undefined)
|
|
43
|
+
)
|
|
44
|
+
throw new Error("Payout fee receipt is incomplete.");
|
|
45
|
+
if (
|
|
46
|
+
(claim.debitTransactionId !== undefined &&
|
|
47
|
+
claim.debitTransactionId !== proof.debitTransactionId) ||
|
|
48
|
+
(claim.debitFee !== undefined && claim.debitFee !== proof.debitFee)
|
|
49
|
+
)
|
|
50
|
+
throw new Error("Original payout fee receipt changed.");
|
|
51
|
+
const existing = await ctx.db
|
|
52
|
+
.query("payoutReleaseClaims")
|
|
53
|
+
.withIndex("by_debit_receipt", (q) =>
|
|
54
|
+
q.eq("debitTransactionId", proof.debitTransactionId),
|
|
55
|
+
)
|
|
56
|
+
.unique();
|
|
57
|
+
if (existing && existing._id !== claim._id)
|
|
58
|
+
throw new Error("Payout debit receipt is already allocated.");
|
|
59
|
+
const fee = add(proof.debitFee!, returned ? proof.returnFee! : 0);
|
|
60
|
+
if (fee < 0)
|
|
61
|
+
throw new Error("Payout fee credit requires explicit allocation.");
|
|
62
|
+
if (
|
|
63
|
+
claim.status === "returned" &&
|
|
64
|
+
claim.providerFee !== undefined &&
|
|
65
|
+
claim.providerFee !== fee
|
|
66
|
+
)
|
|
67
|
+
throw new Error("Verified payout return fee receipt changed.");
|
|
68
|
+
const delta = add(fee, -(claim.providerFee ?? 0));
|
|
69
|
+
if (delta !== 0) {
|
|
70
|
+
const balance = await readFees(ctx, claim);
|
|
71
|
+
const amount = add(balance?.amount ?? 0, delta);
|
|
72
|
+
if (amount < 0) throw new Error("Payout fee balance underflow.");
|
|
73
|
+
const revision = add(balance?.revision ?? 0, 1);
|
|
74
|
+
if (balance)
|
|
75
|
+
await ctx.db.patch("payoutFeeBalances", balance._id, {
|
|
76
|
+
amount,
|
|
77
|
+
revision,
|
|
78
|
+
});
|
|
79
|
+
else
|
|
80
|
+
await ctx.db.insert("payoutFeeBalances", {
|
|
81
|
+
accountId: claim.accountId,
|
|
82
|
+
ownerKey: claim.ownerKey,
|
|
83
|
+
currency: claim.currency,
|
|
84
|
+
amount,
|
|
85
|
+
revision,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
await ctx.db.patch("payoutReleaseClaims", claim._id, {
|
|
89
|
+
debitTransactionId: proof.debitTransactionId,
|
|
90
|
+
debitFee: proof.debitFee,
|
|
91
|
+
providerFee: fee,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFees, recordFees } from "./fees.js";
|
|
1
2
|
import { v } from "convex/values";
|
|
2
3
|
import { mutation } from "../_generated/server.js";
|
|
3
4
|
import { identity, observation, claimDoc } from "./schema.js";
|
|
@@ -88,11 +89,13 @@ export const claim = mutation({
|
|
|
88
89
|
reservedAmount: prior?.reservedAmount ?? 0,
|
|
89
90
|
});
|
|
90
91
|
}
|
|
92
|
+
const fees = await readFees(ctx, args);
|
|
91
93
|
const plan = planRecipientPayout({
|
|
92
94
|
currency: args.currency,
|
|
93
95
|
threshold: minimum.amount,
|
|
94
96
|
providerAvailable: args.providerAvailable,
|
|
95
97
|
independentLiabilities: args.independentLiabilities,
|
|
98
|
+
confirmedFees: fees?.amount ?? 0,
|
|
96
99
|
sources,
|
|
97
100
|
});
|
|
98
101
|
if (plan.status === "held") return plan;
|
|
@@ -121,6 +124,7 @@ export const claim = mutation({
|
|
|
121
124
|
amount: plan.amount,
|
|
122
125
|
allocations: plan.allocations,
|
|
123
126
|
observations: args.observations,
|
|
127
|
+
feeRevision: fees?.revision ?? 0,
|
|
124
128
|
status: "reserved",
|
|
125
129
|
});
|
|
126
130
|
return (await ctx.db.get("payoutReleaseClaims", id))!;
|
|
@@ -135,6 +139,9 @@ export const beginAttempt = mutation({
|
|
|
135
139
|
throw new Error("Payout claim is not reserved.");
|
|
136
140
|
if (claim.attemptedAt !== undefined) return claim;
|
|
137
141
|
await readyPolicy(ctx, claim);
|
|
142
|
+
const feeRevision = (await readFees(ctx, claim))?.revision ?? 0;
|
|
143
|
+
if (feeRevision !== (claim.feeRevision ?? 0))
|
|
144
|
+
throw new Error("Payout fee revision changed after reservation.");
|
|
138
145
|
await checkSnapshot(ctx, claim.accountId, claim.observations, true);
|
|
139
146
|
await ctx.db.patch("payoutReleaseClaims", claim._id, {
|
|
140
147
|
attemptedAt: Date.now(),
|
|
@@ -175,6 +182,9 @@ export const recordOutcome = mutation({
|
|
|
175
182
|
v.literal("canceled"),
|
|
176
183
|
),
|
|
177
184
|
returnTransactionId: v.optional(v.string()),
|
|
185
|
+
debitTransactionId: v.optional(v.string()),
|
|
186
|
+
debitFee: v.optional(v.number()),
|
|
187
|
+
returnFee: v.optional(v.number()),
|
|
178
188
|
},
|
|
179
189
|
returns: claimDoc,
|
|
180
190
|
handler: async (ctx, args) => {
|
|
@@ -226,8 +236,15 @@ export const recordOutcome = mutation({
|
|
|
226
236
|
if (claim.status === "returned") {
|
|
227
237
|
if (returned && args.returnTransactionId !== claim.returnTransactionId)
|
|
228
238
|
throw new Error("Payout return receipt identity changed.");
|
|
239
|
+
if (returned) {
|
|
240
|
+
await recordFees(ctx, claim, args, true);
|
|
241
|
+
return (await ctx.db.get("payoutReleaseClaims", claim._id))!;
|
|
242
|
+
}
|
|
229
243
|
return claim;
|
|
230
244
|
}
|
|
245
|
+
if (claim.status === "paid" && args.providerStatus !== "paid" && !returned)
|
|
246
|
+
return claim;
|
|
247
|
+
await recordFees(ctx, claim, args, returned);
|
|
231
248
|
if (returned) {
|
|
232
249
|
await moveAllocations(
|
|
233
250
|
ctx,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFees } from "./fees.js";
|
|
1
2
|
import { paginationOptsValidator } from "convex/server";
|
|
2
3
|
import { v } from "convex/values";
|
|
3
4
|
import { query } from "../_generated/server.js";
|
|
@@ -54,3 +55,13 @@ export const pending = query({
|
|
|
54
55
|
};
|
|
55
56
|
},
|
|
56
57
|
});
|
|
58
|
+
|
|
59
|
+
export const fees = query({
|
|
60
|
+
args: { accountId: v.string(), ownerKey: v.string(), currency: v.string() },
|
|
61
|
+
returns: v.object({ amount: v.number(), revision: v.number() }),
|
|
62
|
+
handler: async (ctx, args) => {
|
|
63
|
+
await readPolicy(ctx, args);
|
|
64
|
+
const balance = await readFees(ctx, args);
|
|
65
|
+
return { amount: balance?.amount ?? 0, revision: balance?.revision ?? 0 };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
@@ -36,6 +36,10 @@ export const claimFields = {
|
|
|
36
36
|
attemptedAt: v.optional(v.number()),
|
|
37
37
|
stripePayoutId: v.optional(v.string()),
|
|
38
38
|
returnTransactionId: v.optional(v.string()),
|
|
39
|
+
feeRevision: v.optional(v.number()),
|
|
40
|
+
debitTransactionId: v.optional(v.string()),
|
|
41
|
+
debitFee: v.optional(v.number()),
|
|
42
|
+
providerFee: v.optional(v.number()),
|
|
39
43
|
rejection: v.optional(
|
|
40
44
|
v.object({
|
|
41
45
|
requestId: v.string(),
|
|
@@ -55,6 +59,7 @@ export const claimsTable = defineTable(claimFields)
|
|
|
55
59
|
.index("by_operation", ["operationKey"])
|
|
56
60
|
.index("by_account_status", ["accountId", "status"])
|
|
57
61
|
.index("by_provider_payout", ["stripePayoutId"])
|
|
62
|
+
.index("by_debit_receipt", ["debitTransactionId"])
|
|
58
63
|
.index("by_return_receipt", ["returnTransactionId"])
|
|
59
64
|
.index("by_rejection_request", ["rejection.requestId"]);
|
|
60
65
|
export const sourcesTable = defineTable({
|
|
@@ -67,3 +72,10 @@ export const sourcesTable = defineTable({
|
|
|
67
72
|
.index("by_transfer", ["stripeTransferId"])
|
|
68
73
|
.index("by_account", ["accountId"]);
|
|
69
74
|
export const identity = { ...scopeFields, operationKey: v.string() };
|
|
75
|
+
|
|
76
|
+
export const feeBalancesTable = defineTable({
|
|
77
|
+
...scopeFields,
|
|
78
|
+
currency: v.string(),
|
|
79
|
+
amount: v.number(),
|
|
80
|
+
revision: v.number(),
|
|
81
|
+
}).index("by_account_currency", ["accountId", "currency"]);
|
package/src/component/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
feeBalancesTable as payoutFeeBalancesTable,
|
|
2
3
|
claimsTable as payoutReleaseClaimsTable,
|
|
3
4
|
sourcesTable as payoutSourceAllocationsTable,
|
|
4
5
|
} from "./payoutRelease/schema.js";
|
|
@@ -36,6 +37,7 @@ import { pricesTable, productsTable } from "./products/schema";
|
|
|
36
37
|
import { webhookEventsTable } from "./webhooks/schema";
|
|
37
38
|
|
|
38
39
|
export default defineSchema({
|
|
40
|
+
payoutFeeBalances: payoutFeeBalancesTable,
|
|
39
41
|
payoutReleaseClaims: payoutReleaseClaimsTable,
|
|
40
42
|
payoutSourceAllocations: payoutSourceAllocationsTable,
|
|
41
43
|
payoutPolicies: policiesTable,
|