fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
@@ -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