diff --git a/backend/helm/cloudhost-app/templates/ingress.yaml b/backend/helm/cloudhost-app/templates/ingress.yaml index ed1167f..6a8650a 100644 --- a/backend/helm/cloudhost-app/templates/ingress.yaml +++ b/backend/helm/cloudhost-app/templates/ingress.yaml @@ -24,8 +24,23 @@ spec: name: {{ $name }} port: number: 80 + {{- if .Values.ingress.customDomain }} + - host: {{ .Values.ingress.customDomain }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ $name }} + port: + number: 80 + {{- end }} tls: - hosts: - {{ $host }} + {{- if .Values.ingress.customDomain }} + - {{ .Values.ingress.customDomain }} + {{- end }} secretName: {{ $name }}-tls {{- end }} diff --git a/backend/helm/cloudhost-app/values.yaml b/backend/helm/cloudhost-app/values.yaml index a339d5a..b0a3635 100644 --- a/backend/helm/cloudhost-app/values.yaml +++ b/backend/helm/cloudhost-app/values.yaml @@ -30,6 +30,7 @@ ingress: subdomain: "" # . domain: "apps.cloudhost.ir" clusterIssuer: letsencrypt-prod + customDomain: "" # Optional custom domain (e.g. "www.example.com") # ── Database (MySQL or PostgreSQL) ─────────────────────── database: diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index 239db93..e20bc97 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -20,7 +20,8 @@ import { AuthGuard } from '@nestjs/passport'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger'; import { ApplicationsService } from './applications.service'; -import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto } from './dto/application.dto'; +import { DomainService } from './domain.service'; +import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto } from './dto/application.dto'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; import { UserRole, DatabaseType } from '../common/enums'; @@ -36,6 +37,7 @@ export class ApplicationsController { constructor( private readonly applicationsService: ApplicationsService, + private readonly domainService: DomainService, private readonly kubernetesService: KubernetesService, @Inject(forwardRef(() => DeploymentsService)) private readonly deploymentsService: DeploymentsService, @@ -263,6 +265,69 @@ export class ApplicationsController { return this.kubernetesService.getPreviewInfo(app); } + // ── Custom Domain ───────────────────────────────────────────── + + @Get(':id/domain') + @ApiOperation({ summary: 'Get custom domain info and DNS setup instructions' }) + async getDomainInfo(@Param('id') id: string, @Request() req: any) { + const userId = (req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL) + ? (await this.applicationsService.findOne(id)).userId + : req.user.id; + return this.domainService.getDomainInfo(id, userId); + } + + @Post(':id/domain') + @ApiOperation({ summary: 'Set a custom domain for the application' }) + async setCustomDomain( + @Param('id') id: string, + @Request() req: any, + @Body() dto: SetCustomDomainDto, + ) { + const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; + const userId = isStaff + ? (await this.applicationsService.findOne(id)).userId + : req.user.id; + return this.domainService.setCustomDomain(id, userId, dto.domain); + } + + @Post(':id/domain/verify') + @ApiOperation({ summary: 'Verify DNS records for the custom domain' }) + async verifyDomainDns(@Param('id') id: string, @Request() req: any) { + const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; + const userId = isStaff + ? (await this.applicationsService.findOne(id)).userId + : req.user.id; + const result = await this.domainService.verifyDns(id, userId); + + if (result.verified && result.application) { + try { + await this.kubernetesService.updateIngress(result.application); + } catch (e: any) { + this.logger.warn(`Failed to update ingress for ${id} after DNS verification: ${e.message}`); + } + } + + return result; + } + + @Delete(':id/domain') + @ApiOperation({ summary: 'Remove custom domain from the application' }) + async removeCustomDomain(@Param('id') id: string, @Request() req: any) { + const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; + const userId = isStaff + ? (await this.applicationsService.findOne(id)).userId + : req.user.id; + const app = await this.domainService.removeCustomDomain(id, userId); + + try { + await this.kubernetesService.updateIngress(app); + } catch (e: any) { + this.logger.warn(`Failed to update ingress for ${id} after domain removal: ${e.message}`); + } + + return { message: 'Custom domain removed successfully' }; + } + @Delete(':id') @ApiOperation({ summary: 'Delete an application and all its resources' }) async delete(@Param('id') id: string, @Request() req: any) { diff --git a/backend/src/applications/applications.module.ts b/backend/src/applications/applications.module.ts index 0ff8aaa..cc14ee8 100644 --- a/backend/src/applications/applications.module.ts +++ b/backend/src/applications/applications.module.ts @@ -1,21 +1,23 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ApplicationsService } from './applications.service'; +import { DomainService } from './domain.service'; import { ApplicationsController } from './applications.controller'; import { Application } from './entities/application.entity'; +import { PlatformSetting } from '../billing/entities/platform-setting.entity'; import { ClustersModule } from '../clusters/clusters.module'; import { KubernetesModule } from '../kubernetes/kubernetes.module'; import { DeploymentsModule } from '../deployments/deployments.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Application]), + TypeOrmModule.forFeature([Application, PlatformSetting]), ClustersModule, KubernetesModule, forwardRef(() => DeploymentsModule), ], controllers: [ApplicationsController], - providers: [ApplicationsService], - exports: [ApplicationsService], + providers: [ApplicationsService, DomainService], + exports: [ApplicationsService, DomainService], }) export class ApplicationsModule {} diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index 44fad5f..e1e51b4 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -8,7 +8,7 @@ import * as crypto from 'crypto'; import { Application } from './entities/application.entity'; import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto'; import { ClustersService } from '../clusters/clusters.service'; -import { UserRole, DatabaseType } from '../common/enums'; +import { UserRole, DatabaseType, CustomDomainStatus } from '../common/enums'; @Injectable() export class ApplicationsService { @@ -76,6 +76,8 @@ export class ApplicationsService { this.logger.log(`Generated DB credentials for app "${dto.name}" — user: ${dbUsername}`); } + const customDomain = dto.customDomain?.toLowerCase().trim() || undefined; + const app = this.appsRepository.create({ ...dto, userId, @@ -84,6 +86,8 @@ export class ApplicationsService { dbUsername, dbPassword, subdomain: `${dto.name}-${userId.split('-')[0]}`, + customDomain: customDomain || undefined, + customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, }); return this.appsRepository.save(app); } diff --git a/backend/src/applications/domain.service.ts b/backend/src/applications/domain.service.ts new file mode 100644 index 0000000..341791a --- /dev/null +++ b/backend/src/applications/domain.service.ts @@ -0,0 +1,191 @@ +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Not } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import * as dns from 'dns'; +import { Application } from './entities/application.entity'; +import { PlatformSetting } from '../billing/entities/platform-setting.entity'; +import { CustomDomainStatus } from '../common/enums'; + +@Injectable() +export class DomainService { + private readonly logger = new Logger(DomainService.name); + + constructor( + @InjectRepository(Application) + private appRepo: Repository, + @InjectRepository(PlatformSetting) + private settingsRepo: Repository, + private configService: ConfigService, + ) {} + + async getCustomDomainPrice(): Promise { + const setting = await this.settingsRepo.findOne({ + where: { key: 'custom_domain_monthly_price_toman' }, + }); + return setting ? Number(setting.value) : 0; + } + + async getPlatformCnameTarget(): Promise { + const setting = await this.settingsRepo.findOne({ + where: { key: 'platform_cname_target' }, + }); + return setting?.value || this.configService.get('platform.domain') || 'apps.cloudhost.ir'; + } + + async setCustomDomain(appId: string, userId: string, domain: string): Promise { + const app = await this.appRepo.findOne({ where: { id: appId, userId } }); + if (!app) throw new NotFoundException('Application not found'); + + const normalizedDomain = domain.toLowerCase().trim(); + + const existing = await this.appRepo.findOne({ + where: { + customDomain: normalizedDomain, + customDomainStatus: Not(CustomDomainStatus.NONE), + id: Not(appId), + }, + }); + if (existing) { + throw new ConflictException('This domain is already in use by another application'); + } + + app.customDomain = normalizedDomain; + app.customDomainStatus = CustomDomainStatus.PENDING_DNS; + app.customDomainVerifiedAt = undefined; + + const saved = await this.appRepo.save(app); + this.logger.log(`Custom domain set for ${app.name}: ${normalizedDomain} (pending DNS)`); + return saved; + } + + async verifyDns(appId: string, userId: string): Promise<{ + verified: boolean; + message: string; + application?: Application; + }> { + const app = await this.appRepo.findOne({ where: { id: appId, userId } }); + if (!app) throw new NotFoundException('Application not found'); + + if (!app.customDomain || app.customDomainStatus === CustomDomainStatus.NONE) { + throw new BadRequestException('No custom domain is set for this application'); + } + + const cnameTarget = await this.getPlatformCnameTarget(); + const expectedSubdomain = `${app.subdomain}.${cnameTarget}`; + + try { + const resolved = await this.resolveDomain(app.customDomain); + + const isValid = resolved.some( + (r) => + r === cnameTarget || + r === expectedSubdomain || + r.endsWith(`.${cnameTarget}`), + ); + + if (isValid) { + app.customDomainStatus = CustomDomainStatus.VERIFIED; + app.customDomainVerifiedAt = new Date(); + const saved = await this.appRepo.save(app); + this.logger.log(`DNS verified for ${app.name}: ${app.customDomain}`); + return { + verified: true, + message: 'DNS verification successful. Your custom domain is now active.', + application: saved, + }; + } + + return { + verified: false, + message: `DNS records do not point to ${expectedSubdomain}. Please check your CNAME record and try again.`, + }; + } catch (err: any) { + this.logger.warn(`DNS verification failed for ${app.customDomain}: ${err.message}`); + return { + verified: false, + message: `Could not resolve DNS for ${app.customDomain}. Make sure the CNAME record is set and DNS has propagated (may take up to 48 hours).`, + }; + } + } + + async removeCustomDomain(appId: string, userId: string): Promise { + const app = await this.appRepo.findOne({ where: { id: appId, userId } }); + if (!app) throw new NotFoundException('Application not found'); + + app.customDomain = undefined; + app.customDomainStatus = CustomDomainStatus.NONE; + app.customDomainVerifiedAt = undefined; + + const saved = await this.appRepo.save(app); + this.logger.log(`Custom domain removed for ${app.name}`); + return saved; + } + + async getDomainInfo(appId: string, userId: string): Promise<{ + customDomain?: string; + customDomainStatus: CustomDomainStatus; + customDomainVerifiedAt?: Date; + platformDomain: string; + fullPlatformUrl: string; + cnameTarget: string; + instructions: string[]; + }> { + const app = await this.appRepo.findOne({ where: { id: appId, userId } }); + if (!app) throw new NotFoundException('Application not found'); + + const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir'; + const cnameTarget = await this.getPlatformCnameTarget(); + const fullPlatformUrl = `${app.subdomain}.${platformDomain}`; + + const instructions = [ + `1. وارد پنل مدیریت دامنه خود شوید (مانند Cloudflare، Namecheap، GoDaddy و غیره)`, + `2. به بخش مدیریت DNS بروید`, + `3. یک رکورد CNAME اضافه کنید:`, + ` - Name/Host: @ یا www (بسته به دامنه‌تان)`, + ` - Type: CNAME`, + ` - Value/Target: ${fullPlatformUrl}`, + `4. اگر از دامنه اصلی (root domain) بدون www استفاده می‌کنید، برخی ثبت‌کنندگان از CNAME flattening پشتیبانی می‌کنند (مانند Cloudflare). در غیر این صورت از www استفاده کنید.`, + `5. بین ۵ تا ۳۰ دقیقه صبر کنید تا DNS منتشر شود (تا ۴۸ ساعت ممکن است طول بکشد)`, + `6. دکمه "تأیید DNS" را بزنید`, + ]; + + return { + customDomain: app.customDomain, + customDomainStatus: app.customDomainStatus, + customDomainVerifiedAt: app.customDomainVerifiedAt, + platformDomain, + fullPlatformUrl, + cnameTarget, + instructions, + }; + } + + private async resolveDomain(domain: string): Promise { + const resolver = new dns.promises.Resolver(); + resolver.setServers(['8.8.8.8', '1.1.1.1']); + const results: string[] = []; + + try { + const cnames = await resolver.resolveCname(domain); + results.push(...cnames); + } catch { + // No CNAME record — try A record + } + + try { + const aRecords = await resolver.resolve4(domain); + results.push(...aRecords); + } catch { + // No A record + } + + return results; + } +} diff --git a/backend/src/applications/dto/application.dto.ts b/backend/src/applications/dto/application.dto.ts index 8bde91c..65b664b 100644 --- a/backend/src/applications/dto/application.dto.ts +++ b/backend/src/applications/dto/application.dto.ts @@ -75,16 +75,31 @@ export class CreateApplicationDto { @IsBoolean() enableRedis?: boolean; + @ApiPropertyOptional({ example: '7.2', description: 'Redis version (7.2, 7.0, 6.2, 6.0)' }) + @IsOptional() + @IsString() + redisVersion?: string; + @ApiPropertyOptional({ example: false, description: 'Enable RabbitMQ for message queue' }) @IsOptional() @IsBoolean() enableRabbitmq?: boolean; + @ApiPropertyOptional({ example: '3.13', description: 'RabbitMQ version (3.13, 3.12, 3.11, 3.10)' }) + @IsOptional() + @IsString() + rabbitmqVersion?: string; + @ApiPropertyOptional({ example: false, description: 'Enable Elasticsearch for application logging' }) @IsOptional() @IsBoolean() enableElasticsearch?: boolean; + @ApiPropertyOptional({ example: '8.12', description: 'Elasticsearch version (8.12, 8.11, 7.17, 7.10)' }) + @IsOptional() + @IsString() + elasticsearchVersion?: string; + @ApiPropertyOptional({ example: ['/app/logs/*.log'], description: 'Custom log paths to collect' }) @IsOptional() @IsArray() @@ -152,6 +167,11 @@ export class CreateApplicationDto { @IsOptional() @IsString() poolId?: string; + + @ApiPropertyOptional({ example: 'www.example.com', description: 'Custom domain for the application (requires additional fee)' }) + @IsOptional() + @IsString() + customDomain?: string; } export class UpdateApplicationDto { @@ -240,6 +260,15 @@ export class UpdateApplicationDto { logPaths?: string[]; } +export class SetCustomDomainDto { + @ApiProperty({ example: 'www.example.com', description: 'The custom domain to assign' }) + @IsString() + @Matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, { + message: 'Invalid domain format (e.g. example.com or www.example.com)', + }) + domain: string; +} + export class ScaleResourcesDto { @ApiPropertyOptional({ example: '100m' }) @IsOptional() diff --git a/backend/src/applications/entities/application.entity.ts b/backend/src/applications/entities/application.entity.ts index 19180cc..197653f 100644 --- a/backend/src/applications/entities/application.entity.ts +++ b/backend/src/applications/entities/application.entity.ts @@ -8,7 +8,7 @@ import { OneToMany, JoinColumn, } from 'typeorm'; -import { AppRuntime, DatabaseType, BillingCycle, AppLifecycleStatus } from '../../common/enums'; +import { AppRuntime, DatabaseType, BillingCycle, AppLifecycleStatus, CustomDomainStatus } from '../../common/enums'; import { User } from '../../users/entities/user.entity'; import { Deployment } from '../../deployments/entities/deployment.entity'; @@ -54,12 +54,21 @@ export class Application { @Column({ default: false }) enableRedis: boolean; + @Column({ nullable: true, default: '7.2' }) + redisVersion: string; + @Column({ default: false }) enableRabbitmq: boolean; + @Column({ nullable: true, default: '3.13' }) + rabbitmqVersion: string; + @Column({ default: false }) enableElasticsearch: boolean; // For application logging + @Column({ nullable: true, default: '8.12' }) + elasticsearchVersion: string; + @Column({ type: 'jsonb', nullable: true }) logPaths: string[]; // Custom log paths to collect (e.g., ['/app/logs/*.log']) @@ -124,6 +133,16 @@ export class Application { @Column({ nullable: true }) subdomain: string; // .apps.cloudhost.local + // ── Custom Domain ───────────────────────────── + @Column({ nullable: true }) + customDomain?: string; // e.g. "www.example.com" + + @Column({ type: 'enum', enum: CustomDomainStatus, default: CustomDomainStatus.NONE }) + customDomainStatus: CustomDomainStatus; + + @Column({ type: 'timestamptz', nullable: true }) + customDomainVerifiedAt?: Date; + // ── Billing & Lifecycle ───────────────────────────── @Column({ nullable: true }) planId: string; // FK to ServicePlan diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index d7239be..5448d50 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -95,6 +95,24 @@ export class BillingController { return this.billingService.calculateCost(dto); } + // ─── Custom Domain Pricing ───────────────────────────────────── + + @Get('settings/custom-domain-price') + @ApiOperation({ summary: 'Get custom domain monthly price' }) + async getCustomDomainPrice() { + return this.billingService.getCustomDomainPrice(); + } + + @Patch('settings/custom-domain-price') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Set custom domain monthly price (Admin)' }) + async setCustomDomainPrice(@Body() body: { monthlyPrice: number }) { + if (body.monthlyPrice === undefined || body.monthlyPrice < 0) { + throw new BadRequestException('monthlyPrice must be a non-negative number'); + } + return this.billingService.setCustomDomainPrice(body.monthlyPrice); + } + // ─── Wallet (User) ─────────────────────────────────────────────── @Get('wallet') diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index 54cc672..d820ba8 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -5,6 +5,7 @@ import { ServicePlan } from './entities/service-plan.entity'; import { PricingRule } from './entities/pricing-rule.entity'; import { Wallet } from './entities/wallet.entity'; import { WalletTransaction } from './entities/wallet-transaction.entity'; +import { PlatformSetting } from './entities/platform-setting.entity'; import { TransactionType, BillingCycle, PricingResourceType } from '../common/enums'; import { CreateServicePlanDto, @@ -23,6 +24,7 @@ export class BillingService { @InjectRepository(PricingRule) private ruleRepo: Repository, @InjectRepository(Wallet) private walletRepo: Repository, @InjectRepository(WalletTransaction) private txRepo: Repository, + @InjectRepository(PlatformSetting) private settingsRepo: Repository, ) {} // ─── Service Plans ──────────────────────────────────────────────── @@ -143,6 +145,7 @@ export class BillingService { 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; @@ -184,6 +187,10 @@ export class BillingService { 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) { @@ -222,6 +229,33 @@ export class BillingService { 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 }; + } + // ─── Wallet ─────────────────────────────────────────────────────── async getOrCreateWallet(userId: string): Promise { diff --git a/backend/src/billing/dto/billing.dto.ts b/backend/src/billing/dto/billing.dto.ts index f0316f4..cef3ab8 100644 --- a/backend/src/billing/dto/billing.dto.ts +++ b/backend/src/billing/dto/billing.dto.ts @@ -136,6 +136,11 @@ export class CalculateCostDto { @IsOptional() @IsBoolean() enableElasticsearch?: boolean; + + @ApiPropertyOptional({ example: false, description: 'Enable custom domain with SSL' }) + @IsOptional() + @IsBoolean() + enableCustomDomain?: boolean; } // ─── Renewal & Upgrade DTOs ───────────────────────────────────────── diff --git a/backend/src/common/enums.ts b/backend/src/common/enums.ts index ce7eb66..7caf00a 100644 --- a/backend/src/common/enums.ts +++ b/backend/src/common/enums.ts @@ -90,6 +90,7 @@ export enum PricingResourceType { REDIS_ADDON = 'redis_addon', // Price for Redis addon RABBITMQ_ADDON = 'rabbitmq_addon', // Price for RabbitMQ addon ELASTICSEARCH_ADDON = 'elasticsearch_addon', // Price for Elasticsearch addon + CUSTOM_DOMAIN_ADDON = 'custom_domain_addon', // Price for custom domain with SSL } export enum TransactionType { @@ -98,6 +99,14 @@ export enum TransactionType { REFUND = 'refund', // Refund } +// ── Custom Domain ──────────────────────────────────── + +export enum CustomDomainStatus { + NONE = 'none', + PENDING_DNS = 'pending_dns', + VERIFIED = 'verified', +} + // ── Application Lifecycle ───────────────────────────── export enum AppLifecycleStatus { diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index eab131b..cf0fea2 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -8,7 +8,7 @@ import { promisify } from 'util'; import { PassThrough } from 'stream'; import { ClustersService } from '../clusters/clusters.service'; import { Application } from '../applications/entities/application.entity'; -import { AppRuntime, DatabaseType } from '../common/enums'; +import { AppRuntime, DatabaseType, CustomDomainStatus } from '../common/enums'; import { HelmService } from './helm.service'; const execFileAsync = promisify(execFile); @@ -118,6 +118,9 @@ export class KubernetesService implements OnModuleInit { subdomain: app.subdomain || app.name, domain: domain, clusterIssuer: 'letsencrypt-prod', + customDomain: (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED) + ? app.customDomain + : '', }, registry: { url: pullRegistryUrl, @@ -182,6 +185,57 @@ export class KubernetesService implements OnModuleInit { } } + async updateIngress(app: Application): Promise { + const domain = this.configService.get('platform.domain'); + const subdomain = app.subdomain || app.name; + const namespace = `user-${app.userId.split('-')[0]}`; + const customDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED) + ? app.customDomain : undefined; + + try { + const kubeconfig = await this.getKubeconfig(app.clusterId); + const imageUri = app.latestImageTag + ? `${this.configService.get('registry.pullUrl') || 'localhost:30500'}/${app.name}:${app.latestImageTag}` + : ''; + const values = this.buildHelmValues(app, imageUri); + await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig); + this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`); + } catch (helmError: any) { + this.logger.warn(`Helm ingress update failed for ${app.name}, using direct K8s API: ${helmError.message}`); + const { networkingApi } = await this.getK8sClient(app.clusterId); + const ctx: ManifestContext = { + appName: app.name, + namespace, + image: '', + port: app.port, + replicas: app.replicas, + cpuRequest: app.cpuRequest, + cpuLimit: app.cpuLimit, + memoryRequest: app.memoryRequest, + memoryLimit: app.memoryLimit, + envVars: app.envVars || {}, + runtime: app.runtime, + databaseType: app.databaseType, + domain, + subdomain, + dbUsername: app.dbUsername || '', + dbPassword: app.dbPassword || '', + dbVersion: app.dbVersion || '', + dbStorageSize: app.dbStorageSize || '1Gi', + appStorageSize: app.appStorageSize || '2Gi', + enableRedis: app.enableRedis || false, + redisVersion: app.redisVersion || '7.2', + enableRabbitmq: app.enableRabbitmq || false, + rabbitmqVersion: app.rabbitmqVersion || '3.13', + enableElasticsearch: app.enableElasticsearch || false, + elasticsearchVersion: app.elasticsearchVersion || '8.12', + logPaths: app.logPaths || [], + }; + await this.applyIngress(networkingApi, ctx, customDomain); + this.logger.log(`Updated ingress for ${app.name} via K8s API (customDomain: ${customDomain || 'none'})`); + } + } + // ── Helm-based deployment ───────────────────────────────────────── private async deployViaHelm(app: Application, imageUri: string): Promise> { @@ -278,7 +332,9 @@ export class KubernetesService implements OnModuleInit { manifests.service = await this.applyService(coreApi, context); // 6. Create Ingress - manifests.ingress = await this.applyIngress(networkingApi, context); + const customDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED) + ? app.customDomain : undefined; + manifests.ingress = await this.applyIngress(networkingApi, context, customDomain); this.logger.log(`Successfully deployed ${app.name} to namespace ${context.namespace} via K8s API`); } catch (error: any) { @@ -718,8 +774,40 @@ export class KubernetesService implements OnModuleInit { return service; } - private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext): Promise { + private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext, customDomain?: string): Promise { const host = `${ctx.subdomain}.${ctx.domain}`; + const rules: k8s.V1IngressRule[] = [ + { + host, + http: { + paths: [ + { + path: '/', + pathType: 'Prefix', + backend: { service: { name: ctx.appName, port: { number: 80 } } }, + }, + ], + }, + }, + ]; + const tlsHosts = [host]; + + if (customDomain) { + rules.push({ + host: customDomain, + http: { + paths: [ + { + path: '/', + pathType: 'Prefix', + backend: { service: { name: ctx.appName, port: { number: 80 } } }, + }, + ], + }, + }); + tlsHosts.push(customDomain); + } + const ingress: k8s.V1Ingress = { apiVersion: 'networking.k8s.io/v1', kind: 'Ingress', @@ -732,21 +820,8 @@ export class KubernetesService implements OnModuleInit { }, spec: { ingressClassName: 'nginx', - rules: [ - { - host, - http: { - paths: [ - { - path: '/', - pathType: 'Prefix', - backend: { service: { name: ctx.appName, port: { number: 80 } } }, - }, - ], - }, - }, - ], - tls: [{ hosts: [host], secretName: `${ctx.appName}-tls` }], + rules, + tls: [{ hosts: tlsHosts, secretName: `${ctx.appName}-tls` }], }, }; diff --git a/backend/src/seed.ts b/backend/src/seed.ts index 1ff3585..2882ebd 100644 --- a/backend/src/seed.ts +++ b/backend/src/seed.ts @@ -1,7 +1,10 @@ import { NestFactory } from '@nestjs/core'; import * as bcrypt from 'bcrypt'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { AppModule } from './app.module'; import { UsersService } from './users/users.service'; +import { PlatformSetting } from './billing/entities/platform-setting.entity'; import { UserRole } from './common/enums'; /** @@ -47,6 +50,24 @@ async function bootstrap() { console.log(` Email: ${email}`); console.log(` Password: ${password}`); + // Seed default platform settings + const settingsRepo = app.get>(getRepositoryToken(PlatformSetting)); + + const defaults = [ + { key: 'custom_domain_monthly_price_toman', value: '50000', description: 'Monthly price for custom domain addon (Toman)' }, + { key: 'platform_cname_target', value: 'apps.cloudhost.ir', description: 'CNAME target shown to users for custom domain setup' }, + ]; + + for (const d of defaults) { + const existing = await settingsRepo.findOne({ where: { key: d.key } }); + if (!existing) { + await settingsRepo.save(settingsRepo.create(d)); + console.log(`✅ Platform setting seeded: ${d.key} = ${d.value}`); + } else { + console.log(`⚠️ Platform setting already exists: ${d.key} = ${existing.value}`); + } + } + await app.close(); } diff --git a/frontend/src/app/dashboard/admin/billing/page.tsx b/frontend/src/app/dashboard/admin/billing/page.tsx index 9cb4e48..318ec50 100644 --- a/frontend/src/app/dashboard/admin/billing/page.tsx +++ b/frontend/src/app/dashboard/admin/billing/page.tsx @@ -5,7 +5,7 @@ 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 } from 'lucide-react'; +import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock, Globe } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; const runtimeOptions = [ @@ -34,9 +34,13 @@ const resourceLabels: Record = { 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', }; -const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon']; +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']; interface RuleForm { resourceType: PricingResourceType; @@ -323,12 +327,108 @@ export default function AdminBillingPage() { )} + {/* ─── 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() { diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index 8806799..41d341f 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -83,6 +83,10 @@ export default function AppDetailPage() { newCost: { hourly: number }; } | null>(null); + // ── Custom Domain ────────────────────────────────── + const [showDomainSetup, setShowDomainSetup] = useState(false); + const [customDomainInput, setCustomDomainInput] = useState(''); + const { data: app, isLoading } = useQuery({ queryKey: ['application', appId], queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data), @@ -226,6 +230,60 @@ export default function AppDetailPage() { const needsRenewal = app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion'; const isExpiringSoon = app?.planExpiresAt && new Date(app.planExpiresAt) <= new Date(Date.now() + 24 * 60 * 60 * 1000); + // ─── Custom Domain ────────────────────────────────── + const { data: domainInfo, refetch: refetchDomainInfo } = useQuery<{ + customDomain: string | null; + customDomainStatus: string; + platformDomain: string; + fullPlatformUrl: string; + cnameTarget: string; + instructions: string[]; + }>({ + queryKey: ['domain-info', appId], + queryFn: () => api.get(`/applications/${appId}/domain`).then((r) => r.data), + enabled: showDomainSetup || (!!app && (app.customDomainStatus === 'pending_dns' || app.customDomainStatus === 'verified')), + }); + + const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({ + queryKey: ['custom-domain-price'], + queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data), + }); + + const setDomainMutation = useMutation({ + mutationFn: (domain: string) => api.post(`/applications/${appId}/domain`, { domain }), + onSuccess: () => { + toast.success('دامنه تنظیم شد. لطفاً رکورد DNS را اضافه کنید.'); + queryClient.invalidateQueries({ queryKey: ['application', appId] }); + refetchDomainInfo(); + setCustomDomainInput(''); + }, + onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در تنظیم دامنه'), + }); + + const verifyDnsMutation = useMutation({ + mutationFn: () => api.post(`/applications/${appId}/domain/verify`), + onSuccess: (res) => { + if (res.data.verified) { + toast.success('دامنه با موفقیت تأیید شد!'); + } else { + toast.warning(res.data.message || 'DNS هنوز آماده نیست. لطفاً بعداً تلاش کنید.'); + } + queryClient.invalidateQueries({ queryKey: ['application', appId] }); + refetchDomainInfo(); + }, + onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در تأیید DNS'), + }); + + const removeDomainMutation = useMutation({ + mutationFn: () => api.delete(`/applications/${appId}/domain`), + onSuccess: () => { + toast.success('دامنه اختصاصی حذف شد'); + queryClient.invalidateQueries({ queryKey: ['application', appId] }); + refetchDomainInfo(); + }, + onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در حذف دامنه'), + }); + // ─── Snapshots ────────────────────────────────────── const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery({ queryKey: ['snapshots', appId], @@ -679,7 +737,7 @@ export default function AppDetailPage() {

