@fantastic.dev/better-stripe 0.20.1 → 0.22.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 +16 -5
- package/dist/client/connect/payoutExecution.d.ts +34 -0
- package/dist/client/connect/payoutExecution.d.ts.map +1 -0
- package/dist/client/connect/payoutExecution.js +189 -0
- package/dist/client/connect/payoutExecution.js.map +1 -0
- package/dist/client/connect/payoutHistory.d.ts +22 -0
- package/dist/client/connect/payoutHistory.d.ts.map +1 -0
- package/dist/client/connect/payoutHistory.js +122 -0
- package/dist/client/connect/payoutHistory.js.map +1 -0
- 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 +112 -0
- package/dist/client/connect/payoutReconciliation.js.map +1 -0
- package/dist/client/payoutPolicy.d.ts +9 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +12 -0
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +112 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +52 -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 +5 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts +29 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +24 -1
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/queries.d.ts +87 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/queries.js +39 -0
- package/dist/component/payoutRelease/queries.js.map +1 -1
- package/dist/component/payoutRelease/schema.d.ts +43 -2
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +12 -0
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/schema.d.ts +27 -1
- package/dist/component/schema.js +2 -1
- package/dist/component/schema.js.map +1 -1
- package/docs/settlement.md +134 -3
- package/package.json +1 -1
- package/src/client/connect/payoutAllocation.test.ts +36 -3
- package/src/client/connect/payoutExecution.test.ts +504 -0
- package/src/client/connect/payoutExecution.ts +265 -0
- package/src/client/connect/payoutHistory.test.ts +145 -0
- package/src/client/connect/payoutHistory.ts +149 -0
- package/src/client/connect/payoutReceipt.test.ts +312 -0
- package/src/client/connect/payoutReceipt.ts +287 -0
- package/src/client/connect/payoutReconciliation.test.ts +398 -0
- package/src/client/connect/payoutReconciliation.ts +178 -0
- package/src/client/payoutPolicy.ts +30 -0
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +118 -0
- package/src/component/payout-fees.test.ts +224 -0
- package/src/component/payout-release-snapshot.test.ts +57 -0
- package/src/component/payoutRelease/allocation.ts +6 -0
- package/src/component/payoutRelease/fees.ts +93 -0
- package/src/component/payoutRelease/mutations.ts +26 -1
- package/src/component/payoutRelease/queries.ts +46 -0
- package/src/component/payoutRelease/schema.ts +13 -0
- package/src/component/schema.ts +2 -0
|
@@ -2492,7 +2492,11 @@ 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;
|
|
2497
|
+
destinationId?: string;
|
|
2495
2498
|
expectedRevision: number;
|
|
2499
|
+
feeRevision?: number;
|
|
2496
2500
|
observations: Array<{
|
|
2497
2501
|
currency: string;
|
|
2498
2502
|
ledgerFingerprint: string;
|
|
@@ -2504,6 +2508,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2504
2508
|
}>;
|
|
2505
2509
|
operationKey: string;
|
|
2506
2510
|
ownerKey: string;
|
|
2511
|
+
providerFee?: number;
|
|
2507
2512
|
rejection?: {
|
|
2508
2513
|
code: "balance_insufficient" | "amount_too_small";
|
|
2509
2514
|
requestId: string;
|
|
@@ -2536,7 +2541,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2536
2541
|
amount: number;
|
|
2537
2542
|
attemptedAt?: number;
|
|
2538
2543
|
currency: string;
|
|
2544
|
+
debitFee?: number;
|
|
2545
|
+
debitTransactionId?: string;
|
|
2546
|
+
destinationId?: string;
|
|
2539
2547
|
expectedRevision: number;
|
|
2548
|
+
feeRevision?: number;
|
|
2540
2549
|
observations: Array<{
|
|
2541
2550
|
currency: string;
|
|
2542
2551
|
ledgerFingerprint: string;
|
|
@@ -2548,6 +2557,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2548
2557
|
}>;
|
|
2549
2558
|
operationKey: string;
|
|
2550
2559
|
ownerKey: string;
|
|
2560
|
+
providerFee?: number;
|
|
2551
2561
|
rejection?: {
|
|
2552
2562
|
code: "balance_insufficient" | "amount_too_small";
|
|
2553
2563
|
requestId: string;
|
|
@@ -2570,6 +2580,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2570
2580
|
{
|
|
2571
2581
|
accountId: string;
|
|
2572
2582
|
currency: string;
|
|
2583
|
+
destinationId?: string;
|
|
2573
2584
|
expectedRevision: number;
|
|
2574
2585
|
independentLiabilities: number;
|
|
2575
2586
|
observations: Array<{
|
|
@@ -2597,7 +2608,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2597
2608
|
amount: number;
|
|
2598
2609
|
attemptedAt?: number;
|
|
2599
2610
|
currency: string;
|
|
2611
|
+
debitFee?: number;
|
|
2612
|
+
debitTransactionId?: string;
|
|
2613
|
+
destinationId?: string;
|
|
2600
2614
|
expectedRevision: number;
|
|
2615
|
+
feeRevision?: number;
|
|
2601
2616
|
observations: Array<{
|
|
2602
2617
|
currency: string;
|
|
2603
2618
|
ledgerFingerprint: string;
|
|
@@ -2609,6 +2624,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2609
2624
|
}>;
|
|
2610
2625
|
operationKey: string;
|
|
2611
2626
|
ownerKey: string;
|
|
2627
|
+
providerFee?: number;
|
|
2612
2628
|
rejection?: {
|
|
2613
2629
|
code: "balance_insufficient" | "amount_too_small";
|
|
2614
2630
|
requestId: string;
|
|
@@ -2642,10 +2658,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2642
2658
|
accountId: string;
|
|
2643
2659
|
amount: number;
|
|
2644
2660
|
currency: string;
|
|
2661
|
+
debitFee?: number;
|
|
2662
|
+
debitTransactionId?: string;
|
|
2645
2663
|
operationKey: string;
|
|
2646
2664
|
ownerKey: string;
|
|
2647
2665
|
providerStatus:
|
|
2648
2666
|
"pending" | "in_transit" | "paid" | "failed" | "canceled";
|
|
2667
|
+
returnFee?: number;
|
|
2649
2668
|
returnTransactionId?: string;
|
|
2650
2669
|
stripePayoutId: string;
|
|
2651
2670
|
},
|
|
@@ -2661,7 +2680,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2661
2680
|
amount: number;
|
|
2662
2681
|
attemptedAt?: number;
|
|
2663
2682
|
currency: string;
|
|
2683
|
+
debitFee?: number;
|
|
2684
|
+
debitTransactionId?: string;
|
|
2685
|
+
destinationId?: string;
|
|
2664
2686
|
expectedRevision: number;
|
|
2687
|
+
feeRevision?: number;
|
|
2665
2688
|
observations: Array<{
|
|
2666
2689
|
currency: string;
|
|
2667
2690
|
ledgerFingerprint: string;
|
|
@@ -2673,6 +2696,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2673
2696
|
}>;
|
|
2674
2697
|
operationKey: string;
|
|
2675
2698
|
ownerKey: string;
|
|
2699
|
+
providerFee?: number;
|
|
2676
2700
|
rejection?: {
|
|
2677
2701
|
code: "balance_insufficient" | "amount_too_small";
|
|
2678
2702
|
requestId: string;
|
|
@@ -2711,7 +2735,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2711
2735
|
amount: number;
|
|
2712
2736
|
attemptedAt?: number;
|
|
2713
2737
|
currency: string;
|
|
2738
|
+
debitFee?: number;
|
|
2739
|
+
debitTransactionId?: string;
|
|
2740
|
+
destinationId?: string;
|
|
2714
2741
|
expectedRevision: number;
|
|
2742
|
+
feeRevision?: number;
|
|
2715
2743
|
observations: Array<{
|
|
2716
2744
|
currency: string;
|
|
2717
2745
|
ledgerFingerprint: string;
|
|
@@ -2723,6 +2751,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2723
2751
|
}>;
|
|
2724
2752
|
operationKey: string;
|
|
2725
2753
|
ownerKey: string;
|
|
2754
|
+
providerFee?: number;
|
|
2726
2755
|
rejection?: {
|
|
2727
2756
|
code: "balance_insufficient" | "amount_too_small";
|
|
2728
2757
|
requestId: string;
|
|
@@ -2741,6 +2770,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2741
2770
|
>;
|
|
2742
2771
|
};
|
|
2743
2772
|
queries: {
|
|
2773
|
+
fees: FunctionReference<
|
|
2774
|
+
"query",
|
|
2775
|
+
"internal",
|
|
2776
|
+
{ accountId: string; currency: string; ownerKey: string },
|
|
2777
|
+
{ amount: number; revision: number },
|
|
2778
|
+
Name
|
|
2779
|
+
>;
|
|
2744
2780
|
get: FunctionReference<
|
|
2745
2781
|
"query",
|
|
2746
2782
|
"internal",
|
|
@@ -2757,7 +2793,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2757
2793
|
amount: number;
|
|
2758
2794
|
attemptedAt?: number;
|
|
2759
2795
|
currency: string;
|
|
2796
|
+
debitFee?: number;
|
|
2797
|
+
debitTransactionId?: string;
|
|
2798
|
+
destinationId?: string;
|
|
2760
2799
|
expectedRevision: number;
|
|
2800
|
+
feeRevision?: number;
|
|
2761
2801
|
observations: Array<{
|
|
2762
2802
|
currency: string;
|
|
2763
2803
|
ledgerFingerprint: string;
|
|
@@ -2769,6 +2809,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2769
2809
|
}>;
|
|
2770
2810
|
operationKey: string;
|
|
2771
2811
|
ownerKey: string;
|
|
2812
|
+
providerFee?: number;
|
|
2772
2813
|
rejection?: {
|
|
2773
2814
|
code: "balance_insufficient" | "amount_too_small";
|
|
2774
2815
|
requestId: string;
|
|
@@ -2815,7 +2856,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2815
2856
|
amount: number;
|
|
2816
2857
|
attemptedAt?: number;
|
|
2817
2858
|
currency: string;
|
|
2859
|
+
debitFee?: number;
|
|
2860
|
+
debitTransactionId?: string;
|
|
2861
|
+
destinationId?: string;
|
|
2818
2862
|
expectedRevision: number;
|
|
2863
|
+
feeRevision?: number;
|
|
2819
2864
|
observations: Array<{
|
|
2820
2865
|
currency: string;
|
|
2821
2866
|
ledgerFingerprint: string;
|
|
@@ -2827,6 +2872,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2827
2872
|
}>;
|
|
2828
2873
|
operationKey: string;
|
|
2829
2874
|
ownerKey: string;
|
|
2875
|
+
providerFee?: number;
|
|
2830
2876
|
rejection?: {
|
|
2831
2877
|
code: "balance_insufficient" | "amount_too_small";
|
|
2832
2878
|
requestId: string;
|
|
@@ -2844,6 +2890,78 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2844
2890
|
},
|
|
2845
2891
|
Name
|
|
2846
2892
|
>;
|
|
2893
|
+
snapshot: FunctionReference<
|
|
2894
|
+
"query",
|
|
2895
|
+
"internal",
|
|
2896
|
+
{ accountId: string; ownerKey: string },
|
|
2897
|
+
| { reason: "snapshot_limit"; status: "held" }
|
|
2898
|
+
| {
|
|
2899
|
+
claims: Array<{
|
|
2900
|
+
_creationTime: number;
|
|
2901
|
+
_id: string;
|
|
2902
|
+
accountId: string;
|
|
2903
|
+
allocations: Array<{
|
|
2904
|
+
amount: number;
|
|
2905
|
+
role?: "store" | "affiliate" | "other";
|
|
2906
|
+
stripeTransferId: string;
|
|
2907
|
+
}>;
|
|
2908
|
+
amount: number;
|
|
2909
|
+
attemptedAt?: number;
|
|
2910
|
+
currency: string;
|
|
2911
|
+
debitFee?: number;
|
|
2912
|
+
debitTransactionId?: string;
|
|
2913
|
+
destinationId?: string;
|
|
2914
|
+
expectedRevision: number;
|
|
2915
|
+
feeRevision?: number;
|
|
2916
|
+
observations: Array<{
|
|
2917
|
+
currency: string;
|
|
2918
|
+
ledgerFingerprint: string;
|
|
2919
|
+
matureNet: number;
|
|
2920
|
+
observedAt: number;
|
|
2921
|
+
pendingCredits: number;
|
|
2922
|
+
stripeTransferId: string;
|
|
2923
|
+
transactionIds: Array<string>;
|
|
2924
|
+
}>;
|
|
2925
|
+
operationKey: string;
|
|
2926
|
+
ownerKey: string;
|
|
2927
|
+
providerFee?: number;
|
|
2928
|
+
rejection?: {
|
|
2929
|
+
code: "balance_insufficient" | "amount_too_small";
|
|
2930
|
+
requestId: string;
|
|
2931
|
+
};
|
|
2932
|
+
returnTransactionId?: string;
|
|
2933
|
+
status:
|
|
2934
|
+
| "reserved"
|
|
2935
|
+
| "submitted"
|
|
2936
|
+
| "paid"
|
|
2937
|
+
| "returned"
|
|
2938
|
+
| "canceled"
|
|
2939
|
+
| "rejected";
|
|
2940
|
+
stripePayoutId?: string;
|
|
2941
|
+
}>;
|
|
2942
|
+
status: "ready";
|
|
2943
|
+
transfers: Array<{
|
|
2944
|
+
_creationTime: number;
|
|
2945
|
+
_id: string;
|
|
2946
|
+
amount: number;
|
|
2947
|
+
currency: string;
|
|
2948
|
+
destinationAccountId: string;
|
|
2949
|
+
metadata?: any;
|
|
2950
|
+
paymentId?: string;
|
|
2951
|
+
reinstatement?: boolean;
|
|
2952
|
+
reversalClaimedAmount?: number;
|
|
2953
|
+
reversalStatus?: "partially_reversed" | "fully_reversed";
|
|
2954
|
+
reversedAmount?: number;
|
|
2955
|
+
role?: "store" | "affiliate" | "other";
|
|
2956
|
+
sourceChargeId?: string;
|
|
2957
|
+
sourceInvoiceId?: string;
|
|
2958
|
+
status: "pending" | "paid" | "failed" | "reversed";
|
|
2959
|
+
stripeTransferId: string;
|
|
2960
|
+
totalsRecorded?: true;
|
|
2961
|
+
}>;
|
|
2962
|
+
},
|
|
2963
|
+
Name
|
|
2964
|
+
>;
|
|
2847
2965
|
};
|
|
2848
2966
|
};
|
|
2849
2967
|
products: {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
7
|
+
const snapshot = makeFunctionReference<"query">(
|
|
8
|
+
"payoutRelease/queries:snapshot",
|
|
9
|
+
);
|
|
10
|
+
const scope = { accountId: "acct_recipient", ownerKey: "owner" };
|
|
11
|
+
async function setup(count = 2) {
|
|
12
|
+
const t = convexTest(schema, modules);
|
|
13
|
+
await t.run(async (ctx) => {
|
|
14
|
+
await ctx.db.insert("payoutPolicies", {
|
|
15
|
+
...scope,
|
|
16
|
+
mode: "custom",
|
|
17
|
+
status: "ready",
|
|
18
|
+
revision: 0,
|
|
19
|
+
operationKey: "enable",
|
|
20
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
21
|
+
country: "US",
|
|
22
|
+
previousSchedule: { interval: "daily" },
|
|
23
|
+
holdDeadline: Date.now() + 86400000,
|
|
24
|
+
});
|
|
25
|
+
for (let i = 0; i < count; i++)
|
|
26
|
+
await ctx.db.insert("transfers", {
|
|
27
|
+
stripeTransferId: `tr_${i}`,
|
|
28
|
+
destinationAccountId: scope.accountId,
|
|
29
|
+
amount: 500,
|
|
30
|
+
currency: "usd",
|
|
31
|
+
status: "paid",
|
|
32
|
+
role: i % 2 ? "affiliate" : "store",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
return t;
|
|
36
|
+
}
|
|
37
|
+
it("returns complete source membership and historical claims for the owned account", async () => {
|
|
38
|
+
const t = await setup();
|
|
39
|
+
expect(await t.query(snapshot, scope)).toMatchObject({
|
|
40
|
+
status: "ready",
|
|
41
|
+
transfers: [{ stripeTransferId: "tr_0" }, { stripeTransferId: "tr_1" }],
|
|
42
|
+
claims: [],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
it("holds an account exceeding the atomic claim bound rather than qualifying a partial page", async () => {
|
|
46
|
+
const t = await setup(101);
|
|
47
|
+
expect(await t.query(snapshot, scope)).toEqual({
|
|
48
|
+
status: "held",
|
|
49
|
+
reason: "snapshot_limit",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it("rejects another owner", async () => {
|
|
53
|
+
const t = await setup();
|
|
54
|
+
await expect(
|
|
55
|
+
t.query(snapshot, { ...scope, ownerKey: "other" }),
|
|
56
|
+
).rejects.toThrow(/owner/i);
|
|
57
|
+
});
|
|
@@ -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
|
+
}
|