@startsimpli/billing 0.4.32 → 0.4.34
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/package.json +16 -3
- package/src/components/PricingDetailPage.tsx +2 -1
- package/src/components/PricingPage.tsx +2 -2
- package/src/components/PricingSection.tsx +2 -2
- package/src/components/SubscriptionManager.tsx +5 -4
- package/src/components/UpgradeModal.tsx +2 -1
- package/src/components/UsageMeters.test.tsx +27 -0
- package/src/components/UsageMeters.tsx +85 -0
- package/src/components/index.ts +2 -0
- package/src/credits/CreditBalanceBadge.tsx +13 -0
- package/src/credits/CreditHistoryList.tsx +32 -0
- package/src/credits/CreditPackCard.tsx +51 -0
- package/src/credits/CreditsPanel.tsx +60 -0
- package/src/credits/CreditsReturn.tsx +89 -0
- package/src/credits/UseCreditPrompt.tsx +46 -0
- package/src/credits/copy.ts +96 -0
- package/src/credits/credits.test.tsx +75 -0
- package/src/credits/index.ts +13 -0
- package/src/credits/types.ts +17 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useAutoCheckout.test.tsx +34 -0
- package/src/hooks/useAutoCheckout.ts +31 -0
- package/src/hooks/useOfferCheckout.test.tsx +73 -0
- package/src/hooks/useOfferCheckout.ts +82 -0
- package/src/index.ts +24 -2
- package/src/types/index.ts +2 -0
- package/src/utils/checkout-flow.test.ts +56 -0
- package/src/utils/checkout-flow.ts +55 -0
- package/src/utils/credit-math.test.ts +18 -0
- package/src/utils/credit-math.ts +32 -0
- package/src/utils/plan.test.ts +13 -1
- package/src/utils/plan.ts +22 -0
- package/src/utils/return-urls.test.ts +38 -0
- package/src/utils/return-urls.ts +74 -0
- package/src/utils/usage-meters.test.ts +20 -0
- package/src/utils/usage-meters.ts +35 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every customer-facing string of the credit-pack UI. The app owns wording
|
|
3
|
+
* (what a credit is called, its brand voice); these defaults are neutral.
|
|
4
|
+
* Prices and pack sizes are NOT here: they come from the offers.
|
|
5
|
+
*/
|
|
6
|
+
export interface CreditsCopy {
|
|
7
|
+
badge: (n: number) => string;
|
|
8
|
+
badgeLabel: (n: number) => string;
|
|
9
|
+
sectionTitle: string;
|
|
10
|
+
sectionIntro: string;
|
|
11
|
+
loading: string;
|
|
12
|
+
loadError: string;
|
|
13
|
+
retry: string;
|
|
14
|
+
packName: (credits: number) => string;
|
|
15
|
+
perCredit: (price: string) => string;
|
|
16
|
+
save: (pct: number) => string;
|
|
17
|
+
featured: string;
|
|
18
|
+
buy: (name: string) => string;
|
|
19
|
+
buying: string;
|
|
20
|
+
secure: string;
|
|
21
|
+
historyTitle: string;
|
|
22
|
+
historyEmpty: string;
|
|
23
|
+
/** Label per history entry type (grant, reserve, commit, release, refund, expire, adjust). */
|
|
24
|
+
entry: Record<string, string>;
|
|
25
|
+
successTitle: string;
|
|
26
|
+
successPending: string;
|
|
27
|
+
successDone: (granted: number | null, balance: number) => string;
|
|
28
|
+
successStillPending: string;
|
|
29
|
+
successCheckAgain: string;
|
|
30
|
+
successMissingSession: string;
|
|
31
|
+
successSyncError: string;
|
|
32
|
+
/** Primary action on the success page (e.g. "Make a design"). */
|
|
33
|
+
continueLabel: string;
|
|
34
|
+
seeBilling: string;
|
|
35
|
+
cancelledTitle: string;
|
|
36
|
+
cancelledBody: string;
|
|
37
|
+
tryAgain: string;
|
|
38
|
+
confirmTitle: string;
|
|
39
|
+
confirmBody: (balance: number) => string;
|
|
40
|
+
confirmUse: string;
|
|
41
|
+
confirmCancel: string;
|
|
42
|
+
outTitle: string;
|
|
43
|
+
outBody: string;
|
|
44
|
+
planAllowanceUsed: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
|
|
48
|
+
|
|
49
|
+
export const defaultCreditsCopy: CreditsCopy = {
|
|
50
|
+
badge: (n) => `${n} ${plural(n, "credit", "credits")}`,
|
|
51
|
+
badgeLabel: (n) => `You have ${n} ${plural(n, "credit", "credits")}`,
|
|
52
|
+
sectionTitle: "Credits",
|
|
53
|
+
sectionIntro: "Buy credits to use when your plan's allowance runs out. Credits never expire.",
|
|
54
|
+
loading: "Loading your credits…",
|
|
55
|
+
loadError: "We couldn’t load your credits.",
|
|
56
|
+
retry: "Try again",
|
|
57
|
+
packName: (c) => `${c} ${plural(c, "credit", "credits")}`,
|
|
58
|
+
perCredit: (p) => `${p} each`,
|
|
59
|
+
save: (pct) => `Save ${pct}%`,
|
|
60
|
+
featured: "Best value",
|
|
61
|
+
buy: (name) => `Buy ${name}`,
|
|
62
|
+
buying: "Opening checkout…",
|
|
63
|
+
secure: "Secure checkout by Stripe.",
|
|
64
|
+
historyTitle: "Purchases and use",
|
|
65
|
+
historyEmpty: "Nothing yet. When you buy or use a credit it shows up here.",
|
|
66
|
+
entry: {
|
|
67
|
+
grant: "Bought",
|
|
68
|
+
reserve: "Used",
|
|
69
|
+
commit: "Used",
|
|
70
|
+
release: "Returned",
|
|
71
|
+
refund: "Refunded",
|
|
72
|
+
expire: "Expired",
|
|
73
|
+
adjust: "Adjustment",
|
|
74
|
+
},
|
|
75
|
+
successTitle: "Thank you",
|
|
76
|
+
successPending: "Adding your credits…",
|
|
77
|
+
successDone: (_g, balance) => `Your credits are ready. You now have ${balance}.`,
|
|
78
|
+
successStillPending: "Your payment went through, and your credits are still being added. This can take a minute.",
|
|
79
|
+
successCheckAgain: "Check again",
|
|
80
|
+
successMissingSession: "We couldn’t find a checkout to confirm. If you just paid, your credits will appear on the billing page shortly.",
|
|
81
|
+
successSyncError: "We couldn’t confirm your payment just yet. Your card was not charged twice; check again in a moment.",
|
|
82
|
+
continueLabel: "Continue",
|
|
83
|
+
seeBilling: "Back to billing",
|
|
84
|
+
cancelledTitle: "Checkout cancelled",
|
|
85
|
+
cancelledBody: "No charge was made. Your credits are unchanged.",
|
|
86
|
+
tryAgain: "Choose a pack",
|
|
87
|
+
confirmTitle: "Use a credit?",
|
|
88
|
+
confirmBody: (b) => `This uses 1 credit. You have ${b}.`,
|
|
89
|
+
confirmUse: "Use a credit",
|
|
90
|
+
confirmCancel: "Not now",
|
|
91
|
+
outTitle: "You’re out of credits",
|
|
92
|
+
outBody: "Buy a pack to continue.",
|
|
93
|
+
planAllowanceUsed: "Your plan’s allowance for this month is used. You can use a credit instead.",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const withCreditsCopy = (copy?: Partial<CreditsCopy>): CreditsCopy => ({ ...defaultCreditsCopy, ...copy });
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { CreditsCancelled, CreditsPanel, CreditsSuccess, UseCreditPrompt, type CreditsView } from "./index";
|
|
5
|
+
|
|
6
|
+
const offer = (over = {}) => ({ id: "o1", credits: 5, price: "8.00", compareAt: "10.00", currency: "usd", featured: true, ...over }) as never;
|
|
7
|
+
const view = (over: Partial<CreditsView> = {}): CreditsView => ({
|
|
8
|
+
status: "ready", balance: 3, offers: [offer()], history: [], error: null, buying: null,
|
|
9
|
+
refresh: vi.fn(async () => {}), buy: vi.fn(async () => {}), sync: vi.fn(async () => ({ granted: true, balance: 7 })), ...over,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("CreditsPanel", () => {
|
|
13
|
+
it("renders nothing while disabled", () => {
|
|
14
|
+
const { container } = render(<CreditsPanel credits={view({ status: "disabled" })} />);
|
|
15
|
+
expect(container.innerHTML).toBe("");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("shows balance, price, saving and buys the clicked pack with injected copy", () => {
|
|
19
|
+
const credits = view();
|
|
20
|
+
render(<CreditsPanel credits={credits} copy={{ packName: (n) => `${n} pattern credits`, badge: (n) => `${n} pattern credits` }} />);
|
|
21
|
+
expect(screen.getByText("3 pattern credits")).toBeTruthy();
|
|
22
|
+
expect(screen.getByText("$8.00")).toBeTruthy();
|
|
23
|
+
expect(screen.getByText("Save 20%")).toBeTruthy();
|
|
24
|
+
fireEvent.click(screen.getByRole("button", { name: "Buy 5 pattern credits" }));
|
|
25
|
+
expect(credits.buy).toHaveBeenCalledWith("o1");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("offers a retry on error", () => {
|
|
29
|
+
const credits = view({ status: "error" });
|
|
30
|
+
render(<CreditsPanel credits={credits} />);
|
|
31
|
+
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
|
32
|
+
expect(credits.refresh).toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("UseCreditPrompt", () => {
|
|
37
|
+
it("confirms with a balance, sells packs without one", () => {
|
|
38
|
+
const onUse = vi.fn();
|
|
39
|
+
const { rerender } = render(<UseCreditPrompt balance={2} offers={[offer()]} buying={null} error={null} onUse={onUse} onBuy={vi.fn()} onCancel={vi.fn()} />);
|
|
40
|
+
fireEvent.click(screen.getByRole("button", { name: "Use a credit" }));
|
|
41
|
+
expect(onUse).toHaveBeenCalled();
|
|
42
|
+
rerender(<UseCreditPrompt balance={0} offers={[offer()]} buying={null} error={null} onUse={onUse} onBuy={vi.fn()} onCancel={vi.fn()} />);
|
|
43
|
+
expect(screen.getByText("You’re out of credits")).toBeTruthy();
|
|
44
|
+
expect(screen.getByRole("button", { name: "Buy 5 credits" })).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("CreditsSuccess / CreditsCancelled", () => {
|
|
49
|
+
it("syncs the session once and reports the balance", async () => {
|
|
50
|
+
const credits = view();
|
|
51
|
+
render(<CreditsSuccess credits={credits} sessionId="cs_1" continueHref="/go" billingHref="/b" />);
|
|
52
|
+
await waitFor(() => expect(screen.getByText("Your credits are ready. You now have 7.")).toBeTruthy());
|
|
53
|
+
expect(credits.sync).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(screen.getByText("Continue").getAttribute("href")).toBe("/go");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("says so when there is no session and never syncs", () => {
|
|
58
|
+
const credits = view();
|
|
59
|
+
render(<CreditsSuccess credits={credits} sessionId={null} continueHref="/go" billingHref="/b" />);
|
|
60
|
+
expect(screen.getByText(/couldn’t find a checkout/)).toBeTruthy();
|
|
61
|
+
expect(credits.sync).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("waits and allows re-check when not granted yet", async () => {
|
|
65
|
+
const sync = vi.fn(async () => ({ granted: false, balance: 1 }));
|
|
66
|
+
render(<CreditsSuccess credits={view({ sync })} sessionId="cs_1" continueHref="/go" billingHref="/b" />);
|
|
67
|
+
fireEvent.click(await screen.findByRole("button", { name: "Check again" }));
|
|
68
|
+
await waitFor(() => expect(sync).toHaveBeenCalledTimes(2));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("cancelled links back to billing", () => {
|
|
72
|
+
render(<CreditsCancelled billingHref="/b" />);
|
|
73
|
+
expect(screen.getByText("Choose a pack").getAttribute("href")).toBe("/b");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { CreditsPanel } from "./CreditsPanel";
|
|
2
|
+
export type { CreditsPanelProps } from "./CreditsPanel";
|
|
3
|
+
export { CreditPackCard } from "./CreditPackCard";
|
|
4
|
+
export type { CreditPackCardProps } from "./CreditPackCard";
|
|
5
|
+
export { CreditBalanceBadge } from "./CreditBalanceBadge";
|
|
6
|
+
export { CreditHistoryList } from "./CreditHistoryList";
|
|
7
|
+
export { UseCreditPrompt } from "./UseCreditPrompt";
|
|
8
|
+
export type { UseCreditPromptProps } from "./UseCreditPrompt";
|
|
9
|
+
export { CreditsSuccess, CreditsCancelled } from "./CreditsReturn";
|
|
10
|
+
export type { CreditsSuccessProps, CreditsReturnLinks } from "./CreditsReturn";
|
|
11
|
+
export { defaultCreditsCopy, withCreditsCopy } from "./copy";
|
|
12
|
+
export type { CreditsCopy } from "./copy";
|
|
13
|
+
export type { CreditsView } from "./types";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PatternCreditHistoryEntry, PatternCreditOffer, PatternCreditsSyncResult } from "@startsimpli/api";
|
|
2
|
+
|
|
3
|
+
/** The credit state the components render. `usePatternCredits()` from @startsimpli/hooks returns exactly this. */
|
|
4
|
+
export interface CreditsView {
|
|
5
|
+
status: "loading" | "ready" | "error" | "disabled";
|
|
6
|
+
balance: number;
|
|
7
|
+
offers: PatternCreditOffer[];
|
|
8
|
+
history: PatternCreditHistoryEntry[];
|
|
9
|
+
error: string | null;
|
|
10
|
+
/** Offer id whose checkout is opening, else null. */
|
|
11
|
+
buying: string | null;
|
|
12
|
+
refresh: () => Promise<void>;
|
|
13
|
+
buy: (offerId: string) => Promise<void>;
|
|
14
|
+
sync: (sessionId: string) => Promise<PatternCreditsSyncResult>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type { PatternCreditHistoryEntry, PatternCreditOffer };
|
package/src/hooks/index.ts
CHANGED
|
@@ -8,3 +8,7 @@ export { useSuccessSync } from "./useSuccessSync";
|
|
|
8
8
|
export { useUsage, notifyUsageChanged, USAGE_CHANGED_EVENT } from "./useUsage";
|
|
9
9
|
export { usePlanChange } from "./usePlanChange";
|
|
10
10
|
export type { PlanChangeContext, PlanChangeConfirmState, PlanChangeProceed, UsePlanChangeResult } from "./usePlanChange";
|
|
11
|
+
export { useOfferCheckout } from "./useOfferCheckout";
|
|
12
|
+
export type { UseOfferCheckoutOptions } from "./useOfferCheckout";
|
|
13
|
+
export { useAutoCheckout } from "./useAutoCheckout";
|
|
14
|
+
export type { UseAutoCheckoutOptions } from "./useAutoCheckout";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { renderHook } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useAutoCheckout } from "./useAutoCheckout";
|
|
4
|
+
import type { ProductOffer } from "../types";
|
|
5
|
+
|
|
6
|
+
const offers = [{ slug: "pro" }, { slug: "lite" }] as unknown as ProductOffer[];
|
|
7
|
+
const q = (s: string) => new URLSearchParams(s);
|
|
8
|
+
|
|
9
|
+
describe("useAutoCheckout", () => {
|
|
10
|
+
it("starts the requested plan once when signed in and loaded", () => {
|
|
11
|
+
const select = vi.fn();
|
|
12
|
+
const { rerender, result } = renderHook((p) => useAutoCheckout(p), {
|
|
13
|
+
initialProps: { signedIn: true, offers, params: q("checkout=auto&plan=pro"), selectOffer: select },
|
|
14
|
+
});
|
|
15
|
+
expect(result.current).toBe(true);
|
|
16
|
+
expect(select).toHaveBeenCalledTimes(1);
|
|
17
|
+
expect(select.mock.calls[0][0].slug).toBe("pro");
|
|
18
|
+
rerender({ signedIn: true, offers: [...offers], params: q("checkout=auto&plan=pro"), selectOffer: select });
|
|
19
|
+
expect(select).toHaveBeenCalledTimes(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("waits for sign-in and for offers, and ignores non-auto urls", () => {
|
|
23
|
+
const select = vi.fn();
|
|
24
|
+
const { rerender, result } = renderHook((p) => useAutoCheckout(p), {
|
|
25
|
+
initialProps: { signedIn: false, offers, params: q("checkout=auto&plan=pro"), selectOffer: select },
|
|
26
|
+
});
|
|
27
|
+
expect(select).not.toHaveBeenCalled();
|
|
28
|
+
rerender({ signedIn: true, offers: undefined, params: q("checkout=auto&plan=pro"), selectOffer: select });
|
|
29
|
+
expect(select).not.toHaveBeenCalled();
|
|
30
|
+
rerender({ signedIn: true, offers, params: q("plan=pro"), selectOffer: select });
|
|
31
|
+
expect(result.current).toBe(false);
|
|
32
|
+
expect(select).not.toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAutoCheckout — back from sign-up on `?checkout=auto&plan=<slug>`: start
|
|
3
|
+
* that plan's checkout once, as soon as the person is signed in and the
|
|
4
|
+
* product's offers are loaded. Never fires twice, never when signed out.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useRef } from "react";
|
|
8
|
+
import type { ProductOffer } from "../types";
|
|
9
|
+
import { autoCheckoutOffer } from "../utils/return-urls";
|
|
10
|
+
|
|
11
|
+
export interface UseAutoCheckoutOptions {
|
|
12
|
+
signedIn: boolean;
|
|
13
|
+
/** The product's offers; undefined/empty until loaded (from `useProduct`). */
|
|
14
|
+
offers: ProductOffer[] | undefined;
|
|
15
|
+
params: URLSearchParams;
|
|
16
|
+
/** The same `selectOffer` the plan buttons use (`useOfferCheckout`). */
|
|
17
|
+
selectOffer: (offer: ProductOffer) => void | Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Returns whether the URL asked for auto checkout, so the page can explain a signed-out stall. */
|
|
21
|
+
export function useAutoCheckout({ signedIn, offers, params, selectOffer }: UseAutoCheckoutOptions): boolean {
|
|
22
|
+
const started = useRef(false);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (started.current || !signedIn || !offers?.length) return;
|
|
25
|
+
const offer = autoCheckoutOffer(offers, params);
|
|
26
|
+
if (!offer) return;
|
|
27
|
+
started.current = true;
|
|
28
|
+
void selectOffer(offer);
|
|
29
|
+
}, [signedIn, offers, params, selectOffer]);
|
|
30
|
+
return params.get("checkout") === "auto";
|
|
31
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { renderHook, act } from "@testing-library/react";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { BillingProvider } from "./BillingProvider";
|
|
5
|
+
import { useOfferCheckout } from "./useOfferCheckout";
|
|
6
|
+
import type { ProductOffer } from "../types";
|
|
7
|
+
|
|
8
|
+
const offer = (over: Partial<ProductOffer> = {}) =>
|
|
9
|
+
({ id: "o1", slug: "pro", name: "Pro", isFree: false, unitPrice: "9", currency: "usd", ...over }) as ProductOffer;
|
|
10
|
+
|
|
11
|
+
function setup(opts: { authed?: boolean; json?: unknown; ok?: boolean } = {}) {
|
|
12
|
+
const fetcher = vi.fn().mockResolvedValue({ ok: opts.ok ?? true, json: () => Promise.resolve(opts.json ?? {}) });
|
|
13
|
+
const navigate = vi.fn();
|
|
14
|
+
const onSignedOut = vi.fn();
|
|
15
|
+
const onBeginCheckout = vi.fn();
|
|
16
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
17
|
+
<BillingProvider apiBaseUrl="/api/v1" fetcher={fetcher as unknown as typeof fetch}>{children}</BillingProvider>
|
|
18
|
+
);
|
|
19
|
+
const hook = renderHook(
|
|
20
|
+
() =>
|
|
21
|
+
useOfferCheckout({
|
|
22
|
+
isAuthenticated: opts.authed ?? true,
|
|
23
|
+
cancelPath: "/pricing",
|
|
24
|
+
returnUrl: (p) => `https://app.test${p}`,
|
|
25
|
+
navigate,
|
|
26
|
+
onSignedOut,
|
|
27
|
+
onBeginCheckout,
|
|
28
|
+
}),
|
|
29
|
+
{ wrapper },
|
|
30
|
+
);
|
|
31
|
+
return { ...hook, fetcher, navigate, onSignedOut, onBeginCheckout };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("useOfferCheckout", () => {
|
|
35
|
+
it("signed out: hands the offer to onSignedOut and does not call the server", async () => {
|
|
36
|
+
const t = setup({ authed: false });
|
|
37
|
+
let r: unknown;
|
|
38
|
+
await act(async () => { r = await t.result.current.selectOffer(offer()); });
|
|
39
|
+
expect(r).toBe("navigating");
|
|
40
|
+
expect(t.onSignedOut).toHaveBeenCalledWith(expect.objectContaining({ slug: "pro" }));
|
|
41
|
+
expect(t.fetcher).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("free offer: enrols and goes to billing success", async () => {
|
|
45
|
+
const t = setup({ json: { subscriptionId: "s1", status: "active" } });
|
|
46
|
+
await act(async () => { await t.result.current.selectOffer(offer({ isFree: true })); });
|
|
47
|
+
expect(t.navigate).toHaveBeenCalledWith("/settings/billing?status=success");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("free offer leaving a paid plan: scheduled", async () => {
|
|
51
|
+
const t = setup({ json: { subscriptionId: "s1", status: "active", scheduled_downgrade: true } });
|
|
52
|
+
await act(async () => { await t.result.current.selectOffer(offer({ isFree: true })); });
|
|
53
|
+
expect(t.navigate).toHaveBeenCalledWith("/settings/billing?status=scheduled");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("paid offer: posts checkout with session placeholder + cancel url, then goes to Stripe", async () => {
|
|
57
|
+
const t = setup({ json: { sessionId: "cs_1", url: "https://stripe.test/pay" } });
|
|
58
|
+
await act(async () => { await t.result.current.selectOffer(offer()); });
|
|
59
|
+
const body = JSON.parse((t.fetcher.mock.calls[0][1] as RequestInit).body as string);
|
|
60
|
+
expect(body.success_url).toBe("https://app.test/settings/billing?status=success&session_id={CHECKOUT_SESSION_ID}");
|
|
61
|
+
expect(body.cancel_url).toBe("https://app.test/pricing");
|
|
62
|
+
expect(t.onBeginCheckout).toHaveBeenCalled();
|
|
63
|
+
expect(t.navigate).toHaveBeenCalledWith("https://stripe.test/pay");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("a failed checkout shows the error and re-enables the buttons", async () => {
|
|
67
|
+
const t = setup({ ok: false, json: { detail: "no card" } });
|
|
68
|
+
await act(async () => { await t.result.current.selectOffer(offer()); });
|
|
69
|
+
expect(t.navigate).not.toHaveBeenCalled();
|
|
70
|
+
expect(t.result.current.error).toBeTruthy();
|
|
71
|
+
expect(t.result.current.loading).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOfferCheckout — what a plan button does, wherever plans are listed
|
|
3
|
+
* (pricing page, upgrade modal, billing settings):
|
|
4
|
+
* signed out → onSignedOut(offer) (the app sends them to sign up and back)
|
|
5
|
+
* free plan → enrol, then billing ("scheduled" when it ends a paid plan)
|
|
6
|
+
* paid plan → hosted checkout, or an in-place change (afterCheckout)
|
|
7
|
+
*
|
|
8
|
+
* Returns "navigating" once the browser is being sent away, so plan buttons
|
|
9
|
+
* stay disabled until the page unloads. Must be inside a <BillingProvider>.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useCallback, useState } from "react";
|
|
13
|
+
import type { PlanChangePreview, ProductOffer } from "../types";
|
|
14
|
+
import { afterCheckout, withSessionPlaceholder, type CheckoutPaths } from "../utils/checkout-flow";
|
|
15
|
+
import { useCheckout } from "./useCheckout";
|
|
16
|
+
|
|
17
|
+
export interface UseOfferCheckoutOptions {
|
|
18
|
+
isAuthenticated: boolean;
|
|
19
|
+
/** Same-origin path to land on after paying (already through safeNextPath). */
|
|
20
|
+
nextPath?: string | null;
|
|
21
|
+
/** Path to come back to when checkout is cancelled. */
|
|
22
|
+
cancelPath: string;
|
|
23
|
+
/** Absolute return URL for a path, using the app's public origin. */
|
|
24
|
+
returnUrl: (path: string) => string;
|
|
25
|
+
/** Default `window.location.assign`. */
|
|
26
|
+
navigate?: (url: string) => void;
|
|
27
|
+
/** Signed out: send the person to sign up and back to this offer. */
|
|
28
|
+
onSignedOut: (offer: ProductOffer) => void;
|
|
29
|
+
/** A paid checkout is about to start (analytics). */
|
|
30
|
+
onBeginCheckout?: (offer: ProductOffer) => void;
|
|
31
|
+
/** Defaults: /settings/billing[?status=success|scheduled]. */
|
|
32
|
+
paths?: Partial<CheckoutPaths>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useOfferCheckout(options: UseOfferCheckoutOptions) {
|
|
36
|
+
const { isAuthenticated, nextPath = null, cancelPath, returnUrl, navigate, onSignedOut, onBeginCheckout } = options;
|
|
37
|
+
const billingPath = options.paths?.billingPath ?? "/settings/billing";
|
|
38
|
+
const successPath = options.paths?.successPath ?? `${billingPath}?status=success`;
|
|
39
|
+
const scheduledPath = options.paths?.scheduledPath ?? `${billingPath}?status=scheduled`;
|
|
40
|
+
const { checkout, subscribeFree } = useCheckout();
|
|
41
|
+
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
const [loading, setLoading] = useState(false);
|
|
43
|
+
|
|
44
|
+
const selectOffer = useCallback(
|
|
45
|
+
async (offer: ProductOffer, _preview?: PlanChangePreview | null): Promise<"navigating" | void> => {
|
|
46
|
+
const go = navigate ?? ((u: string) => window.location.assign(u));
|
|
47
|
+
if (!isAuthenticated) {
|
|
48
|
+
onSignedOut(offer);
|
|
49
|
+
return "navigating";
|
|
50
|
+
}
|
|
51
|
+
setError(null);
|
|
52
|
+
setLoading(true);
|
|
53
|
+
try {
|
|
54
|
+
if (offer.isFree) {
|
|
55
|
+
const result = await subscribeFree(offer.id);
|
|
56
|
+
go(result?.scheduled_downgrade ? scheduledPath : successPath);
|
|
57
|
+
return "navigating";
|
|
58
|
+
}
|
|
59
|
+
onBeginCheckout?.(offer);
|
|
60
|
+
const result = await checkout({
|
|
61
|
+
offerId: offer.id,
|
|
62
|
+
successUrl: nextPath ? returnUrl(nextPath) : withSessionPlaceholder(returnUrl(successPath)),
|
|
63
|
+
cancelUrl: returnUrl(cancelPath),
|
|
64
|
+
});
|
|
65
|
+
const destination = afterCheckout(result, nextPath, { billingPath, successPath, scheduledPath });
|
|
66
|
+
if (destination) {
|
|
67
|
+
go(destination);
|
|
68
|
+
return "navigating";
|
|
69
|
+
}
|
|
70
|
+
setError("Unable to start checkout. Please try again.");
|
|
71
|
+
setLoading(false);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
setError(err instanceof Error ? err.message : "Something went wrong. Please try again.");
|
|
74
|
+
setLoading(false);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
[isAuthenticated, nextPath, cancelPath, returnUrl, navigate, onSignedOut, onBeginCheckout,
|
|
78
|
+
billingPath, successPath, scheduledPath, checkout, subscribeFree],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return { selectOffer, error, loading };
|
|
82
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,12 +18,14 @@ export {
|
|
|
18
18
|
usePortal,
|
|
19
19
|
useSubscription,
|
|
20
20
|
useSuccessSync,
|
|
21
|
+
useOfferCheckout,
|
|
22
|
+
useAutoCheckout,
|
|
21
23
|
useUsage,
|
|
22
24
|
usePlanChange,
|
|
23
25
|
notifyUsageChanged,
|
|
24
26
|
USAGE_CHANGED_EVENT,
|
|
25
27
|
} from "./hooks";
|
|
26
|
-
export type { BillingProviderProps, PlanChangeContext, PlanChangeConfirmState, PlanChangeProceed, UsePlanChangeResult } from "./hooks";
|
|
28
|
+
export type { BillingProviderProps, UseOfferCheckoutOptions, UseAutoCheckoutOptions, PlanChangeContext, PlanChangeConfirmState, PlanChangeProceed, UsePlanChangeResult } from "./hooks";
|
|
27
29
|
|
|
28
30
|
// Types
|
|
29
31
|
export type {
|
|
@@ -42,7 +44,7 @@ export type {
|
|
|
42
44
|
} from "./types";
|
|
43
45
|
|
|
44
46
|
// Components
|
|
45
|
-
export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager, PlanGate, CurrentPlanCard, LimitNudge, PlanChangeConfirm, planChangeCopy } from "./components";
|
|
47
|
+
export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager, PlanGate, CurrentPlanCard, LimitNudge, PlanChangeConfirm, planChangeCopy, UsageMeters } from "./components";
|
|
46
48
|
export type {
|
|
47
49
|
PricingPageProps,
|
|
48
50
|
PricingSectionProps,
|
|
@@ -54,6 +56,8 @@ export type {
|
|
|
54
56
|
CurrentPlanCardProps,
|
|
55
57
|
LimitNudgeProps,
|
|
56
58
|
PlanChangeConfirmProps,
|
|
59
|
+
UsageMetersProps,
|
|
60
|
+
UsageMetersCopy,
|
|
57
61
|
} from "./components";
|
|
58
62
|
|
|
59
63
|
// API client (for advanced usage)
|
|
@@ -63,6 +67,7 @@ export { BillingApiClient } from "./utils/api";
|
|
|
63
67
|
export {
|
|
64
68
|
isPaidSubscription,
|
|
65
69
|
isPayingSubscription,
|
|
70
|
+
trialLabel,
|
|
66
71
|
isUnlistedOffer,
|
|
67
72
|
getFeatureLimit,
|
|
68
73
|
resolvePlanEntitlement,
|
|
@@ -76,5 +81,22 @@ export {
|
|
|
76
81
|
} from "./utils/plan";
|
|
77
82
|
export type { PlanEntitlement, ResolvePlanEntitlementOptions, UsageWindow, CurrentPlanState, PlanDirection } from "./utils/plan";
|
|
78
83
|
export { formatPrice, formatFeatureLabel } from "./utils/format";
|
|
84
|
+
export { afterCheckout, checkoutSessionIdFrom, safeNextPath, withSessionPlaceholder } from "./utils/checkout-flow";
|
|
85
|
+
export type { CheckoutPaths } from "./utils/checkout-flow";
|
|
79
86
|
export { groupOffersByTier, offersForInterval, hasIntervalChoice, annualSavings, tierOffer } from "./utils/tiers";
|
|
80
87
|
export type { BillingInterval, OfferTier } from "./utils/tiers";
|
|
88
|
+
|
|
89
|
+
// Return-URL / hand-off helpers, credit-pack math, usage meters
|
|
90
|
+
export {
|
|
91
|
+
autoCheckoutOffer,
|
|
92
|
+
billingReturnUrl,
|
|
93
|
+
creditReturnUrls,
|
|
94
|
+
creditSessionFrom,
|
|
95
|
+
signUpReturnUrl,
|
|
96
|
+
statusMessageFrom,
|
|
97
|
+
} from "./utils/return-urls";
|
|
98
|
+
export type { CreditReturnPaths, PlanStatusMessage } from "./utils/return-urls";
|
|
99
|
+
export { formatPackPrice, pricePerCredit, savingsPercent } from "./utils/credit-math";
|
|
100
|
+
export type { PackPrice } from "./utils/credit-math";
|
|
101
|
+
export { meterRows } from "./utils/usage-meters";
|
|
102
|
+
export type { MeterRow, MeterState } from "./utils/usage-meters";
|
package/src/types/index.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface ProductOffer {
|
|
|
21
21
|
isActive: boolean;
|
|
22
22
|
isFeatured: boolean;
|
|
23
23
|
trialDays: number;
|
|
24
|
+
/** Charged at checkout to start the trial (cents); 0/absent = a free trial. */
|
|
25
|
+
trialFeeCents?: number;
|
|
24
26
|
sortOrder: number;
|
|
25
27
|
ctaText: string;
|
|
26
28
|
/** Plan tier shared by an offer's monthly and annual prices ("lite",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { afterCheckout, checkoutSessionIdFrom, safeNextPath, withSessionPlaceholder } from "./checkout-flow";
|
|
3
|
+
|
|
4
|
+
const PATHS = { billingPath: "/settings/billing", successPath: "/settings/billing?status=success", scheduledPath: "/settings/billing?status=scheduled" };
|
|
5
|
+
|
|
6
|
+
describe("afterCheckout", () => {
|
|
7
|
+
it("nothing back is no destination", () => {
|
|
8
|
+
expect(afterCheckout(null, null, PATHS)).toBeNull();
|
|
9
|
+
});
|
|
10
|
+
it("a new checkout goes to its page", () => {
|
|
11
|
+
expect(afterCheckout({ sessionId: "cs_1", url: "https://stripe.test/x" }, null, PATHS)).toBe("https://stripe.test/x");
|
|
12
|
+
});
|
|
13
|
+
it("a change at period end says scheduled, not activated", () => {
|
|
14
|
+
expect(afterCheckout({ sessionId: "", url: "", plan_change: "at_period_end" }, null, PATHS)).toBe(PATHS.scheduledPath);
|
|
15
|
+
});
|
|
16
|
+
it("an unpaid upgrade goes to the invoice, else billing success", () => {
|
|
17
|
+
expect(afterCheckout({ sessionId: "", url: "https://inv", planChange: "payment_required" }, null, PATHS)).toBe("https://inv");
|
|
18
|
+
expect(afterCheckout({ sessionId: "", url: "", plan_change: "payment_required" }, null, PATHS)).toBe(PATHS.successPath);
|
|
19
|
+
});
|
|
20
|
+
it("a cancelled pending change goes to plain billing", () => {
|
|
21
|
+
expect(afterCheckout({ sessionId: "", url: "", plan_change: "pending_change_cancelled" }, null, PATHS)).toBe("/settings/billing");
|
|
22
|
+
});
|
|
23
|
+
it("an immediate in-place change goes to next, else billing success", () => {
|
|
24
|
+
expect(afterCheckout({ sessionId: "", url: "", plan_change: "immediate" }, "/create", PATHS)).toBe("/create");
|
|
25
|
+
expect(afterCheckout({ sessionId: "", url: "", plan_change: "immediate" }, null, PATHS)).toBe(PATHS.successPath);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("checkoutSessionIdFrom", () => {
|
|
30
|
+
it("accepts a real Stripe session id only", () => {
|
|
31
|
+
expect(checkoutSessionIdFrom("cs_test_a1B2")).toBe("cs_test_a1B2");
|
|
32
|
+
expect(checkoutSessionIdFrom("{CHECKOUT_SESSION_ID}")).toBeNull();
|
|
33
|
+
expect(checkoutSessionIdFrom(null)).toBeNull();
|
|
34
|
+
expect(checkoutSessionIdFrom("")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("safeNextPath", () => {
|
|
39
|
+
it("keeps same-origin paths", () => {
|
|
40
|
+
expect(safeNextPath("/create?x=1")).toBe("/create?x=1");
|
|
41
|
+
});
|
|
42
|
+
it("drops anything that could leave the site", () => {
|
|
43
|
+
for (const bad of ["https://evil.test", "//evil.test", "/\\evil.test", "javascript:alert(1)", "create", "", null, undefined]) {
|
|
44
|
+
expect(safeNextPath(bad as string | null | undefined)).toBeNull();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("withSessionPlaceholder", () => {
|
|
50
|
+
it("appends Stripe's placeholder after the query, braces unencoded", () => {
|
|
51
|
+
expect(withSessionPlaceholder("https://app.test/settings/billing?status=success")).toBe(
|
|
52
|
+
"https://app.test/settings/billing?status=success&session_id={CHECKOUT_SESSION_ID}",
|
|
53
|
+
);
|
|
54
|
+
expect(withSessionPlaceholder("https://app.test/x")).toBe("https://app.test/x?session_id={CHECKOUT_SESSION_ID}");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the browser goes after a checkout call, and the small guards around it.
|
|
3
|
+
* Pure: no React, no window. Used by useOfferCheckout.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CheckoutResult } from "../types";
|
|
7
|
+
|
|
8
|
+
export interface CheckoutPaths {
|
|
9
|
+
/** The billing settings page, e.g. "/settings/billing". */
|
|
10
|
+
billingPath: string;
|
|
11
|
+
/** Billing after paying, e.g. "/settings/billing?status=success". */
|
|
12
|
+
successPath: string;
|
|
13
|
+
/** Billing after a change that takes effect at period end. */
|
|
14
|
+
scheduledPath: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where the browser goes after a checkout call, from what the backend did:
|
|
19
|
+
* a new checkout goes to its page; a plan already paid for changed in place
|
|
20
|
+
* goes to `next` (or billing success); a change at period end says "scheduled",
|
|
21
|
+
* not "activated"; an unpaid upgrade goes to the invoice.
|
|
22
|
+
*/
|
|
23
|
+
export function afterCheckout(
|
|
24
|
+
result: CheckoutResult | null | undefined,
|
|
25
|
+
nextPath: string | null,
|
|
26
|
+
paths: CheckoutPaths,
|
|
27
|
+
): string | null {
|
|
28
|
+
if (!result) return null;
|
|
29
|
+
const change = result.plan_change ?? result.planChange;
|
|
30
|
+
if (!change) return result.url || null;
|
|
31
|
+
if (change === "at_period_end") return paths.scheduledPath;
|
|
32
|
+
if (change === "payment_required") return result.url || paths.successPath;
|
|
33
|
+
if (change === "pending_change_cancelled") return paths.billingPath;
|
|
34
|
+
return nextPath ?? paths.successPath;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A real Stripe checkout session id, not the unfilled {CHECKOUT_SESSION_ID}
|
|
38
|
+
* an in-place plan change returns verbatim. */
|
|
39
|
+
export function checkoutSessionIdFrom(raw: string | null | undefined): string | null {
|
|
40
|
+
return raw && /^cs_[A-Za-z0-9_]+$/.test(raw) ? raw : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A same-origin path to land on after paying; anything else is dropped so a
|
|
44
|
+
* `?next=` can never be an open redirect. */
|
|
45
|
+
export function safeNextPath(raw: string | null | undefined): string | null {
|
|
46
|
+
if (!raw || !raw.startsWith("/")) return null;
|
|
47
|
+
if (raw.startsWith("//") || raw.startsWith("/\\")) return null;
|
|
48
|
+
return raw;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Stripe fills {CHECKOUT_SESSION_ID} in; appended after building the URL so the
|
|
52
|
+
* braces are not percent-encoded, and success-sync reads that exact checkout. */
|
|
53
|
+
export function withSessionPlaceholder(url: string): string {
|
|
54
|
+
return `${url}${url.includes("?") ? "&" : "?"}session_id={CHECKOUT_SESSION_ID}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatPackPrice, pricePerCredit, savingsPercent } from "./credit-math";
|
|
3
|
+
|
|
4
|
+
describe("credit math", () => {
|
|
5
|
+
it("formats with cents always", () => {
|
|
6
|
+
expect(formatPackPrice("19", "usd")).toBe("$19.00");
|
|
7
|
+
expect(formatPackPrice("abc", "usd")).toBe("abc");
|
|
8
|
+
});
|
|
9
|
+
it("computes saving only when compareAt is higher", () => {
|
|
10
|
+
expect(savingsPercent({ price: "8", compareAt: "10" })).toBe(20);
|
|
11
|
+
expect(savingsPercent({ price: "10", compareAt: "10" })).toBeNull();
|
|
12
|
+
expect(savingsPercent({ price: "10", compareAt: null })).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
it("divides price per credit", () => {
|
|
15
|
+
expect(pricePerCredit({ price: "10", credits: 4 })).toBe("2.50");
|
|
16
|
+
expect(pricePerCredit({ price: "10", credits: 0 })).toBe("10");
|
|
17
|
+
});
|
|
18
|
+
});
|