fixing platform admin
This commit is contained in:
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 ?? '—',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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 },
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user