@fantastic.dev/better-stripe 0.23.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/CHANGELOG.md +26 -2
  2. package/README.md +40 -5
  3. package/dist/client/affiliateRecovery/disputes.d.ts +18 -0
  4. package/dist/client/affiliateRecovery/disputes.d.ts.map +1 -0
  5. package/dist/client/affiliateRecovery/disputes.js +33 -0
  6. package/dist/client/affiliateRecovery/disputes.js.map +1 -0
  7. package/dist/client/affiliateRecovery/engine.d.ts +29 -0
  8. package/dist/client/affiliateRecovery/engine.d.ts.map +1 -0
  9. package/dist/client/affiliateRecovery/engine.js +116 -0
  10. package/dist/client/affiliateRecovery/engine.js.map +1 -0
  11. package/dist/client/affiliateRecovery/payoutGate.d.ts +24 -0
  12. package/dist/client/affiliateRecovery/payoutGate.d.ts.map +1 -0
  13. package/dist/client/affiliateRecovery/payoutGate.js +30 -0
  14. package/dist/client/affiliateRecovery/payoutGate.js.map +1 -0
  15. package/dist/client/affiliateRecovery/provider.d.ts +14 -0
  16. package/dist/client/affiliateRecovery/provider.d.ts.map +1 -0
  17. package/dist/client/affiliateRecovery/provider.js +96 -0
  18. package/dist/client/affiliateRecovery/provider.js.map +1 -0
  19. package/dist/client/affiliateRecovery/review.d.ts +28 -0
  20. package/dist/client/affiliateRecovery/review.d.ts.map +1 -0
  21. package/dist/client/affiliateRecovery/review.js +86 -0
  22. package/dist/client/affiliateRecovery/review.js.map +1 -0
  23. package/dist/client/affiliateRecovery/validators.d.ts +402 -0
  24. package/dist/client/affiliateRecovery/validators.d.ts.map +1 -0
  25. package/dist/client/affiliateRecovery/validators.js +27 -0
  26. package/dist/client/affiliateRecovery/validators.js.map +1 -0
  27. package/dist/client/affiliateRecovery.d.ts +131 -0
  28. package/dist/client/affiliateRecovery.d.ts.map +1 -0
  29. package/dist/client/affiliateRecovery.js +69 -0
  30. package/dist/client/affiliateRecovery.js.map +1 -0
  31. package/dist/client/connect/commissionReceipts.d.ts +4 -0
  32. package/dist/client/connect/commissionReceipts.d.ts.map +1 -0
  33. package/dist/client/connect/commissionReceipts.js +17 -0
  34. package/dist/client/connect/commissionReceipts.js.map +1 -0
  35. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +536 -2
  36. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
  37. package/dist/client/connect/payoutSync.d.ts +32 -0
  38. package/dist/client/connect/payoutSync.d.ts.map +1 -0
  39. package/dist/client/connect/payoutSync.js +48 -0
  40. package/dist/client/connect/payoutSync.js.map +1 -0
  41. package/dist/client/connect/payoutSyncTestHelpers.d.ts +1509 -0
  42. package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -0
  43. package/dist/client/connect/payoutSyncTestHelpers.js +62 -0
  44. package/dist/client/connect/payoutSyncTestHelpers.js.map +1 -0
  45. package/dist/client/connect/payoutSyncValidation.d.ts +16 -0
  46. package/dist/client/connect/payoutSyncValidation.d.ts.map +1 -0
  47. package/dist/client/connect/payoutSyncValidation.js +54 -0
  48. package/dist/client/connect/payoutSyncValidation.js.map +1 -0
  49. package/dist/client/connect/payouts.d.ts +3 -1
  50. package/dist/client/connect/payouts.d.ts.map +1 -1
  51. package/dist/client/connect/payouts.js +45 -12
  52. package/dist/client/connect/payouts.js.map +1 -1
  53. package/dist/client/connect/refundPrincipal.d.ts +4 -0
  54. package/dist/client/connect/refundPrincipal.d.ts.map +1 -0
  55. package/dist/client/connect/refundPrincipal.js +21 -0
  56. package/dist/client/connect/refundPrincipal.js.map +1 -0
  57. package/dist/client/connect/reversalExecution.d.ts +47 -0
  58. package/dist/client/connect/reversalExecution.d.ts.map +1 -0
  59. package/dist/client/connect/reversalExecution.js +84 -0
  60. package/dist/client/connect/reversalExecution.js.map +1 -0
  61. package/dist/client/connect/transfers.d.ts +8 -4
  62. package/dist/client/connect/transfers.d.ts.map +1 -1
  63. package/dist/client/connect/transfers.js +52 -57
  64. package/dist/client/connect/transfers.js.map +1 -1
  65. package/dist/client/index.d.ts +29 -9
  66. package/dist/client/index.d.ts.map +1 -1
  67. package/dist/client/index.js +9 -6
  68. package/dist/client/index.js.map +1 -1
  69. package/dist/client/readContracts.d.ts +8 -0
  70. package/dist/client/readContracts.d.ts.map +1 -1
  71. package/dist/client/readContracts.js +7 -0
  72. package/dist/client/readContracts.js.map +1 -1
  73. package/dist/client/settlement/execution.d.ts.map +1 -1
  74. package/dist/client/settlement/execution.js +30 -3
  75. package/dist/client/settlement/execution.js.map +1 -1
  76. package/dist/client/settlement/reconciliation.d.ts.map +1 -1
  77. package/dist/client/settlement/reconciliation.js +16 -2
  78. package/dist/client/settlement/reconciliation.js.map +1 -1
  79. package/dist/client/webhooks/disputeRecovery.d.ts +4 -0
  80. package/dist/client/webhooks/disputeRecovery.d.ts.map +1 -0
  81. package/dist/client/webhooks/disputeRecovery.js +40 -0
  82. package/dist/client/webhooks/disputeRecovery.js.map +1 -0
  83. package/dist/client/webhooks/processors.d.ts.map +1 -1
  84. package/dist/client/webhooks/processors.js +14 -29
  85. package/dist/client/webhooks/processors.js.map +1 -1
  86. package/dist/component/_generated/component.d.ts +560 -0
  87. package/dist/component/_generated/component.d.ts.map +1 -1
  88. package/dist/component/affiliateEarnings/mutations.d.ts +22 -0
  89. package/dist/component/affiliateEarnings/mutations.d.ts.map +1 -0
  90. package/dist/component/affiliateEarnings/mutations.js +160 -0
  91. package/dist/component/affiliateEarnings/mutations.js.map +1 -0
  92. package/dist/component/affiliatePayouts/mutations.d.ts +25 -0
  93. package/dist/component/affiliatePayouts/mutations.d.ts.map +1 -0
  94. package/dist/component/affiliatePayouts/mutations.js +104 -0
  95. package/dist/component/affiliatePayouts/mutations.js.map +1 -0
  96. package/dist/component/affiliatePayouts/queries.d.ts +23 -0
  97. package/dist/component/affiliatePayouts/queries.d.ts.map +1 -0
  98. package/dist/component/affiliatePayouts/queries.js +23 -0
  99. package/dist/component/affiliatePayouts/queries.js.map +1 -0
  100. package/dist/component/affiliatePayouts/recipientGuard.d.ts +12 -0
  101. package/dist/component/affiliatePayouts/recipientGuard.d.ts.map +1 -0
  102. package/dist/component/affiliatePayouts/recipientGuard.js +26 -0
  103. package/dist/component/affiliatePayouts/recipientGuard.js.map +1 -0
  104. package/dist/component/affiliateRecovery/accounting.d.ts +28 -0
  105. package/dist/component/affiliateRecovery/accounting.d.ts.map +1 -0
  106. package/dist/component/affiliateRecovery/accounting.js +71 -0
  107. package/dist/component/affiliateRecovery/accounting.js.map +1 -0
  108. package/dist/component/affiliateRecovery/mutations.d.ts +65 -0
  109. package/dist/component/affiliateRecovery/mutations.d.ts.map +1 -0
  110. package/dist/component/affiliateRecovery/mutations.js +250 -0
  111. package/dist/component/affiliateRecovery/mutations.js.map +1 -0
  112. package/dist/component/affiliateRecovery/queries.d.ts +305 -0
  113. package/dist/component/affiliateRecovery/queries.d.ts.map +1 -0
  114. package/dist/component/affiliateRecovery/queries.js +113 -0
  115. package/dist/component/affiliateRecovery/queries.js.map +1 -0
  116. package/dist/component/affiliateRecovery/schema.d.ts +171 -0
  117. package/dist/component/affiliateRecovery/schema.d.ts.map +1 -0
  118. package/dist/component/affiliateRecovery/schema.js +71 -0
  119. package/dist/component/affiliateRecovery/schema.js.map +1 -0
  120. package/dist/component/connect/mutations.d.ts +36 -23
  121. package/dist/component/connect/mutations.d.ts.map +1 -1
  122. package/dist/component/connect/mutations.js +11 -151
  123. package/dist/component/connect/mutations.js.map +1 -1
  124. package/dist/component/connect/payoutWrites.d.ts +21 -0
  125. package/dist/component/connect/payoutWrites.d.ts.map +1 -0
  126. package/dist/component/connect/payoutWrites.js +19 -0
  127. package/dist/component/connect/payoutWrites.js.map +1 -0
  128. package/dist/component/connect/queries.d.ts +10 -0
  129. package/dist/component/connect/queries.d.ts.map +1 -1
  130. package/dist/component/connect/queries.js +3 -40
  131. package/dist/component/connect/queries.js.map +1 -1
  132. package/dist/component/connect/reversalClaims.d.ts +169 -0
  133. package/dist/component/connect/reversalClaims.d.ts.map +1 -0
  134. package/dist/component/connect/reversalClaims.js +176 -0
  135. package/dist/component/connect/reversalClaims.js.map +1 -0
  136. package/dist/component/connect/reversalDiagnostics.d.ts +59 -0
  137. package/dist/component/connect/reversalDiagnostics.d.ts.map +1 -0
  138. package/dist/component/connect/reversalDiagnostics.js +50 -0
  139. package/dist/component/connect/reversalDiagnostics.js.map +1 -0
  140. package/dist/component/connect/reversalReceipts.d.ts +27 -0
  141. package/dist/component/connect/reversalReceipts.d.ts.map +1 -0
  142. package/dist/component/connect/reversalReceipts.js +85 -0
  143. package/dist/component/connect/reversalReceipts.js.map +1 -0
  144. package/dist/component/connect/schema.d.ts +31 -1
  145. package/dist/component/connect/schema.d.ts.map +1 -1
  146. package/dist/component/connect/schema.js +3 -1
  147. package/dist/component/connect/schema.js.map +1 -1
  148. package/dist/component/connect/validators.d.ts +49 -1
  149. package/dist/component/connect/validators.d.ts.map +1 -1
  150. package/dist/component/connect/validators.js +2 -0
  151. package/dist/component/connect/validators.js.map +1 -1
  152. package/dist/component/earnings/triggers.d.ts +454 -2
  153. package/dist/component/earnings/triggers.d.ts.map +1 -1
  154. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  155. package/dist/component/payoutRelease/mutations.js +6 -0
  156. package/dist/component/payoutRelease/mutations.js.map +1 -1
  157. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  158. package/dist/component/payoutRelease/queries.js +3 -1
  159. package/dist/component/payoutRelease/queries.js.map +1 -1
  160. package/dist/component/payoutSync/helpers.d.ts +44 -0
  161. package/dist/component/payoutSync/helpers.d.ts.map +1 -0
  162. package/dist/component/payoutSync/helpers.js +60 -0
  163. package/dist/component/payoutSync/helpers.js.map +1 -0
  164. package/dist/component/payoutSync/mutations.d.ts +51 -0
  165. package/dist/component/payoutSync/mutations.d.ts.map +1 -0
  166. package/dist/component/payoutSync/mutations.js +88 -0
  167. package/dist/component/payoutSync/mutations.js.map +1 -0
  168. package/dist/component/payoutSync/queries.d.ts +16 -0
  169. package/dist/component/payoutSync/queries.d.ts.map +1 -0
  170. package/dist/component/payoutSync/queries.js +11 -0
  171. package/dist/component/payoutSync/queries.js.map +1 -0
  172. package/dist/component/payoutSync/schema.d.ts +46 -0
  173. package/dist/component/payoutSync/schema.d.ts.map +1 -0
  174. package/dist/component/payoutSync/schema.js +22 -0
  175. package/dist/component/payoutSync/schema.js.map +1 -0
  176. package/dist/component/payoutSync/validators.d.ts +137 -0
  177. package/dist/component/payoutSync/validators.d.ts.map +1 -0
  178. package/dist/component/payoutSync/validators.js +19 -0
  179. package/dist/component/payoutSync/validators.js.map +1 -0
  180. package/dist/component/schema.d.ts +268 -1
  181. package/dist/component/schema.js +11 -0
  182. package/dist/component/schema.js.map +1 -1
  183. package/dist/component/settlement/clawbackSchema.d.ts +161 -0
  184. package/dist/component/settlement/clawbackSchema.d.ts.map +1 -0
  185. package/dist/component/settlement/clawbackSchema.js +73 -0
  186. package/dist/component/settlement/clawbackSchema.js.map +1 -0
  187. package/dist/component/settlement/clawbacks.d.ts +55 -0
  188. package/dist/component/settlement/clawbacks.d.ts.map +1 -0
  189. package/dist/component/settlement/clawbacks.js +146 -0
  190. package/dist/component/settlement/clawbacks.js.map +1 -0
  191. package/dist/component/settlement/mutations.d.ts +16 -0
  192. package/dist/component/settlement/mutations.d.ts.map +1 -1
  193. package/dist/component/settlement/mutations.js +48 -0
  194. package/dist/component/settlement/mutations.js.map +1 -1
  195. package/dist/component/settlement/queries.d.ts +53 -0
  196. package/dist/component/settlement/queries.d.ts.map +1 -1
  197. package/dist/component/settlement/queries.js +26 -0
  198. package/dist/component/settlement/queries.js.map +1 -1
  199. package/dist/react/workflows/AffiliateRecovery.d.ts +46 -0
  200. package/dist/react/workflows/AffiliateRecovery.d.ts.map +1 -0
  201. package/dist/react/workflows/AffiliateRecovery.js +84 -0
  202. package/dist/react/workflows/AffiliateRecovery.js.map +1 -0
  203. package/dist/react/workflows/index.d.ts +2 -0
  204. package/dist/react/workflows/index.d.ts.map +1 -1
  205. package/dist/react/workflows/index.js +1 -0
  206. package/dist/react/workflows/index.js.map +1 -1
  207. package/dist/testing/affiliate-recovery.d.ts +10 -0
  208. package/dist/testing/affiliate-recovery.d.ts.map +1 -0
  209. package/dist/testing/affiliate-recovery.js +126 -0
  210. package/dist/testing/affiliate-recovery.js.map +1 -0
  211. package/dist/testing/index.d.ts +2 -0
  212. package/dist/testing/index.d.ts.map +1 -1
  213. package/dist/testing/index.js +1 -0
  214. package/dist/testing/index.js.map +1 -1
  215. package/docs/affiliate-clawbacks.md +306 -0
  216. package/docs/read-contracts.md +40 -5
  217. package/docs/settlement.md +17 -0
  218. package/package.json +2 -1
  219. package/src/client/affiliateRecovery/disputes.ts +62 -0
  220. package/src/client/affiliateRecovery/engine.ts +196 -0
  221. package/src/client/affiliateRecovery/payoutGate.ts +65 -0
  222. package/src/client/affiliateRecovery/provider.ts +127 -0
  223. package/src/client/affiliateRecovery/review.ts +117 -0
  224. package/src/client/affiliateRecovery/validators.ts +38 -0
  225. package/src/client/affiliateRecovery.ts +173 -0
  226. package/src/client/connect/commissionReceipts.test.ts +157 -0
  227. package/src/client/connect/commissionReceipts.ts +23 -0
  228. package/src/client/connect/payoutSync.test.ts +313 -0
  229. package/src/client/connect/payoutSync.ts +93 -0
  230. package/src/client/connect/payoutSyncTestHelpers.ts +82 -0
  231. package/src/client/connect/payoutSyncValidation.ts +74 -0
  232. package/src/client/connect/payouts.ts +75 -15
  233. package/src/client/connect/refundPrincipal.test.ts +46 -0
  234. package/src/client/connect/refundPrincipal.ts +26 -0
  235. package/src/client/connect/reversalExecution.ts +130 -0
  236. package/src/client/connect/transfers.test.ts +3 -3
  237. package/src/client/connect/transfers.ts +93 -92
  238. package/src/client/index.ts +20 -18
  239. package/src/client/readContracts.ts +12 -0
  240. package/src/client/settlement/execution.test.ts +5 -0
  241. package/src/client/settlement/execution.ts +50 -3
  242. package/src/client/settlement/reconciliation.ts +20 -2
  243. package/src/client/webhooks/disputeRecovery.ts +74 -0
  244. package/src/client/webhooks/processors.test.ts +36 -53
  245. package/src/client/webhooks/processors.ts +18 -31
  246. package/src/component/_generated/api.ts +34 -0
  247. package/src/component/_generated/component.ts +696 -1
  248. package/src/component/affiliate-clawbacks.test.ts +370 -0
  249. package/src/component/affiliate-component-pagination.test.ts +83 -0
  250. package/src/component/affiliate-direct-payout.test.ts +160 -0
  251. package/src/component/affiliate-recovery-lifecycle.test.ts +587 -0
  252. package/src/component/affiliateEarnings/mutations.ts +196 -0
  253. package/src/component/affiliatePayouts/mutations.ts +121 -0
  254. package/src/component/affiliatePayouts/queries.ts +28 -0
  255. package/src/component/affiliatePayouts/recipientGuard.ts +42 -0
  256. package/src/component/affiliateRecovery/accounting.ts +111 -0
  257. package/src/component/affiliateRecovery/mutations.ts +275 -0
  258. package/src/component/affiliateRecovery/queries.ts +138 -0
  259. package/src/component/affiliateRecovery/schema.ts +86 -0
  260. package/src/component/connect/mutations.ts +13 -190
  261. package/src/component/connect/payoutWrites.ts +25 -0
  262. package/src/component/connect/queries.ts +4 -47
  263. package/src/component/connect/reversalClaims.ts +232 -0
  264. package/src/component/connect/reversalDiagnostics.ts +61 -0
  265. package/src/component/connect/reversalReceipts.ts +147 -0
  266. package/src/component/connect/schema.ts +3 -3
  267. package/src/component/connect/validators.ts +2 -0
  268. package/src/component/payout-recovery-integration.test.ts +172 -0
  269. package/src/component/payout-sync.test.ts +117 -0
  270. package/src/component/payoutRelease/mutations.ts +9 -0
  271. package/src/component/payoutRelease/queries.ts +3 -1
  272. package/src/component/payoutSync/helpers.ts +86 -0
  273. package/src/component/payoutSync/mutations.ts +102 -0
  274. package/src/component/payoutSync/queries.ts +10 -0
  275. package/src/component/payoutSync/schema.ts +25 -0
  276. package/src/component/payoutSync/validators.ts +21 -0
  277. package/src/component/schema.ts +20 -0
  278. package/src/component/settlement/clawbackSchema.ts +78 -0
  279. package/src/component/settlement/clawbacks.ts +233 -0
  280. package/src/component/settlement/mutations.ts +54 -0
  281. package/src/component/settlement/queries.ts +35 -0
  282. package/src/component/summary-arithmetic.test.ts +9 -4
  283. package/src/react/workflows/AffiliateRecovery.test.tsx +106 -0
  284. package/src/react/workflows/AffiliateRecovery.tsx +273 -0
  285. package/src/react/workflows/index.ts +9 -0
  286. package/src/testing/affiliate-recovery.ts +137 -0
  287. package/src/testing/index.ts +2 -0
