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
@@ -95,6 +95,7 @@ export type AdminPanelRecentUser = {
export type AdminPanelTopWorkspace = {
__typename?: 'AdminPanelTopWorkspace';
id: Scalars['UUID'];
logo?: Maybe<Scalars['String']>;
name: Scalars['String'];
subdomain: Scalars['String'];
totalUsers: Scalars['Int'];
@@ -107,6 +108,40 @@ export type AdminPanelWorkerQueueHealth = {
status: AdminPanelHealthServiceStatus;
};
export type AdminPanelWorkspaceBilling = {
__typename?: 'AdminPanelWorkspaceBilling';
creditBalance?: Maybe<Scalars['Float']>;
stripeCustomerId?: Maybe<Scalars['String']>;
subscription?: Maybe<AdminPanelWorkspaceSubscription>;
};
export type AdminPanelWorkspaceSubscription = {
__typename?: 'AdminPanelWorkspaceSubscription';
cancelAt?: Maybe<Scalars['DateTime']>;
cancelAtPeriodEnd: Scalars['Boolean'];
canceledAt?: Maybe<Scalars['DateTime']>;
currency: Scalars['String'];
currentPeriodEnd: Scalars['DateTime'];
currentPeriodStart: Scalars['DateTime'];
interval?: Maybe<SubscriptionInterval>;
items: Array<AdminPanelWorkspaceSubscriptionItem>;
planKey?: Maybe<Scalars['String']>;
status: SubscriptionStatus;
stripeSubscriptionId: Scalars['String'];
trialEnd?: Maybe<Scalars['DateTime']>;
trialStart?: Maybe<Scalars['DateTime']>;
};
export type AdminPanelWorkspaceSubscriptionItem = {
__typename?: 'AdminPanelWorkspaceSubscriptionItem';
includedCredits?: Maybe<Scalars['Float']>;
productKey?: Maybe<Scalars['String']>;
productName: Scalars['String'];
quantity?: Maybe<Scalars['Float']>;
stripePriceId: Scalars['String'];
unitAmount?: Maybe<Scalars['Float']>;
};
export type AdminWorkspaceChatThread = {
__typename?: 'AdminWorkspaceChatThread';
conversationSize: Scalars['Int'];
@@ -447,6 +482,7 @@ export type Query = {
getSystemHealthStatus: SystemHealth;
userLookupAdminPanel: UserLookup;
versionInfo: VersionInfo;
workspaceBillingAdminPanel?: Maybe<AdminPanelWorkspaceBilling>;
workspaceLookupAdminPanel: UserLookup;
};
@@ -516,6 +552,11 @@ export type QueryUserLookupAdminPanelArgs = {
};
export type QueryWorkspaceBillingAdminPanelArgs = {
workspaceId: Scalars['UUID'];
};
export type QueryWorkspaceLookupAdminPanelArgs = {
workspaceId: Scalars['UUID'];
};
@@ -588,6 +629,22 @@ export type RetryJobsResponse = {
retriedCount: Scalars['Int'];
};
export enum SubscriptionInterval {
Month = 'Month',
Year = 'Year'
}
export enum SubscriptionStatus {
Active = 'Active',
Canceled = 'Canceled',
Incomplete = 'Incomplete',
IncompleteExpired = 'IncompleteExpired',
PastDue = 'PastDue',
Paused = 'Paused',
Trialing = 'Trialing',
Unpaid = 'Unpaid'
}
export type SystemHealth = {
__typename?: 'SystemHealth';
services: Array<SystemHealthService>;
@@ -832,7 +889,7 @@ export type AdminPanelTopWorkspacesQueryVariables = Exact<{
}>;
export type AdminPanelTopWorkspacesQuery = { __typename?: 'Query', adminPanelTopWorkspaces: Array<{ __typename?: 'AdminPanelTopWorkspace', id: string, name: string, totalUsers: number, subdomain: string }> };
export type AdminPanelTopWorkspacesQuery = { __typename?: 'Query', adminPanelTopWorkspaces: Array<{ __typename?: 'AdminPanelTopWorkspace', id: string, name: string, totalUsers: number, subdomain: string, logo?: string | null }> };
export type FindOneAdminApplicationRegistrationQueryVariables = Exact<{
id: Scalars['String'];
@@ -860,6 +917,13 @@ export type GetVersionInfoQueryVariables = Exact<{ [key: string]: never; }>;
export type GetVersionInfoQuery = { __typename?: 'Query', versionInfo: { __typename?: 'VersionInfo', currentVersion?: string | null, latestVersion: string } };
export type WorkspaceBillingAdminPanelQueryVariables = Exact<{
workspaceId: Scalars['UUID'];
}>;
export type WorkspaceBillingAdminPanelQuery = { __typename?: 'Query', workspaceBillingAdminPanel?: { __typename?: 'AdminPanelWorkspaceBilling', stripeCustomerId?: string | null, creditBalance?: number | null, subscription?: { __typename?: 'AdminPanelWorkspaceSubscription', stripeSubscriptionId: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, currency: string, planKey?: string | null, currentPeriodStart: string, currentPeriodEnd: string, trialStart?: string | null, trialEnd?: string | null, cancelAt?: string | null, canceledAt?: string | null, cancelAtPeriodEnd: boolean, items: Array<{ __typename?: 'AdminPanelWorkspaceSubscriptionItem', productName: string, productKey?: string | null, stripePriceId: string, quantity?: number | null, unitAmount?: number | null, includedCredits?: number | null }> } | null } | null };
export type UserLookupAdminPanelQueryVariables = Exact<{
userIdentifier: Scalars['String'];
}>;
@@ -965,11 +1029,12 @@ export const GetConfigVariablesGroupedDocument = {"kind":"Document","definitions
export const GetDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"isSensitive"}},{"kind":"Field","name":{"kind":"Name","value":"isEnvOnly"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"source"}}]}}]}}]} as unknown as DocumentNode<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>;
export const UpdateWorkspaceFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"featureFlag"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkspaceFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"featureFlag"},"value":{"kind":"Variable","name":{"kind":"Name","value":"featureFlag"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<UpdateWorkspaceFeatureFlagMutation, UpdateWorkspaceFeatureFlagMutationVariables>;
export const AdminPanelRecentUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelRecentUsers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelRecentUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}}]}}]}}]} as unknown as DocumentNode<AdminPanelRecentUsersQuery, AdminPanelRecentUsersQueryVariables>;
export const AdminPanelTopWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelTopWorkspaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelTopWorkspaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}}]}}]}}]} as unknown as DocumentNode<AdminPanelTopWorkspacesQuery, AdminPanelTopWorkspacesQueryVariables>;
export const AdminPanelTopWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelTopWorkspaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelTopWorkspaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}}]}}]}}]} as unknown as DocumentNode<AdminPanelTopWorkspacesQuery, AdminPanelTopWorkspacesQueryVariables>;
export const FindOneAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindOneAdminApplicationRegistrationQuery, FindOneAdminApplicationRegistrationQueryVariables>;
export const GetAdminChatThreadMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminChatThreadMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminChatThreadMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"thread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetAdminChatThreadMessagesQuery, GetAdminChatThreadMessagesQueryVariables>;
export const GetAdminWorkspaceChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminWorkspaceChatThreads"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminWorkspaceChatThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetAdminWorkspaceChatThreadsQuery, GetAdminWorkspaceChatThreadsQueryVariables>;
export const GetVersionInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetVersionInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versionInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}}]}}]}}]} as unknown as DocumentNode<GetVersionInfoQuery, GetVersionInfoQueryVariables>;
export const WorkspaceBillingAdminPanelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkspaceBillingAdminPanel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceBillingAdminPanel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeCustomerId"}},{"kind":"Field","name":{"kind":"Name","value":"creditBalance"}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeSubscriptionId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodStart"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"trialStart"}},{"kind":"Field","name":{"kind":"Name","value":"trialEnd"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"canceledAt"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAtPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"includedCredits"}}]}}]}}]}}]}}]} as unknown as DocumentNode<WorkspaceBillingAdminPanelQuery, WorkspaceBillingAdminPanelQueryVariables>;
export const UserLookupAdminPanelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserLookupAdminPanel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userLookupAdminPanel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userIdentifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userIdentifier"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserInfoFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"activationStatus"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"allowImpersonation"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customUrl"}},{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"users"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"featureFlags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserInfoFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode<UserLookupAdminPanelQuery, UserLookupAdminPanelQueryVariables>;
export const WorkspaceLookupAdminPanelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkspaceLookupAdminPanel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceLookupAdminPanel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserInfoFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"allowImpersonation"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"activationStatus"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customUrl"}},{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"users"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"featureFlags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserInfoFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode<WorkspaceLookupAdminPanelQuery, WorkspaceLookupAdminPanelQueryVariables>;
export const DeleteJobsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteJobs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"queueName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"jobIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteJobs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"queueName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"queueName"}}},{"kind":"Argument","name":{"kind":"Name","value":"jobIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"jobIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletedCount"}},{"kind":"Field","name":{"kind":"Name","value":"results"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"jobId"}},{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteJobsMutation, DeleteJobsMutationVariables>;
@@ -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>
);
};
@@ -7,6 +7,7 @@ export const ADMIN_PANEL_TOP_WORKSPACES = gql`
name
totalUsers
subdomain
logo
}
}
`;
@@ -0,0 +1,32 @@
import { gql } from '@apollo/client';
export const GET_WORKSPACE_BILLING_ADMIN_PANEL = gql`
query WorkspaceBillingAdminPanel($workspaceId: UUID!) {
workspaceBillingAdminPanel(workspaceId: $workspaceId) {
stripeCustomerId
creditBalance
subscription {
stripeSubscriptionId
status
interval
currency
planKey
currentPeriodStart
currentPeriodEnd
trialStart
trialEnd
cancelAt
canceledAt
cancelAtPeriodEnd
items {
productName
productKey
stripePriceId
quantity
unitAmount
includedCredits
}
}
}
}
`;
@@ -1,10 +1,11 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { AppTooltip, IconInfoCircle, TooltipDelay } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsBillingLabelValueItemProps = {
label: string;
value: string;
value: ReactNode;
isValueInPrimaryColor?: boolean;
tooltipText?: string;
tooltipId?: string;
@@ -7,11 +7,13 @@ import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { AI_ADMIN_PATH } from '@/settings/admin-panel/ai/constants/AiAdminPath';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { SettingsAdminWorkspaceBillingContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceBillingContent';
import { SettingsAdminWorkspaceContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceContent';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { GET_ADMIN_WORKSPACE_CHAT_THREADS } from '@/settings/admin-panel/graphql/queries/getAdminWorkspaceChatThreads';
import { WORKSPACE_LOOKUP_ADMIN_PANEL } from '@/settings/admin-panel/graphql/queries/workspaceLookupAdminPanel';
import { useFeatureFlagState } from '@/settings/admin-panel/hooks/useFeatureFlagState';
@@ -31,6 +33,7 @@ import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/use
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
H2Title,
IconCreditCard,
IconEyeShare,
IconFlag,
IconMessage,
@@ -51,6 +54,7 @@ const WORKSPACE_DETAIL_TABS_ID = 'settings-admin-workspace-detail-tabs';
const WORKSPACE_DETAIL_TAB_IDS = {
INFO: 'info',
BILLING: 'billing',
MEMBERS: 'members',
FEATURE_FLAGS: 'feature-flags',
CHATS: 'chats',
@@ -67,6 +71,8 @@ export const SettingsAdminWorkspaceDetail = () => {
const currentUser = useAtomStateValue(currentUserState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const canManageFeatureFlags = useAtomStateValue(canManageFeatureFlagsState);
const { enqueueErrorSnackBar } = useSnackBar();
const { updateFeatureFlagState } = useFeatureFlagState();
@@ -135,6 +141,15 @@ export const SettingsAdminWorkspaceDetail = () => {
title: t`Info`,
Icon: IconSettings2,
},
...(isBillingEnabled
? [
{
id: WORKSPACE_DETAIL_TAB_IDS.BILLING,
title: t`Billing`,
Icon: IconCreditCard,
},
]
: []),
...(currentUser?.canImpersonate
? [
{
@@ -197,6 +212,12 @@ export const SettingsAdminWorkspaceDetail = () => {
<SettingsAdminWorkspaceContent activeWorkspace={workspace} />
)}
{effectiveTabId === WORKSPACE_DETAIL_TAB_IDS.BILLING &&
isBillingEnabled &&
workspaceId && (
<SettingsAdminWorkspaceBillingContent workspaceId={workspaceId} />
)}
{effectiveTabId === WORKSPACE_DETAIL_TAB_IDS.MEMBERS && workspace && (
<Section>
<H2Title title={t`Members`} description={t`Workspace members`} />
@@ -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,
}),
);