From 18894bebe87b5fbdc22f5f4dfd07ca759144a256 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 19 Apr 2026 07:27:29 -0700 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20Phase=205=20=E2=80=94=20credit?= =?UTF-8?q?=20balance=20pill=20+=20low-balance=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- canvas/src/app/orgs/page.tsx | 54 ++++++++++++++++++++++++ canvas/src/lib/__tests__/credits.test.ts | 53 +++++++++++++++++++++++ canvas/src/lib/credits.ts | 53 +++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 canvas/src/lib/__tests__/credits.test.ts create mode 100644 canvas/src/lib/credits.ts 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"; +}