Files
maps-saas/apps/api/src/billing/billing.service.ts
T

144 lines
4.5 KiB
TypeScript

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<Subscription>,
@InjectRepository(Transaction)
private transactionRepository: Repository<Transaction>,
@InjectRepository(Tenant)
private tenantRepository: Repository<Tenant>,
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<Subscription> {
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<Transaction[]> {
return this.transactionRepository.find({
where: { tenantId },
order: { createdAt: 'DESC' },
});
}
}