@fantastic.dev/better-stripe 0.24.0 → 0.25.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 (147) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +7 -1
  3. package/dist/client/billing/checkout.d.ts +3 -43
  4. package/dist/client/billing/checkout.d.ts.map +1 -1
  5. package/dist/client/billing/checkout.js +5 -260
  6. package/dist/client/billing/checkout.js.map +1 -1
  7. package/dist/client/billing/checkoutRequest.d.ts +129 -0
  8. package/dist/client/billing/checkoutRequest.d.ts.map +1 -0
  9. package/dist/client/billing/checkoutRequest.js +263 -0
  10. package/dist/client/billing/checkoutRequest.js.map +1 -0
  11. package/dist/client/billing/preparedCheckout.d.ts +22 -0
  12. package/dist/client/billing/preparedCheckout.d.ts.map +1 -0
  13. package/dist/client/billing/preparedCheckout.js +86 -0
  14. package/dist/client/billing/preparedCheckout.js.map +1 -0
  15. package/dist/client/checkoutRequests.d.ts +80 -0
  16. package/dist/client/checkoutRequests.d.ts.map +1 -0
  17. package/dist/client/checkoutRequests.js +28 -0
  18. package/dist/client/checkoutRequests.js.map +1 -0
  19. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +464 -0
  20. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
  21. package/dist/client/connect/payoutCollection.d.ts +21 -0
  22. package/dist/client/connect/payoutCollection.d.ts.map +1 -0
  23. package/dist/client/connect/payoutCollection.js +47 -0
  24. package/dist/client/connect/payoutCollection.js.map +1 -0
  25. package/dist/client/connect/payoutExecutionTestHelpers.d.ts +1805 -0
  26. package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -0
  27. package/dist/client/connect/payoutExecutionTestHelpers.js +194 -0
  28. package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -0
  29. package/dist/client/connect/payoutSyncTestHelpers.d.ts +232 -0
  30. package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
  31. package/dist/client/connect/payoutWorker.d.ts +48 -0
  32. package/dist/client/connect/payoutWorker.d.ts.map +1 -0
  33. package/dist/client/connect/payoutWorker.js +78 -0
  34. package/dist/client/connect/payoutWorker.js.map +1 -0
  35. package/dist/client/index.d.ts +4 -11
  36. package/dist/client/index.d.ts.map +1 -1
  37. package/dist/client/index.js +4 -9
  38. package/dist/client/index.js.map +1 -1
  39. package/dist/client/payoutPolicy.d.ts +67 -0
  40. package/dist/client/payoutPolicy.d.ts.map +1 -1
  41. package/dist/client/payoutPolicy.js +10 -0
  42. package/dist/client/payoutPolicy.js.map +1 -1
  43. package/dist/client/readContracts.d.ts +11 -1
  44. package/dist/client/readContracts.d.ts.map +1 -1
  45. package/dist/client/readContracts.js +10 -0
  46. package/dist/client/readContracts.js.map +1 -1
  47. package/dist/component/_generated/component.d.ts +293 -0
  48. package/dist/component/_generated/component.d.ts.map +1 -1
  49. package/dist/component/core/mutations.d.ts.map +1 -1
  50. package/dist/component/core/mutations.js +2 -0
  51. package/dist/component/core/mutations.js.map +1 -1
  52. package/dist/component/core/resetSafety.d.ts +4 -0
  53. package/dist/component/core/resetSafety.d.ts.map +1 -0
  54. package/dist/component/core/resetSafety.js +54 -0
  55. package/dist/component/core/resetSafety.js.map +1 -0
  56. package/dist/component/earnings/triggers.d.ts +418 -0
  57. package/dist/component/earnings/triggers.d.ts.map +1 -1
  58. package/dist/component/earnings/triggers.js +2 -0
  59. package/dist/component/earnings/triggers.js.map +1 -1
  60. package/dist/component/payoutPolicy/schema.d.ts +2 -0
  61. package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
  62. package/dist/component/payoutPolicy/schema.js +3 -1
  63. package/dist/component/payoutPolicy/schema.js.map +1 -1
  64. package/dist/component/payoutRelease/mutations.js +1 -1
  65. package/dist/component/payoutRelease/schema.d.ts +1 -0
  66. package/dist/component/payoutRelease/schema.d.ts.map +1 -1
  67. package/dist/component/payoutRelease/schema.js +1 -0
  68. package/dist/component/payoutRelease/schema.js.map +1 -1
  69. package/dist/component/payoutSnapshots/localState.d.ts +89 -0
  70. package/dist/component/payoutSnapshots/localState.d.ts.map +1 -0
  71. package/dist/component/payoutSnapshots/localState.js +186 -0
  72. package/dist/component/payoutSnapshots/localState.js.map +1 -0
  73. package/dist/component/payoutSnapshots/mutations.d.ts +108 -0
  74. package/dist/component/payoutSnapshots/mutations.d.ts.map +1 -0
  75. package/dist/component/payoutSnapshots/mutations.js +263 -0
  76. package/dist/component/payoutSnapshots/mutations.js.map +1 -0
  77. package/dist/component/payoutSnapshots/revisions.d.ts +12 -0
  78. package/dist/component/payoutSnapshots/revisions.d.ts.map +1 -0
  79. package/dist/component/payoutSnapshots/revisions.js +173 -0
  80. package/dist/component/payoutSnapshots/revisions.js.map +1 -0
  81. package/dist/component/payoutSnapshots/schema.d.ts +285 -0
  82. package/dist/component/payoutSnapshots/schema.d.ts.map +1 -0
  83. package/dist/component/payoutSnapshots/schema.js +108 -0
  84. package/dist/component/payoutSnapshots/schema.js.map +1 -0
  85. package/dist/component/payoutSnapshots/scope.d.ts +7 -0
  86. package/dist/component/payoutSnapshots/scope.d.ts.map +1 -0
  87. package/dist/component/payoutSnapshots/scope.js +8 -0
  88. package/dist/component/payoutSnapshots/scope.js.map +1 -0
  89. package/dist/component/payoutWorker/helpers.d.ts +30 -0
  90. package/dist/component/payoutWorker/helpers.d.ts.map +1 -0
  91. package/dist/component/payoutWorker/helpers.js +21 -0
  92. package/dist/component/payoutWorker/helpers.js.map +1 -0
  93. package/dist/component/payoutWorker/mutations.d.ts +68 -0
  94. package/dist/component/payoutWorker/mutations.d.ts.map +1 -0
  95. package/dist/component/payoutWorker/mutations.js +161 -0
  96. package/dist/component/payoutWorker/mutations.js.map +1 -0
  97. package/dist/component/payoutWorker/queries.d.ts +123 -0
  98. package/dist/component/payoutWorker/queries.d.ts.map +1 -0
  99. package/dist/component/payoutWorker/queries.js +61 -0
  100. package/dist/component/payoutWorker/queries.js.map +1 -0
  101. package/dist/component/payoutWorker/schema.d.ts +91 -0
  102. package/dist/component/payoutWorker/schema.d.ts.map +1 -0
  103. package/dist/component/payoutWorker/schema.js +27 -0
  104. package/dist/component/payoutWorker/schema.js.map +1 -0
  105. package/dist/component/schema.d.ts +232 -0
  106. package/dist/component/schema.js +10 -0
  107. package/dist/component/schema.js.map +1 -1
  108. package/docs/read-contracts.md +48 -0
  109. package/docs/settlement.md +128 -1
  110. package/package.json +1 -1
  111. package/src/client/billing/checkout.ts +11 -359
  112. package/src/client/billing/checkoutRequest.ts +362 -0
  113. package/src/client/billing/prepared-checkout.test.ts +430 -0
  114. package/src/client/billing/preparedCheckout.ts +143 -0
  115. package/src/client/checkoutRequests.ts +40 -0
  116. package/src/client/connect/payoutCollection.test.ts +74 -0
  117. package/src/client/connect/payoutCollection.ts +82 -0
  118. package/src/client/connect/payoutExecution.test.ts +1 -211
  119. package/src/client/connect/payoutExecutionTestHelpers.ts +213 -0
  120. package/src/client/connect/payoutWorker.test.ts +145 -0
  121. package/src/client/connect/payoutWorker.ts +144 -0
  122. package/src/client/index.ts +10 -21
  123. package/src/client/payoutPolicy.ts +26 -1
  124. package/src/client/readContracts.ts +23 -0
  125. package/src/component/_generated/api.ts +16 -0
  126. package/src/component/_generated/component.ts +361 -0
  127. package/src/component/core/mutations.ts +2 -0
  128. package/src/component/core/resetSafety.ts +67 -0
  129. package/src/component/earnings/triggers.ts +2 -0
  130. package/src/component/payout-reset-safety.test.ts +148 -0
  131. package/src/component/payout-revisions.test.ts +125 -0
  132. package/src/component/payout-snapshots.test.ts +395 -0
  133. package/src/component/payout-worker.test.ts +165 -0
  134. package/src/component/payoutPolicy/schema.ts +3 -1
  135. package/src/component/payoutRelease/mutations.ts +1 -1
  136. package/src/component/payoutRelease/schema.ts +1 -0
  137. package/src/component/payoutSnapshots/localState.ts +238 -0
  138. package/src/component/payoutSnapshots/mutations.ts +275 -0
  139. package/src/component/payoutSnapshots/revisions.ts +247 -0
  140. package/src/component/payoutSnapshots/schema.ts +125 -0
  141. package/src/component/payoutSnapshots/scope.ts +7 -0
  142. package/src/component/payoutWorker/helpers.ts +30 -0
  143. package/src/component/payoutWorker/mutations.ts +181 -0
  144. package/src/component/payoutWorker/queries.ts +64 -0
  145. package/src/component/payoutWorker/schema.ts +26 -0
  146. package/src/component/public.test.ts +2 -2
  147. package/src/component/schema.ts +18 -0
