chore(billing) - remove feature flag (#20531)
- remove feature flag - remove old enforce cap usage logic
This commit is contained in:
-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',
|
||||
|
||||
Reference in New Issue
Block a user