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