@@ -0,0 +1,395 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { expect, it } from "vitest";
5
+ import type { Doc } from "./_generated/dataModel.js";
6
+ import schema from "./schema.js";
7
+ import { transferFingerprint } from "./payoutRelease/helpers.js";
8
+ const modules = import.meta.glob("./**/*.*s");
9
+ const m = (name: string) =>
10
+ makeFunctionReference<"mutation">(`payoutSnapshots/mutations:${name}`);
11
+ const cm = (name: string) =>
12
+ makeFunctionReference<"mutation">(`connect/mutations:${name}`);
13
+ const scope = {
14
+ accountId: "acct_one",
15
+ ownerKey: "owner",
16
+ operationKey: "collect-one",
17
+ };
18
+ async function setup(count = 151) {
19
+ const t = convexTest(schema, modules);
20
+ await t.mutation(cm("upsertTransfer"), {
21
+ stripeTransferId: "tr_one",
22
+ destinationAccountId: scope.accountId,
23
+ sourceChargeId: "ch_one",
24
+ amount: 1000,
25
+ currency: "usd",
26
+ role: "affiliate",
27
+ status: "paid",
28
+ });
29
+ await t.run(async (ctx) => {
30
+ for (let i = 1; i < count; i++)
31
+ await ctx.db.insert("transfers", {
32
+ stripeTransferId: `tr_${i}`,
33
+ destinationAccountId: scope.accountId,
34
+ amount: 1000,
35
+ currency: "usd",
36
+ role: "store",
37
+ status: "paid",
38
+ });
39
+ });
40
+ const begin = () =>
41
+ t.mutation(m("begin"), { ...scope, expectedRevision: -1 });
42
+ const step = (expectedStep: number, batchSize = 25) =>
43
+ t.mutation(m("advance"), { ...scope, expectedStep, batchSize });
44
+ async function finish(state: Doc<"payoutSnapshots">) {
45
+ for (
46
+ let i = 0;
47
+ i < 300 && state.phase !== "local_ready" && state.phase !== "invalidated";
48
+ i++
49
+ )
50
+ state = await step(state.step);
51
+ return state;
52
+ }
53
+ return { t, begin, step, finish };
54
+ }
55
+ it("collects more than 100 sources across durable pages without declaring partial eligibility", async () => {
56
+ const s = await setup(501);
57
+ let state = await s.begin();
58
+ expect(state).toMatchObject({ phase: "sources", sourceCount: 0 });
59
+ state = await s.step(state.step);
60
+ expect(state).toMatchObject({ phase: "sources", sourceCount: 25 });
61
+ state = await s.finish(state);
62
+ expect(state).toMatchObject({
63
+ phase: "local_ready",
64
+ sourceCount: 501,
65
+ allocationCount: 0,
66
+ claimCount: 0,
67
+ });
68
+ expect(
69
+ await s.t.run((ctx) => ctx.db.query("payoutSnapshotSources").collect()),
70
+ ).toHaveLength(501);
71
+ });
72
+ it("duplicate worker steps are idempotent", async () => {
73
+ const s = await setup();
74
+ await s.begin();
75
+ const states = await Promise.all([s.step(0), s.step(0)]);
76
+ expect(states.map((x) => x.step)).toEqual([1, 1]);
77
+ expect(
78
+ await s.t.run((ctx) => ctx.db.query("payoutSnapshotSources").collect()),
79
+ ).toHaveLength(25);
80
+ await expect(s.step(3)).rejects.toThrow(/step/i);
81
+ });
82
+ it("new positive income does not restart an older collection", async () => {
83
+ const s = await setup();
84
+ let state = await s.begin();
85
+ state = await s.step(0);
86
+ await s.t.mutation(cm("upsertTransfer"), {
87
+ stripeTransferId: "tr_later",
88
+ destinationAccountId: scope.accountId,
89
+ amount: 1000,
90
+ currency: "usd",
91
+ role: "affiliate",
92
+ status: "paid",
93
+ });
94
+ state = await s.finish(state);
95
+ expect(state).toMatchObject({
96
+ phase: "local_ready",
97
+ sourceCount: 151,
98
+ deferredPositiveChanges: true,
99
+ });
100
+ });
101
+ it("repairs a refund in an already-collected source without discarding other pages", async () => {
102
+ const s = await setup();
103
+ let state = await s.begin();
104
+ state = await s.step(0);
105
+ await s.t.mutation(cm("recordTransferReversal"), {
106
+ stripeTransferId: "tr_one",
107
+ reversedAmount: 100,
108
+ });
109
+ state = await s.finish(state);
110
+ expect(state).toMatchObject({
111
+ phase: "local_ready",
112
+ sourceCount: 151,
113
+ riskRevision: 1,
114
+ });
115
+ const original = await s.t.run((ctx) =>
116
+ ctx.db
117
+ .query("transfers")
118
+ .withIndex("by_stripe_transfer_id", (q) =>
119
+ q.eq("stripeTransferId", "tr_one"),
120
+ )
121
+ .unique(),
122
+ );
123
+ const cached = await s.t.run((ctx) =>
124
+ ctx.db
125
+ .query("payoutSnapshotSources")
126
+ .withIndex("by_snapshot_transfer", (q) =>
127
+ q.eq("snapshotId", state._id).eq("stripeTransferId", "tr_one"),
128
+ )
129
+ .unique(),
130
+ );
131
+ expect(cached?.ledgerFingerprint).toBe(transferFingerprint(original!));
132
+ });
133
+ it("retains cursor progress across a restarted caller", async () => {
134
+ const s = await setup();
135
+ await s.begin();
136
+ const first = await s.step(0);
137
+ const resumed = await s.begin();
138
+ expect(resumed.step).toBe(first.step);
139
+ expect(await s.finish(resumed)).toMatchObject({
140
+ phase: "local_ready",
141
+ sourceCount: 151,
142
+ });
143
+ });
144
+ it("collects all historical claims beyond the old 100-claim limit", async () => {
145
+ const s = await setup(1);
146
+ await s.t.run(async (ctx) => {
147
+ await ctx.db.insert("payoutSourceAllocations", {
148
+ accountId: scope.accountId,
149
+ ownerKey: scope.ownerKey,
150
+ stripeTransferId: "tr_one",
151
+ currency: "usd",
152
+ paidAmount: 201,
153
+ reservedAmount: 0,
154
+ });
155
+ for (let i = 0; i < 201; i++)
156
+ await ctx.db.insert("payoutReleaseClaims", {
157
+ accountId: scope.accountId,
158
+ ownerKey: scope.ownerKey,
159
+ operationKey: `old_${i}`,
160
+ expectedRevision: 0,
161
+ currency: "usd",
162
+ amount: 1,
163
+ allocations: [
164
+ { stripeTransferId: "tr_one", amount: 1, role: "affiliate" },
165
+ ],
166
+ observations: [],
167
+ status: "paid",
168
+ stripePayoutId: `po_${i}`,
169
+ attemptedAt: Date.now() - 1000,
170
+ });
171
+ });
172
+ const state = await s.finish(await s.begin());
173
+ expect(state).toMatchObject({
174
+ phase: "local_ready",
175
+ claimCount: 201,
176
+ allocationCount: 1,
177
+ });
178
+ expect(
179
+ await s.t.run((ctx) => ctx.db.query("payoutSnapshotClaims").collect()),
180
+ ).toHaveLength(201);
181
+ });
182
+ it("holds if an allocated source is missing", async () => {
183
+ const s = await setup(1);
184
+ await s.t.run((ctx) =>
185
+ ctx.db.insert("payoutSourceAllocations", {
186
+ accountId: scope.accountId,
187
+ ownerKey: scope.ownerKey,
188
+ stripeTransferId: "tr_missing",
189
+ currency: "usd",
190
+ paidAmount: 100,
191
+ reservedAmount: 0,
192
+ }),
193
+ );
194
+ expect(await s.finish(await s.begin())).toMatchObject({
195
+ phase: "invalidated",
196
+ reason: "source_missing",
197
+ });
198
+ });
199
+ it("never skips a missing risk-journal event", async () => {
200
+ const s = await setup();
201
+ let state = await s.begin();
202
+ state = await s.step(0);
203
+ await s.t.mutation(cm("recordTransferReversal"), {
204
+ stripeTransferId: "tr_one",
205
+ reversedAmount: 100,
206
+ });
207
+ await s.t.mutation(cm("recordTransferReversal"), {
208
+ stripeTransferId: "tr_one",
209
+ reversedAmount: 200,
210
+ });
211
+ await s.t.run(async (ctx) => {
212
+ const event = await ctx.db.query("payoutRiskEvents").first();
213
+ await ctx.db.delete("payoutRiskEvents", event!._id);
214
+ });
215
+ expect(await s.finish(state)).toMatchObject({
216
+ phase: "invalidated",
217
+ reason: "risk_history_incomplete",
218
+ });
219
+ });
220
+ it("policy revision changes stop a collection before it can be consumed", async () => {
221
+ const s = await setup();
222
+ let state = await s.begin();
223
+ await s.t.run((ctx) =>
224
+ ctx.db.insert("payoutPolicies", {
225
+ accountId: scope.accountId,
226
+ ownerKey: scope.ownerKey,
227
+ revision: 0,
228
+ mode: "stripe_managed",
229
+ status: "ready",
230
+ operationKey: "default",
231
+ country: "US",
232
+ thresholds: [],
233
+ }),
234
+ );
235
+ state = await s.step(state.step);
236
+ expect(state).toMatchObject({
237
+ phase: "invalidated",
238
+ reason: "policy_changed",
239
+ });
240
+ });
241
+ it("snapshot identity cannot be reused for another owner", async () => {
242
+ const s = await setup();
243
+ await s.begin();
244
+ await expect(
245
+ s.t.mutation(m("begin"), {
246
+ ...scope,
247
+ ownerKey: "other",
248
+ expectedRevision: -1,
249
+ }),
250
+ ).rejects.toThrow(/identity/i);
251
+ });
252
+ it("a paid-allocation mismatch never becomes local ready", async () => {
253
+ const s = await setup(1);
254
+ await s.t.run((ctx) =>
255
+ ctx.db.insert("payoutSourceAllocations", {
256
+ accountId: scope.accountId,
257
+ ownerKey: scope.ownerKey,
258
+ stripeTransferId: "tr_one",
259
+ currency: "usd",
260
+ paidAmount: 100,
261
+ reservedAmount: 0,
262
+ }),
263
+ );
264
+ expect(await s.finish(await s.begin())).toMatchObject({
265
+ phase: "invalidated",
266
+ reason: "source_allocation_mismatch",
267
+ });
268
+ });
269
+ it("repairs a returned historical payout and its paid source rollup together", async () => {
270
+ const s = await setup(1);
271
+ await s.t.run(async (ctx) => {
272
+ await ctx.db.insert("payoutSourceAllocations", {
273
+ accountId: scope.accountId,
274
+ ownerKey: scope.ownerKey,
275
+ stripeTransferId: "tr_one",
276
+ currency: "usd",
277
+ paidAmount: 1000,
278
+ reservedAmount: 0,
279
+ });
280
+ await ctx.db.insert("payoutReleaseClaims", {
281
+ accountId: scope.accountId,
282
+ ownerKey: scope.ownerKey,
283
+ operationKey: "old",
284
+ expectedRevision: 0,
285
+ currency: "usd",
286
+ amount: 1000,
287
+ allocations: [{ stripeTransferId: "tr_one", amount: 1000 }],
288
+ observations: [],
289
+ status: "paid",
290
+ stripePayoutId: "po_old",
291
+ attemptedAt: Date.now() - 1000,
292
+ });
293
+ });
294
+ let state = await s.finish(await s.begin());
295
+ expect(state.phase).toBe("local_ready");
296
+ await s.t.mutation(
297
+ makeFunctionReference<"mutation">("payoutRelease/mutations:recordOutcome"),
298
+ {
299
+ accountId: scope.accountId,
300
+ ownerKey: scope.ownerKey,
301
+ operationKey: "old",
302
+ stripePayoutId: "po_old",
303
+ amount: 1000,
304
+ currency: "usd",
305
+ providerStatus: "failed",
306
+ returnTransactionId: "txn_return",
307
+ },
308
+ );
309
+ state = await s.step(state.step);
310
+ state = await s.finish(state);
311
+ expect(state.phase).toBe("local_ready");
312
+ const source = await s.t.run((ctx) =>
313
+ ctx.db.query("payoutSnapshotSources").first(),
314
+ );
315
+ expect(source).toMatchObject({
316
+ paidAmount: 0,
317
+ expectedPaid: 0,
318
+ reservedAmount: 0,
319
+ expectedReserved: 0,
320
+ });
321
+ });
322
+ it("discarding a read-only collection removes caches without touching financial records", async () => {
323
+ const s = await setup(151);
324
+ let state = await s.finish(await s.begin());
325
+ const before = await s.t.run((ctx) =>
326
+ ctx.db.query("payoutFinancialRevisions").collect(),
327
+ );
328
+ for (let i = 0; i < 30 && state; i++)
329
+ state = await s.t.mutation(m("discard"), scope);
330
+ expect(state).toBeNull();
331
+ expect(
332
+ await s.t.run((ctx) => ctx.db.query("payoutSnapshotSources").collect()),
333
+ ).toEqual([]);
334
+ expect(
335
+ await s.t.run((ctx) => ctx.db.query("transfers").collect()),
336
+ ).toHaveLength(151);
337
+ expect(
338
+ await s.t.run((ctx) => ctx.db.query("payoutFinancialRevisions").collect()),
339
+ ).toEqual(before);
340
+ });
341
+ it("a financially bound collection cannot be discarded or expired as a read-only lease", async () => {
342
+ const s = await setup(1);
343
+ const state = await s.begin();
344
+ await s.t.run((ctx) =>
345
+ ctx.db.patch("payoutSnapshots", state._id, {
346
+ readOnly: false,
347
+ expiresAt: Date.now() - 1000,
348
+ }),
349
+ );
350
+ await expect(s.t.mutation(m("discard"), scope)).rejects.toThrow(/bound/i);
351
+ await expect(s.step(state.step)).rejects.toThrow(/bound/i);
352
+ });
353
+ it("retains source provenance even for a returned payout with no currently-paid principal", async () => {
354
+ const s = await setup(1);
355
+ await s.t.run((ctx) =>
356
+ ctx.db.insert("payoutReleaseClaims", {
357
+ accountId: scope.accountId,
358
+ ownerKey: scope.ownerKey,
359
+ operationKey: "returned",
360
+ expectedRevision: 0,
361
+ currency: "usd",
362
+ amount: 1000,
363
+ allocations: [{ stripeTransferId: "tr_missing", amount: 1000 }],
364
+ observations: [],
365
+ status: "returned",
366
+ stripePayoutId: "po_returned",
367
+ returnTransactionId: "txn_return",
368
+ attemptedAt: Date.now() - 1000,
369
+ }),
370
+ );
371
+ expect(await s.finish(await s.begin())).toMatchObject({
372
+ phase: "invalidated",
373
+ reason: "source_missing",
374
+ });
375
+ });
376
+
377
+ it("retains a risk-journal continuation when changes exceed one page", async () => {
378
+ const s = await setup(1);
379
+ let state = await s.begin();
380
+ while (state.phase !== "repair") state = await s.step(state.step);
381
+ for (let reversedAmount = 1; reversedAmount <= 31; reversedAmount++)
382
+ await s.t.mutation(cm("recordTransferReversal"), {
383
+ stripeTransferId: "tr_one",
384
+ reversedAmount,
385
+ });
386
+ state = await s.step(state.step);
387
+ expect(state).toMatchObject({ phase: "repair", riskRevision: 25 });
388
+ state = await s.step(state.step);
389
+ expect(state).toMatchObject({ phase: "check", riskRevision: 31 });
390
+ expect(await s.finish(state)).toMatchObject({
391
+ phase: "local_ready",
392
+ riskRevision: 31,
393
+ sourceCount: 1,
394
+ });
395
+ });
@@ -0,0 +1,165 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { expect, it, vi, afterEach } from "vitest";
5
+ import schema from "./schema.js";
6
+ const modules = import.meta.glob("./**/*.*s");
7
+ const m = (name: string) =>
8
+ makeFunctionReference<"mutation">(`payoutWorker/mutations:${name}`);
9
+ const q = (name: string) =>
10
+ makeFunctionReference<"query">(`payoutWorker/queries:${name}`);
11
+ const scope = { accountId: "acct_one", ownerKey: "owner", currency: "usd" };
12
+ const cadenceMs = 300000;
13
+ afterEach(() => vi.useRealTimers());
14
+ async function setup() {
15
+ const t = convexTest(schema, modules);
16
+ await t.run((ctx) =>
17
+ ctx.db.insert("payoutPolicies", {
18
+ accountId: scope.accountId,
19
+ ownerKey: scope.ownerKey,
20
+ mode: "custom",
21
+ status: "ready",
22
+ revision: 0,
23
+ operationKey: "enable",
24
+ country: "US",
25
+ thresholds: [{ currency: "usd", amount: 1000 }],
26
+ holdDeadline: Date.now() + 86400000,
27
+ }),
28
+ );
29
+ const begin = (extra = {}) =>
30
+ t.mutation(m("begin"), { ...scope, cadenceMs, ...extra });
31
+ return { t, begin };
32
+ }
33
+ it("leases one automatic attempt and stores the next attempt without creating a money claim", async () => {
34
+ const { t, begin } = await setup();
35
+ const first = await begin();
36
+ expect(first.status).toBe("run");
37
+ expect(first.job.operationKey).toMatch(/^bs:auto:/);
38
+ expect(await begin()).toMatchObject({
39
+ status: "held",
40
+ reason: "worker_busy",
41
+ });
42
+ const finished = await t.mutation(m("finish"), {
43
+ ...scope,
44
+ generation: first.job.generation,
45
+ operationKey: first.job.operationKey,
46
+ reason: "below_threshold",
47
+ });
48
+ expect(finished).toMatchObject({
49
+ lastReason: "below_threshold",
50
+ hasOperation: false,
51
+ });
52
+ expect(finished.nextAttemptAt).toBeGreaterThan(Date.now());
53
+ expect(await begin()).toMatchObject({ status: "held", reason: "not_due" });
54
+ expect(
55
+ await t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
56
+ ).toEqual([]);
57
+ });
58
+ it("resumes the same operation after a crashed lease and rejects a stale completion", async () => {
59
+ vi.useFakeTimers();
60
+ const { t, begin } = await setup();
61
+ const first = await begin();
62
+ vi.advanceTimersByTime(600001);
63
+ const resumed = await begin();
64
+ expect(resumed.job.operationKey).toBe(first.job.operationKey);
65
+ expect(resumed.job.generation).toBe(first.job.generation + 1);
66
+ await expect(
67
+ t.mutation(m("finish"), {
68
+ ...scope,
69
+ generation: first.job.generation,
70
+ operationKey: first.job.operationKey,
71
+ reason: "paid",
72
+ }),
73
+ ).rejects.toThrow(/generation/i);
74
+ });
75
+ it("keeps an uncertain attempted payout attached to the job across retries and opt-out", async () => {
76
+ vi.useFakeTimers();
77
+ const { t, begin } = await setup();
78
+ const first = await begin();
79
+ await t.run(async (ctx) => {
80
+ await ctx.db.insert("payoutReleaseClaims", {
81
+ ...scope,
82
+ operationKey: first.job.operationKey,
83
+ expectedRevision: 0,
84
+ amount: 1000,
85
+ allocations: [],
86
+ observations: [],
87
+ status: "reserved",
88
+ attemptedAt: Date.now(),
89
+ });
90
+ const policy = await ctx.db.query("payoutPolicies").first();
91
+ await ctx.db.patch("payoutPolicies", policy!._id, {
92
+ mode: "stripe_managed",
93
+ thresholds: [],
94
+ revision: 1,
95
+ });
96
+ });
97
+ const finished = await t.mutation(m("finish"), {
98
+ ...scope,
99
+ generation: first.job.generation,
100
+ operationKey: first.job.operationKey,
101
+ reason: "payout_unidentified",
102
+ });
103
+ expect(finished.operationKey).toBe(first.job.operationKey);
104
+ vi.advanceTimersByTime(cadenceMs);
105
+ const resumed = await begin();
106
+ expect(resumed).toMatchObject({ status: "run", reconcileOnly: true });
107
+ expect(resumed.job.operationKey).toBe(first.job.operationKey);
108
+ });
109
+ it("never starts a new payout for default, changing, expired or unconfigured policies", async () => {
110
+ const { t, begin } = await setup();
111
+ for (const change of [
112
+ { mode: "stripe_managed" as const },
113
+ { mode: "custom" as const, status: "changing" as const },
114
+ { status: "ready" as const, holdDeadline: Date.now() - 1 },
115
+ { holdDeadline: Date.now() + 86400000, thresholds: [] },
116
+ ]) {
117
+ await t.run(async (ctx) => {
118
+ const p = await ctx.db.query("payoutPolicies").first();
119
+ await ctx.db.patch("payoutPolicies", p!._id, change);
120
+ });
121
+ expect((await begin()).status).toBe("held");
122
+ }
123
+ });
124
+ it("scopes worker state and rejects invalid cadence before creating a job", async () => {
125
+ const { t, begin } = await setup();
126
+ await expect(begin({ ownerKey: "foreign" })).rejects.toThrow(/owner/i);
127
+ for (const value of [0, -1, 1000, NaN, 86400001])
128
+ await expect(begin({ cadenceMs: value })).rejects.toThrow();
129
+ expect(
130
+ await t.run((ctx) => ctx.db.query("payoutWorkerJobs").collect()),
131
+ ).toEqual([]);
132
+ });
133
+ it("completion replay preserves the original next attempt time", async () => {
134
+ vi.useFakeTimers();
135
+ const { t, begin } = await setup();
136
+ const started = await begin();
137
+ const args = {
138
+ ...scope,
139
+ generation: started.job.generation,
140
+ operationKey: started.job.operationKey,
141
+ reason: "below_threshold",
142
+ };
143
+ const first = await t.mutation(m("finish"), args);
144
+ vi.advanceTimersByTime(1000);
145
+ expect(await t.mutation(m("finish"), args)).toEqual(first);
146
+ await expect(
147
+ t.mutation(m("finish"), { ...args, reason: "paid" }),
148
+ ).rejects.toThrow(/result/i);
149
+ });
150
+ it("discovers custom policies and retained jobs on separate bounded pages", async () => {
151
+ const { t, begin } = await setup();
152
+ await begin();
153
+ const policies = await t.query(q("policies"), {
154
+ paginationOpts: { cursor: null, numItems: 1 },
155
+ });
156
+ expect(policies.page).toHaveLength(1);
157
+ expect(policies.page[0].accountId).toBe(scope.accountId);
158
+ const jobs = await t.query(q("recovering"), {
159
+ paginationOpts: { cursor: null, numItems: 1 },
160
+ });
161
+ expect(jobs.page).toHaveLength(1);
162
+ await expect(
163
+ t.query(q("get"), { ...scope, ownerKey: "foreign" }),
164
+ ).rejects.toThrow(/owner/i);
165
+ });
@@ -69,7 +69,9 @@ export const operationDoc = v.object({
69
69
  });
