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:
@@ -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>;
|
||||
|
||||
+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>
|
||||
);
|
||||
};
|
||||
+1
@@ -7,6 +7,7 @@ export const ADMIN_PANEL_TOP_WORKSPACES = gql`
|
||||
name
|
||||
totalUsers
|
||||
subdomain
|
||||
logo
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+32
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+22
-1
@@ -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(
|
||||
|
||||
+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