Files
edr-platform/apps/edr-passenger-api/src/modules/payments/payments.service.ts

364 lines
13 KiB
TypeScript

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 } from '@prisma/client';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto } from './payments.dto';
import { 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 { 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<PaymentMethodType, PaymentProvider>;
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,
) {
this.providers = new Map<PaymentMethodType, PaymentProvider>([
[PaymentMethodType.TELEBIRR, this.telebirrProvider],
[PaymentMethodType.CBE_BIRR, this.cbeBirrProvider],
[PaymentMethodType.EBIRR, this.eBirrProvider],
[PaymentMethodType.CARD, this.cardProvider],
]);
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
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);
}
throw new BadRequestException(`Unsupported payment method: ${method}`);
}
private async initiateWalletPayment(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
): Promise<InitiateResponseDto> {
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,
): Promise<InitiateResponseDto> {
const merchantOrderId = createMerchantOrderId();
const result = await provider.initiate({
merchantOrderId,
bookingRef: booking.bookingRef,
amountMinor: booking.totalMinor,
currency: booking.currency,
});
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<Record<string, never>>,
): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === 'object'
? (intent.clientAction as unknown as { type: 'REDIRECT'; url: string })
: undefined;
return {
intentId: intent.id,
status: intent.status,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
};
}
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
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<void> {
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<Record<string, never>>,
): 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) { return this.prisma.paymentMethod.create({ data: dto }); }
getPaymentMethods(userId: string) { return this.prisma.paymentMethod.findMany({ where: { userId }, orderBy: { isDefault: 'desc' } }); }
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' },
});
});
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
await this.ticketsService.generate(booking.id);
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
this.eventEmitter.emit('payment.succeeded', { booking });
return { alreadyFinalized: false };
}
async markPaymentFailed(input: {
intentId: string;
failureCode?: string;
failureMessage?: string;
}): Promise<void> {
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 } });
}
}