diff --git a/backend/migrations/003_resource_credits.sql b/backend/migrations/003_resource_credits.sql new file mode 100644 index 0000000..85f6b09 --- /dev/null +++ b/backend/migrations/003_resource_credits.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS resource_credits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + "sourceAppName" VARCHAR, + runtime VARCHAR NOT NULL, + "databaseType" VARCHAR NOT NULL, + "cpuLimit" VARCHAR NOT NULL, + "memoryLimit" VARCHAR NOT NULL, + replicas INT NOT NULL DEFAULT 1, + "dbStorageSize" VARCHAR, + "appStorageSize" VARCHAR, + "enableRedis" BOOLEAN NOT NULL DEFAULT false, + "enableRabbitmq" BOOLEAN NOT NULL DEFAULT false, + "enableElasticsearch" BOOLEAN NOT NULL DEFAULT false, + "billingCycle" VARCHAR, + "expiresAt" TIMESTAMPTZ NOT NULL, + "consumedAt" TIMESTAMPTZ, + "appliedApplicationId" VARCHAR, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_resource_credits_user_active + ON resource_credits ("userId", "expiresAt") + WHERE "consumedAt" IS NULL; diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index 3664f30..3acfbe7 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -29,6 +29,8 @@ import { KubernetesService } from '../kubernetes/kubernetes.service'; import { DeploymentsService } from '../deployments/deployments.service'; import { AccessService } from '../access/access.service'; import { CreateServiceAccessDto } from '../access/dto/service-access.dto'; +import { SnapshotsService } from '../snapshots/snapshots.service'; +import { BillingService } from '../billing/billing.service'; @ApiTags('Applications') @ApiBearerAuth() @@ -44,6 +46,10 @@ export class ApplicationsController { @Inject(forwardRef(() => DeploymentsService)) private readonly deploymentsService: DeploymentsService, private readonly accessService: AccessService, + @Inject(forwardRef(() => SnapshotsService)) + private readonly snapshotsService: SnapshotsService, + @Inject(forwardRef(() => BillingService)) + private readonly billingService: BillingService, ) {} private isStaff(role: string): boolean { @@ -384,20 +390,19 @@ export class ApplicationsController { } @Delete(':id') - @ApiOperation({ summary: 'Delete an application and all its resources' }) + @ApiOperation({ summary: 'Permanently delete an application and all its resources' }) async delete(@Param('id') id: string, @Request() req: any) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; - // 1. Get the app first const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); - // 2. Revoke temporary access grants + const credit = await this.billingService.createCreditFromDeletedApp(app); + try { await this.accessService.revokeAllForApplication(app.id); } catch (e: any) { this.logger.warn(`Access grant cleanup failed for ${app.name}: ${e.message}`); } - // 3. Delete K8s resources (deployment, service, ingress, db, secrets, PVCs) try { await this.kubernetesService.deleteApplication(app); this.logger.log(`Deleted K8s resources for ${app.name}`); @@ -405,16 +410,23 @@ export class ApplicationsController { this.logger.warn(`K8s cleanup failed for ${app.name}: ${e.message}`); } - // 4. Delete deployment records from DB try { await this.deploymentsService.deleteAllForApplication(app.id); } catch (e: any) { this.logger.warn(`Deployment records cleanup failed for ${app.name}: ${e.message}`); } - // 5. Delete app (also deletes uploaded files) + try { + await this.snapshotsService.deleteAllForApplication(app.id); + } catch (e: any) { + this.logger.warn(`Snapshot cleanup failed for ${app.name}: ${e.message}`); + } + await this.applicationsService.delete(id, isStaff ? app.userId : req.user.id); - return { message: `Application "${app.name}" and all resources deleted` }; + return { + message: `Application "${app.name}" and all resources deleted`, + resourceCredit: credit ? this.billingService.formatCreditForApi(credit) : null, + }; } } diff --git a/backend/src/applications/applications.module.ts b/backend/src/applications/applications.module.ts index bb44270..88f5fc4 100644 --- a/backend/src/applications/applications.module.ts +++ b/backend/src/applications/applications.module.ts @@ -9,6 +9,8 @@ import { ClustersModule } from '../clusters/clusters.module'; import { KubernetesModule } from '../kubernetes/kubernetes.module'; import { DeploymentsModule } from '../deployments/deployments.module'; import { AccessModule } from '../access/access.module'; +import { SnapshotsModule } from '../snapshots/snapshots.module'; +import { BillingModule } from '../billing/billing.module'; @Module({ imports: [ @@ -17,6 +19,8 @@ import { AccessModule } from '../access/access.module'; KubernetesModule, AccessModule, forwardRef(() => DeploymentsModule), + forwardRef(() => SnapshotsModule), + forwardRef(() => BillingModule), ], controllers: [ApplicationsController], providers: [ApplicationsService, DomainService], diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index 0542f73..f106616 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -163,7 +163,7 @@ export class ApplicationsService { async delete(id: string, userId: string): Promise { const app = await this.findOne(id, userId); - + // Delete uploaded files if (app.codePath) { try { diff --git a/backend/src/applications/entities/application.entity.ts b/backend/src/applications/entities/application.entity.ts index c793723..1517230 100644 --- a/backend/src/applications/entities/application.entity.ts +++ b/backend/src/applications/entities/application.entity.ts @@ -166,6 +166,14 @@ export class Application { @Column({ type: 'timestamptz', nullable: true }) scheduledDeletionAt: Date; // When the app will be permanently deleted + /** When the user voluntarily removed the app (data docked for restore). */ + @Column({ type: 'timestamptz', nullable: true }) + dockedAt?: Date; + + /** Snapshot captured at dock time — used to restore DB / wp-content / source. */ + @Column({ nullable: true }) + dockSnapshotId?: string; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index 5448d50..623969d 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -25,6 +25,7 @@ import { UpdateServicePlanDto, ChargeWalletDto, CalculateCostDto, + CalculateDeployCostDto, RenewApplicationDto, UpgradeResourcesDto, CalculateUpgradeCostDto, @@ -95,6 +96,17 @@ export class BillingController { return this.billingService.calculateCost(dto); } + @Post('calculate-deploy') + @ApiOperation({ + summary: 'Calculate deploy cost with prepaid resource credits applied', + }) + async calculateDeployCost(@Request() req: any, @Body() dto: CalculateDeployCostDto) { + if (!Object.values(BillingCycle).includes(dto.cycle)) { + throw new BadRequestException(`Invalid billing cycle: ${dto.cycle}`); + } + return this.billingService.calculateDeployPayment(req.user.id, dto, dto.cycle); + } + // ─── Custom Domain Pricing ───────────────────────────────────── @Get('settings/custom-domain-price') @@ -133,49 +145,94 @@ export class BillingController { return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50); } + @Get('resource-credits') + @ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' }) + async getResourceCredits(@Request() req: any) { + const credits = await this.billingService.getActiveCredits(req.user.id); + return credits.map((c) => this.billingService.formatCreditForApi(c)); + } + @Post('wallet/pay/:applicationId') @ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' }) async payForApplication( @Request() req: any, @Param('applicationId') applicationId: string, - @Body() body: { planId: string; cycle: string }, + @Body() body: { planId?: string; cycle: string; amount?: number }, ) { const cycle = body.cycle as BillingCycle; if (!Object.values(BillingCycle).includes(cycle)) { throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`); } - // Calculate cost - const plan = await this.billingService.findPlan(body.planId); - const costs = await this.billingService.calculateCost({ - runtime: plan.runtime, - databaseType: 'none', // Will be refined per-app later - cpuLimit: '500m', - memoryLimit: '512Mi', - replicas: 1, - }); + const app = await this.applicationsService.findOne(applicationId, req.user.id); - const amount = cycle === BillingCycle.HOURLY ? costs.hourly - : cycle === BillingCycle.MONTHLY ? costs.monthly - : costs.yearly; + let amount = body.amount; + let planId = body.planId || app.planId || ''; - // Deduct from wallet - const tx = await this.billingService.deductWallet( + 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, - amount, - `Payment for app ${applicationId} (${cycle}) — plan: ${plan.name}`, - applicationId, + app, + cycle, ); - // Activate/reactivate the application - const app = await this.lifecycleService.activateApp(applicationId, cycle, body.planId); + let tx = null; + if (payment.amountDue > 0) { + tx = await this.billingService.deductWallet( + req.user.id, + payment.amountDue, + `Payment for app ${applicationId} (${cycle})`, + applicationId, + ); + } + + const activated = await this.lifecycleService.activateApp( + applicationId, + cycle, + planId, + payment.planExpiresAt, + ); return { transaction: tx, - application: { id: app.id, name: app.name, lifecycleStatus: app.lifecycleStatus, planExpiresAt: app.planExpiresAt }, - message: app.lifecycleStatus === AppLifecycleStatus.ACTIVE - ? `Application "${app.name}" activated until ${app.planExpiresAt?.toISOString()}` - : `Payment processed — application status: ${app.lifecycleStatus}`, + creditApplied: payment.creditId || null, + waivedAmount: payment.waivedAmount, + paidAmount: payment.amountDue, + application: { + id: activated.id, + name: activated.name, + lifecycleStatus: activated.lifecycleStatus, + planExpiresAt: activated.planExpiresAt, + }, + message: payment.waivedAmount > 0 + ? payment.amountDue > 0 + ? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.` + : `Application "${activated.name}" activated using your prepaid resource credit (no charge).` + : payment.amountDue > 0 + ? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}` + : `Application "${activated.name}" activated`, }; } diff --git a/backend/src/billing/billing.module.ts b/backend/src/billing/billing.module.ts index 50c5045..1d82415 100644 --- a/backend/src/billing/billing.module.ts +++ b/backend/src/billing/billing.module.ts @@ -7,13 +7,14 @@ 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 { ResourceCredit } from './entities/resource-credit.entity'; import { LifecycleModule } from '../lifecycle/lifecycle.module'; import { ApplicationsModule } from '../applications/applications.module'; import { KubernetesModule } from '../kubernetes/kubernetes.module'; @Module({ imports: [ - TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting]), + TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting, ResourceCredit]), forwardRef(() => LifecycleModule), forwardRef(() => ApplicationsModule), forwardRef(() => KubernetesModule), diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index 6302565..0a413ac 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -6,7 +6,7 @@ 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 { TransactionType, BillingCycle, PricingResourceType, DatabaseType } from '../common/enums'; import { CreateServicePlanDto, UpdateServicePlanDto, @@ -14,6 +14,8 @@ import { UpgradeResourcesDto, } from './dto/billing.dto'; import { Application } from '../applications/entities/application.entity'; +import { ResourceCredit } from './entities/resource-credit.entity'; +import { IsNull, MoreThan } from 'typeorm'; @Injectable() export class BillingService { @@ -25,6 +27,7 @@ export class BillingService { @InjectRepository(Wallet) private walletRepo: Repository, @InjectRepository(WalletTransaction) private txRepo: Repository, @InjectRepository(PlatformSetting) private settingsRepo: Repository, + @InjectRepository(ResourceCredit) private creditRepo: Repository, ) {} // ─── Service Plans ──────────────────────────────────────────────── @@ -366,6 +369,11 @@ export class BillingService { replicas: number; dbStorageSize?: string; appStorageSize?: string; + enableRedis?: boolean; + enableRabbitmq?: boolean; + enableElasticsearch?: boolean; + customDomain?: string; + customDomainStatus?: string; }): Promise<{ hourly: number; monthly: number; yearly: number }> { const result = await this.calculateCost({ runtime: app.runtime, @@ -375,6 +383,10 @@ export class BillingService { replicas: app.replicas, dbStorageSize: app.dbStorageSize, appStorageSize: app.appStorageSize, + enableRedis: app.enableRedis, + enableRabbitmq: app.enableRabbitmq, + enableElasticsearch: app.enableElasticsearch, + enableCustomDomain: !!app.customDomain && app.customDomainStatus === 'verified', }); return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly }; } @@ -470,4 +482,522 @@ export class BillingService { billingCycle: app.billingCycle, }; } + + // ─── Resource credits (prepaid resources after app deletion) ─────── + + private amountForCycle( + costs: { hourly: number; monthly: number; yearly: number }, + cycle: BillingCycle, + ): number { + switch (cycle) { + case BillingCycle.HOURLY: + return costs.hourly; + case BillingCycle.MONTHLY: + return costs.monthly; + case BillingCycle.YEARLY: + return costs.yearly; + default: + return costs.monthly; + } + } + + private storageGi(size?: string, fallback = 1): number { + if (!size) return fallback; + return parseFloat(String(size).replace(/Gi$/i, '')) || fallback; + } + + private appToResourceConfig( + app: Application | CalculateCostDto, + options?: { enableCustomDomain?: boolean }, + ) { + const enableCustomDomain = + options?.enableCustomDomain ?? + ('enableCustomDomain' in app + ? !!(app as CalculateCostDto).enableCustomDomain + : !!(app as Application).customDomain); + return { + runtime: app.runtime, + databaseType: app.databaseType, + cpuLimit: app.cpuLimit, + memoryLimit: app.memoryLimit, + replicas: app.replicas || 1, + dbStorageSize: app.dbStorageSize, + appStorageSize: app.appStorageSize, + enableRedis: !!app.enableRedis, + enableRabbitmq: !!app.enableRabbitmq, + enableElasticsearch: !!app.enableElasticsearch, + enableCustomDomain, + }; + } + + async createCreditFromDeletedApp(app: Application): Promise { + if (!app.planExpiresAt) return null; + const expiresAt = new Date(app.planExpiresAt); + if (expiresAt <= new Date()) return null; + + const credit = this.creditRepo.create({ + userId: app.userId, + sourceAppName: app.name, + runtime: app.runtime, + databaseType: app.databaseType, + cpuLimit: app.cpuLimit, + memoryLimit: app.memoryLimit, + replicas: app.replicas, + dbStorageSize: app.dbStorageSize || '1Gi', + appStorageSize: app.appStorageSize || '2Gi', + enableRedis: !!app.enableRedis, + enableRabbitmq: !!app.enableRabbitmq, + enableElasticsearch: !!app.enableElasticsearch, + billingCycle: app.billingCycle || BillingCycle.MONTHLY, + expiresAt, + }); + const saved = await this.creditRepo.save(credit); + this.logger.log(`Resource credit created for user ${app.userId} from deleted app ${app.name}`); + return saved; + } + + async getActiveCredits(userId: string): Promise { + return this.creditRepo.find({ + where: { + userId, + consumedAt: IsNull(), + expiresAt: MoreThan(new Date()), + }, + order: { expiresAt: 'ASC' }, + }); + } + + formatCreditForApi(credit: ResourceCredit) { + const now = Date.now(); + const remainingMs = Math.max(0, new Date(credit.expiresAt).getTime() - now); + const remainingDays = Math.floor(remainingMs / 86400000); + const remainingHours = Math.floor((remainingMs % 86400000) / 3600000); + return { + id: credit.id, + sourceAppName: credit.sourceAppName, + runtime: credit.runtime, + databaseType: credit.databaseType, + cpuLimit: credit.cpuLimit, + memoryLimit: credit.memoryLimit, + replicas: credit.replicas, + dbStorageSize: credit.dbStorageSize, + appStorageSize: credit.appStorageSize, + enableRedis: credit.enableRedis, + enableRabbitmq: credit.enableRabbitmq, + enableElasticsearch: credit.enableElasticsearch, + billingCycle: credit.billingCycle, + expiresAt: credit.expiresAt, + remainingMs, + remainingLabel: + remainingDays > 0 ? `${remainingDays}d ${remainingHours}h` : `${remainingHours}h`, + }; + } + + configWithinCredit( + config: ReturnType, + credit: ResourceCredit, + ): boolean { + if (config.runtime !== credit.runtime) return false; + if ( + credit.databaseType !== DatabaseType.NONE && + config.databaseType !== credit.databaseType + ) { + return false; + } + if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) { + return false; + } + if ( + this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit) + ) { + return false; + } + if (config.replicas > credit.replicas) return false; + if (config.enableRedis && !credit.enableRedis) return false; + if (config.enableRabbitmq && !credit.enableRabbitmq) return false; + if (config.enableElasticsearch && !credit.enableElasticsearch) return false; + if (this.storageGi(config.dbStorageSize, 1) > this.storageGi(credit.dbStorageSize, 1)) { + return false; + } + if (this.storageGi(config.appStorageSize, 2) > this.storageGi(credit.appStorageSize, 2)) { + return false; + } + return true; + } + + /** Credit applies when runtime (and DB type, if any) match — upgrades are charged as extras. */ + async findApplicableCredit( + userId: string, + config: ReturnType, + ): Promise { + const credits = await this.getActiveCredits(userId); + return ( + credits.find( + (c) => + c.runtime === config.runtime && + (c.databaseType === DatabaseType.NONE || + c.databaseType === config.databaseType), + ) ?? null + ); + } + + private async costDelta( + base: CalculateCostDto, + withExtras: Partial, + cycle: BillingCycle, + ): Promise { + const a = await this.calculateCost({ ...base, ...withExtras }); + const b = await this.calculateCost(base); + return Math.max(0, this.amountForCycle(a, cycle) - this.amountForCycle(b, cycle)); + } + + private toCalculateDto( + config: ReturnType, + ): CalculateCostDto { + return { + runtime: config.runtime, + databaseType: config.databaseType, + cpuLimit: config.cpuLimit, + memoryLimit: config.memoryLimit, + replicas: config.replicas, + dbStorageSize: config.dbStorageSize, + appStorageSize: config.appStorageSize, + enableRedis: config.enableRedis, + enableRabbitmq: config.enableRabbitmq, + enableElasticsearch: config.enableElasticsearch, + enableCustomDomain: config.enableCustomDomain, + }; + } + + /** Baseline config covered by the prepaid credit (used for isolated add-on pricing). */ + private creditBaselineDto( + credit: ResourceCredit, + patch: Partial = {}, + ): CalculateCostDto { + return { + runtime: credit.runtime, + databaseType: credit.databaseType, + cpuLimit: credit.cpuLimit, + memoryLimit: credit.memoryLimit, + replicas: credit.replicas, + dbStorageSize: credit.dbStorageSize || '1Gi', + appStorageSize: credit.appStorageSize || '2Gi', + enableRedis: !!credit.enableRedis, + enableRabbitmq: !!credit.enableRabbitmq, + enableElasticsearch: !!credit.enableElasticsearch, + enableCustomDomain: false, + ...patch, + }; + } + + private getCreditProrateFactor(credit: ResourceCredit) { + const created = new Date(credit.createdAt).getTime(); + const expires = new Date(credit.expiresAt).getTime(); + const now = Date.now(); + const totalMs = Math.max(1, expires - created); + const remainingMs = Math.max(0, expires - now); + const factor = Math.min(1, remainingMs / totalMs); + const remainingDays = Math.max(1, Math.ceil(remainingMs / 86400000)); + const periodDays = Math.max(1, Math.ceil(totalMs / 86400000)); + return { factor, remainingDays, periodDays }; + } + + private prorateLabel(credit: ResourceCredit): string { + const { remainingDays, periodDays } = this.getCreditProrateFactor(credit); + return `prorated ${remainingDays}/${periodDays} days`; + } + + private async addExtraLineProrated( + items: { label: string; amount: number; fullPeriodAmount?: number }[], + from: CalculateCostDto, + to: Partial, + credit: ResourceCredit, + label: string, + ): Promise { + const billCycle = credit.billingCycle || BillingCycle.MONTHLY; + let fullPeriodAmount = await this.costDelta(from, to, billCycle); + + if (fullPeriodAmount <= 0) { + fullPeriodAmount = await this.getAddonPriceFromBreakdown(from, to, billCycle); + } + if (fullPeriodAmount <= 0) return; + + const { factor } = this.getCreditProrateFactor(credit); + const amount = Math.round(fullPeriodAmount * factor); + if (amount <= 0) return; + + items.push({ + label: `${label} (${this.prorateLabel(credit)})`, + amount, + fullPeriodAmount, + }); + } + + /** Fallback: read marginal addon price from cost breakdown labels. */ + private async getAddonPriceFromBreakdown( + from: CalculateCostDto, + to: Partial, + cycle: BillingCycle, + ): Promise { + const before = await this.calculateCost(from); + const after = await this.calculateCost({ ...from, ...to }); + const labelHints: string[] = []; + if (to.enableRedis) labelHints.push('Redis addon'); + if (to.enableRabbitmq) labelHints.push('RabbitMQ addon'); + if (to.enableElasticsearch) labelHints.push('Elasticsearch addon'); + if (to.enableCustomDomain) labelHints.push('Custom domain + SSL'); + if (to.databaseType && to.databaseType !== DatabaseType.NONE) { + labelHints.push('Database addon'); + } + + let sum = 0; + 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; + sum += Math.max(0, afterAmt - beforeAmt); + } + return sum; + } + + /** + * Line-item charges for anything beyond the prepaid credit bundle (prorated to remaining credit time). + */ + async calculateExtrasBeyondCredit( + config: ReturnType, + credit: ResourceCredit, + _cycle: BillingCycle, + ): Promise<{ + total: number; + items: { label: string; amount: number; fullPeriodAmount?: number }[]; + }> { + const items: { label: string; amount: number; fullPeriodAmount?: number }[] = []; + const baseline = this.creditBaselineDto(credit); + + if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) { + await this.addExtraLineProrated( + items, + baseline, + { cpuLimit: config.cpuLimit }, + credit, + `Extra CPU (${credit.cpuLimit} → ${config.cpuLimit})`, + ); + } + + if (this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit)) { + await this.addExtraLineProrated( + items, + baseline, + { memoryLimit: config.memoryLimit }, + credit, + `Extra memory (${credit.memoryLimit} → ${config.memoryLimit})`, + ); + } + + if (config.replicas > credit.replicas) { + await this.addExtraLineProrated( + items, + baseline, + { replicas: config.replicas }, + credit, + `Extra replicas (${credit.replicas} → ${config.replicas})`, + ); + } + + const appDb = this.storageGi(config.dbStorageSize, 1); + const creditDb = this.storageGi(credit.dbStorageSize, 1); + if (appDb > creditDb) { + await this.addExtraLineProrated( + items, + baseline, + { dbStorageSize: `${appDb}Gi` }, + credit, + `Extra database storage (${creditDb}Gi → ${appDb}Gi)`, + ); + } + + const appSt = this.storageGi(config.appStorageSize, 2); + const creditSt = this.storageGi(credit.appStorageSize, 2); + if (appSt > creditSt) { + await this.addExtraLineProrated( + items, + baseline, + { appStorageSize: `${appSt}Gi` }, + credit, + `Extra app storage (${creditSt}Gi → ${appSt}Gi)`, + ); + } + + if (config.databaseType !== DatabaseType.NONE && credit.databaseType === DatabaseType.NONE) { + await this.addExtraLineProrated( + items, + this.creditBaselineDto(credit, { + databaseType: DatabaseType.NONE, + dbStorageSize: undefined, + }), + { + databaseType: config.databaseType, + dbStorageSize: config.dbStorageSize || '1Gi', + }, + credit, + `Database (${config.databaseType})`, + ); + } + + if (config.enableRedis && !credit.enableRedis) { + await this.addExtraLineProrated( + items, + this.creditBaselineDto(credit, { enableRedis: false }), + { enableRedis: true }, + credit, + 'Redis', + ); + } + + if (config.enableRabbitmq && !credit.enableRabbitmq) { + await this.addExtraLineProrated( + items, + this.creditBaselineDto(credit, { enableRabbitmq: false }), + { enableRabbitmq: true }, + credit, + 'RabbitMQ', + ); + } + + if (config.enableElasticsearch && !credit.enableElasticsearch) { + await this.addExtraLineProrated( + items, + this.creditBaselineDto(credit, { enableElasticsearch: false }), + { enableElasticsearch: true }, + credit, + 'Elasticsearch', + ); + } + + if (config.enableCustomDomain) { + await this.addExtraLineProrated( + items, + this.creditBaselineDto(credit, { enableCustomDomain: false }), + { enableCustomDomain: true }, + credit, + 'Custom domain + SSL', + ); + } + + const total = items.reduce((sum, i) => sum + i.amount, 0); + return { total: Math.round(total), items }; + } + + /** + * Deploy cost preview — applies prepaid resource credits when the config fits. + */ + async calculateDeployPayment( + userId: string, + dto: CalculateCostDto, + cycle: BillingCycle, + ) { + const costs = await this.calculateCost(dto); + const fullAmount = this.amountForCycle(costs, cycle); + const config = this.appToResourceConfig({ + ...dto, + enableRedis: !!dto.enableRedis, + enableRabbitmq: !!dto.enableRabbitmq, + enableElasticsearch: !!dto.enableElasticsearch, + enableCustomDomain: !!dto.enableCustomDomain, + } as CalculateCostDto); + const credit = await this.findApplicableCredit(userId, config); + + if (!credit) { + return { + ...costs, + cycle, + fullAmount, + amountDue: fullAmount, + coveredAmount: 0, + waivedAmount: 0, + extrasBreakdown: [], + creditApplied: null, + prepaidCreditUsed: false, + }; + } + + const { total: extrasDue, items: extrasBreakdown } = + await this.calculateExtrasBeyondCredit(config, credit, cycle); + const waivedAmount = Math.max(0, fullAmount - extrasDue); + const prorate = this.getCreditProrateFactor(credit); + return { + ...costs, + cycle, + fullAmount, + amountDue: extrasDue, + coveredAmount: waivedAmount, + waivedAmount, + extrasBreakdown, + creditApplied: this.formatCreditForApi(credit), + prepaidCreditUsed: waivedAmount > 0, + prorateRemainingDays: prorate.remainingDays, + proratePeriodDays: prorate.periodDays, + }; + } + + /** + * Resolve wallet/gateway payment for an app — consumes a matching credit when applicable. + */ + async resolveAppPayment( + userId: string, + app: Application, + cycle: BillingCycle, + ): Promise<{ + fullAmount: number; + amountDue: number; + creditId?: string; + planExpiresAt?: Date; + waivedAmount: number; + }> { + const config = this.appToResourceConfig(app, { enableCustomDomain: !!app.customDomain }); + const fullCosts = await this.calculateCost(this.toCalculateDto(config)); + const fullAmount = this.amountForCycle(fullCosts, cycle); + + const credit = await this.findApplicableCredit(userId, config); + if (!credit) { + return { fullAmount, amountDue: fullAmount, waivedAmount: 0 }; + } + + const { total: extrasDue } = await this.calculateExtrasBeyondCredit( + config, + credit, + cycle, + ); + credit.consumedAt = new Date(); + credit.appliedApplicationId = app.id; + await this.creditRepo.save(credit); + this.logger.log( + `Applied resource credit ${credit.id} to app ${app.name} — due ${extrasDue} Toman (waived ${fullAmount - extrasDue})`, + ); + + return { + fullAmount, + amountDue: extrasDue, + creditId: credit.id, + planExpiresAt: credit.expiresAt, + waivedAmount: fullAmount - extrasDue, + }; + } + + /** @deprecated Use resolveAppPayment */ + async applyResourceCredit( + userId: string, + app: Application, + amount: number, + cycle: BillingCycle = BillingCycle.MONTHLY, + ): Promise<{ finalAmount: number; creditId?: string; waived: boolean; planExpiresAt?: Date }> { + const resolved = await this.resolveAppPayment(userId, app, cycle); + return { + finalAmount: resolved.amountDue, + creditId: resolved.creditId, + waived: resolved.waivedAmount > 0, + planExpiresAt: resolved.planExpiresAt, + }; + } } diff --git a/backend/src/billing/dto/billing.dto.ts b/backend/src/billing/dto/billing.dto.ts index cef3ab8..5a3b417 100644 --- a/backend/src/billing/dto/billing.dto.ts +++ b/backend/src/billing/dto/billing.dto.ts @@ -143,6 +143,12 @@ export class CalculateCostDto { enableCustomDomain?: boolean; } +export class CalculateDeployCostDto extends CalculateCostDto { + @ApiProperty({ enum: BillingCycle, example: 'monthly' }) + @IsEnum(BillingCycle) + cycle: BillingCycle; +} + // ─── Renewal & Upgrade DTOs ───────────────────────────────────────── export class RenewApplicationDto { diff --git a/backend/src/billing/entities/resource-credit.entity.ts b/backend/src/billing/entities/resource-credit.entity.ts new file mode 100644 index 0000000..3363dc0 --- /dev/null +++ b/backend/src/billing/entities/resource-credit.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { AppRuntime, DatabaseType, BillingCycle } from '../../common/enums'; + +/** Prepaid resources returned to the user when they delete an app before plan expiry. */ +@Entity('resource_credits') +export class ResourceCredit { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ nullable: true }) + sourceAppName: string; + + @Column({ type: 'enum', enum: AppRuntime }) + runtime: AppRuntime; + + @Column({ type: 'enum', enum: DatabaseType }) + databaseType: DatabaseType; + + @Column() + cpuLimit: string; + + @Column() + memoryLimit: string; + + @Column({ default: 1 }) + replicas: number; + + @Column({ nullable: true }) + dbStorageSize: string; + + @Column({ nullable: true }) + appStorageSize: string; + + @Column({ default: false }) + enableRedis: boolean; + + @Column({ default: false }) + enableRabbitmq: boolean; + + @Column({ default: false }) + enableElasticsearch: boolean; + + @Column({ type: 'enum', enum: BillingCycle, nullable: true }) + billingCycle: BillingCycle; + + @Column({ type: 'timestamptz' }) + expiresAt: Date; + + @Column({ type: 'timestamptz', nullable: true }) + consumedAt: Date; + + @Column({ nullable: true }) + appliedApplicationId: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/common/enums.ts b/backend/src/common/enums.ts index 440e074..a5adbba 100644 --- a/backend/src/common/enums.ts +++ b/backend/src/common/enums.ts @@ -127,5 +127,6 @@ export enum AppLifecycleStatus { ACTIVE = 'active', // Paid & running SUSPENDED = 'suspended', // Plan expired, pods scaled to 0, data retained PENDING_DELETION = 'pending_deletion', // Grace period — will be deleted if no payment + DOCKED = 'docked', // User removed service; data retained until plan expires DELETED = 'deleted', // Fully removed from K8s and DB } diff --git a/backend/src/lifecycle/app-lifecycle.service.ts b/backend/src/lifecycle/app-lifecycle.service.ts index eb1e18f..834fefb 100644 --- a/backend/src/lifecycle/app-lifecycle.service.ts +++ b/backend/src/lifecycle/app-lifecycle.service.ts @@ -54,12 +54,19 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy { * Activate or reactivate an application after successful payment. * Calculates the new expiry based on billing cycle and scales pods back up. */ - async activateApp(appId: string, billingCycle: BillingCycle, planId: string): Promise { + async activateApp( + appId: string, + billingCycle: BillingCycle, + planId: string, + planExpiresAt?: Date, + ): Promise { const app = await this.appRepo.findOne({ where: { id: appId } }); if (!app) throw new Error(`Application ${appId} not found`); const now = new Date(); - const expiresAt = this.calculateExpiry(now, billingCycle); + const expiresAt = planExpiresAt && planExpiresAt > now + ? planExpiresAt + : this.calculateExpiry(now, billingCycle); app.planId = planId; app.billingCycle = billingCycle; diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index dbe1217..7eaa08f 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -566,9 +566,14 @@ export default function AppDetailPage() { const deleteMutation = useMutation({ mutationFn: () => api.delete(`/applications/${appId}`), - onSuccess: () => { + onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ['applications'] }); - toast.success('Application deleted'); + queryClient.invalidateQueries({ queryKey: ['resource-credits'] }); + if (res.data?.resourceCredit) { + toast.success('Application deleted. Prepaid resources are on your dashboard.'); + } else { + toast.success('Application deleted'); + } router.push('/dashboard/apps'); }, onError: () => toast.error('Failed to delete application'), @@ -862,7 +867,11 @@ export default function AppDetailPage() { const handleDelete = async () => { const ok = await confirm({ title: `Delete "${app.name}"?`, - message: 'This will permanently remove:\n• All Kubernetes resources (pods, services, ingress)\n• Database and volumes\n• All deployment records\n• Uploaded source code', + message: + 'This will permanently remove all Kubernetes resources, data, and deployment records.\n\n' + + (app.planExpiresAt && new Date(app.planExpiresAt) > new Date() + ? 'Your remaining paid resources will appear on the dashboard for use on a new app at no extra charge.' + : ''), confirmText: 'Delete', variant: 'danger', }); @@ -937,11 +946,12 @@ export default function AppDetailPage() { )} + {/* Renewal Banner for Expired/Suspended Apps */} {needsRenewal && (
api.delete(`/applications/${id}`), - onSuccess: () => { + onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ['applications'] }); - toast.success('Application deleted'); + queryClient.invalidateQueries({ queryKey: ['resource-credits'] }); + if (res.data?.resourceCredit) { + toast.success('Application deleted. Your prepaid resources are shown on the dashboard.'); + } else { + toast.success('Application deleted'); + } }, onError: () => toast.error('Failed to delete application'), }); @@ -110,7 +115,6 @@ export default function AppsPage() {
) : ( <> - {/* Desktop Table */}
@@ -129,22 +133,21 @@ export default function AppsPage() { const lifecycle = app.lifecycleStatus || 'active'; const expiry = formatExpiry(app.planExpiresAt); return ( - +
- - {app.name} - + {app.name}
{app.runtime} - - {latestStatus} - + {latestStatus} @@ -168,8 +171,14 @@ export default function AppsPage() { View
- {/* Mobile Cards */}
{apps.map((app) => { const latestStatus = app.deployments?.[0]?.status || 'pending'; @@ -207,11 +215,8 @@ export default function AppsPage() {

{app.runtime}

- - {latestStatus} - + {latestStatus} - {/* Lifecycle & Expiry row */}
{lifecycle === 'suspended' && } @@ -225,8 +230,12 @@ export default function AppsPage() { )}
- {app.databaseType} - {app.replicas} replica{app.replicas > 1 ? 's' : ''} + + {app.databaseType} + + + {app.replicas} replica{app.replicas > 1 ? 's' : ''} +
); diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx index 3159221..8584b13 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 { parseDotenv } from '@/lib/parseDotenv'; import { useAuthStore } from '@/lib/store'; import { toast } from 'react-toastify'; -import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types'; +import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, DeployCostPreview, 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, Globe, Copy, AlertCircle, ShieldCheck } from 'lucide-react'; const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review']; @@ -123,22 +123,25 @@ export default function DeployPage() { }, }); - // 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, enableCustomDomain], - queryFn: () => api.post('/billing/calculate', { - runtime: form.runtime, - databaseType: form.databaseType, - cpuLimit: form.cpuLimit, - memoryLimit: form.memoryLimit, - replicas: form.replicas, - dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}` : undefined, - appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}`, - enableRedis: form.enableRedis, - enableRabbitmq: form.enableRabbitmq, - enableElasticsearch: form.enableElasticsearch, - enableCustomDomain, - }).then((r) => r.data), + const deployCostPayload = { + runtime: form.runtime, + databaseType: form.databaseType, + cpuLimit: form.cpuLimit, + memoryLimit: form.memoryLimit, + replicas: form.replicas, + dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}Gi` : undefined, + appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}Gi`, + enableRedis: form.enableRedis, + enableRabbitmq: form.enableRabbitmq, + enableElasticsearch: form.enableElasticsearch, + enableCustomDomain, + cycle: selectedCycle, + }; + + // Cost calculation for the review step (includes prepaid resource credits) + const { data: costData, isLoading: costLoading } = useQuery({ + queryKey: ['deploy-cost', deployCostPayload], + queryFn: () => api.post('/billing/calculate-deploy', deployCostPayload).then((r) => r.data), enabled: step === 3, }); @@ -149,9 +152,14 @@ export default function DeployPage() { enabled: step === 3, }); - const payAmount = costData ? costData[selectedCycle] : 0; + const payAmount = costData?.amountDue ?? 0; + const fullPrice = costData?.fullAmount ?? 0; + const coveredAmount = costData?.coveredAmount ?? 0; + const extrasBreakdown = costData?.extrasBreakdown ?? []; + const prepaidCreditUsed = costData?.prepaidCreditUsed ?? false; const walletBalance = walletData?.balance ?? 0; - const hasEnoughBalance = walletBalance >= payAmount; + const hasEnoughBalance = payAmount === 0 || walletBalance >= payAmount; + const requiresPayment = (costData?.monthly ?? 0) > 0 && payAmount > 0; const walletPayMutation = useMutation({ mutationFn: async () => { @@ -218,18 +226,20 @@ export default function DeployPage() { mutationFn: async () => { // Initiate gateway setDeployStage('paying'); - const { data: gw } = await api.post('/billing/gateway/initiate', { - amount: payAmount, - description: `Deploy: ${form.name} (${selectedCycle})`, - callbackUrl: `${window.location.origin}/dashboard/deploy`, - }); + if (payAmount > 0) { + const { data: gw } = await api.post('/billing/gateway/initiate', { + amount: payAmount, + description: `Deploy: ${form.name} (${selectedCycle})`, + callbackUrl: `${window.location.origin}/dashboard/deploy`, + }); - // In production, redirect to gw.gatewayUrl - // For now, auto-verify (simulated) - await api.post('/billing/gateway/verify', { - trackingCode: gw.trackingCode, - amount: payAmount, - }); + // In production, redirect to gw.gatewayUrl + // For now, auto-verify (simulated) + await api.post('/billing/gateway/verify', { + trackingCode: gw.trackingCode, + amount: payAmount, + }); + } // Now create the app setDeployStage('creating'); @@ -2227,6 +2237,18 @@ export default function DeployPage() {
Calculating...
) : costData && costData.monthly > 0 ? (
+ {prepaidCreditUsed && costData.creditApplied && ( +
+

Prepaid credit applied

+

+ Resources from "{costData.creditApplied.sourceAppName || 'deleted app'}" are covered + until {costData.creditApplied.remainingLabel} remaining. + {payAmount > 0 + ? ` You only pay for new add-ons below (prorated to ${costData.prorateRemainingDays ?? '?'}/${costData.proratePeriodDays ?? '?'} days left on your credit).` + : ' No charge for this deploy.'} +

+
+ )} {/* Billing cycle selector */}
{(['hourly', 'monthly', 'yearly'] as BillingCycle[]).map((cycle) => ( @@ -2244,14 +2266,67 @@ export default function DeployPage() { {cycle === 'hourly' ? 'Hourly' : cycle === 'monthly' ? 'Monthly' : 'Yearly'}

- {Number(costData[cycle]).toLocaleString('en-US')} + {prepaidCreditUsed && cycle === selectedCycle && fullPrice > payAmount ? ( + <> + + {Number(costData[cycle]).toLocaleString('en-US')} + + {Number(payAmount).toLocaleString('en-US')} + + ) : ( + Number(costData[cycle]).toLocaleString('en-US') + )}

Toman

))}
- {costData.breakdown && costData.breakdown.length > 0 && ( + {prepaidCreditUsed && ( +
+

Prepaid credit pricing

+
+ Full plan price ({selectedCycle}) + + {Number(fullPrice).toLocaleString('en-US')} T + +
+
+ Covered by prepaid credit + + −{Number(coveredAmount).toLocaleString('en-US')} T + +
+ {extrasBreakdown.length > 0 ? ( + <> +

Additional charges (you pay)

+ {extrasBreakdown.map((item, i) => ( +
+ {item.label} + + {item.fullPeriodAmount != null && item.fullPeriodAmount > item.amount && ( + + {Number(item.fullPeriodAmount).toLocaleString('en-US')} T full period + + )} + +{Number(item.amount).toLocaleString('en-US')} T + +
+ ))} + + ) : ( +

No additional charges — fully covered.

+ )} +
+ Amount due ({selectedCycle}) + + {Number(payAmount).toLocaleString('en-US')} Toman + +
+
+ )} + + {!prepaidCreditUsed && costData.breakdown && costData.breakdown.length > 0 && (

Breakdown

{costData.breakdown.map((item, i) => ( @@ -2262,6 +2337,12 @@ export default function DeployPage() {
))} +
+ Amount due ({selectedCycle}) + + {Number(payAmount).toLocaleString('en-US')} Toman + +
)} @@ -2271,7 +2352,12 @@ export default function DeployPage() { {/* Payment Method */} - {costData && costData.monthly > 0 && ( + {costData && costData.monthly > 0 && !requiresPayment && ( +
+ No payment required — your prepaid resource credit covers this deployment. +
+ )} + {requiresPayment && (

Payment Method

@@ -2286,7 +2372,9 @@ export default function DeployPage() {

Pay from Wallet

Balance: {Number(walletBalance).toLocaleString('en-US')} T - {!hasEnoughBalance && Insufficient balance} + {requiresPayment && !hasEnoughBalance && ( + Insufficient balance + )}

)} @@ -2337,6 +2432,8 @@ export default function DeployPage() { if (!costData || costData.monthly === 0) { // No pricing — deploy directly handleSubmit(); + } else if (payAmount === 0) { + walletPayMutation.mutate(); } else if (paymentMethod === 'wallet') { if (!hasEnoughBalance) { toast.error('Insufficient wallet balance. Please top up or use payment gateway.'); @@ -2355,7 +2452,9 @@ export default function DeployPage() { ? `Uploading... ${uploadProgress}%` : 'Processing...' : costData && costData.monthly > 0 - ? <> Pay & Deploy + ? payAmount === 0 + ? <> Deploy with prepaid credit + : <> Pay & Deploy : <> Deploy Application} )} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 292f8d1..a409d8d 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -6,7 +6,8 @@ import type { ReactNode } from 'react'; import api from '@/lib/api'; import { useAuthStore } from '@/lib/store'; import type { Application } from '@/types'; -import { Rocket, Package, Circle, Hexagon } from 'lucide-react'; +import { Rocket, Package, Circle, Hexagon, Wallet, Clock } from 'lucide-react'; +import type { ResourceCredit } from '@/types'; const statusColors: Record = { running: 'bg-emerald-100 text-emerald-700', @@ -36,6 +37,11 @@ export default function DashboardPage() { queryFn: () => api.get('/applications').then((r) => r.data), }); + const { data: resourceCredits = [] } = useQuery({ + queryKey: ['resource-credits'], + queryFn: () => api.get('/billing/resource-credits').then((r) => r.data), + }); + const runningApps = apps.filter( (a) => a.deployments?.some((d) => d.status === 'running'), ); @@ -55,6 +61,47 @@ export default function DashboardPage() {

+ {resourceCredits.length > 0 && ( +
+
+ +
+

Prepaid resource credits

+

+ If you delete an app before your plan ends, you can deploy a new app with the same resources at no extra charge until the credit expires. +

+
+
+
+ {resourceCredits.map((credit) => ( +
+

+ {credit.sourceAppName ? `From app “${credit.sourceAppName}”` : 'Resource credit'} +

+

+ + {credit.remainingLabel} remaining +

+
    +
  • CPU: {credit.cpuLimit}
  • +
  • RAM: {credit.memoryLimit}
  • +
  • Replicas: {credit.replicas}
  • +
  • DB: {credit.databaseType}
  • +
  • DB disk: {credit.dbStorageSize}
  • +
  • App disk: {credit.appStorageSize}
  • + {credit.enableRedis &&
  • Redis
  • } + {credit.enableRabbitmq &&
  • RabbitMQ
  • } + {credit.enableElasticsearch &&
  • Elasticsearch
  • } +
+ + Use on new app → + +
+ ))} +
+
+ )} + {/* Stats */}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d7b66e4..5d0c9c8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -144,6 +144,25 @@ export type DeploymentStatus = export type AppLifecycleStatus = 'active' | 'suspended' | 'pending_deletion' | 'deleted'; +export interface ResourceCredit { + id: string; + sourceAppName?: string; + runtime: string; + databaseType: string; + cpuLimit: string; + memoryLimit: string; + replicas: number; + dbStorageSize?: string; + appStorageSize?: string; + enableRedis: boolean; + enableRabbitmq: boolean; + enableElasticsearch: boolean; + billingCycle?: string; + expiresAt: string; + remainingMs: number; + remainingLabel: string; +} + export interface Cluster { id: string; name: string; @@ -399,6 +418,25 @@ export interface CostBreakdown { breakdown: { label: string; hourly: number; monthly: number; yearly: number }[]; } +export interface DeployExtraChargeLine { + label: string; + amount: number; + fullPeriodAmount?: number; +} + +export interface DeployCostPreview extends CostBreakdown { + cycle: BillingCycle; + fullAmount: number; + amountDue: number; + coveredAmount: number; + waivedAmount: number; + extrasBreakdown: DeployExtraChargeLine[]; + creditApplied: ResourceCredit | null; + prepaidCreditUsed: boolean; + prorateRemainingDays?: number; + proratePeriodDays?: number; +} + // ─── Snapshot / Rollback types ────────────────────── export type SnapshotType = 'pre_deploy' | 'manual';