@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
@@ -1,14 +1,18 @@
1
+ import { syncCommissionTransfer } from "../settlement/clawbacks.js";
2
+ import { recordTransferReversalDefinition } from "./reversalReceipts.js";
3
+ import { claimReversalSlicesDefinition } from "./reversalClaims.js";
4
+ import { writePayout } from "./payoutWrites.js";
1
5
  import { v } from "convex/values";
2
6
 
3
- import type { Doc, Id } from "../_generated/dataModel";
7
+ import type { Id } from "../_generated/dataModel";
4
8
  import { mutation } from "../earnings/triggers.js";
5
9
  import { feeRoutingFields } from "../lib/fees";
6
- import { computeReversalSlices, deriveReversalState } from "../lib/reversals";
10
+ import { deriveReversalState } from "../lib/reversals";
7
11
  import { paymentSeller } from "../lib/paymentSeller";
8
12
  import {
9
13
  disputeFields,
10
14
  paymentStatusValidator,
11
- payoutStatusValidator,
15
+ payoutFields,
12
16
  refundFields,
13
17
  transferFields,
14
18
  } from "./validators";
@@ -113,37 +117,10 @@ export const upsertPayment = mutation({
113
117
  // Payout mutations
114
118
 
115
119
  export const upsertPayout = mutation({
116
- args: {
117
- stripePayoutId: v.string(),
118
- accountId: v.string(),
119
- amount: v.number(),
120
- currency: v.string(),
121
- status: payoutStatusValidator,
122
- arrivalDate: v.optional(v.string()),
123
- method: v.optional(v.string()),
124
- failureCode: v.optional(v.string()),
125
- failureMessage: v.optional(v.string()),
126
- metadata: v.optional(v.any()),
127
- },
120
+ args: payoutFields,
128
121
  returns: v.null(),
129
122
  handler: async (ctx, args) => {
130
- const existing = await ctx.db
131
- .query("payouts")
132
- .withIndex("by_stripe_payout_id", (q) =>
133
- q.eq("stripePayoutId", args.stripePayoutId),
134
- )
135
- .first();
136
-
137
- if (existing) {
138
- await ctx.db.patch("payouts", existing._id, {
139
- ...args,
140
- failureCode: args.failureCode,
141
- failureMessage: args.failureMessage,
142
- });
143
- } else {
144
- await ctx.db.insert("payouts", args);
145
- }
146
-
123
+ await writePayout(ctx, args);
147
124
  return null;
148
125
  },
149
126
  });
@@ -338,6 +315,7 @@ export const upsertTransfer = mutation({
338
315
  reversedAmount,
339
316
  args.status,
340
317
  );
318
+ await syncCommissionTransfer(ctx, { ...existing, ...args, ...derived });
341
319
  await ctx.db.patch("transfers", existing._id, { ...args, ...derived });
342
320
  } else {
343
321
  const derived = deriveReversalState(
@@ -357,166 +335,11 @@ export const upsertTransfer = mutation({
357
335
  * cumulative `reversedAmount` and a derived `reversalStatus`. `reversedAmount`
358
336
  * is the new cumulative total (matches Stripe's `Transfer.amount_reversed`).
359
337
  */
360
- export const recordTransferReversal = mutation({
361
- args: {
362
- stripeTransferId: v.string(),
363
- reversedAmount: v.number(),
364
- },
365
- returns: v.null(),
366
- handler: async (ctx, args) => {
367
- const transfer = await ctx.db
368
- .query("transfers")
369
- .withIndex("by_stripe_transfer_id", (q) =>
370
- q.eq("stripeTransferId", args.stripeTransferId),
371
- )
372
- .first();
373
-
374
- if (!transfer) {
375
- throw new Error(
376
- `Cannot record reversal: no transfer found for ${args.stripeTransferId}`,
377
- );
378
- }
379
-
380
- // `reversedAmount` is a cumulative total (matches Stripe's
381
- // `Transfer.amount_reversed`). Keep it monotonic so a stale/out-of-order
382
- // event can't shrink it or undo a full reversal.
383
- const reversedAmount = Math.max(
384
- transfer.reversedAmount ?? 0,
385
- args.reversedAmount,
386
- );
387
- const derived = deriveReversalState(
388
- transfer.amount,
389
- reversedAmount,
390
- transfer.status,
391
- );
392
-
393
- await ctx.db.patch("transfers", transfer._id, derived);
394
-
395
- return null;
396
- },
397
- });
398
-
399
- /** How `claimReversalSlices` sizes each leg's slice. */
400
- const reversalModeValidator = v.union(
401
- v.object({ kind: v.literal("full") }),
402
- v.object({ kind: v.literal("percent"), percent: v.number() }),
403
- v.object({ kind: v.literal("amount"), amount: v.number() }),
404
- v.object({
405
- kind: v.literal("fraction"),
406
- chargeAmount: v.number(),
407
- amountRefunded: v.number(),
408
- }),
338
+ export const recordTransferReversal = mutation(
339
+ recordTransferReversalDefinition,
409
340
  );
410
341
 
411
- /**
412
- * Atomically claim reversal slices for one operation (a dispute or refund
413
- * clawback) BEFORE any money moves (BTS-63). Runs in a single transaction, so
414
- * concurrent operations serialize here instead of racing between the ledger
415
- * read and the Stripe call:
416
- *
417
- * - First claim for an `operationId`: computes slices against each leg's
418
- * claim frontier (`max(reversedAmount, reversalClaimedAmount)`), advances
419
- * the frontier, and records the slices — atomically. A concurrent distinct
420
- * operation claiming the same target gets an EMPTY claim, not a duplicate.
421
- * - Any later call with the same `operationId` (webhook redelivery, retry
422
- * after a partial failure) replays the recorded slices verbatim, so the
423
- * caller re-sends byte-identical Stripe params under the same idempotency
424
- * keys no matter how the ledger moved in between.
425
- *
426
- * Each returned slice carries the leg's current CONFIRMED `reversedAmount` so
427
- * the executor can skip slices whose money already moved and was recorded.
428
- */
429
- export const claimReversalSlices = mutation({
430
- args: {
431
- operationId: v.string(),
432
- sourceChargeId: v.string(),
433
- mode: reversalModeValidator,
434
- },
435
- returns: v.object({
436
- replay: v.boolean(),
437
- slices: v.array(
438
- v.object({
439
- stripeTransferId: v.string(),
440
- from: v.number(),
441
- to: v.number(),
442
- confirmed: v.number(),
443
- }),
444
- ),
445
- }),
446
- handler: async (ctx, args) => {
447
- const confirmedFor = async (stripeTransferId: string) => {
448
- const row = await ctx.db
449
- .query("transfers")
450
- .withIndex("by_stripe_transfer_id", (q) =>
451
- q.eq("stripeTransferId", stripeTransferId),
452
- )
453
- .first();
454
- return row?.reversedAmount ?? 0;
455
- };
456
-
457
- const existingOp = await ctx.db
458
- .query("transferReversalOps")
459
- .withIndex("by_operation_id", (q) =>
460
- q.eq("operationId", args.operationId),
461
- )
462
- .first();
463
- if (existingOp) {
464
- const slices = [];
465
- for (const s of existingOp.slices) {
466
- slices.push({
467
- ...s,
468
- confirmed: await confirmedFor(s.stripeTransferId),
469
- });
470
- }
471
- return { replay: true, slices };
472
- }
473
-
474
- // Original split legs only — reinstatement payouts are audit rows and
475
- // must never be re-reversed (mirrors listTransfersByCharge).
476
- // Claim every original split leg, including those beyond the former
477
- // 50-row preview. Read/write limits abort the transaction before a partial
478
- // claim can commit; no Stripe call happens until this mutation succeeds.
479
- const rows: Doc<"transfers">[] = [];
480
- for await (const transfer of ctx.db
481
- .query("transfers")
482
- .withIndex("by_source_charge_id", (q) =>
483
- q.eq("sourceChargeId", args.sourceChargeId),
484
- )) {
485
- if (!transfer.reinstatement) rows.push(transfer);
486
- }
487
-
488
- const byId = new Map(rows.map((r) => [r.stripeTransferId, r]));
489
- const slices = computeReversalSlices(
490
- rows.map((t) => ({
491
- stripeTransferId: t.stripeTransferId,
492
- amount: t.amount,
493
- frontier: Math.max(t.reversedAmount ?? 0, t.reversalClaimedAmount ?? 0),
494
- })),
495
- args.mode,
496
- );
497
-
498
- for (const s of slices) {
499
- await ctx.db.patch("transfers", byId.get(s.stripeTransferId)!._id, {
500
- reversalClaimedAmount: s.to,
501
- });
502
- }
503
- // Record the operation even when it claims nothing, so a redelivery is a
504
- // deterministic no-op instead of a recomputation against newer state.
505
- await ctx.db.insert("transferReversalOps", {
506
- operationId: args.operationId,
507
- sourceChargeId: args.sourceChargeId,
508
- slices,
509
- });
510
-
511
- return {
512
- replay: false,
513
- slices: slices.map((s) => ({
514
- ...s,
515
- confirmed: byId.get(s.stripeTransferId)!.reversedAmount ?? 0,
516
- })),
517
- };
518
- },
519
- });
342
+ export const claimReversalSlices = mutation(claimReversalSlicesDefinition);
520
343
 
521
344
  /**
522
345
  * Release a permanently-dead reversal claim (BTS-74): rewind each of the
@@ -0,0 +1,25 @@
1
+ import type { MutationCtx } from "../_generated/server.js";
2
+ import type { Doc } from "../_generated/dataModel.js";
3
+ type PayoutData = Omit<
4
+ Doc<"payouts">,
5
+ "_id" | "_creationTime" | "totalsRecorded"
6
+ >;
7
+ /** The caller's triggered DB maintains the existing exact currency summaries. */
8
+ export async function writePayout(ctx: MutationCtx, args: PayoutData) {
9
+ const existing = await ctx.db
10
+ .query("payouts")
11
+ .withIndex("by_stripe_payout_id", (q) =>
12
+ q.eq("stripePayoutId", args.stripePayoutId),
13
+ )
14
+ .first();
15
+ const id = existing?._id ?? (await ctx.db.insert("payouts", args));
16
+ if (existing)
17
+ await ctx.db.patch("payouts", id, {
18
+ ...args,
19
+ failureCode: args.failureCode,
20
+ failureMessage: args.failureMessage,
21
+ });
22
+ const saved = await ctx.db.get("payouts", id);
23
+ if (!saved) throw new Error("Payout was not found after synchronization.");
24
+ return saved;
25
+ }
@@ -1,3 +1,4 @@
1
+ import { listWedgedReversalClaimsDefinition } from "./reversalDiagnostics.js";
1
2
  import {
2
3
  getAccountEarnings as getAccountEarningsLegacy,
3
4
  getAccountPayouts as getAccountPayoutsLegacy,
@@ -24,7 +25,6 @@ import {
24
25
  refundStatusValidator,
25
26
  transferDocValidator,
26
27
  transferReversalOpDocValidator,
27
- wedgedReversalClaimValidator,
28
28
  } from "./validators";
29
29
 
30
30
  // PAYMENT QUERIES
@@ -394,52 +394,9 @@ export const getReversalOp = query({
394
394
  * per-query document-read limit (16,384) — beyond which it throws (loud) rather
395
395
  * than silently truncating, matching the BTS-64 aggregate convention.
396
396
  */
397
- export const listWedgedReversalClaims = query({
398
- args: {},
399
- returns: v.array(wedgedReversalClaimValidator),
400
- handler: async (ctx) => {
401
- // Collect the wedged legs first.
402
- const wedged: Doc<"transfers">[] = [];
403
- for await (const tr of ctx.db.query("transfers")) {
404
- if ((tr.reversalClaimedAmount ?? 0) > (tr.reversedAmount ?? 0)) {
405
- wedged.push(tr);
406
- }
407
- }
408
- if (wedged.length === 0) return [];
409
-
410
- const wedgedIds = new Set(wedged.map((t) => t.stripeTransferId));
411
-
412
- // Index the ops that hold an unexecuted slice on a wedged leg. A slice is
413
- // unexecuted when its `to` exceeds the leg's confirmed `reversedAmount`.
414
- const confirmedById = new Map(
415
- wedged.map((t) => [t.stripeTransferId, t.reversedAmount ?? 0]),
416
- );
417
- const blockersByLeg = new Map<
418
- string,
419
- { operationId: string; from: number; to: number }[]
420
- >();
421
- for await (const op of ctx.db.query("transferReversalOps")) {
422
- for (const s of op.slices) {
423
- if (!wedgedIds.has(s.stripeTransferId)) continue;
424
- if (s.to <= (confirmedById.get(s.stripeTransferId) ?? 0)) continue;
425
- const list = blockersByLeg.get(s.stripeTransferId) ?? [];
426
- list.push({ operationId: op.operationId, from: s.from, to: s.to });
427
- blockersByLeg.set(s.stripeTransferId, list);
428
- }
429
- }
430
-
431
- return wedged.map((t) => ({
432
- stripeTransferId: t.stripeTransferId,
433
- sourceChargeId: t.sourceChargeId,
434
- amount: t.amount,
435
- reversedAmount: t.reversedAmount ?? 0,
436
- reversalClaimedAmount: t.reversalClaimedAmount ?? 0,
437
- blockingOps: (blockersByLeg.get(t.stripeTransferId) ?? []).sort(
438
- (a, b) => a.from - b.from,
439
- ),
440
- }));
441
- },
442
- });
397
+ export const listWedgedReversalClaims = query(
398
+ listWedgedReversalClaimsDefinition,
399
+ );
443
400
 
444
401
  // EARNINGS AGGREGATES (BTS-64)
445
402
  //
@@ -0,0 +1,232 @@
1
+ import { v } from "convex/values";
2
+ import type { MutationCtx } from "../_generated/server.js";
3
+ import { computeReversalSlices } from "../lib/reversals.js";
4
+ import {
5
+ applyCommissionExposure,
6
+ applyObligationExposure,
7
+ } from "../settlement/clawbacks.js";
8
+ import { commissionExposureValidator } from "../settlement/clawbackSchema.js";
9
+ import type { Infer } from "convex/values";
10
+ /** How `claimReversalSlices` sizes each leg's slice. */
11
+ const reversalModeValidator = v.union(
12
+ v.object({ kind: v.literal("full") }),
13
+ v.object({ kind: v.literal("percent"), percent: v.number() }),
14
+ v.object({ kind: v.literal("amount"), amount: v.number() }),
15
+ v.object({
16
+ kind: v.literal("fraction"),
17
+ chargeAmount: v.number(),
18
+ amountRefunded: v.number(),
19
+ }),
20
+ );
21
+
22
+ /**
23
+ * Atomically claim reversal slices for one operation (a dispute or refund
24
+ * clawback) BEFORE any money moves (BTS-63). Runs in a single transaction, so
25
+ * concurrent operations serialize here instead of racing between the ledger
26
+ * read and the Stripe call:
27
+ *
28
+ * - First claim for an `operationId`: computes slices against each leg's
29
+ * claim frontier (`max(reversedAmount, reversalClaimedAmount)`), advances
30
+ * the frontier, and records the slices — atomically. A concurrent distinct
31
+ * operation claiming the same target gets an EMPTY claim, not a duplicate.
32
+ * - Any later call with the same `operationId` (webhook redelivery, retry
33
+ * after a partial failure) replays the recorded slices verbatim, so the
34
+ * caller re-sends byte-identical Stripe params under the same idempotency
35
+ * keys no matter how the ledger moved in between.
36
+ *
37
+ * Each returned slice carries the leg's current CONFIRMED `reversedAmount` so
38
+ * the executor can skip slices whose money already moved and was recorded.
39
+ */
40
+ export const claimReversalSlicesDefinition = {
41
+ args: {
42
+ operationId: v.string(),
43
+ sourceChargeId: v.string(),
44
+ mode: reversalModeValidator,
45
+ commissionExposure: v.optional(commissionExposureValidator),
46
+ },
47
+ returns: v.object({
48
+ replay: v.boolean(),
49
+ slices: v.array(
50
+ v.object({
51
+ stripeTransferId: v.string(),
52
+ from: v.number(),
53
+ to: v.number(),
54
+ confirmed: v.number(),
55
+ commissionTracked: v.optional(v.boolean()),
56
+ }),
57
+ ),
58
+ }),
59
+ handler: async (
60
+ ctx: MutationCtx,
61
+ args: {
62
+ operationId: string;
63
+ sourceChargeId: string;
64
+ mode: Infer<typeof reversalModeValidator>;
65
+ commissionExposure?: Infer<typeof commissionExposureValidator>;
66
+ },
67
+ ) => {
68
+ const confirmedFor = async (stripeTransferId: string) => {
69
+ const row = await ctx.db
70
+ .query("transfers")
71
+ .withIndex("by_stripe_transfer_id", (q) =>
72
+ q.eq("stripeTransferId", stripeTransferId),
73
+ )
74
+ .first();
75
+ return row?.reversedAmount ?? 0;
76
+ };
77
+
78
+ const isAffiliate = async (stripeTransferId: string) => {
79
+ const row = await ctx.db
80
+ .query("transfers")
81
+ .withIndex("by_stripe_transfer_id", (q) =>
82
+ q.eq("stripeTransferId", stripeTransferId),
83
+ )
84
+ .unique();
85
+ return row?.role === "affiliate";
86
+ };
87
+ // Fail atomically rather than commit a partial recovery beyond the bound.
88
+ const chargeRows = await ctx.db
89
+ .query("transfers")
90
+ .withIndex("by_source_charge_id", (q) =>
91
+ q.eq("sourceChargeId", args.sourceChargeId),
92
+ )
93
+ .take(1001);
94
+ if (chargeRows.length > 1000)
95
+ throw new Error("Charge transfer recovery limit exceeded.");
96
+ const rows = chargeRows.filter((t) => !t.reinstatement);
97
+
98
+ const existingOp = await ctx.db
99
+ .query("transferReversalOps")
100
+ .withIndex("by_operation_id", (q) =>
101
+ q.eq("operationId", args.operationId),
102
+ )
103
+ .first();
104
+ const exposure =
105
+ existingOp?.commissionExposure && args.commissionExposure
106
+ ? {
107
+ ...existingOp.commissionExposure,
108
+ finalized:
109
+ existingOp.commissionExposure.finalized ||
110
+ args.commissionExposure.finalized,
111
+ disputeState:
112
+ args.commissionExposure.disputeState ??
113
+ existingOp.commissionExposure.disputeState,
114
+ refundedPrincipal: args.commissionExposure.refundedPrincipal,
115
+ }
116
+ : existingOp
117
+ ? undefined
118
+ : args.commissionExposure;
119
+ const targets = new Map<string, number>();
120
+ if (exposure) {
121
+ for (const transfer of rows)
122
+ if (transfer.role === "affiliate")
123
+ targets.set(
124
+ transfer.stripeTransferId,
125
+ await applyCommissionExposure(ctx, transfer, exposure),
126
+ );
127
+ const untransferred = await ctx.db
128
+ .query("affiliateObligations")
129
+ .withIndex("by_charge", (q) =>
130
+ q.eq("sourceChargeId", args.sourceChargeId),
131
+ )
132
+ .take(1001);
133
+ if (untransferred.length > 1000)
134
+ throw new Error("Affiliate obligation limit exceeded.");
135
+ for (const row of untransferred)
136
+ if (!row.stripeTransferId)
137
+ await applyObligationExposure(ctx, row, exposure);
138
+ }
139
+ if (existingOp) {
140
+ if (existingOp.sourceChargeId !== args.sourceChargeId)
141
+ throw new Error("Reversal operation source identity cannot change.");
142
+ if (existingOp.commissionExposure && args.commissionExposure) {
143
+ if (
144
+ existingOp.commissionExposure.kind !== args.commissionExposure.kind ||
145
+ existingOp.commissionExposure.chargeAmount !==
146
+ args.commissionExposure.chargeAmount
147
+ )
148
+ throw new Error("Commission operation identity cannot change.");
149
+ if (exposure)
150
+ await ctx.db.patch("transferReversalOps", existingOp._id, {
151
+ commissionExposure: exposure,
152
+ });
153
+ }
154
+ const slices = [];
155
+ for (const s of existingOp.slices) {
156
+ slices.push({
157
+ ...s,
158
+ confirmed: await confirmedFor(s.stripeTransferId),
159
+ ...(existingOp.commissionExposure
160
+ ? { commissionTracked: await isAffiliate(s.stripeTransferId) }
161
+ : {}),
162
+ });
163
+ }
164
+ return { replay: true, slices };
165
+ }
166
+
167
+ const byId = new Map(rows.map((r) => [r.stripeTransferId, r]));
168
+ let slices = computeReversalSlices(
169
+ rows.map((t) => ({
170
+ stripeTransferId: t.stripeTransferId,
171
+ amount: t.amount,
172
+ frontier: Math.max(t.reversedAmount ?? 0, t.reversalClaimedAmount ?? 0),
173
+ })),
174
+ args.mode,
175
+ );
176
+
177
+ if (args.commissionExposure) {
178
+ // Keep the shipped seller/other paths; affiliates converge on original
179
+ // commission exposure rather than a second incremental dispute debit.
180
+ const affiliates = rows.filter((t) => t.role === "affiliate");
181
+ slices = slices.filter(
182
+ (s) => byId.get(s.stripeTransferId)!.role !== "affiliate",
183
+ );
184
+ for (const transfer of affiliates) {
185
+ const target = Math.min(
186
+ transfer.amount,
187
+ targets.get(transfer.stripeTransferId) ?? 0,
188
+ );
189
+ const from = Math.max(
190
+ transfer.reversedAmount ?? 0,
191
+ transfer.reversalClaimedAmount ?? 0,
192
+ );
193
+ if (target > from)
194
+ slices.push({
195
+ stripeTransferId: transfer.stripeTransferId,
196
+ from,
197
+ to: target,
198
+ });
199
+ }
200
+ }
201
+
202
+ for (const s of slices) {
203
+ await ctx.db.patch("transfers", byId.get(s.stripeTransferId)!._id, {
204
+ reversalClaimedAmount: s.to,
205
+ });
206
+ }
207
+ // Record the operation even when it claims nothing, so a redelivery is a
208
+ // deterministic no-op instead of a recomputation against newer state.
209
+ await ctx.db.insert("transferReversalOps", {
210
+ operationId: args.operationId,
211
+ sourceChargeId: args.sourceChargeId,
212
+ slices,
213
+ ...(args.commissionExposure
214
+ ? { commissionExposure: args.commissionExposure }
215
+ : {}),
216
+ });
217
+
218
+ return {
219
+ replay: false,
220
+ slices: slices.map((s) => ({
221
+ ...s,
222
+ confirmed: byId.get(s.stripeTransferId)!.reversedAmount ?? 0,
223
+ ...(args.commissionExposure
224
+ ? {
225
+ commissionTracked:
226
+ byId.get(s.stripeTransferId)!.role === "affiliate",
227
+ }
228
+ : {}),
229
+ })),
230
+ };
231
+ },
232
+ };
@@ -0,0 +1,61 @@
1
+ import { v } from "convex/values";
2
+ import type { QueryCtx } from "../_generated/server.js";
3
+ import type { Doc } from "../_generated/dataModel.js";
4
+ import { wedgedReversalClaimValidator } from "./validators.js";
5
+
6
+ export const listWedgedReversalClaimsDefinition = {
7
+ args: {},
8
+ returns: v.array(wedgedReversalClaimValidator),
9
+ handler: async (ctx: QueryCtx) => {
10
+ // Collect the wedged legs first.
11
+ const wedged: Doc<"transfers">[] = [];
12
+ for await (const tr of ctx.db.query("transfers")) {
13
+ const tracked =
14
+ tr.role === "affiliate"
15
+ ? await ctx.db
16
+ .query("affiliateObligations")
17
+ .withIndex("by_transfer", (q) =>
18
+ q.eq("stripeTransferId", tr.stripeTransferId),
19
+ )
20
+ .unique()
21
+ : null;
22
+ if (tracked?.accounted) continue;
23
+ if ((tr.reversalClaimedAmount ?? 0) > (tr.reversedAmount ?? 0)) {
24
+ wedged.push(tr);
25
+ }
26
+ }
27
+ if (wedged.length === 0) return [];
28
+
29
+ const wedgedIds = new Set(wedged.map((t) => t.stripeTransferId));
30
+
31
+ // Index the ops that hold an unexecuted slice on a wedged leg. A slice is
32
+ // unexecuted when its `to` exceeds the leg's confirmed `reversedAmount`.
33
+ const confirmedById = new Map(
34
+ wedged.map((t) => [t.stripeTransferId, t.reversedAmount ?? 0]),
35
+ );
36
+ const blockersByLeg = new Map<
37
+ string,
38
+ { operationId: string; from: number; to: number }[]
39
+ >();
40
+ for await (const op of ctx.db.query("transferReversalOps")) {
41
+ for (const s of op.slices) {
42
+ if (!wedgedIds.has(s.stripeTransferId)) continue;
43
+ if (s.to <= (confirmedById.get(s.stripeTransferId) ?? 0)) continue;
44
+ const list = blockersByLeg.get(s.stripeTransferId) ?? [];
45
+ list.push({ operationId: op.operationId, from: s.from, to: s.to });
46
+ blockersByLeg.set(s.stripeTransferId, list);
47
+ }
48
+ }
49
+
50
+ return wedged.map((t) => ({
51
+ stripeTransferId: t.stripeTransferId,
52
+ sourceChargeId: t.sourceChargeId,
53
+ amount: t.amount,
54
+ reversedAmount: t.reversedAmount ?? 0,
55
+ reversalClaimedAmount: t.reversalClaimedAmount ?? 0,
56
+ blockingOps: (blockersByLeg.get(t.stripeTransferId) ?? []).sort(
57
+ (a, b) => a.from - b.from,
58
+ ),
59
+ }));
60
+ },
61
+ };