add first files
This commit is contained in:
@@ -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')
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user