archetecture security fix
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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' }) }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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('<Admin> & 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user