Skip to content

Build a billing page

This guide adds a /billing page to the app from Add Stripe subscriptions to your app. The page shows the user’s plan, its price, and the next date that matters, such as a renewal or the end of a canceled plan, and sends the user to the right Stripe-hosted page. Finish that guide first: this one changes its lib/billing.ts and app/billing/actions.ts, and uses the same stack tabs.

One Strent call, customers.retrieveBillingState, returns everything the page shows. Strent has already worked out the dates and prices from Stripe’s raw fields, so your code only picks the words.

The page reads the user’s billing state and shows the first row that matches:

Billing state The page says The user’s next step
No Stripe customer, or no subscriptions “You’re on the free plan” Subscribe, which opens Stripe Checkout
last_refreshed_at is null “We’re loading your billing details” Refresh in a few seconds
grants_access is false (status unpaid, paused, or incomplete) “Needs attention” Complete the payment or manage it in the portal
status is past_due “Your last payment failed” Update the payment method in the portal
cancellation is set Ends on <date> Renew it in the portal before that date, if they want
status is trialing Free trial ends on <date> Nothing yet
Otherwise Renews on <date> Nothing

Each subscription also shows its plan and price, and a line for a pending_change: a change Stripe has scheduled for a future date, such as a switch to another price. A subscription can show both a change and an end date, for example a schedule that switches plans in March and ends the subscription in September. One Manage billing button below the list opens the Stripe Customer Portal, which handles every portal action in the table for all of the user’s subscriptions.

Offer Checkout only when the user has no subscriptions. Stripe keeps a subscription whose payment failed as unpaid, and Strent reports it with grants_access: false so the page can ask for payment: a new Checkout would create a second subscription and charge the user twice.

customers.retrieveBillingState is not cached and is slower than access checks. Call it on the billing page; keep checking access with userHasPro.

