import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { TicketsService } from '../tickets/tickets.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client'; import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto'; import { ClientAction, PaymentProvider, ProviderStatus } from './payments.types'; import { TelebirrProvider } from './providers/telebirr.provider'; import { CbeBirrProvider } from './providers/cbe-birr.provider'; import { EBirrProvider } from './providers/ebirr.provider'; import { CardProvider } from './providers/card.provider'; import { WaafiProvider } from './providers/waafi.provider'; import { createMerchantOrderId } from './providers/telebirr.crypto'; const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ PaymentIntentStatus.REQUIRES_ACTION, PaymentIntentStatus.PROCESSING, PaymentIntentStatus.SUCCEEDED, ]; @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); private readonly providers: Map; constructor( private prisma: PrismaService, private seatsService: SeatsService, private ticketsService: TicketsService, private eventEmitter: EventEmitter2, private telebirrProvider: TelebirrProvider, private cbeBirrProvider: CbeBirrProvider, private eBirrProvider: EBirrProvider, private cardProvider: CardProvider, private waafiProvider: WaafiProvider, ) { this.providers = new Map([ [PaymentMethodType.TELEBIRR, this.telebirrProvider], [PaymentMethodType.CBE_BIRR, this.cbeBirrProvider], [PaymentMethodType.EBIRR, this.eBirrProvider], [PaymentMethodType.CARD, this.cardProvider], [PaymentMethodType.WAAFI, this.waafiProvider], ]); } async initiatePayment(dto: InitiatePaymentDto): Promise { const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true }, }); if (!booking) throw new NotFoundException('Booking not found'); if (booking.status !== 'PENDING_PAYMENT') { throw new BadRequestException('Booking not payable'); } const existing = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId }, }); if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { return this.formatIntentResponse(existing); } const method = dto.method as PaymentMethodType; if (method === PaymentMethodType.WALLET) { return this.initiateWalletPayment(booking); } const provider = this.providers.get(method); if (provider) { return this.initiateProviderPayment(booking, provider, dto.platform); } throw new BadRequestException(`Unsupported payment method: ${method}`); } private async initiateWalletPayment( booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, ): Promise { const debitResult = await this.prisma.$transaction(async (tx) => { const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId }, }); if (!wallet || wallet.balanceMinor < booking.totalMinor) { return { success: false }; } const newBalance = wallet.balanceMinor - booking.totalMinor; await tx.walletAccount.update({ where: { passengerId: booking.passengerId }, data: { balanceMinor: newBalance }, }); await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'DEBIT', amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, relatedBookingId: booking.id, }, }); return { success: true }; }); if (!debitResult.success) { const failed = await this.prisma.paymentIntent.upsert({ where: { bookingId: booking.id }, update: { status: PaymentIntentStatus.FAILED, failureCode: 'INSUFFICIENT_BALANCE', }, create: { bookingId: booking.id, amountMinor: booking.totalMinor, method: PaymentMethodType.WALLET, status: PaymentIntentStatus.FAILED, failureCode: 'INSUFFICIENT_BALANCE', }, }); return this.formatIntentResponse(failed); } const intent = await this.prisma.paymentIntent.upsert({ where: { bookingId: booking.id }, update: { status: PaymentIntentStatus.PROCESSING }, create: { bookingId: booking.id, amountMinor: booking.totalMinor, method: PaymentMethodType.WALLET, status: PaymentIntentStatus.PROCESSING, providerRef: `WALLET-${Date.now()}`, }, }); await this.finalizePaymentSuccess({ intentId: intent.id }); const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id }, }); return this.formatIntentResponse(refreshed); } private async initiateProviderPayment( booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, provider: PaymentProvider, platform: 'web' | 'mobile' | undefined, ): Promise { const merchantOrderId = createMerchantOrderId(); const result = await provider.initiate({ merchantOrderId, bookingRef: booking.bookingRef, amountMinor: booking.totalMinor, currency: booking.currency, platform, }); const intent = await this.prisma.paymentIntent.upsert({ where: { bookingId: booking.id }, update: { status: PaymentIntentStatus.REQUIRES_ACTION, method: provider.method, merchantOrderId, providerOrderId: result.providerOrderId, clientAction: result.clientAction as unknown as Prisma.InputJsonValue, rawInitiation: result.rawInitiation as Prisma.InputJsonValue, expiresAt: result.expiresAt, failureCode: null, failureMessage: null, }, create: { bookingId: booking.id, amountMinor: booking.totalMinor, currency: booking.currency, method: provider.method, status: PaymentIntentStatus.REQUIRES_ACTION, merchantOrderId, providerOrderId: result.providerOrderId, clientAction: result.clientAction as unknown as Prisma.InputJsonValue, rawInitiation: result.rawInitiation as Prisma.InputJsonValue, expiresAt: result.expiresAt, }, }); return this.formatIntentResponse(intent); } private formatIntentResponse( intent: Prisma.PaymentIntentGetPayload>, ): InitiateResponseDto { const clientAction = intent.clientAction && typeof intent.clientAction === 'object' ? (intent.clientAction as unknown as ClientAction) : undefined; return { intentId: intent.id, status: intent.status, clientAction, merchantOrderId: intent.merchantOrderId ?? undefined, }; } async getIntentByBookingId(bookingId: string): Promise { const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId }, }); if (!intent) throw new NotFoundException('PaymentIntent not found'); const refreshable = intent.status === PaymentIntentStatus.REQUIRES_ACTION || intent.status === PaymentIntentStatus.PROCESSING; const stale = intent.updatedAt.getTime() < Date.now() - 5_000; const provider = this.providers.get(intent.method); if (refreshable && stale && intent.merchantOrderId && provider) { try { const status = await provider.queryStatus(intent.merchantOrderId); await this.applyProviderStatus(intent.id, status); const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({ where: { id: intent.id }, }); return this.formatIntentStatus(refreshed); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.warn( `queryStatus failed for intent ${intent.id}: ${message}; returning cached`, ); } } return this.formatIntentStatus(intent); } private async applyProviderStatus( intentId: string, status: ProviderStatus, ): Promise { if (status.status === PaymentIntentStatus.SUCCEEDED) { await this.finalizePaymentSuccess({ intentId, providerTxnId: status.providerTxnId, }); return; } if (status.status === PaymentIntentStatus.FAILED) { await this.markPaymentFailed({ intentId, failureCode: status.failureCode, failureMessage: status.failureMessage, }); return; } await this.prisma.paymentIntent.update({ where: { id: intentId }, data: { status: status.status, providerTxnId: status.providerTxnId ?? undefined, }, }); } private formatIntentStatus( intent: Prisma.PaymentIntentGetPayload>, ): IntentStatusDto { const base = this.formatIntentResponse(intent); return { ...base, paidAt: intent.paidAt?.toISOString(), failureCode: intent.failureCode ?? undefined, failureMessage: intent.failureMessage ?? undefined, }; } async refund(dto: RefundDto) { const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } }); if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund'); await this.prisma.paymentIntent.update({ where: { bookingId: dto.bookingId }, data: { status: 'CANCELLED' } }); const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } }); if (booking) { await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CANCELLED' } }); } return { refunded: true, bookingRef: booking?.bookingRef }; } addPaymentMethod(dto: AddPaymentMethodDto) { const data = { type: dto.type as unknown as PaymentMethodType, displayName: dto.displayName, region: dto.region as unknown as PaymentRegion, currency: dto.currency ?? 'ETB', providerId: dto.providerId, enabled: dto.enabled ?? true, sortOrder: dto.sortOrder ?? 0, }; return this.prisma.paymentMethod.upsert({ where: { type: data.type }, update: data, create: data, }); } getSupportedPaymentMethods(region?: PaymentRegionEnum) { return this.prisma.paymentMethod.findMany({ where: { enabled: true, ...(region ? { region: { in: [region, PaymentRegionEnum.GLOBAL] as unknown as PaymentRegion[] } } : {}), }, orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }], }); } async finalizePaymentSuccess(input: { intentId: string; providerTxnId?: string; paidAt?: Date; }): Promise<{ alreadyFinalized: boolean }> { const intent = await this.prisma.paymentIntent.findUnique({ where: { id: input.intentId }, }); if (!intent) throw new NotFoundException('PaymentIntent not found'); if (intent.status === PaymentIntentStatus.SUCCEEDED) { return { alreadyFinalized: true }; } if (intent.status === PaymentIntentStatus.CANCELLED) { throw new BadRequestException('PaymentIntent is cancelled; cannot finalize'); } const booking = await this.prisma.booking.findUnique({ where: { id: intent.bookingId }, include: { seats: true }, }); if (!booking) throw new NotFoundException('Booking not found'); const paidAt = input.paidAt ?? new Date(); await this.prisma.$transaction(async (tx) => { await tx.paymentIntent.update({ where: { id: intent.id }, data: { status: PaymentIntentStatus.SUCCEEDED, providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined, paidAt, }, }); await tx.booking.update({ where: { id: booking.id }, data: { status: 'CONFIRMED' }, }); }); try { await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); } catch (err) { this.logger.error(`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`); } try { await this.createJourneySegments(booking); } catch (err) { this.logger.error(`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`); } try { await this.ticketsService.generate(booking.id); } catch (err) { this.logger.error(`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`); throw err; } try { await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id); } catch (err) { this.logger.warn(`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`); } this.eventEmitter.emit('payment.succeeded', { booking }); return { alreadyFinalized: false }; } async markPaymentFailed(input: { intentId: string; failureCode?: string; failureMessage?: string; }): Promise { const intent = await this.prisma.paymentIntent.findUnique({ where: { id: input.intentId }, }); if (!intent) throw new NotFoundException('PaymentIntent not found'); if ( intent.status === PaymentIntentStatus.SUCCEEDED || intent.status === PaymentIntentStatus.CANCELLED ) { return; } await this.prisma.paymentIntent.update({ where: { id: intent.id }, data: { status: PaymentIntentStatus.FAILED, failureCode: input.failureCode, failureMessage: input.failureMessage, }, }); } private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) { const points = Math.floor(amountMinor / 100); const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }); if (!account) return; const newBalance = account.pointsBalance + points; const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE'; await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } }); await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } }); } private async createJourneySegments(booking: Prisma.BookingGetPayload<{ include: { seats: true } }>) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: booking.scheduleId }, include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, }); if (!schedule) return; const stopTimes = schedule.stopTimes; if (stopTimes.length < 2) return; const originSequence = stopTimes.findIndex(st => st.stationId === schedule.originStationId); const destSequence = stopTimes.findIndex(st => st.stationId === schedule.destinationStationId); if (originSequence < 0 || destSequence < 0 || originSequence >= destSequence) return; const journey = await this.prisma.journey.create({ data: { passengerId: booking.passengerId, status: 'CONFIRMED', totalMinor: booking.totalMinor, currency: booking.currency, }, }); const journeySegments = []; for (const bookingSeat of booking.seats) { for (let i = originSequence; i < destSequence; i++) { journeySegments.push({ journeyId: journey.id, scheduleId: booking.scheduleId, segmentOrder: i, seatId: bookingSeat.seatId, departureStationId: stopTimes[i].stationId, arrivalStationId: stopTimes[i + 1].stationId, }); } } if (journeySegments.length > 0) { await this.prisma.journeySegment.createMany({ data: journeySegments }); } } }