@fantastic.dev/better-stripe 0.15.0 → 0.17.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 +16 -0
- package/MIGRATION.md +6 -0
- package/README.md +15 -1
- package/dist/client/connect/payoutSnapshot.d.ts +2 -0
- package/dist/client/connect/payoutSnapshot.d.ts.map +1 -1
- package/dist/client/connect/payoutSnapshot.js +2 -0
- package/dist/client/connect/payoutSnapshot.js.map +1 -1
- package/dist/client/connect/refunds.d.ts +12 -0
- package/dist/client/connect/refunds.d.ts.map +1 -1
- package/dist/client/connect/refunds.js +8 -0
- package/dist/client/connect/refunds.js.map +1 -1
- package/dist/client/index.d.ts +8 -10
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +6 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/readContracts.d.ts +50 -0
- package/dist/client/readContracts.d.ts.map +1 -0
- package/dist/client/readContracts.js +24 -0
- package/dist/client/readContracts.js.map +1 -0
- package/dist/client/types/documents.d.ts +2 -0
- package/dist/client/types/documents.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +2 -0
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +213 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/mutations.d.ts +2 -0
- package/dist/component/connect/mutations.d.ts.map +1 -1
- package/dist/component/connect/mutations.js +8 -8
- package/dist/component/connect/mutations.js.map +1 -1
- package/dist/component/connect/queries.d.ts +102 -2
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +88 -45
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/component/connect/schema.d.ts +14 -2
- package/dist/component/connect/schema.d.ts.map +1 -1
- package/dist/component/connect/schema.js +19 -1
- package/dist/component/connect/schema.js.map +1 -1
- package/dist/component/connect/validators.d.ts +13 -2
- package/dist/component/connect/validators.d.ts.map +1 -1
- package/dist/component/connect/validators.js +4 -0
- package/dist/component/connect/validators.js.map +1 -1
- package/dist/component/earnings/legacyReads.d.ts +51 -0
- package/dist/component/earnings/legacyReads.d.ts.map +1 -0
- package/dist/component/earnings/legacyReads.js +43 -0
- package/dist/component/earnings/legacyReads.js.map +1 -0
- package/dist/component/earnings/mutations.d.ts +9 -0
- package/dist/component/earnings/mutations.d.ts.map +1 -0
- package/dist/component/earnings/mutations.js +23 -0
- package/dist/component/earnings/mutations.js.map +1 -0
- package/dist/component/earnings/reads.d.ts +14 -0
- package/dist/component/earnings/reads.d.ts.map +1 -0
- package/dist/component/earnings/reads.js +28 -0
- package/dist/component/earnings/reads.js.map +1 -0
- package/dist/component/earnings/schema.d.ts +20 -0
- package/dist/component/earnings/schema.d.ts.map +1 -0
- package/dist/component/earnings/schema.js +12 -0
- package/dist/component/earnings/schema.js.map +1 -0
- package/dist/component/earnings/triggers.d.ts +1294 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -0
- package/dist/component/earnings/triggers.js +68 -0
- package/dist/component/earnings/triggers.js.map +1 -0
- package/dist/component/products/queries.d.ts +47 -0
- package/dist/component/products/queries.d.ts.map +1 -1
- package/dist/component/products/queries.js +59 -0
- package/dist/component/products/queries.js.map +1 -1
- package/dist/component/refundHistory/queries.d.ts +28 -0
- package/dist/component/refundHistory/queries.d.ts.map +1 -0
- package/dist/component/refundHistory/queries.js +59 -0
- package/dist/component/refundHistory/queries.js.map +1 -0
- package/dist/component/schema.d.ts +33 -2
- package/dist/component/schema.js +2 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/components/EarningsSummary.d.ts +3 -1
- package/dist/react/components/EarningsSummary.d.ts.map +1 -1
- package/dist/react/components/EarningsSummary.js +3 -3
- package/dist/react/components/EarningsSummary.js.map +1 -1
- package/dist/react/hooks/useCurrencyEarnings.d.ts +31 -0
- package/dist/react/hooks/useCurrencyEarnings.d.ts.map +1 -0
- package/dist/react/hooks/useCurrencyEarnings.js +30 -0
- package/dist/react/hooks/useCurrencyEarnings.js.map +1 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/workflows/Costs.d.ts +1 -0
- package/dist/react/workflows/Costs.d.ts.map +1 -1
- package/dist/react/workflows/Costs.js +7 -3
- package/dist/react/workflows/Costs.js.map +1 -1
- package/docs/react-workflows.md +40 -0
- package/docs/read-contracts.md +136 -0
- package/package.json +3 -2
- package/src/client/connect/payoutSnapshot.test.ts +27 -0
- package/src/client/connect/payoutSnapshot.ts +4 -0
- package/src/client/connect/refunds.ts +28 -0
- package/src/client/index.ts +10 -11
- package/src/client/readContracts.ts +90 -0
- package/src/client/readPagination.test.ts +62 -0
- package/src/client/refund-history.test.ts +67 -0
- package/src/client/types/documents.ts +2 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +25 -0
- package/src/client/webhooks/processors.ts +2 -0
- package/src/component/_generated/api.ts +10 -0
- package/src/component/_generated/component.ts +257 -0
- package/src/component/connect/mutations.ts +8 -8
- package/src/component/connect/queries.ts +110 -48
- package/src/component/connect/schema.ts +19 -1
- package/src/component/connect/validators.ts +4 -0
- package/src/component/currency-migration.test.ts +119 -0
- package/src/component/currency-totals.test.ts +95 -0
- package/src/component/earnings/legacyReads.ts +59 -0
- package/src/component/earnings/mutations.ts +21 -0
- package/src/component/earnings/reads.ts +41 -0
- package/src/component/earnings/schema.ts +11 -0
- package/src/component/earnings/triggers.ts +91 -0
- package/src/component/products/queries.ts +74 -0
- package/src/component/public.test.ts +1 -1
- package/src/component/read-pagination.test.ts +193 -0
- package/src/component/refund-history.test.ts +198 -0
- package/src/component/refundHistory/queries.ts +99 -0
- package/src/component/schema.ts +2 -0
- package/src/react/components/EarningsSummary.tsx +5 -2
- package/src/react/hooks/useCurrencyEarnings.test.ts +59 -0
- package/src/react/hooks/useCurrencyEarnings.ts +73 -0
- package/src/react/index.ts +12 -0
- package/src/react/read-contracts.render.test.tsx +20 -0
- package/src/react/workflows/Costs.test.tsx +21 -0
- package/src/react/workflows/Costs.tsx +9 -3
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Triggers } from "convex-helpers/server/triggers";
|
|
2
|
+
import {
|
|
3
|
+
customCtx,
|
|
4
|
+
customMutation,
|
|
5
|
+
} from "convex-helpers/server/customFunctions";
|
|
6
|
+
import type { DataModel } from "../_generated/dataModel.js";
|
|
7
|
+
import {
|
|
8
|
+
mutation as rawMutation,
|
|
9
|
+
type MutationCtx,
|
|
10
|
+
} from "../_generated/server.js";
|
|
11
|
+
|
|
12
|
+
type Contribution = {
|
|
13
|
+
accountId: string;
|
|
14
|
+
currency: string;
|
|
15
|
+
gross: number;
|
|
16
|
+
reversed: number;
|
|
17
|
+
paidOut: number;
|
|
18
|
+
count: number;
|
|
19
|
+
};
|
|
20
|
+
async function adjust(
|
|
21
|
+
ctx: MutationCtx,
|
|
22
|
+
kind: "earnings" | "payouts",
|
|
23
|
+
value: Contribution,
|
|
24
|
+
sign: number,
|
|
25
|
+
) {
|
|
26
|
+
const row = await ctx.db
|
|
27
|
+
.query("currencyTotals")
|
|
28
|
+
.withIndex("by_account_kind_currency", (q) =>
|
|
29
|
+
q
|
|
30
|
+
.eq("accountId", value.accountId)
|
|
31
|
+
.eq("kind", kind)
|
|
32
|
+
.eq("currency", value.currency),
|
|
33
|
+
)
|
|
34
|
+
.unique();
|
|
35
|
+
const totals = {
|
|
36
|
+
gross: (row?.gross ?? 0) + sign * value.gross,
|
|
37
|
+
reversed: (row?.reversed ?? 0) + sign * value.reversed,
|
|
38
|
+
paidOut: (row?.paidOut ?? 0) + sign * value.paidOut,
|
|
39
|
+
count: (row?.count ?? 0) + sign * value.count,
|
|
40
|
+
};
|
|
41
|
+
if (row) {
|
|
42
|
+
if (totals.count === 0) await ctx.db.delete("currencyTotals", row._id);
|
|
43
|
+
else await ctx.db.patch("currencyTotals", row._id, totals);
|
|
44
|
+
} else
|
|
45
|
+
await ctx.db.insert("currencyTotals", {
|
|
46
|
+
accountId: value.accountId,
|
|
47
|
+
currency: value.currency,
|
|
48
|
+
kind,
|
|
49
|
+
...totals,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const triggers = new Triggers<DataModel>();
|
|
53
|
+
triggers.register("transfers", async (ctx, change) => {
|
|
54
|
+
const contribution = (
|
|
55
|
+
row: NonNullable<typeof change.newDoc>,
|
|
56
|
+
): Contribution => ({
|
|
57
|
+
accountId: row.destinationAccountId,
|
|
58
|
+
currency: row.currency,
|
|
59
|
+
gross: row.amount,
|
|
60
|
+
reversed: row.reversedAmount ?? 0,
|
|
61
|
+
paidOut: 0,
|
|
62
|
+
count: 1,
|
|
63
|
+
});
|
|
64
|
+
if (change.oldDoc?.totalsRecorded)
|
|
65
|
+
await adjust(ctx, "earnings", contribution(change.oldDoc), -1);
|
|
66
|
+
if (change.newDoc) {
|
|
67
|
+
await adjust(ctx, "earnings", contribution(change.newDoc), 1);
|
|
68
|
+
if (!change.newDoc.totalsRecorded)
|
|
69
|
+
await ctx.innerDb.patch("transfers", change.id, { totalsRecorded: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
triggers.register("payouts", async (ctx, change) => {
|
|
73
|
+
const contribution = (
|
|
74
|
+
row: NonNullable<typeof change.newDoc>,
|
|
75
|
+
): Contribution => ({
|
|
76
|
+
accountId: row.accountId,
|
|
77
|
+
currency: row.currency,
|
|
78
|
+
gross: 0,
|
|
79
|
+
reversed: 0,
|
|
80
|
+
paidOut: row.status === "paid" ? row.amount : 0,
|
|
81
|
+
count: 1,
|
|
82
|
+
});
|
|
83
|
+
if (change.oldDoc?.totalsRecorded)
|
|
84
|
+
await adjust(ctx, "payouts", contribution(change.oldDoc), -1);
|
|
85
|
+
if (change.newDoc) {
|
|
86
|
+
await adjust(ctx, "payouts", contribution(change.newDoc), 1);
|
|
87
|
+
if (!change.newDoc.totalsRecorded)
|
|
88
|
+
await ctx.innerDb.patch("payouts", change.id, { totalsRecorded: true });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
2
|
+
import schema from "../schema.js";
|
|
3
|
+
import {
|
|
4
|
+
paginationOptsValidator,
|
|
5
|
+
paginationResultValidator,
|
|
6
|
+
} from "convex/server";
|
|
1
7
|
import { v } from "convex/values";
|
|
2
8
|
|
|
3
9
|
import { query } from "../_generated/server";
|
|
@@ -147,3 +153,71 @@ export const listPricesByProduct = query({
|
|
|
147
153
|
.collect();
|
|
148
154
|
},
|
|
149
155
|
});
|
|
156
|
+
|
|
157
|
+
export const paginateProducts = query({
|
|
158
|
+
args: {
|
|
159
|
+
accountId: v.optional(v.string()),
|
|
160
|
+
active: v.optional(v.boolean()),
|
|
161
|
+
paginationOpts: paginationOptsValidator,
|
|
162
|
+
},
|
|
163
|
+
returns: paginationResultValidator(productDocValidator),
|
|
164
|
+
handler: (ctx, args) => {
|
|
165
|
+
if (
|
|
166
|
+
!Number.isInteger(args.paginationOpts.numItems) ||
|
|
167
|
+
args.paginationOpts.numItems < 1 ||
|
|
168
|
+
args.paginationOpts.numItems > 100
|
|
169
|
+
)
|
|
170
|
+
throw new Error("Page size must be an integer from 1 to 100");
|
|
171
|
+
const { accountId, active } = args;
|
|
172
|
+
const rows = paginator(ctx.db, schema).query("products");
|
|
173
|
+
const scoped =
|
|
174
|
+
accountId !== undefined
|
|
175
|
+
? active !== undefined
|
|
176
|
+
? rows.withIndex("by_account_active", (q) =>
|
|
177
|
+
q.eq("accountId", accountId).eq("active", active),
|
|
178
|
+
)
|
|
179
|
+
: rows.withIndex("by_account_id", (q) => q.eq("accountId", accountId))
|
|
180
|
+
: active !== undefined
|
|
181
|
+
? rows.withIndex("by_active", (q) => q.eq("active", active))
|
|
182
|
+
: rows;
|
|
183
|
+
return scoped.order("desc").paginate({
|
|
184
|
+
...args.paginationOpts,
|
|
185
|
+
maximumRowsRead: 200,
|
|
186
|
+
maximumBytesRead: 1_000_000,
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
export const paginatePrices = query({
|
|
192
|
+
args: {
|
|
193
|
+
productId: v.optional(v.string()),
|
|
194
|
+
active: v.optional(v.boolean()),
|
|
195
|
+
paginationOpts: paginationOptsValidator,
|
|
196
|
+
},
|
|
197
|
+
returns: paginationResultValidator(priceDocValidator),
|
|
198
|
+
handler: (ctx, args) => {
|
|
199
|
+
if (
|
|
200
|
+
!Number.isInteger(args.paginationOpts.numItems) ||
|
|
201
|
+
args.paginationOpts.numItems < 1 ||
|
|
202
|
+
args.paginationOpts.numItems > 100
|
|
203
|
+
)
|
|
204
|
+
throw new Error("Page size must be an integer from 1 to 100");
|
|
205
|
+
const { productId, active } = args;
|
|
206
|
+
const rows = paginator(ctx.db, schema).query("prices");
|
|
207
|
+
const scoped =
|
|
208
|
+
productId !== undefined
|
|
209
|
+
? active !== undefined
|
|
210
|
+
? rows.withIndex("by_product_active", (q) =>
|
|
211
|
+
q.eq("productId", productId).eq("active", active),
|
|
212
|
+
)
|
|
213
|
+
: rows.withIndex("by_product_id", (q) => q.eq("productId", productId))
|
|
214
|
+
: active !== undefined
|
|
215
|
+
? rows.withIndex("by_active", (q) => q.eq("active", active))
|
|
216
|
+
: rows;
|
|
217
|
+
return scoped.order("desc").paginate({
|
|
218
|
+
...args.paginationOpts,
|
|
219
|
+
maximumRowsRead: 200,
|
|
220
|
+
maximumBytesRead: 1_000_000,
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
});
|
|
@@ -471,7 +471,7 @@ describe("core — clearAllTables", () => {
|
|
|
471
471
|
const result = await t.mutation(api.core.mutations.clearAllTables, {});
|
|
472
472
|
const tableCount = Object.keys(schema.tables).length;
|
|
473
473
|
|
|
474
|
-
expect(result.cleared).toBe(
|
|
474
|
+
expect(result.cleared).toBe(15);
|
|
475
475
|
expect(result.tables).toHaveLength(tableCount);
|
|
476
476
|
|
|
477
477
|
for (const tableName of Object.keys(schema.tables) as TableNames[]) {
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { convexTest } from "convex-test";
|
|
3
|
+
import { expect, it } from "vitest";
|
|
4
|
+
import { api } from "./_generated/api.js";
|
|
5
|
+
import schema from "./schema.js";
|
|
6
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
7
|
+
|
|
8
|
+
it("traverses account/status payout history beyond the cap without duplicates", async () => {
|
|
9
|
+
const t = convexTest(schema, modules);
|
|
10
|
+
for (let i = 0; i < 67; i++)
|
|
11
|
+
await t.mutation(api.connect.mutations.upsertPayout, {
|
|
12
|
+
stripePayoutId: `po_${i}`,
|
|
13
|
+
accountId: i < 57 ? "acct_a" : "acct_b",
|
|
14
|
+
status: "paid",
|
|
15
|
+
amount: i,
|
|
16
|
+
currency: "usd",
|
|
17
|
+
});
|
|
18
|
+
await t.mutation(api.connect.mutations.upsertPayout, {
|
|
19
|
+
stripePayoutId: "po_failed",
|
|
20
|
+
accountId: "acct_a",
|
|
21
|
+
status: "failed",
|
|
22
|
+
amount: 999,
|
|
23
|
+
currency: "usd",
|
|
24
|
+
});
|
|
25
|
+
const ids: string[] = [];
|
|
26
|
+
let cursor: string | null = null;
|
|
27
|
+
for (let i = 0; i < 10; i++) {
|
|
28
|
+
const result: {
|
|
29
|
+
page: { stripePayoutId: string }[];
|
|
30
|
+
isDone: boolean;
|
|
31
|
+
continueCursor: string;
|
|
32
|
+
} = await t.query(api.connect.queries.paginatePayouts, {
|
|
33
|
+
accountId: "acct_a",
|
|
34
|
+
status: "paid",
|
|
35
|
+
paginationOpts: { numItems: 10, cursor },
|
|
36
|
+
});
|
|
37
|
+
ids.push(...result.page.map((p) => p.stripePayoutId));
|
|
38
|
+
if (result.isDone) break;
|
|
39
|
+
cursor = result.continueCursor;
|
|
40
|
+
}
|
|
41
|
+
expect(ids).toEqual(Array.from({ length: 57 }, (_, i) => `po_${56 - i}`));
|
|
42
|
+
expect(new Set(ids).size).toBe(57);
|
|
43
|
+
expect(
|
|
44
|
+
await t.query(api.connect.queries.paginatePayouts, {
|
|
45
|
+
accountId: "empty",
|
|
46
|
+
paginationOpts: { numItems: 10, cursor: null },
|
|
47
|
+
}),
|
|
48
|
+
).toMatchObject({ page: [], isDone: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("traverses indexed product and price pages with account/product/active isolation", async () => {
|
|
52
|
+
const t = convexTest(schema, modules);
|
|
53
|
+
for (let i = 0; i < 67; i++) {
|
|
54
|
+
await t.mutation(api.products.mutations.upsertProduct, {
|
|
55
|
+
stripeProductId: `prod_${i}`,
|
|
56
|
+
accountId: i < 57 ? "acct_a" : "acct_b",
|
|
57
|
+
name: `Product ${i}`,
|
|
58
|
+
active: true,
|
|
59
|
+
});
|
|
60
|
+
await t.mutation(api.products.mutations.upsertPrice, {
|
|
61
|
+
stripePriceId: `price_${i}`,
|
|
62
|
+
productId: i < 57 ? "product_a" : "product_b",
|
|
63
|
+
stripeProductId: i < 57 ? "prod_a" : "prod_b",
|
|
64
|
+
unitAmount: i,
|
|
65
|
+
currency: "usd",
|
|
66
|
+
type: "one_time",
|
|
67
|
+
active: true,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
for (const [ref, filters, key, prefix] of [
|
|
71
|
+
[
|
|
72
|
+
api.products.queries.paginateProducts,
|
|
73
|
+
{ accountId: "acct_a", active: true },
|
|
74
|
+
"stripeProductId",
|
|
75
|
+
"prod_",
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
api.products.queries.paginatePrices,
|
|
79
|
+
{ productId: "product_a", active: true },
|
|
80
|
+
"stripePriceId",
|
|
81
|
+
"price_",
|
|
82
|
+
],
|
|
83
|
+
] as const) {
|
|
84
|
+
const ids: string[] = [];
|
|
85
|
+
let cursor: string | null = null;
|
|
86
|
+
for (let i = 0; i < 10; i++) {
|
|
87
|
+
const result: {
|
|
88
|
+
page: Record<string, unknown>[];
|
|
89
|
+
isDone: boolean;
|
|
90
|
+
continueCursor: string;
|
|
91
|
+
} = await t.query(ref, {
|
|
92
|
+
...filters,
|
|
93
|
+
paginationOpts: { numItems: 10, cursor },
|
|
94
|
+
});
|
|
95
|
+
ids.push(
|
|
96
|
+
...result.page.map((row: Record<string, unknown>) => String(row[key])),
|
|
97
|
+
);
|
|
98
|
+
if (result.isDone) break;
|
|
99
|
+
cursor = result.continueCursor;
|
|
100
|
+
}
|
|
101
|
+
expect(ids).toEqual(
|
|
102
|
+
Array.from({ length: 57 }, (_, i) => `${prefix}${56 - i}`),
|
|
103
|
+
);
|
|
104
|
+
expect(
|
|
105
|
+
await t.query(ref, {
|
|
106
|
+
...filters,
|
|
107
|
+
active: false,
|
|
108
|
+
paginationOpts: { numItems: 10, cursor: null },
|
|
109
|
+
}),
|
|
110
|
+
).toMatchObject({ page: [], isDone: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects invalid page bounds and preserves legacy capped readers", async () => {
|
|
115
|
+
const t = convexTest(schema, modules);
|
|
116
|
+
for (const numItems of [0, -1, 101, 1.5]) {
|
|
117
|
+
await expect(
|
|
118
|
+
t.query(api.products.queries.paginateProducts, {
|
|
119
|
+
paginationOpts: { numItems, cursor: null },
|
|
120
|
+
}),
|
|
121
|
+
).rejects.toThrow("Page size");
|
|
122
|
+
await expect(
|
|
123
|
+
t.query(api.products.queries.paginatePrices, {
|
|
124
|
+
paginationOpts: { numItems, cursor: null },
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow("Page size");
|
|
127
|
+
await expect(
|
|
128
|
+
t.query(api.connect.queries.paginatePayouts, {
|
|
129
|
+
accountId: "acct_a",
|
|
130
|
+
paginationOpts: { numItems, cursor: null },
|
|
131
|
+
}),
|
|
132
|
+
).rejects.toThrow("Page size");
|
|
133
|
+
}
|
|
134
|
+
expect(await t.query(api.products.queries.listProducts, {})).toEqual([]);
|
|
135
|
+
expect(await t.query(api.products.queries.listPrices, {})).toEqual([]);
|
|
136
|
+
expect(await t.query(api.connect.queries.listPayouts, {})).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("pins reactive product boundaries and splits a grown page before read limits", async () => {
|
|
140
|
+
const t = convexTest(schema, modules);
|
|
141
|
+
for (let i = 0; i < 20; i++)
|
|
142
|
+
await t.mutation(api.products.mutations.upsertProduct, {
|
|
143
|
+
stripeProductId: `boundary_${i}`,
|
|
144
|
+
accountId: "acct_boundary",
|
|
145
|
+
name: "Boundary",
|
|
146
|
+
active: true,
|
|
147
|
+
});
|
|
148
|
+
const first = await t.query(api.products.queries.paginateProducts, {
|
|
149
|
+
accountId: "acct_boundary",
|
|
150
|
+
paginationOpts: { numItems: 10, cursor: null },
|
|
151
|
+
});
|
|
152
|
+
const second = await t.query(api.products.queries.paginateProducts, {
|
|
153
|
+
accountId: "acct_boundary",
|
|
154
|
+
paginationOpts: { numItems: 10, cursor: first.continueCursor },
|
|
155
|
+
});
|
|
156
|
+
await t.mutation(api.products.mutations.upsertProduct, {
|
|
157
|
+
stripeProductId: "new_boundary",
|
|
158
|
+
accountId: "acct_boundary",
|
|
159
|
+
name: "New",
|
|
160
|
+
active: true,
|
|
161
|
+
});
|
|
162
|
+
const pinned = await t.query(api.products.queries.paginateProducts, {
|
|
163
|
+
accountId: "acct_boundary",
|
|
164
|
+
paginationOpts: {
|
|
165
|
+
numItems: 10,
|
|
166
|
+
cursor: null,
|
|
167
|
+
endCursor: first.continueCursor,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
expect(pinned.page).toHaveLength(11);
|
|
171
|
+
expect(
|
|
172
|
+
new Set([...pinned.page, ...second.page].map((p) => p.stripeProductId))
|
|
173
|
+
.size,
|
|
174
|
+
).toBe(21);
|
|
175
|
+
for (let i = 0; i < 220; i++)
|
|
176
|
+
await t.mutation(api.products.mutations.upsertProduct, {
|
|
177
|
+
stripeProductId: `growth_${i}`,
|
|
178
|
+
accountId: "acct_boundary",
|
|
179
|
+
name: "Growth",
|
|
180
|
+
active: true,
|
|
181
|
+
});
|
|
182
|
+
const grown = await t.query(api.products.queries.paginateProducts, {
|
|
183
|
+
accountId: "acct_boundary",
|
|
184
|
+
paginationOpts: {
|
|
185
|
+
numItems: 10,
|
|
186
|
+
cursor: null,
|
|
187
|
+
endCursor: first.continueCursor,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
expect(grown.page).toHaveLength(200);
|
|
191
|
+
expect(grown.pageStatus).toBe("SplitRequired");
|
|
192
|
+
expect(grown.splitCursor).toBeTruthy();
|
|
193
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { convexTest } from "convex-test";
|
|
2
|
+
import { afterEach, expect, it, vi } 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 paginate = api.refundHistory.queries.paginateRefunds;
|
|
9
|
+
afterEach(() => vi.useRealTimers());
|
|
10
|
+
const rows = [
|
|
11
|
+
{
|
|
12
|
+
stripeRefundId: "re_a1",
|
|
13
|
+
accountId: "acct_a",
|
|
14
|
+
stripePaymentIntentId: "pi_a",
|
|
15
|
+
status: "succeeded",
|
|
16
|
+
currency: "jpy",
|
|
17
|
+
amount: 500,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
stripeRefundId: "re_a2",
|
|
21
|
+
accountId: "acct_a",
|
|
22
|
+
stripePaymentIntentId: "pi_a",
|
|
23
|
+
status: "pending",
|
|
24
|
+
currency: "isk",
|
|
25
|
+
amount: 500,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
stripeRefundId: "re_a3",
|
|
29
|
+
accountId: "acct_a",
|
|
30
|
+
stripePaymentIntentId: "pi_b",
|
|
31
|
+
status: "succeeded",
|
|
32
|
+
currency: "usd",
|
|
33
|
+
amount: 100,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
stripeRefundId: "re_b1",
|
|
37
|
+
accountId: "acct_b",
|
|
38
|
+
stripePaymentIntentId: "pi_a",
|
|
39
|
+
status: "succeeded",
|
|
40
|
+
currency: "eur",
|
|
41
|
+
amount: 200,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
stripeRefundId: "re_b2",
|
|
45
|
+
accountId: "acct_b",
|
|
46
|
+
stripePaymentIntentId: "pi_only_b",
|
|
47
|
+
status: "failed",
|
|
48
|
+
currency: "usd",
|
|
49
|
+
amount: 300,
|
|
50
|
+
},
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
async function fixture() {
|
|
54
|
+
const t = convexTest(schema, modules);
|
|
55
|
+
vi.useFakeTimers();
|
|
56
|
+
for (const [index, row] of rows.entries()) {
|
|
57
|
+
vi.setSystemTime(1800000000000 + index * 1000);
|
|
58
|
+
await t.mutation(api.connect.mutations.upsertRefund, {
|
|
59
|
+
...row,
|
|
60
|
+
stripeChargeId: `ch_${index}`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return t;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it("walks every original refund over bounded newest-first pages and replays a cursor", async () => {
|
|
67
|
+
const t = await fixture();
|
|
68
|
+
const first = await t.query(paginate, {
|
|
69
|
+
paginationOpts: { numItems: 2, cursor: null },
|
|
70
|
+
});
|
|
71
|
+
expect(
|
|
72
|
+
first.page.map((row: { stripeRefundId: string }) => row.stripeRefundId),
|
|
73
|
+
).toEqual(["re_b2", "re_b1"]);
|
|
74
|
+
expect(first.isDone).toBe(false);
|
|
75
|
+
const args = {
|
|
76
|
+
paginationOpts: { numItems: 2, cursor: first.continueCursor },
|
|
77
|
+
};
|
|
78
|
+
const second = await t.query(paginate, args);
|
|
79
|
+
expect(await t.query(paginate, args)).toEqual(second);
|
|
80
|
+
const last = await t.query(paginate, {
|
|
81
|
+
paginationOpts: { numItems: 2, cursor: second.continueCursor },
|
|
82
|
+
});
|
|
83
|
+
expect(last.isDone).toBe(true);
|
|
84
|
+
expect(
|
|
85
|
+
[...first.page, ...second.page, ...last.page].map((row) => ({
|
|
86
|
+
stripeRefundId: row.stripeRefundId,
|
|
87
|
+
accountId: row.accountId,
|
|
88
|
+
stripePaymentIntentId: row.stripePaymentIntentId,
|
|
89
|
+
status: row.status,
|
|
90
|
+
currency: row.currency,
|
|
91
|
+
amount: row.amount,
|
|
92
|
+
})),
|
|
93
|
+
).toEqual([...rows].reverse());
|
|
94
|
+
expect(last.page[0].stripeChargeId).toBe("ch_0");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it.each([
|
|
98
|
+
[{ accountId: "acct_a" }, ["re_a3", "re_a2", "re_a1"]],
|
|
99
|
+
[{ stripePaymentIntentId: "pi_a" }, ["re_b1", "re_a2", "re_a1"]],
|
|
100
|
+
[{ status: "succeeded" }, ["re_b1", "re_a3", "re_a1"]],
|
|
101
|
+
[{ accountId: "acct_a", status: "succeeded" }, ["re_a3", "re_a1"]],
|
|
102
|
+
[{ stripePaymentIntentId: "pi_a", status: "succeeded" }, ["re_b1", "re_a1"]],
|
|
103
|
+
[{ accountId: "acct_a", stripePaymentIntentId: "pi_a" }, ["re_a2", "re_a1"]],
|
|
104
|
+
[
|
|
105
|
+
{ accountId: "acct_a", stripePaymentIntentId: "pi_a", status: "succeeded" },
|
|
106
|
+
["re_a1"],
|
|
107
|
+
],
|
|
108
|
+
[{ accountId: "acct_a", stripePaymentIntentId: "pi_only_b" }, []],
|
|
109
|
+
[
|
|
110
|
+
{
|
|
111
|
+
accountId: "acct_a",
|
|
112
|
+
stripePaymentIntentId: "pi_only_b",
|
|
113
|
+
status: "failed",
|
|
114
|
+
},
|
|
115
|
+
[],
|
|
116
|
+
],
|
|
117
|
+
] as const)(
|
|
118
|
+
"applies every supplied scope conjunctively: %j",
|
|
119
|
+
async (scope, expected) => {
|
|
120
|
+
const t = await fixture();
|
|
121
|
+
const result = await t.query(paginate, {
|
|
122
|
+
...scope,
|
|
123
|
+
paginationOpts: { numItems: 100, cursor: null },
|
|
124
|
+
});
|
|
125
|
+
expect(
|
|
126
|
+
result.page.map((row: { stripeRefundId: string }) => row.stripeRefundId),
|
|
127
|
+
).toEqual(expected);
|
|
128
|
+
expect(result.isDone).toBe(true);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
it.each([0, -1, 1.5, 101, Number.NaN, Number.POSITIVE_INFINITY])(
|
|
133
|
+
"rejects invalid page size %s",
|
|
134
|
+
async (numItems) => {
|
|
135
|
+
const t = convexTest(schema, modules);
|
|
136
|
+
await expect(
|
|
137
|
+
t.query(paginate, { paginationOpts: { numItems, cursor: null } }),
|
|
138
|
+
).rejects.toThrow(/page size/i);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
it("returns a completed empty page on empty history", async () => {
|
|
143
|
+
const t = convexTest(schema, modules);
|
|
144
|
+
expect(
|
|
145
|
+
await t.query(paginate, { paginationOpts: { numItems: 1, cursor: null } }),
|
|
146
|
+
).toMatchObject({
|
|
147
|
+
page: [],
|
|
148
|
+
isDone: true,
|
|
149
|
+
continueCursor: expect.any(String),
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("pins reactive refund ranges and splits growth before bounded read limits", async () => {
|
|
154
|
+
const t = await fixture();
|
|
155
|
+
const scope = { accountId: "acct_a" };
|
|
156
|
+
const first = await t.query(paginate, {
|
|
157
|
+
...scope,
|
|
158
|
+
paginationOpts: { numItems: 2, cursor: null },
|
|
159
|
+
});
|
|
160
|
+
const second = await t.query(paginate, {
|
|
161
|
+
...scope,
|
|
162
|
+
paginationOpts: { numItems: 2, cursor: first.continueCursor },
|
|
163
|
+
});
|
|
164
|
+
vi.setSystemTime(1800000010000);
|
|
165
|
+
await t.mutation(api.connect.mutations.upsertRefund, {
|
|
166
|
+
stripeRefundId: "re_new",
|
|
167
|
+
...scope,
|
|
168
|
+
amount: 500,
|
|
169
|
+
currency: "jpy",
|
|
170
|
+
status: "pending",
|
|
171
|
+
});
|
|
172
|
+
const paginationOpts = {
|
|
173
|
+
numItems: 2,
|
|
174
|
+
cursor: null,
|
|
175
|
+
endCursor: first.continueCursor,
|
|
176
|
+
};
|
|
177
|
+
const pinned = await t.query(paginate, { ...scope, paginationOpts });
|
|
178
|
+
expect(pinned.page).toHaveLength(3);
|
|
179
|
+
expect(
|
|
180
|
+
new Set([...pinned.page, ...second.page].map((row) => row.stripeRefundId))
|
|
181
|
+
.size,
|
|
182
|
+
).toBe(4);
|
|
183
|
+
for (let index = 0; index < 205; index++) {
|
|
184
|
+
vi.setSystemTime(1800000020000 + index * 1000);
|
|
185
|
+
await t.mutation(api.connect.mutations.upsertRefund, {
|
|
186
|
+
stripeRefundId: `re_growth_${index}`,
|
|
187
|
+
...scope,
|
|
188
|
+
amount: 500,
|
|
189
|
+
currency: "jpy",
|
|
190
|
+
status: "succeeded",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const grown = await t.query(paginate, { ...scope, paginationOpts });
|
|
194
|
+
expect(grown.page).toHaveLength(200);
|
|
195
|
+
expect(grown.pageStatus).toBe("SplitRequired");
|
|
196
|
+
expect(grown.splitCursor).toBeTruthy();
|
|
197
|
+
expect(grown.page.every((row) => row.accountId === "acct_a")).toBe(true);
|
|
198
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
paginationOptsValidator,
|
|
3
|
+
paginationResultValidator,
|
|
4
|
+
} from "convex/server";
|
|
5
|
+
import { v } from "convex/values";
|
|
6
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
7
|
+
|
|
8
|
+
import { query, type QueryCtx } from "../_generated/server.js";
|
|
9
|
+
import schema from "../schema.js";
|
|
10
|
+
import {
|
|
11
|
+
refundDocValidator,
|
|
12
|
+
refundStatusValidator,
|
|
13
|
+
type RefundStatus,
|
|
14
|
+
} from "../connect/validators.js";
|
|
15
|
+
|
|
16
|
+
/** Host-authorized scopes are conjunctive; use the package React pagination binding. */
|
|
17
|
+
export const paginateRefunds = query({
|
|
18
|
+
args: {
|
|
19
|
+
accountId: v.optional(v.string()),
|
|
20
|
+
stripePaymentIntentId: v.optional(v.string()),
|
|
21
|
+
status: v.optional(refundStatusValidator),
|
|
22
|
+
paginationOpts: paginationOptsValidator,
|
|
23
|
+
},
|
|
24
|
+
returns: paginationResultValidator(refundDocValidator),
|
|
25
|
+
handler: (ctx, args) => {
|
|
26
|
+
const { paginationOpts } = args;
|
|
27
|
+
if (
|
|
28
|
+
!Number.isSafeInteger(paginationOpts.numItems) ||
|
|
29
|
+
paginationOpts.numItems < 1 ||
|
|
30
|
+
paginationOpts.numItems > 100
|
|
31
|
+
)
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Refund history page size must be an integer from 1 to 100",
|
|
34
|
+
);
|
|
35
|
+
return scopedRefunds(ctx, args)
|
|
36
|
+
.order("desc")
|
|
37
|
+
.paginate({
|
|
38
|
+
...paginationOpts,
|
|
39
|
+
maximumRowsRead: 200,
|
|
40
|
+
maximumBytesRead: 1_000_000,
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function scopedRefunds(
|
|
46
|
+
ctx: QueryCtx,
|
|
47
|
+
args: {
|
|
48
|
+
accountId?: string;
|
|
49
|
+
stripePaymentIntentId?: string;
|
|
50
|
+
status?: RefundStatus;
|
|
51
|
+
},
|
|
52
|
+
) {
|
|
53
|
+
const { accountId, stripePaymentIntentId, status } = args;
|
|
54
|
+
const refunds = paginator(ctx.db, schema).query("refunds");
|
|
55
|
+
if (
|
|
56
|
+
accountId !== undefined &&
|
|
57
|
+
stripePaymentIntentId !== undefined &&
|
|
58
|
+
status === undefined
|
|
59
|
+
)
|
|
60
|
+
return refunds.withIndex(
|
|
61
|
+
"by_account_id_and_stripe_payment_intent_id",
|
|
62
|
+
(q) =>
|
|
63
|
+
q
|
|
64
|
+
.eq("accountId", accountId)
|
|
65
|
+
.eq("stripePaymentIntentId", stripePaymentIntentId),
|
|
66
|
+
);
|
|
67
|
+
if (
|
|
68
|
+
accountId !== undefined &&
|
|
69
|
+
stripePaymentIntentId !== undefined &&
|
|
70
|
+
status !== undefined
|
|
71
|
+
)
|
|
72
|
+
return refunds.withIndex(
|
|
73
|
+
"by_account_id_and_stripe_payment_intent_id_and_status",
|
|
74
|
+
(q) =>
|
|
75
|
+
q
|
|
76
|
+
.eq("accountId", accountId)
|
|
77
|
+
.eq("stripePaymentIntentId", stripePaymentIntentId)
|
|
78
|
+
.eq("status", status),
|
|
79
|
+
);
|
|
80
|
+
if (accountId !== undefined && status !== undefined)
|
|
81
|
+
return refunds.withIndex("by_account_id_and_status", (q) =>
|
|
82
|
+
q.eq("accountId", accountId).eq("status", status),
|
|
83
|
+
);
|
|
84
|
+
if (stripePaymentIntentId !== undefined && status !== undefined)
|
|
85
|
+
return refunds.withIndex("by_payment_intent_id_and_status", (q) =>
|
|
86
|
+
q.eq("stripePaymentIntentId", stripePaymentIntentId).eq("status", status),
|
|
87
|
+
);
|
|
88
|
+
if (accountId !== undefined)
|
|
89
|
+
return refunds.withIndex("by_account_id", (q) =>
|
|
90
|
+
q.eq("accountId", accountId),
|
|
91
|
+
);
|
|
92
|
+
if (stripePaymentIntentId !== undefined)
|
|
93
|
+
return refunds.withIndex("by_stripe_payment_intent_id", (q) =>
|
|
94
|
+
q.eq("stripePaymentIntentId", stripePaymentIntentId),
|
|
95
|
+
);
|
|
96
|
+
if (status !== undefined)
|
|
97
|
+
return refunds.withIndex("by_status", (q) => q.eq("status", status));
|
|
98
|
+
return refunds;
|
|
99
|
+
}
|
package/src/component/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { currencyTotalsTable } from "./earnings/schema.js";
|
|
1
2
|
import {
|
|
2
3
|
claimsTable,
|
|
3
4
|
balancesTable,
|
|
@@ -30,6 +31,7 @@ import { pricesTable, productsTable } from "./products/schema";
|
|
|
30
31
|
import { webhookEventsTable } from "./webhooks/schema";
|
|
31
32
|
|
|
32
33
|
export default defineSchema({
|
|
34
|
+
currencyTotals: currencyTotalsTable,
|
|
33
35
|
recoveryClaims: claimsTable,
|
|
34
36
|
recoveryBalances: balancesTable,
|
|
35
37
|
recoveryGroups: groupsTable,
|