@fantastic.dev/better-stripe 0.21.0 → 0.23.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 +12 -0
- package/README.md +8 -5
- package/dist/client/connect/payoutActivationProof.d.ts +16 -0
- package/dist/client/connect/payoutActivationProof.d.ts.map +1 -0
- package/dist/client/connect/payoutActivationProof.js +72 -0
- package/dist/client/connect/payoutActivationProof.js.map +1 -0
- package/dist/client/connect/payoutAdoption.d.ts +35 -0
- package/dist/client/connect/payoutAdoption.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoption.js +230 -0
- package/dist/client/connect/payoutAdoption.js.map +1 -0
- package/dist/client/connect/payoutAdoptionAge.d.ts +19 -0
- package/dist/client/connect/payoutAdoptionAge.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoptionAge.js +56 -0
- package/dist/client/connect/payoutAdoptionAge.js.map +1 -0
- package/dist/client/connect/payoutAdoptionAllocation.d.ts +24 -0
- package/dist/client/connect/payoutAdoptionAllocation.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoptionAllocation.js +78 -0
- package/dist/client/connect/payoutAdoptionAllocation.js.map +1 -0
- package/dist/client/connect/payoutExecution.d.ts +34 -0
- package/dist/client/connect/payoutExecution.d.ts.map +1 -0
- package/dist/client/connect/payoutExecution.js +189 -0
- package/dist/client/connect/payoutExecution.js.map +1 -0
- package/dist/client/connect/payoutHistory.d.ts +22 -0
- package/dist/client/connect/payoutHistory.d.ts.map +1 -0
- package/dist/client/connect/payoutHistory.js +122 -0
- package/dist/client/connect/payoutHistory.js.map +1 -0
- package/dist/client/connect/payoutReconciliation.d.ts.map +1 -1
- package/dist/client/connect/payoutReconciliation.js +6 -2
- package/dist/client/connect/payoutReconciliation.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +10 -1
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +82 -7
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +269 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +40 -4
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutPolicy/adoption.d.ts +9 -0
- package/dist/component/payoutPolicy/adoption.d.ts.map +1 -0
- package/dist/component/payoutPolicy/adoption.js +45 -0
- package/dist/component/payoutPolicy/adoption.js.map +1 -0
- package/dist/component/payoutPolicy/mutations.d.ts +96 -0
- package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
- package/dist/component/payoutPolicy/mutations.js +38 -5
- package/dist/component/payoutPolicy/mutations.js.map +1 -1
- package/dist/component/payoutPolicy/queries.d.ts +16 -0
- package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.d.ts +299 -2
- package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.js +7 -0
- package/dist/component/payoutPolicy/schema.js.map +1 -1
- package/dist/component/payoutRelease/adoption.d.ts +269 -0
- package/dist/component/payoutRelease/adoption.d.ts.map +1 -0
- package/dist/component/payoutRelease/adoption.js +197 -0
- package/dist/component/payoutRelease/adoption.js.map +1 -0
- package/dist/component/payoutRelease/helpers.d.ts +4 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/helpers.js +20 -0
- package/dist/component/payoutRelease/helpers.js.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts +87 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +13 -1
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/observation.d.ts +18 -0
- package/dist/component/payoutRelease/observation.d.ts.map +1 -0
- package/dist/component/payoutRelease/observation.js +11 -0
- package/dist/component/payoutRelease/observation.js.map +1 -0
- package/dist/component/payoutRelease/queries.d.ts +74 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/queries.js +29 -0
- package/dist/component/payoutRelease/queries.js.map +1 -1
- package/dist/component/payoutRelease/schema.d.ts +13 -19
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +4 -9
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/schema.d.ts +75 -2
- package/docs/settlement.md +181 -40
- package/package.json +1 -1
- package/src/client/connect/payoutActivationProof.ts +101 -0
- package/src/client/connect/payoutAdoption.test.ts +421 -0
- package/src/client/connect/payoutAdoption.ts +330 -0
- package/src/client/connect/payoutAdoptionAge.test.ts +89 -0
- package/src/client/connect/payoutAdoptionAge.ts +74 -0
- package/src/client/connect/payoutAdoptionAllocation.test.ts +132 -0
- package/src/client/connect/payoutAdoptionAllocation.ts +106 -0
- package/src/client/connect/payoutExecution.test.ts +504 -0
- package/src/client/connect/payoutExecution.ts +265 -0
- package/src/client/connect/payoutHistory.test.ts +145 -0
- package/src/client/connect/payoutHistory.ts +149 -0
- package/src/client/connect/payoutReconciliation.test.ts +29 -0
- package/src/client/connect/payoutReconciliation.ts +10 -2
- package/src/client/payoutPolicy.test.ts +16 -0
- package/src/client/payoutPolicy.ts +121 -5
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +272 -1
- package/src/component/payout-adoption.test.ts +272 -0
- package/src/component/payout-policy-adoption.test.ts +131 -0
- package/src/component/payout-release-snapshot.test.ts +57 -0
- package/src/component/payoutPolicy/adoption.ts +76 -0
- package/src/component/payoutPolicy/mutations.ts +46 -3
- package/src/component/payoutPolicy/schema.ts +9 -0
- package/src/component/payoutRelease/adoption.ts +262 -0
- package/src/component/payoutRelease/helpers.ts +21 -0
- package/src/component/payoutRelease/mutations.ts +19 -1
- package/src/component/payoutRelease/observation.ts +10 -0
- package/src/component/payoutRelease/queries.ts +35 -0
- package/src/component/payoutRelease/schema.ts +4 -9
|
@@ -0,0 +1,272 @@
|
|
|
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 schema from "./schema.js";
|
|
6
|
+
import {
|
|
7
|
+
transferFingerprint,
|
|
8
|
+
payoutClaimFingerprint,
|
|
9
|
+
} from "./payoutRelease/helpers.js";
|
|
10
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
11
|
+
const scope = { accountId: "acct_one", ownerKey: "owner" };
|
|
12
|
+
|
|
13
|
+
async function setup() {
|
|
14
|
+
const t = convexTest(schema, modules);
|
|
15
|
+
await t.run(async (ctx) => {
|
|
16
|
+
await ctx.db.insert("payoutPolicies", {
|
|
17
|
+
...scope,
|
|
18
|
+
mode: "stripe_managed",
|
|
19
|
+
status: "ready",
|
|
20
|
+
revision: 0,
|
|
21
|
+
operationKey: "default",
|
|
22
|
+
country: "US",
|
|
23
|
+
thresholds: [],
|
|
24
|
+
});
|
|
25
|
+
for (const [id, amount, role] of [
|
|
26
|
+
["tr_one", 600, "store"],
|
|
27
|
+
["tr_two", 400, "affiliate"],
|
|
28
|
+
] as const)
|
|
29
|
+
await ctx.db.insert("transfers", {
|
|
30
|
+
stripeTransferId: id,
|
|
31
|
+
amount,
|
|
32
|
+
currency: "usd",
|
|
33
|
+
destinationAccountId: scope.accountId,
|
|
34
|
+
status: "paid",
|
|
35
|
+
role,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
const rows = await t.run((ctx) => ctx.db.query("transfers").collect());
|
|
39
|
+
const args = {
|
|
40
|
+
...scope,
|
|
41
|
+
expectedRevision: 0,
|
|
42
|
+
payout: {
|
|
43
|
+
stripePayoutId: "po_old",
|
|
44
|
+
amount: 1000,
|
|
45
|
+
currency: "usd",
|
|
46
|
+
created: Math.floor(Date.now() / 1000) - 100,
|
|
47
|
+
destinationId: "ba_one",
|
|
48
|
+
status: "paid",
|
|
49
|
+
debitTransactionId: "txn_debit",
|
|
50
|
+
debitFee: 30,
|
|
51
|
+
},
|
|
52
|
+
allocations: [
|
|
53
|
+
{ stripeTransferId: "tr_one", amount: 600 },
|
|
54
|
+
{ stripeTransferId: "tr_two", amount: 400 },
|
|
55
|
+
],
|
|
56
|
+
observations: rows.map((row) => ({
|
|
57
|
+
stripeTransferId: row.stripeTransferId,
|
|
58
|
+
currency: "usd",
|
|
59
|
+
matureNet: row.amount,
|
|
60
|
+
pendingCredits: 0,
|
|
61
|
+
ledgerFingerprint: transferFingerprint(row),
|
|
62
|
+
observedAt: Date.now(),
|
|
63
|
+
transactionIds: [`txn_${row.stripeTransferId}`],
|
|
64
|
+
})),
|
|
65
|
+
};
|
|
66
|
+
return { t, args };
|
|
67
|
+
}
|
|
68
|
+
async function importOne(
|
|
69
|
+
t: ReturnType<typeof convexTest>,
|
|
70
|
+
args: Awaited<ReturnType<typeof setup>>["args"] & {
|
|
71
|
+
payout: { returnTransactionId?: string; returnFee?: number };
|
|
72
|
+
},
|
|
73
|
+
) {
|
|
74
|
+
const rows = await t.run((ctx) =>
|
|
75
|
+
ctx.db.query("payoutReleaseClaims").collect(),
|
|
76
|
+
);
|
|
77
|
+
const result = await t.mutation(
|
|
78
|
+
makeFunctionReference<"mutation">(
|
|
79
|
+
"payoutRelease/mutations:adoptHistoricalBatch",
|
|
80
|
+
),
|
|
81
|
+
{
|
|
82
|
+
accountId: args.accountId,
|
|
83
|
+
ownerKey: args.ownerKey,
|
|
84
|
+
expectedRevision: args.expectedRevision,
|
|
85
|
+
observations: args.observations,
|
|
86
|
+
expectedClaims: rows.map((c) => ({
|
|
87
|
+
operationKey: c.operationKey,
|
|
88
|
+
fingerprint: payoutClaimFingerprint(c),
|
|
89
|
+
})),
|
|
90
|
+
payouts: [{ payout: args.payout, allocations: args.allocations }],
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
return result.claims[0];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it("imports historical paid principal and actual fees once", async () => {
|
|
97
|
+
const { t, args } = await setup();
|
|
98
|
+
expect(await importOne(t, args)).toMatchObject({
|
|
99
|
+
historical: true,
|
|
100
|
+
status: "paid",
|
|
101
|
+
amount: 1000,
|
|
102
|
+
providerFee: 30,
|
|
103
|
+
destinationId: "ba_one",
|
|
104
|
+
});
|
|
105
|
+
await importOne(t, args);
|
|
106
|
+
expect(
|
|
107
|
+
await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
108
|
+
).toMatchObject([
|
|
109
|
+
{ paidAmount: 600, reservedAmount: 0 },
|
|
110
|
+
{ paidAmount: 400, reservedAmount: 0 },
|
|
111
|
+
]);
|
|
112
|
+
expect(
|
|
113
|
+
await t.run((ctx) => ctx.db.query("payoutFeeBalances").collect()),
|
|
114
|
+
).toMatchObject([{ amount: 30 }]);
|
|
115
|
+
});
|
|
116
|
+
it("imports a verified returned payout without recording its principal as paid", async () => {
|
|
117
|
+
const { t, args } = await setup();
|
|
118
|
+
expect(
|
|
119
|
+
await importOne(t, {
|
|
120
|
+
...args,
|
|
121
|
+
payout: {
|
|
122
|
+
...args.payout,
|
|
123
|
+
status: "returned",
|
|
124
|
+
returnTransactionId: "txn_return",
|
|
125
|
+
returnFee: -10,
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
).toMatchObject({ status: "returned", providerFee: 20 });
|
|
129
|
+
expect(
|
|
130
|
+
await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
131
|
+
).toMatchObject([{ paidAmount: 0 }, { paidAmount: 0 }]);
|
|
132
|
+
});
|
|
133
|
+
it("rejects changing source allocations on replay", async () => {
|
|
134
|
+
const { t, args } = await setup();
|
|
135
|
+
await importOne(t, args);
|
|
136
|
+
await expect(
|
|
137
|
+
importOne(t, {
|
|
138
|
+
...args,
|
|
139
|
+
allocations: [
|
|
140
|
+
{ stripeTransferId: "tr_one", amount: 500 },
|
|
141
|
+
{ stripeTransferId: "tr_two", amount: 500 },
|
|
142
|
+
],
|
|
143
|
+
}),
|
|
144
|
+
).rejects.toThrow(/identity|allocation/i);
|
|
145
|
+
});
|
|
146
|
+
it.each([999, 1001, 0, -1, 1.5])(
|
|
147
|
+
"requires exact positive integer allocations: %s",
|
|
148
|
+
async (amount) => {
|
|
149
|
+
const { t, args } = await setup();
|
|
150
|
+
await expect(
|
|
151
|
+
importOne(t, {
|
|
152
|
+
...args,
|
|
153
|
+
allocations: [{ stripeTransferId: "tr_one", amount }],
|
|
154
|
+
}),
|
|
155
|
+
).rejects.toThrow();
|
|
156
|
+
expect(
|
|
157
|
+
await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
158
|
+
).toEqual([]);
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
it("rejects another owner before any ledger writes", async () => {
|
|
162
|
+
const { t, args } = await setup();
|
|
163
|
+
await expect(importOne(t, { ...args, ownerKey: "other" })).rejects.toThrow(
|
|
164
|
+
/owner/i,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
it("rejects an incomplete source snapshot", async () => {
|
|
168
|
+
const { t, args } = await setup();
|
|
169
|
+
await expect(
|
|
170
|
+
importOne(t, { ...args, observations: args.observations.slice(0, 1) }),
|
|
171
|
+
).rejects.toThrow(/snapshot/i);
|
|
172
|
+
});
|
|
173
|
+
it("requires inactive automatic policy at the observed revision", async () => {
|
|
174
|
+
const { t, args } = await setup();
|
|
175
|
+
await expect(importOne(t, { ...args, expectedRevision: 1 })).rejects.toThrow(
|
|
176
|
+
/revision/i,
|
|
177
|
+
);
|
|
178
|
+
await t.run(async (ctx) => {
|
|
179
|
+
const p = await ctx.db.query("payoutPolicies").first();
|
|
180
|
+
await ctx.db.patch("payoutPolicies", p!._id, { mode: "custom" });
|
|
181
|
+
});
|
|
182
|
+
await expect(importOne(t, args)).rejects.toThrow(/policy/i);
|
|
183
|
+
});
|
|
184
|
+
it("retains historical paid allocations even when a later refund leaves debt", async () => {
|
|
185
|
+
const { t, args } = await setup();
|
|
186
|
+
await t.run(async (ctx) => {
|
|
187
|
+
const row = await ctx.db.query("transfers").first();
|
|
188
|
+
await ctx.db.patch("transfers", row!._id, { reversedAmount: 500 });
|
|
189
|
+
});
|
|
190
|
+
const rows = await t.run((ctx) => ctx.db.query("transfers").collect());
|
|
191
|
+
const observations = args.observations.map((o) => ({
|
|
192
|
+
...o,
|
|
193
|
+
matureNet: o.stripeTransferId === "tr_one" ? 100 : o.matureNet,
|
|
194
|
+
ledgerFingerprint: transferFingerprint(
|
|
195
|
+
rows.find((r) => r.stripeTransferId === o.stripeTransferId)!,
|
|
196
|
+
),
|
|
197
|
+
}));
|
|
198
|
+
await importOne(t, { ...args, observations });
|
|
199
|
+
expect(
|
|
200
|
+
await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
201
|
+
).toMatchObject([{ paidAmount: 600 }, { paidAmount: 400 }]);
|
|
202
|
+
});
|
|
203
|
+
it("imports a complete historical batch atomically and rejects stale competing mappings", async () => {
|
|
204
|
+
const { t, args } = await setup();
|
|
205
|
+
const batch = makeFunctionReference<"mutation">(
|
|
206
|
+
"payoutRelease/mutations:adoptHistoricalBatch",
|
|
207
|
+
);
|
|
208
|
+
const input = {
|
|
209
|
+
...scope,
|
|
210
|
+
expectedRevision: 0,
|
|
211
|
+
observations: args.observations,
|
|
212
|
+
expectedClaims: [],
|
|
213
|
+
payouts: [
|
|
214
|
+
{
|
|
215
|
+
payout: {
|
|
216
|
+
...args.payout,
|
|
217
|
+
stripePayoutId: "po_first",
|
|
218
|
+
amount: 600,
|
|
219
|
+
debitTransactionId: "txn_first",
|
|
220
|
+
},
|
|
221
|
+
allocations: [{ stripeTransferId: "tr_one", amount: 600 }],
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
payout: {
|
|
225
|
+
...args.payout,
|
|
226
|
+
stripePayoutId: "po_second",
|
|
227
|
+
amount: 400,
|
|
228
|
+
debitTransactionId: "txn_second",
|
|
229
|
+
},
|
|
230
|
+
allocations: [{ stripeTransferId: "tr_two", amount: 400 }],
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
};
|
|
234
|
+
expect(await t.mutation(batch, input)).toMatchObject({
|
|
235
|
+
claims: [{ amount: 600 }, { amount: 400 }],
|
|
236
|
+
});
|
|
237
|
+
await expect(t.mutation(batch, input)).rejects.toThrow(/snapshot|history/i);
|
|
238
|
+
expect(
|
|
239
|
+
await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
240
|
+
).toMatchObject([{ paidAmount: 600 }, { paidAmount: 400 }]);
|
|
241
|
+
});
|
|
242
|
+
it("a bad later payout rolls back the entire historical batch", async () => {
|
|
243
|
+
const { t, args } = await setup();
|
|
244
|
+
const batch = makeFunctionReference<"mutation">(
|
|
245
|
+
"payoutRelease/mutations:adoptHistoricalBatch",
|
|
246
|
+
);
|
|
247
|
+
await expect(
|
|
248
|
+
t.mutation(batch, {
|
|
249
|
+
...scope,
|
|
250
|
+
expectedRevision: 0,
|
|
251
|
+
observations: args.observations,
|
|
252
|
+
expectedClaims: [],
|
|
253
|
+
payouts: [
|
|
254
|
+
{ payout: args.payout, allocations: args.allocations },
|
|
255
|
+
{
|
|
256
|
+
payout: {
|
|
257
|
+
...args.payout,
|
|
258
|
+
stripePayoutId: "po_bad",
|
|
259
|
+
debitTransactionId: "txn_bad",
|
|
260
|
+
},
|
|
261
|
+
allocations: [],
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
}),
|
|
265
|
+
).rejects.toThrow();
|
|
266
|
+
expect(
|
|
267
|
+
await t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
|
|
268
|
+
).toEqual([]);
|
|
269
|
+
expect(
|
|
270
|
+
await t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
271
|
+
).toEqual([]);
|
|
272
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
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 schema from "./schema.js";
|
|
6
|
+
import { transferFingerprint } from "./payoutRelease/helpers.js";
|
|
7
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
8
|
+
const m = (name: string) =>
|
|
9
|
+
makeFunctionReference<"mutation">(`payoutPolicy/mutations:${name}`);
|
|
10
|
+
const scope = { accountId: "acct_one", ownerKey: "owner" };
|
|
11
|
+
async function setup(country = "US") {
|
|
12
|
+
const t = convexTest(schema, modules);
|
|
13
|
+
await t.mutation(m("prepare"), {
|
|
14
|
+
...scope,
|
|
15
|
+
operationKey: "default",
|
|
16
|
+
expectedRevision: -1,
|
|
17
|
+
mode: "stripe_managed",
|
|
18
|
+
thresholds: [],
|
|
19
|
+
country,
|
|
20
|
+
observedSchedule: { interval: "weekly", weeklyDays: ["monday"] },
|
|
21
|
+
});
|
|
22
|
+
await t.mutation(m("confirm"), {
|
|
23
|
+
...scope,
|
|
24
|
+
operationKey: "default",
|
|
25
|
+
observedSchedule: { interval: "weekly", weeklyDays: ["monday"] },
|
|
26
|
+
});
|
|
27
|
+
const row = await t.run(async (ctx) => {
|
|
28
|
+
const id = await ctx.db.insert("transfers", {
|
|
29
|
+
stripeTransferId: "tr_one",
|
|
30
|
+
destinationAccountId: scope.accountId,
|
|
31
|
+
amount: 1000,
|
|
32
|
+
currency: "usd",
|
|
33
|
+
status: "paid",
|
|
34
|
+
role: "store",
|
|
35
|
+
});
|
|
36
|
+
return (await ctx.db.get("transfers", id))!;
|
|
37
|
+
});
|
|
38
|
+
const proof = {
|
|
39
|
+
observations: [
|
|
40
|
+
{
|
|
41
|
+
stripeTransferId: row.stripeTransferId,
|
|
42
|
+
currency: "usd",
|
|
43
|
+
matureNet: 1000,
|
|
44
|
+
pendingCredits: 0,
|
|
45
|
+
ledgerFingerprint: transferFingerprint(row),
|
|
46
|
+
observedAt: Date.now(),
|
|
47
|
+
transactionIds: ["txn_one"],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
claims: [],
|
|
51
|
+
oldestHeldAt: Date.now() - 20 * 86400000,
|
|
52
|
+
};
|
|
53
|
+
const args = {
|
|
54
|
+
...scope,
|
|
55
|
+
operationKey: "custom",
|
|
56
|
+
expectedRevision: 0,
|
|
57
|
+
mode: "custom",
|
|
58
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
59
|
+
country,
|
|
60
|
+
observedSchedule: { interval: "weekly", weeklyDays: ["monday"] },
|
|
61
|
+
adoption: proof,
|
|
62
|
+
};
|
|
63
|
+
return { t, args, proof, row };
|
|
64
|
+
}
|
|
65
|
+
it("anchors funded activation to the oldest held money rather than activation time", async () => {
|
|
66
|
+
const { t, args, proof } = await setup();
|
|
67
|
+
await t.mutation(m("prepare"), args);
|
|
68
|
+
const p = await t.run((ctx) => ctx.db.query("payoutPolicies").first());
|
|
69
|
+
expect(p!.holdDeadline).toBe(proof.oldestHeldAt + 730 * 86400000);
|
|
70
|
+
});
|
|
71
|
+
it("rejects already-expired holding periods before changing policy", async () => {
|
|
72
|
+
const { t, args } = await setup("TH");
|
|
73
|
+
await expect(t.mutation(m("prepare"), args)).rejects.toThrow(
|
|
74
|
+
/holding|deadline/i,
|
|
75
|
+
);
|
|
76
|
+
expect(
|
|
77
|
+
await t.run((ctx) => ctx.db.query("payoutPolicies").first()),
|
|
78
|
+
).toMatchObject({ mode: "stripe_managed", status: "ready", revision: 0 });
|
|
79
|
+
});
|
|
80
|
+
it("requires fresh adoption proof before a delayed provider attempt", async () => {
|
|
81
|
+
const { t, args } = await setup();
|
|
82
|
+
await t.mutation(m("prepare"), args);
|
|
83
|
+
await expect(
|
|
84
|
+
t.mutation(m("beginAttempt"), { ...scope, operationKey: "custom" }),
|
|
85
|
+
).rejects.toThrow(/adoption/i);
|
|
86
|
+
});
|
|
87
|
+
it("rejects changed source state before marking the provider attempt", async () => {
|
|
88
|
+
const { t, args, proof, row } = await setup();
|
|
89
|
+
await t.mutation(m("prepare"), args);
|
|
90
|
+
await t.run((ctx) =>
|
|
91
|
+
ctx.db.patch("transfers", row._id, { reversedAmount: 10 }),
|
|
92
|
+
);
|
|
93
|
+
await expect(
|
|
94
|
+
t.mutation(m("beginAttempt"), {
|
|
95
|
+
...scope,
|
|
96
|
+
operationKey: "custom",
|
|
97
|
+
adoption: proof,
|
|
98
|
+
}),
|
|
99
|
+
).rejects.toThrow(/snapshot/i);
|
|
100
|
+
expect(
|
|
101
|
+
await t.run((ctx) =>
|
|
102
|
+
ctx.db
|
|
103
|
+
.query("payoutPolicyOperations")
|
|
104
|
+
.withIndex("by_operation", (q) => q.eq("operationKey", "custom"))
|
|
105
|
+
.unique(),
|
|
106
|
+
),
|
|
107
|
+
).not.toHaveProperty("attempted");
|
|
108
|
+
});
|
|
109
|
+
it("fresh checks cannot extend the stored holding deadline", async () => {
|
|
110
|
+
const { t, args, proof } = await setup();
|
|
111
|
+
await t.mutation(m("prepare"), args);
|
|
112
|
+
await t.mutation(m("beginAttempt"), {
|
|
113
|
+
...scope,
|
|
114
|
+
operationKey: "custom",
|
|
115
|
+
adoption: { ...proof, oldestHeldAt: Date.now() - 86400000 },
|
|
116
|
+
});
|
|
117
|
+
expect(
|
|
118
|
+
await t.run((ctx) => ctx.db.query("payoutPolicies").first()),
|
|
119
|
+
).toMatchObject({ holdDeadline: proof.oldestHeldAt + 730 * 86400000 });
|
|
120
|
+
});
|
|
121
|
+
it("cannot confirm funded adoption without the fresh checked attempt marker", async () => {
|
|
122
|
+
const { t, args } = await setup();
|
|
123
|
+
await t.mutation(m("prepare"), args);
|
|
124
|
+
await expect(
|
|
125
|
+
t.mutation(m("confirm"), {
|
|
126
|
+
...scope,
|
|
127
|
+
operationKey: "custom",
|
|
128
|
+
observedSchedule: { interval: "manual" },
|
|
129
|
+
}),
|
|
130
|
+
).rejects.toThrow(/attempt/i);
|
|
131
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
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 schema from "./schema.js";
|
|
6
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
7
|
+
const snapshot = makeFunctionReference<"query">(
|
|
8
|
+
"payoutRelease/queries:snapshot",
|
|
9
|
+
);
|
|
10
|
+
const scope = { accountId: "acct_recipient", ownerKey: "owner" };
|
|
11
|
+
async function setup(count = 2) {
|
|
12
|
+
const t = convexTest(schema, modules);
|
|
13
|
+
await t.run(async (ctx) => {
|
|
14
|
+
await ctx.db.insert("payoutPolicies", {
|
|
15
|
+
...scope,
|
|
16
|
+
mode: "custom",
|
|
17
|
+
status: "ready",
|
|
18
|
+
revision: 0,
|
|
19
|
+
operationKey: "enable",
|
|
20
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
21
|
+
country: "US",
|
|
22
|
+
previousSchedule: { interval: "daily" },
|
|
23
|
+
holdDeadline: Date.now() + 86400000,
|
|
24
|
+
});
|
|
25
|
+
for (let i = 0; i < count; i++)
|
|
26
|
+
await ctx.db.insert("transfers", {
|
|
27
|
+
stripeTransferId: `tr_${i}`,
|
|
28
|
+
destinationAccountId: scope.accountId,
|
|
29
|
+
amount: 500,
|
|
30
|
+
currency: "usd",
|
|
31
|
+
status: "paid",
|
|
32
|
+
role: i % 2 ? "affiliate" : "store",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
return t;
|
|
36
|
+
}
|
|
37
|
+
it("returns complete source membership and historical claims for the owned account", async () => {
|
|
38
|
+
const t = await setup();
|
|
39
|
+
expect(await t.query(snapshot, scope)).toMatchObject({
|
|
40
|
+
status: "ready",
|
|
41
|
+
transfers: [{ stripeTransferId: "tr_0" }, { stripeTransferId: "tr_1" }],
|
|
42
|
+
claims: [],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
it("holds an account exceeding the atomic claim bound rather than qualifying a partial page", async () => {
|
|
46
|
+
const t = await setup(101);
|
|
47
|
+
expect(await t.query(snapshot, scope)).toEqual({
|
|
48
|
+
status: "held",
|
|
49
|
+
reason: "snapshot_limit",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it("rejects another owner", async () => {
|
|
53
|
+
const t = await setup();
|
|
54
|
+
await expect(
|
|
55
|
+
t.query(snapshot, { ...scope, ownerKey: "other" }),
|
|
56
|
+
).rejects.toThrow(/owner/i);
|
|
57
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Infer } from "convex/values";
|
|
2
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
3
|
+
import type { adoptionProof } from "./schema.js";
|
|
4
|
+
import {
|
|
5
|
+
add,
|
|
6
|
+
checkSnapshot,
|
|
7
|
+
payoutClaimFingerprint,
|
|
8
|
+
sourceById,
|
|
9
|
+
} from "../payoutRelease/helpers.js";
|
|
10
|
+
import { readFees } from "../payoutRelease/fees.js";
|
|
11
|
+
/** Package-authenticated provider proof, rechecked against all local financial state under OCC. */
|
|
12
|
+
export async function validateAdoption(
|
|
13
|
+
ctx: MutationCtx,
|
|
14
|
+
scope: { accountId: string; ownerKey: string },
|
|
15
|
+
proof: Infer<typeof adoptionProof>,
|
|
16
|
+
) {
|
|
17
|
+
await checkSnapshot(ctx, scope.accountId, proof.observations, true);
|
|
18
|
+
const rows = await ctx.db
|
|
19
|
+
.query("payoutReleaseClaims")
|
|
20
|
+
.withIndex("by_account_status", (q) => q.eq("accountId", scope.accountId))
|
|
21
|
+
.take(101);
|
|
22
|
+
const expected = new Map(
|
|
23
|
+
proof.claims.map((c) => [c.operationKey, c.fingerprint]),
|
|
24
|
+
);
|
|
25
|
+
if (
|
|
26
|
+
rows.length > 100 ||
|
|
27
|
+
expected.size !== proof.claims.length ||
|
|
28
|
+
rows.length !== expected.size ||
|
|
29
|
+
rows.some(
|
|
30
|
+
(c) =>
|
|
31
|
+
c.ownerKey !== scope.ownerKey ||
|
|
32
|
+
c.status === "reserved" ||
|
|
33
|
+
c.status === "submitted" ||
|
|
34
|
+
expected.get(c.operationKey) !== payoutClaimFingerprint(c),
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Adoption payout history snapshot changed or is unresolved.",
|
|
39
|
+
);
|
|
40
|
+
const owed = new Map<string, number>();
|
|
41
|
+
for (const o of proof.observations) {
|
|
42
|
+
const source = await sourceById(ctx, o.stripeTransferId);
|
|
43
|
+
if (
|
|
44
|
+
source &&
|
|
45
|
+
(source.accountId !== scope.accountId ||
|
|
46
|
+
source.ownerKey !== scope.ownerKey ||
|
|
47
|
+
source.currency !== o.currency ||
|
|
48
|
+
source.reservedAmount !== 0)
|
|
49
|
+
)
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Adoption source allocation identity or reservations changed.",
|
|
52
|
+
);
|
|
53
|
+
owed.set(
|
|
54
|
+
o.currency,
|
|
55
|
+
add(
|
|
56
|
+
owed.get(o.currency) ?? 0,
|
|
57
|
+
add(add(o.matureNet, o.pendingCredits), -(source?.paidAmount ?? 0)),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
let hasHeld = false;
|
|
62
|
+
for (const [currency, amount] of owed) {
|
|
63
|
+
const fees = await readFees(ctx, { ...scope, currency });
|
|
64
|
+
if (add(amount, -(fees?.amount ?? 0)) > 0) hasHeld = true;
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
proof.oldestHeldAt !== undefined &&
|
|
68
|
+
(!Number.isSafeInteger(proof.oldestHeldAt) ||
|
|
69
|
+
proof.oldestHeldAt < 0 ||
|
|
70
|
+
proof.oldestHeldAt > Date.now())
|
|
71
|
+
)
|
|
72
|
+
throw new Error("Invalid adoption holding timestamp.");
|
|
73
|
+
if (hasHeld && proof.oldestHeldAt === undefined)
|
|
74
|
+
throw new Error("Adoption holding age is required for unpaid funds.");
|
|
75
|
+
return proof.oldestHeldAt ?? Date.now();
|
|
76
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { validateAdoption } from "./adoption.js";
|
|
1
2
|
import { v } from "convex/values";
|
|
2
3
|
import { mutation } from "../_generated/server.js";
|
|
3
4
|
import {
|
|
5
|
+
adoptionProof,
|
|
4
6
|
prepareFields,
|
|
5
7
|
operationDoc,
|
|
6
8
|
scopeFields,
|
|
@@ -49,6 +51,7 @@ export const prepare = mutation({
|
|
|
49
51
|
old.mode !== args.mode ||
|
|
50
52
|
old.country !== args.country ||
|
|
51
53
|
old.observedAt !== args.observedAt ||
|
|
54
|
+
Boolean(old.adoption) !== Boolean(args.adoption) ||
|
|
52
55
|
!same(old.thresholds, args.thresholds) ||
|
|
53
56
|
!same(old.observedSchedule, args.observedSchedule)
|
|
54
57
|
)
|
|
@@ -84,6 +87,17 @@ export const prepare = mutation({
|
|
|
84
87
|
args.observedSchedule.interval === "manual"
|
|
85
88
|
)
|
|
86
89
|
throw new Error("An existing manual schedule requires migration.");
|
|
90
|
+
let adoptedDeadline: number | undefined;
|
|
91
|
+
if (args.adoption) {
|
|
92
|
+
if (args.mode !== "custom" || policy?.mode !== "stripe_managed")
|
|
93
|
+
throw new Error(
|
|
94
|
+
"Adoption requires an owned automatic policy before custom activation.",
|
|
95
|
+
);
|
|
96
|
+
const anchor = await validateAdoption(ctx, args, args.adoption);
|
|
97
|
+
adoptedDeadline = anchor + days * 86400000;
|
|
98
|
+
if (adoptedDeadline <= Date.now())
|
|
99
|
+
throw new Error("Adoption holding deadline has already expired.");
|
|
100
|
+
}
|
|
87
101
|
const previousSchedule =
|
|
88
102
|
policy?.mode === "custom"
|
|
89
103
|
? policy.previousSchedule!
|
|
@@ -111,6 +125,7 @@ export const prepare = mutation({
|
|
|
111
125
|
country: args.country,
|
|
112
126
|
previousSchedule,
|
|
113
127
|
holdDeadline:
|
|
128
|
+
adoptedDeadline ??
|
|
114
129
|
policy?.holdDeadline ??
|
|
115
130
|
(args.mode === "custom"
|
|
116
131
|
? (args.observedAt ?? Date.now()) + days * 86400000
|
|
@@ -139,6 +154,10 @@ export const confirm = mutation({
|
|
|
139
154
|
if (!same(canonicalSchedule(args.observedSchedule), op.targetSchedule))
|
|
140
155
|
throw new Error("Provider payout schedule was not confirmed.");
|
|
141
156
|
if (op.status === "complete") return op;
|
|
157
|
+
if (op.adoption && !op.attempted)
|
|
158
|
+
throw new Error(
|
|
159
|
+
"Funded adoption requires a verified provider attempt before confirmation.",
|
|
160
|
+
);
|
|
142
161
|
if (op.status === "canceled")
|
|
143
162
|
throw new Error("Payout policy operation was canceled.");
|
|
144
163
|
if (
|
|
@@ -162,7 +181,11 @@ export const confirm = mutation({
|
|
|
162
181
|
},
|
|
163
182
|
});
|
|
164
183
|
export const beginAttempt = mutation({
|
|
165
|
-
args: {
|
|
184
|
+
args: {
|
|
185
|
+
...scopeFields,
|
|
186
|
+
operationKey: v.string(),
|
|
187
|
+
adoption: v.optional(adoptionProof),
|
|
188
|
+
},
|
|
166
189
|
returns: operationDoc,
|
|
167
190
|
handler: async (ctx, args) => {
|
|
168
191
|
const policy = await readPolicy(ctx, args);
|
|
@@ -179,8 +202,28 @@ export const beginAttempt = mutation({
|
|
|
179
202
|
policy.status !== "changing"
|
|
180
203
|
)
|
|
181
204
|
throw new Error("Payout policy operation is not pending.");
|
|
182
|
-
if (!op.attempted)
|
|
183
|
-
|
|
205
|
+
if (!op.attempted) {
|
|
206
|
+
if (Boolean(op.adoption) !== Boolean(args.adoption))
|
|
207
|
+
throw new Error(
|
|
208
|
+
"A fresh adoption proof is required for this operation.",
|
|
209
|
+
);
|
|
210
|
+
if (args.adoption) {
|
|
211
|
+
const anchor = await validateAdoption(ctx, args, args.adoption);
|
|
212
|
+
const deadline = Math.min(
|
|
213
|
+
policy.holdDeadline!,
|
|
214
|
+
anchor + holdingDays(op.country) * 86400000,
|
|
215
|
+
);
|
|
216
|
+
if (deadline <= Date.now())
|
|
217
|
+
throw new Error("Adoption holding deadline has already expired.");
|
|
218
|
+
await ctx.db.patch("payoutPolicies", policy._id, {
|
|
219
|
+
holdDeadline: deadline,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
await ctx.db.patch("payoutPolicyOperations", op._id, {
|
|
223
|
+
attempted: true,
|
|
224
|
+
...(args.adoption ? { adoption: args.adoption } : {}),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
184
227
|
return (await ctx.db.get("payoutPolicyOperations", op._id))!;
|
|
185
228
|
},
|
|
186
229
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observation } from "../payoutRelease/observation.js";
|
|
1
2
|
import { defineTable } from "convex/server";
|
|
2
3
|
import { v } from "convex/values";
|
|
3
4
|
export const scopeFields = { accountId: v.string(), ownerKey: v.string() };
|
|
@@ -15,7 +16,15 @@ export const mode = v.union(v.literal("stripe_managed"), v.literal("custom"));
|
|
|
15
16
|
export const thresholds = v.array(
|
|
16
17
|
v.object({ currency: v.string(), amount: v.number() }),
|
|
17
18
|
);
|
|
19
|
+
export const adoptionProof = v.object({
|
|
20
|
+
observations: v.array(observation),
|
|
21
|
+
claims: v.array(
|
|
22
|
+
v.object({ operationKey: v.string(), fingerprint: v.string() }),
|
|
23
|
+
),
|
|
24
|
+
oldestHeldAt: v.optional(v.number()),
|
|
25
|
+
});
|
|
18
26
|
export const prepareFields = {
|
|
27
|
+
adoption: v.optional(adoptionProof),
|
|
19
28
|
...scopeFields,
|
|
20
29
|
operationKey: v.string(),
|
|
21
30
|
expectedRevision: v.number(),
|