2026-04-15-4
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { Controller, Get, Post, Body, Query, UseGuards, Req, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { BillingService } from './billing.service';
|
||||
import { PayMobProvider } from './providers/paymob.provider';
|
||||
import { BinanceProvider } from './providers/binance.provider';
|
||||
import { FirebaseAuthGuard } from '../auth/guards/firebase-auth.guard';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { PaymentProvider, PaymentStatus } from './entities/transaction.entity';
|
||||
|
||||
@ApiTags('billing')
|
||||
@Controller('billing')
|
||||
export class BillingController {
|
||||
private readonly logger = new Logger(BillingController.name);
|
||||
|
||||
constructor(
|
||||
private billingService: BillingService,
|
||||
private paymobProvider: PayMobProvider,
|
||||
private binanceProvider: BinanceProvider,
|
||||
) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Get('subscription')
|
||||
@ApiOperation({ summary: 'Get current subscription details' })
|
||||
async getSubscription(@Req() req: any) {
|
||||
return this.billingService.getSubscription(req.tenant.id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Post('checkout')
|
||||
@ApiOperation({ summary: 'Initialize a checkout session' })
|
||||
async checkout(@Req() req: any, @Body() body: { plan: string; provider: PaymentProvider }) {
|
||||
const tenantId = req.tenant.id;
|
||||
const plan = body.plan;
|
||||
const amount = plan === 'PRO' ? 40 : 0; // Price logic
|
||||
|
||||
if (body.provider === PaymentProvider.PAYMOB) {
|
||||
const { paymentKey, orderId } = await this.paymobProvider.createPaymentKey(tenantId, amount, plan);
|
||||
|
||||
// Iframe URL (Using the new provided Iframe ID)
|
||||
const iframeId = this.billingService.getIframeId();
|
||||
const checkoutUrl = `https://accept.paymob.com/api/acceptance/iframes/${iframeId}?payment_token=${paymentKey}`;
|
||||
|
||||
return { checkoutUrl, orderId };
|
||||
}
|
||||
|
||||
if (body.provider === PaymentProvider.BINANCE) {
|
||||
return this.binanceProvider.createOrder(tenantId, amount, plan);
|
||||
}
|
||||
|
||||
throw new BadRequestException('Invalid payment provider');
|
||||
}
|
||||
|
||||
/**
|
||||
* PayMob Transaction Processed Webhook
|
||||
* This is called by PayMob when a transaction is attempted
|
||||
*/
|
||||
@Post('webhooks/paymob')
|
||||
@ApiOperation({ summary: 'PayMob Payment Webhook' })
|
||||
async handlePaymobWebhook(@Body() body: any, @Query('hmac') hmac: string) {
|
||||
this.logger.log(`📥 PayMob Webhook Received: Transaction ID ${body.obj?.id}`);
|
||||
|
||||
// 1. Verify HMAC
|
||||
if (!this.paymobProvider.verifyHmac(body.obj, hmac)) {
|
||||
this.logger.error('❌ PayMob HMAC Verification Failed');
|
||||
throw new BadRequestException('Invalid signature');
|
||||
}
|
||||
|
||||
// 2. Process payment if successful
|
||||
if (body.obj.success === true) {
|
||||
// Extract metadata from extra_description (format: tenantId|plan)
|
||||
const extraDesc = body.obj.order?.shipping_data?.extra_description || "";
|
||||
const [tenantId, plan] = extraDesc.split('|');
|
||||
|
||||
if (!tenantId) {
|
||||
this.logger.error(`❌ PayMob Webhook failed: No tenantId in metadata. Raw: ${extraDesc}`);
|
||||
return { status: 'error', message: 'No tenantId found' };
|
||||
}
|
||||
|
||||
await this.billingService.processSuccessfulPayment(
|
||||
body.obj.id.toString(),
|
||||
PaymentProvider.PAYMOB,
|
||||
body.obj.amount_cents / 100,
|
||||
{
|
||||
tenantId,
|
||||
plan: plan || 'PRO'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { status: 'success' };
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Get('invoices')
|
||||
@ApiOperation({ summary: 'Get payment history' })
|
||||
async getInvoices(@Req() req: any) {
|
||||
return this.billingService.getTransactionHistory(req.tenant.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Subscription } from './entities/subscription.entity';
|
||||
import { Transaction } from './entities/transaction.entity';
|
||||
import { BillingService } from './billing.service';
|
||||
import { BillingController } from './billing.controller';
|
||||
import { PayMobProvider } from './providers/paymob.provider';
|
||||
import { BinanceProvider } from './providers/binance.provider';
|
||||
import { Tenant } from '../auth/entities/tenant.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Subscription, Transaction, Tenant]),
|
||||
],
|
||||
providers: [BillingService, PayMobProvider, BinanceProvider],
|
||||
controllers: [BillingController],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -0,0 +1,142 @@
|
||||
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: 8000,
|
||||
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]: 8000,
|
||||
[TenantPlan.PRO]: 50000,
|
||||
[TenantPlan.ENTERPRISE]: 1000000,
|
||||
};
|
||||
|
||||
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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../auth/entities/tenant.entity';
|
||||
|
||||
export enum SubscriptionStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
PAST_DUE = 'PAST_DUE',
|
||||
CANCELED = 'CANCELED',
|
||||
TRIALING = 'TRIALING',
|
||||
}
|
||||
|
||||
@Entity('subscriptions')
|
||||
export class Subscription {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ default: 'FREE' })
|
||||
plan: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: SubscriptionStatus,
|
||||
default: SubscriptionStatus.ACTIVE,
|
||||
})
|
||||
status: SubscriptionStatus;
|
||||
|
||||
@Column({ type: 'int', default: 8000 })
|
||||
monthlyRequestLimit: number;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
currentPeriodStart: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
currentPeriodEnd: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
canceledAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum PaymentStatus {
|
||||
PENDING = 'PENDING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
FAILED = 'FAILED',
|
||||
REFUNDED = 'REFUNDED',
|
||||
}
|
||||
|
||||
export enum PaymentProvider {
|
||||
PAYMOB = 'PAYMOB',
|
||||
BINANCE = 'BINANCE',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
|
||||
@Entity('transactions')
|
||||
export class Transaction {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
subscriptionId: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||
amount: number;
|
||||
|
||||
@Column({ default: 'USD' })
|
||||
currency: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: PaymentProvider,
|
||||
})
|
||||
provider: PaymentProvider;
|
||||
|
||||
@Index()
|
||||
@Column({ nullable: true })
|
||||
externalTransactionId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: PaymentStatus,
|
||||
default: PaymentStatus.PENDING,
|
||||
})
|
||||
status: PaymentStatus;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class BinanceProvider {
|
||||
private readonly logger = new Logger(BinanceProvider.name);
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Mock Binance Pay Order Creation
|
||||
* Will lead to a success/fail redirect for testing
|
||||
*/
|
||||
async createOrder(tenantId: string, amount: number, plan: string) {
|
||||
this.logger.log(`[Mock] Creating Binance Pay order for ${tenantId} - ${plan}`);
|
||||
|
||||
// In production, this calls https://bpay.binanceapi.com/binancepay/openapi/v2/order
|
||||
// and returns a checkoutUrl.
|
||||
|
||||
return {
|
||||
checkoutUrl: `https://map-dashbord.intaleqapp.com/api/billing/mock-binance-success?tenantId=${tenantId}&plan=${plan}`,
|
||||
prepayId: `mock_${Date.now()}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Webhook Signature Verification
|
||||
*/
|
||||
verifySignature(payload: any, signature: string): boolean {
|
||||
// In production, uses HMAC-SHA512 with Binance Secret Key
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class PayMobProvider {
|
||||
private readonly logger = new Logger(PayMobProvider.name);
|
||||
private readonly baseUrl = 'https://accept.paymob.com/api';
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Step 1 & 2: Authenticate and Create Order
|
||||
*/
|
||||
async createPaymentKey(tenantId: string, amount: number, plan: string): Promise<{ paymentKey: string; orderId: string }> {
|
||||
try {
|
||||
// 1. Authentication Request
|
||||
const authRes = await axios.post(`${this.baseUrl}/auth/tokens`, {
|
||||
api_key: this.configService.get('PAYMOB_API_KEY'),
|
||||
});
|
||||
const authToken = authRes.data.token;
|
||||
|
||||
// 2. Order Registration
|
||||
const orderRes = await axios.post(`${this.baseUrl}/ecommerce/orders`, {
|
||||
auth_token: authToken,
|
||||
delivery_needed: "false",
|
||||
amount_cents: amount * 100, // PayMob uses cents
|
||||
currency: "EGP", // Or USD based on integration
|
||||
items: [{
|
||||
name: `${plan} Subscription`,
|
||||
amount_cents: amount * 100,
|
||||
description: `Intaleq Maps ${plan} Plan`
|
||||
}],
|
||||
shipping_data: {
|
||||
// Mandatory dummy data + Metadata for identification
|
||||
first_name: "Intaleq",
|
||||
last_name: "User",
|
||||
email: "user@intaleq.com",
|
||||
phone_number: "+201000000000",
|
||||
extra_description: `${tenantId}|${plan}` // Used to identify tenant in webhook
|
||||
}
|
||||
});
|
||||
const orderId = orderRes.data.id;
|
||||
|
||||
// 3. Payment Key Generation
|
||||
const keyRes = await axios.post(`${this.baseUrl}/acceptance/payment_keys`, {
|
||||
auth_token: authToken,
|
||||
amount_cents: amount * 100,
|
||||
expiration: 3600,
|
||||
order_id: orderId,
|
||||
billing_data: {
|
||||
apartment: "NA",
|
||||
email: "user@intaleq.com",
|
||||
floor: "NA",
|
||||
first_name: "Intaleq",
|
||||
street: "NA",
|
||||
building: "NA",
|
||||
phone_number: "+201000000000",
|
||||
shipping_method: "NA",
|
||||
postal_code: "NA",
|
||||
city: "NA",
|
||||
country: "NA",
|
||||
last_name: "User",
|
||||
state: "NA"
|
||||
},
|
||||
currency: "EGP",
|
||||
integration_id: this.configService.get('PAYMOB_INTEGRATION_ID') || 4556055,
|
||||
lock_order_when_paid: "false"
|
||||
});
|
||||
|
||||
return {
|
||||
paymentKey: keyRes.data.token,
|
||||
orderId: orderId.toString()
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`PayMob Payment Key failed: ${error.response?.data ? JSON.stringify(error.response.data) : error.message}`);
|
||||
throw new BadRequestException('Failed to initialize PayMob payment');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature from PayMob Webhook
|
||||
*/
|
||||
verifyHmac(payload: any, hmac: string): boolean {
|
||||
const secret = this.configService.get('PAYMOB_HMAC_SECRET');
|
||||
if (!secret) return false;
|
||||
|
||||
// Concat fields in specific order as per PayMob docs
|
||||
const {
|
||||
amount_cents,
|
||||
created_at,
|
||||
currency,
|
||||
error_occured,
|
||||
has_parent_transaction,
|
||||
id,
|
||||
integration_id,
|
||||
is_3d_secure,
|
||||
is_auth,
|
||||
is_capture,
|
||||
is_refunded,
|
||||
is_standalone_payment,
|
||||
is_voided,
|
||||
order,
|
||||
owner,
|
||||
pending,
|
||||
source_data_pan,
|
||||
source_data_sub_type,
|
||||
source_data_type,
|
||||
success
|
||||
} = payload;
|
||||
|
||||
const source_pan = source_data_pan || "";
|
||||
const source_sub_type = source_data_sub_type || "";
|
||||
const source_type = source_data_type || "";
|
||||
|
||||
const data = [
|
||||
amount_cents,
|
||||
created_at,
|
||||
currency,
|
||||
error_occured,
|
||||
has_parent_transaction,
|
||||
id,
|
||||
integration_id,
|
||||
is_3d_secure,
|
||||
is_auth,
|
||||
is_capture,
|
||||
is_refunded,
|
||||
is_standalone_payment,
|
||||
is_voided,
|
||||
order.id, // PayMob sends order as object
|
||||
owner,
|
||||
pending,
|
||||
source_pan,
|
||||
source_sub_type,
|
||||
source_type,
|
||||
success
|
||||
].join("");
|
||||
|
||||
const hash = crypto
|
||||
.createHmac('sha512', secret)
|
||||
.update(data)
|
||||
.digest('hex');
|
||||
|
||||
return hash === hmac;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user