c2c6a32ae8
- ServicePlan now has 'runtime' field (nodejs/laravel/wordpress) - Admin billing page: Application Type dropdown, English UI, Toman prices - calculateCost filters active plans by matching runtime - Wallet page: English UI, payment gateway integration (Pay Now button) - Deploy page Review step: billing cycle selector (hourly/monthly/yearly), payment method choice (wallet or payment gateway), Pay & Deploy button - Payment gateway endpoints: POST /billing/gateway/initiate + /verify (simulated — ready for Zarinpal/IDPay integration) - Deploy requires payment: wallet deduction or gateway charge before deploy
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
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 { TransactionType, BillingCycle, PricingResourceType } from '../common/enums';
|
|
import {
|
|
CreateServicePlanDto,
|
|
UpdateServicePlanDto,
|
|
CalculateCostDto,
|
|
} from './dto/billing.dto';
|
|
|
|
@Injectable()
|
|
export class BillingService {
|
|
private readonly logger = new Logger(BillingService.name);
|
|
|
|
constructor(
|
|
@InjectRepository(ServicePlan) private planRepo: Repository<ServicePlan>,
|
|
@InjectRepository(PricingRule) private ruleRepo: Repository<PricingRule>,
|
|
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
|
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
|
) {}
|
|
|
|
// ─── Service Plans ────────────────────────────────────────────────
|
|
|
|
async createPlan(dto: CreateServicePlanDto): Promise<ServicePlan> {
|
|
const plan = this.planRepo.create({
|
|
name: dto.name,
|
|
runtime: dto.runtime,
|
|
description: dto.description,
|
|
billingCycle: dto.billingCycle,
|
|
});
|
|
const saved = await this.planRepo.save(plan);
|
|
|
|
// Create pricing rules
|
|
const rules = dto.pricingRules.map((r) =>
|
|
this.ruleRepo.create({ ...r, planId: saved.id }),
|
|
);
|
|
await this.ruleRepo.save(rules);
|
|
|
|
return this.planRepo.findOne({ where: { id: saved.id }, relations: ['pricingRules'] }) as Promise<ServicePlan>;
|
|
}
|
|
|
|
async updatePlan(id: string, dto: UpdateServicePlanDto): Promise<ServicePlan> {
|
|
const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] });
|
|
if (!plan) throw new NotFoundException('Plan not found');
|
|
|
|
if (dto.name !== undefined) plan.name = dto.name;
|
|
if (dto.runtime !== undefined) plan.runtime = dto.runtime;
|
|
if (dto.description !== undefined) plan.description = dto.description;
|
|
if (dto.billingCycle !== undefined) plan.billingCycle = dto.billingCycle;
|
|
if (dto.isActive !== undefined) plan.isActive = dto.isActive;
|
|
|
|
await this.planRepo.save(plan);
|
|
|
|
// If pricing rules are provided, replace them
|
|
if (dto.pricingRules) {
|
|
await this.ruleRepo.delete({ planId: id });
|
|
const rules = dto.pricingRules.map((r) =>
|
|
this.ruleRepo.create({ ...r, planId: id }),
|
|
);
|
|
await this.ruleRepo.save(rules);
|
|
}
|
|
|
|
return this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] }) as Promise<ServicePlan>;
|
|
}
|
|
|
|
async deletePlan(id: string): Promise<void> {
|
|
const plan = await this.planRepo.findOne({ where: { id } });
|
|
if (!plan) throw new NotFoundException('Plan not found');
|
|
await this.planRepo.remove(plan);
|
|
}
|
|
|
|
async findAllPlans(): Promise<ServicePlan[]> {
|
|
return this.planRepo.find({ relations: ['pricingRules'], order: { createdAt: 'DESC' } });
|
|
}
|
|
|
|
async findActivePlans(): Promise<ServicePlan[]> {
|
|
return this.planRepo.find({
|
|
where: { isActive: true },
|
|
relations: ['pricingRules'],
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
async findPlan(id: string): Promise<ServicePlan> {
|
|
const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] });
|
|
if (!plan) throw new NotFoundException('Plan not found');
|
|
return plan;
|
|
}
|
|
|
|
// ─── Cost Calculation ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Calculate cost for an application config against all active plans.
|
|
* Returns breakdown for each billing cycle (hourly, monthly, yearly).
|
|
*/
|
|
async calculateCost(dto: CalculateCostDto): Promise<{
|
|
hourly: number;
|
|
monthly: number;
|
|
yearly: number;
|
|
breakdown: { label: string; hourly: number; monthly: number; yearly: number }[];
|
|
}> {
|
|
// Only use active plans that match the requested runtime
|
|
const plans = await this.planRepo.find({
|
|
where: { isActive: true, runtime: dto.runtime as any },
|
|
relations: ['pricingRules'],
|
|
});
|
|
|
|
if (plans.length === 0) {
|
|
return { hourly: 0, monthly: 0, yearly: 0, breakdown: [] };
|
|
}
|
|
|
|
// Gather all active pricing rules across all plans, grouped by cycle
|
|
const hourlyRules: PricingRule[] = [];
|
|
const monthlyRules: PricingRule[] = [];
|
|
const yearlyRules: PricingRule[] = [];
|
|
|
|
for (const plan of plans) {
|
|
switch (plan.billingCycle) {
|
|
case BillingCycle.HOURLY: hourlyRules.push(...plan.pricingRules); break;
|
|
case BillingCycle.MONTHLY: monthlyRules.push(...plan.pricingRules); break;
|
|
case BillingCycle.YEARLY: yearlyRules.push(...plan.pricingRules); break;
|
|
}
|
|
}
|
|
|
|
// Use the first available set of rules, or compute cross-conversions
|
|
const rules = hourlyRules.length > 0 ? hourlyRules : monthlyRules.length > 0 ? monthlyRules : yearlyRules;
|
|
const baseCycle = hourlyRules.length > 0 ? 'hourly' : monthlyRules.length > 0 ? 'monthly' : 'yearly';
|
|
|
|
// Parse resource values
|
|
const cpuCores = this.parseCpuToCores(dto.cpuLimit);
|
|
const memoryGb = this.parseMemoryToGb(dto.memoryLimit);
|
|
const storageGb = dto.dbStorageSize ? parseFloat(dto.dbStorageSize.replace('Gi', '')) || 0 : 0;
|
|
const hasDatabase = dto.databaseType !== 'none';
|
|
const replicas = dto.replicas || 1;
|
|
|
|
const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = [];
|
|
let totalBase = 0;
|
|
|
|
for (const rule of rules) {
|
|
let cost = 0;
|
|
let label = '';
|
|
|
|
switch (rule.resourceType) {
|
|
case PricingResourceType.BASE_FEE:
|
|
cost = Number(rule.unitPrice);
|
|
label = 'Base fee';
|
|
break;
|
|
case PricingResourceType.CPU_PER_CORE:
|
|
cost = cpuCores * replicas * Number(rule.unitPrice);
|
|
label = `CPU (${(cpuCores * replicas).toFixed(2)} core)`;
|
|
break;
|
|
case PricingResourceType.MEMORY_PER_GB:
|
|
cost = memoryGb * replicas * Number(rule.unitPrice);
|
|
label = `Memory (${(memoryGb * replicas).toFixed(2)} GB)`;
|
|
break;
|
|
case PricingResourceType.STORAGE_PER_GB:
|
|
cost = storageGb * Number(rule.unitPrice);
|
|
label = `Storage (${storageGb} GB)`;
|
|
break;
|
|
case PricingResourceType.DATABASE_ADDON:
|
|
cost = hasDatabase ? Number(rule.unitPrice) : 0;
|
|
label = 'Database addon';
|
|
break;
|
|
}
|
|
|
|
if (cost > 0) {
|
|
const hourly = baseCycle === 'hourly' ? cost : baseCycle === 'monthly' ? cost / 720 : cost / 8640;
|
|
const monthly = baseCycle === 'monthly' ? cost : baseCycle === 'hourly' ? cost * 720 : cost / 12;
|
|
const yearly = baseCycle === 'yearly' ? cost : baseCycle === 'monthly' ? cost * 12 : cost * 8640;
|
|
|
|
breakdown.push({ label, hourly: Math.round(hourly), monthly: Math.round(monthly), yearly: Math.round(yearly) });
|
|
totalBase += cost;
|
|
}
|
|
}
|
|
|
|
const hourlyTotal = baseCycle === 'hourly' ? totalBase : baseCycle === 'monthly' ? totalBase / 720 : totalBase / 8640;
|
|
const monthlyTotal = baseCycle === 'monthly' ? totalBase : baseCycle === 'hourly' ? totalBase * 720 : totalBase / 12;
|
|
const yearlyTotal = baseCycle === 'yearly' ? totalBase : baseCycle === 'monthly' ? totalBase * 12 : totalBase * 8640;
|
|
|
|
return {
|
|
hourly: Math.round(hourlyTotal),
|
|
monthly: Math.round(monthlyTotal),
|
|
yearly: Math.round(yearlyTotal),
|
|
breakdown,
|
|
};
|
|
}
|
|
|
|
private parseCpuToCores(cpu: string): number {
|
|
if (!cpu) return 0;
|
|
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
|
|
return parseFloat(cpu) || 0;
|
|
}
|
|
|
|
private parseMemoryToGb(memory: string): number {
|
|
if (!memory) return 0;
|
|
if (memory.endsWith('Gi')) return parseFloat(memory);
|
|
if (memory.endsWith('Mi')) return parseFloat(memory) / 1024;
|
|
if (memory.endsWith('Ki')) return parseFloat(memory) / (1024 * 1024);
|
|
return parseFloat(memory) / (1024 * 1024 * 1024);
|
|
}
|
|
|
|
// ─── Wallet ───────────────────────────────────────────────────────
|
|
|
|
async getOrCreateWallet(userId: string): Promise<Wallet> {
|
|
let wallet = await this.walletRepo.findOne({ where: { userId } });
|
|
if (!wallet) {
|
|
wallet = this.walletRepo.create({ userId, balance: 0 });
|
|
wallet = await this.walletRepo.save(wallet);
|
|
this.logger.log(`Created wallet for user ${userId}`);
|
|
}
|
|
return wallet;
|
|
}
|
|
|
|
async getBalance(userId: string): Promise<{ balance: number }> {
|
|
const wallet = await this.getOrCreateWallet(userId);
|
|
return { balance: Number(wallet.balance) };
|
|
}
|
|
|
|
async chargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
|
|
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
|
|
|
const wallet = await this.getOrCreateWallet(userId);
|
|
wallet.balance = Number(wallet.balance) + amount;
|
|
await this.walletRepo.save(wallet);
|
|
|
|
const tx = this.txRepo.create({
|
|
walletId: wallet.id,
|
|
type: TransactionType.CHARGE,
|
|
amount,
|
|
balanceAfter: wallet.balance,
|
|
description: description || 'Wallet charge',
|
|
});
|
|
const saved = await this.txRepo.save(tx);
|
|
|
|
this.logger.log(`Charged wallet of user ${userId}: +${amount} Toman → balance: ${wallet.balance}`);
|
|
return saved;
|
|
}
|
|
|
|
async deductWallet(
|
|
userId: string,
|
|
amount: number,
|
|
description?: string,
|
|
applicationId?: string,
|
|
): Promise<WalletTransaction> {
|
|
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
|
|
|
const wallet = await this.getOrCreateWallet(userId);
|
|
if (Number(wallet.balance) < amount) {
|
|
throw new BadRequestException('Insufficient wallet balance');
|
|
}
|
|
|
|
wallet.balance = Number(wallet.balance) - amount;
|
|
await this.walletRepo.save(wallet);
|
|
|
|
const tx = this.txRepo.create({
|
|
walletId: wallet.id,
|
|
type: TransactionType.DEDUCTION,
|
|
amount,
|
|
balanceAfter: wallet.balance,
|
|
description: description || 'Service payment',
|
|
applicationId,
|
|
});
|
|
const saved = await this.txRepo.save(tx);
|
|
|
|
this.logger.log(`Deducted from wallet of user ${userId}: -${amount} Toman → balance: ${wallet.balance}`);
|
|
return saved;
|
|
}
|
|
|
|
async getTransactions(userId: string, limit = 50): Promise<WalletTransaction[]> {
|
|
const wallet = await this.getOrCreateWallet(userId);
|
|
return this.txRepo.find({
|
|
where: { walletId: wallet.id },
|
|
order: { createdAt: 'DESC' },
|
|
take: limit,
|
|
});
|
|
}
|
|
|
|
// Admin: charge any user's wallet
|
|
async adminChargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
|
|
return this.chargeWallet(userId, amount, description || 'Admin charge');
|
|
}
|
|
|
|
// Admin: get all wallets
|
|
async getAllWallets(): Promise<Wallet[]> {
|
|
return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } });
|
|
}
|
|
}
|