Controlling AI Spend Before the Bill Arrives by Henry TochukwuControlling AI Spend Before the Bill Arrives by Henry Tochukwu
Built with Lovable

Controlling AI Spend Before the Bill Arrives

Henry Tochukwu

Henry Tochukwu

Tokenly

A spend-control console for teams paying for AI across several providers. One prepaid balance, budgets that act before the bill, and roles that put finance and engineering on the same numbers.
Role: Product designer & UX engineer. Designed and built the whole product.
Timeline: Sep 22 – Oct 15, 2026 3½ weeks, 141 commits
Built with: Lovable for UI iteration, Claude Code for logic and review
Stack: TanStack Start, React, Tailwind, Radix, pdf-lib

The problem

Paying per token makes AI easy to start and hard to budget. Three failures came up again and again:
The bill arrives late. Providers invoice after the month ends. A runaway script or a retry loop has already spent the money by the time anyone sees it.
No one owns the spend. Dozens of API keys are shared across projects. When cost jumps, nobody can say which project, key or model did it.
Finance and engineering read different numbers. Finance reconciles one invoice per provider in spreadsheets. Engineering looks at token counts. Neither sees runway.
The real job isn’t reporting spend. It’s knowing, at any moment, how long the money lasts and what to change.

Approach

I work as a design engineer: I design the product and build it as working software, so a team can use the real thing before committing an engineering team to it. For Tokenly that meant the design was the code from the first week, and four decisions shaped everything else.
Status, then cause, then action. Every screen opens with its verdict (balance, runway, budget left), then shows what is driving it, then offers the fix as a button. The Overview’s “Needs attention” rows link straight to the right limit or usage view, so no one has to hunt.
Prepaid credits instead of a postpaid bill. A balance and a 7-day burn rate turn “how much will we owe?” into “how many days do we have?”. Runway became the product’s headline number.
Design against data that behaves like the real thing. I built a typed, deterministic mock layer of usage events, keys, projects, top-ups and invoices first. Every chart, forecast and empty state was designed against the same numbers, so the screens agree with each other and the review sessions were reproducible.
Make the preview fully usable without accounts. Top-ups, new keys, budgets and role changes persist on the device. Stakeholders could click through real flows a week in, and the same stores define the contract the backend will implement.
Lovable handled fast UI iteration on the connected branch. I used Claude Code for the parts that needed precise logic, such as forecasting, permissions and the PDF invoice, and for reviewing what Lovable generated. Open issues went into a roadmap.md kept in the repo, not in someone’s head.

The solution

Usage you can link to

Engineering and finance needed to point at the same slice of data. Every filter lives in the URL, so “Claude Sonnet on Production API, Sep 14–18” is a link anyone can open. Hand-edited or stale links can’t break the page.
Grouped by model, project, key or user. Rows open a detail drawer, and each grouping exports to CSV.
Grouped by model, project, key or user. Rows open a detail drawer, and each grouping exports to CSV.
// Only real YYYY-MM-DD dates survive a hand-edited link.
const dateField = (search: Record<string, unknown>, key: string) => {
const value = field(search, key);
return value && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value))
? value
: undefined;
};

export const Route = createFileRoute("/usage")({
validateSearch: (search) => {
let from = dateField(search, "from"),
to = dateField(search, "to");
if (from && to && from > to) [from, to] = [to, from]; // forgive swapped ranges
return {
model: field(search, "model"),
project: field(search, "project"),
key: field(search, "key"),
token: tokenField(search), // "input" | "output" | undefined
from,
to,
};
},
component: UsageView,
});

Budgets that forecast instead of report

