fix architecture and write new tests
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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() },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user