Billing - Migrate from Stripe metering (#20298)
**Overall strategy** **1. Introduce “Billing V2” behind a workspace flag** Gate the new model with FeatureFlagKey.IS_BILLING_V2_ENABLED so existing workspaces stay on the old behavior until they’re migrated or explicitly on V2. **2. Replace workflow metered SKUs with a resource-credit product** Conceptually, billable “workflow execution” usage is not the primary subscription line item anymore. Add a RESOURCE_CREDIT product (and keep WORKFLOW_NODE_EXECUTION as deprecated for the transition). Usage and limits are expressed through credit buckets (e.g. price metadata like credit_amount), so one product can represent pooled credits instead of a narrow workflow-only meter. **3. Migrate subscriptions in two layers** Schema/catalog: persist extra price metadata (instance upgrade) so the server knows credit amounts and can match Stripe prices to the new model. Per workspace: the registered workspace command upgrade:2-2:migrate-to-billing-v2 finds subscriptions that still have WORKFLOW_NODE_EXECUTION, swaps those items to the right RESOURCE_CREDIT prices (using existing Stripe schedule + BillingSubscriptionUpdateService stack), then treats the workspace as V2 (flag). Workspaces without that legacy item or without a subscription are skipped. **4. Unify subscription lifecycle + usage on the server** **5. Refresh the product surface in Settings** Test : - [x] Subscribe v1 + Update subscribe + Migrate - [x] Subscribe v2 + Update subscribe
This commit is contained in:
+1
-1
@@ -67,7 +67,7 @@ export class AppBillingController {
|
||||
APP_BILLING_CHARGE_THROTTLE_TTL_MS,
|
||||
);
|
||||
|
||||
this.appBillingService.emitChargeEvent({
|
||||
await this.appBillingService.emitChargeEvent({
|
||||
workspaceId: request.workspace.id,
|
||||
applicationId: request.application.id,
|
||||
userWorkspaceId: request.userWorkspaceId,
|
||||
|
||||
+4
@@ -5,16 +5,20 @@ import { Module } from '@nestjs/common';
|
||||
import { AppBillingController } from 'src/engine/core-modules/billing/app-billing/app-billing.controller';
|
||||
import { AppBillingService } from 'src/engine/core-modules/billing/app-billing/app-billing.service';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
BillingModule,
|
||||
ThrottlerModule,
|
||||
TwentyConfigModule,
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
WorkspaceEventEmitterModule,
|
||||
],
|
||||
|
||||
+22
-3
@@ -3,11 +3,13 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type ChargeDto } from 'src/engine/core-modules/billing/app-billing/dtos/charge.dto';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
|
||||
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
|
||||
import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
|
||||
import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
// Each operation type has one canonical counting unit — matches how
|
||||
@@ -27,14 +29,18 @@ const USAGE_UNIT_BY_OPERATION_TYPE: Record<UsageOperationType, UsageUnit> = {
|
||||
export class AppBillingService {
|
||||
private readonly logger = new Logger(AppBillingService.name);
|
||||
|
||||
constructor(private readonly workspaceEventEmitter: WorkspaceEventEmitter) {}
|
||||
constructor(
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
emitChargeEvent(params: {
|
||||
async emitChargeEvent(params: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string | null;
|
||||
charge: ChargeDto;
|
||||
}): void {
|
||||
}): Promise<void> {
|
||||
const { workspaceId, applicationId, userWorkspaceId, charge } = params;
|
||||
const unit = USAGE_UNIT_BY_OPERATION_TYPE[charge.operationType];
|
||||
|
||||
@@ -43,6 +49,18 @@ export class AppBillingService {
|
||||
`${charge.creditsUsedMicro} micro-credits (${charge.quantity} ${unit}, ${charge.operationType})`,
|
||||
);
|
||||
|
||||
let periodStart: Date | undefined;
|
||||
|
||||
if (this.billingService.isBillingEnabled()) {
|
||||
const {
|
||||
billingSubscription: { currentPeriodStart },
|
||||
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'billingSubscription',
|
||||
]);
|
||||
|
||||
periodStart = currentPeriodStart;
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
|
||||
USAGE_RECORDED,
|
||||
[
|
||||
@@ -55,6 +73,7 @@ export class AppBillingService {
|
||||
resourceId: applicationId,
|
||||
resourceContext: charge.resourceContext ?? null,
|
||||
userWorkspaceId: userWorkspaceId ?? null,
|
||||
periodStart,
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
|
||||
@@ -101,6 +101,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
BillingSubscriptionService,
|
||||
BillingSubscriptionUpdateService,
|
||||
BillingSubscriptionItemService,
|
||||
BillingSubscriptionPhaseService,
|
||||
BillingPortalWorkspaceService,
|
||||
BillingService,
|
||||
BillingUsageService,
|
||||
|
||||
@@ -6,15 +6,17 @@ import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
|
||||
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
|
||||
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { BillingEndTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-end-trial-period.dto';
|
||||
import { BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
|
||||
import { BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
|
||||
import { BillingSessionDTO } from 'src/engine/core-modules/billing/dtos/billing-session.dto';
|
||||
import { BillingUpdateDTO } from 'src/engine/core-modules/billing/dtos/billing-update.dto';
|
||||
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
|
||||
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
|
||||
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
|
||||
@@ -23,13 +25,13 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import {
|
||||
INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
|
||||
toDisplayCredits,
|
||||
} from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
@@ -46,7 +48,7 @@ import {
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
@MetadataResolver()
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@@ -63,6 +65,7 @@ export class BillingResolver {
|
||||
private readonly billingService: BillingService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
@Query(() => BillingSessionDTO)
|
||||
@@ -238,11 +241,23 @@ export class BillingResolver {
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Args() { priceId }: BillingUpdateSubscriptionItemPriceInput,
|
||||
) {
|
||||
await this.billingSubscriptionUpdateService.changeMeteredPrice(
|
||||
const isV2 = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_BILLING_V2_ENABLED,
|
||||
workspace.id,
|
||||
priceId,
|
||||
);
|
||||
|
||||
if (isV2) {
|
||||
await this.billingSubscriptionUpdateService.changeResourceCreditPrice(
|
||||
workspace.id,
|
||||
priceId,
|
||||
);
|
||||
} else {
|
||||
await this.billingSubscriptionUpdateService.changeMeteredPrice(
|
||||
workspace.id,
|
||||
priceId,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
billingSubscriptions:
|
||||
await this.billingSubscriptionService.getBillingSubscriptions(
|
||||
@@ -300,11 +315,18 @@ export class BillingResolver {
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
)
|
||||
//TODO: To rename to getResourceCreditProductsUsage
|
||||
async getMeteredProductsUsage(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<BillingMeteredProductUsageDTO[]> {
|
||||
const usageData =
|
||||
await this.billingUsageService.getMeteredProductsUsage(workspace);
|
||||
const isV2 = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_BILLING_V2_ENABLED,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const usageData = isV2
|
||||
? await this.billingUsageService.getResourceCreditProductUsage(workspace)
|
||||
: await this.billingUsageService.getMeteredProductsUsage(workspace);
|
||||
|
||||
return usageData.map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { type MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
|
||||
import { isNumber } from 'class-validator';
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { type BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
|
||||
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import {
|
||||
type LicensedBillingSubscriptionItem,
|
||||
type MeteredBillingSubscriptionItem,
|
||||
} from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
|
||||
import { type BillingSubscriptionWithSubscriptionItems } from 'src/engine/core-modules/billing/types/billing-subscription-with-subscription-items';
|
||||
import { type MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
|
||||
|
||||
const assertIsMeteredTiersSchemaOrThrow = (
|
||||
tiers: BillingPriceEntity['tiers'] | undefined | null,
|
||||
@@ -148,6 +150,45 @@ const assertIsSubscription = (
|
||||
return;
|
||||
};
|
||||
|
||||
// V2 validators — do not throw for V1 items; only used on V2 code paths
|
||||
|
||||
const isLicensedResourceCreditItem = (
|
||||
subscriptionItem: BillingSubscriptionItemEntity,
|
||||
): boolean => {
|
||||
return (
|
||||
subscriptionItem.billingProduct?.metadata?.productKey ===
|
||||
BillingProductKey.RESOURCE_CREDIT
|
||||
);
|
||||
};
|
||||
|
||||
const assertIsLicensedResourceCreditPrice = (
|
||||
price: BillingPriceEntity,
|
||||
): void => {
|
||||
if (
|
||||
price.billingProduct?.metadata?.productKey !==
|
||||
BillingProductKey.RESOURCE_CREDIT
|
||||
) {
|
||||
throw new BillingException(
|
||||
'Price is not a RESOURCE_CREDIT licensed price',
|
||||
BillingExceptionCode.BILLING_PRICE_INVALID,
|
||||
);
|
||||
}
|
||||
|
||||
const creditAmount = price.metadata?.credit_amount;
|
||||
|
||||
if (!isDefined(creditAmount) || !isNumber(Number(creditAmount))) {
|
||||
throw new BillingException(
|
||||
'RESOURCE_CREDIT price must have a credit_amount in metadata',
|
||||
BillingExceptionCode.BILLING_PRICE_INVALID,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getCapFromCreditMetadata = (price: BillingPriceEntity): number => {
|
||||
assertIsLicensedResourceCreditPrice(price);
|
||||
return Number(price.metadata?.credit_amount);
|
||||
};
|
||||
|
||||
export const billingValidator: {
|
||||
assertIsMeteredTiersSchemaOrThrow: typeof assertIsMeteredTiersSchemaOrThrow;
|
||||
isMeteredTiersSchema: typeof isMeteredTiersSchema;
|
||||
@@ -156,6 +197,9 @@ export const billingValidator: {
|
||||
assertIsMeteredPrice: typeof assertIsMeteredPrice;
|
||||
assertIsSubscription: typeof assertIsSubscription;
|
||||
isMeteredPrice: typeof isMeteredPrice;
|
||||
assertIsLicensedResourceCreditPrice: typeof assertIsLicensedResourceCreditPrice;
|
||||
isLicensedResourceCreditItem: typeof isLicensedResourceCreditItem;
|
||||
getCapFromCreditMetadata: typeof getCapFromCreditMetadata;
|
||||
} = {
|
||||
assertIsMeteredTiersSchemaOrThrow,
|
||||
isMeteredTiersSchema,
|
||||
@@ -164,4 +208,7 @@ export const billingValidator: {
|
||||
assertIsMeteredPrice,
|
||||
assertIsSubscription,
|
||||
isMeteredPrice,
|
||||
assertIsLicensedResourceCreditPrice,
|
||||
isLicensedResourceCreditItem,
|
||||
getCapFromCreditMetadata,
|
||||
};
|
||||
|
||||
+17
-1
@@ -11,6 +11,7 @@ import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/e
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
const METERED_STRIPE_PRODUCT_ID = 'prod_metered';
|
||||
@@ -22,7 +23,15 @@ describe('EnforceUsageCapJob', () => {
|
||||
let billingSubscriptionItemRepository: jest.Mocked<{
|
||||
update: jest.Mock;
|
||||
}>;
|
||||
let billingUsageCapService: jest.Mocked<BillingUsageCapService>;
|
||||
let billingUsageCapService: jest.Mocked<
|
||||
Pick<
|
||||
BillingUsageCapService,
|
||||
| 'isClickHouseEnabled'
|
||||
| 'getBatchPeriodCreditsUsed'
|
||||
| 'evaluateCapBatch'
|
||||
| 'evaluateCapBatchV2'
|
||||
>
|
||||
>;
|
||||
let twentyConfigService: jest.Mocked<TwentyConfigService>;
|
||||
|
||||
const buildSubscription = ({
|
||||
@@ -87,12 +96,19 @@ describe('EnforceUsageCapJob', () => {
|
||||
isClickHouseEnabled: jest.fn().mockReturnValue(true),
|
||||
getBatchPeriodCreditsUsed: jest.fn().mockResolvedValue(new Map()),
|
||||
evaluateCapBatch: jest.fn().mockReturnValue(new Map()),
|
||||
evaluateCapBatchV2: jest.fn().mockReturnValue(new Map()),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: { get: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: FeatureFlagService,
|
||||
useValue: {
|
||||
isFeatureEnabled: jest.fn().mockResolvedValue(false),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
+35
-3
@@ -4,6 +4,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { In, IsNull, Repository } from 'typeorm';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { enforceUsageCapCronPattern } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.cron.pattern';
|
||||
@@ -14,6 +15,7 @@ import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
@@ -35,6 +37,7 @@ export class EnforceUsageCapJob {
|
||||
private readonly billingProductRepository: Repository<BillingProductEntity>,
|
||||
private readonly billingUsageCapService: BillingUsageCapService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
@Process(EnforceUsageCapJob.name)
|
||||
@@ -155,11 +158,35 @@ export class EnforceUsageCapJob {
|
||||
}
|
||||
}
|
||||
|
||||
const evaluations = this.billingUsageCapService.evaluateCapBatch(
|
||||
batch,
|
||||
// Collect V2 workspace IDs in this batch (Redis-cached, so ~0 cost per call)
|
||||
const v2WorkspaceIds = new Set<string>();
|
||||
|
||||
for (const subscription of batch) {
|
||||
const isV2 = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_BILLING_V2_ENABLED,
|
||||
subscription.workspaceId,
|
||||
);
|
||||
|
||||
if (isV2) {
|
||||
v2WorkspaceIds.add(subscription.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
const v2Batch = batch.filter((s) => v2WorkspaceIds.has(s.workspaceId));
|
||||
const v1Batch = batch.filter((s) => !v2WorkspaceIds.has(s.workspaceId));
|
||||
|
||||
const v1Evaluations = this.billingUsageCapService.evaluateCapBatch(
|
||||
v1Batch,
|
||||
usageByWorkspace,
|
||||
creditBalanceByCustomer,
|
||||
);
|
||||
const v2Evaluations = this.billingUsageCapService.evaluateCapBatchV2(
|
||||
v2Batch,
|
||||
usageByWorkspace,
|
||||
creditBalanceByCustomer,
|
||||
);
|
||||
|
||||
const evaluations = new Map([...v1Evaluations, ...v2Evaluations]);
|
||||
|
||||
const idsToCapTrue: string[] = [];
|
||||
const idsToCapFalse: string[] = [];
|
||||
@@ -177,10 +204,15 @@ export class EnforceUsageCapJob {
|
||||
|
||||
evaluated += 1;
|
||||
|
||||
// V2: find item by RESOURCE_CREDIT; V1: find by WORKFLOW_NODE_EXECUTION
|
||||
const targetProductKey = v2WorkspaceIds.has(subscription.workspaceId)
|
||||
? BillingProductKey.RESOURCE_CREDIT
|
||||
: BillingProductKey.WORKFLOW_NODE_EXECUTION;
|
||||
|
||||
const meteredItem = subscription.billingSubscriptionItems.find(
|
||||
(item) =>
|
||||
productByStripeProductId.get(item.stripeProductId)?.metadata
|
||||
?.productKey === BillingProductKey.WORKFLOW_NODE_EXECUTION,
|
||||
?.productKey === targetProductKey,
|
||||
);
|
||||
|
||||
if (!meteredItem) {
|
||||
|
||||
@@ -14,7 +14,10 @@ export class BillingPlanDTO {
|
||||
planKey: BillingPlanKey;
|
||||
|
||||
@Field(() => [BillingLicensedProduct])
|
||||
licensedProducts: BillingLicensedProduct[];
|
||||
baseProducts: BillingLicensedProduct[];
|
||||
|
||||
@Field(() => [BillingLicensedProduct])
|
||||
resourceCreditProducts: BillingLicensedProduct[];
|
||||
|
||||
@Field(() => [BillingMeteredProduct])
|
||||
meteredProducts: BillingMeteredProduct[];
|
||||
|
||||
+3
@@ -18,4 +18,7 @@ export class BillingPriceLicensedDTO {
|
||||
|
||||
@Field(() => BillingUsageType)
|
||||
priceUsageType: BillingUsageType.LICENSED;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
creditAmount: number | null;
|
||||
}
|
||||
|
||||
+4
@@ -22,6 +22,7 @@ import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/b
|
||||
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
import { BillingPriceMetadata } from 'src/engine/core-modules/billing/types/billing-price-metadata.type';
|
||||
|
||||
@Entity({ name: 'billingPrice', schema: 'core' })
|
||||
export class BillingPriceEntity {
|
||||
@@ -94,6 +95,9 @@ export class BillingPriceEntity {
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
stripeMeterId: string | null;
|
||||
|
||||
@Column({ nullable: false, type: 'jsonb', default: {} })
|
||||
metadata: BillingPriceMetadata;
|
||||
|
||||
@Field(() => BillingUsageType)
|
||||
@Column({
|
||||
type: 'enum',
|
||||
|
||||
+2
@@ -4,6 +4,8 @@ import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum BillingProductKey {
|
||||
BASE_PRODUCT = 'BASE_PRODUCT',
|
||||
RESOURCE_CREDIT = 'RESOURCE_CREDIT',
|
||||
// @deprecated — replaced by RESOURCE_CREDIT, kept while IS_BILLING_V2_ENABLED is not universal
|
||||
WORKFLOW_NODE_EXECUTION = 'WORKFLOW_NODE_EXECUTION',
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -3,9 +3,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { OnCustomBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-custom-batch-event.decorator';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
@@ -16,6 +18,7 @@ export class BillingUsageEventListener {
|
||||
constructor(
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
@OnCustomBatchEvent(USAGE_RECORDED)
|
||||
@@ -38,6 +41,16 @@ export class BillingUsageEventListener {
|
||||
return;
|
||||
}
|
||||
|
||||
const isV2 = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_BILLING_V2_ENABLED,
|
||||
payload.workspaceId,
|
||||
);
|
||||
|
||||
if (isV2) {
|
||||
// V2: ClickHouse is the sole record; no Stripe meter events needed
|
||||
return;
|
||||
}
|
||||
|
||||
//TODO: To be removed
|
||||
await this.billingUsageService.billUsage({
|
||||
workspaceId: payload.workspaceId,
|
||||
|
||||
+7
@@ -7,6 +7,7 @@ import type Stripe from 'stripe';
|
||||
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
|
||||
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
|
||||
|
||||
@@ -35,6 +36,12 @@ describe('BillingCreditRolloverService', () => {
|
||||
sumMeterEvents: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BillingUsageService,
|
||||
useValue: {
|
||||
getCurrentPeriodCreditsUsed: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(BillingCustomerEntity),
|
||||
useValue: {
|
||||
|
||||
+62
-16
@@ -17,9 +17,11 @@ import { BillingSubscriptionUpdateService } from 'src/engine/core-modules/billin
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
|
||||
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
|
||||
import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/services/stripe-invoice.service';
|
||||
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
|
||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||
import { SubscriptionUpdateType } from 'src/engine/core-modules/billing/types/billing-subscription-update.type';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
|
||||
import {
|
||||
arrangeBillingPriceRepositoryFindOneOrFail,
|
||||
@@ -66,6 +68,20 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
BillingSubscriptionUpdateService,
|
||||
{
|
||||
provide: FeatureFlagService,
|
||||
useValue: {
|
||||
isFeatureEnabled: jest.fn().mockResolvedValue(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StripeInvoiceService,
|
||||
useValue: {
|
||||
createImmediateUpgradeInvoice: jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BillingSubscriptionService,
|
||||
useValue: {
|
||||
@@ -100,29 +116,59 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
provide: BillingSubscriptionPhaseService,
|
||||
useValue: {
|
||||
toPhaseUpdateParams: jest.fn(),
|
||||
buildPhaseUpdateParams: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async ({
|
||||
licensedStripePriceId,
|
||||
seats,
|
||||
meteredStripePriceId,
|
||||
startDate,
|
||||
endDate,
|
||||
}) => ({
|
||||
buildPhaseUpdateParams: jest.fn().mockImplementation(
|
||||
async ({
|
||||
toUpdatePrices,
|
||||
startDate,
|
||||
endDate,
|
||||
isV2,
|
||||
}: {
|
||||
toUpdatePrices: {
|
||||
licensedPriceId: string;
|
||||
seats: number;
|
||||
meteredPriceId?: string;
|
||||
resourceCreditPriceId?: string;
|
||||
};
|
||||
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
|
||||
endDate: number | undefined;
|
||||
isV2: boolean;
|
||||
}) => {
|
||||
if (isV2) {
|
||||
return {
|
||||
start_date: startDate,
|
||||
...(endDate ? { end_date: endDate } : {}),
|
||||
proration_behavior: 'none',
|
||||
items: [
|
||||
{
|
||||
price: toUpdatePrices.licensedPriceId,
|
||||
quantity: toUpdatePrices.seats,
|
||||
},
|
||||
{
|
||||
price: toUpdatePrices.resourceCreditPriceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start_date: startDate,
|
||||
...(endDate ? { end_date: endDate } : {}),
|
||||
proration_behavior: 'none',
|
||||
items: [
|
||||
{ price: licensedStripePriceId, quantity: seats },
|
||||
{ price: meteredStripePriceId },
|
||||
{
|
||||
price: toUpdatePrices.licensedPriceId,
|
||||
quantity: toUpdatePrices.seats,
|
||||
},
|
||||
{ price: toUpdatePrices.meteredPriceId },
|
||||
],
|
||||
billing_thresholds: {
|
||||
amount_gte: 1000,
|
||||
reset_billing_cycle_anchor: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
isSamePhaseSignature: jest.fn().mockResolvedValue(false),
|
||||
},
|
||||
},
|
||||
@@ -251,7 +297,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
},
|
||||
{ id: 'si_metered', price: METER_PRICE_ENTERPRISE_MONTH_ID },
|
||||
],
|
||||
proration_behavior: 'create_prorations',
|
||||
proration_behavior: 'always_invoice',
|
||||
metadata: { plan: BillingPlanKey.ENTERPRISE },
|
||||
billing_thresholds: {
|
||||
amount_gte: 1000,
|
||||
@@ -377,7 +423,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
},
|
||||
{ id: 'si_metered', price: METER_PRICE_ENTERPRISE_MONTH_ID },
|
||||
],
|
||||
proration_behavior: 'create_prorations',
|
||||
proration_behavior: 'always_invoice',
|
||||
metadata: { plan: BillingPlanKey.ENTERPRISE },
|
||||
billing_thresholds: {
|
||||
amount_gte: 1000,
|
||||
|
||||
+3
-19
@@ -9,9 +9,8 @@ import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/e
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
|
||||
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
describe('BillingUsageCapService', () => {
|
||||
let service: BillingUsageCapService;
|
||||
@@ -55,18 +54,9 @@ describe('BillingUsageCapService', () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CacheStorageNamespace.EngineBillingUsage,
|
||||
provide: FeatureFlagService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
incrBy: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(BillingSubscriptionEntity),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
isFeatureEnabled: jest.fn().mockResolvedValue(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -76,12 +66,6 @@ describe('BillingUsageCapService', () => {
|
||||
update: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheService,
|
||||
useValue: {
|
||||
getOrRecompute: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
+37
-1
@@ -100,7 +100,7 @@ export const arrangeBillingSubscriptionRepositoryFindOneOrFail = (
|
||||
export const arrangeBillingPriceRepositoryFindOneOrFail = (
|
||||
billingPriceRepository: jest.Mocked<Repository<BillingPriceEntity>>,
|
||||
priceIdToPriceMap: Record<string, BillingPriceEntity | BillingMeterPrice>,
|
||||
) =>
|
||||
) => {
|
||||
jest
|
||||
.spyOn(billingPriceRepository, 'findOneOrFail')
|
||||
.mockImplementation(async (criteria: unknown) => {
|
||||
@@ -114,6 +114,42 @@ export const arrangeBillingPriceRepositoryFindOneOrFail = (
|
||||
return {} as BillingPriceEntity;
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(billingPriceRepository, 'find')
|
||||
.mockImplementation(async (criteria?: unknown) => {
|
||||
const where = (criteria as { where?: { stripePriceId?: unknown } })
|
||||
?.where;
|
||||
const stripePriceIdCondition = where?.stripePriceId;
|
||||
|
||||
const resolveStripePriceIds = (cond: unknown): string[] => {
|
||||
if (typeof cond === 'string') {
|
||||
return [cond];
|
||||
}
|
||||
if (
|
||||
cond &&
|
||||
typeof cond === 'object' &&
|
||||
'value' in cond &&
|
||||
(cond as { value: unknown }).value !== undefined
|
||||
) {
|
||||
const value = (cond as { value: string | string[] }).value;
|
||||
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const stripePriceIds = resolveStripePriceIds(stripePriceIdCondition);
|
||||
|
||||
return stripePriceIds
|
||||
.map((stripePriceId) => priceIdToPriceMap[stripePriceId])
|
||||
.filter(
|
||||
(entity): entity is BillingPriceEntity =>
|
||||
entity !== null && entity !== undefined,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule =
|
||||
(
|
||||
stripeSubscriptionScheduleService: jest.Mocked<StripeSubscriptionScheduleService>,
|
||||
|
||||
+29
@@ -6,6 +6,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
|
||||
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
|
||||
|
||||
@@ -14,6 +15,7 @@ export class BillingCreditRolloverService {
|
||||
constructor(
|
||||
private readonly stripeCreditGrantService: StripeCreditGrantService,
|
||||
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
) {}
|
||||
@@ -75,6 +77,33 @@ export class BillingCreditRolloverService {
|
||||
await this.refreshCreditBalance(stripeCustomerId, unitPriceCents);
|
||||
}
|
||||
|
||||
// V2 path — reads usedCredits from ClickHouse; writes rollover directly to creditBalanceMicro
|
||||
async processRolloverOnPeriodTransitionV2({
|
||||
workspaceId,
|
||||
stripeCustomerId,
|
||||
tierQuantity,
|
||||
previousPeriodStart,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
stripeCustomerId: string;
|
||||
tierQuantity: number;
|
||||
previousPeriodStart: Date;
|
||||
}): Promise<void> {
|
||||
const usedCredits =
|
||||
await this.billingUsageService.getCurrentPeriodCreditsUsed(
|
||||
workspaceId,
|
||||
previousPeriodStart,
|
||||
);
|
||||
|
||||
const unusedCredits = Math.max(0, tierQuantity - usedCredits);
|
||||
const rolloverAmount = Math.min(unusedCredits, tierQuantity);
|
||||
|
||||
await this.billingCustomerRepository.update(
|
||||
{ stripeCustomerId },
|
||||
{ creditBalanceMicro: rolloverAmount },
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshCreditBalance(
|
||||
stripeCustomerId: string,
|
||||
unitPriceCents: number,
|
||||
|
||||
+23
-10
@@ -3,8 +3,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { JsonContains, Repository } from 'typeorm';
|
||||
import { findOrThrow } from 'twenty-shared/utils';
|
||||
import { JsonContains, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
BillingException,
|
||||
@@ -82,15 +82,20 @@ export class BillingPlanService {
|
||||
(product) =>
|
||||
product.metadata.priceUsageBased === BillingUsageType.METERED,
|
||||
);
|
||||
const licensedProducts = planProducts.filter(
|
||||
const baseProducts = planProducts.filter(
|
||||
(product) =>
|
||||
product.metadata.priceUsageBased === BillingUsageType.LICENSED,
|
||||
product.metadata.productKey === BillingProductKey.BASE_PRODUCT,
|
||||
);
|
||||
const resourceCreditProducts = planProducts.filter(
|
||||
(product) =>
|
||||
product.metadata.productKey === BillingProductKey.RESOURCE_CREDIT,
|
||||
);
|
||||
|
||||
return {
|
||||
planKey,
|
||||
meteredProducts,
|
||||
licensedProducts,
|
||||
baseProducts,
|
||||
resourceCreditProducts,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -105,7 +110,12 @@ export class BillingPlanService {
|
||||
(price) => price.stripePriceId === stripePriceId,
|
||||
),
|
||||
) ||
|
||||
plan.licensedProducts.some((product) =>
|
||||
plan.baseProducts.some((product) =>
|
||||
product.billingPrices.some(
|
||||
(price) => price.stripePriceId === stripePriceId,
|
||||
),
|
||||
) ||
|
||||
plan.resourceCreditProducts.some((product) =>
|
||||
product.billingPrices.some(
|
||||
(price) => price.stripePriceId === stripePriceId,
|
||||
),
|
||||
@@ -130,21 +140,24 @@ export class BillingPlanService {
|
||||
BillingExceptionCode.BILLING_PLAN_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
const { meteredProducts, licensedProducts } = plan;
|
||||
const { meteredProducts, baseProducts, resourceCreditProducts } = plan;
|
||||
|
||||
const filterPricesByInterval = (product: BillingProductEntity) =>
|
||||
product.billingPrices.filter((price) => price.interval === interval);
|
||||
|
||||
const meteredProductsPrices = meteredProducts.flatMap(
|
||||
const meteredProductPrices = meteredProducts.flatMap(
|
||||
filterPricesByInterval,
|
||||
);
|
||||
const licensedProductsPrices = licensedProducts.flatMap(
|
||||
const baseProductPrices = baseProducts.flatMap(filterPricesByInterval);
|
||||
|
||||
const resourceCreditProductPrices = resourceCreditProducts.flatMap(
|
||||
filterPricesByInterval,
|
||||
);
|
||||
|
||||
return {
|
||||
meteredProductsPrices,
|
||||
licensedProductsPrices,
|
||||
meteredProductPrices,
|
||||
baseProductPrices,
|
||||
resourceCreditProductPrices,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+73
-22
@@ -29,8 +29,11 @@ import { type BillingGetPricesPerPlanResult } from 'src/engine/core-modules/bill
|
||||
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
|
||||
import { type BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
@Injectable()
|
||||
export class BillingPortalWorkspaceService {
|
||||
@@ -40,12 +43,14 @@ export class BillingPortalWorkspaceService {
|
||||
private readonly stripeBillingPortalService: StripeBillingPortalService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async computeCheckoutSessionURL({
|
||||
@@ -127,14 +132,9 @@ export class BillingPortalWorkspaceService {
|
||||
!isDefined(customer) || customer.billingSubscriptions.length === 0,
|
||||
});
|
||||
|
||||
const createdBillingSubscription =
|
||||
await this.billingSubscriptionService.syncSubscriptionToDatabase(
|
||||
workspace.id,
|
||||
stripeSubscription.id,
|
||||
);
|
||||
|
||||
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
|
||||
createdBillingSubscription.id,
|
||||
await this.billingSubscriptionService.syncSubscriptionToDatabase(
|
||||
workspace.id,
|
||||
stripeSubscription.id,
|
||||
);
|
||||
|
||||
return successUrl;
|
||||
@@ -168,10 +168,12 @@ export class BillingPortalWorkspaceService {
|
||||
relations: ['billingSubscriptions'],
|
||||
});
|
||||
|
||||
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
|
||||
quantity,
|
||||
billingPricesPerPlan,
|
||||
});
|
||||
const stripeSubscriptionLineItems =
|
||||
await this.getStripeSubscriptionLineItems({
|
||||
quantity,
|
||||
billingPricesPerPlan,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
return {
|
||||
successUrl,
|
||||
@@ -265,7 +267,7 @@ export class BillingPortalWorkspaceService {
|
||||
billingPricesPerPlan: BillingGetPricesPerPlanResult,
|
||||
): BillingMeterPrice {
|
||||
const defaultMeteredProductPrice =
|
||||
billingPricesPerPlan.meteredProductsPrices.reduce(
|
||||
billingPricesPerPlan.meteredProductPrices.reduce(
|
||||
(result, billingPrice) => {
|
||||
if (!result) {
|
||||
return billingPrice as BillingMeterPrice;
|
||||
@@ -293,20 +295,50 @@ export class BillingPortalWorkspaceService {
|
||||
return defaultMeteredProductPrice;
|
||||
}
|
||||
|
||||
private getStripeSubscriptionLineItems({
|
||||
// V2 path — finds the lowest credit_amount RESOURCE_CREDIT licensed price as default
|
||||
private getDefaultResourceCreditPrice(
|
||||
billingPricesPerPlan: BillingGetPricesPerPlanResult,
|
||||
) {
|
||||
const resourceCreditPrices =
|
||||
billingPricesPerPlan.resourceCreditProductPrices;
|
||||
|
||||
if (!isDefined(resourceCreditPrices) || resourceCreditPrices.length === 0) {
|
||||
throw new BillingException(
|
||||
'Missing Default RESOURCE_CREDIT price',
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return resourceCreditPrices.reduce((lowest, price) => {
|
||||
const amount = Number(price.metadata?.credit_amount ?? 0);
|
||||
const lowestAmount = Number(lowest.metadata?.credit_amount ?? 0);
|
||||
|
||||
return amount < lowestAmount ? price : lowest;
|
||||
});
|
||||
}
|
||||
|
||||
private async getStripeSubscriptionLineItems({
|
||||
quantity,
|
||||
billingPricesPerPlan,
|
||||
workspaceId,
|
||||
}: {
|
||||
quantity: number;
|
||||
billingPricesPerPlan: BillingGetPricesPerPlanResult;
|
||||
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
|
||||
const defaultMeteredProductPrice =
|
||||
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
|
||||
workspaceId: string;
|
||||
}): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
|
||||
const isV2 = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_BILLING_V2_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const defaultLicensedProductPrice = findOrThrow(
|
||||
billingPricesPerPlan.licensedProductsPrices,
|
||||
(licensedProductsPrice) =>
|
||||
licensedProductsPrice.billingProduct?.metadata.productKey ===
|
||||
const isBillingV2EnabledForNewWorkspaces = this.twentyConfigService.get(
|
||||
'IS_BILLING_V2_ENABLED_FOR_NEW_WORKSPACES',
|
||||
);
|
||||
|
||||
const defaultBaseProductPrice = findOrThrow(
|
||||
billingPricesPerPlan.baseProductPrices,
|
||||
(baseProductPrice) =>
|
||||
baseProductPrice.billingProduct?.metadata.productKey ===
|
||||
BillingProductKey.BASE_PRODUCT,
|
||||
new BillingException(
|
||||
`Base product not found`,
|
||||
@@ -314,9 +346,28 @@ export class BillingPortalWorkspaceService {
|
||||
),
|
||||
);
|
||||
|
||||
if (isBillingV2EnabledForNewWorkspaces || isV2) {
|
||||
const defaultResourceCreditPrice =
|
||||
this.getDefaultResourceCreditPrice(billingPricesPerPlan);
|
||||
|
||||
return [
|
||||
{
|
||||
price: defaultBaseProductPrice.stripePriceId,
|
||||
quantity,
|
||||
},
|
||||
{
|
||||
price: defaultResourceCreditPrice.stripePriceId,
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const defaultMeteredProductPrice =
|
||||
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
|
||||
|
||||
return [
|
||||
{
|
||||
price: defaultLicensedProductPrice.stripePriceId,
|
||||
price: defaultBaseProductPrice.stripePriceId,
|
||||
quantity,
|
||||
},
|
||||
{
|
||||
|
||||
+63
@@ -12,6 +12,7 @@ import {
|
||||
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
|
||||
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
|
||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||
@@ -129,4 +130,66 @@ export class BillingPriceService {
|
||||
) as BillingMeterPrice[]
|
||||
).sort((a, b) => a.tiers[0].up_to - b.tiers[0].up_to);
|
||||
}
|
||||
|
||||
// V2 counterpart of findEquivalentMeteredPrice.
|
||||
// Finds a RESOURCE_CREDIT price matching the target interval and plan,
|
||||
// with the largest credit_amount that does not exceed the reference credit_amount.
|
||||
async findEquivalentResourceCreditPrice({
|
||||
targetInterval,
|
||||
targetPlanKey,
|
||||
hasSameInterval,
|
||||
hasSamePlanKey,
|
||||
referencePrice,
|
||||
}: {
|
||||
targetInterval: SubscriptionInterval;
|
||||
targetPlanKey: BillingPlanKey;
|
||||
hasSameInterval: boolean;
|
||||
hasSamePlanKey: boolean;
|
||||
referencePrice: BillingPriceEntity;
|
||||
}): Promise<BillingPriceEntity> {
|
||||
if (hasSameInterval && hasSamePlanKey) {
|
||||
return referencePrice;
|
||||
}
|
||||
|
||||
const catalog = await this.billingProductService.getProductPrices({
|
||||
interval: targetInterval,
|
||||
planKey: targetPlanKey,
|
||||
});
|
||||
|
||||
const referenceCreditAmount = Number(
|
||||
referencePrice.metadata?.credit_amount,
|
||||
);
|
||||
|
||||
const scaledAmount =
|
||||
!hasSameInterval && targetInterval === SubscriptionInterval.Year
|
||||
? referenceCreditAmount * 12
|
||||
: !hasSameInterval && targetInterval === SubscriptionInterval.Month
|
||||
? referenceCreditAmount / 12
|
||||
: referenceCreditAmount;
|
||||
|
||||
const resourceCreditCandidates = catalog
|
||||
.filter(
|
||||
(p) =>
|
||||
p.billingProduct?.metadata?.productKey ===
|
||||
BillingProductKey.RESOURCE_CREDIT,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(a.metadata?.credit_amount ?? 0) -
|
||||
Number(b.metadata?.credit_amount ?? 0),
|
||||
);
|
||||
|
||||
if (!resourceCreditCandidates.length) {
|
||||
throw new BillingException(
|
||||
'No RESOURCE_CREDIT price candidates found',
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
resourceCreditCandidates
|
||||
.filter((p) => Number(p.metadata?.credit_amount ?? 0) <= scaledAmount)
|
||||
.pop() ?? resourceCreditCandidates[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -59,6 +59,10 @@ export class BillingProductService {
|
||||
);
|
||||
}
|
||||
|
||||
return [...plan.licensedProducts, ...plan.meteredProducts];
|
||||
return [
|
||||
...plan.baseProducts,
|
||||
...plan.resourceCreditProducts,
|
||||
...plan.meteredProducts,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+57
-3
@@ -1,17 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { Raw, Repository } from 'typeorm';
|
||||
|
||||
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
|
||||
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@Injectable()
|
||||
export class BillingSubscriptionItemService {
|
||||
@@ -58,6 +61,57 @@ export class BillingSubscriptionItemService {
|
||||
);
|
||||
}
|
||||
|
||||
async getResourceCreditSubscriptionItemDetails(
|
||||
subscription: BillingSubscriptionEntity,
|
||||
): Promise<{
|
||||
stripeSubscriptionItemId: string;
|
||||
productKey: BillingProductKey;
|
||||
creditAmount: number;
|
||||
freeTrialQuantity: number;
|
||||
unitPriceCents: number;
|
||||
} | null> {
|
||||
const item = await this.billingSubscriptionItemRepository.findOne({
|
||||
where: {
|
||||
billingSubscriptionId: subscription.id,
|
||||
billingProduct: {
|
||||
metadata: Raw((alias) => `${alias} @> :metadata::jsonb`, {
|
||||
metadata: JSON.stringify({
|
||||
productKey: BillingProductKey.RESOURCE_CREDIT,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
relations: ['billingProduct', 'billingProduct.billingPrices'],
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const price = this.findMatchingPrice(item);
|
||||
|
||||
const trialDuration =
|
||||
isDefined(subscription.trialEnd) && isDefined(subscription.trialStart)
|
||||
? differenceInDays(subscription.trialEnd, subscription.trialStart)
|
||||
: 0;
|
||||
|
||||
const trialWithCreditCardDuration = this.twentyConfigService.get(
|
||||
'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS',
|
||||
);
|
||||
|
||||
return {
|
||||
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
|
||||
productKey: BillingProductKey.RESOURCE_CREDIT,
|
||||
creditAmount: Number(price.metadata?.credit_amount ?? 0),
|
||||
freeTrialQuantity: this.twentyConfigService.get(
|
||||
trialDuration === trialWithCreditCardDuration
|
||||
? 'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD'
|
||||
: 'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
|
||||
),
|
||||
unitPriceCents: price.unitAmount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private findMatchingPrice(
|
||||
item: BillingSubscriptionItemEntity,
|
||||
): BillingPriceEntity {
|
||||
|
||||
+112
-19
@@ -16,6 +16,7 @@ import { type BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-module
|
||||
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
|
||||
import { SubscriptionStripePrices } from 'src/engine/core-modules/billing/services/billing-subscription-update.service';
|
||||
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
|
||||
|
||||
@Injectable()
|
||||
@@ -83,31 +84,51 @@ export class BillingSubscriptionPhaseService {
|
||||
}
|
||||
|
||||
async buildPhaseUpdateParams({
|
||||
licensedStripePriceId,
|
||||
seats,
|
||||
meteredStripePriceId,
|
||||
toUpdatePrices,
|
||||
startDate,
|
||||
endDate,
|
||||
isV2,
|
||||
}: {
|
||||
licensedStripePriceId: string;
|
||||
seats: number;
|
||||
meteredStripePriceId: string;
|
||||
toUpdatePrices: SubscriptionStripePrices;
|
||||
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
|
||||
endDate: number | undefined;
|
||||
isV2: boolean;
|
||||
}): Promise<Stripe.SubscriptionScheduleUpdateParams.Phase> {
|
||||
return {
|
||||
start_date: startDate,
|
||||
...(endDate ? { end_date: endDate } : {}),
|
||||
proration_behavior: 'none',
|
||||
items: [
|
||||
{ price: licensedStripePriceId, quantity: seats },
|
||||
{ price: meteredStripePriceId },
|
||||
],
|
||||
billing_thresholds:
|
||||
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
|
||||
meteredStripePriceId,
|
||||
),
|
||||
};
|
||||
if (isV2) {
|
||||
assertIsDefinedOrThrow(toUpdatePrices.resourceCreditPriceId);
|
||||
return {
|
||||
start_date: startDate,
|
||||
...(endDate ? { end_date: endDate } : {}),
|
||||
proration_behavior: 'none',
|
||||
items: [
|
||||
{
|
||||
price: toUpdatePrices.licensedPriceId,
|
||||
quantity: toUpdatePrices.seats,
|
||||
},
|
||||
{ price: toUpdatePrices.resourceCreditPriceId, quantity: 1 },
|
||||
],
|
||||
};
|
||||
} else {
|
||||
assertIsDefinedOrThrow(toUpdatePrices.meteredPriceId);
|
||||
return {
|
||||
start_date: startDate,
|
||||
...(endDate ? { end_date: endDate } : {}),
|
||||
proration_behavior: 'none',
|
||||
items: [
|
||||
{
|
||||
price: toUpdatePrices.licensedPriceId,
|
||||
quantity: toUpdatePrices.seats,
|
||||
},
|
||||
{
|
||||
price: toUpdatePrices.meteredPriceId,
|
||||
},
|
||||
],
|
||||
billing_thresholds:
|
||||
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
|
||||
toUpdatePrices.meteredPriceId,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getLicensedPriceIdAndQuantityFromPhaseUpdateParams(
|
||||
@@ -159,4 +180,76 @@ export class BillingSubscriptionPhaseService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Billing V2: emits { price, quantity: 1 } for the resource credit price; no billing_thresholds
|
||||
async buildResourceCreditPhaseUpdateParams({
|
||||
basePlanStripePriceId,
|
||||
seats,
|
||||
resourceCreditStripePriceId,
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
basePlanStripePriceId: string;
|
||||
seats: number;
|
||||
resourceCreditStripePriceId: string;
|
||||
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
|
||||
endDate: number | undefined;
|
||||
}): Promise<Stripe.SubscriptionScheduleUpdateParams.Phase> {
|
||||
return {
|
||||
start_date: startDate,
|
||||
...(endDate ? { end_date: endDate } : {}),
|
||||
proration_behavior: 'none',
|
||||
items: [
|
||||
{ price: basePlanStripePriceId, quantity: seats },
|
||||
{ price: resourceCreditStripePriceId, quantity: 1 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Billing V2: compares resource credit Stripe price id between phases
|
||||
async isSameResourceCreditPhaseSignature(
|
||||
a: Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
b: Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const phaseALicensedPriceIdAndQuantity =
|
||||
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(a);
|
||||
const phaseBLicensedPriceIdAndQuantity =
|
||||
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(b);
|
||||
const phaseAResourceCreditPriceId =
|
||||
this.getResourceCreditPriceIdFromPhaseUpdateParams(a);
|
||||
const phaseBResourceCreditPriceId =
|
||||
this.getResourceCreditPriceIdFromPhaseUpdateParams(b);
|
||||
|
||||
return (
|
||||
phaseALicensedPriceIdAndQuantity.price ===
|
||||
phaseBLicensedPriceIdAndQuantity.price &&
|
||||
phaseALicensedPriceIdAndQuantity.quantity ===
|
||||
phaseBLicensedPriceIdAndQuantity.quantity &&
|
||||
phaseAResourceCreditPriceId === phaseBResourceCreditPriceId
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Billing V2 counterpart of getMeteredPriceIdFromPhaseUpdateParams (resource credit has quantity: 1)
|
||||
getResourceCreditPriceIdFromPhaseUpdateParams(
|
||||
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
): string {
|
||||
const items = phase.items ?? [];
|
||||
const licensedPriceIdAndQuantity =
|
||||
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(phase);
|
||||
|
||||
const resourceCreditItem = items.find(
|
||||
(item) =>
|
||||
item.price !== licensedPriceIdAndQuantity.price && item.quantity === 1,
|
||||
);
|
||||
|
||||
if (!resourceCreditItem?.price) {
|
||||
throw new Error('Resource credit item not found in V2 phase params');
|
||||
}
|
||||
|
||||
return resourceCreditItem.price;
|
||||
}
|
||||
}
|
||||
|
||||
+450
-99
@@ -9,7 +9,7 @@ import {
|
||||
findOrThrow,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { type Repository } from 'typeorm';
|
||||
import { In, type Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
@@ -26,6 +26,7 @@ import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
|
||||
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
|
||||
import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/services/stripe-invoice.service';
|
||||
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
|
||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||
import {
|
||||
@@ -33,15 +34,20 @@ import {
|
||||
SubscriptionUpdateType,
|
||||
} from 'src/engine/core-modules/billing/types/billing-subscription-update.type';
|
||||
import { computeSubscriptionUpdateOptions } from 'src/engine/core-modules/billing/utils/compute-subscription-update-options.util';
|
||||
import { getBaseProductSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-base-product-subscription-item-or-throw.util';
|
||||
import { getCurrentLicensedBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-licensed-billing-subscription-item-or-throw.util';
|
||||
import { getCurrentMeteredBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-metered-billing-subscription-item-or-throw.util';
|
||||
import { getSubscriptionPricesFromSchedulePhase } from 'src/engine/core-modules/billing/utils/get-subscription-prices-from-schedule-phase.util';
|
||||
import { getCurrentResourceCreditSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-resource-credit-subscription-item-or-throw.util';
|
||||
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
type SubscriptionStripePrices = {
|
||||
export type SubscriptionStripePrices = {
|
||||
licensedPriceId: string;
|
||||
seats: number;
|
||||
meteredPriceId: string;
|
||||
meteredPriceId: string | undefined;
|
||||
resourceCreditPriceId: string | undefined;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -50,6 +56,7 @@ export class BillingSubscriptionUpdateService {
|
||||
|
||||
constructor(
|
||||
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
||||
private readonly stripeInvoiceService: StripeInvoiceService,
|
||||
private readonly billingPriceService: BillingPriceService,
|
||||
private readonly billingProductService: BillingProductService,
|
||||
@InjectRepository(BillingPriceEntity)
|
||||
@@ -63,8 +70,16 @@ export class BillingSubscriptionUpdateService {
|
||||
private readonly stripeBillingAlertService: StripeBillingAlertService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly meteredCreditService: MeteredCreditService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
private async isV2(workspaceId: string): Promise<boolean> {
|
||||
return await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_BILLING_V2_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
async changeMeteredPrice(
|
||||
workspaceId: string,
|
||||
meteredPriceId: string,
|
||||
@@ -83,6 +98,10 @@ export class BillingSubscriptionUpdateService {
|
||||
}
|
||||
|
||||
async cancelSwitchMeteredPrice(workspace: WorkspaceEntity): Promise<void> {
|
||||
if (await this.isV2(workspace.id)) {
|
||||
return this.cancelSwitchResourceCreditPrice(workspace);
|
||||
}
|
||||
|
||||
const billingSubscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
@@ -97,6 +116,40 @@ export class BillingSubscriptionUpdateService {
|
||||
});
|
||||
}
|
||||
|
||||
async changeResourceCreditPrice(
|
||||
workspaceId: string,
|
||||
resourceCreditPriceId: string,
|
||||
): Promise<void> {
|
||||
const billingSubscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
const subscriptionUpdate = {
|
||||
type: SubscriptionUpdateType.RESOURCE_CREDIT_PRICE,
|
||||
newResourceCreditPriceId: resourceCreditPriceId,
|
||||
} as const;
|
||||
|
||||
await this.updateSubscription(billingSubscription.id, subscriptionUpdate);
|
||||
}
|
||||
|
||||
async cancelSwitchResourceCreditPrice(
|
||||
workspace: WorkspaceEntity,
|
||||
): Promise<void> {
|
||||
const billingSubscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
|
||||
const currentResourceCreditPrice =
|
||||
getCurrentResourceCreditSubscriptionItemOrThrow(billingSubscription);
|
||||
const subscriptionUpdate = {
|
||||
type: SubscriptionUpdateType.RESOURCE_CREDIT_PRICE,
|
||||
newResourceCreditPriceId: currentResourceCreditPrice.stripePriceId,
|
||||
} as const;
|
||||
|
||||
await this.updateSubscription(billingSubscription.id, subscriptionUpdate);
|
||||
}
|
||||
|
||||
async cancelSwitchPlan(workspaceId: string) {
|
||||
const billingSubscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
@@ -189,18 +242,27 @@ export class BillingSubscriptionUpdateService {
|
||||
},
|
||||
);
|
||||
|
||||
const licensedItem =
|
||||
getCurrentLicensedBillingSubscriptionItemOrThrow(subscription);
|
||||
const meteredItem =
|
||||
getCurrentMeteredBillingSubscriptionItemOrThrow(subscription);
|
||||
const isV2 = await this.isV2(subscription.workspaceId);
|
||||
|
||||
const licensedItem = isV2
|
||||
? getBaseProductSubscriptionItemOrThrow(subscription)
|
||||
: getCurrentLicensedBillingSubscriptionItemOrThrow(subscription);
|
||||
const resourceCreditItem = isV2
|
||||
? getCurrentResourceCreditSubscriptionItemOrThrow(subscription)
|
||||
: undefined;
|
||||
|
||||
const meteredItem = isV2
|
||||
? undefined
|
||||
: getCurrentMeteredBillingSubscriptionItemOrThrow(subscription);
|
||||
const toUpdateCurrentPrices = await this.computeSubscriptionPricesUpdate(
|
||||
subscriptionUpdate,
|
||||
{
|
||||
licensedPriceId: licensedItem.stripePriceId,
|
||||
meteredPriceId: meteredItem.stripePriceId,
|
||||
meteredPriceId: meteredItem?.stripePriceId,
|
||||
resourceCreditPriceId: resourceCreditItem?.stripePriceId,
|
||||
seats: licensedItem.quantity,
|
||||
},
|
||||
isV2,
|
||||
);
|
||||
|
||||
const { schedule, currentPhase, nextPhase } =
|
||||
@@ -232,14 +294,19 @@ export class BillingSubscriptionUpdateService {
|
||||
subscriptionCurrentPeriodEnd: Math.floor(
|
||||
subscription.currentPeriodEnd.getTime() / 1000,
|
||||
),
|
||||
isV2,
|
||||
});
|
||||
} else {
|
||||
assertIsDefinedOrThrow(nextPhase);
|
||||
assertIsDefinedOrThrow(currentPhase);
|
||||
|
||||
const nextPhasePrices =
|
||||
await this.getSubscriptionPricesFromSchedulePhaseV2(nextPhase, isV2);
|
||||
|
||||
const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate(
|
||||
subscriptionUpdate,
|
||||
getSubscriptionPricesFromSchedulePhase(nextPhase),
|
||||
nextPhasePrices,
|
||||
isV2,
|
||||
);
|
||||
|
||||
await this.runSubscriptionScheduleUpdate({
|
||||
@@ -253,20 +320,37 @@ export class BillingSubscriptionUpdateService {
|
||||
subscriptionCurrentPeriodEnd: Math.floor(
|
||||
subscription.currentPeriodEnd.getTime() / 1000,
|
||||
),
|
||||
isV2,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const subscriptionOptions =
|
||||
computeSubscriptionUpdateOptions(subscriptionUpdate);
|
||||
|
||||
if (
|
||||
subscriptionUpdate.type === SubscriptionUpdateType.RESOURCE_CREDIT_PRICE
|
||||
) {
|
||||
assertIsDefinedOrThrow(resourceCreditItem);
|
||||
await this.createResourceCreditUpgradeInvoice({
|
||||
subscription,
|
||||
currentResourceCreditPriceId: resourceCreditItem.stripePriceId,
|
||||
newResourceCreditPriceId: subscriptionUpdate.newResourceCreditPriceId,
|
||||
});
|
||||
}
|
||||
|
||||
await this.runSubscriptionUpdate({
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
licensedStripeItemId: licensedItem.stripeSubscriptionItemId,
|
||||
meteredStripeItemId: meteredItem.stripeSubscriptionItemId,
|
||||
meteredStripeItemId: meteredItem?.stripeSubscriptionItemId,
|
||||
resourceCreditStripeItemId:
|
||||
resourceCreditItem?.stripeSubscriptionItemId,
|
||||
licensedStripePriceId: toUpdateCurrentPrices.licensedPriceId,
|
||||
meteredStripePriceId: toUpdateCurrentPrices.meteredPriceId,
|
||||
meteredStripePriceId: toUpdateCurrentPrices?.meteredPriceId,
|
||||
resourceCreditStripePriceId:
|
||||
toUpdateCurrentPrices?.resourceCreditPriceId,
|
||||
seats: toUpdateCurrentPrices.seats,
|
||||
...subscriptionOptions,
|
||||
isV2,
|
||||
});
|
||||
|
||||
if (subscriptionUpdate.type !== SubscriptionUpdateType.SEATS) {
|
||||
@@ -274,23 +358,6 @@ export class BillingSubscriptionUpdateService {
|
||||
{ stripeSubscriptionId: subscription.stripeSubscriptionId },
|
||||
{ hasReachedCurrentPeriodCap: false },
|
||||
);
|
||||
|
||||
const meteredPricingInfo =
|
||||
await this.meteredCreditService.getMeteredPricingInfoFromPriceId(
|
||||
toUpdateCurrentPrices.meteredPriceId,
|
||||
);
|
||||
|
||||
const creditBalance = await this.meteredCreditService.getCreditBalance(
|
||||
subscription.stripeCustomerId,
|
||||
meteredPricingInfo.unitPriceCents,
|
||||
);
|
||||
|
||||
await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter(
|
||||
subscription.stripeCustomerId,
|
||||
meteredPricingInfo.tierCap,
|
||||
creditBalance,
|
||||
subscription.currentPeriodStart,
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(nextPhase)) {
|
||||
@@ -303,10 +370,11 @@ export class BillingSubscriptionUpdateService {
|
||||
assertIsDefinedOrThrow(refreshedCurrentPhase);
|
||||
|
||||
const nextPhasePrices =
|
||||
getSubscriptionPricesFromSchedulePhase(nextPhase);
|
||||
await this.getSubscriptionPricesFromSchedulePhaseV2(nextPhase, isV2);
|
||||
const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate(
|
||||
subscriptionUpdate,
|
||||
nextPhasePrices,
|
||||
isV2,
|
||||
);
|
||||
|
||||
await this.runSubscriptionScheduleUpdate({
|
||||
@@ -320,6 +388,7 @@ export class BillingSubscriptionUpdateService {
|
||||
subscriptionCurrentPeriodEnd: Math.floor(
|
||||
subscription.currentPeriodEnd.getTime() / 1000,
|
||||
),
|
||||
isV2,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -330,61 +399,196 @@ export class BillingSubscriptionUpdateService {
|
||||
);
|
||||
}
|
||||
|
||||
private async createResourceCreditUpgradeInvoice({
|
||||
subscription,
|
||||
currentResourceCreditPriceId,
|
||||
newResourceCreditPriceId,
|
||||
}: {
|
||||
subscription: BillingSubscriptionEntity;
|
||||
currentResourceCreditPriceId: string;
|
||||
newResourceCreditPriceId: string;
|
||||
}): Promise<void> {
|
||||
const prices = await this.billingPriceRepository.find({
|
||||
where: {
|
||||
stripePriceId: In([
|
||||
currentResourceCreditPriceId,
|
||||
newResourceCreditPriceId,
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const currentPrice = prices.find(
|
||||
(price) => price.stripePriceId === currentResourceCreditPriceId,
|
||||
);
|
||||
const newPrice = prices.find(
|
||||
(price) => price.stripePriceId === newResourceCreditPriceId,
|
||||
);
|
||||
|
||||
assertIsDefinedOrThrow(currentPrice);
|
||||
assertIsDefinedOrThrow(newPrice);
|
||||
|
||||
const diffInCents =
|
||||
Number(newPrice.unitAmount) - Number(currentPrice.unitAmount);
|
||||
|
||||
if (diffInCents > 0) {
|
||||
await this.stripeInvoiceService.createImmediateUpgradeInvoice({
|
||||
stripeCustomerId: subscription.stripeCustomerId,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
diffAmountInCents: diffInCents,
|
||||
description: `Resource usage - Upgrade resource credit price from $${Number(currentPrice.unitAmount) / 100} to $${Number(newPrice.unitAmount) / 100}`,
|
||||
currency: newPrice.currency,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getSubscriptionPricesFromSchedulePhaseV2(
|
||||
phase: Stripe.SubscriptionSchedule.Phase,
|
||||
isV2: boolean,
|
||||
): Promise<SubscriptionStripePrices> {
|
||||
const licensedItemPriceIds = phase.items
|
||||
.filter((item) => item.quantity != null)
|
||||
.map((item) => normalizePriceRef(item.price));
|
||||
|
||||
const licensedItemPrices = await this.billingPriceRepository.find({
|
||||
where: { stripePriceId: In(licensedItemPriceIds) },
|
||||
relations: ['billingProduct'],
|
||||
});
|
||||
|
||||
const basePlanPrice = licensedItemPrices.find(
|
||||
(price) =>
|
||||
price.billingProduct?.metadata?.productKey ===
|
||||
BillingProductKey.BASE_PRODUCT,
|
||||
);
|
||||
|
||||
assertIsDefinedOrThrow(basePlanPrice);
|
||||
|
||||
const basePlanPhaseItem = findOrThrow(
|
||||
phase.items,
|
||||
(item) => normalizePriceRef(item.price) === basePlanPrice.stripePriceId,
|
||||
);
|
||||
|
||||
assertIsDefinedOrThrow(basePlanPhaseItem.quantity);
|
||||
|
||||
if (isV2) {
|
||||
const resourceCreditPrice = licensedItemPrices.find(
|
||||
(price) =>
|
||||
price.billingProduct?.metadata?.productKey ===
|
||||
BillingProductKey.RESOURCE_CREDIT,
|
||||
);
|
||||
|
||||
assertIsDefinedOrThrow(resourceCreditPrice);
|
||||
|
||||
return {
|
||||
licensedPriceId: basePlanPrice.stripePriceId,
|
||||
meteredPriceId: undefined,
|
||||
seats: basePlanPhaseItem.quantity,
|
||||
resourceCreditPriceId: resourceCreditPrice.stripePriceId,
|
||||
};
|
||||
} else {
|
||||
const meteredItem = findOrThrow(
|
||||
phase.items,
|
||||
(item) => item.quantity == null,
|
||||
);
|
||||
|
||||
return {
|
||||
licensedPriceId: basePlanPrice.stripePriceId,
|
||||
meteredPriceId: normalizePriceRef(meteredItem.price),
|
||||
seats: basePlanPhaseItem.quantity,
|
||||
resourceCreditPriceId: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async runSubscriptionUpdate({
|
||||
stripeSubscriptionId,
|
||||
licensedStripeItemId,
|
||||
meteredStripeItemId,
|
||||
resourceCreditStripeItemId,
|
||||
licensedStripePriceId,
|
||||
meteredStripePriceId,
|
||||
resourceCreditStripePriceId,
|
||||
seats,
|
||||
anchor,
|
||||
proration,
|
||||
metadata,
|
||||
isV2,
|
||||
}: {
|
||||
stripeSubscriptionId: string;
|
||||
licensedStripeItemId: string;
|
||||
meteredStripeItemId: string;
|
||||
meteredStripeItemId: string | undefined;
|
||||
resourceCreditStripeItemId: string | undefined;
|
||||
licensedStripePriceId: string;
|
||||
meteredStripePriceId: string;
|
||||
meteredStripePriceId: string | undefined;
|
||||
resourceCreditStripePriceId: string | undefined;
|
||||
seats: number;
|
||||
anchor?: Stripe.SubscriptionUpdateParams.BillingCycleAnchor;
|
||||
proration?: Stripe.SubscriptionUpdateParams.ProrationBehavior;
|
||||
metadata?: Record<string, string>;
|
||||
isV2: boolean;
|
||||
}) {
|
||||
return await this.stripeSubscriptionService.updateSubscription(
|
||||
stripeSubscriptionId,
|
||||
{
|
||||
items: [
|
||||
{
|
||||
id: licensedStripeItemId,
|
||||
price: licensedStripePriceId,
|
||||
quantity: seats,
|
||||
},
|
||||
{ id: meteredStripeItemId, price: meteredStripePriceId },
|
||||
],
|
||||
...(anchor ? { billing_cycle_anchor: anchor } : {}),
|
||||
...(proration ? { proration_behavior: proration } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
billing_thresholds:
|
||||
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
|
||||
meteredStripePriceId,
|
||||
),
|
||||
},
|
||||
);
|
||||
if (isV2) {
|
||||
assertIsDefinedOrThrow(resourceCreditStripePriceId);
|
||||
assertIsDefinedOrThrow(resourceCreditStripeItemId);
|
||||
return await this.stripeSubscriptionService.updateSubscription(
|
||||
stripeSubscriptionId,
|
||||
{
|
||||
items: [
|
||||
{
|
||||
id: licensedStripeItemId,
|
||||
price: licensedStripePriceId,
|
||||
quantity: seats,
|
||||
},
|
||||
{
|
||||
id: resourceCreditStripeItemId,
|
||||
price: resourceCreditStripePriceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
...(anchor ? { billing_cycle_anchor: anchor } : {}),
|
||||
...(proration ? { proration_behavior: proration } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
assertIsDefinedOrThrow(meteredStripePriceId);
|
||||
assertIsDefinedOrThrow(meteredStripeItemId);
|
||||
return await this.stripeSubscriptionService.updateSubscription(
|
||||
stripeSubscriptionId,
|
||||
{
|
||||
items: [
|
||||
{
|
||||
id: licensedStripeItemId,
|
||||
price: licensedStripePriceId,
|
||||
quantity: seats,
|
||||
},
|
||||
{ id: meteredStripeItemId, price: meteredStripePriceId },
|
||||
],
|
||||
...(anchor ? { billing_cycle_anchor: anchor } : {}),
|
||||
...(proration ? { proration_behavior: proration } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
billing_thresholds:
|
||||
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
|
||||
meteredStripePriceId,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async runSubscriptionScheduleUpdate({
|
||||
async runSubscriptionScheduleUpdate({
|
||||
stripeScheduleId,
|
||||
toUpdateNextPrices,
|
||||
toUpdateCurrentPrices,
|
||||
currentPhase,
|
||||
subscriptionCurrentPeriodEnd,
|
||||
isV2,
|
||||
}: {
|
||||
stripeScheduleId: string;
|
||||
toUpdateNextPrices: SubscriptionStripePrices;
|
||||
toUpdateCurrentPrices: SubscriptionStripePrices | undefined;
|
||||
currentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase;
|
||||
subscriptionCurrentPeriodEnd: number;
|
||||
isV2: boolean;
|
||||
}) {
|
||||
let toUpdateCurrentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase = {
|
||||
...currentPhase,
|
||||
@@ -394,21 +598,19 @@ export class BillingSubscriptionUpdateService {
|
||||
if (isDefined(toUpdateCurrentPrices)) {
|
||||
toUpdateCurrentPhase =
|
||||
await this.billingSubscriptionPhaseService.buildPhaseUpdateParams({
|
||||
licensedStripePriceId: toUpdateCurrentPrices.licensedPriceId,
|
||||
seats: toUpdateCurrentPrices.seats,
|
||||
meteredStripePriceId: toUpdateCurrentPrices.meteredPriceId,
|
||||
toUpdatePrices: toUpdateCurrentPrices,
|
||||
endDate: subscriptionCurrentPeriodEnd,
|
||||
startDate: currentPhase.start_date,
|
||||
isV2,
|
||||
});
|
||||
}
|
||||
|
||||
const toUpdateNextPhase =
|
||||
await this.billingSubscriptionPhaseService.buildPhaseUpdateParams({
|
||||
licensedStripePriceId: toUpdateNextPrices.licensedPriceId,
|
||||
seats: toUpdateNextPrices.seats,
|
||||
meteredStripePriceId: toUpdateNextPrices.meteredPriceId,
|
||||
toUpdatePrices: toUpdateNextPrices,
|
||||
startDate: subscriptionCurrentPeriodEnd,
|
||||
endDate: undefined,
|
||||
isV2,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -475,6 +677,44 @@ export class BillingSubscriptionUpdateService {
|
||||
|
||||
return isDowngrade;
|
||||
}
|
||||
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE: {
|
||||
const currentResourceCreditPriceId =
|
||||
subscription.billingSubscriptionItems.find(
|
||||
(item) =>
|
||||
item.billingProduct?.metadata.productKey ===
|
||||
BillingProductKey.RESOURCE_CREDIT,
|
||||
)?.stripePriceId;
|
||||
|
||||
assertIsDefinedOrThrow(currentResourceCreditPriceId);
|
||||
const currentResourceCreditPrice =
|
||||
await this.billingPriceRepository.findOneOrFail({
|
||||
where: { stripePriceId: currentResourceCreditPriceId },
|
||||
relations: ['billingProduct'],
|
||||
});
|
||||
const newResourceCreditPrice =
|
||||
await this.billingPriceRepository.findOneOrFail({
|
||||
where: { stripePriceId: update.newResourceCreditPriceId },
|
||||
relations: ['billingProduct'],
|
||||
});
|
||||
|
||||
billingValidator.assertIsLicensedResourceCreditPrice(
|
||||
currentResourceCreditPrice,
|
||||
);
|
||||
billingValidator.assertIsLicensedResourceCreditPrice(
|
||||
newResourceCreditPrice,
|
||||
);
|
||||
|
||||
const currentResourceCreditCap = Number(
|
||||
currentResourceCreditPrice.metadata?.credit_amount,
|
||||
);
|
||||
const newResourceCreditCap = Number(
|
||||
newResourceCreditPrice.metadata?.credit_amount,
|
||||
);
|
||||
|
||||
const isDowngrade = currentResourceCreditCap > newResourceCreditCap;
|
||||
|
||||
return isDowngrade;
|
||||
}
|
||||
case SubscriptionUpdateType.SEATS:
|
||||
return false;
|
||||
case SubscriptionUpdateType.INTERVAL: {
|
||||
@@ -497,12 +737,14 @@ export class BillingSubscriptionUpdateService {
|
||||
async computeSubscriptionPricesUpdate(
|
||||
update: SubscriptionUpdate,
|
||||
currentPrices: SubscriptionStripePrices,
|
||||
isV2: boolean,
|
||||
): Promise<SubscriptionStripePrices> {
|
||||
switch (update.type) {
|
||||
case SubscriptionUpdateType.PLAN:
|
||||
return await this.computeSubscriptionPricesUpdateByPlan(
|
||||
update.newPlan,
|
||||
currentPrices,
|
||||
isV2,
|
||||
);
|
||||
case SubscriptionUpdateType.METERED_PRICE:
|
||||
return await this.computeSubscriptionPricesUpdateByMeteredPrice(
|
||||
@@ -518,6 +760,12 @@ export class BillingSubscriptionUpdateService {
|
||||
return await this.computeSubscriptionPricesUpdateByInterval(
|
||||
update.newInterval,
|
||||
currentPrices,
|
||||
isV2,
|
||||
);
|
||||
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE:
|
||||
return await this.computeSubscriptionPricesUpdateByResourceCreditPrice(
|
||||
update.newResourceCreditPriceId,
|
||||
currentPrices,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -578,10 +826,60 @@ export class BillingSubscriptionUpdateService {
|
||||
meteredPriceId: newEquivalentMeteredPrice.stripePriceId,
|
||||
};
|
||||
}
|
||||
private async computeSubscriptionPricesUpdateByResourceCreditPrice(
|
||||
newResourceCreditPriceId: string,
|
||||
currentPrices: SubscriptionStripePrices,
|
||||
): Promise<SubscriptionStripePrices> {
|
||||
const currentLicensedPrice =
|
||||
await this.billingPriceRepository.findOneOrFail({
|
||||
where: { stripePriceId: currentPrices.licensedPriceId },
|
||||
relations: ['billingProduct'],
|
||||
});
|
||||
const currentInterval = currentLicensedPrice.interval;
|
||||
const currentPlanKey =
|
||||
currentLicensedPrice.billingProduct?.metadata.planKey;
|
||||
|
||||
assertIsDefinedOrThrow(currentPlanKey);
|
||||
|
||||
const newResourceCreditPrice =
|
||||
await this.billingPriceRepository.findOneOrFail({
|
||||
where: { stripePriceId: newResourceCreditPriceId },
|
||||
relations: ['billingProduct'],
|
||||
});
|
||||
|
||||
billingValidator.assertIsLicensedResourceCreditPrice(
|
||||
newResourceCreditPrice,
|
||||
);
|
||||
|
||||
const newInterval = newResourceCreditPrice.interval;
|
||||
const newPlanKey = newResourceCreditPrice.billingProduct?.metadata.planKey;
|
||||
|
||||
if (newInterval === currentInterval && currentPlanKey === newPlanKey) {
|
||||
return {
|
||||
...currentPrices,
|
||||
resourceCreditPriceId: newResourceCreditPriceId,
|
||||
};
|
||||
}
|
||||
|
||||
const newEquivalentResourceCreditPrice =
|
||||
await this.billingPriceService.findEquivalentResourceCreditPrice({
|
||||
referencePrice: newResourceCreditPrice,
|
||||
targetInterval: currentInterval,
|
||||
targetPlanKey: currentPlanKey,
|
||||
hasSameInterval: newInterval === currentInterval,
|
||||
hasSamePlanKey: currentPlanKey === newPlanKey,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentPrices,
|
||||
resourceCreditPriceId: newEquivalentResourceCreditPrice.stripePriceId,
|
||||
};
|
||||
}
|
||||
|
||||
private async computeSubscriptionPricesUpdateByPlan(
|
||||
newPlan: BillingPlanKey,
|
||||
currentPrices: SubscriptionStripePrices,
|
||||
isV2: boolean,
|
||||
): Promise<SubscriptionStripePrices> {
|
||||
const currentLicensedPrice =
|
||||
await this.billingPriceRepository.findOneOrFail({
|
||||
@@ -611,34 +909,61 @@ export class BillingSubscriptionUpdateService {
|
||||
billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT,
|
||||
);
|
||||
|
||||
const currentMeteredPrice = await this.billingPriceRepository.findOneOrFail(
|
||||
{
|
||||
where: { stripePriceId: currentPrices.meteredPriceId },
|
||||
relations: ['billingProduct'],
|
||||
},
|
||||
);
|
||||
if (isV2) {
|
||||
const currentResourceCreditPrice =
|
||||
await this.billingPriceRepository.findOneOrFail({
|
||||
where: { stripePriceId: currentPrices.resourceCreditPriceId },
|
||||
relations: ['billingProduct'],
|
||||
});
|
||||
|
||||
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
|
||||
billingValidator.assertIsLicensedResourceCreditPrice(
|
||||
currentResourceCreditPrice,
|
||||
);
|
||||
|
||||
const targetMeteredPrice =
|
||||
await this.billingPriceService.findEquivalentMeteredPrice({
|
||||
meteredPrice: currentMeteredPrice,
|
||||
targetInterval: currentInterval,
|
||||
targetPlanKey: newPlan,
|
||||
hasSameInterval: true,
|
||||
hasSamePlanKey: false,
|
||||
});
|
||||
const targetResourceCreditPrice =
|
||||
await this.billingPriceService.findEquivalentResourceCreditPrice({
|
||||
referencePrice: currentResourceCreditPrice,
|
||||
targetInterval: currentInterval,
|
||||
targetPlanKey: newPlan,
|
||||
hasSameInterval: true,
|
||||
hasSamePlanKey: false,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentPrices,
|
||||
licensedPriceId: targetLicensedPrice.stripePriceId,
|
||||
meteredPriceId: targetMeteredPrice.stripePriceId,
|
||||
};
|
||||
return {
|
||||
...currentPrices,
|
||||
licensedPriceId: targetLicensedPrice.stripePriceId,
|
||||
resourceCreditPriceId: targetResourceCreditPrice.stripePriceId,
|
||||
};
|
||||
} else {
|
||||
const currentMeteredPrice =
|
||||
await this.billingPriceRepository.findOneOrFail({
|
||||
where: { stripePriceId: currentPrices.meteredPriceId },
|
||||
relations: ['billingProduct'],
|
||||
});
|
||||
|
||||
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
|
||||
|
||||
const targetMeteredPrice =
|
||||
await this.billingPriceService.findEquivalentMeteredPrice({
|
||||
meteredPrice: currentMeteredPrice,
|
||||
targetInterval: currentInterval,
|
||||
targetPlanKey: newPlan,
|
||||
hasSameInterval: true,
|
||||
hasSamePlanKey: false,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentPrices,
|
||||
licensedPriceId: targetLicensedPrice.stripePriceId,
|
||||
meteredPriceId: targetMeteredPrice.stripePriceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async computeSubscriptionPricesUpdateByInterval(
|
||||
newInterval: SubscriptionInterval,
|
||||
currentPrices: SubscriptionStripePrices,
|
||||
isV2: boolean,
|
||||
): Promise<SubscriptionStripePrices> {
|
||||
const currentLicensedPrice =
|
||||
await this.billingPriceRepository.findOneOrFail({
|
||||
@@ -668,28 +993,54 @@ export class BillingSubscriptionUpdateService {
|
||||
billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT,
|
||||
);
|
||||
|
||||
const currentMeteredPrice = await this.billingPriceRepository.findOneOrFail(
|
||||
{
|
||||
where: { stripePriceId: currentPrices.meteredPriceId },
|
||||
relations: ['billingProduct'],
|
||||
},
|
||||
);
|
||||
if (isV2) {
|
||||
const currentResourceCreditPrice =
|
||||
await this.billingPriceRepository.findOneOrFail({
|
||||
where: { stripePriceId: currentPrices.resourceCreditPriceId },
|
||||
relations: ['billingProduct'],
|
||||
});
|
||||
|
||||
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
|
||||
billingValidator.assertIsLicensedResourceCreditPrice(
|
||||
currentResourceCreditPrice,
|
||||
);
|
||||
|
||||
const targetMeteredPrice =
|
||||
await this.billingPriceService.findEquivalentMeteredPrice({
|
||||
meteredPrice: currentMeteredPrice,
|
||||
targetInterval: newInterval,
|
||||
targetPlanKey: currentPlanKey,
|
||||
hasSameInterval: false,
|
||||
hasSamePlanKey: true,
|
||||
});
|
||||
const targetResourceCreditPrice =
|
||||
await this.billingPriceService.findEquivalentResourceCreditPrice({
|
||||
referencePrice: currentResourceCreditPrice,
|
||||
targetInterval: newInterval,
|
||||
targetPlanKey: currentPlanKey,
|
||||
hasSameInterval: false,
|
||||
hasSamePlanKey: true,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentPrices,
|
||||
licensedPriceId: targetLicensedPrice.stripePriceId,
|
||||
meteredPriceId: targetMeteredPrice.stripePriceId,
|
||||
};
|
||||
return {
|
||||
...currentPrices,
|
||||
licensedPriceId: targetLicensedPrice.stripePriceId,
|
||||
resourceCreditPriceId: targetResourceCreditPrice.stripePriceId,
|
||||
};
|
||||
} else {
|
||||
const currentMeteredPrice =
|
||||
await this.billingPriceRepository.findOneOrFail({
|
||||
where: { stripePriceId: currentPrices.meteredPriceId },
|
||||
relations: ['billingProduct'],
|
||||
});
|
||||
|
||||
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
|
||||
|
||||
const targetMeteredPrice =
|
||||
await this.billingPriceService.findEquivalentMeteredPrice({
|
||||
meteredPrice: currentMeteredPrice,
|
||||
targetInterval: newInterval,
|
||||
targetPlanKey: currentPlanKey,
|
||||
hasSameInterval: false,
|
||||
hasSamePlanKey: true,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentPrices,
|
||||
licensedPriceId: targetLicensedPrice.stripePriceId,
|
||||
meteredPriceId: targetMeteredPrice.stripePriceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-52
@@ -4,11 +4,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import {
|
||||
assertIsDefinedOrThrow,
|
||||
findOrThrow,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { Not, type Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
@@ -29,7 +25,6 @@ import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entiti
|
||||
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
|
||||
@@ -267,36 +262,6 @@ export class BillingSubscriptionService {
|
||||
};
|
||||
}
|
||||
|
||||
async setBillingThresholdsAndTrialPeriodWorkflowCredits(
|
||||
billingSubscriptionId: string,
|
||||
) {
|
||||
const billingSubscription =
|
||||
await this.billingSubscriptionRepository.findOneOrFail({
|
||||
where: { id: billingSubscriptionId },
|
||||
relations: [
|
||||
'billingSubscriptionItems',
|
||||
'billingSubscriptionItems.billingProduct',
|
||||
],
|
||||
});
|
||||
|
||||
const { stripePriceId: meterStripePriceId } = findOrThrow(
|
||||
billingSubscription.billingSubscriptionItems,
|
||||
(billingSubscriptionItem) =>
|
||||
billingSubscriptionItem.billingProduct.metadata.productKey ===
|
||||
BillingProductKey.WORKFLOW_NODE_EXECUTION,
|
||||
);
|
||||
|
||||
await this.stripeSubscriptionService.updateSubscription(
|
||||
billingSubscription.stripeSubscriptionId,
|
||||
{
|
||||
billing_thresholds:
|
||||
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
|
||||
meterStripePriceId,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async syncSubscriptionToDatabase(
|
||||
workspaceId: string,
|
||||
stripeSubscriptionId: string,
|
||||
@@ -351,27 +316,29 @@ export class BillingSubscriptionService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const meterBillingSubscriptionItem = findOrThrow(
|
||||
billingSubscriptionItems,
|
||||
// V2 subscriptions have no quantityless metered item; skip the stale-item cleanup in that case
|
||||
const meterBillingSubscriptionItem = billingSubscriptionItems.find(
|
||||
(item) => !isDefined(item.quantity),
|
||||
);
|
||||
|
||||
const existingBillingSubscriptionItem =
|
||||
await this.billingSubscriptionItemRepository.findOne({
|
||||
where: {
|
||||
if (isDefined(meterBillingSubscriptionItem)) {
|
||||
const existingBillingSubscriptionItem =
|
||||
await this.billingSubscriptionItemRepository.findOne({
|
||||
where: {
|
||||
billingSubscriptionId: currentBillingSubscription.id,
|
||||
stripeProductId: meterBillingSubscriptionItem.stripeProductId,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
existingBillingSubscriptionItem?.stripeSubscriptionItemId !==
|
||||
meterBillingSubscriptionItem.stripeSubscriptionItemId
|
||||
) {
|
||||
await this.billingSubscriptionItemRepository.delete({
|
||||
billingSubscriptionId: currentBillingSubscription.id,
|
||||
stripeProductId: meterBillingSubscriptionItem.stripeProductId,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
existingBillingSubscriptionItem?.stripeSubscriptionItemId !==
|
||||
meterBillingSubscriptionItem.stripeSubscriptionItemId
|
||||
) {
|
||||
await this.billingSubscriptionItemRepository.delete({
|
||||
billingSubscriptionId: currentBillingSubscription.id,
|
||||
stripeProductId: meterBillingSubscriptionItem.stripeProductId,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.billingSubscriptionItemRepository.upsert(
|
||||
|
||||
+55
-2
@@ -15,6 +15,8 @@ import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { Not, Raw, Repository } from 'typeorm';
|
||||
|
||||
export type BillingCapEvaluation =
|
||||
@@ -42,6 +44,7 @@ export class BillingUsageCapService {
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly meteredCreditService: MeteredCreditService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
@InjectRepository(BillingSubscriptionItemEntity)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
|
||||
) {}
|
||||
@@ -122,10 +125,60 @@ export class BillingUsageCapService {
|
||||
return results;
|
||||
}
|
||||
|
||||
// V2 path — uses extractResourceCreditPricingInfo (productKey === RESOURCE_CREDIT)
|
||||
// instead of extractMeteredPricingInfoFromSubscription (productKey === WORKFLOW_NODE_EXECUTION)
|
||||
evaluateCapBatchV2(
|
||||
subscriptions: BillingSubscriptionEntity[],
|
||||
usageByWorkspace: Map<string, number>,
|
||||
creditBalanceByCustomer: Map<string, number>,
|
||||
): Map<string, BillingCapEvaluation> {
|
||||
const results = new Map<string, BillingCapEvaluation>();
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const resourceCreditPricingInfo =
|
||||
this.meteredCreditService.extractResourceCreditPricingInfo(
|
||||
subscription,
|
||||
);
|
||||
|
||||
if (!resourceCreditPricingInfo) {
|
||||
results.set(subscription.id, {
|
||||
skipped: true,
|
||||
reason: 'no-metered-item',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const usage = usageByWorkspace.get(subscription.workspaceId) ?? 0;
|
||||
const creditBalance =
|
||||
creditBalanceByCustomer.get(subscription.stripeCustomerId) ?? 0;
|
||||
const allowance = resourceCreditPricingInfo.tierCap + creditBalance;
|
||||
|
||||
results.set(subscription.id, {
|
||||
skipped: false,
|
||||
hasReachedCap: usage >= allowance,
|
||||
usage,
|
||||
allowance,
|
||||
tierCap: resourceCreditPricingInfo.tierCap,
|
||||
creditBalance,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async setSubscriptionItemHasReachedCap(
|
||||
workspaceId: string,
|
||||
hasReachedCap: boolean,
|
||||
): Promise<void> {
|
||||
const isV2 = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_BILLING_V2_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const productKey = isV2
|
||||
? BillingProductKey.RESOURCE_CREDIT
|
||||
: BillingProductKey.WORKFLOW_NODE_EXECUTION;
|
||||
|
||||
const billingSubscriptionItems =
|
||||
await this.billingSubscriptionItemRepository.find({
|
||||
where: {
|
||||
@@ -136,7 +189,7 @@ export class BillingUsageCapService {
|
||||
billingProduct: {
|
||||
metadata: Raw((alias) => `${alias} @> :metadata::jsonb`, {
|
||||
metadata: JSON.stringify({
|
||||
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
|
||||
productKey,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
@@ -145,7 +198,7 @@ export class BillingUsageCapService {
|
||||
|
||||
if (billingSubscriptionItems.length !== 1) {
|
||||
throw new BillingException(
|
||||
`Expected 1 metered billing subscription item for workspace ${workspaceId}, but got ${billingSubscriptionItems.length}`,
|
||||
`Expected 1 billing subscription item for workspace ${workspaceId}, but got ${billingSubscriptionItems.length}`,
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
+159
-18
@@ -6,6 +6,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { type BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
@@ -26,10 +28,12 @@ import { buildBillingUsageAvailableCreditsCacheKey } from 'src/engine/core-modul
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
type UsageSumRow = {
|
||||
total: string | number | null;
|
||||
@@ -54,6 +58,7 @@ export class BillingUsageService {
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly billingUsageCapService: BillingUsageCapService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
async canFeatureBeUsed(workspaceId: string): Promise<boolean> {
|
||||
@@ -136,6 +141,79 @@ export class BillingUsageService {
|
||||
);
|
||||
}
|
||||
|
||||
async getResourceCreditProductUsage(
|
||||
workspace: WorkspaceEntity,
|
||||
): Promise<BillingMeteredProductUsageDTO[]> {
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
|
||||
const resourceCreditItemDetail =
|
||||
await this.billingSubscriptionItemService.getResourceCreditSubscriptionItemDetails(
|
||||
subscription,
|
||||
);
|
||||
|
||||
if (!isDefined(resourceCreditItemDetail)) {
|
||||
throw new BillingException(
|
||||
`Resource credit item not found for workspace ${workspace.id}`,
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const { periodStart, periodEnd } = this.getSubscriptionPeriod(subscription);
|
||||
|
||||
return [
|
||||
await this.buildResourceCreditUsage(
|
||||
workspace.id,
|
||||
subscription,
|
||||
resourceCreditItemDetail,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private async buildResourceCreditUsage(
|
||||
workspaceId: string,
|
||||
subscription: BillingSubscriptionEntity,
|
||||
item: NonNullable<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
typeof this.billingSubscriptionItemService.getResourceCreditSubscriptionItemDetails
|
||||
>
|
||||
>
|
||||
>,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<BillingMeteredProductUsageDTO> {
|
||||
const usedCredits = await this.getCurrentPeriodCreditsUsed(
|
||||
workspaceId,
|
||||
periodStart,
|
||||
);
|
||||
|
||||
const grantedCredits =
|
||||
subscription.status === SubscriptionStatus.Trialing
|
||||
? item.freeTrialQuantity
|
||||
: item.creditAmount;
|
||||
|
||||
const billingCustomer = await this.billingCustomerRepository.findOne({
|
||||
where: { workspaceId },
|
||||
});
|
||||
const rolloverCredits = billingCustomer?.creditBalanceMicro ?? 0;
|
||||
|
||||
return {
|
||||
productKey: item.productKey,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
usedCredits,
|
||||
grantedCredits,
|
||||
rolloverCredits,
|
||||
totalGrantedCredits: grantedCredits + rolloverCredits,
|
||||
unitPriceCents: item.unitPriceCents,
|
||||
};
|
||||
}
|
||||
|
||||
//TODO: TO be deprecated
|
||||
private getSubscriptionPeriod(subscription: BillingSubscriptionEntity): {
|
||||
periodStart: Date;
|
||||
@@ -254,30 +332,93 @@ export class BillingUsageService {
|
||||
);
|
||||
}
|
||||
|
||||
const meteredPricingInfo =
|
||||
this.meteredCreditService.extractMeteredPricingInfoFromSubscription(
|
||||
subscription,
|
||||
const isV2 = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_BILLING_V2_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (isV2) {
|
||||
const resourceUsageCap = this.getResourceUsageCap(subscription);
|
||||
|
||||
const { creditBalanceMicro: creditBalance } =
|
||||
await this.billingCustomerRepository.findOneOrFail({
|
||||
select: { creditBalanceMicro: true },
|
||||
where: { workspaceId },
|
||||
});
|
||||
|
||||
const usage = await this.getCurrentPeriodCreditsUsed(
|
||||
subscription.workspaceId,
|
||||
subscription.currentPeriodStart,
|
||||
);
|
||||
return resourceUsageCap + creditBalance - usage;
|
||||
} else {
|
||||
const meteredPricingInfo =
|
||||
this.meteredCreditService.extractMeteredPricingInfoFromSubscription(
|
||||
subscription,
|
||||
);
|
||||
|
||||
if (!meteredPricingInfo) {
|
||||
throw new BillingException(
|
||||
`No metered item found for workspace ${workspaceId}`,
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const [creditBalance, usage] = await Promise.all([
|
||||
this.meteredCreditService.getCreditBalance(
|
||||
subscription.stripeCustomerId,
|
||||
meteredPricingInfo.unitPriceCents,
|
||||
),
|
||||
this.getCurrentPeriodCreditsUsed(
|
||||
subscription.workspaceId,
|
||||
subscription.currentPeriodStart,
|
||||
),
|
||||
]);
|
||||
return meteredPricingInfo.tierCap + creditBalance - usage;
|
||||
}
|
||||
}
|
||||
|
||||
getResourceUsageCap(subscription: BillingSubscriptionEntity): number {
|
||||
const isInFreeTrial = subscription.status === SubscriptionStatus.Trialing;
|
||||
|
||||
if (isInFreeTrial) {
|
||||
const trialDuration =
|
||||
isDefined(subscription.trialEnd) && isDefined(subscription.trialStart)
|
||||
? differenceInDays(subscription.trialEnd, subscription.trialStart)
|
||||
: 0;
|
||||
|
||||
const trialWithCreditCardDuration = this.twentyConfigService.get(
|
||||
'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS',
|
||||
);
|
||||
|
||||
if (!meteredPricingInfo) {
|
||||
return trialDuration === trialWithCreditCardDuration
|
||||
? this.twentyConfigService.get(
|
||||
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD',
|
||||
)
|
||||
: this.twentyConfigService.get(
|
||||
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
|
||||
);
|
||||
}
|
||||
|
||||
const resourceCreditItem = subscription.billingSubscriptionItems.find(
|
||||
(item) =>
|
||||
item.billingProduct.metadata?.productKey ===
|
||||
BillingProductKey.RESOURCE_CREDIT,
|
||||
);
|
||||
|
||||
const resourceCreditPrice =
|
||||
resourceCreditItem?.billingProduct.billingPrices.find(
|
||||
(price) => price.stripePriceId === resourceCreditItem.stripePriceId,
|
||||
);
|
||||
|
||||
if (!isDefined(resourceCreditPrice)) {
|
||||
throw new BillingException(
|
||||
`No metered item found for workspace ${workspaceId}`,
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
|
||||
`Resource credit price not found for workspace ${subscription.workspaceId}`,
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const [creditBalance, usage] = await Promise.all([
|
||||
this.meteredCreditService.getCreditBalance(
|
||||
subscription.stripeCustomerId,
|
||||
meteredPricingInfo.unitPriceCents,
|
||||
),
|
||||
this.getCurrentPeriodCreditsUsed(
|
||||
subscription.workspaceId,
|
||||
subscription.currentPeriodStart,
|
||||
),
|
||||
]);
|
||||
|
||||
return meteredPricingInfo.tierCap + creditBalance - usage;
|
||||
return Number(resourceCreditPrice.metadata?.credit_amount ?? 0);
|
||||
}
|
||||
|
||||
async decrementAvailableCredits({
|
||||
|
||||
+70
@@ -20,6 +20,11 @@ export type MeteredPricingInfo = {
|
||||
stripeMeterId?: string;
|
||||
};
|
||||
|
||||
export type ResourceCreditPricingInfo = {
|
||||
tierCap: number;
|
||||
unitPriceCents: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MeteredCreditService {
|
||||
protected readonly logger = new Logger(MeteredCreditService.name);
|
||||
@@ -186,4 +191,69 @@ export class MeteredCreditService {
|
||||
unitPriceCents,
|
||||
);
|
||||
}
|
||||
|
||||
// V2 path — uses productKey === RESOURCE_CREDIT; derives cap from price.metadata.credit_amount
|
||||
extractResourceCreditPricingInfo(
|
||||
subscription: BillingSubscriptionEntity,
|
||||
): ResourceCreditPricingInfo | null {
|
||||
const resourceCreditItem = subscription.billingSubscriptionItems?.find(
|
||||
(item) =>
|
||||
item.billingProduct?.metadata?.productKey ===
|
||||
BillingProductKey.RESOURCE_CREDIT,
|
||||
);
|
||||
|
||||
if (!isDefined(resourceCreditItem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchingPrice =
|
||||
resourceCreditItem.billingProduct?.billingPrices?.find(
|
||||
(price) => price.stripePriceId === resourceCreditItem.stripePriceId,
|
||||
);
|
||||
|
||||
if (!isDefined(matchingPrice)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tierCap = Number(matchingPrice.metadata?.credit_amount ?? 0);
|
||||
|
||||
if (!Number.isFinite(tierCap) || tierCap <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tierCap,
|
||||
unitPriceCents: matchingPrice.unitAmount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getResourceCreditRolloverParameters(subscriptionId: string): Promise<{
|
||||
tierQuantity: number;
|
||||
unitPriceCents: number;
|
||||
} | null> {
|
||||
//TODO : To optimize once evaluateCapBatch is deprecated
|
||||
const subscription = await this.billingSubscriptionRepository.findOne({
|
||||
where: { id: subscriptionId },
|
||||
relations: [
|
||||
'billingSubscriptionItems',
|
||||
'billingSubscriptionItems.billingProduct',
|
||||
'billingSubscriptionItems.billingProduct.billingPrices',
|
||||
],
|
||||
});
|
||||
|
||||
if (!isDefined(subscription)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pricingInfo = this.extractResourceCreditPricingInfo(subscription);
|
||||
|
||||
if (!isDefined(pricingInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tierQuantity: pricingInfo.tierCap,
|
||||
unitPriceCents: pricingInfo.unitPriceCents,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -40,4 +40,37 @@ export class StripeInvoiceService {
|
||||
auto_advance: true,
|
||||
});
|
||||
}
|
||||
|
||||
async createImmediateUpgradeInvoice({
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
diffAmountInCents,
|
||||
currency,
|
||||
description,
|
||||
}: {
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId: string;
|
||||
diffAmountInCents: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
}): Promise<void> {
|
||||
await this.stripe.invoiceItems.create({
|
||||
customer: stripeCustomerId,
|
||||
subscription: stripeSubscriptionId,
|
||||
amount: diffAmountInCents,
|
||||
currency,
|
||||
description,
|
||||
});
|
||||
|
||||
const invoice = await this.stripe.invoices.create({
|
||||
customer: stripeCustomerId,
|
||||
subscription: stripeSubscriptionId,
|
||||
});
|
||||
|
||||
await this.stripe.invoices.finalizeInvoice(invoice.id, {
|
||||
auto_advance: true,
|
||||
});
|
||||
|
||||
await this.stripe.invoices.pay(invoice.id);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -74,7 +74,10 @@ export class StripeSubscriptionScheduleService {
|
||||
) {
|
||||
if (!this.stripe) throw new Error('Billing is disabled');
|
||||
|
||||
return await this.stripe.subscriptionSchedules.update(scheduleId, params);
|
||||
return await this.stripe.subscriptionSchedules.update(scheduleId, {
|
||||
...params,
|
||||
proration_behavior: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
async createSubscriptionSchedule(stripeSubscriptionId: string) {
|
||||
|
||||
+2
-1
@@ -6,5 +6,6 @@ import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billi
|
||||
export type BillingGetPlanResult = {
|
||||
planKey: BillingPlanKey;
|
||||
meteredProducts: BillingProductEntity[];
|
||||
licensedProducts: BillingProductEntity[];
|
||||
baseProducts: BillingProductEntity[];
|
||||
resourceCreditProducts: BillingProductEntity[];
|
||||
};
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
|
||||
export type BillingGetPricesPerPlanResult = {
|
||||
meteredProductsPrices: BillingPriceEntity[];
|
||||
licensedProductsPrices: BillingPriceEntity[];
|
||||
meteredProductPrices: BillingPriceEntity[];
|
||||
baseProductPrices: BillingPriceEntity[];
|
||||
resourceCreditProductPrices: BillingPriceEntity[];
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { type BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
export type BillingPortalCheckoutSessionParameters = {
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingPriceMetadata {
|
||||
@Field(() => String, { nullable: true })
|
||||
credit_amount?: string;
|
||||
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
+5
@@ -4,6 +4,7 @@ import { type SubscriptionInterval } from 'src/engine/core-modules/billing/enums
|
||||
export enum SubscriptionUpdateType {
|
||||
PLAN = 'PLAN',
|
||||
METERED_PRICE = 'METERED_PRICE',
|
||||
RESOURCE_CREDIT_PRICE = 'RESOURCE_CREDIT_PRICE',
|
||||
SEATS = 'SEATS',
|
||||
INTERVAL = 'INTERVAL',
|
||||
}
|
||||
@@ -17,6 +18,10 @@ export type SubscriptionUpdate =
|
||||
type: SubscriptionUpdateType.METERED_PRICE;
|
||||
newMeteredPriceId: string;
|
||||
}
|
||||
| {
|
||||
type: SubscriptionUpdateType.RESOURCE_CREDIT_PRICE;
|
||||
newResourceCreditPriceId: string;
|
||||
}
|
||||
| {
|
||||
type: SubscriptionUpdateType.SEATS;
|
||||
newSeats: number;
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ describe('computeSubscriptionUpdateOptions', () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
proration: 'create_prorations',
|
||||
proration: 'always_invoice',
|
||||
metadata: {
|
||||
plan: BillingPlanKey.PRO,
|
||||
},
|
||||
@@ -25,7 +25,7 @@ describe('computeSubscriptionUpdateOptions', () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
proration: 'create_prorations',
|
||||
proration: 'always_invoice',
|
||||
metadata: {
|
||||
plan: BillingPlanKey.ENTERPRISE,
|
||||
},
|
||||
|
||||
+7
-3
@@ -9,7 +9,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
||||
it('should correctly format a billing plan with licensed and metered products', () => {
|
||||
const mockPlan = {
|
||||
planKey: BillingPlanKey.PRO,
|
||||
licensedProducts: [
|
||||
baseProducts: [
|
||||
{
|
||||
id: 'product-1',
|
||||
name: 'Test Licensed Product',
|
||||
@@ -23,6 +23,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
resourceCreditProducts: [],
|
||||
meteredProducts: [
|
||||
{
|
||||
id: 'product-2',
|
||||
@@ -52,7 +53,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
planKey: BillingPlanKey.PRO,
|
||||
licensedProducts: [
|
||||
baseProducts: [
|
||||
{
|
||||
id: 'product-1',
|
||||
name: 'Test Licensed Product',
|
||||
@@ -70,10 +71,12 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
||||
unitAmount: 1500,
|
||||
stripePriceId: 'price_123',
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
creditAmount: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
resourceCreditProducts: [],
|
||||
meteredProducts: [
|
||||
{
|
||||
id: 'product-2',
|
||||
@@ -115,7 +118,8 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
||||
it('should convert internal credits to display credits in metered tier upTo', () => {
|
||||
const mockPlan = {
|
||||
planKey: BillingPlanKey.PRO,
|
||||
licensedProducts: [],
|
||||
baseProducts: [],
|
||||
resourceCreditProducts: [],
|
||||
meteredProducts: [
|
||||
{
|
||||
id: 'product-2',
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ describe('transformStripePriceToDatabasePrice', () => {
|
||||
transformQuantity: undefined,
|
||||
usageType: BillingUsageType.LICENSED,
|
||||
interval: SubscriptionInterval.Month,
|
||||
metadata: {},
|
||||
currencyOptions: undefined,
|
||||
tiers: undefined,
|
||||
recurring: {
|
||||
|
||||
+5
-2
@@ -17,7 +17,7 @@ export const computeSubscriptionUpdateOptions = (
|
||||
switch (subscriptionUpdate.type) {
|
||||
case SubscriptionUpdateType.PLAN:
|
||||
return {
|
||||
proration: 'create_prorations',
|
||||
proration: 'always_invoice',
|
||||
metadata: {
|
||||
plan: subscriptionUpdate.newPlan,
|
||||
},
|
||||
@@ -26,7 +26,10 @@ export const computeSubscriptionUpdateOptions = (
|
||||
return {
|
||||
proration: 'create_prorations',
|
||||
};
|
||||
|
||||
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE:
|
||||
return {
|
||||
proration: 'none',
|
||||
};
|
||||
case SubscriptionUpdateType.INTERVAL:
|
||||
return {
|
||||
proration: 'create_prorations',
|
||||
|
||||
+19
-3
@@ -1,20 +1,32 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { isDefined } from 'class-validator';
|
||||
import { type BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
|
||||
import { type BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
|
||||
import { type BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
|
||||
import { type BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
|
||||
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
import { type BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
import {
|
||||
INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
|
||||
toDisplayCredits,
|
||||
} from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
|
||||
export const formatBillingDatabaseProductToGraphqlDTO = (
|
||||
plan: BillingGetPlanResult,
|
||||
): BillingPlanDTO => {
|
||||
return {
|
||||
planKey: plan.planKey,
|
||||
licensedProducts: plan.licensedProducts.map((product) => {
|
||||
baseProducts: plan.baseProducts.map((product) => {
|
||||
return {
|
||||
...product,
|
||||
prices: product.billingPrices.map(
|
||||
formatBillingDatabasePriceToLicensedPriceDTO,
|
||||
),
|
||||
};
|
||||
}),
|
||||
resourceCreditProducts: plan.resourceCreditProducts.map((product) => {
|
||||
return {
|
||||
...product,
|
||||
prices: product.billingPrices.map(
|
||||
@@ -61,5 +73,9 @@ const formatBillingDatabasePriceToLicensedPriceDTO = (
|
||||
unitAmount: billingPrice?.unitAmount ?? 0,
|
||||
stripePriceId: billingPrice?.stripePriceId,
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
creditAmount: isDefined(billingPrice?.metadata?.credit_amount)
|
||||
? Number(billingPrice?.metadata?.credit_amount) /
|
||||
INTERNAL_CREDITS_PER_DISPLAY_CREDIT
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { findOrThrow } from 'twenty-shared/utils';
|
||||
|
||||
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { type LicensedBillingSubscriptionItem } from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
|
||||
|
||||
// V2 counterpart of get-licensed-billing-subscription-item-or-throw.util.ts
|
||||
// Identifies the base plan item by productKey === BASE_PRODUCT (not quantity != null)
|
||||
export const getBaseProductSubscriptionItemOrThrow = (
|
||||
billingSubscription: BillingSubscriptionEntity,
|
||||
): LicensedBillingSubscriptionItem => {
|
||||
return findOrThrow(
|
||||
billingSubscription.billingSubscriptionItems,
|
||||
({ billingProduct }) =>
|
||||
billingProduct.metadata.productKey === BillingProductKey.BASE_PRODUCT,
|
||||
) as LicensedBillingSubscriptionItem;
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { findOrThrow } from 'twenty-shared/utils';
|
||||
|
||||
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
|
||||
// V2 counterpart of get-metered-billing-subscription-item-or-throw.util.ts
|
||||
// Identifies the credit item by productKey === RESOURCE_CREDIT (not quantity == null)
|
||||
export const getCurrentResourceCreditSubscriptionItemOrThrow = (
|
||||
billingSubscription: BillingSubscriptionEntity,
|
||||
) => {
|
||||
return findOrThrow(
|
||||
billingSubscription.billingSubscriptionItems,
|
||||
({ billingProduct }) =>
|
||||
billingProduct.metadata.productKey === BillingProductKey.RESOURCE_CREDIT,
|
||||
);
|
||||
};
|
||||
+1
@@ -36,6 +36,7 @@ export const transformStripePriceToDatabasePrice = (data: Stripe.Price) => {
|
||||
data.currency_options === null ? undefined : data.currency_options,
|
||||
tiers: data.tiers === null ? undefined : data.tiers,
|
||||
recurring: data.recurring === null ? undefined : data.recurring,
|
||||
metadata: data.metadata ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user