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
@@ -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;
+81 -24
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
: 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`,
};
}
+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;