@fantastic.dev/better-stripe 0.14.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.
- package/CHANGELOG.md +12 -0
- package/MIGRATION.md +6 -0
- package/README.md +40 -1
- package/dist/client/billing/checkoutOptions.d.ts +27 -0
- package/dist/client/billing/checkoutOptions.d.ts.map +1 -0
- package/dist/client/billing/checkoutOptions.js +2 -0
- package/dist/client/billing/checkoutOptions.js.map +1 -0
- package/dist/client/billing/intents.d.ts +32 -0
- package/dist/client/billing/intents.d.ts.map +1 -0
- package/dist/client/billing/intents.js +71 -0
- package/dist/client/billing/intents.js.map +1 -0
- 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/core/fees.d.ts +2 -0
- package/dist/client/core/fees.d.ts.map +1 -1
- package/dist/client/core/fees.js +7 -0
- package/dist/client/core/fees.js.map +1 -1
- package/dist/client/index.d.ts +18 -26
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +18 -16
- 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 +177 -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 +12 -2
- package/dist/component/connect/schema.d.ts.map +1 -1
- package/dist/component/connect/schema.js +10 -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 +1290 -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/schema.d.ts +31 -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/useConfirmPayment.d.ts +1 -1
- package/dist/react/hooks/useConfirmPayment.d.ts.map +1 -1
- package/dist/react/hooks/useConfirmPayment.js +13 -2
- package/dist/react/hooks/useConfirmPayment.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 +5 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +4 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/workflows/SaveCard.d.ts +27 -0
- package/dist/react/workflows/SaveCard.d.ts.map +1 -0
- package/dist/react/workflows/SaveCard.js +56 -0
- package/dist/react/workflows/SaveCard.js.map +1 -0
- package/docs/react-workflows.md +47 -0
- package/docs/read-contracts.md +136 -0
- package/package.json +3 -2
- package/src/client/billing/checkoutOptions.ts +27 -0
- package/src/client/billing/intents.test.ts +216 -0
- package/src/client/billing/intents.ts +120 -0
- package/src/client/billing/intentsTransport.test.ts +94 -0
- package/src/client/connect/payoutSnapshot.test.ts +27 -0
- package/src/client/connect/payoutSnapshot.ts +4 -0
- package/src/client/core/fees.ts +8 -0
- package/src/client/index.ts +40 -43
- package/src/client/readContracts.ts +90 -0
- package/src/client/readPagination.test.ts +62 -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 +8 -0
- package/src/component/_generated/component.ts +201 -0
- package/src/component/connect/mutations.ts +8 -8
- package/src/component/connect/queries.ts +110 -48
- package/src/component/connect/schema.ts +10 -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/schema.ts +2 -0
- package/src/react/components/EarningsSummary.tsx +5 -2
- package/src/react/hooks/useConfirmPayment.test.tsx +47 -0
- package/src/react/hooks/useConfirmPayment.ts +16 -2
- package/src/react/hooks/useCurrencyEarnings.test.ts +59 -0
- package/src/react/hooks/useCurrencyEarnings.ts +73 -0
- package/src/react/index.ts +15 -0
- package/src/react/read-contracts.render.test.tsx +20 -0
- package/src/react/workflows/SaveCard.test.tsx +127 -0
- package/src/react/workflows/SaveCard.tsx +117 -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
|
+
});
|
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,
|
|
@@ -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
|
-
|
|
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) =>
|
|
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,47 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { renderHook } from "@testing-library/react";
|
|
3
|
+
import { expect, it, vi } from "vitest";
|
|
4
|
+
const f = vi.hoisted(() => ({ confirmSetup: vi.fn(), submit: vi.fn() }));
|
|
5
|
+
vi.mock("@stripe/react-stripe-js", () => ({
|
|
6
|
+
useStripe: () => ({ confirmSetup: f.confirmSetup }),
|
|
7
|
+
useElements: () => ({ submit: f.submit }),
|
|
8
|
+
}));
|
|
9
|
+
import { useConfirmPayment } from "./useConfirmPayment.js";
|
|
10
|
+
it("submits Elements and confirms explicit redisplay permission only on succeeded setup", async () => {
|
|
11
|
+
f.submit.mockResolvedValue({});
|
|
12
|
+
f.confirmSetup.mockResolvedValue({ setupIntent: { status: "succeeded" } });
|
|
13
|
+
const { result } = renderHook(useConfirmPayment);
|
|
14
|
+
expect(
|
|
15
|
+
await result.current.confirmSetup("https://app.test", "always"),
|
|
16
|
+
).toEqual({ success: true });
|
|
17
|
+
expect(f.submit).toHaveBeenCalled();
|
|
18
|
+
expect(f.confirmSetup).toHaveBeenCalledWith({
|
|
19
|
+
elements: expect.anything(),
|
|
20
|
+
confirmParams: {
|
|
21
|
+
return_url: "https://app.test",
|
|
22
|
+
payment_method_data: { allow_redisplay: "always" },
|
|
23
|
+
},
|
|
24
|
+
redirect: "if_required",
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
it("does not turn processing or incomplete authentication into saved-card success", async () => {
|
|
28
|
+
f.submit.mockResolvedValue({});
|
|
29
|
+
f.confirmSetup.mockResolvedValue({
|
|
30
|
+
setupIntent: { status: "requires_action" },
|
|
31
|
+
});
|
|
32
|
+
const { result } = renderHook(useConfirmPayment);
|
|
33
|
+
expect(await result.current.confirmSetup()).toEqual({
|
|
34
|
+
success: false,
|
|
35
|
+
error: "Card setup is not complete.",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
it("reports validation errors before confirmation", async () => {
|
|
39
|
+
f.confirmSetup.mockClear();
|
|
40
|
+
f.submit.mockResolvedValue({ error: { message: "Invalid card" } });
|
|
41
|
+
const { result } = renderHook(useConfirmPayment);
|
|
42
|
+
expect(await result.current.confirmSetup()).toEqual({
|
|
43
|
+
success: false,
|
|
44
|
+
error: "Invalid card",
|
|
45
|
+
});
|
|
46
|
+
expect(f.confirmSetup).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
@@ -77,15 +77,27 @@ export function useConfirmPayment() {
|
|
|
77
77
|
* @param returnUrl - URL to redirect to after 3D Secure. Defaults to current page.
|
|
78
78
|
*/
|
|
79
79
|
const confirmSetup = useCallback(
|
|
80
|
-
async (
|
|
80
|
+
async (
|
|
81
|
+
returnUrl?: string,
|
|
82
|
+
allowRedisplay?: "always" | "limited" | "unspecified",
|
|
83
|
+
): Promise<StripeConfirmResult> => {
|
|
81
84
|
if (!stripe || !elements) {
|
|
82
85
|
return { success: false, error: "Stripe not initialized" };
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
const
|
|
88
|
+
const validation = await elements.submit();
|
|
89
|
+
if (validation.error)
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
error: validation.error.message ?? "Check your card details.",
|
|
93
|
+
};
|
|
94
|
+
const { error, setupIntent } = await stripe.confirmSetup({
|
|
86
95
|
elements,
|
|
87
96
|
confirmParams: {
|
|
88
97
|
return_url: returnUrl ?? window.location.href,
|
|
98
|
+
...(allowRedisplay
|
|
99
|
+
? { payment_method_data: { allow_redisplay: allowRedisplay } }
|
|
100
|
+
: {}),
|
|
89
101
|
},
|
|
90
102
|
redirect: "if_required",
|
|
91
103
|
});
|
|
@@ -96,6 +108,8 @@ export function useConfirmPayment() {
|
|
|
96
108
|
error: error.message ?? "Failed to confirm setup",
|
|
97
109
|
};
|
|
98
110
|
}
|
|
111
|
+
if (setupIntent?.status !== "succeeded")
|
|
112
|
+
return { success: false, error: "Card setup is not complete." };
|
|
99
113
|
return { success: true };
|
|
100
114
|
},
|
|
101
115
|
[stripe, elements],
|
|
@@ -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
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -290,3 +290,18 @@ export {
|
|
|
290
290
|
formatChargeAmount,
|
|
291
291
|
type ChargeCurrencyMetadata,
|
|
292
292
|
} from "../client/charge-currency.js";
|
|
293
|
+
|
|
294
|
+
export { SaveCard } from "./workflows/SaveCard.js";
|
|
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
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
const confirm = vi.hoisted(() => vi.fn());
|
|
6
|
+
vi.mock("../components/StripeProvider.js", () => ({
|
|
7
|
+
StripeProvider: ({ children }: { children: React.ReactNode }) => children,
|
|
8
|
+
}));
|
|
9
|
+
vi.mock("@stripe/react-stripe-js", () => ({
|
|
10
|
+
PaymentElement: () => <div>Card details</div>,
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("../hooks/useConfirmPayment.js", () => ({
|
|
13
|
+
useConfirmPayment: () => ({
|
|
14
|
+
confirmSetup: confirm,
|
|
15
|
+
isReady: true,
|
|
16
|
+
hasElements: true,
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
import { SaveCard } from "./SaveCard.js";
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
cleanup();
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
it("retries provisioning with the same logical request and requires explicit consent before confirmation", async () => {
|
|
25
|
+
const create = vi
|
|
26
|
+
.fn()
|
|
27
|
+
.mockRejectedValueOnce(new Error("offline"))
|
|
28
|
+
.mockResolvedValue({ setupIntentId: "seti_one", clientSecret: "secret" });
|
|
29
|
+
const success = vi.fn();
|
|
30
|
+
confirm.mockResolvedValue({ success: true });
|
|
31
|
+
render(
|
|
32
|
+
<SaveCard
|
|
33
|
+
publishableKey="pk_test"
|
|
34
|
+
scopeKey="buyer"
|
|
35
|
+
requestId="save-one"
|
|
36
|
+
returnUrl="https://app.test/cards"
|
|
37
|
+
createSetupIntent={create}
|
|
38
|
+
onSuccess={success}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
await screen.findByText("offline");
|
|
42
|
+
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
43
|
+
await screen.findByText("Card details");
|
|
44
|
+
expect(create.mock.calls).toEqual([
|
|
45
|
+
[{ requestId: "save-one" }],
|
|
46
|
+
[{ requestId: "save-one" }],
|
|
47
|
+
]);
|
|
48
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
49
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
50
|
+
await userEvent.click(screen.getByRole("checkbox"));
|
|
51
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
52
|
+
await waitFor(() => expect(success).toHaveBeenCalledWith("seti_one"));
|
|
53
|
+
expect(confirm).toHaveBeenCalledWith("https://app.test/cards", "always");
|
|
54
|
+
});
|
|
55
|
+
it("keeps failed authentication retryable and does not report completion", async () => {
|
|
56
|
+
confirm
|
|
57
|
+
.mockResolvedValueOnce({ success: false, error: "Authentication failed" })
|
|
58
|
+
.mockResolvedValueOnce({ success: true });
|
|
59
|
+
const success = vi.fn();
|
|
60
|
+
render(
|
|
61
|
+
<SaveCard
|
|
62
|
+
publishableKey="pk_test"
|
|
63
|
+
scopeKey="buyer"
|
|
64
|
+
returnUrl="https://app.test/cards"
|
|
65
|
+
createSetupIntent={async () => ({
|
|
66
|
+
setupIntentId: "seti_one",
|
|
67
|
+
clientSecret: "secret",
|
|
68
|
+
})}
|
|
69
|
+
onSuccess={success}
|
|
70
|
+
/>,
|
|
71
|
+
);
|
|
72
|
+
await screen.findByText("Card details");
|
|
73
|
+
await userEvent.click(screen.getByRole("checkbox"));
|
|
74
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
75
|
+
await screen.findByRole("alert");
|
|
76
|
+
expect(success).not.toHaveBeenCalled();
|
|
77
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
78
|
+
await waitFor(() => expect(success).toHaveBeenCalledOnce());
|
|
79
|
+
});
|
|
80
|
+
it("does not confirm the same completed setup twice", async () => {
|
|
81
|
+
confirm.mockResolvedValue({ success: true });
|
|
82
|
+
const success = vi.fn();
|
|
83
|
+
render(
|
|
84
|
+
<SaveCard
|
|
85
|
+
publishableKey="pk_test"
|
|
86
|
+
scopeKey="buyer"
|
|
87
|
+
returnUrl="https://app.test/cards"
|
|
88
|
+
createSetupIntent={async () => ({
|
|
89
|
+
setupIntentId: "seti_one",
|
|
90
|
+
clientSecret: "secret",
|
|
91
|
+
})}
|
|
92
|
+
onSuccess={success}
|
|
93
|
+
/>,
|
|
94
|
+
);
|
|
95
|
+
await screen.findByText("Card details");
|
|
96
|
+
await userEvent.click(screen.getByRole("checkbox"));
|
|
97
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
98
|
+
await waitFor(() => expect(success).toHaveBeenCalledOnce());
|
|
99
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
100
|
+
expect(confirm).toHaveBeenCalledOnce();
|
|
101
|
+
});
|
|
102
|
+
it("blocks repeat confirmation through a custom render callback", async () => {
|
|
103
|
+
confirm.mockResolvedValue({ success: true });
|
|
104
|
+
const success = vi.fn();
|
|
105
|
+
let submit: (() => Promise<void>) | undefined;
|
|
106
|
+
render(
|
|
107
|
+
<SaveCard
|
|
108
|
+
publishableKey="pk_test"
|
|
109
|
+
scopeKey="buyer"
|
|
110
|
+
returnUrl="https://app.test/cards"
|
|
111
|
+
createSetupIntent={async () => ({
|
|
112
|
+
setupIntentId: "seti_one",
|
|
113
|
+
clientSecret: "secret",
|
|
114
|
+
})}
|
|
115
|
+
onSuccess={success}
|
|
116
|
+
renderForm={(state) => {
|
|
117
|
+
submit = state.submit;
|
|
118
|
+
return <button onClick={() => state.setConsent(true)}>Consent</button>;
|
|
119
|
+
}}
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
await userEvent.click(await screen.findByRole("button", { name: "Consent" }));
|
|
123
|
+
await submit!();
|
|
124
|
+
await submit!();
|
|
125
|
+
expect(confirm).toHaveBeenCalledOnce();
|
|
126
|
+
expect(success).toHaveBeenCalledOnce();
|
|
127
|
+
});
|