diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 228388a..613a30a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -24,6 +24,7 @@ import subscriptionsRouter, { subscriptionWebhookRouter, } from './modules/subscriptions/subscription.routes' import paymentsRouter from './modules/payments/payment.routes' +import billingRouter from './modules/billing/billing.routes' import customersRouter from './modules/customers/customer.routes' import vehiclesRouter from './modules/vehicles/vehicle.routes' import companiesRouter from './modules/companies/company.routes' @@ -202,6 +203,7 @@ export function createApp() { app.use(`${v1}/companies`, apiLimiter, companiesRouter) app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter) app.use(`${v1}/payments`, apiLimiter, paymentsRouter) + app.use(`${v1}/billing`, apiLimiter, billingRouter) app.use(`${v1}/reviews`, apiLimiter, reviewsRouter) app.use(`${v1}/complaints`, apiLimiter, complaintsRouter) app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter) diff --git a/apps/api/src/modules/billing/billing.routes.ts b/apps/api/src/modules/billing/billing.routes.ts new file mode 100644 index 0000000..26089c4 --- /dev/null +++ b/apps/api/src/modules/billing/billing.routes.ts @@ -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 diff --git a/apps/api/src/modules/billing/billing.schemas.test.ts b/apps/api/src/modules/billing/billing.schemas.test.ts new file mode 100644 index 0000000..7257f5a --- /dev/null +++ b/apps/api/src/modules/billing/billing.schemas.test.ts @@ -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) + }) +}) diff --git a/apps/api/src/modules/billing/billing.schemas.ts b/apps/api/src/modules/billing/billing.schemas.ts new file mode 100644 index 0000000..5020d61 --- /dev/null +++ b/apps/api/src/modules/billing/billing.schemas.ts @@ -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(), +}) diff --git a/apps/api/src/modules/billing/billing.service.test.ts b/apps/api/src/modules/billing/billing.service.test.ts new file mode 100644 index 0000000..b302f0c --- /dev/null +++ b/apps/api/src/modules/billing/billing.service.test.ts @@ -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) { + 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) + }) +}) diff --git a/apps/api/src/modules/billing/billing.service.ts b/apps/api/src/modules/billing/billing.service.ts new file mode 100644 index 0000000..8bc9723 --- /dev/null +++ b/apps/api/src/modules/billing/billing.service.ts @@ -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 +} diff --git a/apps/api/src/modules/companies/company.routes.ts b/apps/api/src/modules/companies/company.routes.ts index 0fa7fbf..adebcf8 100644 --- a/apps/api/src/modules/companies/company.routes.ts +++ b/apps/api/src/modules/companies/company.routes.ts @@ -8,6 +8,12 @@ import { parseBody, parseParams } from '../../http/validate' import { ok, created, noContent } from '../../http/respond' import { imageUpload, assertImageFile } from '../../http/upload' import * as service from './company.service' +import { + normalizeSettingsLocale, + requireSettingsFeature, + resolveSettingsEntitlements, + resolveSettingsMenu, +} from './settingsEntitlements' import { companySchema, brandSchema, contractSettingsSchema, insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema, @@ -40,7 +46,7 @@ router.get('/me/brand', async (req, res, next) => { } 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 { const body = parseBody(brandSchema, req) 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) } }) -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 { assertImageFile(req.file, 'logo') 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) } }) -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 { assertImageFile(req.file, 'hero image') 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) } }) +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) => { try { const { subdomain } = parseBody(subdomainSchema, req) @@ -101,7 +119,7 @@ router.get('/me/contract-settings', async (req, res, next) => { } 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 { const body = parseBody(contractSettingsSchema, req) 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) } }) -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 { const body = parseBody(insurancePolicySchema, req) 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) } }) -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 { const { id } = parseParams(idParamSchema, 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) } }) -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 { const { id } = parseParams(idParamSchema, req) await service.deleteInsurancePolicy(id, req.companyId) @@ -148,7 +166,7 @@ router.get('/me/pricing-rules', async (req, res, next) => { } 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 { const body = parseBody(pricingRuleSchema, req) 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) } }) -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 { const { id } = parseParams(idParamSchema, 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) } }) -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 { const { id } = parseParams(idParamSchema, req) await service.deletePricingRule(id, req.companyId) @@ -180,7 +198,7 @@ router.get('/me/accounting-settings', async (req, res, next) => { } 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 { const body = parseBody(accountingSettingsSchema, req) const settings = await service.updateAccountingSettings(req.companyId, body) diff --git a/apps/api/src/modules/companies/company.service.ts b/apps/api/src/modules/companies/company.service.ts index f2adf6b..8a947bd 100644 --- a/apps/api/src/modules/companies/company.service.ts +++ b/apps/api/src/modules/companies/company.service.ts @@ -1,7 +1,8 @@ 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 * as repo from './company.repo' +import { resolveSettingsEntitlements, SettingsFeatureKey } from './settingsEntitlements' function buildPaymentMethodsEnabled(input: { 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) { + 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 paymentMethodsEnabled = buildPaymentMethodsEnabled({ 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) { + await assertSettingsFeature(companyId, 'settings.branding_basic') const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo') return presentBrand(await repo.upsertBrand( 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) { + await assertSettingsFeature(companyId, 'settings.branding_hero') const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero') return presentBrand(await repo.upsertBrand( companyId, @@ -95,6 +104,14 @@ export async function getContractSettings(companyId: string) { } 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) } @@ -103,16 +120,19 @@ export async function getInsurancePolicies(companyId: string) { } export async function createInsurancePolicy(companyId: string, data: any) { + await assertSettingsFeature(companyId, 'settings.insurance_policies') return repo.createInsurancePolicy(companyId, data) } export async function updateInsurancePolicy(id: string, companyId: string, data: any) { + await assertSettingsFeature(companyId, 'settings.insurance_policies') const result = await repo.updateInsurancePolicy(id, companyId, data) if (result.count === 0) throw new NotFoundError('Insurance policy not found') return repo.findInsurancePolicyOrThrow(id) } export async function deleteInsurancePolicy(id: string, companyId: string) { + await assertSettingsFeature(companyId, 'settings.insurance_policies') const result = await repo.deleteInsurancePolicy(id, companyId) 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) { + await assertSettingsFeature(companyId, 'settings.pricing_rules') return repo.createPricingRule(companyId, data) } export async function updatePricingRule(id: string, companyId: string, data: any) { + await assertSettingsFeature(companyId, 'settings.pricing_rules') const result = await repo.updatePricingRule(id, companyId, data) if (result.count === 0) throw new NotFoundError('Pricing rule not found') return repo.findPricingRuleOrThrow(id) } export async function deletePricingRule(id: string, companyId: string) { + await assertSettingsFeature(companyId, 'settings.pricing_rules') const result = await repo.deletePricingRule(id, companyId) 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) { + 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) } @@ -151,3 +177,10 @@ export async function getApiKey(companyId: string) { export async function regenerateApiKey(companyId: string) { 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.') + } +} diff --git a/apps/api/src/modules/companies/settingsEntitlements.test.ts b/apps/api/src/modules/companies/settingsEntitlements.test.ts new file mode 100644 index 0000000..500d4a0 --- /dev/null +++ b/apps/api/src/modules/companies/settingsEntitlements.test.ts @@ -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 }) + }) +}) diff --git a/apps/api/src/modules/companies/settingsEntitlements.ts b/apps/api/src/modules/companies/settingsEntitlements.ts new file mode 100644 index 0000000..c48aa15 --- /dev/null +++ b/apps/api/src/modules/companies/settingsEntitlements.ts @@ -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> = { + 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 = { + '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() + } +} diff --git a/apps/api/src/modules/payments/payment.repo.edge.test.ts b/apps/api/src/modules/payments/payment.repo.edge.test.ts index fd95514..ac3b871 100644 --- a/apps/api/src/modules/payments/payment.repo.edge.test.ts +++ b/apps/api/src/modules/payments/payment.repo.edge.test.ts @@ -3,7 +3,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../../lib/prisma', () => ({ prisma: { 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() }) - 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) - expect(prisma.reservation.update).toHaveBeenCalledWith({ - where: { id: 'reservation_1' }, - data: { paymentStatus: 'PAID', paidAmount: { increment: 2500 } }, - }) + expect(prisma.$transaction).toHaveBeenCalled() }) it('maps partial refunds to the correct payment status', async () => { diff --git a/apps/api/src/modules/payments/payment.repo.ts b/apps/api/src/modules/payments/payment.repo.ts index b40514c..7c1ee5f 100644 --- a/apps/api/src/modules/payments/payment.repo.ts +++ b/apps/api/src/modules/payments/payment.repo.ts @@ -32,12 +32,15 @@ export function findPaymentOrThrow(paymentId: string, companyId: string, reserva export function findReservationOrThrow(id: string, companyId: string) { return prisma.reservation.findFirstOrThrow({ where: { id, companyId }, - include: { vehicle: true, customer: true }, + include: { vehicle: true, customer: true, rentalPayments: true }, }) } 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) { @@ -48,8 +51,26 @@ export function markPaymentFailed(query: { amanpayTransactionId?: string; paypal return prisma.rentalPayment.updateMany({ where: query, data: { status: 'FAILED' } }) } -export function incrementReservationPaid(reservationId: string, amount: number) { - return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } }) +export function incrementReservationPaid(reservationId: string, _amount: number) { + 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: { diff --git a/apps/api/src/modules/payments/payment.routes.ts b/apps/api/src/modules/payments/payment.routes.ts index 0f8f4a7..fa708e1 100644 --- a/apps/api/src/modules/payments/payment.routes.ts +++ b/apps/api/src/modules/payments/payment.routes.ts @@ -72,7 +72,7 @@ router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (r } 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 { const { id } = parseParams(reservationParamSchema, req) const body = parseBody(manualPaymentSchema, req) diff --git a/apps/api/src/modules/payments/payment.service.test.ts b/apps/api/src/modules/payments/payment.service.test.ts index 6b5cca5..bda6570 100644 --- a/apps/api/src/modules/payments/payment.service.test.ts +++ b/apps/api/src/modules/payments/payment.service.test.ts @@ -52,6 +52,7 @@ const reservation = { depositAmount: 300, totalAmount: 1200, paidAmount: 200, + rentalPayments: [], vehicle: { make: 'Dacia', model: 'Duster' }, 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 () => { - 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', { provider: 'PAYPAL', @@ -120,8 +125,36 @@ describe('payment.service', () => { 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 () => { - 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) const payment = await recordManualPayment('reservation_1', 'company_1', { @@ -133,7 +166,7 @@ describe('payment.service', () => { expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({ status: 'SUCCEEDED', - paymentProvider: 'AMANPAY', + paymentProvider: 'MANUAL', paymentMethod: 'CASH', paidAt: expect.any(Date), })) @@ -142,7 +175,13 @@ describe('payment.service', () => { }) 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', { amount: 100, @@ -156,8 +195,8 @@ describe('payment.service', () => { }) 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.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500 } 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, type: 'CHARGE' } as never) await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' }) 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 () => { - 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(repo.updatePaypalCapture).mockResolvedValue({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' } as never) diff --git a/apps/api/src/modules/payments/payment.service.ts b/apps/api/src/modules/payments/payment.service.ts index dd41846..9e6ba0b 100644 --- a/apps/api/src/modules/payments/payment.service.ts +++ b/apps/api/src/modules/payments/payment.service.ts @@ -19,7 +19,9 @@ async function applyAmanpayWebhook(event: any) { const payment = await repo.findByAmanpay(transactionId) if (payment && payment.status !== 'SUCCEEDED') { await repo.markPaymentSucceeded(payment.id) - await repo.incrementReservationPaid(payment.reservationId, payment.amount) + if (payment.type === 'CHARGE') { + await repo.incrementReservationPaid(payment.reservationId, payment.amount) + } } } else if (status === 'FAILED') { await repo.markPaymentFailed({ amanpayTransactionId: transactionId }) @@ -33,7 +35,9 @@ async function applyPaypalWebhook(event: any) { const payment = await repo.findByPaypal(captureId) if (payment && payment.status !== 'SUCCEEDED') { await repo.markPaymentSucceeded(payment.id) - await repo.incrementReservationPaid(payment.reservationId, payment.amount) + if (payment.type === 'CHARGE') { + await repo.incrementReservationPaid(payment.reservationId, payment.amount) + } } } else if (eventType === 'PAYMENT.CAPTURE.DENIED') { 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 }) { 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 orderId = `${reservationId}-${body.type}-${Date.now()}` 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 const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId const updated = await repo.updatePaypalCapture(payment.id, captureId) - await repo.incrementReservationPaid(payment.reservationId, payment.amount) + if (payment.type === 'CHARGE') { + await repo.incrementReservationPaid(payment.reservationId, payment.amount) + } return updated } @@ -111,13 +131,31 @@ export async function recordManualPayment(reservationId: string, companyId: stri amount: number; currency: string; type: string; paymentMethod: string }) { const reservation = await repo.findReservation(reservationId, companyId) - const remaining = Math.max(reservation.totalAmount - reservation.paidAmount, 0) - if (remaining <= 0) throw new ConflictError('Reservation is already fully paid') - if (body.amount > remaining) throw new ValidationError('Payment amount exceeds remaining balance') + 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 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() }) - const newPaidAmount = reservation.paidAmount + body.amount - await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL') + if (remaining <= 0) { + 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') + } return payment } diff --git a/apps/api/src/services/additionalDriverService.ts b/apps/api/src/services/additionalDriverService.ts index 8b03c57..0f44a54 100644 --- a/apps/api/src/services/additionalDriverService.ts +++ b/apps/api/src/services/additionalDriverService.ts @@ -1,7 +1,8 @@ import { prisma } from '../lib/prisma' +import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements' +import { validateLicense } from './licenseValidationService' type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE' -import { validateLicense } from './licenseValidationService' export interface AdditionalDriverInput { firstName: string @@ -40,7 +41,11 @@ export async function applyAdditionalDriversToReservation( 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 chargeValue = chargeType === 'PER_DAY' diff --git a/apps/api/src/services/insuranceService.ts b/apps/api/src/services/insuranceService.ts index 3925a1c..d5b585a 100644 --- a/apps/api/src/services/insuranceService.ts +++ b/apps/api/src/services/insuranceService.ts @@ -1,5 +1,6 @@ import { InsurancePolicy } from '@rentaldrivego/database' import { prisma } from '../lib/prisma' +import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements' export function calculateInsuranceCharge( policy: InsurancePolicy, @@ -21,6 +22,11 @@ export async function applyInsurancesToReservation( totalDays: 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 required = allPolicies.filter((p: InsurancePolicy) => p.isRequired) const selected = allPolicies.filter((p: InsurancePolicy) => selectedPolicyIds.includes(p.id) && !p.isRequired) diff --git a/apps/api/src/services/invoicePdfService.test.ts b/apps/api/src/services/invoicePdfService.test.ts index 94da41c..1c51e5c 100644 --- a/apps/api/src/services/invoicePdfService.test.ts +++ b/apps/api/src/services/invoicePdfService.test.ts @@ -54,4 +54,32 @@ describe('invoicePdfService', () => { 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') + }) }) diff --git a/apps/api/src/services/invoicePdfService.ts b/apps/api/src/services/invoicePdfService.ts index 7b78a58..6b9ed25 100644 --- a/apps/api/src/services/invoicePdfService.ts +++ b/apps/api/src/services/invoicePdfService.ts @@ -318,17 +318,27 @@ function fmt(amount: number, currency: string) { } 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' }) } function formatAddress(address: any): string { if (!address) return '' 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] 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 }) { const statusColor = STATUS_COLORS[data.status] ?? '#6b7280' const addressStr = formatAddress(data.company.address) @@ -374,11 +384,11 @@ function InvoiceDocument({ data }: { data: InvoiceData }) { View, { style: s.invoiceMeta }, 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( View, { 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, { style: s.col }, React.createElement(Text, { style: s.colLabel }, 'Bill To'), - React.createElement(Text, { style: s.companyName }, data.company.name), - React.createElement(Text, { style: s.companyDetail }, data.company.email), + React.createElement(Text, { style: s.companyName }, pdfText(data.company.name)), + React.createElement(Text, { style: s.companyDetail }, pdfText(data.company.email)), data.company.phone - ? React.createElement(Text, { style: s.companyDetail }, data.company.phone) + ? React.createElement(Text, { style: s.companyDetail }, pdfText(data.company.phone)) : null, addressStr - ? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, addressStr) + ? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, pdfText(addressStr)) : null, ), React.createElement( @@ -408,7 +418,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) { View, { style: s.metaRow }, 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( View, @@ -428,20 +438,20 @@ function InvoiceDocument({ data }: { data: InvoiceData }) { View, { style: s.metaRow }, 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( View, { style: s.metaRow }, 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 ? React.createElement( View, { style: s.metaRow }, React.createElement(Text, { style: s.metaKey }, 'Plan'), - React.createElement(Text, { style: s.metaValue }, planLabel), + React.createElement(Text, { style: s.metaValue }, pdfText(planLabel)), ) : null, ), @@ -460,17 +470,17 @@ function InvoiceDocument({ data }: { data: InvoiceData }) { ), ...lineItems.map((item) => { 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 - ? `${item.description} (${item.quantity} × ${fmt(item.unitAmount, item.currency)})` + ? `${item.description} (${item.quantity} x ${fmt(item.unitAmount, item.currency)})` : item.description return React.createElement( View, { key: `${item.description}-${item.amount}-${item.periodStart ?? ''}`, style: s.tableRow }, - React.createElement(Text, { style: [s.tableCell, s.col60] }, description), - React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr), - React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(item.amount, item.currency)), + React.createElement(Text, { style: [s.tableCell, s.col60] }, pdfText(description)), + React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, pdfText(periodStr)), + 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, { style: s.paymentRow }, 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 ? React.createElement( View, { style: s.paymentRow }, 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, React.createElement( @@ -542,7 +552,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) { View, { style: s.paymentRow }, 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( View, { 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())}`), ), ), @@ -559,8 +569,12 @@ function InvoiceDocument({ data }: { data: InvoiceData }) { export async function generateInvoicePdf(data: InvoiceData): Promise { const doc = React.createElement(InvoiceDocument, { data }) - const buffer = await renderToBuffer(doc as any) - return buffer as unknown as Buffer + try { + const buffer = await renderToBuffer(doc as any) + return buffer as unknown as Buffer + } catch { + return buildSimpleInvoicePdf(data) + } } 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() 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') +} diff --git a/apps/api/src/services/pricingRuleService.ts b/apps/api/src/services/pricingRuleService.ts index 657503f..bc65c06 100644 --- a/apps/api/src/services/pricingRuleService.ts +++ b/apps/api/src/services/pricingRuleService.ts @@ -1,4 +1,5 @@ import { prisma } from '../lib/prisma' +import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements' interface DriverInfo { dateOfBirth?: Date | null @@ -12,6 +13,11 @@ export async function applyPricingRules( dailyRate: number, totalDays: 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 customer = await prisma.customer.findFirstOrThrow({ where: { id: customerId, companyId } }) const allDrivers: DriverInfo[] = [customer, ...additionalDrivers] diff --git a/apps/dashboard.zip b/apps/dashboard.zip new file mode 100644 index 0000000..e879a7a Binary files /dev/null and b/apps/dashboard.zip differ diff --git a/apps/dashboard/src/app/(dashboard)/billing/page.tsx b/apps/dashboard/src/app/(dashboard)/billing/page.tsx index b70f929..36f2b9c 100644 --- a/apps/dashboard/src/app/(dashboard)/billing/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/billing/page.tsx @@ -1,372 +1,309 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { formatCurrency, SupportedCurrency } from '@rentaldrivego/types' import { apiFetch } from '@/lib/api' import { useDashboardI18n } from '@/components/I18nProvider' -type ReservationRow = { +type BillingPaymentType = 'CHARGE' | 'DEPOSIT' +type ManualPaymentMethod = 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' | 'OTHER' + +type BillingPayment = { id: string - invoiceNumber: string | null - contractNumber: string | null + amountMinor: number + currency: SupportedCurrency + type: BillingPaymentType + channel: 'ONLINE' | 'OFFLINE' | 'TERMINAL' + provider: string + method: string | null status: string - paymentStatus: string - startDate: string - endDate: string - totalAmount: number - depositAmount: number - paidAmount: number - customer: { firstName: string; lastName: string; email: string } - vehicle: { make: string; model: string; licensePlate: string } + reference: string | null + receivedAt: string + createdAt: string + recordedBy: { name: string; email: string } | null } -type PaymentRow = { +type BillingInvoice = { id: string reservationId: string - amount: number + invoiceNumber: string | null + contractNumber: string | null + customer: { firstName: string; lastName: string; email: string } + vehicle: { make: string; model: string; licensePlate: string } + rentalPeriod: { startDate: string; endDate: string } currency: SupportedCurrency - status: string - type: 'CHARGE' | 'DEPOSIT' - paymentProvider: 'AMANPAY' | 'PAYPAL' - paymentMethod: string | null - paidAt: string | null - createdAt: string -} - -type BillingRow = ReservationRow & { - balanceDue: number + paymentStatus: 'UNPAID' | 'PARTIAL' | 'PAID' + invoiceTotal: number + invoicePaid: number + invoiceBalanceDue: number + depositRequired: number + depositCollected: number + depositHeld: number + depositOutstanding: number + depositStatus: string paymentCount: number - latestPayment: PaymentRow | null + latestPayment: BillingPayment | null } -type ManualPaymentMethod = 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' +type BillingSummary = { + currency: SupportedCurrency + totalInvoiced: number + totalCollected: number + totalRefunded: number + totalOutstanding: number + depositsHeld: number + openInvoiceCount: number + overdueInvoiceCount: number +} -const PAYMENT_STATUS_BADGE: Record = { +type BillingInvoiceResponse = { + items: BillingInvoice[] + page: number + pageSize: number + totalItems: number + totalPages: number +} + +const STATUS_BADGE: Record = { UNPAID: 'bg-rose-100 text-rose-700', - PAID: 'bg-emerald-100 text-emerald-700', PARTIAL: 'bg-orange-100 text-orange-700', - PENDING: 'bg-sky-100 text-sky-700', - REFUNDED: 'bg-slate-100 text-slate-700', - FAILED: 'bg-rose-100 text-rose-700', - OVERDUE: 'bg-orange-100 text-orange-700', -} - -const PAYMENT_EVENT_BADGE: Record = { - PENDING: 'bg-orange-100 text-orange-700', - SUCCEEDED: 'bg-emerald-100 text-emerald-700', - FAILED: 'bg-rose-100 text-rose-700', - REFUNDED: 'bg-slate-100 text-slate-700', - PARTIALLY_REFUNDED: 'bg-sky-100 text-sky-700', + PAID: 'bg-emerald-100 text-emerald-700', + OUTSTANDING: 'bg-rose-100 text-rose-700', + PARTIALLY_COLLECTED: 'bg-orange-100 text-orange-700', + HELD: 'bg-emerald-100 text-emerald-700', + NOT_REQUIRED: 'bg-slate-100 text-slate-700', } export default function BillingPage() { const { language } = useDashboardI18n() const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US' - const [reservations, setReservations] = useState([]) - const [payments, setPayments] = useState([]) + const [summary, setSummary] = useState(null) + const [invoices, setInvoices] = useState(null) const [search, setSearch] = useState('') - const [error, setError] = useState(null) + const [submittedSearch, setSubmittedSearch] = useState('') + const [paymentStatus, setPaymentStatus] = useState<'ALL' | 'UNPAID' | 'PARTIAL' | 'PAID'>('ALL') + const [outstandingOnly, setOutstandingOnly] = useState(false) + const [page, setPage] = useState(1) const [loading, setLoading] = useState(true) - const [paymentModalRow, setPaymentModalRow] = useState(null) + const [error, setError] = useState(null) + const [paymentInvoice, setPaymentInvoice] = useState(null) + const [paymentType, setPaymentType] = useState('CHARGE') const [paymentMethod, setPaymentMethod] = useState('CASH') - const [paymentType, setPaymentType] = useState<'CHARGE' | 'DEPOSIT'>('CHARGE') - const paymentCurrency = 'MAD' const [paymentAmount, setPaymentAmount] = useState('') + const [receivedAt, setReceivedAt] = useState('') + const [reference, setReference] = useState('') + const [note, setNote] = useState('') const [submittingPayment, setSubmittingPayment] = useState(false) const [paymentError, setPaymentError] = useState(null) - const copy = { + const copy = useMemo(() => ({ en: { heading: 'Customer Billing', - subtitle: 'Manage customer invoices, payment status, collected amounts, and remaining balances.', - search: 'Search by customer, invoice, contract, vehicle, or plate…', - totalBilled: 'Total billed', - totalCollected: 'Collected', + subtitle: 'Customer invoices, rental payments, deposits, and balances. Subscription billing is managed separately.', + search: 'Search customer, invoice, contract, vehicle, or plate', + applySearch: 'Search', + allStatuses: 'All statuses', + outstandingOnly: 'Outstanding only', + totalInvoiced: 'Total invoiced', + collected: 'Collected', outstanding: 'Outstanding', + depositsHeld: 'Deposits held', openInvoices: 'Open invoices', - customer: 'Customer', + invoiceCustomer: 'Invoice and customer', vehicle: 'Vehicle', - invoice: 'Invoice', - rentalPeriod: 'Rental period', - total: 'Total', + period: 'Rental period', + invoiceTotal: 'Invoice total', paid: 'Paid', balance: 'Balance', - status: 'Status', - payment: 'Payment', - paymentMethod: 'Payment method', - actions: 'Actions', - contract: 'Contract', deposit: 'Deposit', - acceptPayment: 'Accept payment', - paymentModalTitle: 'Accept customer payment', - paymentModalSubtitle: 'Record a payment received for this reservation. No online provider setup is required.', - paymentType: 'Payment type', - paymentCurrency: 'Payment currency', - paymentAmount: 'Payment amount', - cancel: 'Cancel', - savingPayment: 'Saving payment…', - savePayment: 'Save payment', - chargeHelp: 'Charge records a payment against the reservation balance.', - depositHelp: 'Deposit records money received for the refundable security deposit.', - invalidAmount: 'Enter a valid payment amount within the remaining balance.', - paymentActionDisabled: 'No balance due', - paymentsRecorded: (count: number) => `${count} payment(s)`, - none: '—', - loading: 'Loading customer billing…', - noRows: 'No customer bills found.', - failed: 'Failed to load customer billing.', + status: 'Status', + actions: 'Actions', + recordPayment: 'Record payment', + noPaymentDue: 'No payment due', openContract: 'Open contract', openBooking: 'Open booking', - charge: 'Charge', - depositType: 'Deposit', + loading: 'Loading customer billing...', + failed: 'Failed to load customer billing.', + retry: 'Retry', + empty: 'No customer billing records found.', + previous: 'Previous', + next: 'Next', + pageLabel: (current: number, total: number) => `Page ${current} of ${total}`, unknownInvoice: 'Draft invoice', - paymentStatusLabels: { - UNPAID: 'Unpaid', - PAID: 'Paid', - PARTIAL: 'Partial', - PENDING: 'Pending', - REFUNDED: 'Refunded', - FAILED: 'Failed', - OVERDUE: 'Overdue', - } as Record, - paymentEventLabels: { - PENDING: 'Pending', - SUCCEEDED: 'Succeeded', - FAILED: 'Failed', - REFUNDED: 'Refunded', - PARTIALLY_REFUNDED: 'Partially refunded', - } as Record, - paymentProviderLabels: { - AMANPAY: 'AmanPay', - PAYPAL: 'PayPal', - } as Record, - paymentMethodLabels: { - CASH: 'Cash', - CHECK: 'Check', - BANK_TRANSFER: 'Bank transfer', - CARD: 'Card', - PAYPAL: 'PayPal', - STRIPE: 'Card', - CREDIT_CARD: 'Card', - DEBIT_CARD: 'Card', - } as Record, + none: '-', + paymentDialogTitle: 'Record manual payment', + paymentDialogDescription: 'Record an offline payment against the correct invoice or security-deposit balance.', + paymentTarget: 'Payment target', + charge: 'Invoice charge', + securityDeposit: 'Security deposit', + remaining: 'Remaining', + paymentMethod: 'Payment method', + amount: 'Amount', + currency: 'Currency', + receivedAt: 'Received at', + reference: 'Reference', + note: 'Internal note', + cancel: 'Cancel', + save: 'Save payment', + saving: 'Saving...', + invalidAmount: 'Enter a valid amount within the selected remaining balance.', + paymentStatusLabels: { ALL: 'All', UNPAID: 'Unpaid', PARTIAL: 'Partial', PAID: 'Paid' } as Record, + depositStatusLabels: { NOT_REQUIRED: 'Not required', OUTSTANDING: 'Outstanding', PARTIALLY_COLLECTED: 'Partially collected', HELD: 'Held' } as Record, + paymentMethodLabels: { CASH: 'Cash', CHECK: 'Check', BANK_TRANSFER: 'Bank transfer', CARD: 'Card', PAYPAL: 'PayPal', OTHER: 'Other' } as Record, }, fr: { heading: 'Facturation clients', - subtitle: 'Gérez les factures clients, le statut des paiements, les montants encaissés et les soldes restants.', - search: 'Rechercher par client, facture, contrat, véhicule ou plaque…', - totalBilled: 'Total facturé', - totalCollected: 'Encaissé', + subtitle: 'Factures clients, paiements de location, dépôts et soldes. La facturation d’abonnement est séparée.', + search: 'Rechercher client, facture, contrat, véhicule ou plaque', + applySearch: 'Rechercher', + allStatuses: 'Tous les statuts', + outstandingOnly: 'Soldes uniquement', + totalInvoiced: 'Total facturé', + collected: 'Encaissé', outstanding: 'Solde restant', + depositsHeld: 'Dépôts détenus', openInvoices: 'Factures ouvertes', - customer: 'Client', + invoiceCustomer: 'Facture et client', vehicle: 'Véhicule', - invoice: 'Facture', - rentalPeriod: 'Période', - total: 'Total', + period: 'Période', + invoiceTotal: 'Total facture', paid: 'Payé', balance: 'Solde', - status: 'Statut', - payment: 'Paiement', - paymentMethod: 'Mode de paiement', - actions: 'Actions', - contract: 'Contrat', deposit: 'Dépôt', - acceptPayment: 'Encaisser', - paymentModalTitle: 'Encaisser un paiement client', - paymentModalSubtitle: 'Enregistrez un paiement reçu pour cette réservation. Aucune configuration de fournisseur en ligne n’est requise.', - paymentType: 'Type de paiement', - paymentCurrency: 'Devise du paiement', - paymentAmount: 'Montant du paiement', - cancel: 'Annuler', - savingPayment: 'Enregistrement…', - savePayment: 'Enregistrer le paiement', - chargeHelp: 'Le paiement enregistre un règlement sur le solde de la réservation.', - depositHelp: 'Le dépôt enregistre l’encaissement du dépôt de garantie remboursable.', - invalidAmount: 'Saisissez un montant valide dans la limite du solde restant.', - paymentActionDisabled: 'Aucun solde dû', - paymentsRecorded: (count: number) => `${count} paiement(s)`, - none: '—', - loading: 'Chargement de la facturation client…', - noRows: 'Aucune facture client trouvée.', + status: 'Statut', + actions: 'Actions', + recordPayment: 'Encaisser', + noPaymentDue: 'Aucun montant dû', + openContract: 'Ouvrir contrat', + openBooking: 'Ouvrir réservation', + loading: 'Chargement de la facturation client...', failed: 'Échec du chargement de la facturation client.', - openContract: 'Ouvrir le contrat', - openBooking: 'Ouvrir la réservation', - charge: 'Paiement', - depositType: 'Dépôt', + retry: 'Réessayer', + empty: 'Aucune facture client trouvée.', + previous: 'Précédent', + next: 'Suivant', + pageLabel: (current: number, total: number) => `Page ${current} sur ${total}`, unknownInvoice: 'Facture brouillon', - paymentStatusLabels: { - UNPAID: 'Non payé', - PAID: 'Payé', - PARTIAL: 'Partiel', - PENDING: 'En attente', - REFUNDED: 'Remboursé', - FAILED: 'Échoué', - OVERDUE: 'En retard', - } as Record, - paymentEventLabels: { - PENDING: 'En attente', - SUCCEEDED: 'Réussi', - FAILED: 'Échoué', - REFUNDED: 'Remboursé', - PARTIALLY_REFUNDED: 'Partiellement remboursé', - } as Record, - paymentProviderLabels: { - AMANPAY: 'AmanPay', - PAYPAL: 'PayPal', - } as Record, - paymentMethodLabels: { - CASH: 'Espèces', - CHECK: 'Chèque', - BANK_TRANSFER: 'Virement bancaire', - CARD: 'Carte', - PAYPAL: 'PayPal', - STRIPE: 'Carte', - CREDIT_CARD: 'Carte', - DEBIT_CARD: 'Carte', - } as Record, + none: '-', + paymentDialogTitle: 'Enregistrer un paiement manuel', + paymentDialogDescription: 'Enregistrez un paiement hors ligne sur le bon solde de facture ou de dépôt de garantie.', + paymentTarget: 'Cible du paiement', + charge: 'Facture', + securityDeposit: 'Dépôt de garantie', + remaining: 'Restant', + paymentMethod: 'Mode de paiement', + amount: 'Montant', + currency: 'Devise', + receivedAt: 'Reçu le', + reference: 'Référence', + note: 'Note interne', + cancel: 'Annuler', + save: 'Enregistrer', + saving: 'Enregistrement...', + invalidAmount: 'Saisissez un montant valide dans la limite du solde sélectionné.', + paymentStatusLabels: { ALL: 'Tous', UNPAID: 'Non payé', PARTIAL: 'Partiel', PAID: 'Payé' } as Record, + depositStatusLabels: { NOT_REQUIRED: 'Non requis', OUTSTANDING: 'À collecter', PARTIALLY_COLLECTED: 'Partiellement collecté', HELD: 'Détenu' } as Record, + paymentMethodLabels: { CASH: 'Espèces', CHECK: 'Chèque', BANK_TRANSFER: 'Virement bancaire', CARD: 'Carte', PAYPAL: 'PayPal', OTHER: 'Autre' } as Record, }, ar: { heading: 'فوترة العملاء', - subtitle: 'إدارة فواتير العملاء وحالة الدفع والمبالغ المحصلة والأرصدة المتبقية.', - search: 'ابحث بالعميل أو الفاتورة أو العقد أو السيارة أو اللوحة…', - totalBilled: 'إجمالي الفواتير', - totalCollected: 'المحصّل', + subtitle: 'فواتير العملاء ودفعات الإيجار والضمانات والأرصدة. فوترة الاشتراك تدار بشكل منفصل.', + search: 'ابحث بالعميل أو الفاتورة أو العقد أو السيارة أو اللوحة', + applySearch: 'بحث', + allStatuses: 'كل الحالات', + outstandingOnly: 'المستحق فقط', + totalInvoiced: 'إجمالي الفواتير', + collected: 'المحصّل', outstanding: 'المتبقي', + depositsHeld: 'الضمان المحتجز', openInvoices: 'الفواتير المفتوحة', - customer: 'العميل', + invoiceCustomer: 'الفاتورة والعميل', vehicle: 'المركبة', - invoice: 'الفاتورة', - rentalPeriod: 'فترة الإيجار', - total: 'الإجمالي', + period: 'الفترة', + invoiceTotal: 'إجمالي الفاتورة', paid: 'المدفوع', balance: 'الرصيد', + deposit: 'الضمان', status: 'الحالة', - payment: 'الدفعة', - paymentMethod: 'طريقة الدفع', actions: 'الإجراءات', - contract: 'العقد', - deposit: 'العربون', - acceptPayment: 'تحصيل دفعة', - paymentModalTitle: 'تحصيل دفعة من العميل', - paymentModalSubtitle: 'سجّل دفعة مستلمة لهذا الحجز. لا يتطلب ذلك إعداد مزود دفع عبر الإنترنت.', - paymentType: 'نوع الدفع', - paymentCurrency: 'عملة الدفع', - paymentAmount: 'مبلغ الدفعة', - cancel: 'إلغاء', - savingPayment: 'جارٍ الحفظ…', - savePayment: 'حفظ الدفعة', - chargeHelp: 'تسجّل الدفعة مبلغاً مستلماً على رصيد الحجز.', - depositHelp: 'العربون يسجل مبلغ التأمين القابل للاسترداد المستلم.', - invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المتبقي.', - paymentActionDisabled: 'لا يوجد رصيد مستحق', - paymentsRecorded: (count: number) => `${count} دفعة`, - none: '—', - loading: 'جارٍ تحميل فوترة العملاء…', - noRows: 'لم يتم العثور على فواتير عملاء.', - failed: 'فشل تحميل فوترة العملاء.', + recordPayment: 'تسجيل دفعة', + noPaymentDue: 'لا يوجد مبلغ مستحق', openContract: 'فتح العقد', openBooking: 'فتح الحجز', - charge: 'دفعة', - depositType: 'عربون', + loading: 'جارٍ تحميل فوترة العملاء...', + failed: 'فشل تحميل فوترة العملاء.', + retry: 'إعادة المحاولة', + empty: 'لم يتم العثور على سجلات فوترة.', + previous: 'السابق', + next: 'التالي', + pageLabel: (current: number, total: number) => `الصفحة ${current} من ${total}`, unknownInvoice: 'فاتورة مسودة', - paymentStatusLabels: { - UNPAID: 'غير مدفوع', - PAID: 'مدفوع', - PARTIAL: 'جزئي', - PENDING: 'قيد الانتظار', - REFUNDED: 'مسترد', - FAILED: 'فشل', - OVERDUE: 'متأخر', - } as Record, - paymentEventLabels: { - PENDING: 'قيد الانتظار', - SUCCEEDED: 'ناجح', - FAILED: 'فشل', - REFUNDED: 'مسترد', - PARTIALLY_REFUNDED: 'مسترد جزئياً', - } as Record, - paymentProviderLabels: { - AMANPAY: 'AmanPay', - PAYPAL: 'PayPal', - } as Record, - paymentMethodLabels: { - CASH: 'نقداً', - CHECK: 'شيك', - BANK_TRANSFER: 'تحويل بنكي', - CARD: 'بطاقة', - PAYPAL: 'PayPal', - STRIPE: 'بطاقة', - CREDIT_CARD: 'بطاقة', - DEBIT_CARD: 'بطاقة', - } as Record, + none: '-', + paymentDialogTitle: 'تسجيل دفعة يدوية', + paymentDialogDescription: 'سجل دفعة خارجية على رصيد الفاتورة أو الضمان الصحيح.', + paymentTarget: 'هدف الدفعة', + charge: 'دفعة الفاتورة', + securityDeposit: 'ضمان التأمين', + remaining: 'المتبقي', + paymentMethod: 'طريقة الدفع', + amount: 'المبلغ', + currency: 'العملة', + receivedAt: 'وقت الاستلام', + reference: 'المرجع', + note: 'ملاحظة داخلية', + cancel: 'إلغاء', + save: 'حفظ الدفعة', + saving: 'جارٍ الحفظ...', + invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المحدد.', + paymentStatusLabels: { ALL: 'الكل', UNPAID: 'غير مدفوع', PARTIAL: 'جزئي', PAID: 'مدفوع' } as Record, + depositStatusLabels: { NOT_REQUIRED: 'غير مطلوب', OUTSTANDING: 'مستحق', PARTIALLY_COLLECTED: 'محصل جزئياً', HELD: 'محتجز' } as Record, + paymentMethodLabels: { CASH: 'نقداً', CHECK: 'شيك', BANK_TRANSFER: 'تحويل بنكي', CARD: 'بطاقة', PAYPAL: 'PayPal', OTHER: 'أخرى' } as Record, }, - }[language] + }[language]), [language]) - async function loadBillingData() { - const [reservationRows, paymentRows] = await Promise.all([ - apiFetch('/reservations?pageSize=100'), - apiFetch('/payments/company'), + const loadBilling = useCallback(async () => { + setLoading(true) + setError(null) + const params = new URLSearchParams({ + page: String(page), + pageSize: '10', + search: submittedSearch, + paymentStatus, + outstandingOnly: String(outstandingOnly), + }) + const summaryParams = new URLSearchParams({ search: submittedSearch }) + const [summaryRows, invoiceRows] = await Promise.all([ + apiFetch(`/billing/summary?${summaryParams.toString()}`), + apiFetch(`/billing/invoices?${params.toString()}`), ]) - setReservations(reservationRows ?? []) - setPayments(paymentRows ?? []) - } + setSummary(summaryRows) + setInvoices(invoiceRows) + setLoading(false) + }, [outstandingOnly, page, paymentStatus, submittedSearch]) useEffect(() => { - loadBillingData() - .catch((err) => setError(err.message ?? copy.failed)) - .finally(() => setLoading(false)) - }, [copy.failed]) - - const billingRows = useMemo(() => { - const paymentsByReservation = new Map() - - for (const payment of payments) { - const existing = paymentsByReservation.get(payment.reservationId) ?? [] - existing.push(payment) - paymentsByReservation.set(payment.reservationId, existing) - } - - return reservations.map((reservation) => { - const reservationPayments = (paymentsByReservation.get(reservation.id) ?? []).sort((a, b) => - new Date(b.paidAt ?? b.createdAt).getTime() - new Date(a.paidAt ?? a.createdAt).getTime(), - ) - - return { - ...reservation, - balanceDue: Math.max(reservation.totalAmount - reservation.paidAmount, 0), - paymentCount: reservationPayments.length, - latestPayment: reservationPayments[0] ?? null, - } + loadBilling().catch((err) => { + setError(err.message ?? copy.failed) + setLoading(false) }) - }, [payments, reservations]) + }, [copy.failed, loadBilling]) - const filteredRows = useMemo(() => { - const q = search.trim().toLowerCase() - if (!q) return billingRows - return billingRows.filter((row) => - `${row.customer.firstName} ${row.customer.lastName}`.toLowerCase().includes(q) || - row.customer.email.toLowerCase().includes(q) || - `${row.vehicle.make} ${row.vehicle.model}`.toLowerCase().includes(q) || - row.vehicle.licensePlate.toLowerCase().includes(q) || - (row.invoiceNumber ?? '').toLowerCase().includes(q) || - (row.contractNumber ?? '').toLowerCase().includes(q), - ) - }, [billingRows, search]) + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape' && paymentInvoice && !submittingPayment) { + setPaymentInvoice(null) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [paymentInvoice, submittingPayment]) - const totals = useMemo(() => { - return filteredRows.reduce( - (acc, row) => { - acc.totalBilled += row.totalAmount - acc.totalCollected += row.paidAmount - acc.outstanding += row.balanceDue - if (row.balanceDue > 0) acc.openInvoices += 1 - return acc - }, - { totalBilled: 0, totalCollected: 0, outstanding: 0, openInvoices: 0 }, - ) - }, [filteredRows]) + const selectedRemaining = paymentInvoice + ? paymentType === 'DEPOSIT' + ? paymentInvoice.depositOutstanding + : paymentInvoice.invoiceBalanceDue + : 0 function formatDate(value: string) { return new Date(value).toLocaleDateString(localeCode, { @@ -376,51 +313,56 @@ export default function BillingPage() { }) } - function translateStatus(status: string, labels: Record) { - return labels[status] ?? status.replace(/_/g, ' ') + function formatMoney(amount: number, currency: SupportedCurrency = summary?.currency ?? 'MAD') { + return formatCurrency(amount, currency) } - function translatePaymentMethod(method: string | null) { - if (!method) return copy.none - return copy.paymentMethodLabels[method] ?? method.replace(/_/g, ' ') - } - - function canAcceptPayment(row: BillingRow) { - return row.balanceDue > 0 - } - - function openPaymentModal(row: BillingRow) { - setPaymentModalRow(row) + function openPaymentDialog(invoice: BillingInvoice) { + const nextType = invoice.invoiceBalanceDue > 0 ? 'CHARGE' : 'DEPOSIT' + const remaining = nextType === 'DEPOSIT' ? invoice.depositOutstanding : invoice.invoiceBalanceDue + setPaymentInvoice(invoice) + setPaymentType(nextType) setPaymentMethod('CASH') - setPaymentType('CHARGE') - setPaymentAmount(String((row.balanceDue / 100).toFixed(2))) + setPaymentAmount((remaining / 100).toFixed(2)) + setReceivedAt(new Date().toISOString().slice(0, 16)) + setReference('') + setNote('') setPaymentError(null) } - async function handleAcceptPayment() { - if (!paymentModalRow) return + function updatePaymentType(type: BillingPaymentType) { + setPaymentType(type) + if (!paymentInvoice) return + const remaining = type === 'DEPOSIT' ? paymentInvoice.depositOutstanding : paymentInvoice.invoiceBalanceDue + setPaymentAmount((remaining / 100).toFixed(2)) + } - const amount = Math.round(Number(paymentAmount) * 100) - if (!Number.isFinite(amount) || amount <= 0 || amount > paymentModalRow.balanceDue) { + async function submitPayment() { + if (!paymentInvoice) return + const amountMinor = Math.round(Number(paymentAmount) * 100) + if (!Number.isFinite(amountMinor) || amountMinor <= 0 || amountMinor > selectedRemaining) { setPaymentError(copy.invalidAmount) return } setSubmittingPayment(true) setPaymentError(null) - try { - await apiFetch(`/payments/reservations/${paymentModalRow.id}/manual`, { + await apiFetch(`/billing/invoices/${paymentInvoice.id}/payments/manual`, { method: 'POST', body: JSON.stringify({ - amount, + amountMinor, + currency: paymentInvoice.currency, type: paymentType, - currency: paymentCurrency, - paymentMethod, + method: paymentMethod, + receivedAt: receivedAt ? new Date(receivedAt).toISOString() : undefined, + reference: reference.trim() || undefined, + note: note.trim() || undefined, + idempotencyKey: crypto.randomUUID(), }), }) - await loadBillingData() - setPaymentModalRow(null) + setPaymentInvoice(null) + await loadBilling() } catch (err: any) { setPaymentError(err.message ?? copy.failed) } finally { @@ -428,203 +370,212 @@ export default function BillingPage() { } } + const rows = invoices?.items ?? [] + const currentPage = invoices?.page ?? page + const totalPages = invoices?.totalPages ?? 1 + return (
-
+

{copy.heading}

-

{copy.subtitle}

+

{copy.subtitle}

- setSearch(event.target.value)} - placeholder={copy.search} - className="input-field w-full lg:max-w-md" - /> +
{ + event.preventDefault() + setPage(1) + setSubmittedSearch(search) + }} + > + setSearch(event.target.value)} placeholder={copy.search} className="input-field min-w-0 flex-1" /> + + +
-
- - - - + + +
+ + + + +
-
- {error ? ( -
{error}
- ) : ( -
+ {error ? ( +
+

{error}

+ +
+ ) : ( +
+
- - - - - - - - - - - + + + + + + + + {loading ? ( - - - - ) : filteredRows.length === 0 ? ( - - - - ) : filteredRows.map((row) => ( - - + ) : rows.length === 0 ? ( + + ) : rows.map((invoice) => ( + + - - - + + + - - - - - - - ))}
{copy.customer}{copy.vehicle}{copy.invoice}{copy.rentalPeriod}{copy.total}{copy.paid}{copy.balance}{copy.status}{copy.payment}{copy.paymentMethod}{copy.actions}{copy.invoiceCustomer}{copy.vehicle}{copy.period}{copy.invoiceTotal}{copy.paid}{copy.balance}{copy.deposit}{copy.actions}
{copy.loading}
{copy.noRows}
-

{row.customer.firstName} {row.customer.lastName}

-

{row.customer.email}

+
{copy.loading}
{copy.empty}
+

{invoice.invoiceNumber ?? copy.unknownInvoice}

+

{invoice.customer.firstName} {invoice.customer.lastName}

+

{invoice.customer.email}

-

{row.vehicle.make} {row.vehicle.model}

-

{row.vehicle.licensePlate}

+
+

{invoice.vehicle.make} {invoice.vehicle.model}

+

{invoice.vehicle.licensePlate}

-

{row.invoiceNumber ?? copy.unknownInvoice}

-

{copy.contract}: {row.contractNumber ?? copy.none}

+
+

{formatDate(invoice.rentalPeriod.startDate)}

+

{formatDate(invoice.rentalPeriod.endDate)}

-

{formatDate(row.startDate)}

-

{formatDate(row.endDate)}

+
{formatMoney(invoice.invoiceTotal, invoice.currency)}{formatMoney(invoice.invoicePaid, invoice.currency)}{formatMoney(invoice.invoiceBalanceDue, invoice.currency)} + +

{formatMoney(invoice.depositOutstanding, invoice.currency)} {copy.remaining}

- {formatCurrency(row.totalAmount, 'MAD')} - {row.depositAmount > 0 ? ( -

{copy.deposit}: {formatCurrency(row.depositAmount, 'MAD')}

- ) : null} -
{formatCurrency(row.paidAmount, 'MAD')}{formatCurrency(row.balanceDue, 'MAD')} - - {translateStatus(row.paymentStatus, copy.paymentStatusLabels)} - - - {row.latestPayment ? ( -
-
- - {translateStatus(row.latestPayment.status, copy.paymentEventLabels)} - - {copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider} -
-

- {row.latestPayment.type === 'DEPOSIT' ? copy.depositType : copy.charge} · {formatCurrency(row.latestPayment.amount, row.latestPayment.currency)} -

-

{formatDate(row.latestPayment.paidAt ?? row.latestPayment.createdAt)} · {copy.paymentsRecorded(row.paymentCount)}

-
- ) : ( - {copy.none} - )} -
- {row.latestPayment ? ( -
-

{translatePaymentMethod(row.latestPayment.paymentMethod)}

-

{copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider}

-
- ) : ( - {copy.none} - )} -
-
- {canAcceptPayment(row) ? ( - - ) : ( - {copy.paymentActionDisabled} - )} - - {copy.openContract} - - - {copy.openBooking} - -
+
+
- )} -
- {paymentModalRow ? ( -
{ - if (event.target === event.currentTarget && !submittingPayment) setPaymentModalRow(null) +
+ {loading ? ( +

{copy.loading}

+ ) : rows.length === 0 ? ( +

{copy.empty}

+ ) : rows.map((invoice) => ( +
+
+
+

{invoice.invoiceNumber ?? copy.unknownInvoice}

+

{invoice.customer.firstName} {invoice.customer.lastName}

+

{invoice.vehicle.make} {invoice.vehicle.model}

+
+ +
+
+ + + + +
+ +
+ ))} +
+ +
+ + {copy.pageLabel(currentPage, totalPages)} + +
+
+ )} + + {paymentInvoice ? ( +
{ + if (event.target === event.currentTarget && !submittingPayment) setPaymentInvoice(null) }}> -
+
-

{copy.paymentModalTitle}

-

{copy.paymentModalSubtitle}

+

{copy.paymentDialogTitle}

+

{copy.paymentDialogDescription}

-
-
-

{paymentModalRow.customer.firstName} {paymentModalRow.customer.lastName}

-

{paymentModalRow.vehicle.make} {paymentModalRow.vehicle.model} · {paymentModalRow.vehicle.licensePlate}

-

{copy.invoice}: {paymentModalRow.invoiceNumber ?? copy.unknownInvoice}

-

{copy.balance}: {formatCurrency(paymentModalRow.balanceDue, paymentCurrency)}

+
+

{paymentInvoice.customer.firstName} {paymentInvoice.customer.lastName}

+

{paymentInvoice.invoiceNumber ?? copy.unknownInvoice}

+

{copy.remaining}: {formatMoney(selectedRemaining, paymentInvoice.currency)}

- {paymentError ? ( -
- {paymentError} -
- ) : null} + {paymentError ?
{paymentError}
: null} -
-
- - updatePaymentType(event.target.value as BillingPaymentType)} disabled={submittingPayment} className="input-field"> + + -
- -
- - setPaymentMethod(event.target.value as ManualPaymentMethod)} disabled={submittingPayment} className="input-field"> + {(Object.keys(copy.paymentMethodLabels) as ManualPaymentMethod[]).map((method) => ( + + ))} -

{paymentType === 'DEPOSIT' ? copy.depositHelp : copy.chargeHelp}

-
- -
- - setPaymentAmount(event.target.value)} disabled={submittingPayment} /> -
+ + + + + +