chore(billing) - remove feature flag (#20531)
- remove feature flag - remove old enforce cap usage logic
This commit is contained in:
@@ -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>;
|
||||
|
||||
+17
-45
@@ -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"
|
||||
|
||||
+1
-3
@@ -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`,
|
||||
|
||||
+2
-12
@@ -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) &&
|
||||
|
||||
+8
-62
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+29
-68
@@ -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}
|
||||
|
||||
-210
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+5
-5
@@ -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();
|
||||
|
||||
+3
-3
@@ -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
|
||||
}
|
||||
+3
-3
@@ -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
|
||||
}
|
||||
+3
-3
@@ -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
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
|
||||
|
||||
describe('useCurrentMetered', () => {
|
||||
it('should be a function', () => {
|
||||
expect(typeof useCurrentMetered).toBe('function');
|
||||
});
|
||||
});
|
||||
-7
@@ -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);
|
||||
|
||||
-60
@@ -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];
|
||||
};
|
||||
+4
-6
@@ -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,
|
||||
);
|
||||
|
||||
-41
@@ -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,
|
||||
};
|
||||
};
|
||||
+6
-20
@@ -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,
|
||||
|
||||
+3
-382
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-21
@@ -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 {};
|
||||
}
|
||||
|
||||
-2
@@ -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 {}
|
||||
|
||||
-52
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+8
-81
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
-377
@@ -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();
|
||||
});
|
||||
});
|
||||
-33
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
-9
@@ -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 * * * *';
|
||||
-296
@@ -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
-2
@@ -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;
|
||||
|
||||
-2
@@ -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, {
|
||||
|
||||
-60
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+59
-128
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+98
-567
File diff suppressed because it is too large
Load Diff
+3
-139
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
-253
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+155
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+6
-6
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
+8
-7
@@ -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;
|
||||
|
||||
-97
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-77
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
-61
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-57
@@ -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 {
|
||||
|
||||
+86
-345
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
-6
@@ -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,
|
||||
|
||||
+3
-96
@@ -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,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
+15
-165
@@ -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 {
|
||||
|
||||
-259
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+88
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
-5
@@ -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;
|
||||
|
||||
-11
@@ -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,
|
||||
|
||||
+1
-1
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
-4
@@ -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',
|
||||
|
||||
-2
@@ -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: {
|
||||
|
||||
-1
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user