Files
carmanagement/apps/api/src/modules/billing/billing.service.ts
T
root 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
update payment method, remove stripe, paypal, amanapay
2026-08-17 21:05:29 -04:00

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
}