HextaUI

Billing

Plan & usage for an AI product, in the style of Cursor, Claude and Vercel. A usage meter split by model that projects the end of the cycle and warns before credits run out, a daily chart you can scrub, a spend limit with alerts you can preview on the meter, plan changes with exact proration, a card form with real validation, and invoices that download as PDFs.

Billing is the Plan & usage section of an AI product’s settings. It drops into a SettingsShell section and covers what people come to that page for: how much they’ve used, whether it will last, what they pay, and how to change it.

The usage meter fills by model, with a lighter extension showing where the cycle will likely end at the pace of the last 7 days. When that runs past the included credits, a quiet note says when: “At this pace you’ll run out around Oct 24, 4 days before your credits reset”, with ways to turn on extra usage or see plans. Below it, a daily bar chart built on Chart covers the whole cycle: past days, today in full strength, the rest of the cycle as faint projected bars at your recent pace, and a dashed line for the even pace that would last the cycle. Hover or focus it and use the arrow keys to read any day.

Limits are a draft, like every other settings section. Drag the alert threshold or turn on extra usage and the markers move on the meters straight away, the extra usage meter slides in, and the save bar rises. A limit lower than what was already spent this cycle is refused with the amount.

Upgrades show the exact proration before anything is charged: the new plan for the days left, the unused days of the current one, and “You’ll pay $13.33 today”. Downgrades wait until the end of the period and list what goes away. Canceling is one honest confirmation with no offers, the plan stays on until the period ends, and Keep Pro undoes it until then.

The card form formats numbers as you type without moving the cursor, checks them with Luhn, detects the brand, and validates the expiry and security code. Invoices have status badges, Pay now for failed ones, and download as real PDF files.

  1. Add the Pro registry to components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Add your token

    Create a token on your account page and put it in .env.local as HEXTAUI_PRO_TOKEN.

  3. Add the block

    pnpm dlx shadcn@latest add @hextaui-pro/billing

Wire it to your API

BillingSettings shows the data you pass and calls you back for every change. Return or throw from a callback: a thrown message shows in the dialog that asked, and nothing changes until you pass new props.

"use client"

import * as React from "react"

import { SettingsSection, SettingsShell } from "../settings/settings"
import {
  BillingSettings,
  BillingSettingsSkeleton,
  type BillingSettingsProps,
} from "@/components/blocks/billing/billing-settings"

type BillingData = Pick<
  BillingSettingsProps,
  "plans" | "subscription" | "usage" | "spendLimit" | "paymentMethod" | "invoices" | "today"
>

async function post(url: string, body: unknown = {}) {
  const response = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  })
  if (!response.ok) {
    const { message } = await response.json().catch(() => ({ message: "" }))
    throw new Error(message || "Try again in a moment.")
  }
}

export function BillingPage() {
  const [data, setData] = React.useState<BillingData | null>(null)
  const [failed, setFailed] = React.useState(false)

  const load = React.useCallback(async () => {
    setFailed(false)
    try {
      const response = await fetch("/api/billing")
      if (!response.ok) throw new Error()
      setData(await response.json())
    } catch {
      setFailed(true)
    }
  }, [])

  React.useEffect(() => {
    void load()
  }, [load])

  const then = (url: string) => async (body?: unknown) => {
    await post(url, body)
    await load()
  }

  return (
    <SettingsShell sections={[{ id: "billing", label: "Plan & usage" }]}>
      <SettingsSection
        id="billing"
        status={failed ? "error" : data ? "ready" : "loading"}
        skeleton={<BillingSettingsSkeleton />}
        onRetry={load}
      >
        {data ? (
          <BillingSettings
            {...data}
            customer={{ name: "Mia Chen", email: "[email protected]" }}
            onChangePlan={then("/api/billing/plan")}
            onCancelPlan={then("/api/billing/cancel")}
            onResumePlan={then("/api/billing/resume")}
            onSpendLimitChange={then("/api/billing/limit")}
            onPayInvoice={(invoice) => then("/api/billing/pay")({ id: invoice.id })}
          />
        ) : null}
      </SettingsSection>
    </SettingsShell>
  )
}

Card updates

onUpdatePaymentMethod gets clean digits, the expiry, the security code and the brand once the form passes its checks. Throw to show a decline in the dialog. In production, hand the details to your payment provider’s tokenization and never store them yourself.

"use client"

import * as React from "react"

import { BillingSettings, type BillingSettingsProps } from "@/components/blocks/billing/billing-settings"

export function BillingWithStripe(props: BillingSettingsProps) {
  return (
    <BillingSettings
      {...props}
      onUpdatePaymentMethod={async (card) => {
        const response = await fetch("/api/billing/card", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(card),
        })
        if (response.status === 402) {
          throw new Error("Your bank declined this card. Try another card.")
        }
        if (!response.ok) throw new Error("We couldn’t save that card. Try again.")
      }}
    />
  )
}

