fix payment customer and dashboard settings
Build & Deploy / Build & Push Docker Image (push) Successful in 12m39s
Test / Type Check (all packages) (push) Successful in 5m8s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 4m24s
Test / Homepage Unit Tests (push) Successful in 3m27s
Test / Storefront Unit Tests (push) Successful in 4m48s
Test / Admin Unit Tests (push) Successful in 3m0s
Test / Dashboard Unit Tests (push) Successful in 4m18s
Test / API Integration Tests (push) Failing after 4m34s

This commit is contained in:
root
2026-06-29 22:15:34 -04:00
parent e1e2f55c93
commit a752a399c2
30 changed files with 4503 additions and 1214 deletions
@@ -3,7 +3,17 @@ 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() },
reservation: { findFirstOrThrow: vi.fn(), update: vi.fn(), findUniqueOrThrow: vi.fn() },
$transaction: vi.fn(async (callback) => callback({
reservation: {
findUniqueOrThrow: vi.fn().mockResolvedValue({
id: 'reservation_1',
totalAmount: 5000,
rentalPayments: [{ amount: 2500 }, { amount: 1500 }],
}),
update: vi.fn(),
},
})),
},
}))
@@ -46,13 +56,10 @@ describe('payment.repo edge queries', () => {
vi.useRealTimers()
})
it('increments paid amount and promotes reservation payment status to paid', async () => {
it('recomputes paid amount from successful charge payments', async () => {
await repo.incrementReservationPaid('reservation_1', 2500)
expect(prisma.reservation.update).toHaveBeenCalledWith({
where: { id: 'reservation_1' },
data: { paymentStatus: 'PAID', paidAmount: { increment: 2500 } },
})
expect(prisma.$transaction).toHaveBeenCalled()
})
it('maps partial refunds to the correct payment status', async () => {
+25 -4
View File
@@ -32,12 +32,15 @@ export function findPaymentOrThrow(paymentId: string, companyId: string, reserva
export function findReservationOrThrow(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id, companyId },
include: { vehicle: true, customer: true },
include: { vehicle: true, customer: true, rentalPayments: true },
})
}
export function findReservation(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
return prisma.reservation.findFirstOrThrow({
where: { id, companyId },
include: { rentalPayments: true },
})
}
export function markPaymentSucceeded(id: string) {
@@ -48,8 +51,26 @@ export function markPaymentFailed(query: { amanpayTransactionId?: string; paypal
return prisma.rentalPayment.updateMany({ where: query, data: { status: 'FAILED' } })
}
export function incrementReservationPaid(reservationId: string, amount: number) {
return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
export function incrementReservationPaid(reservationId: string, _amount: number) {
return prisma.$transaction(async (tx) => {
const reservation = await tx.reservation.findUniqueOrThrow({
where: { id: reservationId },
include: {
rentalPayments: {
where: { type: 'CHARGE', status: 'SUCCEEDED' },
select: { amount: true },
},
},
})
const paidAmount = reservation.rentalPayments.reduce((total, payment) => total + payment.amount, 0)
return tx.reservation.update({
where: { id: reservationId },
data: {
paidAmount,
paymentStatus: paidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL',
},
})
})
}
export function createPayment(data: {
@@ -72,7 +72,7 @@ router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (r
} catch (err) { next(err) }
})
router.post('/reservations/:id/manual', requireRole('MANAGER'), async (req, res, next) => {
router.post('/reservations/:id/manual', requireRole('OWNER'), async (req, res, next) => {
try {
const { id } = parseParams(reservationParamSchema, req)
const body = parseBody(manualPaymentSchema, req)
@@ -52,6 +52,7 @@ const reservation = {
depositAmount: 300,
totalAmount: 1200,
paidAmount: 200,
rentalPayments: [],
vehicle: { make: 'Dacia', model: 'Duster' },
customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' },
}
@@ -106,7 +107,11 @@ describe('payment.service', () => {
})
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)
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({
...reservation,
paymentStatus: 'PAID',
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1200 }],
} as never)
await expect(initCharge('reservation_1', 'company_1', {
provider: 'PAYPAL',
@@ -120,8 +125,36 @@ describe('payment.service', () => {
expect(repo.createPayment).not.toHaveBeenCalled()
})
it('allows an outstanding deposit when the rental invoice is fully paid', async () => {
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({
...reservation,
paymentStatus: 'PAID',
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1200 }],
} as never)
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
vi.mocked(amanpay.createCheckout).mockResolvedValue({ checkoutUrl: 'https://pay.example/checkout', transactionId: 'aman_txn_2' } as never)
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_2', status: 'PENDING' } as never)
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 }))
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({ type: 'DEPOSIT' }))
})
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.findReservation).mockResolvedValue({
id: 'reservation_1',
totalAmount: 1000,
depositAmount: 300,
paidAmount: 700,
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 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', {
@@ -133,7 +166,7 @@ describe('payment.service', () => {
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({
status: 'SUCCEEDED',
paymentProvider: 'AMANPAY',
paymentProvider: 'MANUAL',
paymentMethod: 'CASH',
paidAt: expect.any(Date),
}))
@@ -142,7 +175,13 @@ describe('payment.service', () => {
})
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)
vi.mocked(repo.findReservation).mockResolvedValue({
id: 'reservation_1',
totalAmount: 1000,
depositAmount: 300,
paidAmount: 950,
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 950 }],
} as never)
await expect(recordManualPayment('reservation_1', 'company_1', {
amount: 100,
@@ -156,8 +195,8 @@ describe('payment.service', () => {
})
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)
vi.mocked(repo.findByAmanpay).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 450, type: 'CHARGE' } as never)
vi.mocked(repo.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500, type: 'CHARGE' } as never)
await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' })
await handlePaypalWebhook({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'paypal_capture_1' } })
@@ -171,7 +210,7 @@ describe('payment.service', () => {
})
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(repo.findByPaypalForCompany).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 800, type: 'CHARGE' } 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)
@@ -19,7 +19,9 @@ async function applyAmanpayWebhook(event: any) {
const payment = await repo.findByAmanpay(transactionId)
if (payment && payment.status !== 'SUCCEEDED') {
await repo.markPaymentSucceeded(payment.id)
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
if (payment.type === 'CHARGE') {
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
}
}
} else if (status === 'FAILED') {
await repo.markPaymentFailed({ amanpayTransactionId: transactionId })
@@ -33,7 +35,9 @@ async function applyPaypalWebhook(event: any) {
const payment = await repo.findByPaypal(captureId)
if (payment && payment.status !== 'SUCCEEDED') {
await repo.markPaymentSucceeded(payment.id)
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
if (payment.type === 'CHARGE') {
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
}
}
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
await repo.markPaymentFailed({ paypalCaptureId: event.resource?.id })
@@ -65,9 +69,23 @@ export async function initCharge(reservationId: string, companyId: string, body:
currency: 'MAD'; successUrl: string; failureUrl: string
}) {
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
if (reservation.paymentStatus === 'PAID') throw new ConflictError('Reservation is already fully paid')
const rentalPayments = (reservation as any).rentalPayments ?? []
const invoicePaid = rentalPayments.reduce((total: number, payment: any) => {
if (payment.type !== 'CHARGE' || payment.status !== 'SUCCEEDED') return total
return total + payment.amount
}, 0)
const depositCollected = rentalPayments.reduce((total: number, payment: any) => {
if (payment.type !== 'DEPOSIT' || payment.status !== 'SUCCEEDED') return total
return total + payment.amount
}, 0)
const chargeRemaining = Math.max(reservation.totalAmount - invoicePaid, 0)
const depositRemaining = Math.max(reservation.depositAmount - depositCollected, 0)
const amount = body.type === 'DEPOSIT' ? depositRemaining : chargeRemaining
if (amount <= 0) {
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid')
}
const amount = body.type === 'DEPOSIT' ? reservation.depositAmount : reservation.totalAmount
const description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
const orderId = `${reservationId}-${body.type}-${Date.now()}`
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
@@ -103,7 +121,9 @@ export async function capturePaypal(reservationId: string, companyId: string, pa
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
const updated = await repo.updatePaypalCapture(payment.id, captureId)
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
if (payment.type === 'CHARGE') {
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
}
return updated
}
@@ -111,13 +131,31 @@ export async function recordManualPayment(reservationId: string, companyId: stri
amount: number; currency: string; type: string; paymentMethod: string
}) {
const reservation = await repo.findReservation(reservationId, companyId)
const remaining = Math.max(reservation.totalAmount - reservation.paidAmount, 0)
if (remaining <= 0) throw new ConflictError('Reservation is already fully paid')
if (body.amount > remaining) throw new ValidationError('Payment amount exceeds remaining balance')
const rentalPayments = (reservation as any).rentalPayments ?? []
const invoicePaid = rentalPayments.reduce((total: number, payment: any) => {
if (payment.type !== 'CHARGE' || payment.status !== 'SUCCEEDED') return total
return total + payment.amount
}, 0)
const depositCollected = rentalPayments.reduce((total: number, payment: any) => {
if (payment.type !== 'DEPOSIT' || payment.status !== 'SUCCEEDED') return total
return total + payment.amount
}, 0)
const chargeRemaining = Math.max(reservation.totalAmount - invoicePaid, 0)
const depositRemaining = Math.max(reservation.depositAmount - depositCollected, 0)
const remaining = body.type === 'DEPOSIT' ? depositRemaining : chargeRemaining
const payment = await repo.createPayment({ companyId, reservationId, amount: body.amount, currency: body.currency, status: 'SUCCEEDED', type: body.type, paymentProvider: body.paymentMethod === 'PAYPAL' ? 'PAYPAL' : 'AMANPAY', paymentMethod: body.paymentMethod, paidAt: new Date() })
const newPaidAmount = reservation.paidAmount + body.amount
await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL')
if (remaining <= 0) {
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid')
}
if (body.amount > remaining) {
throw new ValidationError(body.type === 'DEPOSIT' ? 'Payment amount exceeds deposit outstanding' : 'Payment amount exceeds remaining balance')
}
const payment = await repo.createPayment({ companyId, reservationId, amount: body.amount, currency: body.currency, status: 'SUCCEEDED', type: body.type, paymentProvider: 'MANUAL', paymentMethod: body.paymentMethod, paidAt: new Date() })
if (body.type === 'CHARGE') {
const newPaidAmount = invoicePaid + body.amount
await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL')
}
return payment
}