Skip to Content

← All archived runs

Run: tenant-plan-visibility

run.md

Run: tenant-plan-visibility

  • branch: claude/tenant-plan-visibility-pipeline-4ra9vq
  • pr: #890

02_define/output/spec.md

Spec: Plan, seats and subscription status on a tenant

  • slug: tenant-plan-visibility
  • personas: Admin (Sustentus team), Partner
  • touches: apps/console/lib/console-billing.ts, apps/console/lib/console-tenants.ts, apps/console/app/(console)/tenants/[tenantId]/page.tsx, apps/console/components/subscription-card.tsx
  • complexity: standard

Problem

tenant-directory (#887) made a tenant findable and openable, but its detail view answers only identity questions — name, slug, organisation id, created date, users. The commercial state of the account is nowhere: nobody can say which plan a tenant is on, how many seats it is using against what it is allowed, or whether its subscription is healthy. That is the most basic account question the Sustentus team and its partners get asked, and today it is answered by leaving the console and opening the Clerk dashboard. Until the console can answer it, it is not the operations surface that Scale the bridge / Establish product-market fit with vendor partners (2026-Q2, Objective 1) needs it to be — a partner cannot administer an account whose commercial state they cannot see.

Proposed change

A commercial card on the tenant detail view, read live from Clerk on every request, showing three things and nothing else: plan name, seats used against the seat allowance, and subscription status.

The system of record is Clerk Billing, read-only — settled with Jamie 2026-08-26, revising the stub's assumption that a subscription record already exists locally. It does not: there is no Plan or Subscription model in Mongo, nothing commercial on the Tenant document, and no Stripe. Clerk Billing (public beta) is the store, reached through clerkClient().billing:

  • Plan name and subscription status come from billing.getOrganizationBillingSubscription(tenant.clerkOrgId) — the plan of each active subscription item (normally exactly one) and the subscription's own status.
  • Seats used is the tenant's true member total, taken from the totalCount of the Clerk membership read the detail page already makes for its Users table — not the length of the page it renders, which stops at 100 and would under-report a large tenant.
  • Seat allowance is the Clerk organisation's maxAllowedMemberships, read live via organizations.getOrganization. Clerk expresses "no cap" as 0; the card says unlimited rather than printing a number that is not one.

No money, for anyone. The read maps plan name, seat counts and status out of the Clerk objects and nothing else — fee, annualFee, amount, nextPayment and lifetimePaid never leave the data layer. Q-9's partner boundary is then true by construction rather than by a per-audience branch: there is no amount in the shape the card renders, so there is none to leak to either audience.

Partners see the identical card and can change nothing — which v1 satisfies outright, because the card carries no controls at all (see Out of scope on the plan move).

Absence is a state, not a fault. Clerk Billing is not enabled on this instance today and no plans are defined, so the common case at merge is "no subscription". A not-found or Billing-not-enabled answer renders a plain No subscription on record and leaves the rest of the detail view standing — the same discipline readTenantMembers already applies to a missing organisation. Any other Clerk failure is a genuine fault and throws.

Acceptance criteria

  • A tenant's detail view shows its plan name and subscription status, read live from Clerk on the request rather than from any stored copy.
  • The same card shows seats used against the seat allowance, and states an uncapped allowance as unlimited rather than as a number.
  • Seats used is the tenant's true member total, and stays correct for a tenant with more members than the users table lists.
  • Staff and partners see the identical card, and no money amount appears on it for either — no fee, next payment, invoice, or payment history.
  • A tenant with no subscription on record shows a plain no-subscription state, and the rest of the detail view still renders.
  • A partner still sees this only on tenants assigned to them — the existing scoped read is unchanged and no new route reaches a tenant it did not already reach.

Out of scope

  • Moving a tenant between plans — dropped from this run with Jamie, 2026-08-26, revising the stub's second criterion. Clerk's backend Billing API has no plan-change write: its only writes are cancelSubscriptionItem and extendSubscriptionItemFreeTrial, and a plan switch happens in the payer's own checkout, which console users cannot reach — they are org-less by design and never enter a tenant organisation (apps/console/AGENTS.md). It needs its own scope once a writable subscription store exists; it is not a Build-time detail.
  • Creating, editing or pricing plans — the catalogue is a later round [Q-8].
  • Any money amount, for any audience: fees, next payment, invoices, payment history, refunds [Q-9].
  • Currencies management (seen in the reference screenshot; deliberately not in v1).
  • The activity-trail entry for commercial events — tenant-activity-dashboard (stub 7 of the batch) owns the trail and explicitly names plan movement among what it records.
  • A seat-class breakdown (Platform / Sales / Delivery). Those classes are reporting-only and derive from platform roles in the Mongo user collection; this card reads Clerk, which is what keeps it from showing a stale roster.
  • Enabling and configuring Clerk Billing in the Clerk Dashboard, and defining the plans in it — an operator action, not code. The feature ships correct against an instance with Billing off, showing the no-subscription state.

Open questions

  • Clerk Billing is a public beta and its backend surface carries @experimental; Clerk advises pinning the SDK. Non-blocking for a read this shape, but a note for Build: keep every Clerk Billing call inside console-billing.ts so a beta API change lands in one file, and do not widen the pinned @clerk/backend range in this run.

03_build/output/notes.md

Build notes: tenant-plan-visibility

  • commits: feat: tenant-plan-visibility — plan, seats and subscription status on a tenant
  • ci: GREEN on aa5809b (Quality Project · Review diff against CONVENTIONS.md · Vercel tenant-management · Vercel web)

What changed

  • apps/console/lib/clerk-errors.ts (new): clerkErrorStatus — pulls the HTTP status off an unknown thrown by Clerk. Two readers now need to tell an absent record from a genuine fault, and they absorb different statuses; the shape test belongs in one home rather than being re-decided in each. console-tenants.ts's existing isNotFound delegates to it.
  • apps/console/lib/console-billing.ts (new): readTenantSubscription — plan names and status, live from Clerk Billing, null when there is no subscription. The type carries no money by construction: fee, annualFee, amount, nextPayment and lifetimePaid are in scope only inside this module and are never mapped out, so Q-9's partner boundary holds without the view knowing who is looking.
  • apps/console/lib/console-tenants.ts: readTenantMembers superseded by readTenantRoster, which returns the member page plus Clerk's totalCount and the organisation's maxAllowedMemberships. Membership and seat cap are fetched together because they answer one question and share one "is this organisation still there?" answer. maxAllowedMemberships of 0 (Clerk's "no cap") is normalised to null.
  • apps/console/components/subscription-card.tsx (new): the commercial card — plan, status chip, seats used against allowance. No controls, because v1 is read-only.
  • apps/console/app/(console)/tenants/[tenantId]/page.tsx: renders the card between Details and Users; the two live reads run in one Promise.all. The Users pagination note now states the real total (Showing 100 of 240 users.) instead of the old "may have more" hedge, which the member total makes unnecessary.

Acceptance criteria status

  • Plan name and subscription status, read live from Clerk on the request — readTenantSubscription calls billing.getOrganizationBillingSubscription per request; nothing is cached or mirrored.
  • Seats used against allowance, uncapped stated as unlimited — seatLabel renders 14 of 25 used, or 14 used · no seat limit when seatAllowance is null.
  • Seats used is the true member total — taken from Clerk's totalCount, not members.length, which stops at the 100-row page limit.
  • Identical card for both audiences, no money amount for either — one component, no audience branch, and no amount exists in TenantSubscription to render.
  • No-subscription state, rest of the view still renders — a 404 (no subscription) or 403 (Billing not enabled on the instance) returns null and the card reads "No subscription on record"; every other card is untouched.
  • Partner still sees this only on assigned tenants — no route or query changed. The page still enters through tenantService.findForConsole(scopeOf(viewer), tenantId), and the new reads take tenant.clerkOrgId from the row that read already scoped.

Notes for Verify

  • Billing is not enabled on this Clerk instance, so the preview will show "No subscription on record" and "no seat limit" on every tenant. That is the specced correct behaviour, not a defect — the no-subscription path is the one the smoke pass can actually exercise today. Seats used is live and real, so check that number against the Users table.
  • The 403 absorb is the load-bearing decision. ABSENT_STATUSES = [403, 404] in console-billing.ts is what keeps a Billing-disabled instance rendering instead of erroring on every tenant. A bad secret key is a 401 and an outage a 5xx, so neither is swallowed — worth a second opinion on that reasoning.
  • Clerk Billing is a public beta (@experimental on every method). Every call is confined to console-billing.ts so a beta change lands in one file, and @clerk/backend was not re-pinned. Verified against the version the console actually resolves — 3.4.5 via @clerk/nextjs 7.3.1, not the 2.33.3 that packages/services resolves — for client.billing, getOrganizationBillingSubscription, PaginatedResourceResponse.totalCount, getOrganization({ organizationId }) and Organization.maxAllowedMemberships.
  • No unit tests added. apps/console has no vitest.config.ts and no test script; standing a tier up for this app is outside the spec, and CONVENTIONS.md → Testing says not to improvise one the config does not have. What is unit-assertable here is presentation labels (seatLabel/planLabel) and Clerk-mocked reads; the money boundary — the criterion most worth guarding — is proved by the type, not by a test. If a console test tier is wanted, that is its own chore.
  • The plan-change affordance is deliberately absent — see the spec's Out of scope. Clerk's backend API has no plan-change write, so an approving reviewer should expect a read-only card.

04_verify/output/verify.md

Verify: tenant-plan-visibility

  • ci: GREEN on f3f53b0 — settled via ci-status.sh after the merge + review fixes. The commit carrying this file is markdown-only under .icm/**; it was re-settled green before hand-off and that SHA is named in the hand-off message.
  • previews smoked: all seven built for f3f53b0, tenant-management (the console) included — but every one of them sits behind Vercel deployment protection, so the agent reached none of them. See the smoke split below.
  • production-readiness: not required — the diff adds no migration, no model change, no payment path and no env var (git diff main...HEAD | grep process.env is empty; CLERK_SECRET_KEY was already in turbo.jsonglobalEnv). Auth is untouched: proxy.ts, console-access.ts, permissions/, and the scopeOf/consoleScopeClause scoping logic are all unchanged by this feature's diff.
  • code-review: CI Claude review (Review diff against CONVENTIONS.md) passed with zero posted comments. An additional /code-review at medium (spec complexity standard) was run anyway because the build notes had asked for a second opinion on the 403 handling — it found two real defects, both fixed on branch (below).
  • security-review: run — no findings. The two new reads take tenant.clerkOrgId from the row tenantService.findForConsole(scopeOf(viewer), tenantId) already scoped, so a partner cannot reach a tenant they could not reach before and the org id is never user-supplied. The money boundary holds by type: TenantSubscription has no amount field, so there is nothing for the card to leak to either audience. No new PII surface — readTenantSeats uses totalCount only and never touches member records. No injection sink, no dangerouslySetInnerHTML.
  • playwright: TODO — manual DoD smoke performed instead.

DoD smoke (on the preview — each line says who verified it)

The agent could not reach the preview at all. Every route on tenant-management-git-claude-tenant-plan-visib-444596-sustentus.vercel.app answers 302 to vercel.com/sso-api (Vercel deployment protection), and the console is invitation-only behind a deny-by-default proxy besides. So no acceptance criterion is agent-demonstrated, and none is recorded as passed below. What the agent did establish is recorded as evidence, not as a pass.

  • Plan name + subscription status, live from Clerk — operator. Agent evidence: the console preview built and deployed for this commit; readTenantSubscription is called per request with no cache or stored copy.
  • Seats used against allowance, uncapped stated in words — operator. Agent evidence: seatLabel is unit-tested for the capped, uncapped and over-allowance cases; those tests ran green in CI (@sustentus/console:test, lib/subscription-labels.test.ts, 10 tests).
  • Seats used is the true member total — operator. Agent evidence: taken from Clerk's totalCount on a limit: 1 read, so it cannot be capped by the member page size.
  • Identical card both audiences, no money for either — operator (visual). Agent evidence: one component, no audience branch; no amount exists in the type to render.
  • No-subscription state, rest of the view still renders — operator. Agent evidence: 404 and 403 both return non-throwing states; planLabel is unit-tested for both.
  • Partner sees this only on assigned tenants — operator. Agent evidence: no route added, no change to scopeOf or the scoped query; the reads hang off the already-scoped tenant row.
  • auth: staff + partner sign-in and reach the tenant detail view — operator (agent has no console credentials).
  • payments: not touched — no payment path exists in this diff.
  • notifications: none expected — this run is read-only and adds no notification.

Findings & cleanup

  • Merge conflict against a moved main — the significant finding. tenant-user-management (#889) and partner-assignment (#888) both merged while this run was in flight, leaving PR #890 mergeable_state: dirty. Resolved by merging origin/main in (f3f53b0), not by rebasing. Reconciled rather than just de-conflicted:
    • #889 independently created its own apps/console/lib/clerk-errors.ts. My duplicate was deleted and main's isClerkNotFound adopted as the one home; isClerkForbidden was added alongside it, in main's idiom.
    • My readTenantRoster refactor of console-tenants.ts was reverted wholesale to main's version. #889 gave readTenantMembers several new callers, and reshaping it to carry a seat figure would have pushed this feature's concern into all of them. Seats moved to their own additive readTenantSeats, at the cost of one extra Clerk call.
    • Net effect: this feature's footprint on the merged detail page is now 8 lines.
  • console-billing.ts filtered subscription items to status === "active" — a past_due or canceled tenant would have rendered "No active plan" beside a "Past due" badge, blanking the plan name at exactly the moment an operator wants it. Fixed: only genuinely superseded statuses (ended, expired, abandoned) and the not-yet-started upcoming are filtered out.
  • 403 was absorbed as "no subscription on record" — correct for Billing-off today, but once Billing is enabled a revoked permission would silently report a paying tenant as unsubscribed. Fixed: readTenantSubscription now returns a three-state result (subscribed / none / unavailable), and the card says "Billing could not be read" rather than "None". This is the point the build notes flagged for a second opinion; the review agreed.
  • The console preview was skipped on the previous head (9c6cc76 was markdown-only, so Vercel's ignored-build-step correctly skipped it) — a real mismatch under this stage's rules, since the feature ships in that app. Resolved by the merge commit, which touches apps/console and rebuilt every preview. It was never quoted as a pass while skipped.
  • The "no test tier in apps/console" gap from the build notes is closed — #889 added apps/console/vitest.config.ts. The pure label logic was extracted to lib/subscription-labels.ts (so it is testable without pulling in the UI or the Clerk client) and covered by 10 unit tests written from the acceptance criteria. They pass in CI.
  • Accepted, not fixed: main's users table keeps its "Showing the first 100 users. This tenant may have more." note even though the exact total is now available on the same page. Changing it would edit freshly merged code for no acceptance criterion; the note is vague, not wrong.

05_ship/output/changelog.md


title: See a tenant's plan, seats and subscription status in the console date: 2026-08-27T11:00:00Z personas: [admin] slug: tenant-plan-visibility pr: https://github.com/sustentus/sustentus/pull/890

See a tenant's plan, seats and subscription status in the console

Answering "which plan is this account on, and is it healthy?" used to mean leaving the console and opening Clerk. A tenant's page now carries that answer: the plan it is on, the seats it is using against the seats it is allowed, and whether its subscription is in good standing. The Sustentus team sees it on every tenant; a partner sees it on the tenants assigned to them.

Seats are counted properly rather than estimated. The number is the tenant's true member total, so a tenant that has grown past its allowance shows as past it — the case worth catching — instead of stopping at whatever the user list happens to display. A tenant with no seat limit says so in words.

No money appears anywhere on it, for either audience. Plan, seats and status are the whole of it — no fees, no next payment, no invoices, no payment history.

Two things to expect. Billing is not switched on yet, so plan and status read as unavailable until it is; seats are live and correct regardless. And this is a read-only view for now — moving a tenant onto a different plan still happens outside the console.

05_ship/output/investor-update.md

A tenant's commercial state, at a glance

Who it's for: Sustentus staff and partners What shipped: A tenant's page in the console shows its plan, seats used against its allowance, and its subscription status. Why it matters: Partners can read the accounts they administer unaided — Scale the bridge / product-market fit with vendor partners.

Dig deeper: https://github.com/sustentus/sustentus/pull/890 · https://help.sustentus.com/changelog/2026-08-27-tenant-plan-visibility

05_ship/output/release.md

Ship: tenant-plan-visibility

  • pr: #890 · merge: not authorised — held at the gate. Ready to merge is unticked; this commit is the pre-gate work only.
  • CI: GREEN on 82a5e5d before this push, settled via ci-status.sh; re-settled on the head carrying this file before any merge is attempted.
  • technical docs: apps/docs/app/technical/applications/console/page.mdx — new "Plan and subscription" section, and the /tenants/:id row of the routes table now names the subscription.
  • business docs: no business docs impact. The console is an internal operations surface, not a persona capability in business/roles and not a step on the service journey — the batch's own breakdown places it beside the journey rather than on it.
  • release notes: both
  • sent: none — the ship note sends on the merge, which has not happened
  • close-out: will archive .icm/runs/tenant-plan-visibility/ to apps/docs/archive/pipeline-runs/. Not the epic: tenant-management-app still has tenant-lifecycle and tenant-activity-dashboard in intake, so 2 of 7 stubs remain in flight.

Acceptance check (vs spec)

Every criterion is met in code and traced in 04_verify/output/verify.md; none was demonstrated on the preview, because the console preview sits behind Vercel deployment protection and the app is invitation-only. Verify records all six as operator-owned and unticked, and that remains true here.

  • Plan name and subscription status, read live from Clerk — readTenantSubscription per request.
  • Seats used against allowance, uncapped stated in words — seatLabel, unit-tested.
  • Seats used is the true member total — Clerk's totalCount, not the rendered page length.
  • Identical card for both audiences, no money for either — one component, no amount in the type.
  • No-subscription state, rest of the view still renders — 404 and 403 both return states.
  • Partner sees this only on assigned tenants — no route or scoping change.

Notes

  • The two defects the Verify code-review found (plan blanked on a past_due subscription; 403 absorbed as "no subscription") were fixed on this branch before this stage ran.
  • tenant-user-management (#889) and partner-assignment (#888) merged mid-run; the conflict was resolved by merging main in at f3f53b0 and reconciling, not by rebasing.