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:
+36
-2
@@ -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}
|
||||
|
||||
+371
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user