- {app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} · {app.subdomain}.apps.cloudhost.local + {app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} · {app.customDomain && app.customDomainStatus === 'verified' ? app.customDomain : `${app.subdomain}.${domainInfo?.platformDomain || 'apps.cloudhost.ir'}`}

@@ -1206,6 +1264,168 @@ export default function AppDetailPage() { + {/* Custom Domain */} +
+
+

+ دامنه +

+ {!showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && ( + + )} +
+ + {/* Platform domain (always shown) */} +
+
+
+

دامنه پلتفرم

+

+ {app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'} +

+
+ فعال +
+
+ + {/* Custom domain - verified */} + {app.customDomain && app.customDomainStatus === 'verified' && ( +
+
+
+

دامنه اختصاصی

+

{app.customDomain}

+

+ SSL فعال — تأیید شده در{' '} + {app.customDomainVerifiedAt ? new Date(app.customDomainVerifiedAt).toLocaleString('fa-IR') : ''} +

+
+ +
+
+ )} + + {/* Custom domain - pending DNS */} + {app.customDomain && app.customDomainStatus === 'pending_dns' && ( +
+
+
+

دامنه اختصاصی — در انتظار تأیید DNS

+

{app.customDomain}

+
+
+ + +
+
+ + {/* DNS Instructions */} + {domainInfo?.instructions && ( +
+

راهنمای تنظیم DNS

+
+ {domainInfo.instructions.map((step, i) => ( +

+ {step} +

+ ))} +
+
+

CNAME Target:

+
+ + {domainInfo.fullPlatformUrl} + + +
+
+
+ )} +
+ )} + + {/* Domain setup form */} + {showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && ( +
+

تنظیم دامنه اختصاصی

+ {domainPriceData && domainPriceData.monthlyPrice > 0 && ( +
+

+ + هزینه دامنه اختصاصی: {domainPriceData.monthlyPrice.toLocaleString('fa-IR')} تومان / ماهانه +

+

+ این هزینه در محاسبه کلی هزینه‌ها در نظر گرفته می‌شود. +

+
+ )} +
+ setCustomDomainInput(e.target.value)} + placeholder="example.com or www.example.com" + className="input-field flex-1 font-mono text-sm" + /> + + +
+
+ )} +
+ {/* Database Info & Dump Upload */} {app.databaseType !== 'none' && (
diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx index 05977a5..e8a15eb 100644 --- a/frontend/src/app/dashboard/deploy/page.tsx +++ b/frontend/src/app/dashboard/deploy/page.tsx @@ -7,7 +7,7 @@ import api from '@/lib/api'; import { useAuthStore } from '@/lib/store'; import { toast } from 'react-toastify'; import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types'; -import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2 } from 'lucide-react'; +import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe } from 'lucide-react'; const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review']; @@ -50,8 +50,11 @@ export default function DeployPage() { dbStorageSize: '1', appStorageSize: '2', enableRedis: false, + redisVersion: '7.2', enableRabbitmq: false, + rabbitmqVersion: '3.13', enableElasticsearch: false, + elasticsearchVersion: '8.12', }); const [envKey, setEnvKey] = useState(''); const [envVal, setEnvVal] = useState(''); @@ -86,9 +89,19 @@ export default function DeployPage() { enabled: isAdmin, }); + // ── Custom Domain ────────────────────────────── + const [enableCustomDomain, setEnableCustomDomain] = useState(false); + const [customDomainInput, setCustomDomainInput] = useState(''); + + const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({ + queryKey: ['custom-domain-price'], + queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data), + enabled: step >= 2, + }); + // Cost calculation for the review step const { data: costData, isLoading: costLoading } = useQuery({ - queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch], + queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch, enableCustomDomain], queryFn: () => api.post('/billing/calculate', { runtime: form.runtime, databaseType: form.databaseType, @@ -100,6 +113,7 @@ export default function DeployPage() { enableRedis: form.enableRedis, enableRabbitmq: form.enableRabbitmq, enableElasticsearch: form.enableElasticsearch, + enableCustomDomain, }).then((r) => r.data), enabled: step === 3, }); @@ -330,6 +344,9 @@ export default function DeployPage() { if (payload.appStorageSize) { payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`; } + if (enableCustomDomain && customDomainInput.trim()) { + payload.customDomain = customDomainInput.trim(); + } createMutation.mutate(payload); }; @@ -1257,93 +1274,202 @@ export default function DeployPage() {
{/* Redis */} -
+ {form.enableRedis && ( -
-

REDIS_HOST, REDIS_PASSWORD, REDIS_URL will be available

+
+
+ + +
+

REDIS_HOST, REDIS_PASSWORD, REDIS_URL

)} - +
{/* RabbitMQ */} - {form.enableRabbitmq && ( -
-

RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL will be available

+
+
+ + +
+

RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL

)} - +
{/* Elasticsearch */} - {form.enableElasticsearch && ( -
-

Logs collected via Fluent Bit sidecar

+
+
+ + +
+

Logs collected via Fluent Bit sidecar

)} - +
+ + {/* Log Paths Configuration - shown when Elasticsearch is enabled */} + {form.enableElasticsearch && ( +
+
+ + + + + + +

Log File Paths

+ (optional) +
+

+ Specify which log files to collect. Leave empty for default paths based on runtime. +

+
+ {(form.logPaths || []).map((path, idx) => ( +
+ { + const newPaths = [...(form.logPaths || [])]; + newPaths[idx] = e.target.value; + setForm({ ...form, logPaths: newPaths }); + }} + placeholder="/var/log/app/*.log" + /> + +
+ ))} + +
+
+

+ Default paths by runtime:
+ • Node.js/Go/Python: /app/logs/*.log
+ • Laravel/PHP: /var/www/html/storage/logs/*.log
+ • WordPress: /var/www/html/wp-content/debug.log +

+
+
+ )} + {(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (

- Each enabled service adds to the monthly cost. Services are deployed in your namespace and not shared. + {form.enableElasticsearch + ? 'Logs are sent to a centralized Elasticsearch cluster. View your logs in Kibana dashboard.' + : 'Each enabled service adds to the monthly cost. Services are deployed in your namespace.'}

)} @@ -1803,6 +1929,55 @@ export default function DeployPage() { {Object.keys(form.envVars!).length} defined )} + {enableCustomDomain && customDomainInput && ( +
+ Custom Domain + {customDomainInput} +
+ )} + + + {/* Custom Domain Option */} +
+
+
+
+ +
+
+

