2026-04-15-4

This commit is contained in:
Hamza-Ayed
2026-04-15 19:56:49 +03:00
parent 9cd1ac4c1d
commit 3d61362602
54 changed files with 12659 additions and 436 deletions
+101
View File
@@ -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);
}
}
+19
View File
@@ -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 {}
+142
View File
@@ -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;
}
}