Add managed databases and services with billing-aligned upgrades.
Introduce product types for managed PostgreSQL, Redis, and RabbitMQ with a dedicated dashboard, Helm-only deploy pipeline, external access, snapshots with progress, and prorated resource or storage upgrades matching application billing rules. PVCs use an expandable StorageClass with automatic migration when legacy disks cannot resize in place. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,7 +37,17 @@ import {
|
||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole, BillingCycle, AppLifecycleStatus, InvoiceReason, InvoiceStatus, PaymentMethod } from '../common/enums';
|
||||
import {
|
||||
UserRole,
|
||||
BillingCycle,
|
||||
AppLifecycleStatus,
|
||||
InvoiceReason,
|
||||
InvoiceStatus,
|
||||
PaymentMethod,
|
||||
ProductType,
|
||||
DatabaseType,
|
||||
} from '../common/enums';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
|
||||
@ApiTags('Billing')
|
||||
@ApiBearerAuth()
|
||||
@@ -722,33 +732,15 @@ export class BillingController {
|
||||
paidInvoice = paid.invoice;
|
||||
}
|
||||
|
||||
// Apply the resource changes
|
||||
const updatedApp = await this.applicationsService.update(app.id, app.userId, {
|
||||
cpuRequest: dto.cpuRequest || app.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit || app.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest || app.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit || app.memoryLimit,
|
||||
replicas: dto.replicas ?? app.replicas,
|
||||
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
|
||||
appStorageSize: dto.appStorageSize || app.appStorageSize,
|
||||
});
|
||||
const updatedApp = await this.applicationsService.update(
|
||||
app.id,
|
||||
app.userId,
|
||||
this.buildUpgradeEntityPatch(app, dto),
|
||||
);
|
||||
|
||||
// Update Kubernetes resources
|
||||
try {
|
||||
await this.kubernetesService.updateResources(updatedApp, {
|
||||
cpuRequest: dto.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit,
|
||||
replicas: dto.replicas,
|
||||
});
|
||||
|
||||
// Resize app storage PVC if changed
|
||||
if (dto.appStorageSize && dto.appStorageSize !== app.appStorageSize) {
|
||||
await this.kubernetesService.resizeAppStoragePvc(updatedApp, dto.appStorageSize);
|
||||
}
|
||||
await this.applyUpgradeToKubernetes(updatedApp, dto, app);
|
||||
} catch (e: any) {
|
||||
// Log error but don't fail - DB is updated, K8s will sync on next deploy
|
||||
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
@@ -803,29 +795,15 @@ export class BillingController {
|
||||
|
||||
if (action === 'upgrade') {
|
||||
const app = await this.applicationsService.findOne(invoice.applicationId);
|
||||
const resources = invoice.metadata?.resources || {};
|
||||
const updatedApp = await this.applicationsService.update(app.id, app.userId, {
|
||||
cpuRequest: resources.cpuRequest || app.cpuRequest,
|
||||
cpuLimit: resources.cpuLimit || app.cpuLimit,
|
||||
memoryRequest: resources.memoryRequest || app.memoryRequest,
|
||||
memoryLimit: resources.memoryLimit || app.memoryLimit,
|
||||
replicas: resources.replicas ?? app.replicas,
|
||||
dbStorageSize: resources.dbStorageSize || app.dbStorageSize,
|
||||
appStorageSize: resources.appStorageSize || app.appStorageSize,
|
||||
});
|
||||
const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto;
|
||||
const updatedApp = await this.applicationsService.update(
|
||||
app.id,
|
||||
app.userId,
|
||||
this.buildUpgradeEntityPatch(app, resources),
|
||||
);
|
||||
|
||||
try {
|
||||
await this.kubernetesService.updateResources(updatedApp, {
|
||||
cpuRequest: resources.cpuRequest,
|
||||
cpuLimit: resources.cpuLimit,
|
||||
memoryRequest: resources.memoryRequest,
|
||||
memoryLimit: resources.memoryLimit,
|
||||
replicas: resources.replicas,
|
||||
});
|
||||
|
||||
if (resources.appStorageSize && resources.appStorageSize !== app.appStorageSize) {
|
||||
await this.kubernetesService.resizeAppStoragePvc(updatedApp, resources.appStorageSize);
|
||||
}
|
||||
await this.applyUpgradeToKubernetes(updatedApp, resources, app);
|
||||
} catch (e: any) {
|
||||
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
@@ -849,6 +827,136 @@ export class BillingController {
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
|
||||
const pt = app.productType ?? ProductType.APPLICATION;
|
||||
|
||||
if (pt === ProductType.MANAGED_REDIS && dto.redisResources) {
|
||||
return {
|
||||
optionalServiceResources: {
|
||||
...app.optionalServiceResources,
|
||||
redis: {
|
||||
...app.optionalServiceResources?.redis,
|
||||
...dto.redisResources,
|
||||
storageGi:
|
||||
dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (pt === ProductType.MANAGED_RABBITMQ && dto.rabbitmqResources) {
|
||||
return {
|
||||
optionalServiceResources: {
|
||||
...app.optionalServiceResources,
|
||||
rabbitmq: {
|
||||
...app.optionalServiceResources?.rabbitmq,
|
||||
...dto.rabbitmqResources,
|
||||
storageGi:
|
||||
dto.rabbitmqResources.storageGi ??
|
||||
app.optionalServiceResources?.rabbitmq?.storageGi ??
|
||||
2,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cpuRequest: dto.cpuRequest || app.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit || app.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest || app.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit || app.memoryLimit,
|
||||
replicas: dto.replicas ?? app.replicas,
|
||||
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
|
||||
appStorageSize: dto.appStorageSize || app.appStorageSize,
|
||||
};
|
||||
}
|
||||
|
||||
private async applyUpgradeToKubernetes(
|
||||
app: Application,
|
||||
dto: UpgradeResourcesDto,
|
||||
previous: Application,
|
||||
): Promise<void> {
|
||||
const pt = app.productType ?? ProductType.APPLICATION;
|
||||
|
||||
if (pt === ProductType.MANAGED_DATABASE) {
|
||||
await this.kubernetesService.updateResources(
|
||||
app,
|
||||
{
|
||||
cpuRequest: dto.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit,
|
||||
},
|
||||
'database',
|
||||
);
|
||||
if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) {
|
||||
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
|
||||
if (!resize.success) {
|
||||
throw new BadRequestException(resize.message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pt === ProductType.MANAGED_REDIS) {
|
||||
const res = app.optionalServiceResources?.redis;
|
||||
if (res) {
|
||||
await this.kubernetesService.updateResources(
|
||||
app,
|
||||
{
|
||||
cpuRequest: res.cpuRequest,
|
||||
cpuLimit: res.cpuLimit,
|
||||
memoryRequest: res.memoryRequest,
|
||||
memoryLimit: res.memoryLimit,
|
||||
},
|
||||
'redis',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pt === ProductType.MANAGED_RABBITMQ) {
|
||||
const res = app.optionalServiceResources?.rabbitmq;
|
||||
if (res) {
|
||||
await this.kubernetesService.updateResources(
|
||||
app,
|
||||
{
|
||||
cpuRequest: res.cpuRequest,
|
||||
cpuLimit: res.cpuLimit,
|
||||
memoryRequest: res.memoryRequest,
|
||||
memoryLimit: res.memoryLimit,
|
||||
},
|
||||
'rabbitmq',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.kubernetesService.updateResources(app, {
|
||||
cpuRequest: dto.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit,
|
||||
replicas: dto.replicas,
|
||||
});
|
||||
|
||||
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
|
||||
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
|
||||
}
|
||||
|
||||
if (
|
||||
dto.dbStorageSize &&
|
||||
dto.dbStorageSize !== previous.dbStorageSize &&
|
||||
previous.databaseType &&
|
||||
previous.databaseType !== DatabaseType.NONE
|
||||
) {
|
||||
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
|
||||
if (!resize.success) {
|
||||
throw new BadRequestException(resize.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getAppWithAccess(user: any, applicationId: string) {
|
||||
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
InvoiceStatus,
|
||||
PaymentMethod,
|
||||
UserRole,
|
||||
ProductType,
|
||||
isManagedProductType,
|
||||
} from '../common/enums';
|
||||
import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto';
|
||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||
@@ -560,6 +562,7 @@ export class BillingService {
|
||||
* Used by lifecycle service for auto-renew.
|
||||
*/
|
||||
async calculateCostForApp(app: {
|
||||
productType?: ProductType;
|
||||
runtime: string;
|
||||
databaseType: string;
|
||||
cpuLimit: string;
|
||||
@@ -572,20 +575,9 @@ export class BillingService {
|
||||
enableElasticsearch?: boolean;
|
||||
customDomain?: string;
|
||||
customDomainStatus?: string;
|
||||
optionalServiceResources?: Application['optionalServiceResources'];
|
||||
}): Promise<{ hourly: number; monthly: number; yearly: number }> {
|
||||
const result = await this.calculateCost({
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
cpuLimit: app.cpuLimit,
|
||||
memoryLimit: app.memoryLimit,
|
||||
replicas: app.replicas,
|
||||
dbStorageSize: app.dbStorageSize,
|
||||
appStorageSize: app.appStorageSize,
|
||||
enableRedis: app.enableRedis,
|
||||
enableRabbitmq: app.enableRabbitmq,
|
||||
enableElasticsearch: app.enableElasticsearch,
|
||||
enableCustomDomain: !!app.customDomain && app.customDomainStatus === 'verified',
|
||||
});
|
||||
const result = await this.calculateCost(this.toCalculateDto(this.appToResourceConfig(app as Application)));
|
||||
return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly };
|
||||
}
|
||||
|
||||
@@ -635,19 +627,43 @@ export class BillingService {
|
||||
remainingHours: number;
|
||||
billingCycle: BillingCycle | null;
|
||||
}> {
|
||||
// Current cost
|
||||
const currentCost = await this.calculateCostForApp(app);
|
||||
|
||||
// New cost with upgraded resources
|
||||
const newCost = await this.calculateCost({
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
cpuLimit: newResources.cpuLimit || app.cpuLimit,
|
||||
memoryLimit: newResources.memoryLimit || app.memoryLimit,
|
||||
replicas: newResources.replicas ?? app.replicas,
|
||||
dbStorageSize: newResources.dbStorageSize || app.dbStorageSize,
|
||||
appStorageSize: newResources.appStorageSize || app.appStorageSize,
|
||||
});
|
||||
const base = this.appToResourceConfig(app);
|
||||
const merged = {
|
||||
...base,
|
||||
...(newResources.cpuLimit && { cpuLimit: newResources.cpuLimit }),
|
||||
...(newResources.memoryLimit && { memoryLimit: newResources.memoryLimit }),
|
||||
replicas: newResources.replicas ?? base.replicas,
|
||||
...(newResources.dbStorageSize && { dbStorageSize: newResources.dbStorageSize }),
|
||||
...(newResources.appStorageSize && { appStorageSize: newResources.appStorageSize }),
|
||||
...(newResources.redisResources && {
|
||||
redisResources: {
|
||||
...base.redisResources,
|
||||
...newResources.redisResources,
|
||||
storageGi:
|
||||
newResources.redisResources.storageGi ??
|
||||
base.redisResources?.storageGi ??
|
||||
1,
|
||||
},
|
||||
}),
|
||||
...(newResources.rabbitmqResources && {
|
||||
rabbitmqResources: {
|
||||
...base.rabbitmqResources,
|
||||
...newResources.rabbitmqResources,
|
||||
storageGi:
|
||||
newResources.rabbitmqResources.storageGi ??
|
||||
base.rabbitmqResources?.storageGi ??
|
||||
2,
|
||||
},
|
||||
}),
|
||||
};
|
||||
const newCostResult = await this.calculateCost(this.toCalculateDto(merged));
|
||||
const newCost = {
|
||||
hourly: newCostResult.hourly,
|
||||
monthly: newCostResult.monthly,
|
||||
yearly: newCostResult.yearly,
|
||||
};
|
||||
|
||||
// Difference
|
||||
const difference = {
|
||||
@@ -719,18 +735,24 @@ export class BillingService {
|
||||
: undefined;
|
||||
const dtoExtras =
|
||||
'redisResources' in app ? (app as CalculateCostDto) : undefined;
|
||||
const productType =
|
||||
'productType' in app
|
||||
? ((app as Application).productType ?? ProductType.APPLICATION)
|
||||
: ((app as CalculateCostDto).productType ?? ProductType.APPLICATION);
|
||||
const managed = isManagedProductType(productType);
|
||||
return {
|
||||
productType,
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
cpuLimit: app.cpuLimit,
|
||||
memoryLimit: app.memoryLimit,
|
||||
replicas: app.replicas || 1,
|
||||
replicas: managed ? 0 : (app.replicas ?? 1),
|
||||
dbStorageSize: app.dbStorageSize,
|
||||
appStorageSize: app.appStorageSize,
|
||||
enableRedis: !!app.enableRedis,
|
||||
enableRabbitmq: !!app.enableRabbitmq,
|
||||
enableElasticsearch: !!app.enableElasticsearch,
|
||||
enableCustomDomain,
|
||||
enableElasticsearch: managed ? false : !!app.enableElasticsearch,
|
||||
enableCustomDomain: managed ? false : enableCustomDomain,
|
||||
redisResources: optionalRes?.redis ?? dtoExtras?.redisResources,
|
||||
rabbitmqResources: optionalRes?.rabbitmq ?? dtoExtras?.rabbitmqResources,
|
||||
};
|
||||
@@ -744,6 +766,7 @@ export class BillingService {
|
||||
const credit = this.creditRepo.create({
|
||||
userId: app.userId,
|
||||
sourceAppName: app.name,
|
||||
productType: app.productType ?? ProductType.APPLICATION,
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
cpuLimit: app.cpuLimit,
|
||||
@@ -781,6 +804,7 @@ export class BillingService {
|
||||
return {
|
||||
id: credit.id,
|
||||
sourceAppName: credit.sourceAppName,
|
||||
productType: credit.productType ?? ProductType.APPLICATION,
|
||||
runtime: credit.runtime,
|
||||
databaseType: credit.databaseType,
|
||||
cpuLimit: credit.cpuLimit,
|
||||
@@ -803,6 +827,9 @@ export class BillingService {
|
||||
config: ReturnType<typeof this.appToResourceConfig>,
|
||||
credit: ResourceCredit,
|
||||
): boolean {
|
||||
const configProduct = config.productType ?? ProductType.APPLICATION;
|
||||
const creditProduct = credit.productType ?? ProductType.APPLICATION;
|
||||
if (configProduct !== creditProduct) return false;
|
||||
if (config.runtime !== credit.runtime) return false;
|
||||
if (
|
||||
credit.databaseType !== DatabaseType.NONE &&
|
||||
@@ -842,12 +869,16 @@ export class BillingService {
|
||||
): Promise<ResourceCredit | null> {
|
||||
const credits = await this.getActiveCredits(userId);
|
||||
return (
|
||||
credits.find(
|
||||
(c) =>
|
||||
credits.find((c) => {
|
||||
const creditProduct = c.productType ?? ProductType.APPLICATION;
|
||||
const configProduct = config.productType ?? ProductType.APPLICATION;
|
||||
if (creditProduct !== configProduct) return false;
|
||||
return (
|
||||
c.runtime === config.runtime &&
|
||||
(c.databaseType === DatabaseType.NONE ||
|
||||
c.databaseType === config.databaseType),
|
||||
) ?? null
|
||||
c.databaseType === config.databaseType)
|
||||
);
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -865,6 +896,7 @@ export class BillingService {
|
||||
config: ReturnType<typeof this.appToResourceConfig>,
|
||||
): CalculateCostDto {
|
||||
return {
|
||||
productType: config.productType,
|
||||
runtime: config.runtime,
|
||||
databaseType: config.databaseType,
|
||||
cpuLimit: config.cpuLimit,
|
||||
@@ -887,6 +919,7 @@ export class BillingService {
|
||||
patch: Partial<CalculateCostDto> = {},
|
||||
): CalculateCostDto {
|
||||
return {
|
||||
productType: credit.productType ?? ProductType.APPLICATION,
|
||||
runtime: credit.runtime,
|
||||
databaseType: credit.databaseType,
|
||||
cpuLimit: credit.cpuLimit,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber, IsArray, ValidateNested, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { BillingCycle, PricingResourceType, AppRuntime, InvoiceStatus } from '../../common/enums';
|
||||
import { BillingCycle, PricingResourceType, AppRuntime, InvoiceStatus, ProductType } from '../../common/enums';
|
||||
import { OptionalServiceResourcesDto } from './optional-service-resources.dto';
|
||||
|
||||
export class CreatePricingRuleDto {
|
||||
@@ -92,6 +92,11 @@ export class ChargeWalletDto {
|
||||
}
|
||||
|
||||
export class CalculateCostDto {
|
||||
@ApiPropertyOptional({ enum: ProductType, default: ProductType.APPLICATION })
|
||||
@IsOptional()
|
||||
@IsEnum(ProductType)
|
||||
productType?: ProductType;
|
||||
|
||||
@ApiProperty({ example: 'nodejs' })
|
||||
@IsString()
|
||||
runtime: string;
|
||||
@@ -108,10 +113,11 @@ export class CalculateCostDto {
|
||||
@IsString()
|
||||
memoryLimit: string;
|
||||
|
||||
@ApiProperty({ example: 1, description: 'Number of replicas' })
|
||||
@ApiPropertyOptional({ example: 1, description: 'Number of replicas (0 for managed services)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
replicas: number;
|
||||
@Min(0)
|
||||
replicas?: number;
|
||||
|
||||
@ApiProperty({ example: '1Gi', description: 'Database storage size' })
|
||||
@IsOptional()
|
||||
@@ -206,6 +212,18 @@ export class UpgradeResourcesDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
appStorageSize?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Managed Redis resource limits' })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionalServiceResourcesDto)
|
||||
redisResources?: OptionalServiceResourcesDto;
|
||||
|
||||
@ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Managed RabbitMQ resource limits' })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionalServiceResourcesDto)
|
||||
rabbitmqResources?: OptionalServiceResourcesDto;
|
||||
}
|
||||
|
||||
export class CalculateUpgradeCostDto extends UpgradeResourcesDto {}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { AppRuntime, DatabaseType, BillingCycle } from '../../common/enums';
|
||||
import { AppRuntime, DatabaseType, BillingCycle, ProductType } from '../../common/enums';
|
||||
|
||||
/** Prepaid resources returned to the user when they delete an app before plan expiry. */
|
||||
@Entity('resource_credits')
|
||||
@@ -25,6 +25,9 @@ export class ResourceCredit {
|
||||
@Column({ nullable: true })
|
||||
sourceAppName: string;
|
||||
|
||||
@Column({ type: 'varchar', default: ProductType.APPLICATION })
|
||||
productType: ProductType;
|
||||
|
||||
@Column({ type: 'enum', enum: AppRuntime })
|
||||
runtime: AppRuntime;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DatabaseType,
|
||||
OptionalService,
|
||||
PricingResourceType,
|
||||
ProductType,
|
||||
} from '../common/enums';
|
||||
import { OPTIONAL_SERVICE_DEPLOY_SPECS } from './pricing-catalog.constants';
|
||||
import { CalculateCostDto } from './dto/billing.dto';
|
||||
@@ -207,4 +208,92 @@ describe('PricingCatalogService', () => {
|
||||
expect(service.amountForCycleFromLine(line, BillingCycle.MONTHLY)).toBe(2);
|
||||
expect(service.amountForCycleFromLine(line, BillingCycle.YEARLY)).toBe(3);
|
||||
});
|
||||
|
||||
it('managed_database bills database addon and resources without app base fee', () => {
|
||||
const rates = [
|
||||
{
|
||||
runtime: AppRuntime.NODEJS,
|
||||
resourceType: PricingResourceType.BASE_FEE,
|
||||
hourlyPrice: 999,
|
||||
monthlyPrice: 999,
|
||||
yearlyPrice: 999,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
runtime: AppRuntime.NODEJS,
|
||||
resourceType: PricingResourceType.DATABASE_ADDON,
|
||||
hourlyPrice: 0,
|
||||
monthlyPrice: 500,
|
||||
yearlyPrice: 0,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
runtime: AppRuntime.NODEJS,
|
||||
resourceType: PricingResourceType.STORAGE_PER_GB,
|
||||
hourlyPrice: 0,
|
||||
monthlyPrice: 100,
|
||||
yearlyPrice: 0,
|
||||
isActive: true,
|
||||
},
|
||||
] as PricingRate[];
|
||||
|
||||
const result = service.computeTotalsWithRates(
|
||||
{
|
||||
...baseDto(),
|
||||
productType: ProductType.MANAGED_DATABASE,
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
replicas: 0,
|
||||
dbStorageSize: '2Gi',
|
||||
cpuLimit: '500m',
|
||||
memoryLimit: '512Mi',
|
||||
},
|
||||
rates,
|
||||
emptyOptional(),
|
||||
);
|
||||
expect(result.monthly).toBe(700);
|
||||
expect(result.breakdown.some((l) => l.label.includes('Base fee'))).toBe(false);
|
||||
});
|
||||
|
||||
it('managed_redis bills only optional redis lines', () => {
|
||||
const profile = {
|
||||
service: OptionalService.REDIS,
|
||||
cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit,
|
||||
memoryLimit: '256Mi',
|
||||
storageGi: 0,
|
||||
} as OptionalServiceProfile;
|
||||
|
||||
const rates = [
|
||||
{
|
||||
service: OptionalService.REDIS,
|
||||
resourceType: PricingResourceType.BASE_FEE,
|
||||
hourlyPrice: 0,
|
||||
monthlyPrice: 250,
|
||||
yearlyPrice: 0,
|
||||
isActive: true,
|
||||
},
|
||||
] as OptionalServiceRate[];
|
||||
|
||||
const appRates = [
|
||||
{
|
||||
runtime: AppRuntime.NODEJS,
|
||||
resourceType: PricingResourceType.BASE_FEE,
|
||||
hourlyPrice: 999,
|
||||
monthlyPrice: 999,
|
||||
yearlyPrice: 999,
|
||||
isActive: true,
|
||||
},
|
||||
] as PricingRate[];
|
||||
|
||||
const result = service.computeTotalsWithRates(
|
||||
{
|
||||
...baseDto(),
|
||||
productType: ProductType.MANAGED_REDIS,
|
||||
replicas: 0,
|
||||
enableRedis: true,
|
||||
},
|
||||
appRates,
|
||||
{ profiles: [profile], rates, customDomain: null },
|
||||
);
|
||||
expect(result.monthly).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DatabaseType,
|
||||
OptionalService,
|
||||
PricingResourceType,
|
||||
ProductType,
|
||||
} from '../common/enums';
|
||||
import { CalculateCostDto } from './dto/billing.dto';
|
||||
import {
|
||||
@@ -329,6 +330,19 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
rates: PricingRate[],
|
||||
optional: OptionalBillingContext,
|
||||
): CostBreakdownLine[] {
|
||||
const productType = dto.productType ?? ProductType.APPLICATION;
|
||||
|
||||
if (
|
||||
productType === ProductType.MANAGED_REDIS ||
|
||||
productType === ProductType.MANAGED_RABBITMQ
|
||||
) {
|
||||
return this.buildOptionalServiceLines(dto, optional);
|
||||
}
|
||||
|
||||
if (productType === ProductType.MANAGED_DATABASE) {
|
||||
return this.buildManagedDatabaseLines(dto, rates);
|
||||
}
|
||||
|
||||
const lines: CostBreakdownLine[] = [];
|
||||
const quantities = this.getQuantities(dto);
|
||||
|
||||
@@ -351,6 +365,52 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
return lines;
|
||||
}
|
||||
|
||||
private buildManagedDatabaseLines(
|
||||
dto: CalculateCostDto,
|
||||
rates: PricingRate[],
|
||||
): CostBreakdownLine[] {
|
||||
const lines: CostBreakdownLine[] = [];
|
||||
const quantities = this.getManagedDatabaseQuantities(dto);
|
||||
const allowed = new Set([
|
||||
PricingResourceType.DATABASE_ADDON,
|
||||
PricingResourceType.CPU_PER_CORE,
|
||||
PricingResourceType.MEMORY_PER_GB,
|
||||
PricingResourceType.STORAGE_PER_GB,
|
||||
]);
|
||||
|
||||
for (const rate of rates) {
|
||||
if (!allowed.has(rate.resourceType)) continue;
|
||||
const qty = quantities.get(rate.resourceType) ?? 0;
|
||||
if (qty <= 0) continue;
|
||||
const line = this.lineFromPrices(
|
||||
RESOURCE_LABELS[rate.resourceType],
|
||||
qty,
|
||||
Number(rate.hourlyPrice),
|
||||
Number(rate.monthlyPrice),
|
||||
Number(rate.yearlyPrice),
|
||||
rate.resourceType,
|
||||
dto,
|
||||
);
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private getManagedDatabaseQuantities(dto: CalculateCostDto): Map<PricingResourceType, number> {
|
||||
const cpuQty = this.parseCpuToCores(dto.cpuLimit || '500m');
|
||||
const memoryQty = this.parseMemoryToGb(dto.memoryLimit || '512Mi');
|
||||
const storageQty = dto.dbStorageSize
|
||||
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
|
||||
: 1;
|
||||
|
||||
const map = new Map<PricingResourceType, number>();
|
||||
map.set(PricingResourceType.DATABASE_ADDON, 1);
|
||||
map.set(PricingResourceType.CPU_PER_CORE, cpuQty);
|
||||
map.set(PricingResourceType.MEMORY_PER_GB, memoryQty);
|
||||
map.set(PricingResourceType.STORAGE_PER_GB, storageQty);
|
||||
return map;
|
||||
}
|
||||
|
||||
private buildOptionalServiceLines(
|
||||
dto: CalculateCostDto,
|
||||
optional: OptionalBillingContext,
|
||||
@@ -589,7 +649,10 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
}
|
||||
|
||||
getQuantities(dto: CalculateCostDto): Map<PricingResourceType, number> {
|
||||
const replicas = dto.replicas || 1;
|
||||
if ((dto.productType ?? ProductType.APPLICATION) === ProductType.MANAGED_DATABASE) {
|
||||
return this.getManagedDatabaseQuantities(dto);
|
||||
}
|
||||
const replicas = dto.replicas ?? 1;
|
||||
const hasDatabase = dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none';
|
||||
const cpuQty = this.parseCpuToCores(dto.cpuLimit) * replicas;
|
||||
const memoryQty = this.parseMemoryToGb(dto.memoryLimit) * replicas;
|
||||
|
||||
Reference in New Issue
Block a user