Replace billing plans with per-runtime usage pricing catalog.
Store explicit hourly/monthly/yearly rates in pricing_rates and addon_rates, compute deploy costs without cycle conversion, and simplify admin UI and wallet payment to cycle-only. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<ServicePlan>,
|
||||
@InjectRepository(PricingRule) private ruleRepo: Repository<PricingRule>,
|
||||
private readonly pricingCatalog: PricingCatalogService,
|
||||
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
||||
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
||||
@InjectRepository(PlatformSetting) private settingsRepo: Repository<PlatformSetting>,
|
||||
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
|
||||
) {}
|
||||
|
||||
// ─── Service Plans ────────────────────────────────────────────────
|
||||
// ─── Pricing catalog (Admin) ──────────────────────────────────────
|
||||
|
||||
async createPlan(dto: CreateServicePlanDto): Promise<ServicePlan> {
|
||||
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<ServicePlan>;
|
||||
getPricingCatalog() {
|
||||
return this.pricingCatalog.getCatalog();
|
||||
}
|
||||
|
||||
async updatePlan(id: string, dto: UpdateServicePlanDto): Promise<ServicePlan> {
|
||||
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<ServicePlan>;
|
||||
}
|
||||
|
||||
async deletePlan(id: string): Promise<void> {
|
||||
const plan = await this.planRepo.findOne({ where: { id } });
|
||||
if (!plan) throw new NotFoundException('Plan not found');
|
||||
await this.planRepo.remove(plan);
|
||||
}
|
||||
|
||||
async findAllPlans(): Promise<ServicePlan[]> {
|
||||
return this.planRepo.find({ relations: ['pricingRules'], order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async findActivePlans(): Promise<ServicePlan[]> {
|
||||
return this.planRepo.find({
|
||||
where: { isActive: true },
|
||||
relations: ['pricingRules'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findPlan(id: string): Promise<ServicePlan> {
|
||||
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: [] };
|
||||
return this.pricingCatalog.computeTotalsFromDb(dto);
|
||||
}
|
||||
|
||||
// Gather all active pricing rules across all plans, grouped by cycle
|
||||
const hourlyRules: PricingRule[] = [];
|
||||
const monthlyRules: PricingRule[] = [];
|
||||
const yearlyRules: PricingRule[] = [];
|
||||
// ─── Optional services & custom domain (delegates to catalog) ─────
|
||||
|
||||
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;
|
||||
}
|
||||
getOptionalServicesPricing() {
|
||||
return this.pricingCatalog.getOptionalServicesPricing();
|
||||
}
|
||||
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
getCustomDomainPrice() {
|
||||
return this.pricingCatalog.getCustomDomainPrice();
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
private parseCpuToCores(cpu: string): number {
|
||||
if (!cpu) return 0;
|
||||
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
|
||||
return parseFloat(cpu) || 0;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── 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 };
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Record<AppRuntime, PricingRateRowDto[]>>;
|
||||
|
||||
@ApiPropertyOptional({ type: [PricingRateRowDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PricingRateRowDto)
|
||||
addons?: PricingRateRowDto[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, string> = {
|
||||
[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',
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<AppRuntime, PricingRateRow[]>;
|
||||
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<PricingRate>,
|
||||
@InjectRepository(AddonRate) private readonly addonRepo: Repository<AddonRate>,
|
||||
) {}
|
||||
|
||||
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<PricingCatalogResponse> {
|
||||
const rates = await this.rateRepo.find({ order: { runtime: 'ASC', resourceType: 'ASC' } });
|
||||
const addons = await this.addonRepo.find({ order: { resourceType: 'ASC' } });
|
||||
|
||||
const runtimes = {} as Record<AppRuntime, PricingRateRow[]>;
|
||||
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<PricingCatalogResponse> {
|
||||
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<PricingRate[]> {
|
||||
return this.rateRepo.find({
|
||||
where: { runtime, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getAddonRates(): Promise<AddonRate[]> {
|
||||
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<PricingResourceType, number> {
|
||||
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<PricingResourceType, number>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,6 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
|
||||
async activateApp(
|
||||
appId: string,
|
||||
billingCycle: BillingCycle,
|
||||
planId: string,
|
||||
planExpiresAt?: Date,
|
||||
): Promise<Application> {
|
||||
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<boolean> {
|
||||
if (!app.planId) return false;
|
||||
if (!app.billingCycle) return false;
|
||||
|
||||
try {
|
||||
const cost = await this.billingService.calculateCostForApp(app);
|
||||
|
||||
@@ -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<AppRuntime, string> = {
|
||||
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<BillingCycle, string> = {
|
||||
hourly: 'Hourly',
|
||||
monthly: 'Monthly',
|
||||
yearly: 'Yearly',
|
||||
};
|
||||
const addonResourceTypes: PricingResourceType[] = [
|
||||
'redis_addon',
|
||||
'rabbitmq_addon',
|
||||
'elasticsearch_addon',
|
||||
'custom_domain_addon',
|
||||
];
|
||||
|
||||
const resourceLabels: Record<PricingResourceType, string> = {
|
||||
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: '' });
|
||||
|
||||
export default function AdminBillingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [expandedPlan, setExpandedPlan] = useState<string | null>(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formRuntime, setFormRuntime] = useState<AppRuntime>('nodejs');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [formCycle, setFormCycle] = useState<BillingCycle>('monthly');
|
||||
const [rules, setRules] = useState<RuleForm[]>([emptyRule()]);
|
||||
|
||||
const { data: plans = [], isLoading } = useQuery<ServicePlan[]>({
|
||||
queryKey: ['billing-plans'],
|
||||
queryFn: () => api.get('/billing/plans').then((r) => r.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => editingId
|
||||
? api.patch(`/billing/plans/${editingId}`, data)
|
||||
: api.post('/billing/plans', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
|
||||
toast.success(editingId ? 'Plan updated' : 'Plan created');
|
||||
resetForm();
|
||||
},
|
||||
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'] });
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setFormName('');
|
||||
setFormRuntime('nodejs');
|
||||
setFormDesc('');
|
||||
setFormCycle('monthly');
|
||||
setRules([emptyRule()]);
|
||||
};
|
||||
|
||||
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 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 formatPrice = (n: number) => Number(n).toLocaleString('en-US');
|
||||
|
||||
function PricingMatrixTable({
|
||||
rows,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: {
|
||||
rows: PricingRateRow[];
|
||||
onChange: (resourceType: PricingResourceType, cycle: BillingCycle, value: number) => void;
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2"><DollarSign className="w-6 h-6" /> Billing Plans</h1>
|
||||
<p className="page-subtitle">Define service plans and pricing for each application type</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<button onClick={() => setShowForm(true)} className="btn-primary flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" /> New Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create / Edit Form */}
|
||||
{showForm && (
|
||||
<div className="card space-y-4">
|
||||
<h2 className="text-lg font-semibold">{editingId ? 'Edit Plan' : 'Create New Plan'}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Application Type</label>
|
||||
<select className="input-field" value={formRuntime} onChange={(e) => setFormRuntime(e.target.value as AppRuntime)}>
|
||||
{runtimeOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-lg overflow-hidden">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-medium text-gray-600">Resource</th>
|
||||
{cycles.map((cycle) => (
|
||||
<th key={cycle} className="text-left p-3 font-medium text-gray-600 capitalize">
|
||||
{cycle} (T)
|
||||
</th>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Plan Name</label>
|
||||
<input className="input-field" placeholder="e.g. Node.js Standard" value={formName} onChange={(e) => setFormName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Billing Cycle</label>
|
||||
<select className="input-field" value={formCycle} onChange={(e) => setFormCycle(e.target.value as BillingCycle)}>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
|
||||
<input className="input-field" placeholder="Description of this plan" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* Pricing Rules */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-semibold text-gray-700">Pricing Rules</label>
|
||||
<button onClick={addRule} className="btn-secondary text-xs flex items-center gap-1">
|
||||
<Plus className="w-3 h-3" /> Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<select
|
||||
className="input-field flex-1 text-sm"
|
||||
value={rule.resourceType}
|
||||
onChange={(e) => updateRule(i, 'resourceType', e.target.value)}
|
||||
>
|
||||
{allResourceTypes.map((rt) => (
|
||||
<option key={rt} value={rt}>{resourceLabels[rt]}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input-field w-36 text-sm"
|
||||
type="number"
|
||||
placeholder="Price (Toman)"
|
||||
value={rule.unitPrice}
|
||||
onChange={(e) => updateRule(i, 'unitPrice', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input-field flex-1 text-sm"
|
||||
placeholder="Note (optional)"
|
||||
value={rule.description}
|
||||
onChange={(e) => updateRule(i, 'description', e.target.value)}
|
||||
/>
|
||||
{rules.length > 1 && (
|
||||
<button onClick={() => removeRule(i)} className="text-red-500 hover:text-red-700 p-1">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={resetForm} className="btn-ghost">Cancel</button>
|
||||
<button onClick={handleSubmit} disabled={createMutation.isPending} className="btn-primary disabled:opacity-50">
|
||||
{createMutation.isPending ? 'Saving...' : editingId ? 'Update Plan' : 'Create Plan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plans List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-400">Loading...</div>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">No plans created yet</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{plans.map((plan) => (
|
||||
<div key={plan.id} className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setExpandedPlan(expandedPlan === plan.id ? null : plan.id)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{expandedPlan === plan.id ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{plan.name}</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="badge badge-blue">{runtimeLabels[plan.runtime] || plan.runtime}</span>
|
||||
<span className="badge badge-purple">{cycleLabels[plan.billingCycle]}</span>
|
||||
{plan.description && <span>— {plan.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleMutation.mutate({ id: plan.id, isActive: !plan.isActive })}
|
||||
className={`p-1 transition-colors ${plan.isActive ? 'text-green-500' : 'text-gray-400'}`}
|
||||
title={plan.isActive ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{plan.isActive ? <ToggleRight className="w-5 h-5" /> : <ToggleLeft className="w-5 h-5" />}
|
||||
</button>
|
||||
<button onClick={() => startEdit(plan)} className="p-1 text-blue-500 hover:text-blue-700">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const ok = await confirm({ title: 'Delete Plan', message: `Are you sure you want to delete "${plan.name}"?`, confirmText: 'Delete', variant: 'danger' });
|
||||
if (ok) deleteMutation.mutate(plan.id);
|
||||
}}
|
||||
className="p-1 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded pricing rules */}
|
||||
{expandedPlan === plan.id && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-gray-500 text-xs">
|
||||
<th className="text-left pb-2">Resource</th>
|
||||
<th className="text-left pb-2">Unit Price (Toman)</th>
|
||||
<th className="text-left pb-2">Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plan.pricingRules.map((rule) => (
|
||||
<tr key={rule.id} className="border-t border-gray-50">
|
||||
<td className="py-2 font-medium">{resourceLabels[rule.resourceType]}</td>
|
||||
<td className="py-2 text-green-700 font-mono">{formatPrice(rule.unitPrice)}</td>
|
||||
<td className="py-2 text-gray-500">{rule.description || '—'}</td>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.resourceType} className="border-t border-gray-100">
|
||||
<td className="p-3 font-medium text-gray-900">
|
||||
{resourceLabels[row.resourceType]}
|
||||
</td>
|
||||
{cycles.map((cycle) => {
|
||||
const field =
|
||||
cycle === 'hourly'
|
||||
? 'hourlyPrice'
|
||||
: cycle === 'monthly'
|
||||
? 'monthlyPrice'
|
||||
: 'yearlyPrice';
|
||||
const val = row[field];
|
||||
return (
|
||||
<td key={cycle} className="p-3">
|
||||
{readOnly ? (
|
||||
<span className="font-mono text-gray-700">
|
||||
{Number(val).toLocaleString('en-US')}
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input-field w-full max-w-[120px]"
|
||||
value={val}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
row.resourceType,
|
||||
cycle,
|
||||
e.target.value === '' ? 0 : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Custom Domain Pricing ───────────────────── */}
|
||||
<CustomDomainPricingSection />
|
||||
|
||||
{/* ─── Lifecycle Retention Settings ───────────────────── */}
|
||||
<LifecycleSettingsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom Domain Pricing Sub-component ──────────────────────────
|
||||
|
||||
function CustomDomainPricingSection() {
|
||||
export default function AdminBillingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeRuntime, setActiveRuntime] = useState<AppRuntime>('nodejs');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [priceInput, setPriceInput] = useState('');
|
||||
const [draft, setDraft] = useState<PricingCatalog | null>(null);
|
||||
|
||||
const { data: priceData, isLoading } = useQuery<{ monthlyPrice: number }>({
|
||||
queryKey: ['custom-domain-price'],
|
||||
queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data),
|
||||
const { data: catalog, isLoading } = useQuery<PricingCatalog>({
|
||||
queryKey: ['pricing-catalog'],
|
||||
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (monthlyPrice: number) => api.patch('/billing/settings/custom-domain-price', { monthlyPrice }),
|
||||
mutationFn: (body: PricingCatalog) => api.patch('/billing/pricing-catalog', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
|
||||
toast.success('Custom domain pricing updated');
|
||||
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
|
||||
toast.success('Pricing catalog saved');
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
},
|
||||
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');
|
||||
},
|
||||
onError: () => toast.error('Failed to update pricing'),
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
setPriceInput(String(priceData?.monthlyPrice || 0));
|
||||
const display = editing && draft ? draft : catalog;
|
||||
|
||||
const startEdit = () => {
|
||||
if (!catalog) return;
|
||||
setDraft(cloneCatalog(catalog));
|
||||
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);
|
||||
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 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 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 (
|
||||
<div className="card mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" /> Custom Domain Pricing
|
||||
</h2>
|
||||
{!editing && (
|
||||
<button onClick={handleEdit} className="btn-secondary text-sm flex items-center gap-1.5">
|
||||
<Edit2 className="w-4 h-4" /> Edit
|
||||
<div className="max-w-5xl mx-auto space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2">
|
||||
<DollarSign className="w-6 h-6" /> Billing & Pricing
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<button
|
||||
onClick={startEdit}
|
||||
disabled={!catalog}
|
||||
className="btn-primary flex items-center gap-2 shrink-0"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" /> Edit pricing
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
}}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading...</p>
|
||||
) : editing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Monthly Price (Toman)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={priceInput}
|
||||
onChange={(e) => setPriceInput(e.target.value)}
|
||||
className="input-field w-full max-w-xs"
|
||||
min="0"
|
||||
placeholder="e.g. 50000"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Set to 0 to make custom domains free. This price is added to the total cost when users enable a custom domain.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSave} disabled={saveMutation.isPending} className="btn-primary text-sm">
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button onClick={() => setEditing(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center py-12 text-gray-400">Loading...</div>
|
||||
) : !display ? (
|
||||
<div className="text-center py-12 text-gray-400">No pricing data</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Monthly price per custom domain</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{(priceData?.monthlyPrice || 0).toLocaleString('en-US')} <span className="text-sm font-normal text-gray-500">Toman</span>
|
||||
<>
|
||||
<div className="card space-y-4">
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
|
||||
{runtimeTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => setActiveRuntime(tab.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeRuntime === tab.value
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{runtimeTabs.find((t) => t.value === activeRuntime)?.label} resources
|
||||
</h2>
|
||||
{editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillYearlyFromMonthly('runtime')}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
Fill yearly from monthly ×12
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PricingMatrixTable
|
||||
rows={display.runtimes[activeRuntime]}
|
||||
readOnly={!editing}
|
||||
onChange={updateRuntimePrice}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Layers className="w-5 h-5" /> Platform add-ons
|
||||
</h2>
|
||||
{editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillYearlyFromMonthly('addons')}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
Fill yearly from monthly ×12
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Redis, RabbitMQ, Elasticsearch, and custom domain — same prices for all application types.
|
||||
</p>
|
||||
<PricingMatrixTable
|
||||
rows={addonRows}
|
||||
readOnly={!editing}
|
||||
onChange={updateAddonPrice}
|
||||
/>
|
||||
</div>
|
||||
{priceData?.monthlyPrice === 0 && (
|
||||
<span className="badge badge-green">Free</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<LifecycleSettingsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<string, number>) => 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<string, number> = {};
|
||||
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() {
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -540,8 +459,14 @@ function LifecycleSettingsSection() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => setEditing(false)} className="btn-ghost">Cancel</button>
|
||||
<button onClick={handleSave} disabled={saveMutation.isPending} className="btn-primary disabled:opacity-50">
|
||||
<button onClick={() => setEditing(false)} className="btn-ghost">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -553,30 +478,30 @@ function LifecycleSettingsSection() {
|
||||
<Clock className="w-4 h-4" /> Hourly Plans
|
||||
</h3>
|
||||
<p className="text-2xl font-bold text-blue-700 mt-2">
|
||||
{settings?.hourly.deleteAfterHours ?? Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)}
|
||||
{settings?.hourly.deleteAfterHours ??
|
||||
Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)}
|
||||
<span className="text-sm font-normal ml-1">hours</span>
|
||||
</p>
|
||||
<p className="text-xs text-blue-500 mt-1">after suspension → delete</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-purple-50/50 border border-purple-100">
|
||||
<h3 className="font-semibold text-purple-900 flex items-center gap-1 text-sm">
|
||||
<Clock className="w-4 h-4" /> Monthly Plans
|
||||
</h3>
|
||||
<p className="text-2xl font-bold text-purple-700 mt-2">
|
||||
{settings?.monthly.deleteAfterDays ?? Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)}
|
||||
{settings?.monthly.deleteAfterDays ??
|
||||
Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)}
|
||||
<span className="text-sm font-normal ml-1">days</span>
|
||||
</p>
|
||||
<p className="text-xs text-purple-500 mt-1">after suspension → delete</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-green-50/50 border border-green-100">
|
||||
<h3 className="font-semibold text-green-900 flex items-center gap-1 text-sm">
|
||||
<Clock className="w-4 h-4" /> Yearly Plans
|
||||
</h3>
|
||||
<p className="text-2xl font-bold text-green-700 mt-2">
|
||||
{settings?.yearly.deleteAfterDays ?? Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)}
|
||||
{settings?.yearly.deleteAfterDays ??
|
||||
Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)}
|
||||
<span className="text-sm font-normal ml-1">days</span>
|
||||
</p>
|
||||
<p className="text-xs text-green-500 mt-1">after suspension → delete</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
+20
-16
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user