Files
carmanagement/apps/api/src/modules/site/site.service.ts
T
2026-06-11 15:35:25 -04:00

345 lines
14 KiB
TypeScript

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<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')
}
}
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<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
}, {})
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<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,
}
}