fix notification and add billing page and contract

This commit is contained in:
root
2026-05-13 00:09:39 -04:00
committed by Administrator
parent 1a39aa8433
commit 89621a163b
52 changed files with 5631 additions and 1110 deletions
+4 -2
View File
@@ -4,13 +4,15 @@ import type { Request } from 'express'
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing.
// Successful requests (e.g. GET /me profile reads) are skipped so only failed
// attempts count toward the cap.
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
standardHeaders: 'draft-7',
legacyHeaders: false,
skipSuccessfulRequests: false,
skipSuccessfulRequests: true,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
})
+11 -2
View File
@@ -61,8 +61,17 @@ router.get('/:id', async (req, res, next) => {
router.patch('/:id', async (req, res, next) => {
try {
const { vehicleIds, ...body } = offerSchema.partial().parse(req.body)
const offer = await prisma.offer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { ...body, validFrom: body.validFrom ? new Date(body.validFrom) : undefined, validUntil: body.validUntil ? new Date(body.validUntil) : undefined } })
if (offer.count === 0) return res.status(404).json({ error: 'not_found', message: 'Offer not found', statusCode: 404 })
const existing = await prisma.offer.findFirst({ where: { id: req.params.id, companyId: req.companyId } })
if (!existing) return res.status(404).json({ error: 'not_found', message: 'Offer not found', statusCode: 404 })
await prisma.$transaction(async (tx) => {
await tx.offer.update({ where: { id: req.params.id }, data: { ...body, validFrom: body.validFrom ? new Date(body.validFrom) : undefined, validUntil: body.validUntil ? new Date(body.validUntil) : undefined } })
if (vehicleIds !== undefined) {
await tx.offerVehicle.deleteMany({ where: { offerId: req.params.id } })
if (vehicleIds.length > 0) {
await tx.offerVehicle.createMany({ data: vehicleIds.map((vehicleId) => ({ offerId: req.params.id, vehicleId })) })
}
}
})
const updated = await prisma.offer.findUniqueOrThrow({ where: { id: req.params.id }, include: { vehicles: true } })
res.json({ data: updated })
} catch (err) { next(err) }
+19
View File
@@ -99,6 +99,25 @@ const chargeSchema = z.object({
failureUrl: z.string().url(),
})
router.get('/company', async (req, res, next) => {
try {
const payments = await prisma.rentalPayment.findMany({
where: { companyId: req.companyId },
include: {
reservation: {
include: {
customer: true,
vehicle: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 100,
})
res.json({ data: payments })
} catch (err) { next(err) }
})
router.get('/reservations/:id', async (req, res, next) => {
try {
const payments = await prisma.rentalPayment.findMany({
+287
View File
@@ -56,11 +56,122 @@ const createSchema = z.object({
offerId: z.string().cuid().optional(),
promoCodeUsed: z.string().optional(),
depositAmount: z.number().int().min(0).default(0),
paymentMode: z.string().max(50).optional(),
notes: z.string().optional(),
selectedInsurancePolicyIds: z.array(z.string()).default([]),
additionalDrivers: z.array(additionalDriverSchema).default([]),
})
function formatDocumentNumber(prefix: string, sequence: number) {
return `${prefix}-${String(sequence).padStart(6, '0')}`
}
async function ensureReservationDocumentNumbers(companyId: string, reservationId: string) {
return prisma.$transaction(async (tx) => {
const reservation = await tx.reservation.findFirstOrThrow({
where: { id: reservationId, companyId },
select: { id: true, contractNumber: true, invoiceNumber: true },
})
if (reservation.contractNumber && reservation.invoiceNumber) return reservation
const settings = await tx.contractSettings.upsert({
where: { companyId },
update: {},
create: { companyId },
})
const data: { contractNumber?: string; invoiceNumber?: string } = {}
const settingsUpdate: { lastContractSeq?: number; lastInvoiceSeq?: number } = {}
if (!reservation.contractNumber) {
const nextContractSeq = settings.lastContractSeq + 1
data.contractNumber = formatDocumentNumber(settings.contractPrefix, nextContractSeq)
settingsUpdate.lastContractSeq = nextContractSeq
}
if (!reservation.invoiceNumber) {
const nextInvoiceSeq = settings.lastInvoiceSeq + 1
data.invoiceNumber = formatDocumentNumber(settings.invoicePrefix, nextInvoiceSeq)
settingsUpdate.lastInvoiceSeq = nextInvoiceSeq
}
if (Object.keys(settingsUpdate).length > 0) {
await tx.contractSettings.update({
where: { companyId },
data: settingsUpdate,
})
}
return tx.reservation.update({
where: { id: reservationId },
data,
select: { id: true, contractNumber: true, invoiceNumber: true },
})
})
}
function buildReservationInvoiceLineItems(reservation: {
dailyRate: number
totalDays: number
discountAmount: number
depositAmount: number
pricingRulesApplied: Array<{ name?: string; amount?: number; type?: string }> | null
insurances: Array<{ id: string; policyName: string; chargeType: string; chargeValue: number; totalCharge: number }>
additionalDrivers: Array<{ id: string; firstName: string; lastName: string; totalCharge: number }>
vehicle: { year: number; make: string; model: string }
}) {
const baseAmount = reservation.dailyRate * reservation.totalDays
const lineItems = [
{
description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`,
qty: reservation.totalDays,
unitPrice: reservation.dailyRate,
total: baseAmount,
category: 'RENTAL',
},
...reservation.insurances.map((insurance) => ({
description: insurance.policyName,
qty: insurance.chargeType === 'PER_DAY' ? reservation.totalDays : 1,
unitPrice: insurance.chargeType === 'PER_DAY' ? insurance.chargeValue : insurance.totalCharge,
total: insurance.totalCharge,
category: 'INSURANCE',
})),
...reservation.additionalDrivers
.filter((driver) => driver.totalCharge > 0)
.map((driver) => ({
description: `Additional driver - ${driver.firstName} ${driver.lastName}`,
qty: 1,
unitPrice: driver.totalCharge,
total: driver.totalCharge,
category: 'ADDITIONAL_DRIVER',
})),
...((reservation.pricingRulesApplied ?? []).map((rule, index) => ({
description: rule.name?.trim() || `Pricing adjustment ${index + 1}`,
qty: 1,
unitPrice: Number(rule.amount ?? 0),
total: Number(rule.amount ?? 0),
category: 'PRICING_RULE',
}))),
...(reservation.discountAmount > 0 ? [{
description: 'Discount',
qty: 1,
unitPrice: -reservation.discountAmount,
total: -reservation.discountAmount,
category: 'DISCOUNT',
}] : []),
...(reservation.depositAmount > 0 ? [{
description: 'Security deposit',
qty: 1,
unitPrice: reservation.depositAmount,
total: reservation.depositAmount,
category: 'DEPOSIT',
}] : []),
]
return lineItems
}
async function assertReservationLicenseCompliance(reservationId: string, companyId: string) {
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: reservationId, companyId },
@@ -178,6 +289,7 @@ router.post('/', async (req, res, next) => {
totalAmount,
depositAmount: body.depositAmount,
notes: body.notes ?? null,
extras: body.paymentMode ? { paymentMode: body.paymentMode } : undefined,
pricingRulesApplied: applied,
pricingRulesTotal: pricingTotal,
},
@@ -209,6 +321,181 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.get('/:id/contract', async (req, res, next) => {
try {
await ensureReservationDocumentNumbers(req.companyId, req.params.id)
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: req.companyId },
include: {
vehicle: true,
customer: true,
insurances: true,
additionalDrivers: true,
rentalPayments: { orderBy: { createdAt: 'asc' } },
inspections: { include: { damagePoints: true }, orderBy: { inspectedAt: 'asc' } },
company: {
include: {
brand: true,
contractSettings: true,
accountingSettings: true,
},
},
},
})
const contractSettings = reservation.company.contractSettings ?? await prisma.contractSettings.upsert({
where: { companyId: req.companyId },
update: {},
create: { companyId: req.companyId },
})
const paymentMode = reservation.extras && typeof reservation.extras === 'object' && !Array.isArray(reservation.extras)
? String((reservation.extras as Record<string, unknown>).paymentMode ?? '')
: ''
const invoiceLineItems = buildReservationInvoiceLineItems({
dailyRate: reservation.dailyRate,
totalDays: reservation.totalDays,
discountAmount: reservation.discountAmount,
depositAmount: reservation.depositAmount,
pricingRulesApplied: Array.isArray(reservation.pricingRulesApplied) ? reservation.pricingRulesApplied as Array<{ name?: string; amount?: number; type?: string }> : [],
insurances: reservation.insurances,
additionalDrivers: reservation.additionalDrivers,
vehicle: reservation.vehicle,
})
const subtotal = invoiceLineItems.reduce((sum, item) => sum + item.total, 0)
const taxes = contractSettings.showTax && contractSettings.taxRate
? [{
label: contractSettings.taxLabel?.trim() || 'Tax',
rate: contractSettings.taxRate,
amount: Math.round(subtotal * contractSettings.taxRate / 100),
}]
: []
const taxTotal = taxes.reduce((sum, tax) => sum + tax.amount, 0)
const grandTotal = subtotal + taxTotal
const amountPaid = reservation.rentalPayments
.filter((payment) => payment.status === 'SUCCEEDED')
.reduce((sum, payment) => sum + payment.amount, 0)
const checkInInspection = reservation.inspections.find((inspection) => inspection.type === 'CHECKIN') ?? null
const checkOutInspection = reservation.inspections.find((inspection) => inspection.type === 'CHECKOUT') ?? null
res.json({
data: {
reservationId: reservation.id,
contractNumber: reservation.contractNumber,
invoiceNumber: reservation.invoiceNumber,
generatedAt: new Date().toISOString(),
status: reservation.status,
paymentStatus: reservation.paymentStatus,
paymentMode: paymentMode || null,
notes: reservation.notes,
company: {
name: reservation.company.brand?.displayName ?? reservation.company.name,
legalName: contractSettings.legalName || reservation.company.name,
email: reservation.company.brand?.publicEmail ?? reservation.company.email,
phone: reservation.company.brand?.publicPhone ?? reservation.company.phone,
address: reservation.company.brand?.publicAddress ?? reservation.company.address ?? null,
city: reservation.company.brand?.publicCity ?? null,
country: reservation.company.brand?.publicCountry ?? null,
registrationNumber: contractSettings.registrationNumber,
taxId: contractSettings.taxId,
logoUrl: reservation.company.brand?.logoUrl ?? null,
},
driver: {
firstName: reservation.customer.firstName,
lastName: reservation.customer.lastName,
email: reservation.customer.email,
phone: reservation.customer.phone,
dateOfBirth: reservation.customer.dateOfBirth,
driverLicense: reservation.customer.driverLicense,
licenseCountry: reservation.customer.licenseCountry,
licenseCategory: reservation.customer.licenseCategory,
licenseIssuedAt: reservation.customer.licenseIssuedAt,
licenseExpiry: reservation.customer.licenseExpiry,
nationality: reservation.customer.nationality,
},
additionalDrivers: reservation.additionalDrivers.map((driver) => ({
id: driver.id,
firstName: driver.firstName,
lastName: driver.lastName,
email: driver.email,
phone: driver.phone,
driverLicense: driver.driverLicense,
licenseIssuedAt: driver.licenseIssuedAt,
licenseExpiry: driver.licenseExpiry,
licenseCountry: driver.nationality,
dateOfBirth: driver.dateOfBirth,
totalCharge: driver.totalCharge,
})),
vehicle: {
make: reservation.vehicle.make,
model: reservation.vehicle.model,
year: reservation.vehicle.year,
color: reservation.vehicle.color,
licensePlate: reservation.vehicle.licensePlate,
vin: reservation.vehicle.vin,
category: reservation.vehicle.category,
},
rentalPeriod: {
startDate: reservation.startDate,
endDate: reservation.endDate,
totalDays: reservation.totalDays,
pickupLocation: reservation.pickupLocation,
returnLocation: reservation.returnLocation,
},
insurance: {
total: reservation.insuranceTotal,
policies: reservation.insurances.map((insurance) => ({
id: insurance.id,
name: insurance.policyName,
chargeType: insurance.chargeType,
unitPrice: insurance.chargeValue,
totalCharge: insurance.totalCharge,
})),
},
inspections: {
checkIn: checkInInspection,
checkOut: checkOutInspection,
},
terms: {
terms: contractSettings.terms,
fuelPolicy: contractSettings.fuelPolicy,
fuelPolicyType: contractSettings.fuelPolicyType,
depositPolicy: contractSettings.depositPolicy,
lateFeePolicy: contractSettings.lateFeePolicy,
damagePolicy: contractSettings.damagePolicy,
footerNote: contractSettings.contractFooterNote,
signatureRequired: contractSettings.signatureRequired,
},
invoice: {
currency: reservation.company.accountingSettings?.currency ?? reservation.company.brand?.defaultCurrency ?? 'MAD',
lineItems: invoiceLineItems,
subtotal,
taxes,
taxTotal,
total: grandTotal,
amountPaid,
balanceDue: grandTotal - amountPaid,
payments: reservation.rentalPayments.map((payment) => ({
id: payment.id,
amount: payment.amount,
currency: payment.currency,
type: payment.type,
status: payment.status,
provider: payment.paymentProvider,
paymentMethod: payment.paymentMethod,
paidAt: payment.paidAt,
createdAt: payment.createdAt,
})),
},
},
})
} catch (err) { next(err) }
})
router.post('/:id/confirm', async (req, res, next) => {
try {
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
+9
View File
@@ -13,6 +13,15 @@ router.get('/plans', (_req, res) => {
res.json({ data: PLAN_PRICES })
})
router.get('/providers', (_req, res) => {
res.json({
data: {
amanpay: amanpay.isConfigured(),
paypal: paypal.isConfigured(),
},
})
})
// ─── AmanPay subscription webhook (no auth) ──────────────────────────────────
router.post('/webhooks/amanpay', async (req, res, next) => {