Created
July 23, 2026 02:46
-
-
Save emmsdan/16b15e5146e4e3ade818740f1a3b22fc to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Injectable, Logger, HttpException, HttpStatus, Inject } from '@nestjs/common'; | |
| import { InjectRepository } from '@nestjs/typeorm'; | |
| import { Repository, EntityManager, In, DataSource } from 'typeorm'; | |
| import { EventEmitter2 } from '@nestjs/event-emitter'; | |
| import { Cron, CronExpression } from '@nestjs/schedule'; | |
| import { ConfigService } from '@nestjs/config'; | |
| import { validateOrReject } from 'class-validator'; | |
| import { Payment } from './entities/payment.entity'; | |
| import { PaymentLog } from './entities/payment-log.entity'; | |
| import { IdempotencyRecord } from './entities/idempotency-record.entity'; | |
| import { CreatePaymentDto } from './dto/create-payment.dto'; | |
| import { CapturePaymentDto } from './dto/capture-payment.dto'; | |
| import { RefundPaymentDto } from './dto/refund-payment.dto'; | |
| import { PaymentResponseDto } from './dto/payment-response.dto'; | |
| import { FraudDetectionService } from '../fraud/fraud-detection.service'; | |
| import { LedgerService } from '../ledger/ledger.service'; | |
| import { NotificationService } from '../notifications/notification.service'; | |
| import { ExchangeRateService } from '../fx/exchange-rate.service'; | |
| import { PaymentGatewayFactory } from '../gateways/payment-gateway.factory'; | |
| import { PaymentStatus, Currency, PaymentMethod, GatewayType } from './enums/payment.enum'; | |
| import { FeeCalculationStrategy } from './strategies/fee-calculation.strategy'; | |
| import { IPaymentGateway } from '../gateways/interfaces/payment-gateway.interface'; | |
| import { | |
| PaymentNotFoundException, | |
| InvalidStateTransitionException, | |
| IdempotencyConflictException, FraudSuspicionException, | |
| } from './exceptions/payment.exceptions'; | |
| import { ALLOWED_TRANSITIONS } from '../constants/transitions.constants' | |
| @Injectable() | |
| export class PaymentService { | |
| private readonly logger = new Logger(PaymentService.name); | |
| private readonly MAX_IDEMPOTENCY_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours | |
| constructor( | |
| @InjectRepository(Payment) | |
| private readonly paymentRepository: Repository<Payment>, | |
| @InjectRepository(PaymentLog) | |
| private readonly paymentLogRepository: Repository<PaymentLog>, | |
| @InjectRepository(IdempotencyRecord) | |
| private readonly idempotencyRepo: Repository<IdempotencyRecord>, | |
| private readonly fraudDetectionService: FraudDetectionService, | |
| private readonly ledgerService: LedgerService, | |
| private readonly notificationService: NotificationService, | |
| private readonly exchangeRateService: ExchangeRateService, | |
| private readonly gatewayFactory: PaymentGatewayFactory, | |
| private readonly eventEmitter: EventEmitter2, | |
| private readonly configService: ConfigService, | |
| private readonly dataSource: DataSource, | |
| @Inject('FeeCalculationStrategy') | |
| private readonly feeStrategy: FeeCalculationStrategy, | |
| ) {} | |
| /** | |
| * Initiates a new payment. This is the main entry point for a merchant. | |
| * Includes idempotency check, fraud screening, fee calculation, and gateway call. | |
| */ | |
| async createPayment(dto: CreatePaymentDto): Promise<PaymentResponseDto> { | |
| this.logger.log(`Creating payment for merchant ${dto.merchantId}, idempotencyKey=${dto.idempotencyKey}`); | |
| // Idempotency check | |
| const existing = await this.checkIdempotency(dto.idempotencyKey, dto.merchantId); | |
| if (existing) { | |
| this.logger.warn(`Duplicate request detected for key ${dto.idempotencyKey}`); | |
| return this.mapToResponse(existing); | |
| } | |
| // Validate amount & currency | |
| if (dto.amount <= 0) { | |
| throw new HttpException('Amount must be positive', HttpStatus.BAD_REQUEST); | |
| } | |
| if (!Object.values(Currency).includes(dto.currency)) { | |
| throw new HttpException('Unsupported currency', HttpStatus.BAD_REQUEST); | |
| } | |
| // Exchange rate for settlement (if needed) | |
| let baseAmount = dto.amount; | |
| if (dto.currency !== Currency.USD) { | |
| const rate = await this.exchangeRateService.getRate(dto.currency, Currency.USD); | |
| baseAmount = Math.round(dto.amount * rate * 100) / 100; | |
| } | |
| // Fraud pre-check (async, non-blocking can be configured) | |
| const fraudScore = await this.fraudDetectionService.evaluate({ | |
| amount: dto.amount, | |
| currency: dto.currency, | |
| customerId: dto.customerId, | |
| merchantId: dto.merchantId, | |
| method: dto.method, | |
| }); | |
| if (fraudScore > this.configService.get<number>('FRAUD_THRESHOLD', 80)) { | |
| this.logger.error(`Fraud suspicion for customer ${dto.customerId}, score=${fraudScore}`); | |
| throw new FraudSuspicionException('Transaction blocked by fraud system'); | |
| } | |
| // Fee calculation | |
| const fees = await this.feeStrategy.calculate(dto.amount, dto.currency, dto.method, dto.merchantId); | |
| const netAmount = dto.amount - fees.totalFee; | |
| // Create payment entity (in-memory draft) | |
| const newPayment: Partial<Payment> = { | |
| id: this.generatePaymentId(), | |
| merchantId: dto.merchantId, | |
| customerId: dto.customerId, | |
| amount: dto.amount, | |
| currency: dto.currency, | |
| method: dto.method, | |
| status: PaymentStatus.PENDING, | |
| gatewayType: dto.gatewayType ?? GatewayType.DEFAULT, | |
| idempotencyKey: dto.idempotencyKey, | |
| description: dto.description, | |
| feesAmount: fees.totalFee, | |
| netAmount: netAmount, | |
| metadata: dto.metadata, | |
| webhookUrl: dto.webhookUrl, | |
| redirectUrl: dto.redirectUrl, | |
| createdAt: new Date(), | |
| updatedAt: new Date(), | |
| version: 1, | |
| }; | |
| // Persist with idempotency record in transaction | |
| const payment = await this.dataSource.transaction(async (manager: EntityManager) => { | |
| const paymentRepo = manager.getRepository(Payment); | |
| const idempotencyRepo = manager.getRepository(IdempotencyRecord); | |
| const savedPayment = await paymentRepo.save(newPayment); | |
| await idempotencyRepo.save({ | |
| key: dto.idempotencyKey, | |
| merchantId: dto.merchantId, | |
| paymentId: savedPayment.id, | |
| response: savedPayment, | |
| createdAt: new Date(), | |
| }); | |
| return savedPayment; | |
| }); | |
| const gateway: IPaymentGateway = this.gatewayFactory.create(payment.gatewayType); | |
| try { | |
| const authResult = await gateway.authorize({ | |
| paymentId: payment.id, | |
| amount: payment.amount, | |
| currency: payment.currency, | |
| method: payment.method, | |
| description: payment.description, | |
| metadata: payment.metadata, | |
| }); | |
| console.log('ss0s0s0---', authResult) | |
| if (authResult.success) { | |
| await this.updatePaymentStatus(payment.id, PaymentStatus.AUTHORIZED, { | |
| gatewayPaymentId: authResult.gatewayId, | |
| }); | |
| this.logger.log(`Payment ${payment.id} authorized via gateway`); | |
| } else { | |
| await this.updatePaymentStatus(payment.id, PaymentStatus.FAILED, { | |
| failureReason: authResult.reason, | |
| }); | |
| throw new HttpException('Payment authorization failed', HttpStatus.PAYMENT_REQUIRED); | |
| } | |
| } catch (error) { | |
| console.log('test authssssss----', error, '[]') | |
| this.logger.error(`Gateway authorize error for payment ${payment.id}: ${error.message}`); | |
| await this.updatePaymentStatus(payment.id, PaymentStatus.FAILED, { | |
| failureReason: 'GATEWAY_ERROR', | |
| }); | |
| throw new HttpException('Payment processing error', HttpStatus.INTERNAL_SERVER_ERROR); | |
| } | |
| // Emit event for async processing (ledger, notifications) | |
| this.eventEmitter.emit('payment.created', payment); | |
| return this.mapToResponse(payment); | |
| } | |
| /** | |
| * Captures a previously authorized payment (full or partial). | |
| */ | |
| async capturePayment(dto: CapturePaymentDto): Promise<PaymentResponseDto> { | |
| const payment = await this.paymentRepository.findOne({ where: { id: dto.paymentId } }); | |
| if (!payment) throw new PaymentNotFoundException(dto.paymentId); | |
| this.validateStateTransition(payment.status, PaymentStatus.CAPTURED); | |
| const captureAmount = dto.amount ?? payment.amount - (payment.capturedAmount ?? 0); | |
| if (captureAmount <= 0 || captureAmount > payment.amount - (payment.capturedAmount ?? 0)) { | |
| throw new HttpException('Invalid capture amount', HttpStatus.BAD_REQUEST); | |
| } | |
| const gateway: IPaymentGateway = this.gatewayFactory.create(payment.gatewayType); | |
| const captureResult = await gateway.capture({ | |
| gatewayPaymentId: payment.gatewayPaymentId!, | |
| amount: captureAmount, | |
| currency: payment.currency, | |
| }); | |
| console.log("got here----sdsd", captureResult) | |
| if (!captureResult.success) { | |
| throw new HttpException(`Capture failed: ${captureResult.reason}`, HttpStatus.PAYMENT_REQUIRED); | |
| } | |
| const newCaptured = (payment.capturedAmount ?? 0) + captureAmount; | |
| const newStatus = newCaptured >= payment.amount ? PaymentStatus.CAPTURED : payment.status; // remain AUTHORIZED for partial? | |
| // Business rule: partial capture keeps status AUTHORIZED, but record captured amount | |
| await this.paymentRepository.update(payment.id, { | |
| capturedAmount: newCaptured, | |
| status: newCaptured >= payment.amount ? PaymentStatus.CAPTURED : PaymentStatus.AUTHORIZED, | |
| updatedAt: new Date(), | |
| }); | |
| this.eventEmitter.emit('payment.captured', { paymentId: payment.id, amount: captureAmount }); | |
| return this.mapToResponse(await this.paymentRepository.findOne({ where: { id: payment.id } })!); | |
| } | |
| /** | |
| * Refund a captured payment (full or partial). | |
| */ | |
| async refundPayment(dto: RefundPaymentDto): Promise<PaymentResponseDto> { | |
| const payment = await this.paymentRepository.findOne({ where: { id: dto.paymentId } }); | |
| if (!payment) throw new PaymentNotFoundException(dto.paymentId); | |
| this.validateStateTransition(payment.status, PaymentStatus.REFUNDED); | |
| const maxRefundable = payment.capturedAmount ?? payment.amount; // if captured fully | |
| const refundAmount = dto.amount ?? maxRefundable - (payment.refundedAmount ?? 0); | |
| if (refundAmount <= 0 || refundAmount > maxRefundable - (payment.refundedAmount ?? 0)) { | |
| throw new HttpException('Invalid refund amount', HttpStatus.BAD_REQUEST); | |
| } | |
| const gateway: IPaymentGateway = this.gatewayFactory.create(payment.gatewayType); | |
| const refundResult = await gateway.refund({ | |
| gatewayPaymentId: payment.gatewayPaymentId!, | |
| amount: refundAmount, | |
| currency: payment.currency, | |
| reason: dto.reason, | |
| }); | |
| if (!refundResult.success) { | |
| throw new HttpException(`Refund failed: ${refundResult.reason}`, HttpStatus.PAYMENT_REQUIRED); | |
| } | |
| const newRefunded = (payment.refundedAmount ?? 0) + refundAmount; | |
| const newStatus: PaymentStatus = | |
| newRefunded >= (payment.capturedAmount ?? payment.amount) | |
| ? PaymentStatus.REFUNDED | |
| : PaymentStatus.PARTIALLY_REFUNDED; | |
| await this.paymentRepository.update(payment.id, { | |
| refundedAmount: newRefunded, | |
| status: newStatus, | |
| updatedAt: new Date(), | |
| }); | |
| this.eventEmitter.emit('payment.refunded', { paymentId: payment.id, amount: refundAmount }); | |
| // Reverse ledger entry | |
| await this.ledgerService.reverseEntry(payment.id, refundAmount); | |
| return this.mapToResponse(await this.paymentRepository.findOne({ where: { id: payment.id } })!); | |
| } | |
| /** | |
| * Cancel an authorized or pending payment. | |
| */ | |
| async cancelPayment(paymentId: string): Promise<PaymentResponseDto> { | |
| const payment = await this.paymentRepository.findOne({ where: { id: paymentId } }); | |
| if (!payment) throw new PaymentNotFoundException(paymentId); | |
| this.validateStateTransition(payment.status, PaymentStatus.CANCELLED); | |
| if (payment.status === PaymentStatus.AUTHORIZED) { | |
| const gateway: IPaymentGateway = this.gatewayFactory.create(payment.gatewayType); | |
| await gateway.voidAuthorization({ gatewayPaymentId: payment.gatewayPaymentId! }); | |
| } | |
| await this.paymentRepository.update(payment.id, { | |
| status: PaymentStatus.CANCELLED, | |
| updatedAt: new Date(), | |
| }); | |
| this.eventEmitter.emit('payment.cancelled', { paymentId: payment.id }); | |
| return this.mapToResponse(await this.paymentRepository.findOne({ where: { id: paymentId } })!); | |
| } | |
| /** | |
| * Retrieve a single payment by ID. | |
| */ | |
| async getPayment(paymentId: string): Promise<PaymentResponseDto> { | |
| const payment = await this.paymentRepository.findOne({ where: { id: paymentId } }); | |
| if (!payment) throw new PaymentNotFoundException(paymentId); | |
| return this.mapToResponse(payment); | |
| } | |
| /** | |
| * Handle incoming gateway webhooks (stub - actual routing logic would be separate). | |
| */ | |
| async handleWebhook(gateway: GatewayType, payload: any): Promise<void> { | |
| this.logger.log(`Received webhook from gateway ${gateway}: ${JSON.stringify(payload)}`); | |
| // TODO: Validate signature, parse event type, update payment status accordingly | |
| throw new Error('Webhook handler not fully implemented'); | |
| } | |
| /** | |
| * Scheduled job to expire pending payments older than threshold. | |
| */ | |
| @Cron(CronExpression.EVERY_10_MINUTES) | |
| async expireStalePayments(): Promise<void> { | |
| const expirationMinutes = this.configService.get<number>('PAYMENT_EXPIRY_MINUTES', 30); | |
| const cutoff = new Date(Date.now() - expirationMinutes * 60 * 1000); | |
| const stale = await this.paymentRepository.find({ | |
| where: { | |
| status: In([PaymentStatus.PENDING, PaymentStatus.AUTHORIZED]), | |
| createdAt: In as any, // simplified | |
| }, | |
| }); | |
| const toExpire = stale.filter(p => p.createdAt < cutoff); | |
| for (const p of toExpire) { | |
| await this.paymentRepository.update(p.id, { status: PaymentStatus.EXPIRED, updatedAt: new Date() }); | |
| this.logger.log(`Expired payment ${p.id}`); | |
| } | |
| } | |
| /** | |
| * Reconcilation report (stub). | |
| */ | |
| async reconcilePayments(startDate: Date, endDate: Date): Promise<any> { | |
| // TODO: Compare internal ledger with gateway settlements | |
| return { message: 'Reconciliation not implemented' }; | |
| } | |
| private async checkIdempotency(key: string, merchantId: string): Promise<Payment | null> { | |
| const record = await this.idempotencyRepo.findOne({ where: { key, merchantId } }); | |
| if (!record) return null; | |
| // Check age to prevent replay of very old keys | |
| if (Date.now() - record.createdAt.getTime() > this.MAX_IDEMPOTENCY_AGE_MS) { | |
| this.logger.warn(`Idempotency key ${key} expired, treating as new`); | |
| return null; | |
| } | |
| // Return previously stored response | |
| const payment = await this.paymentRepository.findOne({ where: { id: record.paymentId } }); | |
| return payment; | |
| } | |
| private validateStateTransition(current: PaymentStatus, target: PaymentStatus): void { | |
| const allowed = ALLOWED_TRANSITIONS[current]; | |
| if (!allowed || !allowed.includes(target)) { | |
| throw new InvalidStateTransitionException(current, target); | |
| } | |
| } | |
| private async updatePaymentStatus( | |
| paymentId: string, | |
| newStatus: PaymentStatus, | |
| additionalFields?: Partial<Payment>, | |
| ): Promise<void> { | |
| const payment = await this.paymentRepository.findOne({ where: { id: paymentId } }); | |
| if (!payment) return; | |
| this.validateStateTransition(payment.status, newStatus); | |
| const updateData: Partial<Payment> = { | |
| status: newStatus, | |
| updatedAt: new Date(), | |
| ...additionalFields, | |
| }; | |
| await this.paymentRepository.update(paymentId, updateData); | |
| this.eventEmitter.emit('payment.status.changed', { paymentId, oldStatus: payment.status, newStatus }); | |
| } | |
| private generatePaymentId(): string { | |
| return `pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; | |
| } | |
| private mapToResponse(payment: Payment): PaymentResponseDto { | |
| console.log("payloa----", payment) | |
| return { | |
| id: payment.id, | |
| merchantId: payment.merchantId, | |
| amount: payment.amount, | |
| currency: payment.currency, | |
| status: payment.status, | |
| // reference: payment.referenceId | |
| feesAmount: payment.feesAmount, | |
| netAmount: payment.netAmount, | |
| createdAt: payment.createdAt, | |
| updatedAt: payment.updatedAt, | |
| metadata: payment.metadata, | |
| }; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment