207 lines
8.4 KiB
TypeScript
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' })
|
|
})
|
|
})
|