@fantastic.dev/better-stripe 0.28.5 → 0.29.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 (137) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +6 -0
  3. package/README.md +7 -1
  4. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +28 -8
  5. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
  6. package/dist/client/connect/payoutCollectedInput.d.ts +110 -0
  7. package/dist/client/connect/payoutCollectedInput.d.ts.map +1 -0
  8. package/dist/client/connect/payoutCollectedInput.js +70 -0
  9. package/dist/client/connect/payoutCollectedInput.js.map +1 -0
  10. package/dist/client/connect/payoutExecution.d.ts +1 -0
  11. package/dist/client/connect/payoutExecution.d.ts.map +1 -1
  12. package/dist/client/connect/payoutExecution.js +94 -67
  13. package/dist/client/connect/payoutExecution.js.map +1 -1
  14. package/dist/client/connect/payoutExecutionTestHelpers.d.ts +16 -4
  15. package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
  16. package/dist/client/connect/payoutExecutionTestHelpers.js +9 -6
  17. package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
  18. package/dist/client/connect/payoutPreview.d.ts +1 -0
  19. package/dist/client/connect/payoutPreview.d.ts.map +1 -1
  20. package/dist/client/connect/payoutPreview.js +31 -19
  21. package/dist/client/connect/payoutPreview.js.map +1 -1
  22. package/dist/client/connect/payoutQualification.d.ts +7 -1
  23. package/dist/client/connect/payoutQualification.d.ts.map +1 -1
  24. package/dist/client/connect/payoutQualification.js +39 -20
  25. package/dist/client/connect/payoutQualification.js.map +1 -1
  26. package/dist/client/connect/payoutSyncTestHelpers.d.ts +14 -4
  27. package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
  28. package/dist/client/connect/payoutWorker.d.ts.map +1 -1
  29. package/dist/client/connect/payoutWorker.js +4 -1
  30. package/dist/client/connect/payoutWorker.js.map +1 -1
  31. package/dist/client/costRecovery.d.ts +5 -0
  32. package/dist/client/costRecovery.d.ts.map +1 -1
  33. package/dist/client/costRecovery.js +5 -0
  34. package/dist/client/costRecovery.js.map +1 -1
  35. package/dist/client/payoutPolicy.d.ts +4 -0
  36. package/dist/client/payoutPolicy.d.ts.map +1 -1
  37. package/dist/component/_generated/component.d.ts +175 -0
  38. package/dist/component/_generated/component.d.ts.map +1 -1
  39. package/dist/component/costRecovery/mutations.js +1 -1
  40. package/dist/component/costRecovery/queries.d.ts +9 -0
  41. package/dist/component/costRecovery/queries.d.ts.map +1 -1
  42. package/dist/component/costRecovery/queries.js +20 -0
  43. package/dist/component/costRecovery/queries.js.map +1 -1
  44. package/dist/component/earnings/triggers.d.ts +16 -6
  45. package/dist/component/earnings/triggers.d.ts.map +1 -1
  46. package/dist/component/payoutRelease/adoption.d.ts +6 -0
  47. package/dist/component/payoutRelease/adoption.d.ts.map +1 -1
  48. package/dist/component/payoutRelease/helpers.d.ts +4 -1
  49. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  50. package/dist/component/payoutRelease/helpers.js +18 -10
  51. package/dist/component/payoutRelease/helpers.js.map +1 -1
  52. package/dist/component/payoutRelease/mutations.d.ts +20 -0
  53. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  54. package/dist/component/payoutRelease/mutations.js +14 -2
  55. package/dist/component/payoutRelease/mutations.js.map +1 -1
  56. package/dist/component/payoutRelease/planning.d.ts +4 -0
  57. package/dist/component/payoutRelease/planning.d.ts.map +1 -1
  58. package/dist/component/payoutRelease/planning.js +4 -2
  59. package/dist/component/payoutRelease/planning.js.map +1 -1
  60. package/dist/component/payoutRelease/preview.d.ts +2 -0
  61. package/dist/component/payoutRelease/preview.d.ts.map +1 -1
  62. package/dist/component/payoutRelease/preview.js +5 -1
  63. package/dist/component/payoutRelease/preview.js.map +1 -1
  64. package/dist/component/payoutRelease/queries.d.ts +11 -0
  65. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  66. package/dist/component/payoutRelease/schema.d.ts +17 -2
  67. package/dist/component/payoutRelease/schema.d.ts.map +1 -1
  68. package/dist/component/payoutRelease/schema.js +3 -0
  69. package/dist/component/payoutRelease/schema.js.map +1 -1
  70. package/dist/component/payoutSnapshots/financial.d.ts +35 -0
  71. package/dist/component/payoutSnapshots/financial.d.ts.map +1 -0
  72. package/dist/component/payoutSnapshots/financial.js +89 -0
  73. package/dist/component/payoutSnapshots/financial.js.map +1 -0
  74. package/dist/component/payoutSnapshots/liabilities.d.ts +7 -0
  75. package/dist/component/payoutSnapshots/liabilities.d.ts.map +1 -0
  76. package/dist/component/payoutSnapshots/liabilities.js +18 -0
  77. package/dist/component/payoutSnapshots/liabilities.js.map +1 -0
  78. package/dist/component/payoutSnapshots/localState.d.ts +2 -0
  79. package/dist/component/payoutSnapshots/localState.d.ts.map +1 -1
  80. package/dist/component/payoutSnapshots/localState.js +6 -3
  81. package/dist/component/payoutSnapshots/localState.js.map +1 -1
  82. package/dist/component/payoutSnapshots/mutations.d.ts +6 -0
  83. package/dist/component/payoutSnapshots/mutations.d.ts.map +1 -1
  84. package/dist/component/payoutSnapshots/mutations.js +2 -0
  85. package/dist/component/payoutSnapshots/mutations.js.map +1 -1
  86. package/dist/component/payoutSnapshots/queries.d.ts +133 -0
  87. package/dist/component/payoutSnapshots/queries.d.ts.map +1 -0
  88. package/dist/component/payoutSnapshots/queries.js +84 -0
  89. package/dist/component/payoutSnapshots/queries.js.map +1 -0
  90. package/dist/component/payoutSnapshots/revisions.d.ts.map +1 -1
  91. package/dist/component/payoutSnapshots/revisions.js +19 -0
  92. package/dist/component/payoutSnapshots/revisions.js.map +1 -1
  93. package/dist/component/payoutSnapshots/schema.d.ts +15 -5
  94. package/dist/component/payoutSnapshots/schema.d.ts.map +1 -1
  95. package/dist/component/payoutSnapshots/schema.js +3 -1
  96. package/dist/component/payoutSnapshots/schema.js.map +1 -1
  97. package/dist/component/payoutWorker/mutations.d.ts.map +1 -1
  98. package/dist/component/payoutWorker/mutations.js +3 -1
  99. package/dist/component/payoutWorker/mutations.js.map +1 -1
  100. package/dist/component/schema.d.ts +14 -4
  101. package/dist/react/workflows/payout-attempt-state.d.ts.map +1 -1
  102. package/dist/react/workflows/payout-attempt-state.js +3 -0
  103. package/dist/react/workflows/payout-attempt-state.js.map +1 -1
  104. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
  105. package/dist/react/workflows/payout-progress-state.js +3 -0
  106. package/dist/react/workflows/payout-progress-state.js.map +1 -1
  107. package/docs/settlement.md +84 -17
  108. package/package.json +1 -1
  109. package/src/client/connect/payoutCollectedInput.ts +120 -0
  110. package/src/client/connect/payoutExecution.ts +116 -84
  111. package/src/client/connect/payoutExecutionTestHelpers.ts +19 -6
  112. package/src/client/connect/payoutLargeHistory.test.ts +222 -0
  113. package/src/client/connect/payoutLiabilityVersion.test.ts +221 -0
  114. package/src/client/connect/payoutPreview.ts +43 -23
  115. package/src/client/connect/payoutQualification.ts +69 -22
  116. package/src/client/connect/payoutQualificationFreshness.test.ts +192 -0
  117. package/src/client/connect/payoutWorker.ts +4 -1
  118. package/src/client/costRecovery.ts +8 -0
  119. package/src/component/_generated/api.ts +6 -0
  120. package/src/component/_generated/component.ts +203 -0
  121. package/src/component/costRecovery/mutations.ts +1 -1
  122. package/src/component/costRecovery/queries.ts +21 -0
  123. package/src/component/payoutRelease/helpers.ts +21 -11
  124. package/src/component/payoutRelease/mutations.ts +25 -2
  125. package/src/component/payoutRelease/planning.ts +8 -1
  126. package/src/component/payoutRelease/preview.ts +5 -1
  127. package/src/component/payoutRelease/schema.ts +3 -0
  128. package/src/component/payoutSnapshots/financial.ts +121 -0
  129. package/src/component/payoutSnapshots/liabilities.ts +25 -0
  130. package/src/component/payoutSnapshots/localState.ts +7 -3
  131. package/src/component/payoutSnapshots/mutations.ts +2 -0
  132. package/src/component/payoutSnapshots/queries.ts +102 -0
  133. package/src/component/payoutSnapshots/revisions.ts +26 -0
  134. package/src/component/payoutSnapshots/schema.ts +3 -0
  135. package/src/component/payoutWorker/mutations.ts +3 -1
  136. package/src/react/workflows/payout-attempt-state.ts +6 -0
  137. package/src/react/workflows/payout-progress-state.ts +6 -0
