import { ConflictError, NotFoundError, ValidationError } from '../../http/errors' import { prisma } from '../../lib/prisma' type BillingPayment = { id: string reservationId: string amount: number currency: string status: string type: string paymentProvider: string paymentMethod: string | null reference?: string | null note?: string | null receivedAt?: Date | null paidAt: Date | null createdAt: Date recordedByEmployee?: { firstName: string; lastName: string; email: string } | null } type BillingReservation = { id: string invoiceNumber: string | null contractNumber: string | null status: string paymentStatus: string startDate: Date endDate: Date totalAmount: number depositAmount: number customer: { firstName: string; lastName: string; email: string } vehicle: { make: string; model: string; licensePlate: string } rentalPayments: BillingPayment[] } type ListQuery = { page: number pageSize: number search: string paymentStatus: 'ALL' | 'UNPAID' | 'PARTIAL' | 'PAID' outstandingOnly: boolean } type SummaryQuery = { search: string } type ManualPaymentInput = { amountMinor: number currency: 'MAD' type: 'CHARGE' | 'DEPOSIT' method: 'CHECK' | 'BANK_TRANSFER' receivedAt?: string reference?: string note?: string idempotencyKey: string } const BILLING_CURRENCY = 'MAD' const COLLECTED_STATUS = new Set(['SUCCEEDED']) function sumCollected(payments: BillingPayment[], type: 'CHARGE' | 'DEPOSIT') { return payments.reduce((total, payment) => { if (payment.type !== type || !COLLECTED_STATUS.has(payment.status)) return total return total + payment.amount }, 0) } function derivePaymentStatus(invoiceBalanceDue: number, invoicePaid: number) { if (invoiceBalanceDue <= 0) return 'PAID' if (invoicePaid > 0) return 'PARTIAL' return 'UNPAID' } function deriveDepositStatus(depositRequired: number, depositCollected: number) { if (depositRequired <= 0) return 'NOT_REQUIRED' if (depositCollected <= 0) return 'OUTSTANDING' if (depositCollected < depositRequired) return 'PARTIALLY_COLLECTED' return 'HELD' } export function buildBillingInvoice(reservation: BillingReservation) { const invoiceTotal = reservation.totalAmount const invoicePaid = sumCollected(reservation.rentalPayments, 'CHARGE') const invoiceRefunded = 0 const invoiceBalanceDue = Math.max(invoiceTotal - invoicePaid, 0) const depositRequired = reservation.depositAmount const depositCollected = sumCollected(reservation.rentalPayments, 'DEPOSIT') const depositRefunded = 0 const depositHeld = Math.max(depositCollected - depositRefunded, 0) const depositOutstanding = Math.max(depositRequired - depositCollected, 0) const payments = [...reservation.rentalPayments] .sort((a, b) => new Date(b.paidAt ?? b.receivedAt ?? b.createdAt).getTime() - new Date(a.paidAt ?? a.receivedAt ?? a.createdAt).getTime()) .map((payment) => ({ id: payment.id, reservationId: payment.reservationId, amountMinor: payment.amount, currency: payment.currency, type: payment.type, channel: 'OFFLINE', provider: payment.paymentProvider, method: payment.paymentMethod, status: payment.status, reference: payment.reference ?? null, note: payment.note ?? null, receivedAt: payment.receivedAt?.toISOString() ?? payment.paidAt?.toISOString() ?? payment.createdAt.toISOString(), paidAt: payment.paidAt?.toISOString() ?? null, createdAt: payment.createdAt.toISOString(), recordedBy: payment.recordedByEmployee ? { name: `${payment.recordedByEmployee.firstName} ${payment.recordedByEmployee.lastName}`, email: payment.recordedByEmployee.email, } : null, refundedAmountMinor: 0, })) return { id: reservation.id, reservationId: reservation.id, invoiceNumber: reservation.invoiceNumber, contractNumber: reservation.contractNumber, customer: reservation.customer, vehicle: reservation.vehicle, rentalPeriod: { startDate: reservation.startDate.toISOString(), endDate: reservation.endDate.toISOString(), }, currency: BILLING_CURRENCY, status: reservation.status, paymentStatus: derivePaymentStatus(invoiceBalanceDue, invoicePaid), issuedAt: null, dueAt: reservation.startDate.toISOString(), subtotal: invoiceTotal, taxTotal: 0, discountTotal: 0, adjustmentTotal: 0, invoiceTotal, invoicePaid, invoiceRefunded, invoiceBalanceDue, depositRequired, depositCollected, depositRefunded, depositHeld, depositOutstanding, depositStatus: deriveDepositStatus(depositRequired, depositCollected), paymentCount: payments.length, latestPayment: payments[0] ?? null, payments, } } function buildReservationWhere(companyId: string, search = '') { const trimmedSearch = search.trim() const where: any = { companyId } if (trimmedSearch) { where.OR = [ { invoiceNumber: { contains: trimmedSearch, mode: 'insensitive' } }, { contractNumber: { contains: trimmedSearch, mode: 'insensitive' } }, { customer: { firstName: { contains: trimmedSearch, mode: 'insensitive' } } }, { customer: { lastName: { contains: trimmedSearch, mode: 'insensitive' } } }, { customer: { email: { contains: trimmedSearch, mode: 'insensitive' } } }, { vehicle: { make: { contains: trimmedSearch, mode: 'insensitive' } } }, { vehicle: { model: { contains: trimmedSearch, mode: 'insensitive' } } }, { vehicle: { licensePlate: { contains: trimmedSearch, mode: 'insensitive' } } }, ] } return where } const reservationInclude = { customer: true, vehicle: true, rentalPayments: { include: { recordedByEmployee: { select: { firstName: true, lastName: true, email: true }, }, }, orderBy: { createdAt: 'desc' }, }, } as const export async function listInvoices(companyId: string, query: ListQuery) { const where = buildReservationWhere(companyId, query.search) const reservations = await prisma.reservation.findMany({ where, include: reservationInclude as any, orderBy: { createdAt: 'desc' }, }) let items = reservations.map((reservation) => buildBillingInvoice(reservation as any)) if (query.paymentStatus !== 'ALL') { items = items.filter((invoice) => invoice.paymentStatus === query.paymentStatus) } if (query.outstandingOnly) { items = items.filter((invoice) => invoice.invoiceBalanceDue > 0 || invoice.depositOutstanding > 0) } const totalRecords = items.length const pagedItems = items.slice((query.page - 1) * query.pageSize, query.page * query.pageSize) return { items: pagedItems, page: query.page, pageSize: query.pageSize, totalItems: totalRecords, totalPages: Math.max(Math.ceil(totalRecords / query.pageSize), 1), } } export async function getSummary(companyId: string, query: SummaryQuery) { const reservations = await prisma.reservation.findMany({ where: buildReservationWhere(companyId, query.search), include: reservationInclude as any, }) const totals = reservations.reduce( (acc, reservation) => { const invoice = buildBillingInvoice(reservation as any) acc.totalInvoiced += invoice.invoiceTotal acc.totalCollected += invoice.invoicePaid acc.totalRefunded += invoice.invoiceRefunded acc.totalOutstanding += invoice.invoiceBalanceDue acc.depositsHeld += invoice.depositHeld if (invoice.invoiceBalanceDue > 0) acc.openInvoiceCount += 1 return acc }, { currency: BILLING_CURRENCY, totalInvoiced: 0, totalCollected: 0, totalRefunded: 0, totalOutstanding: 0, depositsHeld: 0, openInvoiceCount: 0, overdueInvoiceCount: 0, }, ) return totals } export async function getInvoice(companyId: string, invoiceId: string) { const reservation = await prisma.reservation.findFirst({ where: { id: invoiceId, companyId }, include: reservationInclude as any, }) if (!reservation) throw new NotFoundError('Billing invoice not found') return buildBillingInvoice(reservation as any) } export async function recordManualPayment(companyId: string, employeeId: string, invoiceId: string, body: ManualPaymentInput) { const result = await prisma.$transaction(async (tx: any) => { const existingPayment = await tx.rentalPayment.findFirst({ where: { companyId, idempotencyKey: body.idempotencyKey }, }) if (existingPayment) { return existingPayment } const reservation = await tx.reservation.findFirst({ where: { id: invoiceId, companyId }, include: reservationInclude as any, }) if (!reservation) throw new NotFoundError('Billing invoice not found') const invoice = buildBillingInvoice(reservation) if (body.currency !== invoice.currency) { throw new ValidationError('Payment currency must match the invoice currency') } const permittedAmount = body.type === 'DEPOSIT' ? invoice.depositOutstanding : invoice.invoiceBalanceDue if (permittedAmount <= 0) { throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Invoice is already fully paid') } if (body.amountMinor > permittedAmount) { throw new ValidationError(body.type === 'DEPOSIT' ? 'Payment amount exceeds deposit outstanding' : 'Payment amount exceeds invoice balance due') } const receivedAt = body.receivedAt ? new Date(body.receivedAt) : new Date() const payment = await tx.rentalPayment.create({ data: { companyId, reservationId: reservation.id, amount: body.amountMinor, currency: body.currency, status: 'SUCCEEDED', type: body.type, paymentProvider: 'MANUAL', paymentMethod: body.method, reference: body.reference, note: body.note, receivedAt, paidAt: receivedAt, recordedByEmployeeId: employeeId, idempotencyKey: body.idempotencyKey, }, }) if (body.type === 'CHARGE') { const paidAmount = invoice.invoicePaid + body.amountMinor await tx.reservation.update({ where: { id: reservation.id }, data: { paidAmount, paymentStatus: paidAmount >= invoice.invoiceTotal ? 'PAID' : 'PARTIAL', }, }) } return payment }) return result }