import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types' import { AppError, NotFoundError } from '../../http/errors' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { applyPricingRules } from '../../services/pricingRuleService' import { validateLicense } from '../../services/licenseValidationService' import { getMarketplaceHomepageContent } from '../../services/platformContentService' 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, 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() 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') } } function generateBookingReference() { const year = new Date().getUTCFullYear() const random = Math.random().toString(36).slice(2, 8).toUpperCase() return `BK-${year}-${random}` } export async function getPlatformHomepage() { return getMarketplaceHomepageContent() } export async function getPlatformPricing() { const [configs, features] = await Promise.all([ prisma.pricingConfig.findMany(), prisma.planFeature.findMany({ orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }], }), ]) const prices = configs.length === 0 ? PLAN_PRICES : configs.reduce>>>((acc: Record>>, config: any) => { if (!acc[config.plan]) acc[config.plan] = {} acc[config.plan]![config.billingPeriod] = { MAD: config.amount } return acc }, {}) const planFeatures = Object.fromEntries( Object.entries(PLAN_FEATURES).map(([plan, fallback]) => { const labels = features.filter((feature: any) => feature.plan === plan).map((feature: any) => feature.label) return [plan, labels.length > 0 ? labels : fallback] }), ) return { prices, planFeatures } } 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: any) => { const a = await getVehicleAvailabilitySummary(v.id, { companyId: v.companyId }) 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, { companyId: vehicle.companyId }) 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) { 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 } } 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 pickupLocation: string; returnLocation: string driverLicense?: string; dateOfBirth?: string; licenseExpiry?: string; licenseIssuedAt?: string; nationality?: string identityDocumentNumber?: string; fullAddress?: string; licenseCountry?: string; licenseNumber?: string; licenseCategory?: string; internationalLicenseNumber?: 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') if (start < new Date()) throw new AppError('Start date cannot be in the past', 400, 'invalid_dates') 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, }) } 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, identityDocumentNumber: body.identityDocumentNumber, fullAddress: body.fullAddress, licenseCountry: body.licenseCountry, licenseNumber: body.licenseNumber, licenseCategory: body.licenseCategory, internationalLicenseNumber: body.internationalLicenseNumber, }) 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 { applied, total: pricingRulesTotal } = await applyPricingRules( company.id, customer.id, [], vehicle.dailyRate, totalDays, ) 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, pickupLocation: body.pickupLocation, returnLocation: body.returnLocation, dailyRate: vehicle.dailyRate, totalDays, totalAmount: baseAmount - discountAmount + pricingRulesTotal, discountAmount, pricingRulesApplied: applied, pricingRulesTotal, notes: body.notes ?? null, bookingReference: generateBookingReference(), status: 'DRAFT', }) 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 { ...presentPublicBooking({ ...refreshed, company }), publicAccessToken: publicAccess.token, bookingReference: (refreshed as any).bookingReference, message: 'The company will review your request.', } } 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) 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; 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') { 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: 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}` 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 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, } }