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

1289 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
ConflictException,
} 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,
ForceConfirmDto,
} from "./payments.dto";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import {
PaymentClientService,
PaymentDiagnostic,
} from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
import { AuditService } from "../../common/audit.service";
import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util";
import {
PaymentService as PaymentServiceEnum,
PaymentReferenceType,
PaymentIntentSnapshot,
ProviderMethod,
ClientAction,
ProviderPaymentStatus,
} from "@edr/types";
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
PaymentIntentStatus.REQUIRES_ACTION,
PaymentIntentStatus.PROCESSING,
PaymentIntentStatus.SUCCEEDED,
];
// Methods whose return/failure URLs are browser-facing pages on the passenger
// portal, so they should follow whichever domain the user came in on. DMONEY is
// deliberately excluded — its return URL is a server-to-server webhook host, not
// a page the browser lands on.
const DOMAIN_AWARE_METHODS = new Set<PaymentMethodType>([
PaymentMethodType.TELEBIRR,
PaymentMethodType.WAAFI,
]);
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
private readonly waafiDemoTrustReturn = true;
constructor(
private prisma: PrismaService,
private seatsService: SeatsService,
private ticketsService: TicketsService,
private eventEmitter: EventEmitter2,
private paymentClient: PaymentClientService,
private currencyService: CurrencyService,
private auditService: AuditService,
) {}
async deletePayment(id: string) {
const intent = await this.prisma.paymentIntent.findUnique({ where: { id } });
if (!intent) throw new NotFoundException('Payment intent not found');
await this.prisma.paymentIntent.delete({ where: { id } });
return { deleted: true, id };
}
async getAll(filters: {
search?: string;
status?: string;
method?: string;
page?: number;
pageSize?: number;
}) {
const { search, status, method, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (search) {
where.OR = [
{ id: { contains: search, mode: "insensitive" } },
{ booking: { bookingRef: { contains: search, mode: "insensitive" } } },
];
}
if (status) {
where.status = status;
}
if (method) {
where.method = method;
}
const [items, total] = await Promise.all([
this.prisma.paymentIntent.findMany({
where,
include: {
booking: {
select: {
bookingRef: true,
bookingType: true,
packageId: true,
priceTierId: true,
adultCount: true,
childCount: true,
totalMinor: true,
currency: true,
priceTier: { select: { priceMinor: true } },
},
},
},
skip,
take: pageSize,
orderBy: { createdAt: "desc" },
}),
this.prisma.paymentIntent.count({ where }),
]);
return {
items: items.map((item) => {
const b = item.booking as any;
// For package round-trip bookings the stored amountMinor may be the single-leg
// amount. Recompute from the tier price when applicable.
let amountMinor = item.amountMinor;
if (b?.packageId && b?.bookingType === 'ROUND_TRIP' && b?.priceTier?.priceMinor) {
const adultFare = b.priceTier.priceMinor * 2;
const childFare = Math.round(adultFare * 0.1);
const correctMinor = (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare;
// Convert to the charge currency ratio: stored amountMinor is in charge currency
// (may be DJF/USD), but correctMinor is in ETB minor. Only override when the
// currency is ETB (most common case); for foreign currencies keep stored value.
if (item.currency === 'ETB') amountMinor = correctMinor;
}
return {
id: item.id,
reference: item.id.substring(0, 8),
bookingId: item.bookingId,
booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency },
amountMinor,
currency: item.currency,
method: item.method,
status: item.status,
createdAt: item.createdAt,
paidAt: item.paidAt,
};
}),
total,
page,
pageSize,
};
}
/**
* Returns the correct totalMinor (in ETB) for a booking, accounting for package round-trip
* bookings where totalMinor may have been stored as a single-leg amount before the server fix.
*/
private async resolveBookingTotal(booking: {
id: string;
totalMinor: number;
bookingType: string;
packageId?: string | null;
priceTierId?: string | null;
displayTotalMinor?: number | null;
}): Promise<number> {
if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') {
return booking.totalMinor;
}
// New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their
// totalMinor was already computed in ETB at creation time — no recomputation needed.
if (booking.displayTotalMinor != null && booking.displayTotalMinor > 0) {
return booking.totalMinor;
}
// Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier.
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } });
if (!tier) return booking.totalMinor;
const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } });
const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1;
const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length;
// tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is
// always in the same units as totalMinor (which is always the ETB canonical).
const rawFare = tier.priceMinor * 2;
const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB'
? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any)
: rawFare;
const childFareMinor = Math.round(adultFareMinor * 0.1);
return adultCount * adultFareMinor + childCount * childFareMinor;
}
async initiatePayment(
dto: InitiatePaymentDto,
requestOrigin?: string | null,
): 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 method = dto.method as PaymentMethodType;
// CAC Bank is an OTP debit — the bank SMSes the OTP to this number, so it's required.
if (method === PaymentMethodType.CAC_BANK && !dto.payerAccount?.trim()) {
throw new BadRequestException(
"payerAccount (mobile number) is required for CAC Bank",
);
}
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
// Patch the DB if the stored total is wrong (single-leg for a round-trip package booking)
if (correctTotalMinor !== booking.totalMinor) {
await this.prisma.booking.update({
where: { id: booking.id },
data: { totalMinor: correctTotalMinor },
});
(booking as any).totalMinor = correctTotalMinor;
}
// WALLET is an internal balance debit — it never leaves this app.
if (method === PaymentMethodType.WALLET) {
const existing = await this.prisma.paymentIntent.findUnique({
where: { bookingId: dto.bookingId },
});
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
return this.formatIntentResponse(existing);
}
return this.initiateWalletPayment(booking);
}
const { returnUrl, failureUrl } = this.resolveReturnUrls(
method,
requestOrigin,
);
// The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY
// settle in DJF, CARD in USD, Ethiopian wallets in ETB). When the booking's displayCurrency
// already matches the charge currency, use displayTotalMinor directly — the rate is already
// baked in at booking creation time. Only fall back to ETB→target conversion when they differ.
const paymentMethod = await this.prisma.paymentMethod.findUnique({
where: { type: method },
});
const chargeCurrency = (
paymentMethod?.currency ?? booking.currency
).toUpperCase();
const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
let chargeAmount: number;
if (
chargeCurrency === bookingDisplayCurrency &&
chargeCurrency !== 'ETB' &&
bookingDisplayTotalMinor != null
) {
// Display currency matches charge currency — use the pre-converted amount directly.
chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency);
} else if (chargeCurrency === 'ETB') {
chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB');
} else {
// Booking is in ETB — convert to the provider's settlement currency.
chargeAmount = await this.currencyService.convertMinorToChargeMajor(
booking.totalMinor,
booking.currency,
chargeCurrency,
);
}
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.BOOKING,
referenceId: booking.id,
orderRef: booking.bookingRef,
amountMinor: chargeAmount,
currency: chargeCurrency,
provider: method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl,
failureUrl,
});
let intent = await this.syncIntentProjection(booking.id, snapshot);
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
// Already-paid order re-initiated: converge the booking now (idempotent).
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
}
return this.formatIntentResponse(intent);
}
/**
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by bookingId: the active
* remote intent is looked up by reference, the OTP is forwarded to the payment service,
* and the projection is refreshed. On success the booking is converged immediately
* (idempotent — the outbox → mark-paid path also converges it). A wrong/expired OTP
* bubbles up as a 400 so the payer can retry; the intent stays REQUIRES_ACTION.
*/
async confirmOtpPayment(
bookingId: string,
otp: string,
): Promise<IntentStatusDto> {
const snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
bookingId,
);
if (!snapshot) {
throw new NotFoundException("No active payment to confirm for this booking");
}
const confirmed = await this.paymentClient.confirmOtp(snapshot.intentId, otp);
let intent = await this.syncIntentProjection(bookingId, confirmed);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: confirmed.providerTxnId,
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
});
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
}
return this.formatIntentStatus(intent);
}
private resolveReturnUrls(
method: PaymentMethodType,
requestOrigin?: string | null,
): {
returnUrl?: string;
failureUrl?: string;
} {
const perMethod: Partial<
Record<PaymentMethodType, { returnUrl?: string; failureUrl?: string }>
> = {
[PaymentMethodType.TELEBIRR]: {
returnUrl: process.env.TELEBIRR_RETURN_URL,
},
[PaymentMethodType.WAAFI]: {
returnUrl: process.env.WAAFI_SUCCESS_REDIRECT,
failureUrl: process.env.WAAFI_FAIL_REDIRECT,
},
[PaymentMethodType.DMONEY]: {
returnUrl: process.env.DMONEY_RETURN_URL,
},
[PaymentMethodType.CBE_BIRR]: {
returnUrl: process.env.CBE_RETURN_URL,
},
[PaymentMethodType.EBIRR]: {
returnUrl: process.env.EBIRR_RETURN_URL,
},
[PaymentMethodType.CARD]: {
returnUrl: process.env.CARD_RETURN_URL,
},
};
const m = perMethod[method] ?? {};
let returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined;
let failureUrl =
m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl;
// For browser-facing methods, swap the configured URL's host for whichever
// allowlisted domain the user is currently on (bookingedr.et vs
// passenger.edrsc.com). `requestOrigin` is already validated against the
// allowlist by the controller; when it's null the configured URL is kept.
if (DOMAIN_AWARE_METHODS.has(method) && requestOrigin) {
returnUrl = rebaseUrlOrigin(returnUrl, requestOrigin);
failureUrl = rebaseUrlOrigin(failureUrl, requestOrigin);
}
return { returnUrl, failureUrl };
}
async confirmWaafiReturnDemo(params: {
referenceId?: string;
state?: string;
transactionId?: string;
}): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> {
if (!this.waafiDemoTrustReturn) {
return { confirmed: false, reason: "demo-disabled" };
}
if ((params.state ?? "").toUpperCase() !== "APPROVED") {
return { confirmed: false, reason: `not-approved (${params.state})` };
}
if (!params.referenceId) {
return { confirmed: false, reason: "missing-referenceId" };
}
const intent = await this.prisma.paymentIntent.findFirst({
where: { merchantOrderId: params.referenceId },
});
if (!intent) {
this.logger.warn(
`waafi demo return: no local intent for referenceId ${params.referenceId}`,
);
return { confirmed: false, reason: "intent-not-found" };
}
this.logger.warn(
`WAAFI_DEMO_TRUST_RETURN enabled — confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`,
);
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: params.transactionId,
});
return { confirmed: true, bookingId: intent.bookingId };
}
private async syncIntentProjection(
bookingId: string,
snapshot: PaymentIntentSnapshot,
) {
const status =
snapshot.status === ProviderPaymentStatus.SUCCEEDED
? PaymentIntentStatus.PROCESSING
: (snapshot.status as unknown as PaymentIntentStatus);
const data = {
status,
method: snapshot.provider as unknown as PaymentMethodType,
merchantOrderId: snapshot.merchantOrderId,
clientAction: snapshot.clientAction
? (snapshot.clientAction as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
providerTxnId: snapshot.providerTxnId ?? null,
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
failureCode: snapshot.failureCode ?? null,
failureMessage: snapshot.failureMessage ?? null,
rawInitiation: (snapshot as any).providerResponse
? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
};
return this.prisma.paymentIntent.upsert({
where: { bookingId },
// amountMinor/currency are refreshed on update too: a cross-currency method switch
// (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection
// must reflect the currency the new provider actually charges — not the first one's.
update: {
...data,
amountMinor: snapshot.amountMinor,
currency: snapshot.currency,
},
create: {
bookingId,
amountMinor: snapshot.amountMinor,
currency: snapshot.currency,
...data,
},
});
}
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 formatIntentResponse(
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
): 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,
};
}
/**
* Payment status by booking id (UUID) OR booking reference / PNR (e.g. EDR-20240001).
* Resolves the PNR to its booking id, then pulls the authoritative status from the payment
* microservice (via {@link getIntentByBookingId}).
*/
async getIntentByBookingRefOrId(
bookingRefOrId: string,
): Promise<IntentStatusDto> {
const bookingId = await this.resolveBookingId(bookingRefOrId);
return this.getIntentByBookingId(bookingId);
}
/**
* Diagnostic view by booking id (UUID) OR booking reference / PNR: the payment service's
* stored intent row and a live provider status query, side by side ({ db, provider }).
* Pure read — does not reconcile or confirm the booking.
*/
async getPaymentDiagnosticByBookingRefOrId(
bookingRefOrId: string,
): Promise<PaymentDiagnostic> {
const bookingId = await this.resolveBookingId(bookingRefOrId);
return this.paymentClient.getDiagnosticByReference(
PaymentReferenceType.BOOKING,
bookingId,
);
}
/** Accept a booking UUID as-is; otherwise look the id up from its bookingRef/PNR. */
private async resolveBookingId(bookingRefOrId: string): Promise<string> {
const isUuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
bookingRefOrId,
);
if (isUuid) return bookingRefOrId;
const booking = await this.prisma.booking.findUnique({
where: { bookingRef: bookingRefOrId },
select: { id: true },
});
if (!booking) {
throw new NotFoundException(`Booking not found: ${bookingRefOrId}`);
}
return booking.id;
}
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
const local = await this.prisma.paymentIntent.findUnique({
where: { bookingId },
});
if (local?.status === PaymentIntentStatus.SUCCEEDED) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
select: { status: true },
});
if (booking?.status === "CONFIRMED") {
return this.formatIntentStatus(local);
}
}
// WALLET payments never leave this app — no remote intent exists for them.
if (local?.method === PaymentMethodType.WALLET) {
return this.formatIntentStatus(local);
}
// Pull/reconcile through the payment microservice (it refreshes stale intents from the
// provider itself). Falls back to the legacy local path when the service is unreachable
// or only a pre-cutover local intent exists.
let snapshot: PaymentIntentSnapshot | null = null;
try {
snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
bookingId,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
);
}
if (!snapshot) {
// Pre-cutover/local-only intent (or service briefly unreachable): serve the cached
// status. The payment service owns provider refresh for everything initiated after
// the cutover; webhooks/mark-paid converge the rest.
if (!local) throw new NotFoundException("PaymentIntent not found");
return this.formatIntentStatus(local);
}
let intent = await this.syncIntentProjection(bookingId, snapshot);
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
// Poll observed success before (or instead of) the mark-paid event — converge now.
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
}
return this.formatIntentStatus(intent);
}
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,
providerResponse:
intent.rawInitiation && typeof intent.rawInitiation === "object"
? (intent.rawInitiation as Record<string, unknown>)
: 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.id);
await this.prisma.booking.update({
where: { id: dto.bookingId },
data: { status: "CANCELLED" },
});
}
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'REFUNDED', bookingId: dto.bookingId } });
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,
});
}
async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
const existing = await this.prisma.paymentMethod.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Payment method not found');
const updateData: any = {};
if (dto.displayName !== undefined) updateData.displayName = dto.displayName;
if (dto.region !== undefined) updateData.region = dto.region as unknown as PaymentRegion;
if (dto.currency !== undefined) updateData.currency = dto.currency;
if (dto.providerId !== undefined) updateData.providerId = dto.providerId;
if (dto.enabled !== undefined) updateData.enabled = dto.enabled;
if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder;
return this.prisma.paymentMethod.update({
where: { id },
data: updateData,
});
}
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
return this.prisma.paymentMethod.findMany({
where: {
...(region
? {
region: {
in: [
region,
PaymentRegionEnum.GLOBAL,
] as unknown as PaymentRegion[],
},
}
: {}),
},
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
});
}
async getBookingAmountByCurrency(
bookingId: string,
currency: string,
): Promise<{ booking_id: string; currency: string; amount: number }> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
select: {
id: true,
totalMinor: true,
bookingType: true,
packageId: true,
priceTierId: true,
currency: true,
displayCurrency: true,
displayTotalMinor: true,
},
});
if (!booking) throw new NotFoundException('Booking not found');
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
const requestedCurrency = currency.toUpperCase();
// Source of truth: displayTotalMinor in displayCurrency when available,
// otherwise totalMinor in ETB (bookings with no display currency override).
const sourceCurrency = (booking.displayCurrency ?? 'ETB').toUpperCase();
const sourceMinor = booking.displayTotalMinor ?? correctTotalMinor;
// Same currency — return directly, no conversion needed.
if (requestedCurrency === sourceCurrency) {
return { booking_id: bookingId, currency: requestedCurrency, amount: sourceMinor / 100 };
}
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency: sourceCurrency as any, toCurrency: requestedCurrency as any },
orderBy: { effectiveDate: 'desc' },
});
let rate: number;
if (exchangeRate) {
rate = Number(exchangeRate.rate);
} else {
// Try inverse rate
const inverseRate = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency: requestedCurrency as any, toCurrency: sourceCurrency as any },
orderBy: { effectiveDate: 'desc' },
});
if (inverseRate) {
rate = 1 / Number(inverseRate.rate);
} else {
// Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
rate = await this.currencyService.getRateOrThrow(sourceCurrency as any, requestedCurrency as any);
}
}
const converted = (sourceMinor / 100) * rate;
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
}
/**
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the
* whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the
* booking still confirms.
*/
private sanitizePaidAt(value?: Date): Date {
const now = new Date();
if (!value) return now;
const t = value.getTime();
const oneDayMs = 86_400_000;
if (
Number.isNaN(t) ||
t > now.getTime() + oneDayMs ||
t < Date.UTC(2000, 0, 1)
) {
this.logger.warn(
`finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`,
);
return now;
}
return value;
}
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) {
// Idempotency guard — but still repair missing tickets. They can be absent
// when the first finalization threw from generate() after the transaction
// committed: the caller got a 500, retried, and now hits this early-return.
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
if (ticketCount === 0) {
try {
await this.ticketsService.generate(intent.bookingId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
);
try {
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
} catch (retryErr) {
this.logger.error(
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
);
}
}
}
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 = this.sanitizePaidAt(input.paidAt);
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) {
const msg = err instanceof Error ? err.message : String(err);
// Only reassign seats when a *different* booking genuinely holds the seat
// (ConflictException). Any other error (transient DB issue, etc.) is logged
// and swallowed — the passenger keeps their original seat and the ticket can
// be retried via "Generate Missing" in the backoffice.
if (err instanceof ConflictException) {
this.logger.warn(
`Seat conflict for booking ${booking.id}: ${msg}. Attempting smart seat reassignment.`,
);
try {
await this.ticketsService.smartAssignAndGenerate(booking.id);
} catch (retryErr) {
this.logger.error(
`Smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
);
}
} else {
this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`);
}
}
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 };
}
private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise<MarkPaidResponseDto> {
if (event.eventType === 'payment.failed') {
this.logger.warn(`supplementary charge ${event.referenceId} payment failed`);
return { processed: true };
}
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } });
if (!charge) {
this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`);
return { processed: false, reason: 'charge-not-found' };
}
if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true };
await this.prisma.supplementaryCharge.update({
where: { id: charge.id },
data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } });
return { processed: true };
}
async handlePaymentEvent(
event: PaymentEventDto,
): Promise<MarkPaidResponseDto> {
if (event.service !== PaymentServiceEnum.PASSENGER) {
this.logger.warn(
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
);
return { processed: false, reason: "foreign-reference" };
}
if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) {
return this.handleSupplementaryChargeEvent(event);
}
if (event.referenceType !== PaymentReferenceType.BOOKING) {
this.logger.warn(
`mark-paid: ignoring unknown referenceType ${event.referenceType}`,
);
return { processed: false, reason: "foreign-reference" };
}
if (event.eventType === "payment.failed") {
const intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: event.referenceId },
});
if (intent) {
await this.markPaymentFailed({
intentId: intent.id,
failureCode: event.failureCode,
failureMessage: event.failureMessage,
});
}
const failedBooking = await this.prisma.booking.findUnique({
where: { id: event.referenceId },
});
if (failedBooking) {
this.eventEmitter.emit("payment.failed", { booking: failedBooking });
}
return { processed: true };
}
const booking = await this.prisma.booking.findUnique({
where: { id: event.referenceId },
});
if (!booking) {
// Ack (200) — a missing booking will not appear on redelivery; needs investigation.
this.logger.error(
`mark-paid: no booking for reference ${event.referenceId}`,
);
return { processed: false, reason: "booking-not-found" };
}
// Local intent row is a projection during the strangler migration: reuse it when the
// legacy initiate path created one, otherwise materialize it from the event.
let intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: event.referenceId },
});
if (!intent) {
intent = await this.prisma.paymentIntent.create({
data: {
bookingId: event.referenceId,
amountMinor: event.amountMinor,
currency: event.currency,
method: event.provider as unknown as PaymentMethodType,
status: PaymentIntentStatus.PROCESSING,
merchantOrderId: event.merchantOrderId,
providerTxnId: event.providerTxnId,
},
});
}
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
}).catch((err) => {
this.logger.error(
`finalizePaymentSuccess failed for booking ${event.referenceId}: ${err instanceof Error ? err.message : String(err)}`,
);
return { alreadyFinalized: false };
});
return { processed: true, alreadyFinalized };
}
async forceConfirmPayment(bookingId: string, dto: ForceConfirmDto = {}): Promise<{ alreadyFinalized: boolean }> {
const booking = await this.prisma.booking.findUnique({ where: { id: bookingId } });
if (!booking) throw new NotFoundException('Booking not found');
const resolvedMethod = dto.paymentMethod
? (dto.paymentMethod as unknown as PaymentMethodType)
: PaymentMethodType.TELEBIRR;
let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId } });
if (!intent) {
intent = await this.prisma.paymentIntent.create({
data: {
bookingId,
amountMinor: booking.totalMinor,
currency: booking.currency,
method: resolvedMethod,
status: PaymentIntentStatus.PROCESSING,
providerRef: `FORCE-${Date.now()}`,
providerTxnId: dto.paymentReference ?? null,
failureMessage: dto.notes ?? null,
},
});
} else {
// Update method/reference/notes regardless of current status
const updateData: any = {};
if (dto.paymentReference) updateData.providerTxnId = dto.paymentReference;
if (dto.paymentMethod) updateData.method = resolvedMethod;
if (dto.notes) updateData.failureMessage = dto.notes;
if (intent.status === PaymentIntentStatus.CANCELLED || intent.status === PaymentIntentStatus.FAILED) {
updateData.status = PaymentIntentStatus.PROCESSING;
}
if (Object.keys(updateData).length) {
intent = await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: updateData,
});
}
}
return this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
}).then(async (result) => {
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } });
return result;
});
}
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,
},
});
}
private async createJourneySegments(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
) {
const b = booking as any;
// Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] }
// BookingSeat.leg: 1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2
type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] };
const legDefs: LegDef[] = [];
const seatsForLeg = (legNum: number) =>
booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId);
if (booking.bookingType === 'ONE_WAY') {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.destinationStationId,
seatIds: booking.seats.map((s: any) => s.seatId),
});
} else if (booking.bookingType === 'ROUND_TRIP') {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.destinationStationId,
seatIds: seatsForLeg(1),
});
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
legDefs.push({
scheduleId: b.returnScheduleId,
originStationId: b.returnOriginStationId,
destinationStationId: b.returnDestinationStationId,
seatIds: seatsForLeg(2),
});
}
} else if (booking.bookingType === 'TRANSIT') {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.leg2OriginStationId, // transit station
seatIds: seatsForLeg(1),
});
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
legDefs.push({
scheduleId: b.leg2ScheduleId,
originStationId: b.leg2OriginStationId,
destinationStationId: b.leg2DestinationStationId,
seatIds: seatsForLeg(2),
});
}
} else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.leg2OriginStationId,
seatIds: seatsForLeg(1),
});
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
legDefs.push({
scheduleId: b.leg2ScheduleId,
originStationId: b.leg2OriginStationId,
destinationStationId: b.leg2DestinationStationId,
seatIds: seatsForLeg(2),
});
}
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
legDefs.push({
scheduleId: b.returnScheduleId,
originStationId: b.returnOriginStationId,
destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId,
seatIds: seatsForLeg(3),
});
}
if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) {
legDefs.push({
scheduleId: b.returnLeg2ScheduleId,
originStationId: b.returnLeg2OriginStationId,
destinationStationId: b.returnLeg2DestStationId,
seatIds: seatsForLeg(4),
});
}
}
if (legDefs.length === 0) return;
const journey = await this.prisma.journey.create({
data: {
passengerId: booking.passengerId,
bookingId: booking.id,
status: 'CONFIRMED',
totalMinor: booking.totalMinor,
currency: booking.currency,
} as any,
});
const journeySegments: any[] = [];
let segmentOrder = 0;
for (const leg of legDefs) {
if (leg.seatIds.length === 0) continue;
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId: leg.scheduleId },
orderBy: { sequence: 'asc' },
select: { stationId: true, sequence: true },
});
const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId);
const destIdx = stopTimes.findIndex(st => st.stationId === leg.destinationStationId);
if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue;
for (const seatId of leg.seatIds) {
for (let i = originIdx; i < destIdx; i++) {
journeySegments.push({
journeyId: journey.id,
scheduleId: leg.scheduleId,
segmentOrder: segmentOrder++,
seatId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
}
}
}
if (journeySegments.length > 0) {
await this.prisma.journeySegment.createMany({ data: journeySegments });
}
}
}