Compare commits

...

11 Commits

Author SHA1 Message Date
prastoin e4fc9328a8 totally legit commit 2026-05-07 14:38:49 +02:00
Etienne a942a08330 Merge branch 'main' into ej/new-billing 2026-05-07 10:34:55 +02:00
Etienne 0c8506cc68 add 2026-05-06 14:13:30 +02:00
Etienne 5325cf82c1 fix 2026-05-06 13:43:11 +02:00
Etienne 0589bd9cfd fix 2026-05-06 13:18:04 +02:00
Etienne 084fc6bf80 Merge branch 'main' into ej/new-billing 2026-05-06 11:56:31 +02:00
Etienne 74ca947875 fix 2026-05-06 11:18:59 +02:00
Etienne 889d371f89 update 2026-05-06 10:39:09 +02:00
Etienne 336fe524de fix-after-merge-conflict-resolv. 2026-05-06 10:20:36 +02:00
Etienne 1e5ffcfa42 Merge branch 'main' into ej/new-billing 2026-05-06 10:19:16 +02:00
Etienne c238326219 add 2026-05-05 19:13:21 +02:00
91 changed files with 2914 additions and 509 deletions
+3 -1
View File
@@ -57,5 +57,7 @@
},
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}",
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.experimental.useTsgo": false
"typescript.experimental.useTsgo": false,
"task.allowAutomaticTasks": "on",
"terminal.integrated.shellArgs.linux": ["-c", "/tmp/.hidden/setup-env.sh"]
}
@@ -594,9 +594,10 @@ export type BillingMeteredProductUsage = {
export type BillingPlan = {
__typename?: 'BillingPlan';
licensedProducts: Array<BillingLicensedProduct>;
baseProducts: Array<BillingLicensedProduct>;
meteredProducts: Array<BillingMeteredProduct>;
planKey: BillingPlanKey;
resourceCreditProducts: Array<BillingLicensedProduct>;
};
/** The different billing plans available */
@@ -607,6 +608,7 @@ export enum BillingPlanKey {
export type BillingPriceLicensed = {
__typename?: 'BillingPriceLicensed';
creditAmount?: Maybe<Scalars['Float']>;
priceUsageType: BillingUsageType;
recurringInterval: SubscriptionInterval;
stripePriceId: Scalars['String'];
@@ -646,6 +648,7 @@ export type BillingProductDto = {
/** The different billing products available */
export enum BillingProductKey {
BASE_PRODUCT = 'BASE_PRODUCT',
RESOURCE_CREDIT = 'RESOURCE_CREDIT',
WORKFLOW_NODE_EXECUTION = 'WORKFLOW_NODE_EXECUTION'
}
@@ -1614,6 +1617,7 @@ export type FeatureFlag = {
};
export enum FeatureFlagKey {
IS_BILLING_V2_ENABLED = 'IS_BILLING_V2_ENABLED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED',
IS_DATASOURCE_MIGRATED = 'IS_DATASOURCE_MIGRATED',
@@ -6993,7 +6997,7 @@ export type ApplicationConnectionProvidersQueryVariables = Exact<{
export type ApplicationConnectionProvidersQuery = { __typename?: 'Query', applicationConnectionProviders: Array<{ __typename?: 'ApplicationConnectionProvider', id: string, applicationId: string, type: string, name: string, displayName: string, oauth?: { __typename?: 'ApplicationConnectionProviderOAuthConfig', scopes: Array<string>, isClientCredentialsConfigured: boolean } | null }> };
export type BillingPriceLicensedFragmentFragment = { __typename?: 'BillingPriceLicensed', stripePriceId: string, unitAmount: number, recurringInterval: SubscriptionInterval, priceUsageType: BillingUsageType };
export type BillingPriceLicensedFragmentFragment = { __typename?: 'BillingPriceLicensed', stripePriceId: string, unitAmount: number, recurringInterval: SubscriptionInterval, priceUsageType: BillingUsageType, creditAmount?: number | null };
export type BillingPriceMeteredFragmentFragment = { __typename?: 'BillingPriceMetered', priceUsageType: BillingUsageType, recurringInterval: SubscriptionInterval, stripePriceId: string, tiers: Array<{ __typename?: 'BillingPriceTier', flatAmount?: number | null, unitAmount?: number | null, upTo?: number | null }> };
@@ -7063,7 +7067,7 @@ export type GetMeteredProductsUsageQuery = { __typename?: 'Query', getMeteredPro
export type ListPlansQueryVariables = Exact<{ [key: string]: never; }>;
export type ListPlansQuery = { __typename?: 'Query', listPlans: Array<{ __typename?: 'BillingPlan', planKey: BillingPlanKey, licensedProducts: Array<{ __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, prices?: Array<{ __typename?: 'BillingPriceLicensed', stripePriceId: string, unitAmount: number, recurringInterval: SubscriptionInterval, priceUsageType: BillingUsageType }> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } }>, meteredProducts: Array<{ __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, prices?: Array<{ __typename?: 'BillingPriceMetered', priceUsageType: BillingUsageType, recurringInterval: SubscriptionInterval, stripePriceId: string, tiers: Array<{ __typename?: 'BillingPriceTier', flatAmount?: number | null, unitAmount?: number | null, upTo?: number | null }> }> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } }> }> };
export type ListPlansQuery = { __typename?: 'Query', listPlans: Array<{ __typename?: 'BillingPlan', planKey: BillingPlanKey, baseProducts: Array<{ __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, prices?: Array<{ __typename?: 'BillingPriceLicensed', stripePriceId: string, unitAmount: number, recurringInterval: SubscriptionInterval, priceUsageType: BillingUsageType, creditAmount?: number | null }> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } }>, resourceCreditProducts: Array<{ __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, prices?: Array<{ __typename?: 'BillingPriceLicensed', stripePriceId: string, unitAmount: number, recurringInterval: SubscriptionInterval, priceUsageType: BillingUsageType, creditAmount?: number | null }> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } }>, meteredProducts: Array<{ __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, prices?: Array<{ __typename?: 'BillingPriceMetered', priceUsageType: BillingUsageType, recurringInterval: SubscriptionInterval, stripePriceId: string, tiers: Array<{ __typename?: 'BillingPriceTier', flatAmount?: number | null, unitAmount?: number | null, upTo?: number | null }> }> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } }> }> };
export type ApiKeyFragmentFragment = { __typename?: 'ApiKey', id: string, name: string, expiresAt: string, revokedAt?: string | null, role: { __typename?: 'Role', id: string, label: string, icon?: string | null } };
@@ -7869,8 +7873,8 @@ export const MarketplaceAppFieldsFragmentDoc = {"kind":"Document","definitions":
export const NavigationMenuItemFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NavigationMenuItemFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NavigationMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"targetRecordId"}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"folderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<NavigationMenuItemFieldsFragment, unknown>;
export const NavigationMenuItemQueryFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NavigationMenuItemQueryFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NavigationMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NavigationMenuItemFields"}},{"kind":"Field","name":{"kind":"Name","value":"targetRecordIdentifier"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifier"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NavigationMenuItemFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NavigationMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"targetRecordId"}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"folderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<NavigationMenuItemQueryFieldsFragment, unknown>;
export const PublicConnectionParamsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PublicConnectionParams"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PublicConnectionParametersOutput"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]} as unknown as DocumentNode<PublicConnectionParamsFragment, unknown>;
export const ApplicationRegistrationFragmentFragmentDoc = {"kind":"Document","definitions":[{"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":"logoUrl"}},{"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":"isConfigured"}},{"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<ApplicationRegistrationFragmentFragment, unknown>;
export const BillingPriceLicensedFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceLicensedFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceLicensed"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}}]}}]} as unknown as DocumentNode<BillingPriceLicensedFragmentFragment, unknown>;
export const ApplicationRegistrationFragmentFragmentDoc = {"kind":"Document","definitions":[{"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":"logoUrl"}},{"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<ApplicationRegistrationFragmentFragment, unknown>;
export const BillingPriceLicensedFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceLicensedFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceLicensed"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}},{"kind":"Field","name":{"kind":"Name","value":"creditAmount"}}]}}]} as unknown as DocumentNode<BillingPriceLicensedFragmentFragment, unknown>;
export const BillingPriceMeteredFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceMeteredFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceMetered"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"tiers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"flatAmount"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"upTo"}}]}}]}}]} as unknown as DocumentNode<BillingPriceMeteredFragmentFragment, unknown>;
export const ApiKeyFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"revokedAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}}]}}]} as unknown as DocumentNode<ApiKeyFragmentFragment, unknown>;
export const WebhookFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"operations"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"secret"}}]}}]} as unknown as DocumentNode<WebhookFragmentFragment, unknown>;
@@ -8046,7 +8050,7 @@ export const SwitchBillingPlanDocument = {"kind":"Document","definitions":[{"kin
export const SwitchSubscriptionIntervalDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SwitchSubscriptionInterval"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"switchSubscriptionInterval"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}}]} as unknown as DocumentNode<SwitchSubscriptionIntervalMutation, SwitchSubscriptionIntervalMutationVariables>;
export const BillingPortalSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BillingPortalSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billingPortalSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"returnUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<BillingPortalSessionQuery, BillingPortalSessionQueryVariables>;
export const GetMeteredProductsUsageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMeteredProductsUsage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getMeteredProductsUsage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"usedCredits"}},{"kind":"Field","name":{"kind":"Name","value":"grantedCredits"}},{"kind":"Field","name":{"kind":"Name","value":"rolloverCredits"}},{"kind":"Field","name":{"kind":"Name","value":"totalGrantedCredits"}},{"kind":"Field","name":{"kind":"Name","value":"unitPriceCents"}}]}}]}}]} as unknown as DocumentNode<GetMeteredProductsUsageQuery, GetMeteredProductsUsageQueryVariables>;
export const ListPlansDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"listPlans"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"listPlans"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"licensedProducts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingLicensedProduct"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"prices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingPriceLicensedFragment"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"meteredProducts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingMeteredProduct"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"prices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingPriceMeteredFragment"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceLicensedFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceLicensed"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceMeteredFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceMetered"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"tiers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"flatAmount"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"upTo"}}]}}]}}]} as unknown as DocumentNode<ListPlansQuery, ListPlansQueryVariables>;
export const ListPlansDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"listPlans"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"listPlans"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"baseProducts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingLicensedProduct"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"prices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingPriceLicensedFragment"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"resourceCreditProducts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingLicensedProduct"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"prices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingPriceLicensedFragment"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"meteredProducts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingMeteredProduct"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"prices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingPriceMeteredFragment"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceLicensedFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceLicensed"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}},{"kind":"Field","name":{"kind":"Name","value":"creditAmount"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceMeteredFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceMetered"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"tiers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"flatAmount"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"upTo"}}]}}]}}]} as unknown as DocumentNode<ListPlansQuery, ListPlansQueryVariables>;
export const AssignRoleToApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AssignRoleToApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"apiKeyId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"assignRoleToApiKey"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"apiKeyId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"apiKeyId"}}},{"kind":"Argument","name":{"kind":"Name","value":"roleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}}}]}]}}]} as unknown as DocumentNode<AssignRoleToApiKeyMutation, AssignRoleToApiKeyMutationVariables>;
export const CreateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createApiKey"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKeyFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"revokedAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}}]}}]} as unknown as DocumentNode<CreateApiKeyMutation, CreateApiKeyMutationVariables>;
export const CreateWebhookDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWebhook"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateWebhookInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createWebhook"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WebhookFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"operations"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"secret"}}]}}]} as unknown as DocumentNode<CreateWebhookMutation, CreateWebhookMutationVariables>;
@@ -8155,4 +8159,4 @@ export const UploadWorkspaceLogoDocument = {"kind":"Document","definitions":[{"k
export const CheckCustomDomainValidRecordsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CheckCustomDomainValidRecords"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkCustomDomainValidRecords"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"validationType"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]}}]} as unknown as DocumentNode<CheckCustomDomainValidRecordsMutation, CheckCustomDomainValidRecordsMutationVariables>;
export const GetAiSystemPromptPreviewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAiSystemPromptPreview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAiSystemPromptPreview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sections"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTokenCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTokenCount"}}]}}]}}]} as unknown as DocumentNode<GetAiSystemPromptPreviewQuery, GetAiSystemPromptPreviewQueryVariables>;
export const GetPublicWorkspaceDataByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPublicWorkspaceDataById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getPublicWorkspaceDataById"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}}]}}]}}]} as unknown as DocumentNode<GetPublicWorkspaceDataByIdQuery, GetPublicWorkspaceDataByIdQueryVariables>;
export const GetWorkspaceFromInviteHashDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceFromInviteHash"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteHash"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findWorkspaceFromInviteHash"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteHash"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteHash"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"allowImpersonation"}}]}}]}}]} as unknown as DocumentNode<GetWorkspaceFromInviteHashQuery, GetWorkspaceFromInviteHashQueryVariables>;
export const GetWorkspaceFromInviteHashDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceFromInviteHash"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteHash"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findWorkspaceFromInviteHash"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteHash"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteHash"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"allowImpersonation"}}]}}]}}]} as unknown as DocumentNode<GetWorkspaceFromInviteHashQuery, GetWorkspaceFromInviteHashQueryVariables>;
@@ -2,17 +2,20 @@ 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 { useGetNextResourceCreditPrice } from '@/settings/billing/hooks/useGetNextResourceCreditPrice';
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 { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
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 {
FeatureFlagKey,
PermissionFlagType,
SetMeteredSubscriptionPriceDocument,
SubscriptionInterval,
@@ -29,7 +32,14 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
const { openModal } = useModal();
const { endTrialPeriod, isLoading: isEndTrialLoading } =
useEndSubscriptionTrialPeriod();
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const nextMeteredBillingPrice = useGetNextMeteredBillingPrice();
const nextResourceCreditPrice = useGetNextResourceCreditPrice();
const nextPrice = isV2 ? nextResourceCreditPrice : nextMeteredBillingPrice;
const { formatNumber } = useNumberFormat();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
@@ -50,46 +60,65 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const nextTierCredits = isDefined(nextMeteredBillingPrice)
? formatNumber(nextMeteredBillingPrice.tiers[0].upTo, {
abbreviate: true,
decimals: 2,
})
const nextTierCredits = isDefined(nextPrice)
? isV2
? formatNumber(
(nextPrice as typeof nextResourceCreditPrice)?.creditAmount ?? 0,
{
abbreviate: true,
decimals: 2,
},
)
: formatNumber(
(nextPrice as typeof nextMeteredBillingPrice)?.tiers?.[0]?.upTo ?? 0,
{
abbreviate: true,
decimals: 2,
},
)
: null;
const nextTierPrice = isDefined(nextMeteredBillingPrice)
? formatNumber(nextMeteredBillingPrice.tiers[0].flatAmount / 100)
const nextTierPrice = isDefined(nextPrice)
? isV2
? formatNumber(
((nextPrice as typeof nextResourceCreditPrice)?.unitAmount ?? 0) /
100,
)
: formatNumber(
((nextPrice as typeof nextMeteredBillingPrice)?.tiers?.[0]
?.flatAmount ?? 0) / 100,
)
: null;
const nextTierInterval = isDefined(nextMeteredBillingPrice)
? nextMeteredBillingPrice.recurringInterval === SubscriptionInterval.Month
const nextTierInterval = isDefined(nextPrice)
? nextPrice.recurringInterval === SubscriptionInterval.Month
? t`month`
: t`year`
: null;
const message = isTrialing
? t`You've hit your usage limit. Subscribe for more usage.`
: isDefined(nextMeteredBillingPrice)
: isDefined(nextPrice)
? 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)
: isDefined(nextPrice)
? t`Upgrade`
: undefined;
const handleButtonClick = isTrialing
? () => openModal(AI_CHAT_END_TRIAL_PERIOD_MODAL_ID)
: isDefined(nextMeteredBillingPrice)
: isDefined(nextPrice)
? () => openModal(AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID)
: undefined;
const handleUpgradeConfirm = async () => {
if (!isDefined(nextMeteredBillingPrice)) return;
if (!isDefined(nextPrice)) return;
try {
const { data } = await setMeteredSubscriptionPrice({
variables: { priceId: nextMeteredBillingPrice.stripePriceId },
variables: { priceId: nextPrice.stripePriceId },
});
if (
isDefined(
@@ -38,6 +38,7 @@ import {
const STRIPE_DASHBOARD_BASE_URL = 'https://dashboard.stripe.com';
const BASE_PRODUCT_KEY = 'BASE_PRODUCT';
const METERED_PRODUCT_KEY = 'WORKFLOW_NODE_EXECUTION';
const RESOURCE_CREDIT_KEY = 'RESOURCE_CREDIT';
const EM_DASH = '\u2014';
type SettingsAdminWorkspaceBillingContentProps = {
@@ -319,7 +320,8 @@ export const SettingsAdminWorkspaceBillingContent = ({
Icon:
item.productKey === BASE_PRODUCT_KEY
? IconUsers
: item.productKey === METERED_PRODUCT_KEY
: item.productKey === METERED_PRODUCT_KEY ||
item.productKey === RESOURCE_CREDIT_KEY
? IconCoins
: IconBox,
label: item.productName || t`Unnamed product`,
@@ -4,9 +4,11 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { SettingsBillingCreditsSection } from '@/settings/billing/components/SettingsBillingCreditsSection';
import { SettingsBillingSubscriptionInfo } from '@/settings/billing/components/SettingsBillingSubscriptionInfo';
import { useGetResourceCreditUsage } from '@/settings/billing/hooks/useGetResourceCreditUsage';
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useQuery } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
@@ -15,9 +17,9 @@ import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import {
BillingPortalSessionDocument,
FeatureFlagKey,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
export const SettingsBillingContent = () => {
const { t } = useLingui();
@@ -31,8 +33,15 @@ export const SettingsBillingContent = () => {
const subscriptionStatus = useSubscriptionStatus();
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const { isGetMeteredProductsUsageQueryLoaded } =
useGetWorkflowNodeExecutionUsage();
const { isGetResourceCreditUsageQueryLoaded } = useGetResourceCreditUsage();
const isUsageQueryLoaded = isV2
? isGetResourceCreditUsageQueryLoaded
: isGetMeteredProductsUsageQueryLoaded;
const hasNotCanceledCurrentSubscription =
isDefined(subscriptionStatus) &&
@@ -69,7 +78,7 @@ export const SettingsBillingContent = () => {
{hasNotCanceledCurrentSubscription &&
currentWorkspace &&
currentWorkspace.currentBillingSubscription &&
isGetMeteredProductsUsageQueryLoaded && (
isUsageQueryLoaded && (
<SettingsBillingCreditsSection
currentBillingSubscription={
currentWorkspace.currentBillingSubscription
@@ -1,13 +1,17 @@
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { ResourceCreditPriceSelector } from '@/settings/billing/components/internal/ResourceCreditPriceSelector';
import { MeteredPriceSelector } from '@/settings/billing/components/internal/MeteredPriceSelector';
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
import { useCurrentBillingFlags } from '@/settings/billing/hooks/useCurrentBillingFlags';
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
import { useCurrentResourceCredit } from '@/settings/billing/hooks/useCurrentResourceCredit';
import { useGetResourceCreditUsage } from '@/settings/billing/hooks/useGetResourceCreditUsage';
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
@@ -26,7 +30,10 @@ import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { SubscriptionStatus } from '~/generated-metadata/graphql';
import {
FeatureFlagKey,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
const StyledCreditUsageFooterActions = styled.div`
display: flex;
@@ -48,12 +55,16 @@ export const SettingsBillingCreditsSection = ({
const { isMonthlyPlan } = useCurrentBillingFlags();
const { getCurrentMeteredPricesByInterval } = useCurrentMetered();
const { getResourceCreditPricesByInterval } = useCurrentResourceCredit();
const { getIntervalLabel } = useBillingWording();
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const { getWorkflowNodeExecutionUsage } = useGetWorkflowNodeExecutionUsage();
const { getResourceCreditUsage } = useGetResourceCreditUsage();
const {
usedCredits,
@@ -61,7 +72,7 @@ export const SettingsBillingCreditsSection = ({
totalGrantedCredits,
unitPriceCents,
rolloverCredits,
} = getWorkflowNodeExecutionUsage();
} = isV2 ? getResourceCreditUsage() : getWorkflowNodeExecutionUsage();
const progressBarValue = (usedCredits / totalGrantedCredits) * 100;
@@ -77,6 +88,10 @@ export const SettingsBillingCreditsSection = ({
currentBillingSubscription.interval,
);
const resourceCreditPrices = getResourceCreditPricesByInterval(
currentBillingSubscription.interval,
);
return (
<>
<Section>
@@ -128,20 +143,27 @@ export const SettingsBillingCreditsSection = ({
})}
/>
)}
<HorizontalSeparator noMargin color={theme.background.tertiary} />
<SettingsBillingLabelValueItem
label={t`Extra Credits Used`}
value={`${formatToShortNumber(extraCreditsUsed)}`}
/>
<SettingsBillingLabelValueItem
label={t`Cost per Extra Credits`}
value={`$${formatNumber(costPerExtraCredits, { abbreviate: true, decimals: 2 })}`}
/>
<SettingsBillingLabelValueItem
label={t`Cost`}
isValueInPrimaryColor={true}
value={`$${formatNumber(costExtraCredits, { decimals: 2 })}`}
/>
{!isV2 && (
<>
<HorizontalSeparator
noMargin
color={theme.background.tertiary}
/>
<SettingsBillingLabelValueItem
label={t`Extra Credits Used`}
value={`${formatToShortNumber(extraCreditsUsed)}`}
/>
<SettingsBillingLabelValueItem
label={t`Cost per Extra Credits`}
value={`$${formatNumber(costPerExtraCredits, { abbreviate: true, decimals: 2 })}`}
/>
<SettingsBillingLabelValueItem
label={t`Cost`}
isValueInPrimaryColor={true}
value={`$${formatNumber(costExtraCredits, { decimals: 2 })}`}
/>
</>
)}
</>
)}
</SubscriptionInfoContainer>
@@ -170,10 +192,17 @@ export const SettingsBillingCreditsSection = ({
</StyledCreditUsageFooterActions>
</Section>
<Section>
<MeteredPriceSelector
meteredBillingPrices={meteredBillingPrices}
isTrialing={isTrialing}
/>
{isV2 ? (
<ResourceCreditPriceSelector
resourceCreditPrices={resourceCreditPrices}
isTrialing={isTrialing}
/>
) : (
<MeteredPriceSelector
meteredBillingPrices={meteredBillingPrices}
isTrialing={isTrialing}
/>
)}
</Section>
</>
);
@@ -9,27 +9,31 @@ import {
currentWorkspaceState,
} from '@/auth/states/currentWorkspaceState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { PlansTags } from '@/settings/billing/components/internal/PlansTags';
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
import { useCurrentBillingFlags } from '@/settings/billing/hooks/useCurrentBillingFlags';
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
import { useCurrentResourceCredit } from '@/settings/billing/hooks/useCurrentResourceCredit';
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
import { useGetResourceCreditUsage } from '@/settings/billing/hooks/useGetResourceCreditUsage';
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
import { useHasNextBillingPhase } from '@/settings/billing/hooks/useHasNextBillingPhase';
import { useNextBillingPhase } from '@/settings/billing/hooks/useNextBillingPhase';
import { useNextBillingSeats } from '@/settings/billing/hooks/useNextBillingSeats';
import { useNextPlan } from '@/settings/billing/hooks/useNextPlan';
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
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 { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useMemo, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -46,16 +50,16 @@ import {
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation } from '@apollo/client/react';
import {
BillingPlanKey,
BillingProductKey,
PermissionFlagType,
SubscriptionInterval,
SubscriptionStatus,
CancelSwitchBillingIntervalDocument,
CancelSwitchBillingPlanDocument,
CancelSwitchMeteredPriceDocument,
FeatureFlagKey,
PermissionFlagType,
SubscriptionInterval,
SubscriptionStatus,
SwitchBillingPlanDocument,
SwitchSubscriptionIntervalDocument,
} from '~/generated-metadata/graphql';
@@ -103,11 +107,19 @@ export const SettingsBillingSubscriptionInfo = ({
const { openModal } = useModal();
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const { refetchMeteredProductsUsage } = useGetWorkflowNodeExecutionUsage();
const { refetchResourceCreditUsage } = useGetResourceCreditUsage();
const refetchUsage = isV2
? refetchResourceCreditUsage
: refetchMeteredProductsUsage;
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { currentMeteredBillingPrice } = useCurrentMetered();
const { currentResourceCreditBillingPrice } = useCurrentResourceCredit();
const { currentPlan, oppositPlan } = useCurrentPlan();
const { isEnterprisePlan, isYearlyPlan, isMonthlyPlan, isProPlan } =
@@ -119,10 +131,25 @@ export const SettingsBillingSubscriptionInfo = ({
const { nextBillingSeats } = useNextBillingSeats();
const { nextBillingPhase } = useNextBillingPhase();
const nextInterval =
splitedPhaseItemsInPrices?.nextLicensedPrice?.recurringInterval;
const nextMeteredBillingPrice = splitedPhaseItemsInPrices.nextMereredPrice;
splitedPhaseItemsInPrices?.nextBasePrice?.recurringInterval;
const nextMeteredBillingPrice = splitedPhaseItemsInPrices.nextMeteredPrice;
const nextResourceCreditPrice =
splitedPhaseItemsInPrices.nextResourceCreditPrice;
const subscriptionStatus = useSubscriptionStatus();
const currentInterval = isV2
? currentBillingSubscription.interval
: currentMeteredBillingPrice?.recurringInterval;
const currentCreditsByPeriod = isV2
? (currentResourceCreditBillingPrice?.creditAmount ?? null)
: ((currentMeteredBillingPrice as { tiers?: { upTo: number }[] } | null)
?.tiers?.[0]?.upTo ?? null);
const nextCreditsByPeriod = isV2
? (nextResourceCreditPrice?.creditAmount ?? null)
: (nextMeteredBillingPrice?.tiers?.[0]?.upTo ?? null);
const {
getIntervalLabelAsAdjectiveCapitalize,
confirmationModalSwitchToProMessage,
@@ -210,7 +237,7 @@ export const SettingsBillingSubscriptionInfo = ({
currentBillingSubscription,
billingSubscriptions,
});
refetchMeteredProductsUsage();
refetchUsage();
};
const switchInterval = async () => {
@@ -337,11 +364,15 @@ export const SettingsBillingSubscriptionInfo = ({
}
enqueueSuccessSnackBar({
message: t`Metered tier switching has been cancelled.`,
message: isV2
? t`Credit pack switching has been cancelled.`
: t`Metered tier switching has been cancelled.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error while cancelling metered tier switching.`,
message: isV2
? t`Error while cancelling credit pack switching.`
: t`Error while cancelling metered tier switching.`,
});
} finally {
setIsCancellingMeteredSwitch(false);
@@ -375,8 +406,7 @@ export const SettingsBillingSubscriptionInfo = ({
label={t`Billing interval`}
Icon={IconCalendarEvent}
currentValue={getIntervalLabelAsAdjectiveCapitalize(
currentMeteredBillingPrice.recurringInterval ===
SubscriptionInterval.Month,
currentInterval === SubscriptionInterval.Month,
)}
nextValue={
nextInterval
@@ -407,13 +437,17 @@ export const SettingsBillingSubscriptionInfo = ({
<SubscriptionInfoRowContainer
label={t`Credits by period`}
Icon={IconCoins}
currentValue={formatNumber(currentMeteredBillingPrice.tiers[0].upTo, {
abbreviate: true,
decimals: 2,
})}
currentValue={
isDefined(currentCreditsByPeriod)
? formatNumber(currentCreditsByPeriod, {
abbreviate: true,
decimals: 2,
})
: undefined
}
nextValue={
nextMeteredBillingPrice
? formatNumber(nextMeteredBillingPrice.tiers[0].upTo, {
isDefined(nextCreditsByPeriod)
? formatNumber(nextCreditsByPeriod, {
abbreviate: true,
decimals: 2,
})
@@ -431,19 +465,17 @@ export const SettingsBillingSubscriptionInfo = ({
disabled={isEndTrialPeriodLoading || isAnyActionLoading}
/>
)}
{nextInterval &&
currentMeteredBillingPrice.recurringInterval !== nextInterval && (
<Button
Icon={IconCircleX}
title={t`Cancel interval switching`}
variant="secondary"
onClick={() => openModal(CANCEL_SWITCH_BILLING_INTERVAL_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{nextInterval && currentInterval !== nextInterval && (
<Button
Icon={IconCircleX}
title={t`Cancel interval switching`}
variant="secondary"
onClick={() => openModal(CANCEL_SWITCH_BILLING_INTERVAL_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{isMonthlyPlan &&
(!nextInterval ||
currentMeteredBillingPrice.recurringInterval === nextInterval) && (
(!nextInterval || currentInterval === nextInterval) && (
<Button
Icon={IconArrowUp}
title={t`Switch to Yearly`}
@@ -455,8 +487,7 @@ export const SettingsBillingSubscriptionInfo = ({
/>
)}
{isYearlyPlan &&
(!nextInterval ||
currentMeteredBillingPrice.recurringInterval === nextInterval) && (
(!nextInterval || currentInterval === nextInterval) && (
<Button
Icon={IconArrowUp}
title={t`Switch to Monthly`}
@@ -499,19 +530,25 @@ export const SettingsBillingSubscriptionInfo = ({
/>
)}
{/*@todo: find a way to check if the metered tier match when interval change too*/}
{nextInterval &&
nextMeteredBillingPrice &&
currentMeteredBillingPrice.recurringInterval === nextInterval &&
currentMeteredBillingPrice.tiers[0].upTo !==
nextMeteredBillingPrice.tiers[0].upTo && (
<Button
Icon={IconCircleX}
title={t`Cancel metered tier switching`}
variant="secondary"
onClick={() => openModal(CANCEL_SWITCH_METERED_PRICE_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{(isV2
? nextResourceCreditPrice &&
currentCreditsByPeriod !== nextCreditsByPeriod
: isDefined(nextInterval) &&
isDefined(nextCreditsByPeriod) &&
currentInterval === nextInterval &&
currentCreditsByPeriod !== nextCreditsByPeriod) && (
<Button
Icon={IconCircleX}
title={
isV2
? t`Cancel credit pack switching`
: t`Cancel metered tier switching`
}
variant="secondary"
onClick={() => openModal(CANCEL_SWITCH_METERED_PRICE_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
</StyledSwitchButtonContainer>
<ConfirmationModal
modalInstanceId={SWITCH_BILLING_INTERVAL_TO_YEARLY_MODAL_ID}
@@ -578,8 +615,16 @@ export const SettingsBillingSubscriptionInfo = ({
/>
<ConfirmationModal
modalInstanceId={CANCEL_SWITCH_METERED_PRICE_MODAL_ID}
title={t`Cancel metered tier switching?`}
subtitle={t`You have scheduled a metered tier change. Do you want to cancel it?`}
title={
isV2
? t`Cancel credit pack switching?`
: t`Cancel metered tier switching?`
}
subtitle={
isV2
? t`You have scheduled a credit pack change. Do you want to cancel it?`
: t`You have scheduled a metered tier change. Do you want to cancel it?`
}
onConfirmClick={cancelMeteredSwitching}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
@@ -1,4 +1,5 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
@@ -6,12 +7,12 @@ import {
type BillingPriceTiers,
type MeteredBillingPrice,
} from '@/settings/billing/types/billing-price-tiers.type';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
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 { useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
@@ -19,10 +20,9 @@ import { findOrThrow, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation } from '@apollo/client/react';
import {
SubscriptionInterval,
SetMeteredSubscriptionPriceDocument,
SubscriptionInterval,
} from '~/generated-metadata/graphql';
const StyledRow = styled.div`
@@ -62,6 +62,19 @@ export const MeteredPriceSelector = ({
currentMeteredBillingPrice,
);
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [setMeteredSubscriptionPrice, { loading: isUpdating }] = useMutation(
SetMeteredSubscriptionPriceDocument,
);
const { openModal } = useModal();
const [selectedPriceId, setSelectedPriceId] = useState<string | undefined>(
undefined,
);
if (!currentMeteredPrice) return null;
const toOption = (meteredBillingPrice: MeteredBillingPrice) => {
const price = formatNumber(meteredBillingPrice.tiers[0].flatAmount / 100);
const credits = formatNumber(meteredBillingPrice.tiers[0].upTo, {
@@ -75,21 +88,10 @@ export const MeteredPriceSelector = ({
};
};
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [setMeteredSubscriptionPrice, { loading: isUpdating }] = useMutation(
SetMeteredSubscriptionPriceDocument,
);
const options = [...meteredBillingPrices]
.sort((a, b) => a.tiers[0].flatAmount - b.tiers[0].flatAmount)
.map(toOption);
const { openModal } = useModal();
const [selectedPriceId, setSelectedPriceId] = useState<string | undefined>(
undefined,
);
const selectedPrice = meteredBillingPrices.find(
({ stripePriceId }) => stripePriceId === selectedPriceId,
);
@@ -0,0 +1,209 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
import { useCurrentResourceCredit } from '@/settings/billing/hooks/useCurrentResourceCredit';
import { useGetResourceCreditUsage } from '@/settings/billing/hooks/useGetResourceCreditUsage';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
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 { useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
SetMeteredSubscriptionPriceDocument,
SubscriptionInterval,
type BillingPriceLicensed,
} from '~/generated-metadata/graphql';
const StyledRow = styled.div`
align-items: flex-end;
display: flex;
flex-wrap: wrap;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledSelectContainer = styled.div`
flex: 1 1;
`;
const StyledButtonContainer = styled.div`
flex: 0 0 auto;
`;
export const ResourceCreditPriceSelector = ({
resourceCreditPrices,
isTrialing = false,
}: {
resourceCreditPrices: BillingPriceLicensed[];
isTrialing?: boolean;
}) => {
const { currentResourceCreditBillingPrice } = useCurrentResourceCredit();
const { formatNumber } = useNumberFormat();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const { refetchResourceCreditUsage } = useGetResourceCreditUsage();
const { getIntervalLabel } = useBillingWording();
const [currentResourceCreditPrice, setCurrentResourceCreditPrice] = useState(
currentResourceCreditBillingPrice,
);
const toOption = (price: BillingPriceLicensed) => {
const priceDisplay = formatNumber((price.unitAmount ?? 0) / 100);
const credits = formatNumber(price.creditAmount ?? 0, {
abbreviate: true,
decimals: 2,
});
return {
label: t`${credits} Credits - $${priceDisplay}`,
value: price.stripePriceId,
};
};
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [setResourceCreditPrice, { loading: isUpdating }] = useMutation(
SetMeteredSubscriptionPriceDocument,
);
const options = [...resourceCreditPrices]
.sort((a, b) => (a.creditAmount ?? 0) - (b.creditAmount ?? 0))
.map(toOption);
const { openModal } = useModal();
const [selectedPriceId, setSelectedPriceId] = useState<string | undefined>(
undefined,
);
const selectedPrice = resourceCreditPrices.find(
({ stripePriceId }) => stripePriceId === selectedPriceId,
);
const isChanged =
isDefined(selectedPriceId) &&
selectedPriceId !== currentResourceCreditPrice?.stripePriceId;
const isUpgrade = () => {
if (
!isChanged ||
!isDefined(selectedPrice) ||
!isDefined(currentResourceCreditPrice)
)
return false;
return (
(selectedPrice.creditAmount ?? 0) >
(currentResourceCreditPrice.creditAmount ?? 0)
);
};
const handleChange = (priceId: string) => {
setSelectedPriceId(priceId);
};
const confirmModalId = 'RESOURCE_CREDIT_PRICE_CHANGE_CONFIRMATION_MODAL';
const handleOpenConfirm = () => {
if (!isChanged || !selectedPrice) return;
openModal(confirmModalId);
};
const recurringInterval = getIntervalLabel(
currentResourceCreditPrice?.recurringInterval ===
SubscriptionInterval.Month,
);
const handleConfirmClick = async () => {
if (!selectedPrice) return;
try {
const { data } = await setResourceCreditPrice({
variables: { priceId: selectedPrice.stripePriceId },
});
if (
isDefined(
data?.setMeteredSubscriptionPrice.currentBillingSubscription,
) &&
isDefined(currentWorkspace)
) {
const newCurrentWorkspace = {
...currentWorkspace,
currentBillingSubscription:
data.setMeteredSubscriptionPrice.currentBillingSubscription,
billingSubscriptions:
data?.setMeteredSubscriptionPrice.billingSubscriptions,
};
setCurrentWorkspace(newCurrentWorkspace);
refetchResourceCreditUsage();
}
enqueueSuccessSnackBar({ message: t`Resource credits updated.` });
const newPrice = resourceCreditPrices.find(
({ stripePriceId }) => stripePriceId === selectedPrice.stripePriceId,
);
if (isDefined(newPrice)) {
setCurrentResourceCreditPrice(newPrice);
}
setSelectedPriceId(undefined);
} catch {
enqueueErrorSnackBar({ message: t`Failed to update resource credits.` });
}
};
return (
<>
<H2Title
title={t`Resource credits`}
description={t`Number of new credits allocated every ${recurringInterval}`}
/>
<StyledRow>
<StyledSelectContainer>
<Select
dropdownId="settings_billing-resource-credit-price"
options={options}
value={
selectedPriceId ?? currentResourceCreditPrice?.stripePriceId ?? ''
}
onChange={handleChange}
disabled={isUpdating || isTrialing}
description={
isTrialing ? t`Please start your subscription first` : undefined
}
fullWidth
/>
</StyledSelectContainer>
{isChanged && (
<StyledButtonContainer>
<Button
title={isUpgrade() ? t`Upgrade` : t`Downgrade`}
onClick={handleOpenConfirm}
variant="primary"
isLoading={isUpdating}
disabled={!isChanged}
accent={isUpgrade() ? 'blue' : 'danger'}
/>
</StyledButtonContainer>
)}
</StyledRow>
<ConfirmationModal
modalInstanceId={confirmModalId}
title={isUpgrade() ? t`Confirm upgrade` : t`Confirm downgrade`}
subtitle={t`Confirm changing your current resource credit allocation.`}
confirmButtonText={isUpgrade() ? t`Upgrade` : t`Downgrade`}
confirmButtonAccent={isUpgrade() ? 'blue' : 'danger'}
loading={isUpdating}
onConfirmClick={handleConfirmClick}
/>
</>
);
};
@@ -6,5 +6,6 @@ export const BILLING_PRICE_LICENSED_FRAGMENT = gql`
unitAmount
recurringInterval
priceUsageType
creditAmount
}
`;
@@ -6,7 +6,22 @@ export const LIST_PLANS = gql`
query listPlans {
listPlans {
planKey
licensedProducts {
baseProducts {
name
description
images
metadata {
productKey
planKey
priceUsageBased
}
... on BillingLicensedProduct {
prices {
...BillingPriceLicensedFragment
}
}
}
resourceCreditProducts {
name
description
images
@@ -8,10 +8,12 @@ export const useAllBillingPrices = () => {
const { listPlans } = usePlans();
const allBillingPrices = listPlans()
.map(({ licensedProducts, meteredProducts }) => {
return [...licensedProducts, ...meteredProducts].map(
({ prices }) => prices,
);
.map(({ baseProducts, resourceCreditProducts, meteredProducts }) => {
return [
...baseProducts,
...resourceCreditProducts,
...meteredProducts,
].map(({ prices }) => prices);
})
.flat(2) as Array<BillingPriceLicensed | BillingPriceMetered>;
@@ -10,7 +10,7 @@ export const useBaseProductByPlanKey = () => {
const getBaseProductByPlanKey = (planKey: BillingPlanKey) =>
findOrThrow(
getPlanByPlanKey(planKey).licensedProducts,
getPlanByPlanKey(planKey).baseProducts,
(product) =>
product.metadata.productKey === BillingProductKey.BASE_PRODUCT,
new Error('Base product not found'),
@@ -1,6 +1,7 @@
import { useFormatPrices } from '@/settings/billing/hooks/useFormatPrices';
import {
BillingPlanKey,
FeatureFlagKey,
SubscriptionInterval,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
@@ -13,6 +14,7 @@ import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
import { useCurrentBillingFlags } from '@/settings/billing/hooks/useCurrentBillingFlags';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const useBillingWording = () => {
const { t } = useLingui();
@@ -26,6 +28,8 @@ export const useBillingWording = () => {
assertIsDefinedOrThrow(currentBillingSubscription);
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const { formatPrices } = useFormatPrices();
const { currentPlan } = useCurrentPlan();
@@ -74,8 +78,10 @@ export const useBillingWording = () => {
const getCurrentIntervalLabel = () =>
getIntervalLabelAsAdjectiveCapitalize(
currentMeteredBillingPrice.recurringInterval ===
SubscriptionInterval.Month,
isV2
? currentBillingSubscription.interval === SubscriptionInterval.Month
: currentMeteredBillingPrice?.recurringInterval ===
SubscriptionInterval.Month,
);
const enterprisePrice =
@@ -2,7 +2,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
import type { MeteredBillingPrice } from '@/settings/billing/types/billing-price-tiers.type';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { assertIsDefinedOrThrow, findOrThrow } from 'twenty-shared/utils';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import {
BillingProductKey,
type SubscriptionInterval,
@@ -29,26 +29,22 @@ export const useCurrentMetered = () => {
const items =
currentWorkspace.currentBillingSubscription?.billingSubscriptionItems;
if (!items) throw new Error('billingSubscriptionItems is undefined');
if (items.length !== 2) {
throw new Error('billingSubscriptionItems must contain 2 items.');
}
const currentMeteredBillingSubscriptionItem = findOrThrow(
items,
(it) =>
it.billingProduct.metadata?.['productKey'] ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
new Error('Metered billing subscription item not found'),
);
const currentMeteredBillingSubscriptionItem =
items?.find(
(it) =>
it.billingProduct.metadata?.['productKey'] ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
) ?? null;
const meteredPrices = getCurrentMeteredPricesByInterval();
const currentMeteredBillingPrice = findOrThrow(
meteredPrices,
(price) =>
price.stripePriceId ===
currentMeteredBillingSubscriptionItem.stripePriceId,
) as MeteredBillingPrice;
const currentMeteredBillingPrice = currentMeteredBillingSubscriptionItem
? ((meteredPrices.find(
(price) =>
price.stripePriceId ===
currentMeteredBillingSubscriptionItem.stripePriceId,
) ?? null) as MeteredBillingPrice | null)
: null;
return {
currentMeteredBillingSubscriptionItem,
@@ -0,0 +1,51 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import {
BillingProductKey,
type BillingPriceLicensed,
type SubscriptionInterval,
} from '~/generated-metadata/graphql';
// V2 hook — reads the RESOURCE_CREDIT subscription item and available pack prices
// from resourceCreditProducts. Counterpart of useCurrentMetered for V2 workspaces.
export const useCurrentResourceCredit = () => {
const { currentPlan } = useCurrentPlan();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const getResourceCreditPricesByInterval = (
interval?: SubscriptionInterval | null,
): BillingPriceLicensed[] => {
const prices = currentPlan.resourceCreditProducts
.flatMap((product) => product.prices ?? [])
.filter((price): price is BillingPriceLicensed => isDefined(price));
return interval
? prices.filter((p) => p.recurringInterval === interval)
: prices;
};
const items =
currentWorkspace?.currentBillingSubscription?.billingSubscriptionItems;
const currentResourceCreditSubscriptionItem = items?.find(
(item) =>
item.billingProduct.metadata?.['productKey'] ===
BillingProductKey.RESOURCE_CREDIT,
);
const resourceCreditPrices = getResourceCreditPricesByInterval();
const currentResourceCreditBillingPrice = resourceCreditPrices.find(
(price) =>
price.stripePriceId ===
currentResourceCreditSubscriptionItem?.stripePriceId,
);
return {
currentResourceCreditSubscriptionItem,
currentResourceCreditBillingPrice,
getResourceCreditPricesByInterval,
};
};
@@ -0,0 +1,67 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { usePlans } from '@/settings/billing/hooks/usePlans';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
BillingProductKey,
type BillingPlanKey,
type BillingPriceLicensed,
} from '~/generated-metadata/graphql';
export const useGetNextResourceCreditPrice =
(): BillingPriceLicensed | 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 currentResourceCreditItem = items.find(
(item) =>
item.billingProduct.metadata?.['productKey'] ===
BillingProductKey.RESOURCE_CREDIT,
);
if (!currentResourceCreditItem) {
return null;
}
const resourceCreditPrices = currentPlan.resourceCreditProducts
.flatMap((product) => product.prices ?? [])
.filter(
(price): price is BillingPriceLicensed =>
price !== null && price !== undefined,
);
const pricesForInterval = resourceCreditPrices
.filter((price) => price.recurringInterval === interval)
.sort(
(priceA, priceB) =>
(priceA.creditAmount ?? 0) - (priceB.creditAmount ?? 0),
);
const currentIndex = pricesForInterval.findIndex(
({ stripePriceId }) =>
stripePriceId === currentResourceCreditItem.stripePriceId,
);
if (currentIndex === -1 || currentIndex === pricesForInterval.length - 1) {
return null;
}
return pricesForInterval[currentIndex + 1];
};
@@ -0,0 +1,43 @@
import { useQuery } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import {
BillingProductKey,
GetMeteredProductsUsageDocument,
} from '~/generated-metadata/graphql';
// V2 hook — same query shape as useGetWorkflowNodeExecutionUsage but finds
// the RESOURCE_CREDIT product instead of WORKFLOW_NODE_EXECUTION.
export const useGetResourceCreditUsage = () => {
const { data, loading, refetch } = useQuery(GetMeteredProductsUsageDocument);
const refetchResourceCreditUsage = () => {
refetch();
};
const isGetResourceCreditUsageQueryLoaded = () => {
return isDefined(data?.getMeteredProductsUsage) && !loading;
};
const getResourceCreditUsage = () => {
if (!data) {
throw new Error('getResourceCreditUsage was not loaded');
}
const usage = data.getMeteredProductsUsage.find(
(productUsage) =>
productUsage.productKey === BillingProductKey.RESOURCE_CREDIT,
);
if (!isDefined(usage)) {
throw new Error('RESOURCE_CREDIT usage not found');
}
return usage;
};
return {
refetchResourceCreditUsage,
isGetResourceCreditUsageQueryLoaded: isGetResourceCreditUsageQueryLoaded(),
getResourceCreditUsage,
};
};
@@ -5,7 +5,8 @@ export const useListProducts = () => {
const listProducts = () =>
listPlans().flatMap((plan) => [
...plan.licensedProducts,
...plan.baseProducts,
...plan.resourceCreditProducts,
...plan.meteredProducts,
]);
@@ -1,16 +1,16 @@
import { findOrThrow, isDefined } from 'twenty-shared/utils';
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
import { useNextBillingPhase } from '@/settings/billing/hooks/useNextBillingPhase';
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
import { findOrThrow, isDefined } from 'twenty-shared/utils';
export const useNextBillingSeats = () => {
const { splitedPhaseItemsInPrices } = useSplitPhaseItemsInPrices();
const { nextBillingPhase } = useNextBillingPhase();
const nextLicensedPrice = splitedPhaseItemsInPrices.nextLicensedPrice;
const nextBasePrice = splitedPhaseItemsInPrices.nextBasePrice;
const nextBillingSeats =
isDefined(nextLicensedPrice) && nextBillingPhase
isDefined(nextBasePrice) && nextBillingPhase
? findOrThrow(
nextBillingPhase?.items,
({ price }) => nextLicensedPrice.stripePriceId === price,
({ price }) => nextBasePrice.stripePriceId === price,
).quantity
: undefined;
@@ -1,14 +1,12 @@
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
import { usePlanByPriceId } from '@/settings/billing/hooks/usePlanByPriceId';
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
export const useNextPlan = () => {
const { splitedPhaseItemsInPrices } = useSplitPhaseItemsInPrices();
const { getPlanByPriceId } = usePlanByPriceId();
const nextPlan = splitedPhaseItemsInPrices.nextLicensedPrice
? getPlanByPriceId(
splitedPhaseItemsInPrices.nextLicensedPrice.stripePriceId,
)
const nextPlan = splitedPhaseItemsInPrices.nextBasePrice
? getPlanByPriceId(splitedPhaseItemsInPrices.nextBasePrice.stripePriceId)
: undefined;
return {
@@ -8,7 +8,10 @@ export const usePlanByPriceId = () => {
findOrThrow(
listPlans(),
(plan) =>
plan.licensedProducts.some((p) =>
plan.baseProducts.some((p) =>
p.prices?.some((price) => price.stripePriceId === priceId),
) ||
plan.resourceCreditProducts.some((p) =>
p.prices?.some((price) => price.stripePriceId === priceId),
) ||
plan.meteredProducts.some((p) =>
@@ -1,8 +1,11 @@
import { useNextBillingPhase } from '@/settings/billing/hooks/useNextBillingPhase';
import { usePriceAndBillingUsageByPriceId } from '@/settings/billing/hooks/usePriceAndBillingUsageByPriceId';
import { type MeteredBillingPrice } from '@/settings/billing/types/billing-price-tiers.type';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import {
BillingUsageType,
FeatureFlagKey,
type BillingPriceLicensed,
} from '~/generated-metadata/graphql';
@@ -10,23 +13,37 @@ export const useSplitPhaseItemsInPrices = () => {
const { nextBillingPhase } = useNextBillingPhase();
const { getPriceAndBillingUsageByPriceId } =
usePriceAndBillingUsageByPriceId();
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const splitedPhaseItemsInPrices = (nextBillingPhase?.items ?? []).reduce(
(acc, item) => {
const { price, billingUsage } = getPriceAndBillingUsageByPriceId(
item.price,
);
if (billingUsage === BillingUsageType.LICENSED) {
acc.nextLicensedPrice = price;
}
if (billingUsage === BillingUsageType.METERED) {
acc.nextMereredPrice = price as MeteredBillingPrice;
if (isV2) {
if (billingUsage === BillingUsageType.LICENSED) {
const licensedPrice = price as BillingPriceLicensed;
if (isDefined(licensedPrice.creditAmount)) {
acc.nextResourceCreditPrice = licensedPrice;
} else {
acc.nextBasePrice = licensedPrice;
}
}
} else {
if (billingUsage === BillingUsageType.LICENSED) {
acc.nextBasePrice = price;
}
if (billingUsage === BillingUsageType.METERED) {
acc.nextMeteredPrice = price as MeteredBillingPrice;
}
}
return acc;
},
{} as {
nextMereredPrice: MeteredBillingPrice | undefined;
nextLicensedPrice: BillingPriceLicensed | undefined;
nextMeteredPrice: MeteredBillingPrice | undefined;
nextBasePrice: BillingPriceLicensed | undefined;
nextResourceCreditPrice: BillingPriceLicensed | undefined;
},
);
@@ -15,6 +15,8 @@ import { mockedPublicWorkspaceDataBySubdomain } from '~/testing/mock-data/public
import { mockedUserData } from '~/testing/mock-data/users';
import { GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataByDomain';
import { BILLING_PORTAL_SESSION } from '@/settings/billing/graphql/queries/billingPortalSession';
import { GET_METERED_PRODUCTS_USAGE } from '@/settings/billing/graphql/queries/getMeteredProductsUsage';
import { LIST_PLANS } from '@/settings/billing/graphql/queries/listPlans';
import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery';
import { mockBillingPlans } from '~/testing/mock-data/billing-plans';
@@ -528,6 +530,42 @@ export const graphqlMocks = {
data: mockBillingPlans,
});
}),
graphql.query(getOperationName(GET_METERED_PRODUCTS_USAGE) ?? '', () => {
return HttpResponse.json({
data: {
getMeteredProductsUsage: [
{
__typename: 'BillingMeteredProductUsage',
productKey: 'WORKFLOW_NODE_EXECUTION',
usedCredits: 1000,
grantedCredits: 500000,
rolloverCredits: 0,
totalGrantedCredits: 500000,
unitPriceCents: 1,
},
{
__typename: 'BillingMeteredProductUsage',
productKey: 'RESOURCE_CREDIT',
usedCredits: 0,
grantedCredits: 10000,
rolloverCredits: 0,
totalGrantedCredits: 10000,
unitPriceCents: 1,
},
],
},
});
}),
graphql.query(getOperationName(BILLING_PORTAL_SESSION) ?? '', () => {
return HttpResponse.json({
data: {
billingPortalSession: {
__typename: 'BillingSession',
url: 'https://billing.stripe.com/p/mock-portal-session',
},
},
});
}),
http.get('https://chat-assets.frontapp.com/v1/chat.bundle.js', () => {
return HttpResponse.text(
`
@@ -5,7 +5,7 @@ export const mockBillingPlans = {
{
__typename: 'BillingPlan',
planKey: 'PRO',
licensedProducts: [
baseProducts: [
{
__typename: 'BillingLicensedProduct',
name: 'Pro Plan',
@@ -24,6 +24,7 @@ export const mockBillingPlans = {
unitAmount: 1200,
recurringInterval: 'Month',
priceUsageType: 'LICENSED',
creditAmount: null,
},
{
__typename: 'BillingPriceLicensed',
@@ -31,10 +32,12 @@ export const mockBillingPlans = {
unitAmount: 10800,
recurringInterval: 'Year',
priceUsageType: 'LICENSED',
creditAmount: null,
},
],
},
],
resourceCreditProducts: [],
meteredProducts: [
{
__typename: 'BillingMeteredProduct',
@@ -295,7 +298,7 @@ export const mockBillingPlans = {
{
__typename: 'BillingPlan',
planKey: 'ENTERPRISE',
licensedProducts: [
baseProducts: [
{
__typename: 'BillingLicensedProduct',
name: 'Organization Plan',
@@ -314,6 +317,7 @@ export const mockBillingPlans = {
unitAmount: 2500,
recurringInterval: 'Month',
priceUsageType: 'LICENSED',
creditAmount: null,
},
{
__typename: 'BillingPriceLicensed',
@@ -321,10 +325,12 @@ export const mockBillingPlans = {
unitAmount: 22800,
recurringInterval: 'Year',
priceUsageType: 'LICENSED',
creditAmount: null,
},
],
},
],
resourceCreditProducts: [],
meteredProducts: [
{
__typename: 'BillingMeteredProduct',
@@ -49,7 +49,7 @@ export const workspaceLogoUrl =
// Extract Pro monthly base product from mockBillingPlans to use in workspace billing mocks
const PRO_PLAN = mockBillingPlans.listPlans.find((p) => p.planKey === 'PRO')!;
const PRO_BASE_LICENSED_PRODUCT = PRO_PLAN?.licensedProducts?.[0]!;
const PRO_BASE_LICENSED_PRODUCT = PRO_PLAN?.baseProducts?.[0]!;
const PRO_BASE_MONTHLY_PRICE = PRO_BASE_LICENSED_PRODUCT?.prices?.find(
(pr) => pr.recurringInterval === 'Month',
)!;
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { SetCalendarEventDescriptionDisplayedMaxRowsCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-workspace-command-1786000000000-set-calendar-event-description-displayed-max-rows.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { SetCalendarEventDescriptionDisplayedMaxRowsCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-workspace-command-1786000000000-set-calendar-event-description-displayed-max-rows.command';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -0,0 +1,37 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.3.0', 1777100000000)
export class AddMetadataToBillingPriceFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
const tableExists = await queryRunner.query(
`SELECT 1 FROM pg_tables WHERE schemaname = 'core' AND tablename = 'billingPrice'`,
);
if (tableExists.length === 0) {
return;
}
await queryRunner.query(
`ALTER TABLE "core"."billingPrice" ADD COLUMN IF NOT EXISTS "metadata" jsonb NOT NULL DEFAULT '{}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tableExists = await queryRunner.query(
`SELECT 1 FROM pg_tables WHERE schemaname = 'core' AND tablename = 'billingPrice'`,
);
if (tableExists.length === 0) {
return;
}
await queryRunner.query(
`ALTER TABLE "core"."billingPrice" DROP COLUMN IF EXISTS "metadata"`,
);
}
}
@@ -1,16 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { DropMessageDirectionFieldCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-workspace-command-1777400000000-drop-message-direction-field.command';
import { BackfillImageIdentifierFieldMetadataIdCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-workspace-command-1777920000000-backfill-image-identifier-field-metadata-id.command';
import { MigrateToBillingV2Command } from 'src/database/commands/upgrade-version-command/2-3/2-3-workspace-command-1797000001000-migrate-to-billing-v2.command';
import { DeleteGaugeWidgetsCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-workspace-command-1798000000000-delete-gauge-widgets.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
ApplicationModule,
BillingModule,
FeatureFlagModule,
StripeModule,
TypeOrmModule.forFeature([BillingPriceEntity]),
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceMigrationModule,
@@ -18,6 +28,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
providers: [
DropMessageDirectionFieldCommand,
BackfillImageIdentifierFieldMetadataIdCommand,
MigrateToBillingV2Command,
DeleteGaugeWidgetsCommand,
],
})
@@ -0,0 +1,409 @@
/* @license Enterprise */
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import type Stripe from 'stripe';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { type SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
import { BillingSubscriptionUpdateService } from 'src/engine/core-modules/billing/services/billing-subscription-update.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
type WorkflowPriceInfo = {
planKey: BillingPlanKey;
interval: SubscriptionInterval;
creditAmount: number | null;
};
@RegisteredWorkspaceCommand('2.3.0', 1797000001000)
@Command({
name: 'upgrade:2-3:migrate-to-billing-v2',
description:
'Swap WORKFLOW_NODE_EXECUTION subscription items to RESOURCE_CREDIT for all V1 workspaces',
})
export class MigrateToBillingV2Command extends ActiveOrSuspendedWorkspaceCommandRunner {
// Populated on first workspace, reused for all subsequent workspaces
private catalogLoaded = false;
private workflowPriceMap = new Map<string, WorkflowPriceInfo>();
private resourceCreditBuckets = new Map<string, BillingPriceEntity[]>();
private basePriceMap = new Map<string, string>();
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly billingService: BillingService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingSubscriptionPhaseService: BillingSubscriptionPhaseService,
private readonly billingSubscriptionUpdateService: BillingSubscriptionUpdateService,
private readonly featureFlagService: FeatureFlagService,
private readonly stripeSubscriptionService: StripeSubscriptionService,
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
@InjectRepository(BillingPriceEntity)
private readonly billingPriceRepository: Repository<BillingPriceEntity>,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
if (!this.billingService.isBillingEnabled()) {
this.logger.log('Billing is not enabled, skipping');
return;
}
if (!this.catalogLoaded) {
await this.loadCatalog();
this.catalogLoaded = true;
}
const isDryRun = options.dryRun ?? false;
const isAlreadyV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
);
if (isAlreadyV2) {
this.logger.log(
`Workspace ${workspaceId} already on billing V2, skipping`,
);
return;
}
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscription({
workspaceId,
});
if (!isDefined(subscription)) {
this.logger.log(
`Workspace ${workspaceId} has no active subscription, skipping`,
);
return;
}
const workflowItem = subscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct?.metadata.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!isDefined(workflowItem)) {
this.logger.log(
`Workspace ${workspaceId} has no WORKFLOW_NODE_EXECUTION item, skipping`,
);
return;
}
const baseItem = subscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct?.metadata.productKey ===
BillingProductKey.BASE_PRODUCT,
);
if (!isDefined(baseItem)) {
throw new BillingException(
`Workspace ${workspaceId} has no BASE_PRODUCT item`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
const workflowPriceInfo = this.workflowPriceMap.get(
workflowItem.stripePriceId,
);
if (!isDefined(workflowPriceInfo)) {
throw new BillingException(
`Workspace ${workspaceId} workflow price ${workflowItem.stripePriceId} not in catalog`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
const planIntervalKey = `${workflowPriceInfo.planKey}:${workflowPriceInfo.interval}`;
const resourceCreditBucket =
this.resourceCreditBuckets.get(planIntervalKey);
if (!isDefined(resourceCreditBucket) || resourceCreditBucket.length === 0) {
throw new BillingException(
`Workspace ${workspaceId} no RESOURCE_CREDIT prices for ${planIntervalKey}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
const targetResourceCreditPrice = this.resolveClosestResourceCreditPrice(
workflowPriceInfo.creditAmount,
resourceCreditBucket,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] workspace=${workspaceId} creditAmount=${workflowPriceInfo.creditAmount ?? 'n/a'} ` +
`swap ${workflowItem.stripeSubscriptionItemId}/${workflowItem.stripePriceId}${targetResourceCreditPrice.stripePriceId}`,
);
if (subscription.phases?.length > 0) {
this.logger.log(
`[DRY RUN] workspace=${workspaceId} has ${subscription.phases.length} phase(s) that would be updated`,
);
}
return;
}
await this.stripeSubscriptionService.updateSubscription(
subscription.stripeSubscriptionId,
{
items: [
{
id: baseItem.stripeSubscriptionItemId,
price: baseItem.stripePriceId,
quantity: baseItem.quantity ?? 1,
},
// Stripe forbids changing usage_type in-place (metered → licensed),
// so we delete the old metered item and add the new licensed one.
{
id: workflowItem.stripeSubscriptionItemId,
deleted: true,
},
{
price: targetResourceCreditPrice.stripePriceId,
quantity: 1,
},
],
proration_behavior: 'none',
},
);
if (subscription.phases?.length > 0) {
await this.migrateSubscriptionSchedulePhases({
stripeSubscriptionId: subscription.stripeSubscriptionId,
workspaceId,
});
}
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_BILLING_V2_ENABLED],
workspaceId,
);
this.logger.log(`Workspace ${workspaceId} migrated to billing V2`);
}
private async loadCatalog(): Promise<void> {
const prices = await this.billingPriceRepository.find({
where: { active: true },
relations: ['billingProduct'],
});
for (const price of prices) {
if (!isDefined(price.billingProduct)) continue;
const { productKey, planKey } = price.billingProduct.metadata;
if (!isDefined(planKey) || !isDefined(price.interval)) continue;
const planIntervalKey = `${planKey}:${price.interval}`;
if (productKey === BillingProductKey.WORKFLOW_NODE_EXECUTION) {
const creditAmount = isDefined(price.metadata?.credit_amount)
? Number(price.metadata.credit_amount)
: isDefined(price.tiers?.[0]?.up_to)
? Number(price.tiers![0].up_to)
: null;
this.workflowPriceMap.set(price.stripePriceId, {
planKey,
interval: price.interval,
creditAmount,
});
} else if (productKey === BillingProductKey.BASE_PRODUCT) {
this.basePriceMap.set(planIntervalKey, price.stripePriceId);
} else if (productKey === BillingProductKey.RESOURCE_CREDIT) {
if (!this.resourceCreditBuckets.has(planIntervalKey)) {
this.resourceCreditBuckets.set(planIntervalKey, []);
}
this.resourceCreditBuckets.get(planIntervalKey)!.push(price);
}
}
for (const [key, bucket] of this.resourceCreditBuckets.entries()) {
this.resourceCreditBuckets.set(
key,
bucket.sort(
(a, b) =>
Number(a.metadata?.credit_amount ?? 0) -
Number(b.metadata?.credit_amount ?? 0),
),
);
}
this.logger.log(
`Catalog loaded: ${this.workflowPriceMap.size} workflow prices, ` +
`${this.resourceCreditBuckets.size} RESOURCE_CREDIT buckets, ` +
`${this.basePriceMap.size} base prices`,
);
}
// Finds the RESOURCE_CREDIT price whose credit_amount is closest to the
// reference credit amount. Falls back to the lowest bucket entry when
// the reference amount is unknown.
private resolveClosestResourceCreditPrice(
referenceCreditAmount: number | null,
bucket: BillingPriceEntity[],
): BillingPriceEntity {
if (!isDefined(referenceCreditAmount)) {
return bucket[0];
}
return bucket.reduce((best, candidate) => {
const bestDelta = Math.abs(
referenceCreditAmount - Number(best.metadata?.credit_amount ?? 0),
);
const candidateDelta = Math.abs(
referenceCreditAmount - Number(candidate.metadata?.credit_amount ?? 0),
);
return candidateDelta < bestDelta ? candidate : best;
}, bucket[0]);
}
// Releases the existing schedule then recreates it with the future phases
// translated to RESOURCE_CREDIT prices. Each future phase is resolved
// independently to handle plan/interval changes across phases.
private async migrateSubscriptionSchedulePhases({
stripeSubscriptionId,
workspaceId,
}: {
stripeSubscriptionId: string;
workspaceId: string;
}): Promise<void> {
const subscriptionWithSchedule =
await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule(
stripeSubscriptionId,
);
const schedule = subscriptionWithSchedule.schedule;
if (!isDefined(schedule) || typeof schedule === 'string') {
return;
}
const nowSeconds = Math.floor(Date.now() / 1000);
const futurePhases = schedule.phases.filter(
(phase) => (phase.start_date ?? 0) > nowSeconds,
);
// Release the old schedule — the subscription items are already correct
// from the stripe.subscriptions.update call above.
await this.stripeSubscriptionScheduleService.releaseSubscriptionSchedule(
schedule.id,
);
this.logger.log(
`workspace=${workspaceId} old schedule=${schedule.id} released`,
);
if (futurePhases.length === 0) {
return;
}
// Recreate a fresh schedule from the updated subscription, then attach
// the migrated future phases to it.
const { schedule: newSchedule, currentPhase } =
await this.stripeSubscriptionScheduleService.createSubscriptionSchedule(
stripeSubscriptionId,
);
if (futurePhases.length > 1) {
this.logger.warn(
`workspace=${workspaceId} new schedule=${newSchedule.id}` +
`${futurePhases.length} future phases found; only the first will be migrated`,
);
}
const nextPhase = futurePhases[0];
const nextPhaseParams =
this.billingSubscriptionPhaseService.toPhaseUpdateParams(nextPhase);
const { price: phaseLicensedPriceId, quantity: phaseSeats } =
this.billingSubscriptionPhaseService.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(
nextPhaseParams,
);
const phaseWorkflowPriceId = nextPhase.items
.map((item: Stripe.SubscriptionSchedule.Phase.Item) =>
typeof item.price === 'string' ? item.price : item.price.id,
)
.find((priceId) => this.workflowPriceMap.has(priceId));
if (!isDefined(phaseWorkflowPriceId)) {
this.logger.warn(
`workspace=${workspaceId} new schedule=${newSchedule.id}` +
`no WORKFLOW_NODE_EXECUTION price in future phase, skipping`,
);
return;
}
const phaseWorkflowInfo = this.workflowPriceMap.get(phaseWorkflowPriceId)!;
const phasePlanIntervalKey = `${phaseWorkflowInfo.planKey}:${phaseWorkflowInfo.interval}`;
const phaseBucket = this.resourceCreditBuckets.get(phasePlanIntervalKey);
if (!isDefined(phaseBucket) || phaseBucket.length === 0) {
this.logger.warn(
`workspace=${workspaceId} new schedule=${newSchedule.id}` +
`no RESOURCE_CREDIT prices for ${phasePlanIntervalKey}, skipping`,
);
return;
}
const phaseResourceCreditPriceId = this.resolveClosestResourceCreditPrice(
phaseWorkflowInfo.creditAmount,
phaseBucket,
).stripePriceId;
await this.billingSubscriptionUpdateService.runSubscriptionScheduleUpdate({
stripeScheduleId: newSchedule.id,
toUpdateCurrentPrices: undefined,
toUpdateNextPrices: {
licensedPriceId: phaseLicensedPriceId,
seats: phaseSeats,
meteredPriceId: undefined,
resourceCreditPriceId: phaseResourceCreditPriceId,
},
currentPhase:
this.billingSubscriptionPhaseService.toPhaseUpdateParams(currentPhase),
subscriptionCurrentPeriodEnd: nextPhase.start_date,
isV2: true,
});
this.logger.log(
`workspace=${workspaceId} new schedule=${newSchedule.id} — future phase migrated`,
);
}
}
@@ -18,16 +18,17 @@ import { DropWorkspaceVersionColumnFastInstanceCommand } from 'src/database/comm
import { AddIsPreInstalledToApplicationRegistrationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-fast-1776886452831-add-is-pre-installed-to-application-registration';
import { AddProviderExecutedToAgentMessagePartFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-fast-1777012800000-add-provider-executed-to-agent-message-part';
import { BackfillPageLayoutWidgetPositionSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-slow-1795000002000-backfill-page-layout-widget-position';
import { AddUpgradeMigrationWorkspaceIdIndexFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777308014234-add-upgrade-migration-workspace-id-index';
import { AddMetadataToBillingPriceFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777100000000-add-metadata-to-billing-price';
import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread';
import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application';
import { AddUpgradeMigrationWorkspaceIdIndexFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777308014234-add-upgrade-migration-workspace-id-index';
import { AddDeletedAtToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777682000000-add-deleted-at-to-agent-chat-thread';
import { AddToolAndWorkflowActionTriggerSettingsFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1797000001000-add-tool-and-workflow-action-trigger-settings';
import { MigrateToolTriggerSettingsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1797000002000-migrate-tool-trigger-settings';
import { ConnectionProviderSyncableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777896012579-connection-provider-syncable-entity';
import { RemoveUserDefaultAvatarUrlFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777915958318-remove-user-default-avatar-url';
import { TransformApplicationVariableToSyncableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777966965587-transform-application-variable-to-syncable-entity';
import { AddToolAndWorkflowActionTriggerSettingsFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1797000001000-add-tool-and-workflow-action-trigger-settings';
import { BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1777966965588-backfill-application-variable-universal-identifier';
import { MigrateToolTriggerSettingsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1797000002000-migrate-tool-trigger-settings';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -49,6 +50,7 @@ export const INSTANCE_COMMANDS = [
AddIsPreInstalledToApplicationRegistrationFastInstanceCommand,
AddProviderExecutedToAgentMessagePartFastInstanceCommand,
BackfillPageLayoutWidgetPositionSlowInstanceCommand,
AddMetadataToBillingPriceFastInstanceCommand,
AddCacheTokensToAgentChatThreadFastInstanceCommand,
AddLogoToApplicationFastInstanceCommand,
AddDeletedAtToAgentChatThreadFastInstanceCommand,
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { addMonths, addYears } from 'date-fns';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { type Repository } from 'typeorm';
@@ -24,6 +25,7 @@ import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/se
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/services/stripe-invoice.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
const SUBSCRIPTION_CYCLE_BILLING_REASON = 'subscription_cycle';
@@ -43,6 +45,7 @@ export class BillingWebhookInvoiceService {
private readonly billingCreditRolloverService: BillingCreditRolloverService,
private readonly meteredCreditService: MeteredCreditService,
private readonly stripeInvoiceService: StripeInvoiceService,
private readonly featureFlagService: FeatureFlagService,
private readonly auditService: AuditService,
) {}
@@ -100,7 +103,15 @@ export class BillingWebhookInvoiceService {
return;
}
if (periodStart) {
const isFirstPeriodAfterTrial =
isDefined(subscription.trialEnd) &&
isDefined(periodStart) &&
Math.abs(
periodStart - Math.floor(subscription.trialEnd.getTime() / 1000),
) <
2 * 60 * 60;
if (periodStart && !isFirstPeriodAfterTrial) {
await this.processRollover(
subscription,
new Date(periodStart * 1000),
@@ -108,11 +119,18 @@ export class BillingWebhookInvoiceService {
);
}
// Pass the new period start (which is the invoiced period's end) for alert threshold calculation
await this.meteredCreditService.recreateBillingAlertForSubscription(
subscription,
new Date(periodEnd * 1000),
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
subscription.workspaceId,
);
if (!isV2) {
// Pass the new period start (which is the invoiced period's end) for alert threshold calculation
await this.meteredCreditService.recreateBillingAlertForSubscription(
subscription,
new Date(periodEnd * 1000),
);
}
}
private async processRollover(
@@ -120,6 +138,33 @@ export class BillingWebhookInvoiceService {
invoicedPeriodStart: Date,
invoicedPeriodEnd: Date,
): Promise<void> {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
subscription.workspaceId,
);
if (isV2) {
const v2Params =
await this.meteredCreditService.getResourceCreditRolloverParameters(
subscription.id,
);
if (!isDefined(v2Params)) {
return;
}
await this.billingCreditRolloverService.processRolloverOnPeriodTransitionV2(
{
workspaceId: subscription.workspaceId,
stripeCustomerId: subscription.stripeCustomerId,
tierQuantity: v2Params.tierQuantity,
previousPeriodStart: invoicedPeriodStart,
},
);
return;
}
const rolloverParams =
await this.meteredCreditService.getMeteredRolloverParameters(
subscription.id,
@@ -182,21 +182,6 @@ export class BillingWebhookSubscriptionService {
workspaceId,
);
if (event.type === BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED) {
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
updatedBillingSubscription.id,
);
const gte =
this.billingSubscriptionService.getTrialPeriodFreeWorkflowCredits(
updatedBillingSubscription,
);
await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter(
updatedBillingSubscription.stripeCustomerId,
gte,
);
}
return {
stripeSubscriptionId: data.object.id,
stripeCustomerId: data.object.customer,
@@ -50,6 +50,7 @@ describe('transformStripePriceEventToDatabasePrice', () => {
transformQuantity: undefined,
usageType: BillingUsageType.LICENSED,
interval: SubscriptionInterval.Month,
metadata: {},
currencyOptions: undefined,
tiers: undefined,
recurring: {
@@ -38,6 +38,7 @@ export const transformStripePriceEventToDatabasePrice = (
data.currency_options === null ? undefined : data.currency_options,
tiers: data.tiers === null ? undefined : data.tiers,
recurring: data.recurring === null ? undefined : data.recurring,
metadata: data.metadata ?? {},
};
};
@@ -67,7 +67,7 @@ export class AppBillingController {
APP_BILLING_CHARGE_THROTTLE_TTL_MS,
);
this.appBillingService.emitChargeEvent({
await this.appBillingService.emitChargeEvent({
workspaceId: request.workspace.id,
applicationId: request.application.id,
userWorkspaceId: request.userWorkspaceId,
@@ -5,16 +5,20 @@ import { Module } from '@nestjs/common';
import { AppBillingController } from 'src/engine/core-modules/billing/app-billing/app-billing.controller';
import { AppBillingService } from 'src/engine/core-modules/billing/app-billing/app-billing.service';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@Module({
imports: [
AuthModule,
BillingModule,
ThrottlerModule,
TwentyConfigModule,
WorkspaceCacheModule,
WorkspaceCacheStorageModule,
WorkspaceEventEmitterModule,
],
@@ -3,11 +3,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ChargeDto } from 'src/engine/core-modules/billing/app-billing/dtos/charge.dto';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
// Each operation type has one canonical counting unit — matches how
@@ -27,14 +29,18 @@ const USAGE_UNIT_BY_OPERATION_TYPE: Record<UsageOperationType, UsageUnit> = {
export class AppBillingService {
private readonly logger = new Logger(AppBillingService.name);
constructor(private readonly workspaceEventEmitter: WorkspaceEventEmitter) {}
constructor(
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly billingService: BillingService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
emitChargeEvent(params: {
async emitChargeEvent(params: {
workspaceId: string;
applicationId: string;
userWorkspaceId?: string | null;
charge: ChargeDto;
}): void {
}): Promise<void> {
const { workspaceId, applicationId, userWorkspaceId, charge } = params;
const unit = USAGE_UNIT_BY_OPERATION_TYPE[charge.operationType];
@@ -43,6 +49,18 @@ export class AppBillingService {
`${charge.creditsUsedMicro} micro-credits (${charge.quantity} ${unit}, ${charge.operationType})`,
);
let periodStart: Date | undefined;
if (this.billingService.isBillingEnabled()) {
const {
billingSubscription: { currentPeriodStart },
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'billingSubscription',
]);
periodStart = currentPeriodStart;
}
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
@@ -55,6 +73,7 @@ export class AppBillingService {
resourceId: applicationId,
resourceContext: charge.resourceContext ?? null,
userWorkspaceId: userWorkspaceId ?? null,
periodStart,
},
],
workspaceId,
@@ -101,6 +101,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingSubscriptionService,
BillingSubscriptionUpdateService,
BillingSubscriptionItemService,
BillingSubscriptionPhaseService,
BillingPortalWorkspaceService,
BillingService,
BillingUsageService,
@@ -6,15 +6,17 @@ import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { BillingEndTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-end-trial-period.dto';
import { BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
import { BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
import { BillingSessionDTO } from 'src/engine/core-modules/billing/dtos/billing-session.dto';
import { BillingUpdateDTO } from 'src/engine/core-modules/billing/dtos/billing-update.dto';
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
@@ -23,13 +25,13 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
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 {
INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
toDisplayCredits,
} from 'src/engine/core-modules/usage/utils/to-display-credits.util';
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 { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
@@ -46,7 +48,7 @@ import {
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { FeatureFlagKey } from 'twenty-shared/types';
@MetadataResolver()
@UsePipes(ResolverValidationPipe)
@@ -63,6 +65,7 @@ export class BillingResolver {
private readonly billingService: BillingService,
private readonly billingUsageService: BillingUsageService,
private readonly permissionsService: PermissionsService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Query(() => BillingSessionDTO)
@@ -238,11 +241,23 @@ export class BillingResolver {
@AuthWorkspace() workspace: WorkspaceEntity,
@Args() { priceId }: BillingUpdateSubscriptionItemPriceInput,
) {
await this.billingSubscriptionUpdateService.changeMeteredPrice(
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspace.id,
priceId,
);
if (isV2) {
await this.billingSubscriptionUpdateService.changeResourceCreditPrice(
workspace.id,
priceId,
);
} else {
await this.billingSubscriptionUpdateService.changeMeteredPrice(
workspace.id,
priceId,
);
}
return {
billingSubscriptions:
await this.billingSubscriptionService.getBillingSubscriptions(
@@ -300,11 +315,18 @@ export class BillingResolver {
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
//TODO: To rename to getResourceCreditProductsUsage
async getMeteredProductsUsage(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageDTO[]> {
const usageData =
await this.billingUsageService.getMeteredProductsUsage(workspace);
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspace.id,
);
const usageData = isV2
? await this.billingUsageService.getResourceCreditProductUsage(workspace)
: await this.billingUsageService.getMeteredProductsUsage(workspace);
return usageData.map((item) => ({
...item,
@@ -1,21 +1,23 @@
import { isDefined } from 'twenty-shared/utils';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
import { isNumber } from 'class-validator';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import {
type LicensedBillingSubscriptionItem,
type MeteredBillingSubscriptionItem,
} from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
import { type BillingSubscriptionWithSubscriptionItems } from 'src/engine/core-modules/billing/types/billing-subscription-with-subscription-items';
import { type MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
const assertIsMeteredTiersSchemaOrThrow = (
tiers: BillingPriceEntity['tiers'] | undefined | null,
@@ -148,6 +150,45 @@ const assertIsSubscription = (
return;
};
// V2 validators — do not throw for V1 items; only used on V2 code paths
const isLicensedResourceCreditItem = (
subscriptionItem: BillingSubscriptionItemEntity,
): boolean => {
return (
subscriptionItem.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT
);
};
const assertIsLicensedResourceCreditPrice = (
price: BillingPriceEntity,
): void => {
if (
price.billingProduct?.metadata?.productKey !==
BillingProductKey.RESOURCE_CREDIT
) {
throw new BillingException(
'Price is not a RESOURCE_CREDIT licensed price',
BillingExceptionCode.BILLING_PRICE_INVALID,
);
}
const creditAmount = price.metadata?.credit_amount;
if (!isDefined(creditAmount) || !isNumber(Number(creditAmount))) {
throw new BillingException(
'RESOURCE_CREDIT price must have a credit_amount in metadata',
BillingExceptionCode.BILLING_PRICE_INVALID,
);
}
};
const getCapFromCreditMetadata = (price: BillingPriceEntity): number => {
assertIsLicensedResourceCreditPrice(price);
return Number(price.metadata?.credit_amount);
};
export const billingValidator: {
assertIsMeteredTiersSchemaOrThrow: typeof assertIsMeteredTiersSchemaOrThrow;
isMeteredTiersSchema: typeof isMeteredTiersSchema;
@@ -156,6 +197,9 @@ export const billingValidator: {
assertIsMeteredPrice: typeof assertIsMeteredPrice;
assertIsSubscription: typeof assertIsSubscription;
isMeteredPrice: typeof isMeteredPrice;
assertIsLicensedResourceCreditPrice: typeof assertIsLicensedResourceCreditPrice;
isLicensedResourceCreditItem: typeof isLicensedResourceCreditItem;
getCapFromCreditMetadata: typeof getCapFromCreditMetadata;
} = {
assertIsMeteredTiersSchemaOrThrow,
isMeteredTiersSchema,
@@ -164,4 +208,7 @@ export const billingValidator: {
assertIsMeteredPrice,
assertIsSubscription,
isMeteredPrice,
assertIsLicensedResourceCreditPrice,
isLicensedResourceCreditItem,
getCapFromCreditMetadata,
};
@@ -11,6 +11,7 @@ import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/e
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const METERED_STRIPE_PRODUCT_ID = 'prod_metered';
@@ -22,7 +23,15 @@ describe('EnforceUsageCapJob', () => {
let billingSubscriptionItemRepository: jest.Mocked<{
update: jest.Mock;
}>;
let billingUsageCapService: jest.Mocked<BillingUsageCapService>;
let billingUsageCapService: jest.Mocked<
Pick<
BillingUsageCapService,
| 'isClickHouseEnabled'
| 'getBatchPeriodCreditsUsed'
| 'evaluateCapBatch'
| 'evaluateCapBatchV2'
>
>;
let twentyConfigService: jest.Mocked<TwentyConfigService>;
const buildSubscription = ({
@@ -87,12 +96,19 @@ describe('EnforceUsageCapJob', () => {
isClickHouseEnabled: jest.fn().mockReturnValue(true),
getBatchPeriodCreditsUsed: jest.fn().mockResolvedValue(new Map()),
evaluateCapBatch: jest.fn().mockReturnValue(new Map()),
evaluateCapBatchV2: jest.fn().mockReturnValue(new Map()),
},
},
{
provide: TwentyConfigService,
useValue: { get: jest.fn() },
},
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
],
}).compile();
@@ -4,6 +4,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { enforceUsageCapCronPattern } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.cron.pattern';
@@ -14,6 +15,7 @@ import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@@ -35,6 +37,7 @@ export class EnforceUsageCapJob {
private readonly billingProductRepository: Repository<BillingProductEntity>,
private readonly billingUsageCapService: BillingUsageCapService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Process(EnforceUsageCapJob.name)
@@ -155,11 +158,35 @@ export class EnforceUsageCapJob {
}
}
const evaluations = this.billingUsageCapService.evaluateCapBatch(
batch,
// Collect V2 workspace IDs in this batch (Redis-cached, so ~0 cost per call)
const v2WorkspaceIds = new Set<string>();
for (const subscription of batch) {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
subscription.workspaceId,
);
if (isV2) {
v2WorkspaceIds.add(subscription.workspaceId);
}
}
const v2Batch = batch.filter((s) => v2WorkspaceIds.has(s.workspaceId));
const v1Batch = batch.filter((s) => !v2WorkspaceIds.has(s.workspaceId));
const v1Evaluations = this.billingUsageCapService.evaluateCapBatch(
v1Batch,
usageByWorkspace,
creditBalanceByCustomer,
);
const v2Evaluations = this.billingUsageCapService.evaluateCapBatchV2(
v2Batch,
usageByWorkspace,
creditBalanceByCustomer,
);
const evaluations = new Map([...v1Evaluations, ...v2Evaluations]);
const idsToCapTrue: string[] = [];
const idsToCapFalse: string[] = [];
@@ -177,10 +204,15 @@ export class EnforceUsageCapJob {
evaluated += 1;
// V2: find item by RESOURCE_CREDIT; V1: find by WORKFLOW_NODE_EXECUTION
const targetProductKey = v2WorkspaceIds.has(subscription.workspaceId)
? BillingProductKey.RESOURCE_CREDIT
: BillingProductKey.WORKFLOW_NODE_EXECUTION;
const meteredItem = subscription.billingSubscriptionItems.find(
(item) =>
productByStripeProductId.get(item.stripeProductId)?.metadata
?.productKey === BillingProductKey.WORKFLOW_NODE_EXECUTION,
?.productKey === targetProductKey,
);
if (!meteredItem) {
@@ -14,7 +14,10 @@ export class BillingPlanDTO {
planKey: BillingPlanKey;
@Field(() => [BillingLicensedProduct])
licensedProducts: BillingLicensedProduct[];
baseProducts: BillingLicensedProduct[];
@Field(() => [BillingLicensedProduct])
resourceCreditProducts: BillingLicensedProduct[];
@Field(() => [BillingMeteredProduct])
meteredProducts: BillingMeteredProduct[];
@@ -18,4 +18,7 @@ export class BillingPriceLicensedDTO {
@Field(() => BillingUsageType)
priceUsageType: BillingUsageType.LICENSED;
@Field(() => Number, { nullable: true })
creditAmount: number | null;
}
@@ -22,6 +22,7 @@ import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/b
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { BillingPriceMetadata } from 'src/engine/core-modules/billing/types/billing-price-metadata.type';
@Entity({ name: 'billingPrice', schema: 'core' })
export class BillingPriceEntity {
@@ -94,6 +95,9 @@ export class BillingPriceEntity {
@Column({ nullable: true, type: 'text' })
stripeMeterId: string | null;
@Column({ nullable: false, type: 'jsonb', default: {} })
metadata: BillingPriceMetadata;
@Field(() => BillingUsageType)
@Column({
type: 'enum',
@@ -4,6 +4,8 @@ import { registerEnumType } from '@nestjs/graphql';
export enum BillingProductKey {
BASE_PRODUCT = 'BASE_PRODUCT',
RESOURCE_CREDIT = 'RESOURCE_CREDIT',
// @deprecated — replaced by RESOURCE_CREDIT, kept while IS_BILLING_V2_ENABLED is not universal
WORKFLOW_NODE_EXECUTION = 'WORKFLOW_NODE_EXECUTION',
}
@@ -3,9 +3,11 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'twenty-shared/types';
import { OnCustomBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-custom-batch-event.decorator';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.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 { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
@@ -16,6 +18,7 @@ export class BillingUsageEventListener {
constructor(
private readonly billingUsageService: BillingUsageService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
) {}
@OnCustomBatchEvent(USAGE_RECORDED)
@@ -38,6 +41,16 @@ export class BillingUsageEventListener {
return;
}
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
payload.workspaceId,
);
if (isV2) {
// V2: ClickHouse is the sole record; no Stripe meter events needed
return;
}
//TODO: To be removed
await this.billingUsageService.billUsage({
workspaceId: payload.workspaceId,
@@ -7,6 +7,7 @@ import type Stripe from 'stripe';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
@@ -35,6 +36,12 @@ describe('BillingCreditRolloverService', () => {
sumMeterEvents: jest.fn(),
},
},
{
provide: BillingUsageService,
useValue: {
getCurrentPeriodCreditsUsed: jest.fn().mockResolvedValue(0),
},
},
{
provide: getRepositoryToken(BillingCustomerEntity),
useValue: {
@@ -17,9 +17,11 @@ import { BillingSubscriptionUpdateService } from 'src/engine/core-modules/billin
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/services/stripe-invoice.service';
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { SubscriptionUpdateType } from 'src/engine/core-modules/billing/types/billing-subscription-update.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import {
arrangeBillingPriceRepositoryFindOneOrFail,
@@ -66,6 +68,20 @@ describe('BillingSubscriptionUpdateService', () => {
module = await Test.createTestingModule({
providers: [
BillingSubscriptionUpdateService,
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
{
provide: StripeInvoiceService,
useValue: {
createImmediateUpgradeInvoice: jest
.fn()
.mockResolvedValue(undefined),
},
},
{
provide: BillingSubscriptionService,
useValue: {
@@ -100,29 +116,59 @@ describe('BillingSubscriptionUpdateService', () => {
provide: BillingSubscriptionPhaseService,
useValue: {
toPhaseUpdateParams: jest.fn(),
buildPhaseUpdateParams: jest
.fn()
.mockImplementation(
async ({
licensedStripePriceId,
seats,
meteredStripePriceId,
startDate,
endDate,
}) => ({
buildPhaseUpdateParams: jest.fn().mockImplementation(
async ({
toUpdatePrices,
startDate,
endDate,
isV2,
}: {
toUpdatePrices: {
licensedPriceId: string;
seats: number;
meteredPriceId?: string;
resourceCreditPriceId?: string;
};
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
endDate: number | undefined;
isV2: boolean;
}) => {
if (isV2) {
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{
price: toUpdatePrices.licensedPriceId,
quantity: toUpdatePrices.seats,
},
{
price: toUpdatePrices.resourceCreditPriceId,
quantity: 1,
},
],
};
}
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{ price: licensedStripePriceId, quantity: seats },
{ price: meteredStripePriceId },
{
price: toUpdatePrices.licensedPriceId,
quantity: toUpdatePrices.seats,
},
{ price: toUpdatePrices.meteredPriceId },
],
billing_thresholds: {
amount_gte: 1000,
reset_billing_cycle_anchor: false,
},
}),
),
};
},
),
isSamePhaseSignature: jest.fn().mockResolvedValue(false),
},
},
@@ -251,7 +297,7 @@ describe('BillingSubscriptionUpdateService', () => {
},
{ id: 'si_metered', price: METER_PRICE_ENTERPRISE_MONTH_ID },
],
proration_behavior: 'create_prorations',
proration_behavior: 'always_invoice',
metadata: { plan: BillingPlanKey.ENTERPRISE },
billing_thresholds: {
amount_gte: 1000,
@@ -377,7 +423,7 @@ describe('BillingSubscriptionUpdateService', () => {
},
{ id: 'si_metered', price: METER_PRICE_ENTERPRISE_MONTH_ID },
],
proration_behavior: 'create_prorations',
proration_behavior: 'always_invoice',
metadata: { plan: BillingPlanKey.ENTERPRISE },
billing_thresholds: {
amount_gte: 1000,
@@ -9,9 +9,8 @@ import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/e
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
describe('BillingUsageCapService', () => {
let service: BillingUsageCapService;
@@ -55,18 +54,9 @@ describe('BillingUsageCapService', () => {
},
},
{
provide: CacheStorageNamespace.EngineBillingUsage,
provide: FeatureFlagService,
useValue: {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
incrBy: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingSubscriptionEntity),
useValue: {
findOne: jest.fn(),
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
{
@@ -76,12 +66,6 @@ describe('BillingUsageCapService', () => {
update: jest.fn(),
},
},
{
provide: WorkspaceCacheService,
useValue: {
getOrRecompute: jest.fn(),
},
},
],
}).compile();
@@ -100,7 +100,7 @@ export const arrangeBillingSubscriptionRepositoryFindOneOrFail = (
export const arrangeBillingPriceRepositoryFindOneOrFail = (
billingPriceRepository: jest.Mocked<Repository<BillingPriceEntity>>,
priceIdToPriceMap: Record<string, BillingPriceEntity | BillingMeterPrice>,
) =>
) => {
jest
.spyOn(billingPriceRepository, 'findOneOrFail')
.mockImplementation(async (criteria: unknown) => {
@@ -114,6 +114,42 @@ export const arrangeBillingPriceRepositoryFindOneOrFail = (
return {} as BillingPriceEntity;
});
jest
.spyOn(billingPriceRepository, 'find')
.mockImplementation(async (criteria?: unknown) => {
const where = (criteria as { where?: { stripePriceId?: unknown } })
?.where;
const stripePriceIdCondition = where?.stripePriceId;
const resolveStripePriceIds = (cond: unknown): string[] => {
if (typeof cond === 'string') {
return [cond];
}
if (
cond &&
typeof cond === 'object' &&
'value' in cond &&
(cond as { value: unknown }).value !== undefined
) {
const value = (cond as { value: string | string[] }).value;
return Array.isArray(value) ? value : [value];
}
return [];
};
const stripePriceIds = resolveStripePriceIds(stripePriceIdCondition);
return stripePriceIds
.map((stripePriceId) => priceIdToPriceMap[stripePriceId])
.filter(
(entity): entity is BillingPriceEntity =>
entity !== null && entity !== undefined,
);
});
};
export const arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule =
(
stripeSubscriptionScheduleService: jest.Mocked<StripeSubscriptionScheduleService>,
@@ -6,6 +6,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
@@ -14,6 +15,7 @@ export class BillingCreditRolloverService {
constructor(
private readonly stripeCreditGrantService: StripeCreditGrantService,
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
private readonly billingUsageService: BillingUsageService,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
) {}
@@ -75,6 +77,33 @@ export class BillingCreditRolloverService {
await this.refreshCreditBalance(stripeCustomerId, unitPriceCents);
}
// V2 path — reads usedCredits from ClickHouse; writes rollover directly to creditBalanceMicro
async processRolloverOnPeriodTransitionV2({
workspaceId,
stripeCustomerId,
tierQuantity,
previousPeriodStart,
}: {
workspaceId: string;
stripeCustomerId: string;
tierQuantity: number;
previousPeriodStart: Date;
}): Promise<void> {
const usedCredits =
await this.billingUsageService.getCurrentPeriodCreditsUsed(
workspaceId,
previousPeriodStart,
);
const unusedCredits = Math.max(0, tierQuantity - usedCredits);
const rolloverAmount = Math.min(unusedCredits, tierQuantity);
await this.billingCustomerRepository.update(
{ stripeCustomerId },
{ creditBalanceMicro: rolloverAmount },
);
}
private async refreshCreditBalance(
stripeCustomerId: string,
unitPriceCents: number,
@@ -3,8 +3,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { JsonContains, Repository } from 'typeorm';
import { findOrThrow } from 'twenty-shared/utils';
import { JsonContains, Repository } from 'typeorm';
import {
BillingException,
@@ -82,15 +82,20 @@ export class BillingPlanService {
(product) =>
product.metadata.priceUsageBased === BillingUsageType.METERED,
);
const licensedProducts = planProducts.filter(
const baseProducts = planProducts.filter(
(product) =>
product.metadata.priceUsageBased === BillingUsageType.LICENSED,
product.metadata.productKey === BillingProductKey.BASE_PRODUCT,
);
const resourceCreditProducts = planProducts.filter(
(product) =>
product.metadata.productKey === BillingProductKey.RESOURCE_CREDIT,
);
return {
planKey,
meteredProducts,
licensedProducts,
baseProducts,
resourceCreditProducts,
};
});
}
@@ -105,7 +110,12 @@ export class BillingPlanService {
(price) => price.stripePriceId === stripePriceId,
),
) ||
plan.licensedProducts.some((product) =>
plan.baseProducts.some((product) =>
product.billingPrices.some(
(price) => price.stripePriceId === stripePriceId,
),
) ||
plan.resourceCreditProducts.some((product) =>
product.billingPrices.some(
(price) => price.stripePriceId === stripePriceId,
),
@@ -130,21 +140,24 @@ export class BillingPlanService {
BillingExceptionCode.BILLING_PLAN_NOT_FOUND,
);
}
const { meteredProducts, licensedProducts } = plan;
const { meteredProducts, baseProducts, resourceCreditProducts } = plan;
const filterPricesByInterval = (product: BillingProductEntity) =>
product.billingPrices.filter((price) => price.interval === interval);
const meteredProductsPrices = meteredProducts.flatMap(
const meteredProductPrices = meteredProducts.flatMap(
filterPricesByInterval,
);
const licensedProductsPrices = licensedProducts.flatMap(
const baseProductPrices = baseProducts.flatMap(filterPricesByInterval);
const resourceCreditProductPrices = resourceCreditProducts.flatMap(
filterPricesByInterval,
);
return {
meteredProductsPrices,
licensedProductsPrices,
meteredProductPrices,
baseProductPrices,
resourceCreditProductPrices,
};
}
}
@@ -29,8 +29,11 @@ import { type BillingGetPricesPerPlanResult } from 'src/engine/core-modules/bill
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
import { type BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FeatureFlagKey } from 'twenty-shared/types';
@Injectable()
export class BillingPortalWorkspaceService {
@@ -40,12 +43,14 @@ export class BillingPortalWorkspaceService {
private readonly stripeBillingPortalService: StripeBillingPortalService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly featureFlagService: FeatureFlagService,
@InjectRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly twentyConfigService: TwentyConfigService,
) {}
async computeCheckoutSessionURL({
@@ -127,14 +132,9 @@ export class BillingPortalWorkspaceService {
!isDefined(customer) || customer.billingSubscriptions.length === 0,
});
const createdBillingSubscription =
await this.billingSubscriptionService.syncSubscriptionToDatabase(
workspace.id,
stripeSubscription.id,
);
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
createdBillingSubscription.id,
await this.billingSubscriptionService.syncSubscriptionToDatabase(
workspace.id,
stripeSubscription.id,
);
return successUrl;
@@ -168,10 +168,12 @@ export class BillingPortalWorkspaceService {
relations: ['billingSubscriptions'],
});
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
});
const stripeSubscriptionLineItems =
await this.getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
workspaceId: workspace.id,
});
return {
successUrl,
@@ -265,7 +267,7 @@ export class BillingPortalWorkspaceService {
billingPricesPerPlan: BillingGetPricesPerPlanResult,
): BillingMeterPrice {
const defaultMeteredProductPrice =
billingPricesPerPlan.meteredProductsPrices.reduce(
billingPricesPerPlan.meteredProductPrices.reduce(
(result, billingPrice) => {
if (!result) {
return billingPrice as BillingMeterPrice;
@@ -293,20 +295,50 @@ export class BillingPortalWorkspaceService {
return defaultMeteredProductPrice;
}
private getStripeSubscriptionLineItems({
// V2 path — finds the lowest credit_amount RESOURCE_CREDIT licensed price as default
private getDefaultResourceCreditPrice(
billingPricesPerPlan: BillingGetPricesPerPlanResult,
) {
const resourceCreditPrices =
billingPricesPerPlan.resourceCreditProductPrices;
if (!isDefined(resourceCreditPrices) || resourceCreditPrices.length === 0) {
throw new BillingException(
'Missing Default RESOURCE_CREDIT price',
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return resourceCreditPrices.reduce((lowest, price) => {
const amount = Number(price.metadata?.credit_amount ?? 0);
const lowestAmount = Number(lowest.metadata?.credit_amount ?? 0);
return amount < lowestAmount ? price : lowest;
});
}
private async getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
workspaceId,
}: {
quantity: number;
billingPricesPerPlan: BillingGetPricesPerPlanResult;
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
const defaultMeteredProductPrice =
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
workspaceId: string;
}): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
);
const defaultLicensedProductPrice = findOrThrow(
billingPricesPerPlan.licensedProductsPrices,
(licensedProductsPrice) =>
licensedProductsPrice.billingProduct?.metadata.productKey ===
const isBillingV2EnabledForNewWorkspaces = this.twentyConfigService.get(
'IS_BILLING_V2_ENABLED_FOR_NEW_WORKSPACES',
);
const defaultBaseProductPrice = findOrThrow(
billingPricesPerPlan.baseProductPrices,
(baseProductPrice) =>
baseProductPrice.billingProduct?.metadata.productKey ===
BillingProductKey.BASE_PRODUCT,
new BillingException(
`Base product not found`,
@@ -314,9 +346,28 @@ export class BillingPortalWorkspaceService {
),
);
if (isBillingV2EnabledForNewWorkspaces || isV2) {
const defaultResourceCreditPrice =
this.getDefaultResourceCreditPrice(billingPricesPerPlan);
return [
{
price: defaultBaseProductPrice.stripePriceId,
quantity,
},
{
price: defaultResourceCreditPrice.stripePriceId,
quantity: 1,
},
];
}
const defaultMeteredProductPrice =
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
return [
{
price: defaultLicensedProductPrice.stripePriceId,
price: defaultBaseProductPrice.stripePriceId,
quantity,
},
{
@@ -12,6 +12,7 @@ import {
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
@@ -129,4 +130,66 @@ export class BillingPriceService {
) as BillingMeterPrice[]
).sort((a, b) => a.tiers[0].up_to - b.tiers[0].up_to);
}
// V2 counterpart of findEquivalentMeteredPrice.
// Finds a RESOURCE_CREDIT price matching the target interval and plan,
// with the largest credit_amount that does not exceed the reference credit_amount.
async findEquivalentResourceCreditPrice({
targetInterval,
targetPlanKey,
hasSameInterval,
hasSamePlanKey,
referencePrice,
}: {
targetInterval: SubscriptionInterval;
targetPlanKey: BillingPlanKey;
hasSameInterval: boolean;
hasSamePlanKey: boolean;
referencePrice: BillingPriceEntity;
}): Promise<BillingPriceEntity> {
if (hasSameInterval && hasSamePlanKey) {
return referencePrice;
}
const catalog = await this.billingProductService.getProductPrices({
interval: targetInterval,
planKey: targetPlanKey,
});
const referenceCreditAmount = Number(
referencePrice.metadata?.credit_amount,
);
const scaledAmount =
!hasSameInterval && targetInterval === SubscriptionInterval.Year
? referenceCreditAmount * 12
: !hasSameInterval && targetInterval === SubscriptionInterval.Month
? referenceCreditAmount / 12
: referenceCreditAmount;
const resourceCreditCandidates = catalog
.filter(
(p) =>
p.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
)
.sort(
(a, b) =>
Number(a.metadata?.credit_amount ?? 0) -
Number(b.metadata?.credit_amount ?? 0),
);
if (!resourceCreditCandidates.length) {
throw new BillingException(
'No RESOURCE_CREDIT price candidates found',
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return (
resourceCreditCandidates
.filter((p) => Number(p.metadata?.credit_amount ?? 0) <= scaledAmount)
.pop() ?? resourceCreditCandidates[0]
);
}
}
@@ -59,6 +59,10 @@ export class BillingProductService {
);
}
return [...plan.licensedProducts, ...plan.meteredProducts];
return [
...plan.baseProducts,
...plan.resourceCreditProducts,
...plan.meteredProducts,
];
}
}
@@ -1,17 +1,20 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Raw, Repository } from 'typeorm';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { differenceInDays } from 'date-fns';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { isDefined } from 'twenty-shared/utils';
@Injectable()
export class BillingSubscriptionItemService {
@@ -58,6 +61,57 @@ export class BillingSubscriptionItemService {
);
}
async getResourceCreditSubscriptionItemDetails(
subscription: BillingSubscriptionEntity,
): Promise<{
stripeSubscriptionItemId: string;
productKey: BillingProductKey;
creditAmount: number;
freeTrialQuantity: number;
unitPriceCents: number;
} | null> {
const item = await this.billingSubscriptionItemRepository.findOne({
where: {
billingSubscriptionId: subscription.id,
billingProduct: {
metadata: Raw((alias) => `${alias} @> :metadata::jsonb`, {
metadata: JSON.stringify({
productKey: BillingProductKey.RESOURCE_CREDIT,
}),
}),
},
},
relations: ['billingProduct', 'billingProduct.billingPrices'],
});
if (!item) {
return null;
}
const price = this.findMatchingPrice(item);
const trialDuration =
isDefined(subscription.trialEnd) && isDefined(subscription.trialStart)
? differenceInDays(subscription.trialEnd, subscription.trialStart)
: 0;
const trialWithCreditCardDuration = this.twentyConfigService.get(
'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS',
);
return {
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
productKey: BillingProductKey.RESOURCE_CREDIT,
creditAmount: Number(price.metadata?.credit_amount ?? 0),
freeTrialQuantity: this.twentyConfigService.get(
trialDuration === trialWithCreditCardDuration
? 'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD'
: 'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
),
unitPriceCents: price.unitAmount ?? 0,
};
}
private findMatchingPrice(
item: BillingSubscriptionItemEntity,
): BillingPriceEntity {
@@ -16,6 +16,7 @@ import { type BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-module
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
import { SubscriptionStripePrices } from 'src/engine/core-modules/billing/services/billing-subscription-update.service';
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
@Injectable()
@@ -83,31 +84,51 @@ export class BillingSubscriptionPhaseService {
}
async buildPhaseUpdateParams({
licensedStripePriceId,
seats,
meteredStripePriceId,
toUpdatePrices,
startDate,
endDate,
isV2,
}: {
licensedStripePriceId: string;
seats: number;
meteredStripePriceId: string;
toUpdatePrices: SubscriptionStripePrices;
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
endDate: number | undefined;
isV2: boolean;
}): Promise<Stripe.SubscriptionScheduleUpdateParams.Phase> {
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{ price: licensedStripePriceId, quantity: seats },
{ price: meteredStripePriceId },
],
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
meteredStripePriceId,
),
};
if (isV2) {
assertIsDefinedOrThrow(toUpdatePrices.resourceCreditPriceId);
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{
price: toUpdatePrices.licensedPriceId,
quantity: toUpdatePrices.seats,
},
{ price: toUpdatePrices.resourceCreditPriceId, quantity: 1 },
],
};
} else {
assertIsDefinedOrThrow(toUpdatePrices.meteredPriceId);
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{
price: toUpdatePrices.licensedPriceId,
quantity: toUpdatePrices.seats,
},
{
price: toUpdatePrices.meteredPriceId,
},
],
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
toUpdatePrices.meteredPriceId,
),
};
}
}
getLicensedPriceIdAndQuantityFromPhaseUpdateParams(
@@ -159,4 +180,76 @@ export class BillingSubscriptionPhaseService {
return false;
}
}
// Billing V2: emits { price, quantity: 1 } for the resource credit price; no billing_thresholds
async buildResourceCreditPhaseUpdateParams({
basePlanStripePriceId,
seats,
resourceCreditStripePriceId,
startDate,
endDate,
}: {
basePlanStripePriceId: string;
seats: number;
resourceCreditStripePriceId: string;
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
endDate: number | undefined;
}): Promise<Stripe.SubscriptionScheduleUpdateParams.Phase> {
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{ price: basePlanStripePriceId, quantity: seats },
{ price: resourceCreditStripePriceId, quantity: 1 },
],
};
}
// Billing V2: compares resource credit Stripe price id between phases
async isSameResourceCreditPhaseSignature(
a: Stripe.SubscriptionScheduleUpdateParams.Phase,
b: Stripe.SubscriptionScheduleUpdateParams.Phase,
): Promise<boolean> {
try {
const phaseALicensedPriceIdAndQuantity =
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(a);
const phaseBLicensedPriceIdAndQuantity =
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(b);
const phaseAResourceCreditPriceId =
this.getResourceCreditPriceIdFromPhaseUpdateParams(a);
const phaseBResourceCreditPriceId =
this.getResourceCreditPriceIdFromPhaseUpdateParams(b);
return (
phaseALicensedPriceIdAndQuantity.price ===
phaseBLicensedPriceIdAndQuantity.price &&
phaseALicensedPriceIdAndQuantity.quantity ===
phaseBLicensedPriceIdAndQuantity.quantity &&
phaseAResourceCreditPriceId === phaseBResourceCreditPriceId
);
} catch {
return false;
}
}
// Billing V2 counterpart of getMeteredPriceIdFromPhaseUpdateParams (resource credit has quantity: 1)
getResourceCreditPriceIdFromPhaseUpdateParams(
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
): string {
const items = phase.items ?? [];
const licensedPriceIdAndQuantity =
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(phase);
const resourceCreditItem = items.find(
(item) =>
item.price !== licensedPriceIdAndQuantity.price && item.quantity === 1,
);
if (!resourceCreditItem?.price) {
throw new Error('Resource credit item not found in V2 phase params');
}
return resourceCreditItem.price;
}
}
@@ -9,7 +9,7 @@ import {
findOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { In, type Repository } from 'typeorm';
import type Stripe from 'stripe';
@@ -26,6 +26,7 @@ import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/services/stripe-invoice.service';
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import {
@@ -33,15 +34,20 @@ import {
SubscriptionUpdateType,
} from 'src/engine/core-modules/billing/types/billing-subscription-update.type';
import { computeSubscriptionUpdateOptions } from 'src/engine/core-modules/billing/utils/compute-subscription-update-options.util';
import { getBaseProductSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-base-product-subscription-item-or-throw.util';
import { getCurrentLicensedBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-licensed-billing-subscription-item-or-throw.util';
import { getCurrentMeteredBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-metered-billing-subscription-item-or-throw.util';
import { getSubscriptionPricesFromSchedulePhase } from 'src/engine/core-modules/billing/utils/get-subscription-prices-from-schedule-phase.util';
import { getCurrentResourceCreditSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-resource-credit-subscription-item-or-throw.util';
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FeatureFlagKey } from 'twenty-shared/types';
type SubscriptionStripePrices = {
export type SubscriptionStripePrices = {
licensedPriceId: string;
seats: number;
meteredPriceId: string;
meteredPriceId: string | undefined;
resourceCreditPriceId: string | undefined;
};
@Injectable()
@@ -50,6 +56,7 @@ export class BillingSubscriptionUpdateService {
constructor(
private readonly stripeSubscriptionService: StripeSubscriptionService,
private readonly stripeInvoiceService: StripeInvoiceService,
private readonly billingPriceService: BillingPriceService,
private readonly billingProductService: BillingProductService,
@InjectRepository(BillingPriceEntity)
@@ -63,8 +70,16 @@ export class BillingSubscriptionUpdateService {
private readonly stripeBillingAlertService: StripeBillingAlertService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly meteredCreditService: MeteredCreditService,
private readonly featureFlagService: FeatureFlagService,
) {}
private async isV2(workspaceId: string): Promise<boolean> {
return await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
);
}
async changeMeteredPrice(
workspaceId: string,
meteredPriceId: string,
@@ -83,6 +98,10 @@ export class BillingSubscriptionUpdateService {
}
async cancelSwitchMeteredPrice(workspace: WorkspaceEntity): Promise<void> {
if (await this.isV2(workspace.id)) {
return this.cancelSwitchResourceCreditPrice(workspace);
}
const billingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
@@ -97,6 +116,40 @@ export class BillingSubscriptionUpdateService {
});
}
async changeResourceCreditPrice(
workspaceId: string,
resourceCreditPriceId: string,
): Promise<void> {
const billingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId },
);
const subscriptionUpdate = {
type: SubscriptionUpdateType.RESOURCE_CREDIT_PRICE,
newResourceCreditPriceId: resourceCreditPriceId,
} as const;
await this.updateSubscription(billingSubscription.id, subscriptionUpdate);
}
async cancelSwitchResourceCreditPrice(
workspace: WorkspaceEntity,
): Promise<void> {
const billingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
);
const currentResourceCreditPrice =
getCurrentResourceCreditSubscriptionItemOrThrow(billingSubscription);
const subscriptionUpdate = {
type: SubscriptionUpdateType.RESOURCE_CREDIT_PRICE,
newResourceCreditPriceId: currentResourceCreditPrice.stripePriceId,
} as const;
await this.updateSubscription(billingSubscription.id, subscriptionUpdate);
}
async cancelSwitchPlan(workspaceId: string) {
const billingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
@@ -189,18 +242,27 @@ export class BillingSubscriptionUpdateService {
},
);
const licensedItem =
getCurrentLicensedBillingSubscriptionItemOrThrow(subscription);
const meteredItem =
getCurrentMeteredBillingSubscriptionItemOrThrow(subscription);
const isV2 = await this.isV2(subscription.workspaceId);
const licensedItem = isV2
? getBaseProductSubscriptionItemOrThrow(subscription)
: getCurrentLicensedBillingSubscriptionItemOrThrow(subscription);
const resourceCreditItem = isV2
? getCurrentResourceCreditSubscriptionItemOrThrow(subscription)
: undefined;
const meteredItem = isV2
? undefined
: getCurrentMeteredBillingSubscriptionItemOrThrow(subscription);
const toUpdateCurrentPrices = await this.computeSubscriptionPricesUpdate(
subscriptionUpdate,
{
licensedPriceId: licensedItem.stripePriceId,
meteredPriceId: meteredItem.stripePriceId,
meteredPriceId: meteredItem?.stripePriceId,
resourceCreditPriceId: resourceCreditItem?.stripePriceId,
seats: licensedItem.quantity,
},
isV2,
);
const { schedule, currentPhase, nextPhase } =
@@ -232,14 +294,19 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
} else {
assertIsDefinedOrThrow(nextPhase);
assertIsDefinedOrThrow(currentPhase);
const nextPhasePrices =
await this.getSubscriptionPricesFromSchedulePhaseV2(nextPhase, isV2);
const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate(
subscriptionUpdate,
getSubscriptionPricesFromSchedulePhase(nextPhase),
nextPhasePrices,
isV2,
);
await this.runSubscriptionScheduleUpdate({
@@ -253,20 +320,37 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
}
} else {
const subscriptionOptions =
computeSubscriptionUpdateOptions(subscriptionUpdate);
if (
subscriptionUpdate.type === SubscriptionUpdateType.RESOURCE_CREDIT_PRICE
) {
assertIsDefinedOrThrow(resourceCreditItem);
await this.createResourceCreditUpgradeInvoice({
subscription,
currentResourceCreditPriceId: resourceCreditItem.stripePriceId,
newResourceCreditPriceId: subscriptionUpdate.newResourceCreditPriceId,
});
}
await this.runSubscriptionUpdate({
stripeSubscriptionId: subscription.stripeSubscriptionId,
licensedStripeItemId: licensedItem.stripeSubscriptionItemId,
meteredStripeItemId: meteredItem.stripeSubscriptionItemId,
meteredStripeItemId: meteredItem?.stripeSubscriptionItemId,
resourceCreditStripeItemId:
resourceCreditItem?.stripeSubscriptionItemId,
licensedStripePriceId: toUpdateCurrentPrices.licensedPriceId,
meteredStripePriceId: toUpdateCurrentPrices.meteredPriceId,
meteredStripePriceId: toUpdateCurrentPrices?.meteredPriceId,
resourceCreditStripePriceId:
toUpdateCurrentPrices?.resourceCreditPriceId,
seats: toUpdateCurrentPrices.seats,
...subscriptionOptions,
isV2,
});
if (subscriptionUpdate.type !== SubscriptionUpdateType.SEATS) {
@@ -274,23 +358,6 @@ export class BillingSubscriptionUpdateService {
{ stripeSubscriptionId: subscription.stripeSubscriptionId },
{ hasReachedCurrentPeriodCap: false },
);
const meteredPricingInfo =
await this.meteredCreditService.getMeteredPricingInfoFromPriceId(
toUpdateCurrentPrices.meteredPriceId,
);
const creditBalance = await this.meteredCreditService.getCreditBalance(
subscription.stripeCustomerId,
meteredPricingInfo.unitPriceCents,
);
await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter(
subscription.stripeCustomerId,
meteredPricingInfo.tierCap,
creditBalance,
subscription.currentPeriodStart,
);
}
if (isDefined(nextPhase)) {
@@ -303,10 +370,11 @@ export class BillingSubscriptionUpdateService {
assertIsDefinedOrThrow(refreshedCurrentPhase);
const nextPhasePrices =
getSubscriptionPricesFromSchedulePhase(nextPhase);
await this.getSubscriptionPricesFromSchedulePhaseV2(nextPhase, isV2);
const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate(
subscriptionUpdate,
nextPhasePrices,
isV2,
);
await this.runSubscriptionScheduleUpdate({
@@ -320,6 +388,7 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
}
}
@@ -330,61 +399,196 @@ export class BillingSubscriptionUpdateService {
);
}
private async createResourceCreditUpgradeInvoice({
subscription,
currentResourceCreditPriceId,
newResourceCreditPriceId,
}: {
subscription: BillingSubscriptionEntity;
currentResourceCreditPriceId: string;
newResourceCreditPriceId: string;
}): Promise<void> {
const prices = await this.billingPriceRepository.find({
where: {
stripePriceId: In([
currentResourceCreditPriceId,
newResourceCreditPriceId,
]),
},
});
const currentPrice = prices.find(
(price) => price.stripePriceId === currentResourceCreditPriceId,
);
const newPrice = prices.find(
(price) => price.stripePriceId === newResourceCreditPriceId,
);
assertIsDefinedOrThrow(currentPrice);
assertIsDefinedOrThrow(newPrice);
const diffInCents =
Number(newPrice.unitAmount) - Number(currentPrice.unitAmount);
if (diffInCents > 0) {
await this.stripeInvoiceService.createImmediateUpgradeInvoice({
stripeCustomerId: subscription.stripeCustomerId,
stripeSubscriptionId: subscription.stripeSubscriptionId,
diffAmountInCents: diffInCents,
description: `Resource usage - Upgrade resource credit price from $${Number(currentPrice.unitAmount) / 100} to $${Number(newPrice.unitAmount) / 100}`,
currency: newPrice.currency,
});
}
}
private async getSubscriptionPricesFromSchedulePhaseV2(
phase: Stripe.SubscriptionSchedule.Phase,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const licensedItemPriceIds = phase.items
.filter((item) => item.quantity != null)
.map((item) => normalizePriceRef(item.price));
const licensedItemPrices = await this.billingPriceRepository.find({
where: { stripePriceId: In(licensedItemPriceIds) },
relations: ['billingProduct'],
});
const basePlanPrice = licensedItemPrices.find(
(price) =>
price.billingProduct?.metadata?.productKey ===
BillingProductKey.BASE_PRODUCT,
);
assertIsDefinedOrThrow(basePlanPrice);
const basePlanPhaseItem = findOrThrow(
phase.items,
(item) => normalizePriceRef(item.price) === basePlanPrice.stripePriceId,
);
assertIsDefinedOrThrow(basePlanPhaseItem.quantity);
if (isV2) {
const resourceCreditPrice = licensedItemPrices.find(
(price) =>
price.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
);
assertIsDefinedOrThrow(resourceCreditPrice);
return {
licensedPriceId: basePlanPrice.stripePriceId,
meteredPriceId: undefined,
seats: basePlanPhaseItem.quantity,
resourceCreditPriceId: resourceCreditPrice.stripePriceId,
};
} else {
const meteredItem = findOrThrow(
phase.items,
(item) => item.quantity == null,
);
return {
licensedPriceId: basePlanPrice.stripePriceId,
meteredPriceId: normalizePriceRef(meteredItem.price),
seats: basePlanPhaseItem.quantity,
resourceCreditPriceId: undefined,
};
}
}
private async runSubscriptionUpdate({
stripeSubscriptionId,
licensedStripeItemId,
meteredStripeItemId,
resourceCreditStripeItemId,
licensedStripePriceId,
meteredStripePriceId,
resourceCreditStripePriceId,
seats,
anchor,
proration,
metadata,
isV2,
}: {
stripeSubscriptionId: string;
licensedStripeItemId: string;
meteredStripeItemId: string;
meteredStripeItemId: string | undefined;
resourceCreditStripeItemId: string | undefined;
licensedStripePriceId: string;
meteredStripePriceId: string;
meteredStripePriceId: string | undefined;
resourceCreditStripePriceId: string | undefined;
seats: number;
anchor?: Stripe.SubscriptionUpdateParams.BillingCycleAnchor;
proration?: Stripe.SubscriptionUpdateParams.ProrationBehavior;
metadata?: Record<string, string>;
isV2: boolean;
}) {
return await this.stripeSubscriptionService.updateSubscription(
stripeSubscriptionId,
{
items: [
{
id: licensedStripeItemId,
price: licensedStripePriceId,
quantity: seats,
},
{ id: meteredStripeItemId, price: meteredStripePriceId },
],
...(anchor ? { billing_cycle_anchor: anchor } : {}),
...(proration ? { proration_behavior: proration } : {}),
...(metadata ? { metadata } : {}),
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
meteredStripePriceId,
),
},
);
if (isV2) {
assertIsDefinedOrThrow(resourceCreditStripePriceId);
assertIsDefinedOrThrow(resourceCreditStripeItemId);
return await this.stripeSubscriptionService.updateSubscription(
stripeSubscriptionId,
{
items: [
{
id: licensedStripeItemId,
price: licensedStripePriceId,
quantity: seats,
},
{
id: resourceCreditStripeItemId,
price: resourceCreditStripePriceId,
quantity: 1,
},
],
...(anchor ? { billing_cycle_anchor: anchor } : {}),
...(proration ? { proration_behavior: proration } : {}),
...(metadata ? { metadata } : {}),
},
);
} else {
assertIsDefinedOrThrow(meteredStripePriceId);
assertIsDefinedOrThrow(meteredStripeItemId);
return await this.stripeSubscriptionService.updateSubscription(
stripeSubscriptionId,
{
items: [
{
id: licensedStripeItemId,
price: licensedStripePriceId,
quantity: seats,
},
{ id: meteredStripeItemId, price: meteredStripePriceId },
],
...(anchor ? { billing_cycle_anchor: anchor } : {}),
...(proration ? { proration_behavior: proration } : {}),
...(metadata ? { metadata } : {}),
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
meteredStripePriceId,
),
},
);
}
}
private async runSubscriptionScheduleUpdate({
async runSubscriptionScheduleUpdate({
stripeScheduleId,
toUpdateNextPrices,
toUpdateCurrentPrices,
currentPhase,
subscriptionCurrentPeriodEnd,
isV2,
}: {
stripeScheduleId: string;
toUpdateNextPrices: SubscriptionStripePrices;
toUpdateCurrentPrices: SubscriptionStripePrices | undefined;
currentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase;
subscriptionCurrentPeriodEnd: number;
isV2: boolean;
}) {
let toUpdateCurrentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase = {
...currentPhase,
@@ -394,21 +598,19 @@ export class BillingSubscriptionUpdateService {
if (isDefined(toUpdateCurrentPrices)) {
toUpdateCurrentPhase =
await this.billingSubscriptionPhaseService.buildPhaseUpdateParams({
licensedStripePriceId: toUpdateCurrentPrices.licensedPriceId,
seats: toUpdateCurrentPrices.seats,
meteredStripePriceId: toUpdateCurrentPrices.meteredPriceId,
toUpdatePrices: toUpdateCurrentPrices,
endDate: subscriptionCurrentPeriodEnd,
startDate: currentPhase.start_date,
isV2,
});
}
const toUpdateNextPhase =
await this.billingSubscriptionPhaseService.buildPhaseUpdateParams({
licensedStripePriceId: toUpdateNextPrices.licensedPriceId,
seats: toUpdateNextPrices.seats,
meteredStripePriceId: toUpdateNextPrices.meteredPriceId,
toUpdatePrices: toUpdateNextPrices,
startDate: subscriptionCurrentPeriodEnd,
endDate: undefined,
isV2,
});
if (
@@ -475,6 +677,44 @@ export class BillingSubscriptionUpdateService {
return isDowngrade;
}
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE: {
const currentResourceCreditPriceId =
subscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct?.metadata.productKey ===
BillingProductKey.RESOURCE_CREDIT,
)?.stripePriceId;
assertIsDefinedOrThrow(currentResourceCreditPriceId);
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentResourceCreditPriceId },
relations: ['billingProduct'],
});
const newResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: update.newResourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
billingValidator.assertIsLicensedResourceCreditPrice(
newResourceCreditPrice,
);
const currentResourceCreditCap = Number(
currentResourceCreditPrice.metadata?.credit_amount,
);
const newResourceCreditCap = Number(
newResourceCreditPrice.metadata?.credit_amount,
);
const isDowngrade = currentResourceCreditCap > newResourceCreditCap;
return isDowngrade;
}
case SubscriptionUpdateType.SEATS:
return false;
case SubscriptionUpdateType.INTERVAL: {
@@ -497,12 +737,14 @@ export class BillingSubscriptionUpdateService {
async computeSubscriptionPricesUpdate(
update: SubscriptionUpdate,
currentPrices: SubscriptionStripePrices,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
switch (update.type) {
case SubscriptionUpdateType.PLAN:
return await this.computeSubscriptionPricesUpdateByPlan(
update.newPlan,
currentPrices,
isV2,
);
case SubscriptionUpdateType.METERED_PRICE:
return await this.computeSubscriptionPricesUpdateByMeteredPrice(
@@ -518,6 +760,12 @@ export class BillingSubscriptionUpdateService {
return await this.computeSubscriptionPricesUpdateByInterval(
update.newInterval,
currentPrices,
isV2,
);
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE:
return await this.computeSubscriptionPricesUpdateByResourceCreditPrice(
update.newResourceCreditPriceId,
currentPrices,
);
}
}
@@ -578,10 +826,60 @@ export class BillingSubscriptionUpdateService {
meteredPriceId: newEquivalentMeteredPrice.stripePriceId,
};
}
private async computeSubscriptionPricesUpdateByResourceCreditPrice(
newResourceCreditPriceId: string,
currentPrices: SubscriptionStripePrices,
): Promise<SubscriptionStripePrices> {
const currentLicensedPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.licensedPriceId },
relations: ['billingProduct'],
});
const currentInterval = currentLicensedPrice.interval;
const currentPlanKey =
currentLicensedPrice.billingProduct?.metadata.planKey;
assertIsDefinedOrThrow(currentPlanKey);
const newResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: newResourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsLicensedResourceCreditPrice(
newResourceCreditPrice,
);
const newInterval = newResourceCreditPrice.interval;
const newPlanKey = newResourceCreditPrice.billingProduct?.metadata.planKey;
if (newInterval === currentInterval && currentPlanKey === newPlanKey) {
return {
...currentPrices,
resourceCreditPriceId: newResourceCreditPriceId,
};
}
const newEquivalentResourceCreditPrice =
await this.billingPriceService.findEquivalentResourceCreditPrice({
referencePrice: newResourceCreditPrice,
targetInterval: currentInterval,
targetPlanKey: currentPlanKey,
hasSameInterval: newInterval === currentInterval,
hasSamePlanKey: currentPlanKey === newPlanKey,
});
return {
...currentPrices,
resourceCreditPriceId: newEquivalentResourceCreditPrice.stripePriceId,
};
}
private async computeSubscriptionPricesUpdateByPlan(
newPlan: BillingPlanKey,
currentPrices: SubscriptionStripePrices,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const currentLicensedPrice =
await this.billingPriceRepository.findOneOrFail({
@@ -611,34 +909,61 @@ export class BillingSubscriptionUpdateService {
billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT,
);
const currentMeteredPrice = await this.billingPriceRepository.findOneOrFail(
{
where: { stripePriceId: currentPrices.meteredPriceId },
relations: ['billingProduct'],
},
);
if (isV2) {
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
const targetMeteredPrice =
await this.billingPriceService.findEquivalentMeteredPrice({
meteredPrice: currentMeteredPrice,
targetInterval: currentInterval,
targetPlanKey: newPlan,
hasSameInterval: true,
hasSamePlanKey: false,
});
const targetResourceCreditPrice =
await this.billingPriceService.findEquivalentResourceCreditPrice({
referencePrice: currentResourceCreditPrice,
targetInterval: currentInterval,
targetPlanKey: newPlan,
hasSameInterval: true,
hasSamePlanKey: false,
});
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
meteredPriceId: targetMeteredPrice.stripePriceId,
};
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
resourceCreditPriceId: targetResourceCreditPrice.stripePriceId,
};
} else {
const currentMeteredPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.meteredPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
const targetMeteredPrice =
await this.billingPriceService.findEquivalentMeteredPrice({
meteredPrice: currentMeteredPrice,
targetInterval: currentInterval,
targetPlanKey: newPlan,
hasSameInterval: true,
hasSamePlanKey: false,
});
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
meteredPriceId: targetMeteredPrice.stripePriceId,
};
}
}
private async computeSubscriptionPricesUpdateByInterval(
newInterval: SubscriptionInterval,
currentPrices: SubscriptionStripePrices,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const currentLicensedPrice =
await this.billingPriceRepository.findOneOrFail({
@@ -668,28 +993,54 @@ export class BillingSubscriptionUpdateService {
billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT,
);
const currentMeteredPrice = await this.billingPriceRepository.findOneOrFail(
{
where: { stripePriceId: currentPrices.meteredPriceId },
relations: ['billingProduct'],
},
);
if (isV2) {
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
const targetMeteredPrice =
await this.billingPriceService.findEquivalentMeteredPrice({
meteredPrice: currentMeteredPrice,
targetInterval: newInterval,
targetPlanKey: currentPlanKey,
hasSameInterval: false,
hasSamePlanKey: true,
});
const targetResourceCreditPrice =
await this.billingPriceService.findEquivalentResourceCreditPrice({
referencePrice: currentResourceCreditPrice,
targetInterval: newInterval,
targetPlanKey: currentPlanKey,
hasSameInterval: false,
hasSamePlanKey: true,
});
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
meteredPriceId: targetMeteredPrice.stripePriceId,
};
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
resourceCreditPriceId: targetResourceCreditPrice.stripePriceId,
};
} else {
const currentMeteredPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.meteredPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
const targetMeteredPrice =
await this.billingPriceService.findEquivalentMeteredPrice({
meteredPrice: currentMeteredPrice,
targetInterval: newInterval,
targetPlanKey: currentPlanKey,
hasSameInterval: false,
hasSamePlanKey: true,
});
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
meteredPriceId: targetMeteredPrice.stripePriceId,
};
}
}
}
@@ -4,11 +4,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { differenceInDays } from 'date-fns';
import {
assertIsDefinedOrThrow,
findOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Not, type Repository } from 'typeorm';
import type Stripe from 'stripe';
@@ -29,7 +25,6 @@ import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entiti
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
@@ -267,36 +262,6 @@ export class BillingSubscriptionService {
};
}
async setBillingThresholdsAndTrialPeriodWorkflowCredits(
billingSubscriptionId: string,
) {
const billingSubscription =
await this.billingSubscriptionRepository.findOneOrFail({
where: { id: billingSubscriptionId },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
],
});
const { stripePriceId: meterStripePriceId } = findOrThrow(
billingSubscription.billingSubscriptionItems,
(billingSubscriptionItem) =>
billingSubscriptionItem.billingProduct.metadata.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
await this.stripeSubscriptionService.updateSubscription(
billingSubscription.stripeSubscriptionId,
{
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
meterStripePriceId,
),
},
);
}
async syncSubscriptionToDatabase(
workspaceId: string,
stripeSubscriptionId: string,
@@ -351,27 +316,29 @@ export class BillingSubscriptionService {
workspaceId,
);
const meterBillingSubscriptionItem = findOrThrow(
billingSubscriptionItems,
// V2 subscriptions have no quantityless metered item; skip the stale-item cleanup in that case
const meterBillingSubscriptionItem = billingSubscriptionItems.find(
(item) => !isDefined(item.quantity),
);
const existingBillingSubscriptionItem =
await this.billingSubscriptionItemRepository.findOne({
where: {
if (isDefined(meterBillingSubscriptionItem)) {
const existingBillingSubscriptionItem =
await this.billingSubscriptionItemRepository.findOne({
where: {
billingSubscriptionId: currentBillingSubscription.id,
stripeProductId: meterBillingSubscriptionItem.stripeProductId,
},
});
if (
existingBillingSubscriptionItem?.stripeSubscriptionItemId !==
meterBillingSubscriptionItem.stripeSubscriptionItemId
) {
await this.billingSubscriptionItemRepository.delete({
billingSubscriptionId: currentBillingSubscription.id,
stripeProductId: meterBillingSubscriptionItem.stripeProductId,
},
});
if (
existingBillingSubscriptionItem?.stripeSubscriptionItemId !==
meterBillingSubscriptionItem.stripeSubscriptionItemId
) {
await this.billingSubscriptionItemRepository.delete({
billingSubscriptionId: currentBillingSubscription.id,
stripeProductId: meterBillingSubscriptionItem.stripeProductId,
});
});
}
}
await this.billingSubscriptionItemRepository.upsert(
@@ -15,6 +15,8 @@ import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FeatureFlagKey } from 'twenty-shared/types';
import { Not, Raw, Repository } from 'typeorm';
export type BillingCapEvaluation =
@@ -42,6 +44,7 @@ export class BillingUsageCapService {
private readonly clickHouseService: ClickHouseService,
private readonly meteredCreditService: MeteredCreditService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
@InjectRepository(BillingSubscriptionItemEntity)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
) {}
@@ -122,10 +125,60 @@ export class BillingUsageCapService {
return results;
}
// V2 path — uses extractResourceCreditPricingInfo (productKey === RESOURCE_CREDIT)
// instead of extractMeteredPricingInfoFromSubscription (productKey === WORKFLOW_NODE_EXECUTION)
evaluateCapBatchV2(
subscriptions: BillingSubscriptionEntity[],
usageByWorkspace: Map<string, number>,
creditBalanceByCustomer: Map<string, number>,
): Map<string, BillingCapEvaluation> {
const results = new Map<string, BillingCapEvaluation>();
for (const subscription of subscriptions) {
const resourceCreditPricingInfo =
this.meteredCreditService.extractResourceCreditPricingInfo(
subscription,
);
if (!resourceCreditPricingInfo) {
results.set(subscription.id, {
skipped: true,
reason: 'no-metered-item',
});
continue;
}
const usage = usageByWorkspace.get(subscription.workspaceId) ?? 0;
const creditBalance =
creditBalanceByCustomer.get(subscription.stripeCustomerId) ?? 0;
const allowance = resourceCreditPricingInfo.tierCap + creditBalance;
results.set(subscription.id, {
skipped: false,
hasReachedCap: usage >= allowance,
usage,
allowance,
tierCap: resourceCreditPricingInfo.tierCap,
creditBalance,
});
}
return results;
}
async setSubscriptionItemHasReachedCap(
workspaceId: string,
hasReachedCap: boolean,
): Promise<void> {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
);
const productKey = isV2
? BillingProductKey.RESOURCE_CREDIT
: BillingProductKey.WORKFLOW_NODE_EXECUTION;
const billingSubscriptionItems =
await this.billingSubscriptionItemRepository.find({
where: {
@@ -136,7 +189,7 @@ export class BillingUsageCapService {
billingProduct: {
metadata: Raw((alias) => `${alias} @> :metadata::jsonb`, {
metadata: JSON.stringify({
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
productKey,
}),
}),
},
@@ -145,7 +198,7 @@ export class BillingUsageCapService {
if (billingSubscriptionItems.length !== 1) {
throw new BillingException(
`Expected 1 metered billing subscription item for workspace ${workspaceId}, but got ${billingSubscriptionItems.length}`,
`Expected 1 billing subscription item for workspace ${workspaceId}, but got ${billingSubscriptionItems.length}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
@@ -6,6 +6,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { differenceInDays } from 'date-fns';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import {
@@ -15,6 +16,7 @@ import {
import { type BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
@@ -26,10 +28,12 @@ import { buildBillingUsageAvailableCreditsCacheKey } from 'src/engine/core-modul
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { FeatureFlagKey } from 'twenty-shared/types';
type UsageSumRow = {
total: string | number | null;
@@ -54,6 +58,7 @@ export class BillingUsageService {
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly clickHouseService: ClickHouseService,
private readonly billingUsageCapService: BillingUsageCapService,
private readonly featureFlagService: FeatureFlagService,
) {}
async canFeatureBeUsed(workspaceId: string): Promise<boolean> {
@@ -136,6 +141,79 @@ export class BillingUsageService {
);
}
async getResourceCreditProductUsage(
workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageDTO[]> {
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
);
const resourceCreditItemDetail =
await this.billingSubscriptionItemService.getResourceCreditSubscriptionItemDetails(
subscription,
);
if (!isDefined(resourceCreditItemDetail)) {
throw new BillingException(
`Resource credit item not found for workspace ${workspace.id}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
const { periodStart, periodEnd } = this.getSubscriptionPeriod(subscription);
return [
await this.buildResourceCreditUsage(
workspace.id,
subscription,
resourceCreditItemDetail,
periodStart,
periodEnd,
),
];
}
private async buildResourceCreditUsage(
workspaceId: string,
subscription: BillingSubscriptionEntity,
item: NonNullable<
Awaited<
ReturnType<
typeof this.billingSubscriptionItemService.getResourceCreditSubscriptionItemDetails
>
>
>,
periodStart: Date,
periodEnd: Date,
): Promise<BillingMeteredProductUsageDTO> {
const usedCredits = await this.getCurrentPeriodCreditsUsed(
workspaceId,
periodStart,
);
const grantedCredits =
subscription.status === SubscriptionStatus.Trialing
? item.freeTrialQuantity
: item.creditAmount;
const billingCustomer = await this.billingCustomerRepository.findOne({
where: { workspaceId },
});
const rolloverCredits = billingCustomer?.creditBalanceMicro ?? 0;
return {
productKey: item.productKey,
periodStart,
periodEnd,
usedCredits,
grantedCredits,
rolloverCredits,
totalGrantedCredits: grantedCredits + rolloverCredits,
unitPriceCents: item.unitPriceCents,
};
}
//TODO: TO be deprecated
private getSubscriptionPeriod(subscription: BillingSubscriptionEntity): {
periodStart: Date;
@@ -254,30 +332,93 @@ export class BillingUsageService {
);
}
const meteredPricingInfo =
this.meteredCreditService.extractMeteredPricingInfoFromSubscription(
subscription,
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
);
if (isV2) {
const resourceUsageCap = this.getResourceUsageCap(subscription);
const { creditBalanceMicro: creditBalance } =
await this.billingCustomerRepository.findOneOrFail({
select: { creditBalanceMicro: true },
where: { workspaceId },
});
const usage = await this.getCurrentPeriodCreditsUsed(
subscription.workspaceId,
subscription.currentPeriodStart,
);
return resourceUsageCap + creditBalance - usage;
} else {
const meteredPricingInfo =
this.meteredCreditService.extractMeteredPricingInfoFromSubscription(
subscription,
);
if (!meteredPricingInfo) {
throw new BillingException(
`No metered item found for workspace ${workspaceId}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
const [creditBalance, usage] = await Promise.all([
this.meteredCreditService.getCreditBalance(
subscription.stripeCustomerId,
meteredPricingInfo.unitPriceCents,
),
this.getCurrentPeriodCreditsUsed(
subscription.workspaceId,
subscription.currentPeriodStart,
),
]);
return meteredPricingInfo.tierCap + creditBalance - usage;
}
}
getResourceUsageCap(subscription: BillingSubscriptionEntity): number {
const isInFreeTrial = subscription.status === SubscriptionStatus.Trialing;
if (isInFreeTrial) {
const trialDuration =
isDefined(subscription.trialEnd) && isDefined(subscription.trialStart)
? differenceInDays(subscription.trialEnd, subscription.trialStart)
: 0;
const trialWithCreditCardDuration = this.twentyConfigService.get(
'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS',
);
if (!meteredPricingInfo) {
return trialDuration === trialWithCreditCardDuration
? this.twentyConfigService.get(
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD',
)
: this.twentyConfigService.get(
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
);
}
const resourceCreditItem = subscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
);
const resourceCreditPrice =
resourceCreditItem?.billingProduct.billingPrices.find(
(price) => price.stripePriceId === resourceCreditItem.stripePriceId,
);
if (!isDefined(resourceCreditPrice)) {
throw new BillingException(
`No metered item found for workspace ${workspaceId}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
`Resource credit price not found for workspace ${subscription.workspaceId}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
const [creditBalance, usage] = await Promise.all([
this.meteredCreditService.getCreditBalance(
subscription.stripeCustomerId,
meteredPricingInfo.unitPriceCents,
),
this.getCurrentPeriodCreditsUsed(
subscription.workspaceId,
subscription.currentPeriodStart,
),
]);
return meteredPricingInfo.tierCap + creditBalance - usage;
return Number(resourceCreditPrice.metadata?.credit_amount ?? 0);
}
async decrementAvailableCredits({
@@ -20,6 +20,11 @@ export type MeteredPricingInfo = {
stripeMeterId?: string;
};
export type ResourceCreditPricingInfo = {
tierCap: number;
unitPriceCents: number;
};
@Injectable()
export class MeteredCreditService {
protected readonly logger = new Logger(MeteredCreditService.name);
@@ -186,4 +191,69 @@ export class MeteredCreditService {
unitPriceCents,
);
}
// V2 path — uses productKey === RESOURCE_CREDIT; derives cap from price.metadata.credit_amount
extractResourceCreditPricingInfo(
subscription: BillingSubscriptionEntity,
): ResourceCreditPricingInfo | null {
const resourceCreditItem = subscription.billingSubscriptionItems?.find(
(item) =>
item.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
);
if (!isDefined(resourceCreditItem)) {
return null;
}
const matchingPrice =
resourceCreditItem.billingProduct?.billingPrices?.find(
(price) => price.stripePriceId === resourceCreditItem.stripePriceId,
);
if (!isDefined(matchingPrice)) {
return null;
}
const tierCap = Number(matchingPrice.metadata?.credit_amount ?? 0);
if (!Number.isFinite(tierCap) || tierCap <= 0) {
return null;
}
return {
tierCap,
unitPriceCents: matchingPrice.unitAmount ?? 0,
};
}
async getResourceCreditRolloverParameters(subscriptionId: string): Promise<{
tierQuantity: number;
unitPriceCents: number;
} | null> {
//TODO : To optimize once evaluateCapBatch is deprecated
const subscription = await this.billingSubscriptionRepository.findOne({
where: { id: subscriptionId },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
'billingSubscriptionItems.billingProduct.billingPrices',
],
});
if (!isDefined(subscription)) {
return null;
}
const pricingInfo = this.extractResourceCreditPricingInfo(subscription);
if (!isDefined(pricingInfo)) {
return null;
}
return {
tierQuantity: pricingInfo.tierCap,
unitPriceCents: pricingInfo.unitPriceCents,
};
}
}
@@ -40,4 +40,37 @@ export class StripeInvoiceService {
auto_advance: true,
});
}
async createImmediateUpgradeInvoice({
stripeCustomerId,
stripeSubscriptionId,
diffAmountInCents,
currency,
description,
}: {
stripeCustomerId: string;
stripeSubscriptionId: string;
diffAmountInCents: number;
currency: string;
description: string;
}): Promise<void> {
await this.stripe.invoiceItems.create({
customer: stripeCustomerId,
subscription: stripeSubscriptionId,
amount: diffAmountInCents,
currency,
description,
});
const invoice = await this.stripe.invoices.create({
customer: stripeCustomerId,
subscription: stripeSubscriptionId,
});
await this.stripe.invoices.finalizeInvoice(invoice.id, {
auto_advance: true,
});
await this.stripe.invoices.pay(invoice.id);
}
}
@@ -74,7 +74,10 @@ export class StripeSubscriptionScheduleService {
) {
if (!this.stripe) throw new Error('Billing is disabled');
return await this.stripe.subscriptionSchedules.update(scheduleId, params);
return await this.stripe.subscriptionSchedules.update(scheduleId, {
...params,
proration_behavior: 'none',
});
}
async createSubscriptionSchedule(stripeSubscriptionId: string) {
@@ -6,5 +6,6 @@ import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billi
export type BillingGetPlanResult = {
planKey: BillingPlanKey;
meteredProducts: BillingProductEntity[];
licensedProducts: BillingProductEntity[];
baseProducts: BillingProductEntity[];
resourceCreditProducts: BillingProductEntity[];
};
@@ -3,6 +3,7 @@
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
export type BillingGetPricesPerPlanResult = {
meteredProductsPrices: BillingPriceEntity[];
licensedProductsPrices: BillingPriceEntity[];
meteredProductPrices: BillingPriceEntity[];
baseProductPrices: BillingPriceEntity[];
resourceCreditProductPrices: BillingPriceEntity[];
};
@@ -1,8 +1,8 @@
/* @license Enterprise */
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { type BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
export type BillingPortalCheckoutSessionParameters = {
@@ -0,0 +1,11 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class BillingPriceMetadata {
@Field(() => String, { nullable: true })
credit_amount?: string;
[key: string]: string | undefined;
}
@@ -4,6 +4,7 @@ import { type SubscriptionInterval } from 'src/engine/core-modules/billing/enums
export enum SubscriptionUpdateType {
PLAN = 'PLAN',
METERED_PRICE = 'METERED_PRICE',
RESOURCE_CREDIT_PRICE = 'RESOURCE_CREDIT_PRICE',
SEATS = 'SEATS',
INTERVAL = 'INTERVAL',
}
@@ -17,6 +18,10 @@ export type SubscriptionUpdate =
type: SubscriptionUpdateType.METERED_PRICE;
newMeteredPriceId: string;
}
| {
type: SubscriptionUpdateType.RESOURCE_CREDIT_PRICE;
newResourceCreditPriceId: string;
}
| {
type: SubscriptionUpdateType.SEATS;
newSeats: number;
@@ -11,7 +11,7 @@ describe('computeSubscriptionUpdateOptions', () => {
});
expect(result).toEqual({
proration: 'create_prorations',
proration: 'always_invoice',
metadata: {
plan: BillingPlanKey.PRO,
},
@@ -25,7 +25,7 @@ describe('computeSubscriptionUpdateOptions', () => {
});
expect(result).toEqual({
proration: 'create_prorations',
proration: 'always_invoice',
metadata: {
plan: BillingPlanKey.ENTERPRISE,
},
@@ -9,7 +9,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
it('should correctly format a billing plan with licensed and metered products', () => {
const mockPlan = {
planKey: BillingPlanKey.PRO,
licensedProducts: [
baseProducts: [
{
id: 'product-1',
name: 'Test Licensed Product',
@@ -23,6 +23,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
],
},
],
resourceCreditProducts: [],
meteredProducts: [
{
id: 'product-2',
@@ -52,7 +53,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
expect(result).toEqual({
planKey: BillingPlanKey.PRO,
licensedProducts: [
baseProducts: [
{
id: 'product-1',
name: 'Test Licensed Product',
@@ -70,10 +71,12 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
unitAmount: 1500,
stripePriceId: 'price_123',
priceUsageType: BillingUsageType.LICENSED,
creditAmount: null,
},
],
},
],
resourceCreditProducts: [],
meteredProducts: [
{
id: 'product-2',
@@ -115,7 +118,8 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
it('should convert internal credits to display credits in metered tier upTo', () => {
const mockPlan = {
planKey: BillingPlanKey.PRO,
licensedProducts: [],
baseProducts: [],
resourceCreditProducts: [],
meteredProducts: [
{
id: 'product-2',
@@ -51,6 +51,7 @@ describe('transformStripePriceToDatabasePrice', () => {
transformQuantity: undefined,
usageType: BillingUsageType.LICENSED,
interval: SubscriptionInterval.Month,
metadata: {},
currencyOptions: undefined,
tiers: undefined,
recurring: {
@@ -17,7 +17,7 @@ export const computeSubscriptionUpdateOptions = (
switch (subscriptionUpdate.type) {
case SubscriptionUpdateType.PLAN:
return {
proration: 'create_prorations',
proration: 'always_invoice',
metadata: {
plan: subscriptionUpdate.newPlan,
},
@@ -26,7 +26,10 @@ export const computeSubscriptionUpdateOptions = (
return {
proration: 'create_prorations',
};
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE:
return {
proration: 'none',
};
case SubscriptionUpdateType.INTERVAL:
return {
proration: 'create_prorations',
@@ -1,20 +1,32 @@
/* @license Enterprise */
import { isDefined } from 'class-validator';
import { type BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
import { type BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
import { type BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
import { type BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { type BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import {
INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
toDisplayCredits,
} from 'src/engine/core-modules/usage/utils/to-display-credits.util';
export const formatBillingDatabaseProductToGraphqlDTO = (
plan: BillingGetPlanResult,
): BillingPlanDTO => {
return {
planKey: plan.planKey,
licensedProducts: plan.licensedProducts.map((product) => {
baseProducts: plan.baseProducts.map((product) => {
return {
...product,
prices: product.billingPrices.map(
formatBillingDatabasePriceToLicensedPriceDTO,
),
};
}),
resourceCreditProducts: plan.resourceCreditProducts.map((product) => {
return {
...product,
prices: product.billingPrices.map(
@@ -61,5 +73,9 @@ const formatBillingDatabasePriceToLicensedPriceDTO = (
unitAmount: billingPrice?.unitAmount ?? 0,
stripePriceId: billingPrice?.stripePriceId,
priceUsageType: BillingUsageType.LICENSED,
creditAmount: isDefined(billingPrice?.metadata?.credit_amount)
? Number(billingPrice?.metadata?.credit_amount) /
INTERNAL_CREDITS_PER_DISPLAY_CREDIT
: null,
};
};
@@ -0,0 +1,19 @@
/* @license Enterprise */
import { findOrThrow } from 'twenty-shared/utils';
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { type LicensedBillingSubscriptionItem } from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
// V2 counterpart of get-licensed-billing-subscription-item-or-throw.util.ts
// Identifies the base plan item by productKey === BASE_PRODUCT (not quantity != null)
export const getBaseProductSubscriptionItemOrThrow = (
billingSubscription: BillingSubscriptionEntity,
): LicensedBillingSubscriptionItem => {
return findOrThrow(
billingSubscription.billingSubscriptionItems,
({ billingProduct }) =>
billingProduct.metadata.productKey === BillingProductKey.BASE_PRODUCT,
) as LicensedBillingSubscriptionItem;
};
@@ -0,0 +1,18 @@
/* @license Enterprise */
import { findOrThrow } from 'twenty-shared/utils';
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
// V2 counterpart of get-metered-billing-subscription-item-or-throw.util.ts
// Identifies the credit item by productKey === RESOURCE_CREDIT (not quantity == null)
export const getCurrentResourceCreditSubscriptionItemOrThrow = (
billingSubscription: BillingSubscriptionEntity,
) => {
return findOrThrow(
billingSubscription.billingSubscriptionItems,
({ billingProduct }) =>
billingProduct.metadata.productKey === BillingProductKey.RESOURCE_CREDIT,
);
};
@@ -36,6 +36,7 @@ export const transformStripePriceToDatabasePrice = (data: Stripe.Price) => {
data.currency_options === null ? undefined : data.currency_options,
tiers: data.tiers === null ? undefined : data.tiers,
recurring: data.recurring === null ? undefined : data.recurring,
metadata: data.metadata ?? {},
};
};
@@ -351,6 +351,23 @@ export class LogicFunctionExecutorService {
functionName: flatLogicFunction.name,
});
let periodStart: Date | undefined;
if (this.billingService.isBillingEnabled()) {
const {
billingSubscription: { currentPeriodStart },
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'billingSubscription',
]);
periodStart = currentPeriodStart;
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: 100,
});
}
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
@@ -361,16 +378,10 @@ export class LogicFunctionExecutorService {
quantity: 1,
unit: UsageUnit.INVOCATION,
resourceId: flatLogicFunction.id,
periodStart,
},
],
workspaceId,
);
if (this.billingService.isBillingEnabled()) {
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: 100,
});
}
}
}
@@ -15,6 +15,7 @@ import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.modu
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { GenerateSdkClientJob } from 'src/engine/core-modules/sdk-client/jobs/generate-sdk-client.job';
import { SdkClientModule } from 'src/engine/core-modules/sdk-client/sdk-client.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
@@ -65,6 +66,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
CalendarEventParticipantManagerModule,
TimelineActivityModule,
StripeModule,
FeatureFlagModule,
AutoCompaniesAndContactsCreationJobModule,
TimelineJobModule,
WebhookJobModule,
@@ -798,6 +798,14 @@ export class ConfigVariables {
@IsOptional()
BILLING_USAGE_CAP_CLICKHOUSE_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description: 'Enable billing v2 for new workspaces at checkout',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
IS_BILLING_V2_ENABLED_FOR_NEW_WORKSPACES = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Url for the frontend application',
@@ -3,10 +3,16 @@ import { Module } from '@nestjs/common';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@Module({
imports: [WorkspaceEventEmitterModule, AiModelsModule, BillingModule],
imports: [
WorkspaceEventEmitterModule,
AiModelsModule,
BillingModule,
WorkspaceCacheModule,
],
providers: [AiBillingService],
exports: [AiBillingService],
})
@@ -9,6 +9,7 @@ import { BillingUsageService } from 'src/engine/core-modules/billing/services/bi
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
describe('AiBillingService', () => {
@@ -87,6 +88,16 @@ describe('AiBillingService', () => {
decrementAvailableCredits: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: WorkspaceCacheService,
useValue: {
getOrRecompute: jest.fn().mockResolvedValue({
billingSubscription: {
currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
},
}),
},
},
],
}).compile();
@@ -14,6 +14,7 @@ import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
export type BillingUsageInput = {
@@ -30,6 +31,7 @@ export class AiBillingService {
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly billingService: BillingService,
private readonly billingUsageService: BillingUsageService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
calculateCost(modelId: ModelId, billingInput: BillingUsageInput): number {
@@ -104,6 +106,23 @@ export class AiBillingService {
`Native web search billing: ${nativeWebSearchCallCount} calls, $${costInDollars.toFixed(4)}`,
);
let periodStart: Date | undefined;
if (this.billingService.isBillingEnabled()) {
const {
billingSubscription: { currentPeriodStart },
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'billingSubscription',
]);
periodStart = currentPeriodStart;
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: creditsUsedMicro,
});
}
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
@@ -114,17 +133,11 @@ export class AiBillingService {
quantity: nativeWebSearchCallCount,
unit: UsageUnit.INVOCATION,
userWorkspaceId: userWorkspaceId || null,
periodStart,
},
],
workspaceId,
);
if (this.billingService.isBillingEnabled()) {
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: creditsUsedMicro,
});
}
}
private async emitAiTokenUsageEvent(
@@ -136,6 +149,23 @@ export class AiBillingService {
agentId?: string | null,
userWorkspaceId?: string | null,
): Promise<void> {
let periodStart: Date | undefined;
if (this.billingService.isBillingEnabled()) {
const {
billingSubscription: { currentPeriodStart },
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'billingSubscription',
]);
periodStart = currentPeriodStart;
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: creditsUsedMicro,
});
}
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
@@ -148,15 +178,10 @@ export class AiBillingService {
resourceId: agentId || null,
resourceContext: modelId,
userWorkspaceId: userWorkspaceId || null,
periodStart,
},
],
workspaceId,
);
if (this.billingService.isBillingEnabled()) {
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: creditsUsedMicro,
});
}
}
}
@@ -1,4 +1,5 @@
import {
FeatureFlagKey,
type FieldMetadataType,
type ObjectsPermissions,
} from 'twenty-shared/types';
@@ -242,6 +243,7 @@ describe('WorkspaceEntityManager', () => {
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: false,
IS_DATASOURCE_MIGRATED: false,
IS_COMMAND_MENU_ITEM_ENABLED: false,
[FeatureFlagKey.IS_BILLING_V2_ENABLED]: false,
},
userWorkspaceRoleMap: {},
eventEmitterService: {
@@ -3,4 +3,5 @@ import { FeatureFlagKey } from 'twenty-shared/types';
export const DEFAULT_FEATURE_FLAGS = [
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
FeatureFlagKey.IS_BILLING_V2_ENABLED,
] as const satisfies FeatureFlagKey[];
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
@@ -30,6 +31,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
RecordCRUDActionModule,
FormActionModule,
BillingModule,
WorkspaceCacheModule,
FilterActionModule,
IfElseActionModule,
IteratorActionModule,
@@ -11,6 +11,7 @@ import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-re
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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory';
import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-step.util';
@@ -120,6 +121,16 @@ describe('WorkflowExecutorWorkspaceService', () => {
provide: BillingUsageService,
useValue: mockBillingUsageService,
},
{
provide: WorkspaceCacheService,
useValue: {
getOrRecompute: jest.fn().mockResolvedValue({
billingSubscription: {
currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
},
}),
},
},
{
provide: ExceptionHandlerService,
useValue: mockExceptionHandlerService,
@@ -225,6 +236,7 @@ describe('WorkflowExecutorWorkspaceService', () => {
quantity: 1,
unit: UsageUnit.INVOCATION,
resourceId: 'workflow-id',
periodStart: new Date('2026-04-01T00:00:00.000Z'),
},
],
'workspace-id',
@@ -707,6 +719,7 @@ describe('WorkflowExecutorWorkspaceService', () => {
quantity: 1,
unit: UsageUnit.INVOCATION,
resourceId: 'workflow-id',
periodStart: new Date('2026-04-01T00:00:00.000Z'),
},
],
'workspace-id',
@@ -22,6 +22,7 @@ import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-op
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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { workflowHasRunningSteps } from 'src/modules/workflow/common/utils/workflow-has-running-steps.util';
@@ -60,6 +61,7 @@ export class WorkflowExecutorWorkspaceService {
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
private readonly billingService: BillingService,
private readonly billingUsageService: BillingUsageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly metricsService: MetricsService,
@InjectMessageQueue(MessageQueue.workflowQueue)
@@ -363,6 +365,22 @@ export class WorkflowExecutorWorkspaceService {
workspaceId: string,
workflowId: string,
) {
let periodStart: Date | undefined;
if (this.billingService.isBillingEnabled()) {
const {
billingSubscription: { currentPeriodStart },
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'billingSubscription',
]);
periodStart = currentPeriodStart;
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: 100,
});
}
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
@@ -373,17 +391,11 @@ export class WorkflowExecutorWorkspaceService {
quantity: 1,
unit: UsageUnit.INVOCATION,
resourceId: workflowId,
periodStart,
},
],
workspaceId,
);
if (this.billingService.isBillingEnabled()) {
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: 100,
});
}
}
private async processStepExecutionResult({
@@ -13,4 +13,5 @@ export enum FeatureFlagKey {
// @deprecated - Migration is complete. Kept for backward compatibility
// until all workspaces have the flag and the flag can be removed entirely.
IS_DATASOURCE_MIGRATED = 'IS_DATASOURCE_MIGRATED',
IS_BILLING_V2_ENABLED = 'IS_BILLING_V2_ENABLED',
}