feat(admin-panel): add read-only Billing tab and workspace logos (#20012)

## Summary
- Adds a **Billing** tab on the admin-panel workspace detail page that
surfaces Stripe customer + active subscription details (status, plan,
interval, current period, trial, cancellation, line items, credit
balance). Tab is gated on `IS_BILLING_ENABLED` both in the backend
service and in the frontend tab list — completely hidden on instances
where billing is disabled.
- Renders a **workspace avatar next to the name** in the admin Top
Workspaces list by plumbing the workspace `logo` field through the admin
DTO, statistics SQL query, and generated admin GraphQL types.
- **Read-only** by design: no Stripe API calls, no mutations — data
comes from the existing \`BillingCustomerEntity\` /
\`BillingSubscriptionEntity\` / \`BillingPriceEntity\` tables via
\`BillingSubscriptionService.getCurrentBillingSubscription\`.

### What the tab shows
- **Customer** container — Stripe customer ID (with link to the Stripe
dashboard, monospaced), credit balance (formatted, from
\`creditBalanceMicro\`).
- **Subscription** container — status tag (color-coded), plan tag,
billing interval, current period range, trial range (if trialing),
\`cancelAtPeriodEnd\` / \`cancelAt\` / \`canceledAt\` (only when set),
Stripe subscription ID (external link).
- **Line items** — one card per subscription item with product name,
product key tag, seats (if quantity), credits per period (for metered),
unit price (formatted with currency).

### Design choices
- Styling matches the user-facing billing page
(\`SubscriptionInfoContainer\` + \`Tag\` + \`H2Title\` + \`Section\`) —
no new UI primitives.
- Currency is rendered inline with amounts via \`Intl.NumberFormat\`
(e.g. \`\$19.00\`) instead of as a separate row.
- Uses the generated admin GraphQL types
(\`WorkspaceBillingAdminPanelQuery\`, \`SubscriptionStatus\`,
\`SubscriptionInterval\`) — no hand-typed response shapes.

## Test plan
- [x] \`npx nx typecheck twenty-server\` — passes
- [x] \`npx nx typecheck twenty-front\` — passes
- [x] oxlint + prettier on all touched files — clean
- [x] \`graphql:generate --configuration=admin\` — regenerated; new
\`workspaceBillingAdminPanel\` query + \`logo\` field on
\`AdminPanelTopWorkspace\` appear in \`generated-admin/graphql.ts\`
- [x] Backend GraphQL schema introspection shows
\`workspaceBillingAdminPanel\` query on \`/admin-panel\`
- [x] Direct GraphQL call with seeded \`BillingCustomer\` +
\`BillingSubscription\` + \`BillingPrice\` rows returns the expected
shape (\`status: "Trialing"\`, plan \`PRO\`, items with
quantity/unitAmount/includedCredits, trial period dates)
- [x] With \`IS_BILLING_ENABLED=false\` (default) the Billing tab is
hidden — verified in the admin panel UI
- [x] Top Workspaces list renders workspace avatars next to names —
verified in the admin panel UI
- [ ] Smoke test the Billing tab render in a real instance that has
\`IS_BILLING_ENABLED=true\` + live Stripe data (skipped locally due to
dev-env auth friction after toggling billing/multi-workspace; recommend
a reviewer check)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-23 22:12:05 +02:00
committed by GitHub
parent 9ce9e2bc12
commit 085c0b9b7f
15 changed files with 754 additions and 7 deletions
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service';
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service';
@@ -14,6 +15,9 @@ import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/main
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
@@ -47,8 +51,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
FeatureFlagEntity,
AgentChatThreadEntity,
AgentMessageEntity,
BillingCustomerEntity,
BillingPriceEntity,
]),
AuthModule,
BillingModule,
FileModule,
WorkspaceDomainsModule,
RedisClientModule,
@@ -69,6 +76,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
AdminPanelResolver,
AdminPanelUserLookupService,
AdminPanelStatisticsService,
AdminPanelBillingService,
AdminPanelChatService,
AdminPanelConfigService,
AdminPanelVersionService,
@@ -11,6 +11,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service';
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service';
@@ -19,6 +20,7 @@ import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/se
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
import { AdminPanelRecentUserDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-recent-user.dto';
import { AdminPanelTopWorkspaceDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-top-workspace.dto';
import { AdminPanelWorkspaceBillingDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-workspace-billing.dto';
import { AdminWorkspaceChatThreadDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto';
import { AdminChatThreadMessagesDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-thread-messages.dto';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
@@ -89,6 +91,7 @@ export class AdminPanelResolver {
constructor(
private readonly adminUserLookupService: AdminPanelUserLookupService,
private readonly adminStatisticsService: AdminPanelStatisticsService,
private readonly adminBillingService: AdminPanelBillingService,
private readonly adminChatService: AdminPanelChatService,
private readonly adminConfigService: AdminPanelConfigService,
private readonly adminVersionService: AdminPanelVersionService,
@@ -667,6 +670,14 @@ export class AdminPanelResolver {
return this.adminUserLookupService.workspaceLookup(workspaceId);
}
@UseGuards(ServerLevelImpersonateGuard)
@Query(() => AdminPanelWorkspaceBillingDTO, { nullable: true })
async workspaceBillingAdminPanel(
@Args('workspaceId', { type: () => UUIDScalarType }) workspaceId: string,
): Promise<AdminPanelWorkspaceBillingDTO | null> {
return this.adminBillingService.getWorkspaceBilling(workspaceId);
}
@UseGuards(ServerLevelImpersonateGuard)
@Query(() => [AdminWorkspaceChatThreadDTO])
async getAdminWorkspaceChatThreads(
@@ -15,4 +15,7 @@ export class AdminPanelTopWorkspaceDTO {
@Field(() => String)
subdomain: string;
@Field(() => String, { nullable: true })
logo: string | null;
}
@@ -0,0 +1,15 @@
import { Field, Float, ObjectType } from '@nestjs/graphql';
import { AdminPanelWorkspaceSubscriptionDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-workspace-subscription.dto';
@ObjectType('AdminPanelWorkspaceBilling')
export class AdminPanelWorkspaceBillingDTO {
@Field(() => String, { nullable: true })
stripeCustomerId: string | null;
@Field(() => Float, { nullable: true })
creditBalance: number | null;
@Field(() => AdminPanelWorkspaceSubscriptionDTO, { nullable: true })
subscription: AdminPanelWorkspaceSubscriptionDTO | null;
}
@@ -0,0 +1,22 @@
import { Field, Float, ObjectType } from '@nestjs/graphql';
@ObjectType('AdminPanelWorkspaceSubscriptionItem')
export class AdminPanelWorkspaceSubscriptionItemDTO {
@Field(() => String)
productName: string;
@Field(() => String, { nullable: true })
productKey: string | null;
@Field(() => String)
stripePriceId: string;
@Field(() => Float, { nullable: true })
quantity: number | null;
@Field(() => Float, { nullable: true })
unitAmount: number | null;
@Field(() => Float, { nullable: true })
includedCredits: number | null;
}
@@ -0,0 +1,47 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AdminPanelWorkspaceSubscriptionItemDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-workspace-subscription-item.dto';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
@ObjectType('AdminPanelWorkspaceSubscription')
export class AdminPanelWorkspaceSubscriptionDTO {
@Field(() => String)
stripeSubscriptionId: string;
@Field(() => SubscriptionStatus)
status: SubscriptionStatus;
@Field(() => SubscriptionInterval, { nullable: true })
interval: SubscriptionInterval | null;
@Field(() => String)
currency: string;
@Field(() => String, { nullable: true })
planKey: string | null;
@Field(() => Date)
currentPeriodStart: Date;
@Field(() => Date)
currentPeriodEnd: Date;
@Field(() => Date, { nullable: true })
trialStart: Date | null;
@Field(() => Date, { nullable: true })
trialEnd: Date | null;
@Field(() => Date, { nullable: true })
cancelAt: Date | null;
@Field(() => Date, { nullable: true })
canceledAt: Date | null;
@Field(() => Boolean)
cancelAtPeriodEnd: boolean;
@Field(() => [AdminPanelWorkspaceSubscriptionItemDTO])
items: AdminPanelWorkspaceSubscriptionItemDTO[];
}
@@ -0,0 +1,114 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, type Repository } from 'typeorm';
import { AdminPanelWorkspaceBillingDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-workspace-billing.dto';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const CREDIT_BALANCE_MICRO_UNIT = 1_000_000;
const KNOWN_PLAN_KEYS: ReadonlySet<string> = new Set(
Object.values(BillingPlanKey),
);
@Injectable()
export class AdminPanelBillingService {
constructor(
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
@InjectRepository(BillingPriceEntity)
private readonly billingPriceRepository: Repository<BillingPriceEntity>,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async getWorkspaceBilling(
workspaceId: string,
): Promise<AdminPanelWorkspaceBillingDTO | null> {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return null;
}
const [customer, subscription] = await Promise.all([
this.billingCustomerRepository.findOne({ where: { workspaceId } }),
this.billingSubscriptionService.getCurrentBillingSubscription({
workspaceId,
}),
]);
if (!customer && !subscription) {
return null;
}
const stripeCustomerId =
customer?.stripeCustomerId ?? subscription?.stripeCustomerId ?? null;
const creditBalance = customer
? customer.creditBalanceMicro / CREDIT_BALANCE_MICRO_UNIT
: null;
if (!subscription) {
return {
stripeCustomerId,
creditBalance,
subscription: null,
};
}
const items = subscription.billingSubscriptionItems ?? [];
const priceIds = items.map((item) => item.stripePriceId);
const prices = priceIds.length
? await this.billingPriceRepository.find({
where: { stripePriceId: In(priceIds) },
})
: [];
const priceByStripeId = new Map(
prices.map((price) => [price.stripePriceId, price]),
);
const planValue = subscription.metadata?.plan;
const planKey =
typeof planValue === 'string' && KNOWN_PLAN_KEYS.has(planValue)
? planValue
: null;
return {
stripeCustomerId,
creditBalance,
subscription: {
stripeSubscriptionId: subscription.stripeSubscriptionId,
status: subscription.status,
interval: subscription.interval ?? null,
currency: subscription.currency,
planKey,
currentPeriodStart: subscription.currentPeriodStart,
currentPeriodEnd: subscription.currentPeriodEnd,
trialStart: subscription.trialStart,
trialEnd: subscription.trialEnd,
cancelAt: subscription.cancelAt,
canceledAt: subscription.canceledAt,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
items: items.map((item) => {
const price = priceByStripeId.get(item.stripePriceId);
const firstTier = price?.tiers?.[0];
const productKey = item.billingProduct?.metadata?.productKey;
return {
productName: item.billingProduct?.name ?? '',
productKey: typeof productKey === 'string' ? productKey : null,
stripePriceId: item.stripePriceId,
quantity: item.quantity != null ? Number(item.quantity) : null,
unitAmount:
price?.unitAmount != null ? Number(price.unitAmount) : null,
includedCredits:
typeof firstTier?.up_to === 'number' ? firstTier.up_to : null,
};
}),
},
};
}
}
@@ -80,7 +80,7 @@ export class AdminPanelStatisticsService {
}
const results = await this.workspaceRepository.manager.query(
`SELECT w.id, w."displayName" AS name, w.subdomain, COUNT(uw.id)::int AS "totalUsers"
`SELECT w.id, w."displayName" AS name, w.subdomain, w.logo, COUNT(uw.id)::int AS "totalUsers"
FROM core.workspace w
LEFT JOIN core."userWorkspace" uw ON uw."workspaceId" = w.id AND uw."deletedAt" IS NULL
WHERE ${whereClause}
@@ -95,11 +95,13 @@ export class AdminPanelStatisticsService {
id: string;
name: string;
subdomain: string;
logo: string | null;
totalUsers: number;
}) => ({
id: row.id,
name: row.name ?? '',
subdomain: row.subdomain ?? '',
logo: row.logo ?? null,
totalUsers: row.totalUsers,
}),
);