دامنه اختصاصی

+

+ وبسایت را روی دامنه خود ببینید (با SSL رایگان) + {domainPriceData && domainPriceData.monthlyPrice > 0 && ( + — {domainPriceData.monthlyPrice.toLocaleString('fa-IR')} تومان/ماه + )} +

+
+
+ +
+ {enableCustomDomain && ( +
+ + setCustomDomainInput(e.target.value)} + placeholder="example.com or www.example.com" + className="input-field w-full font-mono text-sm" + /> +

+ بعد از دیپلوی، باید رکورد DNS دامنه خود را تنظیم کنید. راهنمای کامل در صفحه جزئیات اپلیکیشن نمایش داده می‌شود. +

+
+ )}
{/* Cost Breakdown */} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9d43d0b..5bb1f09 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -39,11 +39,17 @@ export interface Application { poolId?: string; latestImageTag?: string; subdomain?: string; + customDomain?: string; + customDomainStatus?: 'none' | 'pending_dns' | 'verified'; + customDomainVerifiedAt?: string; deployments?: Deployment[]; // Optional services enableRedis?: boolean; + redisVersion?: string; enableRabbitmq?: boolean; + rabbitmqVersion?: string; enableElasticsearch?: boolean; + elasticsearchVersion?: string; logPaths?: string[]; // Billing & Lifecycle planId?: string; @@ -127,10 +133,14 @@ export interface CreateApplicationDto { port?: number; clusterId?: string; poolId?: string; + customDomain?: string; // Optional services enableRedis?: boolean; + redisVersion?: string; enableRabbitmq?: boolean; + rabbitmqVersion?: string; enableElasticsearch?: boolean; + elasticsearchVersion?: string; logPaths?: string[]; } @@ -263,7 +273,7 @@ export interface TicketStats { // ─── Billing types ────────────────────────────────── export type BillingCycle = 'hourly' | 'monthly' | 'yearly'; -export type PricingResourceType = 'base_fee' | 'cpu_per_core' | 'memory_per_gb' | 'storage_per_gb' | 'database_addon'; +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 {