70
70
  export const policiesTable = defineTable(policyFields)
71
71
  .index("by_account", ["accountId"])
72
- .index("by_deadline", ["holdDeadline"]);
72
+ .index("by_deadline", ["holdDeadline"])
73
+ .index("by_status", ["status"])
74
+ .index("by_mode_status", ["mode", "status"]);
73
75
  export const operationsTable = defineTable(operationFields).index(
74
76
  "by_operation",
75
77
  ["operationKey"],
@@ -8,7 +8,7 @@ import {
8
8
  } from "./adoption.js";
9
9
  import { readFees, recordFees } from "./fees.js";
10
10
  import { v } from "convex/values";
11
- import { mutation } from "../_generated/server.js";
11
+ import { mutation } from "../earnings/triggers.js";
12
12
  import { identity, observation, claimDoc } from "./schema.js";
13
13
  import {
14
14
  claimByKey,
@@ -53,6 +53,7 @@ export const claimDoc = v.object({
53
53
  export const claimsTable = defineTable(claimFields)
54
54
  .index("by_operation", ["operationKey"])
55
55
  .index("by_account_status", ["accountId", "status"])
56
+ .index("by_status", ["status"])
56
57
  .index("by_provider_payout", ["stripePayoutId"])
57
58
  .index("by_debit_receipt", ["debitTransactionId"])
58
59
  .index("by_return_receipt", ["returnTransactionId"])