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],
@@ -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;