import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../../services/amanpayService', () => ({ isConfigured: vi.fn(), createCheckout: vi.fn(), refundTransaction: vi.fn(), })) vi.mock('../../services/paypalService', () => ({ isConfigured: vi.fn(), createOrder: vi.fn(), captureOrder: vi.fn(), refundCapture: vi.fn(), })) vi.mock('./payment.repo', () => ({ findByCompany: vi.fn(), findByReservation: vi.fn(), findByAmanpay: vi.fn(), findByPaypal: vi.fn(), findByPaypalForCompany: vi.fn(), findPaymentOrThrow: vi.fn(), findReservationOrThrow: vi.fn(), findReservation: vi.fn(), markPaymentSucceeded: vi.fn(), markPaymentFailed: vi.fn(), incrementReservationPaid: vi.fn(), createPayment: vi.fn(), updatePaypalCapture: vi.fn(), setReservationPaidAmount: vi.fn(), setReservationRefunded: vi.fn(), setPaymentRefunded: vi.fn(), })) import { ConflictError, ValidationError } from '../../http/errors' import * as amanpay from '../../services/amanpayService' import * as paypal from '../../services/paypalService' import * as repo from './payment.repo' import { capturePaypal, handleAmanpayWebhook, handlePaypalWebhook, initCharge, recordManualPayment, refundPayment, } from './payment.service' const reservation = { id: 'reservation_1', companyId: 'company_1', paymentStatus: 'UNPAID', depositAmount: 300, totalAmount: 1200, paidAmount: 200, vehicle: { make: 'Dacia', model: 'Duster' }, customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' }, } describe('payment.service', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() vi.setSystemTime(new Date('2026-06-08T10:15:00.000Z')) delete process.env.API_URL }) afterEach(() => { vi.useRealTimers() delete process.env.API_URL }) it('creates an AmanPay deposit checkout using reservation, customer, and webhook details', async () => { vi.mocked(repo.findReservationOrThrow).mockResolvedValue(reservation as never) vi.mocked(amanpay.isConfigured).mockReturnValue(true) vi.mocked(amanpay.createCheckout).mockResolvedValue({ checkoutUrl: 'https://pay.example/checkout', transactionId: 'aman_txn_1' } as never) vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_1', status: 'PENDING' } as never) const result = await initCharge('reservation_1', 'company_1', { provider: 'AMANPAY', type: 'DEPOSIT', currency: 'MAD', successUrl: 'https://app.example/success', failureUrl: 'https://app.example/failure', }) expect(amanpay.createCheckout).toHaveBeenCalledWith(expect.objectContaining({ amount: 300, currency: 'MAD', orderId: 'reservation_1-DEPOSIT-1780913700000', description: 'Deposit: Dacia Duster', customerEmail: 'nora@example.com', customerName: 'Nora Driver', webhookUrl: 'http://localhost:4000/api/v1/payments/webhooks/amanpay', })) expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'company_1', reservationId: 'reservation_1', amount: 300, status: 'PENDING', type: 'DEPOSIT', paymentProvider: 'AMANPAY', amanpayTransactionId: 'aman_txn_1', paypalCaptureId: null, })) expect(result).toEqual({ payment: { id: 'payment_1', status: 'PENDING' }, checkoutUrl: 'https://pay.example/checkout' }) }) it('refuses to initialize a charge for a fully paid reservation before touching gateways', async () => { vi.mocked(repo.findReservationOrThrow).mockResolvedValue({ ...reservation, paymentStatus: 'PAID' } as never) await expect(initCharge('reservation_1', 'company_1', { provider: 'PAYPAL', type: 'CHARGE', currency: 'MAD', successUrl: 'https://app.example/success', failureUrl: 'https://app.example/failure', })).rejects.toBeInstanceOf(ConflictError) expect(paypal.isConfigured).not.toHaveBeenCalled() expect(repo.createPayment).not.toHaveBeenCalled() }) it('records manual payments and marks the reservation as partially or fully paid from the balance math', async () => { vi.mocked(repo.findReservation).mockResolvedValue({ id: 'reservation_1', totalAmount: 1000, paidAmount: 700 } as never) vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_1', amount: 300, status: 'SUCCEEDED' } as never) const payment = await recordManualPayment('reservation_1', 'company_1', { amount: 300, currency: 'MAD', type: 'CHARGE', paymentMethod: 'CASH', }) expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({ status: 'SUCCEEDED', paymentProvider: 'AMANPAY', paymentMethod: 'CASH', paidAt: expect.any(Date), })) expect(repo.setReservationPaidAmount).toHaveBeenCalledWith('reservation_1', 1000, 'PAID') expect(payment).toEqual({ id: 'payment_1', amount: 300, status: 'SUCCEEDED' }) }) it('rejects manual overpayments instead of silently corrupting the reservation balance', async () => { vi.mocked(repo.findReservation).mockResolvedValue({ id: 'reservation_1', totalAmount: 1000, paidAmount: 950 } as never) await expect(recordManualPayment('reservation_1', 'company_1', { amount: 100, currency: 'MAD', type: 'CHARGE', paymentMethod: 'BANK_TRANSFER', })).rejects.toBeInstanceOf(ValidationError) expect(repo.createPayment).not.toHaveBeenCalled() expect(repo.setReservationPaidAmount).not.toHaveBeenCalled() }) it('applies paid AmanPay and denied PayPal webhook events to the matching records', async () => { vi.mocked(repo.findByAmanpay).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 450 } as never) vi.mocked(repo.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500 } as never) await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' }) await handlePaypalWebhook({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'paypal_capture_1' } }) await handlePaypalWebhook({ event_type: 'PAYMENT.CAPTURE.DENIED', resource: { id: 'paypal_capture_2' } }) expect(repo.markPaymentSucceeded).toHaveBeenCalledWith('payment_1') expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_1', 450) expect(repo.markPaymentSucceeded).toHaveBeenCalledWith('payment_2') expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_2', 500) expect(repo.markPaymentFailed).toHaveBeenCalledWith({ paypalCaptureId: 'paypal_capture_2' }) }) it('captures PayPal orders, stores the capture id, and increments the original reservation payment', async () => { vi.mocked(repo.findByPaypalForCompany).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 800 } as never) vi.mocked(paypal.captureOrder).mockResolvedValue({ purchase_units: [{ payments: { captures: [{ id: 'capture_123' }] } }] } as never) vi.mocked(repo.updatePaypalCapture).mockResolvedValue({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' } as never) const result = await capturePaypal('reservation_1', 'company_1', 'order_123') expect(repo.findByPaypalForCompany).toHaveBeenCalledWith('order_123', 'company_1') expect(repo.updatePaypalCapture).toHaveBeenCalledWith('payment_1', 'capture_123') expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_1', 800) expect(result).toEqual({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' }) }) it('refunds gateway payments and only marks the reservation refunded on full refunds', async () => { vi.mocked(repo.findPaymentOrThrow).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', status: 'SUCCEEDED', amount: 1000, currency: 'MAD', paymentProvider: 'PAYPAL', paypalCaptureId: 'capture_123', amanpayTransactionId: null, } as never) vi.mocked(repo.setPaymentRefunded).mockResolvedValue({ id: 'payment_1', status: 'PARTIALLY_REFUNDED' } as never) const result = await refundPayment('reservation_1', 'payment_1', 'company_1', 250, 'Customer request') expect(paypal.refundCapture).toHaveBeenCalledWith('capture_123', 250, 'MAD', 'Customer request') expect(repo.setPaymentRefunded).toHaveBeenCalledWith('payment_1', true) expect(repo.setReservationRefunded).not.toHaveBeenCalled() expect(result).toEqual({ id: 'payment_1', status: 'PARTIALLY_REFUNDED' }) }) })