fix architecture and write new tests
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
rentalPayment: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn(), updateMany: vi.fn(), create: vi.fn() },
|
||||
reservation: { findFirstOrThrow: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './payment.repo'
|
||||
|
||||
describe('payment.repo edge queries', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('loads recent company payments with reservation, customer and vehicle context', async () => {
|
||||
await repo.findByCompany('company_1')
|
||||
|
||||
expect(prisma.rentalPayment.findMany).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1' },
|
||||
include: { reservation: { include: { customer: true, vehicle: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('finds payment by id only within company and reservation scope', async () => {
|
||||
await repo.findPaymentOrThrow('payment_1', 'company_1', 'reservation_1')
|
||||
|
||||
expect(prisma.rentalPayment.findFirstOrThrow).toHaveBeenCalledWith({
|
||||
where: { id: 'payment_1', companyId: 'company_1', reservationId: 'reservation_1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('marks a payment as succeeded with a fresh paidAt timestamp', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-01T12:00:00.000Z'))
|
||||
|
||||
await repo.markPaymentSucceeded('payment_1')
|
||||
|
||||
expect(prisma.rentalPayment.update).toHaveBeenCalledWith({
|
||||
where: { id: 'payment_1' },
|
||||
data: { status: 'SUCCEEDED', paidAt: new Date('2026-08-01T12:00:00.000Z') },
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('increments paid amount and promotes reservation payment status to paid', async () => {
|
||||
await repo.incrementReservationPaid('reservation_1', 2500)
|
||||
|
||||
expect(prisma.reservation.update).toHaveBeenCalledWith({
|
||||
where: { id: 'reservation_1' },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: 2500 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('maps partial refunds to the correct payment status', async () => {
|
||||
await repo.setPaymentRefunded('payment_1', true)
|
||||
await repo.setPaymentRefunded('payment_2', false)
|
||||
|
||||
expect(prisma.rentalPayment.update).toHaveBeenNthCalledWith(1, {
|
||||
where: { id: 'payment_1' },
|
||||
data: { status: 'PARTIALLY_REFUNDED' },
|
||||
})
|
||||
expect(prisma.rentalPayment.update).toHaveBeenNthCalledWith(2, {
|
||||
where: { id: 'payment_2' },
|
||||
data: { status: 'REFUNDED' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody, parseParams } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as service from './payment.service'
|
||||
@@ -16,22 +17,24 @@ const router = Router()
|
||||
|
||||
router.post('/webhooks/amanpay', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const rawBody = getRawBodyString(req)
|
||||
const payload = parseRawJsonBody(req)
|
||||
const signature = (req.headers['x-amanpay-signature'] as string) ?? ''
|
||||
if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
await service.handleAmanpayWebhook(req.body)
|
||||
await service.handleAmanpayWebhook(payload, rawBody)
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/webhooks/paypal', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const rawBody = getRawBodyString(req)
|
||||
const payload = parseRawJsonBody(req)
|
||||
const isValid = await paypal.verifyWebhookEvent(req.headers as Record<string, string>, rawBody)
|
||||
if (!paypal.isConfigured() || !isValid) return res.status(401).json({ error: 'invalid_signature' })
|
||||
await service.handlePaypalWebhook(req.body)
|
||||
await service.handlePaypalWebhook(payload, rawBody)
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { capturePaypalSchema, chargeSchema, manualPaymentSchema, paymentParamSchema, refundSchema, reservationParamSchema } from './payment.schemas'
|
||||
|
||||
describe('payment schemas edge cases', () => {
|
||||
it('defaults charge and manual payment currency/type while rejecting unsupported providers', () => {
|
||||
expect(chargeSchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' })).toMatchObject({
|
||||
provider: 'PAYPAL',
|
||||
type: 'CHARGE',
|
||||
currency: 'MAD',
|
||||
})
|
||||
expect(chargeSchema.safeParse({ provider: 'STRIPE', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(false)
|
||||
expect(manualPaymentSchema.parse({ amount: 500, paymentMethod: 'CASH' })).toMatchObject({ amount: 500, currency: 'MAD', type: 'CHARGE' })
|
||||
expect(manualPaymentSchema.safeParse({ amount: 0, paymentMethod: 'CASH' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('validates refund/capture/payment parameter payloads', () => {
|
||||
expect(refundSchema.parse({})).toEqual({})
|
||||
expect(refundSchema.safeParse({ amount: -1 }).success).toBe(false)
|
||||
expect(capturePaypalSchema.safeParse({ paypalOrderId: 'order_1' }).success).toBe(true)
|
||||
expect(capturePaypalSchema.safeParse({}).success).toBe(false)
|
||||
expect(reservationParamSchema.safeParse({ id: '' }).success).toBe(false)
|
||||
expect(paymentParamSchema.safeParse({ reservationId: 'reservation_1', paymentId: 'payment_1' }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ 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 { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
|
||||
|
||||
export function listByCompany(companyId: string) {
|
||||
return repo.findByCompany(companyId)
|
||||
@@ -11,12 +12,12 @@ export function listByReservation(reservationId: string, companyId: string) {
|
||||
return repo.findByReservation(reservationId, companyId)
|
||||
}
|
||||
|
||||
export async function handleAmanpayWebhook(event: any) {
|
||||
async function applyAmanpayWebhook(event: any) {
|
||||
const transactionId = event.transaction_id ?? event.id
|
||||
const status = event.status?.toUpperCase()
|
||||
if (status === 'PAID' || status === 'SUCCEEDED') {
|
||||
const payment = await repo.findByAmanpay(transactionId)
|
||||
if (payment) {
|
||||
if (payment && payment.status !== 'SUCCEEDED') {
|
||||
await repo.markPaymentSucceeded(payment.id)
|
||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||
}
|
||||
@@ -25,12 +26,12 @@ export async function handleAmanpayWebhook(event: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePaypalWebhook(event: any) {
|
||||
async function applyPaypalWebhook(event: any) {
|
||||
const eventType = event.event_type as string
|
||||
if (eventType === 'PAYMENT.CAPTURE.COMPLETED') {
|
||||
const captureId = event.resource?.id as string
|
||||
const payment = await repo.findByPaypal(captureId)
|
||||
if (payment) {
|
||||
if (payment && payment.status !== 'SUCCEEDED') {
|
||||
await repo.markPaymentSucceeded(payment.id)
|
||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||
}
|
||||
@@ -39,6 +40,26 @@ export async function handlePaypalWebhook(event: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAmanpayWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) {
|
||||
return processWebhookOnce({
|
||||
provider: 'amanpay:payments',
|
||||
providerEventId: getWebhookEventId('amanpay', event),
|
||||
eventType: String(event.status ?? 'unknown'),
|
||||
rawBody,
|
||||
handle: () => applyAmanpayWebhook(event),
|
||||
})
|
||||
}
|
||||
|
||||
export async function handlePaypalWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) {
|
||||
return processWebhookOnce({
|
||||
provider: 'paypal:payments',
|
||||
providerEventId: getWebhookEventId('paypal', event),
|
||||
eventType: String(event.event_type ?? 'unknown'),
|
||||
rawBody,
|
||||
handle: () => applyPaypalWebhook(event),
|
||||
})
|
||||
}
|
||||
|
||||
export async function initCharge(reservationId: string, companyId: string, body: {
|
||||
provider: 'AMANPAY' | 'PAYPAL'; type: 'CHARGE' | 'DEPOSIT'
|
||||
currency: 'MAD'; successUrl: string; failureUrl: string
|
||||
|
||||
Reference in New Issue
Block a user