diff --git a/backend/migrations/004_pricing_catalog.sql b/backend/migrations/004_pricing_catalog.sql new file mode 100644 index 0000000..995d483 --- /dev/null +++ b/backend/migrations/004_pricing_catalog.sql @@ -0,0 +1,117 @@ +-- Usage-based pricing catalog (replaces per-cycle service_plans + scattered platform_settings) + +CREATE TABLE IF NOT EXISTS pricing_rates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + runtime VARCHAR NOT NULL, + resource_type VARCHAR NOT NULL, + hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (runtime, resource_type) +); + +CREATE TABLE IF NOT EXISTS addon_rates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + resource_type VARCHAR NOT NULL UNIQUE, + hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed addon rows +INSERT INTO addon_rates (resource_type, hourly_price, monthly_price, yearly_price) +VALUES + ('redis_addon', 0, 0, 0), + ('rabbitmq_addon', 0, 0, 0), + ('elasticsearch_addon', 0, 0, 0), + ('custom_domain_addon', 0, 0, 0) +ON CONFLICT (resource_type) DO NOTHING; + +-- Migrate runtime rates from service_plans + pricing_rules +INSERT INTO pricing_rates (runtime, resource_type, hourly_price, monthly_price, yearly_price, is_active) +SELECT + sp.runtime, + pr.resource_type, + COALESCE(MAX(CASE WHEN sp."billingCycle" = 'hourly' THEN pr.unit_price END), 0), + COALESCE(MAX(CASE WHEN sp."billingCycle" = 'monthly' THEN pr.unit_price END), 0), + COALESCE(MAX(CASE WHEN sp."billingCycle" = 'yearly' THEN pr.unit_price END), 0), + BOOL_OR(sp."isActive") +FROM pricing_rules pr +JOIN service_plans sp ON sp.id = pr."planId" +WHERE pr.resource_type IN ( + 'base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon' +) +GROUP BY sp.runtime, pr.resource_type +ON CONFLICT (runtime, resource_type) DO UPDATE SET + hourly_price = EXCLUDED.hourly_price, + monthly_price = EXCLUDED.monthly_price, + yearly_price = EXCLUDED.yearly_price, + is_active = EXCLUDED.is_active, + "updatedAt" = NOW(); + +-- Optional services from platform_settings JSON +UPDATE addon_rates ar SET + hourly_price = COALESCE((s.parsed->'redis'->>'hourly')::decimal, 0), + monthly_price = COALESCE((s.parsed->'redis'->>'monthly')::decimal, 0), + yearly_price = COALESCE((s.parsed->'redis'->>'yearly')::decimal, 0) +FROM ( + SELECT value::jsonb AS parsed + FROM platform_settings + WHERE key = 'optional_services_pricing_toman' + LIMIT 1 +) s +WHERE ar.resource_type = 'redis_addon' AND s.parsed IS NOT NULL; + +UPDATE addon_rates ar SET + hourly_price = COALESCE((s.parsed->'rabbitmq'->>'hourly')::decimal, 0), + monthly_price = COALESCE((s.parsed->'rabbitmq'->>'monthly')::decimal, 0), + yearly_price = COALESCE((s.parsed->'rabbitmq'->>'yearly')::decimal, 0) +FROM ( + SELECT value::jsonb AS parsed + FROM platform_settings + WHERE key = 'optional_services_pricing_toman' + LIMIT 1 +) s +WHERE ar.resource_type = 'rabbitmq_addon' AND s.parsed IS NOT NULL; + +UPDATE addon_rates ar SET + hourly_price = COALESCE((s.parsed->'elasticsearch'->>'hourly')::decimal, 0), + monthly_price = COALESCE((s.parsed->'elasticsearch'->>'monthly')::decimal, 0), + yearly_price = COALESCE((s.parsed->'elasticsearch'->>'yearly')::decimal, 0) +FROM ( + SELECT value::jsonb AS parsed + FROM platform_settings + WHERE key = 'optional_services_pricing_toman' + LIMIT 1 +) s +WHERE ar.resource_type = 'elasticsearch_addon' AND s.parsed IS NOT NULL; + +UPDATE addon_rates ar SET + monthly_price = COALESCE(s.price, 0) +FROM ( + SELECT value::decimal AS price + FROM platform_settings + WHERE key = 'custom_domain_monthly_price_toman' + LIMIT 1 +) s +WHERE ar.resource_type = 'custom_domain_addon' AND s.price IS NOT NULL; + +-- Default runtime rows for nodejs, laravel, wordpress +INSERT INTO pricing_rates (runtime, resource_type, hourly_price, monthly_price, yearly_price) +SELECT r.runtime, t.resource_type, 0, 0, 0 +FROM (VALUES ('nodejs'), ('laravel'), ('wordpress')) AS r(runtime) +CROSS JOIN ( + VALUES + ('base_fee'), + ('cpu_per_core'), + ('memory_per_gb'), + ('storage_per_gb'), + ('database_addon') +) AS t(resource_type) +ON CONFLICT (runtime, resource_type) DO NOTHING; diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index 623969d..897bc1e 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -21,15 +21,15 @@ import { AppLifecycleService } from '../lifecycle/app-lifecycle.service'; import { ApplicationsService } from '../applications/applications.service'; import { KubernetesService } from '../kubernetes/kubernetes.service'; import { - CreateServicePlanDto, - UpdateServicePlanDto, ChargeWalletDto, CalculateCostDto, CalculateDeployCostDto, + SetOptionalServicesPricingDto, RenewApplicationDto, UpgradeResourcesDto, CalculateUpgradeCostDto, } from './dto/billing.dto'; +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 } from '../common/enums'; @@ -49,43 +49,20 @@ export class BillingController { private readonly kubernetesService: KubernetesService, ) {} - // ─── Service Plans (Admin) ──────────────────────────────────────── + // ─── Pricing catalog (Admin) ────────────────────────────────────── - @Post('plans') + @Get('pricing-catalog') @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'Create a new service plan (Admin)' }) - async createPlan(@Body() dto: CreateServicePlanDto) { - return this.billingService.createPlan(dto); + @ApiOperation({ summary: 'Get usage-based pricing catalog (Admin)' }) + async getPricingCatalog() { + return this.billingService.getPricingCatalog(); } - @Get('plans') - @ApiOperation({ summary: 'List all service plans' }) - async findAllPlans(@Request() req: any) { - if (req.user.role === UserRole.ADMIN) { - return this.billingService.findAllPlans(); - } - return this.billingService.findActivePlans(); - } - - @Get('plans/:id') - @ApiOperation({ summary: 'Get service plan details' }) - async findPlan(@Param('id') id: string) { - return this.billingService.findPlan(id); - } - - @Patch('plans/:id') + @Patch('pricing-catalog') @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'Update a service plan (Admin)' }) - async updatePlan(@Param('id') id: string, @Body() dto: UpdateServicePlanDto) { - return this.billingService.updatePlan(id, dto); - } - - @Delete('plans/:id') - @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'Delete a service plan (Admin)' }) - async deletePlan(@Param('id') id: string) { - await this.billingService.deletePlan(id); - return { message: 'Plan deleted' }; + @ApiOperation({ summary: 'Update usage-based pricing catalog (Admin)' }) + async updatePricingCatalog(@Body() dto: UpdatePricingCatalogDto) { + return this.billingService.updatePricingCatalog(dto); } // ─── Cost Calculation ───────────────────────────────────────────── @@ -125,6 +102,19 @@ export class BillingController { return this.billingService.setCustomDomainPrice(body.monthlyPrice); } + @Get('settings/optional-services') + @ApiOperation({ summary: 'Get global optional service addon prices (Redis, RabbitMQ, Elasticsearch)' }) + async getOptionalServicesPricing() { + return this.billingService.getOptionalServicesPricing(); + } + + @Patch('settings/optional-services') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Set global optional service addon prices (Admin)' }) + async setOptionalServicesPricing(@Body() dto: SetOptionalServicesPricingDto) { + return this.billingService.setOptionalServicesPricing(dto); + } + // ─── Wallet (User) ─────────────────────────────────────────────── @Get('wallet') @@ -157,7 +147,7 @@ export class BillingController { async payForApplication( @Request() req: any, @Param('applicationId') applicationId: string, - @Body() body: { planId?: string; cycle: string; amount?: number }, + @Body() body: { cycle: string }, ) { const cycle = body.cycle as BillingCycle; if (!Object.values(BillingCycle).includes(cycle)) { @@ -166,32 +156,6 @@ export class BillingController { const app = await this.applicationsService.findOne(applicationId, req.user.id); - let amount = body.amount; - let planId = body.planId || app.planId || ''; - - if (amount === undefined || amount === null) { - if (!body.planId) { - throw new BadRequestException('planId or amount is required'); - } - const plan = await this.billingService.findPlan(body.planId); - const costs = await this.billingService.calculateCostForApp({ - 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, - }); - amount = cycle === BillingCycle.HOURLY ? costs.hourly - : cycle === BillingCycle.MONTHLY ? costs.monthly - : costs.yearly; - planId = body.planId; - } - const payment = await this.billingService.resolveAppPayment( req.user.id, app, @@ -211,7 +175,6 @@ export class BillingController { const activated = await this.lifecycleService.activateApp( applicationId, cycle, - planId, payment.planExpiresAt, ); @@ -348,7 +311,7 @@ export class BillingController { ); // Activate the application - const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle, app.planId || ''); + const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle); return { success: true, @@ -381,7 +344,7 @@ export class BillingController { if (body.bypassPayment) { // Direct activation without payment (for special cases, support, etc.) - const renewedApp = await this.lifecycleService.activateApp(app.id, cycle, app.planId || ''); + const renewedApp = await this.lifecycleService.activateApp(app.id, cycle); return { success: true, bypassedPayment: true, @@ -409,7 +372,7 @@ export class BillingController { app.id, ); - const renewedApp = await this.lifecycleService.activateApp(app.id, cycle, app.planId || ''); + const renewedApp = await this.lifecycleService.activateApp(app.id, cycle); return { success: true, diff --git a/backend/src/billing/billing.module.ts b/backend/src/billing/billing.module.ts index 1d82415..e3e2739 100644 --- a/backend/src/billing/billing.module.ts +++ b/backend/src/billing/billing.module.ts @@ -2,11 +2,11 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BillingService } from './billing.service'; import { BillingController } from './billing.controller'; -import { ServicePlan } from './entities/service-plan.entity'; -import { PricingRule } from './entities/pricing-rule.entity'; +import { PricingCatalogService } from './pricing-catalog.service'; +import { PricingRate } from './entities/pricing-rate.entity'; +import { AddonRate } from './entities/addon-rate.entity'; import { Wallet } from './entities/wallet.entity'; import { WalletTransaction } from './entities/wallet-transaction.entity'; -import { PlatformSetting } from './entities/platform-setting.entity'; import { ResourceCredit } from './entities/resource-credit.entity'; import { LifecycleModule } from '../lifecycle/lifecycle.module'; import { ApplicationsModule } from '../applications/applications.module'; @@ -14,13 +14,19 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module'; @Module({ imports: [ - TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting, ResourceCredit]), + TypeOrmModule.forFeature([ + PricingRate, + AddonRate, + Wallet, + WalletTransaction, + ResourceCredit, + ]), forwardRef(() => LifecycleModule), forwardRef(() => ApplicationsModule), forwardRef(() => KubernetesModule), ], controllers: [BillingController], - providers: [BillingService], - exports: [BillingService], + providers: [BillingService, PricingCatalogService], + exports: [BillingService, PricingCatalogService], }) export class BillingModule {} diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index 0a413ac..f1264dc 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -1,108 +1,41 @@ -import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { ServicePlan } from './entities/service-plan.entity'; -import { PricingRule } from './entities/pricing-rule.entity'; +import { Repository, IsNull, MoreThan } from 'typeorm'; import { Wallet } from './entities/wallet.entity'; import { WalletTransaction } from './entities/wallet-transaction.entity'; -import { PlatformSetting } from './entities/platform-setting.entity'; -import { TransactionType, BillingCycle, PricingResourceType, DatabaseType } from '../common/enums'; -import { - CreateServicePlanDto, - UpdateServicePlanDto, - CalculateCostDto, - UpgradeResourcesDto, -} from './dto/billing.dto'; +import { TransactionType, BillingCycle, DatabaseType } from '../common/enums'; +import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto'; +import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto'; import { Application } from '../applications/entities/application.entity'; import { ResourceCredit } from './entities/resource-credit.entity'; -import { IsNull, MoreThan } from 'typeorm'; +import { PricingCatalogService } from './pricing-catalog.service'; @Injectable() export class BillingService { private readonly logger = new Logger(BillingService.name); constructor( - @InjectRepository(ServicePlan) private planRepo: Repository, - @InjectRepository(PricingRule) private ruleRepo: Repository, + private readonly pricingCatalog: PricingCatalogService, @InjectRepository(Wallet) private walletRepo: Repository, @InjectRepository(WalletTransaction) private txRepo: Repository, - @InjectRepository(PlatformSetting) private settingsRepo: Repository, @InjectRepository(ResourceCredit) private creditRepo: Repository, ) {} - // ─── Service Plans ──────────────────────────────────────────────── + // ─── Pricing catalog (Admin) ────────────────────────────────────── - async createPlan(dto: CreateServicePlanDto): Promise { - const plan = this.planRepo.create({ - name: dto.name, - runtime: dto.runtime, - description: dto.description, - billingCycle: dto.billingCycle, - }); - const saved = await this.planRepo.save(plan); - - // Create pricing rules - const rules = dto.pricingRules.map((r) => - this.ruleRepo.create({ ...r, planId: saved.id }), - ); - await this.ruleRepo.save(rules); - - return this.planRepo.findOne({ where: { id: saved.id }, relations: ['pricingRules'] }) as Promise; + getPricingCatalog() { + return this.pricingCatalog.getCatalog(); } - async updatePlan(id: string, dto: UpdateServicePlanDto): Promise { - const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] }); - if (!plan) throw new NotFoundException('Plan not found'); - - if (dto.name !== undefined) plan.name = dto.name; - if (dto.runtime !== undefined) plan.runtime = dto.runtime; - if (dto.description !== undefined) plan.description = dto.description; - if (dto.billingCycle !== undefined) plan.billingCycle = dto.billingCycle; - if (dto.isActive !== undefined) plan.isActive = dto.isActive; - - await this.planRepo.save(plan); - - // If pricing rules are provided, replace them - if (dto.pricingRules) { - await this.ruleRepo.delete({ planId: id }); - const rules = dto.pricingRules.map((r) => - this.ruleRepo.create({ ...r, planId: id }), - ); - await this.ruleRepo.save(rules); - } - - return this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] }) as Promise; - } - - async deletePlan(id: string): Promise { - const plan = await this.planRepo.findOne({ where: { id } }); - if (!plan) throw new NotFoundException('Plan not found'); - await this.planRepo.remove(plan); - } - - async findAllPlans(): Promise { - return this.planRepo.find({ relations: ['pricingRules'], order: { createdAt: 'DESC' } }); - } - - async findActivePlans(): Promise { - return this.planRepo.find({ - where: { isActive: true }, - relations: ['pricingRules'], - order: { createdAt: 'DESC' }, - }); - } - - async findPlan(id: string): Promise { - const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] }); - if (!plan) throw new NotFoundException('Plan not found'); - return plan; + updatePricingCatalog(dto: UpdatePricingCatalogDto) { + return this.pricingCatalog.updateCatalog(dto); } // ─── Cost Calculation ───────────────────────────────────────────── /** - * Calculate cost for an application config against all active plans. - * Returns breakdown for each billing cycle (hourly, monthly, yearly). + * Calculate cost from the usage pricing catalog. + * Each billing cycle uses its own price column — no cross-cycle conversion. */ async calculateCost(dto: CalculateCostDto): Promise<{ hourly: number; @@ -110,165 +43,29 @@ export class BillingService { yearly: number; breakdown: { label: string; hourly: number; monthly: number; yearly: number }[]; }> { - // Only use active plans that match the requested runtime - const plans = await this.planRepo.find({ - where: { isActive: true, runtime: dto.runtime as any }, - relations: ['pricingRules'], - }); - - if (plans.length === 0) { - return { hourly: 0, monthly: 0, yearly: 0, breakdown: [] }; - } - - // Gather all active pricing rules across all plans, grouped by cycle - const hourlyRules: PricingRule[] = []; - const monthlyRules: PricingRule[] = []; - const yearlyRules: PricingRule[] = []; - - for (const plan of plans) { - switch (plan.billingCycle) { - case BillingCycle.HOURLY: hourlyRules.push(...plan.pricingRules); break; - case BillingCycle.MONTHLY: monthlyRules.push(...plan.pricingRules); break; - case BillingCycle.YEARLY: yearlyRules.push(...plan.pricingRules); break; - } - } - - // Use the first available set of rules, or compute cross-conversions - const rules = hourlyRules.length > 0 ? hourlyRules : monthlyRules.length > 0 ? monthlyRules : yearlyRules; - const baseCycle = hourlyRules.length > 0 ? 'hourly' : monthlyRules.length > 0 ? 'monthly' : 'yearly'; - - // Parse resource values - const cpuCores = this.parseCpuToCores(dto.cpuLimit); - const memoryGb = this.parseMemoryToGb(dto.memoryLimit); - const dbStorageGb = dto.dbStorageSize ? parseFloat(dto.dbStorageSize.replace('Gi', '')) || 0 : 0; - const appStorageGb = dto.appStorageSize ? parseFloat(dto.appStorageSize.replace('Gi', '')) || 0 : 0; - const totalStorageGb = dbStorageGb + appStorageGb; - const hasDatabase = dto.databaseType !== 'none'; - const replicas = dto.replicas || 1; - const hasRedis = dto.enableRedis || false; - const hasRabbitmq = dto.enableRabbitmq || false; - const hasElasticsearch = dto.enableElasticsearch || false; - const hasCustomDomain = dto.enableCustomDomain || false; - - const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = []; - let totalBase = 0; - - for (const rule of rules) { - let cost = 0; - let label = ''; - - switch (rule.resourceType) { - case PricingResourceType.BASE_FEE: - cost = Number(rule.unitPrice); - label = 'Base fee'; - break; - case PricingResourceType.CPU_PER_CORE: - cost = cpuCores * replicas * Number(rule.unitPrice); - label = `CPU (${(cpuCores * replicas).toFixed(2)} core)`; - break; - case PricingResourceType.MEMORY_PER_GB: - cost = memoryGb * replicas * Number(rule.unitPrice); - label = `Memory (${(memoryGb * replicas).toFixed(2)} GB)`; - break; - case PricingResourceType.STORAGE_PER_GB: - cost = totalStorageGb * Number(rule.unitPrice); - label = `Storage (${totalStorageGb} GB)`; - break; - case PricingResourceType.DATABASE_ADDON: - cost = hasDatabase ? Number(rule.unitPrice) : 0; - label = 'Database addon'; - break; - case PricingResourceType.REDIS_ADDON: - cost = hasRedis ? Number(rule.unitPrice) : 0; - label = 'Redis addon'; - break; - case PricingResourceType.RABBITMQ_ADDON: - cost = hasRabbitmq ? Number(rule.unitPrice) : 0; - label = 'RabbitMQ addon'; - break; - case PricingResourceType.ELASTICSEARCH_ADDON: - cost = hasElasticsearch ? Number(rule.unitPrice) : 0; - label = 'Elasticsearch addon'; - break; - case PricingResourceType.CUSTOM_DOMAIN_ADDON: - cost = hasCustomDomain ? Number(rule.unitPrice) : 0; - label = 'Custom domain + SSL'; - break; - } - - if (cost > 0) { - const hourly = baseCycle === 'hourly' ? cost : baseCycle === 'monthly' ? cost / 720 : cost / 8640; - const monthly = baseCycle === 'monthly' ? cost : baseCycle === 'hourly' ? cost * 720 : cost / 12; - const yearly = baseCycle === 'yearly' ? cost : baseCycle === 'monthly' ? cost * 12 : cost * 8640; - - breakdown.push({ label, hourly: Math.round(hourly), monthly: Math.round(monthly), yearly: Math.round(yearly) }); - totalBase += cost; - } - } - - // If custom domain is enabled but no CUSTOM_DOMAIN_ADDON rule exists, use PlatformSetting price - if (hasCustomDomain && !breakdown.some((b) => b.label === 'Custom domain + SSL')) { - const setting = await this.settingsRepo.findOne({ where: { key: 'custom_domain_monthly_price_toman' } }); - const monthlyPrice = setting ? Number(setting.value) : 0; - if (monthlyPrice > 0) { - const hourly = Math.round(monthlyPrice / 720); - const yearly = monthlyPrice * 12; - breakdown.push({ label: 'Custom domain + SSL', hourly, monthly: monthlyPrice, yearly }); - totalBase += baseCycle === 'monthly' ? monthlyPrice : baseCycle === 'hourly' ? hourly : yearly; - } - } - - const hourlyTotal = baseCycle === 'hourly' ? totalBase : baseCycle === 'monthly' ? totalBase / 720 : totalBase / 8640; - const monthlyTotal = baseCycle === 'monthly' ? totalBase : baseCycle === 'hourly' ? totalBase * 720 : totalBase / 12; - const yearlyTotal = baseCycle === 'yearly' ? totalBase : baseCycle === 'monthly' ? totalBase * 12 : totalBase * 8640; - - return { - hourly: Math.round(hourlyTotal), - monthly: Math.round(monthlyTotal), - yearly: Math.round(yearlyTotal), - breakdown, - }; + return this.pricingCatalog.computeTotalsFromDb(dto); } - private parseCpuToCores(cpu: string): number { - if (!cpu) return 0; - if (cpu.endsWith('m')) return parseFloat(cpu) / 1000; - return parseFloat(cpu) || 0; + // ─── Optional services & custom domain (delegates to catalog) ───── + + getOptionalServicesPricing() { + return this.pricingCatalog.getOptionalServicesPricing(); } - private parseMemoryToGb(memory: string): number { - if (!memory) return 0; - if (memory.endsWith('Gi')) return parseFloat(memory); - if (memory.endsWith('Mi')) return parseFloat(memory) / 1024; - if (memory.endsWith('Ki')) return parseFloat(memory) / (1024 * 1024); - return parseFloat(memory) / (1024 * 1024 * 1024); + setOptionalServicesPricing(pricing: { + redis: { hourly: number; monthly: number; yearly: number }; + rabbitmq: { hourly: number; monthly: number; yearly: number }; + elasticsearch: { hourly: number; monthly: number; yearly: number }; + }) { + return this.pricingCatalog.setOptionalServicesPricing(pricing); } - // ─── Custom Domain Pricing (PlatformSetting) ──────────────────── - - async getCustomDomainPrice(): Promise<{ monthlyPrice: number }> { - const setting = await this.settingsRepo.findOne({ - where: { key: 'custom_domain_monthly_price_toman' }, - }); - return { monthlyPrice: setting ? Number(setting.value) : 0 }; + getCustomDomainPrice() { + return this.pricingCatalog.getCustomDomainPrice(); } - async setCustomDomainPrice(monthlyPrice: number): Promise<{ monthlyPrice: number }> { - let setting = await this.settingsRepo.findOne({ - where: { key: 'custom_domain_monthly_price_toman' }, - }); - if (setting) { - setting.value = String(monthlyPrice); - } else { - setting = this.settingsRepo.create({ - key: 'custom_domain_monthly_price_toman', - value: String(monthlyPrice), - description: 'Monthly price for custom domain addon (Toman)', - }); - } - await this.settingsRepo.save(setting); - this.logger.log(`Custom domain monthly price updated: ${monthlyPrice} Toman`); - return { monthlyPrice }; + setCustomDomainPrice(monthlyPrice: number) { + return this.pricingCatalog.setCustomDomainPrice(monthlyPrice); } // ─── Wallet ─────────────────────────────────────────────────────── @@ -604,11 +401,15 @@ export class BillingService { ) { return false; } - if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) { + if ( + this.pricingCatalog.parseCpuToCores(config.cpuLimit) > + this.pricingCatalog.parseCpuToCores(credit.cpuLimit) + ) { return false; } if ( - this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit) + this.pricingCatalog.parseMemoryToGb(config.memoryLimit) > + this.pricingCatalog.parseMemoryToGb(credit.memoryLimit) ) { return false; } @@ -754,8 +555,12 @@ export class BillingService { for (const hint of labelHints) { const afterLine = after.breakdown.find((b) => b.label === hint); const beforeLine = before.breakdown.find((b) => b.label === hint); - const afterAmt = afterLine ? this.amountForCycle(afterLine, cycle) : 0; - const beforeAmt = beforeLine ? this.amountForCycle(beforeLine, cycle) : 0; + const afterAmt = afterLine + ? this.pricingCatalog.amountForCycleFromLine(afterLine, cycle) + : 0; + const beforeAmt = beforeLine + ? this.pricingCatalog.amountForCycleFromLine(beforeLine, cycle) + : 0; sum += Math.max(0, afterAmt - beforeAmt); } return sum; @@ -775,7 +580,10 @@ export class BillingService { const items: { label: string; amount: number; fullPeriodAmount?: number }[] = []; const baseline = this.creditBaselineDto(credit); - if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) { + if ( + this.pricingCatalog.parseCpuToCores(config.cpuLimit) > + this.pricingCatalog.parseCpuToCores(credit.cpuLimit) + ) { await this.addExtraLineProrated( items, baseline, @@ -785,7 +593,10 @@ export class BillingService { ); } - if (this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit)) { + if ( + this.pricingCatalog.parseMemoryToGb(config.memoryLimit) > + this.pricingCatalog.parseMemoryToGb(credit.memoryLimit) + ) { await this.addExtraLineProrated( items, baseline, diff --git a/backend/src/billing/dto/billing.dto.ts b/backend/src/billing/dto/billing.dto.ts index 5a3b417..c627aaa 100644 --- a/backend/src/billing/dto/billing.dto.ts +++ b/backend/src/billing/dto/billing.dto.ts @@ -197,3 +197,39 @@ export class UpgradeResourcesDto { export class CalculateUpgradeCostDto extends UpgradeResourcesDto {} +// ─── Platform optional services pricing (Admin) ───────────────────── + +export class OptionalServiceCyclePricesDto { + @ApiProperty({ example: 500 }) + @IsNumber() + @Min(0) + hourly: number; + + @ApiProperty({ example: 50000 }) + @IsNumber() + @Min(0) + monthly: number; + + @ApiProperty({ example: 500000 }) + @IsNumber() + @Min(0) + yearly: number; +} + +export class SetOptionalServicesPricingDto { + @ApiProperty({ type: OptionalServiceCyclePricesDto }) + @ValidateNested() + @Type(() => OptionalServiceCyclePricesDto) + redis: OptionalServiceCyclePricesDto; + + @ApiProperty({ type: OptionalServiceCyclePricesDto }) + @ValidateNested() + @Type(() => OptionalServiceCyclePricesDto) + rabbitmq: OptionalServiceCyclePricesDto; + + @ApiProperty({ type: OptionalServiceCyclePricesDto }) + @ValidateNested() + @Type(() => OptionalServiceCyclePricesDto) + elasticsearch: OptionalServiceCyclePricesDto; +} + diff --git a/backend/src/billing/dto/pricing-catalog.dto.ts b/backend/src/billing/dto/pricing-catalog.dto.ts new file mode 100644 index 0000000..3a667ad --- /dev/null +++ b/backend/src/billing/dto/pricing-catalog.dto.ts @@ -0,0 +1,58 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsObject, + IsOptional, + Min, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { AppRuntime, PricingResourceType } from '../../common/enums'; + +export class PricingRateRowDto { + @ApiProperty({ enum: PricingResourceType }) + @IsEnum(PricingResourceType) + resourceType: PricingResourceType; + + @ApiProperty({ example: 500 }) + @IsNumber() + @Min(0) + hourlyPrice: number; + + @ApiProperty({ example: 50000 }) + @IsNumber() + @Min(0) + monthlyPrice: number; + + @ApiProperty({ example: 500000 }) + @IsNumber() + @Min(0) + yearlyPrice: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdatePricingCatalogDto { + @ApiPropertyOptional({ + description: 'Per-runtime pricing rows keyed by runtime', + example: { + nodejs: [{ resourceType: 'base_fee', hourlyPrice: 0, monthlyPrice: 10000, yearlyPrice: 100000 }], + }, + }) + @IsOptional() + @IsObject() + runtimes?: Partial>; + + @ApiPropertyOptional({ type: [PricingRateRowDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PricingRateRowDto) + addons?: PricingRateRowDto[]; +} diff --git a/backend/src/billing/entities/addon-rate.entity.ts b/backend/src/billing/entities/addon-rate.entity.ts new file mode 100644 index 0000000..ba3562b --- /dev/null +++ b/backend/src/billing/entities/addon-rate.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { PricingResourceType } from '../../common/enums'; + +@Entity('addon_rates') +export class AddonRate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: PricingResourceType, unique: true }) + resourceType: PricingResourceType; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + hourlyPrice: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + monthlyPrice: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + yearlyPrice: number; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/billing/entities/pricing-rate.entity.ts b/backend/src/billing/entities/pricing-rate.entity.ts new file mode 100644 index 0000000..b775fcb --- /dev/null +++ b/backend/src/billing/entities/pricing-rate.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Unique, +} from 'typeorm'; +import { AppRuntime, PricingResourceType } from '../../common/enums'; + +@Entity('pricing_rates') +@Unique(['runtime', 'resourceType']) +export class PricingRate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: AppRuntime }) + runtime: AppRuntime; + + @Column({ type: 'enum', enum: PricingResourceType }) + resourceType: PricingResourceType; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + hourlyPrice: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + monthlyPrice: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + yearlyPrice: number; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/billing/pricing-catalog.constants.ts b/backend/src/billing/pricing-catalog.constants.ts new file mode 100644 index 0000000..e947151 --- /dev/null +++ b/backend/src/billing/pricing-catalog.constants.ts @@ -0,0 +1,34 @@ +import { AppRuntime, PricingResourceType } from '../common/enums'; + +export const BILLING_RUNTIMES: AppRuntime[] = [ + AppRuntime.NODEJS, + AppRuntime.LARAVEL, + AppRuntime.WORDPRESS, +]; + +export const RUNTIME_PRICING_RESOURCES: PricingResourceType[] = [ + PricingResourceType.BASE_FEE, + PricingResourceType.CPU_PER_CORE, + PricingResourceType.MEMORY_PER_GB, + PricingResourceType.STORAGE_PER_GB, + PricingResourceType.DATABASE_ADDON, +]; + +export const ADDON_PRICING_RESOURCES: PricingResourceType[] = [ + PricingResourceType.REDIS_ADDON, + PricingResourceType.RABBITMQ_ADDON, + PricingResourceType.ELASTICSEARCH_ADDON, + PricingResourceType.CUSTOM_DOMAIN_ADDON, +]; + +export const RESOURCE_LABELS: Record = { + [PricingResourceType.BASE_FEE]: 'Base fee', + [PricingResourceType.CPU_PER_CORE]: 'CPU (per core)', + [PricingResourceType.MEMORY_PER_GB]: 'Memory (per GB)', + [PricingResourceType.STORAGE_PER_GB]: 'Storage (per GB)', + [PricingResourceType.DATABASE_ADDON]: 'Database addon', + [PricingResourceType.REDIS_ADDON]: 'Redis addon', + [PricingResourceType.RABBITMQ_ADDON]: 'RabbitMQ addon', + [PricingResourceType.ELASTICSEARCH_ADDON]: 'Elasticsearch addon', + [PricingResourceType.CUSTOM_DOMAIN_ADDON]: 'Custom domain + SSL', +}; diff --git a/backend/src/billing/pricing-catalog.service.spec.ts b/backend/src/billing/pricing-catalog.service.spec.ts new file mode 100644 index 0000000..f199ada --- /dev/null +++ b/backend/src/billing/pricing-catalog.service.spec.ts @@ -0,0 +1,124 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { PricingCatalogService } from './pricing-catalog.service'; +import { PricingRate } from './entities/pricing-rate.entity'; +import { AddonRate } from './entities/addon-rate.entity'; +import { + AppRuntime, + BillingCycle, + DatabaseType, + PricingResourceType, +} from '../common/enums'; +import { CalculateCostDto } from './dto/billing.dto'; + +describe('PricingCatalogService', () => { + let service: PricingCatalogService; + + const rateRepo = { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn().mockImplementation((x) => Promise.resolve(x)), + create: jest.fn().mockImplementation((x) => x), + }; + + const addonRepo = { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn().mockImplementation((x) => Promise.resolve(x)), + create: jest.fn().mockImplementation((x) => x), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PricingCatalogService, + { provide: getRepositoryToken(PricingRate), useValue: rateRepo }, + { provide: getRepositoryToken(AddonRate), useValue: addonRepo }, + ], + }).compile(); + + service = module.get(PricingCatalogService); + }); + + const baseDto = (): CalculateCostDto => ({ + runtime: 'nodejs', + databaseType: DatabaseType.NONE, + cpuLimit: '500m', + memoryLimit: '512Mi', + replicas: 1, + }); + + it('parses CPU millicores to cores', () => { + expect(service.parseCpuToCores('500m')).toBe(0.5); + expect(service.parseCpuToCores('2')).toBe(2); + }); + + it('computes CPU line with cycle-native prices (no conversion)', () => { + const rates = [ + { + runtime: AppRuntime.NODEJS, + resourceType: PricingResourceType.CPU_PER_CORE, + hourlyPrice: 100, + monthlyPrice: 5000, + yearlyPrice: 50000, + isActive: true, + }, + ] as PricingRate[]; + + const result = service.computeTotalsWithRates(baseDto(), rates, []); + expect(result.hourly).toBe(50); + expect(result.monthly).toBe(2500); + expect(result.yearly).toBe(25000); + expect(result.breakdown[0].label).toContain('CPU'); + }); + + it('includes redis addon only when enabled', () => { + const addons = [ + { + resourceType: PricingResourceType.REDIS_ADDON, + hourlyPrice: 10, + monthlyPrice: 100, + yearlyPrice: 1000, + isActive: true, + }, + ] as AddonRate[]; + + const without = service.computeTotalsWithRates(baseDto(), [], addons); + expect(without.monthly).toBe(0); + + const withRedis = service.computeTotalsWithRates( + { ...baseDto(), enableRedis: true }, + [], + addons, + ); + expect(withRedis.monthly).toBe(100); + expect(withRedis.yearly).toBe(1000); + expect(withRedis.hourly).toBe(10); + }); + + it('yearly deploy uses yearly column not monthly * 12', () => { + const rates = [ + { + runtime: AppRuntime.NODEJS, + resourceType: PricingResourceType.BASE_FEE, + hourlyPrice: 1, + monthlyPrice: 100, + yearlyPrice: 999, + isActive: true, + }, + ] as PricingRate[]; + + const result = service.computeTotalsWithRates(baseDto(), rates, []); + expect(result.yearly).toBe(999); + expect(result.monthly).toBe(100); + expect(result.yearly).not.toBe(result.monthly * 12); + }); + + it('amountForCycleFromLine picks the correct column', () => { + const line = { label: 'Test', hourly: 1, monthly: 2, yearly: 3 }; + expect(service.amountForCycleFromLine(line, BillingCycle.HOURLY)).toBe(1); + expect(service.amountForCycleFromLine(line, BillingCycle.MONTHLY)).toBe(2); + expect(service.amountForCycleFromLine(line, BillingCycle.YEARLY)).toBe(3); + }); +}); diff --git a/backend/src/billing/pricing-catalog.service.ts b/backend/src/billing/pricing-catalog.service.ts new file mode 100644 index 0000000..d10c67d --- /dev/null +++ b/backend/src/billing/pricing-catalog.service.ts @@ -0,0 +1,426 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PricingRate } from './entities/pricing-rate.entity'; +import { AddonRate } from './entities/addon-rate.entity'; +import { + AppRuntime, + BillingCycle, + DatabaseType, + PricingResourceType, +} from '../common/enums'; +import { CalculateCostDto } from './dto/billing.dto'; +import { + ADDON_PRICING_RESOURCES, + BILLING_RUNTIMES, + RESOURCE_LABELS, + RUNTIME_PRICING_RESOURCES, +} from './pricing-catalog.constants'; +import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto'; + +export interface CyclePrices { + hourly: number; + monthly: number; + yearly: number; +} + +export interface PricingRateRow { + resourceType: PricingResourceType; + hourlyPrice: number; + monthlyPrice: number; + yearlyPrice: number; + isActive?: boolean; +} + +export interface PricingCatalogResponse { + runtimes: Record; + addons: PricingRateRow[]; +} + +export interface CostBreakdownLine { + label: string; + hourly: number; + monthly: number; + yearly: number; +} + +@Injectable() +export class PricingCatalogService implements OnModuleInit { + private readonly logger = new Logger(PricingCatalogService.name); + + constructor( + @InjectRepository(PricingRate) private readonly rateRepo: Repository, + @InjectRepository(AddonRate) private readonly addonRepo: Repository, + ) {} + + async onModuleInit() { + await this.ensureDefaults(); + } + + async ensureDefaults() { + for (const runtime of BILLING_RUNTIMES) { + for (const resourceType of RUNTIME_PRICING_RESOURCES) { + const existing = await this.rateRepo.findOne({ where: { runtime, resourceType } }); + if (!existing) { + await this.rateRepo.save( + this.rateRepo.create({ + runtime, + resourceType, + hourlyPrice: 0, + monthlyPrice: 0, + yearlyPrice: 0, + }), + ); + } + } + } + for (const resourceType of ADDON_PRICING_RESOURCES) { + const existing = await this.addonRepo.findOne({ where: { resourceType } }); + if (!existing) { + await this.addonRepo.save( + this.addonRepo.create({ + resourceType, + hourlyPrice: 0, + monthlyPrice: 0, + yearlyPrice: 0, + }), + ); + } + } + } + + async getCatalog(): Promise { + const rates = await this.rateRepo.find({ order: { runtime: 'ASC', resourceType: 'ASC' } }); + const addons = await this.addonRepo.find({ order: { resourceType: 'ASC' } }); + + const runtimes = {} as Record; + for (const runtime of BILLING_RUNTIMES) { + runtimes[runtime] = RUNTIME_PRICING_RESOURCES.map((resourceType) => { + const row = rates.find((r) => r.runtime === runtime && r.resourceType === resourceType); + return this.toRateRow(resourceType, row); + }); + } + + return { + runtimes, + addons: ADDON_PRICING_RESOURCES.map((resourceType) => { + const row = addons.find((a) => a.resourceType === resourceType); + return this.toRateRow(resourceType, row); + }), + }; + } + + async updateCatalog(dto: UpdatePricingCatalogDto): Promise { + if (dto.runtimes) { + for (const [runtimeKey, rows] of Object.entries(dto.runtimes)) { + const runtime = runtimeKey as AppRuntime; + if (!BILLING_RUNTIMES.includes(runtime)) continue; + for (const row of rows) { + await this.upsertRuntimeRate(runtime, row); + } + } + } + if (dto.addons) { + for (const row of dto.addons) { + await this.upsertAddonRate(row); + } + } + return this.getCatalog(); + } + + async getRatesForRuntime(runtime: AppRuntime): Promise { + return this.rateRepo.find({ + where: { runtime, isActive: true }, + }); + } + + async getAddonRates(): Promise { + return this.addonRepo.find({ where: { isActive: true } }); + } + + computeTotals(dto: CalculateCostDto): { + hourly: number; + monthly: number; + yearly: number; + breakdown: CostBreakdownLine[]; + } { + const lines = this.computeLineItemsSync(dto); + const hourly = lines.reduce((s, l) => s + l.hourly, 0); + const monthly = lines.reduce((s, l) => s + l.monthly, 0); + const yearly = lines.reduce((s, l) => s + l.yearly, 0); + return { + hourly: Math.round(hourly), + monthly: Math.round(monthly), + yearly: Math.round(yearly), + breakdown: lines, + }; + } + + async computeTotalsFromDb(dto: CalculateCostDto) { + const runtime = dto.runtime as AppRuntime; + const rates = await this.getRatesForRuntime(runtime); + const addons = await this.getAddonRates(); + return this.computeTotalsWithRates(dto, rates, addons); + } + + computeTotalsWithRates( + dto: CalculateCostDto, + rates: PricingRate[], + addons: AddonRate[], + ) { + const lines = this.buildLines(dto, rates, addons); + const hourly = lines.reduce((s, l) => s + l.hourly, 0); + const monthly = lines.reduce((s, l) => s + l.monthly, 0); + const yearly = lines.reduce((s, l) => s + l.yearly, 0); + return { + hourly: Math.round(hourly), + monthly: Math.round(monthly), + yearly: Math.round(yearly), + breakdown: lines, + }; + } + + amountForCycleFromLine(line: CostBreakdownLine, cycle: BillingCycle): number { + switch (cycle) { + case BillingCycle.HOURLY: + return line.hourly; + case BillingCycle.MONTHLY: + return line.monthly; + case BillingCycle.YEARLY: + return line.yearly; + default: + return line.monthly; + } + } + + private computeLineItemsSync(dto: CalculateCostDto): CostBreakdownLine[] { + return this.buildLines(dto, [], []); + } + + private buildLines( + dto: CalculateCostDto, + rates: PricingRate[], + addons: AddonRate[], + ): CostBreakdownLine[] { + const lines: CostBreakdownLine[] = []; + const quantities = this.getQuantities(dto); + + for (const rate of rates) { + 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); + } + + for (const addon of addons) { + const qty = quantities.get(addon.resourceType) ?? 0; + if (qty <= 0) continue; + const line = this.lineFromPrices( + RESOURCE_LABELS[addon.resourceType], + qty, + Number(addon.hourlyPrice), + Number(addon.monthlyPrice), + Number(addon.yearlyPrice), + addon.resourceType, + dto, + ); + if (line) lines.push(line); + } + + return lines; + } + + private lineFromPrices( + baseLabel: string, + quantity: number, + hourlyUnit: number, + monthlyUnit: number, + yearlyUnit: number, + resourceType: PricingResourceType, + dto: CalculateCostDto, + ): CostBreakdownLine | null { + const hourly = Math.round(quantity * hourlyUnit); + const monthly = Math.round(quantity * monthlyUnit); + const yearly = Math.round(quantity * yearlyUnit); + if (hourly <= 0 && monthly <= 0 && yearly <= 0) return null; + + const label = this.describeLine(baseLabel, resourceType, quantity, dto); + return { label, hourly, monthly, yearly }; + } + + private describeLine( + baseLabel: string, + resourceType: PricingResourceType, + quantity: number, + dto: CalculateCostDto, + ): string { + switch (resourceType) { + case PricingResourceType.CPU_PER_CORE: + return `CPU (${quantity.toFixed(2)} core)`; + case PricingResourceType.MEMORY_PER_GB: + return `Memory (${quantity.toFixed(2)} GB)`; + case PricingResourceType.STORAGE_PER_GB: + return `Storage (${quantity} GB)`; + default: + return baseLabel; + } + } + + getQuantities(dto: CalculateCostDto): Map { + const cpuCores = this.parseCpuToCores(dto.cpuLimit); + const memoryGb = this.parseMemoryToGb(dto.memoryLimit); + const replicas = dto.replicas || 1; + const dbStorageGb = dto.dbStorageSize + ? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0 + : 0; + const appStorageGb = dto.appStorageSize + ? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0 + : 0; + const totalStorageGb = dbStorageGb + appStorageGb; + const hasDatabase = dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none'; + + const map = new Map(); + map.set(PricingResourceType.BASE_FEE, 1); + map.set(PricingResourceType.CPU_PER_CORE, cpuCores * replicas); + map.set(PricingResourceType.MEMORY_PER_GB, memoryGb * replicas); + map.set(PricingResourceType.STORAGE_PER_GB, totalStorageGb); + map.set(PricingResourceType.DATABASE_ADDON, hasDatabase ? 1 : 0); + map.set(PricingResourceType.REDIS_ADDON, dto.enableRedis ? 1 : 0); + map.set(PricingResourceType.RABBITMQ_ADDON, dto.enableRabbitmq ? 1 : 0); + map.set(PricingResourceType.ELASTICSEARCH_ADDON, dto.enableElasticsearch ? 1 : 0); + map.set(PricingResourceType.CUSTOM_DOMAIN_ADDON, dto.enableCustomDomain ? 1 : 0); + return map; + } + + parseCpuToCores(cpu: string): number { + if (!cpu) return 0; + if (cpu.endsWith('m')) return parseFloat(cpu) / 1000; + return parseFloat(cpu) || 0; + } + + parseMemoryToGb(memory: string): number { + if (!memory) return 0; + if (memory.endsWith('Gi')) return parseFloat(memory); + if (memory.endsWith('Mi')) return parseFloat(memory) / 1024; + if (memory.endsWith('Ki')) return parseFloat(memory) / (1024 * 1024); + return parseFloat(memory) / (1024 * 1024 * 1024); + } + + private async upsertRuntimeRate(runtime: AppRuntime, row: PricingRateRow) { + let entity = await this.rateRepo.findOne({ + where: { runtime, resourceType: row.resourceType }, + }); + if (!entity) { + entity = this.rateRepo.create({ runtime, resourceType: row.resourceType }); + } + entity.hourlyPrice = row.hourlyPrice; + entity.monthlyPrice = row.monthlyPrice; + entity.yearlyPrice = row.yearlyPrice; + if (row.isActive !== undefined) entity.isActive = row.isActive; + await this.rateRepo.save(entity); + } + + private async upsertAddonRate(row: PricingRateRow) { + let entity = await this.addonRepo.findOne({ + where: { resourceType: row.resourceType }, + }); + if (!entity) { + entity = this.addonRepo.create({ resourceType: row.resourceType }); + } + entity.hourlyPrice = row.hourlyPrice; + entity.monthlyPrice = row.monthlyPrice; + entity.yearlyPrice = row.yearlyPrice; + if (row.isActive !== undefined) entity.isActive = row.isActive; + await this.addonRepo.save(entity); + } + + private toRateRow( + resourceType: PricingResourceType, + entity?: PricingRate | AddonRate | null, + ): PricingRateRow { + return { + resourceType, + hourlyPrice: entity ? Number(entity.hourlyPrice) : 0, + monthlyPrice: entity ? Number(entity.monthlyPrice) : 0, + yearlyPrice: entity ? Number(entity.yearlyPrice) : 0, + isActive: entity?.isActive ?? true, + }; + } + + /** Legacy shape for optional-services settings API */ + async getOptionalServicesPricing(): Promise<{ + redis: CyclePrices; + rabbitmq: CyclePrices; + elasticsearch: CyclePrices; + }> { + const addons = await this.addonRepo.find(); + const pick = (type: PricingResourceType): CyclePrices => { + const row = addons.find((a) => a.resourceType === type); + return { + hourly: row ? Number(row.hourlyPrice) : 0, + monthly: row ? Number(row.monthlyPrice) : 0, + yearly: row ? Number(row.yearlyPrice) : 0, + }; + }; + return { + redis: pick(PricingResourceType.REDIS_ADDON), + rabbitmq: pick(PricingResourceType.RABBITMQ_ADDON), + elasticsearch: pick(PricingResourceType.ELASTICSEARCH_ADDON), + }; + } + + async setOptionalServicesPricing(pricing: { + redis: CyclePrices; + rabbitmq: CyclePrices; + elasticsearch: CyclePrices; + }) { + await this.upsertAddonRate({ + resourceType: PricingResourceType.REDIS_ADDON, + hourlyPrice: pricing.redis.hourly, + monthlyPrice: pricing.redis.monthly, + yearlyPrice: pricing.redis.yearly, + isActive: true, + }); + await this.upsertAddonRate({ + resourceType: PricingResourceType.RABBITMQ_ADDON, + hourlyPrice: pricing.rabbitmq.hourly, + monthlyPrice: pricing.rabbitmq.monthly, + yearlyPrice: pricing.rabbitmq.yearly, + isActive: true, + }); + await this.upsertAddonRate({ + resourceType: PricingResourceType.ELASTICSEARCH_ADDON, + hourlyPrice: pricing.elasticsearch.hourly, + monthlyPrice: pricing.elasticsearch.monthly, + yearlyPrice: pricing.elasticsearch.yearly, + isActive: true, + }); + return this.getOptionalServicesPricing(); + } + + async getCustomDomainPrice(): Promise<{ monthlyPrice: number }> { + const row = await this.addonRepo.findOne({ + where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON }, + }); + return { monthlyPrice: row ? Number(row.monthlyPrice) : 0 }; + } + + async setCustomDomainPrice(monthlyPrice: number): Promise<{ monthlyPrice: number }> { + await this.upsertAddonRate({ + resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON, + hourlyPrice: 0, + monthlyPrice, + yearlyPrice: 0, + isActive: true, + }); + return { monthlyPrice }; + } +} diff --git a/backend/src/lifecycle/app-lifecycle.service.ts b/backend/src/lifecycle/app-lifecycle.service.ts index 834fefb..a01024c 100644 --- a/backend/src/lifecycle/app-lifecycle.service.ts +++ b/backend/src/lifecycle/app-lifecycle.service.ts @@ -57,7 +57,6 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy { async activateApp( appId: string, billingCycle: BillingCycle, - planId: string, planExpiresAt?: Date, ): Promise { const app = await this.appRepo.findOne({ where: { id: appId } }); @@ -68,7 +67,6 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy { ? planExpiresAt : this.calculateExpiry(now, billingCycle); - app.planId = planId; app.billingCycle = billingCycle; app.lifecycleStatus = AppLifecycleStatus.ACTIVE; app.planExpiresAt = expiresAt; @@ -225,7 +223,7 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy { // ─── Auto-renew for hourly plans ────────────────────────────────── private async tryAutoRenew(app: Application): Promise { - if (!app.planId) return false; + if (!app.billingCycle) return false; try { const cost = await this.billingService.calculateCostForApp(app); diff --git a/frontend/src/app/dashboard/admin/billing/page.tsx b/frontend/src/app/dashboard/admin/billing/page.tsx index 318ec50..4ba916d 100644 --- a/frontend/src/app/dashboard/admin/billing/page.tsx +++ b/frontend/src/app/dashboard/admin/billing/page.tsx @@ -4,433 +4,347 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; import { toast } from 'react-toastify'; -import type { ServicePlan, BillingCycle, PricingResourceType, LifecycleSettings } from '@/types'; -import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock, Globe } from 'lucide-react'; -import { useConfirm } from '@/components/confirm-modal'; - -const runtimeOptions = [ - { value: 'nodejs', label: 'Node.js' }, - { value: 'laravel', label: 'Laravel' }, - { value: 'wordpress', label: 'WordPress' }, -] as const; +import type { + PricingCatalog, + PricingRateRow, + BillingCycle, + PricingResourceType, + LifecycleSettings, +} from '@/types'; +import { DollarSign, Edit2, Shield, Clock, Layers } from 'lucide-react'; type AppRuntime = 'nodejs' | 'laravel' | 'wordpress'; -const runtimeLabels: Record = { - nodejs: 'Node.js', - laravel: 'Laravel', - wordpress: 'WordPress', -}; +const runtimeTabs: { value: AppRuntime; label: string }[] = [ + { value: 'nodejs', label: 'Node.js' }, + { value: 'laravel', label: 'Laravel' }, + { value: 'wordpress', label: 'WordPress' }, +]; -const cycleLabels: Record = { - hourly: 'Hourly', - monthly: 'Monthly', - yearly: 'Yearly', -}; +const addonResourceTypes: PricingResourceType[] = [ + 'redis_addon', + 'rabbitmq_addon', + 'elasticsearch_addon', + 'custom_domain_addon', +]; const resourceLabels: Record = { - base_fee: 'Base Fee', + base_fee: 'Base fee', cpu_per_core: 'CPU (per core)', memory_per_gb: 'Memory (per GB)', storage_per_gb: 'Storage (per GB)', - database_addon: 'Database Addon', - redis_addon: 'Redis Addon', - rabbitmq_addon: 'RabbitMQ Addon', - elasticsearch_addon: 'Elasticsearch Addon', - custom_domain_addon: 'Custom Domain + SSL', + database_addon: 'Database addon', + redis_addon: 'Redis', + rabbitmq_addon: 'RabbitMQ', + elasticsearch_addon: 'Elasticsearch', + custom_domain_addon: 'Custom domain + SSL', }; -const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon', 'redis_addon', 'rabbitmq_addon', 'elasticsearch_addon', 'custom_domain_addon']; +const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly']; -interface RuleForm { - resourceType: PricingResourceType; - unitPrice: string; - description: string; +function cloneCatalog(catalog: PricingCatalog): PricingCatalog { + const runtimes = {} as PricingCatalog['runtimes']; + for (const rt of runtimeTabs) { + runtimes[rt.value] = catalog.runtimes[rt.value].map((r) => ({ ...r })); + } + return { + runtimes, + addons: catalog.addons.map((a) => ({ ...a })), + }; } -const emptyRule = (): RuleForm => ({ resourceType: 'base_fee', unitPrice: '', description: '' }); +function PricingMatrixTable({ + rows, + onChange, + readOnly, +}: { + rows: PricingRateRow[]; + onChange: (resourceType: PricingResourceType, cycle: BillingCycle, value: number) => void; + readOnly: boolean; +}) { + return ( +
+ + + + + {cycles.map((cycle) => ( + + ))} + + + + {rows.map((row) => ( + + + {cycles.map((cycle) => { + const field = + cycle === 'hourly' + ? 'hourlyPrice' + : cycle === 'monthly' + ? 'monthlyPrice' + : 'yearlyPrice'; + const val = row[field]; + return ( + + ); + })} + + ))} + +
Resource + {cycle} (T) +
+ {resourceLabels[row.resourceType]} + + {readOnly ? ( + + {Number(val).toLocaleString('en-US')} + + ) : ( + + onChange( + row.resourceType, + cycle, + e.target.value === '' ? 0 : Number(e.target.value), + ) + } + /> + )} +
+
+ ); +} export default function AdminBillingPage() { const queryClient = useQueryClient(); - const confirm = useConfirm(); - const [showForm, setShowForm] = useState(false); - const [editingId, setEditingId] = useState(null); - const [expandedPlan, setExpandedPlan] = useState(null); - const [formName, setFormName] = useState(''); - const [formRuntime, setFormRuntime] = useState('nodejs'); - const [formDesc, setFormDesc] = useState(''); - const [formCycle, setFormCycle] = useState('monthly'); - const [rules, setRules] = useState([emptyRule()]); + const [activeRuntime, setActiveRuntime] = useState('nodejs'); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(null); - const { data: plans = [], isLoading } = useQuery({ - queryKey: ['billing-plans'], - queryFn: () => api.get('/billing/plans').then((r) => r.data), + const { data: catalog, isLoading } = useQuery({ + queryKey: ['pricing-catalog'], + queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data), }); - const createMutation = useMutation({ - mutationFn: (data: any) => editingId - ? api.patch(`/billing/plans/${editingId}`, data) - : api.post('/billing/plans', data), + const saveMutation = useMutation({ + mutationFn: (body: PricingCatalog) => api.patch('/billing/pricing-catalog', body), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['billing-plans'] }); - toast.success(editingId ? 'Plan updated' : 'Plan created'); - resetForm(); + queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] }); + queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] }); + queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] }); + toast.success('Pricing catalog saved'); + setEditing(false); + setDraft(null); }, - onError: (err: any) => toast.error(err.response?.data?.message || 'Error'), - }); - - const deleteMutation = useMutation({ - mutationFn: (id: string) => api.delete(`/billing/plans/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['billing-plans'] }); - toast.success('Plan deleted'); - }, - onError: () => toast.error('Failed to delete plan'), - }); - - const toggleMutation = useMutation({ - mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) => - api.patch(`/billing/plans/${id}`, { isActive }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['billing-plans'] }); + onError: (err: unknown) => { + const message = + err && typeof err === 'object' && 'response' in err + ? (err as { response?: { data?: { message?: string } } }).response?.data?.message + : undefined; + toast.error(message || 'Failed to save pricing'); }, }); - const resetForm = () => { - setShowForm(false); - setEditingId(null); - setFormName(''); - setFormRuntime('nodejs'); - setFormDesc(''); - setFormCycle('monthly'); - setRules([emptyRule()]); + const display = editing && draft ? draft : catalog; + + const startEdit = () => { + if (!catalog) return; + setDraft(cloneCatalog(catalog)); + setEditing(true); }; - const startEdit = (plan: ServicePlan) => { - setEditingId(plan.id); - setFormName(plan.name); - setFormRuntime(plan.runtime); - setFormDesc(plan.description || ''); - setFormCycle(plan.billingCycle); - setRules( - plan.pricingRules.map((r) => ({ - resourceType: r.resourceType, - unitPrice: String(r.unitPrice), - description: r.description || '', - })), - ); - setShowForm(true); - }; - - const handleSubmit = () => { - if (!formName.trim()) return toast.error('Plan name is required'); - const validRules = rules.filter((r) => r.unitPrice && Number(r.unitPrice) > 0); - if (validRules.length === 0) return toast.error('Add at least one pricing rule'); - - createMutation.mutate({ - name: formName, - runtime: formRuntime, - description: formDesc || undefined, - billingCycle: formCycle, - pricingRules: validRules.map((r) => ({ - resourceType: r.resourceType, - unitPrice: Number(r.unitPrice), - description: r.description || undefined, - })), + const updateRuntimePrice = ( + resourceType: PricingResourceType, + cycle: BillingCycle, + value: number, + ) => { + if (!draft) return; + const field = + cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice'; + setDraft({ + ...draft, + runtimes: { + ...draft.runtimes, + [activeRuntime]: draft.runtimes[activeRuntime].map((row) => + row.resourceType === resourceType ? { ...row, [field]: value } : row, + ), + }, }); }; - const addRule = () => setRules([...rules, emptyRule()]); - const removeRule = (i: number) => setRules(rules.filter((_, idx) => idx !== i)); - const updateRule = (i: number, field: keyof RuleForm, value: string) => { - const updated = [...rules]; - updated[i] = { ...updated[i], [field]: value }; - setRules(updated); + const updateAddonPrice = ( + resourceType: PricingResourceType, + cycle: BillingCycle, + value: number, + ) => { + if (!draft) return; + const field = + cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice'; + setDraft({ + ...draft, + addons: draft.addons.map((row) => + row.resourceType === resourceType ? { ...row, [field]: value } : row, + ), + }); }; - const formatPrice = (n: number) => Number(n).toLocaleString('en-US'); + const fillYearlyFromMonthly = (scope: 'runtime' | 'addons') => { + if (!draft) return; + if (scope === 'runtime') { + setDraft({ + ...draft, + runtimes: { + ...draft.runtimes, + [activeRuntime]: draft.runtimes[activeRuntime].map((row) => ({ + ...row, + yearlyPrice: Math.round(Number(row.monthlyPrice) * 12), + })), + }, + }); + } else { + setDraft({ + ...draft, + addons: draft.addons.map((row) => ({ + ...row, + yearlyPrice: Math.round(Number(row.monthlyPrice) * 12), + })), + }); + } + }; + + const handleSave = () => { + if (!draft) return; + saveMutation.mutate(draft); + }; + + const addonRows = + display?.addons.filter((a) => addonResourceTypes.includes(a.resourceType)) ?? []; return ( -
-
+
+
-

Billing Plans

-

Define service plans and pricing for each application type

+

+ Billing & Pricing +

+

+ Usage-based prices per application type. Each resource has explicit hourly, monthly, and yearly rates — deploy cost uses the column for the cycle the user selects. +

- {!showForm && ( - + ) : ( +
+ + +
)}
- {/* Create / Edit Form */} - {showForm && ( -
-

{editingId ? 'Edit Plan' : 'Create New Plan'}

- -
-
- - -
-
- - setFormName(e.target.value)} /> -
-
- - -
-
- -
- - setFormDesc(e.target.value)} /> -
- - {/* Pricing Rules */} -
-
- - -
- -
- {rules.map((rule, i) => ( -
- - updateRule(i, 'unitPrice', e.target.value)} - /> - updateRule(i, 'description', e.target.value)} - /> - {rules.length > 1 && ( - - )} -
- ))} -
-
- -
- - -
-
- )} - - {/* Plans List */} {isLoading ? (
Loading...
- ) : plans.length === 0 ? ( -
No plans created yet
+ ) : !display ? ( +
No pricing data
) : ( -
- {plans.map((plan) => ( -
-
-
- -
-

{plan.name}

-
- {runtimeLabels[plan.runtime] || plan.runtime} - {cycleLabels[plan.billingCycle]} - {plan.description && — {plan.description}} -
-
-
-
- - - -
-
+ <> +
+
+ {runtimeTabs.map((tab) => ( + + ))} +
- {/* Expanded pricing rules */} - {expandedPlan === plan.id && ( -
- - - - - - - - - - {plan.pricingRules.map((rule) => ( - - - - - - ))} - -
ResourceUnit Price (Toman)Note
{resourceLabels[rule.resourceType]}{formatPrice(rule.unitPrice)}{rule.description || '—'}
-
+
+

+ {runtimeTabs.find((t) => t.value === activeRuntime)?.label} resources +

+ {editing && ( + )}
- ))} -
+ + +
+ +
+
+

+ Platform add-ons +

+ {editing && ( + + )} +
+

+ Redis, RabbitMQ, Elasticsearch, and custom domain — same prices for all application types. +

+ +
+ )} - {/* ─── Custom Domain Pricing ───────────────────── */} - - - {/* ─── Lifecycle Retention Settings ───────────────────── */}
); } -// ─── Custom Domain Pricing Sub-component ────────────────────────── - -function CustomDomainPricingSection() { - const queryClient = useQueryClient(); - const [editing, setEditing] = useState(false); - const [priceInput, setPriceInput] = useState(''); - - const { data: priceData, isLoading } = useQuery<{ monthlyPrice: number }>({ - queryKey: ['custom-domain-price'], - queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data), - }); - - const saveMutation = useMutation({ - mutationFn: (monthlyPrice: number) => api.patch('/billing/settings/custom-domain-price', { monthlyPrice }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] }); - toast.success('Custom domain pricing updated'); - setEditing(false); - }, - onError: () => toast.error('Failed to update pricing'), - }); - - const handleEdit = () => { - setPriceInput(String(priceData?.monthlyPrice || 0)); - setEditing(true); - }; - - const handleSave = () => { - const price = Number(priceInput); - if (isNaN(price) || price < 0) { - toast.error('Price must be a non-negative number'); - return; - } - saveMutation.mutate(price); - }; - - return ( -
-
-

- Custom Domain Pricing -

- {!editing && ( - - )} -
- - {isLoading ? ( -

Loading...

- ) : editing ? ( -
-
- - setPriceInput(e.target.value)} - className="input-field w-full max-w-xs" - min="0" - placeholder="e.g. 50000" - /> -

- Set to 0 to make custom domains free. This price is added to the total cost when users enable a custom domain. -

-
-
- - -
-
- ) : ( -
-
-
-

Monthly price per custom domain

-

- {(priceData?.monthlyPrice || 0).toLocaleString('en-US')} Toman -

-
- {priceData?.monthlyPrice === 0 && ( - Free - )} -
-
- )} -
- ); -} - -// ─── Lifecycle Settings Sub-component ───────────────────────────── - function LifecycleSettingsSection() { const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); @@ -444,13 +358,19 @@ function LifecycleSettingsSection() { }); const saveMutation = useMutation({ - mutationFn: (body: any) => api.patch('/lifecycle/settings', body), + mutationFn: (body: Record) => api.patch('/lifecycle/settings', body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] }); toast.success('Lifecycle settings updated'); setEditing(false); }, - onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to save'), + onError: (err: unknown) => { + const message = + err && typeof err === 'object' && 'response' in err + ? (err as { response?: { data?: { message?: string } } }).response?.data?.message + : undefined; + toast.error(message || 'Failed to save'); + }, }); const startEditing = () => { @@ -463,7 +383,7 @@ function LifecycleSettingsSection() { }; const handleSave = () => { - const body: any = {}; + const body: Record = {}; if (hourlyHours) body.hourlyDeleteAfterMs = Number(hourlyHours) * 3600000; if (monthlyDays) body.monthlyDeleteAfterMs = Number(monthlyDays) * 86400000; if (yearlyDays) body.yearlyDeleteAfterMs = Number(yearlyDays) * 86400000; @@ -485,7 +405,6 @@ function LifecycleSettingsSection() {

Configure how long user data is retained after plan expiration before permanent deletion. - After a plan expires, the application is suspended (scaled to 0). If no payment is received within the grace period, the application and all its data are permanently deleted.

{isLoading ? ( @@ -540,8 +459,14 @@ function LifecycleSettingsSection() {
- - +
@@ -553,30 +478,30 @@ function LifecycleSettingsSection() { Hourly Plans

- {settings?.hourly.deleteAfterHours ?? Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)} + {settings?.hourly.deleteAfterHours ?? + Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)} hours

-

after suspension → delete

Monthly Plans

- {settings?.monthly.deleteAfterDays ?? Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)} + {settings?.monthly.deleteAfterDays ?? + Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)} days

-

after suspension → delete

Yearly Plans

- {settings?.yearly.deleteAfterDays ?? Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)} + {settings?.yearly.deleteAfterDays ?? + Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)} days

-

after suspension → delete

)} diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx index 8584b13..fc56a0a 100644 --- a/frontend/src/app/dashboard/deploy/page.tsx +++ b/frontend/src/app/dashboard/deploy/page.tsx @@ -202,7 +202,7 @@ export default function DeployPage() { // Deduct from wallet setDeployStage('paying'); - await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle }); + await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle }); return res; }, @@ -280,7 +280,7 @@ export default function DeployPage() { // Deduct from the wallet (which was just charged by gateway) setDeployStage('paying'); - await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle }); + await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle }); return res; }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5d0c9c8..4b8b123 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -375,25 +375,17 @@ export type BillingCycle = 'hourly' | 'monthly' | 'yearly'; export type PricingResourceType = 'base_fee' | 'cpu_per_core' | 'memory_per_gb' | 'storage_per_gb' | 'database_addon' | 'redis_addon' | 'rabbitmq_addon' | 'elasticsearch_addon' | 'custom_domain_addon'; export type TransactionType = 'charge' | 'deduction' | 'refund'; -export interface PricingRule { - id: string; +export interface PricingRateRow { resourceType: PricingResourceType; - unitPrice: number; - description?: string; - planId: string; - createdAt: string; + hourlyPrice: number; + monthlyPrice: number; + yearlyPrice: number; + isActive?: boolean; } -export interface ServicePlan { - id: string; - name: string; - runtime: 'nodejs' | 'laravel' | 'wordpress'; - description?: string; - billingCycle: BillingCycle; - isActive: boolean; - pricingRules: PricingRule[]; - createdAt: string; - updatedAt: string; +export interface PricingCatalog { + runtimes: Record<'nodejs' | 'laravel' | 'wordpress', PricingRateRow[]>; + addons: PricingRateRow[]; } export interface WalletBalance { @@ -411,6 +403,18 @@ export interface WalletTransaction { createdAt: string; } +export interface OptionalServiceCyclePrices { + hourly: number; + monthly: number; + yearly: number; +} + +export interface OptionalServicesPricing { + redis: OptionalServiceCyclePrices; + rabbitmq: OptionalServiceCyclePrices; + elasticsearch: OptionalServiceCyclePrices; +} + export interface CostBreakdown { hourly: number; monthly: number;