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