import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Subscription, SubscriptionStatus } from './entities/subscription.entity'; import { Transaction, PaymentStatus, PaymentProvider } from './entities/transaction.entity'; import { Tenant, TenantPlan } from '../auth/entities/tenant.entity'; import { MailService } from '../common/mail.service'; @Injectable() export class BillingService { private readonly logger = new Logger(BillingService.name); constructor( @InjectRepository(Subscription) private subscriptionRepository: Repository, @InjectRepository(Transaction) private transactionRepository: Repository, @InjectRepository(Tenant) private tenantRepository: Repository, private configService: ConfigService, private mailService: MailService, ) {} /** * Get configured PayMob Iframe ID */ getIframeId(): string { return this.configService.get('PAYMOB_IFRAME_ID', '837992'); } /** * Get current subscription for a tenant */ async getSubscription(tenantId: string): Promise { let sub = await this.subscriptionRepository.findOne({ where: { tenantId } }); // Auto-initialize if not exists if (!sub) { sub = await this.subscriptionRepository.save({ tenantId, plan: 'FREE', monthlyRequestLimit: 5000, status: SubscriptionStatus.ACTIVE, }); } return sub; } /** * Handle successful payment from any provider */ async processSuccessfulPayment( externalTxId: string, provider: PaymentProvider, amount: number, metadata: any ) { // 1. Find or create transaction let txn = await this.transactionRepository.findOne({ where: { externalTransactionId: externalTxId } }); if (txn && txn.status === PaymentStatus.SUCCESS) { this.logger.warn(`Transaction ${externalTxId} already processed.`); return; } if (!txn) { // This might happen if webhook arrives before front-end redirect // Logic to resolve tenantId from metadata should be here const tenantId = metadata.tenantId; if (!tenantId) throw new BadRequestException('No tenantId found in payment metadata'); txn = await this.transactionRepository.save({ tenantId, externalTransactionId: externalTxId, amount, provider, status: PaymentStatus.SUCCESS, metadata, }); } else { txn.status = PaymentStatus.SUCCESS; txn.metadata = { ...txn.metadata, ...metadata }; await this.transactionRepository.save(txn); } // 2. Upgrade the subscription and tenant plan const tenantId = txn.tenantId; const plan = metadata.plan || 'PRO'; // Default to PRO for paid txns await this.upgradeTenantPlan(tenantId, plan as TenantPlan); // 3. Send Invoice Email try { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } }); if (tenant && tenant.email) { await this.mailService.sendInvoiceEmail(tenant.email, { tenantName: tenant.name, plan: plan, amount: `${amount} EGP`, transactionId: externalTxId, }); } } catch (err) { this.logger.error(`Failed to send invoice email for tx ${externalTxId}: ${err.message}`); } this.logger.log(`✅ Tenant ${tenantId} upgraded to ${plan} after payment ${externalTxId}`); } private async upgradeTenantPlan(tenantId: string, plan: TenantPlan) { // Update Tenant await this.tenantRepository.update(tenantId, { plan }); // Update Subscription const limits = { [TenantPlan.FREE]: 5000, [TenantPlan.STARTER]: 25000, [TenantPlan.PRO]: 100000, [TenantPlan.ENTERPRISE]: 500000, }; const sub = await this.getSubscription(tenantId); sub.plan = plan; sub.monthlyRequestLimit = limits[plan] || 8000; sub.status = SubscriptionStatus.ACTIVE; sub.currentPeriodStart = new Date(); const nextMonth = new Date(); nextMonth.setMonth(nextMonth.getMonth() + 1); sub.currentPeriodEnd = nextMonth; await this.subscriptionRepository.save(sub); } async getTransactionHistory(tenantId: string): Promise { return this.transactionRepository.find({ where: { tenantId }, order: { createdAt: 'DESC' }, }); } }