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:
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user