feat(canvas): Phase 5 — credit balance pill + low-balance banner

Adds the UI surface for the credit system to /orgs:
- CreditsPill next to each org row. Tone shifts from zinc → amber at
  10% of plan to red at zero.
- LowCreditsBanner appears under the pill for running orgs when the
  balance crosses thresholds: overage_used > 0 → "overage active",
  balance <= 0 → "out of credits, upgrade", trial tail → "trial almost
  out".
- Pure helpers extracted to lib/credits.ts so formatCredits, pillTone,
  and bannerKind are unit-tested without jsdom.

Backend List query now returns credits_balance / plan_monthly_credits
/ overage_used_credits / overage_cap_credits so no second round-trip
is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-19 07:27:29 -07:00
parent 24e8c5affd
commit 18894bebe8
3 changed files with 160 additions and 0 deletions

View File

@ -20,6 +20,7 @@
import { useEffect, useState } from "react";
import { fetchSession, redirectToLogin, type Session } from "@/lib/auth";
import { PLATFORM_URL } from "@/lib/api";
import { formatCredits, pillTone, bannerKind } from "@/lib/credits";
type OrgStatus = "awaiting_payment" | "provisioning" | "running" | "failed" | string;
@ -31,6 +32,13 @@ interface Org {
status: OrgStatus;
created_at: string;
updated_at: string;
// Credit system fields. Present whenever the control plane's models
// serializer runs — tests + older snapshot JSONs may not have them,
// so treat as optional in TS and fall back to 0 at render time.
credits_balance?: number;
plan_monthly_credits?: number;
overage_used_credits?: number;
overage_cap_credits?: number;
}
export default function OrgsPage() {
@ -174,6 +182,10 @@ function OrgRow({ org }: { org: Org }) {
<div className="text-sm text-zinc-400">
{org.slug} · <StatusLabel status={org.status} /> · {org.plan || "free"}
</div>
<div className="mt-2 flex items-center gap-2">
<CreditsPill org={org} />
<LowCreditsBanner org={org} />
</div>
</div>
<OrgCTA org={org} />
</div>
@ -181,6 +193,48 @@ function OrgRow({ org }: { org: Org }) {
);
}
// CreditsPill renders the balance with a tone that matches the banner
// severity. Format + color logic lives in @/lib/credits so it can be
// tested without mounting React.
function CreditsPill({ org }: { org: Org }) {
const balance = org.credits_balance ?? 0;
return (
<span className={`rounded border px-2 py-0.5 text-xs ${pillTone(org)}`} title="Credit balance">
{formatCredits(balance)} credits
</span>
);
}
// LowCreditsBanner is a one-liner that only renders when the balance
// is low AND the org is running. bannerKind() picks which message to
// show; render just dispatches on it.
function LowCreditsBanner({ org }: { org: Org }) {
if (org.status !== "running") return null;
const kind = bannerKind(org);
if (kind === "none") return null;
if (kind === "overage") {
const used = (org.overage_used_credits ?? 0).toLocaleString();
return (
<span className="text-xs text-amber-300">
overage active · {used} used
</span>
);
}
if (kind === "out-of-credits") {
return (
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-red-300 underline">
out of credits upgrade to keep running
</a>
);
}
// trial-tail
return (
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-amber-300 underline">
trial almost out
</a>
);
}
function StatusLabel({ status }: { status: OrgStatus }) {
const cls =
status === "running"

View File

@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { formatCredits, pillTone, bannerKind } from "@/lib/credits";
describe("formatCredits", () => {
it("renders raw numbers under 10k", () => {
expect(formatCredits(0)).toBe("0");
expect(formatCredits(42)).toBe("42");
expect(formatCredits(9999)).toBe("9999");
});
it("compacts 10k+ with one decimal", () => {
expect(formatCredits(12345)).toBe("12.3k");
expect(formatCredits(30000)).toBe("30.0k");
});
});
describe("pillTone", () => {
it("zinc for healthy balance", () => {
expect(pillTone({ credits_balance: 5000, plan_monthly_credits: 9000 })).toContain("zinc");
});
it("amber when under 10% of monthly", () => {
expect(pillTone({ credits_balance: 500, plan_monthly_credits: 9000 })).toContain("amber");
});
it("red at zero or negative", () => {
expect(pillTone({ credits_balance: 0, plan_monthly_credits: 9000 })).toContain("red");
expect(pillTone({ credits_balance: -1, plan_monthly_credits: 9000 })).toContain("red");
});
it("trial (monthly=0) is healthy until balance hits zero", () => {
// No paid plan → no ratio reference; only "0" means empty.
expect(pillTone({ credits_balance: 50, plan_monthly_credits: 0 })).toContain("zinc");
expect(pillTone({ credits_balance: 0, plan_monthly_credits: 0 })).toContain("red");
});
});
describe("bannerKind", () => {
it("overage wins when overage_used > 0", () => {
// Even a healthy balance gets "overage" so the banner reminds the
// paying customer that extra charges are accruing.
expect(bannerKind({ credits_balance: 3000, plan_monthly_credits: 9000, overage_used_credits: 500 }))
.toBe("overage");
});
it("out-of-credits when balance <= 0 and no overage", () => {
expect(bannerKind({ credits_balance: 0, plan_monthly_credits: 9000 })).toBe("out-of-credits");
});
it("trial-tail when plan is free and balance is low", () => {
expect(bannerKind({ credits_balance: 50, plan_monthly_credits: 0 })).toBe("trial-tail");
});
it("none for healthy paid balance", () => {
expect(bannerKind({ credits_balance: 8000, plan_monthly_credits: 9000 })).toBe("none");
});
it("none for a trial that still has plenty of credits", () => {
expect(bannerKind({ credits_balance: 400, plan_monthly_credits: 0 })).toBe("none");
});
});

53
canvas/src/lib/credits.ts Normal file
View File

@ -0,0 +1,53 @@
// credits.ts — small pure helpers for rendering credit state on /orgs.
// Kept out of page.tsx so unit tests can exercise the formatting +
// banner-kind logic in node (no jsdom) without needing to mount React.
export type CreditsBannerKind =
| "none"
| "overage" // paid plan has started burning overage this period
| "out-of-credits" // balance 0, not on a paid plan (trial ran out)
| "trial-tail"; // balance low but not zero, no paid plan yet
export interface CreditsFields {
credits_balance?: number;
plan_monthly_credits?: number;
overage_used_credits?: number;
}
// formatCredits renders an int as a compact string. 9999 → "9999",
// 12345 → "12.3k". Keeps the balance pill narrow enough to fit on one
// line next to the org slug even for the Scale plan's 30k grant.
export function formatCredits(n: number): string {
if (n < 10_000) return String(n);
return `${(n / 1000).toFixed(1)}k`;
}
// pillTone returns the tailwind classnames that color the balance pill.
// Empty / exhausted → red; within 10% of zero → amber; else zinc. The
// 10% threshold matches the banner trigger — one consistent "low"
// signal so the pill and banner agree.
export function pillTone(fields: CreditsFields): string {
const balance = fields.credits_balance ?? 0;
const monthly = fields.plan_monthly_credits ?? 0;
if (balance <= 0) return "bg-red-950 text-red-200 border-red-800";
const ratio = monthly > 0 ? balance / monthly : 1;
if (ratio < 0.1) return "bg-amber-950 text-amber-200 border-amber-800";
return "bg-zinc-800 text-zinc-200 border-zinc-700";
}
// bannerKind picks which (if any) banner to show under the balance
// pill. Precedence:
// 1. overage_used > 0 → "overage" (even if balance is refreshed)
// 2. balance <= 0 → "out-of-credits"
// 3. trial + low tail → "trial-tail"
// 4. otherwise → "none"
export function bannerKind(fields: CreditsFields): CreditsBannerKind {
const balance = fields.credits_balance ?? 0;
const monthly = fields.plan_monthly_credits ?? 0;
const overageUsed = fields.overage_used_credits ?? 0;
if (overageUsed > 0) return "overage";
if (balance <= 0) return "out-of-credits";
if (monthly === 0 && balance < 100) return "trial-tail";
return "none";
}