@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +16 -5
  3. package/dist/client/connect/payoutExecution.d.ts +34 -0
  4. package/dist/client/connect/payoutExecution.d.ts.map +1 -0
  5. package/dist/client/connect/payoutExecution.js +189 -0
  6. package/dist/client/connect/payoutExecution.js.map +1 -0
  7. package/dist/client/connect/payoutHistory.d.ts +22 -0
  8. package/dist/client/connect/payoutHistory.d.ts.map +1 -0
  9. package/dist/client/connect/payoutHistory.js +122 -0
  10. package/dist/client/connect/payoutHistory.js.map +1 -0
  11. package/dist/client/connect/payoutReceipt.d.ts +53 -0
  12. package/dist/client/connect/payoutReceipt.d.ts.map +1 -0
  13. package/dist/client/connect/payoutReceipt.js +185 -0
  14. package/dist/client/connect/payoutReceipt.js.map +1 -0
  15. package/dist/client/connect/payoutReconciliation.d.ts +38 -0
  16. package/dist/client/connect/payoutReconciliation.d.ts.map +1 -0
  17. package/dist/client/connect/payoutReconciliation.js +112 -0
  18. package/dist/client/connect/payoutReconciliation.js.map +1 -0
  19. package/dist/client/payoutPolicy.d.ts +9 -0
  20. package/dist/client/payoutPolicy.d.ts.map +1 -1
  21. package/dist/client/payoutPolicy.js +12 -0
  22. package/dist/client/payoutPolicy.js.map +1 -1
  23. package/dist/component/_generated/component.d.ts +112 -0
  24. package/dist/component/_generated/component.d.ts.map +1 -1
  25. package/dist/component/earnings/triggers.d.ts +52 -2
  26. package/dist/component/earnings/triggers.d.ts.map +1 -1
  27. package/dist/component/payoutRelease/allocation.d.ts +1 -0
  28. package/dist/component/payoutRelease/allocation.d.ts.map +1 -1
  29. package/dist/component/payoutRelease/allocation.js +3 -0
  30. package/dist/component/payoutRelease/allocation.js.map +1 -1
  31. package/dist/component/payoutRelease/fees.d.ts +22 -0
  32. package/dist/component/payoutRelease/fees.d.ts.map +1 -0
  33. package/dist/component/payoutRelease/fees.js +73 -0
  34. package/dist/component/payoutRelease/fees.js.map +1 -0
  35. package/dist/component/payoutRelease/helpers.d.ts +5 -0
  36. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  37. package/dist/component/payoutRelease/mutations.d.ts +29 -0
  38. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  39. package/dist/component/payoutRelease/mutations.js +24 -1
  40. package/dist/component/payoutRelease/mutations.js.map +1 -1
  41. package/dist/component/payoutRelease/queries.d.ts +87 -0
  42. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  43. package/dist/component/payoutRelease/queries.js +39 -0
  44. package/dist/component/payoutRelease/queries.js.map +1 -1
  45. package/dist/component/payoutRelease/schema.d.ts +43 -2
  46. package/dist/component/payoutRelease/schema.d.ts.map +1 -1
  47. package/dist/component/payoutRelease/schema.js +12 -0
  48. package/dist/component/payoutRelease/schema.js.map +1 -1
  49. package/dist/component/schema.d.ts +27 -1
  50. package/dist/component/schema.js +2 -1
  51. package/dist/component/schema.js.map +1 -1
  52. package/docs/settlement.md +134 -3
  53. package/package.json +1 -1
  54. package/src/client/connect/payoutAllocation.test.ts +36 -3
  55. package/src/client/connect/payoutExecution.test.ts +504 -0
  56. package/src/client/connect/payoutExecution.ts +265 -0
  57. package/src/client/connect/payoutHistory.test.ts +145 -0
  58. package/src/client/connect/payoutHistory.ts +149 -0
  59. package/src/client/connect/payoutReceipt.test.ts +312 -0
  60. package/src/client/connect/payoutReceipt.ts +287 -0
  61. package/src/client/connect/payoutReconciliation.test.ts +398 -0
  62. package/src/client/connect/payoutReconciliation.ts +178 -0
  63. package/src/client/payoutPolicy.ts +30 -0
  64. package/src/component/_generated/api.ts +2 -0
  65. package/src/component/_generated/component.ts +118 -0
  66. package/src/component/payout-fees.test.ts +224 -0
  67. package/src/component/payout-release-snapshot.test.ts +57 -0
  68. package/src/component/payoutRelease/allocation.ts +6 -0
  69. package/src/component/payoutRelease/fees.ts +93 -0
  70. package/src/component/payoutRelease/mutations.ts +26 -1
  71. package/src/component/payoutRelease/queries.ts +46 -0
  72. package/src/component/payoutRelease/schema.ts +13 -0
  73. 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
+ }