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) } export function listByReservation(reservationId: string, companyId: string) { return repo.findByReservation(reservationId, companyId) } function sumSucceededPayments(payments: any[], type: 'CHARGE' | 'DEPOSIT') { return payments.reduce((total: number, payment: any) => { if (payment.type !== type || payment.status !== 'SUCCEEDED') return total return total + payment.amount }, 0) } function getInvoicePaid(reservation: any, rentalPayments: any[]) { return Math.max(sumSucceededPayments(rentalPayments, 'CHARGE'), reservation.paidAmount ?? 0) } 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 && payment.status !== 'SUCCEEDED') { await repo.markPaymentSucceeded(payment.id) if (payment.type === 'CHARGE') { await repo.incrementReservationPaid(payment.reservationId, payment.amount) } } } else if (status === 'FAILED') { await repo.markPaymentFailed({ amanpayTransactionId: transactionId }) } } 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 && payment.status !== 'SUCCEEDED') { await repo.markPaymentSucceeded(payment.id) if (payment.type === 'CHARGE') { await repo.incrementReservationPaid(payment.reservationId, payment.amount) } } } else if (eventType === 'PAYMENT.CAPTURE.DENIED') { await repo.markPaymentFailed({ paypalCaptureId: event.resource?.id }) } } 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 }) { const reservation = await repo.findReservationOrThrow(reservationId, companyId) const rentalPayments = (reservation as any).rentalPayments ?? [] const invoicePaid = getInvoicePaid(reservation, rentalPayments) const depositCollected = sumSucceededPayments(rentalPayments, 'DEPOSIT') const balanceDue = body.type === 'DEPOSIT' ? reservation.depositAmount - depositCollected : reservation.totalAmount - invoicePaid if (balanceDue <= 0) { throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid') } const amount = balanceDue 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 const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId const updated = await repo.updatePaypalCapture(payment.id, captureId) if (payment.type === 'CHARGE') { 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 rentalPayments = (reservation as any).rentalPayments ?? [] const invoicePaid = getInvoicePaid(reservation, rentalPayments) const depositCollected = sumSucceededPayments(rentalPayments, 'DEPOSIT') const remaining = body.type === 'DEPOSIT' ? reservation.depositAmount - depositCollected : reservation.totalAmount - invoicePaid if (remaining <= 0) { throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid') } if (body.amount <= 0) { throw new ValidationError('Payment amount must be greater than zero') } 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 } 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 }