@@ -0,0 +1,370 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { describe, expect, it } from "vitest";
4
+ import { api } from "./_generated/api.js";
5
+ import schema from "./schema.js";
6
+ import { proportionalCommission } from "./settlement/clawbacks.js";
7
+
8
+ const modules = import.meta.glob("./**/*.*s");
9
+ const setup = async (currency = "usd", commission = 1001) => {
10
+ const t = convexTest(schema, modules);
11
+ await t.mutation(api.settlement.mutations.reserveSettlementAllocation, {
12
+ sourceChargeId: "ch_1",
13
+ sourceInvoiceId: "in_1",
14
+ paymentIntentId: "pi_1",
15
+ gross: 10000,
16
+ currency,
17
+ processingFee: 0,
18
+ platformNet: 10000 - commission,
19
+ transfers: [
20
+ {
21
+ destinationAccountId: "acct_aff",
22
+ amount: commission,
23
+ role: "affiliate",
24
+ },
25
+ ],
26
+ });
27
+ await t.mutation(api.connect.mutations.upsertTransfer, {
28
+ stripeTransferId: "tr_1",
29
+ sourceChargeId: "ch_1",
30
+ destinationAccountId: "acct_aff",
31
+ currency,
32
+ amount: commission,
33
+ role: "affiliate",
34
+ status: "paid",
35
+ });
36
+ return t;
37
+ };
38
+ type Test = Awaited<ReturnType<typeof setup>>;
39
+ const claim = (
40
+ t: Test,
41
+ id: string,
42
+ principal: number,
43
+ kind: "refund" | "dispute" = "refund",
44
+ finalized = kind === "refund",
45
+ ) =>
46
+ t.mutation(api.connect.mutations.claimReversalSlices, {
47
+ sourceChargeId: "ch_1",
48
+ operationId: id,
49
+ mode: { kind: "fraction", chargeAmount: 10000, amountRefunded: principal },
50
+ commissionExposure: { kind, chargeAmount: 10000, principal, finalized },
51
+ });
52
+ const rows = (t: Test) =>
53
+ t.query(api.settlement.queries.listAffiliateObligations, {
54
+ affiliateAccountId: "acct_aff",
55
+ currency: "usd",
56
+ paginationOpts: { numItems: 10, cursor: null },
57
+ });
58
+
59
+ describe("original affiliate commission obligations", () => {
60
+ it("converges repeated, reordered, duplicate partial refunds on the original snapshot", async () => {
61
+ const t = await setup();
62
+ expect((await claim(t, "re_1", 2500)).slices).toEqual([
63
+ {
64
+ stripeTransferId: "tr_1",
65
+ from: 0,
66
+ to: 250,
67
+ confirmed: 0,
68
+ commissionTracked: true,
69
+ },
70
+ ]);
71
+ expect((await claim(t, "re_2", 7500)).slices[0]).toMatchObject({
72
+ from: 250,
73
+ to: 751,
74
+ });
75
+ expect((await claim(t, "re_old", 5000)).slices).toEqual([]);
76
+ expect((await claim(t, "re_1", 10000)).slices[0]).toMatchObject({
77
+ from: 0,
78
+ to: 250,
79
+ });
80
+ expect((await rows(t)).page[0]).toMatchObject({
81
+ originalCommission: 1001,
82
+ finalizedAmount: 751,
83
+ recoveredAmount: 0,
84
+ sourceInvoiceId: "in_1",
85
+ paymentIntentId: "pi_1",
86
+ provisionalAmount: 0,
87
+ });
88
+ expect((await claim(t, "re_full", 10000)).slices[0]).toMatchObject({
89
+ from: 751,
90
+ to: 1001,
91
+ });
92
+ });
93
+
94
+ it.each([false, true])(
95
+ "deduplicates refund/dispute overlap regardless of arrival order (refund first: %s)",
96
+ async (refundFirst) => {
97
+ const t = await setup();
98
+ if (refundFirst) {
99
+ await claim(t, "re_1", 5000);
100
+ await claim(t, "dp_1", 10000, "dispute");
101
+ } else {
102
+ await claim(t, "dp_1", 10000, "dispute");
103
+ await claim(t, "re_1", 5000);
104
+ }
105
+ expect((await rows(t)).page[0]).toMatchObject({
106
+ finalizedAmount: 501,
107
+ provisionalAmount: 500,
108
+ recoveredAmount: 0,
109
+ });
110
+ expect((await claim(t, "dp_1", 10000, "dispute", true)).replay).toBe(
111
+ true,
112
+ );
113
+ expect((await rows(t)).page[0]).toMatchObject({
114
+ finalizedAmount: 1001,
115
+ provisionalAmount: 0,
116
+ });
117
+ const ops = await t.run((ctx) =>
118
+ ctx.db.query("transferReversalOps").collect(),
119
+ );
120
+ expect(
121
+ ops.flatMap((o) => o.slices).reduce((n, s) => n + s.to - s.from, 0),
122
+ ).toBe(1001);
123
+ },
124
+ );
125
+
126
+ it("does not create an obligation for pending refunds or invalid money", async () => {
127
+ const t = await setup();
128
+ await expect(claim(t, "re_pending", 5000, "refund", false)).rejects.toThrow(
129
+ "Pending refunds",
130
+ );
131
+ await expect(claim(t, "re_bad", 1.5)).rejects.toThrow("integer units");
132
+ expect((await rows(t)).page).toEqual([]);
133
+ });
134
+
135
+ it("keeps simultaneous claims disjoint and reservations separate from recovery", async () => {
136
+ const t = await setup();
137
+ const results = await Promise.all([
138
+ claim(t, "re_1", 5000),
139
+ claim(t, "dp_1", 5000, "dispute"),
140
+ ]);
141
+ expect(
142
+ results.flatMap((r) => r.slices).reduce((n, s) => n + s.to - s.from, 0),
143
+ ).toBe(501);
144
+ expect((await rows(t)).page[0].recoveredAmount).toBe(0);
145
+ });
146
+
147
+ it("records exact receipts atomically, rejects mismatches, and survives duplicate confirmation", async () => {
148
+ const t = await setup();
149
+ await claim(t, "re_1", 5000);
150
+ const confirmation = {
151
+ stripeTransferId: "tr_1",
152
+ reversedAmount: 501,
153
+ receipt: { stripeReversalId: "trr_1", operationId: "re_1", amount: 501 },
154
+ };
155
+ await expect(
156
+ t.mutation(api.connect.mutations.recordTransferReversal, {
157
+ ...confirmation,
158
+ receipt: { ...confirmation.receipt, amount: 500 },
159
+ }),
160
+ ).rejects.toThrow("does not match");
161
+ expect((await rows(t)).page[0].recoveredAmount).toBe(0);
162
+ await t.mutation(
163
+ api.connect.mutations.recordTransferReversal,
164
+ confirmation,
165
+ );
166
+ await t.mutation(
167
+ api.connect.mutations.recordTransferReversal,
168
+ confirmation,
169
+ );
170
+ await t.mutation(api.connect.mutations.recordTransferReversal, {
171
+ stripeTransferId: "tr_1",
172
+ reversedAmount: 250,
173
+ });
174
+ expect((await rows(t)).page[0]).toMatchObject({
175
+ recoveredAmount: 501,
176
+ finalizedAmount: 501,
177
+ needsReconciliation: false,
178
+ });
179
+ expect(
180
+ await t.run((ctx) => ctx.db.query("affiliateReversalReceipts").collect()),
181
+ ).toHaveLength(1);
182
+ await expect(
183
+ t.mutation(api.connect.mutations.recordTransferReversal, {
184
+ stripeTransferId: "tr_1",
185
+ reversedAmount: 1002,
186
+ }),
187
+ ).rejects.toThrow("Invalid confirmed");
188
+ });
189
+
190
+ it("isolates recurring invoice payments and preserves fixed commission and currency units", async () => {
191
+ const t = await setup("jpy", 7);
192
+ await t.mutation(api.connect.mutations.upsertTransfer, {
193
+ stripeTransferId: "tr_2",
194
+ sourceChargeId: "ch_2",
195
+ sourceInvoiceId: "in_2",
196
+ destinationAccountId: "acct_aff",
197
+ currency: "jpy",
198
+ amount: 7,
199
+ role: "affiliate",
200
+ status: "paid",
201
+ });
202
+ await claim(t, "re_1", 5000);
203
+ await t.mutation(api.connect.mutations.claimReversalSlices, {
204
+ sourceChargeId: "ch_2",
205
+ operationId: "re_2",
206
+ mode: { kind: "full" },
207
+ commissionExposure: {
208
+ kind: "refund",
209
+ chargeAmount: 10000,
210
+ principal: 10000,
211
+ finalized: true,
212
+ },
213
+ });
214
+ const result = await t.query(
215
+ api.settlement.queries.listAffiliateObligations,
216
+ {
217
+ affiliateAccountId: "acct_aff",
218
+ currency: "jpy",
219
+ paginationOpts: { numItems: 10, cursor: null },
220
+ },
221
+ );
222
+ expect(
223
+ result.page.map((r) => [
224
+ r.sourceInvoiceId,
225
+ r.originalCommission,
226
+ r.finalizedAmount,
227
+ ]),
228
+ ).toEqual([
229
+ ["in_1", 7, 4],
230
+ ["in_2", 7, 7],
231
+ ]);
232
+ expect((await rows(t)).page).toEqual([]);
233
+ });
234
+
235
+ it("runs a resumable, replay-safe legacy migration and flags unknown provenance", async () => {
236
+ const t = await setup();
237
+ await t.mutation(api.connect.mutations.recordTransferReversal, {
238
+ stripeTransferId: "tr_1",
239
+ reversedAmount: 100,
240
+ });
241
+ const run = () =>
242
+ t.mutation(api.settlement.mutations.migrateAffiliateObligations, {
243
+ paginationOpts: { numItems: 10, cursor: null },
244
+ });
245
+ expect(await run()).toMatchObject({
246
+ migrated: 1,
247
+ skipped: 0,
248
+ isDone: true,
249
+ });
250
+ await run();
251
+ expect((await rows(t)).page).toHaveLength(1);
252
+ expect((await rows(t)).page[0]).toMatchObject({
253
+ originalCommission: 1001,
254
+ recoveredAmount: 100,
255
+ finalizedAmount: 0,
256
+ needsReconciliation: true,
257
+ });
258
+ expect(
259
+ await t.run((ctx) => ctx.db.query("affiliateReversalReceipts").collect()),
260
+ ).toEqual([]);
261
+ });
262
+
263
+ it("rejects reuse of operation/source and immutable commission identity", async () => {
264
+ const t = await setup();
265
+ await claim(t, "re_1", 5000);
266
+ await expect(
267
+ t.mutation(api.connect.mutations.claimReversalSlices, {
268
+ operationId: "re_1",
269
+ sourceChargeId: "ch_other",
270
+ mode: { kind: "full" },
271
+ }),
272
+ ).rejects.toThrow("source identity");
273
+ await t.run(async (ctx) => {
274
+ const transfer = (await ctx.db.query("transfers").first())!;
275
+ await ctx.db.patch("transfers", transfer._id, { amount: 2000 });
276
+ });
277
+ await expect(claim(t, "re_2", 10000)).rejects.toThrow(
278
+ "identity cannot change",
279
+ );
280
+ });
281
+ });
282
+
283
+ it("rounds proportional snapshots exactly even above floating-point multiplication precision", () => {
284
+ expect(proportionalCommission(7, 5000, 10000)).toBe(4);
285
+ expect(proportionalCommission(1001, 2500, 10000)).toBe(250);
286
+ expect(
287
+ proportionalCommission(
288
+ Number.MAX_SAFE_INTEGER,
289
+ Number.MAX_SAFE_INTEGER - 1,
290
+ Number.MAX_SAFE_INTEGER,
291
+ ),
292
+ ).toBe(Number.MAX_SAFE_INTEGER - 1);
293
+ expect(proportionalCommission(7, 20000, 10000)).toBe(7);
294
+ expect(() => proportionalCommission(7, 1, 0)).toThrow();
295
+ });
296
+
297
+ it("freezes webhook commission identity and keeps provider upserts monotonic", async () => {
298
+ const t = await setup();
299
+ await claim(t, "re_1", 5000);
300
+ const original = {
301
+ stripeTransferId: "tr_1",
302
+ sourceChargeId: "ch_1",
303
+ destinationAccountId: "acct_aff",
304
+ currency: "usd",
305
+ amount: 1001,
306
+ role: "affiliate" as const,
307
+ status: "paid" as const,
308
+ };
309
+ await expect(
310
+ t.mutation(api.connect.mutations.upsertTransfer, {
311
+ ...original,
312
+ amount: 2000,
313
+ }),
314
+ ).rejects.toThrow("identity cannot change");
315
+ await expect(
316
+ t.mutation(api.connect.mutations.upsertTransfer, {
317
+ ...original,
318
+ role: "store",
319
+ }),
320
+ ).rejects.toThrow("original affiliate");
321
+ await t.mutation(api.connect.mutations.upsertTransfer, {
322
+ ...original,
323
+ reversedAmount: 501,
324
+ });
325
+ await t.mutation(api.connect.mutations.upsertTransfer, original);
326
+ expect((await rows(t)).page[0].recoveredAmount).toBe(501);
327
+ });
328
+
329
+ it("sizes a partial dispute using gross charge principal, not the sum of transferred funds", async () => {
330
+ const t = await setup();
331
+ const result = await t.mutation(api.connect.mutations.claimReversalSlices, {
332
+ sourceChargeId: "ch_1",
333
+ operationId: "dp_1",
334
+ mode: { kind: "amount", amount: 5000 },
335
+ commissionExposure: {
336
+ kind: "dispute",
337
+ chargeAmount: 10000,
338
+ principal: 5000,
339
+ finalized: false,
340
+ },
341
+ });
342
+ expect(result.slices[0]).toMatchObject({ from: 0, to: 501 });
343
+ expect((await rows(t)).page[0]).toMatchObject({
344
+ finalizedAmount: 0,
345
+ provisionalAmount: 501,
346
+ });
347
+ });
348
+
349
+ it("fails a charge beyond the transfer bound before reserving any money", async () => {
350
+ const t = await setup();
351
+ await t.run(async (ctx) => {
352
+ for (let i = 0; i < 1000; i++)
353
+ await ctx.db.insert("transfers", {
354
+ stripeTransferId: `tr_extra_${i}`,
355
+ sourceChargeId: "ch_1",
356
+ destinationAccountId: "acct_store",
357
+ currency: "usd",
358
+ amount: 1,
359
+ role: "store",
360
+ status: "paid",
361
+ });
362
+ });
363
+ await expect(claim(t, "re_1", 10000)).rejects.toThrow(
364
+ "recovery limit exceeded",
365
+ );
366
+ expect((await rows(t)).page).toEqual([]);
367
+ expect(
368
+ await t.run((ctx) => ctx.db.query("transferReversalOps").collect()),
369
+ ).toEqual([]);
370
+ });
@@ -0,0 +1,83 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { expect, it } from "vitest";
4
+ import type {
5
+ RegisteredQuery,
6
+ RegisteredMutation,
7
+ DefaultFunctionArgs,
8
+ } from "convex/server";
9
+ import type { MutationCtx } from "./_generated/server.js";
10
+ import schema from "./schema.js";
11
+ import {
12
+ recoverySnapshot,
13
+ pendingOperations,
14
+ } from "./affiliateRecovery/queries.js";
15
+ import { listAffiliateObligations } from "./settlement/queries.js";
16
+ import { migrateAffiliateObligations } from "./settlement/mutations.js";
17
+
18
+ function handler<Args extends DefaultFunctionArgs, Result>(
19
+ fn:
20
+ | RegisteredQuery<"public", Args, Result>
21
+ | RegisteredMutation<"public", Args, Result>,
22
+ ) {
23
+ return (
24
+ fn as unknown as { _handler: (ctx: MutationCtx, args: Args) => Result }
25
+ )._handler;
26
+ }
27
+
28
+ // convex-test supports native pagination even though deployed components do not.
29
+ // Make that runtime restriction explicit while exercising real indexed reads.
30
+ function componentDb<T extends object>(db: T): T {
31
+ function wrap<TValue extends object>(value: TValue): TValue {
32
+ return new Proxy(value, {
33
+ get(target, key) {
34
+ if (key === "paginate")
35
+ return () => {
36
+ throw new Error("paginate() is only supported in the app");
37
+ };
38
+ const member = Reflect.get(target, key);
39
+ if (typeof member !== "function") return member;
40
+ return (...args: unknown[]) => {
41
+ const result = member.apply(target, args);
42
+ return result &&
43
+ typeof result === "object" &&
44
+ !(result instanceof Promise)
45
+ ? wrap(result)
46
+ : result;
47
+ };
48
+ },
49
+ });
50
+ }
51
+ return wrap(db);
52
+ }
53
+ it("reads recovery pages and migrates inside the deployed component pagination boundary", async () => {
54
+ const t = convexTest(schema, import.meta.glob("./**/*.*s"));
55
+ await t.run(async (ctx) => {
56
+ const componentCtx = { ...ctx, db: componentDb(ctx.db) };
57
+ const paginationOpts = { numItems: 10, cursor: null };
58
+ expect(
59
+ await handler(listAffiliateObligations)(componentCtx, {
60
+ affiliateAccountId: "acct_a",
61
+ currency: "usd",
62
+ paginationOpts,
63
+ }),
64
+ ).toMatchObject({ page: [], isDone: true });
65
+ const snapshot = await handler(recoverySnapshot)(componentCtx, {
66
+ affiliateAccountId: "acct_a",
67
+ currency: "usd",
68
+ paginationOpts,
69
+ });
70
+ expect(snapshot.obligations).toMatchObject({ page: [], isDone: true });
71
+ expect(
72
+ await handler(pendingOperations)(componentCtx, {
73
+ state: "unknown",
74
+ paginationOpts,
75
+ }),
76
+ ).toMatchObject({ page: [], isDone: true });
77
+ expect(
78
+ await handler(migrateAffiliateObligations)(componentCtx, {
79
+ paginationOpts,
80
+ }),
81
+ ).toMatchObject({ migrated: 0, isDone: true });
82
+ });
83
+ });
@@ -0,0 +1,160 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { expect, it, vi } from "vitest";
4
+ import type Stripe from "stripe";
5
+ import { api } from "./_generated/api.js";
6
+ import schema from "./schema.js";
7
+ import { changeBalance } from "./affiliateRecovery/accounting.js";
8
+ import { createPayout } from "../client/connect/payouts.js";
9
+ import { AffiliateRecoveryClient } from "../client/affiliateRecovery.js";
10
+ import type { Component, RunCtx } from "../client/helpers.js";
11
+ const modules = import.meta.glob("./**/*.*s");
12
+ const symbol = Symbol.for("toReferencePath");
13
+ function reference(path = "betterStripe"): Component {
14
+ return new Proxy(
15
+ {},
16
+ {
17
+ get: (_target, key) =>
18
+ key === symbol
19
+ ? path
20
+ : typeof key === "string"
21
+ ? reference(`${path}/${key}`)
22
+ : undefined,
23
+ },
24
+ ) as Component;
25
+ }
26
+ async function setup() {
27
+ const t = convexTest(schema, modules);
28
+ await t.run((ctx) => changeBalance(ctx, "acct_affiliate", "usd", {}));
29
+ const route = (ref: unknown) =>
30
+ (ref as Record<symbol, string>)[symbol]
31
+ .split("/")
32
+ .slice(1)
33
+ .reduce(
34
+ (node, key) => (node as Record<string, unknown>)[key],
35
+ api as unknown,
36
+ );
37
+ const mutation = t.mutation as unknown as (
38
+ ref: unknown,
39
+ args: unknown,
40
+ ) => Promise<unknown>;
41
+ const query = t.query as unknown as (
42
+ ref: unknown,
43
+ args: unknown,
44
+ ) => Promise<unknown>;
45
+ const ctx = {
46
+ runMutation: (ref: unknown, args: unknown) => mutation(route(ref), args),
47
+ runQuery: (ref: unknown, args: unknown) => query(route(ref), args),
48
+ } as unknown as RunCtx;
49
+ const payouts = {
50
+ list: vi.fn(() => ({ async *[Symbol.asyncIterator]() {} })),
51
+ create: vi.fn(async () => ({ id: "po_created" })),
52
+ };
53
+ const provider = { payouts } as unknown as Stripe;
54
+ const component = reference();
55
+ const opts = {
56
+ stripeAccountId: "acct_affiliate",
57
+ currency: "usd",
58
+ amount: 1000,
59
+ idempotencyKey: "direct-operation",
60
+ };
61
+ const balance = () =>
62
+ t.run((ctx) => ctx.db.query("affiliateRecoveryBalances").first());
63
+ return { t, ctx, provider, payouts, component, opts, balance };
64
+ }
65
+ it("rechecks debt recorded during direct payout receipt lookup before calling Stripe", async () => {
66
+ const f = await setup();
67
+ f.payouts.list.mockImplementation(() => ({
68
+ async *[Symbol.asyncIterator]() {
69
+ await f.t.run((ctx) =>
70
+ changeBalance(ctx, "acct_affiliate", "usd", {
71
+ outstandingDebt: 100,
72
+ }),
73
+ );
74
+ },
75
+ }));
76
+ await expect(
77
+ createPayout(f.provider, f.ctx, f.opts, f.component),
78
+ ).rejects.toThrow(/recovery/);
79
+ expect(f.payouts.create).not.toHaveBeenCalled();
80
+ expect(await f.balance()).toMatchObject({
81
+ outstandingDebt: 100,
82
+ activePayoutKey: f.opts.idempotencyKey,
83
+ pendingPayoutAmount: 1000,
84
+ });
85
+ });
86
+ it("keeps expired direct payout operations reserved without issuing a new payout", async () => {
87
+ const f = await setup();
88
+ await f.t.mutation(api.affiliatePayouts.mutations.reservePayout, {
89
+ affiliateAccountId: f.opts.stripeAccountId,
90
+ currency: "usd",
91
+ amount: 1000,
92
+ minimumAmount: 1,
93
+ operationKey: f.opts.idempotencyKey,
94
+ now: Date.now() - 24 * 60 * 60 * 1000,
95
+ });
96
+ await expect(
97
+ createPayout(f.provider, f.ctx, f.opts, f.component),
98
+ ).rejects.toThrow(/window expired/);
99
+ expect(f.payouts.create).not.toHaveBeenCalled();
100
+ expect(await f.balance()).toMatchObject({
101
+ activePayoutKey: f.opts.idempotencyKey,
102
+ });
103
+ });
104
+ it("submits an eligible direct payout once and returns the confirmed receipt on replay", async () => {
105
+ const f = await setup();
106
+ expect(await createPayout(f.provider, f.ctx, f.opts, f.component)).toEqual({
107
+ stripePayoutId: "po_created",
108
+ });
109
+ expect(await createPayout(f.provider, f.ctx, f.opts, f.component)).toEqual({
110
+ stripePayoutId: "po_created",
111
+ });
112
+ expect(f.payouts.create).toHaveBeenCalledTimes(1);
113
+ expect((await f.balance())?.activePayoutKey).toBeUndefined();
114
+ });
115
+ it("reconciles an expired payout receipt despite later debt without issuing another payout", async () => {
116
+ const f = await setup();
117
+ const release = await f.t.mutation(
118
+ api.affiliatePayouts.mutations.reservePayout,
119
+ {
120
+ affiliateAccountId: f.opts.stripeAccountId,
121
+ currency: "usd",
122
+ amount: 1000,
123
+ minimumAmount: 1,
124
+ operationKey: f.opts.idempotencyKey,
125
+ now: 1,
126
+ },
127
+ );
128
+ await f.t.run((ctx) =>
129
+ changeBalance(ctx, "acct_affiliate", "usd", { outstandingDebt: 100 }),
130
+ );
131
+ f.payouts.list.mockImplementation(
132
+ () =>
133
+ ({
134
+ async *[Symbol.asyncIterator]() {
135
+ yield {
136
+ id: "po_reconciled",
137
+ amount: 1000,
138
+ currency: "usd",
139
+ metadata: { bsAffiliatePayout: release!._id },
140
+ };
141
+ },
142
+ }) as never,
143
+ );
144
+ await new AffiliateRecoveryClient(
145
+ f.component,
146
+ () => f.provider,
147
+ ).reconcilePayouts(f.ctx, {
148
+ state: "pending",
149
+ paginationOpts: { numItems: 10, cursor: null },
150
+ });
151
+ expect(f.payouts.create).not.toHaveBeenCalled();
152
+ expect(await f.balance()).toMatchObject({
153
+ outstandingDebt: 100,
154
+ pendingPayoutAmount: 0,
155
+ });
156
+ expect((await f.balance())?.activePayoutKey).toBeUndefined();
157
+ expect(
158
+ await f.t.run((ctx) => ctx.db.query("affiliatePayoutReleases").first()),
159
+ ).toMatchObject({ state: "submitted", stripePayoutId: "po_reconciled" });
160
+ });