refractor code,

This commit is contained in:
root
2026-05-21 12:35:49 -04:00
parent e74681e810
commit f009ca10c6
158 changed files with 215801 additions and 5884 deletions
@@ -0,0 +1,39 @@
export function presentBrand(company: {
id: string; slug: string; name: string; phone: string | null; brand: any
}) {
const brand = company.brand
return {
company: { id: company.id, slug: company.slug, name: company.name, phone: company.phone },
brand: brand ? {
displayName: brand.displayName,
tagline: brand.tagline,
logoUrl: brand.logoUrl,
faviconUrl: brand.faviconUrl,
heroImageUrl: brand.heroImageUrl,
primaryColor: brand.primaryColor,
accentColor: brand.accentColor,
subdomain: brand.subdomain,
customDomain: brand.customDomain,
customDomainVerified: brand.customDomainVerified,
customDomainAddedAt: brand.customDomainAddedAt,
publicEmail: brand.publicEmail,
publicPhone: brand.publicPhone,
publicAddress: brand.publicAddress,
publicCity: brand.publicCity,
publicCountry: brand.publicCountry,
websiteUrl: brand.websiteUrl,
whatsappNumber: brand.whatsappNumber,
facebookUrl: brand.facebookUrl,
instagramUrl: brand.instagramUrl,
defaultLocale: brand.defaultLocale,
defaultCurrency: brand.defaultCurrency,
paypalEmail: brand.paypalEmail,
paypalMerchantId: brand.paypalMerchantId,
paymentMethodsEnabled: brand.paymentMethodsEnabled,
isListedOnMarketplace: brand.isListedOnMarketplace,
marketplaceRating: brand.marketplaceRating,
homePageConfig: brand.homePageConfig,
menuConfig: brand.menuConfig,
} : null,
}
}
+101
View File
@@ -0,0 +1,101 @@
import { prisma } from '../../lib/prisma'
export async function findCompanyBySlug(slug: string) {
return prisma.company.findFirstOrThrow({
where: { slug, status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE', 'SUSPENDED'] } },
include: { brand: true, contractSettings: true },
})
}
export async function findPublishedVehicles(companyId: string) {
return prisma.vehicle.findMany({
where: { companyId, isPublished: true },
orderBy: { createdAt: 'desc' },
})
}
export async function findVehicleById(vehicleId: string, companyId: string) {
return prisma.vehicle.findFirstOrThrow({
where: { id: vehicleId, companyId, isPublished: true },
})
}
export async function findActiveOffers(companyId: string) {
return prisma.offer.findMany({
where: { companyId, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
})
}
export async function findInsurancePolicies(companyId: string) {
return prisma.insurancePolicy.findMany({
where: { companyId, isActive: true },
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
})
}
export async function findOfferByPromoCode(companyId: string, code: string) {
return prisma.offer.findFirst({
where: { companyId, promoCode: code, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
})
}
export async function upsertCustomer(companyId: string, data: {
email: string; firstName: string; lastName: string; phone?: string | null
driverLicense?: string | null; dateOfBirth?: string | null; licenseExpiry?: string | null
licenseIssuedAt?: string | null; nationality?: string | null
}) {
const parsed = {
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : null,
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : null,
}
return prisma.customer.upsert({
where: { companyId_email: { companyId, email: data.email } },
create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed },
update: { firstName: data.firstName, lastName: data.lastName, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed },
})
}
export async function createReservation(data: object) {
return prisma.reservation.create({ data: data as any })
}
export async function findReservationWithDetails(reservationId: string) {
return prisma.reservation.findUniqueOrThrow({
where: { id: reservationId },
include: { insurances: true, additionalDrivers: true },
})
}
export async function findBooking(reservationId: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id: reservationId, companyId },
include: { vehicle: true, customer: true },
})
}
export async function findReservationForPayment(reservationId: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id: reservationId, companyId },
include: { vehicle: true, customer: true, additionalDrivers: true },
})
}
export async function createRentalPayment(data: {
companyId: string; reservationId: string; amount: number; currency: string
paymentProvider: string; amanpayTransactionId?: string | null; paypalCaptureId?: string | null
}) {
return prisma.rentalPayment.create({
data: { ...data, paymentProvider: data.paymentProvider as any, status: 'PENDING', type: 'CHARGE' },
})
}
export async function findPaymentByPaypalOrderId(paypalOrderId: string, companyId: string) {
return prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId } })
}
export async function capturePaypalPayment(paymentId: string, captureId: string, reservationId: string, amount: number) {
await prisma.rentalPayment.update({ where: { id: paymentId }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } })
await prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
}
+154
View File
@@ -0,0 +1,154 @@
import { Router } from 'express'
import { parseBody, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
import * as service from './site.service'
import {
slugParamSchema, bookingParamSchema,
availabilitySchema, validateCodeSchema, bookSchema, paySchema, capturePaypalSchema, contactSchema,
} from './site.schemas'
const router = Router()
router.get('/platform/homepage', async (_req, res, next) => {
try {
ok(res, await service.getPlatformHomepage())
} catch (err) { next(err) }
})
router.get('/:slug/brand', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getBrand(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.json({ data: { company: { id: 'demo', slug: req.params.slug, name: 'Demo Company', phone: null }, brand: null } })
}
next(err)
}
})
router.get('/:slug/vehicles', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getPublicVehicles(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/vehicles/:id', async (req, res, next) => {
try {
const { slug, id } = parseParams(bookingParamSchema, req)
ok(res, await service.getVehicleDetail(slug, id))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug/offers', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getOffers(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/booking-options', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getBookingOptions(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: { insurancePolicies: [], contractSettings: null } })
next(err)
}
})
router.post('/:slug/availability', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
const { vehicleId, startDate, endDate } = parseBody(availabilitySchema, req)
ok(res, await service.checkAvailability(slug, vehicleId, startDate, endDate))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.post('/:slug/book/validate-code', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
const { code } = parseBody(validateCodeSchema, req)
ok(res, await service.validatePromoCode(slug, code))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Promo code validation is temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.post('/:slug/book', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
const body = parseBody(bookSchema, req)
const result = await service.createBooking(slug, body)
created(res, result)
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Booking is temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug/booking/:id', async (req, res, next) => {
try {
const { slug, id } = parseParams(bookingParamSchema, req)
ok(res, await service.getBooking(slug, id))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Booking details are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.post('/:slug/booking/:id/pay', async (req, res, next) => {
try {
const { slug, id } = parseParams(bookingParamSchema, req)
const body = parseBody(paySchema, req)
ok(res, await service.initPayment(slug, id, body))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Payment initiation is temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.post('/:slug/booking/:id/capture-paypal', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
const { paypalOrderId } = parseBody(capturePaypalSchema, req)
ok(res, await service.capturePaypal(slug, paypalOrderId))
} catch (err) { next(err) }
})
router.post('/:slug/contact', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
const body = parseBody(contactSchema, req)
ok(res, await service.handleContact(slug, body))
} catch (err) { next(err) }
})
export default router
+58
View File
@@ -0,0 +1,58 @@
import { z } from 'zod'
export const slugParamSchema = z.object({ slug: z.string() })
export const bookingParamSchema = z.object({ slug: z.string(), id: z.string() })
export const availabilitySchema = z.object({
vehicleId: z.string().cuid(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
})
export const validateCodeSchema = z.object({ code: z.string().min(1) })
export const bookSchema = z.object({
vehicleId: z.string().cuid(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
driverLicense: z.string().optional(),
dateOfBirth: z.string().datetime().optional(),
licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(),
nationality: z.string().optional(),
offerId: z.string().cuid().optional(),
promoCodeUsed: z.string().optional(),
notes: z.string().optional(),
source: z.enum(['PUBLIC_SITE', 'MARKETPLACE']).default('PUBLIC_SITE'),
selectedInsurancePolicyIds: z.array(z.string()).default([]),
additionalDrivers: z.array(z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email().optional(),
phone: z.string().optional(),
driverLicense: z.string().min(1),
licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(),
dateOfBirth: z.string().datetime().optional(),
nationality: z.string().optional(),
})).default([]),
})
export const paySchema = z.object({
provider: z.enum(['AMANPAY', 'PAYPAL']),
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
successUrl: z.string().url(),
failureUrl: z.string().url(),
})
export const capturePaypalSchema = z.object({ paypalOrderId: z.string() })
export const contactSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(1),
})
+254
View File
@@ -0,0 +1,254 @@
import { AppError, NotFoundError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyInsurancesToReservation } from '../../services/insuranceService'
import { applyAdditionalDriversToReservation } from '../../services/additionalDriverService'
import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService'
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './site.repo'
import { presentBrand } from './site.presenter'
export async function getPlatformHomepage() {
return getMarketplaceHomepageContent()
}
export async function getBrand(slug: string) {
const company = await repo.findCompanyBySlug(slug)
return presentBrand(company)
}
export async function getPublicVehicles(slug: string) {
const company = await repo.findCompanyBySlug(slug)
const vehicles = await repo.findPublishedVehicles(company.id)
return Promise.all(
vehicles.map(async (v) => {
const a = await getVehicleAvailabilitySummary(v.id)
return { ...v, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
}),
)
}
export async function getVehicleDetail(slug: string, vehicleId: string) {
const company = await repo.findCompanyBySlug(slug)
const vehicle = await repo.findVehicleById(vehicleId, company.id)
const a = await getVehicleAvailabilitySummary(vehicle.id)
return { ...vehicle, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
}
export async function getOffers(slug: string) {
const company = await repo.findCompanyBySlug(slug)
return repo.findActiveOffers(company.id)
}
export async function getBookingOptions(slug: string) {
const company = await repo.findCompanyBySlug(slug)
const insurancePolicies = await repo.findInsurancePolicies(company.id)
return { insurancePolicies, contractSettings: company.contractSettings }
}
export async function checkAvailability(slug: string, vehicleId: string, startDate: string, endDate: string) {
await repo.findCompanyBySlug(slug)
const availability = await getVehicleAvailabilitySummary(vehicleId, {
range: { startDate: new Date(startDate), endDate: new Date(endDate) },
})
return { available: availability.available, nextAvailableAt: availability.nextAvailableAt }
}
export async function validatePromoCode(slug: string, code: string) {
const company = await repo.findCompanyBySlug(slug)
const offer = await repo.findOfferByPromoCode(company.id, code)
if (!offer) throw new NotFoundError('Promo code not found or expired')
return offer
}
export async function createBooking(slug: string, body: {
vehicleId: string; startDate: string; endDate: string
firstName: string; lastName: string; email: string; phone?: string
driverLicense?: string; dateOfBirth?: string; licenseExpiry?: string; licenseIssuedAt?: string; nationality?: string
offerId?: string; promoCodeUsed?: string; notes?: string; source?: string
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
}) {
const company = await repo.findCompanyBySlug(slug)
const vehicle = await repo.findVehicleById(body.vehicleId, company.id)
const start = new Date(body.startDate)
const end = new Date(body.endDate)
if (end <= start) throw new AppError('End date must be after start date', 400, 'invalid_dates')
const availability = await getVehicleAvailabilitySummary(vehicle.id, { range: { startDate: start, endDate: end } })
if (!availability.available) {
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
})
}
const customer = await repo.upsertCustomer(company.id, {
email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone,
driverLicense: body.driverLicense, dateOfBirth: body.dateOfBirth,
licenseExpiry: body.licenseExpiry, licenseIssuedAt: body.licenseIssuedAt, nationality: body.nationality,
})
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86_400_000))
const baseAmount = vehicle.dailyRate * totalDays
let discountAmount = 0
if (body.promoCodeUsed) {
const offer = await repo.findOfferByPromoCode(company.id, body.promoCodeUsed)
if (offer) {
if (offer.type === 'PERCENTAGE') discountAmount = Math.round(baseAmount * offer.discountValue / 100)
else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue
else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue
}
}
const additionalDriversList = body.additionalDrivers ?? []
const { applied, total: pricingRulesTotal } = await applyPricingRules(
company.id, customer.id, additionalDriversList as any[], vehicle.dailyRate, totalDays,
)
const primaryLicenseResult = validateLicense(body.licenseExpiry ? new Date(body.licenseExpiry) : null)
if (primaryLicenseResult.status === 'EXPIRED') {
throw new AppError('The primary driver license is expired', 400, 'license_expired')
}
const reservation = await repo.createReservation({
companyId: company.id,
vehicleId: vehicle.id,
customerId: customer.id,
offerId: body.offerId ?? null,
promoCodeUsed: body.promoCodeUsed ?? null,
vehicleCategory: vehicle.category,
source: body.source ?? 'PUBLIC_SITE',
startDate: start,
endDate: end,
dailyRate: vehicle.dailyRate,
totalDays,
totalAmount: baseAmount - discountAmount + pricingRulesTotal,
discountAmount,
pricingRulesApplied: applied,
pricingRulesTotal,
notes: body.notes ?? null,
status: 'DRAFT',
})
const insuranceIds = body.selectedInsurancePolicyIds ?? []
if (insuranceIds.length > 0) {
await applyInsurancesToReservation(reservation.id, company.id, insuranceIds, totalDays, baseAmount)
}
if (additionalDriversList.length > 0) {
await applyAdditionalDriversToReservation(reservation.id, company.id, additionalDriversList, totalDays)
}
if (body.licenseExpiry) {
await validateAndFlagLicense(customer.id).catch(() => null)
}
const refreshed = await repo.findReservationWithDetails(reservation.id)
return {
...refreshed,
requiresManualApproval:
primaryLicenseResult.requiresApproval ||
refreshed.additionalDrivers.some((d) => d.requiresApproval),
}
}
export async function getBooking(slug: string, reservationId: string) {
const company = await repo.findCompanyBySlug(slug)
return repo.findBooking(reservationId, company.id)
}
export async function initPayment(slug: string, reservationId: string, body: {
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string
}) {
const company = await repo.findCompanyBySlug(slug)
const reservation = await repo.findReservationForPayment(reservationId, company.id)
if (reservation.paymentStatus === 'PAID') {
throw new AppError('This reservation is already paid', 409, 'already_paid')
}
const customerLicenseResult = validateLicense(reservation.customer.licenseExpiry)
const licenseBlocked =
reservation.customer.licenseValidationStatus === 'DENIED' ||
customerLicenseResult.status === 'EXPIRED' ||
(customerLicenseResult.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') ||
reservation.additionalDrivers.some((d) => d.licenseExpired || (d.requiresApproval && !d.approvedAt))
if (licenseBlocked) {
throw new AppError('This reservation requires license review before payment can be processed', 409, 'license_review_required')
}
const currency = body.currency ?? 'MAD'
const amount = reservation.totalAmount
const description = `Rental: ${reservation.vehicle.make} ${reservation.vehicle.model}`
const orderId = `res-${reservation.id}-${Date.now()}`
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
let checkoutUrl: string
let amanpayTransactionId: string | null = null
let paypalCaptureId: string | null = null
if (body.provider === 'AMANPAY') {
if (!amanpay.isConfigured()) {
throw new AppError('Online payment is not available for this company', 503, 'provider_not_configured')
}
const brand = company.brand as any
const merchantId = brand?.amanpayMerchantId ?? process.env.AMANPAY_MERCHANT_ID ?? ''
const secretKey = brand?.amanpaySecretKey ?? process.env.AMANPAY_SECRET_KEY ?? ''
if (!merchantId || !secretKey) {
throw new AppError('AmanPay is not configured for this company', 503, 'provider_not_configured')
}
const result = await amanpay.createCheckout({
amount, currency, orderId, description,
customerEmail: reservation.customer.email,
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
successUrl: body.successUrl,
failureUrl: body.failureUrl,
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
})
checkoutUrl = result.checkoutUrl
amanpayTransactionId = result.transactionId
} else {
if (!paypal.isConfigured()) {
throw new AppError('PayPal is not available for this company', 503, 'provider_not_configured')
}
const result = await paypal.createOrder({
amount, currency, orderId, description,
returnUrl: body.successUrl,
cancelUrl: body.failureUrl,
})
checkoutUrl = result.approveUrl
paypalCaptureId = result.orderId
}
await repo.createRentalPayment({
companyId: company.id,
reservationId: reservation.id,
amount,
currency,
paymentProvider: body.provider,
amanpayTransactionId,
paypalCaptureId,
})
return { checkoutUrl }
}
export async function capturePaypal(slug: string, paypalOrderId: string) {
const company = await repo.findCompanyBySlug(slug)
const payment = await repo.findPaymentByPaypalOrderId(paypalOrderId, company.id)
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
await repo.capturePaypalPayment(payment.id, captureId, payment.reservationId, payment.amount)
return { success: true }
}
export async function handleContact(slug: string, body: { name: string; email: string; message: string }) {
const company = await repo.findCompanyBySlug(slug)
return {
success: true,
deliveredTo: company.brand?.publicEmail ?? company.email,
preview: body,
}
}
+257
View File
@@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { AppError, NotFoundError } from '../../http/errors'
vi.mock('./site.repo', () => ({
findCompanyBySlug: vi.fn(),
findPublishedVehicles: vi.fn(),
findVehicleById: vi.fn(),
findActiveOffers: vi.fn(),
findInsurancePolicies: vi.fn(),
findOfferByPromoCode: vi.fn(),
upsertCustomer: vi.fn(),
createReservation: vi.fn(),
findReservationWithDetails: vi.fn(),
findBooking: vi.fn(),
findReservationForPayment: vi.fn(),
createRentalPayment: vi.fn(),
findPaymentByPaypalOrderId: vi.fn(),
capturePaypalPayment: vi.fn(),
}))
vi.mock('../../services/vehicleAvailabilityService', () => ({
getVehicleAvailabilitySummary: vi.fn(),
}))
vi.mock('../../services/insuranceService', () => ({
applyInsurancesToReservation: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../../services/additionalDriverService', () => ({
applyAdditionalDriversToReservation: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../../services/pricingRuleService', () => ({
applyPricingRules: vi.fn(),
}))
vi.mock('../../services/licenseValidationService', () => ({
validateLicense: vi.fn(),
validateAndFlagLicense: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../../services/amanpayService', () => ({
isConfigured: vi.fn(),
createCheckout: vi.fn(),
}))
vi.mock('../../services/paypalService', () => ({
isConfigured: vi.fn(),
createOrder: vi.fn(),
captureOrder: vi.fn(),
}))
vi.mock('../../services/platformContentService', () => ({
getMarketplaceHomepageContent: vi.fn(),
}))
import * as repo from './site.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense } from '../../services/licenseValidationService'
import * as amanpay from '../../services/amanpayService'
import * as paypalSvc from '../../services/paypalService'
import {
getBrand, getPublicVehicles, checkAvailability, validatePromoCode,
createBooking, initPayment,
} from './site.service'
const SLUG = 'test-company'
function makeCompany(overrides: object = {}) {
return {
id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com',
brand: { publicEmail: null }, contractSettings: null,
...overrides,
}
}
function makeVehicle(overrides: object = {}) {
return {
id: 'v-1', dailyRate: 10000, year: 2022, make: 'Toyota', model: 'Camry',
isPublished: true, status: 'AVAILABLE', companyId: 'co-1', category: 'SEDAN',
...overrides,
}
}
beforeEach(() => { vi.clearAllMocks() })
// ────────────────────────────────────────────────────────────────────────────
describe('getBrand', () => {
it('returns company and public brand data only', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany({
brand: {
displayName: 'Test Co',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
amanpayMerchantId: 'merchant-id',
amanpaySecretKey: 'top-secret',
paypalMerchantId: 'paypal-merchant-id',
},
}) as any)
const result = await getBrand(SLUG)
expect(result.company.id).toBe('co-1')
expect(result.brand).toMatchObject({
displayName: 'Test Co',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
paypalMerchantId: 'paypal-merchant-id',
})
expect(result.brand).not.toHaveProperty('amanpayMerchantId')
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('getPublicVehicles', () => {
it('returns vehicles enriched with availability', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
const result = await getPublicVehicles(SLUG)
expect(result[0].availability).toBe(true)
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('checkAvailability', () => {
it('returns availability result for given date range', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
const result = await checkAvailability(SLUG, 'v-1', '2025-06-01T00:00:00.000Z', '2025-06-04T00:00:00.000Z')
expect(result.available).toBe(true)
expect(getVehicleAvailabilitySummary).toHaveBeenCalledWith('v-1', { range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') } })
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('validatePromoCode', () => {
it('returns offer when code is valid', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'o-1' } as any)
const result = await validatePromoCode(SLUG, 'SUMMER10')
expect((result as any).id).toBe('o-1')
})
it('throws NotFoundError for unknown promo code', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue(null)
await expect(validatePromoCode(SLUG, 'BADCODE')).rejects.toBeInstanceOf(NotFoundError)
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('createBooking', () => {
const baseBody = {
vehicleId: 'v-1',
startDate: '2025-06-01T00:00:00.000Z',
endDate: '2025-06-04T00:00:00.000Z',
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
}
beforeEach(() => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findVehicleById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'r-1' } as any)
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'r-1', additionalDrivers: [], insurances: [] } as any)
})
it('creates a booking and returns reservation with requiresManualApproval flag', async () => {
const result = await createBooking(SLUG, baseBody)
expect(repo.createReservation).toHaveBeenCalledOnce()
expect((result as any).requiresManualApproval).toBe(false)
})
it('throws AppError for invalid dates', async () => {
await expect(
createBooking(SLUG, { ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
).rejects.toMatchObject({ error: 'invalid_dates' })
})
it('throws AppError when vehicle unavailable', async () => {
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
await expect(createBooking(SLUG, baseBody)).rejects.toMatchObject({ error: 'unavailable' })
})
it('throws AppError when primary driver license is expired', async () => {
vi.mocked(validateLicense).mockReturnValue({ status: 'EXPIRED', requiresApproval: false } as any)
await expect(createBooking(SLUG, { ...baseBody, licenseExpiry: '2020-01-01T00:00:00.000Z' })).rejects.toMatchObject({ error: 'license_expired' })
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('initPayment — payment guard paths', () => {
const payBody = { provider: 'PAYPAL' as const, successUrl: 'http://ok', failureUrl: 'http://fail' }
it('throws AppError when reservation is already paid', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
paymentStatus: 'PAID', totalAmount: 100,
customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'A', lastName: 'B' },
additionalDrivers: [],
vehicle: { make: 'Toyota', model: 'Camry' },
} as any)
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'already_paid' })
})
it('throws AppError when license review is required', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
paymentStatus: 'UNPAID', totalAmount: 100,
customer: { licenseExpiry: null, licenseValidationStatus: 'DENIED', email: 'a@b.com', firstName: 'A', lastName: 'B' },
additionalDrivers: [],
vehicle: { make: 'Toyota', model: 'Camry' },
} as any)
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'license_review_required' })
})
it('throws AppError when PayPal is not configured', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
paymentStatus: 'UNPAID', totalAmount: 30000,
customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'Ali', lastName: 'Ben' },
additionalDrivers: [],
vehicle: { make: 'Toyota', model: 'Camry' },
} as any)
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(paypalSvc.isConfigured).mockReturnValue(false)
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'provider_not_configured' })
})
it('returns checkoutUrl when PayPal is configured', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
id: 'r-1', paymentStatus: 'UNPAID', totalAmount: 30000, companyId: 'co-1',
customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'Ali', lastName: 'Ben' },
additionalDrivers: [],
vehicle: { make: 'Toyota', model: 'Camry' },
} as any)
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(paypalSvc.isConfigured).mockReturnValue(true)
vi.mocked(paypalSvc.createOrder).mockResolvedValue({ approveUrl: 'https://paypal.com/approve', orderId: 'pp-1' } as any)
vi.mocked(repo.createRentalPayment).mockResolvedValue(undefined as any)
const result = await initPayment(SLUG, 'r-1', payBody)
expect(result.checkoutUrl).toBe('https://paypal.com/approve')
expect(repo.createRentalPayment).toHaveBeenCalledOnce()
})
})