@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,173 @@
1
+ import {
2
+ reconcileAffiliatePayout,
3
+ completeAffiliatePayout,
4
+ } from "./affiliateRecovery/payoutGate.js";
5
+ import { verifiedLegacyReview } from "./affiliateRecovery/review.js";
6
+ import { runMutationOrThrow } from "./helpers.js";
7
+ import { createPayout } from "./connect/payouts.js";
8
+ import type Stripe from "stripe";
9
+ import type { PaginationOptions } from "convex/server";
10
+ import type { Component, RunCtx } from "./helpers.js";
11
+ import { componentRef } from "./webhooks/helpers.js";
12
+ import { reconcileObligation } from "./affiliateRecovery/engine.js";
13
+ import type {
14
+ RecoveryObligation,
15
+ RecoveryOperation,
16
+ } from "./affiliateRecovery/provider.js";
17
+ import type { Doc } from "../component/_generated/dataModel.js";
18
+ export type AffiliateRecoveryObligation = RecoveryObligation;
19
+ export type AffiliateRecoveryOperation = RecoveryOperation;
20
+ export type AffiliateRecoveryBalance = Doc<"affiliateRecoveryBalances">;
21
+ export type AffiliateRecoverySnapshot = {
22
+ balance: AffiliateRecoveryBalance | null;
23
+ obligations: {
24
+ page: RecoveryObligation[];
25
+ continueCursor: string;
26
+ isDone: boolean;
27
+ };
28
+ };
29
+ /** Server API. Host must authorize account ownership and operator retries. */
30
+ export class AffiliateRecoveryClient {
31
+ constructor(
32
+ private readonly component: Component,
33
+ private readonly provider: () => Stripe,
34
+ ) {}
35
+ async snapshot(
36
+ ctx: RunCtx,
37
+ opts: {
38
+ affiliateAccountId: string;
39
+ currency: string;
40
+ paginationOpts: PaginationOptions;
41
+ },
42
+ ): Promise<AffiliateRecoverySnapshot> {
43
+ return ctx.runQuery(
44
+ componentRef(
45
+ this.component,
46
+ "affiliateRecovery/queries/recoverySnapshot",
47
+ ),
48
+ opts,
49
+ ) as Promise<AffiliateRecoverySnapshot>;
50
+ }
51
+ async obligation(
52
+ ctx: RunCtx,
53
+ opts: { obligationId: string },
54
+ ): Promise<RecoveryObligation | null> {
55
+ return ctx.runQuery(
56
+ componentRef(this.component, "affiliateRecovery/queries/getObligation"),
57
+ opts,
58
+ ) as Promise<RecoveryObligation | null>;
59
+ }
60
+ async history(
61
+ ctx: RunCtx,
62
+ opts: { obligationId: string; paginationOpts: PaginationOptions },
63
+ ) {
64
+ return ctx.runQuery(
65
+ componentRef(
66
+ this.component,
67
+ "affiliateRecovery/queries/operationHistory",
68
+ ),
69
+ opts,
70
+ ) as Promise<{
71
+ page: RecoveryOperation[];
72
+ continueCursor: string;
73
+ isDone: boolean;
74
+ }>;
75
+ }
76
+ async reviewLegacy(
77
+ ctx: RunCtx,
78
+ opts: {
79
+ obligationId: string;
80
+ reversalIds: string[];
81
+ restorationTransferIds?: string[];
82
+ note: string;
83
+ },
84
+ ) {
85
+ const row = await this.obligation(ctx, opts);
86
+ if (!row) throw new Error("Affiliate obligation not found.");
87
+ const review = await verifiedLegacyReview(this.provider(), row, opts);
88
+ await runMutationOrThrow(
89
+ ctx,
90
+ componentRef(this.component, "affiliateRecovery/mutations/reviewLegacy"),
91
+ review,
92
+ );
93
+ return this.obligation(ctx, opts);
94
+ }
95
+ async retry(ctx: RunCtx, opts: { obligationId: string }) {
96
+ return reconcileObligation(
97
+ this.provider(),
98
+ this.component,
99
+ ctx,
100
+ opts.obligationId,
101
+ );
102
+ }
103
+ async reconcilePayouts(
104
+ ctx: RunCtx,
105
+ opts: { paginationOpts: PaginationOptions; state: "pending" | "unknown" },
106
+ ) {
107
+ const page = (await ctx.runQuery(
108
+ componentRef(this.component, "affiliatePayouts/queries/pendingPayouts"),
109
+ opts,
110
+ )) as {
111
+ page: Doc<"affiliatePayoutReleases">[];
112
+ continueCursor: string;
113
+ isDone: boolean;
114
+ };
115
+ for (const row of page.page) {
116
+ try {
117
+ // Confirm money already moved even when new debt blocks another attempt.
118
+ const receipt = await reconcileAffiliatePayout(this.provider(), row);
119
+ if (receipt) {
120
+ await completeAffiliatePayout(
121
+ this.component,
122
+ ctx,
123
+ row.operationKey,
124
+ "submitted",
125
+ receipt,
126
+ );
127
+ continue;
128
+ }
129
+ await createPayout(
130
+ this.provider(),
131
+ ctx,
132
+ {
133
+ stripeAccountId: row.affiliateAccountId,
134
+ currency: row.currency,
135
+ amount: row.amount,
136
+ minimumAmount: row.minimumAmount,
137
+ idempotencyKey: row.operationKey,
138
+ },
139
+ this.component,
140
+ );
141
+ } catch {
142
+ /* The payout gate retains the durable failure/unknown outcome. */
143
+ }
144
+ }
145
+ return page;
146
+ }
147
+ /** Run from a host cron for both pending and unknown outcomes. */
148
+ async reconcilePending(
149
+ ctx: RunCtx,
150
+ opts: { paginationOpts: PaginationOptions; state: "pending" | "unknown" },
151
+ ) {
152
+ const page = (await ctx.runQuery(
153
+ componentRef(
154
+ this.component,
155
+ "affiliateRecovery/queries/pendingOperations",
156
+ ),
157
+ opts,
158
+ )) as {
159
+ page: RecoveryOperation[];
160
+ continueCursor: string;
161
+ isDone: boolean;
162
+ };
163
+ const results = [];
164
+ for (const op of page.page)
165
+ results.push(await this.retry(ctx, { obligationId: op.obligationId }));
166
+ return { ...page, results };
167
+ }
168
+ }
169
+ export {
170
+ affiliateSnapshotValidator,
171
+ affiliateObligationValidator,
172
+ affiliateOperationValidator,
173
+ } from "./affiliateRecovery/validators.js";
@@ -0,0 +1,157 @@
1
+ import { reclaimReversalClaim } from "./transfers.js";
2
+ import type Stripe from "stripe";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { findCommissionReversal } from "./commissionReceipts.js";
5
+ import { executeClaimedSlices } from "./reversalExecution.js";
6
+ import type { Component, RunCtx } from "../helpers.js";
7
+
8
+ const component = new Proxy(
9
+ {},
10
+ {
11
+ get: (_t, p) =>
12
+ p === Symbol.for("toReferencePath") ? "betterStripe" : component,
13
+ },
14
+ ) as Component;
15
+ const claim = {
16
+ replay: false,
17
+ slices: [
18
+ {
19
+ stripeTransferId: "tr_1",
20
+ from: 0,
21
+ to: 501,
22
+ confirmed: 0,
23
+ commissionTracked: true,
24
+ },
25
+ ],
26
+ };
27
+ const fixture = () => {
28
+ const receipts: {
29
+ id: string;
30
+ amount: number;
31
+ metadata: Record<string, string>;
32
+ }[] = [];
33
+ const stripe = {
34
+ transfers: {
35
+ createReversal: vi.fn(
36
+ async (
37
+ _id: string,
38
+ params: { amount: number; metadata: Record<string, string> },
39
+ ) => {
40
+ const receipt = { id: "trr_1", ...params };
41
+ receipts.push(receipt);
42
+ return receipt;
43
+ },
44
+ ),
45
+ listReversals: vi.fn(() =>
46
+ (async function* () {
47
+ yield* receipts;
48
+ })(),
49
+ ),
50
+ retrieve: vi.fn(),
51
+ },
52
+ };
53
+ const ctx = { runMutation: vi.fn().mockResolvedValue(null) };
54
+ const run = (replay = false, confirmed = 0) =>
55
+ executeClaimedSlices(
56
+ stripe as unknown as Stripe,
57
+ component,
58
+ ctx as unknown as RunCtx,
59
+ "re_1",
60
+ { ...claim, replay, slices: [{ ...claim.slices[0], confirmed }] },
61
+ );
62
+ return { stripe, ctx, receipts, run };
63
+ };
64
+
65
+ describe("commission reversal receipt reconciliation", () => {
66
+ it("recovers a provider success after the ledger confirmation crashed, even beyond the idempotency window", async () => {
67
+ const f = fixture();
68
+ f.ctx.runMutation.mockRejectedValueOnce(new Error("ledger offline"));
69
+ await expect(f.run()).rejects.toThrow("ledger offline");
70
+ expect(f.receipts).toHaveLength(1);
71
+ await f.run(true);
72
+ await f.run(true, 501);
73
+ expect(f.stripe.transfers.createReversal).toHaveBeenCalledTimes(1);
74
+ expect(f.ctx.runMutation).toHaveBeenLastCalledWith(expect.anything(), {
75
+ stripeTransferId: "tr_1",
76
+ reversedAmount: 501,
77
+ receipt: { stripeReversalId: "trr_1", operationId: "re_1", amount: 501 },
78
+ });
79
+ });
80
+
81
+ it("does not mistake another operation's aggregate reversal for this receipt", async () => {
82
+ const f = fixture();
83
+ f.receipts.push({
84
+ id: "trr_other",
85
+ amount: 501,
86
+ metadata: { bsReversalOperation: "re_other" },
87
+ });
88
+ await expect(f.run(true, 501)).rejects.toThrow(
89
+ "requires receipt reconciliation",
90
+ );
91
+ expect(f.stripe.transfers.retrieve).not.toHaveBeenCalled();
92
+ expect(f.stripe.transfers.createReversal).not.toHaveBeenCalled();
93
+ expect(f.ctx.runMutation).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("retries a failed provider attempt with byte-identical parameters and operation identity", async () => {
97
+ const f = fixture();
98
+ f.stripe.transfers.createReversal.mockRejectedValueOnce(
99
+ new Error("network offline"),
100
+ );
101
+ await expect(f.run()).rejects.toThrow("network offline");
102
+ await f.run(true);
103
+ expect(f.stripe.transfers.createReversal.mock.calls[0]).toEqual(
104
+ f.stripe.transfers.createReversal.mock.calls[1],
105
+ );
106
+ expect(f.receipts).toHaveLength(1);
107
+ });
108
+
109
+ it("rejects a mismatching receipt amount without confirming money", async () => {
110
+ const f = fixture();
111
+ f.receipts.push({
112
+ id: "trr_bad",
113
+ amount: 500,
114
+ metadata: { bsReversalOperation: "re_1" },
115
+ });
116
+ await expect(f.run(true)).rejects.toThrow("amount mismatch");
117
+ expect(f.ctx.runMutation).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it("fails explicitly when the bounded receipt search cannot establish success", async () => {
121
+ const f = fixture();
122
+ for (let i = 0; i < 1001; i++)
123
+ f.receipts.push({ id: `trr_${i}`, amount: 1, metadata: {} });
124
+ await expect(
125
+ findCommissionReversal(
126
+ f.stripe as unknown as Stripe,
127
+ "tr_1",
128
+ "re_1",
129
+ 501,
130
+ ),
131
+ ).rejects.toThrow("lookup limit exceeded");
132
+ });
133
+ });
134
+
135
+ it("operator re-execution delegates tracked commissions to the durable recovery engine", async () => {
136
+ const f = fixture();
137
+ const runQuery = vi
138
+ .fn()
139
+ .mockResolvedValueOnce({
140
+ operationId: "re_1",
141
+ sourceChargeId: "ch_1",
142
+ _creationTime: 0,
143
+ slices: [{ stripeTransferId: "tr_1", from: 0, to: 501 }],
144
+ commissionExposure: { kind: "refund" },
145
+ })
146
+ .mockResolvedValueOnce({ role: "affiliate", reversedAmount: 0 });
147
+ await reclaimReversalClaim(
148
+ f.stripe as unknown as Stripe,
149
+ component,
150
+ { ...f.ctx, runQuery } as unknown as RunCtx,
151
+ { operationId: "re_1" },
152
+ );
153
+ expect(f.stripe.transfers.createReversal).not.toHaveBeenCalled();
154
+ expect(runQuery).toHaveBeenLastCalledWith(expect.anything(), {
155
+ sourceChargeId: "ch_1",
156
+ });
157
+ });
@@ -0,0 +1,23 @@
1
+ import type Stripe from "stripe";
2
+
3
+ /** Locate the exact receipt, never infer operation success from an aggregate. */
4
+ export async function findCommissionReversal(
5
+ stripe: Stripe,
6
+ stripeTransferId: string,
7
+ operationId: string,
8
+ amount: number,
9
+ ) {
10
+ let count = 0;
11
+ for await (const receipt of stripe.transfers.listReversals(stripeTransferId, {
12
+ limit: 100,
13
+ })) {
14
+ if (++count > 1000)
15
+ throw new Error("Commission reversal receipt lookup limit exceeded.");
16
+ if (receipt.metadata?.bsReversalOperation === operationId) {
17
+ if (receipt.amount !== amount)
18
+ throw new Error("Commission reversal receipt amount mismatch.");
19
+ return receipt;
20
+ }
21
+ }
22
+ return null;
23
+ }
@@ -1,3 +1,9 @@
1
+ import {
2
+ reserveAffiliatePayout,
3
+ completeAffiliatePayout,
4
+ reconcileAffiliatePayout,
5
+ } from "../affiliateRecovery/payoutGate.js";
6
+ import { classifyRecoveryError } from "../affiliateRecovery/provider.js";
1
7
  import type Stripe from "stripe";
2
8
 
3
9
  import type { PayoutStatus } from "../../component/connect/validators.js";
@@ -54,6 +60,8 @@ export async function getAccountBalance(
54
60
  }
55
61
 
56
62
  export type CreatePayoutOptions = {
63
+ /** Host-authorized minimum in the payout currency. */
64
+ minimumAmount?: number;
57
65
  stripeAccountId: string;
58
66
  amount: number;
59
67
  currency?: string;
@@ -64,23 +72,75 @@ export type CreatePayoutOptions = {
64
72
 
65
73
  export async function createPayout(
66
74
  stripe: Stripe,
67
- _ctx: RunCtx,
75
+ ctx: RunCtx,
68
76
  opts: CreatePayoutOptions,
77
+ component?: Component,
69
78
  ) {
70
- const payout = await stripe.payouts.create(
71
- {
72
- amount: opts.amount,
73
- currency: opts.currency ?? "usd",
74
- metadata: opts.metadata ?? undefined,
75
- },
76
- {
77
- stripeAccount: opts.stripeAccountId,
78
- ...(opts.idempotencyKey !== undefined
79
- ? { idempotencyKey: opts.idempotencyKey }
80
- : {}),
81
- },
82
- );
83
- return { stripePayoutId: payout.id };
79
+ const release = component
80
+ ? await reserveAffiliatePayout(component, ctx, opts)
81
+ : null;
82
+ if (release?.state === "submitted")
83
+ return { stripePayoutId: release.stripePayoutId! };
84
+ if (release?.state === "failed")
85
+ throw new Error(
86
+ "Affiliate payout failed; use a new authorized operation key.",
87
+ );
88
+ try {
89
+ if (release) {
90
+ const receiptId = await reconcileAffiliatePayout(stripe, release);
91
+ if (receiptId) {
92
+ await completeAffiliatePayout(
93
+ component!,
94
+ ctx,
95
+ release.operationKey,
96
+ "submitted",
97
+ receiptId,
98
+ );
99
+ return { stripePayoutId: receiptId };
100
+ }
101
+ }
102
+ if (release) {
103
+ if (Date.now() - release.createdAt >= 23 * 60 * 60 * 1000)
104
+ throw new Error(
105
+ "Affiliate payout idempotency window expired; reconcile the provider receipt before retrying.",
106
+ );
107
+ // Receipt lookup may yield while a refund records new debt.
108
+ await reserveAffiliatePayout(component!, ctx, opts);
109
+ }
110
+ const payout = await stripe.payouts.create(
111
+ {
112
+ amount: opts.amount,
113
+ currency: opts.currency ?? "usd",
114
+ metadata: release
115
+ ? { ...opts.metadata, bsAffiliatePayout: release._id }
116
+ : (opts.metadata ?? undefined),
117
+ },
118
+ {
119
+ stripeAccount: opts.stripeAccountId,
120
+ ...(opts.idempotencyKey !== undefined
121
+ ? { idempotencyKey: opts.idempotencyKey }
122
+ : {}),
123
+ },
124
+ );
125
+ if (release)
126
+ await completeAffiliatePayout(
127
+ component!,
128
+ ctx,
129
+ release.operationKey,
130
+ "submitted",
131
+ payout.id,
132
+ );
133
+ return { stripePayoutId: payout.id };
134
+ } catch (error) {
135
+ if (release)
136
+ await completeAffiliatePayout(
137
+ component!,
138
+ ctx,
139
+ release.operationKey,
140
+ classifyRecoveryError(error).state === "unknown" ? "unknown" : "failed",
141
+ );
142
+ throw error;
143
+ }
84
144
  }
85
145
 
86
146
  export async function getPayout(
@@ -0,0 +1,46 @@
1
+ import type Stripe from "stripe";
2
+ import { expect, it } from "vitest";
3
+ import { successfulRefundPrincipal } from "./refundPrincipal.js";
4
+ const provider = (refunds: { amount: number; status: string }[]) =>
5
+ ({
6
+ refunds: {
7
+ list: () =>
8
+ (async function* () {
9
+ yield* refunds;
10
+ })(),
11
+ },
12
+ }) as unknown as Stripe;
13
+ it("excludes pending, failed and canceled refunds from the finalized cumulative principal", async () => {
14
+ expect(
15
+ await successfulRefundPrincipal(
16
+ provider([
17
+ { amount: 100, status: "succeeded" },
18
+ { amount: 200, status: "pending" },
19
+ { amount: 300, status: "failed" },
20
+ { amount: 400, status: "canceled" },
21
+ { amount: 500, status: "succeeded" },
22
+ { amount: 600, status: "requires_action" },
23
+ ]),
24
+ "ch_1",
25
+ ),
26
+ ).toBe(600);
27
+ });
28
+ it("fails closed on invalid money and a refund history beyond the supported bound", async () => {
29
+ await expect(
30
+ successfulRefundPrincipal(
31
+ provider([{ amount: -1, status: "succeeded" }]),
32
+ "ch_1",
33
+ ),
34
+ ).rejects.toThrow("Invalid");
35
+ await expect(
36
+ successfulRefundPrincipal(
37
+ provider(
38
+ Array.from({ length: 1001 }, () => ({
39
+ amount: 1,
40
+ status: "succeeded",
41
+ })),
42
+ ),
43
+ "ch_1",
44
+ ),
45
+ ).rejects.toThrow("limit exceeded");
46
+ });
@@ -0,0 +1,26 @@
1
+ import type Stripe from "stripe";
2
+
3
+ /** Charge.amount_refunded also includes pending refunds; only success is final. */
4
+ export async function successfulRefundPrincipal(
5
+ stripe: Stripe,
6
+ chargeId: string,
7
+ ) {
8
+ let principal = 0;
9
+ let count = 0;
10
+ for await (const refund of stripe.refunds.list({
11
+ charge: chargeId,
12
+ limit: 100,
13
+ })) {
14
+ if (++count > 1000)
15
+ throw new Error("Successful refund lookup limit exceeded.");
16
+ if (refund.status !== "succeeded") continue;
17
+ if (
18
+ !Number.isSafeInteger(refund.amount) ||
19
+ refund.amount < 0 ||
20
+ !Number.isSafeInteger(principal + refund.amount)
21
+ )
22
+ throw new Error("Invalid successful refund principal.");
23
+ principal += refund.amount;
24
+ }
25
+ return principal;
26
+ }
@@ -0,0 +1,130 @@
1
+ import type Stripe from "stripe";
2
+ import type { Component, RunCtx } from "../helpers.js";
3
+ import { runMutationOrThrow } from "../helpers.js";
4
+ import { componentRef } from "../webhooks/helpers.js";
5
+ import { findCommissionReversal } from "./commissionReceipts.js";
6
+
7
+ export type ClaimedSlice = {
8
+ stripeTransferId: string;
9
+ /** Cumulative reversed amount this slice starts from. */
10
+ from: number;
11
+ /** Cumulative reversed amount this slice raises the leg to. */
12
+ to: number;
13
+ /** The leg's confirmed `reversedAmount` at claim time. */
14
+ confirmed: number;
15
+ commissionTracked?: boolean;
16
+ };
17
+
18
+ export type ReversalClaim = { replay: boolean; slices: ClaimedSlice[] };
19
+
20
+ /**
21
+ * Execute the slices an operation claimed via `claimReversalSlices` (BTS-63).
22
+ * The claim already reserved this money atomically, so all that's left is
23
+ * moving it: one `bs_rev_<operationId>_<transferId>` reversal per slice, then
24
+ * confirming the new cumulative total in the ledger. A replayed claim always
25
+ * re-sends byte-identical params under the same key, which Stripe answers
26
+ * from its idempotency cache without moving money twice.
27
+ *
28
+ * Because Stripe prunes idempotency keys after ~24h (and a crash can land
29
+ * between the Stripe call and the ledger confirm), a replay first checks the
30
+ * transfer's live `amount_reversed`: if the slice's money already moved, it
31
+ * just confirms the ledger instead of re-sending the reversal.
32
+ *
33
+ * A slice only executes once every predecessor slice on its leg is confirmed
34
+ * (`confirmed ≥ from`). Without that gate, a sibling operation landing behind
35
+ * a transiently-failed claim would execute and confirm PAST the hole; the
36
+ * failed operation's retry would then see `confirmed ≥ to` and silently skip
37
+ * — its money would never move while the ledger says it did. Throwing instead
38
+ * fails this operation's webhook event, and at-least-once delivery retries
39
+ * both operations until the predecessor fills its hole and each claimed slice
40
+ * has actually moved. The ledger's `reversedAmount` therefore always covers a
41
+ * solid, hole-free prefix of the reversed axis — which is exactly what makes
42
+ * the `confirmed`/`amount_reversed` checks above sound.
43
+ */
44
+ export async function executeClaimedSlices(
45
+ stripe: Stripe,
46
+ component: Component,
47
+ ctx: RunCtx,
48
+ operationId: string,
49
+ claim: ReversalClaim,
50
+ ): Promise<{ reversals: { stripeTransferId: string; amount: number }[] }> {
51
+ const reversals: { stripeTransferId: string; amount: number }[] = [];
52
+ for (const slice of claim.slices) {
53
+ const receiptAmount = slice.to - slice.from;
54
+ if (slice.commissionTracked && claim.replay) {
55
+ const receipt = await findCommissionReversal(
56
+ stripe,
57
+ slice.stripeTransferId,
58
+ operationId,
59
+ receiptAmount,
60
+ );
61
+ if (receipt) {
62
+ await runMutationOrThrow(
63
+ ctx,
64
+ componentRef(component, "connect/mutations/recordTransferReversal"),
65
+ {
66
+ stripeTransferId: slice.stripeTransferId,
67
+ reversedAmount: slice.to,
68
+ receipt: {
69
+ stripeReversalId: receipt.id,
70
+ operationId,
71
+ amount: receiptAmount,
72
+ },
73
+ },
74
+ );
75
+ continue;
76
+ }
77
+ if (slice.confirmed >= slice.to)
78
+ throw new Error("Commission reversal requires receipt reconciliation.");
79
+ }
80
+ if (slice.confirmed >= slice.to) continue; // executed and recorded already
81
+
82
+ if (slice.confirmed < slice.from) {
83
+ throw new Error(
84
+ `reversal slice [${slice.from},${slice.to}) on ${slice.stripeTransferId} ` +
85
+ `blocked: predecessor claim unexecuted (confirmed=${slice.confirmed})`,
86
+ );
87
+ }
88
+
89
+ if (claim.replay && !slice.commissionTracked) {
90
+ const transfer = await stripe.transfers.retrieve(slice.stripeTransferId);
91
+ if ((transfer.amount_reversed ?? 0) >= slice.to) {
92
+ // The money moved on a previous attempt that died before confirming.
93
+ await runMutationOrThrow(
94
+ ctx,
95
+ componentRef(component, "connect/mutations/recordTransferReversal"),
96
+ {
97
+ stripeTransferId: slice.stripeTransferId,
98
+ reversedAmount: slice.to,
99
+ },
100
+ );
101
+ continue;
102
+ }
103
+ }
104
+
105
+ const amount = slice.to - slice.from;
106
+ const receipt = await stripe.transfers.createReversal(
107
+ slice.stripeTransferId,
108
+ {
109
+ amount,
110
+ ...(slice.commissionTracked
111
+ ? { metadata: { bsReversalOperation: operationId } }
112
+ : {}),
113
+ },
114
+ { idempotencyKey: `bs_rev_${operationId}_${slice.stripeTransferId}` },
115
+ );
116
+ await runMutationOrThrow(
117
+ ctx,
118
+ componentRef(component, "connect/mutations/recordTransferReversal"),
119
+ {
120
+ stripeTransferId: slice.stripeTransferId,
121
+ reversedAmount: slice.to,
122
+ ...(slice.commissionTracked
123
+ ? { receipt: { stripeReversalId: receipt.id, operationId, amount } }
124
+ : {}),
125
+ },
126
+ );
127
+ reversals.push({ stripeTransferId: slice.stripeTransferId, amount });
128
+ }
129
+ return { reversals };
130
+ }
@@ -350,8 +350,8 @@ describe("createSplitTransfers engine (BTS-22)", () => {
350
350
  destination: "acct_aff",
351
351
  });
352
352
  // ledger rows
353
- expect(ctx.runMutation).toHaveBeenCalledTimes(3);
354
- expect(ctx.runMutation.mock.calls[1][1]).toMatchObject({
353
+ expect(ctx.runMutation).toHaveBeenCalledTimes(4);
354
+ expect(ctx.runMutation.mock.calls[2][1]).toMatchObject({
355
355
  sourceChargeId: "ch_1",
356
356
  destinationAccountId: "acct_store",
357
357
  amount: 8000,
@@ -380,7 +380,7 @@ describe("createSplitTransfers engine (BTS-22)", () => {
380
380
  });
381
381
 
382
382
  expect(stripe.transfers.create).toHaveBeenCalledTimes(3);
383
- expect(ctx.runMutation).toHaveBeenCalledTimes(4);
383
+ expect(ctx.runMutation).toHaveBeenCalledTimes(5);
384
384
  });
385
385
 
386
386
  it("skips zero-amount transfers", async () => {