A limit is only useful before it’s hit. Creating a budget is a five-step drawer (scope, amount, thresholds, action, notify) with a live preview that projects the day the limit will be reached at the current burn. If you set a limit you’ve already passed, it tells you before you save.
The preview is written as a plain sentence, so the rule reads the way the team will talk about it.
The preview is written as a plain sentence, so the rule reads the way the team will talk about it.
/** Projected date the limit is reached at the current 7-day burn rate. */
export function projectHit(b: BudgetRule) {
const spent = budgetSpend(b),
burn = dailyBurn(b.scope, b.targetId);
if (spent >= b.amount) return { reached: true, date: LAST_DAY };
if (burn <= 0) return null;

const days = Math.ceil((b.amount - spent) / burn);
const date = shift(LAST_DAY, days);
return {
reached: false,
date,
// Weekly and daily windows roll, so compare against the window's own end.
withinPeriod: date <= (b.period === "monthly"
? periodEnd("monthly")
: shift(LAST_DAY, b.period === "weekly" ? 6 : 0)),
};
}

Payments: credits, bonuses and auto-recharge

Top-ups buy credits at a fixed rate, with bonus tiers to reward larger prepayments. The modal shows exactly what you’ll receive before you pay. Every change writes ledger entries, so balance, runway and invoices are always derived from one record.
Bonus tiers are visible up front. The pay button repeats the exact amount.
Bonus tiers are visible up front. The pay button repeats the exact amount.
export const BONUS_TIERS = [
{ min: 1000, pct: 15 },
{ min: 500, pct: 10 },
{ min: 200, pct: 5 },
];
export const bonusPct = (usd: number) => BONUS_TIERS.find((t) => usd >= t.min)?.pct ?? 0;
export const creditsFor = (usd: number) => Math.round(usd / USD_PER_CREDIT);

topUp(usd: number, methodLabel: string) {
const base = creditsFor(usd),
bonus = Math.round((base * bonusPct(usd)) / 100);
const entries: LedgerEntry[] = [
{ id: `tu_${stamp}`, type: "top-up", description: `Top-up · ${methodLabel}`, amount: base },
];
if (bonus) entries.push({ id: `bn_${stamp}`, type: "bonus", amount: bonus,
description: `${bonusPct(usd)}% top-up bonus` });
set({ ...state, balance: state.balance + base + bonus,
extraLedger: [...state.extraLedger, ...entries] });
},
setAutoRecharge(patch: Partial<AutoRecharge>) {
// Never charge without a card on file.
if (patch.enabled && state.methods.length === 0) return;
set({ ...state, autoRecharge: { ...state.autoRecharge, ...patch } });
},
removeMethod(id: string) {
const rest = state.methods.filter((m) => m.id !== id);
// Promote a new default card, and switch auto-recharge off when the last card goes.
if (rest.length && !rest.some((m) => m.isDefault)) rest[0] = { ...rest[0], isDefault: true };
const autoRecharge = rest.length ? state.autoRecharge : { ...state.autoRecharge, enabled: false };
set({ ...state, methods: rest, autoRecharge });
},

Invoices that download on any device

Finance wanted a PDF, not a screenshot. Invoices are drawn client-side with pdf-lib from the current invoice and workspace data, so the status is always accurate. Mobile browsers only allow a download inside the tap itself, so PDFs are prepared ahead of time and the tap hands over a finished file through the native share sheet.
useEffect(() => {
import("./invoice-pdf").then(async ({ createInvoicePdf }) => {
const results = await Promise.all(invoices.map(async (invoice) => [invoice.id,
await createInvoicePdf({ ...invoice, workspace: general.name,
status: invoiceStatus[invoice.id] ?? "paid" })]));
if (active) setPdfs(Object.fromEntries(results));
}).catch(() => { if (active) setPdfError(true); });
}, [invoiceStatus, general.name, pdfAttempt]);

export async function downloadInvoice(blob: Blob, filename: string) {
const file = new File([blob], filename, { type: "application/pdf" });
// Phones: use the share sheet. Desktop: a normal download.
if (matchMedia("(pointer: coarse)").matches && navigator.canShare?.({ files: [file] })) {
try { await navigator.share({ files: [file], title: filename }); return; }
catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; }
}
const url = URL.createObjectURL(blob);
/* …anchor click, then revoke after 30s */
}

