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
@@ -4,6 +4,7 @@ import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSec
import { SettingsAdminVersionContainer } from '@/settings/admin-panel/components/SettingsAdminVersionContainer';
import { ADMIN_PANEL_RECENT_USERS } from '@/settings/admin-panel/graphql/queries/adminPanelRecentUsers';
import { ADMIN_PANEL_TOP_WORKSPACES } from '@/settings/admin-panel/graphql/queries/adminPanelTopWorkspaces';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
@@ -14,21 +15,37 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useDebounce } from 'use-debounce';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { getImageAbsoluteURI, getSettingsPath } from 'twenty-shared/utils';
import { AvatarOrIcon } from 'twenty-ui/components';
import { currentUserState } from '@/auth/states/currentUserState';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
const StyledEmptyState = styled.div`
color: ${themeCssVariables.font.color.tertiary};
padding: ${themeCssVariables.spacing[4]} 0;
`;
const StyledWorkspaceCell = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
`;
const StyledWorkspaceName = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const SettingsAdminGeneral = () => {
const apolloAdminClient = useApolloAdminClient();
const [userSearchTerm, setUserSearchTerm] = useState('');
@@ -64,6 +81,7 @@ export const SettingsAdminGeneral = () => {
name: string;
totalUsers: number;
subdomain: string;
logo: string | null;
}[];
}>(ADMIN_PANEL_TOP_WORKSPACES, {
client: apolloAdminClient,
@@ -176,7 +194,23 @@ export const SettingsAdminGeneral = () => {
)}
>
<TableCell color={themeCssVariables.font.color.primary}>
{workspace.name || '\u2014'}
<StyledWorkspaceCell>
<AvatarOrIcon
avatarUrl={
getImageAbsoluteURI({
imageUrl: isNonEmptyString(workspace.logo)
? workspace.logo
: DEFAULT_WORKSPACE_LOGO,
baseUrl: REACT_APP_SERVER_BASE_URL,
}) ?? ''
}
placeholder={workspace.name}
avatarType="squared"
/>
<StyledWorkspaceName>
{workspace.name || '\u2014'}
</StyledWorkspaceName>
</StyledWorkspaceCell>
</TableCell>
<TableCell align="right">
{workspace.totalUsers}
@@ -0,0 +1,371 @@
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import {
H2Title,
IconBox,
IconCalendarEvent,
IconCalendarRepeat,
IconCircleX,
IconCoins,
IconCreditCard,
IconExternalLink,
IconId,
IconStatusChange,
IconTag,
IconUsers,
} from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { type ThemeColor } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { GET_WORKSPACE_BILLING_ADMIN_PANEL } from '@/settings/admin-panel/graphql/queries/getWorkspaceBillingAdminPanel';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { PlansTags } from '@/settings/billing/components/internal/PlansTags';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { beautifyExactDate } from '~/utils/date-utils';
import { BillingPlanKey } from '~/generated-metadata/graphql';
import {
SubscriptionInterval,
SubscriptionStatus,
type WorkspaceBillingAdminPanelQuery,
} from '~/generated-admin/graphql';
const STRIPE_DASHBOARD_BASE_URL = 'https://dashboard.stripe.com';
const BASE_PRODUCT_KEY = 'BASE_PRODUCT';
const METERED_PRODUCT_KEY = 'WORKFLOW_NODE_EXECUTION';
const EM_DASH = '\u2014';
type SettingsAdminWorkspaceBillingContentProps = {
workspaceId: string;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
margin-top: ${themeCssVariables.spacing[6]};
`;
const StyledExternalLink = styled.a`
align-items: center;
color: inherit;
display: inline-flex;
gap: ${themeCssVariables.spacing[1]};
text-decoration: none;
&:hover {
color: ${themeCssVariables.font.color.primary};
}
`;
const StyledMono = styled.span`
font-family: ${themeCssVariables.code.font.family};
`;
const StyledItemValue = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const STATUS_COLORS: Record<SubscriptionStatus, ThemeColor> = {
[SubscriptionStatus.Active]: 'green',
[SubscriptionStatus.Trialing]: 'blue',
[SubscriptionStatus.PastDue]: 'orange',
[SubscriptionStatus.Canceled]: 'red',
[SubscriptionStatus.Unpaid]: 'red',
[SubscriptionStatus.Paused]: 'gray',
[SubscriptionStatus.Incomplete]: 'gray',
[SubscriptionStatus.IncompleteExpired]: 'gray',
};
const STATUS_LABELS: Record<SubscriptionStatus, string> = {
[SubscriptionStatus.Active]: 'Active',
[SubscriptionStatus.Trialing]: 'Trialing',
[SubscriptionStatus.PastDue]: 'Past Due',
[SubscriptionStatus.Canceled]: 'Canceled',
[SubscriptionStatus.Unpaid]: 'Unpaid',
[SubscriptionStatus.Paused]: 'Paused',
[SubscriptionStatus.Incomplete]: 'Incomplete',
[SubscriptionStatus.IncompleteExpired]: 'Incomplete Expired',
};
const formatCurrency = (amountMinor: number, currency: string): string => {
const normalizedCurrency = currency.toUpperCase();
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: normalizedCurrency,
}).format(amountMinor / 100);
} catch {
return `${(amountMinor / 100).toFixed(2)} ${normalizedCurrency}`;
}
};
const toBillingPlanKey = (planKey: string): BillingPlanKey | null =>
planKey === BillingPlanKey.PRO
? BillingPlanKey.PRO
: planKey === BillingPlanKey.ENTERPRISE
? BillingPlanKey.ENTERPRISE
: null;
const StripeLink = ({
path,
id,
}: {
path: 'customers' | 'subscriptions';
id: string;
}) => (
<StyledExternalLink
href={`${STRIPE_DASHBOARD_BASE_URL}/${path}/${id}`}
target="_blank"
rel="noopener noreferrer"
>
<StyledMono>{id}</StyledMono>
<IconExternalLink size={12} />
</StyledExternalLink>
);
export const SettingsAdminWorkspaceBillingContent = ({
workspaceId,
}: SettingsAdminWorkspaceBillingContentProps) => {
const { t } = useLingui();
const { formatNumber } = useNumberFormat();
const apolloAdminClient = useApolloAdminClient();
const { data, loading } = useQuery<WorkspaceBillingAdminPanelQuery>(
GET_WORKSPACE_BILLING_ADMIN_PANEL,
{
client: apolloAdminClient,
variables: { workspaceId },
skip: !workspaceId,
},
);
if (loading) {
return (
<StyledContainer>
<SettingsSectionSkeletonLoader rowCount={6} />
</StyledContainer>
);
}
const billing = data?.workspaceBillingAdminPanel ?? null;
if (!billing) {
return (
<StyledContainer>
<Section>
<H2Title
title={t`Billing`}
description={t`No billing data is available for this workspace.`}
/>
</Section>
</StyledContainer>
);
}
const { stripeCustomerId, creditBalance, subscription } = billing;
const customerItems = [
{
Icon: IconId,
label: t`Stripe customer`,
value: isDefined(stripeCustomerId) ? (
<StripeLink path="customers" id={stripeCustomerId} />
) : (
EM_DASH
),
},
{
Icon: IconCoins,
label: t`Credit balance`,
value: isDefined(creditBalance)
? `${formatNumber(creditBalance, { abbreviate: true, decimals: 2 })} ${t`credits`}`
: EM_DASH,
},
];
const intervalLabel =
subscription?.interval === SubscriptionInterval.Month
? t`Monthly`
: subscription?.interval === SubscriptionInterval.Year
? t`Yearly`
: null;
const formatPeriod = (start: string, end: string): string =>
`${beautifyExactDate(start)}${beautifyExactDate(end)}`;
const planKey = isDefined(subscription?.planKey)
? toBillingPlanKey(subscription.planKey)
: null;
const isTrialing = subscription?.status === SubscriptionStatus.Trialing;
const formatItemValue = (
item: NonNullable<typeof subscription>['items'][number],
): string => {
const parts: string[] = [];
if (isDefined(item.quantity)) {
parts.push(`${formatNumber(item.quantity)} ${t`seats`}`);
}
if (isDefined(item.includedCredits)) {
parts.push(
`${formatNumber(item.includedCredits, { abbreviate: true, decimals: 2 })} ${t`credits/period`}`,
);
}
if (isDefined(item.unitAmount) && isDefined(subscription)) {
parts.push(formatCurrency(item.unitAmount, subscription.currency));
}
return parts.length > 0 ? parts.join(' · ') : EM_DASH;
};
const subscriptionItems = subscription
? [
{
Icon: IconCreditCard,
label: t`Stripe subscription`,
value: (
<StripeLink
path="subscriptions"
id={subscription.stripeSubscriptionId}
/>
),
},
{
Icon: IconStatusChange,
label: t`Status`,
value: (
<Tag
color={STATUS_COLORS[subscription.status]}
text={STATUS_LABELS[subscription.status]}
/>
),
},
...(isDefined(planKey)
? [
{
Icon: IconTag,
label: t`Plan`,
value: <PlansTags plan={planKey} isTrialPeriod={isTrialing} />,
},
]
: []),
...(isDefined(intervalLabel)
? [
{
Icon: IconCalendarEvent,
label: t`Billing interval`,
value: intervalLabel,
},
]
: []),
{
Icon: IconCalendarRepeat,
label: t`Current period`,
value: formatPeriod(
subscription.currentPeriodStart,
subscription.currentPeriodEnd,
),
},
...(isDefined(subscription.trialStart) &&
isDefined(subscription.trialEnd)
? [
{
Icon: IconCalendarRepeat,
label: t`Trial period`,
value: formatPeriod(
subscription.trialStart,
subscription.trialEnd,
),
},
]
: []),
...(subscription.cancelAtPeriodEnd
? [
{
Icon: IconCircleX,
label: t`Cancels at period end`,
value: t`Yes`,
},
]
: []),
...(isDefined(subscription.cancelAt)
? [
{
Icon: IconCircleX,
label: t`Cancels at`,
value: beautifyExactDate(subscription.cancelAt),
},
]
: []),
...(isDefined(subscription.canceledAt)
? [
{
Icon: IconCircleX,
label: t`Canceled at`,
value: beautifyExactDate(subscription.canceledAt),
},
]
: []),
...subscription.items.map((item) => ({
Icon:
item.productKey === BASE_PRODUCT_KEY
? IconUsers
: item.productKey === METERED_PRODUCT_KEY
? IconCoins
: IconBox,
label: item.productName || t`Unnamed product`,
value: (
<StyledItemValue>
<span>{formatItemValue(item)}</span>
{isDefined(item.productKey) && (
<Tag color="gray" text={item.productKey} />
)}
</StyledItemValue>
),
})),
]
: [];
return (
<StyledContainer>
<Section>
<H2Title
title={t`Customer`}
description={t`Stripe customer linked to this workspace`}
/>
<SettingsTableCard
rounded
items={customerItems}
gridAutoColumns="3fr 8fr"
/>
</Section>
<Section>
<H2Title
title={t`Subscription`}
description={
subscription
? t`Current subscription state and line items`
: t`No active subscription.`
}
/>
{subscription && (
<SettingsTableCard
rounded
items={subscriptionItems}
gridAutoColumns="3fr 8fr"
/>
)}
</Section>
</StyledContainer>
);
};