diff --git a/canvas/src/app/orgs/page.tsx b/canvas/src/app/orgs/page.tsx
index 2d62bebe..653c196d 100644
--- a/canvas/src/app/orgs/page.tsx
+++ b/canvas/src/app/orgs/page.tsx
@@ -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 }) {
{org.slug} · · {org.plan || "free"}
+
+
+
+
@@ -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 (
+
+ {formatCredits(balance)} credits
+
+ );
+}
+
+// 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 (
+
+ overage active · {used} used
+
+ );
+ }
+ if (kind === "out-of-credits") {
+ return (
+
+ out of credits — upgrade to keep running
+
+ );
+ }
+ // trial-tail
+ return (
+
+ trial almost out
+
+ );
+}
+
function StatusLabel({ status }: { status: OrgStatus }) {
const cls =
status === "running"
diff --git a/canvas/src/lib/__tests__/credits.test.ts b/canvas/src/lib/__tests__/credits.test.ts
new file mode 100644
index 00000000..ba37f667
--- /dev/null
+++ b/canvas/src/lib/__tests__/credits.test.ts
@@ -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");
+ });
+});
diff --git a/canvas/src/lib/credits.ts b/canvas/src/lib/credits.ts
new file mode 100644
index 00000000..9b85120f
--- /dev/null
+++ b/canvas/src/lib/credits.ts
@@ -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";
+}