@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,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
+ });
@@ -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,
@@ -46,6 +46,8 @@ export type EarningsSummaryProps = {
46
46
  net?: string;
47
47
  paidOut?: string;
48
48
  };
49
+ /** Override formatting for observed currencies outside the charge formatter. */
50
+ formatAmount?: (amount: number, currency: string, locale?: string) => string;
49
51
  locale?: string;
50
52
  className?: string;
51
53
  children?: (props: EarningsSummaryRenderProps) => ReactNode;
@@ -70,6 +72,7 @@ export function EarningsSummary({
70
72
  noPayoutsLabel = "No payouts yet",
71
73
  labels,
72
74
  locale,
75
+ formatAmount = formatPrice,
73
76
  className,
74
77
  children,
75
78
  }: EarningsSummaryProps) {
@@ -93,12 +96,12 @@ export function EarningsSummary({
93
96
  ? latestPayoutLabel
94
97
  .replace(
95
98
  "{amount}",
96
- formatPrice(latestPayout.amount, latestPayout.currency, locale),
99
+ formatAmount(latestPayout.amount, latestPayout.currency, locale),
97
100
  )
98
101
  .replace("{status}", latestPayout.status)
99
102
  : noPayoutsLabel;
100
103
 
101
- const money = (amount: number) => formatPrice(amount, currency, locale);
104
+ const money = (amount: number) => formatAmount(amount, currency, locale);
102
105
  const renderProps: EarningsSummaryRenderProps = {
103
106
  grossLabel: money(gross),
104
107
  reversedLabel: money(reversed),
@@ -0,0 +1,59 @@
1
+ import { expect, it } from "vitest";
2
+ import { createUseCurrencyEarnings } from "./useCurrencyEarnings.js";
3
+ it("joins exact server totals by currency without summing preview rows", () => {
4
+ const earnings = {
5
+ totals: [
6
+ {
7
+ currency: "jpy",
8
+ gross: 1000,
9
+ reversed: 100,
10
+ net: 900,
11
+ transferCount: 99,
12
+ },
13
+ ],
14
+ transfers: [],
15
+ };
16
+ const payouts = {
17
+ totals: [{ currency: "usd", paidOut: 50, payoutCount: 20 }],
18
+ payouts: [],
19
+ };
20
+ const useEarnings = createUseCurrencyEarnings(
21
+ (ref) => (ref === "earnings" ? earnings : payouts),
22
+ "earnings",
23
+ "payouts",
24
+ );
25
+ expect(useEarnings("acct_a").totals).toEqual([
26
+ {
27
+ currency: "jpy",
28
+ gross: 1000,
29
+ reversed: 100,
30
+ net: 900,
31
+ transferCount: 99,
32
+ paidOut: 0,
33
+ payoutCount: 0,
34
+ },
35
+ {
36
+ currency: "usd",
37
+ gross: 0,
38
+ reversed: 0,
39
+ net: 0,
40
+ transferCount: 0,
41
+ paidOut: 50,
42
+ payoutCount: 20,
43
+ },
44
+ ]);
45
+ });
46
+ it("skips unresolved accounts and distinguishes loading from empty totals", () => {
47
+ const args: unknown[] = [];
48
+ const hook = createUseCurrencyEarnings(
49
+ (_ref, value) => {
50
+ args.push(value);
51
+ return undefined;
52
+ },
53
+ "earnings",
54
+ "payouts",
55
+ );
56
+ expect(hook(undefined)).toMatchObject({ totals: [], isLoading: false });
57
+ expect(args).toEqual(["skip", "skip"]);
58
+ expect(hook("acct_a").isLoading).toBe(true);
59
+ });
@@ -0,0 +1,73 @@
1
+ "use client";
2
+ import type { EarningsTransfer } from "./useEarnings.js";
3
+ import type { StripeComponentPayout } from "../types.js";
4
+ export type CurrencyEarningsTotal = {
5
+ currency: string;
6
+ gross: number;
7
+ reversed: number;
8
+ net: number;
9
+ transferCount: number;
10
+ };
11
+ export type CurrencyPayoutTotal = {
12
+ currency: string;
13
+ paidOut: number;
14
+ payoutCount: number;
15
+ };
16
+ export type AccountCurrencyEarnings = {
17
+ totals: CurrencyEarningsTotal[];
18
+ transfers: EarningsTransfer[];
19
+ };
20
+ export type AccountCurrencyPayouts = {
21
+ totals: CurrencyPayoutTotal[];
22
+ payouts: StripeComponentPayout[];
23
+ };
24
+ export type UseCurrencyEarningsResult = {
25
+ totals: (CurrencyEarningsTotal & CurrencyPayoutTotal)[];
26
+ transfers: EarningsTransfer[];
27
+ payouts: StripeComponentPayout[];
28
+ isLoading: boolean;
29
+ };
30
+ /** Bind authorized host queries to the maintained component currency summaries. */
31
+ export function createUseCurrencyEarnings(
32
+ useQuery: (ref: any, args: Record<string, unknown> | "skip") => any,
33
+ earningsRef: any,
34
+ payoutsRef: any,
35
+ ) {
36
+ return function useCurrencyEarnings(
37
+ accountId: string | undefined,
38
+ ): UseCurrencyEarningsResult {
39
+ const earnings = useQuery(
40
+ earningsRef,
41
+ accountId ? { destinationAccountId: accountId } : "skip",
42
+ ) as AccountCurrencyEarnings | undefined;
43
+ const payouts = useQuery(payoutsRef, accountId ? { accountId } : "skip") as
44
+ AccountCurrencyPayouts | undefined;
45
+ const byCurrency = new Map<
46
+ string,
47
+ CurrencyEarningsTotal & CurrencyPayoutTotal
48
+ >();
49
+ for (const row of earnings?.totals ?? [])
50
+ byCurrency.set(row.currency, { ...row, paidOut: 0, payoutCount: 0 });
51
+ for (const row of payouts?.totals ?? [])
52
+ byCurrency.set(row.currency, {
53
+ ...(byCurrency.get(row.currency) ?? {
54
+ currency: row.currency,
55
+ gross: 0,
56
+ reversed: 0,
57
+ net: 0,
58
+ transferCount: 0,
59
+ }),
60
+ ...row,
61
+ });
62
+ return {
63
+ totals: [...byCurrency.values()].sort((a, b) =>
64
+ a.currency.localeCompare(b.currency),
65
+ ),
66
+ transfers: earnings?.transfers ?? [],
67
+ payouts: payouts?.payouts ?? [],
68
+ isLoading:
69
+ accountId !== undefined &&
70
+ (earnings === undefined || payouts === undefined),
71
+ };
72
+ };
73
+ }
@@ -293,3 +293,15 @@ export {
293
293
 
294
294
  export { SaveCard } from "./workflows/SaveCard.js";
295
295
  export type { SaveCardProps, SaveCardFormState } from "./workflows/SaveCard.js";
296
+
297
+ export { createUseCurrencyEarnings } from "./hooks/useCurrencyEarnings.js";
298
+ export type {
299
+ CurrencyEarningsTotal,
300
+ CurrencyPayoutTotal,
301
+ AccountCurrencyEarnings,
302
+ AccountCurrencyPayouts,
303
+ UseCurrencyEarningsResult,
304
+ } from "./hooks/useCurrencyEarnings.js";
305
+
306
+ // Pins end cursors for the component paginator, including reactive page splits.
307
+ export { usePaginatedQuery as useComponentPaginatedQuery } from "convex-helpers/react";
@@ -0,0 +1,20 @@
1
+ import { createElement } from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { expect, it } from "vitest";
4
+ import { EarningsSummary } from "./components/EarningsSummary.js";
5
+ it("lets currency-aware earnings consumers format three-decimal observed amounts", () => {
6
+ const html = renderToStaticMarkup(
7
+ createElement(EarningsSummary, {
8
+ gross: 1234,
9
+ reversed: 100,
10
+ net: 1134,
11
+ paidOut: 1000,
12
+ currency: "kwd",
13
+ payouts: [],
14
+ formatAmount: (amount: number, currency: string) =>
15
+ `${currency.toUpperCase()} ${(amount / 1000).toFixed(3)}`,
16
+ }),
17
+ );
18
+ expect(html).toContain("KWD 1.234");
19
+ expect(html).toContain("KWD 1.134");
20
+ });