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() {
>
)}
- {deleteMutation.isPending ? <> > : 'Delete'}
+ {deleteMutation.isPending ? <> > : 'Delete'}
+
{/* 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
{
- const ok = await confirm({ title: 'Delete Application', message: `Are you sure you want to delete "${app.name}"?`, confirmText: 'Delete', variant: 'danger' });
+ const ok = await confirm({
+ title: 'Delete Application',
+ message: `Permanently delete "${app.name}" and all its data?\n\nIf your plan still has time left, the prepaid resources will appear on your dashboard for use on a new app at no extra charge.`,
+ confirmText: 'Delete',
+ variant: 'danger',
+ });
if (ok) deleteMutation.mutate(app.id);
}}
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors"
@@ -185,7 +194,6 @@ export default function AppsPage() {
- {/* 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
+ )}
Amount to pay ({selectedCycle})
- {Number(payAmount).toLocaleString('en-US')} Toman
+
+ {prepaidCreditUsed && fullPrice > payAmount && (
+
+ {Number(fullPrice).toLocaleString('en-US')} Toman
+
+ )}
+ {Number(payAmount).toLocaleString('en-US')} Toman
+
)}
@@ -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';