Skip to content

Instantly share code, notes, and snippets.

@emmsdan
Created July 23, 2026 02:46
Show Gist options
  • Select an option

  • Save emmsdan/16b15e5146e4e3ade818740f1a3b22fc to your computer and use it in GitHub Desktop.

Select an option

Save emmsdan/16b15e5146e4e3ade818740f1a3b22fc to your computer and use it in GitHub Desktop.
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