@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.
- package/CHANGELOG.md +7 -0
- package/README.md +7 -1
- package/dist/client/billing/checkout.d.ts +3 -43
- package/dist/client/billing/checkout.d.ts.map +1 -1
- package/dist/client/billing/checkout.js +5 -260
- package/dist/client/billing/checkout.js.map +1 -1
- package/dist/client/billing/checkoutRequest.d.ts +129 -0
- package/dist/client/billing/checkoutRequest.d.ts.map +1 -0
- package/dist/client/billing/checkoutRequest.js +263 -0
- package/dist/client/billing/checkoutRequest.js.map +1 -0
- package/dist/client/billing/preparedCheckout.d.ts +22 -0
- package/dist/client/billing/preparedCheckout.d.ts.map +1 -0
- package/dist/client/billing/preparedCheckout.js +86 -0
- package/dist/client/billing/preparedCheckout.js.map +1 -0
- package/dist/client/checkoutRequests.d.ts +80 -0
- package/dist/client/checkoutRequests.d.ts.map +1 -0
- package/dist/client/checkoutRequests.js +28 -0
- package/dist/client/checkoutRequests.js.map +1 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +464 -0
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutCollection.d.ts +21 -0
- package/dist/client/connect/payoutCollection.d.ts.map +1 -0
- package/dist/client/connect/payoutCollection.js +47 -0
- package/dist/client/connect/payoutCollection.js.map +1 -0
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts +1805 -0
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -0
- package/dist/client/connect/payoutExecutionTestHelpers.js +194 -0
- package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts +232 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutWorker.d.ts +48 -0
- package/dist/client/connect/payoutWorker.d.ts.map +1 -0
- package/dist/client/connect/payoutWorker.js +78 -0
- package/dist/client/connect/payoutWorker.js.map +1 -0
- package/dist/client/index.d.ts +4 -11
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +4 -9
- package/dist/client/index.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +67 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +10 -0
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/client/readContracts.d.ts +11 -1
- package/dist/client/readContracts.d.ts.map +1 -1
- package/dist/client/readContracts.js +10 -0
- package/dist/client/readContracts.js.map +1 -1
- package/dist/component/_generated/component.d.ts +293 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/core/mutations.d.ts.map +1 -1
- package/dist/component/core/mutations.js +2 -0
- package/dist/component/core/mutations.js.map +1 -1
- package/dist/component/core/resetSafety.d.ts +4 -0
- package/dist/component/core/resetSafety.d.ts.map +1 -0
- package/dist/component/core/resetSafety.js +54 -0
- package/dist/component/core/resetSafety.js.map +1 -0
- package/dist/component/earnings/triggers.d.ts +418 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/earnings/triggers.js +2 -0
- package/dist/component/earnings/triggers.js.map +1 -1
- package/dist/component/payoutPolicy/schema.d.ts +2 -0
- package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.js +3 -1
- package/dist/component/payoutPolicy/schema.js.map +1 -1
- package/dist/component/payoutRelease/mutations.js +1 -1
- package/dist/component/payoutRelease/schema.d.ts +1 -0
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +1 -0
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/payoutSnapshots/localState.d.ts +89 -0
- package/dist/component/payoutSnapshots/localState.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/localState.js +186 -0
- package/dist/component/payoutSnapshots/localState.js.map +1 -0
- package/dist/component/payoutSnapshots/mutations.d.ts +108 -0
- package/dist/component/payoutSnapshots/mutations.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/mutations.js +263 -0
- package/dist/component/payoutSnapshots/mutations.js.map +1 -0
- package/dist/component/payoutSnapshots/revisions.d.ts +12 -0
- package/dist/component/payoutSnapshots/revisions.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/revisions.js +173 -0
- package/dist/component/payoutSnapshots/revisions.js.map +1 -0
- package/dist/component/payoutSnapshots/schema.d.ts +285 -0
- package/dist/component/payoutSnapshots/schema.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/schema.js +108 -0
- package/dist/component/payoutSnapshots/schema.js.map +1 -0
- package/dist/component/payoutSnapshots/scope.d.ts +7 -0
- package/dist/component/payoutSnapshots/scope.d.ts.map +1 -0
- package/dist/component/payoutSnapshots/scope.js +8 -0
- package/dist/component/payoutSnapshots/scope.js.map +1 -0
- package/dist/component/payoutWorker/helpers.d.ts +30 -0
- package/dist/component/payoutWorker/helpers.d.ts.map +1 -0
- package/dist/component/payoutWorker/helpers.js +21 -0
- package/dist/component/payoutWorker/helpers.js.map +1 -0
- package/dist/component/payoutWorker/mutations.d.ts +68 -0
- package/dist/component/payoutWorker/mutations.d.ts.map +1 -0
- package/dist/component/payoutWorker/mutations.js +161 -0
- package/dist/component/payoutWorker/mutations.js.map +1 -0
- package/dist/component/payoutWorker/queries.d.ts +123 -0
- package/dist/component/payoutWorker/queries.d.ts.map +1 -0
- package/dist/component/payoutWorker/queries.js +61 -0
- package/dist/component/payoutWorker/queries.js.map +1 -0
- package/dist/component/payoutWorker/schema.d.ts +91 -0
- package/dist/component/payoutWorker/schema.d.ts.map +1 -0
- package/dist/component/payoutWorker/schema.js +27 -0
- package/dist/component/payoutWorker/schema.js.map +1 -0
- package/dist/component/schema.d.ts +232 -0
- package/dist/component/schema.js +10 -0
- package/dist/component/schema.js.map +1 -1
- package/docs/read-contracts.md +48 -0
- package/docs/settlement.md +128 -1
- package/package.json +1 -1
- package/src/client/billing/checkout.ts +11 -359
- package/src/client/billing/checkoutRequest.ts +362 -0
- package/src/client/billing/prepared-checkout.test.ts +430 -0
- package/src/client/billing/preparedCheckout.ts +143 -0
- package/src/client/checkoutRequests.ts +40 -0
- package/src/client/connect/payoutCollection.test.ts +74 -0
- package/src/client/connect/payoutCollection.ts +82 -0
- package/src/client/connect/payoutExecution.test.ts +1 -211
- package/src/client/connect/payoutExecutionTestHelpers.ts +213 -0
- package/src/client/connect/payoutWorker.test.ts +145 -0
- package/src/client/connect/payoutWorker.ts +144 -0
- package/src/client/index.ts +10 -21
- package/src/client/payoutPolicy.ts +26 -1
- package/src/client/readContracts.ts +23 -0
- package/src/component/_generated/api.ts +16 -0
- package/src/component/_generated/component.ts +361 -0
- package/src/component/core/mutations.ts +2 -0
- package/src/component/core/resetSafety.ts +67 -0
- package/src/component/earnings/triggers.ts +2 -0
- package/src/component/payout-reset-safety.test.ts +148 -0
- package/src/component/payout-revisions.test.ts +125 -0
- package/src/component/payout-snapshots.test.ts +395 -0
- package/src/component/payout-worker.test.ts +165 -0
- package/src/component/payoutPolicy/schema.ts +3 -1
- package/src/component/payoutRelease/mutations.ts +1 -1
- package/src/component/payoutRelease/schema.ts +1 -0
- package/src/component/payoutSnapshots/localState.ts +238 -0
- package/src/component/payoutSnapshots/mutations.ts +275 -0
- package/src/component/payoutSnapshots/revisions.ts +247 -0
- package/src/component/payoutSnapshots/schema.ts +125 -0
- package/src/component/payoutSnapshots/scope.ts +7 -0
- package/src/component/payoutWorker/helpers.ts +30 -0
- package/src/component/payoutWorker/mutations.ts +181 -0
- package/src/component/payoutWorker/queries.ts +64 -0
- package/src/component/payoutWorker/schema.ts +26 -0
- package/src/component/public.test.ts +2 -2
- package/src/component/schema.ts +18 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server.js";
|
|
2
|
+
import type { Doc } from "../_generated/dataModel.js";
|
|
3
|
+
import {
|
|
4
|
+
validateIdentity,
|
|
5
|
+
transferFingerprint,
|
|
6
|
+
payoutClaimFingerprint,
|
|
7
|
+
sourceById,
|
|
8
|
+
add,
|
|
9
|
+
} from "../payoutRelease/helpers.js";
|
|
10
|
+
export type Snapshot = Doc<"payoutSnapshots">;
|
|
11
|
+
export type Counts = Pick<
|
|
12
|
+
Snapshot,
|
|
13
|
+
"sourceCount" | "allocationCount" | "claimCount" | "feeCount"
|
|
14
|
+
>;
|
|
15
|
+
export class CollectionError extends Error {
|
|
16
|
+
constructor(readonly reason: string) {
|
|
17
|
+
super(reason);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function ownedSnapshot(
|
|
21
|
+
ctx: MutationCtx | QueryCtx,
|
|
22
|
+
args: { accountId: string; ownerKey: string; operationKey: string },
|
|
23
|
+
) {
|
|
24
|
+
validateIdentity(args);
|
|
25
|
+
const row = await ctx.db
|
|
26
|
+
.query("payoutSnapshots")
|
|
27
|
+
.withIndex("by_operation", (q) => q.eq("operationKey", args.operationKey))
|
|
28
|
+
.unique();
|
|
29
|
+
if (
|
|
30
|
+
row &&
|
|
31
|
+
(row.accountId !== args.accountId || row.ownerKey !== args.ownerKey)
|
|
32
|
+
)
|
|
33
|
+
throw new Error("Collection identity cannot be reused.");
|
|
34
|
+
return row;
|
|
35
|
+
}
|
|
36
|
+
function amount(n: number) {
|
|
37
|
+
if (!Number.isSafeInteger(n) || n < 0)
|
|
38
|
+
throw new CollectionError("financial_arithmetic");
|
|
39
|
+
return n;
|
|
40
|
+
}
|
|
41
|
+
async function sourceCache(ctx: MutationCtx, h: Snapshot, id: string) {
|
|
42
|
+
return ctx.db
|
|
43
|
+
.query("payoutSnapshotSources")
|
|
44
|
+
.withIndex("by_snapshot_transfer", (q) =>
|
|
45
|
+
q.eq("snapshotId", h._id).eq("stripeTransferId", id),
|
|
46
|
+
)
|
|
47
|
+
.unique();
|
|
48
|
+
}
|
|
49
|
+
/** Copy actual local economics. This is not a provider receipt or payout authorization. */
|
|
50
|
+
export async function cacheSource(
|
|
51
|
+
ctx: MutationCtx,
|
|
52
|
+
h: Snapshot,
|
|
53
|
+
id: string,
|
|
54
|
+
counts: Counts,
|
|
55
|
+
provided?: Doc<"transfers">,
|
|
56
|
+
) {
|
|
57
|
+
const row =
|
|
58
|
+
provided ??
|
|
59
|
+
(await ctx.db
|
|
60
|
+
.query("transfers")
|
|
61
|
+
.withIndex("by_stripe_transfer_id", (q) => q.eq("stripeTransferId", id))
|
|
62
|
+
.unique());
|
|
63
|
+
if (!row || row.stripeTransferId !== id)
|
|
64
|
+
throw new CollectionError("source_missing");
|
|
65
|
+
if (row.destinationAccountId !== h.accountId)
|
|
66
|
+
throw new CollectionError("source_scope_changed");
|
|
67
|
+
amount(row.amount);
|
|
68
|
+
amount(row.reversedAmount ?? 0);
|
|
69
|
+
amount(row.reversalClaimedAmount ?? 0);
|
|
70
|
+
const allocated = await sourceById(ctx, id);
|
|
71
|
+
if (
|
|
72
|
+
allocated &&
|
|
73
|
+
(allocated.accountId !== h.accountId || allocated.ownerKey !== h.ownerKey)
|
|
74
|
+
)
|
|
75
|
+
throw new CollectionError("allocation_identity_changed");
|
|
76
|
+
const old = await sourceCache(ctx, h, id);
|
|
77
|
+
const allocationPresent = allocated !== null;
|
|
78
|
+
const fields = {
|
|
79
|
+
stripeTransferId: id,
|
|
80
|
+
ledgerFingerprint: transferFingerprint(row),
|
|
81
|
+
sourceCreatedAt: row._creationTime,
|
|
82
|
+
currency: row.currency,
|
|
83
|
+
amount: row.amount,
|
|
84
|
+
status: row.status,
|
|
85
|
+
role: row.role,
|
|
86
|
+
sourceChargeId: row.sourceChargeId,
|
|
87
|
+
reversedAmount: row.reversedAmount ?? 0,
|
|
88
|
+
reversalClaimedAmount: row.reversalClaimedAmount ?? 0,
|
|
89
|
+
reinstatement: row.reinstatement ?? false,
|
|
90
|
+
allocationPresent,
|
|
91
|
+
allocationCurrency: allocated?.currency,
|
|
92
|
+
paidAmount: amount(allocated?.paidAmount ?? 0),
|
|
93
|
+
reservedAmount: amount(allocated?.reservedAmount ?? 0),
|
|
94
|
+
needsCheck: true,
|
|
95
|
+
};
|
|
96
|
+
if (old) {
|
|
97
|
+
if (old.allocationPresent && !allocationPresent)
|
|
98
|
+
throw new CollectionError("allocation_removed");
|
|
99
|
+
if (!old.allocationPresent && allocationPresent)
|
|
100
|
+
counts.allocationCount = add(counts.allocationCount, 1);
|
|
101
|
+
await ctx.db.patch("payoutSnapshotSources", old._id, fields);
|
|
102
|
+
return { ...old, ...fields };
|
|
103
|
+
}
|
|
104
|
+
const sourceId = await ctx.db.insert("payoutSnapshotSources", {
|
|
105
|
+
snapshotId: h._id,
|
|
106
|
+
...fields,
|
|
107
|
+
expectedPaid: 0,
|
|
108
|
+
expectedReserved: 0,
|
|
109
|
+
});
|
|
110
|
+
counts.sourceCount = add(counts.sourceCount, 1);
|
|
111
|
+
if (allocationPresent)
|
|
112
|
+
counts.allocationCount = add(counts.allocationCount, 1);
|
|
113
|
+
return { _id: sourceId, ...fields, expectedPaid: 0, expectedReserved: 0 };
|
|
114
|
+
}
|
|
115
|
+
async function applyClaimContribution(
|
|
116
|
+
ctx: MutationCtx,
|
|
117
|
+
h: Snapshot,
|
|
118
|
+
claim: Pick<
|
|
119
|
+
Doc<"payoutSnapshotClaims">,
|
|
120
|
+
"allocations" | "status" | "currency"
|
|
121
|
+
>,
|
|
122
|
+
sign: 1 | -1,
|
|
123
|
+
counts: Counts,
|
|
124
|
+
) {
|
|
125
|
+
const paid = claim.status === "paid",
|
|
126
|
+
reserved = claim.status === "reserved" || claim.status === "submitted";
|
|
127
|
+
if (!paid && !reserved) return;
|
|
128
|
+
for (const a of claim.allocations) {
|
|
129
|
+
const source =
|
|
130
|
+
(await sourceCache(ctx, h, a.stripeTransferId)) ??
|
|
131
|
+
(await cacheSource(ctx, h, a.stripeTransferId, counts));
|
|
132
|
+
if (
|
|
133
|
+
source.allocationCurrency !== undefined &&
|
|
134
|
+
source.allocationCurrency !== claim.currency
|
|
135
|
+
)
|
|
136
|
+
throw new CollectionError("allocation_currency_changed");
|
|
137
|
+
const expectedPaid = add(source.expectedPaid, paid ? sign * a.amount : 0),
|
|
138
|
+
expectedReserved = add(
|
|
139
|
+
source.expectedReserved,
|
|
140
|
+
reserved ? sign * a.amount : 0,
|
|
141
|
+
);
|
|
142
|
+
if (expectedPaid < 0 || expectedReserved < 0)
|
|
143
|
+
throw new CollectionError("allocation_rollup_underflow");
|
|
144
|
+
await ctx.db.patch("payoutSnapshotSources", source._id, {
|
|
145
|
+
expectedPaid,
|
|
146
|
+
expectedReserved,
|
|
147
|
+
needsCheck: true,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export async function cacheClaim(
|
|
152
|
+
ctx: MutationCtx,
|
|
153
|
+
h: Snapshot,
|
|
154
|
+
key: string,
|
|
155
|
+
counts: Counts,
|
|
156
|
+
) {
|
|
157
|
+
const row = await ctx.db
|
|
158
|
+
.query("payoutReleaseClaims")
|
|
159
|
+
.withIndex("by_operation", (q) => q.eq("operationKey", key))
|
|
160
|
+
.unique();
|
|
161
|
+
if (!row) throw new CollectionError("claim_removed");
|
|
162
|
+
if (row.accountId !== h.accountId || row.ownerKey !== h.ownerKey)
|
|
163
|
+
throw new CollectionError("claim_identity_changed");
|
|
164
|
+
let total = 0;
|
|
165
|
+
const ids = new Set<string>();
|
|
166
|
+
for (const a of row.allocations) {
|
|
167
|
+
if (ids.has(a.stripeTransferId) || amount(a.amount) === 0)
|
|
168
|
+
throw new CollectionError("allocation_amount_mismatch");
|
|
169
|
+
ids.add(a.stripeTransferId);
|
|
170
|
+
total = add(total, a.amount);
|
|
171
|
+
}
|
|
172
|
+
if (total !== row.amount || total <= 0)
|
|
173
|
+
throw new CollectionError("allocation_amount_mismatch");
|
|
174
|
+
for (const a of row.allocations)
|
|
175
|
+
if (!(await sourceCache(ctx, h, a.stripeTransferId)))
|
|
176
|
+
await cacheSource(ctx, h, a.stripeTransferId, counts);
|
|
177
|
+
const old = await ctx.db
|
|
178
|
+
.query("payoutSnapshotClaims")
|
|
179
|
+
.withIndex("by_snapshot_operation", (q) =>
|
|
180
|
+
q.eq("snapshotId", h._id).eq("operationKey", key),
|
|
181
|
+
)
|
|
182
|
+
.unique();
|
|
183
|
+
const fingerprint = payoutClaimFingerprint(row);
|
|
184
|
+
if (old?.fingerprint === fingerprint) return;
|
|
185
|
+
if (old) await applyClaimContribution(ctx, h, old, -1, counts);
|
|
186
|
+
const fields = {
|
|
187
|
+
operationKey: key,
|
|
188
|
+
fingerprint,
|
|
189
|
+
currency: row.currency,
|
|
190
|
+
amount: row.amount,
|
|
191
|
+
status: row.status,
|
|
192
|
+
stripePayoutId: row.stripePayoutId,
|
|
193
|
+
historical: row.historical,
|
|
194
|
+
providerFee: row.providerFee,
|
|
195
|
+
allocations: row.allocations,
|
|
196
|
+
};
|
|
197
|
+
await applyClaimContribution(ctx, h, fields, 1, counts);
|
|
198
|
+
if (old) await ctx.db.patch("payoutSnapshotClaims", old._id, fields);
|
|
199
|
+
else {
|
|
200
|
+
await ctx.db.insert("payoutSnapshotClaims", {
|
|
201
|
+
snapshotId: h._id,
|
|
202
|
+
...fields,
|
|
203
|
+
});
|
|
204
|
+
counts.claimCount = add(counts.claimCount, 1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export async function cacheFee(
|
|
208
|
+
ctx: MutationCtx,
|
|
209
|
+
h: Snapshot,
|
|
210
|
+
currency: string,
|
|
211
|
+
counts: Counts,
|
|
212
|
+
) {
|
|
213
|
+
const row = await ctx.db
|
|
214
|
+
.query("payoutFeeBalances")
|
|
215
|
+
.withIndex("by_account_currency", (q) =>
|
|
216
|
+
q.eq("accountId", h.accountId).eq("currency", currency),
|
|
217
|
+
)
|
|
218
|
+
.unique();
|
|
219
|
+
if (!row) throw new CollectionError("fee_ledger_removed");
|
|
220
|
+
if (row.ownerKey !== h.ownerKey)
|
|
221
|
+
throw new CollectionError("fee_identity_changed");
|
|
222
|
+
const old = await ctx.db
|
|
223
|
+
.query("payoutSnapshotFees")
|
|
224
|
+
.withIndex("by_snapshot_currency", (q) =>
|
|
225
|
+
q.eq("snapshotId", h._id).eq("currency", currency),
|
|
226
|
+
)
|
|
227
|
+
.unique();
|
|
228
|
+
const fields = {
|
|
229
|
+
currency,
|
|
230
|
+
amount: amount(row.amount),
|
|
231
|
+
revision: amount(row.revision),
|
|
232
|
+
};
|
|
233
|
+
if (old) await ctx.db.patch("payoutSnapshotFees", old._id, fields);
|
|
234
|
+
else {
|
|
235
|
+
await ctx.db.insert("payoutSnapshotFees", { snapshotId: h._id, ...fields });
|
|
236
|
+
counts.feeCount = add(counts.feeCount, 1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation } from "../earnings/triggers.js";
|
|
3
|
+
import { readPolicy } from "../payoutPolicy/helpers.js";
|
|
4
|
+
import { identity, validateIdentity } from "./scope.js";
|
|
5
|
+
import { snapshotDoc } from "./schema.js";
|
|
6
|
+
import { financialRevision, riskRevision } from "./revisions.js";
|
|
7
|
+
import {
|
|
8
|
+
ownedSnapshot,
|
|
9
|
+
cacheSource,
|
|
10
|
+
cacheClaim,
|
|
11
|
+
cacheFee,
|
|
12
|
+
CollectionError,
|
|
13
|
+
type Counts,
|
|
14
|
+
type Snapshot,
|
|
15
|
+
} from "./localState.js";
|
|
16
|
+
export const begin = mutation({
|
|
17
|
+
args: { ...identity, expectedRevision: v.number() },
|
|
18
|
+
returns: snapshotDoc,
|
|
19
|
+
handler: async (ctx, args) => {
|
|
20
|
+
validateIdentity(args);
|
|
21
|
+
if (
|
|
22
|
+
!Number.isSafeInteger(args.expectedRevision) ||
|
|
23
|
+
args.expectedRevision < -1
|
|
24
|
+
)
|
|
25
|
+
throw new Error("Invalid collection policy revision.");
|
|
26
|
+
const old = await ownedSnapshot(ctx, args);
|
|
27
|
+
if (old) {
|
|
28
|
+
if (old.expectedRevision !== args.expectedRevision)
|
|
29
|
+
throw new Error(
|
|
30
|
+
"Collection identity cannot be reused at another revision.",
|
|
31
|
+
);
|
|
32
|
+
return old;
|
|
33
|
+
}
|
|
34
|
+
const policy = await readPolicy(ctx, args);
|
|
35
|
+
if ((policy?.revision ?? -1) !== args.expectedRevision)
|
|
36
|
+
throw new Error("Collection policy revision changed.");
|
|
37
|
+
const revision = await financialRevision(ctx, args.accountId);
|
|
38
|
+
const newest = await ctx.db
|
|
39
|
+
.query("transfers")
|
|
40
|
+
.withIndex("by_destination_account_id", (q) =>
|
|
41
|
+
q.eq("destinationAccountId", args.accountId),
|
|
42
|
+
)
|
|
43
|
+
.order("desc")
|
|
44
|
+
.first();
|
|
45
|
+
const id = await ctx.db.insert("payoutSnapshots", {
|
|
46
|
+
...args,
|
|
47
|
+
readOnly: true,
|
|
48
|
+
policyMode: policy?.mode,
|
|
49
|
+
policyOperationKey: policy?.operationKey,
|
|
50
|
+
phase: "sources",
|
|
51
|
+
step: 0,
|
|
52
|
+
sourceFence: newest?._creationTime ?? 0,
|
|
53
|
+
initialRevision: revision.revision,
|
|
54
|
+
initialRiskRevision: revision.riskRevision,
|
|
55
|
+
riskRevision: revision.riskRevision,
|
|
56
|
+
observedRevision: revision.revision,
|
|
57
|
+
sourceCount: 0,
|
|
58
|
+
allocationCount: 0,
|
|
59
|
+
claimCount: 0,
|
|
60
|
+
feeCount: 0,
|
|
61
|
+
deferredPositiveChanges: false,
|
|
62
|
+
providerChanged: false,
|
|
63
|
+
expiresAt: Date.now() + 86400000,
|
|
64
|
+
});
|
|
65
|
+
return (await ctx.db.get("payoutSnapshots", id))!;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
export const advance = mutation({
|
|
69
|
+
args: {
|
|
70
|
+
...identity,
|
|
71
|
+
expectedStep: v.number(),
|
|
72
|
+
batchSize: v.optional(v.number()),
|
|
73
|
+
},
|
|
74
|
+
returns: snapshotDoc,
|
|
75
|
+
handler: async (ctx, args) => {
|
|
76
|
+
let h = await ownedSnapshot(ctx, args);
|
|
77
|
+
if (!h) throw new Error("Collection is unavailable.");
|
|
78
|
+
if (!h.readOnly) throw new Error("Collection is financially bound.");
|
|
79
|
+
if (h.phase === "discarding") return h;
|
|
80
|
+
if (
|
|
81
|
+
!Number.isSafeInteger(args.expectedStep) ||
|
|
82
|
+
args.expectedStep < 0 ||
|
|
83
|
+
h.step < args.expectedStep
|
|
84
|
+
)
|
|
85
|
+
throw new Error("Invalid collection step.");
|
|
86
|
+
const batch = args.batchSize ?? (h.phase === "claims" ? 5 : 25);
|
|
87
|
+
if (!Number.isInteger(batch) || batch < 1 || batch > 25)
|
|
88
|
+
throw new Error("Collection batch must be between 1 and 25.");
|
|
89
|
+
if (h.phase === "invalidated") return h;
|
|
90
|
+
const policy = await readPolicy(ctx, args);
|
|
91
|
+
const invalidate = async (reason: string) => {
|
|
92
|
+
await ctx.db.patch("payoutSnapshots", h!._id, {
|
|
93
|
+
phase: "invalidated",
|
|
94
|
+
reason,
|
|
95
|
+
step: h!.step + 1,
|
|
96
|
+
});
|
|
97
|
+
return (await ctx.db.get("payoutSnapshots", h!._id))!;
|
|
98
|
+
};
|
|
99
|
+
if (
|
|
100
|
+
(policy?.revision ?? -1) !== h.expectedRevision ||
|
|
101
|
+
policy?.mode !== h.policyMode ||
|
|
102
|
+
policy?.operationKey !== h.policyOperationKey
|
|
103
|
+
)
|
|
104
|
+
return invalidate("policy_changed");
|
|
105
|
+
if (h.expiresAt <= Date.now()) return invalidate("collection_expired");
|
|
106
|
+
const currentRisk = await riskRevision(ctx, h.accountId);
|
|
107
|
+
if (h.phase === "local_ready") {
|
|
108
|
+
if (currentRisk === h.riskRevision) return h;
|
|
109
|
+
await ctx.db.patch("payoutSnapshots", h._id, { phase: "repair" });
|
|
110
|
+
h = (await ctx.db.get("payoutSnapshots", h._id))!;
|
|
111
|
+
}
|
|
112
|
+
if (h.step > args.expectedStep) return h;
|
|
113
|
+
const counts: Counts = {
|
|
114
|
+
sourceCount: h.sourceCount,
|
|
115
|
+
allocationCount: h.allocationCount,
|
|
116
|
+
claimCount: h.claimCount,
|
|
117
|
+
feeCount: h.feeCount,
|
|
118
|
+
};
|
|
119
|
+
const patch: Partial<Snapshot> = { step: h.step + 1 };
|
|
120
|
+
try {
|
|
121
|
+
if (h.phase === "sources") {
|
|
122
|
+
const page = await ctx.db
|
|
123
|
+
.query("transfers")
|
|
124
|
+
.withIndex("by_destination_account_id", (q) =>
|
|
125
|
+
q
|
|
126
|
+
.eq("destinationAccountId", h!.accountId)
|
|
127
|
+
.lte("_creationTime", h!.sourceFence),
|
|
128
|
+
)
|
|
129
|
+
.paginate({ cursor: h.sourceCursor ?? null, numItems: batch });
|
|
130
|
+
for (const row of page.page)
|
|
131
|
+
await cacheSource(ctx, h, row.stripeTransferId, counts, row);
|
|
132
|
+
patch.sourceCursor = page.continueCursor;
|
|
133
|
+
patch.phase = page.isDone ? "allocations" : "sources";
|
|
134
|
+
} else if (h.phase === "allocations") {
|
|
135
|
+
const page = await ctx.db
|
|
136
|
+
.query("payoutSourceAllocations")
|
|
137
|
+
.withIndex("by_account", (q) => q.eq("accountId", h!.accountId))
|
|
138
|
+
.paginate({ cursor: h.allocationCursor ?? null, numItems: batch });
|
|
139
|
+
for (const row of page.page)
|
|
140
|
+
await cacheSource(ctx, h, row.stripeTransferId, counts);
|
|
141
|
+
patch.allocationCursor = page.continueCursor;
|
|
142
|
+
patch.phase = page.isDone ? "fees" : "allocations";
|
|
143
|
+
} else if (h.phase === "fees") {
|
|
144
|
+
const page = await ctx.db
|
|
145
|
+
.query("payoutFeeBalances")
|
|
146
|
+
.withIndex("by_account_currency", (q) =>
|
|
147
|
+
q.eq("accountId", h!.accountId),
|
|
148
|
+
)
|
|
149
|
+
.paginate({ cursor: h.feeCursor ?? null, numItems: batch });
|
|
150
|
+
for (const row of page.page)
|
|
151
|
+
await cacheFee(ctx, h, row.currency, counts);
|
|
152
|
+
patch.feeCursor = page.continueCursor;
|
|
153
|
+
patch.phase = page.isDone ? "claims" : "fees";
|
|
154
|
+
} else if (h.phase === "claims") {
|
|
155
|
+
const page = await ctx.db
|
|
156
|
+
.query("payoutReleaseClaims")
|
|
157
|
+
.withIndex("by_account_status", (q) =>
|
|
158
|
+
q.eq("accountId", h!.accountId),
|
|
159
|
+
)
|
|
160
|
+
.paginate({ cursor: h.claimCursor ?? null, numItems: batch });
|
|
161
|
+
for (const row of page.page)
|
|
162
|
+
await cacheClaim(ctx, h, row.operationKey, counts);
|
|
163
|
+
patch.claimCursor = page.continueCursor;
|
|
164
|
+
patch.phase = page.isDone ? "repair" : "claims";
|
|
165
|
+
} else if (h.phase === "repair") {
|
|
166
|
+
const events = await ctx.db
|
|
167
|
+
.query("payoutRiskEvents")
|
|
168
|
+
.withIndex("by_account_revision", (q) =>
|
|
169
|
+
q
|
|
170
|
+
.eq("accountId", h!.accountId)
|
|
171
|
+
.gt("riskRevision", h!.riskRevision)
|
|
172
|
+
.lte("riskRevision", currentRisk),
|
|
173
|
+
)
|
|
174
|
+
.take(batch);
|
|
175
|
+
let risk = h.riskRevision;
|
|
176
|
+
for (const event of events) {
|
|
177
|
+
if (event.riskRevision !== risk + 1)
|
|
178
|
+
throw new CollectionError("risk_history_incomplete");
|
|
179
|
+
if (event.removed) throw new CollectionError(`${event.kind}_removed`);
|
|
180
|
+
if (event.kind === "transfer" || event.kind === "allocation")
|
|
181
|
+
await cacheSource(ctx, h, event.key, counts);
|
|
182
|
+
else if (event.kind === "claim")
|
|
183
|
+
await cacheClaim(ctx, h, event.key, counts);
|
|
184
|
+
else if (event.kind === "fee")
|
|
185
|
+
await cacheFee(ctx, h, event.key, counts);
|
|
186
|
+
else patch.providerChanged = true;
|
|
187
|
+
risk = event.riskRevision;
|
|
188
|
+
}
|
|
189
|
+
if (!events.length && risk !== currentRisk)
|
|
190
|
+
throw new CollectionError("risk_history_incomplete");
|
|
191
|
+
patch.riskRevision = risk;
|
|
192
|
+
patch.phase = risk === currentRisk ? "check" : "repair";
|
|
193
|
+
} else if (h.phase === "check") {
|
|
194
|
+
if (currentRisk !== h.riskRevision) patch.phase = "repair";
|
|
195
|
+
else {
|
|
196
|
+
const pending = await ctx.db
|
|
197
|
+
.query("payoutSnapshotSources")
|
|
198
|
+
.withIndex("by_snapshot_check", (q) =>
|
|
199
|
+
q.eq("snapshotId", h!._id).eq("needsCheck", true),
|
|
200
|
+
)
|
|
201
|
+
.take(batch);
|
|
202
|
+
for (const s of pending) {
|
|
203
|
+
if (
|
|
204
|
+
s.expectedPaid !== s.paidAmount ||
|
|
205
|
+
s.expectedReserved !== s.reservedAmount
|
|
206
|
+
)
|
|
207
|
+
throw new CollectionError("source_allocation_mismatch");
|
|
208
|
+
await ctx.db.patch("payoutSnapshotSources", s._id, {
|
|
209
|
+
needsCheck: false,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (!pending.length) {
|
|
213
|
+
patch.phase = "local_ready";
|
|
214
|
+
const revision = await financialRevision(ctx, h.accountId);
|
|
215
|
+
patch.observedRevision = revision.revision;
|
|
216
|
+
patch.deferredPositiveChanges =
|
|
217
|
+
revision.revision - h.initialRevision >
|
|
218
|
+
currentRisk - h.initialRiskRevision;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error instanceof CollectionError) return invalidate(error.reason);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
await ctx.db.patch("payoutSnapshots", h._id, { ...patch, ...counts });
|
|
227
|
+
return (await ctx.db.get("payoutSnapshots", h._id))!;
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/** Read-only cache cleanup. Never releases source reservations or bank claims. */
|
|
232
|
+
export const discard = mutation({
|
|
233
|
+
args: { ...identity, batchSize: v.optional(v.number()) },
|
|
234
|
+
returns: v.union(snapshotDoc, v.null()),
|
|
235
|
+
handler: async (ctx, args) => {
|
|
236
|
+
const h = await ownedSnapshot(ctx, args);
|
|
237
|
+
if (!h) return null;
|
|
238
|
+
if (!h.readOnly) throw new Error("Collection is financially bound.");
|
|
239
|
+
const batch = args.batchSize ?? 25;
|
|
240
|
+
if (!Number.isInteger(batch) || batch < 1 || batch > 100)
|
|
241
|
+
throw new Error("Invalid collection cleanup batch.");
|
|
242
|
+
const sources = await ctx.db
|
|
243
|
+
.query("payoutSnapshotSources")
|
|
244
|
+
.withIndex("by_snapshot", (q) => q.eq("snapshotId", h._id))
|
|
245
|
+
.take(batch);
|
|
246
|
+
const claims = await ctx.db
|
|
247
|
+
.query("payoutSnapshotClaims")
|
|
248
|
+
.withIndex("by_snapshot", (q) => q.eq("snapshotId", h._id))
|
|
249
|
+
.take(batch);
|
|
250
|
+
const fees = await ctx.db
|
|
251
|
+
.query("payoutSnapshotFees")
|
|
252
|
+
.withIndex("by_snapshot", (q) => q.eq("snapshotId", h._id))
|
|
253
|
+
.take(batch);
|
|
254
|
+
if (!sources.length && !claims.length && !fees.length) {
|
|
255
|
+
await ctx.db.delete("payoutSnapshots", h._id);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
for (const s of sources)
|
|
259
|
+
await ctx.db.delete("payoutSnapshotSources", s._id);
|
|
260
|
+
for (const c of claims) await ctx.db.delete("payoutSnapshotClaims", c._id);
|
|
261
|
+
for (const f of fees) await ctx.db.delete("payoutSnapshotFees", f._id);
|
|
262
|
+
await ctx.db.patch("payoutSnapshots", h._id, {
|
|
263
|
+
phase: "discarding",
|
|
264
|
+
step: h.step + 1,
|
|
265
|
+
sourceCount: Math.max(0, h.sourceCount - sources.length),
|
|
266
|
+
allocationCount: Math.max(
|
|
267
|
+
0,
|
|
268
|
+
h.allocationCount - sources.filter((s) => s.allocationPresent).length,
|
|
269
|
+
),
|
|
270
|
+
claimCount: Math.max(0, h.claimCount - claims.length),
|
|
271
|
+
feeCount: Math.max(0, h.feeCount - fees.length),
|
|
272
|
+
});
|
|
273
|
+
return (await ctx.db.get("payoutSnapshots", h._id))!;
|
|
274
|
+
},
|
|
275
|
+
});
|