@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,275 @@
1
+ import { commissionExposureValidator } from "../settlement/clawbackSchema.js";
2
+ import { applyObligationExposure } from "../settlement/clawbacks.js";
3
+ import { v } from "convex/values";
4
+ import { mutation } from "../earnings/triggers.js";
5
+ import { balance, updateObligation, due, credit } from "./accounting.js";
6
+ import { operationFields, recoveryState } from "./schema.js";
7
+ import { deriveReversalState } from "../lib/reversals.js";
8
+ const operationDoc = v.object({
9
+ ...operationFields,
10
+ _id: v.id("affiliateRecoveryOperations"),
11
+ _creationTime: v.number(),
12
+ });
13
+ export const reserveOperation = mutation({
14
+ args: {
15
+ obligationId: v.id("affiliateObligations"),
16
+ kind: v.union(v.literal("recovery"), v.literal("restoration")),
17
+ now: v.number(),
18
+ },
19
+ returns: v.union(operationDoc, v.null()),
20
+ handler: async (ctx, args) => {
21
+ const row = await ctx.db.get("affiliateObligations", args.obligationId);
22
+ if (!row) throw new Error("Affiliate obligation not found.");
23
+ if (row.needsReconciliation) return null;
24
+ if (
25
+ (await balance(ctx, row.affiliateAccountId, row.currency))
26
+ ?.activePayoutKey
27
+ )
28
+ throw new Error("Affiliate payout is pending reconciliation.");
29
+ if (row.activeOperationId)
30
+ return ctx.db.get("affiliateRecoveryOperations", row.activeOperationId);
31
+ const originalTransfer = row.stripeTransferId
32
+ ? await ctx.db
33
+ .query("transfers")
34
+ .withIndex("by_stripe_transfer_id", (q) =>
35
+ q.eq("stripeTransferId", row.stripeTransferId!),
36
+ )
37
+ .unique()
38
+ : null;
39
+ const reversible = originalTransfer
40
+ ? originalTransfer.amount - (originalTransfer.reversedAmount ?? 0)
41
+ : 0;
42
+ const amount =
43
+ args.kind === "recovery"
44
+ ? Math.min(due(row), Math.max(0, reversible))
45
+ : credit(row);
46
+ if (amount <= 0 || (args.kind === "recovery" && !row.stripeTransferId)) {
47
+ if (args.kind === "recovery" && due(row) > 0)
48
+ await updateObligation(ctx, row, {
49
+ lastState: "offset_required",
50
+ lastError:
51
+ "Original transfer has no reversible funds; recover from eligible future commissions.",
52
+ });
53
+ return null;
54
+ }
55
+ const id = await ctx.db.insert("affiliateRecoveryOperations", {
56
+ obligationId: row._id,
57
+ sourceEventId:
58
+ args.kind === "restoration"
59
+ ? row.disputeId
60
+ : (row.lastRefundId ?? row.disputeId),
61
+ kind: args.kind,
62
+ amount,
63
+ state: "pending",
64
+ attempted: false,
65
+ providerBaseline: originalTransfer?.reversedAmount ?? 0,
66
+ createdAt: args.now,
67
+ });
68
+ await updateObligation(ctx, row, {
69
+ activeOperationId: id,
70
+ [args.kind === "recovery" ? "reservedRecovery" : "reservedRestoration"]:
71
+ amount,
72
+ lastState: "pending",
73
+ lastError: undefined,
74
+ });
75
+ return (await ctx.db.get("affiliateRecoveryOperations", id))!;
76
+ },
77
+ });
78
+ /** Persist intent before contacting Stripe. Retries keep the exact operation ID. */
79
+ export const beginOperation = mutation({
80
+ args: { operationId: v.id("affiliateRecoveryOperations") },
81
+ returns: operationDoc,
82
+ handler: async (ctx, { operationId }) => {
83
+ const op = await ctx.db.get("affiliateRecoveryOperations", operationId);
84
+ if (!op) throw new Error("Affiliate operation not found.");
85
+ const row = (await ctx.db.get("affiliateObligations", op.obligationId))!;
86
+ if (
87
+ !op.attempted &&
88
+ (op.kind === "recovery" ? due(row) : credit(row)) < op.amount
89
+ ) {
90
+ await ctx.db.patch("affiliateRecoveryOperations", op._id, {
91
+ state: "canceled",
92
+ });
93
+ await updateObligation(ctx, row, {
94
+ activeOperationId: undefined,
95
+ reservedRecovery: 0,
96
+ reservedRestoration: 0,
97
+ lastState: "canceled",
98
+ });
99
+ return { ...op, state: "canceled" as const };
100
+ }
101
+ if (op.state === "pending" && !op.attempted)
102
+ await ctx.db.patch("affiliateRecoveryOperations", op._id, {
103
+ attempted: true,
104
+ });
105
+ return { ...op, attempted: true };
106
+ },
107
+ });
108
+ export const completeOperation = mutation({
109
+ args: {
110
+ operationId: v.id("affiliateRecoveryOperations"),
111
+ state: recoveryState,
112
+ receiptId: v.optional(v.string()),
113
+ confirmedTransferReversed: v.optional(v.number()),
114
+ error: v.optional(v.string()),
115
+ },
116
+ returns: v.null(),
117
+ handler: async (ctx, args) => {
118
+ const op = await ctx.db.get(
119
+ "affiliateRecoveryOperations",
120
+ args.operationId,
121
+ );
122
+ if (!op) throw new Error("Affiliate operation not found.");
123
+ if (op.state === "succeeded") {
124
+ if (args.state === "succeeded" && op.receiptId !== args.receiptId)
125
+ throw new Error("Affiliate receipt identity cannot change.");
126
+ return null;
127
+ }
128
+ const row = (await ctx.db.get("affiliateObligations", op.obligationId))!;
129
+ if (args.state === "succeeded" && !args.receiptId)
130
+ throw new Error("Confirmed affiliate recovery requires a receipt.");
131
+ const pending = args.state === "pending" || args.state === "unknown";
132
+ const patch = {
133
+ lastState: args.state,
134
+ lastError: args.error,
135
+ activeOperationId: pending ? op._id : undefined,
136
+ reservedRecovery: op.kind === "recovery" && pending ? op.amount : 0,
137
+ reservedRestoration: op.kind === "restoration" && pending ? op.amount : 0,
138
+ };
139
+ if (args.state === "succeeded") {
140
+ if (op.kind === "recovery") {
141
+ if (!row.stripeTransferId)
142
+ throw new Error("Recovery has no original Connect transfer.");
143
+ const transfer = await ctx.db
144
+ .query("transfers")
145
+ .withIndex("by_stripe_transfer_id", (q) =>
146
+ q.eq("stripeTransferId", row.stripeTransferId!),
147
+ )
148
+ .unique();
149
+ if (!transfer) throw new Error("Original transfer not found.");
150
+ const total = row.recoveredAmount + op.amount;
151
+ if (total > transfer.amount)
152
+ throw new Error("Recovery exceeds original transfer.");
153
+ const aggregate = args.confirmedTransferReversed;
154
+ if (
155
+ aggregate === undefined ||
156
+ !Number.isSafeInteger(aggregate) ||
157
+ aggregate < total ||
158
+ aggregate > transfer.amount
159
+ )
160
+ throw new Error(
161
+ "A confirmed provider transfer aggregate is required.",
162
+ );
163
+ await ctx.db.patch(
164
+ "transfers",
165
+ transfer._id,
166
+ deriveReversalState(
167
+ transfer.amount,
168
+ Math.max(aggregate, transfer.reversedAmount ?? 0),
169
+ transfer.status,
170
+ ),
171
+ );
172
+ const duplicate = await ctx.db
173
+ .query("affiliateReversalReceipts")
174
+ .withIndex("by_receipt", (q) =>
175
+ q.eq("stripeReversalId", args.receiptId!),
176
+ )
177
+ .unique();
178
+ if (duplicate)
179
+ throw new Error(
180
+ "Affiliate reversal receipt has already been attributed.",
181
+ );
182
+ await ctx.db.insert("affiliateReversalReceipts", {
183
+ stripeReversalId: args.receiptId!,
184
+ stripeTransferId: row.stripeTransferId,
185
+ operationId: op._id,
186
+ amount: op.amount,
187
+ });
188
+ await updateObligation(ctx, row, { ...patch, recoveredAmount: total });
189
+ } else {
190
+ await ctx.db.insert("transfers", {
191
+ stripeTransferId: args.receiptId!,
192
+ sourceChargeId: row.sourceChargeId,
193
+ sourceInvoiceId: row.sourceInvoiceId,
194
+ destinationAccountId: row.affiliateAccountId,
195
+ currency: row.currency,
196
+ amount: op.amount,
197
+ role: "affiliate",
198
+ status: "paid",
199
+ reinstatement: true,
200
+ metadata: { bsRecoveryOperation: op._id },
201
+ });
202
+ await updateObligation(ctx, row, {
203
+ ...patch,
204
+ restoredAmount: (row.restoredAmount ?? 0) + op.amount,
205
+ });
206
+ }
207
+ } else await updateObligation(ctx, row, patch);
208
+ await ctx.db.patch("affiliateRecoveryOperations", op._id, {
209
+ state: args.state,
210
+ receiptId: args.receiptId,
211
+ error: args.error,
212
+ });
213
+ return null;
214
+ },
215
+ });
216
+
217
+ /** Trusted operator API; the client verifies provider facts before this write. */
218
+ export const reviewLegacy = mutation({
219
+ args: {
220
+ obligationId: v.id("affiliateObligations"),
221
+ recoveredAmount: v.number(),
222
+ restoredAmount: v.number(),
223
+ receipts: v.array(v.object({ id: v.string(), amount: v.number() })),
224
+ restorationIds: v.array(v.string()),
225
+ note: v.string(),
226
+ exposure: commissionExposureValidator,
227
+ },
228
+ returns: v.null(),
229
+ handler: async (ctx, args) => {
230
+ const row = await ctx.db.get("affiliateObligations", args.obligationId);
231
+ if (!row || !row.needsReconciliation || row.activeOperationId)
232
+ throw new Error("Obligation is not eligible for legacy review.");
233
+ if (
234
+ !args.note.trim() ||
235
+ args.receipts.length > 100 ||
236
+ new Set(args.receipts.map((r) => r.id)).size !== args.receipts.length ||
237
+ ![
238
+ args.recoveredAmount,
239
+ args.restoredAmount,
240
+ ...args.receipts.map((r) => r.amount),
241
+ ].every((n) => Number.isSafeInteger(n) && n >= 0) ||
242
+ args.recoveredAmount > row.originalCommission ||
243
+ args.restoredAmount > args.recoveredAmount ||
244
+ args.receipts.reduce((n, r) => n + r.amount, 0) !== args.recoveredAmount
245
+ )
246
+ throw new Error("Legacy review does not reconcile.");
247
+ await applyObligationExposure(ctx, row, args.exposure);
248
+ const observed = (await ctx.db.get("affiliateObligations", row._id))!;
249
+ for (const receipt of args.receipts) {
250
+ const existing = await ctx.db
251
+ .query("affiliateReversalReceipts")
252
+ .withIndex("by_receipt", (q) => q.eq("stripeReversalId", receipt.id))
253
+ .unique();
254
+ if (existing && existing.stripeTransferId !== row.stripeTransferId)
255
+ throw new Error("Legacy receipt belongs to another original transfer.");
256
+ if (!existing)
257
+ await ctx.db.insert("affiliateReversalReceipts", {
258
+ stripeReversalId: receipt.id,
259
+ stripeTransferId: row.stripeTransferId!,
260
+ operationId: `review:${row._id}`,
261
+ amount: receipt.amount,
262
+ });
263
+ }
264
+ await updateObligation(ctx, observed, {
265
+ recoveredAmount: args.recoveredAmount,
266
+ restoredAmount: args.restoredAmount,
267
+ needsReconciliation: false,
268
+ legacyReceiptIds: args.receipts.map((r) => r.id),
269
+ legacyRestorationIds: args.restorationIds,
270
+ operatorNote: args.note,
271
+ lastState: "reviewed",
272
+ });
273
+ return null;
274
+ },
275
+ });
@@ -0,0 +1,138 @@
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 { v } from "convex/values";
8
+ import { query } from "../_generated/server.js";
9
+ import { obligationFields } from "../settlement/clawbackSchema.js";
10
+ import { balanceFields, operationFields, earningFields } from "./schema.js";
11
+ export const obligationDoc = v.object({
12
+ ...obligationFields,
13
+ _id: v.id("affiliateObligations"),
14
+ _creationTime: v.number(),
15
+ });
16
+ export const getObligation = query({
17
+ args: { obligationId: v.id("affiliateObligations") },
18
+ returns: v.union(obligationDoc, v.null()),
19
+ handler: (ctx, args) => ctx.db.get("affiliateObligations", args.obligationId),
20
+ });
21
+ export const chargeObligations = query({
22
+ args: { sourceChargeId: v.string() },
23
+ returns: v.array(obligationDoc),
24
+ handler: async (ctx, args) => {
25
+ const rows = await ctx.db
26
+ .query("affiliateObligations")
27
+ .withIndex("by_charge", (q) =>
28
+ q.eq("sourceChargeId", args.sourceChargeId),
29
+ )
30
+ .take(1001);
31
+ if (rows.length > 1000)
32
+ throw new Error("Affiliate charge recovery limit exceeded.");
33
+ return rows;
34
+ },
35
+ });
36
+ export const getEarning = query({
37
+ args: { sourceChargeId: v.string(), affiliateAccountId: v.string() },
38
+ returns: v.union(
39
+ v.object({
40
+ ...earningFields,
41
+ _id: v.id("affiliateEarnings"),
42
+ _creationTime: v.number(),
43
+ }),
44
+ v.null(),
45
+ ),
46
+ handler: (ctx, args) =>
47
+ ctx.db
48
+ .query("affiliateEarnings")
49
+ .withIndex("by_charge_affiliate", (q) =>
50
+ q
51
+ .eq("sourceChargeId", args.sourceChargeId)
52
+ .eq("affiliateAccountId", args.affiliateAccountId),
53
+ )
54
+ .unique(),
55
+ });
56
+ export const recoverySnapshot = query({
57
+ args: {
58
+ affiliateAccountId: v.string(),
59
+ currency: v.string(),
60
+ paginationOpts: paginationOptsValidator,
61
+ },
62
+ returns: v.object({
63
+ balance: v.union(
64
+ v.object({
65
+ ...balanceFields,
66
+ _id: v.id("affiliateRecoveryBalances"),
67
+ _creationTime: v.number(),
68
+ }),
69
+ v.null(),
70
+ ),
71
+ obligations: paginationResultValidator(obligationDoc),
72
+ }),
73
+ handler: async (ctx, args) => ({
74
+ balance: await ctx.db
75
+ .query("affiliateRecoveryBalances")
76
+ .withIndex("by_affiliate_currency", (q) =>
77
+ q
78
+ .eq("affiliateAccountId", args.affiliateAccountId)
79
+ .eq("currency", args.currency),
80
+ )
81
+ .unique(),
82
+ obligations: await paginator(ctx.db, schema)
83
+ .query("affiliateObligations")
84
+ .withIndex("by_affiliate_currency", (q) =>
85
+ q
86
+ .eq("affiliateAccountId", args.affiliateAccountId)
87
+ .eq("currency", args.currency),
88
+ )
89
+ .paginate({
90
+ ...args.paginationOpts,
91
+ numItems: Math.min(100, args.paginationOpts.numItems),
92
+ }),
93
+ }),
94
+ });
95
+ export const operationHistory = query({
96
+ args: {
97
+ obligationId: v.id("affiliateObligations"),
98
+ paginationOpts: paginationOptsValidator,
99
+ },
100
+ returns: paginationResultValidator(
101
+ v.object({
102
+ ...operationFields,
103
+ _id: v.id("affiliateRecoveryOperations"),
104
+ _creationTime: v.number(),
105
+ }),
106
+ ),
107
+ handler: (ctx, args) =>
108
+ paginator(ctx.db, schema)
109
+ .query("affiliateRecoveryOperations")
110
+ .withIndex("by_obligation", (q) =>
111
+ q.eq("obligationId", args.obligationId),
112
+ )
113
+ .paginate({
114
+ ...args.paginationOpts,
115
+ numItems: Math.min(100, args.paginationOpts.numItems),
116
+ }),
117
+ });
118
+ export const pendingOperations = query({
119
+ args: {
120
+ paginationOpts: paginationOptsValidator,
121
+ state: v.union(v.literal("pending"), v.literal("unknown")),
122
+ },
123
+ returns: paginationResultValidator(
124
+ v.object({
125
+ ...operationFields,
126
+ _id: v.id("affiliateRecoveryOperations"),
127
+ _creationTime: v.number(),
128
+ }),
129
+ ),
130
+ handler: (ctx, args) =>
131
+ paginator(ctx.db, schema)
132
+ .query("affiliateRecoveryOperations")
133
+ .withIndex("by_state", (q) => q.eq("state", args.state))
134
+ .paginate({
135
+ ...args.paginationOpts,
136
+ numItems: Math.min(50, args.paginationOpts.numItems),
137
+ }),
138
+ });
@@ -0,0 +1,86 @@
1
+ import { defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ export const recoveryState = v.union(
4
+ v.literal("pending"),
5
+ v.literal("unknown"),
6
+ v.literal("insufficient_balance"),
7
+ v.literal("unsupported"),
8
+ v.literal("failed"),
9
+ v.literal("succeeded"),
10
+ v.literal("canceled"),
11
+ );
12
+ export const operationFields = {
13
+ obligationId: v.id("affiliateObligations"),
14
+ sourceEventId: v.optional(v.string()),
15
+ kind: v.union(v.literal("recovery"), v.literal("restoration")),
16
+ amount: v.number(),
17
+ state: recoveryState,
18
+ receiptId: v.optional(v.string()),
19
+ error: v.optional(v.string()),
20
+ attempted: v.boolean(),
21
+ providerBaseline: v.optional(v.number()),
22
+ createdAt: v.number(),
23
+ };
24
+ export const operationsTable = defineTable(operationFields)
25
+ .index("by_obligation", ["obligationId"])
26
+ .index("by_state", ["state"]);
27
+ export const earningFields = {
28
+ sourceChargeId: v.string(),
29
+ affiliateAccountId: v.string(),
30
+ currency: v.string(),
31
+ originalCommission: v.number(),
32
+ withheldAmount: v.number(),
33
+ releaseAmount: v.number(),
34
+ sourceInvoiceId: v.optional(v.string()),
35
+ paymentIntentId: v.optional(v.string()),
36
+ stripeTransferId: v.optional(v.string()),
37
+ status: v.union(v.literal("reserved"), v.literal("paid")),
38
+ };
39
+ export const earningsTable = defineTable(earningFields)
40
+ .index("by_charge_affiliate", ["sourceChargeId", "affiliateAccountId"])
41
+ .index("by_affiliate_currency", ["affiliateAccountId", "currency"]);
42
+ export const offsetsTable = defineTable({
43
+ earningId: v.id("affiliateEarnings"),
44
+ obligationId: v.id("affiliateObligations"),
45
+ amount: v.number(),
46
+ })
47
+ .index("by_earning", ["earningId"])
48
+ .index("by_obligation", ["obligationId"]);
49
+ export const balanceFields = {
50
+ activePayoutKey: v.optional(v.string()),
51
+ pendingPayoutAmount: v.optional(v.number()),
52
+ affiliateAccountId: v.string(),
53
+ currency: v.string(),
54
+ originalCommission: v.number(),
55
+ recoveredAmount: v.number(),
56
+ offsetAmount: v.number(),
57
+ restoredAmount: v.number(),
58
+ outstandingDebt: v.number(),
59
+ reservedRecovery: v.number(),
60
+ reservedRestoration: v.number(),
61
+ restorationDue: v.number(),
62
+ withheldAmount: v.number(),
63
+ netPayable: v.number(),
64
+ };
65
+ export const balancesTable = defineTable(balanceFields).index(
66
+ "by_affiliate_currency",
67
+ ["affiliateAccountId", "currency"],
68
+ );
69
+ export const payoutReleaseFields = {
70
+ affiliateAccountId: v.string(),
71
+ currency: v.string(),
72
+ operationKey: v.string(),
73
+ amount: v.number(),
74
+ minimumAmount: v.number(),
75
+ state: v.union(
76
+ v.literal("pending"),
77
+ v.literal("unknown"),
78
+ v.literal("submitted"),
79
+ v.literal("failed"),
80
+ ),
81
+ stripePayoutId: v.optional(v.string()),
82
+ createdAt: v.number(),
83
+ };
84
+ export const payoutReleasesTable = defineTable(payoutReleaseFields)
85
+ .index("by_key", ["operationKey"])
86
+ .index("by_state", ["state"]);
@@ -1,10 +1,13 @@
1
+ import { syncCommissionTransfer } from "../settlement/clawbacks.js";
2
+ import { recordTransferReversalDefinition } from "./reversalReceipts.js";
3
+ import { claimReversalSlicesDefinition } from "./reversalClaims.js";
1
4
  import { writePayout } from "./payoutWrites.js";
2
5
  import { v } from "convex/values";
3
6
 
4
- import type { Doc, Id } from "../_generated/dataModel";
7
+ import type { Id } from "../_generated/dataModel";
5
8
  import { mutation } from "../earnings/triggers.js";
6
9
  import { feeRoutingFields } from "../lib/fees";
7
- import { computeReversalSlices, deriveReversalState } from "../lib/reversals";
10
+ import { deriveReversalState } from "../lib/reversals";
8
11
  import { paymentSeller } from "../lib/paymentSeller";
9
12
  import {
10
13
  disputeFields,
@@ -312,6 +315,7 @@ export const upsertTransfer = mutation({
312
315
  reversedAmount,
313
316
  args.status,
314
317
  );
318
+ await syncCommissionTransfer(ctx, { ...existing, ...args, ...derived });
315
319
  await ctx.db.patch("transfers", existing._id, { ...args, ...derived });
316
320
  } else {
317
321
  const derived = deriveReversalState(
@@ -331,166 +335,11 @@ export const upsertTransfer = mutation({
331
335
  * cumulative `reversedAmount` and a derived `reversalStatus`. `reversedAmount`
332
336
  * is the new cumulative total (matches Stripe's `Transfer.amount_reversed`).
333
337
  */
334
- export const recordTransferReversal = mutation({
335
- args: {
336
- stripeTransferId: v.string(),
337
- reversedAmount: v.number(),
338
- },
339
- returns: v.null(),
340
- handler: async (ctx, args) => {
341
- const transfer = await ctx.db
342
- .query("transfers")
343
- .withIndex("by_stripe_transfer_id", (q) =>
344
- q.eq("stripeTransferId", args.stripeTransferId),
345
- )
346
- .first();
347
-
348
- if (!transfer) {
349
- throw new Error(
350
- `Cannot record reversal: no transfer found for ${args.stripeTransferId}`,
351
- );
352
- }
353
-
354
- // `reversedAmount` is a cumulative total (matches Stripe's
355
- // `Transfer.amount_reversed`). Keep it monotonic so a stale/out-of-order
356
- // event can't shrink it or undo a full reversal.
357
- const reversedAmount = Math.max(
358
- transfer.reversedAmount ?? 0,
359
- args.reversedAmount,
360
- );
361
- const derived = deriveReversalState(
362
- transfer.amount,
363
- reversedAmount,
364
- transfer.status,
365
- );
366
-
367
- await ctx.db.patch("transfers", transfer._id, derived);
368
-
369
- return null;
370
- },
371
- });
372
-
373
- /** How `claimReversalSlices` sizes each leg's slice. */
374
- const reversalModeValidator = v.union(
375
- v.object({ kind: v.literal("full") }),
376
- v.object({ kind: v.literal("percent"), percent: v.number() }),
377
- v.object({ kind: v.literal("amount"), amount: v.number() }),
378
- v.object({
379
- kind: v.literal("fraction"),
380
- chargeAmount: v.number(),
381
- amountRefunded: v.number(),
382
- }),
338
+ export const recordTransferReversal = mutation(
339
+ recordTransferReversalDefinition,
383
340
  );
384
341
 
385
- /**
386
- * Atomically claim reversal slices for one operation (a dispute or refund
387
- * clawback) BEFORE any money moves (BTS-63). Runs in a single transaction, so
388
- * concurrent operations serialize here instead of racing between the ledger
389
- * read and the Stripe call:
390
- *
391
- * - First claim for an `operationId`: computes slices against each leg's
392
- * claim frontier (`max(reversedAmount, reversalClaimedAmount)`), advances
393
- * the frontier, and records the slices — atomically. A concurrent distinct
394
- * operation claiming the same target gets an EMPTY claim, not a duplicate.
395
- * - Any later call with the same `operationId` (webhook redelivery, retry
396
- * after a partial failure) replays the recorded slices verbatim, so the
397
- * caller re-sends byte-identical Stripe params under the same idempotency
398
- * keys no matter how the ledger moved in between.
399
- *
400
- * Each returned slice carries the leg's current CONFIRMED `reversedAmount` so
401
- * the executor can skip slices whose money already moved and was recorded.
402
- */
403
- export const claimReversalSlices = mutation({
404
- args: {
405
- operationId: v.string(),
406
- sourceChargeId: v.string(),
407
- mode: reversalModeValidator,
408
- },
409
- returns: v.object({
410
- replay: v.boolean(),
411
- slices: v.array(
412
- v.object({
413
- stripeTransferId: v.string(),
414
- from: v.number(),
415
- to: v.number(),
416
- confirmed: v.number(),
417
- }),
418
- ),
419
- }),
420
- handler: async (ctx, args) => {
421
- const confirmedFor = async (stripeTransferId: string) => {
422
- const row = await ctx.db
423
- .query("transfers")
424
- .withIndex("by_stripe_transfer_id", (q) =>
425
- q.eq("stripeTransferId", stripeTransferId),
426
- )
427
- .first();
428
- return row?.reversedAmount ?? 0;
429
- };
430
-
431
- const existingOp = await ctx.db
432
- .query("transferReversalOps")
433
- .withIndex("by_operation_id", (q) =>
434
- q.eq("operationId", args.operationId),
435
- )
436
- .first();
437
- if (existingOp) {
438
- const slices = [];
439
- for (const s of existingOp.slices) {
440
- slices.push({
441
- ...s,
442
- confirmed: await confirmedFor(s.stripeTransferId),
443
- });
444
- }
445
- return { replay: true, slices };
446
- }
447
-
448
- // Original split legs only — reinstatement payouts are audit rows and
449
- // must never be re-reversed (mirrors listTransfersByCharge).
450
- // Claim every original split leg, including those beyond the former
451
- // 50-row preview. Read/write limits abort the transaction before a partial
452
- // claim can commit; no Stripe call happens until this mutation succeeds.
453
- const rows: Doc<"transfers">[] = [];
454
- for await (const transfer of ctx.db
455
- .query("transfers")
456
- .withIndex("by_source_charge_id", (q) =>
457
- q.eq("sourceChargeId", args.sourceChargeId),
458
- )) {
459
- if (!transfer.reinstatement) rows.push(transfer);
460
- }
461
-
462
- const byId = new Map(rows.map((r) => [r.stripeTransferId, r]));
463
- const slices = computeReversalSlices(
464
- rows.map((t) => ({
465
- stripeTransferId: t.stripeTransferId,
466
- amount: t.amount,
467
- frontier: Math.max(t.reversedAmount ?? 0, t.reversalClaimedAmount ?? 0),
468
- })),
469
- args.mode,
470
- );
471
-
472
- for (const s of slices) {
473
- await ctx.db.patch("transfers", byId.get(s.stripeTransferId)!._id, {
474
- reversalClaimedAmount: s.to,
475
- });
476
- }
477
- // Record the operation even when it claims nothing, so a redelivery is a
478
- // deterministic no-op instead of a recomputation against newer state.
479
- await ctx.db.insert("transferReversalOps", {
480
- operationId: args.operationId,
481
- sourceChargeId: args.sourceChargeId,
482
- slices,
483
- });
484
-
485
- return {
486
- replay: false,
487
- slices: slices.map((s) => ({
488
- ...s,
489
- confirmed: byId.get(s.stripeTransferId)!.reversedAmount ?? 0,
490
- })),
491
- };
492
- },
493
- });
342
+ export const claimReversalSlices = mutation(claimReversalSlicesDefinition);
494
343
 
495
344
  /**
496
345
  * Release a permanently-dead reversal claim (BTS-74): rewind each of the