@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.
Files changed (127) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/MIGRATION.md +6 -0
  3. package/README.md +15 -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/connect/refunds.d.ts +12 -0
  9. package/dist/client/connect/refunds.d.ts.map +1 -1
  10. package/dist/client/connect/refunds.js +8 -0
  11. package/dist/client/connect/refunds.js.map +1 -1
  12. package/dist/client/index.d.ts +8 -10
  13. package/dist/client/index.d.ts.map +1 -1
  14. package/dist/client/index.js +6 -3
  15. package/dist/client/index.js.map +1 -1
  16. package/dist/client/readContracts.d.ts +50 -0
  17. package/dist/client/readContracts.d.ts.map +1 -0
  18. package/dist/client/readContracts.js +24 -0
  19. package/dist/client/readContracts.js.map +1 -0
  20. package/dist/client/types/documents.d.ts +2 -0
  21. package/dist/client/types/documents.d.ts.map +1 -1
  22. package/dist/client/webhooks/processors.js +2 -0
  23. package/dist/client/webhooks/processors.js.map +1 -1
  24. package/dist/component/_generated/component.d.ts +213 -0
  25. package/dist/component/_generated/component.d.ts.map +1 -1
  26. package/dist/component/connect/mutations.d.ts +2 -0
  27. package/dist/component/connect/mutations.d.ts.map +1 -1
  28. package/dist/component/connect/mutations.js +8 -8
  29. package/dist/component/connect/mutations.js.map +1 -1
  30. package/dist/component/connect/queries.d.ts +102 -2
  31. package/dist/component/connect/queries.d.ts.map +1 -1
  32. package/dist/component/connect/queries.js +88 -45
  33. package/dist/component/connect/queries.js.map +1 -1
  34. package/dist/component/connect/schema.d.ts +14 -2
  35. package/dist/component/connect/schema.d.ts.map +1 -1
  36. package/dist/component/connect/schema.js +19 -1
  37. package/dist/component/connect/schema.js.map +1 -1
  38. package/dist/component/connect/validators.d.ts +13 -2
  39. package/dist/component/connect/validators.d.ts.map +1 -1
  40. package/dist/component/connect/validators.js +4 -0
  41. package/dist/component/connect/validators.js.map +1 -1
  42. package/dist/component/earnings/legacyReads.d.ts +51 -0
  43. package/dist/component/earnings/legacyReads.d.ts.map +1 -0
  44. package/dist/component/earnings/legacyReads.js +43 -0
  45. package/dist/component/earnings/legacyReads.js.map +1 -0
  46. package/dist/component/earnings/mutations.d.ts +9 -0
  47. package/dist/component/earnings/mutations.d.ts.map +1 -0
  48. package/dist/component/earnings/mutations.js +23 -0
  49. package/dist/component/earnings/mutations.js.map +1 -0
  50. package/dist/component/earnings/reads.d.ts +14 -0
  51. package/dist/component/earnings/reads.d.ts.map +1 -0
  52. package/dist/component/earnings/reads.js +28 -0
  53. package/dist/component/earnings/reads.js.map +1 -0
  54. package/dist/component/earnings/schema.d.ts +20 -0
  55. package/dist/component/earnings/schema.d.ts.map +1 -0
  56. package/dist/component/earnings/schema.js +12 -0
  57. package/dist/component/earnings/schema.js.map +1 -0
  58. package/dist/component/earnings/triggers.d.ts +1294 -0
  59. package/dist/component/earnings/triggers.d.ts.map +1 -0
  60. package/dist/component/earnings/triggers.js +68 -0
  61. package/dist/component/earnings/triggers.js.map +1 -0
  62. package/dist/component/products/queries.d.ts +47 -0
  63. package/dist/component/products/queries.d.ts.map +1 -1
  64. package/dist/component/products/queries.js +59 -0
  65. package/dist/component/products/queries.js.map +1 -1
  66. package/dist/component/refundHistory/queries.d.ts +28 -0
  67. package/dist/component/refundHistory/queries.d.ts.map +1 -0
  68. package/dist/component/refundHistory/queries.js +59 -0
  69. package/dist/component/refundHistory/queries.js.map +1 -0
  70. package/dist/component/schema.d.ts +33 -2
  71. package/dist/component/schema.js +2 -0
  72. package/dist/component/schema.js.map +1 -1
  73. package/dist/react/components/EarningsSummary.d.ts +3 -1
  74. package/dist/react/components/EarningsSummary.d.ts.map +1 -1
  75. package/dist/react/components/EarningsSummary.js +3 -3
  76. package/dist/react/components/EarningsSummary.js.map +1 -1
  77. package/dist/react/hooks/useCurrencyEarnings.d.ts +31 -0
  78. package/dist/react/hooks/useCurrencyEarnings.d.ts.map +1 -0
  79. package/dist/react/hooks/useCurrencyEarnings.js +30 -0
  80. package/dist/react/hooks/useCurrencyEarnings.js.map +1 -0
  81. package/dist/react/index.d.ts +3 -0
  82. package/dist/react/index.d.ts.map +1 -1
  83. package/dist/react/index.js +3 -0
  84. package/dist/react/index.js.map +1 -1
  85. package/dist/react/workflows/Costs.d.ts +1 -0
  86. package/dist/react/workflows/Costs.d.ts.map +1 -1
  87. package/dist/react/workflows/Costs.js +7 -3
  88. package/dist/react/workflows/Costs.js.map +1 -1
  89. package/docs/react-workflows.md +40 -0
  90. package/docs/read-contracts.md +136 -0
  91. package/package.json +3 -2
  92. package/src/client/connect/payoutSnapshot.test.ts +27 -0
  93. package/src/client/connect/payoutSnapshot.ts +4 -0
  94. package/src/client/connect/refunds.ts +28 -0
  95. package/src/client/index.ts +10 -11
  96. package/src/client/readContracts.ts +90 -0
  97. package/src/client/readPagination.test.ts +62 -0
  98. package/src/client/refund-history.test.ts +67 -0
  99. package/src/client/types/documents.ts +2 -0
  100. package/src/client/webhooks/payout-canceled.integration.test.ts +25 -0
  101. package/src/client/webhooks/processors.ts +2 -0
  102. package/src/component/_generated/api.ts +10 -0
  103. package/src/component/_generated/component.ts +257 -0
  104. package/src/component/connect/mutations.ts +8 -8
  105. package/src/component/connect/queries.ts +110 -48
  106. package/src/component/connect/schema.ts +19 -1
  107. package/src/component/connect/validators.ts +4 -0
  108. package/src/component/currency-migration.test.ts +119 -0
  109. package/src/component/currency-totals.test.ts +95 -0
  110. package/src/component/earnings/legacyReads.ts +59 -0
  111. package/src/component/earnings/mutations.ts +21 -0
  112. package/src/component/earnings/reads.ts +41 -0
  113. package/src/component/earnings/schema.ts +11 -0
  114. package/src/component/earnings/triggers.ts +91 -0
  115. package/src/component/products/queries.ts +74 -0
  116. package/src/component/public.test.ts +1 -1
  117. package/src/component/read-pagination.test.ts +193 -0
  118. package/src/component/refund-history.test.ts +198 -0
  119. package/src/component/refundHistory/queries.ts +99 -0
  120. package/src/component/schema.ts +2 -0
  121. package/src/react/components/EarningsSummary.tsx +5 -2
  122. package/src/react/hooks/useCurrencyEarnings.test.ts +59 -0
  123. package/src/react/hooks/useCurrencyEarnings.ts +73 -0
  124. package/src/react/index.ts +12 -0
  125. package/src/react/read-contracts.render.test.tsx +20 -0
  126. package/src/react/workflows/Costs.test.tsx +21 -0
  127. 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(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[]) {
@@ -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
+ }
@@ -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,