327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
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<string, string>,
|
|
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<string, any>
|
|
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
|