@fantastic.dev/better-stripe 0.20.1 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +11 -1
  3. package/dist/client/connect/payoutReceipt.d.ts +53 -0
  4. package/dist/client/connect/payoutReceipt.d.ts.map +1 -0
  5. package/dist/client/connect/payoutReceipt.js +185 -0
  6. package/dist/client/connect/payoutReceipt.js.map +1 -0
  7. package/dist/client/connect/payoutReconciliation.d.ts +38 -0
  8. package/dist/client/connect/payoutReconciliation.d.ts.map +1 -0
  9. package/dist/client/connect/payoutReconciliation.js +110 -0
  10. package/dist/client/connect/payoutReconciliation.js.map +1 -0
  11. package/dist/client/payoutPolicy.d.ts +6 -0
  12. package/dist/client/payoutPolicy.d.ts.map +1 -1
  13. package/dist/client/payoutPolicy.js +8 -0
  14. package/dist/client/payoutPolicy.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +39 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/earnings/triggers.d.ts +50 -2
  18. package/dist/component/earnings/triggers.d.ts.map +1 -1
  19. package/dist/component/payoutRelease/allocation.d.ts +1 -0
  20. package/dist/component/payoutRelease/allocation.d.ts.map +1 -1
  21. package/dist/component/payoutRelease/allocation.js +3 -0
  22. package/dist/component/payoutRelease/allocation.js.map +1 -1
  23. package/dist/component/payoutRelease/fees.d.ts +22 -0
  24. package/dist/component/payoutRelease/fees.d.ts.map +1 -0
  25. package/dist/component/payoutRelease/fees.js +73 -0
  26. package/dist/component/payoutRelease/fees.js.map +1 -0
  27. package/dist/component/payoutRelease/helpers.d.ts +4 -0
  28. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  29. package/dist/component/payoutRelease/mutations.d.ts +23 -0
  30. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  31. package/dist/component/payoutRelease/mutations.js +17 -0
  32. package/dist/component/payoutRelease/mutations.js.map +1 -1
  33. package/dist/component/payoutRelease/queries.d.ts +16 -0
  34. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  35. package/dist/component/payoutRelease/queries.js +10 -0
  36. package/dist/component/payoutRelease/queries.js.map +1 -1
  37. package/dist/component/payoutRelease/schema.d.ts +38 -2
  38. package/dist/component/payoutRelease/schema.d.ts.map +1 -1
  39. package/dist/component/payoutRelease/schema.js +11 -0
  40. package/dist/component/payoutRelease/schema.js.map +1 -1
  41. package/dist/component/schema.d.ts +25 -1
  42. package/dist/component/schema.js +2 -1
  43. package/dist/component/schema.js.map +1 -1
  44. package/docs/settlement.md +74 -3
  45. package/package.json +1 -1
  46. package/src/client/connect/payoutAllocation.test.ts +36 -3
  47. package/src/client/connect/payoutReceipt.test.ts +312 -0
  48. package/src/client/connect/payoutReceipt.ts +287 -0
  49. package/src/client/connect/payoutReconciliation.test.ts +385 -0
  50. package/src/client/connect/payoutReconciliation.ts +174 -0
  51. package/src/client/payoutPolicy.ts +16 -0
  52. package/src/component/_generated/api.ts +2 -0
  53. package/src/component/_generated/component.ts +38 -0
  54. package/src/component/payout-fees.test.ts +224 -0
  55. package/src/component/payoutRelease/allocation.ts +6 -0
  56. package/src/component/payoutRelease/fees.ts +93 -0
  57. package/src/component/payoutRelease/mutations.ts +17 -0
  58. package/src/component/payoutRelease/queries.ts +11 -0
  59. package/src/component/payoutRelease/schema.ts +12 -0
  60. 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"]);
@@ -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,