@startsimpli/billing 0.4.33 → 0.4.35

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.
@@ -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 };
@@ -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)
@@ -77,5 +81,22 @@ export {
77
81
  } from "./utils/plan";
78
82
  export type { PlanEntitlement, ResolvePlanEntitlementOptions, UsageWindow, CurrentPlanState, PlanDirection } from "./utils/plan";
79
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";
80
86
  export { groupOffersByTier, offersForInterval, hasIntervalChoice, annualSavings, tierOffer } from "./utils/tiers";
81
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";
@@ -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
+ });
@@ -0,0 +1,32 @@
1
+ /** Price math for one-time credit packs. Pure. */
2
+
3
+ export interface PackPrice {
4
+ price: string;
5
+ compareAt?: string | null;
6
+ credits: number;
7
+ }
8
+
9
+ /** "$19.99" from a decimal-string price and its currency; cents always shown. */
10
+ export function formatPackPrice(amount: string, currency: string): string {
11
+ const n = Number(amount);
12
+ if (!Number.isFinite(n)) return amount;
13
+ try {
14
+ return new Intl.NumberFormat("en-US", { style: "currency", currency: (currency || "USD").toUpperCase() }).format(n);
15
+ } catch {
16
+ return `${n.toFixed(2)} ${currency}`;
17
+ }
18
+ }
19
+
20
+ /** Whole-percent saving of `price` against a struck-through `compareAt`, or null when there is none. */
21
+ export function savingsPercent(offer: Pick<PackPrice, "price" | "compareAt">): number | null {
22
+ const price = Number(offer.price);
23
+ const was = Number(offer.compareAt);
24
+ if (!offer.compareAt || !Number.isFinite(price) || !Number.isFinite(was) || was <= price || was <= 0) return null;
25
+ return Math.round((1 - price / was) * 100);
26
+ }
27
+
28
+ /** Price of one credit in this pack (decimal string, 2 places). */
29
+ export function pricePerCredit(offer: Pick<PackPrice, "price" | "credits">): string {
30
+ const total = Number(offer.price);
31
+ return offer.credits > 0 && Number.isFinite(total) ? (total / offer.credits).toFixed(2) : offer.price;
32
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ autoCheckoutOffer, billingReturnUrl, creditReturnUrls, creditSessionFrom, signUpReturnUrl, statusMessageFrom,
4
+ } from "./return-urls";
5
+ import type { ProductOffer } from "../types";
6
+
7
+ const offers = [{ slug: "pro" }, { slug: "lite" }] as unknown as ProductOffer[];
8
+
9
+ describe("return urls", () => {
10
+ it("prefers the public origin, trims slashes and adds a leading slash", () => {
11
+ expect(billingReturnUrl("/x", "http://localhost:3000", "https://app.example/")).toBe("https://app.example/x");
12
+ expect(billingReturnUrl("x", "http://localhost:3000")).toBe("http://localhost:3000/x");
13
+ });
14
+ it("builds the sign-up return with plan, auto and optional next", () => {
15
+ expect(signUpReturnUrl("https://a.b", "pro", null)).toBe("https://a.b/pricing?plan=pro&checkout=auto");
16
+ expect(signUpReturnUrl("https://a.b", "pro", "/go", "/plans")).toBe("https://a.b/plans?plan=pro&checkout=auto&next=%2Fgo");
17
+ });
18
+ it("picks the auto-checkout offer only when asked and known", () => {
19
+ expect(autoCheckoutOffer(offers, new URLSearchParams("checkout=auto&plan=lite"))?.slug).toBe("lite");
20
+ expect(autoCheckoutOffer(offers, new URLSearchParams("plan=lite"))).toBeNull();
21
+ expect(autoCheckoutOffer(offers, new URLSearchParams("checkout=auto&plan=zzz"))).toBeNull();
22
+ expect(autoCheckoutOffer(offers, new URLSearchParams("checkout=auto"))).toBeNull();
23
+ });
24
+ it("credit return urls carry the session placeholder, unencoded", () => {
25
+ const abs = (p: string) => `https://a.b${p}`;
26
+ expect(creditReturnUrls(abs)).toEqual({
27
+ successUrl: "https://a.b/settings/billing/credits/success?session_id={CHECKOUT_SESSION_ID}",
28
+ cancelUrl: "https://a.b/settings/billing/credits/cancelled",
29
+ });
30
+ expect(creditReturnUrls(abs, { successPath: "/ok", cancelledPath: "/no" }).cancelUrl).toBe("https://a.b/no");
31
+ });
32
+ it("reads only real session ids and known statuses", () => {
33
+ expect(creditSessionFrom(new URLSearchParams("session_id=cs_test_1"))).toBe("cs_test_1");
34
+ expect(creditSessionFrom(new URLSearchParams("session_id={CHECKOUT_SESSION_ID}"))).toBeNull();
35
+ expect(statusMessageFrom(new URLSearchParams("status=scheduled"))).toBe("scheduled");
36
+ expect(statusMessageFrom(new URLSearchParams("status=hax"))).toBeNull();
37
+ });
38
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Return-URL and hand-off helpers around checkout: where Stripe (or central
3
+ * sign-up) sends the browser back to, and how a pricing page picks up an
4
+ * "auto checkout" request. Pure: no React, no window.
5
+ */
6
+
7
+ import type { ProductOffer } from "../types";
8
+ import { checkoutSessionIdFrom, withSessionPlaceholder } from "./checkout-flow";
9
+
10
+ /**
11
+ * The absolute URL Stripe (or central auth) sends the browser back to. The
12
+ * configured public origin wins so a build served behind a tunnel or proxy
13
+ * still returns to the public site; else the origin the page is on.
14
+ */
15
+ export function billingReturnUrl(path: string, currentOrigin?: string, publicOrigin?: string): string {
16
+ const origin = (publicOrigin || currentOrigin || "").replace(/\/+$/, "");
17
+ const normalized = path.startsWith("/") ? path : `/${path}`;
18
+ return `${origin}${normalized}`;
19
+ }
20
+
21
+ /**
22
+ * Where sign-up sends the person back to: the pricing page with the plan they
23
+ * picked, so checkout starts without a second click.
24
+ */
25
+ export function signUpReturnUrl(
26
+ origin: string,
27
+ planSlug: string,
28
+ nextPath: string | null,
29
+ pricingPath = "/pricing",
30
+ ): string {
31
+ const q = new URLSearchParams({ plan: planSlug, checkout: "auto" });
32
+ if (nextPath) q.set("next", nextPath);
33
+ return `${origin}${pricingPath}?${q.toString()}`;
34
+ }
35
+
36
+ /** The offer to check out straight away: only when ?checkout=auto&plan=<slug>. */
37
+ export function autoCheckoutOffer(offers: ProductOffer[], params: URLSearchParams): ProductOffer | null {
38
+ if (params.get("checkout") !== "auto") return null;
39
+ const plan = params.get("plan");
40
+ if (!plan) return null;
41
+ return offers.find((o) => o.slug === plan) ?? null;
42
+ }
43
+
44
+ export interface CreditReturnPaths {
45
+ successPath: string;
46
+ cancelledPath: string;
47
+ }
48
+
49
+ /** Where Stripe sends the browser after a credit pack: the return pages. */
50
+ export function creditReturnUrls(
51
+ absolute: (path: string) => string,
52
+ paths: CreditReturnPaths = {
53
+ successPath: "/settings/billing/credits/success",
54
+ cancelledPath: "/settings/billing/credits/cancelled",
55
+ },
56
+ ): { successUrl: string; cancelUrl: string } {
57
+ return {
58
+ successUrl: withSessionPlaceholder(absolute(paths.successPath)),
59
+ cancelUrl: absolute(paths.cancelledPath),
60
+ };
61
+ }
62
+
63
+ /** The Stripe session to sync from the success page's query, or null. */
64
+ export function creditSessionFrom(params: URLSearchParams): string | null {
65
+ return checkoutSessionIdFrom(params.get("session_id"));
66
+ }
67
+
68
+ export type PlanStatusMessage = "success" | "cancelled" | "scheduled";
69
+
70
+ /** ?status= on billing settings after a plan checkout or change. */
71
+ export function statusMessageFrom(params: URLSearchParams): PlanStatusMessage | null {
72
+ const s = params.get("status");
73
+ return s === "success" || s === "cancelled" || s === "scheduled" ? s : null;
74
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { meterRows } from "./usage-meters";
3
+
4
+ describe("meterRows", () => {
5
+ it("skips unlimited and flags, states by fill, humanizes missing labels", () => {
6
+ const rows = meterRows({
7
+ ai_designs: { used: 2, limit: 10 },
8
+ near: { used: 8, limit: 10 },
9
+ full: { used: 12, limit: 10 },
10
+ unlimited: { used: 5, limit: null },
11
+ zero: { used: 0, limit: 0 },
12
+ }, { near: "Near" });
13
+ expect(rows.map((r) => [r.key, r.state, r.label, r.left])).toEqual([
14
+ ["ai_designs", "ok", "Ai designs", 8],
15
+ ["near", "approaching", "Near", 2],
16
+ ["full", "at-cap", "Full", 0],
17
+ ["zero", "at-cap", "Zero", 0],
18
+ ]);
19
+ });
20
+ });
@@ -0,0 +1,35 @@
1
+ import type { UsageMap } from "../types";
2
+
3
+ export type MeterState = "ok" | "approaching" | "at-cap";
4
+
5
+ export interface MeterRow {
6
+ key: string;
7
+ label: string;
8
+ used: number;
9
+ limit: number;
10
+ pct: number;
11
+ left: number;
12
+ state: MeterState;
13
+ }
14
+
15
+ const humanize = (key: string): string => {
16
+ const words = key.replace(/[_-]+/g, " ").trim();
17
+ return words.charAt(0).toUpperCase() + words.slice(1);
18
+ };
19
+
20
+ /**
21
+ * One meter per feature with a real numeric cap. Unlimited (-1 / null) and
22
+ * flags render nothing: a paying person is not shown a bar for what they have
23
+ * no cap on. Labels come from the plan's own feature names.
24
+ */
25
+ export function meterRows(usage: UsageMap, labels: Record<string, string> = {}): MeterRow[] {
26
+ return Object.entries(usage ?? {})
27
+ .filter(([, u]) => u && typeof u.limit === "number" && u.limit >= 0)
28
+ .map(([key, u]) => {
29
+ const limit = u.limit as number;
30
+ const used = Math.max(0, u.used);
31
+ const pct = limit === 0 ? 100 : Math.min(100, Math.round((used / limit) * 100));
32
+ const state: MeterState = used >= limit ? "at-cap" : pct >= 80 ? "approaching" : "ok";
33
+ return { key, label: labels[key] ?? humanize(key), used, limit, pct, left: Math.max(0, limit - used), state };
34
+ });
35
+ }