feat(billing): surface commerce plan + included-usage rollup (#148)
Read-only console view of the commerce billing source of truth. - cloudBillingRouter.getCommerceUsageRollup: org-access-checked tRPC query that calls commerce GET /v1/billing/usage-rollup via the existing commerceClient (COMMERCE_API_URL/COMMERCE_SERVICE_TOKEN). Typed CommerceUsageRollup mirrors the commerce response. - PlanUsageRollup component: shows current plan, included monthly allotment vs consumed (progress bar), remaining, overage, and the prepaid balance the gateway gate reads. Renders nothing if commerce is unconfigured so the existing Stripe cards are unaffected. - BillingOverview: mount PlanUsageRollup atop the billing grid. Console only reads; commerce owns billing, @hanzo/plans owns the catalog. Co-authored-by: Hanzo <dev@hanzo.ai>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { PlanUsageRollup } from "@/src/features/billing/components/overview/PlanUsageRollup";
|
||||
import { PlanSelectionModal } from "@/src/features/billing/components/PlanSectionModal";
|
||||
import { stripeProducts } from "@/src/features/billing/utils/stripeProducts";
|
||||
import { useQueryOrganization } from "@/src/features/organizations/hooks";
|
||||
@@ -74,6 +75,9 @@ export const BillingOverview = () => {
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{/* Commerce-backed plan + included-usage + overage + balance rollup */}
|
||||
<PlanUsageRollup />
|
||||
|
||||
{/* Active Subscription Card */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { Progress } from "@/src/components/ui/progress";
|
||||
import { useQueryOrganization } from "@/src/features/organizations/hooks";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { planLabels, type Plan } from "@hanzo/shared";
|
||||
|
||||
const usd = (cents: number) =>
|
||||
`$${(cents / 100).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
|
||||
/**
|
||||
* PlanUsageRollup renders the commerce-backed plan + included-usage rollup:
|
||||
* current plan, included monthly allotment vs consumed (with a progress bar),
|
||||
* overage, and the prepaid balance the gateway gate reads. Read-only —
|
||||
* commerce is the single billing source of truth.
|
||||
*/
|
||||
export const PlanUsageRollup = () => {
|
||||
const organization = useQueryOrganization();
|
||||
|
||||
const { data, isLoading, error } = api.cloudBilling.getCommerceUsageRollup.useQuery(
|
||||
{ orgId: organization?.id ?? "" },
|
||||
{ enabled: organization !== undefined, retry: false },
|
||||
);
|
||||
|
||||
// Surface nothing if commerce is not configured/reachable — the Stripe-based
|
||||
// cards above still render. (Commerce auth missing -> PRECONDITION_FAILED.)
|
||||
if (error) return null;
|
||||
|
||||
const planSlug = data?.plan ?? "";
|
||||
const planLabel =
|
||||
(planLabels as Record<string, string>)[planSlug as Plan] ||
|
||||
(planSlug ? planSlug.charAt(0).toUpperCase() + planSlug.slice(1) : "Free");
|
||||
|
||||
const includedMonthly = data?.included.monthlyCents ?? 0;
|
||||
const includedGranted = data?.included.grantedCents ?? 0;
|
||||
const includedConsumed = data?.included.consumedCents ?? 0;
|
||||
const includedRemaining = data?.included.remainingCents ?? 0;
|
||||
const consumed = data?.consumedCents ?? 0;
|
||||
const overage = data?.overageCents ?? 0;
|
||||
const available = data?.balance.availableCents ?? 0;
|
||||
|
||||
// Progress against the granted allotment (fall back to the catalog amount
|
||||
// before the period's grant has run).
|
||||
const denom = includedGranted > 0 ? includedGranted : includedMonthly;
|
||||
const pct = denom > 0 ? Math.min(100, Math.round((includedConsumed / denom) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<Card className="col-span-1 p-6 md:col-span-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Plan & Usage</h3>
|
||||
<p className="mt-1 text-2xl font-bold">{isLoading ? "…" : planLabel}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{includedMonthly > 0
|
||||
? `${usd(includedMonthly)} included usage / month`
|
||||
: "No included monthly usage"}
|
||||
{data?.period ? ` · ${data.period}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">Balance available</p>
|
||||
<p className="mt-1 text-2xl font-bold">{usd(available)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{includedMonthly > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span>Included usage consumed</span>
|
||||
<span className="font-medium">
|
||||
{usd(includedConsumed)} / {usd(denom)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={pct} />
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>{usd(includedRemaining)} remaining</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Consumed this period</p>
|
||||
<p className="mt-1 text-lg font-semibold">{usd(consumed)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Overage</p>
|
||||
<p className="mt-1 text-lg font-semibold">{usd(overage)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Included granted</p>
|
||||
<p className="mt-1 text-lg font-semibold">{usd(includedGranted)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overage > 0 && (
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
You have exceeded your included usage. Overage of {usd(overage)} is drawn from your
|
||||
prepaid balance and billed via your payment method.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -10,8 +10,35 @@ import * as z from "zod";
|
||||
import { throwIfNoOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { getObservationCountOfProjectsSinceCreationDate } from "@hanzo/shared/src/server";
|
||||
import { commerceGet } from "@/src/features/billing/server/commerceClient";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Shape returned by Hanzo Commerce GET /v1/billing/usage-rollup — the single
|
||||
* billing source of truth for plan + included-usage + overage + balance.
|
||||
* Commerce derives every figure from the same balance transactions the gateway
|
||||
* prepaid gate reads, so this view is consistent with enforcement.
|
||||
*/
|
||||
export type CommerceUsageRollup = {
|
||||
user: string;
|
||||
plan: string;
|
||||
currency: string;
|
||||
period: string;
|
||||
included: {
|
||||
monthlyCents: number; // catalog allotment for the plan
|
||||
grantedCents: number; // actually granted to the balance this period
|
||||
consumedCents: number; // included credit consumed so far
|
||||
remainingCents: number; // included credit left
|
||||
};
|
||||
consumedCents: number; // total usage this period
|
||||
overageCents: number; // usage beyond the included credit
|
||||
balance: {
|
||||
balanceCents: number;
|
||||
holdsCents: number;
|
||||
availableCents: number; // the value the gateway gate reads (available > 0)
|
||||
};
|
||||
};
|
||||
|
||||
export const cloudBillingRouter = createTRPCRouter({
|
||||
createStripeCheckoutSession: protectedOrganizationProcedure
|
||||
.input(
|
||||
@@ -953,4 +980,48 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
// Commerce-backed plan + included-usage rollup. Read-only: commerce is the
|
||||
// single billing source of truth; the console only displays. Keyed by the
|
||||
// commerce user identity (<org>/<userId>); when not supplied we fall back to
|
||||
// the org slug so the page shows the org's plan/usage aggregate.
|
||||
getCommerceUsageRollup: protectedOrganizationProcedure
|
||||
.input(
|
||||
z.object({
|
||||
orgId: z.string(),
|
||||
// Optional explicit commerce user key ("<org>/<userId>"). When omitted,
|
||||
// the org slug is used.
|
||||
user: z.string().optional(),
|
||||
// Optional plan slug override; commerce resolves from the subscription
|
||||
// when omitted.
|
||||
plan: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }): Promise<CommerceUsageRollup> => {
|
||||
throwIfNoOrganizationAccess({
|
||||
organizationId: input.orgId,
|
||||
scope: "hanzoCloudBilling:CRUD",
|
||||
session: ctx.session,
|
||||
});
|
||||
|
||||
const organization = await ctx.prisma.organization.findUnique({
|
||||
where: { id: input.orgId },
|
||||
});
|
||||
if (!organization) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Commerce namespaces tenants by org slug; the rollup `user` key follows
|
||||
// the gateway convention (<org>/<userId>) but also accepts the org slug
|
||||
// alone for an org-level view.
|
||||
const user = input.user ?? organization.name;
|
||||
|
||||
return commerceGet<CommerceUsageRollup>("/v1/billing/usage-rollup", {
|
||||
user,
|
||||
plan: input.plan,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user