archetecture security fix

This commit is contained in:
root
2026-06-11 03:22:12 -04:00
parent 6def9993da
commit 9483750161
3126 changed files with 177194 additions and 37211 deletions
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/prisma', () => ({
prisma: {
contractSettings: { findUnique: vi.fn() },
additionalDriver: { createMany: vi.fn() },
reservation: { update: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '../lib/prisma'
import { applyAdditionalDriversToReservation, calculateAdditionalDriverCharge } from './additionalDriverService'
describe('additionalDriverService', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
})
it('calculates additional driver charges by configured charge mode', () => {
expect(calculateAdditionalDriverCharge('PER_DAY', 40, 5)).toBe(200)
expect(calculateAdditionalDriverCharge('FLAT', 150, 5)).toBe(150)
expect(calculateAdditionalDriverCharge('FREE', 150, 5)).toBe(0)
})
it('returns a zero-impact result without touching Prisma when no drivers are supplied', async () => {
await expect(applyAdditionalDriversToReservation('reservation_1', 'company_1', [], 4)).resolves.toEqual({
records: [],
additionalDriverTotal: 0,
requiresManualApproval: false,
})
expect(prisma.contractSettings.findUnique).not.toHaveBeenCalled()
})
it('applies charge settings, license risk flags, and reservation totals in one transaction', async () => {
vi.mocked(prisma.contractSettings.findUnique).mockResolvedValue({
additionalDriverCharge: 'PER_DAY',
additionalDriverDailyRate: 75,
additionalDriverFlatRate: 300,
} as never)
vi.mocked(prisma.$transaction).mockResolvedValue([] as never)
const result = await applyAdditionalDriversToReservation('reservation_1', 'company_1', [
{ firstName: 'Nora', lastName: 'Safe', driverLicense: 'A123', licenseExpiry: '2027-06-09T12:00:00.000Z' },
{ firstName: 'Imad', lastName: 'Risky', driverLicense: 'B456', licenseExpiry: '2026-06-20T12:00:00.000Z' },
], 4)
expect(result.additionalDriverTotal).toBe(600)
expect(result.requiresManualApproval).toBe(true)
expect(result.records).toEqual([
expect.objectContaining({ firstName: 'Nora', totalCharge: 300, requiresApproval: false, approvalNote: null }),
expect.objectContaining({ firstName: 'Imad', totalCharge: 300, requiresApproval: true, licenseExpiringSoon: true }),
])
expect(prisma.additionalDriver.createMany).toHaveBeenCalledWith({ data: result.records })
expect(prisma.reservation.update).toHaveBeenCalledWith({
where: { id: 'reservation_1' },
data: { additionalDriverTotal: 600, totalAmount: { increment: 600 } },
})
})
})
@@ -0,0 +1,96 @@
import { prisma } from '../lib/prisma'
type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE'
import { validateLicense } from './licenseValidationService'
export interface AdditionalDriverInput {
firstName: string
lastName: string
email?: string
phone?: string
driverLicense: string
licenseExpiry?: string | null
licenseIssuedAt?: string | null
dateOfBirth?: string | null
nationality?: string
}
export function calculateAdditionalDriverCharge(
chargeType: AdditionalDriverCharge,
chargeValue: number,
totalDays: number,
) {
switch (chargeType) {
case 'PER_DAY':
return chargeValue * totalDays
case 'FLAT':
return chargeValue
default:
return 0
}
}
export async function applyAdditionalDriversToReservation(
reservationId: string,
companyId: string,
drivers: AdditionalDriverInput[],
totalDays: number,
) {
if (drivers.length === 0) {
return { records: [], additionalDriverTotal: 0, requiresManualApproval: false }
}
const settings = await prisma.contractSettings.findUnique({ where: { companyId } })
const chargeType = settings?.additionalDriverCharge ?? 'FREE'
const chargeValue =
chargeType === 'PER_DAY'
? settings?.additionalDriverDailyRate ?? 0
: chargeType === 'FLAT'
? settings?.additionalDriverFlatRate ?? 0
: 0
const records = drivers.map((driver) => {
const licenseResult = validateLicense(driver.licenseExpiry ? new Date(driver.licenseExpiry) : null)
const totalCharge = calculateAdditionalDriverCharge(chargeType, chargeValue, totalDays)
return {
reservationId,
companyId,
firstName: driver.firstName,
lastName: driver.lastName,
email: driver.email ?? null,
phone: driver.phone ?? null,
driverLicense: driver.driverLicense,
licenseExpiry: driver.licenseExpiry ? new Date(driver.licenseExpiry) : null,
licenseIssuedAt: driver.licenseIssuedAt ? new Date(driver.licenseIssuedAt) : null,
dateOfBirth: driver.dateOfBirth ? new Date(driver.dateOfBirth) : null,
nationality: driver.nationality ?? null,
chargeType,
chargeValue,
totalCharge,
licenseExpired: licenseResult.status === 'EXPIRED',
licenseExpiringSoon: licenseResult.status === 'EXPIRING',
requiresApproval: licenseResult.requiresApproval,
approvalNote: licenseResult.requiresApproval ? licenseResult.message : null,
}
})
const additionalDriverTotal = records.reduce((sum, record) => sum + record.totalCharge, 0)
await prisma.$transaction([
prisma.additionalDriver.createMany({ data: records }),
prisma.reservation.update({
where: { id: reservationId },
data: {
additionalDriverTotal,
totalAmount: { increment: additionalDriverTotal },
},
}),
])
return {
records,
additionalDriverTotal,
requiresManualApproval: records.some((record) => record.requiresApproval),
}
}
@@ -0,0 +1,94 @@
import crypto from 'crypto'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const originalEnv = { ...process.env }
async function importService(env: Record<string, string | undefined> = {}) {
vi.resetModules()
process.env = { ...originalEnv, ...env }
return import('./amanpayService')
}
describe('amanpayService', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
process.env = { ...originalEnv }
vi.resetModules()
})
it('reports unconfigured when merchant credentials are placeholders or missing', async () => {
const service = await importService({ AMANPAY_MERCHANT_ID: 'your-amanpay-merchant-id', AMANPAY_SECRET_KEY: 'secret' })
expect(service.isConfigured()).toBe(false)
})
it('creates a checkout using merchant headers and maps alternate provider response fields', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ id: 'txn_1', payment_url: 'https://pay.example/txn_1' }),
})
vi.stubGlobal('fetch', fetchMock)
const service = await importService({
AMANPAY_BASE_URL: 'https://amanpay.test',
AMANPAY_MERCHANT_ID: 'merchant_1',
AMANPAY_SECRET_KEY: 'secret_1',
})
await expect(service.createCheckout({
amount: 12900,
currency: 'MAD',
orderId: 'order_1',
description: 'Reservation payment',
customerEmail: 'nora@example.com',
customerName: 'Nora Saidi',
successUrl: 'https://app.test/success',
failureUrl: 'https://app.test/failure',
webhookUrl: 'https://api.test/webhook',
})).resolves.toEqual({
transactionId: 'txn_1',
checkoutUrl: 'https://pay.example/txn_1',
status: 'PENDING',
})
expect(fetchMock).toHaveBeenCalledWith('https://amanpay.test/v1/payments', expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-merchant-id': 'merchant_1',
'x-api-key': 'secret_1',
},
body: expect.stringContaining('"order_id":"order_1"'),
}))
})
it('turns provider checkout failures into useful errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
statusText: 'Bad Request',
json: vi.fn().mockResolvedValue({ message: 'invalid amount' }),
}))
const service = await importService({ AMANPAY_MERCHANT_ID: 'merchant_1', AMANPAY_SECRET_KEY: 'secret_1' })
await expect(service.createCheckout({
amount: 0,
currency: 'MAD',
orderId: 'order_1',
description: 'Bad payment',
successUrl: 'https://app.test/success',
failureUrl: 'https://app.test/failure',
webhookUrl: 'https://api.test/webhook',
})).rejects.toThrow('AmanPay checkout failed: invalid amount')
})
it('verifies webhook signatures using the configured shared secret', async () => {
const rawBody = JSON.stringify({ id: 'txn_1', status: 'PAID' })
const signature = crypto.createHmac('sha256', 'webhook_secret').update(rawBody).digest('hex')
const service = await importService({ AMANPAY_WEBHOOK_SECRET: 'webhook_secret' })
expect(service.verifyWebhookSignature(rawBody, signature)).toBe(true)
expect(service.verifyWebhookSignature(rawBody, crypto.createHmac('sha256', 'webhook_secret').update('other').digest('hex'))).toBe(false)
})
})
+97
View File
@@ -0,0 +1,97 @@
import crypto from 'crypto'
const BASE_URL = process.env.AMANPAY_BASE_URL ?? 'https://api.amanpay.net'
const MERCHANT_ID = process.env.AMANPAY_MERCHANT_ID ?? ''
const SECRET_KEY = process.env.AMANPAY_SECRET_KEY ?? ''
const WEBHOOK_SECRET = process.env.AMANPAY_WEBHOOK_SECRET ?? ''
export interface AmanPayCreateParams {
amount: number
currency: string
orderId: string
description: string
customerEmail?: string
customerName?: string
successUrl: string
failureUrl: string
webhookUrl: string
}
export interface AmanPayCheckoutResult {
transactionId: string
checkoutUrl: string
status: string
}
async function getAuthHeaders() {
return {
'Content-Type': 'application/json',
'x-merchant-id': MERCHANT_ID,
'x-api-key': SECRET_KEY,
}
}
export async function createCheckout(params: AmanPayCreateParams): Promise<AmanPayCheckoutResult> {
const res = await fetch(`${BASE_URL}/v1/payments`, {
method: 'POST',
headers: await getAuthHeaders(),
body: JSON.stringify({
merchant_id: MERCHANT_ID,
amount: params.amount,
currency: params.currency,
order_id: params.orderId,
description: params.description,
customer_email: params.customerEmail,
customer_name: params.customerName,
success_url: params.successUrl,
failure_url: params.failureUrl,
webhook_url: params.webhookUrl,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({})) as Record<string, unknown>
throw new Error(`AmanPay checkout failed: ${(err?.message as string) ?? res.statusText}`)
}
const json = await res.json() as Record<string, unknown>
return {
transactionId: (json.transaction_id ?? json.id) as string,
checkoutUrl: (json.checkout_url ?? json.payment_url) as string,
status: (json.status ?? 'PENDING') as string,
}
}
export async function getTransaction(transactionId: string) {
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}`, {
headers: await getAuthHeaders(),
})
if (!res.ok) throw new Error('AmanPay: failed to fetch transaction')
return res.json()
}
export async function refundTransaction(transactionId: string, amount: number, reason?: string) {
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}/refund`, {
method: 'POST',
headers: await getAuthHeaders(),
body: JSON.stringify({ amount, reason }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({})) as Record<string, unknown>
throw new Error(`AmanPay refund failed: ${(err?.message as string) ?? res.statusText}`)
}
return res.json()
}
export function verifyWebhookSignature(rawBody: string, signature: string): boolean {
if (!WEBHOOK_SECRET) return false
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
export function isConfigured(): boolean {
return !!(MERCHANT_ID && SECRET_KEY && MERCHANT_ID !== 'your-amanpay-merchant-id')
}
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const execFileMock = vi.fn()
const fsState = new Map<string, string>()
vi.mock('child_process', () => ({
execFile: (file: string, args: string[], cb: (error: any, stdout: string, stderr: string) => void) => {
const result = execFileMock(file, args) as { stdout?: string; stderr?: string } | undefined
cb(null, result?.stdout ?? '', result?.stderr ?? '')
},
}))
vi.mock('fs/promises', () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
access: vi.fn(async (file: string) => {
if (!fsState.has(file)) throw Object.assign(new Error('missing'), { code: 'ENOENT' })
}),
readFile: vi.fn(async (file: string) => fsState.get(file) ?? '{}'),
writeFile: vi.fn(async (file: string, contents: string) => { fsState.set(file, contents) }),
}))
vi.mock('../lib/prisma', () => ({
prisma: {
companyContainer: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
findUniqueOrThrow: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
delete: vi.fn(),
},
},
}))
import * as fs from 'fs/promises'
import { prisma } from '../lib/prisma'
import {
createCompanyContainer,
getContainerLogs,
restartCompanyContainer,
startCompanyContainer,
stopCompanyContainer,
syncContainerStatuses,
} from './containerService'
describe('containerService', () => {
beforeEach(() => {
vi.clearAllMocks()
fsState.clear()
fsState.set('/opt/companies/docker-compose.companies.yml', 'services: {}')
fsState.set('/opt/companies/companies-services.json', '{}')
})
afterEach(() => {
vi.resetModules()
})
it('allocates the first configured port, writes compose service metadata, starts the service, and stores docker id', async () => {
vi.mocked(prisma.companyContainer.findFirst).mockResolvedValue(null as never)
vi.mocked(prisma.companyContainer.create).mockResolvedValue({ id: 'container_row_1' } as never)
execFileMock.mockImplementation((file: string, args: string[]) => {
// The child_process mock calls back with empty stdout. This spy records the command shape.
return { file, args }
})
await createCompanyContainer({ id: 'company_1', slug: 'atlas-cars', name: 'Atlas Cars' })
expect(prisma.companyContainer.create).toHaveBeenCalledWith({
data: expect.objectContaining({
companyId: 'company_1',
containerName: 'rdg-company-atlas-cars',
status: 'CREATING',
port: 5100,
}),
})
expect(fs.writeFile).toHaveBeenCalledWith(
'/opt/companies/companies-services.json',
expect.stringContaining('"atlas-cars"'),
'utf8',
)
expect(fs.writeFile).toHaveBeenCalledWith(
'/opt/companies/docker-compose.companies.yml',
expect.stringContaining('container_name: "rdg-company-atlas-cars"'),
'utf8',
)
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['version'])
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'up', '-d', '--no-recreate', 'company-atlas-cars'])
expect(prisma.companyContainer.update).toHaveBeenCalledWith({
where: { id: 'container_row_1' },
data: { dockerId: null, status: 'RUNNING' },
})
})
it('starts, stops, and restarts an existing company service with status transitions', async () => {
vi.mocked(prisma.companyContainer.findUniqueOrThrow).mockResolvedValue({
companyId: 'company_1',
company: { slug: 'atlas-cars' },
} as never)
await startCompanyContainer('company_1')
await stopCompanyContainer('company_1')
await restartCompanyContainer('company_1')
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'up', '-d', '--no-recreate', 'company-atlas-cars'])
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'stop', 'company-atlas-cars'])
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'restart', 'company-atlas-cars'])
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'RESTARTING', errorMessage: null } })
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'RUNNING', errorMessage: null } })
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'STOPPED', errorMessage: null } })
})
it('returns an empty log string when docker log retrieval fails', async () => {
vi.mocked(prisma.companyContainer.findUniqueOrThrow).mockResolvedValue({ company: { slug: 'atlas-cars' } } as never)
execFileMock.mockImplementationOnce(() => { throw new Error('docker unavailable') })
await expect(getContainerLogs('company_1', 50)).resolves.toBe('')
})
it('loads container records and leaves stored statuses unchanged when docker status sync cannot complete', async () => {
vi.mocked(prisma.companyContainer.findMany).mockResolvedValue([
{ id: 'row_1', status: 'STOPPED', company: { slug: 'atlas-cars' } },
{ id: 'row_2', status: 'RUNNING', company: { slug: 'sahara-rentals' } },
] as never)
execFileMock.mockImplementation(() => { throw new Error('Docker daemon is unavailable') })
await expect(syncContainerStatuses()).resolves.toBeUndefined()
expect(prisma.companyContainer.findMany).toHaveBeenCalledWith({
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
include: { company: { select: { slug: true } } },
})
expect(prisma.companyContainer.update).not.toHaveBeenCalled()
})})
+469
View File
@@ -0,0 +1,469 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import * as fs from 'fs/promises'
import * as path from 'path'
import { prisma as _prisma } from '../lib/prisma'
const db = _prisma as any
const execFileAsync = promisify(execFile)
let composeCommand: 'docker-compose' | 'docker' | null = null
// ── Config ────────────────────────────────────────────────────────────────────
const COMPOSE_DIR = process.env.COMPANIES_COMPOSE_DIR || '/opt/companies'
const COMPOSE_FILE = path.join(COMPOSE_DIR, 'docker-compose.companies.yml')
const SERVICES_FILE = path.join(COMPOSE_DIR, 'companies-services.json') // our source of truth
const DASHBOARD_IMAGE = process.env.DASHBOARD_CONTAINER_IMAGE || 'rentaldrivego/dashboard:latest'
const PORT_RANGE_START = parseInt(process.env.CONTAINER_PORT_START || '5100', 10)
const PORT_RANGE_END = parseInt(process.env.CONTAINER_PORT_END || '5999', 10)
const API_INTERNAL_URL = process.env.API_INTERNAL_URL || 'http://api:4000'
const CONTAINER_NETWORK = process.env.CONTAINER_NETWORK || 'rentaldrivego_default'
// ── Types ─────────────────────────────────────────────────────────────────────
interface ServiceDef {
companyId: string
slug: string
image: string
port: number
}
type ServicesMap = Record<string, ServiceDef> // key = service name e.g. "company-slug"
type ServiceStatus = 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'ERROR'
// ── Internal helpers ──────────────────────────────────────────────────────────
function serviceName(slug: string) {
return `company-${slug}`
}
function containerName(slug: string) {
return `rdg-company-${slug}`
}
async function ensureDir(): Promise<void> {
await fs.mkdir(COMPOSE_DIR, { recursive: true })
}
async function readServices(): Promise<ServicesMap> {
try {
const raw = await fs.readFile(SERVICES_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return {}
}
}
async function writeServices(services: ServicesMap): Promise<void> {
await ensureDir()
await fs.writeFile(SERVICES_FILE, JSON.stringify(services, null, 2), 'utf8')
await fs.writeFile(COMPOSE_FILE, buildComposeYaml(services), 'utf8')
}
async function composeFilesExist(): Promise<boolean> {
try {
await fs.access(COMPOSE_FILE)
await fs.access(SERVICES_FILE)
return true
} catch {
return false
}
}
async function rebuildServicesFromDatabase(): Promise<ServicesMap> {
const records = await db.companyContainer.findMany({
include: { company: { select: { slug: true } } },
})
return Object.fromEntries(
records.map((record: any) => [
serviceName(record.company.slug),
{
companyId: record.companyId,
slug: record.company.slug,
image: record.image,
port: record.port,
},
]),
)
}
async function ensureComposeFiles(): Promise<void> {
if (await composeFilesExist()) return
const services = await rebuildServicesFromDatabase()
await writeServices(services)
}
function buildComposeYaml(services: ServicesMap): string {
const lines: string[] = ['services:']
for (const [name, svc] of Object.entries(services)) {
lines.push(` ${name}:`)
lines.push(` image: "${svc.image}"`)
lines.push(` container_name: "${containerName(svc.slug)}"`)
lines.push(` restart: unless-stopped`)
lines.push(` environment:`)
lines.push(` COMPANY_ID: "${svc.companyId}"`)
lines.push(` COMPANY_SLUG: "${svc.slug}"`)
lines.push(` API_URL: "${API_INTERNAL_URL}"`)
lines.push(` NEXT_PUBLIC_API_URL: "${API_INTERNAL_URL}"`)
lines.push(` PORT: "3000"`)
lines.push(` NODE_ENV: "production"`)
lines.push(` ports:`)
lines.push(` - "${svc.port}:3000"`)
lines.push(` networks:`)
lines.push(` - ${CONTAINER_NETWORK}`)
lines.push(` labels:`)
lines.push(` rdg.managed: "true"`)
lines.push(` rdg.company.id: "${svc.companyId}"`)
lines.push(` rdg.company.slug: "${svc.slug}"`)
}
lines.push('')
lines.push('networks:')
lines.push(` ${CONTAINER_NETWORK}:`)
lines.push(` external: true`)
lines.push('')
return lines.join('\n')
}
async function resolveComposeCommand(): Promise<'docker-compose' | 'docker'> {
if (composeCommand) return composeCommand
try {
await execFileAsync('docker-compose', ['version'])
composeCommand = 'docker-compose'
return composeCommand
} catch {
try {
await execFileAsync('docker', ['compose', 'version'])
composeCommand = 'docker'
return composeCommand
} catch (err) {
throw mapDockerError(err)
}
}
}
async function compose(...args: string[]): Promise<{ stdout: string; stderr: string }> {
try {
await ensureComposeFiles()
const command = await resolveComposeCommand()
if (command === 'docker-compose') {
return await execFileAsync('docker-compose', ['-f', COMPOSE_FILE, ...args])
}
return await execFileAsync('docker', ['compose', '-f', COMPOSE_FILE, ...args])
} catch (err) {
throw mapDockerError(err)
}
}
function dockerUnavailableError(message: string, details?: string) {
const err = Object.assign(new Error(message), {
statusCode: 503,
code: 'docker_unavailable',
})
if (details) {
;(err as Error & { details?: string }).details = details
}
return err
}
function mapDockerError(err: unknown) {
if (err && typeof err === 'object') {
const error = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }
const stderr = error.stderr?.trim()
if (error.code === 'ENOENT' && error.path === 'docker') {
return dockerUnavailableError(
'Docker CLI is not available to the API service.',
'Install Docker in the API container or run the API on a host with Docker available in PATH.',
)
}
if (error.code === 'ENOENT' && error.path === 'docker-compose') {
return dockerUnavailableError(
'Docker Compose is not available to the API service.',
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
)
}
if (
stderr?.includes("docker: 'compose' is not a docker command.") ||
stderr?.includes("unknown shorthand flag: 'f' in -f")
) {
return dockerUnavailableError(
'Docker Compose is not available to the API service.',
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
)
}
if (
stderr?.includes('Cannot connect to the Docker daemon') ||
stderr?.includes('permission denied while trying to connect to the Docker daemon socket') ||
stderr?.includes('error during connect')
) {
return dockerUnavailableError(
'Docker daemon is not reachable from the API service.',
'Mount `/var/run/docker.sock` into the API container and ensure the Docker daemon is running.',
)
}
// Catch-all: docker ran but exited non-zero — surface stderr as a readable 502
if (typeof error.code === 'number' && error.code !== 0) {
const detail = stderr || error.stdout?.trim() || 'docker compose exited with a non-zero status'
return Object.assign(new Error(detail), { statusCode: 502, code: 'docker_error' })
}
}
return err
}
async function setContainerError(companyId: string, message: string): Promise<void> {
await db.companyContainer.update({
where: { companyId },
data: { status: 'ERROR', errorMessage: message },
}).catch(() => null)
}
async function runContainerAction(
companyId: string,
slug: string,
command: 'start' | 'stop' | 'restart',
successStatus: Exclude<ServiceStatus, 'ERROR'>,
preStatus?: Exclude<ServiceStatus, 'ERROR'>,
): Promise<void> {
if (preStatus) {
await db.companyContainer.update({ where: { companyId }, data: { status: preStatus, errorMessage: null } })
}
try {
// Use `up -d --no-recreate` for start so it creates the container if it doesn't exist yet
const args = command === 'start'
? ['up', '-d', '--no-recreate', serviceName(slug)]
: [command, serviceName(slug)]
await compose(...args)
await db.companyContainer.update({
where: { companyId },
data: { status: successStatus, errorMessage: null },
})
} catch (err) {
const message = err instanceof Error ? err.message : `Failed to ${command} container`
await setContainerError(companyId, message)
throw err
}
}
async function allocatePort(): Promise<number> {
const last = await db.companyContainer.findFirst({
orderBy: { port: 'desc' },
select: { port: true },
})
const next = last ? last.port + 1 : PORT_RANGE_START
if (next > PORT_RANGE_END) throw new Error('No available ports in container port range')
return next
}
async function getDockerServiceId(slug: string): Promise<string | null> {
try {
const name = serviceName(slug)
const { stdout } = await compose('ps', '--format', 'json', name)
const line = stdout.trim().split('\n')[0]
if (!line) return null
const info = JSON.parse(line)
return info.ID ?? info.Id ?? null
} catch {
return null
}
}
// Maps docker compose State strings to our DB enum
function mapDockerState(state: string): ServiceStatus {
switch (state.toLowerCase()) {
case 'running': return 'RUNNING'
case 'restarting': return 'RESTARTING'
case 'exited':
case 'created':
case 'paused': return 'STOPPED'
default: return 'ERROR'
}
}
// ── Public API ────────────────────────────────────────────────────────────────
export async function createCompanyContainer(company: {
id: string
slug: string
name: string
}): Promise<void> {
const name = serviceName(company.slug)
const port = await allocatePort()
const record = await db.companyContainer.create({
data: {
companyId: company.id,
containerName: containerName(company.slug),
status: 'CREATING',
port,
image: DASHBOARD_IMAGE,
},
})
try {
const services = await readServices()
services[name] = { companyId: company.id, slug: company.slug, image: DASHBOARD_IMAGE, port }
await writeServices(services)
await compose('up', '-d', '--no-recreate', name)
const dockerId = await getDockerServiceId(company.slug)
await db.companyContainer.update({
where: { id: record.id },
data: { dockerId, status: 'RUNNING' },
})
} catch (err) {
await db.companyContainer.update({
where: { id: record.id },
data: {
status: 'ERROR',
errorMessage: err instanceof Error ? err.message : 'Unknown error during service creation',
},
})
throw err
}
}
export async function startCompanyContainer(companyId: string): Promise<void> {
const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'start', 'RUNNING')
}
export async function stopCompanyContainer(companyId: string): Promise<void> {
const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'stop', 'STOPPED')
}
export async function restartCompanyContainer(companyId: string): Promise<void> {
const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'restart', 'RUNNING', 'RESTARTING')
}
export async function removeCompanyContainer(companyId: string): Promise<void> {
const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
const name = serviceName(record.company.slug)
await db.companyContainer.update({ where: { companyId }, data: { status: 'REMOVING' } })
try {
await compose('stop', name)
} catch { /* already stopped */ }
try {
await compose('rm', '-f', name)
} catch { /* already removed */ }
const services = await readServices()
delete services[name]
await writeServices(services)
await db.companyContainer.delete({ where: { companyId } })
}
export async function redeployCompanyContainer(company: {
id: string
slug: string
name: string
}): Promise<void> {
const name = serviceName(company.slug)
const existing = await db.companyContainer.findUnique({ where: { companyId: company.id } })
if (existing) {
try { await compose('stop', name) } catch { /* ok */ }
try { await compose('rm', '-f', name) } catch { /* ok */ }
await db.companyContainer.delete({ where: { companyId: company.id } })
}
// Remove from compose file too, then recreate
const services = await readServices()
delete services[name]
await writeServices(services)
await createCompanyContainer(company)
}
export async function getContainerLogs(companyId: string, tail = 150): Promise<string> {
const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
try {
const { stdout } = await compose(
'logs',
'--no-log-prefix',
`--tail=${tail}`,
serviceName(record.company.slug),
)
return stdout
} catch {
return ''
}
}
export async function syncContainerStatuses(): Promise<void> {
const records = await db.companyContainer.findMany({
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
include: { company: { select: { slug: true } } },
})
try {
// Get all compose service states in one shot
const { stdout } = await compose('ps', '--format', 'json')
const rows = stdout
.trim()
.split('\n')
.filter(Boolean)
.map((line) => {
try { return JSON.parse(line) } catch { return null }
})
.filter(Boolean) as Array<{ Service: string; State: string }>
const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State]))
await Promise.allSettled(
records.map(async (rec: any) => {
const name = serviceName(rec.company.slug)
const dockerState = stateByService[name]
const status = dockerState ? mapDockerState(dockerState) : 'STOPPED'
if (rec.status !== status) {
await db.companyContainer.update({ where: { id: rec.id }, data: { status } })
}
}),
)
} catch {
// Docker daemon unreachable — leave statuses as-is
}
}
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { prisma } from '../lib/prisma'
import { generateFinancialReport, toCsv } from './financialReportService'
vi.mock('../lib/prisma', () => ({
prisma: {
reservation: { findMany: vi.fn() },
},
}))
describe('financialReportService', () => {
beforeEach(() => vi.clearAllMocks())
it('generates summary totals and rows from confirmed revenue-bearing reservations only as requested from Prisma', async () => {
const startDate = new Date('2026-06-01T00:00:00.000Z')
const endDate = new Date('2026-06-30T23:59:59.999Z')
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
{
id: 'reservation_1',
contractNumber: 'CNT-1',
invoiceNumber: null,
dailyRate: 500,
totalDays: 3,
discountAmount: 100,
insuranceTotal: 60,
additionalDriverTotal: 40,
pricingRulesTotal: -25,
depositAmount: 1000,
totalAmount: 1475,
source: 'DIRECT',
startDate: new Date('2026-06-10T10:00:00.000Z'),
endDate: new Date('2026-06-13T10:00:00.000Z'),
vehicle: { year: 2024, make: 'Dacia', model: 'Duster', licensePlate: 'A-123' },
customer: { firstName: 'Nora', lastName: 'Saidi', email: 'nora@example.com' },
rentalPayments: [{ status: 'SUCCEEDED', paymentMethod: 'CARD' }],
insurances: [],
additionalDrivers: [],
},
{
id: 'reservation_2',
contractNumber: null,
invoiceNumber: 'INV-2',
dailyRate: 300,
totalDays: 2,
discountAmount: 0,
insuranceTotal: 30,
additionalDriverTotal: 0,
pricingRulesTotal: 10,
depositAmount: 500,
totalAmount: 640,
source: 'MARKETPLACE',
startDate: new Date('2026-06-18T10:00:00.000Z'),
endDate: new Date('2026-06-20T10:00:00.000Z'),
vehicle: { year: 2023, make: 'Renault', model: 'Clio', licensePlate: 'B-456' },
customer: { firstName: 'Omar', lastName: 'Tazi', email: 'omar@example.com' },
rentalPayments: [],
insurances: [],
additionalDrivers: [],
},
] as never)
const report = await generateFinancialReport('company_1', startDate, endDate)
expect(prisma.reservation.findMany).toHaveBeenCalledWith({
where: {
companyId: 'company_1',
status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] },
startDate: { gte: startDate },
endDate: { lte: endDate },
},
include: expect.objectContaining({
vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
customer: { select: { firstName: true, lastName: true, email: true } },
rentalPayments: { where: { status: 'SUCCEEDED' } },
}),
orderBy: { startDate: 'asc' },
})
expect(report.summary).toEqual({
totalReservations: 2,
totalRentalRevenue: 2100,
totalDiscounts: 100,
totalInsurance: 90,
totalAdditionalDrivers: 40,
totalPricingRulesAdj: -15,
totalDeposits: 1500,
totalCollected: 2115,
averageRentalDays: 2.5,
})
expect(report.rows).toEqual([
expect.objectContaining({
reservationId: 'reservation_1',
contractNumber: 'CNT-1',
invoiceNumber: '—',
customerName: 'Nora Saidi',
vehicle: '2024 Dacia Duster',
plate: 'A-123',
startDate: '2026-06-10',
endDate: '2026-06-13',
baseAmount: 1500,
paymentStatus: 'SUCCEEDED',
paymentMethod: 'CARD',
}),
expect.objectContaining({
reservationId: 'reservation_2',
contractNumber: '—',
invoiceNumber: 'INV-2',
paymentStatus: 'UNPAID',
paymentMethod: '—',
}),
])
expect(report.period).toEqual({ startDate, endDate })
})
it('returns zero-safe summary values when no rows match', async () => {
vi.mocked(prisma.reservation.findMany).mockResolvedValue([] as never)
const report = await generateFinancialReport('company_1', new Date('2026-06-01'), new Date('2026-06-30'))
expect(report.summary).toMatchObject({
totalReservations: 0,
totalRentalRevenue: 0,
averageRentalDays: 0,
})
expect(report.rows).toEqual([])
})
it('serializes CSV rows with headers and quotes comma-containing string values', () => {
expect(toCsv([
{ customerName: 'Nora, Saidi', totalAmount: 1200, paymentStatus: 'SUCCEEDED' },
{ customerName: 'Omar Tazi', totalAmount: 900, paymentStatus: null },
])).toBe('customerName,totalAmount,paymentStatus\n"Nora, Saidi",1200,SUCCEEDED\nOmar Tazi,900,')
})
it('returns an empty CSV string for empty rows', () => {
expect(toCsv([])).toBe('')
})
})
@@ -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: number, r: (typeof reservations)[number]) => s + r.dailyRate * r.totalDays, 0),
totalDiscounts: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.discountAmount, 0),
totalInsurance: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.insuranceTotal, 0),
totalAdditionalDrivers: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.additionalDriverTotal, 0),
totalPricingRulesAdj: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.pricingRulesTotal, 0),
totalDeposits: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.depositAmount, 0),
totalCollected: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalAmount, 0),
averageRentalDays: reservations.length > 0 ? reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalDays, 0) / reservations.length : 0,
}
const rows = reservations.map((r: (typeof reservations)[number]) => ({
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,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/prisma', () => ({
prisma: {
insurancePolicy: { findMany: vi.fn() },
reservationInsurance: { createMany: vi.fn() },
reservation: { update: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '../lib/prisma'
import { applyInsurancesToReservation, calculateInsuranceCharge } from './insuranceService'
const policy = (overrides: Record<string, unknown>) => ({
id: 'policy_1',
companyId: 'company_1',
name: 'Collision damage waiver',
type: 'CDW',
chargeType: 'PER_DAY',
chargeValue: 100,
isRequired: false,
isActive: true,
...overrides,
}) as any
describe('insuranceService', () => {
beforeEach(() => vi.clearAllMocks())
it('calculates per-day, per-rental, percentage, and unknown insurance charges defensively', () => {
expect(calculateInsuranceCharge(policy({ chargeType: 'PER_DAY', chargeValue: 25 }), 4, 1_000)).toBe(100)
expect(calculateInsuranceCharge(policy({ chargeType: 'PER_RENTAL', chargeValue: 90 }), 4, 1_000)).toBe(90)
expect(calculateInsuranceCharge(policy({ chargeType: 'PERCENTAGE_OF_RENTAL', chargeValue: 12.5 }), 4, 999)).toBe(125)
expect(calculateInsuranceCharge(policy({ chargeType: 'WHO_KNOWS', chargeValue: 50 }), 4, 1_000)).toBe(0)
})
it('always applies required active policies and selected optional policies in one reservation transaction', async () => {
vi.mocked(prisma.insurancePolicy.findMany).mockResolvedValue([
policy({ id: 'required_1', name: 'Mandatory liability', isRequired: true, chargeType: 'PER_RENTAL', chargeValue: 300 }),
policy({ id: 'optional_1', name: 'Glass cover', isRequired: false, chargeType: 'PER_DAY', chargeValue: 50 }),
policy({ id: 'optional_2', name: 'Ignored cover', isRequired: false, chargeType: 'PER_DAY', chargeValue: 999 }),
] as never)
vi.mocked(prisma.$transaction).mockResolvedValue([] as never)
const result = await applyInsurancesToReservation('reservation_1', 'company_1', ['optional_1'], 3, 1_200)
expect(result.insuranceTotal).toBe(450)
expect(result.records.map((r) => r.insurancePolicyId)).toEqual(['required_1', 'optional_1'])
expect(prisma.insurancePolicy.findMany).toHaveBeenCalledWith({ where: { companyId: 'company_1', isActive: true } })
expect(prisma.reservationInsurance.createMany).toHaveBeenCalledWith({ data: result.records })
expect(prisma.reservation.update).toHaveBeenCalledWith({
where: { id: 'reservation_1' },
data: { insuranceTotal: 450, totalAmount: { increment: 450 } },
})
expect(prisma.$transaction).toHaveBeenCalledWith([undefined, undefined])
})
})
+53
View File
@@ -0,0 +1,53 @@
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: InsurancePolicy) => p.isRequired)
const selected = allPolicies.filter((p: InsurancePolicy) => 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,
totalAmount: { increment: insuranceTotal },
},
}),
])
return { records, insuranceTotal }
}
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const renderToBufferMock = vi.fn().mockResolvedValue(Buffer.from('pdf-bytes'))
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: any[]) => renderToBufferMock(...args),
Document: 'Document',
Page: 'Page',
Text: 'Text',
View: 'View',
StyleSheet: { create: (styles: any) => styles },
}))
import { buildInvoiceNumber, generateInvoicePdf } from './invoicePdfService'
describe('invoicePdfService', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('builds stable legacy invoice numbers from the creation year and id suffix', () => {
expect(buildInvoiceNumber('invoice_abcdef', new Date('2026-06-09T12:00:00.000Z'))).toBe('INV-2026-ABCDEF')
expect(buildInvoiceNumber('short', new Date('2027-01-01T00:00:00.000Z'))).toBe('INV-2027-SHORT')
})
it('renders a subscription invoice document into a buffer', async () => {
const result = await generateInvoicePdf({
invoiceNumber: 'INV-2026-000001',
issueDate: '2026-06-09T00:00:00.000Z',
dueDate: null,
company: { name: 'Atlas Cars', email: 'billing@atlas.test', phone: '+212600000000', address: { formatted: 'Casablanca' } },
subscription: { plan: 'PRO', billingPeriod: 'MONTHLY', currency: 'MAD' },
amount: 99000,
currency: 'MAD',
status: 'PAID',
paymentProvider: 'PAYPAL',
transactionId: 'txn_1',
paidAt: '2026-06-09T12:00:00.000Z',
lineItems: [{ description: 'Pro monthly subscription', amount: 99000, currency: 'MAD', quantity: 1, unitAmount: 99000 }],
totals: {
subtotalAmount: 99000,
discountAmount: 0,
creditAmount: 0,
taxAmount: 0,
totalAmount: 99000,
amountPaid: 99000,
amountDue: 0,
},
})
expect(result).toEqual(Buffer.from('pdf-bytes'))
expect(renderToBufferMock).toHaveBeenCalledTimes(1)
expect(renderToBufferMock.mock.calls[0][0]).toEqual(expect.objectContaining({
props: expect.objectContaining({ data: expect.objectContaining({ invoiceNumber: 'INV-2026-000001' }) }),
}))
})
})
+570
View File
@@ -0,0 +1,570 @@
import React from 'react'
import { renderToBuffer, Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
interface InvoiceData {
invoiceNumber: string
issueDate: string
dueDate: string | null
company: {
name: string
email: string
phone?: string | null
address?: any
}
subscription?: {
plan: string
billingPeriod: string
currentPeriodStart?: string | null
currentPeriodEnd?: string | null
currency: string
}
amount: number
currency: string
status: string
paymentProvider: string
transactionId?: string | null
paidAt?: string | null
lineItems?: Array<{
description: string
amount: number
currency: string
quantity?: number
unitAmount?: number
periodStart?: string | null
periodEnd?: string | null
}>
totals?: {
subtotalAmount: number
discountAmount: number
creditAmount: number
taxAmount: number
totalAmount: number
amountPaid: number
amountDue: number
}
}
const PLAN_LABEL: Record<string, string> = {
STARTER: 'Starter',
GROWTH: 'Growth',
PRO: 'Pro',
}
const PERIOD_LABEL: Record<string, string> = {
MONTHLY: 'Monthly',
ANNUAL: 'Annual',
}
const STATUS_COLORS: Record<string, string> = {
PAID: '#10b981',
PENDING: '#f59e0b',
OPEN: '#f59e0b',
PAYMENT_PENDING: '#f59e0b',
PARTIALLY_PAID: '#f59e0b',
PAST_DUE: '#ef4444',
FAILED: '#ef4444',
REFUNDED: '#6b7280',
PARTIALLY_REFUNDED: '#6b7280',
VOID: '#6b7280',
UNCOLLECTIBLE: '#6b7280',
}
const colors = {
bg: '#0f0f11',
surface: '#18181b',
border: '#27272a',
textPrimary: '#f4f4f5',
textSecondary: '#a1a1aa',
textMuted: '#71717a',
accent: '#10b981',
white: '#ffffff',
}
const s = StyleSheet.create({
page: {
backgroundColor: colors.bg,
paddingHorizontal: 48,
paddingVertical: 48,
fontFamily: 'Helvetica',
color: colors.textPrimary,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 40,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
brandName: {
fontSize: 22,
fontFamily: 'Helvetica-Bold',
color: colors.accent,
letterSpacing: 0.5,
},
brandTagline: {
fontSize: 9,
color: colors.textMuted,
marginTop: 3,
},
invoiceMeta: {
alignItems: 'flex-end',
},
invoiceTitle: {
fontSize: 26,
fontFamily: 'Helvetica-Bold',
color: colors.white,
letterSpacing: 1,
},
invoiceNumber: {
fontSize: 11,
color: colors.textSecondary,
marginTop: 4,
},
section: {
marginBottom: 24,
},
sectionLabel: {
fontSize: 8,
fontFamily: 'Helvetica-Bold',
color: colors.textMuted,
letterSpacing: 1.2,
textTransform: 'uppercase',
marginBottom: 8,
},
row: {
flexDirection: 'row',
gap: 24,
marginBottom: 24,
},
col: {
flex: 1,
backgroundColor: colors.surface,
borderRadius: 8,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
},
colLabel: {
fontSize: 8,
fontFamily: 'Helvetica-Bold',
color: colors.textMuted,
letterSpacing: 1,
textTransform: 'uppercase',
marginBottom: 10,
},
companyName: {
fontSize: 13,
fontFamily: 'Helvetica-Bold',
color: colors.textPrimary,
marginBottom: 4,
},
companyDetail: {
fontSize: 10,
color: colors.textSecondary,
marginBottom: 2,
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 6,
},
metaKey: {
fontSize: 9,
color: colors.textMuted,
},
metaValue: {
fontSize: 9,
color: colors.textSecondary,
fontFamily: 'Helvetica-Bold',
},
table: {
backgroundColor: colors.surface,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
marginBottom: 20,
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#1c1c1f',
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
tableHeaderCell: {
fontSize: 8,
fontFamily: 'Helvetica-Bold',
color: colors.textMuted,
letterSpacing: 0.8,
textTransform: 'uppercase',
},
tableRow: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
tableCell: {
fontSize: 10,
color: colors.textPrimary,
},
tableCellMuted: {
fontSize: 10,
color: colors.textSecondary,
},
col60: { flex: 6 },
col20: { flex: 2, textAlign: 'right' as const },
col20Center: { flex: 2, textAlign: 'center' as const },
totalRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginBottom: 6,
},
totalLabel: {
fontSize: 11,
color: colors.textSecondary,
marginRight: 32,
},
totalValue: {
fontSize: 11,
fontFamily: 'Helvetica-Bold',
color: colors.white,
minWidth: 100,
textAlign: 'right' as const,
},
grandTotalRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 8,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: colors.border,
},
grandTotalLabel: {
fontSize: 13,
fontFamily: 'Helvetica-Bold',
color: colors.textSecondary,
marginRight: 32,
},
grandTotalValue: {
fontSize: 15,
fontFamily: 'Helvetica-Bold',
color: colors.accent,
minWidth: 100,
textAlign: 'right' as const,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
alignSelf: 'flex-start',
marginTop: 6,
},
statusText: {
fontSize: 9,
fontFamily: 'Helvetica-Bold',
letterSpacing: 0.5,
color: colors.white,
},
paymentBox: {
backgroundColor: colors.surface,
borderRadius: 8,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
marginBottom: 24,
},
paymentRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
paymentKey: {
fontSize: 9,
color: colors.textMuted,
},
paymentValue: {
fontSize: 9,
color: colors.textSecondary,
fontFamily: 'Helvetica-Bold',
maxWidth: 280,
textAlign: 'right' as const,
},
footer: {
position: 'absolute',
bottom: 32,
left: 48,
right: 48,
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 12,
flexDirection: 'row',
justifyContent: 'space-between',
},
footerText: {
fontSize: 8,
color: colors.textMuted,
},
})
function fmt(amount: number, currency: string) {
return `${(amount / 100).toFixed(2)} ${currency}`
}
function fmtDate(iso: string | null | undefined) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })
}
function formatAddress(address: any): string {
if (!address) return ''
if (typeof address === 'string') return address
const parts = [address.street, address.city, address.region, address.country, address.zip]
return parts.filter(Boolean).join(', ')
}
function InvoiceDocument({ data }: { data: InvoiceData }) {
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
const addressStr = formatAddress(data.company.address)
const planLabel = data.subscription ? (PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan) : 'Manual'
const periodLabel = data.subscription ? (PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod) : 'Custom'
const lineItems = data.lineItems?.length
? data.lineItems
: [{
description: `${planLabel} Plan — ${periodLabel} Subscription`,
amount: data.amount,
currency: data.currency,
periodStart: data.subscription?.currentPeriodStart ?? null,
periodEnd: data.subscription?.currentPeriodEnd ?? null,
}]
const totals = data.totals ?? {
subtotalAmount: data.amount,
discountAmount: 0,
creditAmount: 0,
taxAmount: 0,
totalAmount: data.amount,
amountPaid: data.paidAt ? data.amount : 0,
amountDue: data.paidAt ? 0 : data.amount,
}
return React.createElement(
Document,
{ author: 'RentalDriveGo', title: `Invoice ${data.invoiceNumber}`, subject: 'Subscription Invoice' },
React.createElement(
Page,
{ size: 'A4', style: s.page },
// ── Header ──────────────────────────────────────────────────
React.createElement(
View,
{ style: s.header },
React.createElement(
View,
null,
React.createElement(Text, { style: s.brandName }, 'RentalDriveGo'),
React.createElement(Text, { style: s.brandTagline }, 'Car Rental Management Platform'),
),
React.createElement(
View,
{ style: s.invoiceMeta },
React.createElement(Text, { style: s.invoiceTitle }, 'INVOICE'),
React.createElement(Text, { style: s.invoiceNumber }, data.invoiceNumber),
React.createElement(
View,
{ style: [s.statusBadge, { backgroundColor: statusColor }] },
React.createElement(Text, { style: s.statusText }, data.status),
),
),
),
// ── Bill To / Invoice Dates ──────────────────────────────────
React.createElement(
View,
{ style: s.row },
React.createElement(
View,
{ style: s.col },
React.createElement(Text, { style: s.colLabel }, 'Bill To'),
React.createElement(Text, { style: s.companyName }, data.company.name),
React.createElement(Text, { style: s.companyDetail }, data.company.email),
data.company.phone
? React.createElement(Text, { style: s.companyDetail }, data.company.phone)
: null,
addressStr
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, addressStr)
: null,
),
React.createElement(
View,
{ style: s.col },
React.createElement(Text, { style: s.colLabel }, 'Invoice Details'),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Invoice Number'),
React.createElement(Text, { style: s.metaValue }, data.invoiceNumber),
),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Issue Date'),
React.createElement(Text, { style: s.metaValue }, fmtDate(data.issueDate)),
),
data.dueDate
? React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Due Date'),
React.createElement(Text, { style: s.metaValue }, fmtDate(data.dueDate)),
)
: null,
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Currency'),
React.createElement(Text, { style: s.metaValue }, data.currency),
),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Billing Period'),
React.createElement(Text, { style: s.metaValue }, periodLabel),
),
data.subscription
? React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Plan'),
React.createElement(Text, { style: s.metaValue }, planLabel),
)
: null,
),
),
// ── Line Items ───────────────────────────────────────────────
React.createElement(
View,
{ style: s.table },
React.createElement(
View,
{ style: s.tableHeader },
React.createElement(Text, { style: [s.tableHeaderCell, s.col60] }, 'Description'),
React.createElement(Text, { style: [s.tableHeaderCell, s.col20Center] }, 'Period'),
React.createElement(Text, { style: [s.tableHeaderCell, s.col20] }, 'Amount'),
),
...lineItems.map((item) => {
const periodStr = item.periodStart && item.periodEnd
? `${fmtDate(item.periodStart)} ${fmtDate(item.periodEnd)}`
: '—'
const description = item.quantity && item.quantity > 1 && item.unitAmount !== undefined
? `${item.description} (${item.quantity} × ${fmt(item.unitAmount, item.currency)})`
: item.description
return React.createElement(
View,
{ key: `${item.description}-${item.amount}-${item.periodStart ?? ''}`, style: s.tableRow },
React.createElement(Text, { style: [s.tableCell, s.col60] }, description),
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr),
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(item.amount, item.currency)),
)
}),
),
// ── Totals ───────────────────────────────────────────────────
React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Subtotal'),
React.createElement(Text, { style: s.totalValue }, fmt(totals.subtotalAmount, data.currency)),
),
totals.discountAmount > 0
? React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Discounts'),
React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.discountAmount, data.currency)}`),
)
: null,
totals.creditAmount > 0
? React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Credits'),
React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.creditAmount, data.currency)}`),
)
: null,
totals.taxAmount > 0
? React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Tax'),
React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)),
)
: null,
React.createElement(
View,
{ style: s.grandTotalRow },
React.createElement(Text, { style: s.grandTotalLabel }, 'Total Due'),
React.createElement(Text, { style: s.grandTotalValue }, fmt(totals.totalAmount, data.currency)),
),
// ── Payment Info ─────────────────────────────────────────────
React.createElement(
View,
{ style: [s.paymentBox, { marginTop: 24 }] },
React.createElement(Text, { style: [s.colLabel, { marginBottom: 10 }] }, 'Payment Information'),
React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Payment Provider'),
React.createElement(Text, { style: s.paymentValue }, data.paymentProvider),
),
data.transactionId
? React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Transaction ID'),
React.createElement(Text, { style: s.paymentValue }, data.transactionId),
)
: null,
React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Payment Date'),
React.createElement(Text, { style: s.paymentValue }, fmtDate(data.paidAt)),
),
React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Status'),
React.createElement(Text, { style: [s.paymentValue, { color: statusColor }] }, data.status),
),
),
// ── Footer ───────────────────────────────────────────────────
React.createElement(
View,
{ style: s.footer },
React.createElement(Text, { style: s.footerText }, 'RentalDriveGo — Car Rental Management Platform'),
React.createElement(Text, { style: s.footerText }, `Generated on ${fmtDate(new Date().toISOString())}`),
),
),
)
}
export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
const doc = React.createElement(InvoiceDocument, { data })
const buffer = await renderToBuffer(doc as any)
return buffer as unknown as Buffer
}
export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
const year = createdAt.getFullYear()
const short = invoiceId.slice(-6).toUpperCase()
return `INV-${year}-${short}`
}
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/prisma', () => ({
prisma: {
customer: {
findFirstOrThrow: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
}))
import { prisma } from '../lib/prisma'
import { validateAndFlagLicense, validateLicense } from './licenseValidationService'
describe('licenseValidationService', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('treats missing license expiry as valid but explicitly records that no date exists', () => {
expect(validateLicense(null)).toEqual({
status: 'VALID',
daysUntilExpiry: null,
requiresApproval: false,
message: 'No expiry date on record',
})
})
it('requires approval for licenses expiring inside the three-month risk window', () => {
const result = validateLicense(new Date('2026-07-09T12:00:00.000Z'))
expect(result.status).toBe('EXPIRING')
expect(result.daysUntilExpiry).toBe(30)
expect(result.requiresApproval).toBe(true)
expect(result.message).toContain('License expires in 30 day(s)')
})
it('requires approval for already expired licenses and reports negative days until expiry', () => {
const result = validateLicense(new Date('2026-06-04T12:00:00.000Z'))
expect(result.status).toBe('EXPIRED')
expect(result.daysUntilExpiry).toBe(-5)
expect(result.requiresApproval).toBe(true)
})
it('persists customer flags for risky licenses without clearing existing safe-customer metadata unnecessarily', async () => {
vi.mocked(prisma.customer.findFirstOrThrow).mockResolvedValue({
id: 'customer_1',
licenseExpiry: new Date('2026-06-20T12:00:00.000Z'),
flagged: false,
flagReason: null,
} as never)
const result = await validateAndFlagLicense('customer_1')
expect(result.status).toBe('EXPIRING')
expect(prisma.customer.update).toHaveBeenCalledWith({
where: { id: 'customer_1' },
data: expect.objectContaining({
licenseExpired: false,
licenseExpiringSoon: true,
licenseValidationStatus: 'EXPIRING',
flagged: true,
flagReason: expect.stringContaining('License expires in 11 day(s)'),
}),
})
})
})
@@ -0,0 +1,66 @@
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, companyId?: string) {
const where = companyId ? { id: customerId, companyId } : { id: customerId }
const customer = await prisma.customer.findFirstOrThrow({ where })
const result = validateLicense(customer.licenseExpiry)
const status: LicenseStatus =
result.status === 'EXPIRED' ? 'EXPIRED' :
result.status === 'EXPIRING' ? 'EXPIRING' : 'VALID'
if (companyId) {
await prisma.customer.updateMany({
where: { id: customerId, companyId },
data: {
licenseExpired: result.status === 'EXPIRED',
licenseExpiringSoon: result.status === 'EXPIRING',
licenseValidationStatus: status,
flagged: result.requiresApproval ? true : customer.flagged,
flagReason: result.requiresApproval ? result.message : customer.flagReason,
},
})
} else {
await prisma.customer.update({
where: { id: customerId },
data: {
licenseExpired: result.status === 'EXPIRED',
licenseExpiringSoon: result.status === 'EXPIRING',
licenseValidationStatus: status,
flagged: result.requiresApproval ? true : customer.flagged,
flagReason: result.requiresApproval ? result.message : customer.flagReason,
},
})
}
return result
}
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import {
coerceNotificationLocale,
formatLocalizedCurrency,
localizeBillingPeriod,
localizeInvoiceStatus,
localizePlanName,
renderLocalizedEmailHtml,
renderNotificationText,
resolveNotificationLocale,
resolveNotificationTemplate,
} from './notificationLocalizationService'
function dbStub(overrides: Record<string, any> = {}) {
return {
employee: { findUnique: vi.fn().mockResolvedValue(null) },
renter: { findUnique: vi.fn().mockResolvedValue(null) },
brandSettings: { findUnique: vi.fn().mockResolvedValue(null) },
billingAccount: { findUnique: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null) },
notificationTemplate: { findFirst: vi.fn().mockResolvedValue(null) },
...overrides,
}
}
describe('notificationLocalizationService formatting and fallback boundaries', () => {
it('coerces unsupported or blank locales to English', () => {
expect(coerceNotificationLocale('fr')).toBe('fr')
expect(coerceNotificationLocale('es')).toBe('en')
expect(coerceNotificationLocale(null)).toBe('en')
})
it('formats template values across currency, dates and status objects', () => {
const rendered = renderNotificationText(
'Total {{amount}}, due {{dueDate}}, status {{invoiceStatus}}, plan {{plan}}',
{
amount: { type: 'currency', amountMinor: 123450, currency: 'MAD' },
dueDate: { type: 'date', value: '2026-06-10T00:00:00.000Z', options: { month: 'short', day: 'numeric' } },
invoiceStatus: { type: 'invoice_status', status: 'partially_paid' },
plan: true,
},
['amount', 'dueDate', 'invoiceStatus'],
'en',
)
expect(rendered).toContain('MAD')
expect(rendered).toContain('Jun')
expect(rendered).toContain('Partially paid')
expect(rendered).toContain('true')
})
it('throws before rendering when required variables are missing', () => {
expect(() => renderNotificationText('Hello {{firstName}}', {}, ['firstName'], 'en')).toThrow(
'Missing notification template variable: firstName',
)
})
it('escapes localized email body text while preserving paragraphs and line breaks', () => {
const html = renderLocalizedEmailHtml('Hello <Admin> & team\nLine 2\n\nSecond paragraph', 'en')
expect(html).toContain('&lt;Admin&gt; &amp; team')
expect(html).toContain('Line 2')
expect(html).toContain('<br />')
expect(html).toContain('text-align:left')
})
it('uses explicit locale without querying user, workspace or billing preferences', async () => {
const db = dbStub()
await expect(resolveNotificationLocale({ locale: 'ar', companyId: 'company_1', employeeId: 'employee_1' }, db as any)).resolves.toBe('ar')
expect(db.employee.findUnique).not.toHaveBeenCalled()
expect(db.brandSettings.findUnique).not.toHaveBeenCalled()
expect(db.billingAccount.findFirst).not.toHaveBeenCalled()
})
it('resolves renter preference before workspace and billing defaults', async () => {
const db = dbStub({
renter: { findUnique: vi.fn().mockResolvedValue({ preferredLocale: 'fr' }) },
brandSettings: { findUnique: vi.fn().mockResolvedValue({ defaultLocale: 'ar' }) },
billingAccount: { findUnique: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue({ preferredLanguage: 'en' }) },
})
await expect(resolveNotificationLocale({ companyId: 'company_1', renterId: 'renter_1' }, db as any)).resolves.toBe('fr')
})
it('throws a clear error when neither requested nor fallback templates exist', async () => {
const db = dbStub()
await expect(resolveNotificationTemplate({
templateKey: 'missing.template',
channel: 'EMAIL',
locale: 'fr',
}, db as any)).rejects.toThrow('Missing notification template for missing.template/EMAIL in fr and en')
})
it('keeps unknown labels unchanged instead of fabricating translations', () => {
expect(localizeInvoiceStatus('custom_status', 'fr')).toBe('custom_status')
expect(localizeBillingPeriod('quarterly', 'ar')).toBe('quarterly')
expect(localizePlanName('enterprise', 'en')).toBe('enterprise')
expect(formatLocalizedCurrency(5000, 'MAD', 'fr')).toContain('MAD')
})
})
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest'
import {
renderLocalizedEmailHtml,
resolveNotificationLocale,
resolveNotificationTemplate,
} from './notificationLocalizationService'
function createDbStub(overrides: Record<string, any> = {}) {
return {
employee: {
findUnique: vi.fn().mockResolvedValue(null),
},
renter: {
findUnique: vi.fn().mockResolvedValue(null),
},
brandSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
billingAccount: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi.fn().mockResolvedValue(null),
},
notificationTemplate: {
findFirst: vi.fn().mockResolvedValue(null),
},
...overrides,
}
}
describe('notificationLocalizationService', () => {
it('resolves locale in recipient -> workspace -> billing -> default order', async () => {
const db = createDbStub({
employee: {
findUnique: vi.fn().mockResolvedValue({ preferredLanguage: 'ar' }),
},
brandSettings: {
findUnique: vi.fn().mockResolvedValue({ defaultLocale: 'fr' }),
},
billingAccount: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi.fn().mockResolvedValue({ preferredLanguage: 'en' }),
},
})
const locale = await resolveNotificationLocale({
companyId: 'company_1',
employeeId: 'employee_1',
}, db as any)
expect(locale).toBe('ar')
})
it('falls back to English when a localized template is missing', async () => {
const db = createDbStub({
notificationTemplate: {
findFirst: vi
.fn()
.mockImplementation(({ where }: any) => {
if (where.locale === 'en') {
return Promise.resolve({
templateKey: 'booking.confirmed',
channel: 'EMAIL',
locale: 'en',
subject: 'Booking confirmed - {{companyName}}',
body: 'Hello {{firstName}}',
requiredVariables: ['companyName', 'firstName'],
optionalVariables: [],
version: 1,
})
}
return Promise.resolve(null)
}),
},
})
const template = await resolveNotificationTemplate({
templateKey: 'booking.confirmed',
channel: 'EMAIL',
locale: 'ar',
variables: {
firstName: 'Aya',
companyName: 'Atlas Cars',
},
}, db as any)
expect(template.locale).toBe('en')
expect(template.usedFallback).toBe(true)
expect(template.subject).toBe('Booking confirmed - Atlas Cars')
expect(template.body).toBe('Hello Aya')
})
it('renders Arabic emails with rtl metadata', () => {
const html = renderLocalizedEmailHtml('مرحبا\n\nتم التأكيد', 'ar')
expect(html).toContain('<html lang="ar" dir="rtl">')
expect(html).toContain('text-align:right')
})
})
@@ -0,0 +1,440 @@
import type { NotificationChannel } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
export const SUPPORTED_NOTIFICATION_LOCALES = ['en', 'fr', 'ar'] as const
export type NotificationLocale = (typeof SUPPORTED_NOTIFICATION_LOCALES)[number]
export const DEFAULT_NOTIFICATION_LOCALE: NotificationLocale = 'en'
const localeConfig: Record<NotificationLocale, { intl: string; direction: 'ltr' | 'rtl' }> = {
en: { intl: 'en-US', direction: 'ltr' },
fr: { intl: 'fr-FR', direction: 'ltr' },
ar: { intl: 'ar-MA', direction: 'rtl' },
}
const subscriptionStatusLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
trialing: 'Trial active',
active: 'Active',
payment_pending: 'Payment pending',
past_due: 'Payment overdue',
suspended: 'Suspended',
cancelled: 'Canceled',
canceled: 'Canceled',
expired: 'Expired',
},
fr: {
trialing: 'Essai actif',
active: 'Actif',
payment_pending: 'Paiement en attente',
past_due: 'Paiement en retard',
suspended: 'Suspendu',
cancelled: 'Annule',
canceled: 'Annule',
expired: 'Expire',
},
ar: {
trialing: 'الفترة التجريبية نشطة',
active: 'نشط',
payment_pending: 'الدفع قيد الانتظار',
past_due: 'الدفع متأخر',
suspended: 'معلّق',
cancelled: 'ملغى',
canceled: 'ملغى',
expired: 'منتهي',
},
}
const invoiceStatusLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
draft: 'Preparing',
open: 'Due',
payment_pending: 'Payment processing',
paid: 'Paid',
partially_paid: 'Partially paid',
past_due: 'Past due',
void: 'Canceled',
uncollectible: 'Contact support',
refunded: 'Refunded',
partially_refunded: 'Partially refunded',
},
fr: {
draft: 'En preparation',
open: 'A payer',
payment_pending: 'Paiement en cours',
paid: 'Payee',
partially_paid: 'Partiellement payee',
past_due: 'En retard',
void: 'Annulee',
uncollectible: 'Contacter le support',
refunded: 'Remboursee',
partially_refunded: 'Partiellement remboursee',
},
ar: {
draft: 'قيد الإعداد',
open: 'مستحقة الدفع',
payment_pending: 'الدفع قيد المعالجة',
paid: 'مدفوعة',
partially_paid: 'مدفوعة جزئياً',
past_due: 'متأخرة',
void: 'ملغاة',
uncollectible: 'تواصل مع الدعم',
refunded: 'مستردة',
partially_refunded: 'مستردة جزئياً',
},
}
const billingPeriodLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
monthly: 'monthly',
annual: 'annual',
},
fr: {
monthly: 'mensuel',
annual: 'annuel',
},
ar: {
monthly: 'شهري',
annual: 'سنوي',
},
}
const planLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
starter: 'Starter',
growth: 'Growth',
pro: 'Pro',
},
fr: {
starter: 'Starter',
growth: 'Growth',
pro: 'Pro',
},
ar: {
starter: 'Starter',
growth: 'Growth',
pro: 'Pro',
},
}
type TemplateDateValue = {
type: 'date'
value: Date | string | number
options?: Intl.DateTimeFormatOptions
}
type TemplateCurrencyValue = {
type: 'currency'
amountMinor: number
currency: string
}
type TemplateSubscriptionStatusValue = {
type: 'subscription_status'
status: string
}
type TemplateInvoiceStatusValue = {
type: 'invoice_status'
status: string
}
export type NotificationTemplateValue =
| string
| number
| boolean
| Date
| null
| undefined
| TemplateDateValue
| TemplateCurrencyValue
| TemplateSubscriptionStatusValue
| TemplateInvoiceStatusValue
export type NotificationTemplateVariables = Record<string, NotificationTemplateValue>
type NotificationLocalizationDb = Pick<
typeof prisma,
'brandSettings' | 'billingAccount' | 'employee' | 'notificationTemplate' | 'renter'
>
type NotificationTemplateRecord = {
templateKey: string
channel: NotificationChannel
locale: string
subject: string | null
body: string
requiredVariables: unknown
optionalVariables: unknown
version: number
}
export function coerceNotificationLocale(locale?: string | null): NotificationLocale {
if (locale && SUPPORTED_NOTIFICATION_LOCALES.includes(locale as NotificationLocale)) {
return locale as NotificationLocale
}
return DEFAULT_NOTIFICATION_LOCALE
}
export function getNotificationTextDirection(locale: NotificationLocale) {
return localeConfig[locale].direction
}
export function formatLocalizedDate(
value: Date | string | number,
locale: NotificationLocale,
options?: Intl.DateTimeFormatOptions,
) {
const date = value instanceof Date ? value : new Date(value)
return new Intl.DateTimeFormat(localeConfig[locale].intl, options ?? {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
export function formatLocalizedCurrency(amountMinor: number, currency: string, locale: NotificationLocale) {
return new Intl.NumberFormat(localeConfig[locale].intl, {
style: 'currency',
currency,
}).format(amountMinor / 100)
}
export function localizeSubscriptionStatus(status: string, locale: NotificationLocale) {
const normalized = status.toLowerCase()
return subscriptionStatusLabels[locale][normalized] ?? status
}
export function localizeInvoiceStatus(status: string, locale: NotificationLocale) {
const normalized = status.toLowerCase()
return invoiceStatusLabels[locale][normalized] ?? status
}
export function localizeBillingPeriod(period: string, locale: NotificationLocale) {
const normalized = period.toLowerCase()
return billingPeriodLabels[locale][normalized] ?? period
}
export function localizePlanName(plan: string, locale: NotificationLocale) {
const normalized = plan.toLowerCase()
return planLabels[locale][normalized] ?? plan
}
function isTemplateDateValue(value: NotificationTemplateValue): value is TemplateDateValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'date'
}
function isTemplateCurrencyValue(value: NotificationTemplateValue): value is TemplateCurrencyValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'currency'
}
function isTemplateSubscriptionStatusValue(value: NotificationTemplateValue): value is TemplateSubscriptionStatusValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'subscription_status'
}
function isTemplateInvoiceStatusValue(value: NotificationTemplateValue): value is TemplateInvoiceStatusValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'invoice_status'
}
function formatTemplateValue(value: NotificationTemplateValue, locale: NotificationLocale): string {
if (value instanceof Date) return formatLocalizedDate(value, locale)
if (isTemplateDateValue(value)) return formatLocalizedDate(value.value, locale, value.options)
if (isTemplateCurrencyValue(value)) return formatLocalizedCurrency(value.amountMinor, value.currency, locale)
if (isTemplateSubscriptionStatusValue(value)) return localizeSubscriptionStatus(value.status, locale)
if (isTemplateInvoiceStatusValue(value)) return localizeInvoiceStatus(value.status, locale)
if (value === null || value === undefined) return ''
return String(value)
}
function normalizeTemplateVariableList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === 'string')
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
async function findTemplate(
db: NotificationLocalizationDb,
templateKey: string,
channel: NotificationChannel,
locale: NotificationLocale,
): Promise<NotificationTemplateRecord | null> {
return db.notificationTemplate.findFirst({
where: { templateKey, channel, locale, isActive: true },
orderBy: { version: 'desc' },
select: {
templateKey: true,
channel: true,
locale: true,
subject: true,
body: true,
requiredVariables: true,
optionalVariables: true,
version: true,
},
})
}
export async function resolveNotificationLocale(input: {
companyId?: string | null
employeeId?: string | null
renterId?: string | null
billingAccountId?: string | null
locale?: string | null
}, db: NotificationLocalizationDb = prisma): Promise<NotificationLocale> {
if (input.locale) return coerceNotificationLocale(input.locale)
const employeePromise = input.employeeId
? db.employee.findUnique({
where: { id: input.employeeId },
select: { preferredLanguage: true },
})
: Promise.resolve(null)
const renterPromise = input.renterId
? db.renter.findUnique({
where: { id: input.renterId },
select: { preferredLocale: true },
})
: Promise.resolve(null)
const workspacePromise = input.companyId
? db.brandSettings.findUnique({
where: { companyId: input.companyId },
select: { defaultLocale: true },
})
: Promise.resolve(null)
const billingPromise = input.billingAccountId
? db.billingAccount.findUnique({
where: { id: input.billingAccountId },
select: { preferredLanguage: true },
})
: input.companyId
? db.billingAccount.findFirst({
where: { companyId: input.companyId, isPrimary: true },
select: { preferredLanguage: true },
orderBy: { createdAt: 'asc' },
})
: Promise.resolve(null)
const [employee, renter, workspace, billingAccount] = await Promise.all([
employeePromise,
renterPromise,
workspacePromise,
billingPromise,
])
return coerceNotificationLocale(
employee?.preferredLanguage ??
renter?.preferredLocale ??
workspace?.defaultLocale ??
billingAccount?.preferredLanguage ??
DEFAULT_NOTIFICATION_LOCALE,
)
}
export async function resolveNotificationTemplate(input: {
templateKey: string
channel: NotificationChannel
locale: NotificationLocale
variables?: NotificationTemplateVariables
}, db: NotificationLocalizationDb = prisma) {
const requested = await findTemplate(db, input.templateKey, input.channel, input.locale)
if (requested) {
return {
locale: coerceNotificationLocale(requested.locale),
subject: renderNotificationText(
requested.subject,
input.variables,
normalizeTemplateVariableList(requested.requiredVariables),
input.locale,
),
body: renderNotificationText(
requested.body,
input.variables,
normalizeTemplateVariableList(requested.requiredVariables),
input.locale,
),
templateKey: requested.templateKey,
usedFallback: false,
}
}
if (input.locale !== DEFAULT_NOTIFICATION_LOCALE) {
console.warn(
`[Notifications] Missing ${input.locale} template for ${input.templateKey}/${input.channel}; falling back to ${DEFAULT_NOTIFICATION_LOCALE}.`,
)
}
const fallback = await findTemplate(db, input.templateKey, input.channel, DEFAULT_NOTIFICATION_LOCALE)
if (!fallback) {
throw new Error(
`Missing notification template for ${input.templateKey}/${input.channel} in ${input.locale} and ${DEFAULT_NOTIFICATION_LOCALE}`,
)
}
return {
locale: DEFAULT_NOTIFICATION_LOCALE,
subject: renderNotificationText(
fallback.subject,
input.variables,
normalizeTemplateVariableList(fallback.requiredVariables),
DEFAULT_NOTIFICATION_LOCALE,
),
body: renderNotificationText(
fallback.body,
input.variables,
normalizeTemplateVariableList(fallback.requiredVariables),
DEFAULT_NOTIFICATION_LOCALE,
),
templateKey: fallback.templateKey,
usedFallback: true,
}
}
export function renderNotificationText(
template: string | null,
variables: NotificationTemplateVariables = {},
requiredVariables: string[] = [],
locale: NotificationLocale,
) {
if (!template) return null
for (const variableName of requiredVariables) {
if (variables[variableName] === undefined || variables[variableName] === null) {
throw new Error(`Missing notification template variable: ${variableName}`)
}
}
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, variableName: string) =>
formatTemplateValue(variables[variableName], locale),
)
}
export function renderLocalizedEmailHtml(body: string, locale: NotificationLocale) {
const direction = getNotificationTextDirection(locale)
const textAlign = direction === 'rtl' ? 'right' : 'left'
const paragraphs = body
.split(/\n{2,}/)
.map((paragraph) => `<p style="margin:0 0 16px;text-align:${textAlign};">${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
.join('')
return [
`<html lang="${locale}" dir="${direction}">`,
`<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:32px;color:#1c1917;direction:${direction};text-align:${textAlign};">`,
paragraphs,
'</body>',
'</html>',
].join('')
}
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('resend', () => ({ Resend: vi.fn() }))
vi.mock('twilio', () => ({ default: vi.fn(() => ({ messages: { create: vi.fn() } })) }))
vi.mock('firebase-admin', () => ({
default: {
apps: [],
initializeApp: vi.fn(),
credential: { cert: vi.fn() },
messaging: vi.fn(() => ({ send: vi.fn() })),
},
}))
vi.mock('../lib/prisma', () => ({
prisma: {
notification: {
create: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock('../lib/redis', () => ({
redis: { publish: vi.fn(), on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
}))
vi.mock('./notificationLocalizationService', async () => {
const actual = await vi.importActual<typeof import('./notificationLocalizationService')>('./notificationLocalizationService')
return {
...actual,
resolveNotificationLocale: vi.fn().mockResolvedValue('en'),
resolveNotificationTemplate: vi.fn(),
}
})
import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { sendNotification } from './notificationService'
import { resolveNotificationLocale, resolveNotificationTemplate } from './notificationLocalizationService'
describe('notificationService delivery boundaries', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.notification.create).mockResolvedValue({ id: 'notification_1', title: 'Hello', body: 'Body' } as never)
vi.mocked(prisma.notification.update).mockResolvedValue({ id: 'notification_1' } as never)
vi.mocked(resolveNotificationLocale).mockResolvedValue('en')
vi.mocked(resolveNotificationTemplate).mockResolvedValue(null as never)
})
it('persists and publishes in-app notifications for employee recipients', async () => {
const result = await sendNotification({
type: 'SYSTEM_ALERT' as never,
title: 'Maintenance window',
body: 'The dashboard will be unavailable.',
companyId: 'company_1',
employeeId: 'employee_1',
channels: ['IN_APP' as never],
data: { severity: 'low' },
})
expect(result).toEqual([{ channel: 'IN_APP', success: true }])
expect(resolveNotificationLocale).toHaveBeenCalledWith(expect.objectContaining({
companyId: 'company_1',
employeeId: 'employee_1',
}))
expect(prisma.notification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
type: 'SYSTEM_ALERT',
title: 'Maintenance window',
body: 'The dashboard will be unavailable.',
channel: 'IN_APP',
status: 'PENDING',
companyId: 'company_1',
employeeId: 'employee_1',
renterId: null,
}),
})
expect(redis.publish).toHaveBeenCalledWith(
'notifications:employee_1',
expect.stringContaining('"status":"DELIVERED"'),
)
expect(prisma.notification.update).toHaveBeenCalledWith({
where: { id: 'notification_1' },
data: expect.objectContaining({ status: 'SENT', providerMessageId: null }),
})
})
it('uses resolved templates and records failed channels without throwing', async () => {
vi.mocked(resolveNotificationTemplate).mockResolvedValue({
templateKey: 'reservation.confirmed',
locale: 'fr',
subject: 'Réservation confirmée',
body: 'Votre réservation est confirmée.',
usedFallback: false,
version: 2,
} as never)
const result = await sendNotification({
type: 'RESERVATION_UPDATE' as never,
templateKey: 'reservation.confirmed',
templateVariables: { firstName: 'Aya' },
companyId: 'company_1',
channels: ['EMAIL' as never],
email: 'aya@example.test',
})
expect(result).toEqual([{ channel: 'EMAIL', success: false, error: 'No email provider is configured' }])
expect(resolveNotificationTemplate).toHaveBeenCalledWith(expect.objectContaining({
templateKey: 'reservation.confirmed',
channel: 'EMAIL',
locale: 'en',
variables: { firstName: 'Aya' },
}))
expect(prisma.notification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
title: 'Réservation confirmée',
body: 'Votre réservation est confirmée.',
templateKey: 'reservation.confirmed',
locale: 'fr',
}),
})
expect(prisma.notification.update).not.toHaveBeenCalled()
})
it('fails incomplete notification content before persistence', async () => {
const result = await sendNotification({
type: 'SYSTEM_ALERT' as never,
title: 'Missing body',
channels: ['IN_APP' as never],
employeeId: 'employee_1',
})
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'Notification content is incomplete for channel IN_APP' }])
expect(prisma.notification.create).not.toHaveBeenCalled()
expect(redis.publish).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,334 @@
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'
import {
renderLocalizedEmailHtml,
resolveNotificationLocale,
resolveNotificationTemplate,
} from './notificationLocalizationService'
import type { NotificationTemplateVariables } from './notificationLocalizationService'
const resendApiKey =
process.env.RESEND_API_KEY &&
process.env.RESEND_API_KEY !== 're_...' &&
process.env.RESEND_API_KEY.startsWith('re_')
? process.env.RESEND_API_KEY
: null
const resend = resendApiKey ? new Resend(resendApiKey) : null
const emailFromAddress =
process.env.EMAIL_FROM && process.env.EMAIL_FROM !== 'noreply@example.com'
? process.env.EMAIL_FROM
: null
const emailFromName =
process.env.EMAIL_FROM_NAME && process.env.EMAIL_FROM_NAME !== 'Example App'
? process.env.EMAIL_FROM_NAME
: null
const smtpHost = process.env.MAIL_HOST
const smtpPort = Number(process.env.MAIL_PORT ?? 0)
const smtpUser = process.env.MAIL_USERNAME
const smtpPass = process.env.MAIL_PASSWORD
const smtpSecure =
process.env.MAIL_SCHEME === 'smtps' ||
process.env.MAIL_ENCRYPTION === 'ssl' ||
smtpPort === 465
const smtpFromAddress =
process.env.MAIL_FROM_ADDRESS && !process.env.MAIL_FROM_ADDRESS.includes('${')
? process.env.MAIL_FROM_ADDRESS
: null
const smtpFromName =
process.env.MAIL_FROM_NAME && !process.env.MAIL_FROM_NAME.includes('${')
? process.env.MAIL_FROM_NAME
: null
type SmtpTransport = {
sendMail(options: Record<string, unknown>): Promise<{ messageId?: string }>
}
let smtpTransport: SmtpTransport | null = null
if (smtpHost && smtpPort && smtpUser && smtpPass) {
try {
const nodemailer = require('nodemailer') as {
createTransport(options: Record<string, unknown>): SmtpTransport
}
smtpTransport = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass,
},
})
} catch (err: any) {
console.warn('[Notifications] SMTP transport unavailable:', err?.message ?? String(err))
}
}
const twilioClient =
process.env.TWILIO_ACCOUNT_SID &&
process.env.TWILIO_AUTH_TOKEN &&
process.env.TWILIO_ACCOUNT_SID !== 'AC...' &&
process.env.TWILIO_AUTH_TOKEN !== 'your-twilio-auth-token'
? twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
: null
const firebasePrivateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
const hasFirebaseConfig =
!!process.env.FIREBASE_PROJECT_ID &&
!!process.env.FIREBASE_CLIENT_EMAIL &&
!!firebasePrivateKey &&
!firebasePrivateKey.includes('BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY')
if (hasFirebaseConfig && !admin.apps.length) {
try {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
privateKey: firebasePrivateKey,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
}),
})
} catch (err: any) {
console.warn('[Notifications] Firebase init skipped:', err.message)
}
}
interface SendNotificationOptions {
type: NotificationType
title?: string
body?: string
data?: Record<string, unknown>
companyId?: string
employeeId?: string
renterId?: string
billingAccountId?: string
email?: string
phone?: string
fcmToken?: string
channels: NotificationChannel[]
locale?: string
templateKey?: string
templateVariables?: NotificationTemplateVariables
}
function resolveSmtpReplyTo() {
if (!process.env.MAIL_REPLY_TO_ADDRESS || process.env.MAIL_REPLY_TO_ADDRESS.includes('${')) {
return undefined
}
if (process.env.MAIL_REPLY_TO_NAME && !process.env.MAIL_REPLY_TO_NAME.includes('${')) {
return `${process.env.MAIL_REPLY_TO_NAME} <${process.env.MAIL_REPLY_TO_ADDRESS}>`
}
return process.env.MAIL_REPLY_TO_ADDRESS
}
async function sendEmailWithProviders(opts: {
to: string
subject: string
html: string
text: string
}) {
const errors: string[] = []
if (resend) {
try {
if (!emailFromAddress || !emailFromName) {
throw new Error('Email sender identity is not configured')
}
const { data, error } = await resend.emails.send({
from: `${emailFromName} <${emailFromAddress}>`,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
})
if (error) {
throw new Error(error.message)
}
return {
provider: 'resend' as const,
providerMessageId: data?.id ?? null,
}
} catch (err: any) {
errors.push(`Resend: ${err?.message ?? String(err)}`)
console.warn('[Notifications] Resend delivery failed, falling back if possible:', err?.message ?? String(err))
}
}
if (smtpTransport) {
try {
if (!smtpFromAddress) {
throw new Error('SMTP sender identity is not configured')
}
const info = await smtpTransport.sendMail({
from: smtpFromName ? `${smtpFromName} <${smtpFromAddress}>` : smtpFromAddress,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
replyTo: resolveSmtpReplyTo(),
})
return {
provider: 'smtp' as const,
providerMessageId: info.messageId ?? null,
}
} catch (err: any) {
errors.push(`SMTP: ${err?.message ?? String(err)}`)
}
}
if (errors.length) {
throw new Error(errors.join(' | '))
}
throw new Error('No email provider is configured')
}
export async function sendNotification(opts: SendNotificationOptions) {
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = []
const resolvedLocale = await resolveNotificationLocale({
companyId: opts.companyId,
employeeId: opts.employeeId,
renterId: opts.renterId,
billingAccountId: opts.billingAccountId,
locale: opts.locale,
})
for (const channel of opts.channels) {
try {
const rendered = opts.templateKey
? await resolveNotificationTemplate({
templateKey: opts.templateKey,
channel,
locale: resolvedLocale,
variables: opts.templateVariables,
})
: null
const title = rendered?.subject ?? opts.title
const body = rendered?.body ?? opts.body
if (!title || !body) {
throw new Error(`Notification content is incomplete for channel ${channel}`)
}
const notification = await prisma.notification.create({
data: {
type: opts.type,
title,
body,
data: (opts.data ?? {}) as any,
channel,
templateKey: rendered?.templateKey ?? opts.templateKey ?? null,
locale: rendered?.locale ?? resolvedLocale,
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 emailResult = await sendEmailWithProviders({
to: opts.email,
subject: title,
html: renderLocalizedEmailHtml(body, rendered?.locale ?? resolvedLocale),
text: body,
})
providerMessageId = emailResult.providerMessageId
success = true
}
if (channel === 'SMS' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({
body,
from: process.env.TWILIO_PHONE_NUMBER!,
to: opts.phone,
})
providerMessageId = msg.sid
success = true
}
if (channel === 'WHATSAPP' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({
body,
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
to: `whatsapp:${opts.phone}`,
})
providerMessageId = msg.sid
success = true
}
if (channel === 'PUSH' && opts.fcmToken) {
if (!admin.apps.length) throw new Error('Firebase is not configured')
const response = await admin.messaging().send({
token: opts.fcmToken,
notification: { title, 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
}
export async function sendTransactionalEmail(opts: {
to: string
subject: string
html: string
text: string
}) {
await sendEmailWithProviders(opts)
}
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalEnv = { ...process.env }
async function importService(env: Record<string, string | undefined> = {}) {
vi.resetModules()
process.env = { ...originalEnv, ...env }
return import('./paypalService')
}
describe('paypalService', () => {
afterEach(() => {
process.env = { ...originalEnv }
vi.unstubAllGlobals()
vi.resetModules()
})
it('reports unconfigured when client credentials are placeholders or missing', async () => {
const service = await importService({ PAYPAL_CLIENT_ID: 'your-paypal-client-id', PAYPAL_CLIENT_SECRET: 'secret' })
expect(service.isConfigured()).toBe(false)
})
it('creates an order after obtaining and caching an access token', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue({ access_token: 'access_1', expires_in: 3600 }),
})
.mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue({
id: 'paypal_order_1',
status: 'CREATED',
links: [{ rel: 'approve', href: 'https://paypal.test/approve' }],
}),
})
.mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue({ id: 'paypal_order_2', status: 'CREATED', links: [] }),
})
vi.stubGlobal('fetch', fetchMock)
const service = await importService({
PAYPAL_BASE_URL: 'https://paypal.test',
PAYPAL_CLIENT_ID: 'client_1',
PAYPAL_CLIENT_SECRET: 'secret_1',
})
await expect(service.createOrder({
amount: 12345,
currency: 'MAD',
orderId: 'reservation_1',
description: 'Reservation deposit',
returnUrl: 'https://app.test/return',
cancelUrl: 'https://app.test/cancel',
})).resolves.toEqual({
orderId: 'paypal_order_1',
approveUrl: 'https://paypal.test/approve',
status: 'CREATED',
})
await service.createOrder({
amount: 500,
currency: 'MAD',
orderId: 'reservation_2',
description: 'Second payment',
returnUrl: 'https://app.test/return',
cancelUrl: 'https://app.test/cancel',
})
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://paypal.test/v1/oauth2/token', expect.objectContaining({
method: 'POST',
body: 'grant_type=client_credentials',
headers: expect.objectContaining({ Authorization: `Basic ${Buffer.from('client_1:secret_1').toString('base64')}` }),
}))
expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://paypal.test/v2/checkout/orders', expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: 'Bearer access_1' }),
body: expect.stringContaining('"value":"123.45"'),
}))
})
it('captures and refunds through authenticated PayPal endpoints with cent-to-decimal formatting', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ access_token: 'access_1', expires_in: 3600 }) })
.mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ id: 'capture_result' }) })
.mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ id: 'refund_result' }) })
vi.stubGlobal('fetch', fetchMock)
const service = await importService({ PAYPAL_BASE_URL: 'https://paypal.test', PAYPAL_CLIENT_ID: 'client_1', PAYPAL_CLIENT_SECRET: 'secret_1' })
await expect(service.captureOrder('order_1')).resolves.toEqual({ id: 'capture_result' })
await expect(service.refundCapture('capture_1', 2500, 'MAD', 'Customer cancellation')).resolves.toEqual({ id: 'refund_result' })
expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://paypal.test/v2/checkout/orders/order_1/capture', expect.objectContaining({
method: 'POST',
body: '{}',
}))
expect(fetchMock).toHaveBeenNthCalledWith(3, 'https://paypal.test/v2/payments/captures/capture_1/refund', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ amount: { currency_code: 'MAD', value: '25.00' }, note_to_payer: 'Customer cancellation' }),
}))
})
it('returns false instead of throwing when webhook verification cannot be completed', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
const service = await importService({ PAYPAL_CLIENT_ID: 'client_1', PAYPAL_CLIENT_SECRET: 'secret_1' })
await expect(service.verifyWebhookEvent({}, '{"id":"event_1"}')).resolves.toBe(false)
})
})
+127
View File
@@ -0,0 +1,127 @@
const BASE_URL = process.env.PAYPAL_BASE_URL ?? 'https://api-m.paypal.com'
const CLIENT_ID = process.env.PAYPAL_CLIENT_ID ?? ''
const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET ?? ''
let cachedToken: { token: string; expiresAt: number } | null = null
async function getAccessToken(): Promise<string> {
if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) {
return cachedToken.token
}
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
},
body: 'grant_type=client_credentials',
})
if (!res.ok) throw new Error('PayPal: failed to obtain access token')
const json = await res.json() as Record<string, unknown>
cachedToken = { token: json.access_token as string, expiresAt: Date.now() + (json.expires_in as number) * 1000 }
return cachedToken.token
}
async function ppFetch(path: string, init?: RequestInit) {
const token = await getAccessToken()
const res = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(init?.headers as Record<string, string> ?? {}),
},
})
if (!res.ok) {
const err = await res.json().catch(() => ({})) as Record<string, unknown>
throw new Error(`PayPal ${path} failed: ${(err?.message as string) ?? res.statusText}`)
}
return res.json() as Promise<Record<string, unknown>>
}
export interface PayPalCreateOrderParams {
amount: number
currency: string
orderId: string
description: string
returnUrl: string
cancelUrl: string
}
export interface PayPalOrderResult {
orderId: string
approveUrl: string
status: string
}
export async function createOrder(params: PayPalCreateOrderParams): Promise<PayPalOrderResult> {
const json = await ppFetch('/v2/checkout/orders', {
method: 'POST',
body: JSON.stringify({
intent: 'CAPTURE',
purchase_units: [
{
reference_id: params.orderId,
description: params.description,
amount: {
currency_code: params.currency,
value: (params.amount / 100).toFixed(2),
},
},
],
application_context: {
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
brand_name: 'RentalDriveGo',
user_action: 'PAY_NOW',
},
}),
})
const approveLink = (json.links as Array<{ rel: string; href: string }> | undefined)?.find((l) => l.rel === 'approve')
return {
orderId: json.id as string,
approveUrl: approveLink?.href ?? '',
status: json.status as string,
}
}
export async function captureOrder(paypalOrderId: string) {
return ppFetch(`/v2/checkout/orders/${paypalOrderId}/capture`, { method: 'POST', body: '{}' })
}
export async function refundCapture(captureId: string, amount: number, currency: string, note?: string) {
return ppFetch(`/v2/payments/captures/${captureId}/refund`, {
method: 'POST',
body: JSON.stringify({
amount: { currency_code: currency, value: (amount / 100).toFixed(2) },
note_to_payer: note,
}),
})
}
export async function verifyWebhookEvent(headers: Record<string, string | undefined>, rawBody: string) {
try {
const json = await ppFetch('/v1/notifications/verify-webhook-signature', {
method: 'POST',
body: JSON.stringify({
auth_algo: headers['paypal-auth-algo'],
cert_url: headers['paypal-cert-url'],
transmission_id: headers['paypal-transmission-id'],
transmission_sig: headers['paypal-transmission-sig'],
transmission_time: headers['paypal-transmission-time'],
webhook_id: process.env.PAYPAL_WEBHOOK_ID ?? '',
webhook_event: JSON.parse(rawBody),
}),
})
return (json as Record<string, unknown>).verification_status === 'SUCCESS'
} catch {
return false
}
}
export function isConfigured(): boolean {
return !!(CLIENT_ID && CLIENT_SECRET && CLIENT_ID !== 'your-paypal-client-id')
}
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import os from 'os'
import path from 'path'
let originalCwd: string
let tempDir: string
async function importFreshService() {
vi.resetModules()
return import('./platformContentService')
}
describe('platformContentService', () => {
beforeEach(async () => {
originalCwd = process.cwd()
tempDir = await mkdtemp(path.join(os.tmpdir(), 'rdg-platform-content-'))
await import('fs/promises').then((fs) => fs.mkdir(path.join(tempDir, 'apps/api'), { recursive: true }))
process.chdir(path.join(tempDir, 'apps/api'))
})
afterEach(async () => {
process.chdir(originalCwd)
await rm(tempDir, { recursive: true, force: true })
vi.resetModules()
})
it('returns cloned default homepage content when the file does not exist or cannot be parsed', async () => {
const service = await importFreshService()
const first = await service.getMarketplaceHomepageContent()
first.en.heroTitle = 'Mutated by test'
const second = await service.getMarketplaceHomepageContent()
expect(second.en.sections).toContain('hero')
expect(second.en.heroTitle).not.toBe('Mutated by test')
})
it('saves only the marketplace homepage field while preserving unrelated platform content fields', async () => {
const dataPath = path.join(tempDir, 'apps/api/src/data/platform-content.json')
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => {
await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true }))
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8')
})
const service = await importFreshService()
const homepage = await service.getMarketplaceHomepageContent()
homepage.en.heroTitle = 'A better marketplace title'
await expect(service.saveMarketplaceHomepageContent(homepage)).resolves.toBe(homepage)
const raw = JSON.parse(await readFile(dataPath, 'utf8'))
expect(raw.otherSetting).toEqual({ keep: true })
expect(raw.marketplaceHomepage.en.heroTitle).toBe('A better marketplace title')
})
})
@@ -0,0 +1,44 @@
import { mkdir, readFile, writeFile } from 'fs/promises'
import path from 'path'
import { cloneMarketplaceHomepageContent, type MarketplaceHomepageConfig } from '@rentaldrivego/types'
function resolveContentPath() {
const cwd = process.cwd()
const appScoped = path.resolve(cwd, 'src/data/platform-content.json')
const repoScoped = path.resolve(cwd, 'apps/api/src/data/platform-content.json')
return cwd.includes('/apps/api') ? appScoped : repoScoped
}
const platformContentPath = resolveContentPath()
type PlatformContentFile = {
marketplaceHomepage?: MarketplaceHomepageConfig
}
async function readPlatformContentFile(): Promise<PlatformContentFile> {
try {
const raw = await readFile(platformContentPath, 'utf8')
return JSON.parse(raw) as PlatformContentFile
} catch {
return {}
}
}
async function writePlatformContentFile(content: PlatformContentFile) {
await mkdir(path.dirname(platformContentPath), { recursive: true })
await writeFile(platformContentPath, JSON.stringify(content, null, 2))
}
export async function getMarketplaceHomepageContent() {
const content = await readPlatformContentFile()
return content.marketplaceHomepage ?? cloneMarketplaceHomepageContent()
}
export async function saveMarketplaceHomepageContent(homepage: MarketplaceHomepageConfig) {
const content = await readPlatformContentFile()
await writePlatformContentFile({
...content,
marketplaceHomepage: homepage,
})
return homepage
}
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/prisma', () => ({
prisma: {
pricingRule: {
findMany: vi.fn(),
},
customer: {
findFirstOrThrow: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
},
}))
import { prisma } from '../lib/prisma'
import { applyPricingRules } from './pricingRuleService'
describe('applyPricingRules', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
})
it('applies percentage, flat-per-day, and flat pricing rules against the rental base amount', async () => {
vi.mocked(prisma.customer.findFirstOrThrow).mockResolvedValue({
id: 'customer_1',
dateOfBirth: new Date('2008-06-09T00:00:00.000Z'),
licenseIssuedAt: new Date('2025-06-09T00:00:00.000Z'),
} as never)
vi.mocked(prisma.pricingRule.findMany).mockResolvedValue([
{
id: 'young_driver_fee',
name: 'Young driver fee',
condition: 'AGE_LESS_THAN',
conditionValue: 21,
adjustmentType: 'PERCENTAGE',
adjustmentValue: 10,
type: 'SURCHARGE',
},
{
id: 'new_license_fee',
name: 'New license fee',
condition: 'LICENSE_YEARS_LESS_THAN',
conditionValue: 2,
adjustmentType: 'FLAT_PER_DAY',
adjustmentValue: 50,
type: 'SURCHARGE',
},
{
id: 'senior_discount',
name: 'Senior discount',
condition: 'AGE_GREATER_THAN',
conditionValue: 60,
adjustmentType: 'FLAT',
adjustmentValue: 100,
type: 'DISCOUNT',
},
] as never)
const result = await applyPricingRules('company_1', 'customer_1', [
{ dateOfBirth: new Date('1950-01-01T00:00:00.000Z'), licenseIssuedAt: new Date('1980-01-01T00:00:00.000Z') },
], 200, 3)
expect(prisma.pricingRule.findMany).toHaveBeenCalledWith({ where: { companyId: 'company_1', isActive: true } })
expect(result.applied).toEqual([
{ ruleId: 'young_driver_fee', name: 'Young driver fee', type: 'SURCHARGE', amount: 60 },
{ ruleId: 'new_license_fee', name: 'New license fee', type: 'SURCHARGE', amount: 150 },
{ ruleId: 'senior_discount', name: 'Senior discount', type: 'DISCOUNT', amount: -100 },
])
expect(result.total).toBe(110)
})
it('deduplicates a rule when multiple drivers qualify', async () => {
vi.mocked(prisma.customer.findFirstOrThrow).mockResolvedValue({
id: 'customer_1',
dateOfBirth: new Date('2006-01-01T00:00:00.000Z'),
licenseIssuedAt: null,
} as never)
vi.mocked(prisma.pricingRule.findMany).mockResolvedValue([
{
id: 'young_driver_fee',
name: 'Young driver fee',
condition: 'AGE_LESS_THAN',
conditionValue: 25,
adjustmentType: 'FLAT',
adjustmentValue: 75,
type: 'SURCHARGE',
},
] as never)
const result = await applyPricingRules('company_1', 'customer_1', [
{ dateOfBirth: new Date('2007-01-01T00:00:00.000Z'), licenseIssuedAt: null },
], 100, 5)
expect(result.applied).toHaveLength(1)
expect(result.total).toBe(75)
})
it('ignores rules when required driver data is missing', async () => {
vi.mocked(prisma.customer.findFirstOrThrow).mockResolvedValue({
id: 'customer_1',
dateOfBirth: null,
licenseIssuedAt: null,
} as never)
vi.mocked(prisma.pricingRule.findMany).mockResolvedValue([
{
id: 'license_rule',
name: 'Experienced license discount',
condition: 'LICENSE_YEARS_GREATER_THAN',
conditionValue: 10,
adjustmentType: 'PERCENTAGE',
adjustmentValue: 5,
type: 'DISCOUNT',
},
] as never)
const result = await applyPricingRules('company_1', 'customer_1', [], 100, 2)
expect(result).toEqual({ applied: [], total: 0 })
})
})
@@ -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.findFirstOrThrow({ where: { id: customerId, companyId } })
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 }
}
+70
View File
@@ -0,0 +1,70 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/prisma', () => ({
prisma: {
employee: {
findFirst: vi.fn(),
create: vi.fn(),
},
company: {
findUniqueOrThrow: vi.fn(),
},
},
}))
vi.mock('./notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
import crypto from 'crypto'
import { prisma } from '../lib/prisma'
import { sendTransactionalEmail } from './notificationService'
import { inviteEmployee } from './teamService'
describe('teamService inviteEmployee', () => {
const originalDashboardUrl = process.env.DASHBOARD_URL
const originalPublicDashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL
beforeEach(() => {
vi.clearAllMocks()
process.env.DASHBOARD_URL = 'http://localhost:3000/dashboard'
process.env.NEXT_PUBLIC_DASHBOARD_URL = ''
vi.spyOn(crypto, 'randomBytes').mockReturnValue(Buffer.from('token-123'))
vi.spyOn(crypto, 'randomUUID').mockReturnValue('uuid-123')
vi.mocked(prisma.employee.findFirst).mockResolvedValue(null as any)
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ name: 'Atlas Cars' } as any)
vi.mocked(prisma.employee.create).mockResolvedValue({
id: 'emp_1',
firstName: 'Aya',
lastName: 'Benali',
email: 'aya@example.com',
role: 'AGENT',
isActive: true,
} as any)
})
afterAll(() => {
process.env.DASHBOARD_URL = originalDashboardUrl
process.env.NEXT_PUBLIC_DASHBOARD_URL = originalPublicDashboardUrl
vi.restoreAllMocks()
})
it('builds invite reset links under the dashboard base path', async () => {
await inviteEmployee('company_1', 'owner_1', {
firstName: 'Aya',
lastName: 'Benali',
email: 'aya@example.com',
role: 'AGENT',
})
expect(sendTransactionalEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'aya@example.com',
html: expect.stringContaining('http://localhost:3000/dashboard/reset-password?token=746f6b656e2d313233'),
text: expect.stringContaining('http://localhost:3000/dashboard/reset-password?token=746f6b656e2d313233'),
}),
)
})
})
+204
View File
@@ -0,0 +1,204 @@
import crypto from 'crypto'
import { EmployeeRole } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
import { sendTransactionalEmail } from './notificationService'
import { coerceNotificationLocale } from './notificationLocalizationService'
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
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'
}
function ensureDashboardBasePath(baseUrl: string) {
try {
const url = new URL(baseUrl)
const pathname = url.pathname.replace(/\/$/, '')
if (!pathname.endsWith('/dashboard')) url.pathname = `${pathname}/dashboard`
return url.toString().replace(/\/$/, '')
} catch {
const trimmed = baseUrl.replace(/\/$/, '')
return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard`
}
}
function buildInviteEmail(
resetUrl: string,
companyName: string,
firstName: string,
role: EmployeeRole,
locale: ReturnType<typeof coerceNotificationLocale>,
) {
const greetingName = firstName.trim() || 'there'
if (locale === 'fr') {
return {
subject: `Vous avez ete invite chez ${companyName}`,
html: `
<p>Bonjour ${greetingName},</p>
<p>Vous avez ete invite a rejoindre ${companyName} sur RentalDriveGo en tant que ${role.toLowerCase()}.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Definir votre mot de passe</a></p>
<p>Ce lien d'invitation expire dans 7 jours.</p>
<p>RentalDriveGo</p>
`,
text: `Bonjour ${greetingName},\n\nVous avez ete invite a rejoindre ${companyName} sur RentalDriveGo en tant que ${role.toLowerCase()}.\n\nDefinissez votre mot de passe ici : ${resetUrl}\n\nCe lien d'invitation expire dans 7 jours.\n\nRentalDriveGo`,
}
}
if (locale === 'ar') {
return {
subject: `تمت دعوتك إلى ${companyName}`,
html: `
<div dir="rtl" style="text-align:right">
<p>مرحباً ${greetingName}،</p>
<p>تمت دعوتك للانضمام إلى ${companyName} على RentalDriveGo بصفتك ${role.toLowerCase()}.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">عيّن كلمة المرور</a></p>
<p>تنتهي صلاحية رابط الدعوة هذا خلال 7 أيام.</p>
<p>RentalDriveGo</p>
</div>
`,
text: `مرحباً ${greetingName}،\n\nتمت دعوتك للانضمام إلى ${companyName} على RentalDriveGo بصفتك ${role.toLowerCase()}.\n\nعيّن كلمة المرور هنا: ${resetUrl}\n\nتنتهي صلاحية رابط الدعوة هذا خلال 7 أيام.\n\nRentalDriveGo`,
}
}
return {
subject: `You have been invited to ${companyName}`,
html: `
<p>Hi ${greetingName},</p>
<p>You were invited to join ${companyName} on RentalDriveGo as a ${role.toLowerCase()}.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Set your password</a></p>
<p>This invitation link expires in 7 days.</p>
<p>RentalDriveGo</p>
`,
text: `Hi ${greetingName},\n\nYou were invited to join ${companyName} on RentalDriveGo as a ${role.toLowerCase()}.\n\nSet your password here: ${resetUrl}\n\nThis invitation link expires in 7 days.\n\nRentalDriveGo`,
}
}
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
const employees = await prisma.employee.findMany({
where: { companyId },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
})
return employees.map((employee: any) => ({
...employee,
lastActiveAt: null,
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
}))
}
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: { defaultLocale: true, displayName: true } } },
})
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
const locale = coerceNotificationLocale(company.brand?.defaultLocale)
const employee = await prisma.employee.create({
data: {
companyId,
clerkUserId: `local_member_${crypto.randomUUID()}`,
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
role: payload.role,
isActive: true,
preferredLanguage: locale,
passwordResetToken: rawToken,
passwordResetExpiresAt: expiresAt,
},
})
const dashboardUrl = ensureDashboardBasePath(
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
const message = buildInviteEmail(
resetUrl,
company.brand?.displayName ?? company.name,
payload.firstName,
payload.role,
locale,
)
await sendTransactionalEmail({
to: payload.email,
subject: message.subject,
html: message.html,
text: message.text,
})
return {
employee: {
...employee,
lastActiveAt: null,
invitationStatus: 'pending' as const,
},
invitationId: employee.id,
invitedBy: inviterId,
}
}
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 })
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 })
await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
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 })
await prisma.employee.delete({ where: { id: employeeId } })
return { success: true }
}
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/prisma', () => ({
prisma: {
vehicle: {
findFirstOrThrow: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
reservation: {
findMany: vi.fn(),
},
vehicleCalendarBlock: {
findMany: vi.fn(),
},
},
}))
import { prisma } from '../lib/prisma'
import { getVehicleAvailabilitySummary } from './vehicleAvailabilityService'
const range = {
startDate: new Date('2026-07-10T10:00:00.000Z'),
endDate: new Date('2026-07-12T10:00:00.000Z'),
}
describe('getVehicleAvailabilitySummary', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-07-01T00:00:00.000Z'))
vi.mocked(prisma.vehicle.findFirstOrThrow).mockResolvedValue({ status: 'AVAILABLE' } as never)
vi.mocked(prisma.reservation.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.vehicleCalendarBlock.findMany).mockResolvedValue([] as never)
})
it('returns unavailable immediately when the vehicle itself is out of service', async () => {
vi.mocked(prisma.vehicle.findFirstOrThrow).mockResolvedValue({ status: 'OUT_OF_SERVICE' } as never)
const result = await getVehicleAvailabilitySummary('vehicle_1', { range })
expect(result).toEqual({
available: false,
status: 'UNAVAILABLE',
nextAvailableAt: null,
blockingReason: 'STATUS',
})
expect(prisma.reservation.findMany).not.toHaveBeenCalled()
expect(prisma.vehicleCalendarBlock.findMany).not.toHaveBeenCalled()
})
it('treats overlapping confirmed reservations as reserved and computes the next free instant', async () => {
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
{ startDate: new Date('2026-07-09T10:00:00.000Z'), endDate: new Date('2026-07-11T10:00:00.000Z') },
{ startDate: new Date('2026-07-11T10:00:00.000Z'), endDate: new Date('2026-07-13T10:00:00.000Z') },
] as never)
const result = await getVehicleAvailabilitySummary('vehicle_1', { range })
expect(prisma.reservation.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
vehicleId: 'vehicle_1',
status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] },
endDate: { gt: range.startDate },
}),
}))
expect(result).toEqual({
available: false,
status: 'RESERVED',
nextAvailableAt: new Date('2026-07-13T10:00:00.000Z'),
blockingReason: 'RESERVATION',
})
})
it('can ignore draft reservations for availability checks that only care about firm holds', async () => {
await getVehicleAvailabilitySummary('vehicle_1', { range, includeDraftReservations: false })
expect(prisma.reservation.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: { in: ['CONFIRMED', 'ACTIVE'] } }),
}))
})
it('prioritizes maintenance over generic blocks and reservations when several sources overlap', async () => {
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
{ startDate: new Date('2026-07-10T00:00:00.000Z'), endDate: new Date('2026-07-11T00:00:00.000Z') },
] as never)
vi.mocked(prisma.vehicleCalendarBlock.findMany).mockResolvedValue([
{ startDate: new Date('2026-07-10T00:00:00.000Z'), endDate: new Date('2026-07-12T00:00:00.000Z'), type: 'BLOCK' },
{ startDate: new Date('2026-07-10T00:00:00.000Z'), endDate: new Date('2026-07-13T00:00:00.000Z'), type: 'MAINTENANCE' },
] as never)
const result = await getVehicleAvailabilitySummary('vehicle_1', { range })
expect(result.status).toBe('MAINTENANCE')
expect(result.blockingReason).toBe('MAINTENANCE')
expect(result.nextAvailableAt).toEqual(new Date('2026-07-13T00:00:00.000Z'))
})
it('returns available when no source overlaps the requested range', async () => {
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
{ startDate: new Date('2026-07-01T00:00:00.000Z'), endDate: new Date('2026-07-02T00:00:00.000Z') },
] as never)
const result = await getVehicleAvailabilitySummary('vehicle_1', { range })
expect(result).toEqual({
available: true,
status: 'AVAILABLE',
nextAvailableAt: null,
blockingReason: null,
})
})
})
@@ -0,0 +1,152 @@
import { prisma } from '../lib/prisma'
const BLOCKING_RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE'] as const
type AvailabilityStatus = 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
type DateRange = {
startDate: Date
endDate: Date
}
type AvailabilitySource = {
startDate: Date
endDate: Date
kind: 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
}
export interface VehicleAvailabilitySummary {
available: boolean
status: AvailabilityStatus
nextAvailableAt: Date | null
blockingReason: 'RESERVATION' | 'MAINTENANCE' | 'BLOCK' | 'STATUS' | null
}
function startOfTodayUtc() {
const now = new Date()
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
}
function overlaps(range: DateRange, source: AvailabilitySource) {
return source.startDate < range.endDate && source.endDate > range.startDate
}
function getNextAvailableAt(anchor: Date, sources: AvailabilitySource[]) {
const sorted = sources
.filter((source) => source.endDate > anchor)
.sort((a, b) => {
const startDelta = a.startDate.getTime() - b.startDate.getTime()
if (startDelta !== 0) return startDelta
return a.endDate.getTime() - b.endDate.getTime()
})
let candidate = anchor
let advanced = false
for (const source of sorted) {
if (source.endDate <= candidate) continue
if (source.startDate > candidate) break
candidate = new Date(Math.max(candidate.getTime(), source.endDate.getTime()))
advanced = true
}
return advanced ? candidate : null
}
export async function getVehicleAvailabilitySummary(
vehicleId: string,
{
range,
includeDraftReservations = true,
companyId,
}: {
range?: DateRange
includeDraftReservations?: boolean
companyId?: string
} = {},
): Promise<VehicleAvailabilitySummary> {
const vehicle = await prisma.vehicle.findFirstOrThrow({
where: { id: vehicleId, ...(companyId ? { companyId } : {}) },
select: { status: true },
})
if (vehicle.status === 'OUT_OF_SERVICE') {
return {
available: false,
status: 'UNAVAILABLE',
nextAvailableAt: null,
blockingReason: 'STATUS',
}
}
const anchor = range?.startDate ?? startOfTodayUtc()
const reservationStatuses = includeDraftReservations
? [...BLOCKING_RESERVATION_STATUSES]
: BLOCKING_RESERVATION_STATUSES.filter((status) => status !== 'DRAFT')
const [reservations, blocks] = await Promise.all([
prisma.reservation.findMany({
where: {
vehicleId,
status: { in: reservationStatuses },
endDate: { gt: anchor },
},
select: { startDate: true, endDate: true },
orderBy: { startDate: 'asc' },
}),
prisma.vehicleCalendarBlock.findMany({
where: {
vehicleId,
endDate: { gt: anchor },
},
select: { startDate: true, endDate: true, type: true },
orderBy: { startDate: 'asc' },
}),
])
const sources: AvailabilitySource[] = [
...reservations.map((reservation: any) => ({
startDate: reservation.startDate,
endDate: reservation.endDate,
kind: 'RESERVATION' as const,
})),
...blocks.map((block: any) => ({
startDate: block.startDate,
endDate: block.endDate,
kind: block.type === 'MAINTENANCE' ? 'MAINTENANCE' as const : 'BLOCK' as const,
})),
]
const overlappingSources = range ? sources.filter((source) => overlaps(range, source)) : sources.filter((source) => source.startDate <= anchor && source.endDate > anchor)
const currentSource = overlappingSources.sort((a, b) => {
const priority = { MAINTENANCE: 0, BLOCK: 1, RESERVATION: 2 }
return priority[a.kind] - priority[b.kind]
})[0]
if (vehicle.status === 'MAINTENANCE') {
return {
available: false,
status: 'MAINTENANCE',
nextAvailableAt: getNextAvailableAt(anchor, sources) ?? null,
blockingReason: currentSource?.kind ?? 'STATUS',
}
}
if (!currentSource) {
return {
available: true,
status: 'AVAILABLE',
nextAvailableAt: null,
blockingReason: null,
}
}
const nextAvailableAnchor = currentSource.startDate > anchor ? currentSource.startDate : anchor
return {
available: false,
status: currentSource.kind === 'RESERVATION' ? 'RESERVED' : currentSource.kind === 'MAINTENANCE' ? 'MAINTENANCE' : 'UNAVAILABLE',
nextAvailableAt: getNextAvailableAt(nextAvailableAnchor, sources),
blockingReason: currentSource.kind,
}
}