@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,196 @@
1
+ import { v } from "convex/values";
2
+ import { mutation } from "../_generated/server.js";
3
+ import { earningFields } from "../affiliateRecovery/schema.js";
4
+ import {
5
+ balance,
6
+ changeBalance,
7
+ due,
8
+ updateObligation,
9
+ } from "../affiliateRecovery/accounting.js";
10
+ const earningDoc = v.object({
11
+ ...earningFields,
12
+ _id: v.id("affiliateEarnings"),
13
+ _creationTime: v.number(),
14
+ });
15
+ export const reserveEarning = mutation({
16
+ args: { sourceChargeId: v.string(), affiliateAccountId: v.string() },
17
+ returns: earningDoc,
18
+ handler: async (ctx, args) => {
19
+ const existing = await ctx.db
20
+ .query("affiliateEarnings")
21
+ .withIndex("by_charge_affiliate", (q) =>
22
+ q
23
+ .eq("sourceChargeId", args.sourceChargeId)
24
+ .eq("affiliateAccountId", args.affiliateAccountId),
25
+ )
26
+ .unique();
27
+ if (existing) return existing;
28
+ const allocation = await ctx.db
29
+ .query("settlementAllocations")
30
+ .withIndex("by_source_charge", (q) =>
31
+ q.eq("sourceChargeId", args.sourceChargeId),
32
+ )
33
+ .unique();
34
+ const leg = allocation?.transfers.find(
35
+ (t) =>
36
+ t.role === "affiliate" &&
37
+ t.destinationAccountId === args.affiliateAccountId,
38
+ );
39
+ if (!allocation || !leg)
40
+ throw new Error(
41
+ "Affiliate earning requires an immutable settlement allocation.",
42
+ );
43
+ const totals = await balance(
44
+ ctx,
45
+ args.affiliateAccountId,
46
+ allocation.currency,
47
+ );
48
+ if (totals?.activePayoutKey)
49
+ throw new Error("Affiliate payout is pending reconciliation.");
50
+ if (
51
+ (totals?.reservedRecovery ?? 0) > 0 ||
52
+ (totals?.reservedRestoration ?? 0) > 0
53
+ )
54
+ throw new Error(
55
+ "Affiliate provider recovery is pending reconciliation; retry settlement.",
56
+ );
57
+ const debts = await ctx.db
58
+ .query("affiliateObligations")
59
+ .withIndex("by_affiliate_currency_debt", (q) =>
60
+ q
61
+ .eq("affiliateAccountId", args.affiliateAccountId)
62
+ .eq("currency", allocation.currency)
63
+ .eq("debtStatus", "open"),
64
+ )
65
+ .take(1001);
66
+ if (debts.length > 1000)
67
+ throw new Error(
68
+ "Affiliate debt batch limit exceeded; reconcile before releasing funds.",
69
+ );
70
+ let remaining = leg.amount;
71
+ const offsets = [];
72
+ for (const debt of debts) {
73
+ if (
74
+ debt.sourceChargeId === args.sourceChargeId ||
75
+ debt.needsReconciliation
76
+ )
77
+ continue;
78
+ if (debt.activeOperationId)
79
+ throw new Error("Affiliate debt has an unresolved provider operation.");
80
+ const amount = Math.min(remaining, due(debt));
81
+ if (amount > 0) {
82
+ offsets.push({ debt, amount });
83
+ remaining -= amount;
84
+ }
85
+ if (!remaining) break;
86
+ }
87
+ const earningId = await ctx.db.insert("affiliateEarnings", {
88
+ ...args,
89
+ currency: allocation.currency,
90
+ originalCommission: leg.amount,
91
+ withheldAmount: leg.amount - remaining,
92
+ releaseAmount: remaining,
93
+ status: "reserved",
94
+ sourceInvoiceId: allocation.sourceInvoiceId,
95
+ paymentIntentId: allocation.paymentIntentId,
96
+ });
97
+ for (const { debt, amount } of offsets) {
98
+ await ctx.db.insert("affiliateOffsets", {
99
+ earningId,
100
+ obligationId: debt._id,
101
+ amount,
102
+ });
103
+ await updateObligation(ctx, debt, {
104
+ offsetAmount: (debt.offsetAmount ?? 0) + amount,
105
+ lastState: "offset",
106
+ });
107
+ }
108
+ const obligationId = await ctx.db.insert("affiliateObligations", {
109
+ sourceChargeId: allocation.sourceChargeId,
110
+ sourceInvoiceId: allocation.sourceInvoiceId,
111
+ paymentIntentId: allocation.paymentIntentId,
112
+ paymentId: allocation.paymentId,
113
+ storeAccountId: allocation.transfers.find((leg) => leg.role === "store")
114
+ ?.destinationAccountId,
115
+ affiliateAccountId: args.affiliateAccountId,
116
+ currency: allocation.currency,
117
+ chargeAmount: allocation.gross,
118
+ originalCommission: leg.amount,
119
+ releasedCommission: remaining,
120
+ earningId,
121
+ refundedPrincipal: 0,
122
+ disputedPrincipal: 0,
123
+ lostPrincipal: 0,
124
+ finalizedAmount: 0,
125
+ provisionalAmount: 0,
126
+ recoveredAmount: 0,
127
+ needsReconciliation: false,
128
+ });
129
+ await updateObligation(
130
+ ctx,
131
+ (await ctx.db.get("affiliateObligations", obligationId))!,
132
+ {},
133
+ );
134
+ await changeBalance(ctx, args.affiliateAccountId, allocation.currency, {
135
+ withheldAmount: leg.amount - remaining,
136
+ netPayable: remaining,
137
+ });
138
+ return (await ctx.db.get("affiliateEarnings", earningId))!;
139
+ },
140
+ });
141
+ export const confirmEarning = mutation({
142
+ args: {
143
+ earningId: v.id("affiliateEarnings"),
144
+ stripeTransferId: v.optional(v.string()),
145
+ },
146
+ returns: v.null(),
147
+ handler: async (ctx, args) => {
148
+ const earning = await ctx.db.get("affiliateEarnings", args.earningId);
149
+ if (!earning) throw new Error("Affiliate earning not found.");
150
+ if (earning.status === "paid") {
151
+ if (earning.stripeTransferId !== args.stripeTransferId)
152
+ throw new Error("Affiliate earning receipt cannot change.");
153
+ return null;
154
+ }
155
+ if (earning.releaseAmount > 0 !== !!args.stripeTransferId)
156
+ throw new Error("Affiliate release requires an exact transfer receipt.");
157
+ if (args.stripeTransferId) {
158
+ const transfer = await ctx.db
159
+ .query("transfers")
160
+ .withIndex("by_stripe_transfer_id", (q) =>
161
+ q.eq("stripeTransferId", args.stripeTransferId!),
162
+ )
163
+ .unique();
164
+ if (
165
+ !transfer ||
166
+ transfer.amount !== earning.releaseAmount ||
167
+ transfer.currency !== earning.currency ||
168
+ transfer.destinationAccountId !== earning.affiliateAccountId ||
169
+ transfer.sourceChargeId !== earning.sourceChargeId
170
+ )
171
+ throw new Error(
172
+ "Affiliate release receipt does not match its earning.",
173
+ );
174
+ }
175
+ const obligation = await ctx.db
176
+ .query("affiliateObligations")
177
+ .withIndex("by_charge", (q) =>
178
+ q.eq("sourceChargeId", earning.sourceChargeId),
179
+ )
180
+ .take(1001);
181
+ const own = obligation.find((row) => row.earningId === earning._id);
182
+ if (!own || obligation.length > 1000)
183
+ throw new Error("Affiliate earning obligation requires reconciliation.");
184
+ await ctx.db.patch("affiliateObligations", own._id, {
185
+ stripeTransferId: args.stripeTransferId,
186
+ });
187
+ await ctx.db.patch("affiliateEarnings", earning._id, {
188
+ status: "paid",
189
+ stripeTransferId: args.stripeTransferId,
190
+ });
191
+ await changeBalance(ctx, earning.affiliateAccountId, earning.currency, {
192
+ netPayable: -earning.releaseAmount,
193
+ });
194
+ return null;
195
+ },
196
+ });
@@ -0,0 +1,121 @@
1
+ import { v } from "convex/values";
2
+ import { mutation } from "../_generated/server.js";
3
+ import { balance } from "../affiliateRecovery/accounting.js";
4
+ import { payoutReleaseFields } from "../affiliateRecovery/schema.js";
5
+ const doc = v.object({
6
+ ...payoutReleaseFields,
7
+ _id: v.id("affiliatePayoutReleases"),
8
+ _creationTime: v.number(),
9
+ });
10
+ export const reservePayout = mutation({
11
+ args: {
12
+ affiliateAccountId: v.string(),
13
+ currency: v.string(),
14
+ amount: v.number(),
15
+ minimumAmount: v.number(),
16
+ operationKey: v.optional(v.string()),
17
+ now: v.number(),
18
+ },
19
+ returns: v.union(doc, v.null()),
20
+ handler: async (ctx, args) => {
21
+ const totals = await balance(ctx, args.affiliateAccountId, args.currency);
22
+ if (!totals) return null;
23
+ if (!args.operationKey?.trim())
24
+ throw new Error("Affiliate payouts require a stable idempotency key.");
25
+ const existing = await ctx.db
26
+ .query("affiliatePayoutReleases")
27
+ .withIndex("by_key", (q) => q.eq("operationKey", args.operationKey!))
28
+ .unique();
29
+ if (existing) {
30
+ if (
31
+ existing.amount !== args.amount ||
32
+ existing.affiliateAccountId !== args.affiliateAccountId ||
33
+ existing.currency !== args.currency ||
34
+ existing.minimumAmount !== args.minimumAmount
35
+ )
36
+ throw new Error("Affiliate payout operation identity cannot change.");
37
+ if (existing.state === "pending" || existing.state === "unknown") {
38
+ if (
39
+ totals.outstandingDebt > 0 ||
40
+ totals.reservedRecovery > 0 ||
41
+ totals.reservedRestoration > 0 ||
42
+ totals.activePayoutKey !== existing.operationKey
43
+ )
44
+ throw new Error(
45
+ "Affiliate recovery must be reconciled before payout release.",
46
+ );
47
+ }
48
+ return existing;
49
+ }
50
+ if (
51
+ ![args.amount, args.minimumAmount].every(
52
+ (n) => Number.isSafeInteger(n) && n > 0,
53
+ ) ||
54
+ args.amount < args.minimumAmount
55
+ )
56
+ throw new Error("Affiliate payout does not meet its currency minimum.");
57
+ if (
58
+ totals.outstandingDebt > 0 ||
59
+ totals.reservedRecovery > 0 ||
60
+ totals.reservedRestoration > 0 ||
61
+ totals.activePayoutKey
62
+ )
63
+ throw new Error(
64
+ "Affiliate recovery must be reconciled before payout release.",
65
+ );
66
+ const id = await ctx.db.insert("affiliatePayoutReleases", {
67
+ affiliateAccountId: args.affiliateAccountId,
68
+ currency: args.currency,
69
+ amount: args.amount,
70
+ minimumAmount: args.minimumAmount,
71
+ operationKey: args.operationKey,
72
+ createdAt: args.now,
73
+ state: "pending",
74
+ });
75
+ await ctx.db.patch("affiliateRecoveryBalances", totals._id, {
76
+ activePayoutKey: args.operationKey,
77
+ pendingPayoutAmount: args.amount,
78
+ });
79
+ return (await ctx.db.get("affiliatePayoutReleases", id))!;
80
+ },
81
+ });
82
+ export const finishPayout = mutation({
83
+ args: {
84
+ operationKey: v.string(),
85
+ state: v.union(
86
+ v.literal("unknown"),
87
+ v.literal("submitted"),
88
+ v.literal("failed"),
89
+ ),
90
+ stripePayoutId: v.optional(v.string()),
91
+ },
92
+ returns: v.null(),
93
+ handler: async (ctx, args) => {
94
+ const row = await ctx.db
95
+ .query("affiliatePayoutReleases")
96
+ .withIndex("by_key", (q) => q.eq("operationKey", args.operationKey))
97
+ .unique();
98
+ if (!row) throw new Error("Affiliate payout operation not found.");
99
+ if (row.state === "submitted") {
100
+ if (
101
+ args.state === "submitted" &&
102
+ args.stripePayoutId !== row.stripePayoutId
103
+ )
104
+ throw new Error("Affiliate payout receipt cannot change.");
105
+ return null;
106
+ }
107
+ if (args.state === "submitted" && !args.stripePayoutId)
108
+ throw new Error("Affiliate payout requires a provider receipt.");
109
+ const totals = (await balance(ctx, row.affiliateAccountId, row.currency))!;
110
+ if (args.state !== "unknown" && totals.activePayoutKey === row.operationKey)
111
+ await ctx.db.patch("affiliateRecoveryBalances", totals._id, {
112
+ activePayoutKey: undefined,
113
+ pendingPayoutAmount: 0,
114
+ });
115
+ await ctx.db.patch("affiliatePayoutReleases", row._id, {
116
+ state: args.state,
117
+ stripePayoutId: args.stripePayoutId,
118
+ });
119
+ return null;
120
+ },
121
+ });
@@ -0,0 +1,28 @@
1
+ import {
2
+ paginationOptsValidator,
3
+ paginationResultValidator,
4
+ } from "convex/server";
5
+ import { v } from "convex/values";
6
+ import { query } from "../_generated/server.js";
7
+ import { payoutReleaseFields } from "../affiliateRecovery/schema.js";
8
+ export const pendingPayouts = query({
9
+ args: {
10
+ state: v.union(v.literal("pending"), v.literal("unknown")),
11
+ paginationOpts: paginationOptsValidator,
12
+ },
13
+ returns: paginationResultValidator(
14
+ v.object({
15
+ ...payoutReleaseFields,
16
+ _id: v.id("affiliatePayoutReleases"),
17
+ _creationTime: v.number(),
18
+ }),
19
+ ),
20
+ handler: (ctx, args) =>
21
+ ctx.db
22
+ .query("affiliatePayoutReleases")
23
+ .withIndex("by_state", (q) => q.eq("state", args.state))
24
+ .paginate({
25
+ ...args.paginationOpts,
26
+ numItems: Math.min(10, args.paginationOpts.numItems),
27
+ }),
28
+ });
@@ -0,0 +1,42 @@
1
+ import type { MutationCtx } from "../_generated/server.js";
2
+ import { balance, changeBalance } from "../affiliateRecovery/accounting.js";
3
+
4
+ type Identity = { accountId: string; currency: string; operationKey: string };
5
+ const key = (identity: Identity) =>
6
+ `bs:recipient-payout:${identity.operationKey}`;
7
+
8
+ /** Share the recovery lock with combined seller/affiliate payout claims. */
9
+ export async function reserveRecoveryGuard(
10
+ ctx: MutationCtx,
11
+ identity: Identity,
12
+ amount: number,
13
+ ) {
14
+ await changeBalance(ctx, identity.accountId, identity.currency, {});
15
+ const totals = (await balance(ctx, identity.accountId, identity.currency))!;
16
+ if (
17
+ totals.outstandingDebt > 0 ||
18
+ totals.reservedRecovery > 0 ||
19
+ totals.reservedRestoration > 0 ||
20
+ (totals.activePayoutKey && totals.activePayoutKey !== key(identity))
21
+ )
22
+ throw new Error(
23
+ "Affiliate recovery must be reconciled before payout release.",
24
+ );
25
+ await ctx.db.patch("affiliateRecoveryBalances", totals._id, {
26
+ activePayoutKey: key(identity),
27
+ pendingPayoutAmount: amount,
28
+ });
29
+ }
30
+
31
+ /** Only a verified outcome or a never-attempted cancellation releases the lock. */
32
+ export async function releaseRecoveryGuard(
33
+ ctx: MutationCtx,
34
+ identity: Identity,
35
+ ) {
36
+ const totals = await balance(ctx, identity.accountId, identity.currency);
37
+ if (totals?.activePayoutKey === key(identity))
38
+ await ctx.db.patch("affiliateRecoveryBalances", totals._id, {
39
+ activePayoutKey: undefined,
40
+ pendingPayoutAmount: 0,
41
+ });
42
+ }
@@ -0,0 +1,111 @@
1
+ import type { Doc } from "../_generated/dataModel.js";
2
+ import type { MutationCtx } from "../_generated/server.js";
3
+ export type Obligation = Omit<
4
+ Doc<"affiliateObligations">,
5
+ "_id" | "_creationTime"
6
+ >;
7
+ export const target = (row: Obligation) =>
8
+ row.finalizedAmount + row.provisionalAmount;
9
+ export const netRecovery = (row: Obligation) =>
10
+ row.recoveredAmount + (row.offsetAmount ?? 0) - (row.restoredAmount ?? 0);
11
+ export const due = (row: Obligation) =>
12
+ Math.max(0, target(row) - netRecovery(row));
13
+ export const credit = (row: Obligation) =>
14
+ row.needsReconciliation ? 0 : Math.max(0, netRecovery(row) - target(row));
15
+ export async function balance(
16
+ ctx: MutationCtx,
17
+ account: string,
18
+ currency: string,
19
+ ) {
20
+ return ctx.db
21
+ .query("affiliateRecoveryBalances")
22
+ .withIndex("by_affiliate_currency", (q) =>
23
+ q.eq("affiliateAccountId", account).eq("currency", currency),
24
+ )
25
+ .unique();
26
+ }
27
+ export async function changeBalance(
28
+ ctx: MutationCtx,
29
+ account: string,
30
+ currency: string,
31
+ changes: Partial<
32
+ Record<
33
+ | "originalCommission"
34
+ | "recoveredAmount"
35
+ | "offsetAmount"
36
+ | "restoredAmount"
37
+ | "outstandingDebt"
38
+ | "reservedRecovery"
39
+ | "reservedRestoration"
40
+ | "restorationDue"
41
+ | "withheldAmount"
42
+ | "netPayable",
43
+ number
44
+ >
45
+ >,
46
+ ) {
47
+ const existing = await balance(ctx, account, currency);
48
+ const base = existing ?? {
49
+ affiliateAccountId: account,
50
+ currency,
51
+ originalCommission: 0,
52
+ recoveredAmount: 0,
53
+ offsetAmount: 0,
54
+ restoredAmount: 0,
55
+ outstandingDebt: 0,
56
+ reservedRecovery: 0,
57
+ reservedRestoration: 0,
58
+ restorationDue: 0,
59
+ withheldAmount: 0,
60
+ netPayable: 0,
61
+ };
62
+ const patch: Record<string, number> = {};
63
+ for (const key of Object.keys(changes) as (keyof typeof changes)[]) {
64
+ const next = base[key] + (changes[key] ?? 0);
65
+ if (!Number.isSafeInteger(next) || next < 0)
66
+ throw new Error(`Invalid affiliate balance ${key}.`);
67
+ patch[key] = next;
68
+ }
69
+ if (existing)
70
+ await ctx.db.patch("affiliateRecoveryBalances", existing._id, patch);
71
+ else await ctx.db.insert("affiliateRecoveryBalances", { ...base, ...patch });
72
+ }
73
+ export async function updateObligation(
74
+ ctx: MutationCtx,
75
+ previous: Doc<"affiliateObligations">,
76
+ patch: Partial<Obligation>,
77
+ ) {
78
+ if (!previous.accounted) {
79
+ await changeBalance(ctx, previous.affiliateAccountId, previous.currency, {
80
+ originalCommission: previous.originalCommission,
81
+ recoveredAmount: previous.recoveredAmount,
82
+ offsetAmount: previous.offsetAmount ?? 0,
83
+ restoredAmount: previous.restoredAmount ?? 0,
84
+ outstandingDebt: due(previous),
85
+ restorationDue: credit(previous),
86
+ reservedRecovery: previous.reservedRecovery ?? 0,
87
+ reservedRestoration: previous.reservedRestoration ?? 0,
88
+ });
89
+ }
90
+ const next = { ...previous, ...patch };
91
+ const debtAmount = due(next),
92
+ creditAmount = credit(next);
93
+ await changeBalance(ctx, next.affiliateAccountId, next.currency, {
94
+ outstandingDebt: debtAmount - (previous.debtAmount ?? due(previous)),
95
+ restorationDue: creditAmount - (previous.creditAmount ?? credit(previous)),
96
+ recoveredAmount: next.recoveredAmount - previous.recoveredAmount,
97
+ offsetAmount: (next.offsetAmount ?? 0) - (previous.offsetAmount ?? 0),
98
+ restoredAmount: (next.restoredAmount ?? 0) - (previous.restoredAmount ?? 0),
99
+ reservedRecovery:
100
+ (next.reservedRecovery ?? 0) - (previous.reservedRecovery ?? 0),
101
+ reservedRestoration:
102
+ (next.reservedRestoration ?? 0) - (previous.reservedRestoration ?? 0),
103
+ });
104
+ await ctx.db.patch("affiliateObligations", previous._id, {
105
+ ...patch,
106
+ accounted: true,
107
+ debtAmount,
108
+ creditAmount,
109
+ debtStatus: debtAmount > 0 ? "open" : "settled",
110
+ });
111
+ }