refractor code,
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export function findByCompany(companyId: string) {
|
||||
return prisma.rentalPayment.findMany({
|
||||
where: { companyId },
|
||||
include: { reservation: { include: { customer: true, vehicle: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
})
|
||||
}
|
||||
|
||||
export function findByReservation(reservationId: string, companyId: string) {
|
||||
return prisma.rentalPayment.findMany({ where: { reservationId, companyId }, orderBy: { createdAt: 'desc' } })
|
||||
}
|
||||
|
||||
export function findByAmanpay(transactionId: string) {
|
||||
return prisma.rentalPayment.findFirst({ where: { amanpayTransactionId: transactionId } })
|
||||
}
|
||||
|
||||
export function findByPaypal(captureId: string) {
|
||||
return prisma.rentalPayment.findFirst({ where: { paypalCaptureId: captureId } })
|
||||
}
|
||||
|
||||
export function findByPaypalForCompany(paypalOrderId: string, companyId: string) {
|
||||
return prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId } })
|
||||
}
|
||||
|
||||
export function findPaymentOrThrow(paymentId: string, companyId: string, reservationId: string) {
|
||||
return prisma.rentalPayment.findFirstOrThrow({ where: { id: paymentId, companyId, reservationId } })
|
||||
}
|
||||
|
||||
export function findReservationOrThrow(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({
|
||||
where: { id, companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findReservation(id: string, companyId: string) {
|
||||
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export function markPaymentSucceeded(id: string) {
|
||||
return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date() } })
|
||||
}
|
||||
|
||||
export function markPaymentFailed(query: { amanpayTransactionId?: string; paypalCaptureId?: string }) {
|
||||
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 createPayment(data: {
|
||||
companyId: string; reservationId: string; amount: number; currency: string
|
||||
status: string; type: string; paymentProvider: string
|
||||
amanpayTransactionId?: string | null; paypalCaptureId?: string | null; paymentMethod?: string; paidAt?: Date
|
||||
}) {
|
||||
return prisma.rentalPayment.create({ data: data as any })
|
||||
}
|
||||
|
||||
export function updatePaypalCapture(id: string, captureId: string) {
|
||||
return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } })
|
||||
}
|
||||
|
||||
export function setReservationPaidAmount(reservationId: string, paidAmount: number, paymentStatus: string) {
|
||||
return prisma.reservation.update({ where: { id: reservationId }, data: { paidAmount, paymentStatus: paymentStatus as any } })
|
||||
}
|
||||
|
||||
export function setReservationRefunded(reservationId: string) {
|
||||
return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'REFUNDED' } })
|
||||
}
|
||||
|
||||
export function setPaymentRefunded(id: string, partial: boolean) {
|
||||
return prisma.rentalPayment.update({ where: { id }, data: { status: partial ? 'PARTIALLY_REFUNDED' : 'REFUNDED' } })
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody, parseParams } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as service from './payment.service'
|
||||
import { chargeSchema, manualPaymentSchema, refundSchema, capturePaypalSchema, reservationParamSchema, paymentParamSchema } from './payment.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── Webhooks (no auth) ────────────────────────────────────────
|
||||
|
||||
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' })
|
||||
}
|
||||
await service.handleAmanpayWebhook(req.body)
|
||||
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' })
|
||||
await service.handlePaypalWebhook(req.body)
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Authenticated ────────────────────────────────────────────
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/company', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.listByCompany(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/reservations/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
ok(res, { data: await service.listByReservation(id, req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/charge', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
const body = parseBody(chargeSchema, req)
|
||||
ok(res, { data: await service.initCharge(id, req.companyId, body) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
const { paypalOrderId } = parseBody(capturePaypalSchema, req)
|
||||
ok(res, { data: await service.capturePaypal(id, req.companyId, paypalOrderId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/manual', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(reservationParamSchema, req)
|
||||
const body = parseBody(manualPaymentSchema, req)
|
||||
ok(res, { data: await service.recordManualPayment(id, req.companyId, body) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:reservationId/payments/:paymentId/refund', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { reservationId, paymentId } = parseParams(paymentParamSchema, req)
|
||||
const { amount, reason } = parseBody(refundSchema, req)
|
||||
ok(res, { data: await service.refundPayment(reservationId, paymentId, req.companyId, amount, reason) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export 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(),
|
||||
})
|
||||
|
||||
export 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']),
|
||||
})
|
||||
|
||||
export const refundSchema = z.object({
|
||||
amount: z.number().int().positive().optional(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export const capturePaypalSchema = z.object({
|
||||
paypalOrderId: z.string(),
|
||||
})
|
||||
|
||||
export const reservationParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
export const paymentParamSchema = z.object({
|
||||
reservationId: z.string().min(1),
|
||||
paymentId: z.string().min(1),
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ConflictError, ValidationError } from '../../http/errors'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as repo from './payment.repo'
|
||||
|
||||
export function listByCompany(companyId: string) {
|
||||
return repo.findByCompany(companyId)
|
||||
}
|
||||
|
||||
export function listByReservation(reservationId: string, companyId: string) {
|
||||
return repo.findByReservation(reservationId, companyId)
|
||||
}
|
||||
|
||||
export async function handleAmanpayWebhook(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) {
|
||||
await repo.markPaymentSucceeded(payment.id)
|
||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||
}
|
||||
} else if (status === 'FAILED') {
|
||||
await repo.markPaymentFailed({ amanpayTransactionId: transactionId })
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePaypalWebhook(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) {
|
||||
await repo.markPaymentSucceeded(payment.id)
|
||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||
}
|
||||
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
|
||||
await repo.markPaymentFailed({ paypalCaptureId: event.resource?.id })
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCharge(reservationId: string, companyId: string, body: {
|
||||
provider: 'AMANPAY' | 'PAYPAL'; type: 'CHARGE' | 'DEPOSIT'
|
||||
currency: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
|
||||
if (reservation.paymentStatus === 'PAID') throw new ConflictError('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'
|
||||
|
||||
let checkoutUrl: string
|
||||
let amanpayTransactionId: string | null = null
|
||||
let paypalCaptureId: string | null = null
|
||||
|
||||
if (body.provider === 'AMANPAY') {
|
||||
if (!amanpay.isConfigured()) throw new ValidationError('AmanPay is not configured')
|
||||
const result = await amanpay.createCheckout({
|
||||
amount, currency: body.currency, orderId, description,
|
||||
customerEmail: reservation.customer.email,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
successUrl: body.successUrl, failureUrl: body.failureUrl,
|
||||
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
amanpayTransactionId = result.transactionId
|
||||
} else {
|
||||
if (!paypal.isConfigured()) throw new ValidationError('PayPal is not configured')
|
||||
const result = await paypal.createOrder({ amount, currency: body.currency, orderId, description, returnUrl: body.successUrl, cancelUrl: body.failureUrl })
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
|
||||
const payment = await repo.createPayment({ companyId, reservationId, amount, currency: body.currency, status: 'PENDING', type: body.type, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId })
|
||||
return { payment, checkoutUrl }
|
||||
}
|
||||
|
||||
export async function capturePaypal(reservationId: string, companyId: string, paypalOrderId: string) {
|
||||
const payment = await repo.findByPaypalForCompany(paypalOrderId, 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 repo.updatePaypalCapture(payment.id, captureId)
|
||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function recordManualPayment(reservationId: string, companyId: string, body: {
|
||||
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 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')
|
||||
return payment
|
||||
}
|
||||
|
||||
export async function refundPayment(reservationId: string, paymentId: string, companyId: string, amount?: number, reason?: string) {
|
||||
const payment = await repo.findPaymentOrThrow(paymentId, companyId, reservationId)
|
||||
if (payment.status !== 'SUCCEEDED') throw new ValidationError('Only succeeded payments can be refunded')
|
||||
if (!payment.amanpayTransactionId && !payment.paypalCaptureId) throw new ValidationError('Manual payments must be refunded outside the online gateway flow')
|
||||
|
||||
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 repo.setPaymentRefunded(payment.id, isPartial)
|
||||
if (!isPartial) await repo.setReservationRefunded(payment.reservationId)
|
||||
return updated
|
||||
}
|
||||
Reference in New Issue
Block a user