One permission model for four roles

A Finance viewer should see billing and usage but never rotate a key. Permissions are data in one place, ROLE_PERMS. Denied actions stay visible but disabled, with a tooltip saying who can do them, so people learn the model instead of hitting dead ends. An Owner can preview any role to check what a colleague will see.
Previewing as Finance viewer. The action stays visible, is disabled, and says why.
Previewing as Finance viewer. The action stays visible, is disabled, and says why.
export const ROLE_PERMS: Record<Role, Permission[]> = {
Owner: PERMISSIONS.map((p) => p.id),
Admin: ["viewUsage", "manageKeys", "manageProjects", "manageLimits",
"viewBilling", "manageBilling", "manageTeam"],
Developer: ["viewUsage", "manageKeys", "manageProjects"],
"Finance viewer": ["viewUsage", "viewBilling"],
};

// <Gate> only disables buttons. guarded() re-checks the role inside each
// store action, so a missed button or a console call can't change data.
export function guarded<T extends ActionMap>(actions: T, perms: PermMap<T>) {
return Object.fromEntries(Object.entries(actions).map(([name, fn]) => [name,
(...args: unknown[]) => {
const perm = perms[name], role = roleOf(state);
if (perm && !ROLE_PERMS[role].includes(perm)) {
toast.error(deniedReason(role));
return undefined;
}
return fn(...args);
}]));
}

export const billingActions = guarded({ topUp, setAutoRecharge, /* … */ },
{ topUp: "manageBilling", setAutoRecharge: "manageBilling" });

Built to survive real use

A few smaller decisions that keep the product trustworthy:
Resilient storage. Saved state is merged onto the defaults field by field, keeping only fields whose shape matches. Corrupt or outdated storage falls back gracefully instead of crashing every page that reads it.
A monochrome system. Status is shown by fill weight, never hue. The only colour is the chart ramp, so data is the loudest thing on screen in both light and dark mode.
Mobile from the start. Stat cards stack into one column and the sidebar collapses behind a toggle. Turning wide tables into card lists is still on the polish list.
Overview at 390px.
Overview at 390px.

Outcome

Tokenly went from an empty template to a complete, clickable product in three and a half weeks. Every core job can be done end to end in the preview.
7product areas, from Overview to Settings
4 × 8roles × permissions, from one source of truth
141commits in 3½ weeks
~9.3klines of feature code
Runway is one glance away. Balance, burn rate and projected depletion sit together on the Overview, Billing and the budget preview, all derived from the same ledger.
Overspend is caught before the bill. Budgets forecast their hit date and can notify, throttle or block when a limit is reached.
Finance and engineering share one record. Shareable usage links, CSV exports and PDF invoices come from the same data, and roles decide who can act on it.
The backend has a clear contract. Each store’s actions and types describe exactly what the server must provide, which de-risks the next phase.
These are build outcomes, not customer metrics. Tokenly hasn’t had real users or live payments yet, so I haven’t claimed savings or adoption figures.

What’s next

In order, because each step unlocks the one after it:
Accounts and a real database
Move the device-only stores to Supabase tables with row-level security. The client is already wired for Supabase and the schema is still empty.
Live payments
Save cards with Stripe SetupIntents, take top-ups with PaymentIntents, and let webhooks write the same ledger entries the preview writes today. Auto-recharge becomes an off-session charge, with the monthly cap enforced on the server.
Real usage ingestion
Route provider calls through Tokenly-issued keys so usage, budgets and blocks run on live events instead of fixtures.
Server-side enforcement
Re-check every permission on the server (client checks are UX only). Add security headers and a CSP, and run SSRF checks on webhook URLs.
Close the review list
Validate card expiry with a Luhn check, block duplicate project, key and budget names, and replace the fixed preview dates with the workspace timezone.
Like this project

Posted Nov 2, 2026

Designed and built a working AI spend-control console: prepaid credits, budgets that forecast overspend, invoices and role-based access, in 3½ weeks.