chore(billing) - remove feature flag (#20531)

- remove feature flag
- remove old enforce cap usage logic
This commit is contained in:
Etienne
2026-05-13 18:52:29 +02:00
committed by GitHub
parent 695a374efd
commit fcd2d586ee
69 changed files with 729 additions and 4371 deletions
@@ -1487,7 +1487,6 @@ enum BillingUsageType {
enum BillingProductKey {
BASE_PRODUCT
RESOURCE_CREDIT
WORKFLOW_NODE_EXECUTION
}
type BillingPriceLicensed {
@@ -1581,7 +1580,7 @@ type BillingEndTrialPeriod {
billingPortalUrl: String
}
type BillingMeteredProductUsage {
type BillingResourceCreditUsage {
productKey: BillingProductKey!
periodStart: DateTime!
periodEnd: DateTime!
@@ -1756,7 +1755,6 @@ enum FeatureFlagKey {
IS_EMAIL_GROUP_ENABLED
IS_JUNCTION_RELATIONS_ENABLED
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED
IS_BILLING_V2_ENABLED
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
}
@@ -2963,7 +2961,7 @@ type Query {
enterpriseSubscriptionStatus: EnterpriseSubscriptionStatusDTO
billingPortalSession(returnUrlPath: String): BillingSession!
listPlans: [BillingPlan!]!
getMeteredProductsUsage: [BillingMeteredProductUsage!]!
getResourceCreditUsage: [BillingResourceCreditUsage!]!
findWorkspaceInvitations: [WorkspaceInvitation!]!
getApprovedAccessDomains: [ApprovedAccessDomain!]!
getPageLayoutTabs(pageLayoutId: String!): [PageLayoutTab!]!
@@ -3203,9 +3201,9 @@ type Mutation {
switchBillingPlan: BillingUpdate!
cancelSwitchBillingPlan: BillingUpdate!
cancelSwitchBillingInterval: BillingUpdate!
setMeteredSubscriptionPrice(priceId: String!): BillingUpdate!
setResourceCreditSubscriptionPrice(priceId: String!): BillingUpdate!
endSubscriptionTrialPeriod: BillingEndTrialPeriod!
cancelSwitchMeteredPrice: BillingUpdate!
cancelSwitchResourceCreditPrice: BillingUpdate!
deleteWorkspaceInvitation(appTokenId: String!): String!
resendWorkspaceInvitation(appTokenId: String!): SendInvitations!
sendInvitations(emails: [String!]!, roleId: UUID): SendInvitations!
@@ -1147,7 +1147,7 @@ export type BillingUsageType = 'METERED' | 'LICENSED'
/** The different billing products available */
export type BillingProductKey = 'BASE_PRODUCT' | 'RESOURCE_CREDIT' | 'WORKFLOW_NODE_EXECUTION'
export type BillingProductKey = 'BASE_PRODUCT' | 'RESOURCE_CREDIT'
export interface BillingPriceLicensed {
recurringInterval: SubscriptionInterval
@@ -1233,7 +1233,7 @@ export interface BillingEndTrialPeriod {
__typename: 'BillingEndTrialPeriod'
}
export interface BillingMeteredProductUsage {
export interface BillingResourceCreditUsage {
productKey: BillingProductKey
periodStart: Scalars['DateTime']
periodEnd: Scalars['DateTime']
@@ -1242,7 +1242,7 @@ export interface BillingMeteredProductUsage {
rolloverCredits: Scalars['Float']
totalGrantedCredits: Scalars['Float']
unitPriceCents: Scalars['Float']
__typename: 'BillingMeteredProductUsage'
__typename: 'BillingResourceCreditUsage'
}
export interface BillingPlan {
@@ -1390,7 +1390,7 @@ export interface FeatureFlag {
__typename: 'FeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_BILLING_V2_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT'
export interface WorkspaceUrls {
customUrl?: Scalars['String']
@@ -2589,7 +2589,7 @@ export interface Query {
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTO
billingPortalSession: BillingSession
listPlans: BillingPlan[]
getMeteredProductsUsage: BillingMeteredProductUsage[]
getResourceCreditUsage: BillingResourceCreditUsage[]
findWorkspaceInvitations: WorkspaceInvitation[]
getApprovedAccessDomains: ApprovedAccessDomain[]
getPageLayoutTabs: PageLayoutTab[]
@@ -2735,9 +2735,9 @@ export interface Mutation {
switchBillingPlan: BillingUpdate
cancelSwitchBillingPlan: BillingUpdate
cancelSwitchBillingInterval: BillingUpdate
setMeteredSubscriptionPrice: BillingUpdate
setResourceCreditSubscriptionPrice: BillingUpdate
endSubscriptionTrialPeriod: BillingEndTrialPeriod
cancelSwitchMeteredPrice: BillingUpdate
cancelSwitchResourceCreditPrice: BillingUpdate
deleteWorkspaceInvitation: Scalars['String']
resendWorkspaceInvitation: SendInvitations
sendInvitations: SendInvitations
@@ -4194,7 +4194,7 @@ export interface BillingEndTrialPeriodGenqlSelection{
__scalar?: boolean | number
}
export interface BillingMeteredProductUsageGenqlSelection{
export interface BillingResourceCreditUsageGenqlSelection{
productKey?: boolean | number
periodStart?: boolean | number
periodEnd?: boolean | number
@@ -5635,7 +5635,7 @@ export interface QueryGenqlSelection{
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTOGenqlSelection
billingPortalSession?: (BillingSessionGenqlSelection & { __args?: {returnUrlPath?: (Scalars['String'] | null)} })
listPlans?: BillingPlanGenqlSelection
getMeteredProductsUsage?: BillingMeteredProductUsageGenqlSelection
getResourceCreditUsage?: BillingResourceCreditUsageGenqlSelection
findWorkspaceInvitations?: WorkspaceInvitationGenqlSelection
getApprovedAccessDomains?: ApprovedAccessDomainGenqlSelection
getPageLayoutTabs?: (PageLayoutTabGenqlSelection & { __args: {pageLayoutId: Scalars['String']} })
@@ -5820,9 +5820,9 @@ export interface MutationGenqlSelection{
switchBillingPlan?: BillingUpdateGenqlSelection
cancelSwitchBillingPlan?: BillingUpdateGenqlSelection
cancelSwitchBillingInterval?: BillingUpdateGenqlSelection
setMeteredSubscriptionPrice?: (BillingUpdateGenqlSelection & { __args: {priceId: Scalars['String']} })
setResourceCreditSubscriptionPrice?: (BillingUpdateGenqlSelection & { __args: {priceId: Scalars['String']} })
endSubscriptionTrialPeriod?: BillingEndTrialPeriodGenqlSelection
cancelSwitchMeteredPrice?: BillingUpdateGenqlSelection
cancelSwitchResourceCreditPrice?: BillingUpdateGenqlSelection
deleteWorkspaceInvitation?: { __args: {appTokenId: Scalars['String']} }
resendWorkspaceInvitation?: (SendInvitationsGenqlSelection & { __args: {appTokenId: Scalars['String']} })
sendInvitations?: (SendInvitationsGenqlSelection & { __args: {emails: Scalars['String'][], roleId?: (Scalars['UUID'] | null)} })
@@ -7109,10 +7109,10 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const BillingMeteredProductUsage_possibleTypes: string[] = ['BillingMeteredProductUsage']
export const isBillingMeteredProductUsage = (obj?: { __typename?: any } | null): obj is BillingMeteredProductUsage => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingMeteredProductUsage"')
return BillingMeteredProductUsage_possibleTypes.includes(obj.__typename)
const BillingResourceCreditUsage_possibleTypes: string[] = ['BillingResourceCreditUsage']
export const isBillingResourceCreditUsage = (obj?: { __typename?: any } | null): obj is BillingResourceCreditUsage => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingResourceCreditUsage"')
return BillingResourceCreditUsage_possibleTypes.includes(obj.__typename)
}
@@ -8707,8 +8707,7 @@ export const enumBillingUsageType = {
export const enumBillingProductKey = {
BASE_PRODUCT: 'BASE_PRODUCT' as const,
RESOURCE_CREDIT: 'RESOURCE_CREDIT' as const,
WORKFLOW_NODE_EXECUTION: 'WORKFLOW_NODE_EXECUTION' as const
RESOURCE_CREDIT: 'RESOURCE_CREDIT' as const
}
export const enumSubscriptionInterval = {
@@ -8767,7 +8766,6 @@ export const enumFeatureFlagKey = {
IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const,
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' as const,
IS_BILLING_V2_ENABLED: 'IS_BILLING_V2_ENABLED' as const,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const
}
@@ -3119,7 +3119,7 @@ export default {
1
]
},
"BillingMeteredProductUsage": {
"BillingResourceCreditUsage": {
"productKey": [
137
],
@@ -5980,7 +5980,7 @@ export default {
"listPlans": [
150
],
"getMeteredProductsUsage": [
"getResourceCreditUsage": [
149
],
"findWorkspaceInvitations": [
@@ -7231,7 +7231,7 @@ export default {
"cancelSwitchBillingInterval": [
152
],
"setMeteredSubscriptionPrice": [
"setResourceCreditSubscriptionPrice": [
152,
{
"priceId": [
@@ -7243,7 +7243,7 @@ export default {
"endSubscriptionTrialPeriod": [
148
],
"cancelSwitchMeteredPrice": [
"cancelSwitchResourceCreditPrice": [
152
],
"deleteWorkspaceInvitation": [
@@ -270,7 +270,6 @@ export type FeatureFlag = {
};
export enum FeatureFlagKey {
IS_BILLING_V2_ENABLED = 'IS_BILLING_V2_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
@@ -593,18 +593,6 @@ export type BillingMeteredProduct = BillingProductDto & {
prices?: Maybe<Array<BillingPriceMetered>>;
};
export type BillingMeteredProductUsage = {
__typename?: 'BillingMeteredProductUsage';
grantedCredits: Scalars['Float'];
periodEnd: Scalars['DateTime'];
periodStart: Scalars['DateTime'];
productKey: BillingProductKey;
rolloverCredits: Scalars['Float'];
totalGrantedCredits: Scalars['Float'];
unitPriceCents: Scalars['Float'];
usedCredits: Scalars['Float'];
};
export type BillingPlan = {
__typename?: 'BillingPlan';
baseProducts: Array<BillingLicensedProduct>;
@@ -661,8 +649,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'
RESOURCE_CREDIT = 'RESOURCE_CREDIT'
}
export type BillingProductMetadata = {
@@ -672,6 +659,18 @@ export type BillingProductMetadata = {
productKey: BillingProductKey;
};
export type BillingResourceCreditUsage = {
__typename?: 'BillingResourceCreditUsage';
grantedCredits: Scalars['Float'];
periodEnd: Scalars['DateTime'];
periodStart: Scalars['DateTime'];
productKey: BillingProductKey;
rolloverCredits: Scalars['Float'];
totalGrantedCredits: Scalars['Float'];
unitPriceCents: Scalars['Float'];
usedCredits: Scalars['Float'];
};
export type BillingSession = {
__typename?: 'BillingSession';
url?: Maybe<Scalars['String']>;
@@ -1641,7 +1640,6 @@ export type FeatureFlag = {
};
export enum FeatureFlagKey {
IS_BILLING_V2_ENABLED = 'IS_BILLING_V2_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
@@ -2373,7 +2371,7 @@ export type Mutation = {
authorizeApp: AuthorizeApp;
cancelSwitchBillingInterval: BillingUpdate;
cancelSwitchBillingPlan: BillingUpdate;
cancelSwitchMeteredPrice: BillingUpdate;
cancelSwitchResourceCreditPrice: BillingUpdate;
checkCustomDomainValidRecords?: Maybe<DomainValidRecords>;
checkPublicDomainValidRecords?: Maybe<DomainValidRecords>;
checkoutSession: BillingSession;
@@ -2498,7 +2496,7 @@ export type Mutation = {
sendEmail: SendEmailOutput;
sendInvitations: SendInvitations;
setEnterpriseKey: EnterpriseLicenseInfoDto;
setMeteredSubscriptionPrice: BillingUpdate;
setResourceCreditSubscriptionPrice: BillingUpdate;
signIn: AvailableWorkspacesAndAccessTokens;
signUp: AvailableWorkspacesAndAccessTokens;
signUpInNewWorkspace: SignUp;
@@ -3224,7 +3222,7 @@ export type MutationSetEnterpriseKeyArgs = {
};
export type MutationSetMeteredSubscriptionPriceArgs = {
export type MutationSetResourceCreditSubscriptionPriceArgs = {
priceId: Scalars['String'];
};
@@ -4158,7 +4156,6 @@ export type Query = {
getConnectedImapSmtpCaldavAccount: ConnectedImapSmtpCaldavAccount;
getEmailingDomains: Array<EmailingDomain>;
getLogicFunctionSourceCode?: Maybe<Scalars['String']>;
getMeteredProductsUsage: Array<BillingMeteredProductUsage>;
getPageLayout?: Maybe<PageLayout>;
getPageLayoutTab: PageLayoutTab;
getPageLayoutTabs: Array<PageLayoutTab>;
@@ -4168,6 +4165,7 @@ export type Query = {
getPostgresCredentials?: Maybe<PostgresCredentials>;
getPublicWorkspaceDataByDomain: PublicWorkspaceData;
getPublicWorkspaceDataById: PublicWorkspaceDataSummary;
getResourceCreditUsage: Array<BillingResourceCreditUsage>;
getRoles: Array<Role>;
getSSOIdentityProviders: Array<FindAvailableSsoidp>;
getToolIndex: Array<ToolIndexEntry>;
@@ -7072,16 +7070,16 @@ export type CancelSwitchBillingIntervalMutationVariables = Exact<{ [key: string]
export type CancelSwitchBillingIntervalMutation = { __typename?: 'Mutation', cancelSwitchBillingInterval: { __typename?: 'BillingUpdate', currentBillingSubscription: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }> } };
export type CancelSwitchMeteredPriceMutationVariables = Exact<{ [key: string]: never; }>;
export type CancelSwitchMeteredPriceMutation = { __typename?: 'Mutation', cancelSwitchMeteredPrice: { __typename?: 'BillingUpdate', currentBillingSubscription: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }> } };
export type CancelSwitchBillingPlanMutationVariables = Exact<{ [key: string]: never; }>;
export type CancelSwitchBillingPlanMutation = { __typename?: 'Mutation', cancelSwitchBillingPlan: { __typename?: 'BillingUpdate', currentBillingSubscription: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }> } };
export type CancelSwitchResourceCreditPriceMutationVariables = Exact<{ [key: string]: never; }>;
export type CancelSwitchResourceCreditPriceMutation = { __typename?: 'Mutation', cancelSwitchResourceCreditPrice: { __typename?: 'BillingUpdate', currentBillingSubscription: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }> } };
export type CheckoutSessionMutationVariables = Exact<{
recurringInterval: SubscriptionInterval;
successUrlPath?: InputMaybe<Scalars['String']>;
@@ -7097,12 +7095,12 @@ export type EndSubscriptionTrialPeriodMutationVariables = Exact<{ [key: string]:
export type EndSubscriptionTrialPeriodMutation = { __typename?: 'Mutation', endSubscriptionTrialPeriod: { __typename?: 'BillingEndTrialPeriod', status?: SubscriptionStatus | null, hasPaymentMethod: boolean, billingPortalUrl?: string | null } };
export type SetMeteredSubscriptionPriceMutationVariables = Exact<{
export type SetResourceCreditSubscriptionPriceMutationVariables = Exact<{
priceId: Scalars['String'];
}>;
export type SetMeteredSubscriptionPriceMutation = { __typename?: 'Mutation', setMeteredSubscriptionPrice: { __typename?: 'BillingUpdate', currentBillingSubscription: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }> } };
export type SetResourceCreditSubscriptionPriceMutation = { __typename?: 'Mutation', setResourceCreditSubscriptionPrice: { __typename?: 'BillingUpdate', currentBillingSubscription: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }> } };
export type SwitchBillingPlanMutationVariables = Exact<{ [key: string]: never; }>;
@@ -7121,10 +7119,10 @@ export type BillingPortalSessionQueryVariables = Exact<{
export type BillingPortalSessionQuery = { __typename?: 'Query', billingPortalSession: { __typename?: 'BillingSession', url?: string | null } };
export type GetMeteredProductsUsageQueryVariables = Exact<{ [key: string]: never; }>;
export type GetResourceCreditUsageQueryVariables = Exact<{ [key: string]: never; }>;
export type GetMeteredProductsUsageQuery = { __typename?: 'Query', getMeteredProductsUsage: Array<{ __typename?: 'BillingMeteredProductUsage', productKey: BillingProductKey, usedCredits: number, grantedCredits: number, rolloverCredits: number, totalGrantedCredits: number, unitPriceCents: number }> };
export type GetResourceCreditUsageQuery = { __typename?: 'Query', getResourceCreditUsage: Array<{ __typename?: 'BillingResourceCreditUsage', productKey: BillingProductKey, usedCredits: number, grantedCredits: number, rolloverCredits: number, totalGrantedCredits: number, unitPriceCents: number }> };
export type ListPlansQueryVariables = Exact<{ [key: string]: never; }>;
@@ -8114,15 +8112,15 @@ export const UninstallApplicationDocument = {"kind":"Document","definitions":[{"
export const UpdateOneApplicationVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateOneApplicationVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOneApplicationVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}},{"kind":"Argument","name":{"kind":"Name","value":"applicationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}}}]}]}}]} as unknown as DocumentNode<UpdateOneApplicationVariableMutation, UpdateOneApplicationVariableMutationVariables>;
export const ApplicationConnectionProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ApplicationConnectionProviders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applicationConnectionProviders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"oauth"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"isClientCredentialsConfigured"}}]}}]}}]}}]} as unknown as DocumentNode<ApplicationConnectionProvidersQuery, ApplicationConnectionProvidersQueryVariables>;
export const CancelSwitchBillingIntervalDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelSwitchBillingInterval"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelSwitchBillingInterval"},"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<CancelSwitchBillingIntervalMutation, CancelSwitchBillingIntervalMutationVariables>;
export const CancelSwitchMeteredPriceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelSwitchMeteredPrice"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelSwitchMeteredPrice"},"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<CancelSwitchMeteredPriceMutation, CancelSwitchMeteredPriceMutationVariables>;
export const CancelSwitchBillingPlanDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelSwitchBillingPlan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelSwitchBillingPlan"},"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<CancelSwitchBillingPlanMutation, CancelSwitchBillingPlanMutationVariables>;
export const CancelSwitchResourceCreditPriceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelSwitchResourceCreditPrice"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelSwitchResourceCreditPrice"},"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<CancelSwitchResourceCreditPriceMutation, CancelSwitchResourceCreditPriceMutationVariables>;
export const CheckoutSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CheckoutSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recurringInterval"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubscriptionInterval"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"successUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"plan"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPlanKey"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"requirePaymentMethod"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkoutSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"recurringInterval"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recurringInterval"}}},{"kind":"Argument","name":{"kind":"Name","value":"successUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"successUrlPath"}}},{"kind":"Argument","name":{"kind":"Name","value":"plan"},"value":{"kind":"Variable","name":{"kind":"Name","value":"plan"}}},{"kind":"Argument","name":{"kind":"Name","value":"requirePaymentMethod"},"value":{"kind":"Variable","name":{"kind":"Name","value":"requirePaymentMethod"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<CheckoutSessionMutation, CheckoutSessionMutationVariables>;
export const EndSubscriptionTrialPeriodDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EndSubscriptionTrialPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endSubscriptionTrialPeriod"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"hasPaymentMethod"}},{"kind":"Field","name":{"kind":"Name","value":"billingPortalUrl"}}]}}]}}]} as unknown as DocumentNode<EndSubscriptionTrialPeriodMutation, EndSubscriptionTrialPeriodMutationVariables>;
export const SetMeteredSubscriptionPriceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetMeteredSubscriptionPrice"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"priceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setMeteredSubscriptionPrice"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"priceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"priceId"}}}],"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<SetMeteredSubscriptionPriceMutation, SetMeteredSubscriptionPriceMutationVariables>;
export const SetResourceCreditSubscriptionPriceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetResourceCreditSubscriptionPrice"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"priceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setResourceCreditSubscriptionPrice"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"priceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"priceId"}}}],"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<SetResourceCreditSubscriptionPriceMutation, SetResourceCreditSubscriptionPriceMutationVariables>;
export const SwitchBillingPlanDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SwitchBillingPlan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"switchBillingPlan"},"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<SwitchBillingPlanMutation, SwitchBillingPlanMutationVariables>;
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 GetResourceCreditUsageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetResourceCreditUsage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getResourceCreditUsage"},"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<GetResourceCreditUsageQuery, GetResourceCreditUsageQueryVariables>;
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>;
@@ -3,21 +3,18 @@ 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,
SetResourceCreditSubscriptionPriceDocument,
SubscriptionInterval,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
@@ -33,12 +30,7 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
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 nextPrice = useGetNextResourceCreditPrice();
const { formatNumber } = useNumberFormat();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
@@ -47,9 +39,8 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
currentWorkspaceState,
);
const [setMeteredSubscriptionPrice, { loading: isUpgrading }] = useMutation(
SetMeteredSubscriptionPriceDocument,
);
const [setResourceCreditSubscriptionPrice, { loading: isUpgrading }] =
useMutation(SetResourceCreditSubscriptionPriceDocument);
const { [PermissionFlagType.WORKSPACE]: hasPermissionToManageBilling } =
usePermissionFlagMap();
@@ -60,34 +51,15 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
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,
},
)
const nextResourceCreditsAmount = isDefined(nextPrice)
? formatNumber(nextPrice.creditAmount ?? 0, {
abbreviate: true,
decimals: 2,
})
: null;
const nextTierPrice = isDefined(nextPrice)
? isV2
? formatNumber(
((nextPrice as typeof nextResourceCreditPrice)?.unitAmount ?? 0) /
100,
)
: formatNumber(
((nextPrice as typeof nextMeteredBillingPrice)?.tiers?.[0]
?.flatAmount ?? 0) / 100,
)
const nextResourceCreditPrice = isDefined(nextPrice)
? formatNumber((nextPrice.unitAmount ?? 0) / 100)
: null;
const nextTierInterval = isDefined(nextPrice)
@@ -99,7 +71,7 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
const message = isTrialing
? t`You've hit your usage limit. Subscribe for more usage.`
: isDefined(nextPrice)
? t`You've hit your usage limit. \nUpgrade to ${nextTierCredits} credits for $${nextTierPrice}/${nextTierInterval}.`
? t`You've hit your usage limit. \nUpgrade to ${nextResourceCreditsAmount} credits for $${nextResourceCreditPrice}/${nextTierInterval}.`
: t`You've hit your usage limit. \nReach to our support team to upgrade.`;
const buttonTitle = isTrialing
@@ -117,21 +89,21 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
const handleUpgradeConfirm = async () => {
if (!isDefined(nextPrice)) return;
try {
const { data } = await setMeteredSubscriptionPrice({
const { data } = await setResourceCreditSubscriptionPrice({
variables: { priceId: nextPrice.stripePriceId },
});
if (
isDefined(
data?.setMeteredSubscriptionPrice.currentBillingSubscription,
data?.setResourceCreditSubscriptionPrice.currentBillingSubscription,
) &&
isDefined(currentWorkspace)
) {
setCurrentWorkspace({
...currentWorkspace,
currentBillingSubscription:
data.setMeteredSubscriptionPrice.currentBillingSubscription,
data.setResourceCreditSubscriptionPrice.currentBillingSubscription,
billingSubscriptions:
data.setMeteredSubscriptionPrice.billingSubscriptions,
data.setResourceCreditSubscriptionPrice.billingSubscriptions,
});
}
enqueueSuccessSnackBar({ message: t`Credit plan upgraded.` });
@@ -166,7 +138,7 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
<ConfirmationModal
modalInstanceId={AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID}
title={t`Get more credits`}
subtitle={t`Upgrade to ${nextTierCredits} credits for $${nextTierPrice}/${nextTierInterval}.`}
subtitle={t`Upgrade to ${nextResourceCreditsAmount} credits for $${nextResourceCreditPrice}/${nextTierInterval}.`}
onConfirmClick={handleUpgradeConfirm}
confirmButtonText={t`Upgrade`}
confirmButtonAccent="blue"
@@ -37,7 +37,6 @@ 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';
@@ -320,8 +319,7 @@ export const SettingsAdminWorkspaceBillingContent = ({
Icon:
item.productKey === BASE_PRODUCT_KEY
? IconUsers
: item.productKey === METERED_PRODUCT_KEY ||
item.productKey === RESOURCE_CREDIT_KEY
: item.productKey === RESOURCE_CREDIT_KEY
? IconCoins
: IconBox,
label: item.productName || t`Unnamed product`,
@@ -5,10 +5,8 @@ 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';
@@ -17,7 +15,6 @@ import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import {
BillingPortalSessionDocument,
FeatureFlagKey,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
export const SettingsBillingContent = () => {
@@ -33,15 +30,8 @@ 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 { isGetResourceCreditUsageQueryLoaded: isUsageQueryLoaded } =
useGetResourceCreditUsage();
const hasNotCanceledCurrentSubscription =
isDefined(subscriptionStatus) &&
@@ -1,24 +1,20 @@
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';
import { useContext } from 'react';
import { DOCUMENTATION_PATHS } from 'twenty-shared/constants';
import { SettingsPath } from 'twenty-shared/types';
import { formatToShortNumber, getSettingsPath } from 'twenty-shared/utils';
import { getSettingsPath } from 'twenty-shared/utils';
import {
H2Title,
HorizontalSeparator,
@@ -30,10 +26,7 @@ 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 {
FeatureFlagKey,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
import { SubscriptionStatus } from '~/generated-metadata/graphql';
const StyledCreditUsageFooterActions = styled.div`
display: flex;
@@ -54,40 +47,21 @@ 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,
grantedCredits,
totalGrantedCredits,
unitPriceCents,
rolloverCredits,
} = isV2 ? getResourceCreditUsage() : getWorkflowNodeExecutionUsage();
const { usedCredits, grantedCredits, totalGrantedCredits, rolloverCredits } =
getResourceCreditUsage();
const progressBarValue = (usedCredits / totalGrantedCredits) * 100;
const intervalLabel = getIntervalLabel(isMonthlyPlan);
const extraCreditsUsed = Math.max(0, usedCredits - totalGrantedCredits);
const costPerExtraCredits = unitPriceCents / 100;
const costExtraCredits = (extraCreditsUsed * unitPriceCents) / 100;
const meteredBillingPrices = getCurrentMeteredPricesByInterval(
currentBillingSubscription.interval,
);
const resourceCreditPrices = getResourceCreditPricesByInterval(
currentBillingSubscription.interval,
);
@@ -143,27 +117,6 @@ export const SettingsBillingCreditsSection = ({
})}
/>
)}
{!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>
@@ -192,17 +145,10 @@ export const SettingsBillingCreditsSection = ({
</StyledCreditUsageFooterActions>
</Section>
<Section>
{isV2 ? (
<ResourceCreditPriceSelector
resourceCreditPrices={resourceCreditPrices}
isTrialing={isTrialing}
/>
) : (
<MeteredPriceSelector
meteredBillingPrices={meteredBillingPrices}
isTrialing={isTrialing}
/>
)}
<ResourceCreditPriceSelector
resourceCreditPrices={resourceCreditPrices}
isTrialing={isTrialing}
/>
</Section>
</>
);
@@ -13,12 +13,10 @@ 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';
@@ -29,7 +27,6 @@ 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';
@@ -55,8 +52,7 @@ import {
BillingProductKey,
CancelSwitchBillingIntervalDocument,
CancelSwitchBillingPlanDocument,
CancelSwitchMeteredPriceDocument,
FeatureFlagKey,
CancelSwitchResourceCreditPriceDocument,
PermissionFlagType,
SubscriptionInterval,
SubscriptionStatus,
@@ -107,18 +103,10 @@ 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();
@@ -132,23 +120,16 @@ export const SettingsBillingSubscriptionInfo = ({
const { nextBillingPhase } = useNextBillingPhase();
const nextInterval =
splitedPhaseItemsInPrices?.nextBasePrice?.recurringInterval;
const nextMeteredBillingPrice = splitedPhaseItemsInPrices.nextMeteredPrice;
const nextResourceCreditPrice =
splitedPhaseItemsInPrices.nextResourceCreditPrice;
const subscriptionStatus = useSubscriptionStatus();
const currentInterval = isV2
? currentBillingSubscription.interval
: currentMeteredBillingPrice?.recurringInterval;
const currentInterval = currentBillingSubscription.interval;
const currentCreditsByPeriod = isV2
? (currentResourceCreditBillingPrice?.creditAmount ?? null)
: ((currentMeteredBillingPrice as { tiers?: { upTo: number }[] } | null)
?.tiers?.[0]?.upTo ?? null);
const currentCreditsByPeriod =
currentResourceCreditBillingPrice?.creditAmount ?? null;
const nextCreditsByPeriod = isV2
? (nextResourceCreditPrice?.creditAmount ?? null)
: (nextMeteredBillingPrice?.tiers?.[0]?.upTo ?? null);
const nextCreditsByPeriod = nextResourceCreditPrice?.creditAmount ?? null;
const {
getIntervalLabelAsAdjectiveCapitalize,
@@ -175,8 +156,8 @@ export const SettingsBillingSubscriptionInfo = ({
CancelSwitchBillingPlanDocument,
);
const [cancelSwitchMeteredPrice] = useMutation(
CancelSwitchMeteredPriceDocument,
const [cancelSwitchResourceCreditPrice] = useMutation(
CancelSwitchResourceCreditPriceDocument,
);
const setCurrentWorkspace = useSetAtomState(currentWorkspaceState);
@@ -237,7 +218,7 @@ export const SettingsBillingSubscriptionInfo = ({
currentBillingSubscription,
billingSubscriptions,
});
refetchUsage();
refetchResourceCreditUsage();
};
const switchInterval = async () => {
@@ -351,28 +332,26 @@ export const SettingsBillingSubscriptionInfo = ({
}
};
const cancelMeteredSwitching = async () => {
const cancelResourceCreditSwitching = async () => {
if (isAnyActionLoading || isCancellingMeteredSwitch) return;
setIsCancellingMeteredSwitch(true);
try {
const { data } = await cancelSwitchMeteredPrice();
const { data } = await cancelSwitchResourceCreditPrice();
if (
isDefined(data?.cancelSwitchMeteredPrice?.currentBillingSubscription)
isDefined(
data?.cancelSwitchResourceCreditPrice?.currentBillingSubscription,
)
) {
refreshWorkspace(data.cancelSwitchMeteredPrice);
refreshWorkspace(data.cancelSwitchResourceCreditPrice);
}
enqueueSuccessSnackBar({
message: isV2
? t`Credit pack switching has been cancelled.`
: t`Metered tier switching has been cancelled.`,
message: t`Credit pack switching has been cancelled.`,
});
} catch {
enqueueErrorSnackBar({
message: isV2
? t`Error while cancelling credit pack switching.`
: t`Error while cancelling metered tier switching.`,
message: t`Error while cancelling credit pack switching.`,
});
} finally {
setIsCancellingMeteredSwitch(false);
@@ -529,26 +508,16 @@ export const SettingsBillingSubscriptionInfo = ({
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{/*@todo: find a way to check if the metered tier match when interval change too*/}
{(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}
/>
)}
{nextResourceCreditPrice &&
currentCreditsByPeriod !== nextCreditsByPeriod && (
<Button
Icon={IconCircleX}
title={t`Cancel credit pack switching`}
variant="secondary"
onClick={() => openModal(CANCEL_SWITCH_METERED_PRICE_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
</StyledSwitchButtonContainer>
<ConfirmationModal
modalInstanceId={SWITCH_BILLING_INTERVAL_TO_YEARLY_MODAL_ID}
@@ -615,17 +584,9 @@ export const SettingsBillingSubscriptionInfo = ({
/>
<ConfirmationModal
modalInstanceId={CANCEL_SWITCH_METERED_PRICE_MODAL_ID}
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}
title={t`Cancel credit pack switching?`}
subtitle={t`You have scheduled a credit pack change. Do you want to cancel it?`}
onConfirmClick={cancelResourceCreditSwitching}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isCancellingMeteredSwitch}
@@ -1,210 +0,0 @@
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';
import {
type BillingPriceTiers,
type MeteredBillingPrice,
} from '@/settings/billing/types/billing-price-tiers.type';
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 { 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 {
SetMeteredSubscriptionPriceDocument,
SubscriptionInterval,
} 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 MeteredPriceSelector = ({
meteredBillingPrices,
isTrialing = false,
}: {
meteredBillingPrices: Array<MeteredBillingPrice>;
isTrialing?: boolean;
}) => {
const { currentMeteredBillingPrice } = useCurrentMetered();
const { formatNumber } = useNumberFormat();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const { refetchMeteredProductsUsage } = useGetWorkflowNodeExecutionUsage();
const { getIntervalLabel } = useBillingWording();
const [currentMeteredPrice, setCurrentMeteredPrice] = useState(
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, {
abbreviate: true,
decimals: 2,
});
return {
label: t`${credits} Credits - $${price}`,
value: meteredBillingPrice.stripePriceId,
};
};
const options = [...meteredBillingPrices]
.sort((a, b) => a.tiers[0].flatAmount - b.tiers[0].flatAmount)
.map(toOption);
const selectedPrice = meteredBillingPrices.find(
({ stripePriceId }) => stripePriceId === selectedPriceId,
);
const isChanged =
isDefined(selectedPriceId) &&
selectedPriceId !== currentMeteredPrice?.stripePriceId;
const isUpgrade = () => {
if (
!isChanged ||
!isDefined(selectedPrice) ||
!isDefined(currentMeteredPrice)
)
return false;
return (
(selectedPrice.tiers as BillingPriceTiers)[0].flatAmount >
(currentMeteredPrice.tiers as BillingPriceTiers)[0].flatAmount
);
};
const handleChange = (priceId: string) => {
setSelectedPriceId(priceId);
};
const confirmModalId = 'METERED_PRICE_CHANGE_CONFIRMATION_MODAL';
const handleOpenConfirm = () => {
if (!isChanged || !selectedPrice) return;
openModal(confirmModalId);
};
const recurringInterval = getIntervalLabel(
currentMeteredPrice?.recurringInterval === SubscriptionInterval.Month,
);
const handleConfirmClick = async () => {
if (!selectedPrice) return;
try {
const { data } = await setMeteredSubscriptionPrice({
variables: { priceId: selectedPrice.stripePriceId },
});
if (
isDefined(
data?.setMeteredSubscriptionPrice.currentBillingSubscription,
) &&
isDefined(currentWorkspace)
) {
const newCurrentWorkspace = {
...currentWorkspace,
currentBillingSubscription:
data.setMeteredSubscriptionPrice.currentBillingSubscription,
billingSubscriptions:
data?.setMeteredSubscriptionPrice.billingSubscriptions,
};
setCurrentWorkspace(newCurrentWorkspace);
refetchMeteredProductsUsage();
}
enqueueSuccessSnackBar({ message: t`Price updated.` });
setCurrentMeteredPrice(
findOrThrow(
meteredBillingPrices,
({ stripePriceId }) => stripePriceId === selectedPrice.stripePriceId,
),
);
setSelectedPriceId(undefined);
} catch {
enqueueErrorSnackBar({ message: t`Failed to update price.` });
}
};
return (
<>
<H2Title
title={t`Credit Plan`}
description={t`Number of new credits allocated every ${recurringInterval}`}
/>
<StyledRow>
<StyledSelectContainer>
<Select
dropdownId="settings_billing-metered-price"
options={options}
value={selectedPriceId ?? currentMeteredPrice.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 credit plan.`}
confirmButtonText={isUpgrade() ? t`Upgrade` : t`Downgrade`}
confirmButtonAccent={isUpgrade() ? 'blue' : 'danger'}
loading={isUpdating}
onConfirmClick={handleConfirmClick}
/>
</>
);
};
@@ -17,7 +17,7 @@ import { H2Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
SetMeteredSubscriptionPriceDocument,
SetResourceCreditSubscriptionPriceDocument,
SubscriptionInterval,
type BillingPriceLicensed,
} from '~/generated-metadata/graphql';
@@ -75,7 +75,7 @@ export const ResourceCreditPriceSelector = ({
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [setResourceCreditPrice, { loading: isUpdating }] = useMutation(
SetMeteredSubscriptionPriceDocument,
SetResourceCreditSubscriptionPriceDocument,
);
const options = [...resourceCreditPrices]
@@ -133,16 +133,16 @@ export const ResourceCreditPriceSelector = ({
});
if (
isDefined(
data?.setMeteredSubscriptionPrice.currentBillingSubscription,
data?.setResourceCreditSubscriptionPrice.currentBillingSubscription,
) &&
isDefined(currentWorkspace)
) {
const newCurrentWorkspace = {
...currentWorkspace,
currentBillingSubscription:
data.setMeteredSubscriptionPrice.currentBillingSubscription,
data.setResourceCreditSubscriptionPrice.currentBillingSubscription,
billingSubscriptions:
data?.setMeteredSubscriptionPrice.billingSubscriptions,
data?.setResourceCreditSubscriptionPrice.billingSubscriptions,
};
setCurrentWorkspace(newCurrentWorkspace);
refetchResourceCreditUsage();
@@ -1,8 +1,8 @@
import { gql } from '@apollo/client';
export const CANCEL_SWITCH_METERED_PRICE = gql`
mutation CancelSwitchMeteredPrice {
cancelSwitchMeteredPrice {
export const CANCEL_SWITCH_RESOURCE_CREDIT_PRICE = gql`
mutation CancelSwitchResourceCreditPrice {
cancelSwitchResourceCreditPrice {
currentBillingSubscription {
...CurrentBillingSubscriptionFragment
}
@@ -1,8 +1,8 @@
import { gql } from '@apollo/client';
export const SET_METERED_SUBSCRIPTION_PRICE = gql`
mutation SetMeteredSubscriptionPrice($priceId: String!) {
setMeteredSubscriptionPrice(priceId: $priceId) {
export const SET_RESOURCE_CREDIT_SUBSCRIPTION_PRICE = gql`
mutation SetResourceCreditSubscriptionPrice($priceId: String!) {
setResourceCreditSubscriptionPrice(priceId: $priceId) {
currentBillingSubscription {
...CurrentBillingSubscriptionFragment
}
@@ -1,8 +1,8 @@
import { gql } from '@apollo/client';
export const GET_METERED_PRODUCTS_USAGE = gql`
query GetMeteredProductsUsage {
getMeteredProductsUsage {
export const GET_RESOURCE_CREDIT_USAGE = gql`
query GetResourceCreditUsage {
getResourceCreditUsage {
productKey
usedCredits
grantedCredits
@@ -1,7 +0,0 @@
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
describe('useCurrentMetered', () => {
it('should be a function', () => {
expect(typeof useCurrentMetered).toBe('function');
});
});
@@ -1,7 +0,0 @@
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
describe('useGetWorkflowNodeExecutionUsage', () => {
it('should be a function', () => {
expect(typeof useGetWorkflowNodeExecutionUsage).toBe('function');
});
});
@@ -1,7 +1,6 @@
import { useFormatPrices } from '@/settings/billing/hooks/useFormatPrices';
import {
BillingPlanKey,
FeatureFlagKey,
SubscriptionInterval,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
@@ -11,10 +10,8 @@ import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useLingui } from '@lingui/react/macro';
import { beautifyExactDate } from '~/utils/date-utils';
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();
@@ -28,12 +25,9 @@ export const useBillingWording = () => {
assertIsDefinedOrThrow(currentBillingSubscription);
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const { formatPrices } = useFormatPrices();
const { currentPlan } = useCurrentPlan();
const { currentMeteredBillingPrice } = useCurrentMetered();
const subscriptionStatus = useSubscriptionStatus();
@@ -78,10 +72,7 @@ export const useBillingWording = () => {
const getCurrentIntervalLabel = () =>
getIntervalLabelAsAdjectiveCapitalize(
isV2
? currentBillingSubscription.interval === SubscriptionInterval.Month
: currentMeteredBillingPrice?.recurringInterval ===
SubscriptionInterval.Month,
currentBillingSubscription.interval === SubscriptionInterval.Month,
);
const enterprisePrice =
@@ -1,54 +0,0 @@
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 } from 'twenty-shared/utils';
import {
BillingProductKey,
type SubscriptionInterval,
} from '~/generated-metadata/graphql';
export const useCurrentMetered = () => {
const { currentPlan } = useCurrentPlan();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
assertIsDefinedOrThrow(currentWorkspace);
const getCurrentMeteredPricesByInterval = (
interval?: SubscriptionInterval | null,
): Array<MeteredBillingPrice> => {
const meteredPrices = currentPlan.meteredProducts
.map(({ prices }) => prices)
.flat() as Array<MeteredBillingPrice>;
return interval
? meteredPrices.filter((p) => p.recurringInterval === interval)
: meteredPrices;
};
const items =
currentWorkspace.currentBillingSubscription?.billingSubscriptionItems;
const currentMeteredBillingSubscriptionItem =
items?.find(
(it) =>
it.billingProduct.metadata?.['productKey'] ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
) ?? null;
const meteredPrices = getCurrentMeteredPricesByInterval();
const currentMeteredBillingPrice = currentMeteredBillingSubscriptionItem
? ((meteredPrices.find(
(price) =>
price.stripePriceId ===
currentMeteredBillingSubscriptionItem.stripePriceId,
) ?? null) as MeteredBillingPrice | null)
: null;
return {
currentMeteredBillingSubscriptionItem,
currentMeteredBillingPrice,
getCurrentMeteredPricesByInterval,
};
};
@@ -8,8 +8,6 @@ import {
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);
@@ -1,60 +0,0 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { usePlans } from '@/settings/billing/hooks/usePlans';
import type { MeteredBillingPrice } from '@/settings/billing/types/billing-price-tiers.type';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
type BillingPlanKey,
BillingProductKey,
} from '~/generated-metadata/graphql';
export const useGetNextMeteredBillingPrice = (): MeteredBillingPrice | null => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { listPlans, isPlansLoaded } = usePlans();
const items =
currentWorkspace?.currentBillingSubscription?.billingSubscriptionItems;
const interval = currentWorkspace?.currentBillingSubscription?.interval;
const planKey = currentWorkspace?.currentBillingSubscription?.metadata?.[
'plan'
] as BillingPlanKey | undefined;
if (!items || !planKey || !isPlansLoaded) {
return null;
}
const plans = listPlans();
const currentPlan = plans.find((plan) => plan.planKey === planKey);
if (!currentPlan) {
return null;
}
const currentMeteredItem = items.find(
(item) =>
item.billingProduct.metadata?.['productKey'] ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!currentMeteredItem) {
return null;
}
const meteredPrices = currentPlan.meteredProducts.flatMap(
({ prices }) => prices,
) as Array<MeteredBillingPrice>;
const pricesForInterval = meteredPrices
.filter((price) => price.recurringInterval === interval)
.sort(
(priceA, priceB) =>
priceA.tiers[0].flatAmount - priceB.tiers[0].flatAmount,
);
const currentIndex = pricesForInterval.findIndex(
({ stripePriceId }) => stripePriceId === currentMeteredItem.stripePriceId,
);
if (currentIndex === -1 || currentIndex === pricesForInterval.length - 1) {
return null;
}
return pricesForInterval[currentIndex + 1];
};
@@ -2,20 +2,18 @@ import { useQuery } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import {
BillingProductKey,
GetMeteredProductsUsageDocument,
GetResourceCreditUsageDocument,
} 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 { data, loading, refetch } = useQuery(GetResourceCreditUsageDocument);
const refetchResourceCreditUsage = () => {
refetch();
};
const isGetResourceCreditUsageQueryLoaded = () => {
return isDefined(data?.getMeteredProductsUsage) && !loading;
return isDefined(data?.getResourceCreditUsage) && !loading;
};
const getResourceCreditUsage = () => {
@@ -23,7 +21,7 @@ export const useGetResourceCreditUsage = () => {
throw new Error('getResourceCreditUsage was not loaded');
}
const usage = data.getMeteredProductsUsage.find(
const usage = data.getResourceCreditUsage.find(
(productUsage) =>
productUsage.productKey === BillingProductKey.RESOURCE_CREDIT,
);
@@ -1,41 +0,0 @@
import { useQuery } from '@apollo/client/react';
import {
BillingProductKey,
GetMeteredProductsUsageDocument,
} from '~/generated-metadata/graphql';
import { findOrThrow } from 'twenty-shared/utils';
export const useGetWorkflowNodeExecutionUsage = () => {
const { data, loading, refetch } = useQuery(GetMeteredProductsUsageDocument);
const refetchMeteredProductsUsage = () => {
refetch();
};
const isGetMeteredProductsUsageQueryLoaded = () => {
return data?.getMeteredProductsUsage && !loading;
};
const getMeteredProductUsage = () => {
if (!data) {
throw new Error('getMeteredProductUsage was not loaded');
}
return data.getMeteredProductsUsage;
};
const getWorkflowNodeExecutionUsage = () => {
return findOrThrow(
getMeteredProductUsage(),
(productUsage) =>
productUsage.productKey === BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
};
return {
refetchMeteredProductsUsage,
isGetMeteredProductsUsageQueryLoaded:
isGetMeteredProductsUsageQueryLoaded(),
getWorkflowNodeExecutionUsage,
};
};
@@ -1,11 +1,8 @@
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';
@@ -13,7 +10,6 @@ export const useSplitPhaseItemsInPrices = () => {
const { nextBillingPhase } = useNextBillingPhase();
const { getPriceAndBillingUsageByPriceId } =
usePriceAndBillingUsageByPriceId();
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const splitedPhaseItemsInPrices = (nextBillingPhase?.items ?? []).reduce(
(acc, item) => {
@@ -21,27 +17,17 @@ export const useSplitPhaseItemsInPrices = () => {
item.price,
);
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;
if (billingUsage === BillingUsageType.LICENSED) {
const licensedPrice = price as BillingPriceLicensed;
if (isDefined(licensedPrice.creditAmount)) {
acc.nextResourceCreditPrice = licensedPrice;
} else {
acc.nextBasePrice = licensedPrice;
}
}
return acc;
},
{} as {
nextMeteredPrice: MeteredBillingPrice | undefined;
nextBasePrice: BillingPriceLicensed | undefined;
nextResourceCreditPrice: BillingPriceLicensed | undefined;
},
@@ -16,7 +16,7 @@ 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 { GET_RESOURCE_CREDIT_USAGE } from '@/settings/billing/graphql/queries/getResourceCreditUsage';
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';
@@ -530,28 +530,19 @@ export const graphqlMocks = {
data: mockBillingPlans,
});
}),
graphql.query(getOperationName(GET_METERED_PRODUCTS_USAGE) ?? '', () => {
graphql.query(getOperationName(GET_RESOURCE_CREDIT_USAGE) ?? '', () => {
return HttpResponse.json({
data: {
getMeteredProductsUsage: [
getResourceCreditUsage: [
{
__typename: 'BillingMeteredProductUsage',
productKey: 'WORKFLOW_NODE_EXECUTION',
__typename: 'BillingResourceCreditUsage',
productKey: 'RESOURCE_CREDIT',
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,
},
],
},
});
@@ -46,7 +46,7 @@ export const mockBillingPlans = {
images: [],
metadata: {
__typename: 'BillingProductMetadata',
productKey: 'WORKFLOW_NODE_EXECUTION',
productKey: 'RESOURCE_CREDIT',
planKey: 'PRO',
priceUsageBased: 'METERED',
},
@@ -339,7 +339,7 @@ export const mockBillingPlans = {
images: [],
metadata: {
__typename: 'BillingProductMetadata',
productKey: 'WORKFLOW_NODE_EXECUTION',
productKey: 'RESOURCE_CREDIT',
planKey: 'ENTERPRISE',
priceUsageBased: 'METERED',
},
@@ -5,12 +5,10 @@ import { Command, CommandRunner } from 'nest-commander';
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/commands/stale-registration-cleanup.cron.command';
import { ApplicationVersionCheckCronCommand } from 'src/engine/core-modules/application/application-upgrade/crons/commands/application-version-check.cron.command';
import { EnforceUsageCapCronCommand } from 'src/engine/core-modules/billing/crons/commands/enforce-usage-cap.cron.command';
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/cleanup/commands/event-log-cleanup.cron.command';
import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command';
import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command';
import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command';
@@ -59,11 +57,9 @@ export class CronRegisterAllCommand extends CommandRunner {
private readonly trashCleanupCronCommand: TrashCleanupCronCommand,
private readonly eventLogCleanupCronCommand: EventLogCleanupCronCommand,
private readonly enterpriseKeyValidationCronCommand: EnterpriseKeyValidationCronCommand,
private readonly twentyConfigService: TwentyConfigService,
private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand,
private readonly applicationVersionCheckCronCommand: ApplicationVersionCheckCronCommand,
private readonly staleRegistrationCleanupCronCommand: StaleRegistrationCleanupCronCommand,
private readonly enforceUsageCapCronCommand: EnforceUsageCapCronCommand,
) {
super();
}
@@ -164,10 +160,6 @@ export class CronRegisterAllCommand extends CommandRunner {
name: 'StaleRegistrationCleanup',
command: this.staleRegistrationCleanupCronCommand,
},
{
name: 'EnforceUsageCap',
command: this.enforceUsageCapCronCommand,
},
];
let successCount = 0;
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.command';
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
import { GenerateInstanceCommandCommand } from 'src/database/commands/generate-instance-command.command';
@@ -8,6 +9,7 @@ import { InstallPreInstalledAppsCommand } from 'src/database/commands/install-pr
import { InstanceCommandGenerationService } from 'src/database/commands/instance-command-generation.service';
import { ListOrphanedWorkspaceEntitiesCommand } from 'src/database/commands/list-and-delete-orphaned-workspace-entities.command';
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
import { RebuildApplicationDefaultDepsCommand } from 'src/database/commands/rebuild-application-default-deps.command';
import { RunInstanceCommandsCommand } from 'src/database/commands/run-instance-commands.command';
import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module';
import { WorkspaceExportModule } from 'src/database/commands/workspace-export/workspace-export.module';
@@ -16,13 +18,9 @@ import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command';
import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module';
import { StaleRegistrationCleanupModule } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/stale-registration-cleanup.module';
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module';
import { RebuildApplicationDefaultDepsCommand } from 'src/database/commands/rebuild-application-default-deps.command';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { EnforceUsageCapCronCommand } from 'src/engine/core-modules/billing/crons/commands/enforce-usage-cap.cron.command';
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { EventLogCleanupModule } from 'src/engine/core-modules/event-logs/cleanup/event-log-cleanup.module';
@@ -38,6 +36,7 @@ import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-seeder.module';
import { WorkspaceCleanerModule } from 'src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
@@ -96,7 +95,6 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
ListOrphanedWorkspaceEntitiesCommand,
EnterpriseKeyValidationCronCommand,
GenerateApiKeyCommand,
EnforceUsageCapCronCommand,
UpgradeStatusCommand,
RebuildApplicationDefaultDepsCommand,
InstallPreInstalledAppsCommand,
@@ -1,409 +1,30 @@
/* @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.4.0', 1797000001000)
@Command({
name: 'upgrade:2-4:migrate-to-billing-v2',
description:
'Swap WORKFLOW_NODE_EXECUTION subscription items to RESOURCE_CREDIT for all V1 workspaces',
'No-op: all workspaces are now on the resource-credit billing model',
})
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`,
this.logger.debug(
`Workspace ${workspaceId}: already on resource-credit billing, nothing to do`,
);
}
}
@@ -16,7 +16,6 @@ import { type Response } from 'express';
import Stripe from 'stripe';
import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service';
import { BillingWebhookCreditGrantService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-credit-grant.service';
import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service';
import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service';
@@ -51,7 +50,6 @@ export class BillingWebhookController {
private readonly billingWebhookInvoiceService: BillingWebhookInvoiceService,
private readonly billingWebhookCustomerService: BillingWebhookCustomerService,
private readonly billingWebhookSubscriptionScheduleService: BillingWebhookSubscriptionScheduleService,
private readonly billingWebhookCreditGrantService: BillingWebhookCreditGrantService,
) {}
@Post(['webhooks/stripe'])
@@ -154,25 +152,6 @@ export class BillingWebhookController {
);
}
case BillingWebhookEvent.CREDIT_GRANT_CREATED:
case BillingWebhookEvent.CREDIT_GRANT_UPDATED: {
const customer = event.data.object.customer;
// customer can be string ID, Customer object, or DeletedCustomer object
const stripeCustomerId =
typeof customer === 'string' ? customer : customer?.id;
if (!stripeCustomerId) {
throw new BillingException(
'Customer ID is required for credit grant events',
BillingExceptionCode.BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND,
);
}
return await this.billingWebhookCreditGrantService.processStripeEvent(
stripeCustomerId,
);
}
default:
return {};
}
@@ -4,7 +4,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { BillingWebhookController } from 'src/engine/core-modules/billing-webhook/billing-webhook.controller';
import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service';
import { BillingWebhookCreditGrantService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-credit-grant.service';
import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service';
import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service';
@@ -65,7 +64,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingWebhookSubscriptionService,
BillingWebhookSubscriptionScheduleService,
BillingWebhookEntitlementService,
BillingWebhookCreditGrantService,
],
})
export class BillingWebhookModule {}
@@ -1,52 +0,0 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
@Injectable()
export class BillingWebhookCreditGrantService {
constructor(
private readonly meteredCreditService: MeteredCreditService,
private readonly billingSubscriptionService: BillingSubscriptionService,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
) {}
async processStripeEvent(stripeCustomerId: string): Promise<void> {
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscription({
stripeCustomerId,
});
if (!isDefined(subscription)) {
return;
}
const meteredPricingInfo =
await this.meteredCreditService.getMeteredPricingInfo(subscription.id);
if (isDefined(meteredPricingInfo)) {
const creditBalanceMicro =
await this.meteredCreditService.getCreditBalance(
stripeCustomerId,
meteredPricingInfo.unitPriceCents,
);
await this.billingCustomerRepository.update(
{ stripeCustomerId },
{ creditBalanceMicro },
);
}
await this.meteredCreditService.recreateBillingAlertForSubscription(
subscription,
);
}
}
@@ -1,8 +1,6 @@
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';
@@ -19,13 +17,11 @@ import {
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.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 { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
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 { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-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,9 +39,8 @@ export class BillingWebhookInvoiceService {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingCreditRolloverService: BillingCreditRolloverService,
private readonly meteredCreditService: MeteredCreditService,
private readonly resourceCreditService: ResourceCreditService,
private readonly stripeInvoiceService: StripeInvoiceService,
private readonly featureFlagService: FeatureFlagService,
private readonly auditService: AuditService,
) {}
@@ -115,85 +110,28 @@ export class BillingWebhookInvoiceService {
Math.abs(periodStart - trialEnd) <= TRIAL_END_TOLERANCE_SECONDS;
if (periodStart && !isFirstPeriodAfterTrial) {
await this.processRollover(
subscription,
new Date(periodStart * 1000),
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),
);
await this.processRollover(subscription, new Date(periodStart * 1000));
}
}
private async processRollover(
subscription: BillingSubscriptionEntity,
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(
const params =
await this.resourceCreditService.getResourceCreditRolloverParameters(
subscription.id,
);
if (!isDefined(rolloverParams)) {
if (!isDefined(params)) {
return;
}
// The invoice covers the period that just ended (invoicedPeriodStart to invoicedPeriodEnd)
// We need to calculate unused credits from this period and roll them over
// Credits should expire at the end of the NEXT period
const nextPeriodEnd = this.calculateNextPeriodEnd(
invoicedPeriodEnd,
subscription.interval,
);
await this.billingCreditRolloverService.processRolloverOnPeriodTransition({
workspaceId: subscription.workspaceId,
stripeCustomerId: subscription.stripeCustomerId,
subscriptionId: subscription.id,
stripeMeterId: rolloverParams.stripeMeterId,
tierQuantity: params.tierQuantity,
previousPeriodStart: invoicedPeriodStart,
previousPeriodEnd: invoicedPeriodEnd,
newPeriodEnd: nextPeriodEnd,
tierQuantity: rolloverParams.tierQuantity,
unitPriceCents: rolloverParams.unitPriceCents,
});
}
@@ -284,15 +222,4 @@ export class BillingWebhookInvoiceService {
suspendedAt: new Date(),
});
}
private calculateNextPeriodEnd(
periodEnd: Date,
interval: SubscriptionInterval,
): Date {
if (interval === SubscriptionInterval.Year) {
return addYears(periodEnd, 1);
}
return addMonths(periodEnd, 1);
}
}
@@ -10,7 +10,6 @@ import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolve
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
import { BillingSyncPlansDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-plans-data.command';
import { BillingUpdateSubscriptionPriceCommand } from 'src/engine/core-modules/billing/commands/billing-update-subscription-price.command';
import { EnforceUsageCapCronCommand } from 'src/engine/core-modules/billing/crons/commands/enforce-usage-cap.cron.command';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingMeterEntity } from 'src/engine/core-modules/billing/entities/billing-meter.entity';
@@ -19,7 +18,6 @@ import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/b
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 { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
import { BillingUsageEventListener } from 'src/engine/core-modules/billing/listeners/billing-usage-event.listener';
import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener';
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
@@ -33,7 +31,7 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-credit.service';
import { WorkspaceBillingSubscriptionCacheService } from 'src/engine/core-modules/billing/services/workspace-billing-subscription-cache.service';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
@@ -82,7 +80,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingResolver,
BillingPlanService,
BillingWorkspaceMemberListener,
BillingUsageEventListener,
BillingService,
BillingRestApiExceptionFilter,
BillingSyncCustomerDataCommand,
@@ -92,9 +89,8 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingUsageCapService,
BillingPriceService,
BillingCreditRolloverService,
MeteredCreditService,
ResourceCreditService,
BillingGaugeService,
EnforceUsageCapCronCommand,
WorkspaceBillingSubscriptionCacheService,
],
exports: [
@@ -107,8 +103,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingUsageService,
BillingUsageCapService,
BillingCreditRolloverService,
MeteredCreditService,
EnforceUsageCapCronCommand,
ResourceCreditService,
],
})
export class BillingModule {}
@@ -10,7 +10,7 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
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 { BillingResourceCreditUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-resource-credit-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';
@@ -25,7 +25,6 @@ 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 {
@@ -48,8 +47,6 @@ 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 { FeatureFlagKey } from 'twenty-shared/types';
@MetadataResolver()
@UsePipes(ResolverValidationPipe)
@UseFilters(
@@ -65,7 +62,6 @@ export class BillingResolver {
private readonly billingService: BillingService,
private readonly billingUsageService: BillingUsageService,
private readonly permissionsService: PermissionsService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Query(() => BillingSessionDTO)
@@ -237,27 +233,15 @@ export class BillingResolver {
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
async setMeteredSubscriptionPrice(
async setResourceCreditSubscriptionPrice(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args() { priceId }: BillingUpdateSubscriptionItemPriceInput,
) {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
await this.billingSubscriptionUpdateService.changeResourceCreditPrice(
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(
@@ -310,23 +294,16 @@ export class BillingResolver {
};
}
@Query(() => [BillingMeteredProductUsageDTO])
@Query(() => [BillingResourceCreditUsageDTO])
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
//TODO: To rename to getResourceCreditProductsUsage
async getMeteredProductsUsage(
async getResourceCreditUsage(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageDTO[]> {
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);
): Promise<BillingResourceCreditUsageDTO[]> {
const usageData =
await this.billingUsageService.getResourceCreditProductUsage(workspace);
return usageData.map((item) => ({
...item,
@@ -343,8 +320,10 @@ export class BillingResolver {
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
async cancelSwitchMeteredPrice(@AuthWorkspace() workspace: WorkspaceEntity) {
await this.billingSubscriptionUpdateService.cancelSwitchMeteredPrice(
async cancelSwitchResourceCreditPrice(
@AuthWorkspace() workspace: WorkspaceEntity,
) {
await this.billingSubscriptionUpdateService.cancelSwitchResourceCreditPrice(
workspace,
);
@@ -1,377 +0,0 @@
/* @license Enterprise */
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { In } from 'typeorm';
import { EnforceUsageCapJob } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.job';
import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/billing-product.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 { 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';
const METERED_STRIPE_PRICE_ID = 'price_metered';
describe('EnforceUsageCapJob', () => {
let job: EnforceUsageCapJob;
let billingSubscriptionFindMock: jest.Mock;
let billingSubscriptionItemRepository: jest.Mocked<{
update: jest.Mock;
}>;
let billingUsageCapService: jest.Mocked<
Pick<
BillingUsageCapService,
| 'isClickHouseEnabled'
| 'getBatchPeriodCreditsUsed'
| 'evaluateCapBatch'
| 'evaluateCapBatchV2'
>
>;
let twentyConfigService: jest.Mocked<TwentyConfigService>;
const buildSubscription = ({
id = 'sub_123',
workspaceId = 'workspace_123',
itemId = 'item_123',
hasReachedCurrentPeriodCap = false,
stripeCustomerId = 'cus_123',
creditBalanceMicro = 0,
} = {}) =>
({
id,
workspaceId,
stripeCustomerId,
currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
currentPeriodEnd: new Date('2026-05-01T00:00:00Z'),
billingCustomer: {
stripeCustomerId,
creditBalanceMicro,
},
billingSubscriptionItems: [
{
id: itemId,
hasReachedCurrentPeriodCap,
stripeProductId: METERED_STRIPE_PRODUCT_ID,
stripePriceId: METERED_STRIPE_PRICE_ID,
},
],
}) as unknown as BillingSubscriptionEntity;
beforeEach(async () => {
billingSubscriptionFindMock = jest.fn().mockResolvedValue([]);
const module: TestingModule = await Test.createTestingModule({
providers: [
EnforceUsageCapJob,
{
provide: getRepositoryToken(BillingSubscriptionEntity),
useValue: { find: billingSubscriptionFindMock },
},
{
provide: getRepositoryToken(BillingSubscriptionItemEntity),
useValue: { update: jest.fn() },
},
{
provide: getRepositoryToken(BillingProductEntity),
useValue: {
find: jest.fn().mockResolvedValue([
{
stripeProductId: METERED_STRIPE_PRODUCT_ID,
metadata: {
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
},
billingPrices: [],
},
]),
},
},
{
provide: BillingUsageCapService,
useValue: {
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();
job = module.get<EnforceUsageCapJob>(EnforceUsageCapJob);
billingSubscriptionItemRepository = module.get(
getRepositoryToken(BillingSubscriptionItemEntity),
);
billingUsageCapService = module.get(BillingUsageCapService);
twentyConfigService = module.get(TwentyConfigService);
});
afterEach(() => {
jest.clearAllMocks();
});
const mockConfig = (overrides: Record<string, unknown> = {}) => {
const values: Record<string, unknown> = {
IS_BILLING_ENABLED: true,
BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: false,
...overrides,
};
twentyConfigService.get.mockImplementation(
(key: string) => values[key] as never,
);
};
it('no-ops when billing is disabled', async () => {
mockConfig({ IS_BILLING_ENABLED: false });
await job.handle();
expect(billingSubscriptionFindMock).not.toHaveBeenCalled();
});
it('no-ops when ClickHouse is not configured', async () => {
mockConfig();
billingUsageCapService.isClickHouseEnabled.mockReturnValue(false);
await job.handle();
expect(billingSubscriptionFindMock).not.toHaveBeenCalled();
});
it('skips transitions in shadow mode (flag off)', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: false });
const sub = buildSubscription({ hasReachedCurrentPeriodCap: false });
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map([['workspace_123', 2_000_000]]),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{
skipped: false as const,
hasReachedCap: true,
usage: 2_000_000,
allowance: 1_000_000,
tierCap: 1_000_000,
creditBalance: 0,
},
],
]),
);
await job.handle();
expect(billingSubscriptionItemRepository.update).not.toHaveBeenCalled();
});
it('batch-updates hasReachedCurrentPeriodCap=true in active mode when usage exceeds allowance', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription({
itemId: 'item_123',
hasReachedCurrentPeriodCap: false,
});
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map([['workspace_123', 2_000_000]]),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{
skipped: false as const,
hasReachedCap: true,
usage: 2_000_000,
allowance: 1_000_000,
tierCap: 1_000_000,
creditBalance: 0,
},
],
]),
);
await job.handle();
expect(billingSubscriptionItemRepository.update).toHaveBeenCalledWith(
{ id: In(['item_123']) },
{ hasReachedCurrentPeriodCap: true },
);
});
it('batch-updates hasReachedCurrentPeriodCap=false in active mode when usage drops below allowance', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription({
itemId: 'item_123',
hasReachedCurrentPeriodCap: true,
});
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map([['workspace_123', 500_000]]),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{
skipped: false as const,
hasReachedCap: false,
usage: 500_000,
allowance: 1_000_000,
tierCap: 1_000_000,
creditBalance: 0,
},
],
]),
);
await job.handle();
expect(billingSubscriptionItemRepository.update).toHaveBeenCalledWith(
{ id: In(['item_123']) },
{ hasReachedCurrentPeriodCap: false },
);
});
it('does not update when state already matches', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription({ hasReachedCurrentPeriodCap: true });
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map([['workspace_123', 2_000_000]]),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{
skipped: false as const,
hasReachedCap: true,
usage: 2_000_000,
allowance: 1_000_000,
tierCap: 1_000_000,
creditBalance: 0,
},
],
]),
);
await job.handle();
expect(billingSubscriptionItemRepository.update).not.toHaveBeenCalled();
});
it('skips subscriptions without a metered item', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription();
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map(),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{ skipped: true as const, reason: 'no-metered-item' as const },
],
]),
);
await job.handle();
expect(billingSubscriptionItemRepository.update).not.toHaveBeenCalled();
});
it('passes credit balance from billingCustomer to evaluateCapBatch', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription({
stripeCustomerId: 'cus_456',
creditBalanceMicro: 300_000,
});
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map(),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(new Map());
await job.handle();
expect(billingUsageCapService.evaluateCapBatch).toHaveBeenCalledWith(
[sub],
expect.any(Map),
new Map([['cus_456', 300_000]]),
);
});
it('skips subscriptions whose ClickHouse query failed', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription({ hasReachedCurrentPeriodCap: true });
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockRejectedValue(
new Error('clickhouse exploded'),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{
skipped: false as const,
hasReachedCap: false,
usage: 0,
allowance: 1_000_000,
tierCap: 1_000_000,
creditBalance: 0,
},
],
]),
);
await expect(job.handle()).resolves.not.toThrow();
expect(billingSubscriptionItemRepository.update).not.toHaveBeenCalled();
});
});
@@ -1,33 +0,0 @@
/* @license Enterprise */
import { Command, CommandRunner } from 'nest-commander';
import { enforceUsageCapCronPattern } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.cron.pattern';
import { EnforceUsageCapJob } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@Command({
name: 'cron:billing:enforce-usage-cap',
description:
'Starts the cron that re-evaluates metered-credit caps from ClickHouse usage',
})
export class EnforceUsageCapCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>({
jobName: EnforceUsageCapJob.name,
data: undefined,
options: {
repeat: { pattern: enforceUsageCapCronPattern },
},
});
}
}
@@ -1,9 +0,0 @@
/* @license Enterprise */
// Poll every 2 minutes.
// hasReachedCurrentPeriodCap is reset to false at the start of each billing
// period by BillingWebhookInvoiceService (on subscription_cycle invoice),
// on trial end (BillingSubscriptionService), and on plan changes
// (BillingSubscriptionUpdateService). This cron flips it true/false based
// on live ClickHouse usage within the current period.
export const enforceUsageCapCronPattern = '*/2 * * * *';
@@ -1,296 +0,0 @@
/* @license Enterprise */
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';
import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/billing-product.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 { 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';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const BATCH_SIZE = 100;
@Injectable()
@Processor(MessageQueue.cronQueue)
export class EnforceUsageCapJob {
private readonly logger = new Logger(EnforceUsageCapJob.name);
constructor(
@InjectRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
@InjectRepository(BillingSubscriptionItemEntity)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
@InjectRepository(BillingProductEntity)
private readonly billingProductRepository: Repository<BillingProductEntity>,
private readonly billingUsageCapService: BillingUsageCapService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Process(EnforceUsageCapJob.name)
@SentryCronMonitor(EnforceUsageCapJob.name, enforceUsageCapCronPattern)
async handle(): Promise<void> {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return;
}
if (!this.billingUsageCapService.isClickHouseEnabled()) {
this.logger.debug(
'ClickHouse is not configured; skipping usage cap enforcement',
);
return;
}
const isEnforcementActive = this.twentyConfigService.get(
'BILLING_USAGE_CAP_CLICKHOUSE_ENABLED',
);
let evaluated = 0;
let transitioned = 0;
let errors = 0;
let offset = 0;
const allProducts = await this.billingProductRepository.find({
relations: { billingPrices: true },
});
const productByStripeProductId = new Map(
allProducts.map((product) => [product.stripeProductId, product]),
);
let batch: BillingSubscriptionEntity[];
let idRows: Pick<BillingSubscriptionEntity, 'id'>[];
do {
idRows = await this.billingSubscriptionRepository.find({
select: { id: true },
relations: {
workspace: true,
},
where: {
status: In([
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
SubscriptionStatus.PastDue,
]),
workspace: { suspendedAt: IsNull() },
},
order: { id: 'ASC' },
take: BATCH_SIZE,
skip: offset,
});
if (idRows.length === 0) {
break;
}
const ids = idRows.map((row) => row.id);
batch = await this.billingSubscriptionRepository.find({
where: { id: In(ids) },
relations: {
billingCustomer: true,
billingSubscriptionItems: true,
},
});
if (batch.length === 0) {
break;
}
const periodGroups = this.groupByPeriod(batch);
const usageByWorkspace = new Map<string, number>();
const failedWorkspaceIds = new Set<string>();
for (const [, group] of periodGroups) {
try {
const workspaceIds = group.map((s) => s.workspaceId);
const batchUsage =
await this.billingUsageCapService.getBatchPeriodCreditsUsed(
workspaceIds,
group[0].currentPeriodStart,
);
for (const [id, usage] of batchUsage) {
usageByWorkspace.set(id, usage);
}
} catch (error) {
for (const sub of group) {
failedWorkspaceIds.add(sub.workspaceId);
}
errors += group.length;
this.logger.error(
`Failed to fetch batch usage from ClickHouse for ${group.length} subscriptions`,
error instanceof Error ? error.stack : String(error),
);
}
}
const creditBalanceByCustomer = new Map<string, number>();
for (const subscription of batch) {
if (subscription.billingCustomer) {
creditBalanceByCustomer.set(
subscription.stripeCustomerId,
subscription.billingCustomer.creditBalanceMicro,
);
}
for (const item of subscription.billingSubscriptionItems ?? []) {
const product = productByStripeProductId.get(item.stripeProductId);
if (product) {
item.billingProduct = product;
}
}
}
// 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[] = [];
for (const subscription of batch) {
if (failedWorkspaceIds.has(subscription.workspaceId)) {
continue;
}
const evaluation = evaluations.get(subscription.id);
if (!evaluation || evaluation.skipped) {
continue;
}
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 === targetProductKey,
);
if (!meteredItem) {
continue;
}
const shouldBeCapped = evaluation.hasReachedCap;
if (meteredItem.hasReachedCurrentPeriodCap === shouldBeCapped) {
continue;
}
if (!isEnforcementActive) {
this.logger.log(
`[shadow] would set hasReachedCurrentPeriodCap=${shouldBeCapped} ` +
`for subscription=${subscription.id} workspace=${subscription.workspaceId} ` +
`usage=${evaluation.usage} allowance=${evaluation.allowance} ` +
`tierCap=${evaluation.tierCap} creditBalance=${evaluation.creditBalance}`,
);
continue;
}
if (shouldBeCapped) {
idsToCapTrue.push(meteredItem.id);
} else {
idsToCapFalse.push(meteredItem.id);
}
this.logger.log(
`Set hasReachedCurrentPeriodCap=${shouldBeCapped} ` +
`for subscription=${subscription.id} workspace=${subscription.workspaceId} ` +
`usage=${evaluation.usage} allowance=${evaluation.allowance} ` +
`tierCap=${evaluation.tierCap} creditBalance=${evaluation.creditBalance}`,
);
}
if (idsToCapTrue.length > 0) {
await this.billingSubscriptionItemRepository.update(
{ id: In(idsToCapTrue) },
{ hasReachedCurrentPeriodCap: true },
);
transitioned += idsToCapTrue.length;
}
if (idsToCapFalse.length > 0) {
await this.billingSubscriptionItemRepository.update(
{ id: In(idsToCapFalse) },
{ hasReachedCurrentPeriodCap: false },
);
transitioned += idsToCapFalse.length;
}
offset += idRows.length;
} while (idRows.length === BATCH_SIZE);
this.logger.log(
`Usage cap enforcement run complete: evaluated=${evaluated} ` +
`transitioned=${transitioned} errors=${errors} ` +
`mode=${isEnforcementActive ? 'active' : 'shadow'}`,
);
}
private groupByPeriod(
subscriptions: BillingSubscriptionEntity[],
): Map<string, BillingSubscriptionEntity[]> {
const groups = new Map<string, BillingSubscriptionEntity[]>();
for (const subscription of subscriptions) {
const key = `${formatDateForClickHouse(subscription.currentPeriodStart)}`;
const group = groups.get(key);
if (group) {
group.push(subscription);
} else {
groups.set(key, [subscription]);
}
}
return groups;
}
}
@@ -3,12 +3,11 @@ import { Field, InterfaceType, ObjectType } from '@nestjs/graphql';
import { BillingProductMetadata } from 'src/engine/core-modules/billing/types/billing-product-metadata.type';
import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
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';
@InterfaceType({
resolveType(product: BillingProductDTO) {
return product.metadata.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION
return product.metadata.priceUsageBased === BillingUsageType.METERED
? BillingMeteredProduct
: BillingLicensedProduct;
},
@@ -2,8 +2,8 @@ import { Field, Float, ObjectType } from '@nestjs/graphql';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
@ObjectType('BillingMeteredProductUsage')
export class BillingMeteredProductUsageDTO {
@ObjectType('BillingResourceCreditUsage')
export class BillingResourceCreditUsageDTO {
@Field(() => BillingProductKey)
productKey: BillingProductKey;
@@ -5,8 +5,6 @@ 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',
}
registerEnumType(BillingProductKey, {
@@ -1,60 +0,0 @@
/* @license Enterprise */
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';
import { CustomWorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/custom-workspace-batch-event.type';
@Injectable()
export class BillingUsageEventListener {
constructor(
private readonly billingUsageService: BillingUsageService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
) {}
@OnCustomBatchEvent(USAGE_RECORDED)
async handleUsageRecordedEvent(
payload: CustomWorkspaceEventBatch<UsageEvent>,
) {
if (!isDefined(payload.workspaceId)) {
return;
}
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return;
}
const canFeatureBeUsed = await this.billingUsageService.canFeatureBeUsed(
payload.workspaceId,
);
if (!canFeatureBeUsed) {
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,
usageEvents: payload.events,
});
}
}
@@ -3,39 +3,21 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
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';
describe('BillingCreditRolloverService', () => {
let service: BillingCreditRolloverService;
let stripeCreditGrantService: jest.Mocked<StripeCreditGrantService>;
let stripeBillingMeterEventService: jest.Mocked<StripeBillingMeterEventService>;
let billingUsageService: jest.Mocked<
Pick<BillingUsageService, 'getCurrentPeriodCreditsUsed'>
>;
let billingCustomerRepository: jest.Mocked<{ update: jest.Mock }>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingCreditRolloverService,
{
provide: StripeCreditGrantService,
useValue: {
createCreditGrant: jest.fn(),
listCreditGrants: jest.fn().mockResolvedValue([]),
voidCreditGrant: jest.fn(),
getCustomerCreditBalance: jest.fn().mockResolvedValue(0),
},
},
{
provide: StripeBillingMeterEventService,
useValue: {
sumMeterEvents: jest.fn(),
},
},
{
provide: BillingUsageService,
useValue: {
@@ -54,132 +36,54 @@ describe('BillingCreditRolloverService', () => {
service = module.get<BillingCreditRolloverService>(
BillingCreditRolloverService,
);
stripeCreditGrantService = module.get(StripeCreditGrantService);
stripeBillingMeterEventService = module.get(StripeBillingMeterEventService);
billingUsageService = module.get(BillingUsageService);
billingCustomerRepository = module.get(
getRepositoryToken(BillingCustomerEntity),
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('processRolloverOnPeriodTransition', () => {
const baseParams = {
workspaceId: 'ws_123',
stripeCustomerId: 'cus_123',
subscriptionId: 'sub_123',
stripeMeterId: 'meter_123',
previousPeriodStart: new Date('2024-01-01'),
previousPeriodEnd: new Date('2024-02-01'),
newPeriodEnd: new Date('2024-03-01'),
tierQuantity: 1000,
unitPriceCents: 10,
previousPeriodStart: new Date('2024-01-01'),
};
it('should create rollover grant for unused credits', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(300);
it('writes rollover amount to creditBalanceMicro when credits unused', async () => {
(
billingUsageService.getCurrentPeriodCreditsUsed as jest.Mock
).mockResolvedValue(300);
await service.processRolloverOnPeriodTransition(baseParams);
expect(stripeCreditGrantService.createCreditGrant).toHaveBeenCalledWith({
customerId: 'cus_123',
creditUnits: 700, // 1000 - 300 = 700 unused
unitPriceCents: 10,
expiresAt: baseParams.newPeriodEnd,
metadata: {
type: 'rollover',
fromPeriodStart: baseParams.previousPeriodStart.toISOString(),
fromPeriodEnd: baseParams.previousPeriodEnd.toISOString(),
subscriptionId: 'sub_123',
},
});
});
it('should cap rollover at tier quantity when all credits unused', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(0);
await service.processRolloverOnPeriodTransition(baseParams);
expect(stripeCreditGrantService.createCreditGrant).toHaveBeenCalledWith(
expect.objectContaining({
creditUnits: 1000, // Capped at tierQuantity
}),
);
});
it('should not create grant when all credits used', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(1000);
await service.processRolloverOnPeriodTransition(baseParams);
expect(stripeCreditGrantService.createCreditGrant).not.toHaveBeenCalled();
});
it('should not create grant when usage exceeds tier', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(1500);
await service.processRolloverOnPeriodTransition(baseParams);
expect(stripeCreditGrantService.createCreditGrant).not.toHaveBeenCalled();
});
it('should void existing rollover grants before creating new one', async () => {
const existingGrants = [
{
id: 'grant_old',
metadata: { type: 'rollover' },
voided_at: null,
},
{
id: 'grant_other',
metadata: { type: 'promotional' },
voided_at: null,
},
{
id: 'grant_voided',
metadata: { type: 'rollover' },
voided_at: 123456,
},
] as unknown as Stripe.Billing.CreditGrant[];
stripeCreditGrantService.listCreditGrants.mockResolvedValue(
existingGrants,
);
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(500);
await service.processRolloverOnPeriodTransition(baseParams);
// Should only void the active rollover grant, not promotional or already voided
expect(stripeCreditGrantService.voidCreditGrant).toHaveBeenCalledTimes(1);
expect(stripeCreditGrantService.voidCreditGrant).toHaveBeenCalledWith(
'grant_old',
);
// Should create the new grant after voiding
expect(stripeCreditGrantService.createCreditGrant).toHaveBeenCalledWith(
expect.objectContaining({
creditUnits: 500, // 1000 - 500 = 500 unused
}),
);
});
it('should persist credit balance to Postgres after rollover', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(300);
stripeCreditGrantService.getCustomerCreditBalance.mockResolvedValue(
700_000,
);
await service.processRolloverOnPeriodTransition(baseParams);
expect(
stripeCreditGrantService.getCustomerCreditBalance,
).toHaveBeenCalledWith('cus_123', 10);
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
{ stripeCustomerId: 'cus_123' },
{ creditBalanceMicro: 700_000 },
{ creditBalanceMicro: 700 },
);
});
it('should persist credit balance even when no grant is created', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(1000);
stripeCreditGrantService.getCustomerCreditBalance.mockResolvedValue(0);
it('sets creditBalanceMicro to tierQuantity when no credits used', async () => {
(
billingUsageService.getCurrentPeriodCreditsUsed as jest.Mock
).mockResolvedValue(0);
await service.processRolloverOnPeriodTransition(baseParams);
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
{ stripeCustomerId: 'cus_123' },
{ creditBalanceMicro: 1000 },
);
});
it('sets creditBalanceMicro to 0 when all credits used', async () => {
(
billingUsageService.getCurrentPeriodCreditsUsed as jest.Mock
).mockResolvedValue(1000);
await service.processRolloverOnPeriodTransition(baseParams);
@@ -188,5 +92,32 @@ describe('BillingCreditRolloverService', () => {
{ creditBalanceMicro: 0 },
);
});
it('sets creditBalanceMicro to 0 when usage exceeds tier', async () => {
(
billingUsageService.getCurrentPeriodCreditsUsed as jest.Mock
).mockResolvedValue(1500);
await service.processRolloverOnPeriodTransition(baseParams);
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
{ stripeCustomerId: 'cus_123' },
{ creditBalanceMicro: 0 },
);
});
it('caps rollover at tierQuantity', async () => {
(
billingUsageService.getCurrentPeriodCreditsUsed as jest.Mock
).mockResolvedValue(0);
const params = { ...baseParams, tierQuantity: 500 };
await service.processRolloverOnPeriodTransition(params);
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
{ stripeCustomerId: 'cus_123' },
{ creditBalanceMicro: 500 },
);
});
});
});
@@ -6,30 +6,15 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
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 { 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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-credit.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
describe('BillingUsageCapService', () => {
let service: BillingUsageCapService;
let clickHouseService: jest.Mocked<ClickHouseService>;
let meteredCreditService: jest.Mocked<MeteredCreditService>;
let twentyConfigService: jest.Mocked<TwentyConfigService>;
const buildSubscription = (
overrides: Partial<BillingSubscriptionEntity> = {},
): BillingSubscriptionEntity =>
({
id: 'sub_123',
workspaceId: 'workspace_123',
stripeCustomerId: 'cus_123',
currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
currentPeriodEnd: new Date('2026-05-01T00:00:00Z'),
...overrides,
}) as BillingSubscriptionEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -41,10 +26,9 @@ describe('BillingUsageCapService', () => {
},
},
{
provide: MeteredCreditService,
provide: ResourceCreditService,
useValue: {
extractMeteredPricingInfoFromSubscription: jest.fn(),
getCreditBalance: jest.fn(),
extractResourceCreditPricingInfo: jest.fn(),
},
},
{
@@ -53,12 +37,6 @@ describe('BillingUsageCapService', () => {
get: jest.fn(),
},
},
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
{
provide: getRepositoryToken(BillingSubscriptionItemEntity),
useValue: {
@@ -71,7 +49,6 @@ describe('BillingUsageCapService', () => {
service = module.get<BillingUsageCapService>(BillingUsageCapService);
clickHouseService = module.get(ClickHouseService);
meteredCreditService = module.get(MeteredCreditService);
twentyConfigService = module.get(TwentyConfigService);
});
@@ -157,117 +134,4 @@ describe('BillingUsageCapService', () => {
expect(result.get('ws_1')).toBe(9876543210);
});
});
describe('evaluateCapBatch', () => {
it('returns evaluations keyed by subscription id', () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
{ tierCap: 1_000_000, unitPriceCents: 10 },
);
const sub1 = buildSubscription({
id: 'sub_1',
workspaceId: 'ws_1',
stripeCustomerId: 'cus_1',
});
const sub2 = buildSubscription({
id: 'sub_2',
workspaceId: 'ws_2',
stripeCustomerId: 'cus_2',
});
const usageByWorkspace = new Map([
['ws_1', 500_000],
['ws_2', 1_500_000],
]);
const creditBalanceByCustomer = new Map([
['cus_1', 0],
['cus_2', 200_000],
]);
const results = service.evaluateCapBatch(
[sub1, sub2],
usageByWorkspace,
creditBalanceByCustomer,
);
expect(results.get('sub_1')).toMatchObject({
skipped: false,
hasReachedCap: false,
usage: 500_000,
allowance: 1_000_000,
});
expect(results.get('sub_2')).toMatchObject({
skipped: false,
hasReachedCap: true,
usage: 1_500_000,
allowance: 1_200_000,
});
});
it('defaults usage to 0 for workspaces not in the map', () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
{ tierCap: 1_000_000, unitPriceCents: 10 },
);
const sub = buildSubscription({
id: 'sub_1',
workspaceId: 'ws_unknown',
stripeCustomerId: 'cus_1',
});
const results = service.evaluateCapBatch(
[sub],
new Map(),
new Map([['cus_1', 0]]),
);
expect(results.get('sub_1')).toMatchObject({
skipped: false,
hasReachedCap: false,
usage: 0,
allowance: 1_000_000,
});
});
it('defaults credit balance to 0 for unknown customers', () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
{ tierCap: 1_000_000, unitPriceCents: 10 },
);
const sub = buildSubscription({
id: 'sub_1',
workspaceId: 'ws_1',
stripeCustomerId: 'cus_unknown',
});
const results = service.evaluateCapBatch(
[sub],
new Map([['ws_1', 500_000]]),
new Map(),
);
expect(results.get('sub_1')).toMatchObject({
skipped: false,
hasReachedCap: false,
usage: 500_000,
creditBalance: 0,
allowance: 1_000_000,
});
});
it('returns skipped for subscriptions without metered pricing', () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
null,
);
const sub = buildSubscription({ id: 'sub_1' });
const results = service.evaluateCapBatch([sub], new Map(), new Map());
expect(results.get('sub_1')).toEqual({
skipped: true,
reason: 'no-metered-item',
});
});
});
});
@@ -1,253 +0,0 @@
/* @license Enterprise */
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.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 { 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 { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
describe('MeteredCreditService', () => {
let service: MeteredCreditService;
let billingSubscriptionRepository: jest.Mocked<any>;
let billingSubscriptionItemService: jest.Mocked<BillingSubscriptionItemService>;
let stripeBillingAlertService: jest.Mocked<StripeBillingAlertService>;
let stripeCreditGrantService: jest.Mocked<StripeCreditGrantService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MeteredCreditService,
{
provide: getRepositoryToken(BillingSubscriptionEntity),
useValue: {
findOne: jest.fn(),
find: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingPriceEntity),
useValue: {
findOneOrFail: jest.fn(),
},
},
{
provide: BillingSubscriptionItemService,
useValue: {
getMeteredSubscriptionItemDetails: jest.fn(),
},
},
{
provide: StripeBillingAlertService,
useValue: {
createUsageThresholdAlertForCustomerMeter: jest.fn(),
},
},
{
provide: StripeCreditGrantService,
useValue: {
getCustomerCreditBalance: jest.fn(),
},
},
],
}).compile();
service = module.get<MeteredCreditService>(MeteredCreditService);
billingSubscriptionRepository = module.get(
getRepositoryToken(BillingSubscriptionEntity),
);
billingSubscriptionItemService = module.get(BillingSubscriptionItemService);
stripeBillingAlertService = module.get(StripeBillingAlertService);
stripeCreditGrantService = module.get(StripeCreditGrantService);
});
describe('getMeteredPricingInfo', () => {
const createMockSubscription = (meteredTiers: any[] | null = null) => ({
id: 'sub_123',
billingSubscriptionItems: [
{
stripePriceId: 'price_123',
billingProduct: {
metadata: { productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION },
billingPrices: [
{
stripePriceId: 'price_123',
stripeMeterId: 'meter_123',
tiers: meteredTiers ?? [
{
up_to: 1000,
flat_amount: null,
unit_amount: null,
unit_amount_decimal: null,
},
{
up_to: null,
flat_amount: null,
unit_amount: null,
unit_amount_decimal: '10',
},
],
},
],
},
},
],
});
it('should return pricing info when subscription and metered item found', async () => {
const mockSubscription = createMockSubscription();
billingSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
const result = await service.getMeteredPricingInfo('sub_123');
expect(result).toEqual({
tierCap: 1000,
unitPriceCents: 10,
stripeMeterId: 'meter_123',
});
});
it('should return null when subscription not found', async () => {
billingSubscriptionRepository.findOne.mockResolvedValue(null);
const result = await service.getMeteredPricingInfo('sub_123');
expect(result).toBeNull();
});
it('should return null when metered item not found', async () => {
const mockSubscription = {
id: 'sub_123',
billingSubscriptionItems: [
{
billingProduct: {
metadata: { productKey: 'other_product' },
billingPrices: [],
},
},
],
};
billingSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
const result = await service.getMeteredPricingInfo('sub_123');
expect(result).toBeNull();
});
});
describe('getMeteredRolloverParameters', () => {
it('should return parameters when metered item found', async () => {
billingSubscriptionItemService.getMeteredSubscriptionItemDetails.mockResolvedValue(
[
{
stripeSubscriptionItemId: 'si_123',
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
stripeMeterId: 'meter_123',
tierQuantity: 5000,
unitPriceCents: 5,
freeTrialQuantity: 100,
},
],
);
const result = await service.getMeteredRolloverParameters('sub_123');
expect(result).toEqual({
stripeMeterId: 'meter_123',
tierQuantity: 5000,
unitPriceCents: 5,
});
});
it('should return null when metered item not found', async () => {
billingSubscriptionItemService.getMeteredSubscriptionItemDetails.mockResolvedValue(
[],
);
const result = await service.getMeteredRolloverParameters('sub_123');
expect(result).toBeNull();
});
});
describe('recreateBillingAlertForSubscription', () => {
it('should create billing alert with correct parameters', async () => {
const currentPeriodStart = new Date('2024-01-01');
const mockSubscription = {
id: 'sub_123',
stripeCustomerId: 'cus_123',
currentPeriodStart,
status: SubscriptionStatus.Active,
billingSubscriptionItems: [
{
stripePriceId: 'price_123',
billingProduct: {
metadata: {
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
},
billingPrices: [
{
stripePriceId: 'price_123',
stripeMeterId: 'meter_123',
tiers: [
{
up_to: 1000,
flat_amount: null,
unit_amount: null,
unit_amount_decimal: null,
},
{
up_to: null,
flat_amount: null,
unit_amount: null,
unit_amount_decimal: '10',
},
],
},
],
},
},
],
};
// Mock for getMeteredPricingInfo (uses findOne)
billingSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
stripeCreditGrantService.getCustomerCreditBalance.mockResolvedValue(500);
await service.recreateBillingAlertForSubscription(
mockSubscription as any,
);
expect(
stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter,
).toHaveBeenCalledWith('cus_123', 1000, 500, currentPeriodStart);
});
it('should not create alert when metered pricing info not found', async () => {
const mockSubscription = {
id: 'sub_123',
stripeCustomerId: 'cus_123',
currentPeriodStart: new Date('2024-01-01'),
status: SubscriptionStatus.Active,
billingSubscriptionItems: [],
};
billingSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
await service.recreateBillingAlertForSubscription(
mockSubscription as any,
);
expect(
stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter,
).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,155 @@
/* @license Enterprise */
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.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 { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-credit.service';
describe('ResourceCreditService', () => {
let service: ResourceCreditService;
let billingSubscriptionRepository: jest.Mocked<any>;
const buildSubscriptionWithResourceCredit = (
creditAmount: number,
unitAmount = 0,
) => ({
id: 'sub_123',
billingSubscriptionItems: [
{
stripePriceId: 'price_rc_123',
billingProduct: {
metadata: { productKey: BillingProductKey.RESOURCE_CREDIT },
billingPrices: [
{
stripePriceId: 'price_rc_123',
metadata: { credit_amount: String(creditAmount) },
unitAmount,
},
],
},
},
],
});
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ResourceCreditService,
{
provide: getRepositoryToken(BillingSubscriptionEntity),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingPriceEntity),
useValue: {
findOneOrFail: jest.fn(),
},
},
],
}).compile();
service = module.get<ResourceCreditService>(ResourceCreditService);
billingSubscriptionRepository = module.get(
getRepositoryToken(BillingSubscriptionEntity),
);
});
describe('extractResourceCreditPricingInfo', () => {
it('returns pricing info for a valid resource credit subscription', () => {
const subscription = buildSubscriptionWithResourceCredit(1000, 10);
const result = service.extractResourceCreditPricingInfo(
subscription as any,
);
expect(result).toEqual({ tierCap: 1000, unitPriceCents: 10 });
});
it('returns null when no RESOURCE_CREDIT item found', () => {
const subscription = {
billingSubscriptionItems: [
{
stripePriceId: 'price_base',
billingProduct: {
metadata: { productKey: BillingProductKey.BASE_PRODUCT },
billingPrices: [],
},
},
],
};
expect(
service.extractResourceCreditPricingInfo(subscription as any),
).toBeNull();
});
it('returns null when credit_amount is 0', () => {
const subscription = buildSubscriptionWithResourceCredit(0);
expect(
service.extractResourceCreditPricingInfo(subscription as any),
).toBeNull();
});
it('returns null when matching price not found', () => {
const subscription = {
billingSubscriptionItems: [
{
stripePriceId: 'price_rc_different',
billingProduct: {
metadata: { productKey: BillingProductKey.RESOURCE_CREDIT },
billingPrices: [
{
stripePriceId: 'price_rc_other',
metadata: { credit_amount: '500' },
},
],
},
},
],
};
expect(
service.extractResourceCreditPricingInfo(subscription as any),
).toBeNull();
});
});
describe('getResourceCreditRolloverParameters', () => {
it('returns parameters when resource credit item found', async () => {
const subscription = buildSubscriptionWithResourceCredit(5000, 5);
billingSubscriptionRepository.findOne.mockResolvedValue(subscription);
const result =
await service.getResourceCreditRolloverParameters('sub_123');
expect(result).toEqual({ tierQuantity: 5000, unitPriceCents: 5 });
});
it('returns null when subscription not found', async () => {
billingSubscriptionRepository.findOne.mockResolvedValue(null);
const result =
await service.getResourceCreditRolloverParameters('sub_123');
expect(result).toBeNull();
});
it('returns null when resource credit pricing info not extractable', async () => {
billingSubscriptionRepository.findOne.mockResolvedValue({
billingSubscriptionItems: [],
});
const result =
await service.getResourceCreditRolloverParameters('sub_123');
expect(result).toBeNull();
});
});
});
@@ -14,7 +14,7 @@ export const buildSubscription = ({
planKey = BillingPlanKey.PRO,
interval = SubscriptionInterval.Month,
licensedPriceId = LICENSE_PRICE_PRO_MONTH_ID,
meteredPriceId = METER_PRICE_PRO_MONTH_ID,
resourceCreditPriceId = METER_PRICE_PRO_MONTH_ID,
seats = 1,
workspaceId = 'ws_1',
stripeSubscriptionId = 'sub_1',
@@ -23,7 +23,7 @@ export const buildSubscription = ({
planKey?: BillingPlanKey;
interval?: SubscriptionInterval;
licensedPriceId?: string;
meteredPriceId?: string;
resourceCreditPriceId?: string;
seats?: number;
workspaceId?: string;
stripeSubscriptionId?: string;
@@ -51,13 +51,13 @@ export const buildSubscription = ({
},
},
{
stripeSubscriptionItemId: 'si_metered',
stripeProductId: 'prod_metered',
stripePriceId: meteredPriceId,
stripeSubscriptionItemId: 'si_resource_credit',
stripeProductId: 'prod_resource_credit',
stripePriceId: resourceCreditPriceId,
billingProduct: {
metadata: {
planKey,
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
productKey: BillingProductKey.RESOURCE_CREDIT,
priceUsageBased: BillingUsageType.METERED,
},
},
@@ -45,16 +45,17 @@ export const buildBillingPriceEntity = ({
metadata: {
planKey,
productKey: isMetered
? BillingProductKey.WORKFLOW_NODE_EXECUTION
? BillingProductKey.RESOURCE_CREDIT
: BillingProductKey.BASE_PRODUCT,
priceUsageBased: isMetered
? BillingUsageType.METERED
: BillingUsageType.LICENSED,
},
},
...(isMetered && tiers
...(isMetered
? {
tiers,
metadata: { credit_amount: '1000' },
...(tiers ? { tiers } : {}),
}
: {}),
}) as BillingPriceEntity | BillingMeterPrice;
@@ -86,7 +87,7 @@ export const arrangeBillingSubscriptionRepositoryFindOneOrFail = (
planKey?: BillingPlanKey;
interval?: SubscriptionInterval;
licensedPriceId?: string;
meteredPriceId?: string;
resourceCreditPriceId?: string;
seats?: number;
workspaceId?: string;
stripeSubscriptionId?: string;
@@ -195,13 +196,13 @@ export const arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams = (
export const buildSchedulePhase = ({
licensedPriceId,
meteredPriceId,
resourceCreditPriceId,
seats = 1,
startDate = Math.floor(Date.now() / 1000),
endDate = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
}: {
licensedPriceId: string;
meteredPriceId: string;
resourceCreditPriceId: string;
seats?: number;
startDate?: number;
endDate?: number;
@@ -211,6 +212,6 @@ export const buildSchedulePhase = ({
end_date: endDate,
items: [
{ price: licensedPriceId, quantity: seats },
{ price: meteredPriceId },
{ price: resourceCreditPriceId, quantity: 1 },
],
}) as Stripe.SubscriptionSchedule.Phase;
@@ -7,78 +7,16 @@ 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';
@Injectable()
export class BillingCreditRolloverService {
constructor(
private readonly stripeCreditGrantService: StripeCreditGrantService,
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
private readonly billingUsageService: BillingUsageService,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
) {}
async processRolloverOnPeriodTransition({
stripeCustomerId,
subscriptionId,
stripeMeterId,
previousPeriodStart,
previousPeriodEnd,
newPeriodEnd,
tierQuantity,
unitPriceCents,
}: {
stripeCustomerId: string;
subscriptionId: string;
stripeMeterId: string;
previousPeriodStart: Date;
previousPeriodEnd: Date;
newPeriodEnd: Date;
tierQuantity: number;
unitPriceCents: number;
}): Promise<void> {
// Void any existing rollover grants before creating a new one
// This ensures only one rollover grant is active at a time
await this.voidExistingRolloverGrants(stripeCustomerId);
const usedCredits =
await this.stripeBillingMeterEventService.sumMeterEvents(
stripeMeterId,
stripeCustomerId,
previousPeriodStart,
previousPeriodEnd,
);
const unusedCredits = Math.max(0, tierQuantity - usedCredits);
if (unusedCredits <= 0) {
await this.refreshCreditBalance(stripeCustomerId, unitPriceCents);
return;
}
const rolloverAmount = Math.min(unusedCredits, tierQuantity);
await this.stripeCreditGrantService.createCreditGrant({
customerId: stripeCustomerId,
creditUnits: rolloverAmount,
unitPriceCents,
expiresAt: newPeriodEnd,
metadata: {
type: 'rollover',
fromPeriodStart: previousPeriodStart.toISOString(),
fromPeriodEnd: previousPeriodEnd.toISOString(),
subscriptionId,
},
});
await this.refreshCreditBalance(stripeCustomerId, unitPriceCents);
}
// V2 path — reads usedCredits from ClickHouse; writes rollover directly to creditBalanceMicro
async processRolloverOnPeriodTransitionV2({
workspaceId,
stripeCustomerId,
tierQuantity,
@@ -103,39 +41,4 @@ export class BillingCreditRolloverService {
{ creditBalanceMicro: rolloverAmount },
);
}
private async refreshCreditBalance(
stripeCustomerId: string,
unitPriceCents: number,
): Promise<void> {
const creditBalanceMicro =
await this.stripeCreditGrantService.getCustomerCreditBalance(
stripeCustomerId,
unitPriceCents,
);
await this.billingCustomerRepository.update(
{ stripeCustomerId },
{ creditBalanceMicro },
);
}
private async voidExistingRolloverGrants(
stripeCustomerId: string,
): Promise<void> {
const existingGrants =
await this.stripeCreditGrantService.listCreditGrants(stripeCustomerId);
const rolloverGrants = existingGrants.filter(
(grant) => grant.metadata?.type === 'rollover' && !grant.voided_at,
);
if (rolloverGrants.length === 0) {
return;
}
for (const grant of rolloverGrants) {
await this.stripeCreditGrantService.voidCreditGrant(grant.id);
}
}
}
@@ -17,7 +17,6 @@ import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
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';
@@ -26,14 +25,10 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
import { type BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
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 {
@@ -43,14 +38,12 @@ 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({
@@ -168,12 +161,11 @@ export class BillingPortalWorkspaceService {
relations: ['billingSubscriptions'],
});
const stripeSubscriptionLineItems =
await this.getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
workspaceId: workspace.id,
});
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
workspaceId: workspace.id,
});
return {
successUrl,
@@ -263,39 +255,6 @@ export class BillingPortalWorkspaceService {
return session.url;
}
private getDefaultMeteredProductPrice(
billingPricesPerPlan: BillingGetPricesPerPlanResult,
): BillingMeterPrice {
const defaultMeteredProductPrice =
billingPricesPerPlan.meteredProductPrices.reduce(
(result, billingPrice) => {
if (!result) {
return billingPrice as BillingMeterPrice;
}
const tiers = billingPrice.tiers;
if (billingValidator.isMeteredTiersSchema(tiers)) {
if (tiers[0].flat_amount < result.tiers[0].flat_amount) {
return billingPrice as BillingMeterPrice;
}
}
return result;
},
null as BillingMeterPrice | null,
);
if (!isDefined(defaultMeteredProductPrice)) {
throw new BillingException(
'Missing Default Metered price',
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return defaultMeteredProductPrice;
}
// V2 path — finds the lowest credit_amount RESOURCE_CREDIT licensed price as default
private getDefaultResourceCreditPrice(
billingPricesPerPlan: BillingGetPricesPerPlanResult,
) {
@@ -317,24 +276,14 @@ export class BillingPortalWorkspaceService {
});
}
private async getStripeSubscriptionLineItems({
private getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
workspaceId,
}: {
quantity: number;
billingPricesPerPlan: BillingGetPricesPerPlanResult;
workspaceId: string;
}): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
);
const isBillingV2EnabledForNewWorkspaces = this.twentyConfigService.get(
'IS_BILLING_V2_ENABLED_FOR_NEW_WORKSPACES',
);
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
const defaultBaseProductPrice = findOrThrow(
billingPricesPerPlan.baseProductPrices,
(baseProductPrice) =>
@@ -346,24 +295,8 @@ 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);
const defaultResourceCreditPrice =
this.getDefaultResourceCreditPrice(billingPricesPerPlan);
return [
{
@@ -371,7 +304,8 @@ export class BillingPortalWorkspaceService {
quantity,
},
{
price: defaultMeteredProductPrice.stripePriceId,
price: defaultResourceCreditPrice.stripePriceId,
quantity: 1,
},
];
}
@@ -8,7 +8,6 @@ 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';
@@ -24,43 +23,6 @@ export class BillingSubscriptionItemService {
private readonly twentyConfigService: TwentyConfigService,
) {}
async getMeteredSubscriptionItemDetails(subscriptionId: string) {
const meteredSubscriptionItems =
await this.billingSubscriptionItemRepository.find({
where: {
billingSubscriptionId: subscriptionId,
},
relations: ['billingProduct', 'billingProduct.billingPrices'],
});
return meteredSubscriptionItems.reduce(
(acc, item) => {
const price = this.findMatchingPrice(item);
if (!price.stripeMeterId) {
return acc;
}
return acc.concat({
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
productKey: item.billingProduct.metadata.productKey,
stripeMeterId: price.stripeMeterId,
tierQuantity: this.getTierQuantity(price),
freeTrialQuantity: this.getFreeTrialQuantity(item),
unitPriceCents: this.getUnitPrice(price),
});
},
[] as Array<{
stripeSubscriptionItemId: string;
productKey: BillingProductKey;
stripeMeterId: string;
tierQuantity: number;
freeTrialQuantity: number;
unitPriceCents: number;
}>,
);
}
async getResourceCreditSubscriptionItemDetails(
subscription: BillingSubscriptionEntity,
): Promise<{
@@ -128,27 +90,4 @@ export class BillingSubscriptionItemService {
return matchingPrice;
}
private getTierQuantity(price: BillingPriceEntity): number {
billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers);
return price.tiers[0].up_to;
}
private getFreeTrialQuantity(item: BillingSubscriptionItemEntity): number {
switch (item.billingProduct.metadata.productKey) {
case BillingProductKey.WORKFLOW_NODE_EXECUTION:
return this.twentyConfigService.get(
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
);
default:
return 0;
}
}
private getUnitPrice(price: BillingPriceEntity): number {
billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers);
return Number(price.tiers[1].unit_amount_decimal);
}
}
@@ -83,52 +83,27 @@ export class BillingSubscriptionPhaseService {
} as Stripe.SubscriptionScheduleUpdateParams.Phase;
}
async buildPhaseUpdateParams({
buildPhaseUpdateParams({
toUpdatePrices,
startDate,
endDate,
isV2,
}: {
toUpdatePrices: SubscriptionStripePrices;
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
endDate: number | undefined;
isV2: boolean;
}): Promise<Stripe.SubscriptionScheduleUpdateParams.Phase> {
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,
),
};
}
}): Stripe.SubscriptionScheduleUpdateParams.Phase {
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{
price: toUpdatePrices.licensedPriceId,
quantity: toUpdatePrices.seats,
},
{ price: toUpdatePrices.resourceCreditPriceId, quantity: 1 },
],
};
}
getLicensedPriceIdAndQuantityFromPhaseUpdateParams(
@@ -145,16 +120,6 @@ export class BillingSubscriptionPhaseService {
};
}
getMeteredPriceIdFromPhaseUpdateParams(
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
): string {
const meteredItem = findOrThrow(phase.items!, (i) => i.quantity == null);
assertIsDefinedOrThrow(meteredItem.price);
return meteredItem.price;
}
async isSamePhaseSignature(
a: Stripe.SubscriptionScheduleUpdateParams.Phase,
b: Stripe.SubscriptionScheduleUpdateParams.Phase,
@@ -164,24 +129,22 @@ export class BillingSubscriptionPhaseService {
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(a);
const phaseBLicensedPriceIdAndQuantity =
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(b);
const phaseAMeteredPriceId =
this.getMeteredPriceIdFromPhaseUpdateParams(a);
const phaseBMeteredPriceId =
this.getMeteredPriceIdFromPhaseUpdateParams(b);
const phaseAResourceCreditPriceId =
this.getResourceCreditPriceIdFromPhaseUpdateParams(a);
const phaseBResourceCreditPriceId =
this.getResourceCreditPriceIdFromPhaseUpdateParams(b);
return (
phaseALicensedPriceIdAndQuantity.price ===
phaseBLicensedPriceIdAndQuantity.price &&
phaseALicensedPriceIdAndQuantity.quantity ===
phaseBLicensedPriceIdAndQuantity.quantity &&
phaseAMeteredPriceId === phaseBMeteredPriceId
phaseAResourceCreditPriceId === phaseBResourceCreditPriceId
);
} catch {
return false;
}
}
// Billing V2: emits { price, quantity: 1 } for the resource credit price; no billing_thresholds
async buildResourceCreditPhaseUpdateParams({
basePlanStripePriceId,
seats,
@@ -233,7 +196,6 @@ export class BillingSubscriptionPhaseService {
}
}
// Billing V2 counterpart of getMeteredPriceIdFromPhaseUpdateParams (resource credit has quantity: 1)
getResourceCreditPriceIdFromPhaseUpdateParams(
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
): string {
@@ -24,8 +24,6 @@ import { BillingPriceService } from 'src/engine/core-modules/billing/services/bi
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
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';
@@ -36,18 +34,14 @@ import {
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 { 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';
export type SubscriptionStripePrices = {
licensedPriceId: string;
seats: number;
meteredPriceId: string | undefined;
resourceCreditPriceId: string | undefined;
resourceCreditPriceId: string;
};
@Injectable()
@@ -67,55 +61,9 @@ export class BillingSubscriptionUpdateService {
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
private readonly billingSubscriptionPhaseService: BillingSubscriptionPhaseService,
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,
): Promise<void> {
const billingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId },
);
const subscriptionUpdate = {
type: SubscriptionUpdateType.METERED_PRICE,
newMeteredPriceId: meteredPriceId,
} as const;
await this.updateSubscription(billingSubscription.id, subscriptionUpdate);
}
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 },
);
const currentMeteredPrice =
getCurrentMeteredBillingSubscriptionItemOrThrow(billingSubscription);
await this.updateSubscription(billingSubscription.id, {
type: SubscriptionUpdateType.METERED_PRICE,
newMeteredPriceId: currentMeteredPrice.stripePriceId,
});
}
async changeResourceCreditPrice(
workspaceId: string,
resourceCreditPriceId: string,
@@ -242,27 +190,17 @@ export class BillingSubscriptionUpdateService {
},
);
const isV2 = await this.isV2(subscription.workspaceId);
const licensedItem = getBaseProductSubscriptionItemOrThrow(subscription);
const resourceCreditItem =
getCurrentResourceCreditSubscriptionItemOrThrow(subscription);
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,
resourceCreditPriceId: resourceCreditItem?.stripePriceId,
resourceCreditPriceId: resourceCreditItem.stripePriceId,
seats: licensedItem.quantity,
},
isV2,
);
const { schedule, currentPhase, nextPhase } =
@@ -294,19 +232,17 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
} else {
assertIsDefinedOrThrow(nextPhase);
assertIsDefinedOrThrow(currentPhase);
const nextPhasePrices =
await this.getSubscriptionPricesFromSchedulePhaseV2(nextPhase, isV2);
await this.getSubscriptionPricesFromSchedulePhase(nextPhase);
const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate(
subscriptionUpdate,
nextPhasePrices,
isV2,
);
await this.runSubscriptionScheduleUpdate({
@@ -320,7 +256,6 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
}
} else {
@@ -341,16 +276,12 @@ export class BillingSubscriptionUpdateService {
await this.runSubscriptionUpdate({
stripeSubscriptionId: subscription.stripeSubscriptionId,
licensedStripeItemId: licensedItem.stripeSubscriptionItemId,
meteredStripeItemId: meteredItem?.stripeSubscriptionItemId,
resourceCreditStripeItemId:
resourceCreditItem?.stripeSubscriptionItemId,
resourceCreditStripeItemId: resourceCreditItem.stripeSubscriptionItemId,
licensedStripePriceId: toUpdateCurrentPrices.licensedPriceId,
meteredStripePriceId: toUpdateCurrentPrices?.meteredPriceId,
resourceCreditStripePriceId:
toUpdateCurrentPrices?.resourceCreditPriceId,
toUpdateCurrentPrices.resourceCreditPriceId,
seats: toUpdateCurrentPrices.seats,
...subscriptionOptions,
isV2,
});
if (subscriptionUpdate.type !== SubscriptionUpdateType.SEATS) {
@@ -370,17 +301,16 @@ export class BillingSubscriptionUpdateService {
assertIsDefinedOrThrow(refreshedCurrentPhase);
const nextPhasePrices =
await this.getSubscriptionPricesFromSchedulePhaseV2(nextPhase, isV2);
await this.getSubscriptionPricesFromSchedulePhase(nextPhase);
const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate(
subscriptionUpdate,
nextPhasePrices,
isV2,
);
await this.runSubscriptionScheduleUpdate({
stripeScheduleId: schedule.id,
toUpdateNextPrices,
toUpdateCurrentPrices: undefined, //subscription update causes schedule current phase update
toUpdateCurrentPrices: undefined,
currentPhase:
this.billingSubscriptionPhaseService.toPhaseUpdateParams(
refreshedCurrentPhase,
@@ -388,7 +318,6 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
}
}
@@ -441,9 +370,8 @@ export class BillingSubscriptionUpdateService {
}
}
private async getSubscriptionPricesFromSchedulePhaseV2(
private async getSubscriptionPricesFromSchedulePhase(
phase: Stripe.SubscriptionSchedule.Phase,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const licensedItemPriceIds = phase.items
.filter((item) => item.quantity != null)
@@ -469,110 +397,62 @@ export class BillingSubscriptionUpdateService {
assertIsDefinedOrThrow(basePlanPhaseItem.quantity);
if (isV2) {
const resourceCreditPrice = licensedItemPrices.find(
(price) =>
price.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
);
const resourceCreditPrice = licensedItemPrices.find(
(price) =>
price.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
);
assertIsDefinedOrThrow(resourceCreditPrice);
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,
};
}
return {
licensedPriceId: basePlanPrice.stripePriceId,
seats: basePlanPhaseItem.quantity,
resourceCreditPriceId: resourceCreditPrice.stripePriceId,
};
}
private async runSubscriptionUpdate({
stripeSubscriptionId,
licensedStripeItemId,
meteredStripeItemId,
resourceCreditStripeItemId,
licensedStripePriceId,
meteredStripePriceId,
resourceCreditStripePriceId,
seats,
anchor,
proration,
metadata,
isV2,
}: {
stripeSubscriptionId: string;
licensedStripeItemId: string;
meteredStripeItemId: string | undefined;
resourceCreditStripeItemId: string | undefined;
resourceCreditStripeItemId: string;
licensedStripePriceId: string;
meteredStripePriceId: string | undefined;
resourceCreditStripePriceId: string | undefined;
resourceCreditStripePriceId: string;
seats: number;
anchor?: Stripe.SubscriptionUpdateParams.BillingCycleAnchor;
proration?: Stripe.SubscriptionUpdateParams.ProrationBehavior;
metadata?: Record<string, string>;
isV2: boolean;
}) {
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,
),
},
);
}
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 } : {}),
},
);
}
async runSubscriptionScheduleUpdate({
@@ -581,14 +461,12 @@ export class BillingSubscriptionUpdateService {
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,
@@ -601,7 +479,6 @@ export class BillingSubscriptionUpdateService {
toUpdatePrices: toUpdateCurrentPrices,
endDate: subscriptionCurrentPeriodEnd,
startDate: currentPhase.start_date,
isV2,
});
}
@@ -610,7 +487,6 @@ export class BillingSubscriptionUpdateService {
toUpdatePrices: toUpdateNextPrices,
startDate: subscriptionCurrentPeriodEnd,
endDate: undefined,
isV2,
});
if (
@@ -648,35 +524,6 @@ export class BillingSubscriptionUpdateService {
return isDowngrade;
}
case SubscriptionUpdateType.METERED_PRICE: {
const currentMeteredPriceId =
subscription.billingSubscriptionItems.find(
(item) => item.quantity == null,
)?.stripePriceId;
assertIsDefinedOrThrow(currentMeteredPriceId);
const currentMeteredPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentMeteredPriceId },
relations: ['billingProduct'],
});
const newMeteredPrice = await this.billingPriceRepository.findOneOrFail(
{
where: { stripePriceId: update.newMeteredPriceId },
relations: ['billingProduct'],
},
);
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
billingValidator.assertIsMeteredPrice(newMeteredPrice);
const currentMeteredCap = currentMeteredPrice.tiers[0].up_to;
const newMeteredCap = newMeteredPrice.tiers[0].up_to;
const isDowngrade = currentMeteredCap > newMeteredCap;
return isDowngrade;
}
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE: {
const currentResourceCreditPriceId =
subscription.billingSubscriptionItems.find(
@@ -737,19 +584,12 @@ 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(
update.newMeteredPriceId,
currentPrices,
);
case SubscriptionUpdateType.SEATS:
return this.computeSubscriptionPricesUpdateBySeats(
@@ -760,7 +600,6 @@ export class BillingSubscriptionUpdateService {
return await this.computeSubscriptionPricesUpdateByInterval(
update.newInterval,
currentPrices,
isV2,
);
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE:
return await this.computeSubscriptionPricesUpdateByResourceCreditPrice(
@@ -780,52 +619,6 @@ export class BillingSubscriptionUpdateService {
};
}
private async computeSubscriptionPricesUpdateByMeteredPrice(
newMeteredPriceId: 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 newMeteredPrice = await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: newMeteredPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsMeteredPrice(newMeteredPrice);
const newInterval = newMeteredPrice.interval;
const newPlanKey = newMeteredPrice.billingProduct?.metadata.planKey;
if (newInterval === currentInterval && currentPlanKey === newPlanKey) {
return {
...currentPrices,
meteredPriceId: newMeteredPriceId,
};
}
const newEquivalentMeteredPrice =
await this.billingPriceService.findEquivalentMeteredPrice({
meteredPrice: newMeteredPrice,
targetInterval: currentInterval,
targetPlanKey: currentPlanKey,
hasSameInterval: newInterval === currentInterval,
hasSamePlanKey: currentPlanKey === newPlanKey,
});
return {
...currentPrices,
meteredPriceId: newEquivalentMeteredPrice.stripePriceId,
};
}
private async computeSubscriptionPricesUpdateByResourceCreditPrice(
newResourceCreditPriceId: string,
currentPrices: SubscriptionStripePrices,
@@ -879,7 +672,6 @@ export class BillingSubscriptionUpdateService {
private async computeSubscriptionPricesUpdateByPlan(
newPlan: BillingPlanKey,
currentPrices: SubscriptionStripePrices,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const currentLicensedPrice =
await this.billingPriceRepository.findOneOrFail({
@@ -909,61 +701,35 @@ export class BillingSubscriptionUpdateService {
billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT,
);
if (isV2) {
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
const targetResourceCreditPrice =
await this.billingPriceService.findEquivalentResourceCreditPrice({
referencePrice: currentResourceCreditPrice,
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,
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,
};
}
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
resourceCreditPriceId: targetResourceCreditPrice.stripePriceId,
};
}
private async computeSubscriptionPricesUpdateByInterval(
newInterval: SubscriptionInterval,
currentPrices: SubscriptionStripePrices,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const currentLicensedPrice =
await this.billingPriceRepository.findOneOrFail({
@@ -993,54 +759,29 @@ export class BillingSubscriptionUpdateService {
billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT,
);
if (isV2) {
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
const targetResourceCreditPrice =
await this.billingPriceService.findEquivalentResourceCreditPrice({
referencePrice: currentResourceCreditPrice,
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,
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,
};
}
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
resourceCreditPriceId: targetResourceCreditPrice.stripePriceId,
};
}
}
@@ -28,7 +28,6 @@ import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/bil
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';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.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';
@@ -56,7 +55,6 @@ export class BillingSubscriptionService {
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingSubscriptionEntity>,
private readonly meteredCreditService: MeteredCreditService,
private readonly enterprisePlanService: EnterprisePlanService,
) {}
@@ -252,10 +250,6 @@ export class BillingSubscriptionService {
{ hasReachedCurrentPeriodCap: false },
);
await this.meteredCreditService.recreateBillingAlertForSubscription(
billingSubscription,
);
return {
status: getSubscriptionStatus(updatedSubscription.status),
hasPaymentMethod: true,
@@ -10,13 +10,10 @@ import {
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
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 { 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 { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-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,9 +39,8 @@ type BatchUsageSumRow = {
export class BillingUsageCapService {
constructor(
private readonly clickHouseService: ClickHouseService,
private readonly meteredCreditService: MeteredCreditService,
private readonly resourceCreditService: ResourceCreditService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
@InjectRepository(BillingSubscriptionItemEntity)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
) {}
@@ -86,99 +82,10 @@ export class BillingUsageCapService {
return result;
}
evaluateCapBatch(
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 meteredPricingInfo =
this.meteredCreditService.extractMeteredPricingInfoFromSubscription(
subscription,
);
if (!meteredPricingInfo) {
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 = meteredPricingInfo.tierCap + creditBalance;
results.set(subscription.id, {
skipped: false,
hasReachedCap: usage >= allowance,
usage,
allowance,
tierCap: meteredPricingInfo.tierCap,
creditBalance,
});
}
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: {
@@ -189,7 +96,7 @@ export class BillingUsageCapService {
billingProduct: {
metadata: Raw((alias) => `${alias} @> :metadata::jsonb`, {
metadata: JSON.stringify({
productKey,
productKey: BillingProductKey.RESOURCE_CREDIT,
}),
}),
},
@@ -13,7 +13,7 @@ import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { type BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
import { type BillingResourceCreditUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-resource-credit-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';
@@ -21,19 +21,13 @@ import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billin
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';
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 { 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';
import { buildBillingUsageAvailableCreditsCacheKey } from 'src/engine/core-modules/billing/utils/build-billing-usage-available-credits-cache-key.util';
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;
@@ -46,19 +40,15 @@ export class BillingUsageService {
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
private readonly twentyConfigService: TwentyConfigService,
private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
private readonly stripeCreditGrantService: StripeCreditGrantService,
@InjectCacheStorage(CacheStorageNamespace.EngineBillingUsage)
private readonly billingUsageCacheStorage: CacheStorageService,
@InjectRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
private readonly meteredCreditService: MeteredCreditService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly clickHouseService: ClickHouseService,
private readonly billingUsageCapService: BillingUsageCapService,
private readonly featureFlagService: FeatureFlagService,
) {}
async canFeatureBeUsed(workspaceId: string): Promise<boolean> {
@@ -74,76 +64,9 @@ export class BillingUsageService {
return !!billingSubscription;
}
//TODO: TO be deprecated
async billUsage({
workspaceId,
usageEvents,
}: {
workspaceId: string;
usageEvents: UsageEvent[];
}) {
const workspaceStripeCustomer =
await this.billingCustomerRepository.findOne({
where: {
workspaceId,
},
});
if (!workspaceStripeCustomer) {
throw new BillingException(
'Stripe customer not found',
BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND,
);
}
try {
await Promise.all(
usageEvents.map((usageEvent) =>
this.stripeBillingMeterEventService.sendBillingMeterEvent({
usageEvent,
stripeCustomerId: workspaceStripeCustomer.stripeCustomerId,
}),
),
);
} catch (error) {
throw new BillingException(
`Failed to send billing meter events to Stripe: ${error}`,
BillingExceptionCode.BILLING_METER_EVENT_FAILED,
);
}
}
//TODO: TO be deprecated
async getMeteredProductsUsage(
workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageDTO[]> {
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
);
const meteredSubscriptionItemDetails =
await this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails(
subscription.id,
);
const { periodStart, periodEnd } = this.getSubscriptionPeriod(subscription);
return Promise.all(
meteredSubscriptionItemDetails.map((item) =>
this.buildMeteredProductUsage(
subscription,
item,
periodStart,
periodEnd,
),
),
);
}
async getResourceCreditProductUsage(
workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageDTO[]> {
): Promise<BillingResourceCreditUsageDTO[]> {
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
@@ -186,7 +109,7 @@ export class BillingUsageService {
>,
periodStart: Date,
periodEnd: Date,
): Promise<BillingMeteredProductUsageDTO> {
): Promise<BillingResourceCreditUsageDTO> {
const usedCredits = await this.getCurrentPeriodCreditsUsed(
workspaceId,
periodStart,
@@ -214,7 +137,6 @@ export class BillingUsageService {
};
}
//TODO: TO be deprecated
private getSubscriptionPeriod(subscription: BillingSubscriptionEntity): {
periodStart: Date;
periodEnd: Date;
@@ -237,48 +159,6 @@ export class BillingUsageService {
};
}
//TODO: TO be deprecated
private async buildMeteredProductUsage(
subscription: BillingSubscriptionEntity,
item: Awaited<
ReturnType<
typeof this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails
>
>[number],
periodStart: Date,
periodEnd: Date,
): Promise<BillingMeteredProductUsageDTO> {
const meterEventsSum =
await this.stripeBillingMeterEventService.sumMeterEvents(
item.stripeMeterId,
subscription.stripeCustomerId,
periodStart,
periodEnd,
);
const grantedCredits =
subscription.status === SubscriptionStatus.Trialing
? item.freeTrialQuantity
: item.tierQuantity;
const rolloverCredits =
await this.stripeCreditGrantService.getCustomerCreditBalance(
subscription.stripeCustomerId,
item.unitPriceCents,
);
return {
productKey: item.productKey,
periodStart,
periodEnd,
usedCredits: meterEventsSum,
grantedCredits,
rolloverCredits,
totalGrantedCredits: grantedCredits + rolloverCredits,
unitPriceCents: item.unitPriceCents,
};
}
async flushAvailableCreditsFromCache(workspaceId: string): Promise<void> {
await this.billingUsageCacheStorage.flushByPattern(
`available-credits:${workspaceId}:*`,
@@ -332,50 +212,20 @@ export class BillingUsageService {
);
}
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
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,
);
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;
}
return resourceUsageCap + creditBalance - usage;
}
getResourceUsageCap(subscription: BillingSubscriptionEntity): number {
@@ -1,259 +0,0 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.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 { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
export type MeteredPricingInfo = {
tierCap: number;
unitPriceCents: number;
stripeMeterId?: string;
};
export type ResourceCreditPricingInfo = {
tierCap: number;
unitPriceCents: number;
};
@Injectable()
export class MeteredCreditService {
protected readonly logger = new Logger(MeteredCreditService.name);
constructor(
@InjectRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
@InjectRepository(BillingPriceEntity)
private readonly billingPriceRepository: Repository<BillingPriceEntity>,
private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
private readonly stripeBillingAlertService: StripeBillingAlertService,
private readonly stripeCreditGrantService: StripeCreditGrantService,
) {}
/**
* Get metered pricing info for a subscription by looking up the metered subscription item
* and extracting tier cap and unit price from the associated price.
*/
async getMeteredPricingInfo(
subscriptionId: string,
): Promise<MeteredPricingInfo | null> {
const subscription = await this.billingSubscriptionRepository.findOne({
where: { id: subscriptionId },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
'billingSubscriptionItems.billingProduct.billingPrices',
],
});
if (!isDefined(subscription)) {
return null;
}
return this.extractMeteredPricingInfoFromSubscription(subscription);
}
extractMeteredPricingInfoFromSubscription(
subscription: BillingSubscriptionEntity,
): MeteredPricingInfo | null {
const meteredItem = subscription.billingSubscriptionItems?.find(
(item) =>
item.billingProduct?.metadata?.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!isDefined(meteredItem)) {
return null;
}
const matchingPrice = meteredItem.billingProduct.billingPrices?.find(
(price) => price.stripePriceId === meteredItem.stripePriceId,
);
if (!isDefined(matchingPrice)) {
return null;
}
if (!billingValidator.isMeteredTiersSchema(matchingPrice.tiers)) {
return null;
}
return {
tierCap: matchingPrice.tiers[0].up_to,
unitPriceCents: Number(matchingPrice.tiers[1].unit_amount_decimal),
stripeMeterId: matchingPrice.stripeMeterId ?? undefined,
};
}
/**
* Get metered pricing info directly from a price ID.
*/
async getMeteredPricingInfoFromPriceId(
priceId: string,
): Promise<MeteredPricingInfo> {
const price = await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: priceId },
});
billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers);
return {
tierCap: price.tiers[0].up_to,
unitPriceCents: Number(price.tiers[1].unit_amount_decimal),
stripeMeterId: price.stripeMeterId ?? undefined,
};
}
/**
* Get metered rollover parameters for a subscription.
* Returns null if no metered item is found.
*/
async getMeteredRolloverParameters(subscriptionId: string): Promise<{
stripeMeterId: string;
tierQuantity: number;
unitPriceCents: number;
} | null> {
const meteredDetails =
await this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails(
subscriptionId,
);
const meteredItem = meteredDetails.find(
(item) => item.productKey === BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!meteredItem) {
return null;
}
return {
stripeMeterId: meteredItem.stripeMeterId,
tierQuantity: meteredItem.tierQuantity,
unitPriceCents: meteredItem.unitPriceCents,
};
}
/**
* Recreate billing alert for a subscription. This archives existing alerts and creates
* a new one with the correct threshold based on current pricing and credit balance.
*/
async recreateBillingAlertForSubscription(
subscription: BillingSubscriptionEntity,
periodStart?: Date,
): Promise<void> {
const meteredPricingInfo = await this.getMeteredPricingInfo(
subscription.id,
);
if (!isDefined(meteredPricingInfo)) {
this.logger.warn(
`Cannot create billing alert: metered pricing info not found for subscription ${subscription.id}`,
);
return;
}
const creditBalance =
await this.stripeCreditGrantService.getCustomerCreditBalance(
subscription.stripeCustomerId,
meteredPricingInfo.unitPriceCents,
);
// Use the subscription's current period start if not provided
const effectivePeriodStart = periodStart ?? subscription.currentPeriodStart;
await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter(
subscription.stripeCustomerId,
meteredPricingInfo.tierCap,
creditBalance,
effectivePeriodStart,
);
}
/**
* Get credit balance for a customer in credit units (not monetary).
*/
async getCreditBalance(
stripeCustomerId: string,
unitPriceCents: number,
): Promise<number> {
return this.stripeCreditGrantService.getCustomerCreditBalance(
stripeCustomerId,
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,
};
}
}
@@ -0,0 +1,88 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
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';
export type ResourceCreditPricingInfo = {
tierCap: number;
unitPriceCents: number;
};
@Injectable()
export class ResourceCreditService {
protected readonly logger = new Logger(ResourceCreditService.name);
constructor(
@InjectRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
) {}
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> {
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,
};
}
}
@@ -3,7 +3,6 @@ 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',
@@ -14,10 +13,6 @@ export type SubscriptionUpdate =
type: SubscriptionUpdateType.PLAN;
newPlan: BillingPlanKey;
}
| {
type: SubscriptionUpdateType.METERED_PRICE;
newMeteredPriceId: string;
}
| {
type: SubscriptionUpdateType.RESOURCE_CREDIT_PRICE;
newResourceCreditPriceId: string;
@@ -32,17 +32,6 @@ describe('computeSubscriptionUpdateOptions', () => {
});
});
it('returns only proration for METERED_PRICE update type', () => {
const result = computeSubscriptionUpdateOptions({
type: SubscriptionUpdateType.METERED_PRICE,
newMeteredPriceId: 'price_123',
});
expect(result).toEqual({
proration: 'create_prorations',
});
});
it('returns proration and anchor for INTERVAL update type', () => {
const result = computeSubscriptionUpdateOptions({
type: SubscriptionUpdateType.INTERVAL,
@@ -26,7 +26,7 @@ describe('isStripeValidProductMetadata', () => {
const metadata: Stripe.Metadata = {
planKey: BillingPlanKey.ENTERPRISE,
priceUsageBased: BillingUsageType.METERED,
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
productKey: BillingProductKey.RESOURCE_CREDIT,
randomKey: 'randomValue',
};
@@ -22,10 +22,6 @@ export const computeSubscriptionUpdateOptions = (
plan: subscriptionUpdate.newPlan,
},
};
case SubscriptionUpdateType.METERED_PRICE:
return {
proration: 'create_prorations',
};
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE:
return {
proration: 'none',
@@ -6,7 +6,6 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { AuditJobModule } from 'src/engine/core-modules/audit/jobs/audit-job.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { EnforceUsageCapJob } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.job';
import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/billing-product.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';
@@ -85,7 +84,6 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
CleanSuspendedWorkspacesJob,
CleanOnboardingWorkspacesJob,
EmailSenderJob,
EnforceUsageCapJob,
UpdateSubscriptionQuantityJob,
HandleWorkspaceMemberDeletedJob,
CleanWorkspaceDeletionWarningUserVarsJob,
@@ -798,14 +798,6 @@ 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',
@@ -1,5 +1,4 @@
import {
FeatureFlagKey,
type FieldMetadataType,
type ObjectsPermissions,
} from 'twenty-shared/types';
@@ -241,7 +240,6 @@ describe('WorkspaceEntityManager', () => {
IS_JUNCTION_RELATIONS_ENABLED: false,
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: false,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false,
[FeatureFlagKey.IS_BILLING_V2_ENABLED]: false,
},
userWorkspaceRoleMap: {},
eventEmitterService: {
@@ -4,5 +4,4 @@ export const DEFAULT_FEATURE_FLAGS = [
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
FeatureFlagKey.IS_BILLING_V2_ENABLED,
] as const satisfies FeatureFlagKey[];
@@ -8,6 +8,5 @@ export enum FeatureFlagKey {
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED',
IS_BILLING_V2_ENABLED = 'IS_BILLING_V2_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
}