From 33b6bb55f095b2fe5cbb3bf2337c54cbec51a348 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 25 May 2026 05:35:55 -0400 Subject: [PATCH] billing invoice --- apps/admin/src/app/dashboard/billing/page.tsx | 1393 +++++++++++----- .../modules/admin/admin.billing.service.ts | 1404 +++++++++++++++++ apps/api/src/modules/admin/admin.routes.ts | 104 +- apps/api/src/modules/admin/admin.schemas.ts | 82 + apps/api/src/modules/admin/admin.service.ts | 134 +- apps/api/src/services/invoicePdfService.ts | 117 +- apps/api/src/tests/integration/admin.test.ts | 190 +++ apps/api/src/tests/setup.ts | 11 + contrat_location_voiture_maroc_updated.md | 634 ++++++++ contrat_location_voiture_maroc_with_laws.md | 699 ++++++++ .../migration.sql | 493 ++++++ packages/database/prisma/schema.prisma | 818 +++++++--- 12 files changed, 5434 insertions(+), 645 deletions(-) create mode 100644 apps/api/src/modules/admin/admin.billing.service.ts create mode 100644 contrat_location_voiture_maroc_updated.md create mode 100644 contrat_location_voiture_maroc_with_laws.md create mode 100644 packages/database/prisma/migrations/20260525170000_b2b_billing_foundation/migration.sql diff --git a/apps/admin/src/app/dashboard/billing/page.tsx b/apps/admin/src/app/dashboard/billing/page.tsx index 7aec60f..bee3704 100644 --- a/apps/admin/src/app/dashboard/billing/page.tsx +++ b/apps/admin/src/app/dashboard/billing/page.tsx @@ -1,473 +1,1148 @@ 'use client' -import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { useEffect, useMemo, useState, useTransition } from 'react' import { useAdminI18n } from '@/components/I18nProvider' import { ADMIN_API_BASE } from '@/lib/api' -interface Company { +interface BillingStats { + billingAccountCount: number + openInvoiceCount: number + pastDueInvoiceCount: number + paidInvoiceCount: number + accountsReceivableTotal: number + recognizedRevenueTotal: number +} + +interface SubscriptionSummary { + id: string + plan: string + billingPeriod: string + status: string + currentPeriodEnd: string | null + cancelAtPeriodEnd: boolean +} + +interface CompanySummary { id: string name: string email: string slug: string status: string + subscription: SubscriptionSummary | null } -interface Invoice { +interface CreditBalance { + id: string + currency: string + balanceAmount: number +} + +interface InvoiceLineItem { + id?: string + type?: string + description: string + quantity: number + unitAmount: number + amount: number + currency: string + periodStart?: string | null + periodEnd?: string | null +} + +interface PaymentAttempt { + id: string + status: string + amount: number + currency: string + providerPaymentId?: string | null + failureCode?: string | null + failureMessage?: string | null + attemptedAt: string +} + +interface CreditNote { id: string amount: number currency: string + reason: string status: string - paymentProvider: string - amanpayTransactionId?: string | null - paypalCaptureId?: string | null - paidAt: string | null createdAt: string } -function buildInvoiceNumber(invoiceId: string, createdAt: string) { - const year = new Date(createdAt).getFullYear() - const short = invoiceId.slice(-6).toUpperCase() - return `INV-${year}-${short}` +interface Refund { + id: string + amount: number + currency: string + reason: string + status: string + createdAt: string } -interface Subscription { +interface TaxRecord { id: string - companyId: string - company: Company - plan: string - billingPeriod: string + taxAmount: number + taxRate?: number | null + taxExempt: boolean + taxType?: string | null +} + +interface BillingEvent { + id: string + eventType: string + source: string + createdAt: string +} + +interface BillingInvoice { + id: string + invoiceNumber: string | null + invoiceType: string status: string currency: string - currentPeriodEnd: string | null - cancelAtPeriodEnd: boolean - invoices: Invoice[] - _count: { invoices: number } + totalAmount: number + amountPaid: number + amountDue: number + invoiceDate: string | null + dueAt: string | null + paidAt: string | null createdAt: string + isSubscriptionBlocking: boolean + lineItems: InvoiceLineItem[] + paymentAttempts: PaymentAttempt[] + creditNotes: CreditNote[] + refunds: Refund[] + taxRecords: TaxRecord[] } -interface Stats { - mrr: number - activeCount: number - trialingCount: number - pastDueCount: number - cancelledCount: number +interface BillingAccountSummary { + id: string + companyId: string + legalName: string + billingEmail: string + invoiceTerms: string + netTermsDays: number + taxId?: string | null + taxExempt: boolean + dunningPaused: boolean + createdAt: string + company: CompanySummary + invoices: BillingInvoice[] + creditBalances: CreditBalance[] + openBalance: number + paidBalance: number + creditBalance: number } -const SUB_STATUS_COLORS: Record = { - ACTIVE: 'text-emerald-400 bg-emerald-900/30', - TRIALING: 'text-sky-400 bg-sky-900/30', - PAST_DUE: 'text-orange-400 bg-orange-900/30', - PAYMENT_PENDING: 'text-yellow-400 bg-yellow-900/30', - SUSPENDED: 'text-red-400 bg-red-900/30', - EXPIRED: 'text-zinc-400 bg-zinc-800', - PAUSED: 'text-purple-400 bg-purple-900/30', - CANCELLED: 'text-zinc-400 bg-zinc-800', - UNPAID: 'text-red-400 bg-red-900/30', +interface BillingAccountDetail extends BillingAccountSummary { + billingAddress?: Record | null + paymentMethods: Array<{ + id: string + type: string + label?: string | null + brand?: string | null + last4?: string | null + isDefault: boolean + isActive: boolean + }> + creditLedgerEntries: Array<{ + id: string + type: string + amount: number + currency: string + reason: string + createdAt: string + }> + events: BillingEvent[] +} + +interface PaginatedResponse { + data: T[] + total: number + page: number + pageSize: number + totalPages: number + stats?: BillingStats +} + +interface DraftLineItem { + type: string + description: string + quantity: number + unitAmount: number +} + +const ACCOUNT_STATUS_COLORS: Record = { + ACTIVE: 'text-emerald-300 bg-emerald-500/15', + TRIALING: 'text-sky-300 bg-sky-500/15', + PAYMENT_PENDING: 'text-amber-300 bg-amber-500/15', + PAST_DUE: 'text-rose-300 bg-rose-500/15', + SUSPENDED: 'text-red-300 bg-red-500/15', + CANCELLED: 'text-zinc-300 bg-zinc-700/60', + UNPAID: 'text-red-300 bg-red-500/15', } const INVOICE_STATUS_COLORS: Record = { - PAID: 'text-emerald-400 bg-emerald-900/30', - PENDING: 'text-orange-400 bg-orange-900/30', - FAILED: 'text-red-400 bg-red-900/30', - REFUNDED: 'text-zinc-400 bg-zinc-800', + DRAFT: 'text-zinc-300 bg-zinc-700/60', + OPEN: 'text-amber-300 bg-amber-500/15', + PAYMENT_PENDING: 'text-amber-300 bg-amber-500/15', + PARTIALLY_PAID: 'text-sky-300 bg-sky-500/15', + PAID: 'text-emerald-300 bg-emerald-500/15', + PAST_DUE: 'text-rose-300 bg-rose-500/15', + VOID: 'text-zinc-300 bg-zinc-700/60', + UNCOLLECTIBLE: 'text-red-200 bg-red-950/60', + REFUNDED: 'text-zinc-300 bg-zinc-700/60', + PARTIALLY_REFUNDED: 'text-zinc-300 bg-zinc-700/60', } -const PLAN_COLORS: Record = { - STARTER: 'text-zinc-300', - GROWTH: 'text-sky-400', - PRO: 'text-violet-400', +const FIELD_CLASS = + 'w-full rounded-xl border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none transition focus:border-emerald-500' + +const ACTION_PRIMARY_CLASS = + 'rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400' + +const ACTION_SECONDARY_CLASS = + 'rounded-xl border border-zinc-700 px-4 py-2 text-sm font-semibold text-zinc-200 hover:border-zinc-500' + +const draftLineItem = (): DraftLineItem => ({ + type: 'PROFESSIONAL_SERVICES', + description: '', + quantity: 1, + unitAmount: 0, +}) + +function money(amount: number, currency: string) { + return new Intl.NumberFormat('en-GB', { + style: 'currency', + currency, + minimumFractionDigits: 2, + }).format(amount / 100) } -function fmt(amount: number, currency: string) { - return `${amount.toFixed(2)} ${currency}` +function dateLabel(value: string | null | undefined) { + if (!value) return '—' + return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) } -function fmtDate(iso: string | null) { - if (!iso) return '—' - return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) +function termsLabel(invoiceTerms: string, netTermsDays: number) { + if (invoiceTerms === 'DUE_ON_RECEIPT') return 'Due on receipt' + return `Net ${netTermsDays || invoiceTerms.replace('NET_', '')}` } export default function AdminBillingPage() { const { language } = useAdminI18n() + const [isPending, startTransition] = useTransition() const copy = { en: { - title: 'Billing', - eyebrow: 'Platform', - mrr: 'MRR (MAD)', + title: 'Billing Console', + eyebrow: 'Platform Finance', + searchPlaceholder: 'Search company, billing contact, or slug', + all: 'All', active: 'Active', - trialing: 'Trialing', pastDue: 'Past Due', + suspended: 'Suspended', cancelled: 'Cancelled', + accounts: 'Accounts', + receivables: 'Receivables', + openInvoices: 'Open invoices', + paidInvoices: 'Paid invoices', + revenue: 'Revenue', + billingAccounts: 'Billing Accounts', + selectAccount: 'Select a billing account to manage invoices, credits, dunning, and payment history.', company: 'Company', plan: 'Plan', - period: 'Period', - status: 'Status', - currency: 'Currency', - nextRenewal: 'Next Renewal', - invoices: 'Invoices', - actions: 'Actions', - viewInvoices: 'Invoices', + subscription: 'Subscription', + openBalance: 'Open balance', + credit: 'Credit', + dunning: 'Dunning', + accountDetails: 'Billing Account', + invoiceFeed: 'Invoices', + draftInvoice: 'Create Draft Invoice', + saveAccount: 'Save account', + pauseDunning: 'Pause dunning', + resumeDunning: 'Resume dunning', + createDraft: 'Create draft', loading: 'Loading…', - empty: 'No subscriptions found', - filterAll: 'All', - filterActive: 'Active', - filterTrialing: 'Trialing', - filterPastDue: 'Past Due', - filterCancelled: 'Cancelled', - closeModal: 'Close', - invoicesFor: 'Invoices for', - noInvoices: 'No invoices found', - amount: 'Amount', - invoiceStatus: 'Status', - provider: 'Provider', - paidAt: 'Paid At', - date: 'Date', - invoiceNo: 'Invoice #', - download: 'PDF', - cancelAtEnd: 'Cancels at period end', + noAccounts: 'No billing accounts found', + amountDue: 'Amount due', + amountPaid: 'Amount paid', + dueDate: 'Due date', + issueDate: 'Issue date', + actions: 'Actions', + lineItems: 'Line items', + payments: 'Payment attempts', + credits: 'Credits', + refunds: 'Refunds', + timeline: 'Timeline', + finalize: 'Finalize', + pay: 'Record payment', + retry: 'Retry payment', + void: 'Void', + writeoff: 'Write off', + refund: 'Refund', + creditNote: 'Credit note', + downloadPdf: 'PDF', + invoiceType: 'Invoice type', + blocking: 'Blocking invoice', + reason: 'Reason', + billingEmail: 'Billing email', + legalName: 'Legal name', + taxId: 'Tax ID', + taxExempt: 'Tax exempt', + invoiceTerms: 'Invoice terms', + selectedInvoice: 'Selected invoice', + noInvoices: 'No invoices yet', }, fr: { - title: 'Facturation', - eyebrow: 'Plateforme', - mrr: 'MRR (MAD)', + title: 'Console de facturation', + eyebrow: 'Finance plateforme', + searchPlaceholder: 'Rechercher une entreprise, un contact facturation ou un slug', + all: 'Tous', active: 'Actifs', - trialing: 'En essai', pastDue: 'En retard', + suspended: 'Suspendus', cancelled: 'Annulés', + accounts: 'Comptes', + receivables: 'Créances', + openInvoices: 'Factures ouvertes', + paidInvoices: 'Factures payées', + revenue: 'Revenu', + billingAccounts: 'Comptes de facturation', + selectAccount: 'Sélectionnez un compte de facturation pour gérer factures, crédits, relances et paiements.', company: 'Entreprise', plan: 'Plan', - period: 'Période', - status: 'Statut', - currency: 'Devise', - nextRenewal: 'Prochain renouvellement', - invoices: 'Factures', - actions: 'Actions', - viewInvoices: 'Factures', + subscription: 'Abonnement', + openBalance: 'Solde ouvert', + credit: 'Crédit', + dunning: 'Relance', + accountDetails: 'Compte de facturation', + invoiceFeed: 'Factures', + draftInvoice: 'Créer un brouillon', + saveAccount: 'Enregistrer', + pauseDunning: 'Mettre en pause', + resumeDunning: 'Reprendre', + createDraft: 'Créer le brouillon', loading: 'Chargement…', - empty: 'Aucun abonnement trouvé', - filterAll: 'Tous', - filterActive: 'Actifs', - filterTrialing: 'Essai', - filterPastDue: 'En retard', - filterCancelled: 'Annulés', - closeModal: 'Fermer', - invoicesFor: 'Factures pour', - noInvoices: 'Aucune facture trouvée', - amount: 'Montant', - invoiceStatus: 'Statut', - provider: 'Fournisseur', - paidAt: 'Payé le', - date: 'Date', - invoiceNo: 'Facture n°', - download: 'PDF', - cancelAtEnd: 'Sera annulé en fin de période', + noAccounts: 'Aucun compte trouvé', + amountDue: 'Montant dû', + amountPaid: 'Montant payé', + dueDate: 'Échéance', + issueDate: 'Date émission', + actions: 'Actions', + lineItems: 'Lignes', + payments: 'Tentatives de paiement', + credits: 'Crédits', + refunds: 'Remboursements', + timeline: 'Historique', + finalize: 'Finaliser', + pay: 'Enregistrer paiement', + retry: 'Relancer paiement', + void: 'Annuler', + writeoff: 'Passer en perte', + refund: 'Rembourser', + creditNote: 'Avoir', + downloadPdf: 'PDF', + invoiceType: 'Type de facture', + blocking: 'Facture bloquante', + reason: 'Raison', + billingEmail: 'Email de facturation', + legalName: 'Raison sociale', + taxId: 'Identifiant fiscal', + taxExempt: 'Exonéré de taxe', + invoiceTerms: 'Conditions', + selectedInvoice: 'Facture sélectionnée', + noInvoices: 'Aucune facture', }, ar: { - title: 'الفوترة', - eyebrow: 'المنصة', - mrr: 'الإيراد الشهري (MAD)', + title: 'لوحة الفوترة', + eyebrow: 'مالية المنصة', + searchPlaceholder: 'ابحث عن الشركة أو جهة الفوترة أو الرابط', + all: 'الكل', active: 'نشط', - trialing: 'تجريبي', pastDue: 'متأخر', + suspended: 'معلق', cancelled: 'ملغى', + accounts: 'الحسابات', + receivables: 'الذمم', + openInvoices: 'الفواتير المفتوحة', + paidInvoices: 'الفواتير المدفوعة', + revenue: 'الإيراد', + billingAccounts: 'حسابات الفوترة', + selectAccount: 'اختر حساب فوترة لإدارة الفواتير والأرصدة ومحاولات الدفع.', company: 'الشركة', plan: 'الخطة', - period: 'الفترة', - status: 'الحالة', - currency: 'العملة', - nextRenewal: 'التجديد القادم', - invoices: 'الفواتير', - actions: 'الإجراءات', - viewInvoices: 'الفواتير', + subscription: 'الاشتراك', + openBalance: 'الرصيد المفتوح', + credit: 'الرصيد', + dunning: 'المتابعة', + accountDetails: 'حساب الفوترة', + invoiceFeed: 'الفواتير', + draftInvoice: 'إنشاء مسودة فاتورة', + saveAccount: 'حفظ', + pauseDunning: 'إيقاف المتابعة', + resumeDunning: 'استئناف المتابعة', + createDraft: 'إنشاء المسودة', loading: 'جارٍ التحميل…', - empty: 'لا توجد اشتراكات', - filterAll: 'الكل', - filterActive: 'نشط', - filterTrialing: 'تجريبي', - filterPastDue: 'متأخر', - filterCancelled: 'ملغى', - closeModal: 'إغلاق', - invoicesFor: 'فواتير', + noAccounts: 'لا توجد حسابات فوترة', + amountDue: 'المبلغ المستحق', + amountPaid: 'المبلغ المدفوع', + dueDate: 'تاريخ الاستحقاق', + issueDate: 'تاريخ الإصدار', + actions: 'الإجراءات', + lineItems: 'البنود', + payments: 'محاولات الدفع', + credits: 'الأرصدة', + refunds: 'الاستردادات', + timeline: 'السجل', + finalize: 'اعتماد', + pay: 'تسجيل الدفع', + retry: 'إعادة المحاولة', + void: 'إلغاء', + writeoff: 'شطب', + refund: 'استرداد', + creditNote: 'إشعار دائن', + downloadPdf: 'PDF', + invoiceType: 'نوع الفاتورة', + blocking: 'فاتورة حاجزة', + reason: 'السبب', + billingEmail: 'بريد الفوترة', + legalName: 'الاسم القانوني', + taxId: 'الرقم الضريبي', + taxExempt: 'معفى من الضريبة', + invoiceTerms: 'شروط الفاتورة', + selectedInvoice: 'الفاتورة المحددة', noInvoices: 'لا توجد فواتير', - amount: 'المبلغ', - invoiceStatus: 'الحالة', - provider: 'المزود', - paidAt: 'تاريخ الدفع', - date: 'التاريخ', - invoiceNo: 'رقم الفاتورة', - download: 'PDF', - cancelAtEnd: 'سيُلغى في نهاية الفترة', }, }[language] - const [subscriptions, setSubscriptions] = useState([]) - const [stats, setStats] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [accounts, setAccounts] = useState([]) + const [stats, setStats] = useState(null) + const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('') + const [loadingList, setLoadingList] = useState(true) + const [listError, setListError] = useState(null) - const [invoiceModal, setInvoiceModal] = useState<{ companyId: string; companyName: string } | null>(null) - const [invoices, setInvoices] = useState([]) - const [invoicesLoading, setInvoicesLoading] = useState(false) - const [invoicesError, setInvoicesError] = useState(null) + const [selectedCompanyId, setSelectedCompanyId] = useState(null) + const [detail, setDetail] = useState(null) + const [loadingDetail, setLoadingDetail] = useState(false) + const [detailError, setDetailError] = useState(null) - async function fetchBilling(status?: string) { - setLoading(true) - setError(null) + const [accountForm, setAccountForm] = useState({ + legalName: '', + billingEmail: '', + taxId: '', + taxExempt: false, + invoiceTerms: 'DUE_ON_RECEIPT', + netTermsDays: 0, + }) + const [invoiceDraft, setInvoiceDraft] = useState({ + invoiceType: 'MANUAL', + isSubscriptionBlocking: false, + adminReason: '', + lineItems: [draftLineItem()], + }) + const [selectedInvoiceId, setSelectedInvoiceId] = useState(null) + const [payAmount, setPayAmount] = useState('') + const [creditAmount, setCreditAmount] = useState('') + const [creditReason, setCreditReason] = useState('') + const [refundAmount, setRefundAmount] = useState('') + const [refundReason, setRefundReason] = useState('') + const [voidReason, setVoidReason] = useState('') + const [writeoffReason, setWriteoffReason] = useState('') + const [actionError, setActionError] = useState(null) + const [actionMessage, setActionMessage] = useState(null) + + const filters = [ + { key: '', label: copy.all }, + { key: 'ACTIVE', label: copy.active }, + { key: 'PAST_DUE', label: copy.pastDue }, + { key: 'SUSPENDED', label: copy.suspended }, + { key: 'CANCELLED', label: copy.cancelled }, + ] + + const selectedInvoice = useMemo( + () => detail?.invoices.find((invoice) => invoice.id === selectedInvoiceId) ?? detail?.invoices[0] ?? null, + [detail, selectedInvoiceId], + ) + + async function api(path: string, init?: RequestInit): Promise { const token = localStorage.getItem('admin_token') + const res = await fetch(`${ADMIN_API_BASE}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(init?.headers ?? {}), + }, + cache: 'no-store', + }) + if (res.headers.get('content-type')?.includes('application/pdf')) { + return await (res.blob() as unknown as Promise) + } + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? `Request failed (${res.status})`) + return json.data as T + } + + async function fetchAccounts(q?: string, status?: string) { + setLoadingList(true) + setListError(null) try { - const params = new URLSearchParams({ pageSize: '50' }) + const params = new URLSearchParams({ pageSize: '100' }) + if (q) params.set('q', q) if (status) params.set('status', status) - const res = await fetch(`${ADMIN_API_BASE}/admin/billing?${params}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - cache: 'no-store', - }) - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch') - setSubscriptions(json.data?.data ?? []) - setStats(json.data?.stats ?? null) - } catch (err: any) { - setError(err.message) + const data = await api>(`/admin/billing?${params.toString()}`) + setAccounts(data.data) + setStats(data.stats ?? null) + setSelectedCompanyId((current) => current ?? data.data[0]?.company.id ?? null) + } catch (error: any) { + setListError(error.message) } finally { - setLoading(false) + setLoadingList(false) } } - useEffect(() => { fetchBilling(statusFilter || undefined) }, [statusFilter]) - - async function downloadInvoicePdf(invoiceId: string, createdAt: string) { - const token = localStorage.getItem('admin_token') + async function fetchDetail(companyId: string) { + setLoadingDetail(true) + setDetailError(null) try { - const res = await fetch(`${ADMIN_API_BASE}/admin/billing/invoices/${invoiceId}/pdf`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, + const data = await api(`/admin/billing/${companyId}`) + setDetail(data) + setAccountForm({ + legalName: data.legalName, + billingEmail: data.billingEmail, + taxId: data.taxId ?? '', + taxExempt: data.taxExempt, + invoiceTerms: data.invoiceTerms, + netTermsDays: data.netTermsDays, }) - if (!res.ok) throw new Error('Failed to generate PDF') - const blob = await res.blob() + setSelectedInvoiceId(data.invoices[0]?.id ?? null) + } catch (error: any) { + setDetailError(error.message) + } finally { + setLoadingDetail(false) + } + } + + async function refreshSelectedAccount() { + await fetchAccounts(search || undefined, statusFilter || undefined) + if (selectedCompanyId) await fetchDetail(selectedCompanyId) + } + + useEffect(() => { + const id = setTimeout(() => { + fetchAccounts(search || undefined, statusFilter || undefined) + }, 200) + return () => clearTimeout(id) + }, [search, statusFilter]) + + useEffect(() => { + if (!selectedCompanyId) return + fetchDetail(selectedCompanyId) + }, [selectedCompanyId]) + + function setDraftLine(index: number, key: keyof DraftLineItem, value: string | number) { + setInvoiceDraft((current) => ({ + ...current, + lineItems: current.lineItems.map((item, itemIndex) => + itemIndex === index ? { ...item, [key]: value } : item, + ), + })) + } + + async function saveBillingAccount() { + if (!detail) return + setActionError(null) + setActionMessage(null) + try { + await api(`/admin/billing/accounts/${detail.id}`, { + method: 'PATCH', + body: JSON.stringify({ + legalName: accountForm.legalName, + billingEmail: accountForm.billingEmail, + taxId: accountForm.taxId || null, + taxExempt: accountForm.taxExempt, + invoiceTerms: accountForm.invoiceTerms, + netTermsDays: accountForm.netTermsDays, + }), + }) + setActionMessage('Billing account updated.') + await refreshSelectedAccount() + } catch (error: any) { + setActionError(error.message) + } + } + + async function toggleDunning(paused: boolean) { + if (!detail) return + setActionError(null) + setActionMessage(null) + try { + await api(`/admin/billing/accounts/${detail.id}/${paused ? 'pause-dunning' : 'resume-dunning'}`, { + method: 'POST', + body: JSON.stringify({}), + }) + setActionMessage(paused ? 'Dunning paused.' : 'Dunning resumed.') + await refreshSelectedAccount() + } catch (error: any) { + setActionError(error.message) + } + } + + async function createDraftInvoice() { + if (!detail) return + setActionError(null) + setActionMessage(null) + try { + await api(`/admin/billing/accounts/${detail.id}/invoices`, { + method: 'POST', + body: JSON.stringify({ + invoiceType: invoiceDraft.invoiceType, + isSubscriptionBlocking: invoiceDraft.isSubscriptionBlocking, + adminReason: invoiceDraft.adminReason || null, + lineItems: invoiceDraft.lineItems.map((item) => ({ + ...item, + unitAmount: Number(item.unitAmount), + quantity: Number(item.quantity), + })), + }), + }) + setInvoiceDraft({ + invoiceType: 'MANUAL', + isSubscriptionBlocking: false, + adminReason: '', + lineItems: [draftLineItem()], + }) + setActionMessage('Draft invoice created.') + await refreshSelectedAccount() + } catch (error: any) { + setActionError(error.message) + } + } + + async function runInvoiceAction(path: string, body?: Record) { + if (!selectedInvoice) return + setActionError(null) + setActionMessage(null) + try { + await api(`/admin/billing/invoices/${selectedInvoice.id}/${path}`, { + method: 'POST', + body: JSON.stringify(body ?? {}), + }) + setActionMessage('Invoice action completed.') + await refreshSelectedAccount() + } catch (error: any) { + setActionError(error.message) + } + } + + async function downloadPdf() { + if (!selectedInvoice) return + setActionError(null) + try { + const blob = await api(`/admin/billing/invoices/${selectedInvoice.id}/pdf`) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `${buildInvoiceNumber(invoiceId, createdAt)}.pdf` + a.download = `${selectedInvoice.invoiceNumber ?? selectedInvoice.id}.pdf` a.click() URL.revokeObjectURL(url) - } catch { - // silent — no toast system available here + } catch (error: any) { + setActionError(error.message) } } - async function openInvoices(companyId: string, companyName: string) { - setInvoiceModal({ companyId, companyName }) - setInvoices([]) - setInvoicesError(null) - setInvoicesLoading(true) - const token = localStorage.getItem('admin_token') - try { - const res = await fetch(`${ADMIN_API_BASE}/admin/billing/${companyId}/invoices?pageSize=50`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }) - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? `Error ${res.status}`) - setInvoices(json.data?.data ?? []) - } catch (err: any) { - setInvoicesError(err.message) - } finally { - setInvoicesLoading(false) - } - } - - const filters = [ - { key: '', label: copy.filterAll }, - { key: 'ACTIVE', label: copy.filterActive }, - { key: 'TRIALING', label: copy.filterTrialing }, - { key: 'PAST_DUE', label: copy.filterPastDue }, - { key: 'CANCELLED', label: copy.filterCancelled }, - ] - return ( -
-
-

{copy.eyebrow}

-

{copy.title}

+
+
+
+

{copy.eyebrow}

+

{copy.title}

+
+
+ setSearch(event.target.value)} + placeholder={copy.searchPlaceholder} + className="w-full rounded-2xl border border-zinc-800 bg-zinc-950 px-4 py-3 text-sm text-zinc-100 outline-none transition focus:border-emerald-500" + /> +
{stats && ( -
-
-

{copy.mrr}

-

{stats.mrr.toFixed(0)}

-
-
-

{copy.active}

-

{stats.activeCount}

-
-
-

{copy.trialing}

-

{stats.trialingCount}

-
-
-

{copy.pastDue}

-

{stats.pastDueCount}

-
-
-

{copy.cancelled}

-

{stats.cancelledCount}

-
+
+ + + + +
)} -
- {filters.map((f) => ( +
+ {filters.map((filter) => ( ))}
- {error &&
{error}
} - -
-
- - - - - - - - - - - - - - {loading ? ( - - ) : subscriptions.length === 0 ? ( - - ) : subscriptions.map((sub) => ( - - - - - - - - - - - ))} - -
{copy.company}{copy.plan}{copy.period}{copy.status}{copy.currency}{copy.nextRenewal}{copy.invoices} -
{copy.loading}
{copy.empty}
-

{sub.company.name}

-

{sub.company.email}

-
- {sub.plan} - {sub.billingPeriod} -
- - {sub.status} - - {sub.cancelAtPeriodEnd && ( - {copy.cancelAtEnd} - )} -
-
{sub.currency}{fmtDate(sub.currentPeriodEnd)}{sub._count.invoices} +
+
+
+

{copy.billingAccounts}

+
+
+ {loadingList ? ( +
{copy.loading}
+ ) : listError ? ( +
{listError}
+ ) : accounts.length === 0 ? ( +
{copy.noAccounts}
+ ) : ( +
+ {accounts.map((account) => { + const isSelected = selectedCompanyId === account.company.id + const subscription = account.company.subscription + return ( -
-
+ ) + })} +
+ )} +
+ + +
+ {!selectedCompanyId ? ( +
{copy.selectAccount}
+ ) : loadingDetail && !detail ? ( +
{copy.loading}
+ ) : detailError ? ( +
{detailError}
+ ) : detail ? ( + <> +
+
+
+

{copy.accountDetails}

+

{detail.company.name}

+
+ + {detail.company.subscription?.status ?? 'NO_SUBSCRIPTION'} + + + {detail.company.subscription?.plan ?? 'No plan'} / {detail.company.subscription?.billingPeriod ?? '—'} + + + {termsLabel(detail.invoiceTerms, detail.netTermsDays)} + +
+
+
+ + + + +
+
+ +
+ + setAccountForm((current) => ({ ...current, legalName: event.target.value }))} + className={FIELD_CLASS} + /> + + + setAccountForm((current) => ({ ...current, billingEmail: event.target.value }))} + className={FIELD_CLASS} + /> + + + setAccountForm((current) => ({ ...current, taxId: event.target.value }))} + className={FIELD_CLASS} + /> + + + + +
+ +
+ + + +
+
+ +
+
+

{copy.invoiceFeed}

+ {isPending || loadingDetail ? {copy.loading} : null} +
+ + {detail.invoices.length === 0 ? ( +
{copy.noInvoices}
+ ) : ( +
+
+ + + + + + + + + + + {detail.invoices.map((invoice) => ( + setSelectedInvoiceId(invoice.id)} + > + + + + + + ))} + +
Invoice{copy.amountDue}{copy.dueDate}{copy.actions}
+
+

{invoice.invoiceNumber ?? invoice.invoiceType}

+

{dateLabel(invoice.invoiceDate ?? invoice.createdAt)}

+
+
{money(invoice.amountDue, invoice.currency)}{dateLabel(invoice.dueAt)} + + {invoice.status} + +
+
+ + {selectedInvoice && ( +
+
+

{copy.selectedInvoice}

+
+
+

{selectedInvoice.invoiceNumber ?? selectedInvoice.invoiceType}

+

+ {copy.issueDate}: {dateLabel(selectedInvoice.invoiceDate ?? selectedInvoice.createdAt)} +

+
+ + {selectedInvoice.status} + +
+
+ +
+ + +
+ +
+

{copy.lineItems}

+
+ {selectedInvoice.lineItems.map((item, index) => ( +
+
+
+

{item.description}

+

+ {item.quantity} × {money(item.unitAmount, item.currency)} +

+
+

{money(item.amount, item.currency)}

+
+
+ ))} +
+
+ +
+ {selectedInvoice.status === 'DRAFT' ? ( + + ) : null} + {['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(selectedInvoice.status) ? ( + <> +
+ setPayAmount(event.target.value)} + placeholder="Amount in cents" + className={FIELD_CLASS} + /> + +
+ +
+ setVoidReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> + +
+
+ setWriteoffReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> + +
+
+ setCreditAmount(event.target.value)} placeholder="Cents" className={FIELD_CLASS} /> + setCreditReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> + +
+ + ) : null} + {['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(selectedInvoice.status) ? ( +
+ setRefundAmount(event.target.value)} placeholder="Cents" className={FIELD_CLASS} /> + setRefundReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> + +
+ ) : null} + {selectedInvoice.invoiceNumber ? ( + + ) : null} +
+ + ({ + id: attempt.id, + primary: `${attempt.status} · ${money(attempt.amount, attempt.currency)}`, + secondary: `${dateLabel(attempt.attemptedAt)}${attempt.failureMessage ? ` · ${attempt.failureMessage}` : ''}`, + }))} /> + ({ + id: note.id, + primary: `${money(note.amount, note.currency)} · ${note.status}`, + secondary: note.reason, + }))} /> + ({ + id: refund.id, + primary: `${money(refund.amount, refund.currency)} · ${refund.status}`, + secondary: refund.reason, + }))} /> +
+ )} +
+ )} +
+ +
+

