@fantastic.dev/better-stripe 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/MIGRATION.md +6 -0
  3. package/README.md +7 -1
  4. package/dist/client/connect/payoutSnapshot.d.ts +2 -0
  5. package/dist/client/connect/payoutSnapshot.d.ts.map +1 -1
  6. package/dist/client/connect/payoutSnapshot.js +2 -0
  7. package/dist/client/connect/payoutSnapshot.js.map +1 -1
  8. package/dist/client/index.d.ts +3 -3
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +3 -3
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/client/readContracts.d.ts +50 -0
  13. package/dist/client/readContracts.d.ts.map +1 -0
  14. package/dist/client/readContracts.js +24 -0
  15. package/dist/client/readContracts.js.map +1 -0
  16. package/dist/client/types/documents.d.ts +2 -0
  17. package/dist/client/types/documents.d.ts.map +1 -1
  18. package/dist/client/webhooks/processors.js +2 -0
  19. package/dist/client/webhooks/processors.js.map +1 -1
  20. package/dist/component/_generated/component.d.ts +177 -0
  21. package/dist/component/_generated/component.d.ts.map +1 -1
  22. package/dist/component/connect/mutations.d.ts +2 -0
  23. package/dist/component/connect/mutations.d.ts.map +1 -1
  24. package/dist/component/connect/mutations.js +8 -8
  25. package/dist/component/connect/mutations.js.map +1 -1
  26. package/dist/component/connect/queries.d.ts +102 -2
  27. package/dist/component/connect/queries.d.ts.map +1 -1
  28. package/dist/component/connect/queries.js +88 -45
  29. package/dist/component/connect/queries.js.map +1 -1
  30. package/dist/component/connect/schema.d.ts +12 -2
  31. package/dist/component/connect/schema.d.ts.map +1 -1
  32. package/dist/component/connect/schema.js +10 -1
  33. package/dist/component/connect/schema.js.map +1 -1
  34. package/dist/component/connect/validators.d.ts +13 -2
  35. package/dist/component/connect/validators.d.ts.map +1 -1
  36. package/dist/component/connect/validators.js +4 -0
  37. package/dist/component/connect/validators.js.map +1 -1
  38. package/dist/component/earnings/legacyReads.d.ts +51 -0
  39. package/dist/component/earnings/legacyReads.d.ts.map +1 -0
  40. package/dist/component/earnings/legacyReads.js +43 -0
  41. package/dist/component/earnings/legacyReads.js.map +1 -0
  42. package/dist/component/earnings/mutations.d.ts +9 -0
  43. package/dist/component/earnings/mutations.d.ts.map +1 -0
  44. package/dist/component/earnings/mutations.js +23 -0
  45. package/dist/component/earnings/mutations.js.map +1 -0
  46. package/dist/component/earnings/reads.d.ts +14 -0
  47. package/dist/component/earnings/reads.d.ts.map +1 -0
  48. package/dist/component/earnings/reads.js +28 -0
  49. package/dist/component/earnings/reads.js.map +1 -0
  50. package/dist/component/earnings/schema.d.ts +20 -0
  51. package/dist/component/earnings/schema.d.ts.map +1 -0
  52. package/dist/component/earnings/schema.js +12 -0
  53. package/dist/component/earnings/schema.js.map +1 -0
  54. package/dist/component/earnings/triggers.d.ts +1290 -0
  55. package/dist/component/earnings/triggers.d.ts.map +1 -0
  56. package/dist/component/earnings/triggers.js +68 -0
  57. package/dist/component/earnings/triggers.js.map +1 -0
  58. package/dist/component/products/queries.d.ts +47 -0
  59. package/dist/component/products/queries.d.ts.map +1 -1
  60. package/dist/component/products/queries.js +59 -0
  61. package/dist/component/products/queries.js.map +1 -1
  62. package/dist/component/schema.d.ts +31 -2
  63. package/dist/component/schema.js +2 -0
  64. package/dist/component/schema.js.map +1 -1
  65. package/dist/react/components/EarningsSummary.d.ts +3 -1
  66. package/dist/react/components/EarningsSummary.d.ts.map +1 -1
  67. package/dist/react/components/EarningsSummary.js +3 -3
  68. package/dist/react/components/EarningsSummary.js.map +1 -1
  69. package/dist/react/hooks/useCurrencyEarnings.d.ts +31 -0
  70. package/dist/react/hooks/useCurrencyEarnings.d.ts.map +1 -0
  71. package/dist/react/hooks/useCurrencyEarnings.js +30 -0
  72. package/dist/react/hooks/useCurrencyEarnings.js.map +1 -0
  73. package/dist/react/index.d.ts +3 -0
  74. package/dist/react/index.d.ts.map +1 -1
  75. package/dist/react/index.js +3 -0
  76. package/dist/react/index.js.map +1 -1
  77. package/docs/react-workflows.md +6 -0
  78. package/docs/read-contracts.md +136 -0
  79. package/package.json +3 -2
  80. package/src/client/connect/payoutSnapshot.test.ts +27 -0
  81. package/src/client/connect/payoutSnapshot.ts +4 -0
  82. package/src/client/index.ts +4 -4
  83. package/src/client/readContracts.ts +90 -0
  84. package/src/client/readPagination.test.ts +62 -0
  85. package/src/client/types/documents.ts +2 -0
  86. package/src/client/webhooks/payout-canceled.integration.test.ts +25 -0
  87. package/src/client/webhooks/processors.ts +2 -0
  88. package/src/component/_generated/api.ts +8 -0
  89. package/src/component/_generated/component.ts +201 -0
  90. package/src/component/connect/mutations.ts +8 -8
  91. package/src/component/connect/queries.ts +110 -48
  92. package/src/component/connect/schema.ts +10 -1
  93. package/src/component/connect/validators.ts +4 -0
  94. package/src/component/currency-migration.test.ts +119 -0
  95. package/src/component/currency-totals.test.ts +95 -0
  96. package/src/component/earnings/legacyReads.ts +59 -0
  97. package/src/component/earnings/mutations.ts +21 -0
  98. package/src/component/earnings/reads.ts +41 -0
  99. package/src/component/earnings/schema.ts +11 -0
  100. package/src/component/earnings/triggers.ts +91 -0
  101. package/src/component/products/queries.ts +74 -0
  102. package/src/component/public.test.ts +1 -1
  103. package/src/component/read-pagination.test.ts +193 -0
  104. package/src/component/schema.ts +2 -0
  105. package/src/react/components/EarningsSummary.tsx +5 -2
  106. package/src/react/hooks/useCurrencyEarnings.test.ts +59 -0
  107. package/src/react/hooks/useCurrencyEarnings.ts +73 -0
  108. package/src/react/index.ts +12 -0
  109. package/src/react/read-contracts.render.test.tsx +20 -0
