@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,247 @@
1
+ import type { Infer } from "convex/values";
2
+ import type { riskKind } from "./schema.js";
3
+ import type { Triggers } from "convex-helpers/server/triggers";
4
+ import type { DataModel, Doc } from "../_generated/dataModel.js";
5
+ import type { MutationCtx, QueryCtx } from "../_generated/server.js";
6
+ import { addSummaryAmounts as add } from "../earnings/arithmetic.js";
7
+ import {
8
+ transferFingerprint,
9
+ payoutClaimFingerprint,
10
+ } from "../payoutRelease/helpers.js";
11
+ export async function financialRevision(
12
+ ctx: QueryCtx | MutationCtx,
13
+ accountId: string,
14
+ ) {
15
+ const row = await ctx.db
16
+ .query("payoutFinancialRevisions")
17
+ .withIndex("by_account", (q) => q.eq("accountId", accountId))
18
+ .unique();
19
+ return { revision: row?.revision ?? 0, riskRevision: row?.riskRevision ?? 0 };
20
+ }
21
+ /** Stable fence: ordinary positive funding never writes this row. */
22
+ export async function riskRevision(
23
+ ctx: QueryCtx | MutationCtx,
24
+ accountId: string,
25
+ ) {
26
+ const risk = await ctx.db
27
+ .query("payoutRiskRevisions")
28
+ .withIndex("by_account", (q) => q.eq("accountId", accountId))
29
+ .unique();
30
+ if (risk) return risk.riskRevision;
31
+ return (await financialRevision(ctx, accountId)).riskRevision;
32
+ }
33
+ async function bump(
34
+ ctx: MutationCtx,
35
+ accountId: string,
36
+ risk: boolean,
37
+ kind: Infer<typeof riskKind>,
38
+ key: string,
39
+ removed = false,
40
+ ) {
41
+ const row = await ctx.db
42
+ .query("payoutFinancialRevisions")
43
+ .withIndex("by_account", (q) => q.eq("accountId", accountId))
44
+ .unique();
45
+ const fence = await ctx.db
46
+ .query("payoutRiskRevisions")
47
+ .withIndex("by_account", (q) => q.eq("accountId", accountId))
48
+ .unique();
49
+ if (row && fence && row.riskRevision !== fence.riskRevision)
50
+ throw new Error("Financial risk revisions disagree.");
51
+ const next = {
52
+ revision: add(row?.revision ?? 0, 1),
53
+ riskRevision: add(
54
+ fence?.riskRevision ?? row?.riskRevision ?? 0,
55
+ risk ? 1 : 0,
56
+ ),
57
+ };
58
+ if (row) await ctx.db.patch("payoutFinancialRevisions", row._id, next);
59
+ else await ctx.db.insert("payoutFinancialRevisions", { accountId, ...next });
60
+ if (!fence)
61
+ await ctx.db.insert("payoutRiskRevisions", {
62
+ accountId,
63
+ riskRevision: next.riskRevision,
64
+ });
65
+ else if (risk)
66
+ await ctx.db.patch("payoutRiskRevisions", fence._id, {
67
+ riskRevision: next.riskRevision,
68
+ });
69
+ if (risk)
70
+ await ctx.db.insert("payoutRiskEvents", {
71
+ accountId,
72
+ riskRevision: next.riskRevision,
73
+ kind,
74
+ key,
75
+ ...(removed ? { removed: true } : {}),
76
+ });
77
+ }
78
+ function positiveOnly(
79
+ old: Doc<"transfers"> | null,
80
+ next: Doc<"transfers"> | null,
81
+ ) {
82
+ if (!next) return false;
83
+ if (!old)
84
+ return (
85
+ next.amount >= 0 &&
86
+ (next.reversedAmount ?? 0) === 0 &&
87
+ (next.reversalClaimedAmount ?? 0) === 0 &&
88
+ next.status !== "reversed"
89
+ );
90
+ const identity = (t: Doc<"transfers">) =>
91
+ JSON.stringify([
92
+ t.stripeTransferId,
93
+ t.destinationAccountId,
94
+ t.sourceChargeId,
95
+ t.currency,
96
+ t.role,
97
+ t.reinstatement ?? false,
98
+ ]);
99
+ return (
100
+ identity(old) === identity(next) &&
101
+ next.amount >= old.amount &&
102
+ (next.reversedAmount ?? 0) <= (old.reversedAmount ?? 0) &&
103
+ (next.reversalClaimedAmount ?? 0) <= (old.reversalClaimedAmount ?? 0) &&
104
+ next.status !== "reversed" &&
105
+ !(old.status === "paid" && next.status !== "paid")
106
+ );
107
+ }
108
+ function payoutFingerprint(p: Doc<"payouts">) {
109
+ return JSON.stringify([
110
+ p.stripePayoutId,
111
+ p.accountId,
112
+ p.amount,
113
+ p.currency,
114
+ p.status,
115
+ p.arrivalDate,
116
+ p.method,
117
+ p.failureCode,
118
+ p.failureMessage,
119
+ ]);
120
+ }
121
+ /** Funding, payout claims and allocations commit with their revision counters. */
122
+ export function registerPayoutRevisions(triggers: Triggers<DataModel>) {
123
+ triggers.register("transfers", async (ctx, change) => {
124
+ if (
125
+ change.oldDoc &&
126
+ change.newDoc &&
127
+ transferFingerprint(change.oldDoc) === transferFingerprint(change.newDoc)
128
+ )
129
+ return;
130
+ const accounts = new Set([
131
+ change.oldDoc?.destinationAccountId,
132
+ change.newDoc?.destinationAccountId,
133
+ ]);
134
+ for (const account of accounts)
135
+ if (account)
136
+ await bump(
137
+ ctx,
138
+ account,
139
+ !positiveOnly(change.oldDoc, change.newDoc),
140
+ "transfer",
141
+ (change.newDoc ?? change.oldDoc)!.stripeTransferId,
142
+ change.newDoc === null,
143
+ );
144
+ });
145
+ triggers.register("payouts", async (ctx, change) => {
146
+ if (
147
+ change.oldDoc &&
148
+ change.newDoc &&
149
+ payoutFingerprint(change.oldDoc) === payoutFingerprint(change.newDoc)
150
+ )
151
+ return;
152
+ for (const account of new Set([
153
+ change.oldDoc?.accountId,
154
+ change.newDoc?.accountId,
155
+ ]))
156
+ if (account)
157
+ await bump(
158
+ ctx,
159
+ account,
160
+ true,
161
+ "provider_payout",
162
+ (change.newDoc ?? change.oldDoc)!.stripePayoutId,
163
+ change.newDoc === null,
164
+ );
165
+ });
166
+ triggers.register("payoutSourceAllocations", async (ctx, change) => {
167
+ const fp = (s: Doc<"payoutSourceAllocations">) =>
168
+ JSON.stringify([
169
+ s.accountId,
170
+ s.ownerKey,
171
+ s.stripeTransferId,
172
+ s.currency,
173
+ s.paidAmount,
174
+ s.reservedAmount,
175
+ ]);
176
+ if (
177
+ change.oldDoc &&
178
+ change.newDoc &&
179
+ fp(change.oldDoc) === fp(change.newDoc)
180
+ )
181
+ return;
182
+ for (const account of new Set([
183
+ change.oldDoc?.accountId,
184
+ change.newDoc?.accountId,
185
+ ]))
186
+ if (account)
187
+ await bump(
188
+ ctx,
189
+ account,
190
+ true,
191
+ "allocation",
192
+ (change.newDoc ?? change.oldDoc)!.stripeTransferId,
193
+ change.newDoc === null,
194
+ );
195
+ });
196
+ triggers.register("payoutReleaseClaims", async (ctx, change) => {
197
+ if (
198
+ change.oldDoc &&
199
+ change.newDoc &&
200
+ payoutClaimFingerprint(change.oldDoc) ===
201
+ payoutClaimFingerprint(change.newDoc)
202
+ )
203
+ return;
204
+ for (const account of new Set([
205
+ change.oldDoc?.accountId,
206
+ change.newDoc?.accountId,
207
+ ]))
208
+ if (account)
209
+ await bump(
210
+ ctx,
211
+ account,
212
+ true,
213
+ "claim",
214
+ (change.newDoc ?? change.oldDoc)!.operationKey,
215
+ change.newDoc === null,
216
+ );
217
+ });
218
+ triggers.register("payoutFeeBalances", async (ctx, change) => {
219
+ const fp = (s: Doc<"payoutFeeBalances">) =>
220
+ JSON.stringify([
221
+ s.accountId,
222
+ s.ownerKey,
223
+ s.currency,
224
+ s.amount,
225
+ s.revision,
226
+ ]);
227
+ if (
228
+ change.oldDoc &&
229
+ change.newDoc &&
230
+ fp(change.oldDoc) === fp(change.newDoc)
231
+ )
232
+ return;
233
+ for (const account of new Set([
234
+ change.oldDoc?.accountId,
235
+ change.newDoc?.accountId,
236
+ ]))
237
+ if (account)
238
+ await bump(
239
+ ctx,
240
+ account,
241
+ true,
242
+ "fee",
243
+ (change.newDoc ?? change.oldDoc)!.currency,
244
+ change.newDoc === null,
245
+ );
246
+ });
247
+ }
@@ -0,0 +1,125 @@
1
+ import { defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ export const financialRevisionsTable = defineTable({
4
+ accountId: v.string(),
5
+ revision: v.number(),
6
+ riskRevision: v.number(),
7
+ }).index("by_account", ["accountId"]);
8
+ export const riskKind = v.union(
9
+ v.literal("transfer"),
10
+ v.literal("provider_payout"),
11
+ v.literal("allocation"),
12
+ v.literal("claim"),
13
+ v.literal("fee"),
14
+ );
15
+ export const riskEventsTable = defineTable({
16
+ accountId: v.string(),
17
+ riskRevision: v.number(),
18
+ kind: riskKind,
19
+ key: v.string(),
20
+ removed: v.optional(v.boolean()),
21
+ }).index("by_account_revision", ["accountId", "riskRevision"]);
22
+ export const collectionPhase = v.union(
23
+ v.literal("sources"),
24
+ v.literal("allocations"),
25
+ v.literal("fees"),
26
+ v.literal("claims"),
27
+ v.literal("repair"),
28
+ v.literal("check"),
29
+ v.literal("local_ready"),
30
+ v.literal("invalidated"),
31
+ v.literal("discarding"),
32
+ );
33
+ export const snapshotFields = {
34
+ readOnly: v.boolean(),
35
+ accountId: v.string(),
36
+ ownerKey: v.string(),
37
+ operationKey: v.string(),
38
+ expectedRevision: v.number(),
39
+ policyMode: v.optional(v.string()),
40
+ policyOperationKey: v.optional(v.string()),
41
+ phase: collectionPhase,
42
+ step: v.number(),
43
+ sourceFence: v.number(),
44
+ sourceCursor: v.optional(v.string()),
45
+ allocationCursor: v.optional(v.string()),
46
+ feeCursor: v.optional(v.string()),
47
+ claimCursor: v.optional(v.string()),
48
+ initialRevision: v.number(),
49
+ initialRiskRevision: v.number(),
50
+ riskRevision: v.number(),
51
+ observedRevision: v.number(),
52
+ sourceCount: v.number(),
53
+ allocationCount: v.number(),
54
+ claimCount: v.number(),
55
+ feeCount: v.number(),
56
+ deferredPositiveChanges: v.boolean(),
57
+ providerChanged: v.boolean(),
58
+ expiresAt: v.number(),
59
+ reason: v.optional(v.string()),
60
+ };
61
+ export const snapshotDoc = v.object({
62
+ ...snapshotFields,
63
+ _id: v.id("payoutSnapshots"),
64
+ _creationTime: v.number(),
65
+ });
66
+ export const snapshotsTable = defineTable(snapshotFields)
67
+ .index("by_operation", ["operationKey"])
68
+ .index("by_read_only", ["readOnly"])
69
+ .index("by_account_phase", ["accountId", "phase"]);
70
+ export const snapshotSourcesTable = defineTable({
71
+ snapshotId: v.id("payoutSnapshots"),
72
+ stripeTransferId: v.string(),
73
+ ledgerFingerprint: v.string(),
74
+ sourceCreatedAt: v.number(),
75
+ currency: v.string(),
76
+ amount: v.number(),
77
+ status: v.string(),
78
+ role: v.optional(v.string()),
79
+ sourceChargeId: v.optional(v.string()),
80
+ reversedAmount: v.number(),
81
+ reversalClaimedAmount: v.number(),
82
+ reinstatement: v.boolean(),
83
+ allocationPresent: v.boolean(),
84
+ allocationCurrency: v.optional(v.string()),
85
+ paidAmount: v.number(),
86
+ reservedAmount: v.number(),
87
+ expectedPaid: v.number(),
88
+ expectedReserved: v.number(),
89
+ needsCheck: v.boolean(),
90
+ })
91
+ .index("by_snapshot", ["snapshotId"])
92
+ .index("by_snapshot_transfer", ["snapshotId", "stripeTransferId"])
93
+ .index("by_snapshot_check", ["snapshotId", "needsCheck"]);
94
+ export const snapshotClaimsTable = defineTable({
95
+ snapshotId: v.id("payoutSnapshots"),
96
+ operationKey: v.string(),
97
+ fingerprint: v.string(),
98
+ currency: v.string(),
99
+ amount: v.number(),
100
+ status: v.string(),
101
+ stripePayoutId: v.optional(v.string()),
102
+ historical: v.optional(v.boolean()),
103
+ providerFee: v.optional(v.number()),
104
+ allocations: v.array(
105
+ v.object({
106
+ stripeTransferId: v.string(),
107
+ amount: v.number(),
108
+ role: v.optional(v.string()),
109
+ }),
110
+ ),
111
+ })
112
+ .index("by_snapshot", ["snapshotId"])
113
+ .index("by_snapshot_operation", ["snapshotId", "operationKey"]);
114
+ export const snapshotFeesTable = defineTable({
115
+ snapshotId: v.id("payoutSnapshots"),
116
+ currency: v.string(),
117
+ amount: v.number(),
118
+ revision: v.number(),
119
+ })
120
+ .index("by_snapshot", ["snapshotId"])
121
+ .index("by_snapshot_currency", ["snapshotId", "currency"]);
122
+ export const riskRevisionsTable = defineTable({
123
+ accountId: v.string(),
124
+ riskRevision: v.number(),
125
+ }).index("by_account", ["accountId"]);
@@ -0,0 +1,7 @@
1
+ import { v } from "convex/values";
2
+ export { validateIdentity } from "../payoutRelease/helpers.js";
3
+ export const identity = {
4
+ accountId: v.string(),
5
+ ownerKey: v.string(),
6
+ operationKey: v.string(),
7
+ };
@@ -0,0 +1,30 @@
1
+ import type { MutationCtx, QueryCtx } from "../_generated/server.js";
2
+ import { validateScope } from "../payoutPolicy/helpers.js";
3
+ export function validate(args: {
4
+ accountId: string;
5
+ ownerKey: string;
6
+ currency: string;
7
+ }) {
8
+ validateScope(args);
9
+ if (!/^[a-z]{3}$/.test(args.currency))
10
+ throw new Error("Invalid worker currency.");
11
+ }
12
+ export async function readJob(
13
+ ctx: MutationCtx | QueryCtx,
14
+ args: { accountId: string; ownerKey: string; currency: string },
15
+ ) {
16
+ validate(args);
17
+ const job = await ctx.db
18
+ .query("payoutWorkerJobs")
19
+ .withIndex("by_account_currency", (q) =>
20
+ q.eq("accountId", args.accountId).eq("currency", args.currency),
21
+ )
22
+ .unique();
23
+ if (job && job.ownerKey !== args.ownerKey)
24
+ throw new Error("Payout worker owner does not match.");
25
+ return job;
26
+ }
27
+ export function pageSize(n: number) {
28
+ if (!Number.isInteger(n) || n < 1 || n > 25)
29
+ throw new Error("Invalid worker page size.");
30
+ }
@@ -0,0 +1,181 @@
1
+ import { v } from "convex/values";
2
+ import { mutation } from "../_generated/server.js";
3
+ import { readPolicy } from "../payoutPolicy/helpers.js";
4
+ import { claimByKey, add } from "../payoutRelease/helpers.js";
5
+ import { balance as recoveryBalance } from "../affiliateRecovery/accounting.js";
6
+ import { readJob, validate } from "./helpers.js";
7
+ import { scope, jobDoc } from "./schema.js";
8
+ const held = v.object({
9
+ status: v.literal("held"),
10
+ reason: v.string(),
11
+ nextAttemptAt: v.optional(v.number()),
12
+ });
13
+ /** A worker lease serializes observations; only the payout claim reserves money. */
14
+ export const begin = mutation({
15
+ args: { ...scope, cadenceMs: v.number() },
16
+ returns: v.union(
17
+ held,
18
+ v.object({
19
+ status: v.literal("run"),
20
+ job: jobDoc,
21
+ reconcileOnly: v.boolean(),
22
+ }),
23
+ ),
24
+ handler: async (ctx, args) => {
25
+ validate(args);
26
+ if (
27
+ !Number.isSafeInteger(args.cadenceMs) ||
28
+ args.cadenceMs < 60000 ||
29
+ args.cadenceMs > 86400000
30
+ )
31
+ throw new Error("Worker cadence must be between one minute and one day.");
32
+ const policy = await readPolicy(ctx, args);
33
+ let job = await readJob(ctx, args);
34
+ const now = Date.now();
35
+ if (job?.leaseUntil && job.leaseUntil > now)
36
+ return {
37
+ status: "held" as const,
38
+ reason: "worker_busy",
39
+ nextAttemptAt: job.leaseUntil,
40
+ };
41
+ if (job && job.nextAttemptAt > now)
42
+ return {
43
+ status: "held" as const,
44
+ reason: "not_due",
45
+ nextAttemptAt: job.nextAttemptAt,
46
+ };
47
+ const claim = job?.operationKey
48
+ ? await claimByKey(ctx, { ...args, operationKey: job.operationKey })
49
+ : null;
50
+ if (claim && claim.currency !== args.currency)
51
+ throw new Error("Worker payout currency changed.");
52
+ const unresolved =
53
+ claim?.status === "reserved" || claim?.status === "submitted";
54
+ if (
55
+ !unresolved &&
56
+ (policy?.mode !== "custom" ||
57
+ policy.status !== "ready" ||
58
+ !policy.holdDeadline ||
59
+ policy.holdDeadline <= now ||
60
+ !policy.thresholds.some((t) => t.currency === args.currency))
61
+ ) {
62
+ if (job)
63
+ await ctx.db.patch("payoutWorkerJobs", job._id, {
64
+ operationKey: undefined,
65
+ hasOperation: false,
66
+ leaseUntil: undefined,
67
+ lastReason: "policy_inactive",
68
+ nextAttemptAt: now + args.cadenceMs,
69
+ });
70
+ return { status: "held" as const, reason: "policy_inactive" };
71
+ }
72
+ if (!job) {
73
+ const id = await ctx.db.insert("payoutWorkerJobs", {
74
+ accountId: args.accountId,
75
+ ownerKey: args.ownerKey,
76
+ currency: args.currency,
77
+ generation: 0,
78
+ sequence: 0,
79
+ cadenceMs: args.cadenceMs,
80
+ nextAttemptAt: now,
81
+ hasOperation: false,
82
+ });
83
+ job = (await ctx.db.get("payoutWorkerJobs", id))!;
84
+ }
85
+ if (!unresolved) {
86
+ const recovery = await recoveryBalance(
87
+ ctx,
88
+ args.accountId,
89
+ args.currency,
90
+ );
91
+ if (
92
+ recovery &&
93
+ (recovery.outstandingDebt > 0 ||
94
+ recovery.reservedRecovery > 0 ||
95
+ recovery.reservedRestoration > 0 ||
96
+ recovery.activePayoutKey)
97
+ ) {
98
+ const nextAttemptAt = now + args.cadenceMs;
99
+ await ctx.db.patch("payoutWorkerJobs", job._id, {
100
+ operationKey: undefined,
101
+ hasOperation: false,
102
+ leaseUntil: undefined,
103
+ lastReason: "affiliate_recovery_pending",
104
+ nextAttemptAt,
105
+ });
106
+ return {
107
+ status: "held" as const,
108
+ reason: "affiliate_recovery_pending",
109
+ nextAttemptAt,
110
+ };
111
+ }
112
+ }
113
+ // A lost response with no claim is safe to resume at the same operation ID.
114
+ // A retained attempted claim always reconciles, including after opt-out.
115
+ const retain = job.operationKey && (!claim || unresolved);
116
+ const sequence = retain ? job.sequence : add(job.sequence, 1);
117
+ const operationKey = retain
118
+ ? job.operationKey!
119
+ : `bs:auto:${job._id}:${sequence}`;
120
+ await ctx.db.patch("payoutWorkerJobs", job._id, {
121
+ generation: add(job.generation, 1),
122
+ sequence,
123
+ operationKey,
124
+ hasOperation: true,
125
+ cadenceMs: args.cadenceMs,
126
+ leaseUntil: now + 600000,
127
+ lastAttemptAt: now,
128
+ });
129
+ return {
130
+ status: "run" as const,
131
+ job: (await ctx.db.get("payoutWorkerJobs", job._id))!,
132
+ reconcileOnly: unresolved && claim.attemptedAt !== undefined,
133
+ };
134
+ },
135
+ });
136
+ export const finish = mutation({
137
+ args: {
138
+ ...scope,
139
+ generation: v.number(),
140
+ operationKey: v.string(),
141
+ reason: v.string(),
142
+ },
143
+ returns: jobDoc,
144
+ handler: async (ctx, args) => {
145
+ const job = await readJob(ctx, args);
146
+ if (
147
+ job?.generation === args.generation &&
148
+ job.completedGeneration === args.generation
149
+ ) {
150
+ if (
151
+ job.completedOperationKey !== args.operationKey ||
152
+ job.lastReason !== args.reason
153
+ )
154
+ throw new Error("Payout worker result cannot be changed.");
155
+ return job;
156
+ }
157
+ if (
158
+ !job ||
159
+ job.generation !== args.generation ||
160
+ job.operationKey !== args.operationKey
161
+ )
162
+ throw new Error("Payout worker generation changed.");
163
+ if (!args.reason || args.reason.length > 120 || /[\r\n]/.test(args.reason))
164
+ throw new Error("Invalid worker result reason.");
165
+ const claim = await claimByKey(ctx, args);
166
+ if (claim && claim.currency !== job.currency)
167
+ throw new Error("Worker payout currency changed.");
168
+ const retain =
169
+ claim?.status === "reserved" || claim?.status === "submitted";
170
+ await ctx.db.patch("payoutWorkerJobs", job._id, {
171
+ operationKey: retain ? job.operationKey : undefined,
172
+ hasOperation: retain,
173
+ leaseUntil: undefined,
174
+ nextAttemptAt: Date.now() + job.cadenceMs,
175
+ lastReason: args.reason,
176
+ completedGeneration: args.generation,
177
+ completedOperationKey: args.operationKey,
178
+ });
179
+ return (await ctx.db.get("payoutWorkerJobs", job._id))!;
180
+ },
181
+ });
@@ -0,0 +1,64 @@
1
+ import { paginationOptsValidator } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import { query } from "../_generated/server.js";
4
+ import { policyDoc } from "../payoutPolicy/schema.js";
5
+ import { readJob, pageSize } from "./helpers.js";
6
+ import { jobDoc, scope } from "./schema.js";
7
+ export const get = query({
8
+ args: scope,
9
+ returns: v.union(jobDoc, v.null()),
10
+ handler: readJob,
11
+ });
12
+ export const policies = query({
13
+ args: { paginationOpts: paginationOptsValidator },
14
+ returns: v.object({
15
+ page: v.array(policyDoc),
16
+ continueCursor: v.string(),
17
+ isDone: v.boolean(),
18
+ }),
19
+ handler: async (ctx, args) => {
20
+ pageSize(args.paginationOpts.numItems);
21
+ const p = await ctx.db
22
+ .query("payoutPolicies")
23
+ .withIndex("by_mode_status", (q) =>
24
+ q.eq("mode", "custom").eq("status", "ready"),
25
+ )
26
+ .paginate(args.paginationOpts);
27
+ return { page: p.page, continueCursor: p.continueCursor, isDone: p.isDone };
28
+ },
29
+ });
30
+ export const changing = query({
31
+ args: { paginationOpts: paginationOptsValidator },
32
+ returns: v.object({
33
+ page: v.array(policyDoc),
34
+ continueCursor: v.string(),
35
+ isDone: v.boolean(),
36
+ }),
37
+ handler: async (ctx, args) => {
38
+ pageSize(args.paginationOpts.numItems);
39
+ const p = await ctx.db
40
+ .query("payoutPolicies")
41
+ .withIndex("by_status", (q) => q.eq("status", "changing"))
42
+ .paginate(args.paginationOpts);
43
+ return { page: p.page, continueCursor: p.continueCursor, isDone: p.isDone };
44
+ },
45
+ });
46
+ /** Includes attempted jobs after opt-out. It is not restricted to custom policies. */
47
+ export const recovering = query({
48
+ args: { paginationOpts: paginationOptsValidator },
49
+ returns: v.object({
50
+ page: v.array(jobDoc),
51
+ continueCursor: v.string(),
52
+ isDone: v.boolean(),
53
+ }),
54
+ handler: async (ctx, args) => {
55
+ pageSize(args.paginationOpts.numItems);
56
+ const p = await ctx.db
57
+ .query("payoutWorkerJobs")
58
+ .withIndex("by_operation_pending", (q) =>
59
+ q.eq("hasOperation", true).lte("nextAttemptAt", Date.now()),
60
+ )
61
+ .paginate(args.paginationOpts);
62
+ return { page: p.page, continueCursor: p.continueCursor, isDone: p.isDone };
63
+ },
64
+ });
@@ -0,0 +1,26 @@
1
+ import { defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import { scopeFields } from "../payoutPolicy/schema.js";
4
+ export const scope = { ...scopeFields, currency: v.string() };
5
+ export const fields = {
6
+ ...scope,
7
+ generation: v.number(),
8
+ sequence: v.number(),
9
+ cadenceMs: v.number(),
10
+ nextAttemptAt: v.number(),
11
+ leaseUntil: v.optional(v.number()),
12
+ operationKey: v.optional(v.string()),
13
+ hasOperation: v.boolean(),
14
+ lastReason: v.optional(v.string()),
15
+ lastAttemptAt: v.optional(v.number()),
16
+ completedGeneration: v.optional(v.number()),
17
+ completedOperationKey: v.optional(v.string()),
18
+ };
19
+ export const jobDoc = v.object({
20
+ ...fields,
21
+ _id: v.id("payoutWorkerJobs"),
22
+ _creationTime: v.number(),
23
+ });
24
+ export const jobsTable = defineTable(fields)
25
+ .index("by_account_currency", ["accountId", "currency"])
26
+ .index("by_operation_pending", ["hasOperation", "nextAttemptAt"]);
@@ -428,7 +428,7 @@ describe("core — clearAllTables", () => {
428
428
  accountId: "acct_clear",
429
429
  amount: 1000,
430
430
  currency: "usd",
431
- status: "pending",
431
+ status: "paid",
432
432
  });
433
433
 
434
434
  await t.mutation(api.connect.mutations.upsertRefund, {
@@ -471,7 +471,7 @@ describe("core — clearAllTables", () => {
471
471
  const result = await t.mutation(api.core.mutations.clearAllTables, {});
472
472
  const tableCount = Object.keys(schema.tables).length;
473
473
 
474
- expect(result.cleared).toBe(15);
474
+ expect(result.cleared).toBe(20);
475
475
  expect(result.tables).toHaveLength(tableCount);
476
476
 
477
477
  for (const tableName of Object.keys(schema.tables) as TableNames[]) {