@fantastic.dev/better-stripe 0.23.1 → 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 (287) hide show
  1. package/CHANGELOG.md +26 -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 +536 -2
  36. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
  37. package/dist/client/connect/payoutSync.d.ts +32 -0
  38. package/dist/client/connect/payoutSync.d.ts.map +1 -0
  39. package/dist/client/connect/payoutSync.js +48 -0
  40. package/dist/client/connect/payoutSync.js.map +1 -0
  41. package/dist/client/connect/payoutSyncTestHelpers.d.ts +1509 -0
  42. package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -0
  43. package/dist/client/connect/payoutSyncTestHelpers.js +62 -0
  44. package/dist/client/connect/payoutSyncTestHelpers.js.map +1 -0
  45. package/dist/client/connect/payoutSyncValidation.d.ts +16 -0
  46. package/dist/client/connect/payoutSyncValidation.d.ts.map +1 -0
  47. package/dist/client/connect/payoutSyncValidation.js +54 -0
  48. package/dist/client/connect/payoutSyncValidation.js.map +1 -0
  49. package/dist/client/connect/payouts.d.ts +3 -1
  50. package/dist/client/connect/payouts.d.ts.map +1 -1
  51. package/dist/client/connect/payouts.js +45 -12
  52. package/dist/client/connect/payouts.js.map +1 -1
  53. package/dist/client/connect/refundPrincipal.d.ts +4 -0
  54. package/dist/client/connect/refundPrincipal.d.ts.map +1 -0
  55. package/dist/client/connect/refundPrincipal.js +21 -0
  56. package/dist/client/connect/refundPrincipal.js.map +1 -0
  57. package/dist/client/connect/reversalExecution.d.ts +47 -0
  58. package/dist/client/connect/reversalExecution.d.ts.map +1 -0
  59. package/dist/client/connect/reversalExecution.js +84 -0
  60. package/dist/client/connect/reversalExecution.js.map +1 -0
  61. package/dist/client/connect/transfers.d.ts +8 -4
  62. package/dist/client/connect/transfers.d.ts.map +1 -1
  63. package/dist/client/connect/transfers.js +52 -57
  64. package/dist/client/connect/transfers.js.map +1 -1
  65. package/dist/client/index.d.ts +29 -9
  66. package/dist/client/index.d.ts.map +1 -1
  67. package/dist/client/index.js +9 -6
  68. package/dist/client/index.js.map +1 -1
  69. package/dist/client/readContracts.d.ts +8 -0
  70. package/dist/client/readContracts.d.ts.map +1 -1
  71. package/dist/client/readContracts.js +7 -0
  72. package/dist/client/readContracts.js.map +1 -1
  73. package/dist/client/settlement/execution.d.ts.map +1 -1
  74. package/dist/client/settlement/execution.js +30 -3
  75. package/dist/client/settlement/execution.js.map +1 -1
  76. package/dist/client/settlement/reconciliation.d.ts.map +1 -1
  77. package/dist/client/settlement/reconciliation.js +16 -2
  78. package/dist/client/settlement/reconciliation.js.map +1 -1
  79. package/dist/client/webhooks/disputeRecovery.d.ts +4 -0
  80. package/dist/client/webhooks/disputeRecovery.d.ts.map +1 -0
  81. package/dist/client/webhooks/disputeRecovery.js +40 -0
  82. package/dist/client/webhooks/disputeRecovery.js.map +1 -0
  83. package/dist/client/webhooks/processors.d.ts.map +1 -1
  84. package/dist/client/webhooks/processors.js +14 -29
  85. package/dist/client/webhooks/processors.js.map +1 -1
  86. package/dist/component/_generated/component.d.ts +560 -0
  87. package/dist/component/_generated/component.d.ts.map +1 -1
  88. package/dist/component/affiliateEarnings/mutations.d.ts +22 -0
  89. package/dist/component/affiliateEarnings/mutations.d.ts.map +1 -0
  90. package/dist/component/affiliateEarnings/mutations.js +160 -0
  91. package/dist/component/affiliateEarnings/mutations.js.map +1 -0
  92. package/dist/component/affiliatePayouts/mutations.d.ts +25 -0
  93. package/dist/component/affiliatePayouts/mutations.d.ts.map +1 -0
  94. package/dist/component/affiliatePayouts/mutations.js +104 -0
  95. package/dist/component/affiliatePayouts/mutations.js.map +1 -0
  96. package/dist/component/affiliatePayouts/queries.d.ts +23 -0
  97. package/dist/component/affiliatePayouts/queries.d.ts.map +1 -0
  98. package/dist/component/affiliatePayouts/queries.js +23 -0
  99. package/dist/component/affiliatePayouts/queries.js.map +1 -0
  100. package/dist/component/affiliatePayouts/recipientGuard.d.ts +12 -0
  101. package/dist/component/affiliatePayouts/recipientGuard.d.ts.map +1 -0
  102. package/dist/component/affiliatePayouts/recipientGuard.js +26 -0
  103. package/dist/component/affiliatePayouts/recipientGuard.js.map +1 -0
  104. package/dist/component/affiliateRecovery/accounting.d.ts +28 -0
  105. package/dist/component/affiliateRecovery/accounting.d.ts.map +1 -0
  106. package/dist/component/affiliateRecovery/accounting.js +71 -0
  107. package/dist/component/affiliateRecovery/accounting.js.map +1 -0
  108. package/dist/component/affiliateRecovery/mutations.d.ts +65 -0
  109. package/dist/component/affiliateRecovery/mutations.d.ts.map +1 -0
  110. package/dist/component/affiliateRecovery/mutations.js +250 -0
  111. package/dist/component/affiliateRecovery/mutations.js.map +1 -0
  112. package/dist/component/affiliateRecovery/queries.d.ts +305 -0
  113. package/dist/component/affiliateRecovery/queries.d.ts.map +1 -0
  114. package/dist/component/affiliateRecovery/queries.js +113 -0
  115. package/dist/component/affiliateRecovery/queries.js.map +1 -0
  116. package/dist/component/affiliateRecovery/schema.d.ts +171 -0
  117. package/dist/component/affiliateRecovery/schema.d.ts.map +1 -0
  118. package/dist/component/affiliateRecovery/schema.js +71 -0
  119. package/dist/component/affiliateRecovery/schema.js.map +1 -0
  120. package/dist/component/connect/mutations.d.ts +36 -23
  121. package/dist/component/connect/mutations.d.ts.map +1 -1
  122. package/dist/component/connect/mutations.js +11 -151
  123. package/dist/component/connect/mutations.js.map +1 -1
  124. package/dist/component/connect/payoutWrites.d.ts +21 -0
  125. package/dist/component/connect/payoutWrites.d.ts.map +1 -0
  126. package/dist/component/connect/payoutWrites.js +19 -0
  127. package/dist/component/connect/payoutWrites.js.map +1 -0
  128. package/dist/component/connect/queries.d.ts +10 -0
  129. package/dist/component/connect/queries.d.ts.map +1 -1
  130. package/dist/component/connect/queries.js +3 -40
  131. package/dist/component/connect/queries.js.map +1 -1
  132. package/dist/component/connect/reversalClaims.d.ts +169 -0
  133. package/dist/component/connect/reversalClaims.d.ts.map +1 -0
  134. package/dist/component/connect/reversalClaims.js +176 -0
  135. package/dist/component/connect/reversalClaims.js.map +1 -0
  136. package/dist/component/connect/reversalDiagnostics.d.ts +59 -0
  137. package/dist/component/connect/reversalDiagnostics.d.ts.map +1 -0
  138. package/dist/component/connect/reversalDiagnostics.js +50 -0
  139. package/dist/component/connect/reversalDiagnostics.js.map +1 -0
  140. package/dist/component/connect/reversalReceipts.d.ts +27 -0
  141. package/dist/component/connect/reversalReceipts.d.ts.map +1 -0
  142. package/dist/component/connect/reversalReceipts.js +85 -0
  143. package/dist/component/connect/reversalReceipts.js.map +1 -0
  144. package/dist/component/connect/schema.d.ts +31 -1
  145. package/dist/component/connect/schema.d.ts.map +1 -1
  146. package/dist/component/connect/schema.js +3 -1
  147. package/dist/component/connect/schema.js.map +1 -1
  148. package/dist/component/connect/validators.d.ts +49 -1
  149. package/dist/component/connect/validators.d.ts.map +1 -1
  150. package/dist/component/connect/validators.js +2 -0
  151. package/dist/component/connect/validators.js.map +1 -1
  152. package/dist/component/earnings/triggers.d.ts +454 -2
  153. package/dist/component/earnings/triggers.d.ts.map +1 -1
  154. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  155. package/dist/component/payoutRelease/mutations.js +6 -0
  156. package/dist/component/payoutRelease/mutations.js.map +1 -1
  157. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  158. package/dist/component/payoutRelease/queries.js +3 -1
  159. package/dist/component/payoutRelease/queries.js.map +1 -1
  160. package/dist/component/payoutSync/helpers.d.ts +44 -0
  161. package/dist/component/payoutSync/helpers.d.ts.map +1 -0
  162. package/dist/component/payoutSync/helpers.js +60 -0
  163. package/dist/component/payoutSync/helpers.js.map +1 -0
  164. package/dist/component/payoutSync/mutations.d.ts +51 -0
  165. package/dist/component/payoutSync/mutations.d.ts.map +1 -0
  166. package/dist/component/payoutSync/mutations.js +88 -0
  167. package/dist/component/payoutSync/mutations.js.map +1 -0
  168. package/dist/component/payoutSync/queries.d.ts +16 -0
  169. package/dist/component/payoutSync/queries.d.ts.map +1 -0
  170. package/dist/component/payoutSync/queries.js +11 -0
  171. package/dist/component/payoutSync/queries.js.map +1 -0
  172. package/dist/component/payoutSync/schema.d.ts +46 -0
  173. package/dist/component/payoutSync/schema.d.ts.map +1 -0
  174. package/dist/component/payoutSync/schema.js +22 -0
  175. package/dist/component/payoutSync/schema.js.map +1 -0
  176. package/dist/component/payoutSync/validators.d.ts +137 -0
  177. package/dist/component/payoutSync/validators.d.ts.map +1 -0
  178. package/dist/component/payoutSync/validators.js +19 -0
  179. package/dist/component/payoutSync/validators.js.map +1 -0
  180. package/dist/component/schema.d.ts +268 -1
  181. package/dist/component/schema.js +11 -0
  182. package/dist/component/schema.js.map +1 -1
  183. package/dist/component/settlement/clawbackSchema.d.ts +161 -0
  184. package/dist/component/settlement/clawbackSchema.d.ts.map +1 -0
  185. package/dist/component/settlement/clawbackSchema.js +73 -0
  186. package/dist/component/settlement/clawbackSchema.js.map +1 -0
  187. package/dist/component/settlement/clawbacks.d.ts +55 -0
  188. package/dist/component/settlement/clawbacks.d.ts.map +1 -0
  189. package/dist/component/settlement/clawbacks.js +146 -0
  190. package/dist/component/settlement/clawbacks.js.map +1 -0
  191. package/dist/component/settlement/mutations.d.ts +16 -0
  192. package/dist/component/settlement/mutations.d.ts.map +1 -1
  193. package/dist/component/settlement/mutations.js +48 -0
  194. package/dist/component/settlement/mutations.js.map +1 -1
  195. package/dist/component/settlement/queries.d.ts +53 -0
  196. package/dist/component/settlement/queries.d.ts.map +1 -1
  197. package/dist/component/settlement/queries.js +26 -0
  198. package/dist/component/settlement/queries.js.map +1 -1
  199. package/dist/react/workflows/AffiliateRecovery.d.ts +46 -0
  200. package/dist/react/workflows/AffiliateRecovery.d.ts.map +1 -0
  201. package/dist/react/workflows/AffiliateRecovery.js +84 -0
  202. package/dist/react/workflows/AffiliateRecovery.js.map +1 -0
  203. package/dist/react/workflows/index.d.ts +2 -0
  204. package/dist/react/workflows/index.d.ts.map +1 -1
  205. package/dist/react/workflows/index.js +1 -0
  206. package/dist/react/workflows/index.js.map +1 -1
  207. package/dist/testing/affiliate-recovery.d.ts +10 -0
  208. package/dist/testing/affiliate-recovery.d.ts.map +1 -0
  209. package/dist/testing/affiliate-recovery.js +126 -0
  210. package/dist/testing/affiliate-recovery.js.map +1 -0
  211. package/dist/testing/index.d.ts +2 -0
  212. package/dist/testing/index.d.ts.map +1 -1
  213. package/dist/testing/index.js +1 -0
  214. package/dist/testing/index.js.map +1 -1
  215. package/docs/affiliate-clawbacks.md +306 -0
  216. package/docs/read-contracts.md +40 -5
  217. package/docs/settlement.md +17 -0
  218. package/package.json +2 -1
  219. package/src/client/affiliateRecovery/disputes.ts +62 -0
  220. package/src/client/affiliateRecovery/engine.ts +196 -0
  221. package/src/client/affiliateRecovery/payoutGate.ts +65 -0
  222. package/src/client/affiliateRecovery/provider.ts +127 -0
  223. package/src/client/affiliateRecovery/review.ts +117 -0
  224. package/src/client/affiliateRecovery/validators.ts +38 -0
  225. package/src/client/affiliateRecovery.ts +173 -0
  226. package/src/client/connect/commissionReceipts.test.ts +157 -0
  227. package/src/client/connect/commissionReceipts.ts +23 -0
  228. package/src/client/connect/payoutSync.test.ts +313 -0
  229. package/src/client/connect/payoutSync.ts +93 -0
  230. package/src/client/connect/payoutSyncTestHelpers.ts +82 -0
  231. package/src/client/connect/payoutSyncValidation.ts +74 -0
  232. package/src/client/connect/payouts.ts +75 -15
  233. package/src/client/connect/refundPrincipal.test.ts +46 -0
  234. package/src/client/connect/refundPrincipal.ts +26 -0
  235. package/src/client/connect/reversalExecution.ts +130 -0
  236. package/src/client/connect/transfers.test.ts +3 -3
  237. package/src/client/connect/transfers.ts +93 -92
  238. package/src/client/index.ts +20 -18
  239. package/src/client/readContracts.ts +12 -0
  240. package/src/client/settlement/execution.test.ts +5 -0
  241. package/src/client/settlement/execution.ts +50 -3
  242. package/src/client/settlement/reconciliation.ts +20 -2
  243. package/src/client/webhooks/disputeRecovery.ts +74 -0
  244. package/src/client/webhooks/processors.test.ts +36 -53
  245. package/src/client/webhooks/processors.ts +18 -31
  246. package/src/component/_generated/api.ts +34 -0
  247. package/src/component/_generated/component.ts +696 -1
  248. package/src/component/affiliate-clawbacks.test.ts +370 -0
  249. package/src/component/affiliate-component-pagination.test.ts +83 -0
  250. package/src/component/affiliate-direct-payout.test.ts +160 -0
  251. package/src/component/affiliate-recovery-lifecycle.test.ts +587 -0
  252. package/src/component/affiliateEarnings/mutations.ts +196 -0
  253. package/src/component/affiliatePayouts/mutations.ts +121 -0
  254. package/src/component/affiliatePayouts/queries.ts +28 -0
  255. package/src/component/affiliatePayouts/recipientGuard.ts +42 -0
  256. package/src/component/affiliateRecovery/accounting.ts +111 -0
  257. package/src/component/affiliateRecovery/mutations.ts +275 -0
  258. package/src/component/affiliateRecovery/queries.ts +138 -0
  259. package/src/component/affiliateRecovery/schema.ts +86 -0
  260. package/src/component/connect/mutations.ts +13 -190
  261. package/src/component/connect/payoutWrites.ts +25 -0
  262. package/src/component/connect/queries.ts +4 -47
  263. package/src/component/connect/reversalClaims.ts +232 -0
  264. package/src/component/connect/reversalDiagnostics.ts +61 -0
  265. package/src/component/connect/reversalReceipts.ts +147 -0
  266. package/src/component/connect/schema.ts +3 -3
  267. package/src/component/connect/validators.ts +2 -0
  268. package/src/component/payout-recovery-integration.test.ts +172 -0
  269. package/src/component/payout-sync.test.ts +117 -0
  270. package/src/component/payoutRelease/mutations.ts +9 -0
  271. package/src/component/payoutRelease/queries.ts +3 -1
  272. package/src/component/payoutSync/helpers.ts +86 -0
  273. package/src/component/payoutSync/mutations.ts +102 -0
  274. package/src/component/payoutSync/queries.ts +10 -0
  275. package/src/component/payoutSync/schema.ts +25 -0
  276. package/src/component/payoutSync/validators.ts +21 -0
  277. package/src/component/schema.ts +20 -0
  278. package/src/component/settlement/clawbackSchema.ts +78 -0
  279. package/src/component/settlement/clawbacks.ts +233 -0
  280. package/src/component/settlement/mutations.ts +54 -0
  281. package/src/component/settlement/queries.ts +35 -0
  282. package/src/component/summary-arithmetic.test.ts +9 -4
  283. package/src/react/workflows/AffiliateRecovery.test.tsx +106 -0
  284. package/src/react/workflows/AffiliateRecovery.tsx +273 -0
  285. package/src/react/workflows/index.ts +9 -0
  286. package/src/testing/affiliate-recovery.ts +137 -0
  287. package/src/testing/index.ts +2 -0