Add a billing page to this app by following
https://docs.strent.dev/guides/build-a-billing-page/ in order. It builds on
https://docs.strent.dev/add-stripe-subscriptions/, which must already be
implemented. Use the same stack tabs as that guide. Steps and the checkpoint
each must pass:
1. Read the billing state -> the app starts and /export still works.
2. Describe each subscription -> the project type-checks.
3. Add the page and guard Checkout -> Stripe limits customers to one
subscription, and a user who never subscribed sees "You're on the free
plan" on /billing, also after leaving Checkout without paying.
4. Subscribe -> /billing shows the Pro price and "Renews on" a date one month
away.
5. Cancel and renew in the Customer Portal -> /billing shows "Ends on" right
after canceling, and "Renews on" again after renewing.
The Stripe setting in step 3 and steps 4 and 5 happen in the Stripe Dashboard
or the browser: ask me to do them. Do not compute prices
or dates from Stripe fields. Stop and tell me which checkpoint failed if one
does not pass.
  1. Read the billing state

    Replace lib/billing.ts with this version. The highlighted lines are new.

    lib/billing.ts
    import type { BillingState } from "@strent-dev/node";
    import { getStripeCustomerId, saveStripeCustomerId } from "./billing-store";
    import { strent } from "./strent";
    import { stripe } from "./stripe";
    export type AppUser = { id: string; email: string };
    export async function getOrCreateStripeCustomer(
    user: AppUser,
    ): Promise<string> {
    const saved = await getStripeCustomerId(user.id);
    if (saved) return saved;
    const customer = await stripe.customers.create(
    { email: user.email, metadata: { app_user_id: user.id } },
    // A double click must not create two customers for one user.
    { idempotencyKey: `create-customer-${user.id}` },
    );
    await saveStripeCustomerId(user.id, customer.id);
    return customer.id;
    }
    export async function createCheckoutUrl(user: AppUser): Promise<string> {
    const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: await getOrCreateStripeCustomer(user),
    line_items: [{ price: process.env.STRIPE_PRO_PRICE_ID!, quantity: 1 }],
    success_url: `${process.env.APP_URL}/billing/success`,
    cancel_url: `${process.env.APP_URL}/billing?from=stripe`,
    });
    return session.url!;
    }
    export async function createPortalUrl(user: AppUser): Promise<string> {
    const customer = await getStripeCustomerId(user.id);
    if (!customer) throw new Error("This user has no Stripe customer.");
    const session = await stripe.billingPortal.sessions.create({
    customer,
    return_url: `${process.env.APP_URL}/billing?from=stripe`,
    });
    return session.url;
    }
    // Throws when Strent cannot give a trustworthy answer; callers deny access.
    export async function userHasPro(user: AppUser): Promise<boolean> {
    const customer = await getStripeCustomerId(user.id);
    if (!customer) return false;
    return strent.activeItems.hasProduct({
    customer,
    product: process.env.STRIPE_PRO_PRODUCT_ID!,
    });
    }
    export async function refreshStripeCustomer(user: AppUser): Promise<void> {
    const customer = await getStripeCustomerId(user.id);
    if (customer) await strent.customers.refresh({ customer });
    }
    // null when the user never started Checkout, so no Stripe customer exists.
    // Throws when Strent cannot answer.
    export async function getBillingState(
    user: AppUser,
    ): Promise<BillingState | null> {
    const customer = await getStripeCustomerId(user.id);
    if (!customer) return null;
    return strent.customers.retrieveBillingState({ customer });
    }
    // Checkout always creates a new subscription. Offer it only when Strent
    // confirms the customer has none: a user with an unpaid subscription would
    // otherwise pay twice.
    export function canStartCheckout(state: BillingState | null): boolean {
    return (
    state === null ||
    (state.last_refreshed_at !== null && state.subscriptions.length === 0)
    );
    }
    • getBillingState returns null for a user who never started Checkout, without calling Strent, because that user has no Stripe customer. Otherwise it returns the customer’s billing state and throws when Strent cannot answer.
    • canStartCheckout allows Checkout only when Strent confirms there are no subscriptions. When last_refreshed_at is null, Strent has not read the customer from Stripe yet, so it cannot tell whether a subscription exists.
    • Stripe now sends the user back to /billing?from=stripe when they leave Checkout without paying (cancel_url) or leave the Customer Portal (return_url). The page refreshes the customer when it sees from=stripe, so it shows a cancellation made a second ago instead of waiting for Stripe’s webhook. Like the success page, it only refreshes the signed-in user’s stored customer; the URL carries no ID.

    Checkpoint

    The app starts, and /export still shows Export PDF to a subscribed user.

  2. Describe each subscription

    This module turns one subscription into three lines of text: its plan and price, its status, and a scheduled change, if any. It is plain TypeScript, so any server-side Node.js stack can use it.

    lib/billing-display.ts
    import type { BillingPrice, BillingSubscription } from "@strent-dev/node";
    // Use your user's language here, and pass their timeZone to DateTimeFormat;
    // Intl adds the currency symbol, separators, and date order.
    const LOCALE = "en-US";
    export type SubscriptionSummary = {
    plan: string;
    status: string;
    change: string | null;
    };
    export function describeSubscription(
    subscription: BillingSubscription,
    ): SubscriptionSummary {
    const pendingChange = subscription.pending_change;
    return {
    plan: describeItems(subscription.items),
    status: describeStatus(subscription),
    // Independent of a cancellation: a schedule can switch plans and later
    // end the subscription, and Strent reports both.
    change: pendingChange === null ? null : describeChange(pendingChange),
    };
    }
    // The first match wins, so the message the user must act on is never hidden.
    function describeStatus(subscription: BillingSubscription): string {
    // unpaid, paused, and incomplete each recover differently, so this promises
    // no specific outcome.
    if (!subscription.grants_access) {
    return "Needs attention: this subscription doesn't include access right now.";
    }
    if (subscription.status === "past_due") {
    return "Your last payment failed. Update your payment method.";
    }
    if (subscription.cancellation !== null) {
    const endsAt = subscription.cancellation.effective_at;
    return endsAt === null
    ? "Scheduled to end."
    : `Ends on ${formatDate(endsAt)}.`;
    }
    if (
    subscription.status === "trialing" &&
    subscription.trial_end !== null
    ) {
    return `Free trial ends on ${formatDate(subscription.trial_end)}.`;
    }
    return subscription.current_period_end === null
    ? "Active."
    : `Renews on ${formatDate(subscription.current_period_end)}.`;
    }
    function describeChange(
    change: NonNullable<BillingSubscription["pending_change"]>,
    ): string {
    const target = describeItems(change.items);
    return change.effective_at === null
    ? `Changes to ${target}.`
    : `Changes to ${target} on ${formatDate(change.effective_at)}.`;
    }
    function describeItems(
    items: { price: BillingPrice; quantity: number | null }[],
    ): string {
    return items
    .map(({ price, quantity }) => {
    const name = price.product.name ?? price.nickname ?? price.product.id;
    const times =
    quantity !== null && quantity > 1 ? ` × ${quantity}` : "";
    return `${name}${times}, ${formatPrice(price)}`;
    })
    .join(" + ");
    }
    export function formatPrice(price: BillingPrice): string {
    // null for tiered or usage-based prices, which have no single unit amount.
    if (price.unit_amount_display === null) return "custom pricing";
    const amount = new Intl.NumberFormat(LOCALE, {
    style: "currency",
    currency: price.currency,
    }).format(Number(price.unit_amount_display));
    const recurring = price.recurring;
    if (recurring === null) return amount;
    return recurring.interval_count === 1
    ? `${amount} / ${recurring.interval}`
    : `${amount} every ${recurring.interval_count} ${recurring.interval}s`;
    }
    function formatDate(isoDate: string): string {
    return new Intl.DateTimeFormat(LOCALE, { dateStyle: "medium" }).format(
    new Date(isoDate),
    );
    }

    Prices use unit_amount_display, the amount in whole currency units such as "19.99". Don’t divide unit_amount by 100 yourself: Stripe uses zero, two, or three decimals depending on the currency, so jpy 2000 is ¥2,000, not ¥20. Every date comes from Strent as well: cancellation.effective_at already accounts for each way Stripe can schedule an end. See Choose a runtime fact for each field.

    Checkpoint

    The project type-checks, for example with npx tsc --noEmit.

  3. Add the page and guard Checkout

    The Subscribe button on /export also starts Checkout. The subscribe action now checks the billing state first and sends a user who already has a subscription, or whose state Strent cannot confirm, to /billing, where the page explains why.

    app/billing/page.tsx
    import type { BillingState } from "@strent-dev/node";
    import { redirect } from "next/navigation";
    import { Suspense } from "react";
    import { manageBilling, subscribe } from "@/app/billing/actions";
    import {
    canStartCheckout,
    getBillingState,
    refreshStripeCustomer,
    } from "@/lib/billing";
    import { describeSubscription } from "@/lib/billing-display";
    import { getCurrentUser } from "@/lib/current-user";
    type BillingPageProps = { searchParams: Promise<{ from?: string }> };
    export default function BillingPage({ searchParams }: BillingPageProps) {
    return (
    <Suspense fallback={<p>Loading your billing details…</p>}>
    <Billing searchParams={searchParams} />
    </Suspense>
    );
    }
    async function Billing({ searchParams }: BillingPageProps) {
    const user = await getCurrentUser();
    if (!user) redirect("/auth/login");
    // Back from Checkout or the Customer Portal: show the change now instead of
    // waiting for Stripe's webhook.
    if ((await searchParams).from === "stripe") {
    try {
    await refreshStripeCustomer(user);
    } catch (error) {
    console.error("Strent refresh failed", error);
    }
    }
    let state: BillingState | null;
    try {
    state = await getBillingState(user);
    } catch (error) {
    console.error("Could not load the billing state", error);
    // The Customer Portal is hosted by Stripe and works without Strent.
    return (
    <form action={manageBilling}>
    <p>We couldn't load your billing details, try again in a moment.</p>
    <button type="submit">Manage billing</button>
    </form>
    );
    }
    if (canStartCheckout(state)) {
    return (
    <form action={subscribe}>
    <p>You're on the free plan.</p>
    <button type="submit">Subscribe to Pro</button>
    </form>
    );
    }
    // Strent has not read this customer from Stripe yet, so it cannot tell
    // whether a subscription exists.
    if (state === null || state.last_refreshed_at === null) {
    return (
    <p>We're loading your billing details, refresh in a few seconds.</p>
    );
    }
    return (
    <div>
    {state.subscriptions.map((subscription) => {
    const summary = describeSubscription(subscription);
    return (
    <section key={subscription.id}>
    <h2>{summary.plan}</h2>
    <p>{summary.status}</p>
    {summary.change && <p>{summary.change}</p>}
    </section>
    );
    })}
    <form action={manageBilling}>
    <button type="submit">Manage billing</button>
    </form>
    </div>
    );
    }

    Replace app/billing/actions.ts; the highlighted lines are new:

    app/billing/actions.ts
    "use server";
    import { redirect } from "next/navigation";
    import {
    canStartCheckout,
    createCheckoutUrl,
    createPortalUrl,
    getBillingState,
    } from "@/lib/billing";
    import { getCurrentUser } from "@/lib/current-user";
    export async function subscribe() {
    const user = await getCurrentUser();
    if (!user) redirect("/auth/login");
    // The billing page explains why when Checkout is not safe, including when
    // Strent cannot answer.
    const state = await getBillingState(user).catch(() => undefined);
    if (state === undefined || !canStartCheckout(state)) redirect("/billing");
    redirect(await createCheckoutUrl(user));
    }
    export async function manageBilling() {
    const user = await getCurrentUser();
    if (!user) redirect("/auth/login");
    redirect(await createPortalUrl(user));
    }

    The guard reads Strent, which can lag Stripe by a few seconds, and two open tabs can both pass it. Let Stripe make the final check: in the Stripe Dashboard, open Checkout and Payment Links settings, turn on Limit customers to one subscription, and choose to redirect customers to your website at http://localhost:3000/billing?from=stripe (your APP_URL). Checkout then sends a customer who already has an active, past_due, unpaid, or paused subscription to your billing page instead of taking a second payment. See Stripe’s guide to limiting customers to one subscription.

    Checkpoint

    The Stripe setting is on. Signed in as a user who never subscribed, /billing shows “You’re on the free plan” and a Subscribe to Pro button. Select it, then go back from Checkout without paying: you land on /billing with the same message.

  4. Subscribe and check the price and date

    Checkpoint

    On /billing, select Subscribe to Pro and pay with the test card 4242 4242 4242 4242. After the success page, /billing shows your Pro price, such as “Pro, $19.99 / month”, and “Renews on” a date one month from today.

  5. Cancel and renew in the Customer Portal

    With the portal’s default settings, a cancellation takes effect at the end of the paid period, and until then the portal lets the user renew the subscription.

    Checkpoint

    On /billing, select Manage billing, cancel the subscription, and return to your app with the portal’s back link. /billing shows “Ends on” the same date it showed for the renewal, without a refresh. Open the portal again, renew the subscription, and return: /billing shows “Renews on” again.

Trials, scheduled changes, and payment problems

Section titled “Trials, scheduled changes, and payment problems”

The other rows of the table need a different setup in Stripe:

  • Trials start when Checkout creates the subscription with a trial, such as subscription_data: { trial_period_days: 14 } in stripe.checkout.sessions.create. See Stripe’s trial guide.
  • Scheduled changes come from a Stripe subscription schedule, which can switch prices or products on a future date. The Customer Portal can also schedule a downgrade for the end of the period, but only between prices of the same product, such as yearly to monthly Pro; see Stripe’s portal settings.
  • Payment problems happen at renewal, when the card fails. Stripe’s subscription settings decide whether a failed renewal stays past_due or moves to unpaid or canceled. To try them in test mode, advance time with Stripe’s test clocks.