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:
@@ -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(
|
||||
|
||||
+3
@@ -15,4 +15,7 @@ export class AdminPanelTopWorkspaceDTO {
|
||||
|
||||
@Field(() => String)
|
||||
subdomain: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logo: string | null;
|
||||
}
|
||||
|
||||
+15
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+47
@@ -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[];
|
||||
}
|
||||
+114
@@ -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,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user