@fantastic.dev/better-stripe 0.23.2 → 0.24.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 (236) hide show
  1. package/CHANGELOG.md +20 -2
  2. package/README.md +40 -5
  3. package/dist/client/affiliateRecovery/disputes.d.ts +18 -0
  4. package/dist/client/affiliateRecovery/disputes.d.ts.map +1 -0
  5. package/dist/client/affiliateRecovery/disputes.js +33 -0
  6. package/dist/client/affiliateRecovery/disputes.js.map +1 -0
  7. package/dist/client/affiliateRecovery/engine.d.ts +29 -0
  8. package/dist/client/affiliateRecovery/engine.d.ts.map +1 -0
  9. package/dist/client/affiliateRecovery/engine.js +116 -0
  10. package/dist/client/affiliateRecovery/engine.js.map +1 -0
  11. package/dist/client/affiliateRecovery/payoutGate.d.ts +24 -0
  12. package/dist/client/affiliateRecovery/payoutGate.d.ts.map +1 -0
  13. package/dist/client/affiliateRecovery/payoutGate.js +30 -0
  14. package/dist/client/affiliateRecovery/payoutGate.js.map +1 -0
  15. package/dist/client/affiliateRecovery/provider.d.ts +14 -0
  16. package/dist/client/affiliateRecovery/provider.d.ts.map +1 -0
  17. package/dist/client/affiliateRecovery/provider.js +96 -0
  18. package/dist/client/affiliateRecovery/provider.js.map +1 -0
  19. package/dist/client/affiliateRecovery/review.d.ts +28 -0
  20. package/dist/client/affiliateRecovery/review.d.ts.map +1 -0
  21. package/dist/client/affiliateRecovery/review.js +86 -0
  22. package/dist/client/affiliateRecovery/review.js.map +1 -0
  23. package/dist/client/affiliateRecovery/validators.d.ts +402 -0
  24. package/dist/client/affiliateRecovery/validators.d.ts.map +1 -0
  25. package/dist/client/affiliateRecovery/validators.js +27 -0
  26. package/dist/client/affiliateRecovery/validators.js.map +1 -0
  27. package/dist/client/affiliateRecovery.d.ts +131 -0
  28. package/dist/client/affiliateRecovery.d.ts.map +1 -0
  29. package/dist/client/affiliateRecovery.js +69 -0
  30. package/dist/client/affiliateRecovery.js.map +1 -0
  31. package/dist/client/connect/commissionReceipts.d.ts +4 -0
  32. package/dist/client/connect/commissionReceipts.d.ts.map +1 -0
  33. package/dist/client/connect/commissionReceipts.js +17 -0
  34. package/dist/client/connect/commissionReceipts.js.map +1 -0
  35. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +494 -2
  36. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
  37. package/dist/client/connect/payoutSyncTestHelpers.d.ts +247 -1
  38. package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
  39. package/dist/client/connect/payouts.d.ts +3 -1
  40. package/dist/client/connect/payouts.d.ts.map +1 -1
  41. package/dist/client/connect/payouts.js +45 -12
  42. package/dist/client/connect/payouts.js.map +1 -1
  43. package/dist/client/connect/refundPrincipal.d.ts +4 -0
  44. package/dist/client/connect/refundPrincipal.d.ts.map +1 -0
  45. package/dist/client/connect/refundPrincipal.js +21 -0
  46. package/dist/client/connect/refundPrincipal.js.map +1 -0
  47. package/dist/client/connect/reversalExecution.d.ts +47 -0
  48. package/dist/client/connect/reversalExecution.d.ts.map +1 -0
  49. package/dist/client/connect/reversalExecution.js +84 -0
  50. package/dist/client/connect/reversalExecution.js.map +1 -0
  51. package/dist/client/connect/transfers.d.ts +8 -4
  52. package/dist/client/connect/transfers.d.ts.map +1 -1
  53. package/dist/client/connect/transfers.js +52 -57
  54. package/dist/client/connect/transfers.js.map +1 -1
  55. package/dist/client/index.d.ts +3 -0
  56. package/dist/client/index.d.ts.map +1 -1
  57. package/dist/client/index.js +6 -1
  58. package/dist/client/index.js.map +1 -1
  59. package/dist/client/settlement/execution.d.ts.map +1 -1
  60. package/dist/client/settlement/execution.js +30 -3
  61. package/dist/client/settlement/execution.js.map +1 -1
  62. package/dist/client/settlement/reconciliation.d.ts.map +1 -1
  63. package/dist/client/settlement/reconciliation.js +16 -2
  64. package/dist/client/settlement/reconciliation.js.map +1 -1
  65. package/dist/client/webhooks/disputeRecovery.d.ts +4 -0
  66. package/dist/client/webhooks/disputeRecovery.d.ts.map +1 -0
  67. package/dist/client/webhooks/disputeRecovery.js +40 -0
  68. package/dist/client/webhooks/disputeRecovery.js.map +1 -0
  69. package/dist/client/webhooks/processors.d.ts.map +1 -1
  70. package/dist/client/webhooks/processors.js +14 -29
  71. package/dist/client/webhooks/processors.js.map +1 -1
  72. package/dist/component/_generated/component.d.ts +491 -0
  73. package/dist/component/_generated/component.d.ts.map +1 -1
  74. package/dist/component/affiliateEarnings/mutations.d.ts +22 -0
  75. package/dist/component/affiliateEarnings/mutations.d.ts.map +1 -0
  76. package/dist/component/affiliateEarnings/mutations.js +160 -0
  77. package/dist/component/affiliateEarnings/mutations.js.map +1 -0
  78. package/dist/component/affiliatePayouts/mutations.d.ts +25 -0
  79. package/dist/component/affiliatePayouts/mutations.d.ts.map +1 -0
  80. package/dist/component/affiliatePayouts/mutations.js +104 -0
  81. package/dist/component/affiliatePayouts/mutations.js.map +1 -0
  82. package/dist/component/affiliatePayouts/queries.d.ts +23 -0
  83. package/dist/component/affiliatePayouts/queries.d.ts.map +1 -0
  84. package/dist/component/affiliatePayouts/queries.js +23 -0
  85. package/dist/component/affiliatePayouts/queries.js.map +1 -0
  86. package/dist/component/affiliatePayouts/recipientGuard.d.ts +12 -0
  87. package/dist/component/affiliatePayouts/recipientGuard.d.ts.map +1 -0
  88. package/dist/component/affiliatePayouts/recipientGuard.js +26 -0
  89. package/dist/component/affiliatePayouts/recipientGuard.js.map +1 -0
  90. package/dist/component/affiliateRecovery/accounting.d.ts +28 -0
  91. package/dist/component/affiliateRecovery/accounting.d.ts.map +1 -0
  92. package/dist/component/affiliateRecovery/accounting.js +71 -0
  93. package/dist/component/affiliateRecovery/accounting.js.map +1 -0
  94. package/dist/component/affiliateRecovery/mutations.d.ts +65 -0
  95. package/dist/component/affiliateRecovery/mutations.d.ts.map +1 -0
  96. package/dist/component/affiliateRecovery/mutations.js +250 -0
  97. package/dist/component/affiliateRecovery/mutations.js.map +1 -0
  98. package/dist/component/affiliateRecovery/queries.d.ts +305 -0
  99. package/dist/component/affiliateRecovery/queries.d.ts.map +1 -0
  100. package/dist/component/affiliateRecovery/queries.js +113 -0
  101. package/dist/component/affiliateRecovery/queries.js.map +1 -0
  102. package/dist/component/affiliateRecovery/schema.d.ts +171 -0
  103. package/dist/component/affiliateRecovery/schema.d.ts.map +1 -0
  104. package/dist/component/affiliateRecovery/schema.js +71 -0
  105. package/dist/component/affiliateRecovery/schema.js.map +1 -0
  106. package/dist/component/connect/mutations.d.ts +36 -23
  107. package/dist/component/connect/mutations.d.ts.map +1 -1
  108. package/dist/component/connect/mutations.js +7 -124
  109. package/dist/component/connect/mutations.js.map +1 -1
  110. package/dist/component/connect/queries.d.ts +10 -0
  111. package/dist/component/connect/queries.d.ts.map +1 -1
  112. package/dist/component/connect/queries.js +3 -40
  113. package/dist/component/connect/queries.js.map +1 -1
  114. package/dist/component/connect/reversalClaims.d.ts +169 -0
  115. package/dist/component/connect/reversalClaims.d.ts.map +1 -0
  116. package/dist/component/connect/reversalClaims.js +176 -0
  117. package/dist/component/connect/reversalClaims.js.map +1 -0
  118. package/dist/component/connect/reversalDiagnostics.d.ts +59 -0
  119. package/dist/component/connect/reversalDiagnostics.d.ts.map +1 -0
  120. package/dist/component/connect/reversalDiagnostics.js +50 -0
  121. package/dist/component/connect/reversalDiagnostics.js.map +1 -0
  122. package/dist/component/connect/reversalReceipts.d.ts +27 -0
  123. package/dist/component/connect/reversalReceipts.d.ts.map +1 -0
  124. package/dist/component/connect/reversalReceipts.js +85 -0
  125. package/dist/component/connect/reversalReceipts.js.map +1 -0
  126. package/dist/component/connect/schema.d.ts +31 -1
  127. package/dist/component/connect/schema.d.ts.map +1 -1
  128. package/dist/component/connect/schema.js +3 -1
  129. package/dist/component/connect/schema.js.map +1 -1
  130. package/dist/component/connect/validators.d.ts +49 -1
  131. package/dist/component/connect/validators.d.ts.map +1 -1
  132. package/dist/component/connect/validators.js +2 -0
  133. package/dist/component/connect/validators.js.map +1 -1
  134. package/dist/component/earnings/triggers.d.ts +410 -2
  135. package/dist/component/earnings/triggers.d.ts.map +1 -1
  136. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  137. package/dist/component/payoutRelease/mutations.js +6 -0
  138. package/dist/component/payoutRelease/mutations.js.map +1 -1
  139. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  140. package/dist/component/payoutRelease/queries.js +3 -1
  141. package/dist/component/payoutRelease/queries.js.map +1 -1
  142. package/dist/component/schema.d.ts +247 -1
  143. package/dist/component/schema.js +9 -0
  144. package/dist/component/schema.js.map +1 -1
  145. package/dist/component/settlement/clawbackSchema.d.ts +161 -0
  146. package/dist/component/settlement/clawbackSchema.d.ts.map +1 -0
  147. package/dist/component/settlement/clawbackSchema.js +73 -0
  148. package/dist/component/settlement/clawbackSchema.js.map +1 -0
  149. package/dist/component/settlement/clawbacks.d.ts +55 -0
  150. package/dist/component/settlement/clawbacks.d.ts.map +1 -0
  151. package/dist/component/settlement/clawbacks.js +146 -0
  152. package/dist/component/settlement/clawbacks.js.map +1 -0
  153. package/dist/component/settlement/mutations.d.ts +16 -0
  154. package/dist/component/settlement/mutations.d.ts.map +1 -1
  155. package/dist/component/settlement/mutations.js +48 -0
  156. package/dist/component/settlement/mutations.js.map +1 -1
  157. package/dist/component/settlement/queries.d.ts +53 -0
  158. package/dist/component/settlement/queries.d.ts.map +1 -1
  159. package/dist/component/settlement/queries.js +26 -0
  160. package/dist/component/settlement/queries.js.map +1 -1
  161. package/dist/react/workflows/AffiliateRecovery.d.ts +46 -0
  162. package/dist/react/workflows/AffiliateRecovery.d.ts.map +1 -0
  163. package/dist/react/workflows/AffiliateRecovery.js +84 -0
  164. package/dist/react/workflows/AffiliateRecovery.js.map +1 -0
  165. package/dist/react/workflows/index.d.ts +2 -0
  166. package/dist/react/workflows/index.d.ts.map +1 -1
  167. package/dist/react/workflows/index.js +1 -0
  168. package/dist/react/workflows/index.js.map +1 -1
  169. package/dist/testing/affiliate-recovery.d.ts +10 -0
  170. package/dist/testing/affiliate-recovery.d.ts.map +1 -0
  171. package/dist/testing/affiliate-recovery.js +126 -0
  172. package/dist/testing/affiliate-recovery.js.map +1 -0
  173. package/dist/testing/index.d.ts +2 -0
  174. package/dist/testing/index.d.ts.map +1 -1
  175. package/dist/testing/index.js +1 -0
  176. package/dist/testing/index.js.map +1 -1
  177. package/docs/affiliate-clawbacks.md +306 -0
  178. package/docs/settlement.md +17 -0
  179. package/package.json +2 -1
  180. package/src/client/affiliateRecovery/disputes.ts +62 -0
  181. package/src/client/affiliateRecovery/engine.ts +196 -0
  182. package/src/client/affiliateRecovery/payoutGate.ts +65 -0
  183. package/src/client/affiliateRecovery/provider.ts +127 -0
  184. package/src/client/affiliateRecovery/review.ts +117 -0
  185. package/src/client/affiliateRecovery/validators.ts +38 -0
  186. package/src/client/affiliateRecovery.ts +173 -0
  187. package/src/client/connect/commissionReceipts.test.ts +157 -0
  188. package/src/client/connect/commissionReceipts.ts +23 -0
  189. package/src/client/connect/payouts.ts +75 -15
  190. package/src/client/connect/refundPrincipal.test.ts +46 -0
  191. package/src/client/connect/refundPrincipal.ts +26 -0
  192. package/src/client/connect/reversalExecution.ts +130 -0
  193. package/src/client/connect/transfers.test.ts +3 -3
  194. package/src/client/connect/transfers.ts +93 -92
  195. package/src/client/index.ts +6 -7
  196. package/src/client/settlement/execution.test.ts +5 -0
  197. package/src/client/settlement/execution.ts +50 -3
  198. package/src/client/settlement/reconciliation.ts +20 -2
  199. package/src/client/webhooks/disputeRecovery.ts +74 -0
  200. package/src/client/webhooks/processors.test.ts +36 -53
  201. package/src/client/webhooks/processors.ts +18 -31
  202. package/src/component/_generated/api.ts +24 -0
  203. package/src/component/_generated/component.ts +614 -1
  204. package/src/component/affiliate-clawbacks.test.ts +370 -0
  205. package/src/component/affiliate-component-pagination.test.ts +83 -0
  206. package/src/component/affiliate-direct-payout.test.ts +160 -0
  207. package/src/component/affiliate-recovery-lifecycle.test.ts +587 -0
  208. package/src/component/affiliateEarnings/mutations.ts +196 -0
  209. package/src/component/affiliatePayouts/mutations.ts +121 -0
  210. package/src/component/affiliatePayouts/queries.ts +28 -0
  211. package/src/component/affiliatePayouts/recipientGuard.ts +42 -0
  212. package/src/component/affiliateRecovery/accounting.ts +111 -0
  213. package/src/component/affiliateRecovery/mutations.ts +275 -0
  214. package/src/component/affiliateRecovery/queries.ts +138 -0
  215. package/src/component/affiliateRecovery/schema.ts +86 -0
  216. package/src/component/connect/mutations.ts +9 -160
  217. package/src/component/connect/queries.ts +4 -47
  218. package/src/component/connect/reversalClaims.ts +232 -0
  219. package/src/component/connect/reversalDiagnostics.ts +61 -0
  220. package/src/component/connect/reversalReceipts.ts +147 -0
  221. package/src/component/connect/schema.ts +3 -3
  222. package/src/component/connect/validators.ts +2 -0
  223. package/src/component/payout-recovery-integration.test.ts +172 -0
  224. package/src/component/payoutRelease/mutations.ts +9 -0
  225. package/src/component/payoutRelease/queries.ts +3 -1
  226. package/src/component/schema.ts +18 -0
  227. package/src/component/settlement/clawbackSchema.ts +78 -0
  228. package/src/component/settlement/clawbacks.ts +233 -0
  229. package/src/component/settlement/mutations.ts +54 -0
  230. package/src/component/settlement/queries.ts +35 -0
  231. package/src/component/summary-arithmetic.test.ts +9 -4
  232. package/src/react/workflows/AffiliateRecovery.test.tsx +106 -0
  233. package/src/react/workflows/AffiliateRecovery.tsx +273 -0
  234. package/src/react/workflows/index.ts +9 -0
  235. package/src/testing/affiliate-recovery.ts +137 -0
  236. package/src/testing/index.ts +2 -0
