fixing platform admin

This commit is contained in:
root
2026-05-06 22:58:23 -04:00
parent 695a7f7cc7
commit 750ae56a29
175 changed files with 31249 additions and 328 deletions
@@ -0,0 +1,95 @@
import { AdditionalDriverCharge } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
import { validateLicense } from './licenseValidationService'
export interface AdditionalDriverInput {
firstName: string
lastName: string
email?: string
phone?: string
driverLicense: string
licenseExpiry?: string | null
licenseIssuedAt?: string | null
dateOfBirth?: string | null
nationality?: string
}
export function calculateAdditionalDriverCharge(
chargeType: AdditionalDriverCharge,
chargeValue: number,
totalDays: number,
) {
switch (chargeType) {
case 'PER_DAY':
return chargeValue * totalDays
case 'FLAT':
return chargeValue
default:
return 0
}
}
export async function applyAdditionalDriversToReservation(
reservationId: string,
companyId: string,
drivers: AdditionalDriverInput[],
totalDays: number,
) {
if (drivers.length === 0) {
return { records: [], additionalDriverTotal: 0, requiresManualApproval: false }
}
const settings = await prisma.contractSettings.findUnique({ where: { companyId } })
const chargeType = settings?.additionalDriverCharge ?? 'FREE'
const chargeValue =
chargeType === 'PER_DAY'
? settings?.additionalDriverDailyRate ?? 0
: chargeType === 'FLAT'
? settings?.additionalDriverFlatRate ?? 0
: 0
const records = drivers.map((driver) => {
const licenseResult = validateLicense(driver.licenseExpiry ? new Date(driver.licenseExpiry) : null)
const totalCharge = calculateAdditionalDriverCharge(chargeType, chargeValue, totalDays)
return {
reservationId,
companyId,
firstName: driver.firstName,
lastName: driver.lastName,
email: driver.email ?? null,
phone: driver.phone ?? null,
driverLicense: driver.driverLicense,
licenseExpiry: driver.licenseExpiry ? new Date(driver.licenseExpiry) : null,
licenseIssuedAt: driver.licenseIssuedAt ? new Date(driver.licenseIssuedAt) : null,
dateOfBirth: driver.dateOfBirth ? new Date(driver.dateOfBirth) : null,
nationality: driver.nationality ?? null,
chargeType,
chargeValue,
totalCharge,
licenseExpired: licenseResult.status === 'EXPIRED',
licenseExpiringSoon: licenseResult.status === 'EXPIRING',
requiresApproval: licenseResult.requiresApproval,
approvalNote: licenseResult.requiresApproval ? licenseResult.message : null,
}
})
const additionalDriverTotal = records.reduce((sum, record) => sum + record.totalCharge, 0)
await prisma.$transaction([
prisma.additionalDriver.createMany({ data: records }),
prisma.reservation.update({
where: { id: reservationId },
data: {
additionalDriverTotal,
totalAmount: { increment: additionalDriverTotal },
},
}),
])
return {
records,
additionalDriverTotal,
requiresManualApproval: records.some((record) => record.requiresApproval),
}
}
+97
View File
@@ -0,0 +1,97 @@
import crypto from 'crypto'
const BASE_URL = process.env.AMANPAY_BASE_URL ?? 'https://api.amanpay.net'
const MERCHANT_ID = process.env.AMANPAY_MERCHANT_ID ?? ''
const SECRET_KEY = process.env.AMANPAY_SECRET_KEY ?? ''
const WEBHOOK_SECRET = process.env.AMANPAY_WEBHOOK_SECRET ?? ''
export interface AmanPayCreateParams {
amount: number
currency: string
orderId: string
description: string
customerEmail?: string
customerName?: string
successUrl: string
failureUrl: string
webhookUrl: string
}
export interface AmanPayCheckoutResult {
transactionId: string
checkoutUrl: string
status: string
}
async function getAuthHeaders() {
return {
'Content-Type': 'application/json',
'x-merchant-id': MERCHANT_ID,
'x-api-key': SECRET_KEY,
}
}
export async function createCheckout(params: AmanPayCreateParams): Promise<AmanPayCheckoutResult> {
const res = await fetch(`${BASE_URL}/v1/payments`, {
method: 'POST',
headers: await getAuthHeaders(),
body: JSON.stringify({
merchant_id: MERCHANT_ID,
amount: params.amount,
currency: params.currency,
order_id: params.orderId,
description: params.description,
customer_email: params.customerEmail,
customer_name: params.customerName,
success_url: params.successUrl,
failure_url: params.failureUrl,
webhook_url: params.webhookUrl,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`AmanPay checkout failed: ${err?.message ?? res.statusText}`)
}
const json = await res.json()
return {
transactionId: json.transaction_id ?? json.id,
checkoutUrl: json.checkout_url ?? json.payment_url,
status: json.status ?? 'PENDING',
}
}
export async function getTransaction(transactionId: string) {
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}`, {
headers: await getAuthHeaders(),
})
if (!res.ok) throw new Error('AmanPay: failed to fetch transaction')
return res.json()
}
export async function refundTransaction(transactionId: string, amount: number, reason?: string) {
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}/refund`, {
method: 'POST',
headers: await getAuthHeaders(),
body: JSON.stringify({ amount, reason }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`AmanPay refund failed: ${err?.message ?? res.statusText}`)
}
return res.json()
}
export function verifyWebhookSignature(rawBody: string, signature: string): boolean {
if (!WEBHOOK_SECRET) return false
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
export function isConfigured(): boolean {
return !!(MERCHANT_ID && SECRET_KEY && MERCHANT_ID !== 'your-amanpay-merchant-id')
}
@@ -15,17 +15,17 @@ export async function generateFinancialReport(companyId: string, startDate: Date
const summary = {
totalReservations: reservations.length,
totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0),
totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0),
totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0),
totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0),
totalPricingRulesAdj: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0),
totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0),
totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0),
averageRentalDays: reservations.length > 0 ? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0,
totalRentalRevenue: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.dailyRate * r.totalDays, 0),
totalDiscounts: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.discountAmount, 0),
totalInsurance: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.insuranceTotal, 0),
totalAdditionalDrivers: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.additionalDriverTotal, 0),
totalPricingRulesAdj: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.pricingRulesTotal, 0),
totalDeposits: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.depositAmount, 0),
totalCollected: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalAmount, 0),
averageRentalDays: reservations.length > 0 ? reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalDays, 0) / reservations.length : 0,
}
const rows = reservations.map((r) => ({
const rows = reservations.map((r: (typeof reservations)[number]) => ({
reservationId: r.id,
contractNumber: r.contractNumber ?? '—',
invoiceNumber: r.invoiceNumber ?? '—',
+9 -3
View File
@@ -22,8 +22,8 @@ export async function applyInsurancesToReservation(
baseRentalAmount: number
) {
const allPolicies = await prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } })
const required = allPolicies.filter((p) => p.isRequired)
const selected = allPolicies.filter((p) => selectedPolicyIds.includes(p.id) && !p.isRequired)
const required = allPolicies.filter((p: InsurancePolicy) => p.isRequired)
const selected = allPolicies.filter((p: InsurancePolicy) => selectedPolicyIds.includes(p.id) && !p.isRequired)
const toApply = [...required, ...selected]
const records = toApply.map((policy) => ({
@@ -40,7 +40,13 @@ export async function applyInsurancesToReservation(
await prisma.$transaction([
prisma.reservationInsurance.createMany({ data: records }),
prisma.reservation.update({ where: { id: reservationId }, data: { insuranceTotal } }),
prisma.reservation.update({
where: { id: reservationId },
data: {
insuranceTotal,
totalAmount: { increment: insuranceTotal },
},
}),
])
return { records, insuranceTotal }
@@ -43,6 +43,8 @@ export async function validateAndFlagLicense(customerId: string) {
licenseExpired: result.status === 'EXPIRED',
licenseExpiringSoon: result.status === 'EXPIRING',
licenseValidationStatus: status,
flagged: result.requiresApproval ? true : customer.flagged,
flagReason: result.requiresApproval ? result.message : customer.flagReason,
},
})
+139 -20
View File
@@ -5,21 +5,93 @@ import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
const resend = new Resend(process.env.RESEND_API_KEY)
const resend =
process.env.RESEND_API_KEY && process.env.RESEND_API_KEY !== 're_...'
? new Resend(process.env.RESEND_API_KEY)
: null
const twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
)
const emailFromAddress =
process.env.EMAIL_FROM && process.env.EMAIL_FROM !== 'noreply@example.com'
? process.env.EMAIL_FROM
: null
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
}),
})
const emailFromName =
process.env.EMAIL_FROM_NAME && process.env.EMAIL_FROM_NAME !== 'Example App'
? process.env.EMAIL_FROM_NAME
: null
const smtpHost = process.env.MAIL_HOST
const smtpPort = Number(process.env.MAIL_PORT ?? 0)
const smtpUser = process.env.MAIL_USERNAME
const smtpPass = process.env.MAIL_PASSWORD
const smtpSecure =
process.env.MAIL_SCHEME === 'smtps' ||
process.env.MAIL_ENCRYPTION === 'ssl' ||
smtpPort === 465
const smtpFromAddress =
process.env.MAIL_FROM_ADDRESS && !process.env.MAIL_FROM_ADDRESS.includes('${')
? process.env.MAIL_FROM_ADDRESS
: null
const smtpFromName =
process.env.MAIL_FROM_NAME && !process.env.MAIL_FROM_NAME.includes('${')
? process.env.MAIL_FROM_NAME
: null
type SmtpTransport = {
sendMail(options: Record<string, unknown>): Promise<{ messageId?: string }>
}
let smtpTransport: SmtpTransport | null = null
if (smtpHost && smtpPort && smtpUser && smtpPass) {
try {
const nodemailer = require('nodemailer') as {
createTransport(options: Record<string, unknown>): SmtpTransport
}
smtpTransport = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass,
},
})
} catch (err: any) {
console.warn('[Notifications] SMTP transport unavailable:', err?.message ?? String(err))
}
}
const twilioClient =
process.env.TWILIO_ACCOUNT_SID &&
process.env.TWILIO_AUTH_TOKEN &&
process.env.TWILIO_ACCOUNT_SID !== 'AC...' &&
process.env.TWILIO_AUTH_TOKEN !== 'your-twilio-auth-token'
? twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
: null
const firebasePrivateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
const hasFirebaseConfig =
!!process.env.FIREBASE_PROJECT_ID &&
!!process.env.FIREBASE_CLIENT_EMAIL &&
!!firebasePrivateKey &&
!firebasePrivateKey.includes('BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY')
if (hasFirebaseConfig && !admin.apps.length) {
try {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
privateKey: firebasePrivateKey,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
}),
})
} catch (err: any) {
console.warn('[Notifications] Firebase init skipped:', err.message)
}
}
interface SendNotificationOptions {
@@ -37,6 +109,22 @@ interface SendNotificationOptions {
locale?: string
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderEmailHtml(body: string) {
return body
.split(/\n{2,}/)
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
.join('')
}
export async function sendNotification(opts: SendNotificationOptions) {
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = []
@@ -60,19 +148,48 @@ export async function sendNotification(opts: SendNotificationOptions) {
let success = false
if (channel === 'EMAIL' && opts.email) {
const { data, error } = await resend.emails.send({
from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM}>`,
to: opts.email,
subject: opts.title,
html: `<p>${opts.body}</p>`,
})
if (!error) {
if (resend) {
if (!emailFromAddress || !emailFromName) {
throw new Error('Email sender identity is not configured')
}
const { data, error } = await resend.emails.send({
from: `${emailFromName} <${emailFromAddress}>`,
to: opts.email,
subject: opts.title,
html: renderEmailHtml(opts.body),
text: opts.body,
})
if (error) {
throw new Error(error.message)
}
providerMessageId = data?.id ?? null
success = true
} else if (smtpTransport) {
if (!smtpFromAddress) {
throw new Error('SMTP sender identity is not configured')
}
const info = await smtpTransport.sendMail({
from: smtpFromName ? `${smtpFromName} <${smtpFromAddress}>` : smtpFromAddress,
to: opts.email,
subject: opts.title,
html: renderEmailHtml(opts.body),
text: opts.body,
replyTo:
process.env.MAIL_REPLY_TO_ADDRESS && !process.env.MAIL_REPLY_TO_ADDRESS.includes('${')
? process.env.MAIL_REPLY_TO_NAME && !process.env.MAIL_REPLY_TO_NAME.includes('${')
? `${process.env.MAIL_REPLY_TO_NAME} <${process.env.MAIL_REPLY_TO_ADDRESS}>`
: process.env.MAIL_REPLY_TO_ADDRESS
: undefined,
})
providerMessageId = info.messageId
success = true
} else {
throw new Error('No email provider is configured')
}
}
if (channel === 'SMS' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({
body: opts.body,
from: process.env.TWILIO_PHONE_NUMBER!,
@@ -83,6 +200,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
}
if (channel === 'WHATSAPP' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({
body: opts.body,
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
@@ -93,6 +211,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
}
if (channel === 'PUSH' && opts.fcmToken) {
if (!admin.apps.length) throw new Error('Firebase is not configured')
const response = await admin.messaging().send({
token: opts.fcmToken,
notification: { title: opts.title, body: opts.body },
+127
View File
@@ -0,0 +1,127 @@
const BASE_URL = process.env.PAYPAL_BASE_URL ?? 'https://api-m.paypal.com'
const CLIENT_ID = process.env.PAYPAL_CLIENT_ID ?? ''
const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET ?? ''
let cachedToken: { token: string; expiresAt: number } | null = null
async function getAccessToken(): Promise<string> {
if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) {
return cachedToken.token
}
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
},
body: 'grant_type=client_credentials',
})
if (!res.ok) throw new Error('PayPal: failed to obtain access token')
const json = await res.json()
cachedToken = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 }
return cachedToken.token
}
async function ppFetch(path: string, init?: RequestInit) {
const token = await getAccessToken()
const res = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(init?.headers as Record<string, string> ?? {}),
},
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`PayPal ${path} failed: ${err?.message ?? res.statusText}`)
}
return res.json()
}
export interface PayPalCreateOrderParams {
amount: number
currency: string
orderId: string
description: string
returnUrl: string
cancelUrl: string
}
export interface PayPalOrderResult {
orderId: string
approveUrl: string
status: string
}
export async function createOrder(params: PayPalCreateOrderParams): Promise<PayPalOrderResult> {
const json = await ppFetch('/v2/checkout/orders', {
method: 'POST',
body: JSON.stringify({
intent: 'CAPTURE',
purchase_units: [
{
reference_id: params.orderId,
description: params.description,
amount: {
currency_code: params.currency,
value: (params.amount / 100).toFixed(2),
},
},
],
application_context: {
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
brand_name: 'RentalDriveGo',
user_action: 'PAY_NOW',
},
}),
})
const approveLink = json.links?.find((l: { rel: string; href: string }) => l.rel === 'approve')
return {
orderId: json.id,
approveUrl: approveLink?.href ?? '',
status: json.status,
}
}
export async function captureOrder(paypalOrderId: string) {
return ppFetch(`/v2/checkout/orders/${paypalOrderId}/capture`, { method: 'POST', body: '{}' })
}
export async function refundCapture(captureId: string, amount: number, currency: string, note?: string) {
return ppFetch(`/v2/payments/captures/${captureId}/refund`, {
method: 'POST',
body: JSON.stringify({
amount: { currency_code: currency, value: (amount / 100).toFixed(2) },
note_to_payer: note,
}),
})
}
export async function verifyWebhookEvent(headers: Record<string, string | undefined>, rawBody: string) {
try {
const json = await ppFetch('/v1/notifications/verify-webhook-signature', {
method: 'POST',
body: JSON.stringify({
auth_algo: headers['paypal-auth-algo'],
cert_url: headers['paypal-cert-url'],
transmission_id: headers['paypal-transmission-id'],
transmission_sig: headers['paypal-transmission-sig'],
transmission_time: headers['paypal-transmission-time'],
webhook_id: process.env.PAYPAL_WEBHOOK_ID ?? '',
webhook_event: JSON.parse(rawBody),
}),
})
return json.verification_status === 'SUCCESS'
} catch {
return false
}
}
export function isConfigured(): boolean {
return !!(CLIENT_ID && CLIENT_SECRET && CLIENT_ID !== 'your-paypal-client-id')
}
+2 -2
View File
@@ -31,7 +31,7 @@ export async function listEmployees(companyId: string): Promise<TeamMember[]> {
})
const hydrated = await Promise.allSettled(
employees.map(async (emp) => {
employees.map(async (emp: (typeof employees)[number]) => {
try {
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
return { ...emp, lastActiveAt: clerkUser.lastActiveAt ? new Date(clerkUser.lastActiveAt) : null, invitationStatus: 'accepted' as const }
@@ -41,7 +41,7 @@ export async function listEmployees(companyId: string): Promise<TeamMember[]> {
})
)
return hydrated.map((r) => (r.status === 'fulfilled' ? r.value : (r as any).value))
return hydrated.flatMap((r): TeamMember[] => (r.status === 'fulfilled' ? [r.value] : []))
}
export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) {