Add prepaid resource credits with prorated deploy billing.
When users delete an app before plan expiry, remaining resources become credits for a new deploy. The deploy calculator shows covered vs additional charges, prices optional services correctly, and prorates extras to days left on the credit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE IF NOT EXISTS resource_credits (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
"sourceAppName" VARCHAR,
|
||||
runtime VARCHAR NOT NULL,
|
||||
"databaseType" VARCHAR NOT NULL,
|
||||
"cpuLimit" VARCHAR NOT NULL,
|
||||
"memoryLimit" VARCHAR NOT NULL,
|
||||
replicas INT NOT NULL DEFAULT 1,
|
||||
"dbStorageSize" VARCHAR,
|
||||
"appStorageSize" VARCHAR,
|
||||
"enableRedis" BOOLEAN NOT NULL DEFAULT false,
|
||||
"enableRabbitmq" BOOLEAN NOT NULL DEFAULT false,
|
||||
"enableElasticsearch" BOOLEAN NOT NULL DEFAULT false,
|
||||
"billingCycle" VARCHAR,
|
||||
"expiresAt" TIMESTAMPTZ NOT NULL,
|
||||
"consumedAt" TIMESTAMPTZ,
|
||||
"appliedApplicationId" VARCHAR,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_credits_user_active
|
||||
ON resource_credits ("userId", "expiresAt")
|
||||
WHERE "consumedAt" IS NULL;
|
||||
@@ -29,6 +29,8 @@ import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { DeploymentsService } from '../deployments/deployments.service';
|
||||
import { AccessService } from '../access/access.service';
|
||||
import { CreateServiceAccessDto } from '../access/dto/service-access.dto';
|
||||
import { SnapshotsService } from '../snapshots/snapshots.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
|
||||
@ApiTags('Applications')
|
||||
@ApiBearerAuth()
|
||||
@@ -44,6 +46,10 @@ export class ApplicationsController {
|
||||
@Inject(forwardRef(() => DeploymentsService))
|
||||
private readonly deploymentsService: DeploymentsService,
|
||||
private readonly accessService: AccessService,
|
||||
@Inject(forwardRef(() => SnapshotsService))
|
||||
private readonly snapshotsService: SnapshotsService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
private readonly billingService: BillingService,
|
||||
) {}
|
||||
|
||||
private isStaff(role: string): boolean {
|
||||
@@ -384,20 +390,19 @@ export class ApplicationsController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
||||
@ApiOperation({ summary: 'Permanently delete an application and all its resources' })
|
||||
async delete(@Param('id') id: string, @Request() req: any) {
|
||||
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||
// 1. Get the app first
|
||||
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
||||
|
||||
// 2. Revoke temporary access grants
|
||||
const credit = await this.billingService.createCreditFromDeletedApp(app);
|
||||
|
||||
try {
|
||||
await this.accessService.revokeAllForApplication(app.id);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Access grant cleanup failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
// 3. Delete K8s resources (deployment, service, ingress, db, secrets, PVCs)
|
||||
try {
|
||||
await this.kubernetesService.deleteApplication(app);
|
||||
this.logger.log(`Deleted K8s resources for ${app.name}`);
|
||||
@@ -405,16 +410,23 @@ export class ApplicationsController {
|
||||
this.logger.warn(`K8s cleanup failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
// 4. Delete deployment records from DB
|
||||
try {
|
||||
await this.deploymentsService.deleteAllForApplication(app.id);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Deployment records cleanup failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
// 5. Delete app (also deletes uploaded files)
|
||||
try {
|
||||
await this.snapshotsService.deleteAllForApplication(app.id);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Snapshot cleanup failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
await this.applicationsService.delete(id, isStaff ? app.userId : req.user.id);
|
||||
|
||||
return { message: `Application "${app.name}" and all resources deleted` };
|
||||
return {
|
||||
message: `Application "${app.name}" and all resources deleted`,
|
||||
resourceCredit: credit ? this.billingService.formatCreditForApi(credit) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ClustersModule } from '../clusters/clusters.module';
|
||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
import { DeploymentsModule } from '../deployments/deployments.module';
|
||||
import { AccessModule } from '../access/access.module';
|
||||
import { SnapshotsModule } from '../snapshots/snapshots.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -17,6 +19,8 @@ import { AccessModule } from '../access/access.module';
|
||||
KubernetesModule,
|
||||
AccessModule,
|
||||
forwardRef(() => DeploymentsModule),
|
||||
forwardRef(() => SnapshotsModule),
|
||||
forwardRef(() => BillingModule),
|
||||
],
|
||||
controllers: [ApplicationsController],
|
||||
providers: [ApplicationsService, DomainService],
|
||||
|
||||
@@ -163,7 +163,7 @@ export class ApplicationsService {
|
||||
|
||||
async delete(id: string, userId: string): Promise<Application> {
|
||||
const app = await this.findOne(id, userId);
|
||||
|
||||
|
||||
// Delete uploaded files
|
||||
if (app.codePath) {
|
||||
try {
|
||||
|
||||
@@ -166,6 +166,14 @@ export class Application {
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
scheduledDeletionAt: Date; // When the app will be permanently deleted
|
||||
|
||||
/** When the user voluntarily removed the app (data docked for restore). */
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
dockedAt?: Date;
|
||||
|
||||
/** Snapshot captured at dock time — used to restore DB / wp-content / source. */
|
||||
@Column({ nullable: true })
|
||||
dockSnapshotId?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
UpdateServicePlanDto,
|
||||
ChargeWalletDto,
|
||||
CalculateCostDto,
|
||||
CalculateDeployCostDto,
|
||||
RenewApplicationDto,
|
||||
UpgradeResourcesDto,
|
||||
CalculateUpgradeCostDto,
|
||||
@@ -95,6 +96,17 @@ export class BillingController {
|
||||
return this.billingService.calculateCost(dto);
|
||||
}
|
||||
|
||||
@Post('calculate-deploy')
|
||||
@ApiOperation({
|
||||
summary: 'Calculate deploy cost with prepaid resource credits applied',
|
||||
})
|
||||
async calculateDeployCost(@Request() req: any, @Body() dto: CalculateDeployCostDto) {
|
||||
if (!Object.values(BillingCycle).includes(dto.cycle)) {
|
||||
throw new BadRequestException(`Invalid billing cycle: ${dto.cycle}`);
|
||||
}
|
||||
return this.billingService.calculateDeployPayment(req.user.id, dto, dto.cycle);
|
||||
}
|
||||
|
||||
// ─── Custom Domain Pricing ─────────────────────────────────────
|
||||
|
||||
@Get('settings/custom-domain-price')
|
||||
@@ -133,49 +145,94 @@ export class BillingController {
|
||||
return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50);
|
||||
}
|
||||
|
||||
@Get('resource-credits')
|
||||
@ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' })
|
||||
async getResourceCredits(@Request() req: any) {
|
||||
const credits = await this.billingService.getActiveCredits(req.user.id);
|
||||
return credits.map((c) => this.billingService.formatCreditForApi(c));
|
||||
}
|
||||
|
||||
@Post('wallet/pay/:applicationId')
|
||||
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
|
||||
async payForApplication(
|
||||
@Request() req: any,
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() body: { planId: string; cycle: string },
|
||||
@Body() body: { planId?: string; cycle: string; amount?: number },
|
||||
) {
|
||||
const cycle = body.cycle as BillingCycle;
|
||||
if (!Object.values(BillingCycle).includes(cycle)) {
|
||||
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
|
||||
}
|
||||
|
||||
// Calculate cost
|
||||
const plan = await this.billingService.findPlan(body.planId);
|
||||
const costs = await this.billingService.calculateCost({
|
||||
runtime: plan.runtime,
|
||||
databaseType: 'none', // Will be refined per-app later
|
||||
cpuLimit: '500m',
|
||||
memoryLimit: '512Mi',
|
||||
replicas: 1,
|
||||
});
|
||||
const app = await this.applicationsService.findOne(applicationId, req.user.id);
|
||||
|
||||
const amount = cycle === BillingCycle.HOURLY ? costs.hourly
|
||||
: cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||
: costs.yearly;
|
||||
let amount = body.amount;
|
||||
let planId = body.planId || app.planId || '';
|
||||
|
||||
// Deduct from wallet
|
||||
const tx = await this.billingService.deductWallet(
|
||||
if (amount === undefined || amount === null) {
|
||||
if (!body.planId) {
|
||||
throw new BadRequestException('planId or amount is required');
|
||||
}
|
||||
const plan = await this.billingService.findPlan(body.planId);
|
||||
const costs = await this.billingService.calculateCostForApp({
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
cpuLimit: app.cpuLimit,
|
||||
memoryLimit: app.memoryLimit,
|
||||
replicas: app.replicas,
|
||||
dbStorageSize: app.dbStorageSize,
|
||||
appStorageSize: app.appStorageSize,
|
||||
enableRedis: app.enableRedis,
|
||||
enableRabbitmq: app.enableRabbitmq,
|
||||
enableElasticsearch: app.enableElasticsearch,
|
||||
});
|
||||
amount = cycle === BillingCycle.HOURLY ? costs.hourly
|
||||
: cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||
: costs.yearly;
|
||||
planId = body.planId;
|
||||
}
|
||||
|
||||
const payment = await this.billingService.resolveAppPayment(
|
||||
req.user.id,
|
||||
amount,
|
||||
`Payment for app ${applicationId} (${cycle}) — plan: ${plan.name}`,
|
||||
applicationId,
|
||||
app,
|
||||
cycle,
|
||||
);
|
||||
|
||||
// Activate/reactivate the application
|
||||
const app = await this.lifecycleService.activateApp(applicationId, cycle, body.planId);
|
||||
let tx = null;
|
||||
if (payment.amountDue > 0) {
|
||||
tx = await this.billingService.deductWallet(
|
||||
req.user.id,
|
||||
payment.amountDue,
|
||||
`Payment for app ${applicationId} (${cycle})`,
|
||||
applicationId,
|
||||
);
|
||||
}
|
||||
|
||||
const activated = await this.lifecycleService.activateApp(
|
||||
applicationId,
|
||||
cycle,
|
||||
planId,
|
||||
payment.planExpiresAt,
|
||||
);
|
||||
|
||||
return {
|
||||
transaction: tx,
|
||||
application: { id: app.id, name: app.name, lifecycleStatus: app.lifecycleStatus, planExpiresAt: app.planExpiresAt },
|
||||
message: app.lifecycleStatus === AppLifecycleStatus.ACTIVE
|
||||
? `Application "${app.name}" activated until ${app.planExpiresAt?.toISOString()}`
|
||||
: `Payment processed — application status: ${app.lifecycleStatus}`,
|
||||
creditApplied: payment.creditId || null,
|
||||
waivedAmount: payment.waivedAmount,
|
||||
paidAmount: payment.amountDue,
|
||||
application: {
|
||||
id: activated.id,
|
||||
name: activated.name,
|
||||
lifecycleStatus: activated.lifecycleStatus,
|
||||
planExpiresAt: activated.planExpiresAt,
|
||||
},
|
||||
message: payment.waivedAmount > 0
|
||||
? payment.amountDue > 0
|
||||
? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.`
|
||||
: `Application "${activated.name}" activated using your prepaid resource credit (no charge).`
|
||||
: payment.amountDue > 0
|
||||
? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}`
|
||||
: `Application "${activated.name}" activated`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@ import { PricingRule } from './entities/pricing-rule.entity';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||
import { ResourceCredit } from './entities/resource-credit.entity';
|
||||
import { LifecycleModule } from '../lifecycle/lifecycle.module';
|
||||
import { ApplicationsModule } from '../applications/applications.module';
|
||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting]),
|
||||
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting, ResourceCredit]),
|
||||
forwardRef(() => LifecycleModule),
|
||||
forwardRef(() => ApplicationsModule),
|
||||
forwardRef(() => KubernetesModule),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PricingRule } from './entities/pricing-rule.entity';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||
import { TransactionType, BillingCycle, PricingResourceType } from '../common/enums';
|
||||
import { TransactionType, BillingCycle, PricingResourceType, DatabaseType } from '../common/enums';
|
||||
import {
|
||||
CreateServicePlanDto,
|
||||
UpdateServicePlanDto,
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
UpgradeResourcesDto,
|
||||
} from './dto/billing.dto';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { ResourceCredit } from './entities/resource-credit.entity';
|
||||
import { IsNull, MoreThan } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
@@ -25,6 +27,7 @@ export class BillingService {
|
||||
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
||||
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
||||
@InjectRepository(PlatformSetting) private settingsRepo: Repository<PlatformSetting>,
|
||||
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
|
||||
) {}
|
||||
|
||||
// ─── Service Plans ────────────────────────────────────────────────
|
||||
@@ -366,6 +369,11 @@ export class BillingService {
|
||||
replicas: number;
|
||||
dbStorageSize?: string;
|
||||
appStorageSize?: string;
|
||||
enableRedis?: boolean;
|
||||
enableRabbitmq?: boolean;
|
||||
enableElasticsearch?: boolean;
|
||||
customDomain?: string;
|
||||
customDomainStatus?: string;
|
||||
}): Promise<{ hourly: number; monthly: number; yearly: number }> {
|
||||
const result = await this.calculateCost({
|
||||
runtime: app.runtime,
|
||||
@@ -375,6 +383,10 @@ export class BillingService {
|
||||
replicas: app.replicas,
|
||||
dbStorageSize: app.dbStorageSize,
|
||||
appStorageSize: app.appStorageSize,
|
||||
enableRedis: app.enableRedis,
|
||||
enableRabbitmq: app.enableRabbitmq,
|
||||
enableElasticsearch: app.enableElasticsearch,
|
||||
enableCustomDomain: !!app.customDomain && app.customDomainStatus === 'verified',
|
||||
});
|
||||
return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly };
|
||||
}
|
||||
@@ -470,4 +482,522 @@ export class BillingService {
|
||||
billingCycle: app.billingCycle,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Resource credits (prepaid resources after app deletion) ───────
|
||||
|
||||
private amountForCycle(
|
||||
costs: { hourly: number; monthly: number; yearly: number },
|
||||
cycle: BillingCycle,
|
||||
): number {
|
||||
switch (cycle) {
|
||||
case BillingCycle.HOURLY:
|
||||
return costs.hourly;
|
||||
case BillingCycle.MONTHLY:
|
||||
return costs.monthly;
|
||||
case BillingCycle.YEARLY:
|
||||
return costs.yearly;
|
||||
default:
|
||||
return costs.monthly;
|
||||
}
|
||||
}
|
||||
|
||||
private storageGi(size?: string, fallback = 1): number {
|
||||
if (!size) return fallback;
|
||||
return parseFloat(String(size).replace(/Gi$/i, '')) || fallback;
|
||||
}
|
||||
|
||||
private appToResourceConfig(
|
||||
app: Application | CalculateCostDto,
|
||||
options?: { enableCustomDomain?: boolean },
|
||||
) {
|
||||
const enableCustomDomain =
|
||||
options?.enableCustomDomain ??
|
||||
('enableCustomDomain' in app
|
||||
? !!(app as CalculateCostDto).enableCustomDomain
|
||||
: !!(app as Application).customDomain);
|
||||
return {
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
cpuLimit: app.cpuLimit,
|
||||
memoryLimit: app.memoryLimit,
|
||||
replicas: app.replicas || 1,
|
||||
dbStorageSize: app.dbStorageSize,
|
||||
appStorageSize: app.appStorageSize,
|
||||
enableRedis: !!app.enableRedis,
|
||||
enableRabbitmq: !!app.enableRabbitmq,
|
||||
enableElasticsearch: !!app.enableElasticsearch,
|
||||
enableCustomDomain,
|
||||
};
|
||||
}
|
||||
|
||||
async createCreditFromDeletedApp(app: Application): Promise<ResourceCredit | null> {
|
||||
if (!app.planExpiresAt) return null;
|
||||
const expiresAt = new Date(app.planExpiresAt);
|
||||
if (expiresAt <= new Date()) return null;
|
||||
|
||||
const credit = this.creditRepo.create({
|
||||
userId: app.userId,
|
||||
sourceAppName: app.name,
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
cpuLimit: app.cpuLimit,
|
||||
memoryLimit: app.memoryLimit,
|
||||
replicas: app.replicas,
|
||||
dbStorageSize: app.dbStorageSize || '1Gi',
|
||||
appStorageSize: app.appStorageSize || '2Gi',
|
||||
enableRedis: !!app.enableRedis,
|
||||
enableRabbitmq: !!app.enableRabbitmq,
|
||||
enableElasticsearch: !!app.enableElasticsearch,
|
||||
billingCycle: app.billingCycle || BillingCycle.MONTHLY,
|
||||
expiresAt,
|
||||
});
|
||||
const saved = await this.creditRepo.save(credit);
|
||||
this.logger.log(`Resource credit created for user ${app.userId} from deleted app ${app.name}`);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async getActiveCredits(userId: string): Promise<ResourceCredit[]> {
|
||||
return this.creditRepo.find({
|
||||
where: {
|
||||
userId,
|
||||
consumedAt: IsNull(),
|
||||
expiresAt: MoreThan(new Date()),
|
||||
},
|
||||
order: { expiresAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
formatCreditForApi(credit: ResourceCredit) {
|
||||
const now = Date.now();
|
||||
const remainingMs = Math.max(0, new Date(credit.expiresAt).getTime() - now);
|
||||
const remainingDays = Math.floor(remainingMs / 86400000);
|
||||
const remainingHours = Math.floor((remainingMs % 86400000) / 3600000);
|
||||
return {
|
||||
id: credit.id,
|
||||
sourceAppName: credit.sourceAppName,
|
||||
runtime: credit.runtime,
|
||||
databaseType: credit.databaseType,
|
||||
cpuLimit: credit.cpuLimit,
|
||||
memoryLimit: credit.memoryLimit,
|
||||
replicas: credit.replicas,
|
||||
dbStorageSize: credit.dbStorageSize,
|
||||
appStorageSize: credit.appStorageSize,
|
||||
enableRedis: credit.enableRedis,
|
||||
enableRabbitmq: credit.enableRabbitmq,
|
||||
enableElasticsearch: credit.enableElasticsearch,
|
||||
billingCycle: credit.billingCycle,
|
||||
expiresAt: credit.expiresAt,
|
||||
remainingMs,
|
||||
remainingLabel:
|
||||
remainingDays > 0 ? `${remainingDays}d ${remainingHours}h` : `${remainingHours}h`,
|
||||
};
|
||||
}
|
||||
|
||||
configWithinCredit(
|
||||
config: ReturnType<typeof this.appToResourceConfig>,
|
||||
credit: ResourceCredit,
|
||||
): boolean {
|
||||
if (config.runtime !== credit.runtime) return false;
|
||||
if (
|
||||
credit.databaseType !== DatabaseType.NONE &&
|
||||
config.databaseType !== credit.databaseType
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (config.replicas > credit.replicas) return false;
|
||||
if (config.enableRedis && !credit.enableRedis) return false;
|
||||
if (config.enableRabbitmq && !credit.enableRabbitmq) return false;
|
||||
if (config.enableElasticsearch && !credit.enableElasticsearch) return false;
|
||||
if (this.storageGi(config.dbStorageSize, 1) > this.storageGi(credit.dbStorageSize, 1)) {
|
||||
return false;
|
||||
}
|
||||
if (this.storageGi(config.appStorageSize, 2) > this.storageGi(credit.appStorageSize, 2)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Credit applies when runtime (and DB type, if any) match — upgrades are charged as extras. */
|
||||
async findApplicableCredit(
|
||||
userId: string,
|
||||
config: ReturnType<typeof this.appToResourceConfig>,
|
||||
): Promise<ResourceCredit | null> {
|
||||
const credits = await this.getActiveCredits(userId);
|
||||
return (
|
||||
credits.find(
|
||||
(c) =>
|
||||
c.runtime === config.runtime &&
|
||||
(c.databaseType === DatabaseType.NONE ||
|
||||
c.databaseType === config.databaseType),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private async costDelta(
|
||||
base: CalculateCostDto,
|
||||
withExtras: Partial<CalculateCostDto>,
|
||||
cycle: BillingCycle,
|
||||
): Promise<number> {
|
||||
const a = await this.calculateCost({ ...base, ...withExtras });
|
||||
const b = await this.calculateCost(base);
|
||||
return Math.max(0, this.amountForCycle(a, cycle) - this.amountForCycle(b, cycle));
|
||||
}
|
||||
|
||||
private toCalculateDto(
|
||||
config: ReturnType<typeof this.appToResourceConfig>,
|
||||
): CalculateCostDto {
|
||||
return {
|
||||
runtime: config.runtime,
|
||||
databaseType: config.databaseType,
|
||||
cpuLimit: config.cpuLimit,
|
||||
memoryLimit: config.memoryLimit,
|
||||
replicas: config.replicas,
|
||||
dbStorageSize: config.dbStorageSize,
|
||||
appStorageSize: config.appStorageSize,
|
||||
enableRedis: config.enableRedis,
|
||||
enableRabbitmq: config.enableRabbitmq,
|
||||
enableElasticsearch: config.enableElasticsearch,
|
||||
enableCustomDomain: config.enableCustomDomain,
|
||||
};
|
||||
}
|
||||
|
||||
/** Baseline config covered by the prepaid credit (used for isolated add-on pricing). */
|
||||
private creditBaselineDto(
|
||||
credit: ResourceCredit,
|
||||
patch: Partial<CalculateCostDto> = {},
|
||||
): CalculateCostDto {
|
||||
return {
|
||||
runtime: credit.runtime,
|
||||
databaseType: credit.databaseType,
|
||||
cpuLimit: credit.cpuLimit,
|
||||
memoryLimit: credit.memoryLimit,
|
||||
replicas: credit.replicas,
|
||||
dbStorageSize: credit.dbStorageSize || '1Gi',
|
||||
appStorageSize: credit.appStorageSize || '2Gi',
|
||||
enableRedis: !!credit.enableRedis,
|
||||
enableRabbitmq: !!credit.enableRabbitmq,
|
||||
enableElasticsearch: !!credit.enableElasticsearch,
|
||||
enableCustomDomain: false,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
private getCreditProrateFactor(credit: ResourceCredit) {
|
||||
const created = new Date(credit.createdAt).getTime();
|
||||
const expires = new Date(credit.expiresAt).getTime();
|
||||
const now = Date.now();
|
||||
const totalMs = Math.max(1, expires - created);
|
||||
const remainingMs = Math.max(0, expires - now);
|
||||
const factor = Math.min(1, remainingMs / totalMs);
|
||||
const remainingDays = Math.max(1, Math.ceil(remainingMs / 86400000));
|
||||
const periodDays = Math.max(1, Math.ceil(totalMs / 86400000));
|
||||
return { factor, remainingDays, periodDays };
|
||||
}
|
||||
|
||||
private prorateLabel(credit: ResourceCredit): string {
|
||||
const { remainingDays, periodDays } = this.getCreditProrateFactor(credit);
|
||||
return `prorated ${remainingDays}/${periodDays} days`;
|
||||
}
|
||||
|
||||
private async addExtraLineProrated(
|
||||
items: { label: string; amount: number; fullPeriodAmount?: number }[],
|
||||
from: CalculateCostDto,
|
||||
to: Partial<CalculateCostDto>,
|
||||
credit: ResourceCredit,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const billCycle = credit.billingCycle || BillingCycle.MONTHLY;
|
||||
let fullPeriodAmount = await this.costDelta(from, to, billCycle);
|
||||
|
||||
if (fullPeriodAmount <= 0) {
|
||||
fullPeriodAmount = await this.getAddonPriceFromBreakdown(from, to, billCycle);
|
||||
}
|
||||
if (fullPeriodAmount <= 0) return;
|
||||
|
||||
const { factor } = this.getCreditProrateFactor(credit);
|
||||
const amount = Math.round(fullPeriodAmount * factor);
|
||||
if (amount <= 0) return;
|
||||
|
||||
items.push({
|
||||
label: `${label} (${this.prorateLabel(credit)})`,
|
||||
amount,
|
||||
fullPeriodAmount,
|
||||
});
|
||||
}
|
||||
|
||||
/** Fallback: read marginal addon price from cost breakdown labels. */
|
||||
private async getAddonPriceFromBreakdown(
|
||||
from: CalculateCostDto,
|
||||
to: Partial<CalculateCostDto>,
|
||||
cycle: BillingCycle,
|
||||
): Promise<number> {
|
||||
const before = await this.calculateCost(from);
|
||||
const after = await this.calculateCost({ ...from, ...to });
|
||||
const labelHints: string[] = [];
|
||||
if (to.enableRedis) labelHints.push('Redis addon');
|
||||
if (to.enableRabbitmq) labelHints.push('RabbitMQ addon');
|
||||
if (to.enableElasticsearch) labelHints.push('Elasticsearch addon');
|
||||
if (to.enableCustomDomain) labelHints.push('Custom domain + SSL');
|
||||
if (to.databaseType && to.databaseType !== DatabaseType.NONE) {
|
||||
labelHints.push('Database addon');
|
||||
}
|
||||
|
||||
let sum = 0;
|
||||
for (const hint of labelHints) {
|
||||
const afterLine = after.breakdown.find((b) => b.label === hint);
|
||||
const beforeLine = before.breakdown.find((b) => b.label === hint);
|
||||
const afterAmt = afterLine ? this.amountForCycle(afterLine, cycle) : 0;
|
||||
const beforeAmt = beforeLine ? this.amountForCycle(beforeLine, cycle) : 0;
|
||||
sum += Math.max(0, afterAmt - beforeAmt);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-item charges for anything beyond the prepaid credit bundle (prorated to remaining credit time).
|
||||
*/
|
||||
async calculateExtrasBeyondCredit(
|
||||
config: ReturnType<typeof this.appToResourceConfig>,
|
||||
credit: ResourceCredit,
|
||||
_cycle: BillingCycle,
|
||||
): Promise<{
|
||||
total: number;
|
||||
items: { label: string; amount: number; fullPeriodAmount?: number }[];
|
||||
}> {
|
||||
const items: { label: string; amount: number; fullPeriodAmount?: number }[] = [];
|
||||
const baseline = this.creditBaselineDto(credit);
|
||||
|
||||
if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
baseline,
|
||||
{ cpuLimit: config.cpuLimit },
|
||||
credit,
|
||||
`Extra CPU (${credit.cpuLimit} → ${config.cpuLimit})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit)) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
baseline,
|
||||
{ memoryLimit: config.memoryLimit },
|
||||
credit,
|
||||
`Extra memory (${credit.memoryLimit} → ${config.memoryLimit})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.replicas > credit.replicas) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
baseline,
|
||||
{ replicas: config.replicas },
|
||||
credit,
|
||||
`Extra replicas (${credit.replicas} → ${config.replicas})`,
|
||||
);
|
||||
}
|
||||
|
||||
const appDb = this.storageGi(config.dbStorageSize, 1);
|
||||
const creditDb = this.storageGi(credit.dbStorageSize, 1);
|
||||
if (appDb > creditDb) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
baseline,
|
||||
{ dbStorageSize: `${appDb}Gi` },
|
||||
credit,
|
||||
`Extra database storage (${creditDb}Gi → ${appDb}Gi)`,
|
||||
);
|
||||
}
|
||||
|
||||
const appSt = this.storageGi(config.appStorageSize, 2);
|
||||
const creditSt = this.storageGi(credit.appStorageSize, 2);
|
||||
if (appSt > creditSt) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
baseline,
|
||||
{ appStorageSize: `${appSt}Gi` },
|
||||
credit,
|
||||
`Extra app storage (${creditSt}Gi → ${appSt}Gi)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.databaseType !== DatabaseType.NONE && credit.databaseType === DatabaseType.NONE) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
this.creditBaselineDto(credit, {
|
||||
databaseType: DatabaseType.NONE,
|
||||
dbStorageSize: undefined,
|
||||
}),
|
||||
{
|
||||
databaseType: config.databaseType,
|
||||
dbStorageSize: config.dbStorageSize || '1Gi',
|
||||
},
|
||||
credit,
|
||||
`Database (${config.databaseType})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.enableRedis && !credit.enableRedis) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
this.creditBaselineDto(credit, { enableRedis: false }),
|
||||
{ enableRedis: true },
|
||||
credit,
|
||||
'Redis',
|
||||
);
|
||||
}
|
||||
|
||||
if (config.enableRabbitmq && !credit.enableRabbitmq) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
this.creditBaselineDto(credit, { enableRabbitmq: false }),
|
||||
{ enableRabbitmq: true },
|
||||
credit,
|
||||
'RabbitMQ',
|
||||
);
|
||||
}
|
||||
|
||||
if (config.enableElasticsearch && !credit.enableElasticsearch) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
this.creditBaselineDto(credit, { enableElasticsearch: false }),
|
||||
{ enableElasticsearch: true },
|
||||
credit,
|
||||
'Elasticsearch',
|
||||
);
|
||||
}
|
||||
|
||||
if (config.enableCustomDomain) {
|
||||
await this.addExtraLineProrated(
|
||||
items,
|
||||
this.creditBaselineDto(credit, { enableCustomDomain: false }),
|
||||
{ enableCustomDomain: true },
|
||||
credit,
|
||||
'Custom domain + SSL',
|
||||
);
|
||||
}
|
||||
|
||||
const total = items.reduce((sum, i) => sum + i.amount, 0);
|
||||
return { total: Math.round(total), items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy cost preview — applies prepaid resource credits when the config fits.
|
||||
*/
|
||||
async calculateDeployPayment(
|
||||
userId: string,
|
||||
dto: CalculateCostDto,
|
||||
cycle: BillingCycle,
|
||||
) {
|
||||
const costs = await this.calculateCost(dto);
|
||||
const fullAmount = this.amountForCycle(costs, cycle);
|
||||
const config = this.appToResourceConfig({
|
||||
...dto,
|
||||
enableRedis: !!dto.enableRedis,
|
||||
enableRabbitmq: !!dto.enableRabbitmq,
|
||||
enableElasticsearch: !!dto.enableElasticsearch,
|
||||
enableCustomDomain: !!dto.enableCustomDomain,
|
||||
} as CalculateCostDto);
|
||||
const credit = await this.findApplicableCredit(userId, config);
|
||||
|
||||
if (!credit) {
|
||||
return {
|
||||
...costs,
|
||||
cycle,
|
||||
fullAmount,
|
||||
amountDue: fullAmount,
|
||||
coveredAmount: 0,
|
||||
waivedAmount: 0,
|
||||
extrasBreakdown: [],
|
||||
creditApplied: null,
|
||||
prepaidCreditUsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { total: extrasDue, items: extrasBreakdown } =
|
||||
await this.calculateExtrasBeyondCredit(config, credit, cycle);
|
||||
const waivedAmount = Math.max(0, fullAmount - extrasDue);
|
||||
const prorate = this.getCreditProrateFactor(credit);
|
||||
return {
|
||||
...costs,
|
||||
cycle,
|
||||
fullAmount,
|
||||
amountDue: extrasDue,
|
||||
coveredAmount: waivedAmount,
|
||||
waivedAmount,
|
||||
extrasBreakdown,
|
||||
creditApplied: this.formatCreditForApi(credit),
|
||||
prepaidCreditUsed: waivedAmount > 0,
|
||||
prorateRemainingDays: prorate.remainingDays,
|
||||
proratePeriodDays: prorate.periodDays,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve wallet/gateway payment for an app — consumes a matching credit when applicable.
|
||||
*/
|
||||
async resolveAppPayment(
|
||||
userId: string,
|
||||
app: Application,
|
||||
cycle: BillingCycle,
|
||||
): Promise<{
|
||||
fullAmount: number;
|
||||
amountDue: number;
|
||||
creditId?: string;
|
||||
planExpiresAt?: Date;
|
||||
waivedAmount: number;
|
||||
}> {
|
||||
const config = this.appToResourceConfig(app, { enableCustomDomain: !!app.customDomain });
|
||||
const fullCosts = await this.calculateCost(this.toCalculateDto(config));
|
||||
const fullAmount = this.amountForCycle(fullCosts, cycle);
|
||||
|
||||
const credit = await this.findApplicableCredit(userId, config);
|
||||
if (!credit) {
|
||||
return { fullAmount, amountDue: fullAmount, waivedAmount: 0 };
|
||||
}
|
||||
|
||||
const { total: extrasDue } = await this.calculateExtrasBeyondCredit(
|
||||
config,
|
||||
credit,
|
||||
cycle,
|
||||
);
|
||||
credit.consumedAt = new Date();
|
||||
credit.appliedApplicationId = app.id;
|
||||
await this.creditRepo.save(credit);
|
||||
this.logger.log(
|
||||
`Applied resource credit ${credit.id} to app ${app.name} — due ${extrasDue} Toman (waived ${fullAmount - extrasDue})`,
|
||||
);
|
||||
|
||||
return {
|
||||
fullAmount,
|
||||
amountDue: extrasDue,
|
||||
creditId: credit.id,
|
||||
planExpiresAt: credit.expiresAt,
|
||||
waivedAmount: fullAmount - extrasDue,
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use resolveAppPayment */
|
||||
async applyResourceCredit(
|
||||
userId: string,
|
||||
app: Application,
|
||||
amount: number,
|
||||
cycle: BillingCycle = BillingCycle.MONTHLY,
|
||||
): Promise<{ finalAmount: number; creditId?: string; waived: boolean; planExpiresAt?: Date }> {
|
||||
const resolved = await this.resolveAppPayment(userId, app, cycle);
|
||||
return {
|
||||
finalAmount: resolved.amountDue,
|
||||
creditId: resolved.creditId,
|
||||
waived: resolved.waivedAmount > 0,
|
||||
planExpiresAt: resolved.planExpiresAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,12 @@ export class CalculateCostDto {
|
||||
enableCustomDomain?: boolean;
|
||||
}
|
||||
|
||||
export class CalculateDeployCostDto extends CalculateCostDto {
|
||||
@ApiProperty({ enum: BillingCycle, example: 'monthly' })
|
||||
@IsEnum(BillingCycle)
|
||||
cycle: BillingCycle;
|
||||
}
|
||||
|
||||
// ─── Renewal & Upgrade DTOs ─────────────────────────────────────────
|
||||
|
||||
export class RenewApplicationDto {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { AppRuntime, DatabaseType, BillingCycle } from '../../common/enums';
|
||||
|
||||
/** Prepaid resources returned to the user when they delete an app before plan expiry. */
|
||||
@Entity('resource_credits')
|
||||
export class ResourceCredit {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ nullable: true })
|
||||
sourceAppName: string;
|
||||
|
||||
@Column({ type: 'enum', enum: AppRuntime })
|
||||
runtime: AppRuntime;
|
||||
|
||||
@Column({ type: 'enum', enum: DatabaseType })
|
||||
databaseType: DatabaseType;
|
||||
|
||||
@Column()
|
||||
cpuLimit: string;
|
||||
|
||||
@Column()
|
||||
memoryLimit: string;
|
||||
|
||||
@Column({ default: 1 })
|
||||
replicas: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
dbStorageSize: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
appStorageSize: string;
|
||||
|
||||
@Column({ default: false })
|
||||
enableRedis: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
enableRabbitmq: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
enableElasticsearch: boolean;
|
||||
|
||||
@Column({ type: 'enum', enum: BillingCycle, nullable: true })
|
||||
billingCycle: BillingCycle;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
expiresAt: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
consumedAt: Date;
|
||||
|
||||
@Column({ nullable: true })
|
||||
appliedApplicationId: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -127,5 +127,6 @@ export enum AppLifecycleStatus {
|
||||
ACTIVE = 'active', // Paid & running
|
||||
SUSPENDED = 'suspended', // Plan expired, pods scaled to 0, data retained
|
||||
PENDING_DELETION = 'pending_deletion', // Grace period — will be deleted if no payment
|
||||
DOCKED = 'docked', // User removed service; data retained until plan expires
|
||||
DELETED = 'deleted', // Fully removed from K8s and DB
|
||||
}
|
||||
|
||||
@@ -54,12 +54,19 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
|
||||
* Activate or reactivate an application after successful payment.
|
||||
* Calculates the new expiry based on billing cycle and scales pods back up.
|
||||
*/
|
||||
async activateApp(appId: string, billingCycle: BillingCycle, planId: string): Promise<Application> {
|
||||
async activateApp(
|
||||
appId: string,
|
||||
billingCycle: BillingCycle,
|
||||
planId: string,
|
||||
planExpiresAt?: Date,
|
||||
): Promise<Application> {
|
||||
const app = await this.appRepo.findOne({ where: { id: appId } });
|
||||
if (!app) throw new Error(`Application ${appId} not found`);
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = this.calculateExpiry(now, billingCycle);
|
||||
const expiresAt = planExpiresAt && planExpiresAt > now
|
||||
? planExpiresAt
|
||||
: this.calculateExpiry(now, billingCycle);
|
||||
|
||||
app.planId = planId;
|
||||
app.billingCycle = billingCycle;
|
||||
|
||||
Reference in New Issue
Block a user