{copy.draftInvoice}

+
+ + + + + setInvoiceDraft((current) => ({ ...current, adminReason: event.target.value }))} + className={FIELD_CLASS} + /> + + +
+ +
+ {invoiceDraft.lineItems.map((item, index) => ( +
+ + setDraftLine(index, 'description', event.target.value)} placeholder="Description" className={FIELD_CLASS} /> + setDraftLine(index, 'quantity', Number(event.target.value))} className={FIELD_CLASS} /> + setDraftLine(index, 'unitAmount', Number(event.target.value))} placeholder="Unit amount (cents)" className={FIELD_CLASS} /> + +
+ ))} +
+ +
+ + +
+
+ +
+
+ ({ + id: event.id, + primary: event.eventType, + secondary: `${event.source} · ${dateLabel(event.createdAt)}`, + }))} /> + ({ + id: entry.id, + primary: `${money(entry.amount, entry.currency)} · ${entry.type}`, + secondary: entry.reason, + }))} /> +
+
+ + ) : null} +
- {invoiceModal && ( -
-
-
-
-

{copy.invoicesFor}

-

{invoiceModal.companyName}

-
- + {actionError ?
{actionError}
: null} + {actionMessage ?
{actionMessage}
: null} +
+ ) +} + +function StatCard({ label, value, tone }: { label: string; value: string; tone: string }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ) +} + +function DetailList({ + title, + items, +}: { + title: string + items: Array<{ id: string; primary: string; secondary: string }> +}) { + return ( +
+

{title}

+ {items.length === 0 ? ( +
+ ) : ( +
+ {items.map((item) => ( +
+

{item.primary}

+

{item.secondary}

-
- {invoicesLoading ? ( -
{copy.loading}
- ) : invoicesError ? ( -
{invoicesError}
- ) : invoices.length === 0 ? ( -
{copy.noInvoices}
- ) : ( - - - - - - - - - - - - - {invoices.map((inv) => ( - - - - - - - - - - ))} - -
{copy.invoiceNo}{copy.date}{copy.amount}{copy.invoiceStatus}{copy.provider}{copy.paidAt} -
{buildInvoiceNumber(inv.id, inv.createdAt)}{fmtDate(inv.createdAt)}{fmt(inv.amount, inv.currency)} - - {inv.status} - - {inv.paymentProvider}{fmtDate(inv.paidAt)} - -
- )} -
-
- -
-
+ ))}
)}
diff --git a/apps/api/src/modules/admin/admin.billing.service.ts b/apps/api/src/modules/admin/admin.billing.service.ts new file mode 100644 index 0000000..c291f31 --- /dev/null +++ b/apps/api/src/modules/admin/admin.billing.service.ts @@ -0,0 +1,1404 @@ +import { prisma } from '../../lib/prisma' +import { NotFoundError, ValidationError } from '../../http/errors' +import { generateInvoicePdf } from '../../services/invoicePdfService' + +const BLOCKING_INVOICE_TYPES = new Set([ + 'SUBSCRIPTION_INITIAL', + 'SUBSCRIPTION_RENEWAL', + 'TRIAL_CONVERSION', + 'SUBSCRIPTION_UPGRADE', +]) + +const EDITABLE_BILLING_STATUSES = new Set(['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID']) + +function toSequenceNumber(value: unknown) { + if (typeof value === 'bigint') return Number(value) + if (typeof value === 'number') return value + if (typeof value === 'string') return Number(value) + throw new Error('Invalid invoice sequence value') +} + +function buildSequentialInvoiceNumber(sequence: number, date: Date) { + return `INV-${date.getFullYear()}-${String(sequence).padStart(6, '0')}` +} + +function isBlockingInvoiceType(invoiceType: string) { + return BLOCKING_INVOICE_TYPES.has(invoiceType) +} + +function getInvoiceTermsDays(invoiceTerms: string, explicitNetTermsDays: number) { + if (explicitNetTermsDays > 0) return explicitNetTermsDays + const map: Record = { + DUE_ON_RECEIPT: 0, + NET_7: 7, + NET_15: 15, + NET_30: 30, + NET_45: 45, + NET_60: 60, + } + return map[invoiceTerms] ?? 0 +} + +function addDays(date: Date, days: number) { + const copy = new Date(date) + copy.setDate(copy.getDate() + days) + return copy +} + +function normalizeAddress(address: any) { + if (!address) return null + if (typeof address === 'string') return { formatted: address } + return address +} + +function fmtLegacyInvoiceType(_invoice: any) { + return 'SUBSCRIPTION_INITIAL' +} + +function mapLegacyInvoiceStatus(status: string) { + switch (status) { + case 'PAID': + return 'PAID' + case 'FAILED': + return 'PAST_DUE' + case 'REFUNDED': + return 'REFUNDED' + case 'VOIDED': + return 'VOID' + default: + return 'OPEN' + } +} + +function calculateLineAmounts(items: Array<{ type: string; amount: number }>) { + const subtotalAmount = items + .filter((item) => !['DISCOUNT', 'CREDIT', 'TAX'].includes(item.type)) + .reduce((sum, item) => sum + item.amount, 0) + const discountAmount = Math.abs( + items.filter((item) => item.type === 'DISCOUNT').reduce((sum, item) => sum + item.amount, 0), + ) + const creditAmount = Math.abs( + items.filter((item) => item.type === 'CREDIT').reduce((sum, item) => sum + item.amount, 0), + ) + const taxAmount = items.filter((item) => item.type === 'TAX').reduce((sum, item) => sum + item.amount, 0) + const totalAmount = Math.max(subtotalAmount - discountAmount - creditAmount + taxAmount, 0) + return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount } +} + +async function createBillingEvent(tx: any, data: { + billingAccountId?: string | null + invoiceId?: string | null + subscriptionId?: string | null + companyId?: string | null + eventType: string + source: string + payload?: Record +}) { + await tx.billingEvent.create({ + data: { + billingAccountId: data.billingAccountId ?? null, + invoiceId: data.invoiceId ?? null, + subscriptionId: data.subscriptionId ?? null, + companyId: data.companyId ?? null, + eventType: data.eventType, + source: data.source, + payload: data.payload ?? {}, + occurredAt: new Date(), + }, + }) +} + +async function createAuditLog(data: { + adminUserId: string + action: string + resource: string + resourceId?: string + companyId?: string + before?: any + after?: any + note?: string + ipAddress?: string +}) { + await prisma.auditLog.create({ data: data as any }) +} + +async function ensurePrimaryBillingAccount(companyId: string, tx: any = prisma) { + const existing = await tx.billingAccount.findFirst({ + where: { companyId, isPrimary: true }, + include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true }, + }) + if (existing) return existing + + const company = await tx.company.findUniqueOrThrow({ + where: { id: companyId }, + include: { accountingSettings: true, contractSettings: true, subscription: true }, + }) + + const created = await tx.billingAccount.create({ + data: { + companyId, + isPrimary: true, + legalName: company.name, + billingEmail: company.email, + billingAddress: normalizeAddress(company.address), + defaultCurrency: company.accountingSettings?.currency ?? 'MAD', + invoiceTerms: 'DUE_ON_RECEIPT', + netTermsDays: 0, + metadata: {}, + }, + include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true }, + }) + + await tx.billingCreditBalance.create({ + data: { + billingAccountId: created.id, + currency: created.defaultCurrency, + balanceAmount: 0, + }, + }) + + await createBillingEvent(tx, { + billingAccountId: created.id, + companyId, + eventType: 'billing_account.created', + source: 'system', + payload: { companyId }, + }) + + return tx.billingAccount.findUniqueOrThrow({ + where: { id: created.id }, + include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true }, + }) +} + +async function getNextInvoiceSequence(tx: any) { + const latest = await tx.billingInvoice.findFirst({ + where: { invoiceSequence: { not: null } }, + select: { invoiceSequence: true }, + orderBy: { invoiceSequence: 'desc' }, + }) + return (latest?.invoiceSequence ?? 0) + 1 +} + +async function maybeRestoreSubscription(tx: any, invoice: any) { + if (!invoice.subscriptionId || !invoice.isSubscriptionBlocking) return + const subscription = await tx.subscription.findUnique({ where: { id: invoice.subscriptionId } }) + if (!subscription) return + if (['PAYMENT_PENDING', 'PAST_DUE', 'SUSPENDED', 'TRIALING', 'UNPAID'].includes(subscription.status)) { + await tx.subscription.update({ + where: { id: subscription.id }, + data: { + status: 'ACTIVE', + paymentPendingSince: null, + paymentDueAt: null, + pastDueSince: null, + suspendedAt: null, + retryCount: 0, + }, + }) + } +} + +async function maybeCancelSubscriptionForWriteoff(tx: any, invoice: any) { + if (!invoice.subscriptionId || !invoice.isSubscriptionBlocking) return + const subscription = await tx.subscription.findUnique({ where: { id: invoice.subscriptionId } }) + if (!subscription || ['CANCELLED', 'EXPIRED'].includes(subscription.status)) return + await tx.subscription.update({ + where: { id: subscription.id }, + data: { + status: 'CANCELLED', + cancelledAt: new Date(), + endedAt: new Date(), + }, + }) +} + +async function syncOneLegacyInvoice(tx: any, legacy: any) { + const account = await ensurePrimaryBillingAccount(legacy.companyId, tx) + const status = mapLegacyInvoiceStatus(legacy.status) + const sequence = await getNextInvoiceSequence(tx) + const invoiceDate = legacy.createdAt ?? new Date() + const invoiceNumber = buildSequentialInvoiceNumber(sequence, invoiceDate) + const amountPaid = ['PAID', 'REFUNDED'].includes(status) ? legacy.amount : 0 + const amountDue = ['OPEN', 'PAST_DUE', 'PAYMENT_PENDING'].includes(status) ? legacy.amount : 0 + + const billingInvoice = await tx.billingInvoice.create({ + data: { + billingAccountId: account.id, + companyId: legacy.companyId, + subscriptionId: legacy.subscriptionId, + invoiceNumber, + invoiceSequence: sequence, + invoiceType: fmtLegacyInvoiceType(legacy), + status, + currency: legacy.currency, + subtotalAmount: legacy.amount, + totalAmount: legacy.amount, + amountPaid, + amountDue, + invoiceDate, + dueAt: legacy.dueAt ?? legacy.createdAt ?? new Date(), + finalizedAt: legacy.createdAt ?? new Date(), + paidAt: legacy.paidAt, + voidedAt: legacy.voidedAt, + billingName: account.legalName, + billingEmail: account.billingEmail, + billingAddress: account.billingAddress, + paymentProvider: legacy.paymentProvider, + isSubscriptionBlocking: true, + metadata: { legacySubscriptionInvoiceId: legacy.id }, + }, + }) + + await tx.billingInvoiceLineItem.create({ + data: { + invoiceId: billingInvoice.id, + subscriptionId: legacy.subscriptionId, + plan: legacy.subscription?.plan ?? null, + type: 'SUBSCRIPTION_FEE', + description: `${legacy.subscription?.plan ?? 'Subscription'} legacy charge`, + quantity: 1, + unitAmount: legacy.amount, + amount: legacy.amount, + currency: legacy.currency, + periodStart: legacy.subscription?.currentPeriodStart ?? null, + periodEnd: legacy.subscription?.currentPeriodEnd ?? null, + metadata: { legacySubscriptionInvoiceId: legacy.id }, + }, + }) + + for (const attempt of legacy.attempts ?? []) { + await tx.billingPaymentAttempt.create({ + data: { + invoiceId: billingInvoice.id, + billingAccountId: account.id, + providerPaymentId: attempt.providerPaymentId, + status: attempt.status === 'succeeded' ? 'SUCCEEDED' : attempt.status === 'failed' ? 'FAILED' : 'PENDING', + amount: legacy.amount, + currency: legacy.currency, + failureCode: attempt.failureCode, + failureMessage: attempt.failureMessage, + attemptedAt: attempt.attemptedAt, + metadata: { legacyPaymentAttemptId: attempt.id }, + }, + }) + } + + await tx.subscriptionInvoice.update({ + where: { id: legacy.id }, + data: { billingInvoiceId: billingInvoice.id }, + }) + + await createBillingEvent(tx, { + billingAccountId: account.id, + invoiceId: billingInvoice.id, + subscriptionId: legacy.subscriptionId, + companyId: legacy.companyId, + eventType: 'invoice.created', + source: 'legacy_sync', + payload: { legacySubscriptionInvoiceId: legacy.id, status }, + }) +} + +async function syncLegacySubscriptionInvoices(companyId?: string) { + const unsynced = await prisma.subscriptionInvoice.findMany({ + where: { + billingInvoiceId: null, + ...(companyId ? { companyId } : {}), + }, + include: { + subscription: true, + attempts: true, + }, + orderBy: { createdAt: 'asc' }, + take: companyId ? 100 : 250, + }) + + for (const legacy of unsynced) { + await prisma.$transaction(async (tx) => { + const latest = await tx.subscriptionInvoice.findUnique({ + where: { id: legacy.id }, + include: { subscription: true, attempts: true }, + }) + if (!latest || latest.billingInvoiceId) return + await syncOneLegacyInvoice(tx, latest) + }) + } +} + +function buildBillingAccountWhere(query: { q?: string; status?: string; plan?: string }) { + const where: any = {} + if (query.q) { + where.OR = [ + { legalName: { contains: query.q, mode: 'insensitive' } }, + { billingEmail: { contains: query.q, mode: 'insensitive' } }, + { company: { name: { contains: query.q, mode: 'insensitive' } } }, + { company: { email: { contains: query.q, mode: 'insensitive' } } }, + { company: { slug: { contains: query.q, mode: 'insensitive' } } }, + ] + } + if (query.status || query.plan) { + where.company = { + subscription: { + ...(query.status ? { status: query.status } : {}), + ...(query.plan ? { plan: query.plan } : {}), + }, + } + } + return where +} + +export async function listBillingAccounts(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) { + await syncLegacySubscriptionInvoices() + + const matchingCompanies = await prisma.company.findMany({ + where: { + ...(query.q + ? { + OR: [ + { name: { contains: query.q, mode: 'insensitive' } }, + { email: { contains: query.q, mode: 'insensitive' } }, + { slug: { contains: query.q, mode: 'insensitive' } }, + ], + } + : {}), + ...(query.status || query.plan + ? { + subscription: { + ...(query.status ? { status: query.status as any } : {}), + ...(query.plan ? { plan: query.plan as any } : {}), + }, + } + : {}), + }, + select: { id: true }, + take: 250, + }) + + for (const company of matchingCompanies) { + await ensurePrimaryBillingAccount(company.id) + } + + const where = buildBillingAccountWhere(query) + const [accounts, total, invoiceAgg] = await Promise.all([ + prisma.billingAccount.findMany({ + where, + include: { + company: { + select: { + id: true, + name: true, + email: true, + slug: true, + status: true, + subscription: { + select: { + id: true, + plan: true, + billingPeriod: true, + status: true, + currentPeriodEnd: true, + cancelAtPeriodEnd: true, + }, + }, + }, + }, + creditBalances: true, + invoices: { + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, + invoiceNumber: true, + invoiceType: true, + status: true, + totalAmount: true, + amountDue: true, + amountPaid: true, + currency: true, + dueAt: true, + paidAt: true, + createdAt: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + }), + prisma.billingAccount.count({ where }), + prisma.billingInvoice.groupBy({ + by: ['status'], + _sum: { amountDue: true, totalAmount: true }, + _count: { _all: true }, + }), + ]) + + const stats = { + billingAccountCount: total, + openInvoiceCount: invoiceAgg + .filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status)) + .reduce((sum, item) => sum + item._count._all, 0), + pastDueInvoiceCount: invoiceAgg + .filter((item) => item.status === 'PAST_DUE') + .reduce((sum, item) => sum + item._count._all, 0), + paidInvoiceCount: invoiceAgg + .filter((item) => item.status === 'PAID') + .reduce((sum, item) => sum + item._count._all, 0), + accountsReceivableTotal: invoiceAgg + .filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status)) + .reduce((sum, item) => sum + (item._sum.amountDue ?? 0), 0), + recognizedRevenueTotal: invoiceAgg + .filter((item) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(item.status)) + .reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0), + } + + const data = accounts.map((account) => { + const openBalance = account.invoices + .filter((invoice) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status)) + .reduce((sum, invoice) => sum + invoice.amountDue, 0) + const paidBalance = account.invoices + .filter((invoice) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status)) + .reduce((sum, invoice) => sum + invoice.amountPaid, 0) + return { + ...account, + openBalance, + paidBalance, + creditBalance: account.creditBalances.reduce((sum, item) => sum + item.balanceAmount, 0), + } + }) + + return { data, total, stats } +} + +export async function getBillingAccountDetail(companyId: string) { + await syncLegacySubscriptionInvoices(companyId) + await ensurePrimaryBillingAccount(companyId) + + const account = await prisma.billingAccount.findFirst({ + where: { companyId, isPrimary: true }, + include: { + company: { + include: { + subscription: true, + contractSettings: true, + accountingSettings: true, + }, + }, + paymentMethods: true, + creditBalances: true, + creditLedgerEntries: { + orderBy: { createdAt: 'desc' }, + take: 50, + }, + events: { + orderBy: { createdAt: 'desc' }, + take: 50, + }, + invoices: { + orderBy: { createdAt: 'desc' }, + take: 50, + include: { + lineItems: { orderBy: { createdAt: 'asc' } }, + paymentIntents: { orderBy: { createdAt: 'desc' } }, + paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, + taxRecords: true, + creditNotes: { orderBy: { createdAt: 'desc' } }, + refunds: { orderBy: { createdAt: 'desc' } }, + }, + }, + }, + }) + + if (!account) throw new NotFoundError('Billing account not found') + return account +} + +export async function updateBillingAccount( + billingAccountId: string, + data: { + legalName?: string + billingEmail?: string + billingAddress?: any + taxId?: string | null + taxExempt?: boolean + invoiceTerms?: string + netTermsDays?: number + }, + adminId: string, + ip?: string, +) { + const before = await prisma.billingAccount.findUniqueOrThrow({ where: { id: billingAccountId } }) + const updated = await prisma.billingAccount.update({ + where: { id: billingAccountId }, + data: { + ...(data.legalName !== undefined ? { legalName: data.legalName } : {}), + ...(data.billingEmail !== undefined ? { billingEmail: data.billingEmail } : {}), + ...(data.billingAddress !== undefined ? { billingAddress: normalizeAddress(data.billingAddress) } : {}), + ...(data.taxId !== undefined ? { taxId: data.taxId } : {}), + ...(data.taxExempt !== undefined ? { taxExempt: data.taxExempt } : {}), + ...(data.invoiceTerms !== undefined ? { invoiceTerms: data.invoiceTerms as any } : {}), + ...(data.netTermsDays !== undefined ? { netTermsDays: data.netTermsDays } : {}), + }, + }) + + await createAuditLog({ + adminUserId: adminId, + action: 'UPDATE_BILLING_ACCOUNT', + resource: 'BillingAccount', + resourceId: billingAccountId, + companyId: before.companyId, + before, + after: updated, + ipAddress: ip, + }) + + await prisma.billingEvent.create({ + data: { + billingAccountId, + companyId: before.companyId, + eventType: 'billing_account.updated', + source: 'admin', + payload: data as any, + occurredAt: new Date(), + }, + }) + + return updated +} + +export async function setDunningPaused( + billingAccountId: string, + paused: boolean, + adminId: string, + ip?: string, +) { + const account = await prisma.billingAccount.findUniqueOrThrow({ where: { id: billingAccountId } }) + const updated = await prisma.billingAccount.update({ + where: { id: billingAccountId }, + data: { + dunningPaused: paused, + dunningPausedAt: paused ? new Date() : null, + dunningPausedBy: paused ? adminId : null, + }, + }) + + await createAuditLog({ + adminUserId: adminId, + action: paused ? 'PAUSE_DUNNING' : 'RESUME_DUNNING', + resource: 'BillingAccount', + resourceId: billingAccountId, + companyId: account.companyId, + after: { dunningPaused: paused }, + ipAddress: ip, + }) + + await prisma.billingEvent.create({ + data: { + billingAccountId, + companyId: account.companyId, + eventType: paused ? 'dunning.paused' : 'dunning.resumed', + source: 'admin', + payload: { pausedBy: adminId }, + occurredAt: new Date(), + }, + }) + + return updated +} + +export async function createDraftInvoice( + billingAccountId: string, + data: { + subscriptionId?: string | null + invoiceType: string + currency?: string + dueAt?: string | null + isSubscriptionBlocking?: boolean + adminReason?: string | null + lineItems: Array<{ + type: string + description: string + quantity: number + unitAmount: number + periodStart?: string | null + periodEnd?: string | null + }> + }, + adminId: string, + ip?: string, +) { + if (!data.lineItems.length) throw new ValidationError('Invoice requires at least one line item') + + const invoice = await prisma.$transaction(async (tx) => { + const account = await tx.billingAccount.findUniqueOrThrow({ + where: { id: billingAccountId }, + include: { company: true }, + }) + + const created = await tx.billingInvoice.create({ + data: { + billingAccountId, + companyId: account.companyId, + subscriptionId: data.subscriptionId ?? null, + invoiceType: data.invoiceType as any, + status: 'DRAFT', + currency: data.currency ?? account.defaultCurrency, + dueAt: data.dueAt ? new Date(data.dueAt) : null, + isSubscriptionBlocking: data.isSubscriptionBlocking ?? isBlockingInvoiceType(data.invoiceType), + adminReason: data.adminReason ?? null, + createdByAdminId: adminId, + billingName: account.legalName, + billingEmail: account.billingEmail, + billingAddress: normalizeAddress(account.billingAddress) ?? undefined, + metadata: {}, + lineItems: { + create: data.lineItems.map((item) => ({ + type: item.type as any, + description: item.description, + quantity: item.quantity, + unitAmount: item.unitAmount, + amount: item.quantity * item.unitAmount, + currency: data.currency ?? account.defaultCurrency, + periodStart: item.periodStart ? new Date(item.periodStart) : null, + periodEnd: item.periodEnd ? new Date(item.periodEnd) : null, + })), + }, + }, + include: { lineItems: true }, + }) + + await createBillingEvent(tx, { + billingAccountId, + invoiceId: created.id, + subscriptionId: created.subscriptionId, + companyId: account.companyId, + eventType: 'invoice.created', + source: 'admin', + payload: { invoiceType: created.invoiceType, lineItemCount: data.lineItems.length }, + }) + + return created + }) + + await createAuditLog({ + adminUserId: adminId, + action: 'CREATE_BILLING_INVOICE', + resource: 'BillingInvoice', + resourceId: invoice.id, + companyId: invoice.companyId, + after: invoice, + note: data.adminReason ?? undefined, + ipAddress: ip, + }) + + return invoice +} + +export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) { + const invoice = await prisma.$transaction(async (tx) => { + const current = await tx.billingInvoice.findUnique({ + where: { id: invoiceId }, + include: { + billingAccount: { + include: { + company: { include: { contractSettings: true } }, + creditBalances: true, + }, + }, + lineItems: true, + }, + }) + + if (!current) throw new NotFoundError('Invoice not found') + if (current.status !== 'DRAFT') throw new ValidationError('Only draft invoices can be finalized') + if (!current.lineItems.length) throw new ValidationError('Invoice requires at least one line item') + + const account = current.billingAccount + const currency = current.currency || account.defaultCurrency + const existingCreditAmount = Math.abs( + current.lineItems.filter((item: any) => item.type === 'CREDIT').reduce((sum: number, item: any) => sum + item.amount, 0), + ) + const { subtotalAmount, discountAmount } = calculateLineAmounts(current.lineItems) + const availableBalance = account.creditBalances.find((balance: any) => balance.currency === currency)?.balanceAmount ?? 0 + const preTaxBase = Math.max(subtotalAmount - discountAmount - existingCreditAmount, 0) + const autoCreditToApply = Math.min(availableBalance, preTaxBase) + + if (autoCreditToApply > 0) { + await tx.billingInvoiceLineItem.create({ + data: { + invoiceId: current.id, + type: 'CREDIT', + description: 'Account credit applied', + quantity: 1, + unitAmount: -autoCreditToApply, + amount: -autoCreditToApply, + currency, + metadata: { autoApplied: true }, + }, + }) + + await tx.billingCreditBalance.update({ + where: { + billingAccountId_currency: { + billingAccountId: account.id, + currency, + }, + }, + data: { + balanceAmount: { decrement: autoCreditToApply }, + }, + }) + + await tx.billingCreditLedgerEntry.create({ + data: { + billingAccountId: account.id, + invoiceId: current.id, + type: 'applied', + amount: -autoCreditToApply, + currency, + reason: 'Applied automatically on invoice finalization', + createdBy: adminId, + }, + }) + } + + const taxRate = account.taxExempt ? 0 : Number(account.company.contractSettings?.taxRate ?? 0) + const taxBase = Math.max(preTaxBase - autoCreditToApply, 0) + const taxAmount = taxRate > 0 ? Math.round(taxBase * (taxRate / 100)) : 0 + + if (taxAmount > 0) { + await tx.billingInvoiceLineItem.create({ + data: { + invoiceId: current.id, + type: 'TAX', + description: `Tax (${taxRate}%)`, + quantity: 1, + unitAmount: taxAmount, + amount: taxAmount, + currency, + }, + }) + + await tx.billingTaxRecord.create({ + data: { + invoiceId: current.id, + taxRate, + taxAmount, + taxType: 'VAT', + taxExempt: false, + metadata: {}, + }, + }) + } else if (account.taxExempt) { + await tx.billingTaxRecord.create({ + data: { + invoiceId: current.id, + taxRate: 0, + taxAmount: 0, + taxType: 'EXEMPT', + taxExempt: true, + exemptionReason: 'Billing account marked tax exempt', + metadata: {}, + }, + }) + } + + const finalLineItems = await tx.billingInvoiceLineItem.findMany({ where: { invoiceId: current.id } }) + const amounts = calculateLineAmounts(finalLineItems) + const finalizedAt = new Date() + const sequence = await getNextInvoiceSequence(tx) + const invoiceNumber = buildSequentialInvoiceNumber(sequence, finalizedAt) + const dueAt = current.dueAt ?? addDays(finalizedAt, getInvoiceTermsDays(account.invoiceTerms, account.netTermsDays)) + const status = amounts.totalAmount === 0 ? 'PAID' : 'OPEN' + + const updated = await tx.billingInvoice.update({ + where: { id: current.id }, + data: { + invoiceNumber, + invoiceSequence: sequence, + status, + subtotalAmount: amounts.subtotalAmount, + discountAmount: amounts.discountAmount, + creditAmount: amounts.creditAmount, + taxAmount: amounts.taxAmount, + totalAmount: amounts.totalAmount, + amountPaid: 0, + amountDue: amounts.totalAmount, + invoiceDate: finalizedAt, + dueAt, + finalizedAt, + paidAt: status === 'PAID' ? finalizedAt : null, + billingName: account.legalName, + billingEmail: account.billingEmail, + billingAddress: normalizeAddress(account.billingAddress) ?? undefined, + }, + include: { + lineItems: { orderBy: { createdAt: 'asc' } }, + paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, + taxRecords: true, + creditNotes: true, + refunds: true, + }, + }) + + await createBillingEvent(tx, { + billingAccountId: account.id, + invoiceId: updated.id, + subscriptionId: updated.subscriptionId, + companyId: updated.companyId, + eventType: 'invoice.finalized', + source: 'admin', + payload: { invoiceNumber, totalAmount: updated.totalAmount }, + }) + + if (updated.status === 'PAID') { + await createBillingEvent(tx, { + billingAccountId: account.id, + invoiceId: updated.id, + subscriptionId: updated.subscriptionId, + companyId: updated.companyId, + eventType: 'invoice.paid', + source: 'system', + payload: { method: 'credit_balance' }, + }) + await maybeRestoreSubscription(tx, updated) + } + + return updated + }) + + await createAuditLog({ + adminUserId: adminId, + action: 'FINALIZE_BILLING_INVOICE', + resource: 'BillingInvoice', + resourceId: invoiceId, + companyId: invoice.companyId, + after: { status: invoice.status, invoiceNumber: invoice.invoiceNumber }, + ipAddress: ip, + }) + + return invoice +} + +export async function payInvoice( + invoiceId: string, + data: { + amount?: number + paymentMethodId?: string | null + providerPaymentId?: string | null + }, + adminId: string, + ip?: string, +) { + const invoice = await prisma.$transaction(async (tx) => { + const current = await tx.billingInvoice.findUniqueOrThrow({ + where: { id: invoiceId }, + include: { + billingAccount: true, + paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, + }, + }) + + if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) { + throw new ValidationError('Invoice is not payable in its current state') + } + + const amount = data.amount ?? current.amountDue + if (amount <= 0 || amount > current.amountDue) { + throw new ValidationError('Payment amount must be greater than zero and no more than the invoice balance') + } + + const intent = await tx.billingPaymentIntent.create({ + data: { + invoiceId: current.id, + billingAccountId: current.billingAccountId, + paymentMethodId: data.paymentMethodId ?? null, + providerPaymentIntentId: data.providerPaymentId ?? null, + status: 'SUCCEEDED', + amount, + currency: current.currency, + metadata: { source: 'admin_manual_collection' }, + }, + }) + + await tx.billingPaymentAttempt.create({ + data: { + invoiceId: current.id, + billingAccountId: current.billingAccountId, + paymentIntentId: intent.id, + paymentMethodId: data.paymentMethodId ?? null, + providerPaymentId: data.providerPaymentId ?? null, + status: 'SUCCEEDED', + amount, + currency: current.currency, + attemptedAt: new Date(), + metadata: { source: 'admin_manual_collection' }, + }, + }) + + const nextAmountPaid = current.amountPaid + amount + const nextAmountDue = Math.max(current.amountDue - amount, 0) + const nextStatus = nextAmountDue === 0 ? 'PAID' : 'PARTIALLY_PAID' + + const updated = await tx.billingInvoice.update({ + where: { id: current.id }, + data: { + amountPaid: nextAmountPaid, + amountDue: nextAmountDue, + status: nextStatus, + paidAt: nextAmountDue === 0 ? new Date() : current.paidAt, + }, + include: { + lineItems: { orderBy: { createdAt: 'asc' } }, + paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, + taxRecords: true, + creditNotes: true, + refunds: true, + }, + }) + + await createBillingEvent(tx, { + billingAccountId: updated.billingAccountId, + invoiceId: updated.id, + subscriptionId: updated.subscriptionId, + companyId: updated.companyId, + eventType: nextAmountDue === 0 ? 'invoice.paid' : 'invoice.partially_paid', + source: 'admin', + payload: { amount, amountPaid: nextAmountPaid, amountDue: nextAmountDue }, + }) + + if (nextAmountDue === 0) { + await maybeRestoreSubscription(tx, updated) + } + + return updated + }) + + await createAuditLog({ + adminUserId: adminId, + action: 'PAY_BILLING_INVOICE', + resource: 'BillingInvoice', + resourceId: invoiceId, + companyId: invoice.companyId, + after: { amountPaid: invoice.amountPaid, amountDue: invoice.amountDue, status: invoice.status }, + ipAddress: ip, + }) + + return invoice +} + +export async function retryInvoicePayment( + invoiceId: string, + data: { paymentMethodId?: string | null }, + adminId: string, + ip?: string, +) { + const invoice = await prisma.$transaction(async (tx) => { + const current = await tx.billingInvoice.findUniqueOrThrow({ + where: { id: invoiceId }, + include: { billingAccount: true }, + }) + + if (!['OPEN', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) { + throw new ValidationError('Only open or overdue invoices can be retried') + } + + const intent = await tx.billingPaymentIntent.create({ + data: { + invoiceId: current.id, + billingAccountId: current.billingAccountId, + paymentMethodId: data.paymentMethodId ?? current.billingAccount.defaultPaymentMethodId ?? null, + status: 'PROCESSING', + amount: current.amountDue, + currency: current.currency, + metadata: { source: 'admin_retry' }, + }, + }) + + await tx.billingPaymentAttempt.create({ + data: { + invoiceId: current.id, + billingAccountId: current.billingAccountId, + paymentIntentId: intent.id, + paymentMethodId: data.paymentMethodId ?? current.billingAccount.defaultPaymentMethodId ?? null, + status: 'PENDING', + amount: current.amountDue, + currency: current.currency, + attemptedAt: new Date(), + metadata: { source: 'admin_retry' }, + }, + }) + + const updated = await tx.billingInvoice.update({ + where: { id: current.id }, + data: { status: 'PAYMENT_PENDING' }, + include: { + lineItems: { orderBy: { createdAt: 'asc' } }, + paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, + taxRecords: true, + creditNotes: true, + refunds: true, + }, + }) + + await createBillingEvent(tx, { + billingAccountId: updated.billingAccountId, + invoiceId: updated.id, + subscriptionId: updated.subscriptionId, + companyId: updated.companyId, + eventType: 'invoice.payment_started', + source: 'admin', + payload: { amountDue: updated.amountDue }, + }) + + return updated + }) + + await createAuditLog({ + adminUserId: adminId, + action: 'RETRY_BILLING_INVOICE_PAYMENT', + resource: 'BillingInvoice', + resourceId: invoiceId, + companyId: invoice.companyId, + after: { status: invoice.status }, + ipAddress: ip, + }) + + return invoice +} + +export async function voidInvoice(invoiceId: string, reason: string, adminId: string, ip?: string) { + const invoice = await prisma.$transaction(async (tx) => { + const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } }) + if (!['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) { + throw new ValidationError('Only unpaid invoices can be voided') + } + if (current.amountPaid > 0) { + throw new ValidationError('Paid invoices cannot be voided through this workflow') + } + + const updated = await tx.billingInvoice.update({ + where: { id: invoiceId }, + data: { + status: 'VOID', + amountDue: 0, + voidedAt: new Date(), + }, + include: { + lineItems: { orderBy: { createdAt: 'asc' } }, + paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, + taxRecords: true, + creditNotes: true, + refunds: true, + }, + }) + + await createBillingEvent(tx, { + billingAccountId: updated.billingAccountId, + invoiceId: updated.id, + subscriptionId: updated.subscriptionId, + companyId: updated.companyId, + eventType: 'invoice.voided', + source: 'admin', + payload: { reason }, + }) + + return updated + }) + + await createAuditLog({ + adminUserId: adminId, + action: 'VOID_BILLING_INVOICE', + resource: 'BillingInvoice', + resourceId: invoiceId, + companyId: invoice.companyId, + note: reason, + after: { status: invoice.status }, + ipAddress: ip, + }) + + return invoice +} + +export async function markInvoiceUncollectible(invoiceId: string, reason: string, adminId: string, ip?: string) { + const invoice = await prisma.$transaction(async (tx) => { + const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } }) + if (!['OPEN', 'PAST_DUE', 'PAYMENT_PENDING', 'PARTIALLY_PAID'].includes(current.status)) { + throw new ValidationError('Only open invoices can be marked uncollectible') + } + + const updated = await tx.billingInvoice.update({ + where: { id: invoiceId }, + data: { + status: 'UNCOLLECTIBLE', + markedUncollectibleAt: new Date(), + }, + include: { + lineItems: { orderBy: { createdAt: 'asc' } }, + paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, + taxRecords: true, + creditNotes: true, + refunds: true, + }, + }) + + await createBillingEvent(tx, { + billingAccountId: updated.billingAccountId, + invoiceId: updated.id, + subscriptionId: updated.subscriptionId, + companyId: updated.companyId, + eventType: 'invoice.marked_uncollectible', + source: 'admin', + payload: { reason }, + }) + + await maybeCancelSubscriptionForWriteoff(tx, updated) + return updated + }) + + await createAuditLog({ + adminUserId: adminId, + action: 'MARK_BILLING_INVOICE_UNCOLLECTIBLE', + resource: 'BillingInvoice', + resourceId: invoiceId, + companyId: invoice.companyId, + note: reason, + after: { status: invoice.status }, + ipAddress: ip, + }) + + return invoice +} + +export async function issueCreditNote( + invoiceId: string, + data: { amount: number; reason: string }, + adminId: string, + ip?: string, +) { + const invoice = await prisma.$transaction(async (tx) => { + const current = await tx.billingInvoice.findUniqueOrThrow({ + where: { id: invoiceId }, + include: { creditNotes: true }, + }) + if (!EDITABLE_BILLING_STATUSES.has(current.status)) { + throw new ValidationError('Credit notes can only be issued against active receivable invoices') + } + if (data.amount <= 0 || data.amount > current.amountDue) { + throw new ValidationError('Credit note amount must be positive and no more than the outstanding balance') + } + + await tx.billingCreditNote.create({ + data: { + invoiceId, + billingAccountId: current.billingAccountId, + amount: data.amount, + currency: current.currency, + reason: data.reason, + status: 'APPLIED', + createdBy: adminId, + }, + }) + + const nextTotalAmount = Math.max(current.totalAmount - data.amount, 0) + const nextAmountDue = Math.max(current.amountDue - data.amount, 0) + + const updated = await tx.billingInvoice.update({ + where: { id: invoiceId }, + data: { + creditAmount: current.creditAmount + data.amount, + totalAmount: nextTotalAmount, + amountDue: nextAmountDue, + status: nextAmountDue === 0 ? 'PAID' : current.status, + paidAt: nextAmountDue === 0 ? new Date() : current.paidAt, + }, + include: { + lineItems: { orderBy: { createdAt: 'asc' } }, + paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, + taxRecords: true, + creditNotes: { orderBy: { createdAt: 'desc' } }, + refunds: true, + }, + }) + + await createBillingEvent(tx, { + billingAccountId: updated.billingAccountId, + invoiceId: updated.id, + subscriptionId: updated.subscriptionId, + companyId: updated.companyId, + eventType: 'credit.applied', + source: 'admin', + payload: { amount: data.amount, reason: data.reason }, + }) + + if (nextAmountDue === 0) { + await maybeRestoreSubscription(tx, updated) + } + + return updated + }) + + await createAuditLog({ + adminUserId: adminId, + action: 'ISSUE_BILLING_CREDIT_NOTE', + resource: 'BillingInvoice', + resourceId: invoiceId, + companyId: invoice.companyId, + note: data.reason, + after: { creditAmount: invoice.creditAmount, amountDue: invoice.amountDue, status: invoice.status }, + ipAddress: ip, + }) + + return invoice +} + +export async function issueRefund( + invoiceId: string, + data: { amount: number; reason: string }, + adminId: string, + ip?: string, +) { + const invoice = await prisma.$transaction(async (tx) => { + const current = await tx.billingInvoice.findUniqueOrThrow({ + where: { id: invoiceId }, + include: { + paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, + refunds: true, + }, + }) + if (!['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(current.status)) { + throw new ValidationError('Only paid invoices can be refunded') + } + + const refundedSoFar = current.refunds + .filter((refund: any) => refund.status === 'SUCCEEDED') + .reduce((sum: number, refund: any) => sum + refund.amount, 0) + const refundableAmount = Math.max(current.totalAmount - refundedSoFar, 0) + if (data.amount <= 0 || data.amount > refundableAmount) { + throw new ValidationError('Refund amount must be positive and within the refundable amount') + } + + const latestSuccessfulAttempt = current.paymentAttempts.find((attempt: any) => attempt.status === 'SUCCEEDED') ?? null + + await tx.billingRefund.create({ + data: { + invoiceId, + paymentAttemptId: latestSuccessfulAttempt?.id ?? null, + billingAccountId: current.billingAccountId, + amount: data.amount, + currency: current.currency, + reason: data.reason, + status: 'SUCCEEDED', + createdBy: adminId, + }, + }) + + const totalRefunded = refundedSoFar + data.amount + const nextStatus = totalRefunded >= current.totalAmount ? 'REFUNDED' : 'PARTIALLY_REFUNDED' + + const updated = await tx.billingInvoice.update({ + where: { id: invoiceId }, + data: { status: nextStatus }, + include: { + lineItems: { orderBy: { createdAt: 'asc' } }, + paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, + taxRecords: true, + creditNotes: true, + refunds: { orderBy: { createdAt: 'desc' } }, + }, + }) + + await createBillingEvent(tx, { + billingAccountId: updated.billingAccountId, + invoiceId: updated.id, + subscriptionId: updated.subscriptionId, + companyId: updated.companyId, + eventType: 'refund.succeeded', + source: 'admin', + payload: { amount: data.amount, reason: data.reason }, + }) + + return updated + }) + + await createAuditLog({ + adminUserId: adminId, + action: 'ISSUE_BILLING_REFUND', + resource: 'BillingInvoice', + resourceId: invoiceId, + companyId: invoice.companyId, + note: data.reason, + after: { status: invoice.status }, + ipAddress: ip, + }) + + return invoice +} + +export async function getInvoicePdf(invoiceId: string) { + await syncLegacySubscriptionInvoices() + + const invoice = await prisma.billingInvoice.findUnique({ + where: { id: invoiceId }, + include: { + company: true, + subscription: true, + lineItems: { orderBy: { createdAt: 'asc' } }, + paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, + taxRecords: true, + creditNotes: { orderBy: { createdAt: 'desc' } }, + refunds: { orderBy: { createdAt: 'desc' } }, + }, + }) + if (!invoice) throw new NotFoundError('Invoice not found') + if (!invoice.invoiceNumber) throw new ValidationError('Invoice must be finalized before a PDF can be generated') + + const latestPaymentAttempt = invoice.paymentAttempts.find((attempt) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null + const pdfBuffer = await generateInvoicePdf({ + invoiceNumber: invoice.invoiceNumber, + issueDate: invoice.invoiceDate?.toISOString() ?? invoice.createdAt.toISOString(), + dueDate: invoice.dueAt?.toISOString() ?? null, + company: { + name: invoice.billingName ?? invoice.company.name, + email: invoice.billingEmail ?? invoice.company.email, + phone: invoice.company.phone, + address: invoice.billingAddress ?? invoice.company.address, + }, + subscription: invoice.subscription + ? { + plan: invoice.subscription.plan, + billingPeriod: invoice.subscription.billingPeriod, + currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(), + currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(), + currency: invoice.subscription.currency, + } + : undefined, + amount: invoice.totalAmount, + currency: invoice.currency, + status: invoice.status, + paymentProvider: invoice.paymentProvider ?? 'MANUAL', + transactionId: latestPaymentAttempt?.providerPaymentId ?? null, + paidAt: invoice.paidAt?.toISOString(), + lineItems: invoice.lineItems.map((item) => ({ + description: item.description, + amount: item.amount, + currency: item.currency, + quantity: item.quantity, + unitAmount: item.unitAmount, + periodStart: item.periodStart?.toISOString() ?? null, + periodEnd: item.periodEnd?.toISOString() ?? null, + })), + totals: { + subtotalAmount: invoice.subtotalAmount, + discountAmount: invoice.discountAmount, + creditAmount: invoice.creditAmount, + taxAmount: invoice.taxAmount, + totalAmount: invoice.totalAmount, + amountPaid: invoice.amountPaid, + amountDue: invoice.amountDue, + }, + }) + + return { pdfBuffer, invoiceNumber: invoice.invoiceNumber } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index 25c8516..69ddc31 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -10,9 +10,11 @@ import { companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema, createAdminSchema, adminRoleSchema, adminPermissionsSchema, - homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema, + homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema, pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema, promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema, + billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema, + retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema, } from './admin.schemas' import { z } from 'zod' @@ -203,6 +205,24 @@ router.get('/billing', requireAdminAuth, requireAdminRole('FINANCE'), async (req } catch (err) { next(err) } }) +router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { invoiceId } = parseParams(invoiceIdParamSchema, req) + const { pdfBuffer, invoiceNumber } = await service.getInvoicePdf(invoiceId) + res.setHeader('Content-Type', 'application/pdf') + res.setHeader('Content-Disposition', `attachment; filename="${invoiceNumber}.pdf"`) + res.setHeader('Content-Length', pdfBuffer.length) + res.end(pdfBuffer) + } catch (err) { next(err) } +}) + +router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { companyId } = parseParams(companyIdParamSchema, req) + ok(res, await service.getBillingAccountDetail(companyId)) + } catch (err) { next(err) } +}) + router.get('/billing/:companyId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { try { const { companyId } = parseParams(companyIdParamSchema, req) @@ -210,14 +230,82 @@ router.get('/billing/:companyId/invoices', requireAdminAuth, requireAdminRole('F } catch (err) { next(err) } }) -router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { +router.patch('/billing/accounts/:billingAccountId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { try { - const { invoiceId } = parseParams(invoiceIdParamSchema, req) - const { pdfBuffer, invoiceNumber } = await service.getInvoicePdf(invoiceId) - res.setHeader('Content-Type', 'application/pdf') - res.setHeader('Content-Disposition', `attachment; filename="${invoiceNumber}.pdf"`) - res.setHeader('Content-Length', pdfBuffer.length) - res.end(pdfBuffer) + const { billingAccountId } = parseParams(billingAccountIdParamSchema, req) + ok(res, await service.updateBillingAccount(billingAccountId, parseBody(billingAccountUpdateSchema, req), req.admin.id, req.ip)) + } catch (err) { next(err) } +}) + +router.post('/billing/accounts/:billingAccountId/pause-dunning', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { billingAccountId } = parseParams(billingAccountIdParamSchema, req) + ok(res, await service.setDunningPaused(billingAccountId, true, req.admin.id, req.ip)) + } catch (err) { next(err) } +}) + +router.post('/billing/accounts/:billingAccountId/resume-dunning', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { billingAccountId } = parseParams(billingAccountIdParamSchema, req) + ok(res, await service.setDunningPaused(billingAccountId, false, req.admin.id, req.ip)) + } catch (err) { next(err) } +}) + +router.post('/billing/accounts/:billingAccountId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { billingAccountId } = parseParams(billingAccountIdParamSchema, req) + created(res, await service.createBillingInvoice(billingAccountId, parseBody(createBillingInvoiceSchema, req), req.admin.id, req.ip)) + } catch (err) { next(err) } +}) + +router.post('/billing/invoices/:invoiceId/finalize', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { invoiceId } = parseParams(invoiceIdParamSchema, req) + ok(res, await service.finalizeBillingInvoice(invoiceId, req.admin.id, req.ip)) + } catch (err) { next(err) } +}) + +router.post('/billing/invoices/:invoiceId/pay', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { invoiceId } = parseParams(invoiceIdParamSchema, req) + ok(res, await service.payBillingInvoice(invoiceId, parseBody(payBillingInvoiceSchema, req), req.admin.id, req.ip)) + } catch (err) { next(err) } +}) + +router.post('/billing/invoices/:invoiceId/retry-payment', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { invoiceId } = parseParams(invoiceIdParamSchema, req) + ok(res, await service.retryBillingInvoicePayment(invoiceId, parseBody(retryBillingInvoiceSchema, req), req.admin.id, req.ip)) + } catch (err) { next(err) } +}) + +router.post('/billing/invoices/:invoiceId/void', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { invoiceId } = parseParams(invoiceIdParamSchema, req) + const { reason } = parseBody(billingReasonSchema, req) + ok(res, await service.voidBillingInvoice(invoiceId, reason, req.admin.id, req.ip)) + } catch (err) { next(err) } +}) + +router.post('/billing/invoices/:invoiceId/uncollectible', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { invoiceId } = parseParams(invoiceIdParamSchema, req) + const { reason } = parseBody(billingReasonSchema, req) + ok(res, await service.markBillingInvoiceUncollectible(invoiceId, reason, req.admin.id, req.ip)) + } catch (err) { next(err) } +}) + +router.post('/billing/invoices/:invoiceId/credit-notes', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { invoiceId } = parseParams(invoiceIdParamSchema, req) + ok(res, await service.issueBillingCreditNote(invoiceId, parseBody(billingCreditNoteSchema, req), req.admin.id, req.ip)) + } catch (err) { next(err) } +}) + +router.post('/billing/invoices/:invoiceId/refunds', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { invoiceId } = parseParams(invoiceIdParamSchema, req) + ok(res, await service.issueBillingRefund(invoiceId, parseBody(billingRefundSchema, req), req.admin.id, req.ip)) } catch (err) { next(err) } }) diff --git a/apps/api/src/modules/admin/admin.schemas.ts b/apps/api/src/modules/admin/admin.schemas.ts index 6fdbca9..d80641b 100644 --- a/apps/api/src/modules/admin/admin.schemas.ts +++ b/apps/api/src/modules/admin/admin.schemas.ts @@ -45,6 +45,7 @@ export const auditLogQuerySchema = z.object({ }) export const billingQuerySchema = z.object({ + q: z.string().optional(), status: z.string().optional(), plan: z.string().optional(), page: z.coerce.number().int().min(1).default(1), @@ -167,10 +168,91 @@ export const companyIdParamSchema = z.object({ companyId: z.string().min(1), }) +export const billingAccountIdParamSchema = z.object({ + billingAccountId: z.string().min(1), +}) + export const invoiceIdParamSchema = z.object({ invoiceId: z.string().min(1), }) +export const billingAccountUpdateSchema = z.object({ + legalName: z.string().min(1).max(255).optional(), + billingEmail: z.string().email().optional(), + billingAddress: z.any().optional(), + taxId: z.union([z.string().max(120), z.null()]).optional(), + taxExempt: z.boolean().optional(), + invoiceTerms: z.enum(['DUE_ON_RECEIPT', 'NET_7', 'NET_15', 'NET_30', 'NET_45', 'NET_60']).optional(), + netTermsDays: z.number().int().min(0).max(365).optional(), +}) + +export const billingLineItemInputSchema = z.object({ + type: z.enum([ + 'SUBSCRIPTION_FEE', + 'SEAT_FEE', + 'USAGE_FEE', + 'SETUP_FEE', + 'DISCOUNT', + 'TAX', + 'CREDIT', + 'PRORATION', + 'REFUND_ADJUSTMENT', + 'MANUAL_ADJUSTMENT', + 'PROFESSIONAL_SERVICES', + 'CANCELLATION_FEE', + ]), + description: z.string().min(1).max(255), + quantity: z.number().int().positive().max(100000), + unitAmount: z.number().int(), + periodStart: nullableDate, + periodEnd: nullableDate, +}) + +export const createBillingInvoiceSchema = z.object({ + subscriptionId: z.union([z.string(), z.null()]).optional(), + invoiceType: z.enum([ + 'SUBSCRIPTION_INITIAL', + 'SUBSCRIPTION_RENEWAL', + 'TRIAL_CONVERSION', + 'SUBSCRIPTION_UPGRADE', + 'SUBSCRIPTION_DOWNGRADE_CREDIT', + 'USAGE_BASED', + 'ONE_TIME', + 'MANUAL', + 'CORRECTION', + 'CANCELLATION_FEE', + ]), + currency: z.string().min(3).max(8).optional(), + dueAt: nullableDate, + isSubscriptionBlocking: z.boolean().optional(), + adminReason: z.union([z.string().max(500), z.null()]).optional(), + lineItems: z.array(billingLineItemInputSchema).min(1), +}) + +export const payBillingInvoiceSchema = z.object({ + amount: z.number().int().positive().optional(), + paymentMethodId: z.union([z.string(), z.null()]).optional(), + providerPaymentId: z.union([z.string(), z.null()]).optional(), +}) + +export const retryBillingInvoiceSchema = z.object({ + paymentMethodId: z.union([z.string(), z.null()]).optional(), +}) + +export const billingReasonSchema = z.object({ + reason: z.string().min(1).max(500), +}) + +export const billingCreditNoteSchema = z.object({ + amount: z.number().int().positive(), + reason: z.string().min(1).max(500), +}) + +export const billingRefundSchema = z.object({ + amount: z.number().int().positive(), + reason: z.string().min(1).max(500), +}) + export const homepageUpdateSchema = z.object({ homepage: marketplaceHomepageConfigSchema, }) diff --git a/apps/api/src/modules/admin/admin.service.ts b/apps/api/src/modules/admin/admin.service.ts index df1fc93..fcf4bfb 100644 --- a/apps/api/src/modules/admin/admin.service.ts +++ b/apps/api/src/modules/admin/admin.service.ts @@ -4,19 +4,13 @@ import crypto from 'crypto' import { authenticator } from 'otplib' import qrcode from 'qrcode' import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService' -import { generateInvoicePdf, buildInvoiceNumber } from '../../services/invoicePdfService' import { sendTransactionalEmail } from '../../services/notificationService' import * as presenter from './admin.presenter' import * as repo from './admin.repo' +import * as billingService from './admin.billing.service' const ADMIN_RESET_TTL_MINUTES = 60 -const PLAN_MONTHLY_AMOUNT: Record = { - STARTER: 2990, - GROWTH: 3990, - PRO: 99900, -} - function signAdminToken(adminId: string) { return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' }) } @@ -226,58 +220,96 @@ export async function updateAdminPermissions(id: string, permissions: any[]) { return presenter.presentAdminUser(admin) } -export async function getBilling(query: { status?: string; plan?: string; page: number; pageSize: number }) { - const [{ data, total }, activeSubscriptions, stats] = await Promise.all([ - repo.listBillingPage(query), - repo.listActiveSubscriptionsForMrr(), - repo.getBillingStatusCounts(), - ]) - - const mrr = activeSubscriptions.reduce((acc, subscription) => { - const monthlyAmount = PLAN_MONTHLY_AMOUNT[subscription.plan] ?? 0 - return acc + (subscription.billingPeriod === 'ANNUAL' ? Math.round(monthlyAmount * 10 / 12) : monthlyAmount) - }, 0) - - return presenter.presentPaginated(data, total, query.page, query.pageSize, { - stats: { mrr, ...stats }, - }) +export async function getBilling(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) { + const { data, total, stats } = await billingService.listBillingAccounts(query) + return presenter.presentPaginated(data, total, query.page, query.pageSize, { stats }) } export async function getCompanyInvoices(companyId: string, query: { page: number; pageSize: number }) { - const { data, total } = await repo.listCompanyInvoicesPage(companyId, query) + const detail = await billingService.getBillingAccountDetail(companyId) + const start = (query.page - 1) * query.pageSize + const end = start + query.pageSize + const data = detail.invoices.slice(start, end) + const total = detail.invoices.length return presenter.presentPaginated(data, total, query.page, query.pageSize) } export async function getInvoicePdf(invoiceId: string) { - const invoice = await repo.getInvoicePdfRecord(invoiceId) - const invoiceNumber = buildInvoiceNumber(invoice.id, invoice.createdAt) - const transactionId = invoice.amanpayTransactionId ?? invoice.paypalCaptureId ?? null - const pdfBuffer = await generateInvoicePdf({ - invoiceNumber, - issueDate: invoice.createdAt.toISOString(), - dueDate: invoice.status === 'PENDING' ? invoice.createdAt.toISOString() : null, - company: { - name: invoice.company.name, - email: invoice.company.email, - phone: invoice.company.phone, - address: invoice.company.address, - }, - subscription: { - plan: invoice.subscription.plan, - billingPeriod: invoice.subscription.billingPeriod, - currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(), - currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(), - currency: invoice.subscription.currency, - }, - amount: invoice.amount, - currency: invoice.currency, - status: invoice.status, - paymentProvider: invoice.paymentProvider, - transactionId, - paidAt: invoice.paidAt?.toISOString(), - }) + return billingService.getInvoicePdf(invoiceId) +} - return { pdfBuffer, invoiceNumber } +export function getBillingAccountDetail(companyId: string) { + return billingService.getBillingAccountDetail(companyId) +} + +export function updateBillingAccount( + billingAccountId: string, + data: Parameters[1], + adminId: string, + ip?: string, +) { + return billingService.updateBillingAccount(billingAccountId, data, adminId, ip) +} + +export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) { + return billingService.setDunningPaused(billingAccountId, paused, adminId, ip) +} + +export function createBillingInvoice( + billingAccountId: string, + data: Parameters[1], + adminId: string, + ip?: string, +) { + return billingService.createDraftInvoice(billingAccountId, data, adminId, ip) +} + +export function finalizeBillingInvoice(invoiceId: string, adminId: string, ip?: string) { + return billingService.finalizeInvoice(invoiceId, adminId, ip) +} + +export function payBillingInvoice( + invoiceId: string, + data: Parameters[1], + adminId: string, + ip?: string, +) { + return billingService.payInvoice(invoiceId, data, adminId, ip) +} + +export function retryBillingInvoicePayment( + invoiceId: string, + data: Parameters[1], + adminId: string, + ip?: string, +) { + return billingService.retryInvoicePayment(invoiceId, data, adminId, ip) +} + +export function voidBillingInvoice(invoiceId: string, reason: string, adminId: string, ip?: string) { + return billingService.voidInvoice(invoiceId, reason, adminId, ip) +} + +export function markBillingInvoiceUncollectible(invoiceId: string, reason: string, adminId: string, ip?: string) { + return billingService.markInvoiceUncollectible(invoiceId, reason, adminId, ip) +} + +export function issueBillingCreditNote( + invoiceId: string, + data: Parameters[1], + adminId: string, + ip?: string, +) { + return billingService.issueCreditNote(invoiceId, data, adminId, ip) +} + +export function issueBillingRefund( + invoiceId: string, + data: Parameters[1], + adminId: string, + ip?: string, +) { + return billingService.issueRefund(invoiceId, data, adminId, ip) } export function getMarketplaceHomepage() { diff --git a/apps/api/src/services/invoicePdfService.ts b/apps/api/src/services/invoicePdfService.ts index f88590c..7b78a58 100644 --- a/apps/api/src/services/invoicePdfService.ts +++ b/apps/api/src/services/invoicePdfService.ts @@ -11,7 +11,7 @@ interface InvoiceData { phone?: string | null address?: any } - subscription: { + subscription?: { plan: string billingPeriod: string currentPeriodStart?: string | null @@ -24,6 +24,24 @@ interface InvoiceData { paymentProvider: string transactionId?: string | null paidAt?: string | null + lineItems?: Array<{ + description: string + amount: number + currency: string + quantity?: number + unitAmount?: number + periodStart?: string | null + periodEnd?: string | null + }> + totals?: { + subtotalAmount: number + discountAmount: number + creditAmount: number + taxAmount: number + totalAmount: number + amountPaid: number + amountDue: number + } } const PLAN_LABEL: Record = { @@ -40,8 +58,15 @@ const PERIOD_LABEL: Record = { const STATUS_COLORS: Record = { PAID: '#10b981', PENDING: '#f59e0b', + OPEN: '#f59e0b', + PAYMENT_PENDING: '#f59e0b', + PARTIALLY_PAID: '#f59e0b', + PAST_DUE: '#ef4444', FAILED: '#ef4444', REFUNDED: '#6b7280', + PARTIALLY_REFUNDED: '#6b7280', + VOID: '#6b7280', + UNCOLLECTIBLE: '#6b7280', } const colors = { @@ -307,12 +332,26 @@ function formatAddress(address: any): string { function InvoiceDocument({ data }: { data: InvoiceData }) { const statusColor = STATUS_COLORS[data.status] ?? '#6b7280' const addressStr = formatAddress(data.company.address) - const planLabel = PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan - const periodLabel = PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod - const description = `${planLabel} Plan — ${periodLabel} Subscription` - const periodStr = data.subscription.currentPeriodStart && data.subscription.currentPeriodEnd - ? `${fmtDate(data.subscription.currentPeriodStart)} – ${fmtDate(data.subscription.currentPeriodEnd)}` - : '—' + const planLabel = data.subscription ? (PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan) : 'Manual' + const periodLabel = data.subscription ? (PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod) : 'Custom' + const lineItems = data.lineItems?.length + ? data.lineItems + : [{ + description: `${planLabel} Plan — ${periodLabel} Subscription`, + amount: data.amount, + currency: data.currency, + periodStart: data.subscription?.currentPeriodStart ?? null, + periodEnd: data.subscription?.currentPeriodEnd ?? null, + }] + 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, + } return React.createElement( Document, @@ -397,12 +436,14 @@ function InvoiceDocument({ data }: { data: InvoiceData }) { React.createElement(Text, { style: s.metaKey }, 'Billing Period'), React.createElement(Text, { style: s.metaValue }, periodLabel), ), - React.createElement( - View, - { style: s.metaRow }, - React.createElement(Text, { style: s.metaKey }, 'Plan'), - React.createElement(Text, { style: s.metaValue }, planLabel), - ), + data.subscription + ? React.createElement( + View, + { style: s.metaRow }, + React.createElement(Text, { style: s.metaKey }, 'Plan'), + React.createElement(Text, { style: s.metaValue }, planLabel), + ) + : null, ), ), @@ -417,13 +458,21 @@ function InvoiceDocument({ data }: { data: InvoiceData }) { React.createElement(Text, { style: [s.tableHeaderCell, s.col20Center] }, 'Period'), React.createElement(Text, { style: [s.tableHeaderCell, s.col20] }, 'Amount'), ), - React.createElement( - View, - { 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(data.amount, data.currency)), - ), + ...lineItems.map((item) => { + const periodStr = item.periodStart && 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 + 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)), + ) + }), ), // ── Totals ─────────────────────────────────────────────────── @@ -431,13 +480,37 @@ function InvoiceDocument({ data }: { data: InvoiceData }) { View, { style: s.totalRow }, React.createElement(Text, { style: s.totalLabel }, 'Subtotal'), - React.createElement(Text, { style: s.totalValue }, fmt(data.amount, data.currency)), + React.createElement(Text, { style: s.totalValue }, fmt(totals.subtotalAmount, data.currency)), ), + totals.discountAmount > 0 + ? React.createElement( + View, + { style: s.totalRow }, + React.createElement(Text, { style: s.totalLabel }, 'Discounts'), + React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.discountAmount, data.currency)}`), + ) + : null, + totals.creditAmount > 0 + ? React.createElement( + View, + { style: s.totalRow }, + React.createElement(Text, { style: s.totalLabel }, 'Credits'), + React.createElement(Text, { style: s.totalValue }, `- ${fmt(totals.creditAmount, data.currency)}`), + ) + : null, + totals.taxAmount > 0 + ? React.createElement( + View, + { style: s.totalRow }, + React.createElement(Text, { style: s.totalLabel }, 'Tax'), + React.createElement(Text, { style: s.totalValue }, fmt(totals.taxAmount, data.currency)), + ) + : null, React.createElement( View, { style: s.grandTotalRow }, React.createElement(Text, { style: s.grandTotalLabel }, 'Total Due'), - React.createElement(Text, { style: s.grandTotalValue }, fmt(data.amount, data.currency)), + React.createElement(Text, { style: s.grandTotalValue }, fmt(totals.totalAmount, data.currency)), ), // ── Payment Info ───────────────────────────────────────────── diff --git a/apps/api/src/tests/integration/admin.test.ts b/apps/api/src/tests/integration/admin.test.ts index dfd1beb..1db9b0a 100644 --- a/apps/api/src/tests/integration/admin.test.ts +++ b/apps/api/src/tests/integration/admin.test.ts @@ -136,4 +136,194 @@ describe('Admin API', () => { ) }) }) + + describe('Billing admin workflows', () => { + let billingAccountId: string + + beforeAll(async () => { + const res = await request(app) + .get('/api/v1/admin/billing') + .set(authHeader(financeToken)) + + expect(res.status).toBe(200) + const account = res.body.data.data.find((item: any) => item.company.id === companyId) + expect(account).toBeTruthy() + billingAccountId = account.id + }) + + it('returns billing account detail', async () => { + const res = await request(app) + .get(`/api/v1/admin/billing/${companyId}`) + .set(authHeader(financeToken)) + + expect(res.status).toBe(200) + expect(res.body.data.id).toBe(billingAccountId) + expect(res.body.data.company.id).toBe(companyId) + expect(Array.isArray(res.body.data.invoices)).toBe(true) + }) + + it('supports create, finalize, pay, refund, and pdf download', async () => { + const draftRes = await request(app) + .post(`/api/v1/admin/billing/accounts/${billingAccountId}/invoices`) + .set(authHeader(financeToken)) + .send({ + invoiceType: 'MANUAL', + adminReason: 'Enterprise onboarding', + lineItems: [ + { + type: 'SETUP_FEE', + description: 'Onboarding setup', + quantity: 1, + unitAmount: 50000, + }, + { + type: 'PROFESSIONAL_SERVICES', + description: 'Migration assistance', + quantity: 2, + unitAmount: 15000, + }, + ], + }) + + expect(draftRes.status).toBe(201) + expect(draftRes.body.data.status).toBe('DRAFT') + const invoiceId = draftRes.body.data.id as string + + const finalizeRes = await request(app) + .post(`/api/v1/admin/billing/invoices/${invoiceId}/finalize`) + .set(authHeader(financeToken)) + .send({}) + + expect(finalizeRes.status).toBe(200) + expect(finalizeRes.body.data.status).toBe('OPEN') + expect(finalizeRes.body.data.invoiceNumber).toMatch(/^INV-\d{4}-\d{6}$/) + expect(finalizeRes.body.data.totalAmount).toBe(80000) + + const pdfRes = await request(app) + .get(`/api/v1/admin/billing/invoices/${invoiceId}/pdf`) + .set(authHeader(financeToken)) + + expect(pdfRes.status).toBe(200) + expect(pdfRes.headers['content-type']).toContain('application/pdf') + + const payRes = await request(app) + .post(`/api/v1/admin/billing/invoices/${invoiceId}/pay`) + .set(authHeader(financeToken)) + .send({ amount: 80000, providerPaymentId: 'manual-payment-1' }) + + expect(payRes.status).toBe(200) + expect(payRes.body.data.status).toBe('PAID') + expect(payRes.body.data.amountDue).toBe(0) + + const refundRes = await request(app) + .post(`/api/v1/admin/billing/invoices/${invoiceId}/refunds`) + .set(authHeader(financeToken)) + .send({ amount: 20000, reason: 'Service credit after go-live issue' }) + + expect(refundRes.status).toBe(200) + expect(refundRes.body.data.status).toBe('PARTIALLY_REFUNDED') + }) + + it('supports credit notes, dunning controls, voids, and write-offs', async () => { + const pauseRes = await request(app) + .post(`/api/v1/admin/billing/accounts/${billingAccountId}/pause-dunning`) + .set(authHeader(financeToken)) + .send({}) + + expect(pauseRes.status).toBe(200) + expect(pauseRes.body.data.dunningPaused).toBe(true) + + const invoiceRes = await request(app) + .post(`/api/v1/admin/billing/accounts/${billingAccountId}/invoices`) + .set(authHeader(financeToken)) + .send({ + invoiceType: 'MANUAL', + lineItems: [ + { + type: 'MANUAL_ADJUSTMENT', + description: 'Support package', + quantity: 1, + unitAmount: 30000, + }, + ], + }) + + expect(invoiceRes.status).toBe(201) + const invoiceId = invoiceRes.body.data.id as string + + const finalizeRes = await request(app) + .post(`/api/v1/admin/billing/invoices/${invoiceId}/finalize`) + .set(authHeader(financeToken)) + .send({}) + + expect(finalizeRes.status).toBe(200) + expect(finalizeRes.body.data.status).toBe('OPEN') + + const creditRes = await request(app) + .post(`/api/v1/admin/billing/invoices/${invoiceId}/credit-notes`) + .set(authHeader(financeToken)) + .send({ amount: 5000, reason: 'Manual goodwill credit' }) + + expect(creditRes.status).toBe(200) + expect(creditRes.body.data.amountDue).toBe(25000) + + const retryRes = await request(app) + .post(`/api/v1/admin/billing/invoices/${invoiceId}/retry-payment`) + .set(authHeader(financeToken)) + .send({}) + + expect(retryRes.status).toBe(200) + expect(retryRes.body.data.status).toBe('PAYMENT_PENDING') + + const voidRes = await request(app) + .post(`/api/v1/admin/billing/invoices/${invoiceId}/void`) + .set(authHeader(financeToken)) + .send({ reason: 'Customer rolled into replacement invoice' }) + + expect(voidRes.status).toBe(200) + expect(voidRes.body.data.status).toBe('VOID') + + const badDebtRes = await request(app) + .post(`/api/v1/admin/billing/accounts/${billingAccountId}/invoices`) + .set(authHeader(financeToken)) + .send({ + invoiceType: 'SUBSCRIPTION_RENEWAL', + isSubscriptionBlocking: true, + lineItems: [ + { + type: 'SUBSCRIPTION_FEE', + description: 'Renewal period', + quantity: 1, + unitAmount: 12000, + }, + ], + }) + + expect(badDebtRes.status).toBe(201) + const badDebtInvoiceId = badDebtRes.body.data.id as string + + const finalizeBadDebtRes = await request(app) + .post(`/api/v1/admin/billing/invoices/${badDebtInvoiceId}/finalize`) + .set(authHeader(financeToken)) + .send({}) + + expect(finalizeBadDebtRes.status).toBe(200) + + const writeoffRes = await request(app) + .post(`/api/v1/admin/billing/invoices/${badDebtInvoiceId}/uncollectible`) + .set(authHeader(financeToken)) + .send({ reason: 'Customer account closed after failed recovery' }) + + expect(writeoffRes.status).toBe(200) + expect(writeoffRes.body.data.status).toBe('UNCOLLECTIBLE') + + const resumeRes = await request(app) + .post(`/api/v1/admin/billing/accounts/${billingAccountId}/resume-dunning`) + .set(authHeader(financeToken)) + .send({}) + + expect(resumeRes.status).toBe(200) + expect(resumeRes.body.data.dunningPaused).toBe(false) + }) + }) }) diff --git a/apps/api/src/tests/setup.ts b/apps/api/src/tests/setup.ts index 3a04d7a..001f0ce 100644 --- a/apps/api/src/tests/setup.ts +++ b/apps/api/src/tests/setup.ts @@ -2,6 +2,17 @@ import { beforeAll, afterAll } from 'vitest' import { prisma } from '../lib/prisma' const delegates = [ + 'billingEvent', + 'billingRefund', + 'billingCreditNote', + 'billingCreditLedgerEntry', + 'billingCreditBalance', + 'billingPaymentAttempt', + 'billingPaymentIntent', + 'billingInvoiceLineItem', + 'billingInvoice', + 'billingPaymentMethod', + 'billingAccount', 'auditLog', 'adminPermission', 'adminUser', diff --git a/contrat_location_voiture_maroc_updated.md b/contrat_location_voiture_maroc_updated.md new file mode 100644 index 0000000..544f1de --- /dev/null +++ b/contrat_location_voiture_maroc_updated.md @@ -0,0 +1,634 @@ +# Contrat de Location de Véhicule + +**Contrat n° :** [●] +**Date de signature :** [●] +**Lieu de signature :** [Ville, Maroc] + +--- + +## 1. Parties au contrat + +Entre les soussignés : + +### Le Loueur / Agence + +**Nom commercial :** [●] +**Raison sociale :** [●] +**Forme juridique :** [●] +**RC n° :** [●] +**ICE n° :** [●] +**IF n° :** [●] +**Agrément / Autorisation d’exercice n° :** [●] +**Date de délivrance de l’agrément :** [●] +**Autorité délivrante :** [●] +**Adresse :** [●] +**Téléphone :** [●] +**Email :** [●] +**Représenté par :** [Nom, prénom, fonction] + +Ci-après désigné **« le Loueur »**, + +Et : + +### Le Locataire / Conducteur principal + +**Nom :** [●] +**Prénom :** [●] +**Date de naissance :** [●] +**Nationalité :** [●] +**CIN / Passeport n° :** [●] +**Adresse complète :** [●] +**Téléphone :** [●] +**Email :** [●] + +Ci-après désigné **« le Locataire »**. + +Le Loueur et le Locataire sont ensemble désignés **« les Parties »**. + +--- + +## 2. Responsable légal et conformité réglementaire du Loueur + +Le Loueur déclare exercer l’activité de location de véhicules sans chauffeur conformément à la réglementation marocaine applicable. + +La personne responsable de l’activité est : + +**Nom et prénom :** [●] +**Qualité :** [Dirigeant / Actionnaire / Salarié] +**Pièce d’identité n° :** [●] +**Qualification / diplôme / expérience :** [●] +**Téléphone :** [●] +**Email :** [●] + +Cette personne est responsable du suivi des contrats, de l’entretien des véhicules, de leur conformité aux normes de sécurité routière et du respect des obligations administratives applicables. + +Le Loueur déclare notamment : + +- disposer des autorisations administratives requises pour l’exercice de l’activité ; +- être régulièrement immatriculé auprès des administrations compétentes ; +- disposer d’un siège social ou local professionnel déclaré ; +- respecter les obligations sociales, fiscales et administratives applicables ; +- exploiter une flotte conforme aux seuils et conditions réglementaires applicables ; +- maintenir les véhicules loués en état conforme aux normes de sécurité routière. + +--- + +## 3. Permis de conduire + +Le Locataire déclare être titulaire d’un permis de conduire valide : + +**Numéro du permis :** [●] +**Catégorie :** [B / autre] +**Date de délivrance :** [●] +**Date d’expiration :** [●] +**Pays de délivrance :** [●] +**Permis international, le cas échéant :** [Oui / Non, n° ●] + +Le Locataire garantit que son permis est valide pendant toute la durée de location et qu’il n’est frappé d’aucune suspension, annulation ou restriction incompatible avec la conduite du véhicule loué. + +--- + +## 4. Conducteur(s) autorisé(s) + +Le véhicule ne peut être conduit que par le Locataire et, le cas échéant, par les conducteurs additionnels expressément mentionnés ci-dessous : + +### Conducteur additionnel 1 + +**Nom et prénom :** [●] +**CIN / Passeport :** [●] +**Permis n° :** [●] +**Catégorie :** [●] +**Date de délivrance :** [●] +**Téléphone :** [●] + +Tout conducteur non déclaré est interdit. En cas d’accident, vol, infraction ou dommage causé par un conducteur non autorisé, le Locataire assume l’entière responsabilité des conséquences financières, civiles, pénales et assurantielles. + +--- + +## 5. Véhicule loué + +Le Loueur met à disposition du Locataire le véhicule suivant : + +**Marque :** [●] +**Modèle :** [●] +**Année :** [●] +**Couleur :** [●] +**Immatriculation :** [●] +**Numéro de châssis :** [●] +**Propriétaire du véhicule :** [Loueur / Autre agence / Société de financement / Autre] +**Justificatif d’exploitation si le véhicule appartient à un tiers :** [Contrat / Autorisation / Mandat / Autre] +**Type de motorisation :** [Thermique / Hybride / Électrique] +**Date de première mise en circulation :** [●] +**Âge du véhicule à la date de location :** [●] +**Véhicule conforme aux limites réglementaires d’exploitation :** [Oui / Non] +**Kilométrage au départ :** [●] km +**Niveau de carburant au départ :** [●] +**Carte grise :** [Oui / Non] +**Assurance :** [Oui / Non] +**Visite technique :** [Oui / Non / Non applicable] + +Un état des lieux contradictoire au départ et au retour est annexé au présent contrat et en fait partie intégrante. + +Le Loueur déclare que le véhicule mis à disposition est conforme aux conditions réglementaires d’exploitation applicables à sa catégorie, notamment en matière d’âge, d’immatriculation, d’assurance, d’entretien et de sécurité routière. + + +--- + +## 6. Entretien et conformité du véhicule + +Le Loueur déclare que le véhicule est régulièrement entretenu, assuré, immatriculé et conforme aux normes de sécurité routière applicables. + +Le Locataire reconnaît avoir reçu un véhicule en état apparent de fonctionnement, sous réserve des observations mentionnées dans l’état des lieux de départ. + +En cas d’anomalie mécanique, voyant d’alerte, bruit anormal, crevaison, panne ou incident susceptible d’affecter la sécurité, le Locataire doit cesser l’utilisation du véhicule dès que les conditions de sécurité le permettent et informer immédiatement le Loueur. + +--- + +## 7. Durée de location + +La location commence le : + +**Date :** [●] +**Heure :** [●] +**Lieu de prise en charge :** [●] + +La location prend fin le : + +**Date :** [●] +**Heure :** [●] +**Lieu de restitution :** [●] + +Toute prolongation doit être acceptée préalablement par écrit par le Loueur. À défaut, le véhicule sera considéré comme conservé sans autorisation, et le Loueur pourra facturer les jours supplémentaires, pénalités de retard et frais éventuels. + +--- + +## 8. Prix de location et modalités de paiement + +Le prix de location est fixé comme suit : + +| Désignation | Montant | +|---|---:| +| Tarif journalier HT | [●] MAD | +| Nombre de jours | [●] | +| Sous-total HT | [●] MAD | +| TVA applicable | [●] % | +| Montant TVA | [●] MAD | +| Total TTC | [●] MAD | +| Conducteur additionnel | [●] MAD | +| Livraison / récupération | [●] MAD | +| Siège enfant | [●] MAD | +| GPS / accessoires | [●] MAD | +| Autres frais | [●] MAD | + +**Montant total à payer TTC : [●] MAD** + +**Mode de paiement :** [Espèces / carte bancaire / virement / chèque / autre] +**Date de paiement :** [●] +**Référence de paiement :** [●] + +Le Locataire reconnaît avoir reçu une information claire sur le prix total TTC avant la signature du contrat. + +--- + +## 9. Dépôt de garantie / caution + +Le Locataire verse au Loueur un dépôt de garantie de : + +**Montant : [●] MAD** + +**Forme de la caution :** [Préautorisation bancaire / espèces / chèque / autre] +**Date de constitution :** [●] +**Conditions de libération :** [●] + +Le dépôt de garantie garantit notamment : + +- les dommages non couverts par l’assurance ; +- la franchise d’assurance ; +- les kilomètres supplémentaires ; +- le carburant manquant ; +- les frais de nettoyage exceptionnel ; +- les contraventions, amendes, péages ou frais administratifs ; +- les retards de restitution ; +- la perte de documents, clés, accessoires ou équipements. + +La caution sera restituée après vérification de l’état du véhicule, du kilométrage, du carburant, des éventuelles infractions et des sommes restant dues. + +--- + +## 10. Assurance + +Le véhicule est assuré auprès de : + +**Compagnie d’assurance :** [●] +**Numéro de police :** [●] +**Type de couverture :** [Responsabilité civile / Tous risques / autre] +**Franchise applicable :** [●] MAD +**Assistance :** [Oui / Non] +**Numéro d’assistance :** [●] + +Le Locataire reconnaît avoir été informé des garanties, exclusions et franchises applicables. + +Sont notamment exclus de la couverture, sauf stipulation contraire de l’assureur : + +- conduite par une personne non autorisée ; +- conduite sans permis valide ; +- conduite sous l’emprise d’alcool, stupéfiants ou substances interdites ; +- usage du véhicule hors des voies autorisées ; +- participation à des courses, essais ou compétitions ; +- négligence grave ; +- fausse déclaration ; +- absence de déclaration d’accident dans les délais ; +- vol avec clés laissées dans le véhicule ; +- transport illicite de personnes ou de marchandises. + +--- + +## 11. Kilométrage + +**Kilométrage inclus :** [●] km par jour / [●] km pour toute la durée de location. +**Kilométrage au départ :** [●] km. +**Kilométrage au retour :** [●] km. + +Tout kilomètre supplémentaire sera facturé au tarif de : + +**[●] MAD TTC par kilomètre supplémentaire.** + +Le kilométrage fait foi sur la base du compteur du véhicule, constaté dans l’état des lieux de départ et de retour. + +--- + +## 12. Utilisation du véhicule + +Le Locataire s’engage à utiliser le véhicule avec prudence, conformément à sa destination normale et à la législation marocaine. + +Il est interdit notamment : + +- de sous-louer le véhicule ; +- de transporter des marchandises dangereuses ou illicites ; +- d’utiliser le véhicule pour des activités illégales ; +- de participer à des compétitions ou essais sportifs ; +- de tracter un autre véhicule sans autorisation écrite ; +- de circuler hors du territoire marocain sans autorisation écrite du Loueur ; +- de modifier le véhicule ou ses équipements ; +- de fumer dans le véhicule si l’agence l’interdit ; +- de transporter un nombre de passagers supérieur à celui autorisé. + +Le Locataire est responsable des infractions au Code de la route commises pendant la période de location. + +--- + +## 13. Restitution du véhicule + +Le Locataire doit restituer le véhicule à la date, à l’heure et au lieu prévus au contrat, dans l’état où il lui a été remis, sous réserve de l’usure normale. + +Le véhicule doit être restitué avec : + +- le même niveau de carburant qu’au départ ; +- les papiers du véhicule ; +- les clés ; +- les accessoires et équipements remis ; +- un état de propreté normal ; +- aucun dommage nouveau non déclaré. + +En cas de retard, le Loueur pourra facturer : + +- [●] MAD par heure de retard ; ou +- une journée supplémentaire au tarif contractuel au-delà de [●] heures de retard. + +En cas de carburant manquant, les frais seront facturés selon le coût réel augmenté de frais de service de [●] MAD. + +En cas de salissure excessive, le Loueur pourra facturer des frais de nettoyage de [●] MAD à [●] MAD selon l’état du véhicule. + +--- + +## 14. État des lieux départ et retour + +Les Parties établissent un état des lieux au départ et au retour. Celui-ci peut être accompagné de photographies datées du véhicule, du compteur, du carburant, de la carrosserie, de l’intérieur, des pneus, des vitres et des équipements. + +Les éléments à contrôler comprennent notamment : + +- Kilométrage compteur ; +- Niveau de carburant ; +- Carrosserie ; +- Pare-chocs ; +- Rétroviseurs ; +- Phares et feux ; +- Pare-brise et vitres ; +- Pneus et jantes ; +- Intérieur et sièges ; +- Tableau de bord ; +- Roue de secours ; +- Outillage ; +- Documents du véhicule ; +- Clés et accessoires. + +Tout dommage constaté au retour et non mentionné dans l’état des lieux de départ pourra être facturé au Locataire, sous réserve des garanties d’assurance applicables. + +--- + +## 15. Accident, panne, vol ou sinistre + +En cas d’accident, panne, vol, tentative de vol, incendie ou tout autre sinistre, le Locataire doit immédiatement : + +- informer le Loueur par téléphone et par écrit ; +- prévenir les autorités compétentes si nécessaire ; +- établir un constat amiable en cas d’accident ; +- obtenir un procès-verbal ou document officiel en cas de vol, blessure, délit ou dommage important ; +- ne pas abandonner le véhicule sans autorisation du Loueur ; +- ne pas reconnaître de responsabilité sans accord préalable du Loueur ou de l’assureur. + +Le Locataire doit transmettre au Loueur tous les documents utiles dans un délai maximum de **48 heures** à compter du sinistre. + +En cas de non-respect de cette procédure, le Locataire pourra être tenu responsable des conséquences financières, notamment si l’assureur refuse sa garantie. + +--- + +## 16. Responsabilité du Locataire + +Le Locataire est responsable : + +- des dommages causés au véhicule pendant la période de location ; +- des dommages causés aux tiers dans les limites prévues par la loi et l’assurance ; +- des amendes, contraventions, frais de fourrière, péages et frais administratifs ; +- des frais liés à une mauvaise utilisation du véhicule ; +- des pertes de clés, documents ou accessoires ; +- des dommages non couverts par l’assurance ; +- de la franchise prévue au contrat d’assurance. + +Le Locataire reste responsable jusqu’à la restitution effective du véhicule au Loueur et la signature de l’état des lieux de retour. + +--- + +## 17. Infractions, amendes et frais administratifs + +Toutes les infractions commises pendant la durée de location sont à la charge du Locataire. + +Le Loueur pourra communiquer l’identité du Locataire aux autorités compétentes et facturer au Locataire : + +- le montant des amendes ; +- les frais de dossier ; +- les frais de fourrière ; +- les frais de notification ; +- tout coût lié au traitement administratif de l’infraction. + +**Frais administratifs par infraction : [●] MAD TTC.** + +--- + +## 18. Annulation, non-présentation et résiliation + +En cas d’annulation par le Locataire : + +- plus de [●] heures avant le départ : [conditions de remboursement] ; +- moins de [●] heures avant le départ : [conditions] ; +- non-présentation : [conditions]. + +Le Loueur peut résilier immédiatement le contrat, sans indemnité pour le Locataire, en cas de : + +- fausse déclaration ; +- permis invalide ; +- défaut de paiement ; +- utilisation interdite du véhicule ; +- conduite dangereuse ; +- non-respect grave du présent contrat ; +- suspicion légitime de fraude ou d’usage illicite. + +Dans ce cas, le Locataire doit restituer immédiatement le véhicule. + +--- + +## 19. Indisponibilité administrative, réglementaire ou technique + +En cas d’indisponibilité du véhicule ou d’impossibilité d’exécuter la location pour une raison administrative, réglementaire, technique ou de sécurité, le Loueur pourra proposer au Locataire un véhicule équivalent, sous réserve de disponibilité. + +À défaut de véhicule équivalent accepté par le Locataire, les sommes payées au titre de la période non exécutée seront remboursées, sans préjudice des droits légalement reconnus aux Parties. + +--- + +## 20. Données personnelles + +Le Locataire autorise le Loueur à collecter et conserver les données nécessaires à l’exécution du présent contrat, notamment son identité, ses coordonnées, son permis de conduire, les informations de paiement, les documents liés au véhicule, les états des lieux et les données relatives aux éventuels incidents. + +Ces données sont utilisées pour : + +- l’exécution du contrat ; +- la facturation ; +- la gestion des sinistres ; +- la gestion des infractions ; +- les obligations comptables, fiscales et légales ; +- la défense des droits du Loueur en cas de litige. + +Le Loueur s’engage à protéger les données personnelles du Locataire, à limiter leur accès aux personnes habilitées et à les conserver uniquement pendant la durée nécessaire aux finalités prévues et aux obligations légales applicables. + +--- + +## 21. Documents annexés + +Les documents suivants sont annexés au présent contrat : + +- Copie CIN ou passeport du Locataire ; +- Copie du permis de conduire ; +- Copie permis international, le cas échéant ; +- État des lieux de départ ; +- État des lieux de retour ; +- Photos du véhicule au départ ; +- Photos du véhicule au retour ; +- Copie de la carte grise ; +- Copie de l’attestation d’assurance ; +- Justificatif autorisant l’exploitation du véhicule par le Loueur, si le véhicule n’appartient pas directement au Loueur ; +- Reçu de paiement ; +- Justificatif de dépôt de garantie ; +- Conditions générales de location, le cas échéant. + +Les annexes font partie intégrante du présent contrat. + +--- + +## 22. Loi applicable et juridiction compétente + +Le présent contrat est soumis au droit marocain. + +En cas de litige relatif à sa validité, son interprétation, son exécution ou sa résiliation, les Parties s’efforceront de trouver une solution amiable. + +À défaut d’accord amiable, le litige sera porté devant le tribunal compétent du ressort de : + +**[Ville du siège de l’agence / tribunal compétent]** + +--- + +## 23. Déclaration finale + +Le Locataire déclare : + +- avoir lu et compris le présent contrat ; +- avoir reçu toutes les informations utiles avant signature ; +- avoir vérifié l’état du véhicule au départ ; +- être titulaire d’un permis valide ; +- s’engager à respecter les conditions du présent contrat ; +- accepter les prix, caution, franchises, pénalités et frais indiqués. + +**Fait à :** [●] +**Le :** [●] +**En deux exemplaires originaux.** + +Chaque page du présent contrat doit être paraphée par les deux Parties. + +### Signature du Loueur + +**Nom :** [●] +**Signature et cachet :** + +

+ +### Signature du Locataire + +**Nom :** [●] +**Signature :** + +

+ +--- + +# Annexe 1 — État des lieux de départ + +**Contrat n° :** [●] +**Date :** [●] +**Heure :** [●] +**Lieu :** [●] + +**Véhicule :** [Marque / Modèle] +**Immatriculation :** [●] +**Kilométrage départ :** [●] km +**Carburant départ :** [●] + +| Élément | État au départ | Observations | +|---|---|---| +| Carrosserie avant | Bon / Rayé / Endommagé | [●] | +| Carrosserie arrière | Bon / Rayé / Endommagé | [●] | +| Côté droit | Bon / Rayé / Endommagé | [●] | +| Côté gauche | Bon / Rayé / Endommagé | [●] | +| Pare-brise | Bon / Impact / Fissure | [●] | +| Vitres | Bon / Endommagé | [●] | +| Pneus | Bon / Usé / Endommagé | [●] | +| Jantes | Bon / Rayé / Endommagé | [●] | +| Intérieur | Bon / Taché / Endommagé | [●] | +| Sièges | Bon / Taché / Déchiré | [●] | +| Tableau de bord | Bon / Endommagé | [●] | +| Climatisation | Fonctionne / Ne fonctionne pas | [●] | +| Feux | Fonctionnent / Défaut | [●] | +| Roue de secours | Présente / Absente | [●] | +| Outillage | Présent / Absent | [●] | +| Carte grise | Présente / Absente | [●] | +| Assurance | Présente / Absente | [●] | +| Clés | [Nombre] | [●] | + +**Photos prises au départ :** Oui / Non +**Nombre de photos :** [●] + +**Signature du Loueur :** + +

+ +**Signature du Locataire :** + +

+ +--- + +# Annexe 2 — État des lieux de retour + +**Contrat n° :** [●] +**Date :** [●] +**Heure :** [●] +**Lieu :** [●] + +**Kilométrage retour :** [●] km +**Kilométrage parcouru :** [●] km +**Kilométrage inclus :** [●] km +**Kilométrage supplémentaire :** [●] km +**Montant km supplémentaires :** [●] MAD + +**Carburant retour :** [●] +**Carburant manquant :** Oui / Non +**Montant carburant :** [●] MAD + +| Élément | État au retour | Nouveau dommage ? | Observations | +|---|---|---|---| +| Carrosserie avant | Bon / Rayé / Endommagé | Oui / Non | [●] | +| Carrosserie arrière | Bon / Rayé / Endommagé | Oui / Non | [●] | +| Côté droit | Bon / Rayé / Endommagé | Oui / Non | [●] | +| Côté gauche | Bon / Rayé / Endommagé | Oui / Non | [●] | +| Pare-brise | Bon / Impact / Fissure | Oui / Non | [●] | +| Vitres | Bon / Endommagé | Oui / Non | [●] | +| Pneus | Bon / Usé / Endommagé | Oui / Non | [●] | +| Jantes | Bon / Rayé / Endommagé | Oui / Non | [●] | +| Intérieur | Bon / Taché / Endommagé | Oui / Non | [●] | +| Sièges | Bon / Taché / Déchiré | Oui / Non | [●] | +| Documents | Complets / Manquants | Oui / Non | [●] | +| Clés | Restituées / Manquantes | Oui / Non | [●] | + +## Frais constatés au retour + +| Frais | Montant | +|---|---:| +| Dommages | [●] MAD | +| Franchise assurance | [●] MAD | +| Kilométrage supplémentaire | [●] MAD | +| Carburant | [●] MAD | +| Nettoyage | [●] MAD | +| Retard | [●] MAD | +| Autres frais | [●] MAD | + +**Total à retenir sur la caution : [●] MAD** +**Solde de caution à restituer : [●] MAD** + +**Photos prises au retour :** Oui / Non +**Nombre de photos :** [●] + +**Signature du Loueur :** + +

+ +**Signature du Locataire :** + +

+ +--- + +# Checklist pratique avant signature + +- Ne laisser aucun champ `[●]` vide dans la version signée. +- Faire parapher chaque page par les deux Parties. +- Joindre la copie CIN ou passeport du Locataire. +- Joindre la copie du permis de conduire. +- Photographier le compteur et le niveau de carburant au départ et au retour. +- Photographier toutes les faces du véhicule au départ et au retour. +- Écrire clairement le montant de la caution. +- Mentionner la compagnie d’assurance, le numéro de police et la franchise. +- Conserver une preuve de paiement. +- Conserver les états des lieux signés. + +--- + +# Note importante + +Ce modèle est un document pratique destiné à structurer une location de voiture au Maroc. Il doit être adapté à l’activité réelle du Loueur, à ses conditions d’assurance, à son statut fiscal et aux règles applicables. Pour un usage commercial régulier, une validation par un juriste ou avocat marocain est recommandée. + +--- + +# Checklist interne de conformité du Loueur + +Cette checklist est destinée au Loueur et ne remplace pas les documents administratifs obligatoires. + +- Agrément / autorisation d’exercice disponible. +- Responsable réglementaire désigné. +- Casier judiciaire conforme, si exigé. +- Société inscrite à la CNSS, si applicable. +- Siège social ou local professionnel justifié. +- Capital social conforme au seuil applicable. +- Flotte conforme au seuil réglementaire applicable. +- Âge des véhicules conforme selon motorisation. +- Documents de propriété ou d’exploitation des véhicules disponibles. +- Assurance et visite technique valides pour chaque véhicule. +- Procédure de déclaration en cas de suspension ou cessation d’activité. + diff --git a/contrat_location_voiture_maroc_with_laws.md b/contrat_location_voiture_maroc_with_laws.md new file mode 100644 index 0000000..e281b2a --- /dev/null +++ b/contrat_location_voiture_maroc_with_laws.md @@ -0,0 +1,699 @@ +# Contrat de Location de Véhicule + +**Contrat n° :** [●] +**Date de signature :** [●] +**Lieu de signature :** [Ville, Maroc] + +--- + +## 1. Parties au contrat + +Entre les soussignés : + +### Le Loueur / Agence + +**Nom commercial :** [●] +**Raison sociale :** [●] +**Forme juridique :** [●] +**RC n° :** [●] +**ICE n° :** [●] +**IF n° :** [●] +**Agrément / Autorisation d’exercice n° :** [●] +**Date de délivrance de l’agrément :** [●] +**Autorité délivrante :** [●] +**Adresse :** [●] +**Téléphone :** [●] +**Email :** [●] +**Représenté par :** [Nom, prénom, fonction] + +Ci-après désigné **« le Loueur »**, + +Et : + +### Le Locataire / Conducteur principal + +**Nom :** [●] +**Prénom :** [●] +**Date de naissance :** [●] +**Nationalité :** [●] +**CIN / Passeport n° :** [●] +**Adresse complète :** [●] +**Téléphone :** [●] +**Email :** [●] + +Ci-après désigné **« le Locataire »**. + +Le Loueur et le Locataire sont ensemble désignés **« les Parties »**. + +--- + +## 2. Responsable légal et conformité réglementaire du Loueur + +Le Loueur déclare exercer l’activité de location de véhicules sans chauffeur conformément à la réglementation marocaine applicable. + +Le Loueur reconnaît que son activité doit respecter les textes mentionnés dans la section **Références légales et réglementaires applicables** du présent document. + +La personne responsable de l’activité est : + +**Nom et prénom :** [●] +**Qualité :** [Dirigeant / Actionnaire / Salarié] +**Pièce d’identité n° :** [●] +**Qualification / diplôme / expérience :** [●] +**Téléphone :** [●] +**Email :** [●] + +Cette personne est responsable du suivi des contrats, de l’entretien des véhicules, de leur conformité aux normes de sécurité routière et du respect des obligations administratives applicables. + +Le Loueur déclare notamment : + +- disposer des autorisations administratives requises pour l’exercice de l’activité ; +- être régulièrement immatriculé auprès des administrations compétentes ; +- disposer d’un siège social ou local professionnel déclaré ; +- respecter les obligations sociales, fiscales et administratives applicables ; +- exploiter une flotte conforme aux seuils et conditions réglementaires applicables ; +- maintenir les véhicules loués en état conforme aux normes de sécurité routière. + +--- + +## 3. Permis de conduire + +Le Locataire déclare être titulaire d’un permis de conduire valide : + +**Numéro du permis :** [●] +**Catégorie :** [B / autre] +**Date de délivrance :** [●] +**Date d’expiration :** [●] +**Pays de délivrance :** [●] +**Permis international, le cas échéant :** [Oui / Non, n° ●] + +Le Locataire garantit que son permis est valide pendant toute la durée de location et qu’il n’est frappé d’aucune suspension, annulation ou restriction incompatible avec la conduite du véhicule loué. + +--- + +## 4. Conducteur(s) autorisé(s) + +Le véhicule ne peut être conduit que par le Locataire et, le cas échéant, par les conducteurs additionnels expressément mentionnés ci-dessous : + +### Conducteur additionnel 1 + +**Nom et prénom :** [●] +**CIN / Passeport :** [●] +**Permis n° :** [●] +**Catégorie :** [●] +**Date de délivrance :** [●] +**Téléphone :** [●] + +Tout conducteur non déclaré est interdit. En cas d’accident, vol, infraction ou dommage causé par un conducteur non autorisé, le Locataire assume l’entière responsabilité des conséquences financières, civiles, pénales et assurantielles. + +--- + +## 5. Véhicule loué + +Le Loueur met à disposition du Locataire le véhicule suivant : + +**Marque :** [●] +**Modèle :** [●] +**Année :** [●] +**Couleur :** [●] +**Immatriculation :** [●] +**Numéro de châssis :** [●] +**Propriétaire du véhicule :** [Loueur / Autre agence / Société de financement / Autre] +**Justificatif d’exploitation si le véhicule appartient à un tiers :** [Contrat / Autorisation / Mandat / Autre] +**Type de motorisation :** [Thermique / Hybride / Électrique] +**Date de première mise en circulation :** [●] +**Âge du véhicule à la date de location :** [●] +**Véhicule conforme aux limites réglementaires d’exploitation :** [Oui / Non] +**Kilométrage au départ :** [●] km +**Niveau de carburant au départ :** [●] +**Carte grise :** [Oui / Non] +**Assurance :** [Oui / Non] +**Visite technique :** [Oui / Non / Non applicable] + +Un état des lieux contradictoire au départ et au retour est annexé au présent contrat et en fait partie intégrante. + +Le Loueur déclare que le véhicule mis à disposition est conforme aux conditions réglementaires d’exploitation applicables à sa catégorie, notamment en matière d’âge, d’immatriculation, d’assurance, d’entretien et de sécurité routière. + + +--- + +## 6. Entretien et conformité du véhicule + +Le Loueur déclare que le véhicule est régulièrement entretenu, assuré, immatriculé et conforme aux normes de sécurité routière applicables. + +Le Locataire reconnaît avoir reçu un véhicule en état apparent de fonctionnement, sous réserve des observations mentionnées dans l’état des lieux de départ. + +En cas d’anomalie mécanique, voyant d’alerte, bruit anormal, crevaison, panne ou incident susceptible d’affecter la sécurité, le Locataire doit cesser l’utilisation du véhicule dès que les conditions de sécurité le permettent et informer immédiatement le Loueur. + +--- + +## 7. Durée de location + +La location commence le : + +**Date :** [●] +**Heure :** [●] +**Lieu de prise en charge :** [●] + +La location prend fin le : + +**Date :** [●] +**Heure :** [●] +**Lieu de restitution :** [●] + +Toute prolongation doit être acceptée préalablement par écrit par le Loueur. À défaut, le véhicule sera considéré comme conservé sans autorisation, et le Loueur pourra facturer les jours supplémentaires, pénalités de retard et frais éventuels. + +--- + +## 8. Prix de location et modalités de paiement + +Le prix de location est fixé comme suit : + +| Désignation | Montant | +|---|---:| +| Tarif journalier HT | [●] MAD | +| Nombre de jours | [●] | +| Sous-total HT | [●] MAD | +| TVA applicable | [●] % | +| Montant TVA | [●] MAD | +| Total TTC | [●] MAD | +| Conducteur additionnel | [●] MAD | +| Livraison / récupération | [●] MAD | +| Siège enfant | [●] MAD | +| GPS / accessoires | [●] MAD | +| Autres frais | [●] MAD | + +**Montant total à payer TTC : [●] MAD** + +**Mode de paiement :** [Espèces / carte bancaire / virement / chèque / autre] +**Date de paiement :** [●] +**Référence de paiement :** [●] + +Le Locataire reconnaît avoir reçu une information claire sur le prix total TTC avant la signature du contrat. + +--- + +## 9. Dépôt de garantie / caution + +Le Locataire verse au Loueur un dépôt de garantie de : + +**Montant : [●] MAD** + +**Forme de la caution :** [Préautorisation bancaire / espèces / chèque / autre] +**Date de constitution :** [●] +**Conditions de libération :** [●] + +Le dépôt de garantie garantit notamment : + +- les dommages non couverts par l’assurance ; +- la franchise d’assurance ; +- les kilomètres supplémentaires ; +- le carburant manquant ; +- les frais de nettoyage exceptionnel ; +- les contraventions, amendes, péages ou frais administratifs ; +- les retards de restitution ; +- la perte de documents, clés, accessoires ou équipements. + +La caution sera restituée après vérification de l’état du véhicule, du kilométrage, du carburant, des éventuelles infractions et des sommes restant dues. + +--- + +## 10. Assurance + +Le véhicule est assuré auprès de : + +**Compagnie d’assurance :** [●] +**Numéro de police :** [●] +**Type de couverture :** [Responsabilité civile / Tous risques / autre] +**Franchise applicable :** [●] MAD +**Assistance :** [Oui / Non] +**Numéro d’assistance :** [●] + +Le Locataire reconnaît avoir été informé des garanties, exclusions et franchises applicables. + +Sont notamment exclus de la couverture, sauf stipulation contraire de l’assureur : + +- conduite par une personne non autorisée ; +- conduite sans permis valide ; +- conduite sous l’emprise d’alcool, stupéfiants ou substances interdites ; +- usage du véhicule hors des voies autorisées ; +- participation à des courses, essais ou compétitions ; +- négligence grave ; +- fausse déclaration ; +- absence de déclaration d’accident dans les délais ; +- vol avec clés laissées dans le véhicule ; +- transport illicite de personnes ou de marchandises. + +--- + +## 11. Kilométrage + +**Kilométrage inclus :** [●] km par jour / [●] km pour toute la durée de location. +**Kilométrage au départ :** [●] km. +**Kilométrage au retour :** [●] km. + +Tout kilomètre supplémentaire sera facturé au tarif de : + +**[●] MAD TTC par kilomètre supplémentaire.** + +Le kilométrage fait foi sur la base du compteur du véhicule, constaté dans l’état des lieux de départ et de retour. + +--- + +## 12. Utilisation du véhicule + +Le Locataire s’engage à utiliser le véhicule avec prudence, conformément à sa destination normale et à la législation marocaine. + +Il est interdit notamment : + +- de sous-louer le véhicule ; +- de transporter des marchandises dangereuses ou illicites ; +- d’utiliser le véhicule pour des activités illégales ; +- de participer à des compétitions ou essais sportifs ; +- de tracter un autre véhicule sans autorisation écrite ; +- de circuler hors du territoire marocain sans autorisation écrite du Loueur ; +- de modifier le véhicule ou ses équipements ; +- de fumer dans le véhicule si l’agence l’interdit ; +- de transporter un nombre de passagers supérieur à celui autorisé. + +Le Locataire est responsable des infractions au Code de la route commises pendant la période de location. + +--- + +## 13. Restitution du véhicule + +Le Locataire doit restituer le véhicule à la date, à l’heure et au lieu prévus au contrat, dans l’état où il lui a été remis, sous réserve de l’usure normale. + +Le véhicule doit être restitué avec : + +- le même niveau de carburant qu’au départ ; +- les papiers du véhicule ; +- les clés ; +- les accessoires et équipements remis ; +- un état de propreté normal ; +- aucun dommage nouveau non déclaré. + +En cas de retard, le Loueur pourra facturer : + +- [●] MAD par heure de retard ; ou +- une journée supplémentaire au tarif contractuel au-delà de [●] heures de retard. + +En cas de carburant manquant, les frais seront facturés selon le coût réel augmenté de frais de service de [●] MAD. + +En cas de salissure excessive, le Loueur pourra facturer des frais de nettoyage de [●] MAD à [●] MAD selon l’état du véhicule. + +--- + +## 14. État des lieux départ et retour + +Les Parties établissent un état des lieux au départ et au retour. Celui-ci peut être accompagné de photographies datées du véhicule, du compteur, du carburant, de la carrosserie, de l’intérieur, des pneus, des vitres et des équipements. + +Les éléments à contrôler comprennent notamment : + +- Kilométrage compteur ; +- Niveau de carburant ; +- Carrosserie ; +- Pare-chocs ; +- Rétroviseurs ; +- Phares et feux ; +- Pare-brise et vitres ; +- Pneus et jantes ; +- Intérieur et sièges ; +- Tableau de bord ; +- Roue de secours ; +- Outillage ; +- Documents du véhicule ; +- Clés et accessoires. + +Tout dommage constaté au retour et non mentionné dans l’état des lieux de départ pourra être facturé au Locataire, sous réserve des garanties d’assurance applicables. + +--- + +## 15. Accident, panne, vol ou sinistre + +En cas d’accident, panne, vol, tentative de vol, incendie ou tout autre sinistre, le Locataire doit immédiatement : + +- informer le Loueur par téléphone et par écrit ; +- prévenir les autorités compétentes si nécessaire ; +- établir un constat amiable en cas d’accident ; +- obtenir un procès-verbal ou document officiel en cas de vol, blessure, délit ou dommage important ; +- ne pas abandonner le véhicule sans autorisation du Loueur ; +- ne pas reconnaître de responsabilité sans accord préalable du Loueur ou de l’assureur. + +Le Locataire doit transmettre au Loueur tous les documents utiles dans un délai maximum de **48 heures** à compter du sinistre. + +En cas de non-respect de cette procédure, le Locataire pourra être tenu responsable des conséquences financières, notamment si l’assureur refuse sa garantie. + +--- + +## 16. Responsabilité du Locataire + +Le Locataire est responsable : + +- des dommages causés au véhicule pendant la période de location ; +- des dommages causés aux tiers dans les limites prévues par la loi et l’assurance ; +- des amendes, contraventions, frais de fourrière, péages et frais administratifs ; +- des frais liés à une mauvaise utilisation du véhicule ; +- des pertes de clés, documents ou accessoires ; +- des dommages non couverts par l’assurance ; +- de la franchise prévue au contrat d’assurance. + +Le Locataire reste responsable jusqu’à la restitution effective du véhicule au Loueur et la signature de l’état des lieux de retour. + +--- + +## 17. Infractions, amendes et frais administratifs + +Toutes les infractions commises pendant la durée de location sont à la charge du Locataire. + +Le Loueur pourra communiquer l’identité du Locataire aux autorités compétentes et facturer au Locataire : + +- le montant des amendes ; +- les frais de dossier ; +- les frais de fourrière ; +- les frais de notification ; +- tout coût lié au traitement administratif de l’infraction. + +**Frais administratifs par infraction : [●] MAD TTC.** + +--- + +## 18. Annulation, non-présentation et résiliation + +En cas d’annulation par le Locataire : + +- plus de [●] heures avant le départ : [conditions de remboursement] ; +- moins de [●] heures avant le départ : [conditions] ; +- non-présentation : [conditions]. + +Le Loueur peut résilier immédiatement le contrat, sans indemnité pour le Locataire, en cas de : + +- fausse déclaration ; +- permis invalide ; +- défaut de paiement ; +- utilisation interdite du véhicule ; +- conduite dangereuse ; +- non-respect grave du présent contrat ; +- suspicion légitime de fraude ou d’usage illicite. + +Dans ce cas, le Locataire doit restituer immédiatement le véhicule. + +--- + +## 19. Indisponibilité administrative, réglementaire ou technique + +En cas d’indisponibilité du véhicule ou d’impossibilité d’exécuter la location pour une raison administrative, réglementaire, technique ou de sécurité, le Loueur pourra proposer au Locataire un véhicule équivalent, sous réserve de disponibilité. + +À défaut de véhicule équivalent accepté par le Locataire, les sommes payées au titre de la période non exécutée seront remboursées, sans préjudice des droits légalement reconnus aux Parties. + +--- + +## 20. Données personnelles + +Le Locataire autorise le Loueur à collecter et conserver les données nécessaires à l’exécution du présent contrat, notamment son identité, ses coordonnées, son permis de conduire, les informations de paiement, les documents liés au véhicule, les états des lieux et les données relatives aux éventuels incidents. + +Ces données sont utilisées pour : + +- l’exécution du contrat ; +- la facturation ; +- la gestion des sinistres ; +- la gestion des infractions ; +- les obligations comptables, fiscales et légales ; +- la défense des droits du Loueur en cas de litige. + +Le Loueur s’engage à protéger les données personnelles du Locataire, à limiter leur accès aux personnes habilitées et à les conserver uniquement pendant la durée nécessaire aux finalités prévues et aux obligations légales applicables. + +--- + +## 21. Documents annexés + +Les documents suivants sont annexés au présent contrat : + +- Copie CIN ou passeport du Locataire ; +- Copie du permis de conduire ; +- Copie permis international, le cas échéant ; +- État des lieux de départ ; +- État des lieux de retour ; +- Photos du véhicule au départ ; +- Photos du véhicule au retour ; +- Copie de la carte grise ; +- Copie de l’attestation d’assurance ; +- Justificatif autorisant l’exploitation du véhicule par le Loueur, si le véhicule n’appartient pas directement au Loueur ; +- Reçu de paiement ; +- Justificatif de dépôt de garantie ; +- Conditions générales de location, le cas échéant. +- Liste des références légales et réglementaires applicables ; + +Les annexes font partie intégrante du présent contrat. + +--- + +## 22. Loi applicable et juridiction compétente + +Le présent contrat est soumis au droit marocain. + +En cas de litige relatif à sa validité, son interprétation, son exécution ou sa résiliation, les Parties s’efforceront de trouver une solution amiable. + +À défaut d’accord amiable, le litige sera porté devant le tribunal compétent du ressort de : + +**[Ville du siège de l’agence / tribunal compétent]** + +--- + +## 23. Déclaration finale + +Le Locataire déclare : + +- avoir lu et compris le présent contrat ; +- avoir reçu toutes les informations utiles avant signature ; +- avoir vérifié l’état du véhicule au départ ; +- être titulaire d’un permis valide ; +- s’engager à respecter les conditions du présent contrat ; +- accepter les prix, caution, franchises, pénalités et frais indiqués. + +**Fait à :** [●] +**Le :** [●] +**En deux exemplaires originaux.** + +Chaque page du présent contrat doit être paraphée par les deux Parties. + +### Signature du Loueur + +**Nom :** [●] +**Signature et cachet :** + +

+ +### Signature du Locataire + +**Nom :** [●] +**Signature :** + +

+ +--- + +# Annexe 1 — État des lieux de départ + +**Contrat n° :** [●] +**Date :** [●] +**Heure :** [●] +**Lieu :** [●] + +**Véhicule :** [Marque / Modèle] +**Immatriculation :** [●] +**Kilométrage départ :** [●] km +**Carburant départ :** [●] + +| Élément | État au départ | Observations | +|---|---|---| +| Carrosserie avant | Bon / Rayé / Endommagé | [●] | +| Carrosserie arrière | Bon / Rayé / Endommagé | [●] | +| Côté droit | Bon / Rayé / Endommagé | [●] | +| Côté gauche | Bon / Rayé / Endommagé | [●] | +| Pare-brise | Bon / Impact / Fissure | [●] | +| Vitres | Bon / Endommagé | [●] | +| Pneus | Bon / Usé / Endommagé | [●] | +| Jantes | Bon / Rayé / Endommagé | [●] | +| Intérieur | Bon / Taché / Endommagé | [●] | +| Sièges | Bon / Taché / Déchiré | [●] | +| Tableau de bord | Bon / Endommagé | [●] | +| Climatisation | Fonctionne / Ne fonctionne pas | [●] | +| Feux | Fonctionnent / Défaut | [●] | +| Roue de secours | Présente / Absente | [●] | +| Outillage | Présent / Absent | [●] | +| Carte grise | Présente / Absente | [●] | +| Assurance | Présente / Absente | [●] | +| Clés | [Nombre] | [●] | + +**Photos prises au départ :** Oui / Non +**Nombre de photos :** [●] + +**Signature du Loueur :** + +

+ +**Signature du Locataire :** + +

+ +--- + +# Annexe 2 — État des lieux de retour + +**Contrat n° :** [●] +**Date :** [●] +**Heure :** [●] +**Lieu :** [●] + +**Kilométrage retour :** [●] km +**Kilométrage parcouru :** [●] km +**Kilométrage inclus :** [●] km +**Kilométrage supplémentaire :** [●] km +**Montant km supplémentaires :** [●] MAD + +**Carburant retour :** [●] +**Carburant manquant :** Oui / Non +**Montant carburant :** [●] MAD + +| Élément | État au retour | Nouveau dommage ? | Observations | +|---|---|---|---| +| Carrosserie avant | Bon / Rayé / Endommagé | Oui / Non | [●] | +| Carrosserie arrière | Bon / Rayé / Endommagé | Oui / Non | [●] | +| Côté droit | Bon / Rayé / Endommagé | Oui / Non | [●] | +| Côté gauche | Bon / Rayé / Endommagé | Oui / Non | [●] | +| Pare-brise | Bon / Impact / Fissure | Oui / Non | [●] | +| Vitres | Bon / Endommagé | Oui / Non | [●] | +| Pneus | Bon / Usé / Endommagé | Oui / Non | [●] | +| Jantes | Bon / Rayé / Endommagé | Oui / Non | [●] | +| Intérieur | Bon / Taché / Endommagé | Oui / Non | [●] | +| Sièges | Bon / Taché / Déchiré | Oui / Non | [●] | +| Documents | Complets / Manquants | Oui / Non | [●] | +| Clés | Restituées / Manquantes | Oui / Non | [●] | + +## Frais constatés au retour + +| Frais | Montant | +|---|---:| +| Dommages | [●] MAD | +| Franchise assurance | [●] MAD | +| Kilométrage supplémentaire | [●] MAD | +| Carburant | [●] MAD | +| Nettoyage | [●] MAD | +| Retard | [●] MAD | +| Autres frais | [●] MAD | + +**Total à retenir sur la caution : [●] MAD** +**Solde de caution à restituer : [●] MAD** + +**Photos prises au retour :** Oui / Non +**Nombre de photos :** [●] + +**Signature du Loueur :** + +

+ +**Signature du Locataire :** + +

+ +--- + +# Checklist pratique avant signature + +- Ne laisser aucun champ `[●]` vide dans la version signée. +- Faire parapher chaque page par les deux Parties. +- Joindre la copie CIN ou passeport du Locataire. +- Joindre la copie du permis de conduire. +- Photographier le compteur et le niveau de carburant au départ et au retour. +- Photographier toutes les faces du véhicule au départ et au retour. +- Écrire clairement le montant de la caution. +- Mentionner la compagnie d’assurance, le numéro de police et la franchise. +- Conserver une preuve de paiement. +- Conserver les états des lieux signés. + +--- + +# Note importante + +Ce modèle est un document pratique destiné à structurer une location de voiture au Maroc. Il doit être adapté à l’activité réelle du Loueur, à ses conditions d’assurance, à son statut fiscal et aux règles applicables. Pour un usage commercial régulier, une validation par un juriste ou avocat marocain est recommandée. + + +--- + +# Références légales et réglementaires applicables + +Les références suivantes sont indiquées à titre de base juridique générale pour l’activité de location de véhicules sans chauffeur au Maroc. Elles doivent être vérifiées et actualisées selon les textes officiels en vigueur, notamment le Bulletin Officiel et les circulaires ou cahiers des charges applicables. + +## Textes principaux relatifs à la location de véhicules + +| Référence | Objet | Utilité pour le présent contrat | +|---|---|---| +| **Dahir n° 1-63-260 du 12 novembre 1963** | Transports par véhicules automobiles sur route | Cadre général applicable au transport routier et aux activités connexes. | +| **Décret n° 2.69.351 du 4 avril 1970** | Conditions d’exploitation des voitures louées sans chauffeur | Texte spécifique encadrant l’exploitation des véhicules loués sans chauffeur. | +| **Cahier des charges relatif à la location de voitures sans chauffeur, tel qu’en vigueur** | Conditions administratives, techniques et professionnelles de l’activité | Référence pratique pour l’agrément, la flotte, le responsable d’activité, l’âge des véhicules, les obligations administratives et les sanctions. | + +## Circulation, sécurité routière et véhicules + +| Référence | Objet | Utilité pour le présent contrat | +|---|---|---| +| **Loi n° 52-05 portant Code de la route** | Règles de circulation, infractions, sécurité routière, véhicules et conducteurs | Encadre l’usage du véhicule, les infractions, la responsabilité du conducteur et les obligations de sécurité. | +| **Dahir n° 1-10-07 du 11 février 2010** | Promulgation de la Loi n° 52-05 portant Code de la route | Texte de promulgation du Code de la route. | + +## Assurance, responsabilité et obligations contractuelles + +| Référence | Objet | Utilité pour le présent contrat | +|---|---|---| +| **Loi n° 17-99 portant Code des assurances** | Assurance automobile, garanties, exclusions, franchises et responsabilité assurantielle | Encadre les assurances liées au véhicule loué, les garanties, les exclusions et les franchises. | +| **Dahir des Obligations et des Contrats du 12 août 1913** | Règles générales des contrats, obligations, responsabilité civile, consentement et inexécution | Base générale du contrat de location et de la responsabilité des Parties. | + +## Droit commercial et structure de l’agence + +| Référence | Objet | Utilité pour le présent contrat | +|---|---|---| +| **Loi n° 15-95 formant Code de commerce** | Activité commerciale, commerçants, obligations commerciales, registre de commerce | Applicable à l’activité commerciale du Loueur. | +| **Loi n° 5-96** | Sociétés à responsabilité limitée, sociétés en nom collectif, sociétés en commandite simple, sociétés en commandite par actions et sociétés en participation | Applicable si le Loueur est constitué sous l’une de ces formes, notamment SARL. | +| **Loi n° 17-95 relative aux sociétés anonymes** | Sociétés anonymes | Applicable si le Loueur est constitué sous forme de société anonyme. | +| **Loi n° 69-21 relative aux délais de paiement** | Délais de paiement entre professionnels | Applicable notamment aux locations ou relations commerciales B2B. | + +## Protection du consommateur, données personnelles et signature électronique + +| Référence | Objet | Utilité pour le présent contrat | +|---|---|---| +| **Loi n° 31-08 édictant des mesures de protection du consommateur** | Information du consommateur, transparence des prix, clauses abusives, droits du client | Applicable lorsque le Locataire agit comme consommateur. | +| **Loi n° 09-08 relative à la protection des personnes physiques à l’égard du traitement des données à caractère personnel** | Collecte, traitement, conservation et protection des données personnelles | Applicable aux données du Locataire : CIN, passeport, permis, coordonnées, signatures, documents et photos. | +| **Loi n° 53-05 relative à l’échange électronique de données juridiques** | Documents électroniques, signature électronique et valeur juridique des échanges numériques | Applicable si le contrat est signé, transmis ou conservé électroniquement. | + +## Obligations sociales, fiscales et administratives + +| Référence | Objet | Utilité pour le présent contrat | +|---|---|---| +| **Dahir n° 1-72-184 relatif au régime de sécurité sociale** | Régime CNSS et obligations sociales | Pertinent pour la conformité sociale du Loueur lorsqu’il emploie du personnel. | +| **Loi n° 47-06 relative à la fiscalité des collectivités locales** | Fiscalité locale, taxe professionnelle et obligations locales | Pertinente pour les obligations fiscales locales de l’agence. | +| **Code Général des Impôts** | TVA, impôt sur les sociétés, impôt sur le revenu, facturation et obligations fiscales | Pertinent pour la facturation, les déclarations fiscales et le traitement de la TVA. | + +## Clause de prudence juridique + +Le présent contrat doit être interprété et appliqué conformément aux textes marocains en vigueur à la date de signature. + +Lorsque le présent contrat mentionne le **cahier des charges relatif à la location de voitures sans chauffeur**, cette référence vise le cahier des charges applicable tel qu’en vigueur, y compris ses modifications, circulaires d’application ou textes complémentaires. + +En cas de contradiction entre le présent contrat et une disposition légale ou réglementaire impérative, la disposition légale ou réglementaire prévaut. Les autres clauses du contrat demeurent applicables dans la mesure permise par la loi. + + +--- + +# Checklist interne de conformité du Loueur + +Cette checklist est destinée au Loueur et ne remplace pas les documents administratifs obligatoires. + +- Agrément / autorisation d’exercice disponible. +- Responsable réglementaire désigné. +- Casier judiciaire conforme, si exigé. +- Société inscrite à la CNSS, si applicable. +- Siège social ou local professionnel justifié. +- Capital social conforme au seuil applicable. +- Flotte conforme au seuil réglementaire applicable. +- Âge des véhicules conforme selon motorisation. +- Documents de propriété ou d’exploitation des véhicules disponibles. +- Assurance et visite technique valides pour chaque véhicule. +- Procédure de déclaration en cas de suspension ou cessation d’activité. diff --git a/packages/database/prisma/migrations/20260525170000_b2b_billing_foundation/migration.sql b/packages/database/prisma/migrations/20260525170000_b2b_billing_foundation/migration.sql new file mode 100644 index 0000000..3e20ea4 --- /dev/null +++ b/packages/database/prisma/migrations/20260525170000_b2b_billing_foundation/migration.sql @@ -0,0 +1,493 @@ +CREATE TYPE "BillingInvoiceTerms" AS ENUM ( + 'DUE_ON_RECEIPT', + 'NET_7', + 'NET_15', + 'NET_30', + 'NET_45', + 'NET_60' +); + +CREATE TYPE "BillingInvoiceStatus" AS ENUM ( + 'DRAFT', + 'OPEN', + 'PAID', + 'PARTIALLY_PAID', + 'PAYMENT_PENDING', + 'PAST_DUE', + 'VOID', + 'UNCOLLECTIBLE', + 'REFUNDED', + 'PARTIALLY_REFUNDED' +); + +CREATE TYPE "BillingInvoiceType" AS ENUM ( + 'SUBSCRIPTION_INITIAL', + 'SUBSCRIPTION_RENEWAL', + 'TRIAL_CONVERSION', + 'SUBSCRIPTION_UPGRADE', + 'SUBSCRIPTION_DOWNGRADE_CREDIT', + 'USAGE_BASED', + 'ONE_TIME', + 'MANUAL', + 'CORRECTION', + 'CANCELLATION_FEE' +); + +CREATE TYPE "BillingLineItemType" AS ENUM ( + 'SUBSCRIPTION_FEE', + 'SEAT_FEE', + 'USAGE_FEE', + 'SETUP_FEE', + 'DISCOUNT', + 'TAX', + 'CREDIT', + 'PRORATION', + 'REFUND_ADJUSTMENT', + 'MANUAL_ADJUSTMENT', + 'PROFESSIONAL_SERVICES', + 'CANCELLATION_FEE' +); + +CREATE TYPE "BillingPaymentMethodType" AS ENUM ( + 'CARD', + 'ACH_DEBIT', + 'BANK_TRANSFER', + 'WIRE_TRANSFER', + 'MANUAL_INVOICE', + 'PURCHASE_ORDER' +); + +CREATE TYPE "BillingPaymentIntentStatus" AS ENUM ( + 'REQUIRES_PAYMENT_METHOD', + 'REQUIRES_CONFIRMATION', + 'REQUIRES_ACTION', + 'PROCESSING', + 'SUCCEEDED', + 'FAILED', + 'CANCELED', + 'REFUNDED', + 'PARTIALLY_REFUNDED' +); + +CREATE TYPE "BillingPaymentAttemptStatus" AS ENUM ( + 'PENDING', + 'PROCESSING', + 'SUCCEEDED', + 'FAILED', + 'CANCELED', + 'REFUNDED', + 'PARTIALLY_REFUNDED' +); + +CREATE TYPE "BillingRefundStatus" AS ENUM ( + 'PENDING', + 'SUCCEEDED', + 'FAILED' +); + +CREATE TYPE "BillingCreditNoteStatus" AS ENUM ( + 'ISSUED', + 'APPLIED', + 'VOID' +); + +CREATE SEQUENCE "billing_invoice_sequence_seq" START 1; + +CREATE TABLE "billing_accounts" ( + "id" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "isPrimary" BOOLEAN NOT NULL DEFAULT true, + "legalName" TEXT NOT NULL, + "billingEmail" TEXT NOT NULL, + "billingAddress" JSONB, + "taxId" TEXT, + "taxExempt" BOOLEAN NOT NULL DEFAULT false, + "defaultCurrency" TEXT NOT NULL DEFAULT 'MAD', + "defaultPaymentMethodId" TEXT, + "invoiceTerms" "BillingInvoiceTerms" NOT NULL DEFAULT 'DUE_ON_RECEIPT', + "netTermsDays" INTEGER NOT NULL DEFAULT 0, + "providerCustomerId" TEXT, + "dunningPaused" BOOLEAN NOT NULL DEFAULT false, + "dunningPausedAt" TIMESTAMP(3), + "dunningPausedBy" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_accounts_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "billing_payment_methods" ( + "id" TEXT NOT NULL, + "billingAccountId" TEXT NOT NULL, + "type" "BillingPaymentMethodType" NOT NULL, + "providerPaymentMethodId" TEXT, + "label" TEXT, + "brand" TEXT, + "last4" TEXT, + "expMonth" INTEGER, + "expYear" INTEGER, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_payment_methods_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "billing_invoices" ( + "id" TEXT NOT NULL, + "billingAccountId" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "subscriptionId" TEXT, + "invoiceNumber" TEXT, + "invoiceSequence" INTEGER, + "invoiceType" "BillingInvoiceType" NOT NULL, + "status" "BillingInvoiceStatus" NOT NULL DEFAULT 'DRAFT', + "currency" TEXT NOT NULL DEFAULT 'MAD', + "subtotalAmount" INTEGER NOT NULL DEFAULT 0, + "discountAmount" INTEGER NOT NULL DEFAULT 0, + "creditAmount" INTEGER NOT NULL DEFAULT 0, + "taxAmount" INTEGER NOT NULL DEFAULT 0, + "totalAmount" INTEGER NOT NULL DEFAULT 0, + "amountPaid" INTEGER NOT NULL DEFAULT 0, + "amountDue" INTEGER NOT NULL DEFAULT 0, + "invoiceDate" TIMESTAMP(3), + "dueAt" TIMESTAMP(3), + "finalizedAt" TIMESTAMP(3), + "paidAt" TIMESTAMP(3), + "voidedAt" TIMESTAMP(3), + "markedUncollectibleAt" TIMESTAMP(3), + "billingName" TEXT, + "billingEmail" TEXT, + "billingAddress" JSONB, + "providerInvoiceId" TEXT, + "paymentProvider" "PaymentProvider", + "isSubscriptionBlocking" BOOLEAN NOT NULL DEFAULT false, + "adminReason" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdByAdminId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_invoices_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "billing_invoice_line_items" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "subscriptionId" TEXT, + "plan" "Plan", + "type" "BillingLineItemType" NOT NULL, + "description" TEXT NOT NULL, + "quantity" INTEGER NOT NULL DEFAULT 1, + "unitAmount" INTEGER NOT NULL, + "amount" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'MAD', + "periodStart" TIMESTAMP(3), + "periodEnd" TIMESTAMP(3), + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_invoice_line_items_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "billing_payment_intents" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "billingAccountId" TEXT NOT NULL, + "paymentMethodId" TEXT, + "providerPaymentIntentId" TEXT, + "status" "BillingPaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_PAYMENT_METHOD', + "amount" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'MAD', + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_payment_intents_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "billing_payment_attempts" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "billingAccountId" TEXT NOT NULL, + "paymentIntentId" TEXT, + "paymentMethodId" TEXT, + "providerPaymentId" TEXT, + "status" "BillingPaymentAttemptStatus" NOT NULL, + "amount" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'MAD', + "failureCode" TEXT, + "failureMessage" TEXT, + "attemptedAt" TIMESTAMP(3) NOT NULL, + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_payment_attempts_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "billing_credit_balances" ( + "id" TEXT NOT NULL, + "billingAccountId" TEXT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'MAD', + "balanceAmount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_credit_balances_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "billing_credit_ledger_entries" ( + "id" TEXT NOT NULL, + "billingAccountId" TEXT NOT NULL, + "invoiceId" TEXT, + "type" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'MAD', + "reason" TEXT NOT NULL, + "createdBy" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_credit_ledger_entries_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "billing_credit_notes" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "billingAccountId" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'MAD', + "reason" TEXT NOT NULL, + "status" "BillingCreditNoteStatus" NOT NULL DEFAULT 'ISSUED', + "createdBy" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_credit_notes_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "billing_refunds" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "paymentAttemptId" TEXT, + "billingAccountId" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'MAD', + "reason" TEXT NOT NULL, + "status" "BillingRefundStatus" NOT NULL DEFAULT 'PENDING', + "providerRefundId" TEXT, + "createdBy" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_refunds_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "billing_tax_records" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "jurisdiction" TEXT, + "taxRate" DOUBLE PRECISION, + "taxAmount" INTEGER NOT NULL, + "taxType" TEXT, + "taxExempt" BOOLEAN NOT NULL DEFAULT false, + "exemptionReason" TEXT, + "providerTaxId" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_tax_records_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "billing_events" ( + "id" TEXT NOT NULL, + "billingAccountId" TEXT, + "invoiceId" TEXT, + "subscriptionId" TEXT, + "companyId" TEXT, + "eventType" TEXT NOT NULL, + "source" TEXT NOT NULL, + "payload" JSONB NOT NULL DEFAULT '{}', + "occurredAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "billing_events_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "subscription_invoices" + ADD COLUMN "billingInvoiceId" TEXT; + +CREATE UNIQUE INDEX "subscription_invoices_billingInvoiceId_key" ON "subscription_invoices"("billingInvoiceId"); +CREATE UNIQUE INDEX "billing_invoices_invoiceNumber_key" ON "billing_invoices"("invoiceNumber"); +CREATE UNIQUE INDEX "billing_invoices_invoiceSequence_key" ON "billing_invoices"("invoiceSequence"); +CREATE UNIQUE INDEX "billing_credit_balances_billingAccountId_currency_key" ON "billing_credit_balances"("billingAccountId", "currency"); + +CREATE INDEX "billing_accounts_companyId_idx" ON "billing_accounts"("companyId"); +CREATE INDEX "billing_accounts_companyId_isPrimary_idx" ON "billing_accounts"("companyId", "isPrimary"); +CREATE INDEX "billing_payment_methods_billingAccountId_idx" ON "billing_payment_methods"("billingAccountId"); +CREATE INDEX "billing_invoices_billingAccountId_idx" ON "billing_invoices"("billingAccountId"); +CREATE INDEX "billing_invoices_companyId_idx" ON "billing_invoices"("companyId"); +CREATE INDEX "billing_invoices_subscriptionId_idx" ON "billing_invoices"("subscriptionId"); +CREATE INDEX "billing_invoice_line_items_invoiceId_idx" ON "billing_invoice_line_items"("invoiceId"); +CREATE INDEX "billing_payment_intents_invoiceId_idx" ON "billing_payment_intents"("invoiceId"); +CREATE INDEX "billing_payment_intents_billingAccountId_idx" ON "billing_payment_intents"("billingAccountId"); +CREATE INDEX "billing_payment_attempts_invoiceId_idx" ON "billing_payment_attempts"("invoiceId"); +CREATE INDEX "billing_payment_attempts_billingAccountId_idx" ON "billing_payment_attempts"("billingAccountId"); +CREATE INDEX "billing_credit_ledger_entries_billingAccountId_idx" ON "billing_credit_ledger_entries"("billingAccountId"); +CREATE INDEX "billing_credit_notes_invoiceId_idx" ON "billing_credit_notes"("invoiceId"); +CREATE INDEX "billing_credit_notes_billingAccountId_idx" ON "billing_credit_notes"("billingAccountId"); +CREATE INDEX "billing_refunds_invoiceId_idx" ON "billing_refunds"("invoiceId"); +CREATE INDEX "billing_refunds_billingAccountId_idx" ON "billing_refunds"("billingAccountId"); +CREATE INDEX "billing_tax_records_invoiceId_idx" ON "billing_tax_records"("invoiceId"); +CREATE INDEX "billing_events_billingAccountId_idx" ON "billing_events"("billingAccountId"); +CREATE INDEX "billing_events_invoiceId_idx" ON "billing_events"("invoiceId"); +CREATE INDEX "billing_events_subscriptionId_idx" ON "billing_events"("subscriptionId"); +CREATE INDEX "billing_events_companyId_idx" ON "billing_events"("companyId"); + +ALTER TABLE "billing_accounts" + ADD CONSTRAINT "billing_accounts_companyId_fkey" + FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_payment_methods" + ADD CONSTRAINT "billing_payment_methods_billingAccountId_fkey" + FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_accounts" + ADD CONSTRAINT "billing_accounts_defaultPaymentMethodId_fkey" + FOREIGN KEY ("defaultPaymentMethodId") REFERENCES "billing_payment_methods"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "billing_invoices" + ADD CONSTRAINT "billing_invoices_billingAccountId_fkey" + FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_invoices" + ADD CONSTRAINT "billing_invoices_companyId_fkey" + FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_invoices" + ADD CONSTRAINT "billing_invoices_subscriptionId_fkey" + FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "subscription_invoices" + ADD CONSTRAINT "subscription_invoices_billingInvoiceId_fkey" + FOREIGN KEY ("billingInvoiceId") REFERENCES "billing_invoices"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "billing_invoice_line_items" + ADD CONSTRAINT "billing_invoice_line_items_invoiceId_fkey" + FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_payment_intents" + ADD CONSTRAINT "billing_payment_intents_invoiceId_fkey" + FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_payment_intents" + ADD CONSTRAINT "billing_payment_intents_billingAccountId_fkey" + FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_payment_intents" + ADD CONSTRAINT "billing_payment_intents_paymentMethodId_fkey" + FOREIGN KEY ("paymentMethodId") REFERENCES "billing_payment_methods"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "billing_payment_attempts" + ADD CONSTRAINT "billing_payment_attempts_invoiceId_fkey" + FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_payment_attempts" + ADD CONSTRAINT "billing_payment_attempts_billingAccountId_fkey" + FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_payment_attempts" + ADD CONSTRAINT "billing_payment_attempts_paymentIntentId_fkey" + FOREIGN KEY ("paymentIntentId") REFERENCES "billing_payment_intents"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "billing_payment_attempts" + ADD CONSTRAINT "billing_payment_attempts_paymentMethodId_fkey" + FOREIGN KEY ("paymentMethodId") REFERENCES "billing_payment_methods"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "billing_credit_balances" + ADD CONSTRAINT "billing_credit_balances_billingAccountId_fkey" + FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_credit_ledger_entries" + ADD CONSTRAINT "billing_credit_ledger_entries_billingAccountId_fkey" + FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_credit_ledger_entries" + ADD CONSTRAINT "billing_credit_ledger_entries_invoiceId_fkey" + FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "billing_credit_notes" + ADD CONSTRAINT "billing_credit_notes_invoiceId_fkey" + FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_credit_notes" + ADD CONSTRAINT "billing_credit_notes_billingAccountId_fkey" + FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_refunds" + ADD CONSTRAINT "billing_refunds_invoiceId_fkey" + FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_refunds" + ADD CONSTRAINT "billing_refunds_paymentAttemptId_fkey" + FOREIGN KEY ("paymentAttemptId") REFERENCES "billing_payment_attempts"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "billing_refunds" + ADD CONSTRAINT "billing_refunds_billingAccountId_fkey" + FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_tax_records" + ADD CONSTRAINT "billing_tax_records_invoiceId_fkey" + FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "billing_events" + ADD CONSTRAINT "billing_events_billingAccountId_fkey" + FOREIGN KEY ("billingAccountId") REFERENCES "billing_accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "billing_events" + ADD CONSTRAINT "billing_events_invoiceId_fkey" + FOREIGN KEY ("invoiceId") REFERENCES "billing_invoices"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "billing_events" + ADD CONSTRAINT "billing_events_subscriptionId_fkey" + FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "billing_events" + ADD CONSTRAINT "billing_events_companyId_fkey" + FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +INSERT INTO "billing_accounts" ( + "id", + "companyId", + "isPrimary", + "legalName", + "billingEmail", + "billingAddress", + "taxExempt", + "defaultCurrency", + "invoiceTerms", + "netTermsDays", + "metadata" +) +SELECT + 'ba_' || c."id", + c."id", + true, + c."name", + c."email", + c."address", + false, + COALESCE(a."currency", 'MAD'), + 'DUE_ON_RECEIPT'::"BillingInvoiceTerms", + 0, + '{}'::jsonb +FROM "companies" c +LEFT JOIN "accounting_settings" a ON a."companyId" = c."id" +WHERE NOT EXISTS ( + SELECT 1 + FROM "billing_accounts" ba + WHERE ba."companyId" = c."id" AND ba."isPrimary" = true +); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 8c3bac6..3f99cb7 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -52,6 +52,99 @@ enum InvoiceStatus { VOIDED } +enum BillingInvoiceTerms { + DUE_ON_RECEIPT + NET_7 + NET_15 + NET_30 + NET_45 + NET_60 +} + +enum BillingInvoiceStatus { + DRAFT + OPEN + PAID + PARTIALLY_PAID + PAYMENT_PENDING + PAST_DUE + VOID + UNCOLLECTIBLE + REFUNDED + PARTIALLY_REFUNDED +} + +enum BillingInvoiceType { + SUBSCRIPTION_INITIAL + SUBSCRIPTION_RENEWAL + TRIAL_CONVERSION + SUBSCRIPTION_UPGRADE + SUBSCRIPTION_DOWNGRADE_CREDIT + USAGE_BASED + ONE_TIME + MANUAL + CORRECTION + CANCELLATION_FEE +} + +enum BillingLineItemType { + SUBSCRIPTION_FEE + SEAT_FEE + USAGE_FEE + SETUP_FEE + DISCOUNT + TAX + CREDIT + PRORATION + REFUND_ADJUSTMENT + MANUAL_ADJUSTMENT + PROFESSIONAL_SERVICES + CANCELLATION_FEE +} + +enum BillingPaymentMethodType { + CARD + ACH_DEBIT + BANK_TRANSFER + WIRE_TRANSFER + MANUAL_INVOICE + PURCHASE_ORDER +} + +enum BillingPaymentIntentStatus { + REQUIRES_PAYMENT_METHOD + REQUIRES_CONFIRMATION + REQUIRES_ACTION + PROCESSING + SUCCEEDED + FAILED + CANCELED + REFUNDED + PARTIALLY_REFUNDED +} + +enum BillingPaymentAttemptStatus { + PENDING + PROCESSING + SUCCEEDED + FAILED + CANCELED + REFUNDED + PARTIALLY_REFUNDED +} + +enum BillingRefundStatus { + PENDING + SUCCEEDED + FAILED +} + +enum BillingCreditNoteStatus { + ISSUED + APPLIED + VOID +} + enum PaymentProvider { AMANPAY PAYPAL @@ -340,54 +433,59 @@ model Company { subscriptionPaymentRef String? apiKey String @unique @default(cuid()) - subscription Subscription? - brand BrandSettings? - employees Employee[] - vehicles Vehicle[] - offers Offer[] - reservations Reservation[] - customers Customer[] - rentalPayments RentalPayment[] - subscriptionInvoices SubscriptionInvoice[] - contractSettings ContractSettings? - accountingSettings AccountingSettings? - insurancePolicies InsurancePolicy[] - pricingRules PricingRule[] - notifications Notification[] @relation("CompanyNotifications") + subscription Subscription? + billingAccounts BillingAccount[] + billingInvoices BillingInvoice[] + brand BrandSettings? + employees Employee[] + vehicles Vehicle[] + offers Offer[] + reservations Reservation[] + customers Customer[] + rentalPayments RentalPayment[] + subscriptionInvoices SubscriptionInvoice[] + contractSettings ContractSettings? + accountingSettings AccountingSettings? + insurancePolicies InsurancePolicy[] + pricingRules PricingRule[] + notifications Notification[] @relation("CompanyNotifications") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + BillingEvent BillingEvent[] @@map("companies") } model Subscription { - id String @id @default(cuid()) - companyId String @unique - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - plan Plan @default(STARTER) - billingPeriod BillingPeriod @default(MONTHLY) - status SubscriptionStatus @default(TRIALING) - currency String @default("MAD") - trialStartAt DateTime? - trialEndAt DateTime? - trialUsed Boolean @default(false) - currentPeriodStart DateTime? - currentPeriodEnd DateTime? - paymentPendingSince DateTime? - paymentDueAt DateTime? - pastDueSince DateTime? - suspendedAt DateTime? - endedAt DateTime? - cancelledAt DateTime? - cancelAtPeriodEnd Boolean @default(false) - retryCount Int @default(0) - maxRetryCount Int @default(5) - invoices SubscriptionInvoice[] - events SubscriptionEvent[] + id String @id @default(cuid()) + companyId String @unique + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + plan Plan @default(STARTER) + billingPeriod BillingPeriod @default(MONTHLY) + status SubscriptionStatus @default(TRIALING) + currency String @default("MAD") + trialStartAt DateTime? + trialEndAt DateTime? + trialUsed Boolean @default(false) + currentPeriodStart DateTime? + currentPeriodEnd DateTime? + paymentPendingSince DateTime? + paymentDueAt DateTime? + pastDueSince DateTime? + suspendedAt DateTime? + endedAt DateTime? + cancelledAt DateTime? + cancelAtPeriodEnd Boolean @default(false) + retryCount Int @default(0) + maxRetryCount Int @default(5) + invoices SubscriptionInvoice[] + billingInvoices BillingInvoice[] + events SubscriptionEvent[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + BillingEvent BillingEvent[] @@map("subscriptions") } @@ -421,6 +519,8 @@ model SubscriptionInvoice { amanpayTransactionId String? @unique paypalCaptureId String? @unique paymentProvider PaymentProvider @default(AMANPAY) + billingInvoiceId String? @unique + billingInvoice BillingInvoice? @relation(fields: [billingInvoiceId], references: [id]) dueAt DateTime? paidAt DateTime? failedAt DateTime? @@ -433,6 +533,314 @@ model SubscriptionInvoice { @@map("subscription_invoices") } +model BillingAccount { + id String @id @default(cuid()) + companyId String + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + isPrimary Boolean @default(true) + legalName String + billingEmail String + billingAddress Json? + taxId String? + taxExempt Boolean @default(false) + defaultCurrency String @default("MAD") + defaultPaymentMethodId String? + defaultPaymentMethod BillingPaymentMethod? @relation("BillingAccountDefaultPaymentMethod", fields: [defaultPaymentMethodId], references: [id]) + invoiceTerms BillingInvoiceTerms @default(DUE_ON_RECEIPT) + netTermsDays Int @default(0) + providerCustomerId String? + dunningPaused Boolean @default(false) + dunningPausedAt DateTime? + dunningPausedBy String? + metadata Json @default("{}") + paymentMethods BillingPaymentMethod[] @relation("BillingAccountPaymentMethods") + invoices BillingInvoice[] + paymentIntents BillingPaymentIntent[] + paymentAttempts BillingPaymentAttempt[] + creditBalances BillingCreditBalance[] + creditLedgerEntries BillingCreditLedgerEntry[] + creditNotes BillingCreditNote[] + refunds BillingRefund[] + events BillingEvent[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([companyId]) + @@index([companyId, isPrimary]) + @@map("billing_accounts") +} + +model BillingPaymentMethod { + id String @id @default(cuid()) + billingAccountId String + billingAccount BillingAccount @relation("BillingAccountPaymentMethods", fields: [billingAccountId], references: [id], onDelete: Cascade) + type BillingPaymentMethodType + providerPaymentMethodId String? + label String? + brand String? + last4 String? + expMonth Int? + expYear Int? + isDefault Boolean @default(false) + isActive Boolean @default(true) + metadata Json @default("{}") + defaultForBillingAccounts BillingAccount[] @relation("BillingAccountDefaultPaymentMethod") + paymentIntents BillingPaymentIntent[] + paymentAttempts BillingPaymentAttempt[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([billingAccountId]) + @@map("billing_payment_methods") +} + +model BillingInvoice { + id String @id @default(cuid()) + billingAccountId String + billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade) + companyId String + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + subscriptionId String? + subscription Subscription? @relation(fields: [subscriptionId], references: [id]) + legacySubscriptionInvoice SubscriptionInvoice? + invoiceNumber String? @unique + invoiceSequence Int? @unique + invoiceType BillingInvoiceType + status BillingInvoiceStatus @default(DRAFT) + currency String @default("MAD") + subtotalAmount Int @default(0) + discountAmount Int @default(0) + creditAmount Int @default(0) + taxAmount Int @default(0) + totalAmount Int @default(0) + amountPaid Int @default(0) + amountDue Int @default(0) + invoiceDate DateTime? + dueAt DateTime? + finalizedAt DateTime? + paidAt DateTime? + voidedAt DateTime? + markedUncollectibleAt DateTime? + billingName String? + billingEmail String? + billingAddress Json? + providerInvoiceId String? + paymentProvider PaymentProvider? + isSubscriptionBlocking Boolean @default(false) + adminReason String? + metadata Json @default("{}") + createdByAdminId String? + lineItems BillingInvoiceLineItem[] + paymentIntents BillingPaymentIntent[] + paymentAttempts BillingPaymentAttempt[] + taxRecords BillingTaxRecord[] + creditNotes BillingCreditNote[] + refunds BillingRefund[] + events BillingEvent[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + BillingCreditLedgerEntry BillingCreditLedgerEntry[] + + @@index([billingAccountId]) + @@index([companyId]) + @@index([subscriptionId]) + @@map("billing_invoices") +} + +model BillingInvoiceLineItem { + id String @id @default(cuid()) + invoiceId String + invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + subscriptionId String? + plan Plan? + type BillingLineItemType + description String + quantity Int @default(1) + unitAmount Int + amount Int + currency String @default("MAD") + periodStart DateTime? + periodEnd DateTime? + metadata Json @default("{}") + + createdAt DateTime @default(now()) + + @@index([invoiceId]) + @@map("billing_invoice_line_items") +} + +model BillingPaymentIntent { + id String @id @default(cuid()) + invoiceId String + invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + billingAccountId String + billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade) + paymentMethodId String? + paymentMethod BillingPaymentMethod? @relation(fields: [paymentMethodId], references: [id]) + providerPaymentIntentId String? + status BillingPaymentIntentStatus @default(REQUIRES_PAYMENT_METHOD) + amount Int + currency String @default("MAD") + metadata Json @default("{}") + paymentAttempts BillingPaymentAttempt[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([invoiceId]) + @@index([billingAccountId]) + @@map("billing_payment_intents") +} + +model BillingPaymentAttempt { + id String @id @default(cuid()) + invoiceId String + invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + billingAccountId String + billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade) + paymentIntentId String? + paymentIntent BillingPaymentIntent? @relation(fields: [paymentIntentId], references: [id]) + paymentMethodId String? + paymentMethod BillingPaymentMethod? @relation(fields: [paymentMethodId], references: [id]) + providerPaymentId String? + status BillingPaymentAttemptStatus + amount Int + currency String @default("MAD") + failureCode String? + failureMessage String? + attemptedAt DateTime + metadata Json @default("{}") + refunds BillingRefund[] + + createdAt DateTime @default(now()) + + @@index([invoiceId]) + @@index([billingAccountId]) + @@map("billing_payment_attempts") +} + +model BillingCreditBalance { + id String @id @default(cuid()) + billingAccountId String + billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade) + currency String @default("MAD") + balanceAmount Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([billingAccountId, currency]) + @@map("billing_credit_balances") +} + +model BillingCreditLedgerEntry { + id String @id @default(cuid()) + billingAccountId String + billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade) + invoiceId String? + invoice BillingInvoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) + type String + amount Int + currency String @default("MAD") + reason String + createdBy String? + metadata Json @default("{}") + + createdAt DateTime @default(now()) + + @@index([billingAccountId]) + @@map("billing_credit_ledger_entries") +} + +model BillingCreditNote { + id String @id @default(cuid()) + invoiceId String + invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + billingAccountId String + billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade) + amount Int + currency String @default("MAD") + reason String + status BillingCreditNoteStatus @default(ISSUED) + createdBy String? + metadata Json @default("{}") + + createdAt DateTime @default(now()) + + @@index([invoiceId]) + @@index([billingAccountId]) + @@map("billing_credit_notes") +} + +model BillingRefund { + id String @id @default(cuid()) + invoiceId String + invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + paymentAttemptId String? + paymentAttempt BillingPaymentAttempt? @relation(fields: [paymentAttemptId], references: [id], onDelete: SetNull) + billingAccountId String + billingAccount BillingAccount @relation(fields: [billingAccountId], references: [id], onDelete: Cascade) + amount Int + currency String @default("MAD") + reason String + status BillingRefundStatus @default(PENDING) + providerRefundId String? + createdBy String? + metadata Json @default("{}") + + createdAt DateTime @default(now()) + + @@index([invoiceId]) + @@index([billingAccountId]) + @@map("billing_refunds") +} + +model BillingTaxRecord { + id String @id @default(cuid()) + invoiceId String + invoice BillingInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + jurisdiction String? + taxRate Float? + taxAmount Int + taxType String? + taxExempt Boolean @default(false) + exemptionReason String? + providerTaxId String? + metadata Json @default("{}") + + createdAt DateTime @default(now()) + + @@index([invoiceId]) + @@map("billing_tax_records") +} + +model BillingEvent { + id String @id @default(cuid()) + billingAccountId String? + billingAccount BillingAccount? @relation(fields: [billingAccountId], references: [id], onDelete: SetNull) + invoiceId String? + invoice BillingInvoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) + subscriptionId String? + subscription Subscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull) + companyId String? + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + eventType String + source String + payload Json @default("{}") + occurredAt DateTime + + createdAt DateTime @default(now()) + + @@index([billingAccountId]) + @@index([invoiceId]) + @@index([subscriptionId]) + @@index([companyId]) + @@map("billing_events") +} + model PaymentAttempt { id String @id @default(cuid()) invoiceId String @@ -450,19 +858,19 @@ model PaymentAttempt { } model BrandSettings { - id String @id @default(cuid()) - companyId String @unique - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + companyId String @unique + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) displayName String tagline String? logoUrl String? faviconUrl String? heroImageUrl String? - primaryColor String @default("#1A56DB") - accentColor String @default("#F59E0B") - subdomain String @unique - customDomain String? @unique - customDomainVerified Boolean @default(false) + primaryColor String @default("#1A56DB") + accentColor String @default("#F59E0B") + subdomain String @unique + customDomain String? @unique + customDomainVerified Boolean @default(false) customDomainAddedAt DateTime? publicEmail String? publicPhone String? @@ -473,17 +881,17 @@ model BrandSettings { whatsappNumber String? facebookUrl String? instagramUrl String? - defaultLocale String @default("en") - defaultCurrency String @default("MAD") + defaultLocale String @default("en") + defaultCurrency String @default("MAD") amanpayMerchantId String? amanpaySecretKey String? paypalEmail String? paypalMerchantId String? paymentMethodsEnabled PaymentProvider[] - isListedOnMarketplace Boolean @default(true) + isListedOnMarketplace Boolean @default(true) marketplaceRating Float? - homePageConfig Json? @map("home_page_config") - menuConfig Json? @map("menu_config") + homePageConfig Json? @map("home_page_config") + menuConfig Json? @map("menu_config") updatedAt DateTime @updatedAt @@ -491,20 +899,20 @@ model BrandSettings { } model Employee { - id String @id @default(cuid()) - companyId String - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - clerkUserId String @unique - firstName String - lastName String - email String - phone String? - passwordHash String? - passwordResetToken String? @unique - passwordResetExpiresAt DateTime? - role EmployeeRole @default(AGENT) - preferredLanguage String @default("en") - isActive Boolean @default(true) + id String @id @default(cuid()) + companyId String + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + clerkUserId String @unique + firstName String + lastName String + email String + phone String? + passwordHash String? + passwordResetToken String? @unique + passwordResetExpiresAt DateTime? + role EmployeeRole @default(AGENT) + preferredLanguage String @default("en") + isActive Boolean @default(true) notifications Notification[] @relation("EmployeeNotifications") notificationPreferences NotificationPreference[] @@ -517,34 +925,34 @@ model Employee { } model Vehicle { - id String @id @default(cuid()) - companyId String - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - make String - model String - year Int - color String - licensePlate String - vin String? - category VehicleCategory @default(ECONOMY) - seats Int @default(5) - transmission Transmission @default(AUTOMATIC) - fuelType FuelType @default(GASOLINE) - features String[] - dailyRate Int - status VehicleStatus @default(AVAILABLE) - photos String[] - mileage Int? - notes String? - isPublished Boolean @default(true) + id String @id @default(cuid()) + companyId String + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + make String + model String + year Int + color String + licensePlate String + vin String? + category VehicleCategory @default(ECONOMY) + seats Int @default(5) + transmission Transmission @default(AUTOMATIC) + fuelType FuelType @default(GASOLINE) + features String[] + dailyRate Int + status VehicleStatus @default(AVAILABLE) + photos String[] + mileage Int? + notes String? + isPublished Boolean @default(true) pickupLocations String[] @default([]) allowDifferentDropoff Boolean @default(false) dropoffLocations String[] @default([]) - reservations Reservation[] - maintenance MaintenanceLog[] - offerVehicles OfferVehicle[] - calendarBlocks VehicleCalendarBlock[] + reservations Reservation[] + maintenance MaintenanceLog[] + offerVehicles OfferVehicle[] + calendarBlocks VehicleCalendarBlock[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -556,28 +964,28 @@ model Vehicle { } model Offer { - id String @id @default(cuid()) - companyId String - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - title String - description String? - termsAndConds String? - imageUrl String? - type OfferType - discountValue Int - specialRate Int? - appliesToAll Boolean @default(true) - categories VehicleCategory[] - minRentalDays Int? - maxRentalDays Int? - promoCode String? @unique - maxRedemptions Int? - redemptionCount Int @default(0) - validFrom DateTime - validUntil DateTime - isActive Boolean @default(true) - isPublic Boolean @default(true) - isFeatured Boolean @default(false) + id String @id @default(cuid()) + companyId String + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + title String + description String? + termsAndConds String? + imageUrl String? + type OfferType + discountValue Int + specialRate Int? + appliesToAll Boolean @default(true) + categories VehicleCategory[] + minRentalDays Int? + maxRentalDays Int? + promoCode String? @unique + maxRedemptions Int? + redemptionCount Int @default(0) + validFrom DateTime + validUntil DateTime + isActive Boolean @default(true) + isPublic Boolean @default(true) + isFeatured Boolean @default(false) vehicles OfferVehicle[] reservations Reservation[] @@ -621,10 +1029,10 @@ model Renter { phoneVerified Boolean @default(false) isActive Boolean @default(true) - reservations Reservation[] @relation("RenterReservations") + reservations Reservation[] @relation("RenterReservations") savedCompanies RenterSavedCompany[] reviews Review[] - notifications Notification[] @relation("RenterNotifications") + notifications Notification[] @relation("RenterNotifications") notificationPreferences NotificationPreference[] createdAt DateTime @default(now()) @@ -635,7 +1043,7 @@ model Renter { model RenterSavedCompany { renterId String - renter Renter @relation(fields: [renterId], references: [id], onDelete: Cascade) + renter Renter @relation(fields: [renterId], references: [id], onDelete: Cascade) companyId String savedAt DateTime @default(now()) @@ -684,52 +1092,52 @@ model Customer { } model Reservation { - id String @id @default(cuid()) - companyId String - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - vehicleId String - vehicle Vehicle @relation(fields: [vehicleId], references: [id]) - customerId String - customer Customer @relation(fields: [customerId], references: [id]) - renterId String? - renter Renter? @relation("RenterReservations", fields: [renterId], references: [id]) - offerId String? - offer Offer? @relation(fields: [offerId], references: [id]) - promoCodeUsed String? - vehicleCategory VehicleCategory? - source BookingSource @default(DASHBOARD) - marketplaceRef String? - status ReservationStatus @default(DRAFT) - startDate DateTime - endDate DateTime - pickupLocation String? - returnLocation String? - dailyRate Int - discountAmount Int @default(0) - totalDays Int - totalAmount Int - depositAmount Int @default(0) - paymentStatus String @default("UNPAID") - paidAmount Int @default(0) - checkedInAt DateTime? - checkedOutAt DateTime? - checkInMileage Int? - checkOutMileage Int? - notes String? - cancelReason String? - cancelledBy CancelledBy? - contractNumber String? @unique - invoiceNumber String? @unique - reviewToken String? @unique + id String @id @default(cuid()) + companyId String + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + vehicleId String + vehicle Vehicle @relation(fields: [vehicleId], references: [id]) + customerId String + customer Customer @relation(fields: [customerId], references: [id]) + renterId String? + renter Renter? @relation("RenterReservations", fields: [renterId], references: [id]) + offerId String? + offer Offer? @relation(fields: [offerId], references: [id]) + promoCodeUsed String? + vehicleCategory VehicleCategory? + source BookingSource @default(DASHBOARD) + marketplaceRef String? + status ReservationStatus @default(DRAFT) + startDate DateTime + endDate DateTime + pickupLocation String? + returnLocation String? + dailyRate Int + discountAmount Int @default(0) + totalDays Int + totalAmount Int + depositAmount Int @default(0) + paymentStatus String @default("UNPAID") + paidAmount Int @default(0) + checkedInAt DateTime? + checkedOutAt DateTime? + checkInMileage Int? + checkOutMileage Int? + notes String? + cancelReason String? + cancelledBy CancelledBy? + contractNumber String? @unique + invoiceNumber String? @unique + reviewToken String? @unique checkInFuelLevel String? checkOutFuelLevel String? insurances ReservationInsurance[] - insuranceTotal Int @default(0) + insuranceTotal Int @default(0) additionalDrivers AdditionalDriver[] - additionalDriverTotal Int @default(0) + additionalDriverTotal Int @default(0) pricingRulesApplied Json? - pricingRulesTotal Int @default(0) + pricingRulesTotal Int @default(0) damageReports DamageReport[] rentalPayments RentalPayment[] inspections DamageInspection[] @@ -843,9 +1251,9 @@ model NotificationPreference { // ═══════════════════════════════════════════════════════════════ model MaintenanceLog { - id String @id @default(cuid()) + id String @id @default(cuid()) vehicleId String - vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade) + vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade) type String description String? cost Int? @@ -882,37 +1290,37 @@ model VehicleCalendarBlock { // ═══════════════════════════════════════════════════════════════ model ContractSettings { - id String @id @default(cuid()) - companyId String @unique - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - legalName String? - registrationNumber String? - taxId String? - terms String @default("") - fuelPolicy String @default("The vehicle must be returned with the same fuel level as at pickup.") - depositPolicy String @default("The security deposit is refundable within 7 days after return, subject to vehicle inspection.") - lateFeePolicy String @default("Late returns will incur a charge of 1 additional day rate per hour of delay.") - damagePolicy String @default("The renter is liable for any damage not covered by insurance.") - additionalClauses String[] @default([]) - signatureRequired Boolean @default(true) - contractFooterNote String? - invoiceFooterNote String? - showTax Boolean @default(false) - taxRate Float? - taxLabel String? - contractPrefix String @default("CNT") - invoicePrefix String @default("INV") - fuelPolicyType FuelPolicyType @default(FULL_TO_FULL) - fuelPolicyNote String? - fuelChargePerLiter Int? - fuelShortfallFee Int? - lateFeePerHour Int? - taxLines Json? - lastContractSeq Int @default(0) - lastInvoiceSeq Int @default(0) - additionalDriverCharge AdditionalDriverCharge @default(FREE) - additionalDriverDailyRate Int @default(0) - additionalDriverFlatRate Int @default(0) + id String @id @default(cuid()) + companyId String @unique + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + legalName String? + registrationNumber String? + taxId String? + terms String @default("") + fuelPolicy String @default("The vehicle must be returned with the same fuel level as at pickup.") + depositPolicy String @default("The security deposit is refundable within 7 days after return, subject to vehicle inspection.") + lateFeePolicy String @default("Late returns will incur a charge of 1 additional day rate per hour of delay.") + damagePolicy String @default("The renter is liable for any damage not covered by insurance.") + additionalClauses String[] @default([]) + signatureRequired Boolean @default(true) + contractFooterNote String? + invoiceFooterNote String? + showTax Boolean @default(false) + taxRate Float? + taxLabel String? + contractPrefix String @default("CNT") + invoicePrefix String @default("INV") + fuelPolicyType FuelPolicyType @default(FULL_TO_FULL) + fuelPolicyNote String? + fuelChargePerLiter Int? + fuelShortfallFee Int? + lateFeePerHour Int? + taxLines Json? + lastContractSeq Int @default(0) + lastInvoiceSeq Int @default(0) + additionalDriverCharge AdditionalDriverCharge @default(FREE) + additionalDriverDailyRate Int @default(0) + additionalDriverFlatRate Int @default(0) updatedAt DateTime @updatedAt @@ -1046,16 +1454,16 @@ model DamageInspection { } model DamagePoint { - id String @id @default(cuid()) - inspectionId String - inspection DamageInspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade) - viewType DiagramView - x Float - y Float - damageType DamageType - severity DamageSeverity @default(MINOR) - description String? - isPreExisting Boolean @default(false) + id String @id @default(cuid()) + inspectionId String + inspection DamageInspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade) + viewType DiagramView + x Float + y Float + damageType DamageType + severity DamageSeverity @default(MINOR) + description String? + isPreExisting Boolean @default(false) @@index([inspectionId]) @@map("damage_points") @@ -1168,7 +1576,7 @@ model AuditLog { } model PricingConfig { - id String @id @default(cuid()) + id String @id @default(cuid()) plan String billingPeriod String amount Int @@ -1197,10 +1605,10 @@ model PricingPromotion { code String @unique name String description String? - discountType String // 'PERCENTAGE' | 'FIXED' - discountValue Int // percentage (1-100) or centimes for FIXED - plans String[] // empty array = all plans - periods String[] // empty array = all periods + discountType String // 'PERCENTAGE' | 'FIXED' + discountValue Int // percentage (1-100) or centimes for FIXED + plans String[] // empty array = all plans + periods String[] // empty array = all periods maxUses Int? usedCount Int @default(0) validFrom DateTime