fix payment customer and dashboard settings
Build & Deploy / Build & Push Docker Image (push) Successful in 12m39s
Test / Type Check (all packages) (push) Successful in 5m8s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 4m24s
Test / Homepage Unit Tests (push) Successful in 3m27s
Test / Storefront Unit Tests (push) Successful in 4m48s
Test / Admin Unit Tests (push) Successful in 3m0s
Test / Dashboard Unit Tests (push) Successful in 4m18s
Test / API Integration Tests (push) Failing after 4m34s
Build & Deploy / Build & Push Docker Image (push) Successful in 12m39s
Test / Type Check (all packages) (push) Successful in 5m8s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 4m24s
Test / Homepage Unit Tests (push) Successful in 3m27s
Test / Storefront Unit Tests (push) Successful in 4m48s
Test / Admin Unit Tests (push) Successful in 3m0s
Test / Dashboard Unit Tests (push) Successful in 4m18s
Test / API Integration Tests (push) Failing after 4m34s
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
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: 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' | 'OTHER'
|
||||
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: payment.paymentProvider === 'MANUAL' ? 'OFFLINE' : 'ONLINE',
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user