mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
320 lines
12 KiB
TypeScript
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 } });
|
|
}
|
|
}
|