@@ -0,0 +1,172 @@
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 { changeBalance } from "./affiliateRecovery/accounting.js";
7
+ import { transferFingerprint } from "./payoutRelease/helpers.js";
8
+ const modules = import.meta.glob("./**/*.*s");
9
+ const m = (name: string) =>
10
+ makeFunctionReference<"mutation">(`payoutRelease/mutations:${name}`);
11
+ const pm = (name: string) =>
12
+ makeFunctionReference<"mutation">(`payoutPolicy/mutations:${name}`);
13
+ const scope = { accountId: "acct_recipient", ownerKey: "user_1" };
14
+ const transfer = (
15
+ stripeTransferId: string,
16
+ amount: number,
17
+ role = "store",
18
+ ) => ({
19
+ stripeTransferId,
20
+ destinationAccountId: scope.accountId,
21
+ amount,
22
+ currency: "usd",
23
+ status: "paid",
24
+ role,
25
+ });
26
+ async function setup() {
27
+ const t = convexTest(schema, modules);
28
+ await t.mutation(pm("prepare"), {
29
+ ...scope,
30
+ operationKey: "enable",
31
+ expectedRevision: -1,
32
+ mode: "custom",
33
+ thresholds: [{ currency: "usd", amount: 1000 }],
34
+ country: "US",
35
+ observedSchedule: { interval: "daily" },
36
+ });
37
+ await t.mutation(pm("confirm"), {
38
+ ...scope,
39
+ operationKey: "enable",
40
+ observedSchedule: { interval: "manual" },
41
+ });
42
+ for (const row of [
43
+ transfer("tr_creator", 600),
44
+ transfer("tr_affiliate", 400, "affiliate"),
45
+ ])
46
+ await t.mutation(
47
+ makeFunctionReference<"mutation">("connect/mutations:upsertTransfer"),
48
+ row,
49
+ );
50
+ const rows = await t.run(async (ctx) => ctx.db.query("transfers").take(10));
51
+ const args = {
52
+ ...scope,
53
+ operationKey: "release-1",
54
+ expectedRevision: 0,
55
+ currency: "usd",
56
+ providerAvailable: 1000,
57
+ independentLiabilities: 0,
58
+ observations: rows.map((row) => ({
59
+ stripeTransferId: row.stripeTransferId,
60
+ matureNet: row.amount,
61
+ pendingCredits: 0,
62
+ currency: row.currency,
63
+ ledgerFingerprint: transferFingerprint(row),
64
+ observedAt: Date.now(),
65
+ transactionIds: [`txn_${row.stripeTransferId}`],
66
+ })),
67
+ };
68
+ return { t, args };
69
+ }
70
+ it("blocks combined payouts on affiliate debt in the payout currency", async () => {
71
+ const { t, args } = await setup();
72
+ await t.run((ctx) =>
73
+ changeBalance(ctx, scope.accountId, "usd", { outstandingDebt: 100 }),
74
+ );
75
+ await expect(t.mutation(m("claim"), args)).rejects.toThrow(/recovery/);
76
+ expect(
77
+ await t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
78
+ ).toEqual([]);
79
+ expect(
80
+ await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
81
+ ).toEqual([]);
82
+ });
83
+
84
+ it("keeps independent currency debts out of a verified payout", async () => {
85
+ const { t, args } = await setup();
86
+ await t.run((ctx) =>
87
+ changeBalance(ctx, scope.accountId, "eur", { outstandingDebt: 100 }),
88
+ );
89
+ expect(await t.mutation(m("claim"), args)).toMatchObject({
90
+ status: "reserved",
91
+ amount: 1000,
92
+ });
93
+ });
94
+
95
+ it("shares one reservation between direct and combined payout paths", async () => {
96
+ const { t, args } = await setup();
97
+ await t.run((ctx) => changeBalance(ctx, scope.accountId, "usd", {}));
98
+ const direct = () =>
99
+ t.mutation(
100
+ makeFunctionReference<"mutation">(
101
+ "affiliatePayouts/mutations:reservePayout",
102
+ ),
103
+ {
104
+ affiliateAccountId: scope.accountId,
105
+ currency: "usd",
106
+ amount: 1000,
107
+ minimumAmount: 1,
108
+ operationKey: "direct",
109
+ now: Date.now(),
110
+ },
111
+ );
112
+ const outcomes = await Promise.allSettled([
113
+ t.mutation(m("claim"), args),
114
+ direct(),
115
+ ]);
116
+ expect(
117
+ outcomes.filter((result) => result.status === "fulfilled"),
118
+ ).toHaveLength(1);
119
+ expect(
120
+ outcomes.filter((result) => result.status === "rejected"),
121
+ ).toHaveLength(1);
122
+ });
123
+
124
+ it("rechecks new debt immediately before the provider attempt and permits safe cancellation", async () => {
125
+ const { t, args } = await setup();
126
+ await t.mutation(m("claim"), args);
127
+ await t.run((ctx) =>
128
+ changeBalance(ctx, scope.accountId, "usd", { outstandingDebt: 100 }),
129
+ );
130
+ const identity = { ...scope, operationKey: args.operationKey };
131
+ await expect(t.mutation(m("beginAttempt"), identity)).rejects.toThrow(
132
+ /recovery/,
133
+ );
134
+ await t.mutation(m("cancelUnattempted"), identity);
135
+ expect(
136
+ await t.run((ctx) => ctx.db.query("affiliateRecoveryBalances").first()),
137
+ ).toMatchObject({
138
+ outstandingDebt: 100,
139
+ pendingPayoutAmount: 0,
140
+ });
141
+ });
142
+
143
+ it("holds unknown payout attempts until a verified receipt, without erasing later debt", async () => {
144
+ const { t, args } = await setup();
145
+ await t.mutation(m("claim"), args);
146
+ const identity = { ...scope, operationKey: args.operationKey };
147
+ await t.mutation(m("beginAttempt"), identity);
148
+ await t.run((ctx) =>
149
+ changeBalance(ctx, scope.accountId, "usd", { outstandingDebt: 100 }),
150
+ );
151
+ const before = await t.run((ctx) =>
152
+ ctx.db.query("affiliateRecoveryBalances").first(),
153
+ );
154
+ expect(before?.activePayoutKey).toBe(
155
+ `bs:recipient-payout:${args.operationKey}`,
156
+ );
157
+ await expect(t.mutation(m("cancelUnattempted"), identity)).rejects.toThrow(
158
+ /attempt/,
159
+ );
160
+ await t.mutation(m("recordOutcome"), {
161
+ ...identity,
162
+ stripePayoutId: "po_verified",
163
+ amount: 1000,
164
+ currency: "usd",
165
+ providerStatus: "pending",
166
+ });
167
+ const after = await t.run((ctx) =>
168
+ ctx.db.query("affiliateRecoveryBalances").first(),
169
+ );
170
+ expect(after?.activePayoutKey).toBeUndefined();
171
+ expect(after?.outstandingDebt).toBe(100);
172
+ });
@@ -1,3 +1,7 @@
1
+ import {
2
+ reserveRecoveryGuard,
3
+ releaseRecoveryGuard,
4
+ } from "../affiliatePayouts/recipientGuard.js";
1
5
  import {
2
6
  historicalBatchArgs,
3
7
  adoptHistoricalBatch as importHistoricalBatch,
@@ -110,6 +114,7 @@ export const claim = mutation({
110
114
  sources,
111
115
  });
112
116
  if (plan.status === "held") return plan;
117
+ await reserveRecoveryGuard(ctx, args, plan.amount);
113
118
  for (const allocation of plan.allocations) {
114
119
  const prior = await sourceById(ctx, allocation.stripeTransferId);
115
120
  if (prior)
@@ -155,6 +160,7 @@ export const beginAttempt = mutation({
155
160
  if (feeRevision !== (claim.feeRevision ?? 0))
156
161
  throw new Error("Payout fee revision changed after reservation.");
157
162
  await checkSnapshot(ctx, claim.accountId, claim.observations, true);
163
+ await reserveRecoveryGuard(ctx, claim, claim.amount);
158
164
  await ctx.db.patch("payoutReleaseClaims", claim._id, {
159
165
  attemptedAt: Date.now(),
160
166
  });
@@ -174,6 +180,7 @@ export const cancelUnattempted = mutation({
174
180
  throw new Error("An attempted payout claim cannot be canceled.");
175
181
  if (claim.status === "canceled") return claim;
176
182
  await moveAllocations(ctx, claim, "release");
183
+ await releaseRecoveryGuard(ctx, claim);
177
184
  await ctx.db.patch("payoutReleaseClaims", claim._id, {
178
185
  status: "canceled",
179
186
  });
@@ -257,6 +264,7 @@ export const recordOutcome = mutation({
257
264
  if (claim.status === "paid" && args.providerStatus !== "paid" && !returned)
258
265
  return claim;
259
266
  await recordFees(ctx, claim, args, returned);
267
+ await releaseRecoveryGuard(ctx, claim);
260
268
  if (returned) {
261
269
  await moveAllocations(
262
270
  ctx,
@@ -329,6 +337,7 @@ export const recordRejection = mutation({
329
337
  if (adoptedRejection && adoptedRejection._id !== claim._id)
330
338
  throw new Error("Payout rejection receipt is already allocated.");
331
339
  await moveAllocations(ctx, claim, "release");
340
+ await releaseRecoveryGuard(ctx, claim);
332
341
  await ctx.db.patch("payoutReleaseClaims", claim._id, {
333
342
  status: "rejected",
334
343
  rejection: { requestId: args.requestId, code: args.code },
@@ -1,3 +1,5 @@
1
+ import { paginator } from "convex-helpers/server/pagination";
2
+ import schema from "../schema.js";
1
3
  import { transferDocValidator } from "../connect/validators.js";
2
4
  import { readFees } from "./fees.js";
3
5
  import { paginationOptsValidator } from "convex/server";
@@ -43,7 +45,7 @@ export const pending = query({
43
45
  args.paginationOpts.numItems > 100
44
46
  )
45
47
  throw new Error("Invalid payout claim page.");
46
- const result = await ctx.db
48
+ const result = await paginator(ctx.db, schema)
47
49
  .query("payoutReleaseClaims")
48
50
  .withIndex("by_account_status", (q) =>
49
51
  q.eq("accountId", args.accountId).eq("status", "reserved"),
@@ -1,4 +1,15 @@
1
1
  import { claimsTable as payoutSyncClaimsTable } from "./payoutSync/schema.js";
2
+ import {
3
+ payoutReleasesTable as affiliatePayoutReleases,
4
+ operationsTable as affiliateOperations,
5
+ earningsTable as affiliateEarnings,
6
+ offsetsTable as affiliateOffsets,
7
+ balancesTable as affiliateBalances,
8
+ } from "./affiliateRecovery/schema.js";
9
+ import {
10
+ affiliateObligationsTable,
11
+ affiliateReversalReceiptsTable,
12
+ } from "./settlement/clawbackSchema.js";
2
13
  import {
3
14
  feeBalancesTable as payoutFeeBalancesTable,
4
15
  claimsTable as payoutReleaseClaimsTable,
@@ -38,6 +49,13 @@ import { pricesTable, productsTable } from "./products/schema";
38
49
  import { webhookEventsTable } from "./webhooks/schema";
39
50
 
40
51
  export default defineSchema({
52
+ affiliateObligations: affiliateObligationsTable,
53
+ affiliateRecoveryOperations: affiliateOperations,
54
+ affiliatePayoutReleases,
55
+ affiliateEarnings,
56
+ affiliateOffsets,
57
+ affiliateRecoveryBalances: affiliateBalances,
58
+ affiliateReversalReceipts: affiliateReversalReceiptsTable,
41
59
  payoutSyncClaims: payoutSyncClaimsTable,
42
60
  payoutFeeBalances: payoutFeeBalancesTable,
43
61
  payoutReleaseClaims: payoutReleaseClaimsTable,
@@ -0,0 +1,78 @@
1
+ import { defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ /** Principal snapshots on the original charge axis, never incremental debits. */
5
+ export const commissionExposureValidator = v.object({
6
+ kind: v.union(v.literal("refund"), v.literal("dispute")),
7
+ chargeAmount: v.number(),
8
+ principal: v.number(),
9
+ finalized: v.boolean(),
10
+ disputeState: v.optional(
11
+ v.union(v.literal("open"), v.literal("lost"), v.literal("won")),
12
+ ),
13
+ disputeId: v.optional(v.string()),
14
+ refundedPrincipal: v.optional(v.number()),
15
+ refundId: v.optional(v.string()),
16
+ });
17
+
18
+ export const obligationFields = {
19
+ stripeTransferId: v.optional(v.string()),
20
+ sourceChargeId: v.string(),
21
+ sourceInvoiceId: v.optional(v.string()),
22
+ paymentIntentId: v.optional(v.string()),
23
+ paymentId: v.optional(v.string()),
24
+ storeAccountId: v.optional(v.string()),
25
+ lastRefundId: v.optional(v.string()),
26
+ affiliateAccountId: v.string(),
27
+ currency: v.string(),
28
+ chargeAmount: v.number(),
29
+ originalCommission: v.number(),
30
+ refundedPrincipal: v.number(),
31
+ disputedPrincipal: v.number(),
32
+ lostPrincipal: v.number(),
33
+ finalizedAmount: v.number(),
34
+ provisionalAmount: v.number(),
35
+ /** Confirmed transfer reversal total; reservations are not receipts. */
36
+ recoveredAmount: v.number(),
37
+ /** Existing reversals may have no attributable refund/dispute receipt. */
38
+ needsReconciliation: v.boolean(),
39
+ accounted: v.optional(v.boolean()),
40
+ legacyReceiptIds: v.optional(v.array(v.string())),
41
+ legacyRestorationIds: v.optional(v.array(v.string())),
42
+ operatorNote: v.optional(v.string()),
43
+ earningId: v.optional(v.id("affiliateEarnings")),
44
+ releasedCommission: v.optional(v.number()),
45
+ offsetAmount: v.optional(v.number()),
46
+ restoredAmount: v.optional(v.number()),
47
+ reservedRecovery: v.optional(v.number()),
48
+ reservedRestoration: v.optional(v.number()),
49
+ debtAmount: v.optional(v.number()),
50
+ creditAmount: v.optional(v.number()),
51
+ debtStatus: v.optional(v.union(v.literal("open"), v.literal("settled"))),
52
+ disputeState: v.optional(
53
+ v.union(v.literal("open"), v.literal("lost"), v.literal("won")),
54
+ ),
55
+ disputeId: v.optional(v.string()),
56
+ activeOperationId: v.optional(v.id("affiliateRecoveryOperations")),
57
+ lastState: v.optional(v.string()),
58
+ lastError: v.optional(v.string()),
59
+ operationSequence: v.optional(v.number()),
60
+ };
61
+ export const affiliateObligationsTable = defineTable(obligationFields)
62
+ .index("by_transfer", ["stripeTransferId"])
63
+ .index("by_charge", ["sourceChargeId"])
64
+ .index("by_affiliate_currency_debt", [
65
+ "affiliateAccountId",
66
+ "currency",
67
+ "debtStatus",
68
+ ])
69
+ .index("by_affiliate_currency", ["affiliateAccountId", "currency"]);
70
+ export const affiliateReversalReceiptsTable = defineTable({
71
+ stripeReversalId: v.string(),
72
+ stripeTransferId: v.string(),
73
+ operationId: v.string(),
74
+ amount: v.number(),
75
+ })
76
+ .index("by_receipt", ["stripeReversalId"])
77
+ .index("by_transfer_operation", ["stripeTransferId", "operationId"])
78
+ .index("by_transfer", ["stripeTransferId"]);
@@ -0,0 +1,233 @@
1
+ import { updateObligation } from "../affiliateRecovery/accounting.js";
2
+ import type { Infer } from "convex/values";
3
+ import type { Doc } from "../_generated/dataModel.js";
4
+ import type { MutationCtx } from "../_generated/server.js";
5
+ import { commissionExposureValidator } from "./clawbackSchema.js";
6
+
7
+ export type CommissionExposure = Infer<typeof commissionExposureValidator>;
8
+
9
+ /** Exact round-half-up proportional commission in integer currency units. */
10
+ export function proportionalCommission(
11
+ commission: number,
12
+ principal: number,
13
+ charge: number,
14
+ ): number {
15
+ for (const n of [commission, principal, charge]) {
16
+ if (!Number.isSafeInteger(n) || n < 0)
17
+ throw new Error(
18
+ "Commission recovery requires non-negative integer units.",
19
+ );
20
+ }
21
+ if (charge === 0)
22
+ throw new Error("Commission recovery requires a positive charge.");
23
+ const numerator = BigInt(commission) * BigInt(Math.min(principal, charge));
24
+ return Number((2n * numerator + BigInt(charge)) / (2n * BigInt(charge)));
25
+ }
26
+
27
+ /** Lazily migrate one immutable original affiliate leg, also used by backfill. */
28
+ export async function ensureCommissionObligation(
29
+ ctx: MutationCtx,
30
+ transfer: Doc<"transfers">,
31
+ chargeAmount: number,
32
+ ) {
33
+ if (
34
+ transfer.role !== "affiliate" ||
35
+ transfer.reinstatement ||
36
+ !transfer.sourceChargeId
37
+ )
38
+ throw new Error(
39
+ "Commission obligation requires an original affiliate transfer.",
40
+ );
41
+ const existing = await ctx.db
42
+ .query("affiliateObligations")
43
+ .withIndex("by_transfer", (q) =>
44
+ q.eq("stripeTransferId", transfer.stripeTransferId),
45
+ )
46
+ .unique();
47
+ if (existing) {
48
+ if (
49
+ existing.chargeAmount !== chargeAmount ||
50
+ (existing.releasedCommission ?? existing.originalCommission) !==
51
+ transfer.amount ||
52
+ existing.affiliateAccountId !== transfer.destinationAccountId ||
53
+ existing.currency !== transfer.currency ||
54
+ existing.sourceChargeId !== transfer.sourceChargeId ||
55
+ (transfer.sourceInvoiceId !== undefined &&
56
+ existing.sourceInvoiceId !== transfer.sourceInvoiceId)
57
+ )
58
+ throw new Error("Original affiliate commission identity cannot change.");
59
+ return existing;
60
+ }
61
+ proportionalCommission(transfer.amount, 0, chargeAmount);
62
+ const allocation = await ctx.db
63
+ .query("settlementAllocations")
64
+ .withIndex("by_source_charge", (q) =>
65
+ q.eq("sourceChargeId", transfer.sourceChargeId!),
66
+ )
67
+ .unique();
68
+ const earning = await ctx.db
69
+ .query("affiliateEarnings")
70
+ .withIndex("by_charge_affiliate", (q) =>
71
+ q
72
+ .eq("sourceChargeId", transfer.sourceChargeId!)
73
+ .eq("affiliateAccountId", transfer.destinationAccountId),
74
+ )
75
+ .unique();
76
+ if (
77
+ allocation &&
78
+ (allocation.gross !== chargeAmount ||
79
+ allocation.currency !== transfer.currency ||
80
+ !allocation.transfers.some(
81
+ (leg) =>
82
+ leg.role === "affiliate" &&
83
+ leg.destinationAccountId === transfer.destinationAccountId &&
84
+ leg.amount === (earning?.originalCommission ?? transfer.amount),
85
+ ))
86
+ )
87
+ throw new Error("Commission recovery conflicts with original settlement.");
88
+ const recoveredAmount = transfer.reversedAmount ?? 0;
89
+ if (
90
+ !Number.isSafeInteger(recoveredAmount) ||
91
+ recoveredAmount < 0 ||
92
+ recoveredAmount > transfer.amount
93
+ )
94
+ throw new Error("Invalid original affiliate reversal balance.");
95
+ const id = await ctx.db.insert("affiliateObligations", {
96
+ stripeTransferId: transfer.stripeTransferId,
97
+ sourceChargeId: transfer.sourceChargeId,
98
+ sourceInvoiceId: transfer.sourceInvoiceId ?? allocation?.sourceInvoiceId,
99
+ paymentIntentId: allocation?.paymentIntentId,
100
+ paymentId: transfer.paymentId ?? allocation?.paymentId,
101
+ storeAccountId: allocation?.transfers.find((leg) => leg.role === "store")
102
+ ?.destinationAccountId,
103
+ affiliateAccountId: transfer.destinationAccountId,
104
+ currency: transfer.currency,
105
+ chargeAmount,
106
+ originalCommission: earning?.originalCommission ?? transfer.amount,
107
+ releasedCommission: transfer.amount,
108
+ earningId: earning?._id,
109
+ refundedPrincipal: 0,
110
+ disputedPrincipal: 0,
111
+ lostPrincipal: 0,
112
+ finalizedAmount: 0,
113
+ provisionalAmount: 0,
114
+ recoveredAmount,
115
+ needsReconciliation:
116
+ recoveredAmount > 0 || (transfer.reversalClaimedAmount ?? 0) > 0,
117
+ });
118
+ return (await ctx.db.get("affiliateObligations", id))!;
119
+ }
120
+
121
+ /** Refunds and disputes share the same original-principal exposure axis. */
122
+ export async function applyCommissionExposure(
123
+ ctx: MutationCtx,
124
+ transfer: Doc<"transfers">,
125
+ exposure: CommissionExposure,
126
+ ) {
127
+ proportionalCommission(
128
+ transfer.amount,
129
+ exposure.principal,
130
+ exposure.chargeAmount,
131
+ );
132
+ if (exposure.kind === "refund" && !exposure.finalized)
133
+ throw new Error("Pending refunds cannot create commission obligations.");
134
+ const row = await ensureCommissionObligation(
135
+ ctx,
136
+ transfer,
137
+ exposure.chargeAmount,
138
+ );
139
+ return applyObligationExposure(ctx, row, exposure);
140
+ }
141
+ export async function applyObligationExposure(
142
+ ctx: MutationCtx,
143
+ row: Doc<"affiliateObligations">,
144
+ exposure: CommissionExposure,
145
+ ) {
146
+ proportionalCommission(
147
+ row.originalCommission,
148
+ exposure.principal,
149
+ exposure.chargeAmount,
150
+ );
151
+ if (row.chargeAmount !== exposure.chargeAmount)
152
+ throw new Error("Original charge amount cannot change.");
153
+ const principal = Math.min(exposure.principal, exposure.chargeAmount);
154
+ const refundedPrincipal = Math.max(
155
+ row.refundedPrincipal,
156
+ exposure.refundedPrincipal ?? 0,
157
+ exposure.kind === "refund" ? principal : 0,
158
+ );
159
+ let disputedPrincipal = Math.max(
160
+ row.disputedPrincipal,
161
+ exposure.kind === "dispute" ? principal : 0,
162
+ );
163
+ let lostPrincipal = Math.max(
164
+ row.lostPrincipal,
165
+ exposure.kind === "dispute" && exposure.finalized ? principal : 0,
166
+ );
167
+ const incomingState =
168
+ exposure.disputeState ?? (exposure.finalized ? "lost" : "open");
169
+ const rank = { open: 0, lost: 1, won: 2 };
170
+ const disputeState =
171
+ exposure.kind === "dispute" &&
172
+ rank[incomingState] >= rank[row.disputeState ?? "open"]
173
+ ? incomingState
174
+ : row.disputeState;
175
+ if (disputeState === "won") {
176
+ disputedPrincipal = 0;
177
+ lostPrincipal = 0;
178
+ }
179
+ if (exposure.kind === "dispute" && disputeState === "lost")
180
+ lostPrincipal = Math.max(lostPrincipal, disputedPrincipal);
181
+ const finalizedAmount = proportionalCommission(
182
+ row.originalCommission,
183
+ Math.max(refundedPrincipal, lostPrincipal),
184
+ row.chargeAmount,
185
+ );
186
+ const target = proportionalCommission(
187
+ row.originalCommission,
188
+ Math.max(refundedPrincipal, disputedPrincipal),
189
+ row.chargeAmount,
190
+ );
191
+ await updateObligation(ctx, row, {
192
+ disputeState,
193
+ lastRefundId: exposure.refundId ?? row.lastRefundId,
194
+ disputeId: exposure.disputeId ?? row.disputeId,
195
+ refundedPrincipal,
196
+ disputedPrincipal,
197
+ lostPrincipal,
198
+ finalizedAmount,
199
+ provisionalAmount: target - finalizedAmount,
200
+ });
201
+ return target;
202
+ }
203
+
204
+ /** Webhook upserts cannot rewrite a frozen commission or its confirmed balance. */
205
+ export async function syncCommissionTransfer(
206
+ ctx: MutationCtx,
207
+ transfer: Doc<"transfers">,
208
+ ) {
209
+ const row = await ctx.db
210
+ .query("affiliateObligations")
211
+ .withIndex("by_transfer", (q) =>
212
+ q.eq("stripeTransferId", transfer.stripeTransferId),
213
+ )
214
+ .unique();
215
+ if (!row) return;
216
+ await ensureCommissionObligation(ctx, transfer, row.chargeAmount);
217
+ const recoveredAmount = Math.max(
218
+ row.recoveredAmount,
219
+ transfer.reversedAmount ?? 0,
220
+ );
221
+ if (
222
+ !Number.isSafeInteger(recoveredAmount) ||
223
+ recoveredAmount > row.originalCommission
224
+ )
225
+ throw new Error("Invalid confirmed affiliate reversal balance.");
226
+ if (recoveredAmount > row.recoveredAmount && row.activeOperationId) return;
227
+ await updateObligation(ctx, row, {
228
+ recoveredAmount,
229
+ ...(recoveredAmount > row.recoveredAmount && row.accounted
230
+ ? { needsReconciliation: true }
231
+ : {}),
232
+ });
233
+ }
@@ -1,3 +1,8 @@
1
+ import { paginator } from "convex-helpers/server/pagination";
2
+ import schema from "../schema.js";
3
+ import { updateObligation } from "../affiliateRecovery/accounting.js";
4
+ import { paginationOptsValidator } from "convex/server";
5
+ import { ensureCommissionObligation } from "./clawbacks.js";
1
6
  import { v } from "convex/values";
2
7
  import { mutation } from "../_generated/server.js";
3
8
  import { allocationFields, commissionFields, termValidator } from "./schema.js";
@@ -117,3 +122,52 @@ export const claimCommissionInvoice = mutation({
117
122
  };
118
123
  },
119
124
  });
125
+
126
+ /** Explicit, resumable migration. Never fabricate legacy refund provenance. */
127
+ export const migrateAffiliateObligations = mutation({
128
+ args: { paginationOpts: paginationOptsValidator },
129
+ returns: v.object({
130
+ migrated: v.number(),
131
+ skipped: v.number(),
132
+ isDone: v.boolean(),
133
+ continueCursor: v.string(),
134
+ }),
135
+ handler: async (ctx, args) => {
136
+ const page = await paginator(ctx.db, schema)
137
+ .query("transfers")
138
+ .paginate({
139
+ ...args.paginationOpts,
140
+ numItems: Math.min(100, args.paginationOpts.numItems),
141
+ });
142
+ let migrated = 0;
143
+ let skipped = 0;
144
+ for (const transfer of page.page) {
145
+ if (transfer.role !== "affiliate" || transfer.reinstatement) continue;
146
+ const allocation = transfer.sourceChargeId
147
+ ? await ctx.db
148
+ .query("settlementAllocations")
149
+ .withIndex("by_source_charge", (q) =>
150
+ q.eq("sourceChargeId", transfer.sourceChargeId!),
151
+ )
152
+ .unique()
153
+ : null;
154
+ if (!allocation) {
155
+ skipped++;
156
+ continue;
157
+ }
158
+ const obligation = await ensureCommissionObligation(
159
+ ctx,
160
+ transfer,
161
+ allocation.gross,
162
+ );
163
+ await updateObligation(ctx, obligation, {});
164
+ migrated++;
165
+ }
166
+ return {
167
+ migrated,
168
+ skipped,
169
+ isDone: page.isDone,
170
+ continueCursor: page.continueCursor,
171
+ };
172
+ },
173
+ });
@@ -1,3 +1,10 @@
1
+ import { paginator } from "convex-helpers/server/pagination";
2
+ import schema from "../schema.js";
3
+ import {
4
+ paginationOptsValidator,
5
+ paginationResultValidator,
6
+ } from "convex/server";
7
+ import { obligationFields } from "./clawbackSchema.js";
1
8
  import { v } from "convex/values";
2
9
  import { query } from "../_generated/server.js";
3
10
  import { allocationFields, commissionFields } from "./schema.js";
@@ -36,3 +43,31 @@ export const getCommissionTerm = query({
36
43
  };
37
44
  },
38
45
  });
46
+
47
+ /** Trusted host API: authorize account ownership before forwarding arguments. */
48
+ export const listAffiliateObligations = query({
49
+ args: {
50
+ affiliateAccountId: v.string(),
51
+ currency: v.string(),
52
+ paginationOpts: paginationOptsValidator,
53
+ },
54
+ returns: paginationResultValidator(
55
+ v.object({
56
+ ...obligationFields,
57
+ _id: v.id("affiliateObligations"),
58
+ _creationTime: v.number(),
59
+ }),
60
+ ),
61
+ handler: (ctx, args) =>
62
+ paginator(ctx.db, schema)
63
+ .query("affiliateObligations")
64
+ .withIndex("by_affiliate_currency", (q) =>
65
+ q
66
+ .eq("affiliateAccountId", args.affiliateAccountId)
67
+ .eq("currency", args.currency),
68
+ )
69
+ .paginate({
70
+ ...args.paginationOpts,
71
+ numItems: Math.min(100, args.paginationOpts.numItems),
72
+ }),
73
+ });