import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, IsNull, MoreThan, FindOptionsWhere, EntityManager } from 'typeorm'; import { Wallet } from './entities/wallet.entity'; import { WalletTransaction } from './entities/wallet-transaction.entity'; import { Invoice } from './entities/invoice.entity'; import { InvoiceLine } from './entities/invoice-line.entity'; import { TransactionType, BillingCycle, DatabaseType, InvoiceReason, InvoiceStatus, PaymentMethod, UserRole, ProductType, isManagedProductType, } from '../common/enums'; import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto'; import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto'; import { Application } from '../applications/entities/application.entity'; import { ResourceCredit } from './entities/resource-credit.entity'; import { CostBreakdownLine, PricingCatalogService } from './pricing-catalog.service'; import { DiscountService } from './discount.service'; import { productServiceKey, runtimeServiceKey } from './pricing-catalog.constants'; @Injectable() export class BillingService { private readonly logger = new Logger(BillingService.name); constructor( private readonly pricingCatalog: PricingCatalogService, private readonly discountService: DiscountService, @InjectRepository(Wallet) private walletRepo: Repository, @InjectRepository(WalletTransaction) private txRepo: Repository, @InjectRepository(ResourceCredit) private creditRepo: Repository, @InjectRepository(Invoice) private invoiceRepo: Repository, @InjectRepository(InvoiceLine) private invoiceLineRepo: Repository, ) {} // ─── Pricing catalog (Admin) ────────────────────────────────────── getPricingCatalog() { return this.pricingCatalog.getCatalog(); } updatePricingCatalog(dto: UpdatePricingCatalogDto) { return this.pricingCatalog.updateCatalog(dto); } // ─── Global (platform-wide) discount ────────────────────────────── /** Current platform-wide discount percentage (0–100). */ async getGlobalDiscount(): Promise<{ percentOff: number }> { return { percentOff: await this.pricingCatalog.getGlobalDiscountPercent(true) }; } /** Set the platform-wide discount percentage (Admin). */ async setGlobalDiscount(percentOff: number): Promise<{ percentOff: number }> { return { percentOff: await this.pricingCatalog.setGlobalDiscountPercent(percentOff) }; } // ─── Cost Calculation ───────────────────────────────────────────── /** * Calculate cost from the usage pricing catalog. * Each billing cycle uses its own price column — no cross-cycle conversion. */ async calculateCost(dto: CalculateCostDto): Promise<{ hourly: number; monthly: number; yearly: number; breakdown: CostBreakdownLine[]; }> { return this.pricingCatalog.computeTotalsFromDb(dto); } // ─── Coupon discounts ───────────────────────────────────────────── /** Service-tagged cost breakdown for a deploy config (used for coupon scoping). */ async getDeployBreakdown(dto: CalculateCostDto): Promise { return (await this.pricingCatalog.computeTotalsFromDb(dto)).breakdown; } /** Service-tagged cost breakdown for an existing app (renewal). */ async getAppChargeBreakdown(app: Application): Promise { const config = this.appToResourceConfig(app, { enableCustomDomain: !!app.customDomain }); return (await this.pricingCatalog.computeTotalsFromDb(this.toCalculateDto(config))).breakdown; } /** Single-line breakdown for an upgrade, tagged with the app's service key. */ async getUpgradeBreakdown( app: Application, dto: UpgradeResourcesDto, ): Promise { const cost = await this.calculateUpgradeCost(app, dto); const amount = cost.proratedAmount; if (amount <= 0) return []; const productType = app.productType ?? ProductType.APPLICATION; const serviceKey = productType === ProductType.APPLICATION ? runtimeServiceKey(app.runtime) : productServiceKey(productType); return [{ label: 'Resource upgrade', hourly: amount, monthly: amount, yearly: amount, serviceKey }]; } /** Preview a coupon without charging — returns the evaluation for UI. */ previewCoupon( userId: string, code: string, breakdown: CostBreakdownLine[], cycle: BillingCycle, ) { return this.discountService.evaluate(code, userId, breakdown, cycle); } /** * Resolve a coupon at payment time. Returns the discount to attach to an * invoice (capped to the payable amount), or null when no code is supplied. * Throws if the code is supplied but cannot be applied. */ async resolveCoupon( userId: string, couponCode: string | undefined, breakdown: CostBreakdownLine[], cycle: BillingCycle, payableAmount: number, ): Promise<{ discountId: string; code: string; amount: number } | null> { if (!couponCode) return null; const result = await this.discountService.resolveForCharge(couponCode, userId, breakdown, cycle); const amount = Math.min(result.discountAmount, Math.max(0, Math.round(payableAmount))); if (amount <= 0) return null; return { discountId: result.discount.id, code: result.discount.code, amount }; } // ─── Optional services & custom domain (delegates to catalog) ───── getOptionalServicesPricing() { return this.pricingCatalog.getOptionalServicesPricing(); } setOptionalServicesPricing(pricing: { redis: { hourly: number; monthly: number; yearly: number }; rabbitmq: { hourly: number; monthly: number; yearly: number }; elasticsearch: { hourly: number; monthly: number; yearly: number }; }) { return this.pricingCatalog.setOptionalServicesPricing(pricing); } getCustomDomainPrice() { return this.pricingCatalog.getCustomDomainPrice(); } setCustomDomainPrice(monthlyPrice: number) { return this.pricingCatalog.setCustomDomainPrice(monthlyPrice); } // ─── Wallet ─────────────────────────────────────────────────────── async getOrCreateWallet(userId: string): Promise { 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) }; } /** * Load the user's wallet inside a transaction with a row-level lock * (SELECT ... FOR UPDATE) so concurrent charge/deduct operations serialize * instead of racing on read-modify-write. */ private async lockWallet(em: EntityManager, userId: string): Promise { let wallet = await em.getRepository(Wallet).findOne({ where: { userId }, lock: { mode: 'pessimistic_write' }, }); if (!wallet) { // First-time wallet creation may race; the unique userId column makes // one insert win — re-read with the lock afterwards. try { await em.getRepository(Wallet).insert({ userId, balance: 0 }); } catch { /* concurrent insert won — fall through to locked re-read */ } wallet = await em.getRepository(Wallet).findOneOrFail({ where: { userId }, lock: { mode: 'pessimistic_write' }, }); } return wallet; } async chargeWallet( userId: string, amount: number, description?: string, invoiceId?: string, ): Promise { if (amount <= 0) throw new BadRequestException('Amount must be positive'); const saved = await this.walletRepo.manager.transaction(async (em) => { const wallet = await this.lockWallet(em, userId); wallet.balance = Number(wallet.balance) + amount; await em.getRepository(Wallet).save(wallet); const tx = em.getRepository(WalletTransaction).create({ walletId: wallet.id, type: TransactionType.CHARGE, amount, balanceAfter: wallet.balance, description: description || 'Wallet charge', invoiceId, }); return em.getRepository(WalletTransaction).save(tx); }); this.logger.log(`Charged wallet of user ${userId}: +${amount} Toman → balance: ${saved.balanceAfter}`); return saved; } async deductWallet( userId: string, amount: number, description?: string, applicationId?: string, invoiceId?: string, ): Promise { if (amount <= 0) throw new BadRequestException('Amount must be positive'); const saved = await this.walletRepo.manager.transaction(async (em) => { const wallet = await this.lockWallet(em, userId); if (Number(wallet.balance) < amount) { throw new BadRequestException('Insufficient wallet balance'); } wallet.balance = Number(wallet.balance) - amount; await em.getRepository(Wallet).save(wallet); const tx = em.getRepository(WalletTransaction).create({ walletId: wallet.id, type: TransactionType.DEDUCTION, amount, balanceAfter: wallet.balance, description: description || 'Service payment', applicationId, invoiceId, }); return em.getRepository(WalletTransaction).save(tx); }); this.logger.log(`Deducted from wallet of user ${userId}: -${amount} Toman → balance: ${saved.balanceAfter}`); return saved; } async getTransactions(userId: string, limit = 50): Promise { const wallet = await this.getOrCreateWallet(userId); return this.txRepo.find({ where: { walletId: wallet.id }, relations: { invoice: true }, order: { createdAt: 'DESC' }, take: limit, }); } /** * Aggregate wallet movement for a user, grouped by transaction type. Used by * the admin user-detail dashboard. "revenue" is what the user actually spent * on services (deductions + direct gateway payments) — i.e. platform income — * as opposed to "charged" which is just money topped up into the wallet. */ async getRevenueSummary( userId: string, ): Promise<{ revenue: number; charged: number; refunded: number }> { const wallet = await this.getOrCreateWallet(userId); const rows = await this.txRepo .createQueryBuilder('tx') .select('tx.type', 'type') .addSelect('COALESCE(SUM(tx.amount), 0)', 'total') .where('tx.walletId = :walletId', { walletId: wallet.id }) .groupBy('tx.type') .getRawMany<{ type: TransactionType; total: string }>(); const totals = new Map(rows.map((r) => [r.type, Number(r.total)])); const revenue = (totals.get(TransactionType.DEDUCTION) ?? 0) + (totals.get(TransactionType.GATEWAY_PAYMENT) ?? 0); return { revenue, charged: totals.get(TransactionType.CHARGE) ?? 0, refunded: totals.get(TransactionType.REFUND) ?? 0, }; } async recordGatewayPayment( userId: string, amount: number, description: string, applicationId?: string, invoiceId?: string, gatewayTrackingCode?: string, ): Promise { if (amount <= 0) throw new BadRequestException('Amount must be positive'); const wallet = await this.getOrCreateWallet(userId); const tx = this.txRepo.create({ walletId: wallet.id, type: TransactionType.GATEWAY_PAYMENT, amount, balanceAfter: wallet.balance, description, applicationId, invoiceId, gatewayTrackingCode, }); return this.txRepo.save(tx); } // Admin: charge any user's wallet async adminChargeWallet(userId: string, amount: number, description?: string): Promise { return this.chargeWallet(userId, amount, description || 'Admin charge'); } // Admin: get all wallets async getAllWallets(): Promise { return this.walletRepo.find({ relations: { user: true }, order: { balance: 'DESC' } }); } // ─── Invoices ───────────────────────────────────────────────────── private generateInvoiceNumber(): string { const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); const suffix = Math.random().toString(36).slice(2, 8).toUpperCase(); return `INV-${date}-${suffix}`; } private normalizeAmount(amount: number): number { return Math.max(0, Math.round(Number(amount || 0) * 100) / 100); } async createInvoice(input: { userId: string; applicationId?: string; reason: InvoiceReason; lines: { label: string; description?: string; quantity?: number; unitAmount?: number; amount: number; metadata?: Record }[]; dueDate?: Date; metadata?: Record; discount?: { discountId: string; code: string; amount: number }; }): Promise { const lines = input.lines .filter((line) => this.normalizeAmount(line.amount) > 0) .map((line) => { const quantity = line.quantity || 1; const amount = this.normalizeAmount(line.amount); return this.invoiceLineRepo.create({ label: line.label, description: line.description, quantity, unitAmount: this.normalizeAmount(line.unitAmount ?? amount / quantity), amount, metadata: line.metadata, }); }); const subtotal = this.normalizeAmount(lines.reduce((sum, line) => sum + Number(line.amount), 0)); const discountAmount = input.discount ? Math.min(this.normalizeAmount(input.discount.amount), subtotal) : 0; const total = this.normalizeAmount(subtotal - discountAmount); if (total <= 0) { throw new BadRequestException('Invoice total must be positive'); } const invoice = this.invoiceRepo.create({ invoiceNumber: this.generateInvoiceNumber(), userId: input.userId, applicationId: input.applicationId, reason: input.reason, status: InvoiceStatus.ISSUED, subtotal, discountAmount, discountCode: input.discount?.code, total, paidAmount: 0, dueAmount: total, dueDate: input.dueDate, metadata: input.discount ? { ...(input.metadata || {}), discountId: input.discount.discountId } : input.metadata, lines, }); return this.invoiceRepo.save(invoice); } async listInvoices( user: { id: string; role?: UserRole }, filters: { status?: InvoiceStatus; applicationId?: string; userId?: string; paymentMethod?: PaymentMethod; search?: string; limit?: number; } = {}, ): Promise { const isAdmin = user.role === UserRole.ADMIN; const where: FindOptionsWhere = {}; if (!isAdmin) where.userId = user.id; if (isAdmin && filters.userId) where.userId = filters.userId; if (filters.status) where.status = filters.status; if (filters.applicationId) where.applicationId = filters.applicationId; if (filters.paymentMethod) where.paymentMethod = filters.paymentMethod; const qb = this.invoiceRepo .createQueryBuilder('invoice') .leftJoinAndSelect('invoice.user', 'user') .leftJoinAndSelect('invoice.application', 'application') .leftJoinAndSelect('invoice.lines', 'lines') .where(where) .orderBy('invoice.createdAt', 'DESC') .take(Math.min(filters.limit || 100, 200)); if (filters.search) { qb.andWhere( '(invoice."invoiceNumber" ILIKE :search OR user.email ILIKE :search OR application.name ILIKE :search)', { search: `%${filters.search}%` }, ); } return qb.getMany(); } async getInvoiceForUser(invoiceId: string, user: { id: string; role?: UserRole }): Promise { const invoice = await this.invoiceRepo.findOne({ where: { id: invoiceId }, relations: { user: true, application: true, lines: true, transactions: true }, order: { lines: { createdAt: 'ASC' }, transactions: { createdAt: 'DESC' } }, }); if (!invoice) throw new NotFoundException('Invoice not found'); if (user.role !== UserRole.ADMIN && invoice.userId !== user.id) { throw new ForbiddenException('You do not have access to this invoice'); } return invoice; } private ensureInvoicePayable(invoice: Invoice) { if ([InvoiceStatus.PAID, InvoiceStatus.VOID].includes(invoice.status)) { throw new BadRequestException(`Invoice is ${invoice.status}`); } if (Number(invoice.dueAmount) <= 0) { throw new BadRequestException('Invoice has no due amount'); } } private async applyInvoicePayment( invoice: Invoice, amount: number, paymentMethod: PaymentMethod, gatewayTrackingCode?: string, gatewayReference?: string, ): Promise { const paidAmount = this.normalizeAmount(Number(invoice.paidAmount) + amount); const dueAmount = this.normalizeAmount(Number(invoice.total) - paidAmount); invoice.paidAmount = paidAmount; invoice.dueAmount = dueAmount; invoice.paymentMethod = invoice.paymentMethod && invoice.paymentMethod !== paymentMethod ? PaymentMethod.MIXED : paymentMethod; invoice.status = dueAmount <= 0 ? InvoiceStatus.PAID : InvoiceStatus.PARTIALLY_PAID; invoice.paidAt = dueAmount <= 0 ? new Date() : invoice.paidAt; invoice.gatewayTrackingCode = gatewayTrackingCode || invoice.gatewayTrackingCode; invoice.gatewayReference = gatewayReference || invoice.gatewayReference; // Record the coupon redemption exactly once, when the invoice is fully paid. const discountId = invoice.metadata?.discountId; const alreadyRedeemed = invoice.metadata?.discountRedeemed; if (invoice.status === InvoiceStatus.PAID && discountId && !alreadyRedeemed) { invoice.metadata = { ...(invoice.metadata || {}), discountRedeemed: true }; const saved = await this.invoiceRepo.save(invoice); await this.discountService.recordRedemption( discountId, invoice.userId, invoice.id, Number(invoice.discountAmount) || 0, ); return saved; } return this.invoiceRepo.save(invoice); } async payInvoiceWithWallet(invoiceId: string, user: { id: string; role?: UserRole }) { const invoice = await this.getInvoiceForUser(invoiceId, user); this.ensureInvoicePayable(invoice); const amount = Number(invoice.dueAmount); const tx = await this.deductWallet( invoice.userId, amount, `Invoice ${invoice.invoiceNumber}: ${invoice.reason}`, invoice.applicationId, invoice.id, ); const updated = await this.applyInvoicePayment(invoice, amount, PaymentMethod.WALLET); return { invoice: updated, transaction: tx }; } async initiateInvoiceGatewayPayment( invoiceId: string, user: { id: string; role?: UserRole }, callbackUrl: string, ) { const invoice = await this.getInvoiceForUser(invoiceId, user); this.ensureInvoicePayable(invoice); const amount = Number(invoice.dueAmount); const trackingCode = `INV-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`; invoice.paymentMethod = invoice.paymentMethod && invoice.paymentMethod !== PaymentMethod.GATEWAY ? PaymentMethod.MIXED : PaymentMethod.GATEWAY; invoice.gatewayTrackingCode = trackingCode; await this.invoiceRepo.save(invoice); return { success: true, invoice, amount, trackingCode, gatewayUrl: `${callbackUrl}?invoiceId=${invoice.id}&trackingCode=${trackingCode}&amount=${amount}&status=success`, }; } async initiateInvoiceMixedPayment( invoiceId: string, user: { id: string; role?: UserRole }, callbackUrl: string, ) { const invoice = await this.getInvoiceForUser(invoiceId, user); this.ensureInvoicePayable(invoice); const wallet = await this.getOrCreateWallet(invoice.userId); const walletAmount = Math.min(Number(wallet.balance), Number(invoice.dueAmount)); let updatedInvoice = invoice; let walletTransaction: WalletTransaction | null = null; if (walletAmount > 0) { walletTransaction = await this.deductWallet( invoice.userId, walletAmount, `Partial wallet payment for invoice ${invoice.invoiceNumber}`, invoice.applicationId, invoice.id, ); updatedInvoice = await this.applyInvoicePayment(invoice, walletAmount, PaymentMethod.MIXED); } else { invoice.paymentMethod = PaymentMethod.MIXED; updatedInvoice = await this.invoiceRepo.save(invoice); } if (Number(updatedInvoice.dueAmount) <= 0) { return { success: true, invoice: updatedInvoice, walletAmount, gatewayAmount: 0, walletTransaction, message: 'Invoice paid from wallet balance', }; } const trackingCode = `INV-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`; updatedInvoice.gatewayTrackingCode = trackingCode; updatedInvoice.paymentMethod = PaymentMethod.MIXED; updatedInvoice = await this.invoiceRepo.save(updatedInvoice); return { success: true, invoice: updatedInvoice, walletAmount, gatewayAmount: Number(updatedInvoice.dueAmount), walletTransaction, trackingCode, gatewayUrl: `${callbackUrl}?invoiceId=${updatedInvoice.id}&trackingCode=${trackingCode}&amount=${updatedInvoice.dueAmount}&status=success`, }; } async verifyInvoiceGatewayPayment( invoiceId: string, user: { id: string; role?: UserRole }, trackingCode: string, amount: number, ) { const invoice = await this.getInvoiceForUser(invoiceId, user); this.ensureInvoicePayable(invoice); if (invoice.gatewayTrackingCode && invoice.gatewayTrackingCode !== trackingCode) { throw new BadRequestException('Invalid gateway tracking code'); } const payableAmount = Math.min(Number(invoice.dueAmount), this.normalizeAmount(amount)); const tx = await this.recordGatewayPayment( invoice.userId, payableAmount, `Gateway payment for invoice ${invoice.invoiceNumber}`, invoice.applicationId, invoice.id, trackingCode, ); const updated = await this.applyInvoicePayment( invoice, payableAmount, invoice.paymentMethod === PaymentMethod.MIXED ? PaymentMethod.MIXED : PaymentMethod.GATEWAY, trackingCode, `REF-${trackingCode}`, ); return { invoice: updated, transaction: tx }; } async updateInvoiceStatus( invoiceId: string, status: InvoiceStatus, reason: string, ): Promise { const invoice = await this.getInvoiceForUser(invoiceId, { id: '', role: UserRole.ADMIN }); invoice.status = status; invoice.statusReason = reason; if (status === InvoiceStatus.VOID || status === InvoiceStatus.FAILED) { invoice.dueAmount = Math.max(0, Number(invoice.total) - Number(invoice.paidAmount)); } return this.invoiceRepo.save(invoice); } async markInvoiceEffectCompleted(invoiceId: string, result: Record) { const invoice = await this.getInvoiceForUser(invoiceId, { id: '', role: UserRole.ADMIN }); invoice.metadata = { ...(invoice.metadata || {}), completedAt: new Date().toISOString(), completionResult: result, }; return this.invoiceRepo.save(invoice); } /** * Calculate cost for an existing Application entity. * Used by lifecycle service for auto-renew. */ async calculateCostForApp(app: { productType?: ProductType; runtime: string; databaseType: string; cpuLimit: string; memoryLimit: string; replicas: number; dbStorageSize?: string; appStorageSize?: string; enableRedis?: boolean; enableRabbitmq?: boolean; enableElasticsearch?: boolean; customDomain?: string; customDomainStatus?: string; optionalServiceResources?: Application['optionalServiceResources']; }): Promise<{ hourly: number; monthly: number; yearly: number }> { const result = await this.calculateCost(this.toCalculateDto(this.appToResourceConfig(app as Application))); return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly }; } // ─── Renewal Cost Calculation ───────────────────────────────────── /** * Calculate renewal cost for an application. * Returns cost for each billing cycle based on current app config. */ async calculateRenewalCost(app: Application): Promise<{ hourly: number; monthly: number; yearly: number; currentCycle?: BillingCycle; currentCycleCost?: number; }> { const costs = await this.calculateCostForApp(app); let currentCycleCost: number | undefined; if (app.billingCycle) { currentCycleCost = app.billingCycle === BillingCycle.HOURLY ? costs.hourly : app.billingCycle === BillingCycle.MONTHLY ? costs.monthly : costs.yearly; } return { ...costs, currentCycle: app.billingCycle, currentCycleCost, }; } // ─── Resource Upgrade Cost Calculation ──────────────────────────── /** * Calculate the cost difference for a resource upgrade. * Returns the additional cost per billing cycle. */ async calculateUpgradeCost( app: Application, newResources: UpgradeResourcesDto, ): Promise<{ currentCost: { hourly: number; monthly: number; yearly: number }; newCost: { hourly: number; monthly: number; yearly: number }; difference: { hourly: number; monthly: number; yearly: number }; proratedAmount: number; remainingHours: number; billingCycle: BillingCycle | null; }> { const currentCost = await this.calculateCostForApp(app); const base = this.appToResourceConfig(app); const merged = { ...base, ...(newResources.cpuLimit && { cpuLimit: newResources.cpuLimit }), ...(newResources.memoryLimit && { memoryLimit: newResources.memoryLimit }), replicas: newResources.replicas ?? base.replicas, ...(newResources.dbStorageSize && { dbStorageSize: newResources.dbStorageSize }), ...(newResources.appStorageSize && { appStorageSize: newResources.appStorageSize }), ...(newResources.databaseResources && { databaseResources: { ...base.databaseResources, ...newResources.databaseResources, storageGi: newResources.databaseResources.storageGi ?? base.databaseResources?.storageGi ?? 1, }, }), ...(newResources.redisResources && { redisResources: { ...base.redisResources, ...newResources.redisResources, storageGi: newResources.redisResources.storageGi ?? base.redisResources?.storageGi ?? 1, }, }), ...(newResources.rabbitmqResources && { rabbitmqResources: { ...base.rabbitmqResources, ...newResources.rabbitmqResources, storageGi: newResources.rabbitmqResources.storageGi ?? base.rabbitmqResources?.storageGi ?? 2, }, }), }; const newCostResult = await this.calculateCost(this.toCalculateDto(merged)); const newCost = { hourly: newCostResult.hourly, monthly: newCostResult.monthly, yearly: newCostResult.yearly, }; // Difference const difference = { hourly: newCost.hourly - currentCost.hourly, monthly: newCost.monthly - currentCost.monthly, yearly: newCost.yearly - currentCost.yearly, }; // Calculate prorated amount based on remaining time in billing period. // Use the price difference of the app's own billing cycle scaled by the // fraction of the cycle that remains — not the hourly rate for all cycles. let proratedAmount = 0; let remainingHours = 0; if (app.planExpiresAt && app.billingCycle) { const now = new Date(); const expiresAt = new Date(app.planExpiresAt); remainingHours = Math.max(0, (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60)); const cycleDifference = this.amountForCycle(difference, app.billingCycle); const cycleHours = app.billingCycle === BillingCycle.HOURLY ? 1 : app.billingCycle === BillingCycle.MONTHLY ? 30 * 24 : 365 * 24; // Only charge difference if upgrading (not downgrading) if (cycleDifference > 0) { const remainingFraction = Math.min(1, remainingHours / cycleHours); proratedAmount = Math.ceil(cycleDifference * remainingFraction); } } return { currentCost, newCost, difference, proratedAmount, remainingHours: Math.round(remainingHours), 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); const optionalRes = 'optionalServiceResources' in app ? (app as Application).optionalServiceResources : undefined; const dtoExtras = 'redisResources' in app || 'databaseResources' in app ? (app as CalculateCostDto) : undefined; const productType = 'productType' in app ? ((app as Application).productType ?? ProductType.APPLICATION) : ((app as CalculateCostDto).productType ?? ProductType.APPLICATION); const managed = isManagedProductType(productType); return { productType, runtime: app.runtime, databaseType: app.databaseType, cpuLimit: app.cpuLimit, memoryLimit: app.memoryLimit, replicas: managed ? 0 : (app.replicas ?? 1), dbStorageSize: app.dbStorageSize, appStorageSize: app.appStorageSize, enableRedis: !!app.enableRedis, enableRabbitmq: !!app.enableRabbitmq, enableElasticsearch: managed ? false : !!app.enableElasticsearch, enableCustomDomain: managed ? false : enableCustomDomain, databaseResources: optionalRes?.database ?? dtoExtras?.databaseResources, redisResources: optionalRes?.redis ?? dtoExtras?.redisResources, rabbitmqResources: optionalRes?.rabbitmq ?? dtoExtras?.rabbitmqResources, }; } async createCreditFromDeletedApp(app: Application): Promise { 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, productType: app.productType ?? ProductType.APPLICATION, 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 { 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); const remainingMinutes = Math.floor((remainingMs % 3600000) / 60000); const remainingLabel = remainingDays > 0 ? `${remainingDays}d ${remainingHours}h ${remainingMinutes}m` : remainingHours > 0 ? `${remainingHours}h ${remainingMinutes}m` : remainingMinutes > 0 ? `${remainingMinutes}m` : 'less than 1m'; return { id: credit.id, sourceAppName: credit.sourceAppName, productType: credit.productType ?? ProductType.APPLICATION, 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, remainingDays, remainingHours, remainingMinutes, remainingLabel, }; } configWithinCredit( config: ReturnType, credit: ResourceCredit, ): boolean { const configProduct = config.productType ?? ProductType.APPLICATION; const creditProduct = credit.productType ?? ProductType.APPLICATION; if (configProduct !== creditProduct) return false; if (config.runtime !== credit.runtime) return false; if ( credit.databaseType !== DatabaseType.NONE && config.databaseType !== credit.databaseType ) { return false; } if ( this.pricingCatalog.parseCpuToCores(config.cpuLimit) > this.pricingCatalog.parseCpuToCores(credit.cpuLimit) ) { return false; } if ( this.pricingCatalog.parseMemoryToGb(config.memoryLimit) > this.pricingCatalog.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, ): Promise { const credits = await this.getActiveCredits(userId); return ( credits.find((c) => { const creditProduct = c.productType ?? ProductType.APPLICATION; const configProduct = config.productType ?? ProductType.APPLICATION; if (creditProduct !== configProduct) return false; return ( c.runtime === config.runtime && (c.databaseType === DatabaseType.NONE || c.databaseType === config.databaseType) ); }) ?? null ); } private async costDelta( base: CalculateCostDto, withExtras: Partial, cycle: BillingCycle, ): Promise { 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, ): CalculateCostDto { return { productType: config.productType, 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, databaseResources: config.databaseResources, redisResources: config.redisResources, rabbitmqResources: config.rabbitmqResources, }; } /** Baseline config covered by the prepaid credit (used for isolated add-on pricing). */ private creditBaselineDto( credit: ResourceCredit, patch: Partial = {}, ): CalculateCostDto { return { productType: credit.productType ?? ProductType.APPLICATION, 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, credit: ResourceCredit, label: string, ): Promise { 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, cycle: BillingCycle, ): Promise { 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.pricingCatalog.amountForCycleFromLine(afterLine, cycle) : 0; const beforeAmt = beforeLine ? this.pricingCatalog.amountForCycleFromLine(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, 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.pricingCatalog.parseCpuToCores(config.cpuLimit) > this.pricingCatalog.parseCpuToCores(credit.cpuLimit) ) { await this.addExtraLineProrated( items, baseline, { cpuLimit: config.cpuLimit }, credit, `Extra CPU (${credit.cpuLimit} → ${config.cpuLimit})`, ); } if ( this.pricingCatalog.parseMemoryToGb(config.memoryLimit) > this.pricingCatalog.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, couponCode?: string, ) { 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); // Coupon discount is scoped against the full service breakdown but capped // to whatever is actually payable after prepaid credits. const couponFor = (amountDue: number) => this.previewCouponForResponse(userId, couponCode, costs.breakdown, cycle, amountDue); if (!credit) { const couponDiscount = await couponFor(fullAmount); return { ...costs, cycle, fullAmount, amountDue: fullAmount, coveredAmount: 0, waivedAmount: 0, extrasBreakdown: [], creditApplied: null, prepaidCreditUsed: false, couponDiscount, amountDueAfterDiscount: fullAmount - (couponDiscount && couponDiscount.valid ? couponDiscount.discountAmount : 0), }; } const { total: extrasDue, items: extrasBreakdown } = await this.calculateExtrasBeyondCredit(config, credit, cycle); const waivedAmount = Math.max(0, fullAmount - extrasDue); const prorate = this.getCreditProrateFactor(credit); const couponDiscount = await couponFor(extrasDue); return { ...costs, cycle, fullAmount, amountDue: extrasDue, coveredAmount: waivedAmount, waivedAmount, extrasBreakdown, creditApplied: this.formatCreditForApi(credit), prepaidCreditUsed: waivedAmount > 0, prorateRemainingDays: prorate.remainingDays, proratePeriodDays: prorate.periodDays, couponDiscount, amountDueAfterDiscount: extrasDue - (couponDiscount && couponDiscount.valid ? couponDiscount.discountAmount : 0), }; } /** Evaluate a coupon for a preview response (no throw), capped to amountDue. */ private async previewCouponForResponse( userId: string, couponCode: string | undefined, breakdown: CostBreakdownLine[], cycle: BillingCycle, amountDue: number, ): Promise< | { valid: true; code: string; name: string; percentOff: number; discountAmount: number } | { valid: false; reason: string } | null > { if (!couponCode) return null; const result = await this.discountService.evaluate(couponCode, userId, breakdown, cycle); if (!result.ok) { return { valid: false, reason: result.reason ?? 'not_found' }; } const discountAmount = Math.min(result.discountAmount, Math.max(0, Math.round(amountDue))); if (discountAmount <= 0) return { valid: false, reason: 'no_eligible_services' }; return { valid: true, code: result.discount.code, name: result.discount.name, percentOff: result.discount.percentOff, discountAmount, }; } /** * 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, }; } }