@@ -0,0 +1,119 @@
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
+ it("backfills legacy rows in bounded pages, safely interleaved with updates and replay", async () => {
8
+ const t = convexTest(schema, modules);
9
+ await t.run(async (ctx) => {
10
+ for (let i = 0; i < 205; i++)
11
+ await ctx.db.insert("transfers", {
12
+ stripeTransferId: `legacy_${i}`,
13
+ destinationAccountId: "acct_old",
14
+ amount: 100,
15
+ currency: "usd",
16
+ status: "paid",
17
+ });
18
+ });
19
+ await expect(
20
+ t.query(api.connect.queries.getAccountEarningsByCurrency, {
21
+ destinationAccountId: "acct_old",
22
+ }),
23
+ ).rejects.toThrow("backfill");
24
+ let cursor: string | null = null;
25
+ for (let i = 0; i < 3; i++) {
26
+ const result: { isDone: boolean; continueCursor: string } =
27
+ await t.mutation(api.earnings.mutations.backfillCurrencyTotals, {
28
+ kind: "earnings",
29
+ cursor,
30
+ });
31
+ cursor = result.continueCursor;
32
+ if (i === 0) {
33
+ await t.mutation(api.connect.mutations.recordTransferReversal, {
34
+ stripeTransferId: "legacy_150",
35
+ reversedAmount: 50,
36
+ });
37
+ await expect(
38
+ t.query(api.connect.queries.getAccountEarningsByCurrency, {
39
+ destinationAccountId: "acct_old",
40
+ }),
41
+ ).rejects.toThrow("backfill");
42
+ }
43
+ expect(result.isDone).toBe(i === 2);
44
+ }
45
+ const read = () =>
46
+ t.query(api.connect.queries.getAccountEarningsByCurrency, {
47
+ destinationAccountId: "acct_old",
48
+ previewLimit: 0,
49
+ });
50
+ expect(await read()).toEqual({
51
+ totals: [
52
+ {
53
+ currency: "usd",
54
+ gross: 20500,
55
+ reversed: 50,
56
+ net: 20450,
57
+ transferCount: 205,
58
+ },
59
+ ],
60
+ transfers: [],
61
+ });
62
+ await t.mutation(api.earnings.mutations.backfillCurrencyTotals, {
63
+ kind: "earnings",
64
+ cursor: null,
65
+ });
66
+ expect((await read()).totals[0].gross).toBe(20500);
67
+ });
68
+ it("moves changed payout contributions between accounts and currencies, counting only paid rows", async () => {
69
+ const t = convexTest(schema, modules);
70
+ const args = {
71
+ stripePayoutId: "po_move",
72
+ accountId: "acct_old",
73
+ amount: 100,
74
+ currency: "usd",
75
+ status: "paid" as const,
76
+ };
77
+ await t.run((ctx) => ctx.db.insert("payouts", args));
78
+ await expect(
79
+ t.query(api.connect.queries.getAccountPayoutsByCurrency, {
80
+ accountId: "acct_old",
81
+ }),
82
+ ).rejects.toThrow("backfill");
83
+ await t.mutation(api.earnings.mutations.backfillCurrencyTotals, {
84
+ kind: "payouts",
85
+ cursor: null,
86
+ });
87
+ await t.mutation(api.connect.mutations.upsertPayout, {
88
+ ...args,
89
+ accountId: "acct_new",
90
+ currency: "jpy",
91
+ status: "failed",
92
+ });
93
+ expect(
94
+ (
95
+ await t.query(api.connect.queries.getAccountPayoutsByCurrency, {
96
+ accountId: "acct_old",
97
+ })
98
+ ).totals,
99
+ ).toEqual([]);
100
+ expect(
101
+ (
102
+ await t.query(api.connect.queries.getAccountPayoutsByCurrency, {
103
+ accountId: "acct_new",
104
+ })
105
+ ).totals,
106
+ ).toEqual([{ currency: "jpy", paidOut: 0, payoutCount: 1 }]);
107
+ await t.mutation(api.connect.mutations.upsertPayout, {
108
+ ...args,
109
+ accountId: "acct_new",
110
+ currency: "jpy",
111
+ });
112
+ expect(
113
+ (
114
+ await t.query(api.connect.queries.getAccountPayoutsByCurrency, {
115
+ accountId: "acct_new",
116
+ })
117
+ ).totals[0].paidOut,
118
+ ).toBe(100);
119
+ });
@@ -0,0 +1,95 @@
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
+ it("keeps exact per-currency totals across previews, reversals, reinstatements and payout failures", async () => {
8
+ const t = convexTest(schema, modules);
9
+ for (let i = 0; i < 120; i++) {
10
+ const currency = ["usd", "eur", "jpy", "kwd"][i % 4];
11
+ await t.mutation(api.connect.mutations.upsertTransfer, {
12
+ stripeTransferId: `tr_${i}`,
13
+ destinationAccountId: "acct_a",
14
+ amount: 100,
15
+ currency,
16
+ status: "paid",
17
+ });
18
+ await t.mutation(api.connect.mutations.upsertPayout, {
19
+ stripePayoutId: `po_${i}`,
20
+ accountId: "acct_a",
21
+ amount: 10,
22
+ currency,
23
+ status: i < 60 ? "paid" : "failed",
24
+ });
25
+ }
26
+ await t.mutation(api.connect.mutations.recordTransferReversal, {
27
+ stripeTransferId: "tr_0",
28
+ reversedAmount: 40,
29
+ });
30
+ await t.mutation(api.connect.mutations.upsertTransfer, {
31
+ stripeTransferId: "tr_restored",
32
+ destinationAccountId: "acct_a",
33
+ amount: 40,
34
+ currency: "usd",
35
+ status: "paid",
36
+ reinstatement: true,
37
+ });
38
+ const earnings = await t.query(
39
+ api.connect.queries.getAccountEarningsByCurrency,
40
+ { destinationAccountId: "acct_a", previewLimit: 2 },
41
+ );
42
+ expect(earnings.transfers).toHaveLength(2);
43
+ expect(earnings.totals).toEqual([
44
+ { currency: "eur", gross: 3000, reversed: 0, net: 3000, transferCount: 30 },
45
+ { currency: "jpy", gross: 3000, reversed: 0, net: 3000, transferCount: 30 },
46
+ { currency: "kwd", gross: 3000, reversed: 0, net: 3000, transferCount: 30 },
47
+ {
48
+ currency: "usd",
49
+ gross: 3040,
50
+ reversed: 40,
51
+ net: 3000,
52
+ transferCount: 31,
53
+ },
54
+ ]);
55
+ const payouts = await t.query(
56
+ api.connect.queries.getAccountPayoutsByCurrency,
57
+ { accountId: "acct_a", previewLimit: 1 },
58
+ );
59
+ expect(payouts.payouts).toHaveLength(1);
60
+ expect(payouts.totals.map((row) => row.paidOut)).toEqual([
61
+ 150, 150, 150, 150,
62
+ ]);
63
+ expect(
64
+ await t.query(api.connect.queries.getAccountEarningsByCurrency, {
65
+ destinationAccountId: "other",
66
+ }),
67
+ ).toEqual({ totals: [], transfers: [] });
68
+ });
69
+ it("refuses to add incompatible currencies in the legacy totals", async () => {
70
+ const t = convexTest(schema, modules);
71
+ for (const currency of ["usd", "eur"]) {
72
+ await t.mutation(api.connect.mutations.upsertTransfer, {
73
+ stripeTransferId: currency,
74
+ destinationAccountId: "acct_a",
75
+ amount: 1,
76
+ currency,
77
+ status: "paid",
78
+ });
79
+ await t.mutation(api.connect.mutations.upsertPayout, {
80
+ stripePayoutId: currency,
81
+ accountId: "acct_a",
82
+ amount: 1,
83
+ currency,
84
+ status: "paid",
85
+ });
86
+ }
87
+ await expect(
88
+ t.query(api.connect.queries.getAccountEarnings, {
89
+ destinationAccountId: "acct_a",
90
+ }),
91
+ ).rejects.toThrow("currency");
92
+ await expect(
93
+ t.query(api.connect.queries.getAccountPayouts, { accountId: "acct_a" }),
94
+ ).rejects.toThrow("currency");
95
+ });
@@ -0,0 +1,59 @@
1
+ import type { Doc } from "../_generated/dataModel.js";
2
+ import type { QueryCtx } from "../_generated/server.js";
3
+
4
+ export async function getAccountEarnings(
5
+ ctx: QueryCtx,
6
+ args: { destinationAccountId: string; previewLimit?: number },
7
+ ) {
8
+ const previewLimit = args.previewLimit ?? 50;
9
+ let currency: string | undefined;
10
+ let gross = 0;
11
+ let reversed = 0;
12
+ let transferCount = 0;
13
+ const transfers: Doc<"transfers">[] = [];
14
+
15
+ for await (const tr of ctx.db
16
+ .query("transfers")
17
+ .withIndex("by_destination_account_id", (q) =>
18
+ q.eq("destinationAccountId", args.destinationAccountId),
19
+ )
20
+ .order("desc")) {
21
+ if (currency !== undefined && currency !== tr.currency)
22
+ throw new Error(
23
+ "Mixed currency account: use getAccountEarningsByCurrency",
24
+ );
25
+ currency = tr.currency;
26
+ gross += tr.amount;
27
+ reversed += tr.reversedAmount ?? 0;
28
+ transferCount += 1;
29
+ if (transfers.length < previewLimit) transfers.push(tr);
30
+ }
31
+
32
+ return { gross, reversed, transferCount, transfers };
33
+ }
34
+
35
+ export async function getAccountPayouts(
36
+ ctx: QueryCtx,
37
+ args: { accountId: string; previewLimit?: number },
38
+ ) {
39
+ const previewLimit = args.previewLimit ?? 50;
40
+ let currency: string | undefined;
41
+ let paidOut = 0;
42
+ let payoutCount = 0;
43
+ const payouts: Doc<"payouts">[] = [];
44
+
45
+ for await (const p of ctx.db
46
+ .query("payouts")
47
+ .withIndex("by_account_id", (q) => q.eq("accountId", args.accountId))) {
48
+ if (currency !== undefined && currency !== p.currency)
49
+ throw new Error(
50
+ "Mixed currency account: use getAccountPayoutsByCurrency",
51
+ );
52
+ currency = p.currency;
53
+ payoutCount += 1;
54
+ if (p.status === "paid") paidOut += p.amount;
55
+ if (payouts.length < previewLimit) payouts.push(p);
56
+ }
57
+
58
+ return { paidOut, payoutCount, payouts };
59
+ }
@@ -0,0 +1,21 @@
1
+ import { paginator } from "convex-helpers/server/pagination";
2
+ import schema from "../schema.js";
3
+ import { v } from "convex/values";
4
+ import { mutation } from "./triggers.js";
5
+ /** Call repeatedly until isDone. Updates and backfill share one transaction. */
6
+ export const backfillCurrencyTotals = mutation({
7
+ args: {
8
+ kind: v.union(v.literal("earnings"), v.literal("payouts")),
9
+ cursor: v.union(v.string(), v.null()),
10
+ },
11
+ returns: v.object({ isDone: v.boolean(), continueCursor: v.string() }),
12
+ handler: async (ctx, args) => {
13
+ const table = args.kind === "earnings" ? "transfers" : "payouts";
14
+ const result = await paginator(ctx.db, schema)
15
+ .query(table)
16
+ .paginate({ numItems: 100, cursor: args.cursor });
17
+ for (const row of result.page)
18
+ if (!row.totalsRecorded) await ctx.db.patch(table, row._id, {});
19
+ return { isDone: result.isDone, continueCursor: result.continueCursor };
20
+ },
21
+ });
@@ -0,0 +1,41 @@
1
+ import type { QueryCtx } from "../_generated/server.js";
2
+ export function previewSize(value = 50) {
3
+ if (!Number.isInteger(value) || value < 0 || value > 100)
4
+ throw new Error("Preview size must be an integer from 0 to 100");
5
+ return value;
6
+ }
7
+ export async function currencyTotals(
8
+ ctx: QueryCtx,
9
+ accountId: string,
10
+ kind: "earnings" | "payouts",
11
+ ) {
12
+ const pending =
13
+ kind === "earnings"
14
+ ? await ctx.db
15
+ .query("transfers")
16
+ .withIndex("by_account_totals_recorded", (q) =>
17
+ q
18
+ .eq("destinationAccountId", accountId)
19
+ .eq("totalsRecorded", undefined),
20
+ )
21
+ .first()
22
+ : await ctx.db
23
+ .query("payouts")
24
+ .withIndex("by_account_totals_recorded", (q) =>
25
+ q.eq("accountId", accountId).eq("totalsRecorded", undefined),
26
+ )
27
+ .first();
28
+ if (pending)
29
+ throw new Error(
30
+ "Currency totals require backfillCurrencyTotals before reading this account",
31
+ );
32
+ const rows = await ctx.db
33
+ .query("currencyTotals")
34
+ .withIndex("by_account_kind_currency", (q) =>
35
+ q.eq("accountId", accountId).eq("kind", kind),
36
+ )
37
+ .take(501);
38
+ if (rows.length > 500)
39
+ throw new Error("Account exceeds supported currency count");
40
+ return rows;
41
+ }
@@ -0,0 +1,11 @@
1
+ import { defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ export const currencyTotalsTable = defineTable({
4
+ accountId: v.string(),
5
+ currency: v.string(),
6
+ kind: v.union(v.literal("earnings"), v.literal("payouts")),
7
+ gross: v.number(),
8
+ reversed: v.number(),
9
+ paidOut: v.number(),
10
+ count: v.number(),
11
+ }).index("by_account_kind_currency", ["accountId", "kind", "currency"]);
@@ -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(13);
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[]) {