add first files

This commit is contained in:
root
2026-04-30 14:59:57 -04:00
parent 2b97c1a04a
commit 695a7f7cc7
92 changed files with 4873 additions and 0 deletions
@@ -0,0 +1,66 @@
import { prisma } from '../lib/prisma'
export async function generateFinancialReport(companyId: string, startDate: Date, endDate: Date) {
const reservations = await prisma.reservation.findMany({
where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, startDate: { gte: startDate }, endDate: { lte: endDate } },
include: {
vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
customer: { select: { firstName: true, lastName: true, email: true } },
rentalPayments: { where: { status: 'SUCCEEDED' } },
insurances: true,
additionalDrivers: true,
},
orderBy: { startDate: 'asc' },
})
const summary = {
totalReservations: reservations.length,
totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0),
totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0),
totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0),
totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0),
totalPricingRulesAdj: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0),
totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0),
totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0),
averageRentalDays: reservations.length > 0 ? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0,
}
const rows = reservations.map((r) => ({
reservationId: r.id,
contractNumber: r.contractNumber ?? '—',
invoiceNumber: r.invoiceNumber ?? '—',
customerName: `${r.customer.firstName} ${r.customer.lastName}`,
vehicle: `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`,
plate: r.vehicle.licensePlate,
startDate: r.startDate.toISOString().split('T')[0],
endDate: r.endDate.toISOString().split('T')[0],
days: r.totalDays,
dailyRate: r.dailyRate,
baseAmount: r.dailyRate * r.totalDays,
discount: r.discountAmount,
insurance: r.insuranceTotal,
additionalDriver: r.additionalDriverTotal,
pricingAdj: r.pricingRulesTotal,
deposit: r.depositAmount,
totalAmount: r.totalAmount,
paymentStatus: r.rentalPayments[0]?.status ?? 'UNPAID',
paymentMethod: r.rentalPayments[0]?.paymentMethod ?? '—',
source: r.source,
}))
return { summary, rows, period: { startDate, endDate } }
}
export function toCsv(rows: Record<string, any>[]): string {
if (rows.length === 0) return ''
const headers = Object.keys(rows[0]!)
return [
headers.join(','),
...rows.map((row) =>
headers.map((h) => {
const val = row[h] ?? ''
return typeof val === 'string' && val.includes(',') ? `"${val}"` : String(val)
}).join(',')
),
].join('\n')
}
+47
View File
@@ -0,0 +1,47 @@
import { InsurancePolicy } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
export function calculateInsuranceCharge(
policy: InsurancePolicy,
totalDays: number,
baseRentalAmount: number
): number {
switch (policy.chargeType) {
case 'PER_DAY': return policy.chargeValue * totalDays
case 'PER_RENTAL': return policy.chargeValue
case 'PERCENTAGE_OF_RENTAL': return Math.round(baseRentalAmount * policy.chargeValue / 100)
default: return 0
}
}
export async function applyInsurancesToReservation(
reservationId: string,
companyId: string,
selectedPolicyIds: string[],
totalDays: number,
baseRentalAmount: number
) {
const allPolicies = await prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } })
const required = allPolicies.filter((p) => p.isRequired)
const selected = allPolicies.filter((p) => selectedPolicyIds.includes(p.id) && !p.isRequired)
const toApply = [...required, ...selected]
const records = toApply.map((policy) => ({
reservationId,
insurancePolicyId: policy.id,
policyName: policy.name,
policyType: policy.type,
chargeType: policy.chargeType,
chargeValue: policy.chargeValue,
totalCharge: calculateInsuranceCharge(policy, totalDays, baseRentalAmount),
}))
const insuranceTotal = records.reduce((s, r) => s + r.totalCharge, 0)
await prisma.$transaction([
prisma.reservationInsurance.createMany({ data: records }),
prisma.reservation.update({ where: { id: reservationId }, data: { insuranceTotal } }),
])
return { records, insuranceTotal }
}
@@ -0,0 +1,50 @@
import { prisma } from '../lib/prisma'
import { LicenseStatus } from '@rentaldrivego/database'
const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000
export interface LicenseValidationResult {
status: 'VALID' | 'EXPIRING' | 'EXPIRED'
daysUntilExpiry: number | null
requiresApproval: boolean
message: string
}
export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult {
if (!licenseExpiry) {
return { status: 'VALID', daysUntilExpiry: null, requiresApproval: false, message: 'No expiry date on record' }
}
const now = new Date()
const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (licenseExpiry <= now) {
return { status: 'EXPIRED', daysUntilExpiry, requiresApproval: true, message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` }
}
if (licenseExpiry.getTime() - now.getTime() < THREE_MONTHS_MS) {
return { status: 'EXPIRING', daysUntilExpiry, requiresApproval: true, message: `License expires in ${daysUntilExpiry} day(s) — approval required` }
}
return { status: 'VALID', daysUntilExpiry, requiresApproval: false, message: `Valid — expires in ${daysUntilExpiry} day(s)` }
}
export async function validateAndFlagLicense(customerId: string) {
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } })
const result = validateLicense(customer.licenseExpiry)
const status: LicenseStatus =
result.status === 'EXPIRED' ? 'EXPIRED' :
result.status === 'EXPIRING' ? 'EXPIRING' : 'VALID'
await prisma.customer.update({
where: { id: customerId },
data: {
licenseExpired: result.status === 'EXPIRED',
licenseExpiringSoon: result.status === 'EXPIRING',
licenseValidationStatus: status,
},
})
return result
}
@@ -0,0 +1,135 @@
import { Resend } from 'resend'
import twilio from 'twilio'
import admin from 'firebase-admin'
import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
const resend = new Resend(process.env.RESEND_API_KEY)
const twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
)
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
}),
})
}
interface SendNotificationOptions {
type: NotificationType
title: string
body: string
data?: Record<string, unknown>
companyId?: string
employeeId?: string
renterId?: string
email?: string
phone?: string
fcmToken?: string
channels: NotificationChannel[]
locale?: string
}
export async function sendNotification(opts: SendNotificationOptions) {
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = []
for (const channel of opts.channels) {
try {
const notification = await prisma.notification.create({
data: {
type: opts.type,
title: opts.title,
body: opts.body,
data: opts.data ?? {},
channel,
status: 'PENDING',
companyId: opts.companyId ?? null,
employeeId: opts.employeeId ?? null,
renterId: opts.renterId ?? null,
},
})
let providerMessageId: string | null = null
let success = false
if (channel === 'EMAIL' && opts.email) {
const { data, error } = await resend.emails.send({
from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM}>`,
to: opts.email,
subject: opts.title,
html: `<p>${opts.body}</p>`,
})
if (!error) {
providerMessageId = data?.id ?? null
success = true
}
}
if (channel === 'SMS' && opts.phone) {
const msg = await twilioClient.messages.create({
body: opts.body,
from: process.env.TWILIO_PHONE_NUMBER!,
to: opts.phone,
})
providerMessageId = msg.sid
success = true
}
if (channel === 'WHATSAPP' && opts.phone) {
const msg = await twilioClient.messages.create({
body: opts.body,
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
to: `whatsapp:${opts.phone}`,
})
providerMessageId = msg.sid
success = true
}
if (channel === 'PUSH' && opts.fcmToken) {
const response = await admin.messaging().send({
token: opts.fcmToken,
notification: { title: opts.title, body: opts.body },
data: Object.fromEntries(
Object.entries(opts.data ?? {}).map(([k, v]) => [k, String(v)])
),
})
providerMessageId = response
success = true
}
if (channel === 'IN_APP') {
// Emit via Socket.io through Redis pub/sub
const targetId = opts.employeeId ?? opts.renterId
if (targetId) {
await redis.publish(
`notifications:${targetId}`,
JSON.stringify({ ...notification, status: 'DELIVERED' })
)
}
success = true
}
await prisma.notification.update({
where: { id: notification.id },
data: {
status: success ? 'SENT' : 'FAILED',
sentAt: success ? new Date() : null,
providerMessageId,
},
})
results.push({ channel, success })
} catch (err: any) {
results.push({ channel, success: false, error: err.message })
}
}
return results
}
@@ -0,0 +1,56 @@
import { prisma } from '../lib/prisma'
interface DriverInfo {
dateOfBirth?: Date | null
licenseIssuedAt?: Date | null
}
export async function applyPricingRules(
companyId: string,
customerId: string,
additionalDrivers: DriverInfo[],
dailyRate: number,
totalDays: number
): Promise<{ applied: any[]; total: number }> {
const rules = await prisma.pricingRule.findMany({ where: { companyId, isActive: true } })
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } })
const allDrivers: DriverInfo[] = [customer, ...additionalDrivers]
const applied: any[] = []
const baseAmount = dailyRate * totalDays
for (const driver of allDrivers) {
for (const rule of rules) {
const driverAge = driver.dateOfBirth
? Math.floor((Date.now() - driver.dateOfBirth.getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null
const licenseYears = driver.licenseIssuedAt
? Math.floor((Date.now() - driver.licenseIssuedAt.getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null
let conditionMet = false
if (rule.condition === 'AGE_LESS_THAN' && driverAge !== null) conditionMet = driverAge < rule.conditionValue
else if (rule.condition === 'AGE_GREATER_THAN' && driverAge !== null) conditionMet = driverAge > rule.conditionValue
else if (rule.condition === 'LICENSE_YEARS_LESS_THAN' && licenseYears !== null) conditionMet = licenseYears < rule.conditionValue
else if (rule.condition === 'LICENSE_YEARS_GREATER_THAN' && licenseYears !== null) conditionMet = licenseYears > rule.conditionValue
if (!conditionMet) continue
let amount = 0
if (rule.adjustmentType === 'PERCENTAGE') amount = Math.round(baseAmount * rule.adjustmentValue / 100)
else if (rule.adjustmentType === 'FLAT_PER_DAY') amount = rule.adjustmentValue * totalDays
else amount = rule.adjustmentValue
if (rule.type === 'DISCOUNT') amount = -amount
applied.push({ ruleId: rule.id, name: rule.name, type: rule.type, amount })
}
}
// Deduplicate: each rule applied once even if multiple drivers qualify
const deduped = applied.reduce((acc: any[], item) => {
if (!acc.find((a) => a.ruleId === item.ruleId)) acc.push(item)
return acc
}, [])
const total = deduped.reduce((s: number, r: any) => s + r.amount, 0)
return { applied: deduped, total }
}
+143
View File
@@ -0,0 +1,143 @@
import { EmployeeRole } from '@rentaldrivego/database'
import { clerkClient } from '@clerk/clerk-sdk-node'
import { prisma } from '../lib/prisma'
export interface InvitePayload {
firstName: string
lastName: string
email: string
role: EmployeeRole
}
export interface TeamMember {
id: string
clerkUserId: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
createdAt: Date
updatedAt: Date
lastActiveAt?: Date | null
invitationStatus?: 'accepted' | 'pending' | 'revoked'
}
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
const employees = await prisma.employee.findMany({
where: { companyId },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
})
const hydrated = await Promise.allSettled(
employees.map(async (emp) => {
try {
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
return { ...emp, lastActiveAt: clerkUser.lastActiveAt ? new Date(clerkUser.lastActiveAt) : null, invitationStatus: 'accepted' as const }
} catch {
return { ...emp, lastActiveAt: null, invitationStatus: 'pending' as const }
}
})
)
return hydrated.map((r) => (r.status === 'fulfilled' ? r.value : (r as any).value))
}
export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) {
if (payload.role === 'OWNER') {
throw Object.assign(new Error('Cannot invite a member with the OWNER role'), { statusCode: 400, code: 'invalid_role' })
}
const existing = await prisma.employee.findFirst({ where: { companyId, email: payload.email } })
if (existing) {
throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' })
}
const company = await prisma.company.findUniqueOrThrow({
where: { id: companyId },
include: { brand: { select: { subdomain: true, displayName: true } } },
})
let clerkInvitation: any
try {
clerkInvitation = await clerkClient.invitations.createInvitation({
emailAddress: payload.email,
redirectUrl: `${process.env.DASHBOARD_URL}/onboarding/accept-invite`,
publicMetadata: { companyId, companyName: company.brand?.displayName ?? company.name, role: payload.role, invitedBy: inviterId },
})
} catch (err: any) {
if (err?.errors?.[0]?.code === 'duplicate_record') {
throw Object.assign(new Error('This email already has a pending invitation'), { statusCode: 409, code: 'duplicate_invitation' })
}
throw err
}
const employee = await prisma.employee.create({
data: { companyId, clerkUserId: `pending_${clerkInvitation.id}`, firstName: payload.firstName, lastName: payload.lastName, email: payload.email, role: payload.role, isActive: false },
})
return { employee, invitationId: clerkInvitation.id }
}
export async function updateEmployeeRole(companyId: string, requesterId: string, requesterRole: EmployeeRole, employeeId: string, payload: { role: EmployeeRole }) {
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can change team member roles'), { statusCode: 403, code: 'forbidden' })
if (target.role === 'OWNER') throw Object.assign(new Error("Cannot change the role of the account owner"), { statusCode: 400 })
if (payload.role === 'OWNER') throw Object.assign(new Error("Cannot assign the OWNER role via this endpoint"), { statusCode: 400 })
if (target.id === requesterId) throw Object.assign(new Error("You cannot change your own role"), { statusCode: 400 })
return prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } })
}
export async function deactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can deactivate team members'), { statusCode: 403 })
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot deactivate the account owner'), { statusCode: 400 })
if (!target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.users.banUser(target.clerkUserId) } catch { /* non-fatal */ }
}
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } })
}
export async function reactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can reactivate team members'), { statusCode: 403 })
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (!target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.users.unbanUser(target.clerkUserId) } catch { /* non-fatal */ }
}
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } })
}
export async function removeEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can remove team members'), { statusCode: 403 })
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot remove the account owner'), { statusCode: 400 })
if (target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.invitations.revokeInvitation(target.clerkUserId.replace('pending_', '')) } catch { /* expired */ }
} else {
try { await clerkClient.users.banUser(target.clerkUserId) } catch { /* non-fatal */ }
}
await prisma.employee.delete({ where: { id: employeeId } })
return { success: true }
}
export async function handleInvitationAccepted(clerkUserId: string, email: string, companyId: string, role: EmployeeRole) {
const pendingEmployee = await prisma.employee.findFirst({
where: { companyId, email, clerkUserId: { startsWith: 'pending_' } },
})
if (!pendingEmployee) return null
return prisma.employee.update({
where: { id: pendingEmployee.id },
data: { clerkUserId, isActive: true, role },
})
}