mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
430 lines
14 KiB
TypeScript
430 lines
14 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 { 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";
|
|
|
|
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(),
|
|
},
|
|
paymentIntent: {
|
|
findUnique: jest.fn(),
|
|
findUniqueOrThrow: jest.fn(),
|
|
upsert: jest.fn(),
|
|
update: jest.fn(),
|
|
create: jest.fn(),
|
|
},
|
|
paymentMethod: {
|
|
findUnique: jest.fn(),
|
|
},
|
|
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(),
|
|
};
|
|
|
|
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
|
|
const mockCurrencyService = {
|
|
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
|
|
Promise.resolve(minor),
|
|
),
|
|
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 },
|
|
],
|
|
}).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);
|
|
});
|
|
|
|
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" },
|
|
});
|
|
|
|
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" },
|
|
});
|
|
|
|
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,
|
|
);
|
|
});
|
|
});
|
|
});
|