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

320 lines
12 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 } from './payments.dto';
import { cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
import { PaymentProvider } from './payments.types';
import { TelebirrProvider } from './providers/telebirr.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,
) {
this.providers = new Map<PaymentMethodType, PaymentProvider>([
[PaymentMethodType.TELEBIRR, this.telebirrProvider],
]);
}
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);
}
// TODO: convert CBE_BIRR, EBIRR, CARD into PaymentProvider implementations.
return this.initiateStubPayment(booking, 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 async initiateStubPayment(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
method: PaymentMethodType,
): Promise<InitiateResponseDto> {
const adapters = {
[PaymentMethodType.CBE_BIRR]: cbeBirrAdapter,
[PaymentMethodType.EBIRR]: eBirrAdapter,
[PaymentMethodType.CARD]: cardAdapter,
} as Partial<Record<PaymentMethodType, (a: number, ref: string) => Promise<{ success: boolean; providerRef: string }>>>;
const adapter = adapters[method];
if (!adapter) {
throw new BadRequestException(`Unsupported payment method: ${method}`);
}
const result = await adapter(booking.totalMinor, booking.bookingRef);
const status = result.success ? PaymentIntentStatus.PROCESSING : PaymentIntentStatus.FAILED;
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: booking.id },
update: { status, providerRef: result.providerRef },
create: {
bookingId: booking.id,
amountMinor: booking.totalMinor,
method,
status,
providerRef: result.providerRef,
},
});
if (result.success) {
await this.finalizePaymentSuccess({ intentId: intent.id });
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
return this.formatIntentResponse(refreshed);
}
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 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 } });
}
}