@@ -0,0 +1,147 @@
1
+ import { updateObligation } from "../affiliateRecovery/accounting.js";
2
+ import { v } from "convex/values";
3
+ import type { Doc } from "../_generated/dataModel.js";
4
+ import type { MutationCtx } from "../_generated/server.js";
5
+ import { deriveReversalState } from "../lib/reversals.js";
6
+
7
+ async function recordCommissionReceipt(
8
+ ctx: MutationCtx,
9
+ transfer: Doc<"transfers">,
10
+ reversedAmount: number,
11
+ receipt: { stripeReversalId: string; operationId: string; amount: number },
12
+ ) {
13
+ const op = await ctx.db
14
+ .query("transferReversalOps")
15
+ .withIndex("by_operation_id", (q) =>
16
+ q.eq("operationId", receipt.operationId),
17
+ )
18
+ .unique();
19
+ const slice = op?.slices.find(
20
+ (s) => s.stripeTransferId === transfer.stripeTransferId,
21
+ );
22
+ if (
23
+ transfer.role !== "affiliate" ||
24
+ !op?.commissionExposure ||
25
+ !slice ||
26
+ receipt.amount !== slice.to - slice.from ||
27
+ reversedAmount !== slice.to ||
28
+ !receipt.stripeReversalId
29
+ )
30
+ throw new Error(
31
+ "Commission reversal receipt does not match its reserved operation.",
32
+ );
33
+ const priorOperationReceipt = await ctx.db
34
+ .query("affiliateReversalReceipts")
35
+ .withIndex("by_transfer_operation", (q) =>
36
+ q
37
+ .eq("stripeTransferId", transfer.stripeTransferId)
38
+ .eq("operationId", receipt.operationId),
39
+ )
40
+ .unique();
41
+ if (
42
+ priorOperationReceipt &&
43
+ priorOperationReceipt.stripeReversalId !== receipt.stripeReversalId
44
+ )
45
+ throw new Error(
46
+ "Commission operation already has a different reversal receipt.",
47
+ );
48
+ const existing = await ctx.db
49
+ .query("affiliateReversalReceipts")
50
+ .withIndex("by_receipt", (q) =>
51
+ q.eq("stripeReversalId", receipt.stripeReversalId),
52
+ )
53
+ .unique();
54
+ if (existing) {
55
+ if (
56
+ existing.stripeTransferId !== transfer.stripeTransferId ||
57
+ existing.operationId !== receipt.operationId ||
58
+ existing.amount !== receipt.amount
59
+ )
60
+ throw new Error("Commission reversal receipt identity cannot change.");
61
+ } else {
62
+ await ctx.db.insert("affiliateReversalReceipts", {
63
+ ...receipt,
64
+ stripeTransferId: transfer.stripeTransferId,
65
+ });
66
+ }
67
+ }
68
+
69
+ export const recordTransferReversalDefinition = {
70
+ args: {
71
+ stripeTransferId: v.string(),
72
+ reversedAmount: v.number(),
73
+ receipt: v.optional(
74
+ v.object({
75
+ stripeReversalId: v.string(),
76
+ operationId: v.string(),
77
+ amount: v.number(),
78
+ }),
79
+ ),
80
+ },
81
+ returns: v.null(),
82
+ handler: async (
83
+ ctx: MutationCtx,
84
+ args: {
85
+ stripeTransferId: string;
86
+ reversedAmount: number;
87
+ receipt?: {
88
+ stripeReversalId: string;
89
+ operationId: string;
90
+ amount: number;
91
+ };
92
+ },
93
+ ) => {
94
+ const transfer = await ctx.db
95
+ .query("transfers")
96
+ .withIndex("by_stripe_transfer_id", (q) =>
97
+ q.eq("stripeTransferId", args.stripeTransferId),
98
+ )
99
+ .first();
100
+
101
+ if (!transfer) {
102
+ throw new Error(
103
+ `Cannot record reversal: no transfer found for ${args.stripeTransferId}`,
104
+ );
105
+ }
106
+
107
+ // `reversedAmount` is a cumulative total (matches Stripe's
108
+ // `Transfer.amount_reversed`). Keep it monotonic so a stale/out-of-order
109
+ // event can't shrink it or undo a full reversal.
110
+ const reversedAmount = Math.max(
111
+ transfer.reversedAmount ?? 0,
112
+ args.reversedAmount,
113
+ );
114
+ const derived = deriveReversalState(
115
+ transfer.amount,
116
+ reversedAmount,
117
+ transfer.status,
118
+ );
119
+
120
+ if (
121
+ !Number.isSafeInteger(reversedAmount) ||
122
+ reversedAmount < 0 ||
123
+ reversedAmount > transfer.amount
124
+ )
125
+ throw new Error("Invalid confirmed transfer reversal amount.");
126
+ if (args.receipt)
127
+ await recordCommissionReceipt(
128
+ ctx,
129
+ transfer,
130
+ args.reversedAmount,
131
+ args.receipt,
132
+ );
133
+ await ctx.db.patch("transfers", transfer._id, derived);
134
+ const obligation = await ctx.db
135
+ .query("affiliateObligations")
136
+ .withIndex("by_transfer", (q) =>
137
+ q.eq("stripeTransferId", args.stripeTransferId),
138
+ )
139
+ .unique();
140
+ if (obligation)
141
+ await updateObligation(ctx, obligation, {
142
+ recoveredAmount: reversedAmount,
143
+ });
144
+
145
+ return null;
146
+ },
147
+ };
@@ -90,9 +90,9 @@ export const transfersTable = defineTable({
90
90
  * delivery replays byte-identical Stripe requests instead of recomputing
91
91
  * amounts from mutable ledger state.
92
92
  */
93
- export const transferReversalOpsTable = defineTable(
94
- transferReversalOpFields,
95
- ).index("by_operation_id", ["operationId"]);
93
+ export const transferReversalOpsTable = defineTable(transferReversalOpFields)
94
+ .index("by_operation_id", ["operationId"])
95
+ .index("by_source_charge", ["sourceChargeId"]);
96
96
 
97
97
  /**
98
98
  * Disputes (chargebacks). Same indexing strategy as refunds.
@@ -1,3 +1,4 @@
1
+ import { commissionExposureValidator } from "../settlement/clawbackSchema.js";
1
2
  import { type Infer, v } from "convex/values";
2
3
 
3
4
  import { feeRoutingFields, splitRecipientRoleValidator } from "../lib/fees";
@@ -305,6 +306,7 @@ export const transferReversalOpFields = {
305
306
  operationId: v.string(),
306
307
  sourceChargeId: v.string(),
307
308
  slices: v.array(reversalSliceValidator),
309
+ commissionExposure: v.optional(commissionExposureValidator),
308
310
  };
309
311
 
310
312
  /** Full `transferReversalOps` document, including system fields. */
@@ -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
+ });
@@ -0,0 +1,117 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { expect, it, vi } from "vitest";
5
+ import schema from "./schema.js";
6
+ const modules = import.meta.glob("./**/*.*s");
7
+ const scope = { stripePayoutId: "po_one", accountId: "acct_owner" };
8
+ const payout = {
9
+ ...scope,
10
+ amount: 500,
11
+ currency: "jpy",
12
+ status: "paid" as const,
13
+ arrivalDate: "1970-01-01T00:03:20.000Z",
14
+ method: "standard",
15
+ metadata: {},
16
+ };
17
+ const mutation = (name: string) =>
18
+ makeFunctionReference<"mutation">(`payoutSync/mutations:${name}`);
19
+ async function setup() {
20
+ const t = convexTest(schema, modules);
21
+ const generation = await t.mutation(mutation("begin"), scope);
22
+ return { t, args: { ...scope, generation, payout } };
23
+ }
24
+ it("replays a committed generation without changing its acceptance time or financial row", async () => {
25
+ vi.useFakeTimers();
26
+ try {
27
+ vi.setSystemTime(1000);
28
+ const { t, args } = await setup();
29
+ const first = await t.mutation(mutation("finish"), args);
30
+ const oldRow = await t.run((ctx) => ctx.db.query("payouts").unique());
31
+ vi.setSystemTime(2000);
32
+ expect(await t.mutation(mutation("finish"), args)).toMatchObject({
33
+ ...first,
34
+ changed: false,
35
+ });
36
+ expect(
37
+ await t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
38
+ ).toMatchObject({ acceptedAt: 1000 });
39
+ expect(await t.run((ctx) => ctx.db.query("payouts").unique())).toEqual(
40
+ oldRow,
41
+ );
42
+ } finally {
43
+ vi.useRealTimers();
44
+ }
45
+ });
46
+ it("rejects replacing the accepted snapshot under the same generation", async () => {
47
+ const { t, args } = await setup();
48
+ await t.mutation(mutation("finish"), args);
49
+ await expect(
50
+ t.mutation(mutation("finish"), {
51
+ ...args,
52
+ payout: { ...payout, status: "pending" },
53
+ }),
54
+ ).rejects.toThrow("An accepted payout observation cannot be replaced.");
55
+ expect(await t.run((ctx) => ctx.db.query("payouts").unique())).toMatchObject({
56
+ status: "paid",
57
+ });
58
+ });
59
+ it("ignores stale completion and stale unknown markers before any financial write", async () => {
60
+ const { t, args } = await setup();
61
+ const generation = await t.mutation(mutation("begin"), scope);
62
+ expect(await t.mutation(mutation("finish"), args)).toMatchObject({
63
+ status: "superseded",
64
+ });
65
+ await t.mutation(mutation("markUnknown"), {
66
+ ...scope,
67
+ generation: args.generation,
68
+ });
69
+ expect(
70
+ await t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
71
+ ).toMatchObject({ status: "pending", generation });
72
+ expect(await t.run((ctx) => ctx.db.query("payouts").collect())).toEqual([]);
73
+ await t.mutation(mutation("finish"), { ...args, generation });
74
+ await t.mutation(mutation("markUnknown"), { ...scope, generation });
75
+ expect(
76
+ await t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
77
+ ).toMatchObject({ status: "accepted" });
78
+ });
79
+ it.each([{ stripePayoutId: "po_foreign" }, { accountId: "acct_foreign" }])(
80
+ "rejects snapshot identity mismatch %j atomically",
81
+ async (patch) => {
82
+ const { t, args } = await setup();
83
+ await expect(
84
+ t.mutation(mutation("finish"), {
85
+ ...args,
86
+ payout: { ...payout, ...patch },
87
+ }),
88
+ ).rejects.toThrow(
89
+ "Payout synchronization snapshot identity does not match.",
90
+ );
91
+ expect(await t.run((ctx) => ctx.db.query("payouts").collect())).toEqual([]);
92
+ expect(
93
+ await t.run((ctx) => ctx.db.query("currencyTotals").collect()),
94
+ ).toEqual([]);
95
+ },
96
+ );
97
+ it("rejects a foreign claim lookup and generation overflow without changing the claim", async () => {
98
+ const { t } = await setup();
99
+ await expect(
100
+ t.query(makeFunctionReference<"query">("payoutSync/queries:get"), {
101
+ ...scope,
102
+ accountId: "acct_foreign",
103
+ }),
104
+ ).rejects.toThrow("Payout synchronization account does not match.");
105
+ await t.run(async (ctx) => {
106
+ const row = await ctx.db.query("payoutSyncClaims").unique();
107
+ await ctx.db.patch("payoutSyncClaims", row!._id, {
108
+ generation: Number.MAX_SAFE_INTEGER,
109
+ });
110
+ });
111
+ await expect(t.mutation(mutation("begin"), scope)).rejects.toThrow(
112
+ "Payout synchronization generation exhausted.",
113
+ );
114
+ expect(
115
+ await t.run((ctx) => ctx.db.query("payoutSyncClaims").unique()),
116
+ ).toMatchObject({ generation: Number.MAX_SAFE_INTEGER });
117
+ });
@@ -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"),
@@ -0,0 +1,86 @@
1
+ import type { Infer } from "convex/values";
2
+ import type { MutationCtx, QueryCtx } from "../_generated/server.js";
3
+ import type { Doc } from "../_generated/dataModel.js";
4
+ import { generateIdempotencyKey } from "../lib/idempotency.js";
5
+ import type { snapshot } from "./validators.js";
6
+ export function validateIdentity(args: {
7
+ stripePayoutId: string;
8
+ accountId: string;
9
+ }) {
10
+ if (
11
+ !/^po_[A-Za-z0-9_]+$/.test(args.stripePayoutId) ||
12
+ args.stripePayoutId.length > 255 ||
13
+ (args.accountId !== "platform" &&
14
+ !/^acct_[A-Za-z0-9_]+$/.test(args.accountId)) ||
15
+ args.accountId.length > 255
16
+ )
17
+ throw new Error("Invalid payout synchronization identity.");
18
+ }
19
+ export async function claimByPayout(
20
+ ctx: QueryCtx | MutationCtx,
21
+ args: { stripePayoutId: string; accountId: string },
22
+ ) {
23
+ validateIdentity(args);
24
+ const claim = await ctx.db
25
+ .query("payoutSyncClaims")
26
+ .withIndex("by_stripe_payout_id", (q) =>
27
+ q.eq("stripePayoutId", args.stripePayoutId),
28
+ )
29
+ .unique();
30
+ if (claim && claim.accountId !== args.accountId)
31
+ throw new Error("Payout synchronization account does not match.");
32
+ return claim;
33
+ }
34
+ export async function payoutById(
35
+ ctx: QueryCtx | MutationCtx,
36
+ args: { stripePayoutId: string; accountId: string },
37
+ ) {
38
+ const payout = await ctx.db
39
+ .query("payouts")
40
+ .withIndex("by_stripe_payout_id", (q) =>
41
+ q.eq("stripePayoutId", args.stripePayoutId),
42
+ )
43
+ .unique();
44
+ if (payout && payout.accountId !== args.accountId)
45
+ throw new Error("Existing payout account does not match.");
46
+ return payout;
47
+ }
48
+ export function validateSnapshot(p: Infer<typeof snapshot>) {
49
+ validateIdentity(p);
50
+ if (
51
+ !Number.isSafeInteger(p.amount) ||
52
+ p.amount <= 0 ||
53
+ !/^[a-z]{3}$/.test(p.currency) ||
54
+ (p.method !== undefined && !["standard", "instant"].includes(p.method))
55
+ )
56
+ throw new Error("Invalid payout synchronization money or method.");
57
+ if (
58
+ p.arrivalDate !== undefined &&
59
+ (!Number.isFinite(Date.parse(p.arrivalDate)) ||
60
+ new Date(p.arrivalDate).toISOString() !== p.arrivalDate)
61
+ )
62
+ throw new Error("Invalid payout arrival date.");
63
+ if (
64
+ (p.failureCode !== undefined && p.failureCode.length > 255) ||
65
+ (p.failureMessage !== undefined && p.failureMessage.length > 4096)
66
+ )
67
+ throw new Error("Invalid payout failure detail.");
68
+ }
69
+ export async function fingerprint(p: Infer<typeof snapshot> | Doc<"payouts">) {
70
+ return generateIdempotencyKey("payout-sync", {
71
+ snapshot: [
72
+ p.stripePayoutId,
73
+ p.accountId,
74
+ p.amount,
75
+ p.currency,
76
+ p.status,
77
+ p.arrivalDate,
78
+ p.method,
79
+ p.failureCode,
80
+ p.failureMessage,
81
+ Object.entries(p.metadata ?? {}).sort(([a], [b]) =>
82
+ a < b ? -1 : a > b ? 1 : 0,
83
+ ),
84
+ ],
85
+ });
86
+ }