@@ -0,0 +1,222 @@
1
+ import type Stripe from "stripe";
2
+ import { PayoutWorkerClient } from "./payoutWorker.js";
3
+ import { reconcileRecipientPayout } from "./payoutReconciliation.js";
4
+ // @vitest-environment edge-runtime
5
+ import { expect, it, vi } from "vitest";
6
+ import { makeFunctionReference } from "convex/server";
7
+ import { setup, scope, component } from "./payoutExecutionTestHelpers.js";
8
+ async function largeHistory(amount = 5) {
9
+ const s = await setup(Array.from({ length: 205 }, () => amount));
10
+ await s.t.run(async (ctx) => {
11
+ for (let i = 0; i < 101; i++)
12
+ await ctx.db.insert("payoutReleaseClaims", {
13
+ accountId: "acct_recipient",
14
+ ownerKey: "owner",
15
+ operationKey: `old-${i}`,
16
+ expectedRevision: 0,
17
+ currency: "usd",
18
+ amount: 1,
19
+ allocations: [{ stripeTransferId: "tr_0", amount: 1, role: "store" }],
20
+ observations: [],
21
+ status: "canceled",
22
+ });
23
+ });
24
+ s.provider.balanceTransactions.list.mockImplementation(async (params) => {
25
+ const p = params as typeof params & {
26
+ starting_after?: string;
27
+ currency?: string;
28
+ };
29
+ const all = s
30
+ .transactions()
31
+ .filter(
32
+ (t) =>
33
+ (!p.source || t.source === p.source) &&
34
+ (!p.currency || t.currency === p.currency),
35
+ );
36
+ const start = p.starting_after
37
+ ? all.findIndex((t) => t.id === p.starting_after) + 1
38
+ : 0;
39
+ const data = all.slice(start, start + (p.limit ?? 100));
40
+ return { data, has_more: start + data.length < all.length };
41
+ });
42
+ return s;
43
+ }
44
+ it("releases all 205 verified sources despite 101 terminal historical claims", async () => {
45
+ const s = await largeHistory();
46
+ expect(await s.run()).toMatchObject({
47
+ status: "updated",
48
+ claim: { status: "paid", amount: 1025 },
49
+ });
50
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
51
+ expect(
52
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
53
+ ).toHaveLength(205);
54
+ await s.run();
55
+ expect(await s.run({ operationKey: "release-2" })).toMatchObject({
56
+ status: "held",
57
+ reason: "below_threshold",
58
+ matureUnpaid: 0,
59
+ });
60
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
61
+ // Two complete 205-source passes exercise allocation rollups under coverage.
62
+ }, 15_000);
63
+
64
+ it("preserves a financially bound collection across a crash before attempt, then cancels exactly", async () => {
65
+ const s = await largeHistory();
66
+ const mutation = s.ctx.runMutation!;
67
+ const fault = vi
68
+ .spyOn(s.ctx, "runMutation")
69
+ .mockImplementation(async (...args) => {
70
+ const [ref] = args;
71
+ const path = (ref as unknown as Record<symbol, string>)[
72
+ Symbol.for("toReferencePath")
73
+ ];
74
+ if (path.endsWith("payoutRelease/mutations/beginAttempt"))
75
+ throw new Error("before-attempt outage");
76
+ return mutation(args[0], args[1] ?? {});
77
+ });
78
+ await expect(s.run()).rejects.toThrow("before-attempt outage");
79
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
80
+ expect(
81
+ await s.t.run((ctx) => ctx.db.query("payoutSnapshots").first()),
82
+ ).toMatchObject({ readOnly: false, boundOperationKey: "release-1" });
83
+ fault.mockRestore();
84
+ expect(await s.run()).toMatchObject({
85
+ status: "terminal",
86
+ claim: { status: "canceled" },
87
+ });
88
+ const sources = await s.t.run((ctx) =>
89
+ ctx.db.query("payoutSourceAllocations").collect(),
90
+ );
91
+ expect(sources).toHaveLength(205);
92
+ expect(
93
+ sources.every((s) => s.reservedAmount === 0 && s.paidAmount === 0),
94
+ ).toBe(true);
95
+ expect(
96
+ await s.t.run((ctx) => ctx.db.query("payoutSnapshots").collect()),
97
+ ).toEqual([]);
98
+ });
99
+ it("does not replace an unknown large-history payout outcome", async () => {
100
+ const s = await largeHistory();
101
+ s.provider.payouts.create.mockRejectedValueOnce(
102
+ new Error("provider outcome unknown"),
103
+ );
104
+ await expect(s.run()).rejects.toThrow("provider outcome unknown");
105
+ expect(await s.run()).toMatchObject({
106
+ status: "held",
107
+ reason: "payout_unidentified",
108
+ });
109
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
110
+ const sources = await s.t.run((ctx) =>
111
+ ctx.db.query("payoutSourceAllocations").collect(),
112
+ );
113
+ expect(
114
+ sources.every((s) => s.reservedAmount === 5 && s.paidAmount === 0),
115
+ ).toBe(true);
116
+ expect(
117
+ await s.t.run((ctx) => ctx.db.query("payoutSnapshots").collect()),
118
+ ).toEqual([]);
119
+ });
120
+ it("a source reversal after reservation invalidates the financial binding before Stripe", async () => {
121
+ const s = await largeHistory();
122
+ const verified = await s.verify();
123
+ s.verify.mockResolvedValueOnce(verified).mockImplementationOnce(async () => {
124
+ await s.t.mutation(
125
+ makeFunctionReference<"mutation">("connect/mutations:upsertTransfer"),
126
+ {
127
+ stripeTransferId: "tr_0",
128
+ destinationAccountId: "acct_recipient",
129
+ sourceChargeId: "ch_0",
130
+ amount: 5,
131
+ currency: "usd",
132
+ status: "reversed",
133
+ reversedAmount: 1,
134
+ role: "store",
135
+ },
136
+ );
137
+ return verified;
138
+ });
139
+ await expect(s.run()).rejects.toThrow(/collection changed/);
140
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
141
+ expect(await s.run()).toMatchObject({
142
+ status: "terminal",
143
+ claim: { status: "canceled" },
144
+ });
145
+ const sources = await s.t.run((ctx) =>
146
+ ctx.db.query("payoutSourceAllocations").collect(),
147
+ );
148
+ expect(sources.every((s) => s.reservedAmount === 0)).toBe(true);
149
+ });
150
+
151
+ it("resumes a bounded collection under the same recurring worker operation", async () => {
152
+ const s = await largeHistory();
153
+ const worker = new PayoutWorkerClient(
154
+ component,
155
+ (_ctx, opts) => s.run(opts),
156
+ (_ctx, opts) =>
157
+ reconcileRecipientPayout(
158
+ s.provider as unknown as Stripe,
159
+ component,
160
+ s.ctx,
161
+ opts,
162
+ ),
163
+ );
164
+ const first = await worker.run(s.ctx, { ...scope, maxPages: 1 });
165
+ expect(first).toMatchObject({
166
+ status: "completed",
167
+ payout: { status: "held", reason: "collection_pending" },
168
+ job: { hasOperation: true },
169
+ });
170
+ if (first.status !== "completed")
171
+ throw new Error("Expected collection progress");
172
+ const clock = vi.spyOn(Date, "now").mockReturnValue(Date.now() + 300000);
173
+ try {
174
+ const next = await worker.run(s.ctx, { ...scope, maxPages: 10 });
175
+ expect(next).toMatchObject({
176
+ status: "completed",
177
+ payout: { claim: { amount: 1025, status: "paid" } },
178
+ job: { sequence: first.job.sequence, hasOperation: false },
179
+ });
180
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
181
+ } finally {
182
+ clock.mockRestore();
183
+ }
184
+ });
185
+
186
+ it("carries a complete larger history forward below the combined minimum", async () => {
187
+ const s = await largeHistory(4);
188
+ expect(await s.run()).toMatchObject({
189
+ status: "held",
190
+ reason: "below_threshold",
191
+ matureUnpaid: 820,
192
+ });
193
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
194
+ expect(
195
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
196
+ ).toEqual([]);
197
+ expect(
198
+ await s.t.run((ctx) => ctx.db.query("payoutSnapshots").collect()),
199
+ ).toEqual([]);
200
+ });
201
+ it("deducts known refund/dispute liabilities before qualifying a larger history", async () => {
202
+ const s = await largeHistory();
203
+ expect(await s.run({ independentLiabilities: 30 })).toMatchObject({
204
+ status: "held",
205
+ reason: "below_threshold",
206
+ matureUnpaid: 995,
207
+ });
208
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
209
+ });
210
+ it("does not verify provider data from another owner's historical claim", async () => {
211
+ const s = await largeHistory();
212
+ await s.t.run(async (ctx) => {
213
+ const claim = await ctx.db.query("payoutReleaseClaims").first();
214
+ await ctx.db.patch(claim!._id, { ownerKey: "other-owner" });
215
+ });
216
+ expect(await s.run()).toMatchObject({
217
+ status: "held",
218
+ reason: "collection_invalidated",
219
+ });
220
+ expect(s.provider.accounts.listExternalAccounts).not.toHaveBeenCalled();
221
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
222
+ });
@@ -0,0 +1,221 @@
1
+ // @vitest-environment edge-runtime
2
+ import { expect, it } from "vitest";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { setup } from "./payoutExecutionTestHelpers.js";
5
+ it("invalidates a liability version when refund costs increase before the attempt", async () => {
6
+ const s = await setup([600, 401]);
7
+ const cost = {
8
+ accountId: "acct_recipient",
9
+ scopeKey: "store:owner",
10
+ currency: "usd",
11
+ claimKey: "refund:new",
12
+ sourceChargeId: "ch_refunded",
13
+ sourceGross: 1001,
14
+ sourceProcessingFee: 2,
15
+ amountDue: 1,
16
+ revision: 1,
17
+ };
18
+ await s.t.mutation(
19
+ makeFunctionReference<"mutation">("costRecovery/mutations:record"),
20
+ cost,
21
+ );
22
+ const version = await s.t.run(
23
+ async (ctx) =>
24
+ (await ctx.db.query("payoutRiskRevisions").first())?.riskRevision ?? 0,
25
+ );
26
+ const verified = await s.verify();
27
+ s.verify.mockResolvedValueOnce(verified).mockImplementationOnce(async () => {
28
+ await s.t.mutation(
29
+ makeFunctionReference<"mutation">("costRecovery/mutations:record"),
30
+ { ...cost, amountDue: 2, revision: 2 },
31
+ );
32
+ return verified;
33
+ });
34
+ await expect(
35
+ s.run({ independentLiabilities: 1, liabilityRevision: version }),
36
+ ).rejects.toThrow(/liabilit|collection changed/);
37
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it.each(["new", "increase", "decrease"])(
41
+ "rejects %s recovery costs before attempt and pays only after a fresh quote",
42
+ async (kind) => {
43
+ const { CostRecoveryClient } = await import("../costRecovery.js");
44
+ const { component } = await import("./payoutExecutionTestHelpers.js");
45
+ const s = await setup([600, 405]);
46
+ const client = new CostRecoveryClient(component);
47
+ const cost = {
48
+ accountId: "acct_recipient",
49
+ scopeKey: "store:owner",
50
+ currency: "usd",
51
+ claimKey: "refund:new",
52
+ sourceChargeId: "ch_refunded",
53
+ sourceGross: 1005,
54
+ sourceProcessingFee: 2,
55
+ amountDue: kind === "new" ? 0 : kind === "increase" ? 1 : 2,
56
+ revision: 1,
57
+ };
58
+ await s.t.mutation(
59
+ makeFunctionReference<"mutation">("costRecovery/mutations:record"),
60
+ cost,
61
+ );
62
+ const quote = await client.payoutQuote(s.ctx, cost);
63
+ const changed = kind === "increase" ? 2 : 1;
64
+ const verified = await s.verify();
65
+ s.verify
66
+ .mockResolvedValueOnce(verified)
67
+ .mockImplementationOnce(async () => {
68
+ await s.t.mutation(
69
+ makeFunctionReference<"mutation">("costRecovery/mutations:record"),
70
+ { ...cost, amountDue: changed, revision: 2 },
71
+ );
72
+ return verified;
73
+ });
74
+ await expect(s.run(quote)).rejects.toThrow(/collection changed/);
75
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
76
+ expect(await s.run(quote)).toMatchObject({
77
+ status: "terminal",
78
+ claim: { status: "canceled" },
79
+ });
80
+ const fresh = await client.payoutQuote(s.ctx, cost);
81
+ expect(fresh.independentLiabilities).toBe(changed);
82
+ expect(fresh.liabilityRevision).toBeGreaterThan(quote.liabilityRevision);
83
+ expect(
84
+ await s.run({ ...fresh, operationKey: "fresh-payout" }),
85
+ ).toMatchObject({
86
+ status: "updated",
87
+ claim: {
88
+ status: "paid",
89
+ amount: 1005 - changed,
90
+ liabilityRevision: fresh.liabilityRevision,
91
+ },
92
+ });
93
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
94
+ },
95
+ );
96
+ it("accepts unchanged atomic quotes and excludes costs from another currency", async () => {
97
+ const { CostRecoveryClient } = await import("../costRecovery.js");
98
+ const { component } = await import("./payoutExecutionTestHelpers.js");
99
+ const s = await setup([600, 405]);
100
+ const client = new CostRecoveryClient(component);
101
+ const cost = {
102
+ accountId: "acct_recipient",
103
+ scopeKey: "store:owner",
104
+ currency: "eur",
105
+ claimKey: "refund:eur",
106
+ sourceChargeId: "ch_refunded",
107
+ sourceGross: 1005,
108
+ sourceProcessingFee: 2,
109
+ amountDue: 2,
110
+ revision: 1,
111
+ };
112
+ await s.t.mutation(
113
+ makeFunctionReference<"mutation">("costRecovery/mutations:record"),
114
+ cost,
115
+ );
116
+ const quote = await client.payoutQuote(s.ctx, { ...cost, currency: "usd" });
117
+ expect(quote.independentLiabilities).toBe(0);
118
+ await s.t.mutation(
119
+ makeFunctionReference<"mutation">("costRecovery/mutations:record"),
120
+ cost,
121
+ );
122
+ expect(await client.payoutQuote(s.ctx, { ...cost, currency: "usd" })).toEqual(
123
+ quote,
124
+ );
125
+ expect(await s.run(quote)).toMatchObject({
126
+ status: "updated",
127
+ claim: { status: "paid", amount: 1005, currency: "usd" },
128
+ });
129
+ });
130
+ it("rejects a quote that is already stale before qualification", async () => {
131
+ const { CostRecoveryClient } = await import("../costRecovery.js");
132
+ const { component } = await import("./payoutExecutionTestHelpers.js");
133
+ const s = await setup([600, 405]);
134
+ const client = new CostRecoveryClient(component);
135
+ const cost = {
136
+ accountId: "acct_recipient",
137
+ scopeKey: "store:owner",
138
+ currency: "usd",
139
+ claimKey: "refund:new",
140
+ sourceChargeId: "ch_refunded",
141
+ sourceGross: 1005,
142
+ sourceProcessingFee: 2,
143
+ amountDue: 1,
144
+ revision: 1,
145
+ };
146
+ await s.t.mutation(
147
+ makeFunctionReference<"mutation">("costRecovery/mutations:record"),
148
+ cost,
149
+ );
150
+ const quote = await client.payoutQuote(s.ctx, cost);
151
+ await s.t.mutation(
152
+ makeFunctionReference<"mutation">("costRecovery/mutations:record"),
153
+ { ...cost, amountDue: 2, revision: 2 },
154
+ );
155
+ await expect(s.run(quote)).rejects.toThrow(/liability version changed/);
156
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
157
+ });
158
+ it("fails safely when a legacy unversioned caller gains recovery debt before attempt", async () => {
159
+ const s = await setup();
160
+ const verified = await s.verify();
161
+ s.verify.mockResolvedValueOnce(verified).mockImplementationOnce(async () => {
162
+ await s.t.mutation(
163
+ makeFunctionReference<"mutation">("costRecovery/mutations:record"),
164
+ {
165
+ accountId: "acct_recipient",
166
+ scopeKey: "store:owner",
167
+ currency: "usd",
168
+ claimKey: "refund:new",
169
+ sourceChargeId: "ch_refunded",
170
+ sourceGross: 1000,
171
+ sourceProcessingFee: 1,
172
+ amountDue: 1,
173
+ revision: 1,
174
+ },
175
+ );
176
+ return verified;
177
+ });
178
+ await expect(s.run()).rejects.toThrow(/versioned recovery liability quote/);
179
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it("protects a versioned quote across a larger paginated collection", async () => {
183
+ const { CostRecoveryClient } = await import("../costRecovery.js");
184
+ const { component } = await import("./payoutExecutionTestHelpers.js");
185
+ const s = await setup(Array.from({ length: 205 }, () => 5));
186
+ const client = new CostRecoveryClient(component);
187
+ const cost = {
188
+ accountId: "acct_recipient",
189
+ scopeKey: "store:owner",
190
+ currency: "usd",
191
+ claimKey: "refund:new",
192
+ sourceChargeId: "ch_refunded",
193
+ sourceGross: 1025,
194
+ sourceProcessingFee: 2,
195
+ amountDue: 1,
196
+ revision: 1,
197
+ };
198
+ await s.t.mutation(
199
+ makeFunctionReference<"mutation">("costRecovery/mutations:record"),
200
+ cost,
201
+ );
202
+ const quote = await client.payoutQuote(s.ctx, cost);
203
+ const verified = await s.verify();
204
+ s.verify.mockResolvedValueOnce(verified).mockImplementationOnce(async () => {
205
+ await s.t.mutation(
206
+ makeFunctionReference<"mutation">("costRecovery/mutations:record"),
207
+ { ...cost, amountDue: 2, revision: 2 },
208
+ );
209
+ return verified;
210
+ });
211
+ await expect(s.run(quote)).rejects.toThrow(/collection changed/);
212
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
213
+ await s.run(quote);
214
+ const fresh = await client.payoutQuote(s.ctx, cost);
215
+ expect(await s.run({ ...fresh, operationKey: "fresh-large" })).toMatchObject({
216
+ status: "updated",
217
+ claim: { status: "paid", amount: 1023, independentLiabilities: 2 },
218
+ });
219
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
220
+ // This new integration case qualifies the full ledger twice, with a cancellation between.
221
+ }, 15_000);
@@ -1,3 +1,4 @@
1
+ import { discardQualificationInput } from "./payoutCollectedInput.js";
1
2
  import type Stripe from "stripe";
2
3
  import type { Infer } from "convex/values";
3
4
  import type { Component, RunCtx } from "../helpers.js";
@@ -13,6 +14,7 @@ export type PayoutPreviewOptions = {
13
14
  ownerKey: string;
14
15
  currency: string;
15
16
  independentLiabilities: number;
17
+ liabilityRevision?: number;
16
18
  maxPages?: number;
17
19
  };
18
20
  export type PayoutPreview =
@@ -51,27 +53,45 @@ export async function previewRecipientPayout(
51
53
  opts,
52
54
  maxPages,
53
55
  );
54
- if (proof.status !== "verified") return held(proof.reason);
55
- const latest = await verify();
56
- if (latest.status !== "verified" || !("policy" in latest))
57
- return held(latest.status);
58
- if (latest.policy.revision !== controls.policy.revision)
59
- return held("policy_changed");
60
- if (
61
- proof.observedAt < Date.now() - 30000 ||
62
- proof.observations.some((o) => o.observedAt < Date.now() - 30000)
63
- )
64
- return held("observations_stale");
65
- return ctx.runQuery(
66
- componentRef(component, "payoutRelease/queries/preview"),
67
- {
68
- accountId: opts.accountId,
69
- ownerKey: opts.ownerKey,
70
- currency: opts.currency,
71
- expectedRevision: controls.policy.revision,
72
- observations: proof.observations,
73
- providerAvailable: proof.providerAvailable,
74
- independentLiabilities: opts.independentLiabilities,
75
- },
76
- );
56
+ try {
57
+ if (proof.status !== "verified") return held(proof.reason);
58
+ const latest = await verify();
59
+ if (latest.status !== "verified" || !("policy" in latest))
60
+ return held(latest.status);
61
+ if (latest.policy.revision !== controls.policy.revision)
62
+ return held("policy_changed");
63
+ if (
64
+ proof.observedAt < Date.now() - 30000 ||
65
+ proof.observations.some(
66
+ (o) =>
67
+ o.currency === opts.currency && o.observedAt < Date.now() - 30000,
68
+ )
69
+ )
70
+ return held("observations_stale");
71
+ return await ctx.runQuery(
72
+ componentRef(component, "payoutRelease/queries/preview"),
73
+ {
74
+ accountId: opts.accountId,
75
+ ownerKey: opts.ownerKey,
76
+ currency: opts.currency,
77
+ expectedRevision: controls.policy.revision,
78
+ observations: proof.observations,
79
+ ...(opts.liabilityRevision === undefined
80
+ ? {}
81
+ : { liabilityRevision: opts.liabilityRevision }),
82
+ ...(proof.collection
83
+ ? { snapshotId: proof.collection.snapshotId }
84
+ : {}),
85
+ providerAvailable: proof.providerAvailable,
86
+ independentLiabilities: opts.independentLiabilities,
87
+ },
88
+ );
89
+ } finally {
90
+ await discardQualificationInput(
91
+ component,
92
+ ctx,
93
+ proof.collection,
94
+ proof.status === "held" && proof.reason === "collection_pending",
95
+ );
96
+ }
77
97
  }
@@ -1,3 +1,8 @@
1
+ import {
2
+ collectLargePayoutInput,
3
+ type CollectionRef,
4
+ type PayoutInput,
5
+ } from "./payoutCollectedInput.js";
1
6
  import type Stripe from "stripe";
2
7
  import type { Infer } from "convex/values";
3
8
  import type { Component, RunCtx } from "../helpers.js";
@@ -30,6 +35,8 @@ export type PayoutProofScope = {
30
35
  accountId: string;
31
36
  ownerKey: string;
32
37
  currency: string;
38
+ operationKey?: string;
39
+ liabilityRevision?: number;
33
40
  };
34
41
  /** Shared provider qualification. It can reconcile existing receipts, but never creates a bank payout. */
35
42
  export async function collectPayoutQualification(
@@ -39,20 +46,45 @@ export async function collectPayoutQualification(
39
46
  opts: PayoutProofScope,
40
47
  maxPages: number,
41
48
  ): Promise<
42
- | Held
49
+ | (Held & { collection?: CollectionRef })
43
50
  | {
44
51
  status: "verified";
52
+ collection?: CollectionRef;
45
53
  observations: Infer<typeof observation>[];
46
54
  destinationId: string;
47
55
  providerAvailable: number;
48
56
  observedAt: number;
49
57
  }
50
58
  > {
51
- const snapshot = (await ctx.runQuery(
59
+ let snapshot = (await ctx.runQuery(
52
60
  componentRef(component, "payoutRelease/queries/snapshot"),
53
61
  { accountId: opts.accountId, ownerKey: opts.ownerKey },
54
62
  )) as Snapshot;
55
- if (snapshot.status !== "ready") return snapshot;
63
+ let collection: CollectionRef | undefined;
64
+ if (snapshot.status !== "ready" || opts.liabilityRevision !== undefined) {
65
+ const large = await collectLargePayoutInput(component, ctx, opts, maxPages);
66
+ if (large.status !== "ready") return large;
67
+ collection = large.collection;
68
+ snapshot = large;
69
+ }
70
+ const result = await qualifyInput(
71
+ stripe,
72
+ component,
73
+ ctx,
74
+ opts,
75
+ maxPages,
76
+ snapshot,
77
+ );
78
+ return { ...result, ...(collection ? { collection } : {}) };
79
+ }
80
+ async function qualifyInput(
81
+ stripe: Stripe,
82
+ component: Component,
83
+ ctx: RunCtx,
84
+ opts: PayoutProofScope,
85
+ maxPages: number,
86
+ snapshot: PayoutInput,
87
+ ): Promise<Awaited<ReturnType<typeof collectPayoutQualification>>> {
56
88
  const banks: string[] = [];
57
89
  let bankCursor: string | undefined;
58
90
  for (let page = 0; ; page++) {
@@ -109,24 +141,33 @@ export async function collectPayoutQualification(
109
141
  transactions.push({ ...t, sourceId: result.receipt.stripePayoutId });
110
142
  }
111
143
  const observations = [];
112
- for (const row of snapshot.transfers) {
113
- const receipt = await getTransferFundingReceipt(stripe, component, ctx, {
114
- accountId: opts.accountId,
115
- stripeTransferId: row.stripeTransferId,
116
- });
117
- if (receipt.status !== "ready")
118
- return { status: "held", reason: receipt.reason };
119
- observations.push({
120
- stripeTransferId: row.stripeTransferId,
121
- currency: receipt.settlementCurrency,
122
- matureNet: receipt.matureNet,
123
- pendingCredits: receipt.pendingCredits,
124
- ledgerFingerprint: transferFingerprint(row),
125
- observedAt: receipt.observedAt,
126
- transactionIds: receipt.transactions.map((t) => t.id),
127
- });
128
- if (receipt.settlementCurrency === opts.currency)
129
- transactions.push(...receipt.transactions);
144
+ for (let i = 0; i < snapshot.transfers.length; i += 5) {
145
+ const batch = snapshot.transfers.slice(i, i + 5);
146
+ const receipts = await Promise.all(
147
+ batch.map((row) =>
148
+ getTransferFundingReceipt(stripe, component, ctx, {
149
+ accountId: opts.accountId,
150
+ stripeTransferId: row.stripeTransferId,
151
+ }),
152
+ ),
153
+ );
154
+ for (let j = 0; j < batch.length; j++) {
155
+ const row = batch[j],
156
+ receipt = receipts[j];
157
+ if (receipt.status !== "ready")
158
+ return { status: "held", reason: receipt.reason };
159
+ observations.push({
160
+ stripeTransferId: row.stripeTransferId,
161
+ currency: receipt.settlementCurrency,
162
+ matureNet: receipt.matureNet,
163
+ pendingCredits: receipt.pendingCredits,
164
+ ledgerFingerprint: transferFingerprint(row),
165
+ observedAt: receipt.observedAt,
166
+ transactionIds: receipt.transactions.map((t) => t.id),
167
+ });
168
+ if (receipt.settlementCurrency === opts.currency)
169
+ transactions.push(...receipt.transactions);
170
+ }
130
171
  }
131
172
  const history = await verifyPayoutHistory(stripe, {
132
173
  accountId: opts.accountId,
@@ -137,7 +178,13 @@ export async function collectPayoutQualification(
137
178
  if (history.status !== "verified") return history;
138
179
  return {
139
180
  status: "verified",
140
- observations,
181
+ // The complete currency history just matched every independently verified
182
+ // receipt again. Other currencies have not undergone that verification.
183
+ observations: observations.map((o) =>
184
+ o.currency === opts.currency
185
+ ? { ...o, observedAt: history.observedAt }
186
+ : o,
187
+ ),
141
188
  destinationId,
142
189
  providerAvailable: history.providerAvailable,
143
190
  observedAt: history.observedAt,