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:
keyhan
2026-05-15 17:37:44 +03:30
parent 35dd771f63
commit 5239e8aa94
17 changed files with 1021 additions and 96 deletions
@@ -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],
@@ -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;
+79 -22
View File
@@ -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
let amount = body.amount;
let planId = body.planId || app.planId || '';
if (amount === undefined || amount === null) {
if (!body.planId) {
throw new BadRequestException('planId or amount is required');
}
const plan = await this.billingService.findPlan(body.planId);
const costs = await this.billingService.calculateCostForApp({
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: app.cpuLimit,
memoryLimit: app.memoryLimit,
replicas: app.replicas,
dbStorageSize: app.dbStorageSize,
appStorageSize: app.appStorageSize,
enableRedis: app.enableRedis,
enableRabbitmq: app.enableRabbitmq,
enableElasticsearch: app.enableElasticsearch,
});
amount = cycle === BillingCycle.HOURLY ? costs.hourly
: cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
planId = body.planId;
}
// Deduct from wallet
const tx = await this.billingService.deductWallet(
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`,
};
}
+2 -1
View File
@@ -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),
+531 -1
View File
@@ -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,
};
}
}
+6
View File
@@ -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;
}
+1
View File
@@ -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;
+12 -2
View File
@@ -566,9 +566,14 @@ export default function AppDetailPage() {
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/applications/${appId}`),
onSuccess: () => {
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
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',
});
@@ -942,6 +951,7 @@ export default function AppDetailPage() {
</div>
</div>
{/* Renewal Banner for Expired/Suspended Apps */}
{needsRenewal && (
<div className={`rounded-xl p-4 border-2 ${
+27 -18
View File
@@ -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'] });
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>
);
+113 -14
View File
@@ -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', {
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}` : undefined,
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}`,
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,
}).then((r) => r.data),
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,6 +226,7 @@ export default function DeployPage() {
mutationFn: async () => {
// Initiate gateway
setDeployStage('paying');
if (payAmount > 0) {
const { data: gw } = await api.post('/billing/gateway/initiate', {
amount: payAmount,
description: `Deploy: ${form.name} (${selectedCycle})`,
@@ -230,6 +239,7 @@ export default function DeployPage() {
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 &quot;{costData.creditApplied.sourceAppName || 'deleted app'}&quot; 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">
{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,9 +2392,16 @@ 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>
<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>
)}
</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>
)}
+48 -1
View File
@@ -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">
+38
View File
@@ -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';