fe8ffbeb9f
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 48s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
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
|
|
}
|