import { Router } from 'express' import { z } from 'zod' import { prisma } from '../lib/prisma' import { requireCompanyAuth } from '../middleware/requireCompanyAuth' import { requireTenant } from '../middleware/requireTenant' import { requireSubscription } from '../middleware/requireSubscription' import * as amanpay from '../services/amanpayService' import * as paypal from '../services/paypalService' const router = Router() // ─── Webhook endpoints (no auth — must come before middleware) ────────────── router.post('/webhooks/amanpay', async (req, res, next) => { try { const rawBody = JSON.stringify(req.body) const signature = req.headers['x-amanpay-signature'] as string ?? '' if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) { return res.status(401).json({ error: 'invalid_signature' }) } const event = req.body const transactionId = event.transaction_id ?? event.id const orderId = event.order_id ?? event.metadata?.order_id const status = event.status?.toUpperCase() if (status === 'PAID' || status === 'SUCCEEDED') { const payment = await prisma.rentalPayment.findFirst({ where: { amanpayTransactionId: transactionId } }) if (payment) { await prisma.rentalPayment.update({ where: { id: payment.id }, data: { status: 'SUCCEEDED', paidAt: new Date() }, }) await prisma.reservation.update({ where: { id: payment.reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } }, }) } } else if (status === 'FAILED') { await prisma.rentalPayment.updateMany({ where: { amanpayTransactionId: transactionId }, data: { status: 'FAILED' }, }) } res.json({ received: true }) } catch (err) { next(err) } }) router.post('/webhooks/paypal', async (req, res, next) => { try { const rawBody = JSON.stringify(req.body) const isValid = await paypal.verifyWebhookEvent( req.headers as Record, rawBody, ) if (paypal.isConfigured() && !isValid) { return res.status(401).json({ error: 'invalid_signature' }) } const event = req.body const eventType = event.event_type as string if (eventType === 'PAYMENT.CAPTURE.COMPLETED') { const captureId = event.resource?.id as string const payment = await prisma.rentalPayment.findFirst({ where: { paypalCaptureId: captureId } }) if (payment) { await prisma.rentalPayment.update({ where: { id: payment.id }, data: { status: 'SUCCEEDED', paidAt: new Date() }, }) await prisma.reservation.update({ where: { id: payment.reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } }, }) } } else if (eventType === 'PAYMENT.CAPTURE.DENIED') { const captureId = event.resource?.id as string await prisma.rentalPayment.updateMany({ where: { paypalCaptureId: captureId }, data: { status: 'FAILED' }, }) } res.json({ received: true }) } catch (err) { next(err) } }) // ─── Authenticated routes ──────────────────────────────────────────────────── router.use(requireCompanyAuth, requireTenant, requireSubscription) const chargeSchema = z.object({ provider: z.enum(['AMANPAY', 'PAYPAL']), type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'), currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'), successUrl: z.string().url(), failureUrl: z.string().url(), }) const manualPaymentSchema = z.object({ amount: z.number().int().positive(), currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'), type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'), paymentMethod: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL']), }) router.get('/company', async (req, res, next) => { try { const payments = await prisma.rentalPayment.findMany({ where: { companyId: req.companyId }, include: { reservation: { include: { customer: true, vehicle: true, }, }, }, orderBy: { createdAt: 'desc' }, take: 100, }) res.json({ data: payments }) } catch (err) { next(err) } }) router.get('/reservations/:id', async (req, res, next) => { try { const payments = await prisma.rentalPayment.findMany({ where: { reservationId: req.params.id, companyId: req.companyId }, orderBy: { createdAt: 'desc' }, }) res.json({ data: payments }) } catch (err) { next(err) } }) router.post('/reservations/:id/charge', async (req, res, next) => { try { const { provider, type, currency, successUrl, failureUrl } = chargeSchema.parse(req.body) const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, include: { vehicle: true, customer: true }, }) if (reservation.paymentStatus === 'PAID') { return res.status(409).json({ error: 'already_paid', message: 'Reservation is already fully paid', statusCode: 409 }) } const amount = type === 'DEPOSIT' ? reservation.depositAmount : reservation.totalAmount const description = `${type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}` const orderId = `${reservation.id}-${type}-${Date.now()}` const webhookBase = process.env.API_URL ?? 'http://localhost:4000' let checkoutUrl: string let amanpayTransactionId: string | null = null let paypalCaptureId: string | null = null if (provider === 'AMANPAY') { if (!amanpay.isConfigured()) { return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured', statusCode: 503 }) } const result = await amanpay.createCheckout({ amount, currency, orderId, description, customerEmail: reservation.customer.email, customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`, successUrl, failureUrl, webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`, }) checkoutUrl = result.checkoutUrl amanpayTransactionId = result.transactionId } else { if (!paypal.isConfigured()) { return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not configured', statusCode: 503 }) } const result = await paypal.createOrder({ amount, currency, orderId, description, returnUrl: successUrl, cancelUrl: failureUrl, }) checkoutUrl = result.approveUrl paypalCaptureId = result.orderId } const payment = await prisma.rentalPayment.create({ data: { companyId: req.companyId, reservationId: reservation.id, amount, currency, status: 'PENDING', type, paymentProvider: provider, amanpayTransactionId, paypalCaptureId, }, }) res.json({ data: { payment, checkoutUrl } }) } catch (err) { next(err) } }) router.post('/reservations/:id/capture-paypal', async (req, res, next) => { try { const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body) const payment = await prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId: req.companyId }, }) const capture = await paypal.captureOrder(paypalOrderId) as Record const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId const updated = await prisma.rentalPayment.update({ where: { id: payment.id }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId }, }) await prisma.reservation.update({ where: { id: payment.reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } }, }) res.json({ data: updated }) } catch (err) { next(err) } }) router.post('/reservations/:id/manual', async (req, res, next) => { try { const { amount, currency, type, paymentMethod } = manualPaymentSchema.parse(req.body) const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, }) const remainingBalance = Math.max(reservation.totalAmount - reservation.paidAmount, 0) if (remainingBalance <= 0) { return res.status(409).json({ error: 'already_paid', message: 'Reservation is already fully paid', statusCode: 409 }) } if (amount > remainingBalance) { return res.status(400).json({ error: 'amount_exceeds_balance', message: 'Payment amount exceeds remaining balance', statusCode: 400 }) } const payment = await prisma.rentalPayment.create({ data: { companyId: req.companyId, reservationId: reservation.id, amount, currency, status: 'SUCCEEDED', type, paymentProvider: paymentMethod === 'PAYPAL' ? 'PAYPAL' : 'AMANPAY', paymentMethod, paidAt: new Date(), }, }) const newPaidAmount = reservation.paidAmount + amount const paymentStatus = newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL' await prisma.reservation.update({ where: { id: reservation.id }, data: { paidAmount: newPaidAmount, paymentStatus, }, }) res.json({ data: payment }) } catch (err) { next(err) } }) router.post('/reservations/:reservationId/payments/:paymentId/refund', async (req, res, next) => { try { const { amount, reason } = z.object({ amount: z.number().int().positive().optional(), reason: z.string().optional(), }).parse(req.body) const payment = await prisma.rentalPayment.findFirstOrThrow({ where: { id: req.params.paymentId, companyId: req.companyId, reservationId: req.params.reservationId }, }) if (payment.status !== 'SUCCEEDED') { return res.status(400).json({ error: 'not_capturable', message: 'Only succeeded payments can be refunded', statusCode: 400 }) } if (!payment.amanpayTransactionId && !payment.paypalCaptureId) { return res.status(400).json({ error: 'manual_refund_unsupported', message: 'Manual payments must be refunded outside the online gateway flow', statusCode: 400 }) } const refundAmount = amount ?? payment.amount if (payment.paymentProvider === 'AMANPAY') { if (!payment.amanpayTransactionId) throw new Error('No AmanPay transaction ID') await amanpay.refundTransaction(payment.amanpayTransactionId, refundAmount, reason) } else { if (!payment.paypalCaptureId) throw new Error('No PayPal capture ID') await paypal.refundCapture(payment.paypalCaptureId, refundAmount, payment.currency, reason) } const isPartial = refundAmount < payment.amount const updated = await prisma.rentalPayment.update({ where: { id: payment.id }, data: { status: isPartial ? 'PARTIALLY_REFUNDED' : 'REFUNDED' }, }) if (!isPartial) { await prisma.reservation.update({ where: { id: payment.reservationId }, data: { paymentStatus: 'REFUNDED' }, }) } res.json({ data: updated }) } catch (err) { next(err) } }) export default router