feat(lifecycle): add billing lifecycle with auto-suspend/delete

- New AppLifecycleService with cron-based scanner (60s interval)
- State machine: ACTIVE → SUSPENDED → PENDING_DELETION → DELETED
- Auto-renew hourly plans from wallet
- Admin-configurable grace periods via PlatformSettings table
- New LifecycleController (GET/PATCH /lifecycle/settings)
- PlatformSetting entity for runtime admin config
- BillingService: calculateCostForApp, deductWallet
- Application entity: planId, billingCycle, lifecycleStatus, planExpiresAt
- New enums: AppLifecycleStatus, BillingCycle
This commit is contained in:
keyhan
2026-04-22 16:44:20 +03:30
parent 0e408d1baa
commit 8ca787d46e
11 changed files with 562 additions and 11 deletions
+46 -7
View File
@@ -9,10 +9,14 @@ import {
Query,
UseGuards,
Request,
BadRequestException,
Inject,
forwardRef,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BillingService } from './billing.service';
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
import {
CreateServicePlanDto,
UpdateServicePlanDto,
@@ -21,14 +25,18 @@ import {
} from './dto/billing.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
import { UserRole, BillingCycle, AppLifecycleStatus } from '../common/enums';
@ApiTags('Billing')
@ApiBearerAuth()
@Controller('billing')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class BillingController {
constructor(private readonly billingService: BillingService) {}
constructor(
private readonly billingService: BillingService,
@Inject(forwardRef(() => AppLifecycleService))
private readonly lifecycleService: AppLifecycleService,
) {}
// ─── Service Plans (Admin) ────────────────────────────────────────
@@ -98,18 +106,49 @@ export class BillingController {
}
@Post('wallet/pay/:applicationId')
@ApiOperation({ summary: 'Pay for an application from wallet' })
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
async payForApplication(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() body: { amount: number; cycle: string },
@Body() body: { planId: string; cycle: string },
) {
return this.billingService.deductWallet(
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 amount = cycle === BillingCycle.HOURLY ? costs.hourly
: cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
// Deduct from wallet
const tx = await this.billingService.deductWallet(
req.user.id,
body.amount,
`Payment for application (${body.cycle})`,
amount,
`Payment for app ${applicationId} (${cycle}) — plan: ${plan.name}`,
applicationId,
);
// Activate/reactivate the application
const app = await this.lifecycleService.activateApp(applicationId, cycle, body.planId);
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}`,
};
}
// ─── Payment Gateway ─────────────────────────────────────────────
+5 -2
View File
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingService } from './billing.service';
import { BillingController } from './billing.controller';
@@ -6,10 +6,13 @@ import { ServicePlan } from './entities/service-plan.entity';
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 { LifecycleModule } from '../lifecycle/lifecycle.module';
@Module({
imports: [
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction]),
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting]),
forwardRef(() => LifecycleModule),
],
controllers: [BillingController],
providers: [BillingService],
+23
View File
@@ -288,4 +288,27 @@ export class BillingService {
async getAllWallets(): Promise<Wallet[]> {
return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } });
}
/**
* Calculate cost for an existing Application entity.
* Used by lifecycle service for auto-renew.
*/
async calculateCostForApp(app: {
runtime: string;
databaseType: string;
cpuLimit: string;
memoryLimit: string;
replicas: number;
dbStorageSize?: string;
}): Promise<{ hourly: number; monthly: number; yearly: number }> {
const result = await this.calculateCost({
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: app.cpuLimit,
memoryLimit: app.memoryLimit,
replicas: app.replicas,
dbStorageSize: app.dbStorageSize,
});
return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly };
}
}
@@ -0,0 +1,32 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* Key-value settings table editable by super-admin at runtime.
* Used for lifecycle retention periods, feature flags, etc.
*/
@Entity('platform_settings')
export class PlatformSetting {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
key: string;
@Column({ type: 'text' })
value: string;
@Column({ nullable: true })
description: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}