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

2150 lines
78 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,
BillQueryResponseDto,
} from "./internal-payments.dto";
import {
computePaymentDeadline,
computePaymentSessionExpiry,
canOpenPaymentSession,
MIN_PAYMENT_WINDOW_MINUTES,
PAYMENT_SETTLE_MARGIN_SECONDS,
} from "../../common/utils/payment-deadline.utils";
import {
PaymentClientService,
PaymentDiagnostic,
SettlementUnverifiableReason,
} from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
import { AuditService } from "../../common/audit.service";
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from "../../common/audit.actions";
const PAYMENT_METHOD_AUDIT_FIELDS = [
"type",
"displayName",
"region",
"currency",
"providerId",
"enabled",
"sortOrder",
] as const;
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. For DMONEY
// this is the preOrder `redirect_url` (the page the browser lands on after
// checkout) — NOT `notify_url`, which is the server-to-server webhook and is
// configured provider-side, never rebased.
const DOMAIN_AWARE_METHODS = new Set<PaymentMethodType>([
PaymentMethodType.TELEBIRR,
PaymentMethodType.WAAFI,
PaymentMethodType.DMONEY,
]);
@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 } });
await this.auditService.log({
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.Payment,
entityId: id,
oldData: {
bookingId: intent.bookingId,
status: intent.status,
amountMinor: intent.amountMinor,
currency: intent.currency,
method: intent.method,
},
});
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",
);
}
// eBirr is a direct wallet debit — the PIN prompt is pushed to this number over USSD. There
// is no hosted page that could collect it later, so it must be supplied up front.
if (method === PaymentMethodType.EBIRR && !dto.payerAccount?.trim()) {
throw new BadRequestException(
"payerAccount (mobile wallet number) is required for eBirr",
);
}
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). payerAccount is NOT
// required — CBE identifies the payer at its own channel.
if (
method === PaymentMethodType.CBE_BILL &&
(booking.currency ?? "ETB").toUpperCase() !== "ETB"
) {
throw new BadRequestException(
"CBE bill payment is only available for bookings charged in ETB",
);
}
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);
}
// Refuse to open a provider session that cannot finish before auto-cancel. Everything below
// this point hands the passenger off to an external provider (redirect/HPP/OTP), which takes
// minutes; TasksService cancels the booking the first cron tick after its payment deadline.
// Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible
// outcome — the provider captures the money and the booking is already CANCELLED when the
// capture lands. WALLET is exempt (returned above): it is an instant internal balance debit.
const paymentDeadline = await this.computeBookingPaymentDeadline(
booking.id,
);
const sessionExpiresAt = paymentDeadline
? computePaymentSessionExpiry(paymentDeadline)
: undefined;
if (paymentDeadline && !canOpenPaymentSession(paymentDeadline)) {
const remainingMs = paymentDeadline.getTime() - Date.now();
throw new BadRequestException(
remainingMs <= 0
? "The payment window for this booking has expired. Please make a new booking."
: `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` +
`until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` +
`Please make a new booking.`,
);
}
// Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the
// single passenger projection row (upserted by bookingId below) tracks the latest session.
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
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 =
method === PaymentMethodType.CBE_BILL
? "ETB"
: (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 (method === PaymentMethodType.CBE_BILL) {
// Force ETB, no conversion (D8) — eligibility was already checked above.
chargeAmount = this.currencyService.displayMinorToChargeMajor(
booking.totalMinor,
"ETB",
);
} else 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,
);
}
// CBE_BILL: the bill lives in CBE's system for as long as the booking is payable, so the
// intent expiry is the booking's own payment deadline — never a provider-session TTL
// (plan §6.4); payerName feeds the mandatory Full_Name of CBE's query response.
let payerName: string | undefined;
let expiresAt: string | undefined;
if (method === PaymentMethodType.CBE_BILL) {
payerName =
booking.seats.find((s) => s.leg === 1)?.passengerName ??
booking.seats[0]?.passengerName;
expiresAt = paymentDeadline?.toISOString();
}
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,
payerName,
expiresAt,
});
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),
sessionExpiresAt: sessionExpiresAt?.toISOString(),
paymentDeadline: paymentDeadline?.toISOString(),
};
}
/**
* 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);
}
/**
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check +
* payer identity for a booking. Called by the payment service while a CBE teller/app is
* waiting — read-only and fast. This is the double-payment guard: once the booking is
* confirmed by ANY method, stillPayable=false and CBE refuses the bill (§6.3).
*/
async billQuery(bookingId: string): Promise<BillQueryResponseDto> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { seats: true, passenger: { include: { user: true } } },
});
// Distinct from CANCELLED: the payment service issued a bill reference for a booking that
// no longer exists at all, which is a data problem, not a customer-facing cancellation.
if (!booking) return { stillPayable: false, reason: "NOT_FOUND" };
const base = {
// Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder.
payerName:
booking.seats.find((s) => s.leg === 1)?.passengerName ??
booking.seats[0]?.passengerName ??
booking.passenger?.user?.fullName ??
null,
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
booking.totalMinor,
"ETB",
),
currency: "ETB",
// CBE shows this beside the amount on the confirmation screen. bookingRef is the same
// code on the customer's ticket, so they can match the two before confirming.
paymentReason: `Train ticket booking ${booking.bookingRef}`,
};
// Paid first: a booking that was paid and then boarded/refunded must never be reported as
// merely "not payable" — the payer needs to hear that their money already went through.
if (booking.status === "CONFIRMED" || booking.paidAt) {
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
}
if (booking.status === "REFUNDED") {
return { ...base, stillPayable: false, reason: "REFUNDED" };
}
if (booking.status === "CANCELLED") {
return { ...base, stillPayable: false, reason: "CANCELLED" };
}
// DRAFT / BOARDED / NO_SHOW without a payment: no honest specific wording exists.
if (booking.status !== "PENDING_PAYMENT") {
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
}
// A CBE debit confirmed now lands in seconds, so this doesn't need the full
// MIN_PAYMENT_WINDOW_MINUTES that opening a session does — but it must not be confirmed so
// close to the deadline that the auto-cancel cron cancels the booking before the capture is
// registered. Refusing here is what keeps CBE from debiting a passenger for a dead booking.
const deadline = await this.computeBookingPaymentDeadline(booking.id);
if (
deadline &&
deadline.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < Date.now()
) {
return { ...base, stillPayable: false, reason: "EXPIRED" };
}
return { ...base, stillPayable: true, reason: null };
}
/**
* Bill-query for an excess baggage charge — the same live "still payable?" hop as bookings,
* against `ExcessBaggageCharge` instead. This is the double-payment guard for baggage bills:
* once the charge is paid, waived or lapsed, CBE is told to refuse the debit.
*
* The charge's own `expiresAt` is the deadline (extended to the CBE bill window when the bill
* was issued), so there is no separate schedule-derived deadline to compute as there is for a
* booking.
*/
async billQueryExcessBaggage(
chargeId: string,
): Promise<BillQueryResponseDto> {
const charge = await this.prisma.excessBaggageCharge.findUnique({
where: { id: chargeId },
include: {
booking: {
include: { seats: true, passenger: { include: { user: true } } },
},
},
});
// A bill reference we issued whose charge has since been deleted — a data problem, not a
// customer-facing cancellation.
if (!charge) return { stillPayable: false, reason: "NOT_FOUND" };
const base = {
payerName:
charge.booking?.seats?.find((s) => s.leg === 1)?.passengerName ??
charge.booking?.seats?.[0]?.passengerName ??
charge.booking?.passenger?.user?.fullName ??
null,
// The charge is always booked in ETB and CBE settles ETB only, so no conversion applies.
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
charge.totalMinor,
"ETB",
),
currency: "ETB",
// Rendered beside the amount on CBE's confirmation screen. The weight and booking ref are
// both on the agent's slip, so the payer can match the two before confirming.
paymentReason: `Excess baggage ${charge.excessWeightKg}kg — booking ${
charge.booking?.bookingRef ?? ""
}`.trim(),
};
// Paid first: a charge settled by any method (including cash at the counter) must be reported
// as already paid, never as merely "not payable".
if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") {
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
}
// A supervisor wrote the charge off; from the payer's side the debt is gone.
if (charge.status === "WAIVED") {
return { ...base, stillPayable: false, reason: "CANCELLED" };
}
// Confirmed CBE debits land in seconds, but must not be accepted so close to the deadline
// that the sweep expires the intent before the capture is registered.
if (
charge.status === "EXPIRED" ||
charge.expiresAt.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 <
Date.now()
) {
return { ...base, stillPayable: false, reason: "EXPIRED" };
}
if (charge.status !== "PENDING") {
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
}
return { ...base, stillPayable: true, reason: null };
}
/**
* Bill-query for a supplementary charge — the same live "still payable?" hop as bookings,
* against `SupplementaryCharge`. This is the double-payment guard for balance bills: once the
* charge is paid, waived or lapsed, CBE is told to refuse the debit.
*
* The charge's own 72-hour `expiresAt` is the deadline. It is nullable — a charge raised with
* no expiry is an open-ended debt and stays payable indefinitely, which is the intended reading
* of a null here rather than an immediate refusal.
*/
async billQuerySupplementaryCharge(
chargeId: string,
): Promise<BillQueryResponseDto> {
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { id: chargeId },
include: {
booking: {
include: { seats: true, passenger: { include: { user: true } } },
},
},
});
// A bill reference we issued whose charge has since been deleted — a data problem, not a
// customer-facing cancellation.
if (!charge) return { stillPayable: false, reason: "NOT_FOUND" };
const base = {
payerName:
charge.booking?.seats?.find((s) => s.leg === 1)?.passengerName ??
charge.booking?.seats?.[0]?.passengerName ??
charge.booking?.passenger?.user?.fullName ??
null,
// The charge is raised in ETB and CBE settles ETB only, so no conversion applies.
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
charge.amountMinor,
"ETB",
),
currency: "ETB",
// Rendered beside the amount on CBE's confirmation screen. The booking ref is on the
// passenger's ticket, so they can match the two before confirming.
paymentReason: `Outstanding balance — booking ${
charge.booking?.bookingRef ?? ""
}`.trim(),
};
if (charge.status === "PAID") {
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
}
// Staff wrote the balance off; from the payer's side the debt is gone.
if (charge.status === "WAIVED") {
return { ...base, stillPayable: false, reason: "CANCELLED" };
}
// Confirmed CBE debits land in seconds, but must not be accepted so close to the deadline
// that the sweep expires the intent before the capture is registered.
if (
charge.status === "EXPIRED" ||
(charge.expiresAt &&
charge.expiresAt.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 <
Date.now())
) {
return { ...base, stillPayable: false, reason: "EXPIRED" };
}
if (charge.status !== "PENDING") {
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
}
return { ...base, stillPayable: true, reason: null };
}
/**
* The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's
* origin-segment time and that stop's own check-in window, falling back to the route default.
*/
private async computeBookingPaymentDeadline(
bookingId: string,
): Promise<Date | null> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
select: {
createdAt: true,
originStationId: true,
schedule: {
select: {
departureAt: true,
stopTimes: {
select: {
stationId: true,
plannedArrivalAt: true,
plannedDepartureAt: true,
},
},
route: {
select: {
checkinMinutesBefore: true,
stops: {
select: { stationId: true, checkinMinutesBefore: true },
},
},
},
},
},
},
});
if (!booking?.schedule) return null;
const originStop = booking.schedule.stopTimes?.find(
(s) => s.stationId === booking.originStationId,
);
const dep = (originStop?.plannedArrivalAt ??
originStop?.plannedDepartureAt ??
booking.schedule.departureAt) as Date;
const originRouteStop = booking.schedule.route?.stops?.find(
(s) => s.stationId === booking.originStationId,
);
const checkinMinutes =
originRouteStop?.checkinMinutesBefore ??
booking.schedule.route?.checkinMinutesBefore ??
undefined;
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes);
}
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,
},
// No EBIRR entry: the payer never leaves the page — eBirr pushes a PIN prompt to their
// handset — so there is no browser bounce-back to configure.
[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,
) {
// Writing SUCCEEDED is finalizePaymentSuccess's job alone — it is the only place that can
// enforce confirm-once atomically — so a SUCCEEDED snapshot syncs as PROCESSING here.
const status =
snapshot.status === ProviderPaymentStatus.SUCCEEDED
? PaymentIntentStatus.PROCESSING
: (snapshot.status as unknown as PaymentIntentStatus);
const data = {
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,
};
await 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,
status,
...data,
},
});
await this.prisma.paymentIntent.updateMany({
where: { bookingId, status: { not: PaymentIntentStatus.SUCCEEDED } },
data: { status },
});
return this.prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId } });
}
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,
// eBirr settles inside initiate (its purchase response is the settlement), so a FAILED
// verdict arrives here rather than through a later status poll. Without these the portal
// can only show a generic "please try again" instead of the actual cause.
failureCode: intent.failureCode ?? undefined,
failureMessage: intent.failureMessage ?? 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" },
});
}
// The intent is moved to CANCELLED, not "REFUNDED" — recording the latter made the audit
// row contradict the row it describes.
await this.auditService.log({
action: AUDIT_ACTIONS.REFUND,
entityType: AUDIT_ENTITIES.Payment,
entityId: intent.id,
oldData: { status: intent.status, bookingStatus: booking?.status },
newData: {
status: "CANCELLED",
bookingId: dto.bookingId,
bookingRef: booking?.bookingRef,
bookingStatus: booking ? "CANCELLED" : undefined,
amountMinor: intent.amountMinor,
currency: intent.currency,
reason: dto.reason,
},
});
return { refunded: true, bookingRef: booking?.bookingRef };
}
async 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,
};
// This is an upsert keyed on `type`, so "add" silently overwrites an existing method. The
// audit row reports which of the two actually happened rather than always claiming a create.
const existing = await this.prisma.paymentMethod.findUnique({
where: { type: data.type },
});
const method = await this.prisma.paymentMethod.upsert({
where: { type: data.type },
update: data,
create: data,
});
await this.auditService.log({
action: existing ? AUDIT_ACTIONS.UPDATE : AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.PaymentMethod,
entityId: method.id,
oldData: existing ? this.paymentMethodSnapshot(existing) : undefined,
newData: this.paymentMethodSnapshot(method),
});
return method;
}
private paymentMethodSnapshot(method: Record<string, unknown>) {
return Object.fromEntries(
PAYMENT_METHOD_AUDIT_FIELDS.filter((k) => method[k] !== undefined).map((k) => [
k,
method[k],
]),
);
}
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;
const updated = await this.prisma.paymentMethod.update({
where: { id },
data: updateData,
});
await this.auditService.log({
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.PaymentMethod,
entityId: id,
oldData: this.paymentMethodSnapshot(existing),
newData: this.paymentMethodSnapshot(updated),
});
return updated;
}
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;
}
/**
* Ask the payment service (over HTTP — bypassing the possibly-down RabbitMQ) whether a booking is
* actually paid, and CONFIRM it synchronously if so. Used by (a) every cancellation site as a
* verify-before-cancel guard, and (b) the PaymentSyncService poller as lost-event recovery. Unlike
* getIntentByReference, POST /payments/reconcile loops ALL intents and live-queries even
* terminal (cancelled/expired) ones — so it catches a payment recorded only at the provider.
*
* - paid → the confirming payment is synced + finalized HERE (synchronously); the booking is
* now CONFIRMED, so a cancellation caller must NOT cancel.
* - not paid → verified unpaid; a cancellation caller may proceed.
* - unverifiable (provider query errored, in-flight, or payment service unreachable) → a
* cancellation caller must NOT cancel this cycle; defer and retry later.
*
* When unverifiable, `reason` says WHY, and the two are not interchangeable: `IN_FLIGHT` is a
* payment actually moving (defer forever — this is the case the guard exists for), while
* `PROVIDER_ERROR` may be a gateway that never comes back, which a sweep is allowed to give up
* on after a grace window rather than retry once a minute in perpetuity.
*/
async reconcileAndConfirmIfPaid(bookingId: string): Promise<{
paid: boolean;
verified: boolean;
reason?: SettlementUnverifiableReason;
}> {
const current = await this.prisma.booking.findUnique({
where: { id: bookingId },
select: { status: true },
});
if (current?.status === "CONFIRMED") {
return { paid: true, verified: true };
}
const settlement = await this.paymentClient.reconcileByReference(
PaymentReferenceType.BOOKING,
bookingId,
);
if (settlement.unverifiable) {
const reason = settlement.reason ?? "PROVIDER_ERROR";
this.logger.warn(
`reconcile-before-cancel: settlement UNVERIFIABLE (${reason}) for booking ${bookingId} — not cancelling`,
);
return { paid: false, verified: false, reason };
}
if (settlement.paid) {
if (settlement.intent) {
// Paid, but the confirm event may have been lost. Confirm synchronously (idempotent).
const intent = await this.syncIntentProjection(
bookingId,
settlement.intent,
);
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: settlement.intent.providerTxnId,
paidAt: settlement.intent.paidAt
? new Date(settlement.intent.paidAt)
: undefined,
}).catch((err) => {
this.logger.error(
`reconcile-before-cancel: finalize failed for booking ${bookingId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return { alreadyFinalized: false };
});
this.logger.log(
`reconcile-before-cancel: booking ${bookingId} is PAID (${settlement.intent.merchantOrderId}) — confirmed, NOT cancelling`,
);
} else {
this.logger.error(
`reconcile-before-cancel: booking ${bookingId} reported PAID but no intent snapshot — NOT cancelling`,
);
}
return { paid: true, verified: true };
}
// Verified not paid — safe to cancel.
return { paid: false, verified: true };
}
async finalizePaymentSuccess(input: {
intentId: string;
providerTxnId?: string;
paidAt?: Date;
/** Staff force-confirm: confirm the booking even if it is not PENDING_PAYMENT. */
force?: boolean;
}): 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.
// Only repair for a CONFIRMED booking: a SUCCEEDED intent on a CANCELLED booking is a
// recorded orphan payment (booking cancelled, seats possibly reassigned) and must NEVER
// generate a ticket.
const idempotencyBooking = await this.prisma.booking.findUnique({
where: { id: intent.bookingId },
select: { status: true },
});
if (idempotencyBooking?.status === "CONFIRMED") {
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 };
}
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);
// Atomic confirm-once. A booking may have many intents (free method changes); only the FIRST
// success on a still-PENDING_PAYMENT booking confirms it + generates the ticket. The conditional
// update is the race guard: two simultaneous payments both reach here, but exactly one flips
// PENDING_PAYMENT→CONFIRMED (count 1) — the other gets count 0 and is register-only (the payment
// is already stored on the payment-api ledger; we don't confirm, don't ticket, don't mark this
// row SUCCEEDED). `force` (staff) confirms regardless of the current booking status.
const confirmed = await this.prisma.$transaction(async (tx) => {
const res = input.force
? await tx.booking.updateMany({
where: { id: booking.id, status: { not: "CONFIRMED" } },
data: { status: "CONFIRMED" },
})
: await tx.booking.updateMany({
where: { id: booking.id, status: "PENDING_PAYMENT" },
data: { status: "CONFIRMED" },
});
if (res.count === 0) return 0;
await tx.paymentIntent.update({
where: { id: intent.id },
data: {
status: PaymentIntentStatus.SUCCEEDED,
providerTxnId:
input.providerTxnId ?? intent.providerTxnId ?? undefined,
paidAt,
failureCode: null,
failureMessage: null,
},
});
return res.count;
});
if (confirmed === 0) {
const recordsConfirmingCapture =
booking.status === "CONFIRMED" && intent.paidAt != null;
if (recordsConfirmingCapture) {
const { count } = await this.prisma.paymentIntent.updateMany({
where: { id: intent.id, status: { not: PaymentIntentStatus.SUCCEEDED } },
data: { status: PaymentIntentStatus.SUCCEEDED },
});
if (count > 0) {
this.logger.warn(
`Restored demoted payment projection for booking ${booking.id} ` +
`(intent ${intent.id}): ${intent.status} → SUCCEEDED`,
);
}
}
const duplicateCapture =
input.providerTxnId != null &&
intent.providerTxnId != null &&
input.providerTxnId !== intent.providerTxnId;
if (duplicateCapture || !recordsConfirmingCapture) {
this.logger.error(
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
);
}
return { alreadyFinalized: true };
}
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 };
// Conditional claim, not a plain update: SupplementaryChargesService.markPaid can be
// driving the same transition from the in-app path. Whichever caller flips the row writes
// the audit event; the loser writes nothing, so the trail holds exactly one PAID row.
const { count } = await this.prisma.supplementaryCharge.updateMany({
where: { id: charge.id, status: { not: "PAID" } },
data: {
status: "PAID",
paidAt: new Date(),
providerTxnId: event.providerTxnId ?? null,
},
});
if (count === 1) {
await this.auditService.log({
action: AUDIT_ACTIONS.PAY,
entityType: AUDIT_ENTITIES.SupplementaryCharge,
entityId: charge.id,
oldData: { status: charge.status },
newData: {
status: "PAID",
bookingId: charge.bookingId,
providerTxnId: event.providerTxnId,
settledAmount: event.amountMinor,
settledCurrency: event.currency,
},
});
}
return { processed: true, alreadyFinalized: count === 0 };
}
/**
* Settlement for an excess baggage charge paid through the passenger portal link.
*
* Deliberately has NO short-payment amount guard, unlike the booking path: the charge is stored
* in ETB while `event.amountMinor` arrives in the provider's settlement currency (DJF for
* Waafi/D-Money/CAC, USD for card), so comparing the two directly would reject every legitimate
* cross-currency payment. The amount actually charged was computed server-side at initiate.
*
* An EXPIRED charge is still marked PAID. The link TTL only governs whether a NEW payment may be
* started; once a provider has captured the money the charge is paid, and leaving it EXPIRED
* would hide a real settlement from the agent who has to reconcile it.
*/
private async handleExcessBaggageChargeEvent(
event: PaymentEventDto,
): Promise<MarkPaidResponseDto> {
if (event.eventType === "payment.failed") {
this.logger.warn(
`excess baggage charge ${event.referenceId} payment failed`,
);
return { processed: true };
}
const charge = await this.prisma.excessBaggageCharge.findUnique({
where: { id: event.referenceId },
});
if (!charge) {
// Ack — a missing charge will not appear on redelivery; needs investigation.
this.logger.error(
`mark-paid: no excess baggage charge for reference ${event.referenceId}`,
);
return { processed: false, reason: "charge-not-found" };
}
if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") {
return { processed: true, alreadyFinalized: true };
}
// Money arrived against a charge nobody expected to be paid — record it as PAID (that is the
// truth) but say so loudly: a waived charge that settles anyway needs a refund decision.
if (charge.status !== "PENDING") {
this.logger.warn(
`mark-paid: excess baggage charge ${charge.id} settled while ${charge.status} ` +
`(${event.amountMinor} ${event.currency}) — marking PAID; needs review`,
);
}
// Conditional claim for the same reason as the supplementary handler above:
// ExcessBaggageService.markPaid drives this transition from the in-app path.
const { count } = await this.prisma.excessBaggageCharge.updateMany({
where: { id: charge.id, status: { notIn: ["PAID", "CASH_COLLECTED"] } },
data: {
status: "PAID",
// The provider's own capture time, not when this event happened to be processed — a
// replayed or dead-lettered event must not backdate the money to the wrong minute.
paidAt: event.paidAt ? new Date(event.paidAt) : new Date(),
},
});
if (count === 0) {
return { processed: true, alreadyFinalized: true };
}
await this.auditService.log({
action: AUDIT_ACTIONS.PAY,
entityType: AUDIT_ENTITIES.ExcessBaggageCharge,
entityId: charge.id,
oldData: { status: charge.status },
newData: {
status: "PAID",
bookingId: charge.bookingId,
providerTxnId: event.providerTxnId,
settledAmount: event.amountMinor,
settledCurrency: event.currency,
},
});
this.logger.log(
`excess baggage charge ${charge.id} marked PAID (${event.amountMinor} ${event.currency}, txn ${event.providerTxnId ?? "n/a"})`,
);
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.EXCESS_BAGGAGE) {
return this.handleExcessBaggageChargeEvent(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" };
}
// C-4 guard: a settlement must cover what the passenger was quoted. `event.amountMinor`
// carries the charge amount in MAJOR units (the intent's "real/major price" — what
// initiate sent, e.g. 1500.00 ETB), while booking totals are stored in minor units, so
// normalize before comparing; a short payment must NOT confirm the booking. Amount-only —
// the display↔charge currency divergence is tracked separately under the USD/DJF
// findings. The 1% tolerance absorbs rounding.
const expectedMajor =
(booking.displayTotalMinor ?? booking.totalMinor) / 100;
const shortPayTolerance = Math.max(0.01, expectedMajor * 0.01);
if (event.amountMinor < expectedMajor - shortPayTolerance) {
this.logger.error(
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMajor} ${booking.displayCurrency}; not confirming`,
);
return { processed: false, reason: "amount-mismatch" };
}
// Payment on an already-CANCELLED booking: record the success on the passenger projection too
// (it is already registered on the payment-api ledger), but NEVER confirm the booking and NEVER
// generate a ticket — the seats may already be held by another passenger. Refund is manual.
if (booking.status === "CANCELLED") {
await this.prisma.paymentIntent.upsert({
where: { bookingId: event.referenceId },
update: {
status: PaymentIntentStatus.SUCCEEDED,
method: event.provider as unknown as PaymentMethodType,
amountMinor: event.amountMinor,
currency: event.currency,
merchantOrderId: event.merchantOrderId,
providerTxnId: event.providerTxnId,
paidAt: this.sanitizePaidAt(
event.paidAt ? new Date(event.paidAt) : undefined,
),
},
create: {
bookingId: event.referenceId,
status: PaymentIntentStatus.SUCCEEDED,
method: event.provider as unknown as PaymentMethodType,
amountMinor: event.amountMinor,
currency: event.currency,
merchantOrderId: event.merchantOrderId,
providerTxnId: event.providerTxnId,
paidAt: this.sanitizePaidAt(
event.paidAt ? new Date(event.paidAt) : undefined,
),
},
});
this.logger.error(
`Payment on CANCELLED booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` +
`txn=${event.providerTxnId ?? "n/a"}) — recorded on passenger + payment-api; NOT confirming ` +
`(seats may be reassigned). Refund required.`,
);
return { processed: true, alreadyFinalized: true };
}
// Sequential duplicate on an already-CONFIRMED booking: this success is a second payment,
// already registered on the payment-api ledger. Do NOT touch the passenger row — it must keep
// the confirming payment. (The concurrent-race case is caught atomically in finalizePaymentSuccess.)
if (booking.status !== "PENDING_PAYMENT") {
this.logger.error(
`Duplicate capture on ${booking.status} booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` +
`txn=${event.providerTxnId ?? "n/a"}) — registered in payment-api; not confirming`,
);
return { processed: true, alreadyFinalized: true };
}
// Booking is payable — point the single passenger projection row at THIS paying session (so the
// row reflects the payment that confirms the booking, even if the payer switched methods), then
// finalize (which does the atomic confirm-once).
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: event.referenceId },
update: {
method: event.provider as unknown as PaymentMethodType,
amountMinor: event.amountMinor,
currency: event.currency,
merchantOrderId: event.merchantOrderId,
providerTxnId: event.providerTxnId,
},
create: {
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 },
});
// Captured before the block below rewrites it, so the audit row can show what the override
// moved the payment away from.
const previousStatus = intent?.status ?? null;
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,
force: true,
}).then(async (result) => {
// finalizePaymentSuccess reports `alreadyFinalized` when the booking was already
// confirmed — logging a forced confirmation there would record an override that changed
// nothing.
if (!result?.alreadyFinalized) {
await this.auditService.log({
action: AUDIT_ACTIONS.STATUS_CHANGE,
entityType: AUDIT_ENTITIES.Payment,
entityId: intent.id,
oldData: { status: previousStatus },
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 });
}
}
}