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