Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4fc9328a8 | |||
| a942a08330 | |||
| 0c8506cc68 | |||
| 5325cf82c1 | |||
| 0589bd9cfd | |||
| 084fc6bf80 | |||
| 74ca947875 | |||
| 889d371f89 | |||
| 336fe524de | |||
| 1e5ffcfa42 | |||
| c238326219 |
Vendored
+3
-1
@@ -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>;
|
||||
|
||||
+43
-14
@@ -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(
|
||||
|
||||
+3
-1
@@ -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`,
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
+49
-20
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+93
-48
@@ -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"
|
||||
|
||||
+17
-15
@@ -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,
|
||||
);
|
||||
|
||||
+209
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -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,
|
||||
};
|
||||
};
|
||||
+67
@@ -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) =>
|
||||
|
||||
+24
-7
@@ -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',
|
||||
)!;
|
||||
|
||||
+2
-1
@@ -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';
|
||||
|
||||
|
||||
+37
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
+409
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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,
|
||||
|
||||
+50
-5
@@ -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,
|
||||
|
||||
-15
@@ -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,
|
||||
|
||||
+1
@@ -50,6 +50,7 @@ describe('transformStripePriceEventToDatabasePrice', () => {
|
||||
transformQuantity: undefined,
|
||||
usageType: BillingUsageType.LICENSED,
|
||||
interval: SubscriptionInterval.Month,
|
||||
metadata: {},
|
||||
currencyOptions: undefined,
|
||||
tiers: undefined,
|
||||
recurring: {
|
||||
|
||||
+1
@@ -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 ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+4
@@ -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,
|
||||
],
|
||||
|
||||
+22
-3
@@ -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,
|
||||
};
|
||||
|
||||
+17
-1
@@ -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();
|
||||
|
||||
|
||||
+35
-3
@@ -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[];
|
||||
|
||||
+3
@@ -18,4 +18,7 @@ export class BillingPriceLicensedDTO {
|
||||
|
||||
@Field(() => BillingUsageType)
|
||||
priceUsageType: BillingUsageType.LICENSED;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
creditAmount: number | null;
|
||||
}
|
||||
|
||||
+4
@@ -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',
|
||||
|
||||
+2
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -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
@@ -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: {
|
||||
|
||||
+62
-16
@@ -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,
|
||||
|
||||
+3
-19
@@ -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();
|
||||
|
||||
|
||||
+37
-1
@@ -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>,
|
||||
|
||||
+29
@@ -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,
|
||||
|
||||
+23
-10
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+73
-22
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
+63
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -59,6 +59,10 @@ export class BillingProductService {
|
||||
);
|
||||
}
|
||||
|
||||
return [...plan.licensedProducts, ...plan.meteredProducts];
|
||||
return [
|
||||
...plan.baseProducts,
|
||||
...plan.resourceCreditProducts,
|
||||
...plan.meteredProducts,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+57
-3
@@ -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 {
|
||||
|
||||
+112
-19
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+450
-99
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-52
@@ -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(
|
||||
|
||||
+55
-2
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+159
-18
@@ -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({
|
||||
|
||||
+70
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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
-2
@@ -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
-1
@@ -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 = {
|
||||
|
||||
+11
@@ -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;
|
||||
}
|
||||
+5
@@ -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;
|
||||
|
||||
+2
-2
@@ -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,
|
||||
},
|
||||
|
||||
+7
-3
@@ -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',
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ describe('transformStripePriceToDatabasePrice', () => {
|
||||
transformQuantity: undefined,
|
||||
usageType: BillingUsageType.LICENSED,
|
||||
interval: SubscriptionInterval.Month,
|
||||
metadata: {},
|
||||
currencyOptions: undefined,
|
||||
tiers: undefined,
|
||||
recurring: {
|
||||
|
||||
+5
-2
@@ -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',
|
||||
|
||||
+19
-3
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+19
@@ -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;
|
||||
};
|
||||
+18
@@ -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,
|
||||
);
|
||||
};
|
||||
+1
@@ -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 ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+18
-7
@@ -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',
|
||||
|
||||
+7
-1
@@ -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],
|
||||
})
|
||||
|
||||
+11
@@ -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();
|
||||
|
||||
|
||||
+38
-13
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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: {
|
||||
|
||||
+1
@@ -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[];
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+13
@@ -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',
|
||||
|
||||
+19
-7
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user