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

842 lines
30 KiB
TypeScript

import { Test, TestingModule } from "@nestjs/testing";
import { PaymentsService } from "./payments.service";
import { PaymentClientService } from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
import { PrismaService } from "../../common/prisma.service";
import { AuditService } from "../../common/audit.service";
import { SeatsService } from "../seats/seats.service";
import { TicketsService } from "../tickets/tickets.service";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { PaymentIntentStatus, PaymentMethodType } from "@prisma/client";
import { BadRequestException, NotFoundException } from "@nestjs/common";
import {
PaymentIntentSnapshot,
PaymentReferenceType,
PaymentService as PaymentServiceEnum,
ProviderMethod,
ProviderPaymentStatus,
} from "@edr/types";
import {
MAX_PAYMENT_HOURS,
MIN_PAYMENT_WINDOW_MINUTES,
PAYMENT_SESSION_MINUTES,
} from "../../common/utils/payment-deadline.utils";
describe("PaymentsService", () => {
let service: PaymentsService;
let prisma: PrismaService;
let seatsService: SeatsService;
let ticketsService: TicketsService;
let eventEmitter: EventEmitter2;
const mockPrisma: Record<string, any> = {
booking: {
findUnique: jest.fn(),
update: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
paymentIntent: {
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
upsert: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
create: jest.fn(),
},
paymentMethod: {
findUnique: jest.fn(),
},
excessBaggageCharge: {
findUnique: jest.fn(),
update: jest.fn(),
// The charge handlers claim the PAID transition with a conditional updateMany so the
// in-app path and this webhook cannot both write an audit row for one settlement.
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
supplementaryCharge: {
findUnique: jest.fn(),
update: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
currencyExchangeRate: {
findFirst: jest.fn(),
},
walletAccount: {
findUnique: jest.fn(),
update: jest.fn(),
},
walletLedgerEntry: {
create: jest.fn(),
},
loyaltyAccount: {
findUnique: jest.fn(),
update: jest.fn(),
},
loyaltyLedgerEntry: {
create: jest.fn(),
},
$transaction: jest.fn((callback: (tx: any) => any) => callback(mockPrisma)),
};
const mockSeatsService = {
confirmSeats: jest.fn(),
releaseSeats: jest.fn(),
};
const mockTicketsService = {
generate: jest.fn(),
};
const mockEventEmitter = {
emit: jest.fn(),
};
const mockPaymentClient = {
initiate: jest.fn(),
getIntentByReference: jest.fn(),
reconcileByReference: jest.fn(),
};
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
const mockCurrencyService = {
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
Promise.resolve(minor),
),
displayMinorToChargeMajor: jest.fn((minor: number) => minor / 100),
convertMinorToChargeMajor: jest.fn(async (minor: number) => minor / 100),
getRateOrThrow: jest.fn(),
};
const requiresActionSnapshot = (
provider: ProviderMethod,
): PaymentIntentSnapshot => ({
intentId: "remote-intent-1",
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.BOOKING,
referenceId: "booking-1",
merchantOrderId: "PSG-MERCH-123",
provider,
status: ProviderPaymentStatus.REQUIRES_ACTION,
amountMinor: 50000,
currency: "ETB",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PaymentsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: SeatsService, useValue: mockSeatsService },
{ provide: TicketsService, useValue: mockTicketsService },
{ provide: EventEmitter2, useValue: mockEventEmitter },
{ provide: PaymentClientService, useValue: mockPaymentClient },
{ provide: CurrencyService, useValue: mockCurrencyService },
{ provide: AuditService, useValue: { log: jest.fn() } },
],
}).compile();
service = module.get<PaymentsService>(PaymentsService);
prisma = module.get<PrismaService>(PrismaService);
seatsService = module.get<SeatsService>(SeatsService);
ticketsService = module.get<TicketsService>(TicketsService);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
jest.clearAllMocks();
mockPaymentClient.getIntentByReference.mockResolvedValue(null);
});
describe("initiatePayment", () => {
const mockBooking = {
id: "booking-1",
bookingRef: "EDR123456",
passengerId: "passenger-1",
totalMinor: 50000,
currency: "ETB",
status: "PENDING_PAYMENT",
seats: [{ id: "seat-1", seatId: "seat-id-1" }],
};
it("should throw NotFoundException if booking not found", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(null);
await expect(
service.initiatePayment({
bookingId: "invalid",
method: "TELEBIRR" as any,
}),
).rejects.toThrow(NotFoundException);
});
it("should throw BadRequestException if booking not payable", async () => {
mockPrisma.booking.findUnique.mockResolvedValue({
...mockBooking,
status: "CONFIRMED",
});
await expect(
service.initiatePayment({
bookingId: "booking-1",
method: "TELEBIRR" as any,
}),
).rejects.toThrow(BadRequestException);
});
/**
* A booking whose payment deadline lands exactly `minutesLeft` from now: the deadline is
* MIN(createdAt + MAX_PAYMENT_HOURS, departure - checkin), so back-date createdAt and keep
* departure far away. Derived from MAX_PAYMENT_HOURS so the test survives changes to it.
*/
const bookingWithDeadlineIn = (minutesLeft: number) => ({
...mockBooking,
createdAt: new Date(
Date.now() - (MAX_PAYMENT_HOURS * 60 - minutesLeft) * 60 * 1000,
),
originStationId: null,
schedule: {
departureAt: new Date(Date.now() + 10 * 60 * 60 * 1000),
stopTimes: [],
route: null,
},
});
it("should refuse to open a provider session that cannot finish before auto-cancel", async () => {
// 2 minutes left — the real incident: the session was opened, the provider captured the
// money, and the auto-cancel cron had already cancelled the booking by then.
mockPrisma.booking.findUnique.mockResolvedValue(bookingWithDeadlineIn(2));
await expect(
service.initiatePayment({
bookingId: "booking-1",
method: "TELEBIRR" as any,
}),
).rejects.toThrow(BadRequestException);
// Nothing may reach the provider — no session, no capture, no orphan payment.
expect(mockPaymentClient.initiate).not.toHaveBeenCalled();
});
it("should open a session and report its expiry when the window is wide enough", async () => {
const minutesLeft = MIN_PAYMENT_WINDOW_MINUTES + 3;
mockPrisma.booking.findUnique.mockResolvedValue(
bookingWithDeadlineIn(minutesLeft),
);
mockPaymentClient.initiate.mockResolvedValue(
requiresActionSnapshot(ProviderMethod.TELEBIRR),
);
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
});
const result = await service.initiatePayment({
bookingId: "booking-1",
method: "TELEBIRR" as any,
});
expect(mockPaymentClient.initiate).toHaveBeenCalled();
// Session ends PAYMENT_SESSION_MINUTES from now — before the deadline, not at it.
const sessionMs =
new Date(result.sessionExpiresAt!).getTime() - Date.now();
expect(sessionMs).toBeLessThanOrEqual(PAYMENT_SESSION_MINUTES * 60 * 1000);
expect(new Date(result.sessionExpiresAt!).getTime()).toBeLessThan(
new Date(result.paymentDeadline!).getTime(),
);
});
it("should initiate a provider payment through the payment microservice", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPaymentClient.initiate.mockResolvedValue(
requiresActionSnapshot(ProviderMethod.TELEBIRR),
);
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
// syncIntentProjection applies the status in a separate guarded write (never demoting a
// SUCCEEDED row), then reads the projection back — so this is what it returns.
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
const result = await service.initiatePayment({
bookingId: "booking-1",
method: "TELEBIRR" as any,
});
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect(result.clientAction?.url).toBe("https://provider.example/pay");
expect(mockPaymentClient.initiate).toHaveBeenCalledWith(
expect.objectContaining({
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.BOOKING,
referenceId: "booking-1",
orderRef: "EDR123456",
// 50000 minor ETB → 500.00 major, settled in ETB (no FX for Ethiopian methods).
amountMinor: 500,
currency: "ETB",
provider: "TELEBIRR",
}),
);
// Snapshot mirrored into the local projection.
expect(mockPrisma.paymentIntent.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { bookingId: "booking-1" } }),
);
});
it("should finalize the booking when the service reports an already-paid intent", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPaymentClient.initiate.mockResolvedValue({
...requiresActionSnapshot(ProviderMethod.WAAFI),
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: "TXN-1",
paidAt: new Date().toISOString(),
});
// Projection clamps SUCCEEDED to PROCESSING; finalizePaymentSuccess flips it.
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.PROCESSING,
});
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.PROCESSING,
});
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.SUCCEEDED,
merchantOrderId: "PSG-MERCH-123",
});
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue(null);
const result = await service.initiatePayment({
bookingId: "booking-1",
method: "WAAFI" as any,
});
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1");
});
it("should initiate wallet payment and debit successfully", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
// First call: existing-intent check (none); second call: finalize loads the new intent.
mockPrisma.paymentIntent.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.PROCESSING,
});
mockPrisma.walletAccount.findUnique.mockResolvedValue({
id: "wallet-1",
passengerId: "passenger-1",
balanceMinor: 100000,
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.PROCESSING,
});
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.SUCCEEDED,
bookingId: "booking-1",
});
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
id: "loyalty-1",
pointsBalance: 100,
});
const result = await service.initiatePayment({
bookingId: "booking-1",
method: "WALLET" as any,
});
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
expect(mockSeatsService.confirmSeats).toHaveBeenCalled();
expect(mockTicketsService.generate).toHaveBeenCalled();
expect(mockPaymentClient.initiate).not.toHaveBeenCalled();
});
it("should fail wallet payment with insufficient balance", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPrisma.walletAccount.findUnique.mockResolvedValue({
id: "wallet-1",
passengerId: "passenger-1",
balanceMinor: 10000, // Less than booking total
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.FAILED,
failureCode: "INSUFFICIENT_BALANCE",
});
const result = await service.initiatePayment({
bookingId: "booking-1",
method: "WALLET" as any,
});
expect(result.status).toBe(PaymentIntentStatus.FAILED);
});
});
describe("finalizePaymentSuccess", () => {
it("should finalize payment and issue ticket", async () => {
const mockIntent = {
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.PROCESSING,
};
const mockBooking = {
id: "booking-1",
passengerId: "passenger-1",
totalMinor: 50000,
seats: [{ seatId: "seat-1" }],
};
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
id: "loyalty-1",
pointsBalance: 100,
});
const result = await service.finalizePaymentSuccess({
intentId: "intent-1",
providerTxnId: "TXN-123",
});
expect(result.alreadyFinalized).toBe(false);
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(["seat-1"]);
expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1");
expect(mockEventEmitter.emit).toHaveBeenCalledWith("payment.succeeded", {
booking: mockBooking,
});
});
it("should return alreadyFinalized if payment already succeeded", async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.SUCCEEDED,
});
const result = await service.finalizePaymentSuccess({
intentId: "intent-1",
});
expect(result.alreadyFinalized).toBe(true);
});
});
describe("getBookingAmountByCurrency", () => {
it("should convert from the booking currency to the requested currency", async () => {
mockPrisma.booking.findUnique.mockResolvedValue({
id: "booking-1",
totalMinor: 100000,
bookingType: "ONE_WAY",
packageId: null,
priceTierId: null,
currency: "USD",
displayCurrency: "USD",
displayTotalMinor: 125000,
});
mockPrisma.currencyExchangeRate.findFirst.mockResolvedValue({ rate: 2.5 });
const result = await service.getBookingAmountByCurrency("booking-1", "DJF");
expect(result).toEqual({
booking_id: "booking-1",
currency: "DJF",
amount: 3125,
});
expect(mockPrisma.currencyExchangeRate.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
fromCurrency: "USD",
toCurrency: "DJF",
}),
}),
);
});
});
describe("getIntentByBookingId", () => {
it("should return the cached local intent when the payment service has none", async () => {
const mockIntent = {
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.SUCCEEDED,
method: PaymentMethodType.TELEBIRR,
paidAt: new Date(),
merchantOrderId: "MERCH-123",
updatedAt: new Date(),
};
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
mockPaymentClient.getIntentByReference.mockResolvedValue(null);
const result = await service.getIntentByBookingId("booking-1");
expect(result.intentId).toBe("intent-1");
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
});
it("should mirror a payment-service snapshot into the local projection", async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPaymentClient.getIntentByReference.mockResolvedValue(
requiresActionSnapshot(ProviderMethod.WAAFI),
);
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
// See above: the projection is read back after the guarded status write.
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
const result = await service.getIntentByBookingId("booking-1");
expect(mockPaymentClient.getIntentByReference).toHaveBeenCalledWith(
PaymentReferenceType.BOOKING,
"booking-1",
);
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect(result.clientAction?.url).toBe("https://provider.example/pay");
});
it("should throw NotFoundException if intent not found anywhere", async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPaymentClient.getIntentByReference.mockResolvedValue(null);
await expect(service.getIntentByBookingId("invalid")).rejects.toThrow(
NotFoundException,
);
});
});
// Regression: a booking confirmed between a sweep's candidate query and its turn in the loop
// used to have its SUCCEEDED projection demoted to PROCESSING by the re-sync, with nothing
// able to restore it (finalizePaymentSuccess only writes SUCCEEDED while the booking is still
// PENDING_PAYMENT). A later stale payment.failed from an abandoned sibling attempt could then
// push that same row to FAILED, because markPaymentFailed only shields SUCCEEDED/CANCELLED.
describe("confirmed-booking projection integrity", () => {
it("does not re-sync or cancel a booking confirmed since the caller's snapshot", async () => {
mockPrisma.booking.findUnique.mockResolvedValue({ status: "CONFIRMED" });
const result = await service.reconcileAndConfirmIfPaid("booking-1");
expect(result).toEqual({ paid: true, verified: true });
// Neither the payment service nor the projection is touched.
expect(mockPaymentClient.reconcileByReference).not.toHaveBeenCalled();
expect(mockPrisma.paymentIntent.upsert).not.toHaveBeenCalled();
});
it("writes the mirrored status only where the row is not already SUCCEEDED", async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPaymentClient.getIntentByReference.mockResolvedValue(
requiresActionSnapshot(ProviderMethod.TELEBIRR),
);
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
});
await service.getIntentByBookingId("booking-1");
// The upsert must never carry a status on its update path...
const upsertArg = mockPrisma.paymentIntent.upsert.mock.calls[0][0];
expect(upsertArg.update).not.toHaveProperty("status");
// ...the status arrives through a write guarded on the row not being SUCCEEDED, which is
// what makes demoting the confirming payment structurally impossible.
expect(mockPrisma.paymentIntent.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: { not: PaymentIntentStatus.SUCCEEDED },
}),
}),
);
});
});
/**
* Excess baggage settles through the same outbox → RabbitMQ path as bookings. Before this
* existed the consumer dropped every EXCESS_BAGGAGE event as "foreign-reference", so a charge
* the payer had genuinely paid stayed PENDING until its TTL flipped it to EXPIRED.
*/
describe("handlePaymentEvent — excess baggage", () => {
const CHARGE_ID = "charge-1";
const succeededEvent = (overrides: Record<string, any> = {}) =>
({
eventId: "evt-1",
eventType: "payment.succeeded",
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.EXCESS_BAGGAGE,
referenceId: CHARGE_ID,
amountMinor: 500,
currency: "ETB",
providerTxnId: "TXN-9",
...overrides,
}) as any;
it("marks a pending charge PAID", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
id: CHARGE_ID,
status: "PENDING",
});
const result = await service.handlePaymentEvent(succeededEvent());
expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: CHARGE_ID }),
data: expect.objectContaining({ status: "PAID" }),
}),
);
expect(result).toEqual({ processed: true });
});
it("marks an EXPIRED charge PAID — the TTL governs starting a payment, not receiving one", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
id: CHARGE_ID,
status: "EXPIRED",
});
await service.handlePaymentEvent(succeededEvent());
expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: "PAID" }),
}),
);
});
it("does not re-pay an already PAID charge", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
id: CHARGE_ID,
status: "PAID",
});
const result = await service.handlePaymentEvent(succeededEvent());
expect(mockPrisma.excessBaggageCharge.updateMany).not.toHaveBeenCalled();
expect(result).toEqual({ processed: true, alreadyFinalized: true });
});
it("accepts a foreign-currency settlement without a short-pay comparison", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
id: CHARGE_ID,
status: "PENDING",
});
// 500.00 ETB charge settled as 1625 DJF — numerically unlike the stored total.
await service.handlePaymentEvent(
succeededEvent({ amountMinor: 1625, currency: "DJF" }),
);
expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: "PAID" }),
}),
);
});
it("acks a failure event without touching the charge", async () => {
const result = await service.handlePaymentEvent(
succeededEvent({ eventType: "payment.failed" }),
);
expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled();
expect(result).toEqual({ processed: true });
});
it("acks an event for a charge that no longer exists", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null);
const result = await service.handlePaymentEvent(succeededEvent());
expect(result).toEqual({
processed: false,
reason: "charge-not-found",
});
});
});
/**
* The live hop CBE makes while a teller is on the line, for a baggage bill. This is the
* double-payment guard: anything other than stillPayable=true makes CBE refuse the debit.
*/
describe("billQueryExcessBaggage", () => {
const payable = {
id: "charge-1",
excessWeightKg: 7,
totalMinor: 25_000,
status: "PENDING",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
booking: {
bookingRef: "BAG-001",
seats: [{ leg: 1, passengerName: "Abebe Kebede" }],
passenger: { user: { fullName: "Account Holder" } },
},
};
it("reports a pending charge as payable, in ETB, with the passenger name", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(payable);
const result = await service.billQueryExcessBaggage("charge-1");
expect(result).toMatchObject({
stillPayable: true,
currency: "ETB",
currentAmountMinor: 250,
payerName: "Abebe Kebede",
});
expect(result.paymentReason).toContain("BAG-001");
});
it("refuses a charge already paid at the counter in cash", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
...payable,
status: "CASH_COLLECTED",
});
await expect(
service.billQueryExcessBaggage("charge-1"),
).resolves.toMatchObject({
stillPayable: false,
reason: "ALREADY_PAID",
});
});
it("refuses a waived charge", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
...payable,
status: "WAIVED",
});
await expect(
service.billQueryExcessBaggage("charge-1"),
).resolves.toMatchObject({ stillPayable: false, reason: "CANCELLED" });
});
it("refuses a charge whose deadline has passed", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
...payable,
expiresAt: new Date(Date.now() - 1000),
});
await expect(
service.billQueryExcessBaggage("charge-1"),
).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" });
});
it("refuses within the settle margin, so a debit cannot land after expiry", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
...payable,
expiresAt: new Date(Date.now() + 5_000), // inside the 60s margin
});
await expect(
service.billQueryExcessBaggage("charge-1"),
).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" });
});
it("reports NOT_FOUND for a bill whose charge is gone", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null);
await expect(
service.billQueryExcessBaggage("charge-1"),
).resolves.toEqual({ stillPayable: false, reason: "NOT_FOUND" });
});
});
/**
* The live hop CBE makes while a teller is on the line, for a balance bill. Same
* double-payment guard as bookings and baggage: anything other than stillPayable=true makes
* CBE refuse the debit.
*/
describe("billQuerySupplementaryCharge", () => {
const payable = {
id: "sc-1",
amountMinor: 100_000,
status: "PENDING",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
booking: {
bookingRef: "BAL-001",
seats: [{ leg: 1, passengerName: "Abebe Kebede" }],
passenger: { user: { fullName: "Account Holder" } },
},
};
it("reports a pending charge as payable, in ETB, with the passenger name", async () => {
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue(payable);
const result = await service.billQuerySupplementaryCharge("sc-1");
expect(result).toMatchObject({
stillPayable: true,
currency: "ETB",
currentAmountMinor: 1000,
payerName: "Abebe Kebede",
});
expect(result.paymentReason).toContain("BAL-001");
});
it("refuses an already paid charge", async () => {
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, status: "PAID" });
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
stillPayable: false,
reason: "ALREADY_PAID",
});
});
it("refuses a waived charge", async () => {
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, status: "WAIVED" });
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
stillPayable: false,
reason: "CANCELLED",
});
});
it("refuses within the settle margin so a debit cannot land after expiry", async () => {
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({
...payable,
expiresAt: new Date(Date.now() + 5_000),
});
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
stillPayable: false,
reason: "EXPIRED",
});
});
it("treats a null expiry as an open-ended debt, still payable", async () => {
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, expiresAt: null });
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({
stillPayable: true,
});
});
it("reports NOT_FOUND for a bill whose charge is gone", async () => {
mockPrisma.supplementaryCharge.findUnique.mockResolvedValue(null);
await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toEqual({
stillPayable: false,
reason: "NOT_FOUND",
});
});
});
});