@fantastic.dev/better-stripe 0.17.0 → 0.18.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 +17 -0
- package/README.md +58 -9
- package/dist/client/connect/payouts.d.ts +3 -2
- package/dist/client/connect/payouts.d.ts.map +1 -1
- package/dist/client/connect/payouts.js.map +1 -1
- package/dist/client/index.d.ts +4 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +81 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -0
- package/dist/client/payoutPolicy.js +262 -0
- package/dist/client/payoutPolicy.js.map +1 -0
- package/dist/component/_generated/component.d.ts +273 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +2 -1
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/component/earnings/arithmetic.d.ts +3 -0
- package/dist/component/earnings/arithmetic.d.ts.map +1 -0
- package/dist/component/earnings/arithmetic.js +11 -0
- package/dist/component/earnings/arithmetic.js.map +1 -0
- package/dist/component/earnings/reads.d.ts.map +1 -1
- package/dist/component/earnings/reads.js +6 -0
- package/dist/component/earnings/reads.js.map +1 -1
- package/dist/component/earnings/triggers.d.ts +150 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/earnings/triggers.js +17 -12
- package/dist/component/earnings/triggers.js.map +1 -1
- package/dist/component/payoutPolicy/helpers.d.ts +51 -0
- package/dist/component/payoutPolicy/helpers.d.ts.map +1 -0
- package/dist/component/payoutPolicy/helpers.js +68 -0
- package/dist/component/payoutPolicy/helpers.js.map +1 -0
- package/dist/component/payoutPolicy/mutations.d.ts +172 -0
- package/dist/component/payoutPolicy/mutations.d.ts.map +1 -0
- package/dist/component/payoutPolicy/mutations.js +191 -0
- package/dist/component/payoutPolicy/mutations.js.map +1 -0
- package/dist/component/payoutPolicy/queries.d.ts +97 -0
- package/dist/component/payoutPolicy/queries.d.ts.map +1 -0
- package/dist/component/payoutPolicy/queries.js +51 -0
- package/dist/component/payoutPolicy/queries.js.map +1 -0
- package/dist/component/payoutPolicy/schema.d.ts +396 -0
- package/dist/component/payoutPolicy/schema.d.ts.map +1 -0
- package/dist/component/payoutPolicy/schema.js +54 -0
- package/dist/component/payoutPolicy/schema.js.map +1 -0
- package/dist/component/schema.d.ts +131 -0
- package/dist/component/schema.js +3 -0
- package/dist/component/schema.js.map +1 -1
- package/docs/react-workflows.md +5 -3
- package/docs/read-contracts.md +13 -0
- package/docs/settlement.md +62 -0
- package/package.json +1 -1
- package/src/client/connect/payouts.ts +10 -8
- package/src/client/index.ts +7 -11
- package/src/client/payoutPolicy.test.ts +320 -0
- package/src/client/payoutPolicy.ts +409 -0
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +280 -0
- package/src/component/connect/queries.ts +2 -1
- package/src/component/earnings/arithmetic.ts +10 -0
- package/src/component/earnings/reads.ts +6 -0
- package/src/component/earnings/triggers.ts +18 -13
- package/src/component/payout-policy.test.ts +248 -0
- package/src/component/payoutPolicy/helpers.ts +88 -0
- package/src/component/payoutPolicy/mutations.ts +225 -0
- package/src/component/payoutPolicy/queries.ts +56 -0
- package/src/component/payoutPolicy/schema.ts +67 -0
- package/src/component/schema.ts +3 -0
- package/src/component/summary-arithmetic.test.ts +246 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Infer } from "convex/values";
|
|
2
|
+
import type { QueryCtx, MutationCtx } from "../_generated/server.js";
|
|
3
|
+
import type { schedule, thresholds } from "./schema.js";
|
|
4
|
+
export function canonicalSchedule(value: Infer<typeof schedule>) {
|
|
5
|
+
const weekdays = [
|
|
6
|
+
"monday",
|
|
7
|
+
"tuesday",
|
|
8
|
+
"wednesday",
|
|
9
|
+
"thursday",
|
|
10
|
+
"friday",
|
|
11
|
+
"saturday",
|
|
12
|
+
"sunday",
|
|
13
|
+
];
|
|
14
|
+
if (value.interval === "weekly") {
|
|
15
|
+
const days = value.weeklyDays;
|
|
16
|
+
if (
|
|
17
|
+
!days?.length ||
|
|
18
|
+
days.length > 7 ||
|
|
19
|
+
new Set(days).size !== days.length ||
|
|
20
|
+
days.some((day) => !weekdays.includes(day))
|
|
21
|
+
)
|
|
22
|
+
throw new Error("Invalid weekly payout schedule.");
|
|
23
|
+
return { interval: value.interval, weeklyDays: [...days].sort() };
|
|
24
|
+
}
|
|
25
|
+
if (value.interval === "monthly") {
|
|
26
|
+
const days = value.monthlyDays;
|
|
27
|
+
if (
|
|
28
|
+
!days?.length ||
|
|
29
|
+
days.length > 31 ||
|
|
30
|
+
new Set(days).size !== days.length ||
|
|
31
|
+
days.some((day) => !Number.isInteger(day) || day < 1 || day > 31)
|
|
32
|
+
)
|
|
33
|
+
throw new Error("Invalid monthly payout schedule.");
|
|
34
|
+
return {
|
|
35
|
+
interval: value.interval,
|
|
36
|
+
monthlyDays: [...days].sort((a, b) => a - b),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { interval: value.interval };
|
|
40
|
+
}
|
|
41
|
+
export function canonicalThresholds(
|
|
42
|
+
values: Infer<typeof thresholds>,
|
|
43
|
+
custom: boolean,
|
|
44
|
+
) {
|
|
45
|
+
if (
|
|
46
|
+
(custom && !values.length) ||
|
|
47
|
+
values.length > 50 ||
|
|
48
|
+
(!custom && values.length) ||
|
|
49
|
+
new Set(values.map((v) => v.currency)).size !== values.length ||
|
|
50
|
+
values.some(
|
|
51
|
+
(v) =>
|
|
52
|
+
!Number.isSafeInteger(v.amount) ||
|
|
53
|
+
v.amount <= 0 ||
|
|
54
|
+
!/^[a-z]{3}$/.test(v.currency),
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
throw new Error("Invalid custom payout thresholds.");
|
|
58
|
+
return [...values].sort((a, b) => a.currency.localeCompare(b.currency));
|
|
59
|
+
}
|
|
60
|
+
export function validateScope(scope: { accountId: string; ownerKey: string }) {
|
|
61
|
+
if (
|
|
62
|
+
!/^acct_\w+$/.test(scope.accountId) ||
|
|
63
|
+
scope.accountId.length > 80 ||
|
|
64
|
+
!scope.ownerKey.trim() ||
|
|
65
|
+
scope.ownerKey.length > 200
|
|
66
|
+
)
|
|
67
|
+
throw new Error("Invalid payout policy identity.");
|
|
68
|
+
}
|
|
69
|
+
export async function readPolicy(
|
|
70
|
+
ctx: QueryCtx | MutationCtx,
|
|
71
|
+
scope: { accountId: string; ownerKey: string },
|
|
72
|
+
) {
|
|
73
|
+
validateScope(scope);
|
|
74
|
+
const value = await ctx.db
|
|
75
|
+
.query("payoutPolicies")
|
|
76
|
+
.withIndex("by_account", (q) => q.eq("accountId", scope.accountId))
|
|
77
|
+
.unique();
|
|
78
|
+
if (value && value.ownerKey !== scope.ownerKey)
|
|
79
|
+
throw new Error("Payout policy owner does not match.");
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
export const same = (a: unknown, b: unknown) =>
|
|
83
|
+
JSON.stringify(a) === JSON.stringify(b);
|
|
84
|
+
export function holdingDays(country: string) {
|
|
85
|
+
if (!/^[A-Z]{2}$/.test(country))
|
|
86
|
+
throw new Error("A verified account country is required.");
|
|
87
|
+
return country === "TH" ? 10 : country === "US" ? 730 : 90;
|
|
88
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation } from "../_generated/server.js";
|
|
3
|
+
import {
|
|
4
|
+
prepareFields,
|
|
5
|
+
operationDoc,
|
|
6
|
+
scopeFields,
|
|
7
|
+
schedule,
|
|
8
|
+
} from "./schema.js";
|
|
9
|
+
import {
|
|
10
|
+
canonicalSchedule,
|
|
11
|
+
canonicalThresholds,
|
|
12
|
+
readPolicy,
|
|
13
|
+
holdingDays,
|
|
14
|
+
same,
|
|
15
|
+
validateScope,
|
|
16
|
+
} from "./helpers.js";
|
|
17
|
+
export const prepare = mutation({
|
|
18
|
+
args: prepareFields,
|
|
19
|
+
returns: operationDoc,
|
|
20
|
+
handler: async (ctx, input) => {
|
|
21
|
+
validateScope(input);
|
|
22
|
+
if (
|
|
23
|
+
!input.operationKey.trim() ||
|
|
24
|
+
input.operationKey.length > 180 ||
|
|
25
|
+
/[\r\n]/.test(input.operationKey) ||
|
|
26
|
+
!Number.isSafeInteger(input.expectedRevision) ||
|
|
27
|
+
input.expectedRevision < -1 ||
|
|
28
|
+
input.expectedRevision >= Number.MAX_SAFE_INTEGER
|
|
29
|
+
)
|
|
30
|
+
throw new Error("Invalid payout policy operation identity or revision.");
|
|
31
|
+
const days = holdingDays(input.country);
|
|
32
|
+
const args = {
|
|
33
|
+
...input,
|
|
34
|
+
observedSchedule: canonicalSchedule(input.observedSchedule),
|
|
35
|
+
thresholds: canonicalThresholds(
|
|
36
|
+
input.thresholds,
|
|
37
|
+
input.mode === "custom",
|
|
38
|
+
),
|
|
39
|
+
};
|
|
40
|
+
const old = await ctx.db
|
|
41
|
+
.query("payoutPolicyOperations")
|
|
42
|
+
.withIndex("by_operation", (q) => q.eq("operationKey", args.operationKey))
|
|
43
|
+
.unique();
|
|
44
|
+
if (old) {
|
|
45
|
+
if (
|
|
46
|
+
old.accountId !== args.accountId ||
|
|
47
|
+
old.ownerKey !== args.ownerKey ||
|
|
48
|
+
old.expectedRevision !== args.expectedRevision ||
|
|
49
|
+
old.mode !== args.mode ||
|
|
50
|
+
old.country !== args.country ||
|
|
51
|
+
old.observedAt !== args.observedAt ||
|
|
52
|
+
!same(old.thresholds, args.thresholds) ||
|
|
53
|
+
!same(old.observedSchedule, args.observedSchedule)
|
|
54
|
+
)
|
|
55
|
+
throw new Error("Payout policy operation identity cannot be reused.");
|
|
56
|
+
return old;
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
input.observedAt !== undefined &&
|
|
60
|
+
(!Number.isSafeInteger(input.observedAt) ||
|
|
61
|
+
input.observedAt > Date.now() ||
|
|
62
|
+
input.observedAt < Date.now() - 600000)
|
|
63
|
+
)
|
|
64
|
+
throw new Error("Payout policy observation is stale or in the future.");
|
|
65
|
+
const policy = await readPolicy(ctx, args);
|
|
66
|
+
if (policy?.status === "changing")
|
|
67
|
+
throw new Error("A payout policy operation is pending.");
|
|
68
|
+
if ((policy?.revision ?? -1) !== args.expectedRevision)
|
|
69
|
+
throw new Error("Payout policy revision changed.");
|
|
70
|
+
if (
|
|
71
|
+
policy?.mode === "custom" &&
|
|
72
|
+
args.observedSchedule.interval !== "manual"
|
|
73
|
+
)
|
|
74
|
+
throw new Error("Payout schedule drift requires reconciliation.");
|
|
75
|
+
if (
|
|
76
|
+
policy?.mode === "custom" &&
|
|
77
|
+
policy.country !== args.country &&
|
|
78
|
+
args.mode === "custom"
|
|
79
|
+
)
|
|
80
|
+
throw new Error("Payout policy country changed.");
|
|
81
|
+
if (
|
|
82
|
+
args.mode === "custom" &&
|
|
83
|
+
policy?.mode !== "custom" &&
|
|
84
|
+
args.observedSchedule.interval === "manual"
|
|
85
|
+
)
|
|
86
|
+
throw new Error("An existing manual schedule requires migration.");
|
|
87
|
+
const previousSchedule =
|
|
88
|
+
policy?.mode === "custom"
|
|
89
|
+
? policy.previousSchedule!
|
|
90
|
+
: args.observedSchedule;
|
|
91
|
+
const targetSchedule =
|
|
92
|
+
args.mode === "custom"
|
|
93
|
+
? { interval: "manual" as const }
|
|
94
|
+
: previousSchedule;
|
|
95
|
+
const revision = args.expectedRevision + 1;
|
|
96
|
+
const operationId = await ctx.db.insert("payoutPolicyOperations", {
|
|
97
|
+
...args,
|
|
98
|
+
revision,
|
|
99
|
+
previousSchedule,
|
|
100
|
+
targetSchedule,
|
|
101
|
+
status: "pending",
|
|
102
|
+
});
|
|
103
|
+
const next = {
|
|
104
|
+
accountId: args.accountId,
|
|
105
|
+
ownerKey: args.ownerKey,
|
|
106
|
+
revision,
|
|
107
|
+
mode: policy?.mode ?? ("stripe_managed" as const),
|
|
108
|
+
thresholds: policy?.thresholds ?? [],
|
|
109
|
+
status: "changing" as const,
|
|
110
|
+
operationKey: args.operationKey,
|
|
111
|
+
country: args.country,
|
|
112
|
+
previousSchedule,
|
|
113
|
+
holdDeadline:
|
|
114
|
+
policy?.holdDeadline ??
|
|
115
|
+
(args.mode === "custom"
|
|
116
|
+
? (args.observedAt ?? Date.now()) + days * 86400000
|
|
117
|
+
: undefined),
|
|
118
|
+
};
|
|
119
|
+
if (policy) await ctx.db.patch("payoutPolicies", policy._id, next);
|
|
120
|
+
else await ctx.db.insert("payoutPolicies", next);
|
|
121
|
+
return (await ctx.db.get("payoutPolicyOperations", operationId))!;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
export const confirm = mutation({
|
|
125
|
+
args: {
|
|
126
|
+
...scopeFields,
|
|
127
|
+
operationKey: v.string(),
|
|
128
|
+
observedSchedule: schedule,
|
|
129
|
+
},
|
|
130
|
+
returns: operationDoc,
|
|
131
|
+
handler: async (ctx, args) => {
|
|
132
|
+
const policy = await readPolicy(ctx, args);
|
|
133
|
+
const op = await ctx.db
|
|
134
|
+
.query("payoutPolicyOperations")
|
|
135
|
+
.withIndex("by_operation", (q) => q.eq("operationKey", args.operationKey))
|
|
136
|
+
.unique();
|
|
137
|
+
if (!op || op.accountId !== args.accountId || op.ownerKey !== args.ownerKey)
|
|
138
|
+
throw new Error("Payout policy operation identity does not match.");
|
|
139
|
+
if (!same(canonicalSchedule(args.observedSchedule), op.targetSchedule))
|
|
140
|
+
throw new Error("Provider payout schedule was not confirmed.");
|
|
141
|
+
if (op.status === "complete") return op;
|
|
142
|
+
if (op.status === "canceled")
|
|
143
|
+
throw new Error("Payout policy operation was canceled.");
|
|
144
|
+
if (
|
|
145
|
+
!policy ||
|
|
146
|
+
policy.operationKey !== op.operationKey ||
|
|
147
|
+
policy.revision !== op.revision
|
|
148
|
+
)
|
|
149
|
+
throw new Error("Payout policy revision changed.");
|
|
150
|
+
await ctx.db.patch("payoutPolicyOperations", op._id, {
|
|
151
|
+
status: "complete",
|
|
152
|
+
});
|
|
153
|
+
await ctx.db.patch("payoutPolicies", policy._id, {
|
|
154
|
+
mode: op.mode,
|
|
155
|
+
thresholds: op.thresholds,
|
|
156
|
+
status: "ready",
|
|
157
|
+
...(op.mode === "stripe_managed"
|
|
158
|
+
? { previousSchedule: undefined, holdDeadline: undefined }
|
|
159
|
+
: {}),
|
|
160
|
+
});
|
|
161
|
+
return (await ctx.db.get("payoutPolicyOperations", op._id))!;
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
export const beginAttempt = mutation({
|
|
165
|
+
args: { ...scopeFields, operationKey: v.string() },
|
|
166
|
+
returns: operationDoc,
|
|
167
|
+
handler: async (ctx, args) => {
|
|
168
|
+
const policy = await readPolicy(ctx, args);
|
|
169
|
+
const op = await ctx.db
|
|
170
|
+
.query("payoutPolicyOperations")
|
|
171
|
+
.withIndex("by_operation", (q) => q.eq("operationKey", args.operationKey))
|
|
172
|
+
.unique();
|
|
173
|
+
if (
|
|
174
|
+
!op ||
|
|
175
|
+
op.accountId !== args.accountId ||
|
|
176
|
+
op.ownerKey !== args.ownerKey ||
|
|
177
|
+
op.status !== "pending" ||
|
|
178
|
+
policy?.operationKey !== op.operationKey ||
|
|
179
|
+
policy.status !== "changing"
|
|
180
|
+
)
|
|
181
|
+
throw new Error("Payout policy operation is not pending.");
|
|
182
|
+
if (!op.attempted)
|
|
183
|
+
await ctx.db.patch("payoutPolicyOperations", op._id, { attempted: true });
|
|
184
|
+
return (await ctx.db.get("payoutPolicyOperations", op._id))!;
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
export const cancelUnattempted = mutation({
|
|
188
|
+
args: {
|
|
189
|
+
...scopeFields,
|
|
190
|
+
operationKey: v.string(),
|
|
191
|
+
observedSchedule: schedule,
|
|
192
|
+
},
|
|
193
|
+
returns: operationDoc,
|
|
194
|
+
handler: async (ctx, args) => {
|
|
195
|
+
const policy = await readPolicy(ctx, args);
|
|
196
|
+
const op = await ctx.db
|
|
197
|
+
.query("payoutPolicyOperations")
|
|
198
|
+
.withIndex("by_operation", (q) => q.eq("operationKey", args.operationKey))
|
|
199
|
+
.unique();
|
|
200
|
+
if (
|
|
201
|
+
!op ||
|
|
202
|
+
op.accountId !== args.accountId ||
|
|
203
|
+
op.ownerKey !== args.ownerKey ||
|
|
204
|
+
op.attempted ||
|
|
205
|
+
op.status !== "pending" ||
|
|
206
|
+
policy?.operationKey !== op.operationKey ||
|
|
207
|
+
policy.status !== "changing"
|
|
208
|
+
)
|
|
209
|
+
throw new Error(
|
|
210
|
+
"An attempted payout policy operation cannot be canceled.",
|
|
211
|
+
);
|
|
212
|
+
if (!same(canonicalSchedule(args.observedSchedule), op.observedSchedule))
|
|
213
|
+
throw new Error("Provider schedule changed before cancellation.");
|
|
214
|
+
await ctx.db.patch("payoutPolicyOperations", op._id, {
|
|
215
|
+
status: "canceled",
|
|
216
|
+
});
|
|
217
|
+
await ctx.db.patch("payoutPolicies", policy._id, {
|
|
218
|
+
status: "ready",
|
|
219
|
+
...(policy.mode === "stripe_managed"
|
|
220
|
+
? { previousSchedule: undefined, holdDeadline: undefined }
|
|
221
|
+
: {}),
|
|
222
|
+
});
|
|
223
|
+
return (await ctx.db.get("payoutPolicyOperations", op._id))!;
|
|
224
|
+
},
|
|
225
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { paginationOptsValidator } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { query } from "../_generated/server.js";
|
|
4
|
+
import { scopeFields, policyDoc, operationDoc } from "./schema.js";
|
|
5
|
+
import { readPolicy, validateScope } from "./helpers.js";
|
|
6
|
+
export const get = query({
|
|
7
|
+
args: scopeFields,
|
|
8
|
+
returns: v.union(policyDoc, v.null()),
|
|
9
|
+
handler: readPolicy,
|
|
10
|
+
});
|
|
11
|
+
export const operation = query({
|
|
12
|
+
args: { ...scopeFields, operationKey: v.string() },
|
|
13
|
+
returns: v.union(operationDoc, v.null()),
|
|
14
|
+
handler: async (ctx, args) => {
|
|
15
|
+
validateScope(args);
|
|
16
|
+
const op = await ctx.db
|
|
17
|
+
.query("payoutPolicyOperations")
|
|
18
|
+
.withIndex("by_operation", (q) => q.eq("operationKey", args.operationKey))
|
|
19
|
+
.unique();
|
|
20
|
+
if (
|
|
21
|
+
op &&
|
|
22
|
+
(op.accountId !== args.accountId || op.ownerKey !== args.ownerKey)
|
|
23
|
+
)
|
|
24
|
+
throw new Error("Payout policy operation identity does not match.");
|
|
25
|
+
return op;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
export const due = query({
|
|
29
|
+
args: { now: v.number(), paginationOpts: paginationOptsValidator },
|
|
30
|
+
returns: v.object({
|
|
31
|
+
page: v.array(policyDoc),
|
|
32
|
+
continueCursor: v.string(),
|
|
33
|
+
isDone: v.boolean(),
|
|
34
|
+
}),
|
|
35
|
+
handler: async (ctx, args) => {
|
|
36
|
+
if (
|
|
37
|
+
!Number.isSafeInteger(args.now) ||
|
|
38
|
+
args.now < 0 ||
|
|
39
|
+
!Number.isInteger(args.paginationOpts.numItems) ||
|
|
40
|
+
args.paginationOpts.numItems < 1 ||
|
|
41
|
+
args.paginationOpts.numItems > 100
|
|
42
|
+
)
|
|
43
|
+
throw new Error("Invalid payout policy due page.");
|
|
44
|
+
const page = await ctx.db
|
|
45
|
+
.query("payoutPolicies")
|
|
46
|
+
.withIndex("by_deadline", (q) =>
|
|
47
|
+
q.gt("holdDeadline", 0).lte("holdDeadline", args.now),
|
|
48
|
+
)
|
|
49
|
+
.paginate(args.paginationOpts);
|
|
50
|
+
return {
|
|
51
|
+
page: page.page,
|
|
52
|
+
continueCursor: page.continueCursor,
|
|
53
|
+
isDone: page.isDone,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
export const scopeFields = { accountId: v.string(), ownerKey: v.string() };
|
|
4
|
+
export const schedule = v.object({
|
|
5
|
+
interval: v.union(
|
|
6
|
+
v.literal("daily"),
|
|
7
|
+
v.literal("weekly"),
|
|
8
|
+
v.literal("monthly"),
|
|
9
|
+
v.literal("manual"),
|
|
10
|
+
),
|
|
11
|
+
weeklyDays: v.optional(v.array(v.string())),
|
|
12
|
+
monthlyDays: v.optional(v.array(v.number())),
|
|
13
|
+
});
|
|
14
|
+
export const mode = v.union(v.literal("stripe_managed"), v.literal("custom"));
|
|
15
|
+
export const thresholds = v.array(
|
|
16
|
+
v.object({ currency: v.string(), amount: v.number() }),
|
|
17
|
+
);
|
|
18
|
+
export const prepareFields = {
|
|
19
|
+
...scopeFields,
|
|
20
|
+
operationKey: v.string(),
|
|
21
|
+
expectedRevision: v.number(),
|
|
22
|
+
mode,
|
|
23
|
+
thresholds,
|
|
24
|
+
country: v.string(),
|
|
25
|
+
observedSchedule: schedule,
|
|
26
|
+
observedAt: v.optional(v.number()),
|
|
27
|
+
};
|
|
28
|
+
export const policyFields = {
|
|
29
|
+
...scopeFields,
|
|
30
|
+
revision: v.number(),
|
|
31
|
+
mode,
|
|
32
|
+
thresholds,
|
|
33
|
+
status: v.union(v.literal("changing"), v.literal("ready")),
|
|
34
|
+
operationKey: v.string(),
|
|
35
|
+
country: v.string(),
|
|
36
|
+
previousSchedule: v.optional(schedule),
|
|
37
|
+
holdDeadline: v.optional(v.number()),
|
|
38
|
+
};
|
|
39
|
+
export const operationFields = {
|
|
40
|
+
...prepareFields,
|
|
41
|
+
revision: v.number(),
|
|
42
|
+
previousSchedule: schedule,
|
|
43
|
+
targetSchedule: schedule,
|
|
44
|
+
status: v.union(
|
|
45
|
+
v.literal("pending"),
|
|
46
|
+
v.literal("complete"),
|
|
47
|
+
v.literal("canceled"),
|
|
48
|
+
),
|
|
49
|
+
attempted: v.optional(v.boolean()),
|
|
50
|
+
};
|
|
51
|
+
export const policyDoc = v.object({
|
|
52
|
+
...policyFields,
|
|
53
|
+
_id: v.id("payoutPolicies"),
|
|
54
|
+
_creationTime: v.number(),
|
|
55
|
+
});
|
|
56
|
+
export const operationDoc = v.object({
|
|
57
|
+
...operationFields,
|
|
58
|
+
_id: v.id("payoutPolicyOperations"),
|
|
59
|
+
_creationTime: v.number(),
|
|
60
|
+
});
|
|
61
|
+
export const policiesTable = defineTable(policyFields)
|
|
62
|
+
.index("by_account", ["accountId"])
|
|
63
|
+
.index("by_deadline", ["holdDeadline"]);
|
|
64
|
+
export const operationsTable = defineTable(operationFields).index(
|
|
65
|
+
"by_operation",
|
|
66
|
+
["operationKey"],
|
|
67
|
+
);
|
package/src/component/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { policiesTable, operationsTable } from "./payoutPolicy/schema.js";
|
|
1
2
|
import { currencyTotalsTable } from "./earnings/schema.js";
|
|
2
3
|
import {
|
|
3
4
|
claimsTable,
|
|
@@ -31,6 +32,8 @@ import { pricesTable, productsTable } from "./products/schema";
|
|
|
31
32
|
import { webhookEventsTable } from "./webhooks/schema";
|
|
32
33
|
|
|
33
34
|
export default defineSchema({
|
|
35
|
+
payoutPolicies: policiesTable,
|
|
36
|
+
payoutPolicyOperations: operationsTable,
|
|
34
37
|
currencyTotals: currencyTotalsTable,
|
|
35
38
|
recoveryClaims: claimsTable,
|
|
36
39
|
recoveryBalances: balancesTable,
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { convexTest } from "convex-test";
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { api } from "./_generated/api.js";
|
|
5
|
+
import schema from "./schema.js";
|
|
6
|
+
|
|
7
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
8
|
+
const max = Number.MAX_SAFE_INTEGER;
|
|
9
|
+
const transfer = (stripeTransferId: string, amount: number) => ({
|
|
10
|
+
stripeTransferId,
|
|
11
|
+
amount,
|
|
12
|
+
destinationAccountId: "acct_a",
|
|
13
|
+
currency: "usd",
|
|
14
|
+
status: "paid" as const,
|
|
15
|
+
});
|
|
16
|
+
const payout = (stripePayoutId: string, amount: number) => ({
|
|
17
|
+
stripePayoutId,
|
|
18
|
+
amount,
|
|
19
|
+
accountId: "acct_a",
|
|
20
|
+
currency: "usd",
|
|
21
|
+
status: "paid" as const,
|
|
22
|
+
});
|
|
23
|
+
const earnings = { destinationAccountId: "acct_a", previewLimit: 0 };
|
|
24
|
+
|
|
25
|
+
it("rejects the public artifact overflow before rounding and rolls back each rejected transfer", async () => {
|
|
26
|
+
const t = convexTest(schema, modules);
|
|
27
|
+
await t.mutation(
|
|
28
|
+
api.connect.mutations.upsertTransfer,
|
|
29
|
+
transfer("tr_max", max),
|
|
30
|
+
);
|
|
31
|
+
const original = await t.query(
|
|
32
|
+
api.connect.queries.getAccountEarningsByCurrency,
|
|
33
|
+
earnings,
|
|
34
|
+
);
|
|
35
|
+
for (const id of ["tr_overflow_1", "tr_overflow_2"])
|
|
36
|
+
await expect(
|
|
37
|
+
t.mutation(api.connect.mutations.upsertTransfer, transfer(id, 1)),
|
|
38
|
+
).rejects.toThrow(/safe integer/i);
|
|
39
|
+
expect(
|
|
40
|
+
await t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
41
|
+
).toEqual(original);
|
|
42
|
+
expect(await t.run((ctx) => ctx.db.query("transfers").take(3))).toHaveLength(
|
|
43
|
+
1,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects paid payout overflow without persisting the payout or changing its summary", async () => {
|
|
48
|
+
const t = convexTest(schema, modules);
|
|
49
|
+
await t.mutation(api.connect.mutations.upsertPayout, payout("po_max", max));
|
|
50
|
+
await expect(
|
|
51
|
+
t.mutation(api.connect.mutations.upsertPayout, payout("po_overflow", 1)),
|
|
52
|
+
).rejects.toThrow(/safe integer/i);
|
|
53
|
+
expect(
|
|
54
|
+
await t.query(api.connect.queries.getAccountPayoutsByCurrency, {
|
|
55
|
+
accountId: "acct_a",
|
|
56
|
+
previewLimit: 0,
|
|
57
|
+
}),
|
|
58
|
+
).toMatchObject({ totals: [{ paidOut: max, payoutCount: 1 }] });
|
|
59
|
+
expect(await t.run((ctx) => ctx.db.query("payouts").take(2))).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it.each([0.5, Number.NaN, Number.POSITIVE_INFINITY, max + 1])(
|
|
63
|
+
"rejects invalid source amounts %s, including unpaid payouts",
|
|
64
|
+
async (amount) => {
|
|
65
|
+
const t = convexTest(schema, modules);
|
|
66
|
+
await expect(
|
|
67
|
+
t.mutation(
|
|
68
|
+
api.connect.mutations.upsertTransfer,
|
|
69
|
+
transfer("tr_invalid", amount),
|
|
70
|
+
),
|
|
71
|
+
).rejects.toThrow(/safe integer/i);
|
|
72
|
+
await expect(
|
|
73
|
+
t.mutation(api.connect.mutations.upsertPayout, {
|
|
74
|
+
...payout("po_invalid", amount),
|
|
75
|
+
status: "pending",
|
|
76
|
+
}),
|
|
77
|
+
).rejects.toThrow(/safe integer/i);
|
|
78
|
+
expect(
|
|
79
|
+
await t.run((ctx) => ctx.db.query("currencyTotals").take(1)),
|
|
80
|
+
).toEqual([]);
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
it("rolls back subtract/reapply updates that would overflow", async () => {
|
|
85
|
+
const t = convexTest(schema, modules);
|
|
86
|
+
await t.mutation(
|
|
87
|
+
api.connect.mutations.upsertTransfer,
|
|
88
|
+
transfer("tr_max", max),
|
|
89
|
+
);
|
|
90
|
+
await t.mutation(
|
|
91
|
+
api.connect.mutations.upsertTransfer,
|
|
92
|
+
transfer("tr_update", 0),
|
|
93
|
+
);
|
|
94
|
+
const before = await t.run((ctx) => ctx.db.query("transfers").take(3));
|
|
95
|
+
await expect(
|
|
96
|
+
t.mutation(api.connect.mutations.upsertTransfer, transfer("tr_update", 1)),
|
|
97
|
+
).rejects.toThrow(/safe integer/i);
|
|
98
|
+
expect(await t.run((ctx) => ctx.db.query("transfers").take(3))).toEqual(
|
|
99
|
+
before,
|
|
100
|
+
);
|
|
101
|
+
expect(
|
|
102
|
+
await t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
103
|
+
).toMatchObject({ totals: [{ gross: max, transferCount: 2 }] });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rolls back a reversal whose cumulative summary would overflow", async () => {
|
|
107
|
+
const t = convexTest(schema, modules);
|
|
108
|
+
await t.mutation(api.connect.mutations.upsertTransfer, {
|
|
109
|
+
...transfer("tr_a", 1),
|
|
110
|
+
reversedAmount: max - 1,
|
|
111
|
+
});
|
|
112
|
+
await t.mutation(api.connect.mutations.upsertTransfer, {
|
|
113
|
+
...transfer("tr_b", 1),
|
|
114
|
+
reversedAmount: 1,
|
|
115
|
+
});
|
|
116
|
+
const before = await t.query(
|
|
117
|
+
api.connect.queries.getAccountEarningsByCurrency,
|
|
118
|
+
earnings,
|
|
119
|
+
);
|
|
120
|
+
await expect(
|
|
121
|
+
t.mutation(api.connect.mutations.recordTransferReversal, {
|
|
122
|
+
stripeTransferId: "tr_b",
|
|
123
|
+
reversedAmount: 2,
|
|
124
|
+
}),
|
|
125
|
+
).rejects.toThrow(/safe integer/i);
|
|
126
|
+
expect(
|
|
127
|
+
await t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
128
|
+
).toEqual(before);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it.each(["earnings", "payouts"] as const)(
|
|
132
|
+
"rolls back an entire %s backfill page and source markers on unsafe totals",
|
|
133
|
+
async (kind) => {
|
|
134
|
+
const t = convexTest(schema, modules);
|
|
135
|
+
await t.run(async (ctx) => {
|
|
136
|
+
if (kind === "earnings") {
|
|
137
|
+
await ctx.db.insert("transfers", transfer("tr_legacy_max", max));
|
|
138
|
+
await ctx.db.insert("transfers", transfer("tr_legacy_overflow", 1));
|
|
139
|
+
} else {
|
|
140
|
+
await ctx.db.insert("payouts", payout("po_legacy_max", max));
|
|
141
|
+
await ctx.db.insert("payouts", payout("po_legacy_overflow", 1));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
await expect(
|
|
145
|
+
t.mutation(api.earnings.mutations.backfillCurrencyTotals, {
|
|
146
|
+
kind,
|
|
147
|
+
cursor: null,
|
|
148
|
+
}),
|
|
149
|
+
).rejects.toThrow(/safe integer/i);
|
|
150
|
+
expect(
|
|
151
|
+
await t.run((ctx) => ctx.db.query("currencyTotals").take(1)),
|
|
152
|
+
).toEqual([]);
|
|
153
|
+
expect(
|
|
154
|
+
(
|
|
155
|
+
await t.run((ctx) =>
|
|
156
|
+
ctx.db.query(kind === "earnings" ? "transfers" : "payouts").take(2),
|
|
157
|
+
)
|
|
158
|
+
).every((row) => row.totalsRecorded === undefined),
|
|
159
|
+
).toBe(true);
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
it.each([
|
|
164
|
+
["gross", max + 1],
|
|
165
|
+
["reversed", 0.5],
|
|
166
|
+
["paidOut", Number.POSITIVE_INFINITY],
|
|
167
|
+
["count", Number.NaN],
|
|
168
|
+
] as const)(
|
|
169
|
+
"rejects unsafe stored summary field %s before exposing totals",
|
|
170
|
+
async (field, value) => {
|
|
171
|
+
const t = convexTest(schema, modules);
|
|
172
|
+
await t.run((ctx) =>
|
|
173
|
+
ctx.db.insert("currencyTotals", {
|
|
174
|
+
accountId: "acct_a",
|
|
175
|
+
currency: "usd",
|
|
176
|
+
kind: "earnings",
|
|
177
|
+
gross: 100,
|
|
178
|
+
reversed: 0,
|
|
179
|
+
paidOut: 0,
|
|
180
|
+
count: 1,
|
|
181
|
+
[field]: value,
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
await expect(
|
|
185
|
+
t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
186
|
+
).rejects.toThrow(/safe integer/i);
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
it("checks summary counts before persisting a new source record", async () => {
|
|
191
|
+
const t = convexTest(schema, modules);
|
|
192
|
+
await t.run((ctx) =>
|
|
193
|
+
ctx.db.insert("currencyTotals", {
|
|
194
|
+
accountId: "acct_a",
|
|
195
|
+
currency: "usd",
|
|
196
|
+
kind: "earnings",
|
|
197
|
+
gross: 0,
|
|
198
|
+
reversed: 0,
|
|
199
|
+
paidOut: 0,
|
|
200
|
+
count: max,
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
await expect(
|
|
204
|
+
t.mutation(api.connect.mutations.upsertTransfer, transfer("tr_count", 0)),
|
|
205
|
+
).rejects.toThrow(/safe integer/i);
|
|
206
|
+
expect(await t.run((ctx) => ctx.db.query("transfers").take(1))).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("rejects an unsafe derived net even when both stored operands are safe integers", async () => {
|
|
210
|
+
const t = convexTest(schema, modules);
|
|
211
|
+
await t.run((ctx) =>
|
|
212
|
+
ctx.db.insert("currencyTotals", {
|
|
213
|
+
accountId: "acct_a",
|
|
214
|
+
currency: "usd",
|
|
215
|
+
kind: "earnings",
|
|
216
|
+
gross: max,
|
|
217
|
+
reversed: -1,
|
|
218
|
+
paidOut: 0,
|
|
219
|
+
count: 1,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
await expect(
|
|
223
|
+
t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
224
|
+
).rejects.toThrow(/safe integer/i);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("preserves supported boundary amounts through reversal and reinstatement", async () => {
|
|
228
|
+
const t = convexTest(schema, modules);
|
|
229
|
+
await t.mutation(
|
|
230
|
+
api.connect.mutations.upsertTransfer,
|
|
231
|
+
transfer("tr_boundary", max - 2),
|
|
232
|
+
);
|
|
233
|
+
await t.mutation(api.connect.mutations.recordTransferReversal, {
|
|
234
|
+
stripeTransferId: "tr_boundary",
|
|
235
|
+
reversedAmount: 1,
|
|
236
|
+
});
|
|
237
|
+
await t.mutation(api.connect.mutations.upsertTransfer, {
|
|
238
|
+
...transfer("tr_restore", 1),
|
|
239
|
+
reinstatement: true,
|
|
240
|
+
});
|
|
241
|
+
expect(
|
|
242
|
+
await t.query(api.connect.queries.getAccountEarningsByCurrency, earnings),
|
|
243
|
+
).toMatchObject({
|
|
244
|
+
totals: [{ gross: max - 1, reversed: 1, net: max - 2, transferCount: 2 }],
|
|
245
|
+
});
|
|
246
|
+
});
|