Billing - fixes (#19867)

- Uniformize credit formating : In UI, 1$=1credit. In BE 1 UI credit =
1_000_000 BE "crédits"
- Add crédit rollover information + Link to documentation +
Documentation update
<img width="291" height="317" alt="Screenshot 2026-04-17 at 18 22 59"
src="https://github.com/user-attachments/assets/2519fb9f-159d-4c85-95f4-a6e005a8a1a3"
/>
<img width="848" height="763" alt="Screenshot 2026-04-17 at 14 12 20"
src="https://github.com/user-attachments/assets/a3cc0874-f275-49ea-819f-305ec314bdfe"
/>
<img width="797" height="757" alt="Screenshot 2026-04-17 at 14 12 13"
src="https://github.com/user-attachments/assets/9048409b-d5a2-435a-b735-70370705e668"
/>

- Enable direct top-up (or subscription if in trial) from AI chat
<img width="333" height="215" alt="Screenshot 2026-04-17 at 22 52 00"
src="https://github.com/user-attachments/assets/7a20c627-2806-4bcf-a037-b45752232be9"
/>
<img width="457" height="769" alt="Screenshot 2026-04-17 at 22 51 41"
src="https://github.com/user-attachments/assets/d2a90c1b-271f-4fe9-8891-baeb2fabb86d"
/>

- Inform users if credit limit is reached - Banner
<img width="1130" height="127" alt="Screenshot 2026-04-17 at 19 15 11"
src="https://github.com/user-attachments/assets/30723e5e-c07e-462f-8eb8-e08f52bbab1c"
/>
This commit is contained in:
Etienne
2026-04-20 10:43:02 +02:00
committed by GitHub
parent 903ae5cb09
commit e68842c268
45 changed files with 521 additions and 151 deletions
+19 -15
View File
@@ -309,7 +309,7 @@
"group": "Capabilities",
"pages": [
"user-guide/billing/capabilities/pricing-plans",
"user-guide/billing/capabilities/workflow-credits"
"user-guide/billing/capabilities/credits"
]
},
{
@@ -756,7 +756,7 @@
"group": "Capacités",
"pages": [
"l/fr/user-guide/billing/capabilities/pricing-plans",
"l/fr/user-guide/billing/capabilities/workflow-credits"
"l/fr/user-guide/billing/capabilities/credits"
]
},
{
@@ -1203,7 +1203,7 @@
"group": "القدرات",
"pages": [
"l/ar/user-guide/billing/capabilities/pricing-plans",
"l/ar/user-guide/billing/capabilities/workflow-credits"
"l/ar/user-guide/billing/capabilities/credits"
]
},
{
@@ -1650,7 +1650,7 @@
"group": "Možnosti",
"pages": [
"l/cs/user-guide/billing/capabilities/pricing-plans",
"l/cs/user-guide/billing/capabilities/workflow-credits"
"l/cs/user-guide/billing/capabilities/credits"
]
},
{
@@ -2097,7 +2097,7 @@
"group": "Funktionen",
"pages": [
"l/de/user-guide/billing/capabilities/pricing-plans",
"l/de/user-guide/billing/capabilities/workflow-credits"
"l/de/user-guide/billing/capabilities/credits"
]
},
{
@@ -2544,7 +2544,7 @@
"group": "Capacidades",
"pages": [
"l/es/user-guide/billing/capabilities/pricing-plans",
"l/es/user-guide/billing/capabilities/workflow-credits"
"l/es/user-guide/billing/capabilities/credits"
]
},
{
@@ -2991,7 +2991,7 @@
"group": "Funzionalità",
"pages": [
"l/it/user-guide/billing/capabilities/pricing-plans",
"l/it/user-guide/billing/capabilities/workflow-credits"
"l/it/user-guide/billing/capabilities/credits"
]
},
{
@@ -3438,7 +3438,7 @@
"group": "機能",
"pages": [
"l/ja/user-guide/billing/capabilities/pricing-plans",
"l/ja/user-guide/billing/capabilities/workflow-credits"
"l/ja/user-guide/billing/capabilities/credits"
]
},
{
@@ -3885,7 +3885,7 @@
"group": "기능",
"pages": [
"l/ko/user-guide/billing/capabilities/pricing-plans",
"l/ko/user-guide/billing/capabilities/workflow-credits"
"l/ko/user-guide/billing/capabilities/credits"
]
},
{
@@ -4332,7 +4332,7 @@
"group": "Capabilities",
"pages": [
"l/pt/user-guide/billing/capabilities/pricing-plans",
"l/pt/user-guide/billing/capabilities/workflow-credits"
"l/pt/user-guide/billing/capabilities/credits"
]
},
{
@@ -4779,7 +4779,7 @@
"group": "Capabilities",
"pages": [
"l/ro/user-guide/billing/capabilities/pricing-plans",
"l/ro/user-guide/billing/capabilities/workflow-credits"
"l/ro/user-guide/billing/capabilities/credits"
]
},
{
@@ -5226,7 +5226,7 @@
"group": "Возможности",
"pages": [
"l/ru/user-guide/billing/capabilities/pricing-plans",
"l/ru/user-guide/billing/capabilities/workflow-credits"
"l/ru/user-guide/billing/capabilities/credits"
]
},
{
@@ -5673,7 +5673,7 @@
"group": "Yetkinlikler",
"pages": [
"l/tr/user-guide/billing/capabilities/pricing-plans",
"l/tr/user-guide/billing/capabilities/workflow-credits"
"l/tr/user-guide/billing/capabilities/credits"
]
},
{
@@ -6120,7 +6120,7 @@
"group": "功能",
"pages": [
"l/zh/user-guide/billing/capabilities/pricing-plans",
"l/zh/user-guide/billing/capabilities/workflow-credits"
"l/zh/user-guide/billing/capabilities/credits"
]
},
{
@@ -6734,7 +6734,11 @@
},
{
"source": "/user-guide/billing/workflow-credits",
"destination": "/user-guide/billing/capabilities/workflow-credits"
"destination": "/user-guide/billing/capabilities/credits"
},
{
"source": "/user-guide/billing/capabilities/workflow-credits",
"destination": "/user-guide/billing/capabilities/credits"
},
{
"source": "/user-guide/billing/billing-faq",
@@ -46,8 +46,8 @@ description: Frequently asked questions about Twenty pricing and billing.
<Accordion title="ワークフロークレジットはいくつありますか?">
The number of credits depends on your billing cycle, not your plan:
* **Monthly subscriptions**: 5 million credits per month
* **Yearly subscriptions**: 50 million credits per year
* **Monthly subscriptions**: 5 credits per month
* **Yearly subscriptions**: 50 credits per year
</Accordion>
<Accordion title="ワークフロークレジットの消費はどのように機能しますか?">
@@ -308,7 +308,7 @@
"label": "Capabilities",
"pages": [
"user-guide/billing/capabilities/pricing-plans",
"user-guide/billing/capabilities/workflow-credits"
"user-guide/billing/capabilities/credits"
]
},
{
@@ -307,7 +307,7 @@
"label": "Capabilities",
"pages": [
"user-guide/billing/capabilities/pricing-plans",
"user-guide/billing/capabilities/workflow-credits"
"user-guide/billing/capabilities/credits"
]
},
{
@@ -0,0 +1,55 @@
---
title: Credits
description: Understanding how credits power workflows, AI agents, and the AI chatbot — and how to manage your balance.
---
## Overview
Credits power workflow automations and AI capabilities. They are consumed when workflow actions execute, when AI agents process tasks inside workflows, and whithin the AI chat.
## Credit Allocation
Credits are based on your billing cycle, not your plan:
| Billing Cycle | Credits |
|---------------|---------|
| Monthly | 5/month |
| Yearly | 50/year |
<Note>
The 5 monthly credits are designed to empower you to run automations without worrying about costs. For most workflows using standard actions, this is more than enough. You'll only need additional credits when running advanced code nodes or AI-powered features.
</Note>
## Credit Rollover
Unused credits at the end of a billing period are automatically carried over to the next period.
- **Cap**: The rollover amount is capped at one period's full allocation, so you can never carry over more credits than your plan provides per period.
- **Visibility**: When rollover credits are available, they appear as a separate **Rollover Credits** line in **Settings → Billing**, alongside a **Total Available** balance.
## Credit Consumption
Different actions consume different amounts of credits:
| Action Type | Credit Usage |
|-------------|--------------|
| **Basic operations** (search, update, create records) | Minimal |
| **Complex operations** (code nodes, external API calls) | More credits |
| **AI prompt & AI chats** | Variable based on usage |
Credits are deducted in real-time when workflows execute.
## Monitoring Usage
Track your credit consumption:
1. Go to **Settings → Billing**
2. View your current usage and remaining credits
3. Monitor trends to plan for additional credits if needed
## Purchasing Additional Credits
Need more credits?
1. Go to **Settings → Billing**
2. Click on the option to purchase additional credit packs
3. Select the amount you need
@@ -1,48 +0,0 @@
---
title: Workflow Credits
description: Understanding workflow credits, consumption, and how to purchase more.
---
## Overview
Credits power your workflow automations in Twenty. Every workflow action consumes credits based on its complexity.
## Credit Allocation
Credits are based on your billing cycle, not your plan:
| Billing Cycle | Credits |
|---------------|---------|
| Monthly | 5 million/month |
| Yearly | 50 million/year |
<Note>
The 5 million monthly credits are designed to empower you to run automations without worrying about costs. For most workflows using standard actions, this is more than enough. You'll only need additional credits when running advanced code nodes or AI-powered features.
</Note>
## Credit Consumption
Different actions consume different amounts of credits:
| Action Type | Credit Usage |
|-------------|--------------|
| **Basic operations** (search, update, create records) | Minimal |
| **Complex operations** (code nodes, external API calls) | More credits |
| **AI prompts** (coming soon) | Variable based on usage |
Credits are deducted in real-time when workflows execute.
## Monitoring Usage
Track your credit consumption:
1. Go to **Settings → Billing**
2. View your current usage and remaining credits
3. Monitor trends to plan for additional credits if needed
## Purchasing Additional Credits
Need more credits?
1. Go to **Settings → Billing**
2. Click on the option to purchase additional credit packs
3. Select the amount you need
@@ -44,8 +44,8 @@ You will find this under `Settings → Billing`.
<Accordion title="How many workflow credits do we have?">
The number of credits depends on your billing cycle, not your plan:
- **Monthly subscriptions**: 5 million credits per month
- **Yearly subscriptions**: 50 million credits per year
- **Monthly subscriptions**: 5 credits per month
- **Yearly subscriptions**: 50 credits per year
</Accordion>
<Accordion title="How does workflow credit consumption work?">
@@ -11,11 +11,11 @@ Workflow credits are allocated based on your billing cycle, not your plan tier:
| Billing Cycle | Credits |
|---------------|---------|
| **Monthly subscription** | 5 million credits per month |
| **Yearly subscription** | 50 million credits per year |
| **Monthly subscription** | 5 credits per month |
| **Yearly subscription** | 50 credits per year |
<Note>
5 million monthly credits are generous for standard automations. Most teams won't exceed this limit with typical workflow usage. Additional credits are primarily needed for advanced Code actions and AI-powered workflows.
5 monthly credits are generous for standard automations. Most teams won't exceed this limit with typical workflow usage. Additional credits are primarily needed for advanced Code actions and AI-powered workflows.
</Note>
## How Credit Consumption Works
@@ -0,0 +1,149 @@
import { AiChatBanner } from '@/ai/components/AiChatBanner';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
import { useGetNextMeteredBillingPrice } from '@/settings/billing/hooks/useGetNextMeteredBillingPrice';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useMutation } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
PermissionFlagType,
SetMeteredSubscriptionPriceDocument,
SubscriptionInterval,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
const AI_CHAT_END_TRIAL_PERIOD_MODAL_ID = 'ai-chat-end-trial-period-modal';
const AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID =
'ai-chat-upgrade-credit-plan-modal';
export const AIChatNoMoreBillingCreditsBanner = () => {
const subscriptionStatus = useSubscriptionStatus();
const { openModal } = useModal();
const { endTrialPeriod, isLoading: isEndTrialLoading } =
useEndSubscriptionTrialPeriod();
const nextMeteredBillingPrice = useGetNextMeteredBillingPrice();
const { formatNumber } = useNumberFormat();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const [setMeteredSubscriptionPrice, { loading: isUpgrading }] = useMutation(
SetMeteredSubscriptionPriceDocument,
);
const { [PermissionFlagType.WORKSPACE]: hasPermissionToManageBilling } =
usePermissionFlagMap();
if (!hasPermissionToManageBilling) {
return null;
}
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const nextTierCredits = isDefined(nextMeteredBillingPrice)
? formatNumber(nextMeteredBillingPrice.tiers[0].upTo, {
abbreviate: true,
decimals: 2,
})
: null;
const nextTierPrice = isDefined(nextMeteredBillingPrice)
? formatNumber(nextMeteredBillingPrice.tiers[0].flatAmount / 100)
: null;
const nextTierInterval = isDefined(nextMeteredBillingPrice)
? nextMeteredBillingPrice.recurringInterval === SubscriptionInterval.Month
? t`month`
: t`year`
: null;
const message = isTrialing
? t`You've hit your usage limit. Subscribe for more usage.`
: isDefined(nextMeteredBillingPrice)
? t`You've hit your usage limit. \nUpgrade to ${nextTierCredits} credits for $${nextTierPrice}/${nextTierInterval}.`
: t`You've hit your usage limit. \nReach to our support team to upgrade.`;
const buttonTitle = isTrialing
? t`Subscribe Now`
: isDefined(nextMeteredBillingPrice)
? t`Upgrade`
: undefined;
const handleButtonClick = isTrialing
? () => openModal(AI_CHAT_END_TRIAL_PERIOD_MODAL_ID)
: isDefined(nextMeteredBillingPrice)
? () => openModal(AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID)
: undefined;
const handleUpgradeConfirm = async () => {
if (!isDefined(nextMeteredBillingPrice)) return;
try {
const { data } = await setMeteredSubscriptionPrice({
variables: { priceId: nextMeteredBillingPrice.stripePriceId },
});
if (
isDefined(
data?.setMeteredSubscriptionPrice.currentBillingSubscription,
) &&
isDefined(currentWorkspace)
) {
setCurrentWorkspace({
...currentWorkspace,
currentBillingSubscription:
data.setMeteredSubscriptionPrice.currentBillingSubscription,
billingSubscriptions:
data.setMeteredSubscriptionPrice.billingSubscriptions,
});
}
enqueueSuccessSnackBar({ message: t`Credit plan upgraded.` });
} catch {
enqueueErrorSnackBar({ message: t`Failed to upgrade credit plan.` });
}
};
return (
<>
<AiChatBanner
message={message}
variant="warning"
buttonTitle={buttonTitle}
buttonOnClick={handleButtonClick}
isButtonLoading={
(isTrialing && isEndTrialLoading) || (!isTrialing && isUpgrading)
}
/>
{isTrialing && (
<ConfirmationModal
modalInstanceId={AI_CHAT_END_TRIAL_PERIOD_MODAL_ID}
title={t`Start Your Subscription`}
subtitle={t`We will activate your paid plan. Do you want to proceed?`}
onConfirmClick={endTrialPeriod}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isEndTrialLoading}
/>
)}
{!isTrialing && (
<ConfirmationModal
modalInstanceId={AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID}
title={t`Get more credits`}
subtitle={t`Upgrade to ${nextTierCredits} credits for $${nextTierPrice}/${nextTierInterval}.`}
onConfirmClick={handleUpgradeConfirm}
confirmButtonText={t`Upgrade`}
confirmButtonAccent="blue"
loading={isUpgrading}
/>
)}
</>
);
};
@@ -51,6 +51,7 @@ const StyledMessage = styled.p<{ variant: AiChatBannerVariant }>`
line-height: 1.4;
margin: 0;
min-width: 0;
white-space: pre-line;
`;
export type AiChatBannerProps = {
@@ -3,6 +3,7 @@ import { EditorContent } from '@tiptap/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatEmptyState } from '@/ai/components/AiChatEmptyState';
import { AIChatNoMoreBillingCreditsBanner } from '@/ai/components/AIChatNoMoreBillingCreditsBanner';
import { AiChatStandaloneError } from '@/ai/components/AiChatStandaloneError';
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
@@ -10,13 +11,15 @@ import { AiChatContextUsageButton } from '@/ai/components/internal/AiChatContext
import { AiChatEditorFocusEffect } from '@/ai/components/internal/AiChatEditorFocusEffect';
import { AiChatSkeletonLoader } from '@/ai/components/internal/AiChatSkeletonLoader';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { useAiChatEditor } from '@/ai/hooks/useAiChatEditor';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
import { Select } from '@/ui/input/components/Select';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { hasReachedCurrentBillingPeriodCapSelector } from '@/workspace/states/hasReachedCurrentBillingPeriodCapSelector';
import { type SelectOption } from 'twenty-ui/input';
const StyledInputArea = styled.div<{ isMobile: boolean }>`
@@ -106,6 +109,9 @@ const StyledRightButtonsContainer = styled.div`
export const AiChatEditorSection = () => {
const isMobile = useIsMobile();
const hasReachedCurrentBillingPeriodCap = useAtomStateValue(
hasReachedCurrentBillingPeriodCapSelector,
);
const { options, pinnedOption } = useAiModelOptions({
variant: 'pinned-default',
});
@@ -134,6 +140,9 @@ export const AiChatEditorSection = () => {
<StyledInputArea isMobile={isMobile}>
<AgentChatContextPreview />
{hasReachedCurrentBillingPeriodCap && (
<AIChatNoMoreBillingCreditsBanner />
)}
<StyledInputBox>
<StyledEditorWrapper>
<EditorContent editor={editor} />
@@ -9,6 +9,7 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useLingui } from '@lingui/react/macro';
import { type DataMessagePart } from 'twenty-shared/ai';
import { type JsonValue } from 'type-fest';
import { useUsageValueFormatter } from '@/settings/usage/hooks/useUsageValueFormatter';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledContainer = styled.div`
@@ -157,6 +158,7 @@ type TimingTabProps = {
const TimingTab = ({ debug }: TimingTabProps) => {
const { t } = useLingui();
const { formatUsageValue } = useUsageValueFormatter();
const totalTime =
debug.agentExecutionStartTimeMs !== undefined
? `${debug.agentExecutionStartTimeMs + (debug.agentExecutionTimeMs || 0)}ms`
@@ -164,7 +166,7 @@ const TimingTab = ({ debug }: TimingTabProps) => {
const totalCost =
debug.totalCostInCredits !== undefined
? formatNumber(debug.totalCostInCredits)
? formatUsageValue(debug.totalCostInCredits)
: undefined;
return (
@@ -231,10 +233,7 @@ const TimingTab = ({ debug }: TimingTabProps) => {
: undefined
}
/>
<TimingRow
label={t`Total cost`}
value={totalCost !== undefined ? t`${totalCost} credits` : undefined}
/>
<TimingRow label={t`Total cost`} value={totalCost} />
</StyledTimingSection>
);
};
@@ -14,8 +14,9 @@ import {
type AgentChatLastMessageUsage,
} from '@/ai/states/agentChatUsageComponentFamilyState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
import { billingState } from '@/client-config/states/billingState';
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
import { useUsageValueFormatter } from '@/settings/usage/hooks/useUsageValueFormatter';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -103,12 +104,12 @@ export const AiChatContextUsageButton = () => {
);
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const { formatUsageValue } = useUsageValueFormatter();
// Values from the streaming API arrive as display credits (micro-credits / 1000).
// 1000 display credits = $1. Convert accordingly.
// Values from the streaming API arrive as display credits (micro-credits).
const formatChatCost = (displayCredits: number): string => {
if (isBillingEnabled) {
return `${formatNumber(displayCredits, { decimals: 1 })} credits`;
return `${formatUsageValue(displayCredits)}`;
}
const dollars = displayCredits / 1000;
@@ -11,10 +11,12 @@ import { InformationBannerMaintenance } from '@/information-banner/components/ma
import { InformationBannerReconnectAccountEmailAliases } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases';
import { InformationBannerReconnectAccountInsufficientPermissions } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useIsSomeMeteredProductCapReached } from '@/workspace/hooks/useIsSomeMeteredProductCapReached';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { hasReachedCurrentBillingPeriodCapSelector } from '@/workspace/states/hasReachedCurrentBillingPeriodCapSelector';
import { InformationBannerNoMoreCredits } from '@/information-banner/components/billing/InformationBannerNoMoreCredits';
import {
PermissionFlagType,
SubscriptionStatus,
@@ -36,7 +38,9 @@ export const InformationBannerWrapper = () => {
const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo(
WorkspaceActivationStatus.SUSPENDED,
);
const isSomeMeteredProductCapReached = useIsSomeMeteredProductCapReached();
const hasReachedCurrentBillingPeriodCap = useAtomStateValue(
hasReachedCurrentBillingPeriodCapSelector,
);
const displayBillingSubscriptionPausedBanner =
isWorkspaceSuspended && subscriptionStatus === SubscriptionStatus.Paused;
@@ -49,9 +53,15 @@ export const InformationBannerWrapper = () => {
subscriptionStatus === SubscriptionStatus.Unpaid;
const displayEndTrialPeriodBanner =
isSomeMeteredProductCapReached &&
hasReachedCurrentBillingPeriodCap &&
subscriptionStatus === SubscriptionStatus.Trialing;
const displayNoMoreCreditsBanner =
!isWorkspaceSuspended &&
!displayFailPaymentInfoBanner &&
!displayEndTrialPeriodBanner &&
hasReachedCurrentBillingPeriodCap;
return (
<StyledInformationBannerWrapper>
<InformationBannerMaintenance />
@@ -70,6 +80,7 @@ export const InformationBannerWrapper = () => {
)}
{displayFailPaymentInfoBanner && <InformationBannerFailPaymentInfo />}
{displayEndTrialPeriodBanner && <InformationBannerEndTrialPeriod />}
{displayNoMoreCreditsBanner && <InformationBannerNoMoreCredits />}
</StyledInformationBannerWrapper>
);
};
@@ -0,0 +1,30 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const InformationBannerNoMoreCredits = () => {
const { t } = useLingui();
const { [PermissionFlagType.WORKSPACE]: hasPermissionToUpdateCreditPlan } =
usePermissionFlagMap();
const navigateSettings = useNavigateSettings();
return (
<InformationBanner
componentInstanceId="information-banner-no-more-credits"
color="danger"
variant="secondary"
message={
hasPermissionToUpdateCreditPlan
? t`Credits limit reached. Update your credit plan to keep Workflows and AI running.`
: t`Credits limit reached. Contact your admin to resume Workflows and AI.`
}
buttonTitle={hasPermissionToUpdateCreditPlan ? t`Update plan` : undefined}
buttonOnClick={async () => navigateSettings(SettingsPath.Billing)}
/>
);
};
@@ -7,13 +7,20 @@ import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
import { useCurrentBillingFlags } from '@/settings/billing/hooks/useCurrentBillingFlags';
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { DOCUMENTATION_PATHS } from 'twenty-shared/constants';
import { SettingsPath } from 'twenty-shared/types';
import { formatToShortNumber, getSettingsPath } from 'twenty-shared/utils';
import { H2Title, HorizontalSeparator, IconChartBar } from 'twenty-ui/display';
import {
H2Title,
HorizontalSeparator,
IconChartBar,
IconExternalLink,
} from 'twenty-ui/display';
import { ProgressBar } from 'twenty-ui/feedback';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
@@ -22,6 +29,8 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { SubscriptionStatus } from '~/generated-metadata/graphql';
const StyledCreditUsageFooterActions = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-top: ${themeCssVariables.spacing[4]};
`;
@@ -49,9 +58,9 @@ export const SettingsBillingCreditsSection = ({
const {
usedCredits,
grantedCredits,
rolloverCredits,
totalGrantedCredits,
unitPriceCents,
rolloverCredits,
} = getWorkflowNodeExecutionUsage();
const progressBarValue = (usedCredits / totalGrantedCredits) * 100;
@@ -60,7 +69,7 @@ export const SettingsBillingCreditsSection = ({
const extraCreditsUsed = Math.max(0, usedCredits - totalGrantedCredits);
const costPer1kExtraCredits = (unitPriceCents / 100) * 1000;
const costPerExtraCredits = unitPriceCents / 100;
const costExtraCredits = (extraCreditsUsed * unitPriceCents) / 100;
@@ -78,7 +87,7 @@ export const SettingsBillingCreditsSection = ({
<SubscriptionInfoContainer>
<SettingsBillingLabelValueItem
label={t`Credits Used`}
value={`${formatNumber(usedCredits)}/${formatNumber(totalGrantedCredits, { abbreviate: true, decimals: 2 })}`}
value={`${formatNumber(usedCredits, { abbreviate: true, decimals: 2 })}/${formatNumber(totalGrantedCredits, { abbreviate: true, decimals: 2 })}`}
/>
<ProgressBar
value={progressBarValue}
@@ -106,6 +115,8 @@ export const SettingsBillingCreditsSection = ({
abbreviate: true,
decimals: 2,
})}
tooltipText={t`Unused credits from the previous period. Expired at the end of the period.`}
tooltipId="rollover-credits-info"
/>
)}
{rolloverCredits > 0 && (
@@ -123,8 +134,8 @@ export const SettingsBillingCreditsSection = ({
value={`${formatToShortNumber(extraCreditsUsed)}`}
/>
<SettingsBillingLabelValueItem
label={t`Cost per 1k Extra Credits`}
value={`$${formatNumber(costPer1kExtraCredits, { abbreviate: true, decimals: 6 })}`}
label={t`Cost per Extra Credits`}
value={`$${formatNumber(costPerExtraCredits, { abbreviate: true, decimals: 2 })}`}
/>
<SettingsBillingLabelValueItem
label={t`Cost`}
@@ -143,6 +154,19 @@ export const SettingsBillingCreditsSection = ({
variant="secondary"
/>
</UndecoratedLink>
<Button
Icon={IconExternalLink}
title={t`How credits work`}
variant="secondary"
onClick={() =>
window.open(
getDocumentationUrl({
path: DOCUMENTATION_PATHS.USER_GUIDE_BILLING_CAPABILITIES_CREDITS,
}),
'_blank',
)
}
/>
</StyledCreditUsageFooterActions>
</Section>
<Section>
@@ -1,10 +1,13 @@
import { styled } from '@linaria/react';
import { AppTooltip, IconInfoCircle, TooltipDelay } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsBillingLabelValueItemProps = {
label: string;
value: string;
isValueInPrimaryColor?: boolean;
tooltipText?: string;
tooltipId?: string;
};
const StyledContainer = styled.div`
@@ -12,12 +15,24 @@ const StyledContainer = styled.div`
justify-content: space-between;
`;
const StyledLabelWrapper = styled.span`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledLabelSpan = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
const StyledInfoIcon = styled(IconInfoCircle)`
color: ${themeCssVariables.font.color.tertiary};
cursor: default;
flex-shrink: 0;
`;
const StyledValueSpan = styled.span<{ isPrimaryColor: boolean }>`
color: ${({ isPrimaryColor }) =>
isPrimaryColor
@@ -31,10 +46,27 @@ export const SettingsBillingLabelValueItem = ({
label,
value,
isValueInPrimaryColor = false,
tooltipText,
tooltipId,
}: SettingsBillingLabelValueItemProps) => {
return (
<StyledContainer>
<StyledLabelSpan>{label}</StyledLabelSpan>
<StyledLabelWrapper>
<StyledLabelSpan>{label}</StyledLabelSpan>
{tooltipText && tooltipId && (
<>
<StyledInfoIcon id={tooltipId} size={12} />
<AppTooltip
anchorSelect={`#${tooltipId}`}
content={tooltipText}
noArrow={false}
place="top"
delay={TooltipDelay.shortDelay}
positionStrategy="fixed"
/>
</>
)}
</StyledLabelWrapper>
<StyledValueSpan isPrimaryColor={isValueInPrimaryColor}>
{value}
</StyledValueSpan>
@@ -0,0 +1,60 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { usePlans } from '@/settings/billing/hooks/usePlans';
import type { MeteredBillingPrice } from '@/settings/billing/types/billing-price-tiers.type';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
type BillingPlanKey,
BillingProductKey,
} from '~/generated-metadata/graphql';
export const useGetNextMeteredBillingPrice = (): MeteredBillingPrice | null => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { listPlans, isPlansLoaded } = usePlans();
const items =
currentWorkspace?.currentBillingSubscription?.billingSubscriptionItems;
const interval = currentWorkspace?.currentBillingSubscription?.interval;
const planKey = currentWorkspace?.currentBillingSubscription?.metadata?.[
'plan'
] as BillingPlanKey | undefined;
if (!items || !planKey || !isPlansLoaded) {
return null;
}
const plans = listPlans();
const currentPlan = plans.find((plan) => plan.planKey === planKey);
if (!currentPlan) {
return null;
}
const currentMeteredItem = items.find(
(item) =>
item.billingProduct.metadata?.['productKey'] ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!currentMeteredItem) {
return null;
}
const meteredPrices = currentPlan.meteredProducts.flatMap(
({ prices }) => prices,
) as Array<MeteredBillingPrice>;
const pricesForInterval = meteredPrices
.filter((price) => price.recurringInterval === interval)
.sort(
(priceA, priceB) =>
priceA.tiers[0].flatAmount - priceB.tiers[0].flatAmount,
);
const currentIndex = pricesForInterval.findIndex(
({ stripePriceId }) => stripePriceId === currentMeteredItem.stripePriceId,
);
if (currentIndex === -1 || currentIndex === pricesForInterval.length - 1) {
return null;
}
return pricesForInterval[currentIndex + 1];
};
@@ -100,8 +100,8 @@ export const EnterprisePlanModal = () => {
t`SSO (SAML / OIDC)`,
t`Row-level security`,
t`Audit logs`,
t`Custom objects`,
t`API & Webhooks`,
t`Advanced Encryption`,
t`Custom AI Models`,
];
const price = selectedInterval === 'monthly' ? MONTHLY_PRICE : YEARLY_PRICE;
@@ -4,6 +4,7 @@ import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/con
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData';
import { useUsageValueFormatter } from '@/settings/usage/hooks/useUsageValueFormatter';
import { Select } from '@/ui/input/components/Select';
import { styled } from '@linaria/react';
import { H2Title } from 'twenty-ui/display';
@@ -42,6 +43,8 @@ export const UsageDailyChartSection = ({
skip,
});
const { formatUsageValue } = useUsageValueFormatter();
if (isInitialLoading) {
return <UsageSectionSkeleton />;
}
@@ -96,6 +99,8 @@ export const UsageDailyChartSection = ({
colorMode="automaticPalette"
showLegend={false}
enableArea
displayType="custom"
customFormatter={formatUsageValue}
/>
</WidgetComponentInstanceContext.Provider>
</StyledLineChartContainer>
@@ -1,7 +1,6 @@
import { CHART_MOTION_CONFIG } from '@/page-layout/widgets/graph/constants/ChartMotionConfig';
import { GraphWidgetLegendDot } from '@/page-layout/widgets/graph/components/GraphWidgetLegendDot';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { t } from '@lingui/core/macro';
import { useUsageValueFormatter } from '@/settings/usage/hooks/useUsageValueFormatter';
import { styled } from '@linaria/react';
import { ResponsivePie } from '@nivo/pie';
import { useContext } from 'react';
@@ -53,7 +52,7 @@ const StyledTooltipValue = styled.span`
export const UsagePieChart = ({ data }: UsagePieChartProps) => {
const { theme } = useContext(ThemeContext);
const { formatNumber } = useNumberFormat();
const { formatUsageValue } = useUsageValueFormatter();
return (
<StyledContainer>
@@ -79,7 +78,7 @@ export const UsagePieChart = ({ data }: UsagePieChartProps) => {
<GraphWidgetLegendDot color={datum.color} />
<StyledTooltipLabel>{String(datum.id)}</StyledTooltipLabel>
<StyledTooltipValue>
{t`${formatNumber(datum.value)} credits`}
{formatUsageValue(datum.value)}
</StyledTooltipValue>
</StyledTooltipRow>
</StyledTooltip>
@@ -2,6 +2,13 @@ import { billingState } from '@/client-config/states/billingState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
// Returns how many decimal places are needed to represent a small value.
// Always two digits precision, no more than 7 decimal places.
const getDecimalsNeeded = (value: number): number => {
if (value === 0) return 2;
return Math.min(7, Math.max(0, Math.ceil(-Math.log10(value))) + 2);
};
export const useUsageValueFormatter = () => {
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
@@ -9,7 +16,9 @@ export const useUsageValueFormatter = () => {
const formatUsageValue = (value: number): string => {
if (isBillingEnabled) {
return `${formatNumber(value)} credits`;
const decimals = getDecimalsNeeded(value);
return `${formatNumber(value, { decimals })} credits`;
}
return `$${formatNumber(value, { decimals: 2 })}`;
@@ -1,15 +0,0 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useIsSomeMeteredProductCapReached = (): boolean => {
const billingSubscriptionItems = useAtomStateValue(currentWorkspaceState)
?.currentBillingSubscription?.billingSubscriptionItems;
if (!billingSubscriptionItems) {
return false;
}
return billingSubscriptionItems.some(
({ hasReachedCurrentPeriodCap }) => hasReachedCurrentPeriodCap,
);
};
@@ -0,0 +1,18 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const hasReachedCurrentBillingPeriodCapSelector = createAtomSelector({
key: 'hasReachedCurrentBillingPeriodCapSelector',
get: ({ get }) => {
const billingSubscriptionItems = get(currentWorkspaceState)
?.currentBillingSubscription?.billingSubscriptionItems;
if (!billingSubscriptionItems) {
return false;
}
return billingSubscriptionItems.some(
({ hasReachedCurrentPeriodCap }) => hasReachedCurrentPeriodCap,
);
},
});
@@ -99,7 +99,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
prices: [
{
tiers: [
{ upTo: 10000, flatAmount: 500, unitAmount: null },
{ upTo: 10, flatAmount: 500, unitAmount: null },
{ upTo: null, flatAmount: null, unitAmount: 0.001 },
],
recurringInterval: SubscriptionInterval.Month,
@@ -150,7 +150,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
const meteredPrices = result.meteredProducts[0].prices;
expect(meteredPrices![0].tiers[0]).toEqual(
expect.objectContaining({ upTo: 50000 }),
expect.objectContaining({ upTo: 50 }),
);
expect(meteredPrices![0].tiers[1]).toEqual(
expect.objectContaining({ upTo: null }),
@@ -26,11 +26,17 @@ import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-functi
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { cleanServerUrl } from 'src/utils/clean-server-url';
export class LogicFunctionExecutionException extends Error {
@@ -62,6 +68,7 @@ export class LogicFunctionExecutorService {
private readonly subscriptionService: SubscriptionService,
private readonly auditService: AuditService,
private readonly applicationLogsService: ApplicationLogsService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
) {}
async execute({
@@ -274,5 +281,20 @@ export class LogicFunctionExecutorService {
functionId: flatLogicFunction.id,
functionName: flatLogicFunction.name,
});
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
{
resourceType: UsageResourceType.LOGIC_FUNCTION,
operationType: UsageOperationType.CODE_EXECUTION,
creditsUsedMicro: 1,
quantity: 1,
unit: UsageUnit.INVOCATION,
resourceId: flatLogicFunction.id,
},
],
workspaceId,
);
}
}
@@ -20,7 +20,6 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import { ApplicationLogDriver } from 'src/engine/core-modules/application-logs/interfaces/application-log-driver.enum';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
import { WebSearchDriverType } from 'src/engine/core-modules/web-search/web-search.interface';
import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.enum';
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces';
@@ -43,6 +42,7 @@ import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
import { WebSearchDriverType } from 'src/engine/core-modules/web-search/web-search.interface';
import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type';
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
import { loadDefaultModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util';
@@ -783,7 +783,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description: 'Amount of credits for the free trial without credit card',
description:
'Amount of credits for the free trial without credit card (in microCredits)',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@@ -792,7 +793,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description: 'Amount of credits for the free trial with credit card',
description:
'Amount of credits for the free trial with credit card (in microCredits)',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@@ -6,4 +6,5 @@ export enum UsageResourceType {
APP = 'APP',
STORAGE = 'STORAGE',
API = 'API',
LOGIC_FUNCTION = 'LOGIC_FUNCTION',
}
@@ -33,7 +33,6 @@ type PeriodParams = {
periodStart: Date;
periodEnd: Date;
operationTypes?: string[];
useDollarMode?: boolean;
};
const ALLOWED_GROUP_BY_FIELDS = [
@@ -137,7 +136,6 @@ export class UsageAnalyticsService {
periodEnd,
groupByField,
operationTypes,
useDollarMode = false,
extraWhere = '',
extraParams,
}: PeriodParams & {
@@ -173,8 +171,6 @@ export class UsageAnalyticsService {
LIMIT ${BREAKDOWN_QUERY_LIMIT}
`;
const convert = useDollarMode ? toDollars : toDisplayCredits;
const rows = await this.clickHouseService.select<BreakdownRowMicro>(query, {
workspaceId,
periodStart: formatDateForClickHouse(periodStart),
@@ -187,7 +183,7 @@ export class UsageAnalyticsService {
return rows.map((row) => ({
key: row.key,
creditsUsed: convert(row.creditsUsedMicro),
creditsUsed: row.creditsUsedMicro,
}));
}
@@ -196,7 +192,6 @@ export class UsageAnalyticsService {
periodStart,
periodEnd,
operationTypes,
useDollarMode = false,
extraWhere = '',
extraParams,
}: PeriodParams & {
@@ -222,8 +217,6 @@ export class UsageAnalyticsService {
ORDER BY date ASC
`;
const convert = useDollarMode ? toDollars : toDisplayCredits;
const rows = await this.clickHouseService.select<TimeSeriesRowMicro>(
query,
{
@@ -239,7 +232,7 @@ export class UsageAnalyticsService {
return rows.map((row) => ({
date: row.date,
creditsUsed: convert(row.creditsUsedMicro),
creditsUsed: row.creditsUsedMicro,
}));
}
}
@@ -8,21 +8,21 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { In, type Repository } from 'typeorm';
import { UsageAnalyticsDTO } from 'src/engine/core-modules/usage/dtos/usage-analytics.dto';
import { UsageAnalyticsInput } from 'src/engine/core-modules/usage/dtos/inputs/usage-analytics.input';
import {
type UsageBreakdownItem,
UsageAnalyticsService,
} from 'src/engine/core-modules/usage/services/usage-analytics.service';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UsageAnalyticsInput } from 'src/engine/core-modules/usage/dtos/inputs/usage-analytics.input';
import { UsageAnalyticsDTO } from 'src/engine/core-modules/usage/dtos/usage-analytics.dto';
import {
UsageAnalyticsService,
type UsageBreakdownItem,
} from 'src/engine/core-modules/usage/services/usage-analytics.service';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
@MetadataResolver()
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@@ -30,7 +30,6 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato
export class UsageResolver {
constructor(
private readonly usageAnalyticsService: UsageAnalyticsService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
@@ -51,14 +50,12 @@ export class UsageResolver {
const periodStart = input?.periodStart ?? defaultPeriodStart;
const periodEnd = input?.periodEnd ?? defaultPeriodEnd;
const useDollarMode = !this.twentyConfigService.get('IS_BILLING_ENABLED');
const periodParams = {
workspaceId: workspace.id,
periodStart,
periodEnd,
operationTypes: input?.operationTypes ?? undefined,
useDollarMode,
};
const [usageByUser, usageByOperationType, usageByModel, timeSeries] =
@@ -78,10 +75,22 @@ export class UsageResolver {
);
const result: UsageAnalyticsDTO = {
usageByUser: resolvedUsageByUser,
usageByOperationType,
usageByModel,
timeSeries,
usageByUser: resolvedUsageByUser.map((item) => ({
...item,
creditsUsed: toDisplayCredits(item.creditsUsed),
})),
usageByOperationType: usageByOperationType.map((item) => ({
...item,
creditsUsed: toDisplayCredits(item.creditsUsed),
})),
usageByModel: usageByModel.map((item) => ({
...item,
creditsUsed: toDisplayCredits(item.creditsUsed),
})),
timeSeries: timeSeries.map((point) => ({
...point,
creditsUsed: toDisplayCredits(point.creditsUsed),
})),
periodStart,
periodEnd,
};
@@ -101,7 +110,10 @@ export class UsageResolver {
result.userDailyUsage = {
userWorkspaceId: input.userWorkspaceId,
dailyUsage,
dailyUsage: dailyUsage.map((point) => ({
...point,
creditsUsed: toDisplayCredits(point.creditsUsed),
})),
};
}
}
@@ -1,9 +1,6 @@
// Internal credits use micro-precision: $1 = 1,000,000 internal credits
// Display credits are 1000x coarser: $1 = 1,000 display credits
// This mirrors the "micro" pattern in payment systems (e.g. microdollars → dollars)
export const INTERNAL_CREDITS_PER_DISPLAY_CREDIT = 1000;
export const INTERNAL_CREDITS_PER_DISPLAY_CREDIT = 1_000_000;
// Converts internal (high-precision) credits to user-facing display credits.
// Rounds to 1 decimal place for clean display (e.g. 7500 → 7.5).
export const toDisplayCredits = (internalCredits: number): number =>
Math.round((internalCredits / INTERNAL_CREDITS_PER_DISPLAY_CREDIT) * 10) / 10;
internalCredits / INTERNAL_CREDITS_PER_DISPLAY_CREDIT;
@@ -94,10 +94,10 @@ export const DOCUMENTATION_PATHS = {
'/user-guide/ai/capabilities/permissions-access-control',
USER_GUIDE_AI_HOW_TOS_AI_FAQ: '/user-guide/ai/how-tos/ai-faq',
USER_GUIDE_AI_OVERVIEW: '/user-guide/ai/overview',
USER_GUIDE_BILLING_CAPABILITIES_CREDITS:
'/user-guide/billing/capabilities/credits',
USER_GUIDE_BILLING_CAPABILITIES_PRICING_PLANS:
'/user-guide/billing/capabilities/pricing-plans',
USER_GUIDE_BILLING_CAPABILITIES_WORKFLOW_CREDITS:
'/user-guide/billing/capabilities/workflow-credits',
USER_GUIDE_BILLING_HOW_TOS_BILLING_FAQ:
'/user-guide/billing/how-tos/billing-faq',
USER_GUIDE_BILLING_OVERVIEW: '/user-guide/billing/overview',