Your own invoice files

Invoices download as PDFs built in the browser from the invoice data. Return a Blob from onDownloadInvoice to serve your own file instead, or give an invoice an href.

"use client"

import * as React from "react"

import { BillingSettings, type BillingSettingsProps } from "@/components/blocks/billing/billing-settings"

export function BillingWithServerInvoices(props: BillingSettingsProps) {
  return (
    <BillingSettings
      {...props}
      seller="Acme Labs, Inc."
      onDownloadInvoice={async (invoice) => {
        const response = await fetch(`/api/invoices/${invoice.id}/pdf`)
        if (!response.ok) throw new Error("That invoice isn’t ready yet.")
        return response.blob()
      }}
    />
  )
}

Anatomy

The parts you compose, from the outside in.

PartDescription
BillingSettingsThe section content: plan summary, usage meters and the daily Chart, limits, plans, payment, invoices and cancel, with their dialogs.
BillingSettingsSkeletonA loading placeholder shaped like the section. Pass it to SettingsSection’s skeleton.
prorateThe proration math behind the upgrade dialog, for your server or tests.
forecastUsageThe projection behind the meter and the run-out note.
createInvoicePdfBuilds the invoice PDF as a Blob.

BillingSettings

Prices are in cents and dates are ISO days like 2026-10-28. Leave out a callback to hide its action.

PropTypeDefault
plans{ id, name, price, included, description?, features }. Features are full lists per plan, so a downgrade can list what’s missing. A plan with price 0 is the plan people move to when they cancel.
BillingPlan[]–
subscription{ planId, periodStart, periodEnd, status?, cancelAtPeriodEnd?, scheduledPlanId? }.
BillingSubscription–
usage{ models: { id, name, used }[], daily: { date, used }[], extraRate, unit? }. extraRate is cents per unit beyond what’s included. unit defaults to "credits".
BillingUsage–
spendLimit{ enabled, limit, alertAt }. limit is in cents and alertAt is a percentage.
BillingSpendLimit–
paymentMethod{ brand, last4, expMonth, expYear, name? }.
BillingPaymentMethod | null–
invoices{ id, number, date, description, amount, status, href?, lines?, paidWith? }. status is paid, open, failed, refunded or void.
BillingInvoice[][]
todayThe day your server considers today. Used for proration, projections and card expiry, so server and browser agree.
stringthe last daily date
currencyAny ISO currency code.
string"USD"
sellerThe name at the top of generated invoices.
string"Hexta"
customerPrinted under Bill to on generated invoices.
{ name?, email? }–
onChangePlanCalled with { planId, when, amountDue }. when is "now" for upgrades and "period_end" for downgrades.
(change) => void | Promise–
onCancelPlanCancel at the end of the period.
() => void | Promise–
onResumePlanUndo a cancellation or a scheduled downgrade.
() => void | Promise–
onSpendLimitChangeSave the limits. Return { limit: message } to show a field error.
(limit) => void | errors | Promise–
onUpdatePaymentMethodCalled with { number, expMonth, expYear, cvc, name, brand } once the form is valid. Throw to show a decline.
(card) => void | Promise–
onPayInvoiceAdds Pay now to open and failed invoices.
(invoice) => void | Promise–
onDownloadInvoiceReturn a Blob to download your own file, or nothing if you handled it. Without it, a PDF is built from the invoice.
(invoice) => Blob | void | Promise–

prorate

Returns { kind, amountDue, charge, credit, daysLeft, totalDays, effectiveDate, nextBillingDate }.

PropTypeDefault
optionsUpgrades charge the price difference for the days left, rounded once. Downgrades charge nothing and start at periodEnd. Moving off a free plan charges a full month from today.
{ from, to, periodStart, periodEnd, today }–
KeyAction
←→With the daily chart focused, move the tooltip to the previous or next day.
EnterIn the card form, check the details and save.
⌘SSaves changed limits. Ctrl+S on Windows and Linux.
  • Meters use role meter with a value text that includes the projection and the alert threshold, and the model breakdown is a real list.
  • The daily chart is a figure named “Daily usage this cycle”, described by a summary with the total, the recent daily average, the busiest day and the even pace. A visually hidden table lists every day, with projected days marked as estimates.
  • Plan changes, cancellations, card updates and downloads are announced politely. Failures show in the dialog that asked, as an alert, and the dialog stays open with your input.
  • Card fields use the standard autocomplete tokens, so browsers and password managers can fill them. The first invalid field gets focus.
  • Inputs are 16px on touch screens so iOS doesn’t zoom, and buttons grow to 44px.
  • Numbers and dates are formatted in English with fixed time zones, so the server and the browser render the same text.

Code

4 files, added to components/blocks/billing.