fix payment customer and dashboard settings
Build & Deploy / Build & Push Docker Image (push) Successful in 12m39s
Test / Type Check (all packages) (push) Successful in 5m8s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 4m24s
Test / Homepage Unit Tests (push) Successful in 3m27s
Test / Storefront Unit Tests (push) Successful in 4m48s
Test / Admin Unit Tests (push) Successful in 3m0s
Test / Dashboard Unit Tests (push) Successful in 4m18s
Test / API Integration Tests (push) Failing after 4m34s
Build & Deploy / Build & Push Docker Image (push) Successful in 12m39s
Test / Type Check (all packages) (push) Successful in 5m8s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 4m24s
Test / Homepage Unit Tests (push) Successful in 3m27s
Test / Storefront Unit Tests (push) Successful in 4m48s
Test / Admin Unit Tests (push) Successful in 3m0s
Test / Dashboard Unit Tests (push) Successful in 4m18s
Test / API Integration Tests (push) Failing after 4m34s
This commit is contained in:
@@ -24,6 +24,7 @@ import subscriptionsRouter, {
|
|||||||
subscriptionWebhookRouter,
|
subscriptionWebhookRouter,
|
||||||
} from './modules/subscriptions/subscription.routes'
|
} from './modules/subscriptions/subscription.routes'
|
||||||
import paymentsRouter from './modules/payments/payment.routes'
|
import paymentsRouter from './modules/payments/payment.routes'
|
||||||
|
import billingRouter from './modules/billing/billing.routes'
|
||||||
import customersRouter from './modules/customers/customer.routes'
|
import customersRouter from './modules/customers/customer.routes'
|
||||||
import vehiclesRouter from './modules/vehicles/vehicle.routes'
|
import vehiclesRouter from './modules/vehicles/vehicle.routes'
|
||||||
import companiesRouter from './modules/companies/company.routes'
|
import companiesRouter from './modules/companies/company.routes'
|
||||||
@@ -202,6 +203,7 @@ export function createApp() {
|
|||||||
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
||||||
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
||||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||||
|
app.use(`${v1}/billing`, apiLimiter, billingRouter)
|
||||||
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
||||||
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
||||||
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
|
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||||
|
import { requireTenant } from '../../middleware/requireTenant'
|
||||||
|
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||||
|
import { requireRole } from '../../middleware/requireRole'
|
||||||
|
import { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||||
|
import { ok } from '../../http/respond'
|
||||||
|
import * as service from './billing.service'
|
||||||
|
import {
|
||||||
|
billingListQuerySchema,
|
||||||
|
billingSummaryQuerySchema,
|
||||||
|
invoiceParamSchema,
|
||||||
|
manualBillingPaymentSchema,
|
||||||
|
} from './billing.schemas'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.use(requireCompanyAuth, requireTenant, requireSubscription, requireRole('OWNER'))
|
||||||
|
|
||||||
|
router.get('/summary', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(billingSummaryQuerySchema, req)
|
||||||
|
ok(res, await service.getSummary(req.companyId, query))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/invoices', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(billingListQuerySchema, req)
|
||||||
|
ok(res, await service.listInvoices(req.companyId, query))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/invoices/:invoiceId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { invoiceId } = parseParams(invoiceParamSchema, req)
|
||||||
|
ok(res, await service.getInvoice(req.companyId, invoiceId))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/invoices/:invoiceId/payments/manual', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { invoiceId } = parseParams(invoiceParamSchema, req)
|
||||||
|
const body = parseBody(manualBillingPaymentSchema, req)
|
||||||
|
ok(res, await service.recordManualPayment(req.companyId, req.employee.id, invoiceId, body))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { billingListQuerySchema } from './billing.schemas'
|
||||||
|
|
||||||
|
describe('billing.schemas billingListQuerySchema', () => {
|
||||||
|
it('parses explicit outstandingOnly query strings as booleans', () => {
|
||||||
|
expect(billingListQuerySchema.parse({ outstandingOnly: 'false' }).outstandingOnly).toBe(false)
|
||||||
|
expect(billingListQuerySchema.parse({ outstandingOnly: 'true' }).outstandingOnly).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults outstandingOnly to false', () => {
|
||||||
|
expect(billingListQuerySchema.parse({}).outstandingOnly).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const queryBooleanSchema = z.preprocess((value) => {
|
||||||
|
if (value === 'true') return true
|
||||||
|
if (value === 'false') return false
|
||||||
|
return value
|
||||||
|
}, z.boolean())
|
||||||
|
|
||||||
|
export const billingListQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
pageSize: z.coerce.number().int().positive().max(100).default(25),
|
||||||
|
search: z.string().trim().max(120).optional().default(''),
|
||||||
|
paymentStatus: z.enum(['ALL', 'UNPAID', 'PARTIAL', 'PAID']).optional().default('ALL'),
|
||||||
|
outstandingOnly: queryBooleanSchema.optional().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const billingSummaryQuerySchema = z.object({
|
||||||
|
search: z.string().trim().max(120).optional().default(''),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const invoiceParamSchema = z.object({
|
||||||
|
invoiceId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const manualBillingPaymentSchema = z.object({
|
||||||
|
amountMinor: z.number().int().positive(),
|
||||||
|
currency: z.literal('MAD').default('MAD'),
|
||||||
|
type: z.enum(['CHARGE', 'DEPOSIT']),
|
||||||
|
method: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL', 'OTHER']),
|
||||||
|
receivedAt: z.string().datetime().optional(),
|
||||||
|
reference: z.string().trim().max(120).optional(),
|
||||||
|
note: z.string().trim().max(1000).optional(),
|
||||||
|
idempotencyKey: z.string().uuid(),
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildBillingInvoice } from './billing.service'
|
||||||
|
|
||||||
|
const baseReservation = {
|
||||||
|
id: 'reservation_1',
|
||||||
|
invoiceNumber: 'INV-001',
|
||||||
|
contractNumber: 'CON-001',
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
paymentStatus: 'UNPAID',
|
||||||
|
startDate: new Date('2026-06-01T10:00:00.000Z'),
|
||||||
|
endDate: new Date('2026-06-05T10:00:00.000Z'),
|
||||||
|
totalAmount: 100000,
|
||||||
|
depositAmount: 20000,
|
||||||
|
customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' },
|
||||||
|
vehicle: { make: 'Dacia', model: 'Duster', licensePlate: '123-A-6' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function payment(overrides: Partial<any>) {
|
||||||
|
return {
|
||||||
|
id: `payment_${Math.random()}`,
|
||||||
|
reservationId: 'reservation_1',
|
||||||
|
amount: 10000,
|
||||||
|
currency: 'MAD',
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
type: 'CHARGE',
|
||||||
|
paymentProvider: 'MANUAL',
|
||||||
|
paymentMethod: 'CASH',
|
||||||
|
paidAt: new Date('2026-06-01T12:00:00.000Z'),
|
||||||
|
createdAt: new Date('2026-06-01T12:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('billing.service buildBillingInvoice', () => {
|
||||||
|
it('keeps invoice charges and security deposits in separate balances', () => {
|
||||||
|
const invoice = buildBillingInvoice({
|
||||||
|
...baseReservation,
|
||||||
|
rentalPayments: [
|
||||||
|
payment({ amount: 100000, type: 'CHARGE' }),
|
||||||
|
payment({ amount: 5000, type: 'DEPOSIT' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(invoice.invoicePaid).toBe(100000)
|
||||||
|
expect(invoice.invoiceBalanceDue).toBe(0)
|
||||||
|
expect(invoice.depositCollected).toBe(5000)
|
||||||
|
expect(invoice.depositOutstanding).toBe(15000)
|
||||||
|
expect(invoice.paymentStatus).toBe('PAID')
|
||||||
|
expect(invoice.depositStatus).toBe('PARTIALLY_COLLECTED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not count failed or pending payments as collected', () => {
|
||||||
|
const invoice = buildBillingInvoice({
|
||||||
|
...baseReservation,
|
||||||
|
rentalPayments: [
|
||||||
|
payment({ amount: 30000, type: 'CHARGE', status: 'PENDING' }),
|
||||||
|
payment({ amount: 20000, type: 'DEPOSIT', status: 'FAILED' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(invoice.invoicePaid).toBe(0)
|
||||||
|
expect(invoice.invoiceBalanceDue).toBe(100000)
|
||||||
|
expect(invoice.depositCollected).toBe(0)
|
||||||
|
expect(invoice.depositOutstanding).toBe(20000)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { ConflictError, NotFoundError, ValidationError } from '../../http/errors'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
|
||||||
|
type BillingPayment = {
|
||||||
|
id: string
|
||||||
|
reservationId: string
|
||||||
|
amount: number
|
||||||
|
currency: string
|
||||||
|
status: string
|
||||||
|
type: string
|
||||||
|
paymentProvider: string
|
||||||
|
paymentMethod: string | null
|
||||||
|
reference?: string | null
|
||||||
|
note?: string | null
|
||||||
|
receivedAt?: Date | null
|
||||||
|
paidAt: Date | null
|
||||||
|
createdAt: Date
|
||||||
|
recordedByEmployee?: { firstName: string; lastName: string; email: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type BillingReservation = {
|
||||||
|
id: string
|
||||||
|
invoiceNumber: string | null
|
||||||
|
contractNumber: string | null
|
||||||
|
status: string
|
||||||
|
paymentStatus: string
|
||||||
|
startDate: Date
|
||||||
|
endDate: Date
|
||||||
|
totalAmount: number
|
||||||
|
depositAmount: number
|
||||||
|
customer: { firstName: string; lastName: string; email: string }
|
||||||
|
vehicle: { make: string; model: string; licensePlate: string }
|
||||||
|
rentalPayments: BillingPayment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListQuery = {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
search: string
|
||||||
|
paymentStatus: 'ALL' | 'UNPAID' | 'PARTIAL' | 'PAID'
|
||||||
|
outstandingOnly: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SummaryQuery = {
|
||||||
|
search: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualPaymentInput = {
|
||||||
|
amountMinor: number
|
||||||
|
currency: 'MAD'
|
||||||
|
type: 'CHARGE' | 'DEPOSIT'
|
||||||
|
method: 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' | 'OTHER'
|
||||||
|
receivedAt?: string
|
||||||
|
reference?: string
|
||||||
|
note?: string
|
||||||
|
idempotencyKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BILLING_CURRENCY = 'MAD'
|
||||||
|
const COLLECTED_STATUS = new Set(['SUCCEEDED'])
|
||||||
|
|
||||||
|
function sumCollected(payments: BillingPayment[], type: 'CHARGE' | 'DEPOSIT') {
|
||||||
|
return payments.reduce((total, payment) => {
|
||||||
|
if (payment.type !== type || !COLLECTED_STATUS.has(payment.status)) return total
|
||||||
|
return total + payment.amount
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivePaymentStatus(invoiceBalanceDue: number, invoicePaid: number) {
|
||||||
|
if (invoiceBalanceDue <= 0) return 'PAID'
|
||||||
|
if (invoicePaid > 0) return 'PARTIAL'
|
||||||
|
return 'UNPAID'
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveDepositStatus(depositRequired: number, depositCollected: number) {
|
||||||
|
if (depositRequired <= 0) return 'NOT_REQUIRED'
|
||||||
|
if (depositCollected <= 0) return 'OUTSTANDING'
|
||||||
|
if (depositCollected < depositRequired) return 'PARTIALLY_COLLECTED'
|
||||||
|
return 'HELD'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBillingInvoice(reservation: BillingReservation) {
|
||||||
|
const invoiceTotal = reservation.totalAmount
|
||||||
|
const invoicePaid = sumCollected(reservation.rentalPayments, 'CHARGE')
|
||||||
|
const invoiceRefunded = 0
|
||||||
|
const invoiceBalanceDue = Math.max(invoiceTotal - invoicePaid, 0)
|
||||||
|
const depositRequired = reservation.depositAmount
|
||||||
|
const depositCollected = sumCollected(reservation.rentalPayments, 'DEPOSIT')
|
||||||
|
const depositRefunded = 0
|
||||||
|
const depositHeld = Math.max(depositCollected - depositRefunded, 0)
|
||||||
|
const depositOutstanding = Math.max(depositRequired - depositCollected, 0)
|
||||||
|
|
||||||
|
const payments = [...reservation.rentalPayments]
|
||||||
|
.sort((a, b) => new Date(b.paidAt ?? b.receivedAt ?? b.createdAt).getTime() - new Date(a.paidAt ?? a.receivedAt ?? a.createdAt).getTime())
|
||||||
|
.map((payment) => ({
|
||||||
|
id: payment.id,
|
||||||
|
reservationId: payment.reservationId,
|
||||||
|
amountMinor: payment.amount,
|
||||||
|
currency: payment.currency,
|
||||||
|
type: payment.type,
|
||||||
|
channel: payment.paymentProvider === 'MANUAL' ? 'OFFLINE' : 'ONLINE',
|
||||||
|
provider: payment.paymentProvider,
|
||||||
|
method: payment.paymentMethod,
|
||||||
|
status: payment.status,
|
||||||
|
reference: payment.reference ?? null,
|
||||||
|
note: payment.note ?? null,
|
||||||
|
receivedAt: payment.receivedAt?.toISOString() ?? payment.paidAt?.toISOString() ?? payment.createdAt.toISOString(),
|
||||||
|
paidAt: payment.paidAt?.toISOString() ?? null,
|
||||||
|
createdAt: payment.createdAt.toISOString(),
|
||||||
|
recordedBy: payment.recordedByEmployee
|
||||||
|
? {
|
||||||
|
name: `${payment.recordedByEmployee.firstName} ${payment.recordedByEmployee.lastName}`,
|
||||||
|
email: payment.recordedByEmployee.email,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
refundedAmountMinor: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: reservation.id,
|
||||||
|
reservationId: reservation.id,
|
||||||
|
invoiceNumber: reservation.invoiceNumber,
|
||||||
|
contractNumber: reservation.contractNumber,
|
||||||
|
customer: reservation.customer,
|
||||||
|
vehicle: reservation.vehicle,
|
||||||
|
rentalPeriod: {
|
||||||
|
startDate: reservation.startDate.toISOString(),
|
||||||
|
endDate: reservation.endDate.toISOString(),
|
||||||
|
},
|
||||||
|
currency: BILLING_CURRENCY,
|
||||||
|
status: reservation.status,
|
||||||
|
paymentStatus: derivePaymentStatus(invoiceBalanceDue, invoicePaid),
|
||||||
|
issuedAt: null,
|
||||||
|
dueAt: reservation.startDate.toISOString(),
|
||||||
|
subtotal: invoiceTotal,
|
||||||
|
taxTotal: 0,
|
||||||
|
discountTotal: 0,
|
||||||
|
adjustmentTotal: 0,
|
||||||
|
invoiceTotal,
|
||||||
|
invoicePaid,
|
||||||
|
invoiceRefunded,
|
||||||
|
invoiceBalanceDue,
|
||||||
|
depositRequired,
|
||||||
|
depositCollected,
|
||||||
|
depositRefunded,
|
||||||
|
depositHeld,
|
||||||
|
depositOutstanding,
|
||||||
|
depositStatus: deriveDepositStatus(depositRequired, depositCollected),
|
||||||
|
paymentCount: payments.length,
|
||||||
|
latestPayment: payments[0] ?? null,
|
||||||
|
payments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReservationWhere(companyId: string, search = '') {
|
||||||
|
const trimmedSearch = search.trim()
|
||||||
|
const where: any = { companyId }
|
||||||
|
|
||||||
|
if (trimmedSearch) {
|
||||||
|
where.OR = [
|
||||||
|
{ invoiceNumber: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||||
|
{ contractNumber: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||||
|
{ customer: { firstName: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
{ customer: { lastName: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
{ customer: { email: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
{ vehicle: { make: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
{ vehicle: { model: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
{ vehicle: { licensePlate: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return where
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservationInclude = {
|
||||||
|
customer: true,
|
||||||
|
vehicle: true,
|
||||||
|
rentalPayments: {
|
||||||
|
include: {
|
||||||
|
recordedByEmployee: {
|
||||||
|
select: { firstName: true, lastName: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export async function listInvoices(companyId: string, query: ListQuery) {
|
||||||
|
const where = buildReservationWhere(companyId, query.search)
|
||||||
|
const reservations = await prisma.reservation.findMany({
|
||||||
|
where,
|
||||||
|
include: reservationInclude as any,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
let items = reservations.map((reservation) => buildBillingInvoice(reservation as any))
|
||||||
|
if (query.paymentStatus !== 'ALL') {
|
||||||
|
items = items.filter((invoice) => invoice.paymentStatus === query.paymentStatus)
|
||||||
|
}
|
||||||
|
if (query.outstandingOnly) {
|
||||||
|
items = items.filter((invoice) => invoice.invoiceBalanceDue > 0 || invoice.depositOutstanding > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRecords = items.length
|
||||||
|
const pagedItems = items.slice((query.page - 1) * query.pageSize, query.page * query.pageSize)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: pagedItems,
|
||||||
|
page: query.page,
|
||||||
|
pageSize: query.pageSize,
|
||||||
|
totalItems: totalRecords,
|
||||||
|
totalPages: Math.max(Math.ceil(totalRecords / query.pageSize), 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSummary(companyId: string, query: SummaryQuery) {
|
||||||
|
const reservations = await prisma.reservation.findMany({
|
||||||
|
where: buildReservationWhere(companyId, query.search),
|
||||||
|
include: reservationInclude as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const totals = reservations.reduce(
|
||||||
|
(acc, reservation) => {
|
||||||
|
const invoice = buildBillingInvoice(reservation as any)
|
||||||
|
acc.totalInvoiced += invoice.invoiceTotal
|
||||||
|
acc.totalCollected += invoice.invoicePaid
|
||||||
|
acc.totalRefunded += invoice.invoiceRefunded
|
||||||
|
acc.totalOutstanding += invoice.invoiceBalanceDue
|
||||||
|
acc.depositsHeld += invoice.depositHeld
|
||||||
|
if (invoice.invoiceBalanceDue > 0) acc.openInvoiceCount += 1
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency: BILLING_CURRENCY,
|
||||||
|
totalInvoiced: 0,
|
||||||
|
totalCollected: 0,
|
||||||
|
totalRefunded: 0,
|
||||||
|
totalOutstanding: 0,
|
||||||
|
depositsHeld: 0,
|
||||||
|
openInvoiceCount: 0,
|
||||||
|
overdueInvoiceCount: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return totals
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvoice(companyId: string, invoiceId: string) {
|
||||||
|
const reservation = await prisma.reservation.findFirst({
|
||||||
|
where: { id: invoiceId, companyId },
|
||||||
|
include: reservationInclude as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!reservation) throw new NotFoundError('Billing invoice not found')
|
||||||
|
return buildBillingInvoice(reservation as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordManualPayment(companyId: string, employeeId: string, invoiceId: string, body: ManualPaymentInput) {
|
||||||
|
const result = await prisma.$transaction(async (tx: any) => {
|
||||||
|
const existingPayment = await tx.rentalPayment.findFirst({
|
||||||
|
where: { companyId, idempotencyKey: body.idempotencyKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingPayment) {
|
||||||
|
return existingPayment
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservation = await tx.reservation.findFirst({
|
||||||
|
where: { id: invoiceId, companyId },
|
||||||
|
include: reservationInclude as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!reservation) throw new NotFoundError('Billing invoice not found')
|
||||||
|
|
||||||
|
const invoice = buildBillingInvoice(reservation)
|
||||||
|
if (body.currency !== invoice.currency) {
|
||||||
|
throw new ValidationError('Payment currency must match the invoice currency')
|
||||||
|
}
|
||||||
|
|
||||||
|
const permittedAmount = body.type === 'DEPOSIT' ? invoice.depositOutstanding : invoice.invoiceBalanceDue
|
||||||
|
if (permittedAmount <= 0) {
|
||||||
|
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Invoice is already fully paid')
|
||||||
|
}
|
||||||
|
if (body.amountMinor > permittedAmount) {
|
||||||
|
throw new ValidationError(body.type === 'DEPOSIT' ? 'Payment amount exceeds deposit outstanding' : 'Payment amount exceeds invoice balance due')
|
||||||
|
}
|
||||||
|
|
||||||
|
const receivedAt = body.receivedAt ? new Date(body.receivedAt) : new Date()
|
||||||
|
const payment = await tx.rentalPayment.create({
|
||||||
|
data: {
|
||||||
|
companyId,
|
||||||
|
reservationId: reservation.id,
|
||||||
|
amount: body.amountMinor,
|
||||||
|
currency: body.currency,
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
type: body.type,
|
||||||
|
paymentProvider: 'MANUAL',
|
||||||
|
paymentMethod: body.method,
|
||||||
|
reference: body.reference,
|
||||||
|
note: body.note,
|
||||||
|
receivedAt,
|
||||||
|
paidAt: receivedAt,
|
||||||
|
recordedByEmployeeId: employeeId,
|
||||||
|
idempotencyKey: body.idempotencyKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (body.type === 'CHARGE') {
|
||||||
|
const paidAmount = invoice.invoicePaid + body.amountMinor
|
||||||
|
await tx.reservation.update({
|
||||||
|
where: { id: reservation.id },
|
||||||
|
data: {
|
||||||
|
paidAmount,
|
||||||
|
paymentStatus: paidAmount >= invoice.invoiceTotal ? 'PAID' : 'PARTIAL',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return payment
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ import { parseBody, parseParams } from '../../http/validate'
|
|||||||
import { ok, created, noContent } from '../../http/respond'
|
import { ok, created, noContent } from '../../http/respond'
|
||||||
import { imageUpload, assertImageFile } from '../../http/upload'
|
import { imageUpload, assertImageFile } from '../../http/upload'
|
||||||
import * as service from './company.service'
|
import * as service from './company.service'
|
||||||
|
import {
|
||||||
|
normalizeSettingsLocale,
|
||||||
|
requireSettingsFeature,
|
||||||
|
resolveSettingsEntitlements,
|
||||||
|
resolveSettingsMenu,
|
||||||
|
} from './settingsEntitlements'
|
||||||
import {
|
import {
|
||||||
companySchema, brandSchema, contractSettingsSchema,
|
companySchema, brandSchema, contractSettingsSchema,
|
||||||
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
|
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
|
||||||
@@ -40,7 +46,7 @@ router.get('/me/brand', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
|
router.patch('/me/brand', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(brandSchema, req)
|
const body = parseBody(brandSchema, req)
|
||||||
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
|
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
|
||||||
@@ -48,7 +54,7 @@ router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
|
router.post('/me/brand/logo', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), imageUpload.single('file'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
assertImageFile(req.file, 'logo')
|
assertImageFile(req.file, 'logo')
|
||||||
const brand = await service.uploadLogo(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
const brand = await service.uploadLogo(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
||||||
@@ -56,7 +62,7 @@ router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'),
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
|
router.post('/me/brand/hero', requireRole('OWNER'), requireSettingsFeature('settings.branding_hero'), imageUpload.single('file'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
assertImageFile(req.file, 'hero image')
|
assertImageFile(req.file, 'hero image')
|
||||||
const brand = await service.uploadHeroImage(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
const brand = await service.uploadHeroImage(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
||||||
@@ -64,6 +70,18 @@ router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'),
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/me/settings-menu', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
ok(res, await resolveSettingsMenu(req.companyId, normalizeSettingsLocale(req.query.locale as string | undefined)))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/me/settings-entitlements', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
ok(res, await resolveSettingsEntitlements(req.companyId))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
|
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { subdomain } = parseBody(subdomainSchema, req)
|
const { subdomain } = parseBody(subdomainSchema, req)
|
||||||
@@ -101,7 +119,7 @@ router.get('/me/contract-settings', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/me/contract-settings', requireRole('MANAGER'), async (req, res, next) => {
|
router.patch('/me/contract-settings', requireRole('MANAGER'), requireSettingsFeature('settings.rental_policies_basic'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(contractSettingsSchema, req)
|
const body = parseBody(contractSettingsSchema, req)
|
||||||
const settings = await service.updateContractSettings(req.companyId, body)
|
const settings = await service.updateContractSettings(req.companyId, body)
|
||||||
@@ -116,7 +134,7 @@ router.get('/me/insurance-policies', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, next) => {
|
router.post('/me/insurance-policies', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(insurancePolicySchema, req)
|
const body = parseBody(insurancePolicySchema, req)
|
||||||
const policy = await service.createInsurancePolicy(req.companyId, body)
|
const policy = await service.createInsurancePolicy(req.companyId, body)
|
||||||
@@ -124,7 +142,7 @@ router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, n
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
const body = parseBody(insurancePolicySchema.partial(), req)
|
const body = parseBody(insurancePolicySchema.partial(), req)
|
||||||
@@ -133,7 +151,7 @@ router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, r
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
await service.deleteInsurancePolicy(id, req.companyId)
|
await service.deleteInsurancePolicy(id, req.companyId)
|
||||||
@@ -148,7 +166,7 @@ router.get('/me/pricing-rules', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next) => {
|
router.post('/me/pricing-rules', requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(pricingRuleSchema, req)
|
const body = parseBody(pricingRuleSchema, req)
|
||||||
const rule = await service.createPricingRule(req.companyId, body)
|
const rule = await service.createPricingRule(req.companyId, body)
|
||||||
@@ -156,7 +174,7 @@ router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next)
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, next) => {
|
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
const body = parseBody(pricingRuleSchema.partial(), req)
|
const body = parseBody(pricingRuleSchema.partial(), req)
|
||||||
@@ -165,7 +183,7 @@ router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, n
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), async (req, res, next) => {
|
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
await service.deletePricingRule(id, req.companyId)
|
await service.deletePricingRule(id, req.companyId)
|
||||||
@@ -180,7 +198,7 @@ router.get('/me/accounting-settings', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), async (req, res, next) => {
|
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), requireSettingsFeature('settings.accounting_defaults'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(accountingSettingsSchema, req)
|
const body = parseBody(accountingSettingsSchema, req)
|
||||||
const settings = await service.updateAccountingSettings(req.companyId, body)
|
const settings = await service.updateAccountingSettings(req.companyId, body)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { uploadImage } from '../../lib/storage'
|
import { uploadImage } from '../../lib/storage'
|
||||||
import { ConflictError, NotFoundError } from '../../http/errors'
|
import { ConflictError, ForbiddenError, NotFoundError } from '../../http/errors'
|
||||||
import { presentCompany, presentBrand } from './company.presenter'
|
import { presentCompany, presentBrand } from './company.presenter'
|
||||||
import * as repo from './company.repo'
|
import * as repo from './company.repo'
|
||||||
|
import { resolveSettingsEntitlements, SettingsFeatureKey } from './settingsEntitlements'
|
||||||
|
|
||||||
function buildPaymentMethodsEnabled(input: {
|
function buildPaymentMethodsEnabled(input: {
|
||||||
amanpayMerchantId?: string | null
|
amanpayMerchantId?: string | null
|
||||||
@@ -27,6 +28,12 @@ export async function getBrand(companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
|
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.branding_basic')
|
||||||
|
if (body.primaryColor || body.accentColor) await assertSettingsFeature(companyId, 'settings.branding_custom')
|
||||||
|
if (body.amanpayMerchantId || body.amanpaySecretKey || body.paypalEmail || body.paypalMerchantId) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.renter_payments')
|
||||||
|
}
|
||||||
|
|
||||||
const current = await repo.findBrand(companyId)
|
const current = await repo.findBrand(companyId)
|
||||||
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
|
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
|
||||||
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
|
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
|
||||||
@@ -41,6 +48,7 @@ export async function updateBrand(companyId: string, body: any, companyName: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.branding_basic')
|
||||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
|
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
|
||||||
return presentBrand(await repo.upsertBrand(
|
return presentBrand(await repo.upsertBrand(
|
||||||
companyId,
|
companyId,
|
||||||
@@ -50,6 +58,7 @@ export async function uploadLogo(companyId: string, companyName: string, company
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadHeroImage(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
export async function uploadHeroImage(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.branding_hero')
|
||||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
|
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
|
||||||
return presentBrand(await repo.upsertBrand(
|
return presentBrand(await repo.upsertBrand(
|
||||||
companyId,
|
companyId,
|
||||||
@@ -95,6 +104,14 @@ export async function getContractSettings(companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContractSettings(companyId: string, data: any) {
|
export async function updateContractSettings(companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.rental_policies_basic')
|
||||||
|
if (
|
||||||
|
data.additionalDriverCharge ||
|
||||||
|
data.additionalDriverDailyRate !== undefined ||
|
||||||
|
data.additionalDriverFlatRate !== undefined
|
||||||
|
) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.additional_driver_fees')
|
||||||
|
}
|
||||||
return repo.upsertContractSettings(companyId, data)
|
return repo.upsertContractSettings(companyId, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,16 +120,19 @@ export async function getInsurancePolicies(companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createInsurancePolicy(companyId: string, data: any) {
|
export async function createInsurancePolicy(companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.insurance_policies')
|
||||||
return repo.createInsurancePolicy(companyId, data)
|
return repo.createInsurancePolicy(companyId, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
|
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.insurance_policies')
|
||||||
const result = await repo.updateInsurancePolicy(id, companyId, data)
|
const result = await repo.updateInsurancePolicy(id, companyId, data)
|
||||||
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
||||||
return repo.findInsurancePolicyOrThrow(id)
|
return repo.findInsurancePolicyOrThrow(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInsurancePolicy(id: string, companyId: string) {
|
export async function deleteInsurancePolicy(id: string, companyId: string) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.insurance_policies')
|
||||||
const result = await repo.deleteInsurancePolicy(id, companyId)
|
const result = await repo.deleteInsurancePolicy(id, companyId)
|
||||||
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
||||||
}
|
}
|
||||||
@@ -122,16 +142,19 @@ export async function getPricingRules(companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createPricingRule(companyId: string, data: any) {
|
export async function createPricingRule(companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.pricing_rules')
|
||||||
return repo.createPricingRule(companyId, data)
|
return repo.createPricingRule(companyId, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePricingRule(id: string, companyId: string, data: any) {
|
export async function updatePricingRule(id: string, companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.pricing_rules')
|
||||||
const result = await repo.updatePricingRule(id, companyId, data)
|
const result = await repo.updatePricingRule(id, companyId, data)
|
||||||
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
||||||
return repo.findPricingRuleOrThrow(id)
|
return repo.findPricingRuleOrThrow(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePricingRule(id: string, companyId: string) {
|
export async function deletePricingRule(id: string, companyId: string) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.pricing_rules')
|
||||||
const result = await repo.deletePricingRule(id, companyId)
|
const result = await repo.deletePricingRule(id, companyId)
|
||||||
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
||||||
}
|
}
|
||||||
@@ -141,6 +164,9 @@ export async function getAccountingSettings(companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAccountingSettings(companyId: string, data: any) {
|
export async function updateAccountingSettings(companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.accounting_defaults')
|
||||||
|
if (data.autoSendReport) await assertSettingsFeature(companyId, 'settings.accounting_scheduled_delivery')
|
||||||
|
if (data.reportFormat === 'BOTH') await assertSettingsFeature(companyId, 'settings.accounting_advanced_formats')
|
||||||
return repo.upsertAccountingSettings(companyId, data)
|
return repo.upsertAccountingSettings(companyId, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,3 +177,10 @@ export async function getApiKey(companyId: string) {
|
|||||||
export async function regenerateApiKey(companyId: string) {
|
export async function regenerateApiKey(companyId: string) {
|
||||||
return repo.regenerateApiKey(companyId)
|
return repo.regenerateApiKey(companyId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertSettingsFeature(companyId: string, featureKey: SettingsFeatureKey) {
|
||||||
|
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||||
|
if (!entitlements.features[featureKey]?.editable) {
|
||||||
|
throw new ForbiddenError('This settings feature is not available for your current subscription.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
subscription: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { resolveSettingsEntitlements, resolveSettingsMenu } from './settingsEntitlements'
|
||||||
|
|
||||||
|
describe('settingsEntitlements', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps company profile available while locking premium STARTER capabilities', async () => {
|
||||||
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||||
|
plan: 'STARTER',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
trialEndAt: null,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const result = await resolveSettingsEntitlements('company_1')
|
||||||
|
|
||||||
|
expect(result.features['settings.company_profile']).toMatchObject({ available: true, editable: true })
|
||||||
|
expect(result.features['settings.renter_payments']).toMatchObject({
|
||||||
|
available: false,
|
||||||
|
editable: false,
|
||||||
|
requiredPlan: 'GROWTH',
|
||||||
|
})
|
||||||
|
expect(result.features['settings.accounting_scheduled_delivery']).toMatchObject({
|
||||||
|
available: false,
|
||||||
|
requiredPlan: 'PRO',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the approved seven settings sections with localized locked states', async () => {
|
||||||
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||||
|
plan: 'STARTER',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
trialEndAt: null,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const result = await resolveSettingsMenu('company_1', 'fr')
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(7)
|
||||||
|
expect(result.items.map((item) => item.sectionKey)).toEqual([
|
||||||
|
'company',
|
||||||
|
'storefront',
|
||||||
|
'payments',
|
||||||
|
'rental-policies',
|
||||||
|
'insurance',
|
||||||
|
'pricing',
|
||||||
|
'accounting',
|
||||||
|
])
|
||||||
|
expect(result.items.find((item) => item.sectionKey === 'payments')).toMatchObject({
|
||||||
|
label: 'Méthodes de paiement',
|
||||||
|
state: 'LOCKED',
|
||||||
|
requiredPlan: 'GROWTH',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('makes premium features read-only during past-due access', async () => {
|
||||||
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||||
|
plan: 'PRO',
|
||||||
|
status: 'PAST_DUE',
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
trialEndAt: null,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const result = await resolveSettingsEntitlements('company_1')
|
||||||
|
|
||||||
|
expect(result.features['settings.company_profile']).toMatchObject({ available: true, editable: true })
|
||||||
|
expect(result.features['settings.pricing_rules']).toMatchObject({ available: true, editable: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { NextFunction, Request, Response } from 'express'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { getAccessLevel } from '../subscriptions/subscription.policy'
|
||||||
|
import { sendForbidden, sendUnauthorized } from '../../middleware/authHelpers'
|
||||||
|
|
||||||
|
export type SettingsFeatureKey =
|
||||||
|
| 'settings.company_profile'
|
||||||
|
| 'settings.public_contact'
|
||||||
|
| 'settings.locale_currency'
|
||||||
|
| 'settings.storefront_listing'
|
||||||
|
| 'settings.branding_basic'
|
||||||
|
| 'settings.branding_custom'
|
||||||
|
| 'settings.branding_hero'
|
||||||
|
| 'settings.renter_payments'
|
||||||
|
| 'settings.rental_policies_basic'
|
||||||
|
| 'settings.additional_driver_fees'
|
||||||
|
| 'settings.insurance_policies'
|
||||||
|
| 'settings.pricing_rules'
|
||||||
|
| 'settings.accounting_defaults'
|
||||||
|
| 'settings.accounting_scheduled_delivery'
|
||||||
|
| 'settings.accounting_advanced_formats'
|
||||||
|
|
||||||
|
export type SettingsSectionKey =
|
||||||
|
| 'company'
|
||||||
|
| 'storefront'
|
||||||
|
| 'payments'
|
||||||
|
| 'rental-policies'
|
||||||
|
| 'insurance'
|
||||||
|
| 'pricing'
|
||||||
|
| 'accounting'
|
||||||
|
|
||||||
|
type Locale = 'en' | 'fr' | 'ar'
|
||||||
|
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||||
|
type SubscriptionStatus =
|
||||||
|
| 'TRIALING'
|
||||||
|
| 'ACTIVE'
|
||||||
|
| 'PAYMENT_PENDING'
|
||||||
|
| 'PAST_DUE'
|
||||||
|
| 'SUSPENDED'
|
||||||
|
| 'CANCELLED'
|
||||||
|
| 'EXPIRED'
|
||||||
|
| 'PAUSED'
|
||||||
|
| 'UNPAID'
|
||||||
|
|
||||||
|
type MenuState = 'ENABLED' | 'LOCKED'
|
||||||
|
|
||||||
|
const ALL_PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||||
|
const GROWTH_PLUS: Plan[] = ['GROWTH', 'PRO']
|
||||||
|
const PRO_ONLY: Plan[] = ['PRO']
|
||||||
|
|
||||||
|
const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = {
|
||||||
|
en: {
|
||||||
|
company: { label: 'Company Profile', description: 'Manage public company details and defaults.' },
|
||||||
|
storefront: { label: 'Branding and Storefront', description: 'Control marketplace listing, logo, colors, and storefront media.' },
|
||||||
|
payments: { label: 'Payment Methods', description: 'Configure renter payment providers.' },
|
||||||
|
'rental-policies': { label: 'Rental Policies', description: 'Set fuel, damage, and additional-driver policies.' },
|
||||||
|
insurance: { label: 'Insurance Policies', description: 'Manage optional and required insurance products.' },
|
||||||
|
pricing: { label: 'Pricing Rules', description: 'Automate surcharges, discounts, and driver-based pricing.' },
|
||||||
|
accounting: { label: 'Accounting and Reports', description: 'Configure accounting defaults and report delivery.' },
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
company: { label: 'Profil entreprise', description: 'Gérez les informations publiques et les valeurs par défaut.' },
|
||||||
|
storefront: { label: 'Marque et vitrine', description: 'Contrôlez la publication, le logo, les couleurs et les médias.' },
|
||||||
|
payments: { label: 'Méthodes de paiement', description: 'Configurez les prestataires de paiement des locataires.' },
|
||||||
|
'rental-policies': { label: 'Politiques de location', description: 'Définissez les règles carburant, dommages et conducteurs.' },
|
||||||
|
insurance: { label: 'Polices d’assurance', description: 'Gérez les assurances optionnelles et obligatoires.' },
|
||||||
|
pricing: { label: 'Règles tarifaires', description: 'Automatisez les suppléments, remises et règles conducteur.' },
|
||||||
|
accounting: { label: 'Comptabilité et rapports', description: 'Configurez les paramètres comptables et l’envoi des rapports.' },
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
company: { label: 'ملف الشركة', description: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' },
|
||||||
|
storefront: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' },
|
||||||
|
payments: { label: 'طرق الدفع', description: 'إعداد مزودي دفع المستأجرين.' },
|
||||||
|
'rental-policies': { label: 'سياسات الإيجار', description: 'ضبط سياسات الوقود والأضرار والسائق الإضافي.' },
|
||||||
|
insurance: { label: 'سياسات التأمين', description: 'إدارة منتجات التأمين الاختيارية والإلزامية.' },
|
||||||
|
pricing: { label: 'قواعد التسعير', description: 'أتمتة الرسوم والخصومات وقواعد السائق.' },
|
||||||
|
accounting: { label: 'المحاسبة والتقارير', description: 'إعداد الافتراضات المحاسبية وتسليم التقارير.' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTINGS_MENU_CATALOG: Array<{
|
||||||
|
menuKey: SettingsSectionKey
|
||||||
|
sectionKey: SettingsSectionKey
|
||||||
|
iconKey: string
|
||||||
|
sortOrder: number
|
||||||
|
plans: Plan[]
|
||||||
|
requiredPlan: Plan | null
|
||||||
|
}> = [
|
||||||
|
{ menuKey: 'company', sectionKey: 'company', iconKey: 'building', sortOrder: 10, plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
{ menuKey: 'storefront', sectionKey: 'storefront', iconKey: 'palette', sortOrder: 20, plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
{ menuKey: 'payments', sectionKey: 'payments', iconKey: 'credit-card', sortOrder: 30, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
{ menuKey: 'rental-policies', sectionKey: 'rental-policies', iconKey: 'file-text', sortOrder: 40, plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
{ menuKey: 'insurance', sectionKey: 'insurance', iconKey: 'shield', sortOrder: 50, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
{ menuKey: 'pricing', sectionKey: 'pricing', iconKey: 'tags', sortOrder: 60, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
{ menuKey: 'accounting', sectionKey: 'accounting', iconKey: 'bar-chart', sortOrder: 70, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FEATURE_PLANS: Record<SettingsFeatureKey, { plans: Plan[]; requiredPlan: Plan | null }> = {
|
||||||
|
'settings.company_profile': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.public_contact': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.locale_currency': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.storefront_listing': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.branding_basic': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.branding_custom': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.branding_hero': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.renter_payments': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.rental_policies_basic': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.additional_driver_fees': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.insurance_policies': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.pricing_rules': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.accounting_defaults': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.accounting_scheduled_delivery': { plans: PRO_ONLY, requiredPlan: 'PRO' },
|
||||||
|
'settings.accounting_advanced_formats': { plans: PRO_ONLY, requiredPlan: 'PRO' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSettingsLocale(value?: string): Locale {
|
||||||
|
return value === 'fr' || value === 'ar' ? value : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSubscription(companyId: string) {
|
||||||
|
const subscriptionModel = (prisma as any).subscription
|
||||||
|
if (!subscriptionModel?.findUnique) {
|
||||||
|
return {
|
||||||
|
plan: 'GROWTH' as Plan,
|
||||||
|
subscriptionStatus: 'ACTIVE' as SubscriptionStatus,
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
trialEndAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await subscriptionModel.findUnique({ where: { companyId } })
|
||||||
|
return {
|
||||||
|
plan: subscription?.plan ?? 'STARTER',
|
||||||
|
subscriptionStatus: subscription?.status ?? 'EXPIRED',
|
||||||
|
currentPeriodEnd: subscription?.currentPeriodEnd?.toISOString() ?? null,
|
||||||
|
trialEndAt: subscription?.trialEndAt?.toISOString() ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusAllowsAvailability(status: SubscriptionStatus) {
|
||||||
|
return getAccessLevel(status) !== 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusAllowsEditing(status: SubscriptionStatus, featureKey: SettingsFeatureKey) {
|
||||||
|
const accessLevel = getAccessLevel(status)
|
||||||
|
if (accessLevel === 'full') return true
|
||||||
|
if (status === 'PAST_DUE') {
|
||||||
|
return [
|
||||||
|
'settings.company_profile',
|
||||||
|
'settings.public_contact',
|
||||||
|
'settings.locale_currency',
|
||||||
|
'settings.storefront_listing',
|
||||||
|
'settings.branding_basic',
|
||||||
|
'settings.rental_policies_basic',
|
||||||
|
].includes(featureKey)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSettingsEntitlements(companyId: string) {
|
||||||
|
const subscription = await getSubscription(companyId)
|
||||||
|
const accessLevel = getAccessLevel(subscription.subscriptionStatus)
|
||||||
|
const features = Object.fromEntries(
|
||||||
|
Object.entries(FEATURE_PLANS).map(([featureKey, assignment]) => {
|
||||||
|
const assigned = assignment.plans.includes(subscription.plan)
|
||||||
|
const available = assigned && statusAllowsAvailability(subscription.subscriptionStatus)
|
||||||
|
const editable = available && statusAllowsEditing(subscription.subscriptionStatus, featureKey as SettingsFeatureKey)
|
||||||
|
return [featureKey, {
|
||||||
|
available,
|
||||||
|
editable,
|
||||||
|
requiredPlan: assigned ? null : assignment.requiredPlan,
|
||||||
|
reason: available ? null : assigned ? 'subscription_status_restricted' : 'plan_required',
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
plan: subscription.plan,
|
||||||
|
subscriptionStatus: subscription.subscriptionStatus,
|
||||||
|
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||||
|
trialEndAt: subscription.trialEndAt,
|
||||||
|
accessLevel,
|
||||||
|
features,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSettingsMenu(companyId: string, locale: Locale) {
|
||||||
|
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
items: SETTINGS_MENU_CATALOG.map((item) => {
|
||||||
|
const assigned = item.plans.includes(entitlements.plan)
|
||||||
|
const coreAvailable = entitlements.accessLevel !== 'none' || item.sectionKey === 'company'
|
||||||
|
const state: MenuState = assigned && coreAvailable ? 'ENABLED' : 'LOCKED'
|
||||||
|
const copy = SECTION_COPY[locale][item.sectionKey]
|
||||||
|
return {
|
||||||
|
menuKey: item.menuKey,
|
||||||
|
sectionKey: item.sectionKey,
|
||||||
|
label: copy.label,
|
||||||
|
description: copy.description,
|
||||||
|
iconKey: item.iconKey,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
state,
|
||||||
|
requiredPlan: state === 'LOCKED' ? item.requiredPlan : null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireSettingsFeature(featureKey: SettingsFeatureKey) {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.companyId) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
||||||
|
|
||||||
|
const entitlements = await resolveSettingsEntitlements(req.companyId)
|
||||||
|
const feature = entitlements.features[featureKey]
|
||||||
|
if (!feature?.editable) {
|
||||||
|
return sendForbidden(res, 'feature_unavailable', 'This settings feature is not available for your current subscription.', {
|
||||||
|
featureKey,
|
||||||
|
requiredPlan: feature?.requiredPlan ?? null,
|
||||||
|
subscriptionStatus: entitlements.subscriptionStatus,
|
||||||
|
accessLevel: entitlements.accessLevel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
vi.mock('../../lib/prisma', () => ({
|
vi.mock('../../lib/prisma', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
rentalPayment: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn(), updateMany: vi.fn(), create: vi.fn() },
|
rentalPayment: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn(), updateMany: vi.fn(), create: vi.fn() },
|
||||||
reservation: { findFirstOrThrow: vi.fn(), update: vi.fn() },
|
reservation: { findFirstOrThrow: vi.fn(), update: vi.fn(), findUniqueOrThrow: vi.fn() },
|
||||||
|
$transaction: vi.fn(async (callback) => callback({
|
||||||
|
reservation: {
|
||||||
|
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
||||||
|
id: 'reservation_1',
|
||||||
|
totalAmount: 5000,
|
||||||
|
rentalPayments: [{ amount: 2500 }, { amount: 1500 }],
|
||||||
|
}),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -46,13 +56,10 @@ describe('payment.repo edge queries', () => {
|
|||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('increments paid amount and promotes reservation payment status to paid', async () => {
|
it('recomputes paid amount from successful charge payments', async () => {
|
||||||
await repo.incrementReservationPaid('reservation_1', 2500)
|
await repo.incrementReservationPaid('reservation_1', 2500)
|
||||||
|
|
||||||
expect(prisma.reservation.update).toHaveBeenCalledWith({
|
expect(prisma.$transaction).toHaveBeenCalled()
|
||||||
where: { id: 'reservation_1' },
|
|
||||||
data: { paymentStatus: 'PAID', paidAmount: { increment: 2500 } },
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps partial refunds to the correct payment status', async () => {
|
it('maps partial refunds to the correct payment status', async () => {
|
||||||
|
|||||||
@@ -32,12 +32,15 @@ export function findPaymentOrThrow(paymentId: string, companyId: string, reserva
|
|||||||
export function findReservationOrThrow(id: string, companyId: string) {
|
export function findReservationOrThrow(id: string, companyId: string) {
|
||||||
return prisma.reservation.findFirstOrThrow({
|
return prisma.reservation.findFirstOrThrow({
|
||||||
where: { id, companyId },
|
where: { id, companyId },
|
||||||
include: { vehicle: true, customer: true },
|
include: { vehicle: true, customer: true, rentalPayments: true },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findReservation(id: string, companyId: string) {
|
export function findReservation(id: string, companyId: string) {
|
||||||
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
|
return prisma.reservation.findFirstOrThrow({
|
||||||
|
where: { id, companyId },
|
||||||
|
include: { rentalPayments: true },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markPaymentSucceeded(id: string) {
|
export function markPaymentSucceeded(id: string) {
|
||||||
@@ -48,8 +51,26 @@ export function markPaymentFailed(query: { amanpayTransactionId?: string; paypal
|
|||||||
return prisma.rentalPayment.updateMany({ where: query, data: { status: 'FAILED' } })
|
return prisma.rentalPayment.updateMany({ where: query, data: { status: 'FAILED' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function incrementReservationPaid(reservationId: string, amount: number) {
|
export function incrementReservationPaid(reservationId: string, _amount: number) {
|
||||||
return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const reservation = await tx.reservation.findUniqueOrThrow({
|
||||||
|
where: { id: reservationId },
|
||||||
|
include: {
|
||||||
|
rentalPayments: {
|
||||||
|
where: { type: 'CHARGE', status: 'SUCCEEDED' },
|
||||||
|
select: { amount: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const paidAmount = reservation.rentalPayments.reduce((total, payment) => total + payment.amount, 0)
|
||||||
|
return tx.reservation.update({
|
||||||
|
where: { id: reservationId },
|
||||||
|
data: {
|
||||||
|
paidAmount,
|
||||||
|
paymentStatus: paidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPayment(data: {
|
export function createPayment(data: {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (r
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/reservations/:id/manual', requireRole('MANAGER'), async (req, res, next) => {
|
router.post('/reservations/:id/manual', requireRole('OWNER'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(reservationParamSchema, req)
|
const { id } = parseParams(reservationParamSchema, req)
|
||||||
const body = parseBody(manualPaymentSchema, req)
|
const body = parseBody(manualPaymentSchema, req)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const reservation = {
|
|||||||
depositAmount: 300,
|
depositAmount: 300,
|
||||||
totalAmount: 1200,
|
totalAmount: 1200,
|
||||||
paidAmount: 200,
|
paidAmount: 200,
|
||||||
|
rentalPayments: [],
|
||||||
vehicle: { make: 'Dacia', model: 'Duster' },
|
vehicle: { make: 'Dacia', model: 'Duster' },
|
||||||
customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' },
|
customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' },
|
||||||
}
|
}
|
||||||
@@ -106,7 +107,11 @@ describe('payment.service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('refuses to initialize a charge for a fully paid reservation before touching gateways', async () => {
|
it('refuses to initialize a charge for a fully paid reservation before touching gateways', async () => {
|
||||||
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({ ...reservation, paymentStatus: 'PAID' } as never)
|
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({
|
||||||
|
...reservation,
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1200 }],
|
||||||
|
} as never)
|
||||||
|
|
||||||
await expect(initCharge('reservation_1', 'company_1', {
|
await expect(initCharge('reservation_1', 'company_1', {
|
||||||
provider: 'PAYPAL',
|
provider: 'PAYPAL',
|
||||||
@@ -120,8 +125,36 @@ describe('payment.service', () => {
|
|||||||
expect(repo.createPayment).not.toHaveBeenCalled()
|
expect(repo.createPayment).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('allows an outstanding deposit when the rental invoice is fully paid', async () => {
|
||||||
|
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({
|
||||||
|
...reservation,
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1200 }],
|
||||||
|
} as never)
|
||||||
|
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
|
||||||
|
vi.mocked(amanpay.createCheckout).mockResolvedValue({ checkoutUrl: 'https://pay.example/checkout', transactionId: 'aman_txn_2' } as never)
|
||||||
|
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_2', status: 'PENDING' } as never)
|
||||||
|
|
||||||
|
await initCharge('reservation_1', 'company_1', {
|
||||||
|
provider: 'AMANPAY',
|
||||||
|
type: 'DEPOSIT',
|
||||||
|
currency: 'MAD',
|
||||||
|
successUrl: 'https://app.example/success',
|
||||||
|
failureUrl: 'https://app.example/failure',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(amanpay.createCheckout).toHaveBeenCalledWith(expect.objectContaining({ amount: 300 }))
|
||||||
|
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({ type: 'DEPOSIT' }))
|
||||||
|
})
|
||||||
|
|
||||||
it('records manual payments and marks the reservation as partially or fully paid from the balance math', async () => {
|
it('records manual payments and marks the reservation as partially or fully paid from the balance math', async () => {
|
||||||
vi.mocked(repo.findReservation).mockResolvedValue({ id: 'reservation_1', totalAmount: 1000, paidAmount: 700 } as never)
|
vi.mocked(repo.findReservation).mockResolvedValue({
|
||||||
|
id: 'reservation_1',
|
||||||
|
totalAmount: 1000,
|
||||||
|
depositAmount: 300,
|
||||||
|
paidAmount: 700,
|
||||||
|
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 700 }],
|
||||||
|
} as never)
|
||||||
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_1', amount: 300, status: 'SUCCEEDED' } as never)
|
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_1', amount: 300, status: 'SUCCEEDED' } as never)
|
||||||
|
|
||||||
const payment = await recordManualPayment('reservation_1', 'company_1', {
|
const payment = await recordManualPayment('reservation_1', 'company_1', {
|
||||||
@@ -133,7 +166,7 @@ describe('payment.service', () => {
|
|||||||
|
|
||||||
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({
|
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
paymentProvider: 'AMANPAY',
|
paymentProvider: 'MANUAL',
|
||||||
paymentMethod: 'CASH',
|
paymentMethod: 'CASH',
|
||||||
paidAt: expect.any(Date),
|
paidAt: expect.any(Date),
|
||||||
}))
|
}))
|
||||||
@@ -142,7 +175,13 @@ describe('payment.service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('rejects manual overpayments instead of silently corrupting the reservation balance', async () => {
|
it('rejects manual overpayments instead of silently corrupting the reservation balance', async () => {
|
||||||
vi.mocked(repo.findReservation).mockResolvedValue({ id: 'reservation_1', totalAmount: 1000, paidAmount: 950 } as never)
|
vi.mocked(repo.findReservation).mockResolvedValue({
|
||||||
|
id: 'reservation_1',
|
||||||
|
totalAmount: 1000,
|
||||||
|
depositAmount: 300,
|
||||||
|
paidAmount: 950,
|
||||||
|
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 950 }],
|
||||||
|
} as never)
|
||||||
|
|
||||||
await expect(recordManualPayment('reservation_1', 'company_1', {
|
await expect(recordManualPayment('reservation_1', 'company_1', {
|
||||||
amount: 100,
|
amount: 100,
|
||||||
@@ -156,8 +195,8 @@ describe('payment.service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('applies paid AmanPay and denied PayPal webhook events to the matching records', async () => {
|
it('applies paid AmanPay and denied PayPal webhook events to the matching records', async () => {
|
||||||
vi.mocked(repo.findByAmanpay).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 450 } as never)
|
vi.mocked(repo.findByAmanpay).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 450, type: 'CHARGE' } as never)
|
||||||
vi.mocked(repo.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500 } as never)
|
vi.mocked(repo.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500, type: 'CHARGE' } as never)
|
||||||
|
|
||||||
await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' })
|
await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' })
|
||||||
await handlePaypalWebhook({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'paypal_capture_1' } })
|
await handlePaypalWebhook({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'paypal_capture_1' } })
|
||||||
@@ -171,7 +210,7 @@ describe('payment.service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('captures PayPal orders, stores the capture id, and increments the original reservation payment', async () => {
|
it('captures PayPal orders, stores the capture id, and increments the original reservation payment', async () => {
|
||||||
vi.mocked(repo.findByPaypalForCompany).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 800 } as never)
|
vi.mocked(repo.findByPaypalForCompany).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 800, type: 'CHARGE' } as never)
|
||||||
vi.mocked(paypal.captureOrder).mockResolvedValue({ purchase_units: [{ payments: { captures: [{ id: 'capture_123' }] } }] } as never)
|
vi.mocked(paypal.captureOrder).mockResolvedValue({ purchase_units: [{ payments: { captures: [{ id: 'capture_123' }] } }] } as never)
|
||||||
vi.mocked(repo.updatePaypalCapture).mockResolvedValue({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' } as never)
|
vi.mocked(repo.updatePaypalCapture).mockResolvedValue({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' } as never)
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ async function applyAmanpayWebhook(event: any) {
|
|||||||
const payment = await repo.findByAmanpay(transactionId)
|
const payment = await repo.findByAmanpay(transactionId)
|
||||||
if (payment && payment.status !== 'SUCCEEDED') {
|
if (payment && payment.status !== 'SUCCEEDED') {
|
||||||
await repo.markPaymentSucceeded(payment.id)
|
await repo.markPaymentSucceeded(payment.id)
|
||||||
|
if (payment.type === 'CHARGE') {
|
||||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (status === 'FAILED') {
|
} else if (status === 'FAILED') {
|
||||||
await repo.markPaymentFailed({ amanpayTransactionId: transactionId })
|
await repo.markPaymentFailed({ amanpayTransactionId: transactionId })
|
||||||
}
|
}
|
||||||
@@ -33,8 +35,10 @@ async function applyPaypalWebhook(event: any) {
|
|||||||
const payment = await repo.findByPaypal(captureId)
|
const payment = await repo.findByPaypal(captureId)
|
||||||
if (payment && payment.status !== 'SUCCEEDED') {
|
if (payment && payment.status !== 'SUCCEEDED') {
|
||||||
await repo.markPaymentSucceeded(payment.id)
|
await repo.markPaymentSucceeded(payment.id)
|
||||||
|
if (payment.type === 'CHARGE') {
|
||||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
|
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
|
||||||
await repo.markPaymentFailed({ paypalCaptureId: event.resource?.id })
|
await repo.markPaymentFailed({ paypalCaptureId: event.resource?.id })
|
||||||
}
|
}
|
||||||
@@ -65,9 +69,23 @@ export async function initCharge(reservationId: string, companyId: string, body:
|
|||||||
currency: 'MAD'; successUrl: string; failureUrl: string
|
currency: 'MAD'; successUrl: string; failureUrl: string
|
||||||
}) {
|
}) {
|
||||||
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
|
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
|
||||||
if (reservation.paymentStatus === 'PAID') throw new ConflictError('Reservation is already fully paid')
|
const rentalPayments = (reservation as any).rentalPayments ?? []
|
||||||
|
const invoicePaid = rentalPayments.reduce((total: number, payment: any) => {
|
||||||
|
if (payment.type !== 'CHARGE' || payment.status !== 'SUCCEEDED') return total
|
||||||
|
return total + payment.amount
|
||||||
|
}, 0)
|
||||||
|
const depositCollected = rentalPayments.reduce((total: number, payment: any) => {
|
||||||
|
if (payment.type !== 'DEPOSIT' || payment.status !== 'SUCCEEDED') return total
|
||||||
|
return total + payment.amount
|
||||||
|
}, 0)
|
||||||
|
const chargeRemaining = Math.max(reservation.totalAmount - invoicePaid, 0)
|
||||||
|
const depositRemaining = Math.max(reservation.depositAmount - depositCollected, 0)
|
||||||
|
const amount = body.type === 'DEPOSIT' ? depositRemaining : chargeRemaining
|
||||||
|
|
||||||
|
if (amount <= 0) {
|
||||||
|
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid')
|
||||||
|
}
|
||||||
|
|
||||||
const amount = body.type === 'DEPOSIT' ? reservation.depositAmount : reservation.totalAmount
|
|
||||||
const description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
const description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||||
const orderId = `${reservationId}-${body.type}-${Date.now()}`
|
const orderId = `${reservationId}-${body.type}-${Date.now()}`
|
||||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||||
@@ -103,7 +121,9 @@ export async function capturePaypal(reservationId: string, companyId: string, pa
|
|||||||
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
||||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||||
const updated = await repo.updatePaypalCapture(payment.id, captureId)
|
const updated = await repo.updatePaypalCapture(payment.id, captureId)
|
||||||
|
if (payment.type === 'CHARGE') {
|
||||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||||
|
}
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,13 +131,31 @@ export async function recordManualPayment(reservationId: string, companyId: stri
|
|||||||
amount: number; currency: string; type: string; paymentMethod: string
|
amount: number; currency: string; type: string; paymentMethod: string
|
||||||
}) {
|
}) {
|
||||||
const reservation = await repo.findReservation(reservationId, companyId)
|
const reservation = await repo.findReservation(reservationId, companyId)
|
||||||
const remaining = Math.max(reservation.totalAmount - reservation.paidAmount, 0)
|
const rentalPayments = (reservation as any).rentalPayments ?? []
|
||||||
if (remaining <= 0) throw new ConflictError('Reservation is already fully paid')
|
const invoicePaid = rentalPayments.reduce((total: number, payment: any) => {
|
||||||
if (body.amount > remaining) throw new ValidationError('Payment amount exceeds remaining balance')
|
if (payment.type !== 'CHARGE' || payment.status !== 'SUCCEEDED') return total
|
||||||
|
return total + payment.amount
|
||||||
|
}, 0)
|
||||||
|
const depositCollected = rentalPayments.reduce((total: number, payment: any) => {
|
||||||
|
if (payment.type !== 'DEPOSIT' || payment.status !== 'SUCCEEDED') return total
|
||||||
|
return total + payment.amount
|
||||||
|
}, 0)
|
||||||
|
const chargeRemaining = Math.max(reservation.totalAmount - invoicePaid, 0)
|
||||||
|
const depositRemaining = Math.max(reservation.depositAmount - depositCollected, 0)
|
||||||
|
const remaining = body.type === 'DEPOSIT' ? depositRemaining : chargeRemaining
|
||||||
|
|
||||||
const payment = await repo.createPayment({ companyId, reservationId, amount: body.amount, currency: body.currency, status: 'SUCCEEDED', type: body.type, paymentProvider: body.paymentMethod === 'PAYPAL' ? 'PAYPAL' : 'AMANPAY', paymentMethod: body.paymentMethod, paidAt: new Date() })
|
if (remaining <= 0) {
|
||||||
const newPaidAmount = reservation.paidAmount + body.amount
|
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid')
|
||||||
|
}
|
||||||
|
if (body.amount > remaining) {
|
||||||
|
throw new ValidationError(body.type === 'DEPOSIT' ? 'Payment amount exceeds deposit outstanding' : 'Payment amount exceeds remaining balance')
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = await repo.createPayment({ companyId, reservationId, amount: body.amount, currency: body.currency, status: 'SUCCEEDED', type: body.type, paymentProvider: 'MANUAL', paymentMethod: body.paymentMethod, paidAt: new Date() })
|
||||||
|
if (body.type === 'CHARGE') {
|
||||||
|
const newPaidAmount = invoicePaid + body.amount
|
||||||
await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL')
|
await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL')
|
||||||
|
}
|
||||||
return payment
|
return payment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements'
|
||||||
|
import { validateLicense } from './licenseValidationService'
|
||||||
|
|
||||||
type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE'
|
type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE'
|
||||||
import { validateLicense } from './licenseValidationService'
|
|
||||||
|
|
||||||
export interface AdditionalDriverInput {
|
export interface AdditionalDriverInput {
|
||||||
firstName: string
|
firstName: string
|
||||||
@@ -40,7 +41,11 @@ export async function applyAdditionalDriversToReservation(
|
|||||||
return { records: [], additionalDriverTotal: 0, requiresManualApproval: false }
|
return { records: [], additionalDriverTotal: 0, requiresManualApproval: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await prisma.contractSettings.findUnique({ where: { companyId } })
|
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||||
|
const canApplyAutomatedFees = entitlements.features['settings.additional_driver_fees']?.available
|
||||||
|
const settings = canApplyAutomatedFees
|
||||||
|
? await prisma.contractSettings.findUnique({ where: { companyId } })
|
||||||
|
: null
|
||||||
const chargeType = settings?.additionalDriverCharge ?? 'FREE'
|
const chargeType = settings?.additionalDriverCharge ?? 'FREE'
|
||||||
const chargeValue =
|
const chargeValue =
|
||||||
chargeType === 'PER_DAY'
|
chargeType === 'PER_DAY'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { InsurancePolicy } from '@rentaldrivego/database'
|
import { InsurancePolicy } from '@rentaldrivego/database'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements'
|
||||||
|
|
||||||
export function calculateInsuranceCharge(
|
export function calculateInsuranceCharge(
|
||||||
policy: InsurancePolicy,
|
policy: InsurancePolicy,
|
||||||
@@ -21,6 +22,11 @@ export async function applyInsurancesToReservation(
|
|||||||
totalDays: number,
|
totalDays: number,
|
||||||
baseRentalAmount: number
|
baseRentalAmount: number
|
||||||
) {
|
) {
|
||||||
|
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||||
|
if (!entitlements.features['settings.insurance_policies']?.available) {
|
||||||
|
return { records: [], insuranceTotal: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
const allPolicies = await prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } })
|
const allPolicies = await prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } })
|
||||||
const required = allPolicies.filter((p: InsurancePolicy) => p.isRequired)
|
const required = allPolicies.filter((p: InsurancePolicy) => p.isRequired)
|
||||||
const selected = allPolicies.filter((p: InsurancePolicy) => selectedPolicyIds.includes(p.id) && !p.isRequired)
|
const selected = allPolicies.filter((p: InsurancePolicy) => selectedPolicyIds.includes(p.id) && !p.isRequired)
|
||||||
|
|||||||
@@ -54,4 +54,32 @@ describe('invoicePdfService', () => {
|
|||||||
props: expect.objectContaining({ data: expect.objectContaining({ invoiceNumber: 'INV-2026-000001' }) }),
|
props: expect.objectContaining({ data: expect.objectContaining({ invoiceNumber: 'INV-2026-000001' }) }),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('falls back to a simple PDF when the React PDF renderer fails', async () => {
|
||||||
|
renderToBufferMock.mockRejectedValueOnce(new Error('renderer failed'))
|
||||||
|
|
||||||
|
const result = await generateInvoicePdf({
|
||||||
|
invoiceNumber: 'INV-2026-000002',
|
||||||
|
issueDate: '2026-06-09T00:00:00.000Z',
|
||||||
|
dueDate: null,
|
||||||
|
company: { name: 'Atlas Cars', email: 'billing@atlas.test', address: { formatted: 'Casablanca' } },
|
||||||
|
amount: 80000,
|
||||||
|
currency: 'MAD',
|
||||||
|
status: 'OPEN',
|
||||||
|
paymentProvider: 'MANUAL',
|
||||||
|
lineItems: [{ description: 'Migration assistance', amount: 30000, currency: 'MAD', quantity: 2, unitAmount: 15000 }],
|
||||||
|
totals: {
|
||||||
|
subtotalAmount: 80000,
|
||||||
|
discountAmount: 0,
|
||||||
|
creditAmount: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
totalAmount: 80000,
|
||||||
|
amountPaid: 0,
|
||||||
|
amountDue: 80000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.subarray(0, 8).toString()).toBe('%PDF-1.4')
|
||||||
|
expect(result.toString()).toContain('INV-2026-000002')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -318,17 +318,27 @@ function fmt(amount: number, currency: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fmtDate(iso: string | null | undefined) {
|
function fmtDate(iso: string | null | undefined) {
|
||||||
if (!iso) return '—'
|
if (!iso) return '-'
|
||||||
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })
|
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAddress(address: any): string {
|
function formatAddress(address: any): string {
|
||||||
if (!address) return ''
|
if (!address) return ''
|
||||||
if (typeof address === 'string') return address
|
if (typeof address === 'string') return address
|
||||||
|
if (typeof address.formatted === 'string') return address.formatted
|
||||||
const parts = [address.street, address.city, address.region, address.country, address.zip]
|
const parts = [address.street, address.city, address.region, address.country, address.zip]
|
||||||
return parts.filter(Boolean).join(', ')
|
return parts.filter(Boolean).join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pdfText(value: unknown) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[\u2013\u2014]/g, '-')
|
||||||
|
.replace(/\u00d7/g, 'x')
|
||||||
|
.replace(/[^\x20-\x7E]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
function InvoiceDocument({ data }: { data: InvoiceData }) {
|
function InvoiceDocument({ data }: { data: InvoiceData }) {
|
||||||
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
|
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
|
||||||
const addressStr = formatAddress(data.company.address)
|
const addressStr = formatAddress(data.company.address)
|
||||||
@@ -374,11 +384,11 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.invoiceMeta },
|
{ style: s.invoiceMeta },
|
||||||
React.createElement(Text, { style: s.invoiceTitle }, 'INVOICE'),
|
React.createElement(Text, { style: s.invoiceTitle }, 'INVOICE'),
|
||||||
React.createElement(Text, { style: s.invoiceNumber }, data.invoiceNumber),
|
React.createElement(Text, { style: s.invoiceNumber }, pdfText(data.invoiceNumber)),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
View,
|
View,
|
||||||
{ style: [s.statusBadge, { backgroundColor: statusColor }] },
|
{ style: [s.statusBadge, { backgroundColor: statusColor }] },
|
||||||
React.createElement(Text, { style: s.statusText }, data.status),
|
React.createElement(Text, { style: s.statusText }, pdfText(data.status)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -391,13 +401,13 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.col },
|
{ style: s.col },
|
||||||
React.createElement(Text, { style: s.colLabel }, 'Bill To'),
|
React.createElement(Text, { style: s.colLabel }, 'Bill To'),
|
||||||
React.createElement(Text, { style: s.companyName }, data.company.name),
|
React.createElement(Text, { style: s.companyName }, pdfText(data.company.name)),
|
||||||
React.createElement(Text, { style: s.companyDetail }, data.company.email),
|
React.createElement(Text, { style: s.companyDetail }, pdfText(data.company.email)),
|
||||||
data.company.phone
|
data.company.phone
|
||||||
? React.createElement(Text, { style: s.companyDetail }, data.company.phone)
|
? React.createElement(Text, { style: s.companyDetail }, pdfText(data.company.phone))
|
||||||
: null,
|
: null,
|
||||||
addressStr
|
addressStr
|
||||||
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, addressStr)
|
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, pdfText(addressStr))
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
@@ -408,7 +418,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.metaRow },
|
{ style: s.metaRow },
|
||||||
React.createElement(Text, { style: s.metaKey }, 'Invoice Number'),
|
React.createElement(Text, { style: s.metaKey }, 'Invoice Number'),
|
||||||
React.createElement(Text, { style: s.metaValue }, data.invoiceNumber),
|
React.createElement(Text, { style: s.metaValue }, pdfText(data.invoiceNumber)),
|
||||||
),
|
),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
View,
|
View,
|
||||||
@@ -428,20 +438,20 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.metaRow },
|
{ style: s.metaRow },
|
||||||
React.createElement(Text, { style: s.metaKey }, 'Currency'),
|
React.createElement(Text, { style: s.metaKey }, 'Currency'),
|
||||||
React.createElement(Text, { style: s.metaValue }, data.currency),
|
React.createElement(Text, { style: s.metaValue }, pdfText(data.currency)),
|
||||||
),
|
),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
View,
|
View,
|
||||||
{ style: s.metaRow },
|
{ style: s.metaRow },
|
||||||
React.createElement(Text, { style: s.metaKey }, 'Billing Period'),
|
React.createElement(Text, { style: s.metaKey }, 'Billing Period'),
|
||||||
React.createElement(Text, { style: s.metaValue }, periodLabel),
|
React.createElement(Text, { style: s.metaValue }, pdfText(periodLabel)),
|
||||||
),
|
),
|
||||||
data.subscription
|
data.subscription
|
||||||
? React.createElement(
|
? React.createElement(
|
||||||
View,
|
View,
|
||||||
{ style: s.metaRow },
|
{ style: s.metaRow },
|
||||||
React.createElement(Text, { style: s.metaKey }, 'Plan'),
|
React.createElement(Text, { style: s.metaKey }, 'Plan'),
|
||||||
React.createElement(Text, { style: s.metaValue }, planLabel),
|
React.createElement(Text, { style: s.metaValue }, pdfText(planLabel)),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -460,17 +470,17 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
),
|
),
|
||||||
...lineItems.map((item) => {
|
...lineItems.map((item) => {
|
||||||
const periodStr = item.periodStart && item.periodEnd
|
const periodStr = item.periodStart && item.periodEnd
|
||||||
? `${fmtDate(item.periodStart)} – ${fmtDate(item.periodEnd)}`
|
? `${fmtDate(item.periodStart)} - ${fmtDate(item.periodEnd)}`
|
||||||
: '—'
|
: '-'
|
||||||
const description = item.quantity && item.quantity > 1 && item.unitAmount !== undefined
|
const description = item.quantity && item.quantity > 1 && item.unitAmount !== undefined
|
||||||
? `${item.description} (${item.quantity} × ${fmt(item.unitAmount, item.currency)})`
|
? `${item.description} (${item.quantity} x ${fmt(item.unitAmount, item.currency)})`
|
||||||
: item.description
|
: item.description
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
View,
|
View,
|
||||||
{ key: `${item.description}-${item.amount}-${item.periodStart ?? ''}`, style: s.tableRow },
|
{ key: `${item.description}-${item.amount}-${item.periodStart ?? ''}`, style: s.tableRow },
|
||||||
React.createElement(Text, { style: [s.tableCell, s.col60] }, description),
|
React.createElement(Text, { style: [s.tableCell, s.col60] }, pdfText(description)),
|
||||||
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr),
|
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, pdfText(periodStr)),
|
||||||
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(item.amount, item.currency)),
|
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, pdfText(fmt(item.amount, item.currency))),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -522,14 +532,14 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.paymentRow },
|
{ style: s.paymentRow },
|
||||||
React.createElement(Text, { style: s.paymentKey }, 'Payment Provider'),
|
React.createElement(Text, { style: s.paymentKey }, 'Payment Provider'),
|
||||||
React.createElement(Text, { style: s.paymentValue }, data.paymentProvider),
|
React.createElement(Text, { style: s.paymentValue }, pdfText(data.paymentProvider)),
|
||||||
),
|
),
|
||||||
data.transactionId
|
data.transactionId
|
||||||
? React.createElement(
|
? React.createElement(
|
||||||
View,
|
View,
|
||||||
{ style: s.paymentRow },
|
{ style: s.paymentRow },
|
||||||
React.createElement(Text, { style: s.paymentKey }, 'Transaction ID'),
|
React.createElement(Text, { style: s.paymentKey }, 'Transaction ID'),
|
||||||
React.createElement(Text, { style: s.paymentValue }, data.transactionId),
|
React.createElement(Text, { style: s.paymentValue }, pdfText(data.transactionId)),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
React.createElement(
|
React.createElement(
|
||||||
@@ -542,7 +552,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.paymentRow },
|
{ style: s.paymentRow },
|
||||||
React.createElement(Text, { style: s.paymentKey }, 'Status'),
|
React.createElement(Text, { style: s.paymentKey }, 'Status'),
|
||||||
React.createElement(Text, { style: [s.paymentValue, { color: statusColor }] }, data.status),
|
React.createElement(Text, { style: [s.paymentValue, { color: statusColor }] }, pdfText(data.status)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -550,7 +560,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
React.createElement(
|
React.createElement(
|
||||||
View,
|
View,
|
||||||
{ style: s.footer },
|
{ style: s.footer },
|
||||||
React.createElement(Text, { style: s.footerText }, 'RentalDriveGo — Car Rental Management Platform'),
|
React.createElement(Text, { style: s.footerText }, 'RentalDriveGo - Car Rental Management Platform'),
|
||||||
React.createElement(Text, { style: s.footerText }, `Generated on ${fmtDate(new Date().toISOString())}`),
|
React.createElement(Text, { style: s.footerText }, `Generated on ${fmtDate(new Date().toISOString())}`),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -559,8 +569,12 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
|
|
||||||
export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
|
export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
|
||||||
const doc = React.createElement(InvoiceDocument, { data })
|
const doc = React.createElement(InvoiceDocument, { data })
|
||||||
|
try {
|
||||||
const buffer = await renderToBuffer(doc as any)
|
const buffer = await renderToBuffer(doc as any)
|
||||||
return buffer as unknown as Buffer
|
return buffer as unknown as Buffer
|
||||||
|
} catch {
|
||||||
|
return buildSimpleInvoicePdf(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
|
export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
|
||||||
@@ -568,3 +582,81 @@ export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
|
|||||||
const short = invoiceId.slice(-6).toUpperCase()
|
const short = invoiceId.slice(-6).toUpperCase()
|
||||||
return `INV-${year}-${short}`
|
return `INV-${year}-${short}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pdfEscape(value: unknown) {
|
||||||
|
return pdfText(value).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSimpleInvoicePdf(data: InvoiceData) {
|
||||||
|
const lineItems = data.lineItems?.length
|
||||||
|
? data.lineItems
|
||||||
|
: [{ description: 'Invoice charge', amount: data.amount, currency: data.currency }]
|
||||||
|
const totals = data.totals ?? {
|
||||||
|
subtotalAmount: data.amount,
|
||||||
|
discountAmount: 0,
|
||||||
|
creditAmount: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
totalAmount: data.amount,
|
||||||
|
amountPaid: data.paidAt ? data.amount : 0,
|
||||||
|
amountDue: data.paidAt ? 0 : data.amount,
|
||||||
|
}
|
||||||
|
const address = formatAddress(data.company.address)
|
||||||
|
const lines = [
|
||||||
|
'RentalDriveGo - Invoice',
|
||||||
|
`Invoice Number: ${data.invoiceNumber}`,
|
||||||
|
`Status: ${data.status}`,
|
||||||
|
`Issue Date: ${fmtDate(data.issueDate)}`,
|
||||||
|
data.dueDate ? `Due Date: ${fmtDate(data.dueDate)}` : null,
|
||||||
|
`Bill To: ${data.company.name}`,
|
||||||
|
`Email: ${data.company.email}`,
|
||||||
|
data.company.phone ? `Phone: ${data.company.phone}` : null,
|
||||||
|
address ? `Address: ${address}` : null,
|
||||||
|
'',
|
||||||
|
'Line Items',
|
||||||
|
...lineItems.map((item) => `${item.description} - ${fmt(item.amount, item.currency)}`),
|
||||||
|
'',
|
||||||
|
`Subtotal: ${fmt(totals.subtotalAmount, data.currency)}`,
|
||||||
|
totals.discountAmount ? `Discounts: -${fmt(totals.discountAmount, data.currency)}` : null,
|
||||||
|
totals.creditAmount ? `Credits: -${fmt(totals.creditAmount, data.currency)}` : null,
|
||||||
|
totals.taxAmount ? `Tax: ${fmt(totals.taxAmount, data.currency)}` : null,
|
||||||
|
`Total: ${fmt(totals.totalAmount, data.currency)}`,
|
||||||
|
`Amount Paid: ${fmt(totals.amountPaid, data.currency)}`,
|
||||||
|
`Amount Due: ${fmt(totals.amountDue, data.currency)}`,
|
||||||
|
'',
|
||||||
|
`Payment Provider: ${data.paymentProvider}`,
|
||||||
|
data.transactionId ? `Transaction ID: ${data.transactionId}` : null,
|
||||||
|
].filter((line): line is string => line !== null)
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
'BT',
|
||||||
|
'/F1 18 Tf',
|
||||||
|
'50 790 Td',
|
||||||
|
`(${pdfEscape(lines[0])}) Tj`,
|
||||||
|
'/F1 10 Tf',
|
||||||
|
...lines.slice(1).flatMap((line) => ['0 -18 Td', `(${pdfEscape(line)}) Tj`]),
|
||||||
|
'ET',
|
||||||
|
].join('\n')
|
||||||
|
const objects = [
|
||||||
|
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||||
|
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||||
|
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
|
||||||
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||||
|
`<< /Length ${Buffer.byteLength(content, 'utf8')} >>\nstream\n${content}\nendstream`,
|
||||||
|
]
|
||||||
|
|
||||||
|
let pdf = '%PDF-1.4\n'
|
||||||
|
const offsets = [0]
|
||||||
|
objects.forEach((object, index) => {
|
||||||
|
offsets.push(Buffer.byteLength(pdf, 'utf8'))
|
||||||
|
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`
|
||||||
|
})
|
||||||
|
const xrefOffset = Buffer.byteLength(pdf, 'utf8')
|
||||||
|
pdf += `xref\n0 ${objects.length + 1}\n`
|
||||||
|
pdf += '0000000000 65535 f \n'
|
||||||
|
for (let i = 1; i < offsets.length; i += 1) {
|
||||||
|
pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`
|
||||||
|
}
|
||||||
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`
|
||||||
|
|
||||||
|
return Buffer.from(pdf, 'utf8')
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements'
|
||||||
|
|
||||||
interface DriverInfo {
|
interface DriverInfo {
|
||||||
dateOfBirth?: Date | null
|
dateOfBirth?: Date | null
|
||||||
@@ -12,6 +13,11 @@ export async function applyPricingRules(
|
|||||||
dailyRate: number,
|
dailyRate: number,
|
||||||
totalDays: number
|
totalDays: number
|
||||||
): Promise<{ applied: any[]; total: number }> {
|
): Promise<{ applied: any[]; total: number }> {
|
||||||
|
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||||
|
if (!entitlements.features['settings.pricing_rules']?.available) {
|
||||||
|
return { applied: [], total: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
const rules = await prisma.pricingRule.findMany({ where: { companyId, isActive: true } })
|
const rules = await prisma.pricingRule.findMany({ where: { companyId, isActive: true } })
|
||||||
const customer = await prisma.customer.findFirstOrThrow({ where: { id: customerId, companyId } })
|
const customer = await prisma.customer.findFirstOrThrow({ where: { id: customerId, companyId } })
|
||||||
const allDrivers: DriverInfo[] = [customer, ...additionalDrivers]
|
const allDrivers: DriverInfo[] = [customer, ...additionalDrivers]
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -116,6 +116,20 @@ export function routeIdFromSlug(locale: Locale, slug: string): RouteId | null {
|
|||||||
return route?.id ?? null;
|
return route?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function routeIdFromAnyLocaleSlug(slug: string): RouteId | null {
|
||||||
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(slug);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = decoded.replace(/^\/+|\/+$/g, '');
|
||||||
|
const route = routeManifest.routes.find((item) =>
|
||||||
|
locales.some((locale) => item.slugs[locale] === normalized),
|
||||||
|
);
|
||||||
|
return route?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function routeIdFromPathname(pathname: string): RouteId | null {
|
export function routeIdFromPathname(pathname: string): RouteId | null {
|
||||||
const [localeValue, ...segments] = pathname.split('/').filter(Boolean);
|
const [localeValue, ...segments] = pathname.split('/').filter(Boolean);
|
||||||
if (!isLocale(localeValue)) return null;
|
if (!isLocale(localeValue)) return null;
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import {
|
|||||||
localeCookie,
|
localeCookie,
|
||||||
localeCookieMaxAgeSeconds,
|
localeCookieMaxAgeSeconds,
|
||||||
localeFromAcceptLanguage,
|
localeFromAcceptLanguage,
|
||||||
|
localizedPath,
|
||||||
|
routeIdFromAnyLocaleSlug,
|
||||||
|
routeIdFromSlug,
|
||||||
} from '@/lib/localization/config';
|
} from '@/lib/localization/config';
|
||||||
import { buildContentSecurityPolicy, createNonce } from '@/lib/security/csp';
|
import { buildContentSecurityPolicy, createNonce } from '@/lib/security/csp';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const dashboardLanguageCookie = 'rentaldrivego-language';
|
||||||
|
|
||||||
function applySecurityHeaders(response: NextResponse, contentSecurityPolicy: string): NextResponse {
|
function applySecurityHeaders(response: NextResponse, contentSecurityPolicy: string): NextResponse {
|
||||||
response.headers.set('Content-Security-Policy', contentSecurityPolicy);
|
response.headers.set('Content-Security-Policy', contentSecurityPolicy);
|
||||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
@@ -73,6 +78,40 @@ export function proxy(request: NextRequest): NextResponse {
|
|||||||
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLocale(firstSegment) && request.nextUrl.pathname === `/${firstSegment}/subscription`) {
|
||||||
|
const destination = request.nextUrl.clone();
|
||||||
|
destination.pathname = '/dashboard/subscription';
|
||||||
|
const response = NextResponse.redirect(destination);
|
||||||
|
response.cookies.set(localeCookie, firstSegment, {
|
||||||
|
path: '/',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: localeCookieMaxAgeSeconds,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: false,
|
||||||
|
});
|
||||||
|
response.cookies.set(dashboardLanguageCookie, firstSegment, {
|
||||||
|
path: '/',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: localeCookieMaxAgeSeconds,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: false,
|
||||||
|
});
|
||||||
|
return applySecurityHeaders(response, csp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLocale(firstSegment)) {
|
||||||
|
const segments = request.nextUrl.pathname.split('/').filter(Boolean);
|
||||||
|
const slug = segments.slice(1).join('/');
|
||||||
|
if (slug && !routeIdFromSlug(firstSegment, slug)) {
|
||||||
|
const routeId = routeIdFromAnyLocaleSlug(slug);
|
||||||
|
if (routeId) {
|
||||||
|
const destination = request.nextUrl.clone();
|
||||||
|
destination.pathname = localizedPath(routeId, firstSegment);
|
||||||
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||||
const explicitLocale = request.nextUrl.pathname.split('/')[1];
|
const explicitLocale = request.nextUrl.pathname.split('/')[1];
|
||||||
if (isLocale(explicitLocale)) {
|
if (isLocale(explicitLocale)) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
localeSwitchUrl,
|
localeSwitchUrl,
|
||||||
localizedPath,
|
localizedPath,
|
||||||
routeById,
|
routeById,
|
||||||
|
routeIdFromAnyLocaleSlug,
|
||||||
routeIdFromPathname,
|
routeIdFromPathname,
|
||||||
routeIdFromSlug,
|
routeIdFromSlug,
|
||||||
} from '@/lib/localization/config';
|
} from '@/lib/localization/config';
|
||||||
@@ -30,6 +31,10 @@ describe('localization foundations', () => {
|
|||||||
expect(localizedPath('privacy', 'fr')).toBe('/fr/confidentialite');
|
expect(localizedPath('privacy', 'fr')).toBe('/fr/confidentialite');
|
||||||
expect(localizedPath('sign-in', 'ar')).toBe('/ar/تسجيل-الدخول');
|
expect(localizedPath('sign-in', 'ar')).toBe('/ar/تسجيل-الدخول');
|
||||||
expect(routeIdFromSlug('fr', 'confidentialite')).toBe('privacy');
|
expect(routeIdFromSlug('fr', 'confidentialite')).toBe('privacy');
|
||||||
|
expect(routeIdFromSlug('fr', 'privacy')).toBeNull();
|
||||||
|
expect(routeIdFromAnyLocaleSlug('privacy')).toBe('privacy');
|
||||||
|
expect(routeIdFromAnyLocaleSlug('confidentialite')).toBe('privacy');
|
||||||
|
expect(routeIdFromAnyLocaleSlug('unknown')).toBeNull();
|
||||||
expect(routeIdFromPathname('/ar/%D8%A7%D9%84%D8%AE%D8%B5%D9%88%D8%B5%D9%8A%D8%A9')).toBe(
|
expect(routeIdFromPathname('/ar/%D8%A7%D9%84%D8%AE%D8%B5%D9%88%D8%B5%D9%8A%D8%A9')).toBe(
|
||||||
'privacy',
|
'privacy',
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+17
@@ -0,0 +1,17 @@
|
|||||||
|
ALTER TYPE "PaymentProvider" ADD VALUE IF NOT EXISTS 'MANUAL';
|
||||||
|
|
||||||
|
ALTER TABLE "rental_payments"
|
||||||
|
ADD COLUMN "reference" TEXT,
|
||||||
|
ADD COLUMN "note" TEXT,
|
||||||
|
ADD COLUMN "receivedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "recordedByEmployeeId" TEXT,
|
||||||
|
ADD COLUMN "idempotencyKey" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "rental_payments"
|
||||||
|
ADD CONSTRAINT "rental_payments_recordedByEmployeeId_fkey"
|
||||||
|
FOREIGN KEY ("recordedByEmployeeId") REFERENCES "employees"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "rental_payments"
|
||||||
|
ADD CONSTRAINT "rental_payments_companyId_idempotencyKey_key"
|
||||||
|
UNIQUE ("companyId", "idempotencyKey");
|
||||||
@@ -148,6 +148,7 @@ enum BillingCreditNoteStatus {
|
|||||||
enum PaymentProvider {
|
enum PaymentProvider {
|
||||||
AMANPAY
|
AMANPAY
|
||||||
PAYPAL
|
PAYPAL
|
||||||
|
MANUAL
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PaymentStatus {
|
enum PaymentStatus {
|
||||||
@@ -1009,6 +1010,7 @@ model Employee {
|
|||||||
|
|
||||||
notifications Notification[] @relation("EmployeeNotifications")
|
notifications Notification[] @relation("EmployeeNotifications")
|
||||||
notificationPreferences NotificationPreference[]
|
notificationPreferences NotificationPreference[]
|
||||||
|
recordedRentalPayments RentalPayment[] @relation("RecordedRentalPayments")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -1401,6 +1403,12 @@ model RentalPayment {
|
|||||||
amanpayTransactionId String? @unique
|
amanpayTransactionId String? @unique
|
||||||
paypalCaptureId String? @unique
|
paypalCaptureId String? @unique
|
||||||
paymentMethod String?
|
paymentMethod String?
|
||||||
|
reference String?
|
||||||
|
note String?
|
||||||
|
receivedAt DateTime?
|
||||||
|
recordedByEmployeeId String?
|
||||||
|
recordedByEmployee Employee? @relation("RecordedRentalPayments", fields: [recordedByEmployeeId], references: [id])
|
||||||
|
idempotencyKey String?
|
||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -1408,6 +1416,7 @@ model RentalPayment {
|
|||||||
|
|
||||||
@@index([companyId])
|
@@index([companyId])
|
||||||
@@index([reservationId])
|
@@index([reservationId])
|
||||||
|
@@unique([companyId, idempotencyKey])
|
||||||
@@map("rental_payments")
|
@@map("rental_payments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user