Files
carmanagement/apps/api/src/modules/payments/payment.service.test.ts
T
2026-06-10 00:40:19 -04:00

207 lines
8.4 KiB
TypeScript

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' })
})
})