fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { presentBrand } from './site.presenter'
describe('site.presenter', () => {
it('exposes only public brand and company fields for anonymous site clients', () => {
const result = presentBrand({
id: 'company_1',
slug: 'atlas-cars',
name: 'Atlas Cars',
phone: '+212600000000',
brand: {
displayName: 'Atlas Cars',
tagline: 'Premium rentals',
logoUrl: 'https://cdn.test/logo.png',
amanpaySecretKey: 'must-not-leak',
amanpayMerchantId: 'must-not-leak',
paypalEmail: 'billing@example.com',
paymentMethodsEnabled: ['PAYPAL'],
defaultLocale: 'fr',
defaultCurrency: 'MAD',
isListedOnMarketplace: true,
},
})
expect(result).toMatchObject({
company: { id: 'company_1', slug: 'atlas-cars', name: 'Atlas Cars', phone: '+212600000000' },
brand: {
displayName: 'Atlas Cars',
tagline: 'Premium rentals',
logoUrl: 'https://cdn.test/logo.png',
paypalEmail: 'billing@example.com',
paymentMethodsEnabled: ['PAYPAL'],
defaultLocale: 'fr',
defaultCurrency: 'MAD',
isListedOnMarketplace: true,
},
})
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
expect(result.brand).not.toHaveProperty('amanpayMerchantId')
})
it('returns a null brand when company branding has not been configured', () => {
expect(presentBrand({ id: 'company_1', slug: 'atlas-cars', name: 'Atlas Cars', phone: null, brand: null })).toEqual({
company: { id: 'company_1', slug: 'atlas-cars', name: 'Atlas Cars', phone: null },
brand: null,
})
})
})
@@ -37,3 +37,40 @@ export function presentBrand(company: {
} : null,
}
}
function maskEmail(email?: string | null) {
if (!email || !email.includes('@')) return undefined
const [name = '', domain = ''] = email.split('@')
return `${name.slice(0, 1)}***@${domain}`
}
function maskPhone(phone?: string | null) {
if (!phone) return undefined
const visible = phone.replace(/\D/g, '').slice(-4)
return visible ? `***${visible}` : undefined
}
export function presentPublicBooking(reservation: any) {
return {
id: reservation.id,
status: reservation.status,
pickupAt: reservation.startDate instanceof Date ? reservation.startDate.toISOString() : reservation.startDate,
returnAt: reservation.endDate instanceof Date ? reservation.endDate.toISOString() : reservation.endDate,
vehicle: {
make: reservation.vehicle?.make,
model: reservation.vehicle?.model,
year: reservation.vehicle?.year,
imageUrl: Array.isArray(reservation.vehicle?.images) ? reservation.vehicle.images[0] : reservation.vehicle?.imageUrl,
},
customer: {
displayName: [reservation.customer?.firstName, reservation.customer?.lastName?.slice(0, 1)].filter(Boolean).join(' '),
maskedEmail: maskEmail(reservation.customer?.email),
maskedPhone: maskPhone(reservation.customer?.phone),
},
totals: {
amountDue: reservation.totalAmount - (reservation.paidAmount ?? 0),
currency: 'MAD',
},
paymentStatus: reservation.paymentStatus,
}
}
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
company: { findFirstOrThrow: vi.fn() },
vehicle: { findMany: vi.fn(), findFirstOrThrow: vi.fn() },
offer: { findMany: vi.fn(), findFirst: vi.fn() },
insurancePolicy: { findMany: vi.fn() },
customer: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
reservation: { create: vi.fn(), findUniqueOrThrow: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn() },
rentalPayment: { create: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn() },
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './site.repo'
describe('site.repo public booking boundaries', () => {
beforeEach(() => vi.clearAllMocks())
it('only exposes companies with public-rental statuses by slug', async () => {
await repo.findCompanyBySlug('atlas-rentals')
expect(prisma.company.findFirstOrThrow).toHaveBeenCalledWith({
where: { slug: 'atlas-rentals', status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE', 'SUSPENDED'] } },
include: { brand: true, contractSettings: true },
})
})
it('loads only published vehicles for the public site', async () => {
await repo.findPublishedVehicles('company_1')
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
where: { companyId: 'company_1', isPublished: true },
orderBy: { createdAt: 'desc' },
})
})
it('merges existing customer address metadata when upserting a public booking customer', async () => {
vi.mocked(prisma.customer.findUnique).mockResolvedValue({
id: 'customer_1',
address: { city: 'Marrakesh', old: 'kept' },
} as never)
await repo.upsertCustomer('company_1', {
email: 'customer@example.test',
firstName: 'Nora',
lastName: 'Guest',
phone: '+212600000000',
driverLicense: 'DL-1',
dateOfBirth: '1995-01-02',
licenseExpiry: '2031-05-06',
licenseIssuedAt: '2021-05-06',
nationality: 'MA',
identityDocumentNumber: 'ID-9',
fullAddress: 'New address',
licenseCountry: 'MA',
licenseCategory: 'B',
internationalLicenseNumber: undefined,
})
expect(prisma.customer.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'customer_1' },
data: expect.objectContaining({
firstName: 'Nora',
address: { city: 'Marrakesh', old: 'kept', fullAddress: 'New address', identityDocumentNumber: 'ID-9', internationalLicenseNumber: null },
licenseNumber: 'DL-1',
}),
}))
})
it('creates pending rental payments with normalized charge metadata', async () => {
await repo.createRentalPayment({
companyId: 'company_1',
reservationId: 'reservation_1',
amount: 1200,
currency: 'MAD',
paymentProvider: 'PAYPAL',
paypalCaptureId: 'order_1',
})
expect(prisma.rentalPayment.create).toHaveBeenCalledWith({
data: {
companyId: 'company_1',
reservationId: 'reservation_1',
amount: 1200,
currency: 'MAD',
paymentProvider: 'PAYPAL',
paypalCaptureId: 'order_1',
status: 'PENDING',
type: 'CHARGE',
},
})
})
it('captures PayPal payments by updating payment and reservation atomically in sequence', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-11-10T09:30:00.000Z'))
await repo.capturePaypalPayment('payment_1', 'capture_1', 'reservation_1', 1500)
expect(prisma.rentalPayment.update).toHaveBeenCalledWith({
where: { id: 'payment_1' },
data: { status: 'SUCCEEDED', paidAt: new Date('2026-11-10T09:30:00.000Z'), paypalCaptureId: 'capture_1' },
})
expect(prisma.reservation.update).toHaveBeenCalledWith({
where: { id: 'reservation_1' },
data: { paymentStatus: 'PAID', paidAmount: { increment: 1500 } },
})
vi.useRealTimers()
})
})
+20
View File
@@ -141,3 +141,23 @@ export async function capturePaypalPayment(paymentId: string, captureId: string,
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 } } })
}
export async function createReservationPublicAccess(reservationId: string, tokenHash: string, expiresAt: Date) {
return (prisma as any).reservationPublicAccess.create({
data: { reservationId, tokenHash, expiresAt },
})
}
export async function findReservationPublicAccess(reservationId: string, tokenHash: string) {
return (prisma as any).reservationPublicAccess.findFirst({
where: { reservationId, tokenHash, revokedAt: null, expiresAt: { gt: new Date() } },
})
}
export async function markReservationPublicAccessUsed(id: string) {
return (prisma as any).reservationPublicAccess.update({
where: { id },
data: { usedAt: new Date() },
})
}
+2 -1
View File
@@ -125,7 +125,8 @@ router.post('/:slug/book', async (req, res, next) => {
router.get('/:slug/booking/:id', async (req, res, next) => {
try {
const { slug, id } = parseParams(bookingParamSchema, req)
ok(res, await service.getBooking(slug, id))
const accessToken = typeof req.query.token === 'string' ? req.query.token : undefined
ok(res, await service.getBooking(slug, id, accessToken))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Booking details are temporarily unavailable', statusCode: 503 })
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { availabilitySchema, bookSchema, capturePaypalSchema, contactSchema, paySchema } from './site.schemas'
describe('site.schemas', () => {
const baseBooking = {
vehicleId: 'ckvvehicle000000000000001',
startDate: '2026-07-01T10:00:00.000Z',
endDate: '2026-07-03T10:00:00.000Z',
firstName: 'Aya',
lastName: 'Benali',
email: 'aya@example.com',
phone: '+212600000000',
driverLicense: 'DL-123',
dateOfBirth: '1995-01-01T00:00:00.000Z',
licenseExpiry: '2028-01-01T00:00:00.000Z',
licenseIssuedAt: '2020-01-01T00:00:00.000Z',
nationality: 'Moroccan',
identityDocumentNumber: 'ID-123',
fullAddress: '12 Main Street',
licenseCountry: 'MA',
licenseCategory: 'B',
}
it('defaults optional public booking arrays and source', () => {
expect(bookSchema.parse(baseBooking)).toMatchObject({
source: 'PUBLIC_SITE',
selectedInsurancePolicyIds: [],
additionalDrivers: [],
})
})
it('validates nested additional drivers instead of letting anonymous payloads drift into services', () => {
expect(bookSchema.parse({
...baseBooking,
additionalDrivers: [{ firstName: 'Omar', lastName: 'Alaoui', email: 'omar@example.com', driverLicense: 'DL-2' }],
}).additionalDrivers).toHaveLength(1)
expect(() => bookSchema.parse({
...baseBooking,
additionalDrivers: [{ firstName: 'Omar', lastName: 'Alaoui', email: 'not-email', driverLicense: 'DL-2' }],
})).toThrow()
})
it('rejects malformed availability and payment payloads at the schema layer', () => {
expect(availabilitySchema.parse({
vehicleId: 'ckvvehicle000000000000001',
startDate: '2026-07-01T10:00:00.000Z',
endDate: '2026-07-03T10:00:00.000Z',
})).toMatchObject({ vehicleId: 'ckvvehicle000000000000001' })
expect(() => availabilitySchema.parse({ vehicleId: 'not-cuid', startDate: 'x', endDate: 'y' })).toThrow()
expect(() => paySchema.parse({ provider: 'STRIPE', successUrl: 'https://ok.test', failureUrl: 'https://fail.test' })).toThrow()
expect(paySchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.test', failureUrl: 'https://fail.test', accessToken: 'booking-access-token-123' }).currency).toBe('MAD')
})
it('keeps contact, PayPal capture, and required identity fields strict', () => {
expect(contactSchema.parse({ name: 'Aya', email: 'aya@example.com', message: 'Hello' })).toEqual({
name: 'Aya', email: 'aya@example.com', message: 'Hello',
})
expect(capturePaypalSchema.parse({ paypalOrderId: 'ORDER-1' })).toEqual({ paypalOrderId: 'ORDER-1' })
expect(() => bookSchema.parse({ ...baseBooking, email: 'bad-email' })).toThrow()
expect(() => contactSchema.parse({ name: '', email: 'bad', message: '' })).toThrow()
})
})
@@ -53,6 +53,7 @@ export const paySchema = z.object({
currency: z.literal('MAD').default('MAD'),
successUrl: z.string().url(),
failureUrl: z.string().url(),
accessToken: z.string().min(20),
})
export const capturePaypalSchema = z.object({ paypalOrderId: z.string() })
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@rentaldrivego/types', () => ({
PLAN_FEATURES: { STARTER: ['fallback feature'] },
PLAN_PRICES: { STARTER: { MONTHLY: { MAD: 199 } } },
}))
vi.mock('../../lib/prisma', () => ({
prisma: {
pricingConfig: { findMany: vi.fn() },
planFeature: { findMany: vi.fn() },
},
}))
vi.mock('../../services/platformContentService', () => ({ getMarketplaceHomepageContent: vi.fn() }))
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
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(), verifyWebhookSignature: vi.fn(), findTransactionFromWebhook: vi.fn() }))
vi.mock('../../services/paypalService', () => ({ createOrder: vi.fn(), captureOrder: vi.fn() }))
vi.mock('./site.presenter', () => ({ presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })) }))
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(),
createReservationPublicAccess: vi.fn(),
findReservationPublicAccess: vi.fn(),
markReservationPublicAccessUsed: vi.fn(),
createRentalPayment: vi.fn(),
findPaymentByPaypalOrderId: vi.fn(),
capturePaypalPayment: vi.fn(),
}))
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyPricingRules } from '../../services/pricingRuleService'
import { applyInsurancesToReservation } from '../../services/insuranceService'
import { applyAdditionalDriversToReservation } from '../../services/additionalDriverService'
import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './site.repo'
import * as service from './site.service'
const company = { id: 'company_1', slug: 'atlas', email: 'office@example.test', contractSettings: { depositRequired: true }, brand: { publicEmail: 'hello@example.test' } }
function bookingBody(overrides: Record<string, unknown> = {}) {
return {
vehicleId: 'vehicle_1',
startDate: '2026-07-01T00:00:00.000Z',
endDate: '2026-07-04T00:00:00.000Z',
firstName: 'Nora',
lastName: 'Renter',
email: 'nora@example.test',
phone: '+212600000000',
driverLicense: 'DL-1',
dateOfBirth: '1992-01-01',
licenseExpiry: '2027-01-01',
licenseIssuedAt: '2022-01-01',
nationality: 'MA',
identityDocumentNumber: 'ID-1',
fullAddress: 'Casablanca',
licenseCountry: 'MA',
licenseCategory: 'B',
...overrides,
} as any
}
describe('site.service public booking/payment boundaries', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
vi.mocked(repo.markReservationPublicAccessUsed).mockResolvedValue({ id: 'access_1' } as never)
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(company as never)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null } as never)
vi.mocked(applyPricingRules).mockResolvedValue({ applied: [{ code: 'WEEKEND' }], total: 90 } as never)
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false, expired: false, expiringSoon: false } as never)
})
it('uses configured pricing and configured feature labels when platform pricing exists', async () => {
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 299 },
{ plan: 'PRO', billingPeriod: 'YEARLY', amount: 2999 },
] as never)
vi.mocked(prisma.planFeature.findMany).mockResolvedValue([
{ plan: 'STARTER', label: '3 vehicles' },
{ plan: 'PRO', label: 'Priority support' },
] as never)
await expect(service.getPlatformPricing()).resolves.toEqual({
prices: { STARTER: { MONTHLY: { MAD: 299 } }, PRO: { YEARLY: { MAD: 2999 } } },
planFeatures: { STARTER: ['3 vehicles'] },
})
})
it('calculates booking totals from base rate, free-day promo, pricing rules, insurance and additional drivers', async () => {
vi.mocked(repo.findVehicleById).mockResolvedValue({ id: 'vehicle_1', companyId: 'company_1', dailyRate: 500, category: 'SUV' } as never)
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'customer_1' } as never)
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'offer_1', type: 'FREE_DAY', discountValue: 1 } as never)
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'reservation_1' } as never)
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'reservation_1', additionalDrivers: [{ requiresApproval: true }] } as never)
const result = await service.createBooking('atlas', bookingBody({
promoCodeUsed: 'FREEDAY',
selectedInsurancePolicyIds: ['insurance_1'],
additionalDrivers: [{ firstName: 'Second' }],
}))
expect(repo.createReservation).toHaveBeenCalledWith(expect.objectContaining({
totalDays: 3,
dailyRate: 500,
discountAmount: 500,
pricingRulesTotal: 90,
totalAmount: 1090,
status: 'DRAFT',
source: 'PUBLIC_SITE',
}))
expect(applyInsurancesToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', ['insurance_1'], 3, 1500)
expect(applyAdditionalDriversToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', [{ firstName: 'Second' }], 3)
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1')
expect(result.requiresManualApproval).toBe(true)
})
it('rejects payment initialization for already-paid reservations before provider calls', async () => {
vi.mocked(repo.findReservationForPayment).mockResolvedValue({ paymentStatus: 'PAID' } as never)
await expect(service.initPayment('atlas', 'reservation_1', {
provider: 'AMANPAY',
accessToken: 'booking-access-token-123',
successUrl: 'https://example.test/success',
failureUrl: 'https://example.test/failure',
})).rejects.toMatchObject({ statusCode: 409, error: 'already_paid' })
expect(amanpay.createCheckout).not.toHaveBeenCalled()
})
it('blocks payment when the primary or additional driver license requires review', async () => {
vi.mocked(validateLicense).mockReturnValue({ status: 'EXPIRING_SOON', requiresApproval: true } as never)
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
id: 'reservation_1',
paymentStatus: 'UNPAID',
totalAmount: 400,
customer: { licenseExpiry: new Date('2026-07-01'), licenseValidationStatus: 'PENDING' },
additionalDrivers: [],
vehicle: { make: 'Dacia', model: 'Duster' },
} as never)
await expect(service.initPayment('atlas', 'reservation_1', {
provider: 'PAYPAL',
accessToken: 'booking-access-token-123',
successUrl: 'https://example.test/success',
failureUrl: 'https://example.test/failure',
})).rejects.toMatchObject({ statusCode: 409, error: 'license_review_required' })
expect(paypal.createOrder).not.toHaveBeenCalled()
})
it('routes public contact messages to the brand public email when configured', async () => {
await expect(service.handleContact('atlas', { name: 'Visitor', email: 'visitor@example.test', message: 'Hi' })).resolves.toEqual({
success: true,
deliveredTo: 'hello@example.test',
preview: { name: 'Visitor', email: 'visitor@example.test', message: 'Hi' },
})
})
})
+72 -14
View File
@@ -10,7 +10,43 @@ import { prisma } from '../../lib/prisma'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './site.repo'
import { presentBrand } from './site.presenter'
import { presentBrand, presentPublicBooking } from './site.presenter'
import { generatePublicAccessToken, hashPublicAccessToken } from '../../security/publicAccessTokens'
function assertAllowedPaymentRedirect(urlValue: string, company: any) {
let parsed: URL
try {
parsed = new URL(urlValue)
} catch {
throw new AppError('Invalid payment redirect URL', 400, 'invalid_redirect_url')
}
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
throw new AppError('Payment redirect URLs must use HTTPS', 400, 'invalid_redirect_url')
}
const allowedHosts = new Set<string>()
if (process.env.NODE_ENV !== 'production') {
allowedHosts.add('localhost:3000')
allowedHosts.add('localhost:4000')
allowedHosts.add('127.0.0.1:3000')
allowedHosts.add('127.0.0.1:4000')
}
for (const value of [process.env.MARKETPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_MARKETPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
if (!value) continue
try { allowedHosts.add(new URL(value).host) } catch {}
}
const brand = company.brand as any
if (brand?.customDomain && brand?.customDomainVerified) allowedHosts.add(brand.customDomain)
if (brand?.subdomain && process.env.PUBLIC_SITE_BASE_DOMAIN) {
allowedHosts.add(`${brand.subdomain}.${process.env.PUBLIC_SITE_BASE_DOMAIN}`)
}
if (!allowedHosts.has(parsed.host)) {
throw new AppError('Payment redirect host is not allowed', 400, 'invalid_redirect_url')
}
}
export async function getPlatformHomepage() {
return getMarketplaceHomepageContent()
@@ -26,7 +62,7 @@ export async function getPlatformPricing() {
const prices = configs.length === 0
? PLAN_PRICES
: configs.reduce<Record<string, Record<string, Record<string, number>>>>((acc, config) => {
: configs.reduce<Record<string, Record<string, Record<string, number>>>>((acc: Record<string, Record<string, Record<string, number>>>, config: any) => {
if (!acc[config.plan]) acc[config.plan] = {}
acc[config.plan]![config.billingPeriod] = { MAD: config.amount }
return acc
@@ -34,7 +70,7 @@ export async function getPlatformPricing() {
const planFeatures = Object.fromEntries(
Object.entries(PLAN_FEATURES).map(([plan, fallback]) => {
const labels = features.filter((feature) => feature.plan === plan).map((feature) => feature.label)
const labels = features.filter((feature: any) => feature.plan === plan).map((feature: any) => feature.label)
return [plan, labels.length > 0 ? labels : fallback]
}),
)
@@ -51,8 +87,8 @@ 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)
vehicles.map(async (v: any) => {
const a = await getVehicleAvailabilitySummary(v.id, { companyId: v.companyId })
return { ...v, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
}),
)
@@ -61,7 +97,7 @@ export async function getPublicVehicles(slug: string) {
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)
const a = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId })
return { ...vehicle, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
}
@@ -77,8 +113,10 @@ export async function getBookingOptions(slug: string) {
}
export async function checkAvailability(slug: string, vehicleId: string, startDate: string, endDate: string) {
await repo.findCompanyBySlug(slug)
const availability = await getVehicleAvailabilitySummary(vehicleId, {
const company = await repo.findCompanyBySlug(slug)
const vehicle = await repo.findVehicleById(vehicleId, company.id)
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
companyId: company.id,
range: { startDate: new Date(startDate), endDate: new Date(endDate) },
})
return { available: availability.available, nextAvailableAt: availability.nextAvailableAt }
@@ -106,7 +144,7 @@ export async function createBooking(slug: string, body: {
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 } })
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: company.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,
@@ -185,24 +223,41 @@ export async function createBooking(slug: string, body: {
await validateAndFlagLicense(customer.id).catch(() => null)
}
const publicAccess = generatePublicAccessToken()
await repo.createReservationPublicAccess(
reservation.id,
publicAccess.tokenHash,
new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
)
const refreshed = await repo.findReservationWithDetails(reservation.id)
return {
...refreshed,
publicAccessToken: publicAccess.token,
requiresManualApproval:
primaryLicenseResult.requiresApproval ||
refreshed.additionalDrivers.some((d) => d.requiresApproval),
refreshed.additionalDrivers.some((d: any) => d.requiresApproval),
}
}
export async function getBooking(slug: string, reservationId: string) {
async function assertPublicBookingAccess(reservationId: string, token: string | undefined) {
if (!token) throw new AppError('Booking access token is required', 403, 'booking_token_required')
const access = await repo.findReservationPublicAccess(reservationId, hashPublicAccessToken(token))
if (!access) throw new AppError('Booking not found', 404, 'not_found')
await repo.markReservationPublicAccessUsed(access.id)
}
export async function getBooking(slug: string, reservationId: string, accessToken?: string) {
const company = await repo.findCompanyBySlug(slug)
return repo.findBooking(reservationId, company.id)
await assertPublicBookingAccess(reservationId, accessToken)
return presentPublicBooking(await repo.findBooking(reservationId, company.id))
}
export async function initPayment(slug: string, reservationId: string, body: {
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string; accessToken?: string
}) {
const company = await repo.findCompanyBySlug(slug)
await assertPublicBookingAccess(reservationId, body.accessToken)
const reservation = await repo.findReservationForPayment(reservationId, company.id)
if (reservation.paymentStatus === 'PAID') {
@@ -214,12 +269,15 @@ export async function initPayment(slug: string, reservationId: string, body: {
reservation.customer.licenseValidationStatus === 'DENIED' ||
customerLicenseResult.status === 'EXPIRED' ||
(customerLicenseResult.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') ||
reservation.additionalDrivers.some((d) => d.licenseExpired || (d.requiresApproval && !d.approvedAt))
reservation.additionalDrivers.some((d: any) => 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')
}
assertAllowedPaymentRedirect(body.successUrl, company)
assertAllowedPaymentRedirect(body.failureUrl, company)
const currency = body.currency ?? 'MAD'
const amount = reservation.totalAmount
const description = `Rental: ${reservation.vehicle.make} ${reservation.vehicle.model}`
+24 -3
View File
@@ -1,6 +1,18 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { AppError, NotFoundError } from '../../http/errors'
vi.mock('@rentaldrivego/types', () => ({
PLAN_FEATURES: { STARTER: ['fallback feature'] },
PLAN_PRICES: { STARTER: { MONTHLY: { MAD: 199 } } },
}))
vi.mock('../../lib/prisma', () => ({
prisma: {
pricingConfig: { findMany: vi.fn() },
planFeature: { findMany: vi.fn() },
},
}))
vi.mock('./site.repo', () => ({
findCompanyBySlug: vi.fn(),
findPublishedVehicles: vi.fn(),
@@ -13,6 +25,9 @@ vi.mock('./site.repo', () => ({
findReservationWithDetails: vi.fn(),
findBooking: vi.fn(),
findReservationForPayment: vi.fn(),
createReservationPublicAccess: vi.fn(),
findReservationPublicAccess: vi.fn(),
markReservationPublicAccessUsed: vi.fn(),
createRentalPayment: vi.fn(),
findPaymentByPaypalOrderId: vi.fn(),
capturePaypalPayment: vi.fn(),
@@ -83,7 +98,12 @@ function makeVehicle(overrides: object = {}) {
}
}
beforeEach(() => { vi.clearAllMocks() })
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
vi.mocked(repo.markReservationPublicAccessUsed).mockResolvedValue({ id: 'access_1' } as never)
})
// ────────────────────────────────────────────────────────────────────────────
describe('getBrand', () => {
@@ -126,11 +146,12 @@ describe('getPublicVehicles', () => {
describe('checkAvailability', () => {
it('returns availability result for given date range', async () => {
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 })
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') } })
expect(getVehicleAvailabilitySummary).toHaveBeenCalledWith('v-1', { companyId: 'co-1', range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') } })
})
})
@@ -195,7 +216,7 @@ describe('createBooking', () => {
// ────────────────────────────────────────────────────────────────────────────
describe('initPayment — payment guard paths', () => {
const payBody = { provider: 'PAYPAL' as const, successUrl: 'http://ok', failureUrl: 'http://fail' }
const payBody = { provider: 'PAYPAL' as const, successUrl: 'http://localhost:3000/ok', failureUrl: 'http://localhost:3000/fail', accessToken: 'booking-access-token-123' }
it('throws AppError when reservation is already paid', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)