Add prepaid resource credits with prorated deploy billing.
When users delete an app before plan expiry, remaining resources become credits for a new deploy. The deploy calculator shows covered vs additional charges, prices optional services correctly, and prorates extras to days left on the credit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -163,7 +163,7 @@ export class ApplicationsService {
|
||||
|
||||
async delete(id: string, userId: string): Promise<Application> {
|
||||
const app = await this.findOne(id, userId);
|
||||
|
||||
|
||||
// Delete uploaded files
|
||||
if (app.codePath) {
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Wallet>,
|
||||
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
||||
@InjectRepository(PlatformSetting) private settingsRepo: Repository<PlatformSetting>,
|
||||
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
|
||||
) {}
|
||||
|
||||
// ─── 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<ResourceCredit | null> {
|
||||
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<ResourceCredit[]> {
|
||||
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<typeof this.appToResourceConfig>,
|
||||
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<typeof this.appToResourceConfig>,
|
||||
): Promise<ResourceCredit | null> {
|
||||
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<CalculateCostDto>,
|
||||
cycle: BillingCycle,
|
||||
): Promise<number> {
|
||||
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<typeof this.appToResourceConfig>,
|
||||
): 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> = {},
|
||||
): 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<CalculateCostDto>,
|
||||
credit: ResourceCredit,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
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<CalculateCostDto>,
|
||||
cycle: BillingCycle,
|
||||
): Promise<number> {
|
||||
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<typeof this.appToResourceConfig>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Application> {
|
||||
async activateApp(
|
||||
appId: string,
|
||||
billingCycle: BillingCycle,
|
||||
planId: string,
|
||||
planExpiresAt?: Date,
|
||||
): Promise<Application> {
|
||||
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;
|
||||
|
||||
@@ -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() {
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleDelete} disabled={deleteMutation.isPending} className="btn-danger text-sm disabled:opacity-50">
|
||||
{deleteMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : 'Delete'}
|
||||
{deleteMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Renewal Banner for Expired/Suspended Apps */}
|
||||
{needsRenewal && (
|
||||
<div className={`rounded-xl p-4 border-2 ${
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application, AppLifecycleStatus } from '@/types';
|
||||
import type { Application } from '@/types';
|
||||
import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
|
||||
@@ -57,9 +57,14 @@ export default function AppsPage() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => 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() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block table-wrapper">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50/80">
|
||||
@@ -129,22 +133,21 @@ export default function AppsPage() {
|
||||
const lifecycle = app.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(app.planExpiresAt);
|
||||
return (
|
||||
<tr key={app.id} className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''}`}>
|
||||
<tr
|
||||
key={app.id}
|
||||
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''}`}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
|
||||
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">
|
||||
{app.name}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">{app.name}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.runtime}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
|
||||
@@ -168,8 +171,14 @@ export default function AppsPage() {
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
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() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="md:hidden grid gap-3">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
@@ -207,11 +215,8 @@ export default function AppsPage() {
|
||||
<p className="text-xs text-gray-500 capitalize">{app.runtime}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
|
||||
</div>
|
||||
{/* Lifecycle & Expiry row */}
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
|
||||
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
||||
@@ -225,8 +230,12 @@ export default function AppsPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><Database className="w-3 h-3" /> {app.databaseType}</span>
|
||||
<span className="flex items-center gap-1"><Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Database className="w-3 h-3" /> {app.databaseType}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -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<CostBreakdown>({
|
||||
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<DeployCostPreview>({
|
||||
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() {
|
||||
<div className="text-sm text-gray-400 text-center py-3">Calculating...</div>
|
||||
) : costData && costData.monthly > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{prepaidCreditUsed && costData.creditApplied && (
|
||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-900">
|
||||
<p className="font-medium">Prepaid credit applied</p>
|
||||
<p className="text-indigo-700 mt-0.5">
|
||||
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.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Billing cycle selector */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['hourly', 'monthly', 'yearly'] as BillingCycle[]).map((cycle) => (
|
||||
@@ -2244,14 +2266,67 @@ export default function DeployPage() {
|
||||
{cycle === 'hourly' ? 'Hourly' : cycle === 'monthly' ? 'Monthly' : 'Yearly'}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-emerald-700">
|
||||
{Number(costData[cycle]).toLocaleString('en-US')}
|
||||
{prepaidCreditUsed && cycle === selectedCycle && fullPrice > payAmount ? (
|
||||
<>
|
||||
<span className="block text-xs font-normal text-gray-400 line-through">
|
||||
{Number(costData[cycle]).toLocaleString('en-US')}
|
||||
</span>
|
||||
{Number(payAmount).toLocaleString('en-US')}
|
||||
</>
|
||||
) : (
|
||||
Number(costData[cycle]).toLocaleString('en-US')
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Toman</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{costData.breakdown && costData.breakdown.length > 0 && (
|
||||
{prepaidCreditUsed && (
|
||||
<div className="mt-2 pt-3 border-t border-emerald-200/50 space-y-2">
|
||||
<p className="text-xs font-medium text-gray-500">Prepaid credit pricing</p>
|
||||
<div className="flex justify-between text-xs py-1">
|
||||
<span className="text-gray-600">Full plan price ({selectedCycle})</span>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{Number(fullPrice).toLocaleString('en-US')} T
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs py-1 text-indigo-700">
|
||||
<span>Covered by prepaid credit</span>
|
||||
<span className="font-medium">
|
||||
−{Number(coveredAmount).toLocaleString('en-US')} T
|
||||
</span>
|
||||
</div>
|
||||
{extrasBreakdown.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs font-medium text-amber-700 pt-1">Additional charges (you pay)</p>
|
||||
{extrasBreakdown.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-xs py-1 gap-2">
|
||||
<span className="text-gray-600">{item.label}</span>
|
||||
<span className="text-amber-800 font-medium text-right shrink-0">
|
||||
{item.fullPeriodAmount != null && item.fullPeriodAmount > item.amount && (
|
||||
<span className="text-gray-400 line-through block text-[10px]">
|
||||
{Number(item.fullPeriodAmount).toLocaleString('en-US')} T full period
|
||||
</span>
|
||||
)}
|
||||
+{Number(item.amount).toLocaleString('en-US')} T
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-emerald-700">No additional charges — fully covered.</p>
|
||||
)}
|
||||
<div className="flex justify-between text-sm py-2 border-t border-emerald-200/50 font-semibold">
|
||||
<span className="text-gray-800">Amount due ({selectedCycle})</span>
|
||||
<span className="text-emerald-800">
|
||||
{Number(payAmount).toLocaleString('en-US')} Toman
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!prepaidCreditUsed && costData.breakdown && costData.breakdown.length > 0 && (
|
||||
<div className="mt-2 pt-3 border-t border-emerald-200/50">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Breakdown</p>
|
||||
{costData.breakdown.map((item, i) => (
|
||||
@@ -2262,6 +2337,12 @@ export default function DeployPage() {
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between text-sm py-2 border-t border-emerald-200/50 mt-1 font-semibold">
|
||||
<span className="text-gray-800">Amount due ({selectedCycle})</span>
|
||||
<span className="text-emerald-800">
|
||||
{Number(payAmount).toLocaleString('en-US')} Toman
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2271,7 +2352,12 @@ export default function DeployPage() {
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
{costData && costData.monthly > 0 && (
|
||||
{costData && costData.monthly > 0 && !requiresPayment && (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
|
||||
No payment required — your prepaid resource credit covers this deployment.
|
||||
</div>
|
||||
)}
|
||||
{requiresPayment && (
|
||||
<div className="bg-white rounded-xl p-5 border border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Payment Method</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -2286,7 +2372,9 @@ export default function DeployPage() {
|
||||
<p className="mt-2 font-semibold text-sm text-gray-900">Pay from Wallet</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Balance: {Number(walletBalance).toLocaleString('en-US')} T
|
||||
{!hasEnoughBalance && <span className="text-red-500 block mt-0.5">Insufficient balance</span>}
|
||||
{requiresPayment && !hasEnoughBalance && (
|
||||
<span className="text-red-500 block mt-0.5">Insufficient balance</span>
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
@@ -2304,7 +2392,14 @@ export default function DeployPage() {
|
||||
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Amount to pay ({selectedCycle})</span>
|
||||
<span className="text-lg font-bold text-gray-900">{Number(payAmount).toLocaleString('en-US')} Toman</span>
|
||||
<div className="text-right">
|
||||
{prepaidCreditUsed && fullPrice > payAmount && (
|
||||
<span className="block text-sm text-gray-400 line-through">
|
||||
{Number(fullPrice).toLocaleString('en-US')} Toman
|
||||
</span>
|
||||
)}
|
||||
<span className="text-lg font-bold text-gray-900">{Number(payAmount).toLocaleString('en-US')} Toman</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -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
|
||||
? <><CreditCard className="w-4 h-4 inline" /> Pay & Deploy</>
|
||||
? payAmount === 0
|
||||
? <><Rocket className="w-4 h-4 inline" /> Deploy with prepaid credit</>
|
||||
: <><CreditCard className="w-4 h-4 inline" /> Pay & Deploy</>
|
||||
: <><Rocket className="w-4 h-4 inline" /> Deploy Application</>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<ResourceCredit[]>({
|
||||
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() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{resourceCredits.length > 0 && (
|
||||
<div className="card border-2 border-indigo-100 bg-indigo-50/40 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Wallet className="w-5 h-5 text-indigo-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-indigo-900">Prepaid resource credits</h2>
|
||||
<p className="text-sm text-indigo-700 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{resourceCredits.map((credit) => (
|
||||
<div key={credit.id} className="rounded-xl border border-indigo-200 bg-white p-4 text-sm">
|
||||
<p className="font-semibold text-gray-900">
|
||||
{credit.sourceAppName ? `From app “${credit.sourceAppName}”` : 'Resource credit'}
|
||||
</p>
|
||||
<p className="mt-2 inline-flex items-center gap-1 text-indigo-700 font-medium">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{credit.remainingLabel} remaining
|
||||
</p>
|
||||
<ul className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-gray-600">
|
||||
<li>CPU: {credit.cpuLimit}</li>
|
||||
<li>RAM: {credit.memoryLimit}</li>
|
||||
<li>Replicas: {credit.replicas}</li>
|
||||
<li>DB: {credit.databaseType}</li>
|
||||
<li>DB disk: {credit.dbStorageSize}</li>
|
||||
<li>App disk: {credit.appStorageSize}</li>
|
||||
{credit.enableRedis && <li>Redis</li>}
|
||||
{credit.enableRabbitmq && <li>RabbitMQ</li>}
|
||||
{credit.enableElasticsearch && <li>Elasticsearch</li>}
|
||||
</ul>
|
||||
<Link href="/dashboard/deploy" className="mt-3 inline-block text-xs font-medium text-primary-600 hover:underline">
|
||||
Use on new app →
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="stat-card">
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user