From c91fa2ce4de78d6368fcb2911a7dcff6cd4dd578 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 11 Jun 2026 11:06:32 -0400 Subject: [PATCH] fix inventory --- .../inventory/InventoryDashboardPage.tsx | 183 ++ .../pages/inventory/InventoryLowStockPage.tsx | 112 ++ .../InventoryReorderSuggestionsPage.tsx | 109 ++ .../inventory/InventoryStockStatusPage.tsx | 167 ++ app/Console/Commands/InventoryReconcile.php | 78 + .../Api/Inventory/InventoryController.php | 160 +- .../Inventory/InventoryMovementController.php | 51 + .../Inventory/InventoryItemStoreRequest.php | 8 +- .../Inventory/InventoryItemUpdateRequest.php | 6 +- .../Inventory/InventoryItemResource.php | 10 + app/Models/InventoryItem.php | 39 + app/Models/InventoryMovement.php | 37 + app/Models/PurchaseOrderItem.php | 1 + .../Inventory/InventoryMovementService.php | 215 ++- .../PurchaseOrderReceiveService.php | 48 +- ...03_add_inventory_movement_audit_fields.php | 46 + ...4621_add_inventory_item_reorder_fields.php | 52 + ...entory_item_id_to_purchase_order_items.php | 40 + docs/SECURITY_HARDENING_APPLIED_REPORT.md | 160 -- ...URITY_HARDENING_LEFTOVER_APPLIED_REPORT.md | 255 --- docs/project-design/API_REFACTOR_TASK_LIST.md | 83 - docs/project-design/AUDIT.md | 41 - .../B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md | 1670 ----------------- .../B2B_SUBSCRIPTION_EXECUTION_PLAN.md | 1340 ------------- docs/project-design/COOKIE_POLICY.md | 166 -- docs/project-design/FEATURES.md | 152 -- .../FINANCE_PLAN_IMPLEMENTATION_NOTES.md | 0 docs/project-design/INTEGRATION.md | 141 -- ...TORY_IMPROVEMENT_IMPLEMENTATION_SUMMARY.md | 162 ++ .../NOTIFICATION_LOCALIZATION_POLICY.md | 739 -------- docs/project-design/PAGES.md | 179 -- .../STRONG_GRADING_IMPLEMENTATION_NOTES.md | 0 .../VULNERABILITY_FIXES_2026-05-22.md | 179 -- docs/project-design/advanced-features.md | 1308 ------------- docs/project-design/api-routes.md | 396 ---- .../command_Create_admin_account.md | 62 - ...ontrat_location_voiture_maroc_with_laws.md | 699 ------- .../inventory_system_improvement_plan.md | 866 +++++++++ .../review_management_process.md | 694 ------- docs/project-design/schema.md | 778 -------- docs/{ => project-design}/school_api.md | Bin .../simple_robust_car_rental_process.md | 695 ------- docs/rental_car_pricing_management_plan.md | 392 ---- docs/security_hardening_changed_files.txt | 14 - docs/security_hardening_incremental.diff | 342 ---- docs/security_hardening_leftover.diff | 1498 --------------- ...urity_hardening_leftover_changed_files.txt | 32 - docs/website-admin-menu-management-plan.md | 615 ------ routes/api.php | 11 + .../inventory/InventoryDashboardPage.tsx | 183 ++ src/pages/inventory/InventoryLowStockPage.tsx | 112 ++ .../InventoryReorderSuggestionsPage.tsx | 109 ++ .../inventory/InventoryStockStatusPage.tsx | 167 ++ 53 files changed, 2936 insertions(+), 12666 deletions(-) create mode 100644 Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryDashboardPage.tsx create mode 100644 Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryLowStockPage.tsx create mode 100644 Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryReorderSuggestionsPage.tsx create mode 100644 Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryStockStatusPage.tsx create mode 100644 app/Console/Commands/InventoryReconcile.php create mode 100644 database/migrations/2026_06_11_074503_add_inventory_movement_audit_fields.php create mode 100644 database/migrations/2026_06_11_074621_add_inventory_item_reorder_fields.php create mode 100644 database/migrations/2026_06_11_074642_add_inventory_item_id_to_purchase_order_items.php delete mode 100644 docs/SECURITY_HARDENING_APPLIED_REPORT.md delete mode 100644 docs/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md delete mode 100644 docs/project-design/API_REFACTOR_TASK_LIST.md delete mode 100644 docs/project-design/AUDIT.md delete mode 100644 docs/project-design/B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md delete mode 100644 docs/project-design/B2B_SUBSCRIPTION_EXECUTION_PLAN.md delete mode 100644 docs/project-design/COOKIE_POLICY.md delete mode 100644 docs/project-design/FEATURES.md rename docs/{ => project-design}/FINANCE_PLAN_IMPLEMENTATION_NOTES.md (100%) delete mode 100644 docs/project-design/INTEGRATION.md create mode 100644 docs/project-design/INVENTORY_IMPROVEMENT_IMPLEMENTATION_SUMMARY.md delete mode 100644 docs/project-design/NOTIFICATION_LOCALIZATION_POLICY.md delete mode 100644 docs/project-design/PAGES.md rename docs/{ => project-design}/STRONG_GRADING_IMPLEMENTATION_NOTES.md (100%) delete mode 100644 docs/project-design/VULNERABILITY_FIXES_2026-05-22.md delete mode 100644 docs/project-design/advanced-features.md delete mode 100644 docs/project-design/api-routes.md delete mode 100644 docs/project-design/command_Create_admin_account.md delete mode 100644 docs/project-design/contrat_location_voiture_maroc_with_laws.md create mode 100644 docs/project-design/inventory_system_improvement_plan.md delete mode 100644 docs/project-design/review_management_process.md delete mode 100644 docs/project-design/schema.md rename docs/{ => project-design}/school_api.md (100%) delete mode 100644 docs/project-design/simple_robust_car_rental_process.md delete mode 100644 docs/rental_car_pricing_management_plan.md delete mode 100644 docs/security_hardening_changed_files.txt delete mode 100644 docs/security_hardening_incremental.diff delete mode 100644 docs/security_hardening_leftover.diff delete mode 100644 docs/security_hardening_leftover_changed_files.txt delete mode 100644 docs/website-admin-menu-management-plan.md create mode 100644 src/pages/inventory/InventoryDashboardPage.tsx create mode 100644 src/pages/inventory/InventoryLowStockPage.tsx create mode 100644 src/pages/inventory/InventoryReorderSuggestionsPage.tsx create mode 100644 src/pages/inventory/InventoryStockStatusPage.tsx diff --git a/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryDashboardPage.tsx b/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryDashboardPage.tsx new file mode 100644 index 00000000..794b576a --- /dev/null +++ b/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryDashboardPage.tsx @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchInventoryDashboard } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Inventory Dashboard — summary counts by type, low stock, out of stock, etc. + * Backend: GET /api/v1/inventory/dashboard + */ +export function InventoryDashboardPage() { + const [searchParams] = useSearchParams() + const [data, setData] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchInventoryDashboard(searchParams) + .then((res) => { + if (res && typeof res === 'object') { + setData(res as Record) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load dashboard.') + }) + .finally(() => setLoading(false)) + }, [searchParams]) + + useEffect(() => { + load() + }, [load]) + + const byType = (data.by_type as Record) ?? {} + const totalItems = Number(data.total_items ?? 0) + const lowStockCount = Number(data.low_stock_count ?? 0) + const outOfStockCount = Number(data.out_of_stock_count ?? 0) + const needsRepairCount = Number(data.needs_repair_count ?? 0) + const missingCount = Number(data.missing_count ?? 0) + + return ( +
+
+

Inventory Dashboard

+
+ + Stock Status + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : ( +
+ {/* Summary Cards */} +
+
+
+
{totalItems}
+

Total Items

+
+
+
+ +
+
+
+
{lowStockCount}
+

Low Stock Items

+ {lowStockCount > 0 ? ( + + View Low Stock + + ) : null} +
+
+
+ +
+
+
+
{outOfStockCount}
+

Out of Stock

+
+
+
+ +
+
+
+
{needsRepairCount}
+

Needs Repair

+
+
+
+ +
+
+
+
{missingCount}
+

Missing / Cannot Find

+
+
+
+ + {/* Items by Type */} +
+
+
Items by Type
+
+ {Object.keys(byType).length === 0 ? ( +

No items found.

+ ) : ( +
+ + + + + + + + + {Object.entries(byType).map(([type, count]) => ( + + + + + ))} + +
TypeCount
{type}{Number(count).toLocaleString()}
+
+ )} +
+
+
+ + {/* Quick Links */} +
+
+
Quick Actions
+
+
+ + Classroom Equipment + + + Books + + + Office Supplies + + + Kitchen Supplies + + + Movements + + + Summary + + + Reorder Suggestions + + + Low Stock + +
+
+
+
+
+ )} +
+ ) +} diff --git a/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryLowStockPage.tsx b/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryLowStockPage.tsx new file mode 100644 index 00000000..2911d70b --- /dev/null +++ b/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryLowStockPage.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchLowStock } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Low Stock Items — items where quantity <= reorder_point. + * Backend: GET /api/v1/inventory/low-stock + */ +export function InventoryLowStockPage() { + const [items, setItems] = useState[]>([]) + const [count, setCount] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchLowStock() + .then((res) => { + if (res && typeof res === 'object') { + const o = res as Record + const arr = Array.isArray(o.items) ? (o.items as Record[]) : [] + setItems(arr) + setCount(Number(o.count ?? arr.length)) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load low stock items.') + }) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { + load() + }, [load]) + + return ( +
+
+

Low Stock Items ({count})

+
+ + Reorder Suggestions + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : items.length === 0 ? ( +
+ All stocked up! No items are below their reorder point. +
+ ) : ( +
+
+ + + + + + + + + + + + + + + + {items.map((item) => { + const i = item as Record + const supplier = i.preferred_supplier as Record | null + return ( + + + + + + + + + + + + ) + })} + +
NameTypeCategoryCurrent QtyReorder PointReorder QtyLead Time (Days)Preferred SupplierActions
{String(i.name ?? '')}{String(i.type ?? '')}{String((i.category as Record)?.name ?? i.category ?? '')} + {Number(i.quantity ?? 0).toLocaleString()} + {Number(i.reorder_point ?? 0).toLocaleString()}{Number(i.reorder_quantity ?? 0).toLocaleString() || '—'}{Number(i.lead_time_days ?? 0) || '—'}{supplier ? String(supplier.name ?? '') : '—'} + + Adjust Stock + +
+
+
+ )} +
+ ) +} diff --git a/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryReorderSuggestionsPage.tsx b/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryReorderSuggestionsPage.tsx new file mode 100644 index 00000000..c01d58e0 --- /dev/null +++ b/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryReorderSuggestionsPage.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchReorderSuggestions } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Reorder Suggestions — calculated suggested order quantities for low-stock items. + * Backend: GET /api/v1/inventory/reorder-suggestions + */ +export function InventoryReorderSuggestionsPage() { + const [suggestions, setSuggestions] = useState[]>([]) + const [count, setCount] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchReorderSuggestions() + .then((res) => { + if (res && typeof res === 'object') { + const o = res as Record + const arr = Array.isArray(o.suggestions) ? (o.suggestions as Record[]) : [] + setSuggestions(arr) + setCount(Number(o.count ?? arr.length)) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load reorder suggestions.') + }) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { + load() + }, [load]) + + return ( +
+
+

Reorder Suggestions ({count})

+
+ + Low Stock + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : suggestions.length === 0 ? ( +
+ All stocked up! No reorder suggestions at this time. +
+ ) : ( +
+
+ + + + + + + + + + + + + + {suggestions.map((s) => { + const supplier = s.preferred_supplier as Record | null + return ( + + + + + + + + + + ) + })} + +
NameCategoryCurrent QtyReorder PointSuggested OrderPreferred SupplierActions
{String(s.name ?? '')}{String(s.category ?? '—')} + {Number(s.current_quantity ?? 0).toLocaleString()} + {Number(s.reorder_point ?? 0).toLocaleString()} + {Number(s.suggested_order_qty ?? 0).toLocaleString()} + {supplier ? String(supplier.name ?? '') : '—'} + + Adjust Stock + +
+
+
+ )} +
+ ) +} diff --git a/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryStockStatusPage.tsx b/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryStockStatusPage.tsx new file mode 100644 index 00000000..e355e9f8 --- /dev/null +++ b/Volumes/ExternalApps/Documents/alrahma_web_client/src/pages/inventory/InventoryStockStatusPage.tsx @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchStockStatus } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Stock Status — all items with status: ok/low_stock/out_of_stock. + * Backend: GET /api/v1/inventory/stock-status + */ +export function InventoryStockStatusPage() { + const [searchParams, setSearchParams] = useSearchParams() + const [items, setItems] = useState[]>([]) + const [counts, setCounts] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchStockStatus(searchParams) + .then((res) => { + if (res && typeof res === 'object') { + const o = res as Record + setItems(Array.isArray(o.items) ? (o.items as Record[]) : []) + setCounts((o.counts as Record) ?? {}) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load stock status.') + }) + .finally(() => setLoading(false)) + }, [searchParams]) + + useEffect(() => { + load() + }, [load]) + + function onTypeFilter(v: string) { + const next = new URLSearchParams(searchParams) + if (v) next.set('type', v) + else next.delete('type') + setSearchParams(next) + } + + const currentType = searchParams.get('type') ?? '' + + return ( +
+
+

Stock Status

+
+ + Low Stock + + + Reorder Suggestions + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : ( + <> + {/* Summary Badges */} +
+ + OK: {counts.ok ?? 0} + + + Low Stock: {counts.low_stock ?? 0} + + + Out of Stock: {counts.out_of_stock ?? 0} + +
+ +
+
+ All Items + +
+
+ + + + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((item) => { + const status = String(item.status ?? 'ok') + const badgeClass = + status === 'out_of_stock' + ? 'bg-danger' + : status === 'low_stock' + ? 'bg-warning' + : 'bg-success' + const badgeLabel = + status === 'out_of_stock' + ? 'Out of Stock' + : status === 'low_stock' + ? 'Low Stock' + : 'OK' + return ( + + + + + + + + + + ) + }) + )} + +
NameTypeCategoryQuantityReorder PointStatusActions
+ No items found. +
{String(item.name ?? '')}{String(item.type ?? '')}{String(item.category ?? '—')}{Number(item.quantity ?? 0).toLocaleString()} + {item.reorder_point != null ? Number(item.reorder_point).toLocaleString() : '—'} + + {badgeLabel} + + + Adjust + +
+
+
+ + )} +
+ ) +} diff --git a/app/Console/Commands/InventoryReconcile.php b/app/Console/Commands/InventoryReconcile.php new file mode 100644 index 00000000..a836e86f --- /dev/null +++ b/app/Console/Commands/InventoryReconcile.php @@ -0,0 +1,78 @@ +option('school-year'); + $autoFix = $this->option('fix'); + + $result = $movementService->reconcile($schoolYear); + + $this->info("School Year: {$result['school_year']}"); + $this->info("Total Items: {$result['total_items']}"); + $this->info("Discrepancies: {$result['discrepancies_count']}"); + + if (empty($result['discrepancies'])) { + $this->info('✅ All item quantities match movement ledger.'); + return Command::SUCCESS; + } + + $this->table( + ['ID', 'Name', 'Stored Qty', 'Movement Sum', 'Variance'], + collect($result['discrepancies'])->map(fn ($d) => [ + $d['id'], + $d['name'], + $d['stored_quantity'], + $d['movement_sum'], + $d['variance'], + ])->toArray() + ); + + if ($autoFix) { + $this->info('Fixing discrepancies...'); + foreach ($result['discrepancies'] as $d) { + if ($d['variance'] !== 0) { + $movementService->recordMovement( + itemId: $d['id'], + qtyChange: -$d['variance'], + type: 'adjust', + reason: 'Auto-reconciliation: stored qty differed from movement sum', + note: 'Reconciled from ' . $d['stored_quantity'] . ' to ' . $d['movement_sum'], + performedBy: null + ); + $this->line(" Fixed item #{$d['id']} ({$d['name']}): variance was {$d['variance']}"); + } + } + $this->info('✅ Fix applied.'); + } else { + $this->warn('Run with --fix to automatically correct discrepancies.'); + } + + return Command::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/Inventory/InventoryController.php b/app/Http/Controllers/Api/Inventory/InventoryController.php index 669ea5dd..de4057dc 100644 --- a/app/Http/Controllers/Api/Inventory/InventoryController.php +++ b/app/Http/Controllers/Api/Inventory/InventoryController.php @@ -11,7 +11,9 @@ use App\Http\Requests\Inventory\InventoryItemUpdateRequest; use App\Http\Requests\Inventory\InventoryTeacherDistributionRequest; use App\Http\Requests\Inventory\InventoryTeacherDistributionStoreRequest; use App\Http\Resources\Inventory\InventoryItemResource; +use App\Models\InventoryItem; use App\Services\Inventory\InventoryItemService; +use App\Services\Inventory\InventoryMovementService; use App\Services\Inventory\InventorySummaryService; use App\Services\Inventory\InventoryTeacherDistributionService; use Illuminate\Http\JsonResponse; @@ -23,7 +25,8 @@ class InventoryController extends BaseApiController public function __construct( private InventoryItemService $items, private InventorySummaryService $summary, - private InventoryTeacherDistributionService $distribution + private InventoryTeacherDistributionService $distribution, + private InventoryMovementService $movementService ) { parent::__construct(); } @@ -182,6 +185,161 @@ class InventoryController extends BaseApiController return $this->success($result); } + /** + * Get low-stock items (where quantity <= reorder_point). + */ + public function lowStock(): JsonResponse + { + $items = InventoryItem::lowStock() + ->with('category', 'preferredSupplier') + ->orderBy('name') + ->get(); + + return $this->success([ + 'items' => InventoryItemResource::collection($items), + 'count' => $items->count(), + ]); + } + + /** + * Get reorder suggestions based on low-stock items. + */ + public function reorderSuggestions(): JsonResponse + { + $lowStockItems = InventoryItem::lowStock() + ->with('category', 'preferredSupplier') + ->orderBy('name') + ->get(); + + $suggestions = $lowStockItems->map(function ($item) { + $reorderQty = (int) ($item->reorder_quantity ?: 0); + $currentQty = (int) $item->quantity; + $reorderPoint = (int) ($item->reorder_point ?: 0); + + // If no reorder quantity is set, suggest enough to bring back above reorder point + if ($reorderQty <= 0) { + $reorderQty = max(1, $reorderPoint - $currentQty + 5); + } + + return [ + 'id' => $item->id, + 'name' => $item->name, + 'current_quantity' => $currentQty, + 'reorder_point' => $reorderPoint, + 'suggested_order_qty' => $reorderQty, + 'preferred_supplier' => $item->preferredSupplier ? [ + 'id' => $item->preferredSupplier->id, + 'name' => $item->preferredSupplier->name, + ] : null, + 'category' => $item->category ? $item->category->name : null, + ]; + }); + + return $this->success([ + 'suggestions' => $suggestions, + 'count' => $suggestions->count(), + ]); + } + + /** + * Create a reorder request for a low-stock item. + */ + public function reorderRequest(int $id): JsonResponse + { + $item = InventoryItem::with('preferredSupplier')->find($id); + if (!$item) { + return $this->error('Item not found.', Response::HTTP_NOT_FOUND); + } + + $reorderQty = (int) ($item->reorder_quantity ?: 0); + if ($reorderQty <= 0) { + $reorderQty = max(1, ((int) ($item->reorder_point ?: 0)) - ((int) $item->quantity) + 5); + } + + return $this->success([ + 'item_id' => $item->id, + 'item_name' => $item->name, + 'current_quantity' => (int) $item->quantity, + 'suggested_quantity' => $reorderQty, + 'preferred_supplier' => $item->preferredSupplier ? [ + 'id' => $item->preferredSupplier->id, + 'name' => $item->preferredSupplier->name, + ] : null, + ]); + } + + /** + * View all items with their stock status. + */ + public function stockStatus(): JsonResponse + { + $type = request()->query('type'); + $items = InventoryItem::query() + ->with('category', 'preferredSupplier') + ->when($type, fn ($q) => $q->where('type', $type)) + ->orderBy('name') + ->get() + ->map(function ($item) { + $qty = (int) $item->quantity; + $reorderPoint = (int) ($item->reorder_point ?: 0); + $status = 'ok'; + if ($qty <= 0) { + $status = 'out_of_stock'; + } elseif ($reorderPoint > 0 && $qty <= $reorderPoint) { + $status = 'low_stock'; + } + + return [ + 'id' => $item->id, + 'name' => $item->name, + 'type' => $item->type, + 'category' => $item->category ? $item->category->name : null, + 'quantity' => $qty, + 'reorder_point' => $reorderPoint, + 'status' => $status, + ]; + }); + + $counts = [ + 'ok' => $items->where('status', 'ok')->count(), + 'low_stock' => $items->where('status', 'low_stock')->count(), + 'out_of_stock' => $items->where('status', 'out_of_stock')->count(), + ]; + + return $this->success([ + 'items' => $items, + 'counts' => $counts, + ]); + } + + /** + * Inventory dashboard summary. + */ + public function dashboard(): JsonResponse + { + $schoolYear = request()->query('school_year'); + + $items = InventoryItem::query() + ->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear)) + ->get(); + + $totalItems = $items->count(); + $byType = $items->groupBy('type')->map(fn ($g) => $g->count()); + $lowStockCount = $items->filter(fn ($i) => $i->reorder_point && $i->quantity <= $i->reorder_point)->count(); + $outOfStockCount = $items->filter(fn ($i) => $i->quantity <= 0)->count(); + $needsRepairCount = $items->sum('needs_repair_qty'); + $missingCount = $items->sum('cannot_find_qty'); + + return $this->success([ + 'total_items' => $totalItems, + 'by_type' => $byType, + 'low_stock_count' => $lowStockCount, + 'out_of_stock_count' => $outOfStockCount, + 'needs_repair_count' => (int) $needsRepairCount, + 'missing_count' => (int) $missingCount, + ]); + } + /** * @return int|JsonResponse */ diff --git a/app/Http/Controllers/Api/Inventory/InventoryMovementController.php b/app/Http/Controllers/Api/Inventory/InventoryMovementController.php index 6e18cf08..0a2881b8 100644 --- a/app/Http/Controllers/Api/Inventory/InventoryMovementController.php +++ b/app/Http/Controllers/Api/Inventory/InventoryMovementController.php @@ -73,6 +73,57 @@ class InventoryMovementController extends BaseApiController return $this->success($result); } + /** + * Void a movement (append-only correction). + */ + public function void(int $id): JsonResponse + { + $actor = $this->authenticatedUserIdOrUnauthorized(); + if ($actor instanceof JsonResponse) { + return $actor; + } + + $reason = (string) request()->input('reason', ''); + if (empty(trim($reason))) { + return $this->error('Reason is required to void a movement.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $result = $this->movements->voidMovement($id, $reason, $actor); + if (!($result['ok'] ?? false)) { + return $this->error($result['message'] ?? 'Failed to void movement.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->success(['message' => 'Movement voided successfully.']); + } + + /** + * Create a correction movement. + */ + public function correct(int $id): JsonResponse + { + $actor = $this->authenticatedUserIdOrUnauthorized(); + if ($actor instanceof JsonResponse) { + return $actor; + } + + $qtyChange = (int) request()->input('qty_change', 0); + $reason = (string) request()->input('reason', ''); + + if ($qtyChange === 0) { + return $this->error('Quantity change must be non-zero.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + if (empty(trim($reason))) { + return $this->error('Reason is required.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $result = $this->movements->correctMovement($id, $qtyChange, $reason, $actor); + if (!($result['ok'] ?? false)) { + return $this->error($result['message'] ?? 'Failed to correct movement.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->success(['message' => 'Correction movement created.']); + } + /** * @return int|JsonResponse */ diff --git a/app/Http/Requests/Inventory/InventoryItemStoreRequest.php b/app/Http/Requests/Inventory/InventoryItemStoreRequest.php index 2339330d..a024ebd9 100644 --- a/app/Http/Requests/Inventory/InventoryItemStoreRequest.php +++ b/app/Http/Requests/Inventory/InventoryItemStoreRequest.php @@ -14,13 +14,13 @@ class InventoryItemStoreRequest extends ApiFormRequest public function rules(): array { return [ - 'type' => ['required', 'string', 'max:20'], - 'category_id' => ['nullable', 'integer', 'min:1'], + 'type' => ['required', 'string', 'in:classroom,book,office,kitchen'], + 'category_id' => ['nullable', 'integer', 'min:1', 'exists:inventory_categories,id'], 'name' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], - 'quantity' => ['nullable', 'integer'], + 'quantity' => ['nullable', 'integer', 'min:0'], 'unit' => ['nullable', 'string', 'max:50'], - 'condition' => ['nullable', 'string', 'max:50'], + 'condition' => ['nullable', 'string', 'in:good,needs_repair,need_replace,cannot_find'], 'isbn' => ['nullable', 'string', 'max:50'], 'edition' => ['nullable', 'string', 'max:50'], 'sku' => ['nullable', 'string', 'max:50'], diff --git a/app/Http/Requests/Inventory/InventoryItemUpdateRequest.php b/app/Http/Requests/Inventory/InventoryItemUpdateRequest.php index 11f34a1a..5f26f7cc 100644 --- a/app/Http/Requests/Inventory/InventoryItemUpdateRequest.php +++ b/app/Http/Requests/Inventory/InventoryItemUpdateRequest.php @@ -14,12 +14,12 @@ class InventoryItemUpdateRequest extends ApiFormRequest public function rules(): array { return [ - 'category_id' => ['nullable', 'integer', 'min:1'], + 'category_id' => ['nullable', 'integer', 'min:1', 'exists:inventory_categories,id'], 'name' => ['sometimes', 'string', 'max:255'], 'description' => ['nullable', 'string'], - 'quantity' => ['nullable', 'integer'], + // quantity is intentionally excluded: stock changes must go through movements 'unit' => ['nullable', 'string', 'max:50'], - 'condition' => ['nullable', 'string', 'max:50'], + 'condition' => ['nullable', 'string', 'in:good,needs_repair,need_replace,cannot_find'], 'isbn' => ['nullable', 'string', 'max:50'], 'edition' => ['nullable', 'string', 'max:50'], 'sku' => ['nullable', 'string', 'max:50'], diff --git a/app/Http/Resources/Inventory/InventoryItemResource.php b/app/Http/Resources/Inventory/InventoryItemResource.php index ff93000a..68d09128 100644 --- a/app/Http/Resources/Inventory/InventoryItemResource.php +++ b/app/Http/Resources/Inventory/InventoryItemResource.php @@ -28,6 +28,16 @@ class InventoryItemResource extends JsonResource 'semester' => $this['semester'] ?? null, 'school_year' => $this['school_year'] ?? null, 'updated_by' => $this['updated_by'] ?? null, + // New fields + 'preferred_supplier_id' => $this['preferred_supplier_id'] ?? null, + 'last_purchase_price' => $this['last_purchase_price'] ?? null, + 'average_unit_cost' => $this['average_unit_cost'] ?? null, + 'reorder_point' => $this['reorder_point'] ?? null, + 'reorder_quantity' => $this['reorder_quantity'] ?? null, + 'lead_time_days' => $this['lead_time_days'] ?? null, + 'barcode' => $this['barcode'] ?? null, + 'qr_code' => $this['qr_code'] ?? null, + 'asset_tag' => $this['asset_tag'] ?? null, ]; } } diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php index 51c0dbbb..e04d239f 100644 --- a/app/Models/InventoryItem.php +++ b/app/Models/InventoryItem.php @@ -30,6 +30,17 @@ class InventoryItem extends BaseModel 'needs_repair_qty', 'need_replace_qty', 'cannot_find_qty', + // Supplier/reorder fields + 'preferred_supplier_id', + 'last_purchase_price', + 'average_unit_cost', + 'reorder_point', + 'reorder_quantity', + 'lead_time_days', + // Barcode/QR fields + 'barcode', + 'qr_code', + 'asset_tag', ]; protected $casts = [ @@ -41,6 +52,13 @@ class InventoryItem extends BaseModel 'needs_repair_qty' => 'integer', 'need_replace_qty' => 'integer', 'cannot_find_qty' => 'integer', + + 'preferred_supplier_id' => 'integer', + 'last_purchase_price' => 'decimal:2', + 'average_unit_cost' => 'decimal:2', + 'reorder_point' => 'integer', + 'reorder_quantity' => 'integer', + 'lead_time_days' => 'integer', ]; /* Optional relationships */ @@ -48,4 +66,25 @@ class InventoryItem extends BaseModel { return $this->belongsTo(InventoryCategory::class, 'category_id'); } + + public function preferredSupplier() + { + return $this->belongsTo(Supplier::class, 'preferred_supplier_id'); + } + + public function movements() + { + return $this->hasMany(InventoryMovement::class, 'item_id'); + } + + public function scopeLowStock($query) + { + return $query->whereNotNull('reorder_point') + ->whereColumn('quantity', '<=', 'reorder_point'); + } + + public function scopeOutOfStock($query) + { + return $query->where('quantity', '<=', 0); + } } \ No newline at end of file diff --git a/app/Models/InventoryMovement.php b/app/Models/InventoryMovement.php index b6bdeda9..3d3b0f8d 100644 --- a/app/Models/InventoryMovement.php +++ b/app/Models/InventoryMovement.php @@ -23,6 +23,13 @@ class InventoryMovement extends BaseModel 'teacher_id', 'student_id', 'class_section_id', + // Audit/correction fields + 'voided_at', + 'voided_by', + 'void_reason', + 'corrects_movement_id', + 'source_type', + 'source_id', ]; protected $casts = [ @@ -32,8 +39,23 @@ class InventoryMovement extends BaseModel 'teacher_id' => 'integer', 'student_id' => 'integer', 'class_section_id'=> 'integer', + 'voided_by' => 'integer', + 'corrects_movement_id' => 'integer', + 'source_id' => 'integer', + 'voided_at' => 'datetime', ]; + /* Scopes */ + public function scopeNotVoided($query) + { + return $query->whereNull('voided_at'); + } + + public function scopeVoided($query) + { + return $query->whereNotNull('voided_at'); + } + /* Optional relationships */ public function item() { @@ -59,4 +81,19 @@ class InventoryMovement extends BaseModel { return $this->belongsTo(ClassSection::class, 'class_section_id'); } + + public function voidedBy() + { + return $this->belongsTo(User::class, 'voided_by'); + } + + public function correctsMovement() + { + return $this->belongsTo(self::class, 'corrects_movement_id'); + } + + public function corrections() + { + return $this->hasMany(self::class, 'corrects_movement_id'); + } } \ No newline at end of file diff --git a/app/Models/PurchaseOrderItem.php b/app/Models/PurchaseOrderItem.php index ba44d165..276a3043 100644 --- a/app/Models/PurchaseOrderItem.php +++ b/app/Models/PurchaseOrderItem.php @@ -12,6 +12,7 @@ class PurchaseOrderItem extends BaseModel protected $fillable = [ 'purchase_order_id', 'supply_id', + 'inventory_item_id', 'description', 'quantity', 'received_qty', diff --git a/app/Services/Inventory/InventoryMovementService.php b/app/Services/Inventory/InventoryMovementService.php index daa9e524..b6df0efc 100644 --- a/app/Services/Inventory/InventoryMovementService.php +++ b/app/Services/Inventory/InventoryMovementService.php @@ -17,6 +17,7 @@ class InventoryMovementService { $selectedYear = trim((string) ($filters['school_year'] ?? $this->context->schoolYear())); $selectedSem = trim((string) ($filters['semester'] ?? $this->context->semester())); + $includeVoided = !empty($filters['include_voided']); $builder = DB::table('inventory_movements as m') ->select([ @@ -34,6 +35,11 @@ class InventoryMovementService ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'm.class_section_id') ->orderBy('m.id', 'DESC'); + // Exclude voided movements by default + if (!$includeVoided) { + $builder->whereNull('m.voided_at'); + } + if ($selectedYear !== '') { $builder->where('m.school_year', $selectedYear); } @@ -44,6 +50,10 @@ class InventoryMovementService return $builder->get()->map(fn ($r) => (array) $r)->all(); } + /** + * @deprecated Use recordMovement() instead. This method allows editing + * movement history which weakens auditability. + */ public function create(array $data, int $performedBy = 0): array { $itemId = (int) ($data['item_id'] ?? 0); @@ -60,19 +70,7 @@ class InventoryMovementService return ['ok' => false, 'message' => 'Not enough stock to apply this movement.']; } - $payload = [ - 'item_id' => $itemId, - 'qty_change' => $qtyChange, - 'movement_type' => $movementType, - 'reason' => trim((string) ($data['reason'] ?? '')), - 'note' => trim((string) ($data['note'] ?? '')), - 'semester' => $data['semester'] ?? $this->context->semester(), - 'school_year' => $data['school_year'] ?? $this->context->schoolYear(), - 'performed_by' => $performedBy ?: null, - 'teacher_id' => $this->nullableInt($data['teacher_id'] ?? null), - 'student_id' => $this->nullableInt($data['student_id'] ?? null), - 'class_section_id' => $this->nullableInt($data['class_section_id'] ?? null), - ]; + $payload = $this->buildMovementPayload($data, $itemId, $qtyChange, $movementType, $performedBy); try { InventoryMovement::query()->create($payload); @@ -85,6 +83,10 @@ class InventoryMovementService return ['ok' => true]; } + /** + * @deprecated Do NOT edit movements directly. Use voidMovement() + recordMovement() instead. + * Editing historical movements destroys audit integrity. + */ public function update(int $id, array $data): array { $movement = InventoryMovement::query()->find($id); @@ -92,6 +94,11 @@ class InventoryMovementService return ['ok' => false, 'message' => 'Movement not found.']; } + // Prevent editing voided movements + if ($movement->voided_at) { + return ['ok' => false, 'message' => 'Cannot edit a voided movement.']; + } + $movementType = (string) ($data['movement_type'] ?? $movement->movement_type); $qtyChange = (int) ($data['qty_change'] ?? $movement->qty_change); $qtyChange = $this->normalizeMovementQty($movementType, $qtyChange); @@ -125,6 +132,10 @@ class InventoryMovementService return ['ok' => true]; } + /** + * @deprecated Do NOT hard-delete movements. Use voidMovement() instead. + * Deleting historical movements destroys audit integrity. + */ public function delete(int $id): array { $movement = InventoryMovement::query()->find($id); @@ -132,6 +143,14 @@ class InventoryMovementService return ['ok' => false, 'message' => 'Movement not found.']; } + // Prevent hard-deleting movements that have corrections + $hasCorrections = InventoryMovement::query() + ->where('corrects_movement_id', $id) + ->exists(); + if ($hasCorrections) { + return ['ok' => false, 'message' => 'Cannot delete a movement that has corrections. Void it instead.']; + } + $itemId = (int) $movement->item_id; try { $movement->delete(); @@ -146,6 +165,10 @@ class InventoryMovementService return ['ok' => true]; } + /** + * @deprecated Do NOT bulk-delete movements. Use voidMovement() instead. + * Deleting historical movements destroys audit integrity. + */ public function bulkDelete(array $ids): array { $ids = array_values(array_filter(array_map('intval', $ids), static fn ($v) => $v > 0)); @@ -153,6 +176,14 @@ class InventoryMovementService return ['ok' => false, 'message' => 'No movements selected.']; } + // Check for corrections + $hasCorrections = InventoryMovement::query() + ->whereIn('corrects_movement_id', $ids) + ->exists(); + if ($hasCorrections) { + return ['ok' => false, 'message' => 'Cannot delete movements that have corrections. Void them instead.']; + } + $itemIds = DB::table('inventory_movements') ->select('item_id') ->whereIn('id', $ids) @@ -176,6 +207,10 @@ class InventoryMovementService return ['ok' => true, 'deleted' => count($ids)]; } + /** + * Record a new inventory movement (append-only). + * This is the primary method for all stock changes. + */ public function recordMovement( int $itemId, int $qtyChange, @@ -220,11 +255,107 @@ class InventoryMovementService return true; } + /** + * Void an existing movement (append-only correction). + * The original movement is preserved but marked as voided. + */ + public function voidMovement(int $movementId, string $reason, int $voidedBy): array + { + $movement = InventoryMovement::query()->find($movementId); + if (!$movement) { + return ['ok' => false, 'message' => 'Movement not found.']; + } + + if ($movement->voided_at) { + return ['ok' => false, 'message' => 'Movement is already voided.']; + } + + $itemId = (int) $movement->item_id; + + try { + DB::transaction(function () use ($movement, $reason, $voidedBy, $itemId) { + // Mark original as voided + $movement->update([ + 'voided_at' => now(), + 'voided_by' => $voidedBy, + 'void_reason' => $reason, + ]); + + // Create reverse movement to correct the quantity + $reverseQty = -(int) $movement->qty_change; + if ($reverseQty !== 0) { + InventoryMovement::query()->create([ + 'item_id' => $itemId, + 'qty_change' => $reverseQty, + 'movement_type' => 'adjust', + 'reason' => 'Correction: voided movement #' . $movement->id, + 'note' => $reason, + 'semester' => $this->context->semester(), + 'school_year' => $this->context->schoolYear(), + 'performed_by' => $voidedBy, + 'corrects_movement_id' => $movement->id, + ]); + } + + $this->recalcQuantity($itemId); + }); + } catch (\Throwable $e) { + Log::error('Inventory movement void failed: ' . $e->getMessage()); + return ['ok' => false, 'message' => 'Could not void movement.']; + } + + return ['ok' => true]; + } + + /** + * Create a correction movement for an existing movement. + * This does NOT void the original; it adds an adjusting entry. + */ + public function correctMovement(int $movementId, int $correctedQtyChange, string $reason, int $performedBy): array + { + $movement = InventoryMovement::query()->find($movementId); + if (!$movement) { + return ['ok' => false, 'message' => 'Movement not found.']; + } + + if ($movement->voided_at) { + return ['ok' => false, 'message' => 'Cannot correct a voided movement.']; + } + + $itemId = (int) $movement->item_id; + + if ($this->wouldGoNegative($itemId, $correctedQtyChange)) { + return ['ok' => false, 'message' => 'Not enough stock to apply correction.']; + } + + try { + InventoryMovement::query()->create([ + 'item_id' => $itemId, + 'qty_change' => $correctedQtyChange, + 'movement_type' => 'adjust', + 'reason' => 'Correction for movement #' . $movement->id, + 'note' => $reason, + 'semester' => $this->context->semester(), + 'school_year' => $this->context->schoolYear(), + 'performed_by' => $performedBy, + 'corrects_movement_id' => $movement->id, + ]); + + $this->recalcQuantity($itemId); + } catch (\Throwable $e) { + Log::error('Inventory movement correction failed: ' . $e->getMessage()); + return ['ok' => false, 'message' => 'Could not correct movement.']; + } + + return ['ok' => true]; + } + public function recalcQuantity(int $itemId): void { $sumRow = InventoryMovement::query() ->selectRaw('SUM(qty_change) as qty_change') ->where('item_id', $itemId) + ->whereNull('voided_at') // Exclude voided movements from calculations ->first(); $sum = (int) ($sumRow?->qty_change ?? 0); @@ -255,6 +386,47 @@ class InventoryMovementService } } + /** + * Reconcile inventory items - compare stored quantity against movement totals. + * Returns an array of items that have discrepancies. + */ + public function reconcile(?string $schoolYear = null): array + { + $schoolYear = $schoolYear ?: $this->context->schoolYear(); + $discrepancies = []; + + $items = InventoryItem::query() + ->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear)) + ->get(); + + foreach ($items as $item) { + $movementSum = (int) InventoryMovement::query() + ->where('item_id', $item->id) + ->whereNull('voided_at') + ->sum('qty_change'); + + $storedQty = (int) $item->quantity; + $variance = $storedQty - $movementSum; + + if ($variance !== 0) { + $discrepancies[] = [ + 'id' => $item->id, + 'name' => $item->name, + 'stored_quantity' => $storedQty, + 'movement_sum' => $movementSum, + 'variance' => $variance, + ]; + } + } + + return [ + 'school_year' => $schoolYear, + 'total_items' => $items->count(), + 'discrepancies_count' => count($discrepancies), + 'discrepancies' => $discrepancies, + ]; + } + private function ensureInitialMovement(int $itemId): void { $hasMov = InventoryMovement::query()->where('item_id', $itemId)->exists(); @@ -338,4 +510,21 @@ class InventoryMovementService } return (int) $val; } + + private function buildMovementPayload(array $data, int $itemId, int $qtyChange, string $movementType, int $performedBy): array + { + return [ + 'item_id' => $itemId, + 'qty_change' => $qtyChange, + 'movement_type' => $movementType, + 'reason' => trim((string) ($data['reason'] ?? '')), + 'note' => trim((string) ($data['note'] ?? '')), + 'semester' => $data['semester'] ?? $this->context->semester(), + 'school_year' => $data['school_year'] ?? $this->context->schoolYear(), + 'performed_by' => $performedBy ?: null, + 'teacher_id' => $this->nullableInt($data['teacher_id'] ?? null), + 'student_id' => $this->nullableInt($data['student_id'] ?? null), + 'class_section_id' => $this->nullableInt($data['class_section_id'] ?? null), + ]; + } } diff --git a/app/Services/PurchaseOrders/PurchaseOrderReceiveService.php b/app/Services/PurchaseOrders/PurchaseOrderReceiveService.php index 7539f623..9dec9681 100644 --- a/app/Services/PurchaseOrders/PurchaseOrderReceiveService.php +++ b/app/Services/PurchaseOrders/PurchaseOrderReceiveService.php @@ -6,11 +6,17 @@ use App\Models\PurchaseOrder; use App\Models\PurchaseOrderItem; use App\Models\Supply; use App\Models\SupplyTransaction; +use App\Services\Inventory\InventoryMovementService; use Illuminate\Support\Facades\DB; use RuntimeException; class PurchaseOrderReceiveService { + public function __construct( + private InventoryMovementService $inventoryMovementService + ) { + } + public function receive(int $poId, array $received, string $issuedBy): array { $po = PurchaseOrder::query()->find($poId); @@ -51,24 +57,36 @@ class PurchaseOrderReceiveService $poItem->received_qty = (int) $poItem->received_qty + $toReceive; $poItem->save(); + // Update supply qty_on_hand (legacy support) $supply = Supply::query()->find($poItem->supply_id); - if (!$supply) { - $completed = false; - continue; + if ($supply) { + $supply->qty_on_hand = (int) $supply->qty_on_hand + $toReceive; + $supply->save(); + + SupplyTransaction::query()->create([ + 'supply_id' => $supply->id, + 'type' => 'in', + 'quantity' => $toReceive, + 'ref' => 'PO ' . ($po->po_number ?? $po->id), + 'issued_to' => 'Inventory', + 'issued_by' => $issuedBy, + 'notes' => 'Received against PO', + ]); } - $supply->qty_on_hand = (int) $supply->qty_on_hand + $toReceive; - $supply->save(); - - SupplyTransaction::query()->create([ - 'supply_id' => $supply->id, - 'type' => 'in', - 'quantity' => $toReceive, - 'ref' => 'PO ' . ($po->po_number ?? $po->id), - 'issued_to' => 'Inventory', - 'issued_by' => $issuedBy, - 'notes' => 'Received against PO', - ]); + // Create inventory movement if the PO item is linked to an inventory item + $inventoryItemId = $poItem->inventory_item_id; + if ($inventoryItemId) { + $performedBy = is_numeric($issuedBy) ? (int) $issuedBy : null; + $this->inventoryMovementService->recordMovement( + itemId: (int) $inventoryItemId, + qtyChange: $toReceive, + type: 'in', + reason: 'Purchase order received', + note: 'PO ' . ($po->po_number ?? $po->id), + performedBy: $performedBy + ); + } if ($poItem->received_qty < $poItem->quantity) { $completed = false; diff --git a/database/migrations/2026_06_11_074503_add_inventory_movement_audit_fields.php b/database/migrations/2026_06_11_074503_add_inventory_movement_audit_fields.php new file mode 100644 index 00000000..b55574eb --- /dev/null +++ b/database/migrations/2026_06_11_074503_add_inventory_movement_audit_fields.php @@ -0,0 +1,46 @@ +timestamp('voided_at')->nullable()->after('updated_at'); + $table->unsignedBigInteger('voided_by')->nullable()->after('voided_at'); + $table->string('void_reason', 255)->nullable()->after('voided_by'); + $table->unsignedBigInteger('corrects_movement_id')->nullable()->after('void_reason'); + $table->string('source_type', 50)->nullable()->after('corrects_movement_id'); + $table->unsignedBigInteger('source_id')->nullable()->after('source_type'); + + // Index for efficient queries + $table->index('voided_at'); + $table->index('corrects_movement_id'); + $table->index('source_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_movements', function (Blueprint $table) { + $table->dropColumn([ + 'voided_at', + 'voided_by', + 'void_reason', + 'corrects_movement_id', + 'source_type', + 'source_id', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_11_074621_add_inventory_item_reorder_fields.php b/database/migrations/2026_06_11_074621_add_inventory_item_reorder_fields.php new file mode 100644 index 00000000..e28a2d2f --- /dev/null +++ b/database/migrations/2026_06_11_074621_add_inventory_item_reorder_fields.php @@ -0,0 +1,52 @@ +unsignedBigInteger('preferred_supplier_id')->nullable()->after('updated_by'); + $table->decimal('last_purchase_price', 10, 2)->nullable()->after('preferred_supplier_id'); + $table->decimal('average_unit_cost', 10, 2)->nullable()->after('last_purchase_price'); + $table->integer('reorder_point')->nullable()->after('average_unit_cost'); + $table->integer('reorder_quantity')->nullable()->after('reorder_point'); + $table->integer('lead_time_days')->nullable()->after('reorder_quantity'); + + // Barcode/QR support + $table->string('barcode', 100)->nullable()->after('lead_time_days'); + $table->string('qr_code', 255)->nullable()->after('barcode'); + $table->string('asset_tag', 100)->nullable()->after('qr_code'); + + $table->index('preferred_supplier_id'); + $table->index('reorder_point'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_items', function (Blueprint $table) { + $table->dropColumn([ + 'preferred_supplier_id', + 'last_purchase_price', + 'average_unit_cost', + 'reorder_point', + 'reorder_quantity', + 'lead_time_days', + 'barcode', + 'qr_code', + 'asset_tag', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_11_074642_add_inventory_item_id_to_purchase_order_items.php b/database/migrations/2026_06_11_074642_add_inventory_item_id_to_purchase_order_items.php new file mode 100644 index 00000000..46e4675e --- /dev/null +++ b/database/migrations/2026_06_11_074642_add_inventory_item_id_to_purchase_order_items.php @@ -0,0 +1,40 @@ +unsignedInteger('inventory_item_id')->nullable()->after('supply_id'); + } + }); + + // Add index if it doesn't exist + $indexExists = DB::select("SHOW INDEX FROM purchase_order_items WHERE Key_name = 'po_items_inventory_item_idx'"); + if (empty($indexExists)) { + Schema::table('purchase_order_items', function (Blueprint $table) { + $table->index('inventory_item_id', 'po_items_inventory_item_idx'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('purchase_order_items', function (Blueprint $table) { + $table->dropIndex('po_items_inventory_item_idx'); + $table->dropColumn('inventory_item_id'); + }); + } +}; diff --git a/docs/SECURITY_HARDENING_APPLIED_REPORT.md b/docs/SECURITY_HARDENING_APPLIED_REPORT.md deleted file mode 100644 index 89a56e90..00000000 --- a/docs/SECURITY_HARDENING_APPLIED_REPORT.md +++ /dev/null @@ -1,160 +0,0 @@ -# Security Hardening Application Report - -Generated: 2026-06-09 -Project: RentalDriveGo / Car Management System -Input archive: `/mnt/data/car_management_system_plan_applied(1).zip` -Plan applied: `/mnt/data/SECURITY_HARDENING_IMPLEMENTATION_PLAN(1).md` - -## Executive result - -The uploaded project was inspected and the security hardening plan was applied as far as safely possible inside the source archive. The archive already contained a broad previous hardening pass. I did not blindly trust that state, because that is how software becomes an expensive apology letter. I re-audited the implementation against the plan and applied additional corrections where the code still contradicted the target security model. - -The final output includes a patched project archive, this report, a changed-file list, and an incremental diff for the additional changes made during this pass. - -## What was already present in the uploaded project - -The project already contained many of the plan-aligned building blocks: - -- Centralized JWT helpers for actor tokens. -- HttpOnly session cookie helpers for admin, employee, and renter sessions. -- Company authorization policy helpers. -- Admin 2FA and fresh-2FA enforcement middleware. -- Public booking access-token helpers. -- Webhook idempotency helpers. -- Hashed `CompanyApiKey` model and migration. -- `ReservationPublicAccess` model and migration. -- `WebhookEvent` model and migration. -- Upload validation helpers and public/private storage separation. -- Request ID and sanitized error response middleware. -- Production Docker/Traefik hardening, including Redis authentication and `x-middleware-subrequest` blocking. -- Static security scan script and CI security gates. - -That base work was useful, but it had a few security and test-consistency gaps. - -## Additional changes applied in this pass - -### 1. Removed legacy plaintext company API key storage - -The plan requires company API keys to be hash-only. The code had introduced `CompanyApiKey`, but the legacy `Company.apiKey` field still existed in the Prisma schema and middleware still allowed a fallback lookup against that plaintext field when `ALLOW_LEGACY_COMPANY_API_KEYS=true`. - -Changes applied: - -- Removed `Company.apiKey` from `packages/database/prisma/schema.prisma`. -- Removed `apiKey` from the local `Company` TypeScript interface in `packages/database/src/index.ts` and `packages/database/src/index.d.ts`. -- Removed the legacy plaintext fallback path from `apps/api/src/middleware/requireApiKey.ts`. -- Added migration `20260609233000_drop_legacy_company_api_key` to drop the old column and unique index. -- Rewrote `requireApiKey` tests around prefix lookup, hash comparison, revocation, and `lastUsedAt` updates. - -Security effect: raw company API keys are no longer accepted through the legacy company column and are no longer represented in the current Prisma schema. - -### 2. Centralized Socket.io token verification - -The main API process still verified Socket.io auth tokens directly with `jwt.verify(token, JWT_SECRET)` and did not enforce issuer, audience, actor type, or allowed algorithm. This contradicted the session-authentication phase of the plan. - -Changes applied: - -- Added `verifyAnyActorToken()` to `apps/api/src/security/tokens.ts`. -- Updated `apps/api/src/index.ts` to use centralized actor-token verification for Socket.io authentication. -- Removed the direct `jsonwebtoken` import from the main API entrypoint. - -Security effect: Socket.io no longer accepts tokens that bypass the centralized actor-token constraints. - -### 3. Hardened employee password-reset JWT verification - -Employee password-reset tokens were signed and verified directly with the JWT secret and no issuer, audience, or algorithm constraints. - -Changes applied: - -- Added explicit `HS256` signing for employee password-reset tokens. -- Added issuer `rentaldrivego-api`. -- Added audience `employee_password_reset`. -- Added matching verification constraints. - -Security effect: reset tokens now reject wrong issuer, wrong audience, or wrong algorithm instead of relying on a bare shared-secret verification. - -### 4. Repaired test fixtures and middleware tests - -Some tests still expected pre-hardening behavior. That is a bad smell: tests defending old weaknesses are basically tiny lobbyists for future incidents. - -Changes applied: - -- Updated API-key middleware tests to validate hashed-key behavior instead of plaintext `Company.apiKey` lookup. -- Updated auth middleware tests to match the centralized token verifier behavior. -- Updated integration test helper token generation to use `signActorToken()` so generated test tokens include issuer and audience. - -Security effect: future test runs are less likely to push developers back toward weaker auth behavior. - -## Verification performed in this sandbox - -| Check | Result | Notes | -|---|---:|---| -| Static security scan | PASS | `npm run security:static` completed successfully. | -| Critical production dependency audit | PASS | `npm audit --package-lock-only --omit=dev --audit-level=critical` exited successfully. | -| JSON syntax check | PASS | Root and app `package.json` files and lockfile parsed successfully. | -| Shell syntax check | PASS | Shell scripts and production entrypoint parsed with `bash -n`. | -| YAML parse check | PASS | `docker-compose.production.yml` and `.gitlab-ci.yml` parsed successfully. | -| Node script syntax | PASS | `scripts/security-static-check.mjs` passed `node --check`. | -| Full dependency install | NOT COMPLETED | `npm ci --ignore-scripts --prefer-offline` could not complete in this sandbox. | -| Full type-check/test/build | NOT RUN | Requires dependencies to be installed. Run in CI or a normal development environment. | -| Docker compose config/render | NOT RUN | Docker is unavailable in this sandbox. | -| Prisma generate/migrate | NOT RUN | Requires dependency installation and a normal Prisma/DB environment. | - -## Dependency audit note - -The critical audit gate passes. The audit still reports moderate findings involving `postcss` through Next.js and `uuid` through Firebase/cron-related dependency chains. The lockfile’s suggested fixes require forced or breaking upgrades, so I did not casually smash the dependency graph with a hammer and call the mess “security.” Those should be handled in a controlled dependency-upgrade ticket with full frontend and notification regression testing. - -## Files changed by this pass - -- `apps/api/src/index.ts` -- `apps/api/src/middleware/requireApiKey.ts` -- `apps/api/src/middleware/requireApiKey.test.ts` -- `apps/api/src/middleware/requireCompanyAuth.test.ts` -- `apps/api/src/middleware/requireRenterAuth.test.ts` -- `apps/api/src/modules/auth/auth.employee.service.ts` -- `apps/api/src/security/tokens.ts` -- `apps/api/src/tests/helpers/fixtures.ts` -- `packages/database/prisma/schema.prisma` -- `packages/database/prisma/migrations/20260609233000_drop_legacy_company_api_key/migration.sql` -- `packages/database/src/index.ts` -- `packages/database/src/index.d.ts` - -See also: - -- `security_hardening_incremental.diff` -- `security_hardening_changed_files.txt` - -## Phase status against the hardening plan - -| Phase | Status | Evidence / caveat | -|---|---:|---| -| Phase 0: Emergency stabilization | Partial / needs operator action | Static scan passes, placeholders are present, but real production secret rotation cannot be performed inside the archive. | -| Phase 1: Sessions and authentication | Substantially applied | HttpOnly session helpers and centralized actor JWT verification exist; Socket.io and reset-token gaps were corrected in this pass. | -| Phase 2: Authorization and tenant isolation | Substantially applied | Company policy middleware exists; tenant-safe patterns are present in many modules. Full proof requires test suite and code review across all repository methods. | -| Phase 3: Public booking privacy | Applied at source level | Public access token model/helper and safe public booking flow are present. Must be verified with integration tests. | -| Phase 4: Admin hardening | Applied at source level | Mandatory 2FA and fresh-2FA middleware are present. Enrollment and recovery-code workflows still need production validation. | -| Phase 5: API key hardening | Strengthened in this pass | Legacy plaintext company API key storage/fallback removed; hash-only `CompanyApiKey` path remains. | -| Phase 6: Payments and webhooks | Applied at source level | Raw-body webhook handling and idempotency helpers are present. Must be verified against provider test events. | -| Phase 7: Upload and storage hardening | Applied at source level | Magic-byte validation and public/private storage split are present. Must be verified with upload abuse tests. | -| Phase 8: Rate limiting, errors, browser security | Applied at source level | Request IDs, sanitized errors, rate limit middleware, and security headers are present. Must be verified in deployed environment. | -| Phase 9: Deployment hardening | Applied at config level | Redis auth, non-public DB tooling, private networks, and Traefik header blocking are present. Must be verified on the real host. | -| Phase 10: Observability, auditability, jobs | Partial | Logging/audit hooks exist, but queue migration and operational observability need dedicated validation. | -| CI/CD security gates | Applied at config level | Security scan and critical audit gates exist; full CI must run outside this sandbox. | - -## Required follow-up before launch - -1. Rotate all real production secrets and invalidate old sessions/API keys where appropriate. -2. Run `npm ci` in CI or a normal development environment. -3. Run `npm run db:generate` and apply the new migration after backup. -4. Run full type-check, unit tests, integration tests, e2e tests, and builds. -5. Run payment-provider webhook test events using real sandbox provider signatures. -6. Verify public/private storage behavior with real uploaded files. -7. Verify Redis and PostgreSQL are not externally reachable from the production host. -8. Run container image build and Trivy scan. -9. Confirm admin 2FA enrollment and fresh-2FA gates before enabling privileged admin actions in production. -10. Document any deferrals with owner, risk acceptance, compensating control, deadline, and ticket number. - -## Launch recommendation - -Do not launch publicly yet based only on the patched archive. The source now better matches the plan, and the critical static/audit checks pass, but the hard launch gate still depends on full CI, Prisma migration validation, deployment verification, and production secret rotation. - -The patched archive is suitable for the next CI/staging pass. Treat it as implementation-ready source, not production clearance. Production clearance comes from reproducible evidence, not vibes in a ZIP file. diff --git a/docs/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md b/docs/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md deleted file mode 100644 index f641a28c..00000000 --- a/docs/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md +++ /dev/null @@ -1,255 +0,0 @@ -# Security Hardening Leftover Application Report - -Project: RentalDriveGo / Car Management System -Input archive: `car_management_system_hardened_applied.zip` -Output archive: `car_management_system_leftover_applied.zip` -Date: 2026-06-09 - -## Executive Summary - -This pass applied the remaining source-level hardening gaps that were still practical to implement directly in the repository after the first hardening pass. The focus was on eliminating browser-readable authentication assumptions, enforcing app-layer blocking for the `x-middleware-subrequest` bypass class, improving actor-aware rate limiting, adding an admin 2FA recovery-code workflow, and bringing documentation/static checks into line with the hardened authentication model. - -This does not replace production operator work such as real secret rotation, live infrastructure verification, container scanning, applying migrations to a real database, or running the complete CI/test pipeline. Those items remain launch-gate evidence requirements. - -## Applied Changes - -### 1. Removed remaining browser-side employee token assumptions - -Changed files: - -- `apps/dashboard/src/lib/api.ts` -- `apps/dashboard/src/components/layout/TopBar.tsx` -- `apps/dashboard/src/components/layout/Sidebar.tsx` -- `apps/dashboard/src/app/(dashboard)/team/page.tsx` -- `apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx` - -What changed: - -- Removed dashboard use of employee auth tokens from `localStorage`. -- Dashboard API calls now use `credentials: 'include'` and depend on HttpOnly cookies. -- Dashboard Socket.io connection now uses cookie credentials instead of script-provided auth tokens. -- Team page no longer decodes employee identity from a localStorage JWT. It resolves the current actor through `/auth/employee/me`. -- Admin 2FA sign-in form now accepts either a six-digit TOTP code or a recovery code value. - -Security effect: - -- Reduces script-readable authentication exposure. -- Aligns the dashboard with the intended HttpOnly session-cookie model. -- Prevents UI code from treating a readable JWT as the authority for employee identity. - -### 2. Added Socket.io HttpOnly-cookie session support - -Changed file: - -- `apps/api/src/index.ts` - -What changed: - -- Added Socket.io session-token extraction from HttpOnly cookies. -- Preserved explicit token verification for trusted non-browser/server contexts, while browser clients can now authenticate through cookies. -- Reused centralized actor-token verification. - -Security effect: - -- Real-time dashboard connections no longer require JavaScript-readable employee tokens. -- Socket authentication now follows the same actor-token validation path used elsewhere. - -### 3. Added app-layer `x-middleware-subrequest` blocking - -Changed files: - -- `apps/api/src/app.ts` -- `apps/dashboard/src/middleware.ts` -- `apps/marketplace/src/middleware.ts` -- `apps/admin/src/middleware.ts` -- `apps/dashboard/src/middleware.test.ts` -- `apps/marketplace/src/middleware.test.ts` - -What changed: - -- API now rejects requests containing `x-middleware-subrequest` before route handling. -- Dashboard, marketplace, and admin Next middleware now reject the same header at the app layer. -- Added/updated middleware tests for the rejection path. - -Security effect: - -- Adds defense in depth beyond reverse-proxy filtering. -- Prevents the project from depending on a single infrastructure control for this bypass class. - -### 4. Hardened admin/browser fetch behavior - -Changed files include: - -- `apps/admin/src/lib/api.ts` -- `apps/admin/src/app/dashboard/admin-users/page.tsx` -- `apps/admin/src/app/dashboard/renters/page.tsx` -- `apps/admin/src/app/dashboard/companies/[id]/page.tsx` -- `apps/admin/src/app/dashboard/containers/page.tsx` -- `apps/admin/src/app/dashboard/pricing/page.tsx` -- `apps/admin/src/app/forgot-password/page.tsx` -- `apps/admin/src/app/reset-password/page.tsx` - -What changed: - -- Admin API wrapper uses `credentials: 'include'`. -- Manual admin fetch calls now include credentials where they directly call the admin API. -- Removed dead placeholder `getToken()` helpers that returned empty strings and created meaningless `Authorization: Bearer ` headers. - -Security effect: - -- Admin browser requests now consistently rely on the HttpOnly admin session cookie. -- Removes misleading bearer-token scaffolding from the admin UI. - -### 5. Improved actor-aware rate limiting - -Changed files: - -- `apps/api/src/middleware/rateLimiter.ts` -- `apps/api/src/app.ts` - -What changed: - -- API rate-limit keys now prefer a verified actor identity from session cookies or valid Bearer tokens. -- Actor-aware rate limiting falls back safely to existing request actor fields or anonymous IP keys. -- Admin authentication routes now receive the stricter authentication limiter before the broader admin limiter. - -Security effect: - -- Authenticated traffic is limited by actor identity instead of only coarse IP data. -- Login and admin-auth abuse get stricter protection. -- Multi-container correctness still depends on Redis availability/configuration in production, which must be verified during deployment. - -### 6. Added admin 2FA recovery-code backend workflow - -Changed files: - -- `packages/database/prisma/schema.prisma` -- `packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql` -- `apps/api/src/modules/admin/admin.repo.ts` -- `apps/api/src/modules/admin/admin.service.ts` -- `apps/api/src/modules/admin/admin.schemas.ts` -- `apps/api/src/modules/admin/admin.routes.ts` - -What changed: - -- Added `AdminRecoveryCode` model. -- Recovery codes are stored as bcrypt hashes, not plaintext. -- TOTP enrollment now issues one-time recovery codes. -- Recovery-code regeneration is protected by authenticated admin access and fresh 2FA. -- Login can consume a valid unused recovery code when TOTP is enabled. -- Recovery-code issuance and use are audited. - -Security effect: - -- Adds a recovery path for mandatory admin 2FA without storing backup codes in plaintext. -- Preserves one-time-use semantics. -- Adds auditability for recovery-code lifecycle events. - -Limitation: - -- The backend returns recovery codes after enrollment/regeneration. A production-ready admin UI still needs to display them once with clear save instructions and must not persist them client-side. - -### 7. Strengthened static scanning for auth-token regressions - -Changed file: - -- `scripts/security-static-check.mjs` - -What changed: - -- Static scan now catches common regressions involving auth/session token names in `localStorage` and `document.cookie`. -- Scan specifically targets auth token/session names such as employee/admin/renter tokens and sessions. - -Security effect: - -- Makes it harder for future code changes to quietly reintroduce script-readable authentication tokens. - -### 8. Updated documentation to match the hardened model - -Changed files: - -- `apps/dashboard/README.md` -- `memory/project_auth_architecture.md` -- `docs/project-design/COOKIE_POLICY.md` -- `apps/api/src/swagger/openapi.ts` - -What changed: - -- Replaced stale localStorage/JWT handoff claims with HttpOnly session-cookie wording. -- Clarified that browser clients use HttpOnly sessions and that Bearer tokens are only for documented trusted server/mobile contexts. -- Updated cookie policy references from legacy `employee_token` wording to `employee_session`. - -Security effect: - -- Reduces the odds that a future developer follows stale documentation and reintroduces the old pattern. - -## Validation Performed - -The following checks were run successfully in the available environment: - -```bash -npm run security:static -node --check scripts/security-static-check.mjs -# JSON parse validation for package.json and package-lock.json files -# YAML parse validation for docker-compose.production.yml and .gitlab-ci.yml -bash -n docker/entrypoint.production.sh scripts/docker-prod-*.sh scripts/docker-registry-local-up.sh scripts/setup-clerk-keys.sh -# TypeScript/TSX syntax transpile check over 507 source files -npm audit --package-lock-only --omit=dev --audit-level=critical -``` - -Results: - -- Security static check: passed. -- Static-check script syntax: passed. -- Package JSON validation: passed. -- YAML validation: passed. -- Shell syntax validation: passed. -- TypeScript/TSX syntax transpile validation: passed. -- Critical production dependency audit: passed. - -Audit note: - -- `npm audit --audit-level=critical` exited successfully. -- Moderate advisories remain for transitive `postcss` and `uuid` paths. The available automatic fixes require breaking/force dependency changes, so they were not applied blindly in this source pass. - -## Not Fully Verified in This Environment - -The following items require a real development/CI/deployment environment: - -- `npm ci` from a clean checkout. -- Full workspace typecheck. -- Full unit, integration, security, and e2e test suites. -- Prisma client generation. -- Applying the new database migration to a real database. -- Database backup/restore verification. -- Docker image build and runtime validation. -- Container scanning with Trivy or equivalent. -- Live reverse-proxy validation for `x-middleware-subrequest` blocking. -- Redis-backed distributed rate-limit validation across multiple API containers. -- Provider webhook sandbox tests. -- Live admin 2FA recovery-code UX verification. - -## Remaining Launch-Gate Work - -These are still not things source edits can prove by themselves: - -1. Rotate all real production secrets. -2. Confirm no real secrets exist in repository history or image layers. -3. Apply and verify the new `admin_recovery_codes` migration. -4. Run the full CI gate: lint, typecheck, unit tests, integration tests, security tests, build, dependency audit, secret scan, and container scan. -5. Confirm Redis and PostgreSQL are private in production. -6. Confirm DB management tools are not publicly reachable. -7. Confirm production containers run non-root with reduced capabilities and resource limits. -8. Confirm webhook signature/idempotency behavior against real provider sandbox payloads. -9. Confirm private files are inaccessible through static routes in the deployed environment. -10. Confirm the admin UI displays recovery codes once and instructs admins to save them securely. - -## Changed Files - -See `security_hardening_leftover_changed_files.txt` for the complete file list and `security_hardening_leftover.diff` for the unified diff. - -## Final Assessment - -This pass closes a meaningful set of leftover source-level gaps from the security-hardening plan. The project is closer to the intended model: API-enforced security, HttpOnly browser sessions, stronger admin 2FA recovery, app-layer bypass blocking, and less stale documentation. - -However, this still should not be treated as production-ready until the remaining launch-gate evidence is collected from CI and the live deployment environment. Security that has not been tested in the actual runtime is mostly optimism with a lanyard. diff --git a/docs/project-design/API_REFACTOR_TASK_LIST.md b/docs/project-design/API_REFACTOR_TASK_LIST.md deleted file mode 100644 index bedcd0b0..00000000 --- a/docs/project-design/API_REFACTOR_TASK_LIST.md +++ /dev/null @@ -1,83 +0,0 @@ -# API Refactor Status - -This file replaces the older pre-refactor task list that referenced route paths and module boundaries no longer present in the codebase. - -Source of truth: - -- `apps/api/src/app.ts` -- `apps/api/src/modules/*` -- `apps/api/src/http/*` - -## Refactor State - -The API has already been refactored into the modular structure the old task list was aiming for. - -Current structure includes: - -- `modules/*` for route/service/repo/presenter grouping -- `http/errors` for centralized error types and middleware -- `http/validate` for request parsing helpers -- `http/respond` for success helpers -- module-level tests and integration tests under `apps/api/src/tests` - -The older checklist items that referenced: - -- `apps/api/src/routes/*.ts` -- missing test tooling -- missing shared validation helpers -- missing shared response helpers - -are no longer accurate and should not be used as active work items. - -## Remaining Follow-Up Work - -These are the only meaningful refactor follow-ups still worth tracking at a high level. - -### 1. Keep OpenAPI coverage aligned with route growth - -The API now has: - -- Swagger UI at `/docs` -- OpenAPI JSON at `/api/v1/openapi.json` - -But the OpenAPI document does not yet mirror every newer route group perfectly. Continue updating: - -- `apps/api/src/swagger/openapi.ts` -- route schemas -- module docs - -whenever new endpoints are added. - -### 2. Expand integration test coverage - -Integration tests exist, but the heaviest workflows still benefit from deeper coverage: - -- subscription billing transitions -- marketplace reservation intake -- public booking and payment initialization -- reservation inspection and close flows -- admin billing operations - -### 3. Reduce remaining direct Prisma orchestration in large services - -The route layer is already thin in the current API. The next cleanup target is inside larger service files where orchestration still mixes: - -- multi-step workflow logic -- transaction boundaries -- some direct Prisma writes - -especially in: - -- reservation flows -- subscription/billing flows -- admin billing flows - -### 4. Keep docs aligned with disabled flows - -Some legacy surfaces still exist as placeholders or schema remnants, for example: - -- disabled Clerk webhook endpoint -- legacy `clerkUserId` field on `Employee` -- disabled renter signup/login API endpoints - -Those should stay clearly marked in docs so design specs do not drift back toward removed implementations. diff --git a/docs/project-design/AUDIT.md b/docs/project-design/AUDIT.md deleted file mode 100644 index 0c7974f6..00000000 --- a/docs/project-design/AUDIT.md +++ /dev/null @@ -1,41 +0,0 @@ -# Documentation Audit - -Date: 2026-05-26 - -## Purpose - -This note records the cleanup applied to the `docs/project-design` folder after comparing it with the live codebase. - -## Drift That Was Removed - -The older design docs included several features or assumptions that are not active in the current implementation: - -- Clerk-based employee auth and webhooks -- Clerk-based team invite acceptance -- renter self-service signup/login as an active user flow -- renter email verification as an active flow -- a separate white-label company public-site frontend app -- multi-currency SaaS subscription checkout beyond `MAD` -- marketing routes such as `/about`, `/contact`, and `/blog` -- outdated API file paths under `apps/api/src/routes/*` - -## Current Documentation Rule - -The `docs/project-design` files should describe only one of two things: - -1. current implemented behavior -2. explicit future plans, clearly labeled as plans - -They should not describe removed systems as if they are still live. - -## Current References - -Use these documents as the current implementation references: - -- `api-routes.md` -- `schema.md` -- `FEATURES.md` -- `PAGES.md` -- `INTEGRATION.md` - -Use the execution-plan documents only as future/planning material, not as evidence that a feature is already shipped. diff --git a/docs/project-design/B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md b/docs/project-design/B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md deleted file mode 100644 index be33e780..00000000 --- a/docs/project-design/B2B_BILLING_AND_INVOICE_EXECUTION_PLAN.md +++ /dev/null @@ -1,1670 +0,0 @@ -# B2B Billing and Invoice Execution Plan - -## 1. Objective - -Build a billing and invoice system that manages the financial lifecycle behind B2B subscriptions. - -The system must support: - -- Subscription invoices -- Trial conversion invoices -- Renewal invoices -- One-time invoices -- Usage-based invoices -- Manual invoices -- Payment collection -- Payment retries -- Credits -- Refunds -- Taxes -- Discounts -- Invoice adjustments -- Failed payments -- Invoice reconciliation -- Revenue auditability -- Admin billing operations - -The billing system must integrate with the subscription policy system but remain logically separate from subscription status. - ---- - -## 2. Core Principle - -Subscription state and invoice state are related, but they are not the same thing. - -Bad design: - -```text -subscription.status = invoice.status -``` - -Better design: - -```text -subscription.status = active -invoice.status = open -payment.status = failed -entitlement.access_level = full -``` - -A customer can have an active subscription and an unpaid invoice during a grace period. - -A customer can have a canceled subscription and still owe an unpaid invoice. - -A customer can have a paid invoice and no active subscription if the invoice was for a one-time service. - -Do not merge these concepts. That is how billing systems become haunted. - ---- - -## 3. Core Billing Entities - -The billing system should use these primary objects: - -```text -billing_account -subscription -invoice -invoice_line_item -payment_intent -payment_attempt -payment_method -credit_balance -credit_note -refund -tax_record -billing_event -``` - -| Entity | Purpose | -|---|---| -| `billing_account` | Represents the paying customer or company | -| `subscription` | Represents recurring service agreement | -| `invoice` | Represents money owed | -| `invoice_line_item` | Represents individual charges or credits | -| `payment_intent` | Represents attempt to collect invoice payment | -| `payment_attempt` | Represents each actual payment try | -| `payment_method` | Represents card, ACH, bank transfer, invoice terms, etc. | -| `credit_balance` | Tracks customer prepaid or credited amount | -| `credit_note` | Reduces or reverses invoice amount | -| `refund` | Returns money after payment | -| `tax_record` | Stores calculated tax details | -| `billing_event` | Immutable audit trail | - ---- - -## 4. Billing Account Model - -A billing account represents the organization or legal entity responsible for payment. - -```sql -CREATE TABLE billing_accounts ( - id UUID PRIMARY KEY, - - workspace_id UUID NOT NULL, - customer_id UUID NOT NULL, - - legal_name TEXT NOT NULL, - billing_email TEXT NOT NULL, - - billing_address_line1 TEXT, - billing_address_line2 TEXT, - billing_city TEXT, - billing_state TEXT, - billing_postal_code TEXT, - billing_country TEXT, - - tax_id TEXT, - tax_exempt BOOLEAN DEFAULT FALSE, - - default_currency TEXT NOT NULL DEFAULT 'USD', - default_payment_method_id UUID, - - invoice_terms TEXT NOT NULL DEFAULT 'due_on_receipt', - net_terms_days INTEGER DEFAULT 0, - - provider_customer_id TEXT, - - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - -Billing account rules: - -- A workspace should have one primary billing account. -- Enterprise customers may have multiple billing accounts if departmental billing is supported. -- A billing account owns invoices, payment methods, and credit balance. -- Billing contact information must be snapshotted on finalized invoices. -- Invoices should preserve historical billing details and must not dynamically read current billing address after creation. - ---- - -## 5. Invoice Lifecycle - -Invoices should have their own state machine. - -```text -draft -open -paid -partially_paid -payment_pending -past_due -void -uncollectible -refunded -partially_refunded -``` - -| Status | Meaning | -|---|---| -| `draft` | Invoice is being prepared and is not payable yet | -| `open` | Invoice is finalized and awaiting payment | -| `payment_pending` | Payment has been initiated but not completed | -| `partially_paid` | Some amount has been paid, but balance remains | -| `paid` | Invoice is fully paid | -| `past_due` | Invoice due date has passed | -| `void` | Invoice was canceled before payment | -| `uncollectible` | Invoice is written off as bad debt | -| `refunded` | Fully paid invoice was fully refunded | -| `partially_refunded` | Paid invoice was partially refunded | - ---- - -## 6. Invoice State Transitions - -Happy path: - -```text -draft - ↓ -open - ↓ -payment_pending - ↓ -paid -``` - -Failure flow: - -```text -open - ↓ -payment_pending - ↓ -open - ↓ -past_due - ↓ -uncollectible -``` - -Void flow: - -```text -draft/open - ↓ -void -``` - -Refund flow: - -```text -paid - ↓ -partially_refunded - ↓ -refunded -``` - -| Current Status | Event | New Status | Notes | -|---|---|---|---| -| `draft` | Invoice finalized | `open` | Invoice becomes payable | -| `draft` | Invoice canceled | `void` | No payment expected | -| `open` | Payment started | `payment_pending` | Payment is in progress | -| `open` | Full payment received | `paid` | Invoice closed | -| `open` | Partial payment received | `partially_paid` | Balance remains | -| `open` | Due date passed | `past_due` | Trigger dunning | -| `payment_pending` | Payment succeeded | `paid` | Invoice closed | -| `payment_pending` | Payment failed | `open` | Retry may continue | -| `partially_paid` | Remaining balance paid | `paid` | Invoice closed | -| `partially_paid` | Due date passed | `past_due` | Balance overdue | -| `past_due` | Payment received | `paid` | Restore account if needed | -| `past_due` | Written off | `uncollectible` | Bad debt | -| `paid` | Partial refund issued | `partially_refunded` | Access review may be needed | -| `paid` | Full refund issued | `refunded` | Access review required | -| `open` | Admin voids invoice | `void` | Audit required | - ---- - -## 7. Invoice Types - -```text -subscription_initial -subscription_renewal -trial_conversion -subscription_upgrade -subscription_downgrade_credit -usage_based -one_time -manual -correction -cancellation_fee -``` - -| Invoice Type | Description | -|---|---| -| `subscription_initial` | First paid invoice for a new subscription | -| `subscription_renewal` | Recurring invoice for next billing period | -| `trial_conversion` | Invoice generated when trial converts to paid | -| `subscription_upgrade` | Prorated invoice for plan upgrade | -| `subscription_downgrade_credit` | Credit issued for downgrade | -| `usage_based` | Usage charges for metered products | -| `one_time` | Non-recurring charge | -| `manual` | Admin-created invoice | -| `correction` | Accounting correction | -| `cancellation_fee` | Fee after contract cancellation, if applicable | - ---- - -## 8. Invoice Creation Policy - -### 8.1 Trial Conversion Invoice - -```text -trialing subscription - ↓ -generate trial_conversion invoice - ↓ -attempt payment -``` - -Rules: - -- Generate invoice at trial end. -- Include subscription plan line item. -- Include tax if applicable. -- Apply credits if available. -- Apply discounts if valid. -- Attempt payment immediately if payment method exists. -- If payment succeeds, mark invoice `paid` and subscription `active`. -- If payment fails, mark invoice `open` or `payment_pending` and subscription `payment_pending`. - -### 8.2 Renewal Invoice - -```text -active subscription - ↓ -generate renewal invoice - ↓ -finalize invoice - ↓ -attempt payment -``` - -| Billing Model | Invoice Timing | -|---|---| -| Credit card / auto-pay | Generate and charge at renewal time | -| ACH / bank debit | Generate 3-5 days before due date | -| Net terms | Generate at period start, due later | -| Enterprise contract | Generate according to contract schedule | - -Default SaaS rule: - -```text -Generate renewal invoice at current_period_end. -Charge immediately. -``` - -Default B2B enterprise rule: - -```text -Generate invoice at period start. -Due date = invoice_date + net_terms_days. -``` - -### 8.3 Upgrade Invoice - -```text -active subscription - ↓ -calculate prorated charge - ↓ -generate upgrade invoice - ↓ -attempt payment -``` - -Recommended policy: - -- Upgrade takes effect immediately. -- Charge prorated amount immediately. -- If payment succeeds, apply upgraded entitlements. -- If payment fails, keep existing plan active. -- Do not remove already-paid access because an upgrade payment failed. - -### 8.4 Downgrade Invoice or Credit - -Recommended policy: - -```text -Downgrade takes effect at next billing cycle. -No immediate refund by default. -``` - -Alternative policy: - -```text -Immediate downgrade creates account credit. -``` - -Preferred default: - -| Action | Policy | -|---|---| -| Downgrade | Effective next billing period | -| Refund | No automatic refund | -| Credit | Optional, admin-controlled | -| Entitlement reduction | At period end | - -### 8.5 Manual Invoice - -Admins may create manual invoices for: - -- Custom services -- Enterprise onboarding -- Extra seats -- Support packages -- Contracted fees -- Usage corrections -- Migration adjustments - -Manual invoices require: - -```text -admin_id -reason -line_items -approval if over threshold -audit event -``` - ---- - -## 9. Invoice Line Items - -Every invoice must be composed of line items. - -```text -subscription_fee -seat_fee -usage_fee -setup_fee -discount -tax -credit -proration -refund_adjustment -manual_adjustment -``` - -```sql -CREATE TABLE invoice_line_items ( - id UUID PRIMARY KEY, - - invoice_id UUID NOT NULL, - subscription_id UUID, - plan_id UUID, - - type TEXT NOT NULL, - description TEXT NOT NULL, - - quantity NUMERIC NOT NULL DEFAULT 1, - unit_amount INTEGER NOT NULL, - amount INTEGER NOT NULL, - - currency TEXT NOT NULL, - - period_start TIMESTAMP, - period_end TIMESTAMP, - - metadata JSONB, - - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - -Rules: - -- Invoice totals must be derived from line items. -- Never manually mutate invoice total without a corresponding line item. -- Negative line items are allowed for discounts, credits, and adjustments. -- Tax should be represented as a separate line item or tax record. -- Historical line item descriptions should not change after invoice finalization. - ---- - -## 10. Invoice Amount Calculation - -```text -subtotal = sum(charge line items) -discount_total = sum(discount line items) -credit_total = sum(credit line items) -tax_total = calculated tax -total = subtotal - discount_total - credit_total + tax_total -amount_due = total - amount_paid -``` - -Constraints: - -```text -amount_due cannot be less than 0 -paid invoice cannot have amount_due > 0 -void invoice cannot accept payments -draft invoice cannot accept payments -``` - -If credits exceed invoice total: - -```text -invoice amount_due = 0 -remaining credit stays on billing account -invoice may be marked paid -``` - ---- - -## 11. Payment Lifecycle - -Payments should be tracked separately from invoices. - -```text -requires_payment_method -requires_confirmation -requires_action -processing -succeeded -failed -canceled -refunded -partially_refunded -``` - -Payment flow: - -```text -invoice.open - ↓ -payment_intent.created - ↓ -payment.processing - ↓ -payment.succeeded - ↓ -invoice.paid -``` - -Failure flow: - -```text -payment.failed - ↓ -invoice.open - ↓ -retry scheduled - ↓ -subscription payment_pending / past_due -``` - ---- - -## 12. Payment Methods - -Supported payment methods: - -```text -card -ach_debit -bank_transfer -wire_transfer -manual_invoice -purchase_order -``` - -| Customer Type | Payment Method | -|---|---| -| Self-serve SaaS | Card | -| Mid-market | Card or ACH | -| Enterprise | Invoice terms / ACH / wire | -| High-risk customer | Card or upfront payment only | - -Rules: - -- Each billing account may have one default payment method. -- Payment methods must be tokenized through the provider. -- Do not store raw card or bank details. -- Expired cards should trigger notification before renewal. -- Failed payment method should not be retried indefinitely. - ---- - -## 13. Net Terms Policy - -Supported terms: - -```text -due_on_receipt -net_7 -net_15 -net_30 -net_45 -net_60 -``` - -Default: - -```text -Self-serve: due_on_receipt -B2B standard: net_15 or net_30 -Enterprise: contract-specific -``` - -Rules: - -- Net terms require approval above a revenue threshold. -- Net terms customers may remain `active` while invoice is `open`. -- Once due date passes, invoice becomes `past_due`. -- Subscription state should only degrade after the configured grace policy. - -Example: - -```json -{ - "invoice_date": "2026-06-01T00:00:00Z", - "net_terms_days": 30, - "due_at": "2026-07-01T00:00:00Z" -} -``` - ---- - -## 14. Dunning and Payment Recovery - -Dunning is triggered by unpaid invoices. - -| Day | Invoice Status | Subscription Status | Action | -|---:|---|---|---| -| 0 | `open` | `active` or `payment_pending` | Notify billing owner | -| 3 | `open` | `payment_pending` | Retry payment | -| 7 | `past_due` | `past_due` | Limit access | -| 14 | `past_due` | `suspended` | Suspend account | -| 30 | `uncollectible` or `void` | `canceled` | Cancel subscription | - -Rules: - -- Dunning is invoice-driven. -- Subscription status reacts to unpaid invoice state. -- A customer may have multiple unpaid invoices. -- The oldest unpaid invoice should control escalation. -- Payment of all blocking invoices should restore subscription. -- Admins may pause dunning for enterprise customers. - ---- - -## 15. Credits and Credit Balance - -Credits should reduce future invoice amounts. - -Credit sources: - -```text -downgrade credit -service credit -SLA credit -manual goodwill credit -overpayment -refund converted to credit -``` - -```sql -CREATE TABLE credit_balances ( - id UUID PRIMARY KEY, - - billing_account_id UUID NOT NULL, - currency TEXT NOT NULL, - - balance_amount INTEGER NOT NULL DEFAULT 0, - - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - -```sql -CREATE TABLE credit_ledger_entries ( - id UUID PRIMARY KEY, - - billing_account_id UUID NOT NULL, - invoice_id UUID, - - type TEXT NOT NULL, - amount INTEGER NOT NULL, - currency TEXT NOT NULL, - - reason TEXT NOT NULL, - created_by TEXT, - - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - -Credit rules: - -- Credits must be ledgered. -- Credit balance cannot change without a ledger entry. -- Credits should be currency-specific. -- Credits apply before payment collection. -- Credits should not silently expire unless terms clearly say so. -- Enterprise credits may require approval. - ---- - -## 16. Discounts and Coupons - -Discount types: - -```text -percentage -fixed_amount -trial_extension -contractual -promotional -manual -``` - -Rules: - -- Discounts must have start and end dates. -- Discounts must define scope: invoice, subscription, plan, or account. -- Discounts should not apply to taxes unless legally permitted. -- Expired discounts must not apply to new invoices. -- Manual discounts require admin reason. - -Example: - -```json -{ - "discount_type": "percentage", - "percent_off": 20, - "applies_to": "subscription", - "starts_at": "2026-06-01T00:00:00Z", - "ends_at": "2026-12-01T00:00:00Z" -} -``` - ---- - -## 17. Tax Policy - -Tax calculation should happen at invoice finalization. - -Requirements: - -- Use billing address for tax jurisdiction. -- Use tax exemption status if present. -- Store tax calculation result. -- Store tax rate and jurisdiction. -- Do not recalculate finalized invoice taxes unless issuing correction. -- Support reverse charge or VAT if applicable. - -```sql -CREATE TABLE tax_records ( - id UUID PRIMARY KEY, - - invoice_id UUID NOT NULL, - - jurisdiction TEXT, - tax_rate NUMERIC, - tax_amount INTEGER NOT NULL, - tax_type TEXT, - - tax_exempt BOOLEAN DEFAULT FALSE, - exemption_reason TEXT, - - provider_tax_id TEXT, - metadata JSONB, - - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - -Recommended: integrate tax calculation through a provider if selling across jurisdictions. Tax logic is not the place to be brave. - ---- - -## 18. Refund Policy - -Refunds reverse payments, not invoices. - -Refund types: - -```text -full -partial -goodwill -duplicate_payment -service_issue -fraud -chargeback -``` - -Rules: - -- Refunds require a paid invoice. -- Refunds must reference the original payment. -- Refunds must create a refund event. -- Full refund may require subscription cancellation or entitlement review. -- Partial refund may leave subscription active. -- Refund does not delete the invoice. -- Refund does not erase revenue history. - -```sql -CREATE TABLE refunds ( - id UUID PRIMARY KEY, - - invoice_id UUID NOT NULL, - payment_attempt_id UUID NOT NULL, - billing_account_id UUID NOT NULL, - - amount INTEGER NOT NULL, - currency TEXT NOT NULL, - - reason TEXT NOT NULL, - status TEXT NOT NULL, - - provider_refund_id TEXT, - created_by TEXT, - - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - -| Refund Event | Invoice Impact | Subscription Impact | -|---|---|---| -| Partial refund | `partially_refunded` | Usually no change | -| Full refund current period | `refunded` | Review access | -| Fraud refund | `refunded` | Suspend or cancel | -| Duplicate payment refund | Keep `paid` | No change | -| SLA credit as refund | `partially_refunded` | No change | - ---- - -## 19. Credit Notes - -Credit notes reduce the amount owed on an invoice. - -Use credit notes when: - -- Invoice was wrong -- Customer received a contractual credit -- Customer downgraded and receives credit -- Tax correction is needed -- Admin adjusts invoice before collection - -Do not use refunds when money was never collected. - -```sql -CREATE TABLE credit_notes ( - id UUID PRIMARY KEY, - - invoice_id UUID NOT NULL, - billing_account_id UUID NOT NULL, - - amount INTEGER NOT NULL, - currency TEXT NOT NULL, - - reason TEXT NOT NULL, - status TEXT NOT NULL, - - created_by TEXT, - - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - ---- - -## 20. Chargeback Policy - -Chargebacks are high-risk and should override normal happy-path billing. - -```text -chargeback.created - ↓ -invoice disputed - ↓ -subscription suspended - ↓ -admin/risk review -``` - -Rules: - -- Chargeback should immediately flag billing account. -- Current subscription should become `suspended` unless enterprise exception applies. -- Disable automatic reactivation until resolved. -- Require admin review to restore. -- Track dispute outcome. - -| Outcome | Action | -|---|---| -| Won | Restore invoice/payment state | -| Lost | Keep invoice unpaid or refunded | -| Withdrawn | Restore access after review | -| Under review | Keep account restricted | - ---- - -## 21. Reconciliation Policy - -The billing system must reconcile local records with the payment provider. - -Daily reconciliation should compare: - -- Provider customer records -- Provider subscriptions -- Provider invoices -- Provider payments -- Provider refunds -- Provider disputes - -| Mismatch | Action | -|---|---| -| Provider invoice paid, local invoice open | Mark local invoice paid | -| Provider payment failed, local payment processing | Mark failed | -| Provider subscription canceled, local active | Flag for review | -| Local invoice exists, provider missing | Flag critical | -| Provider refund exists, local missing | Import refund | - -Rules: - -- Do not blindly overwrite local admin overrides. -- Create reconciliation events. -- Alert on high-risk mismatches. -- Store provider snapshot for audit. - ---- - -## 22. Billing Events - -Every financial action must create an immutable event. - -```text -billing_account.created -billing_account.updated - -invoice.created -invoice.finalized -invoice.sent -invoice.payment_started -invoice.payment_failed -invoice.payment_succeeded -invoice.partially_paid -invoice.paid -invoice.past_due -invoice.voided -invoice.marked_uncollectible - -payment_intent.created -payment_attempt.created -payment_attempt.succeeded -payment_attempt.failed -payment_method.updated - -credit.created -credit.applied -credit.expired -credit.reversed - -discount.applied -discount.expired - -tax.calculated -tax.exempt_applied - -refund.created -refund.succeeded -refund.failed - -chargeback.created -chargeback.won -chargeback.lost - -billing.reconciled -billing.reconciliation_failed -admin.billing_override_created -``` - ---- - -## 23. Admin Billing Console - -Admins need operational tools, but every button should leave fingerprints. - -Capabilities: - -- View billing account -- View invoices -- View invoice line items -- View payments -- View payment attempts -- View credit balance -- View credit ledger -- View refunds -- View chargebacks -- Retry payment -- Send invoice -- Void invoice -- Mark invoice uncollectible -- Issue credit note -- Issue refund -- Apply discount -- Update billing email -- Update tax ID -- Update billing address -- Change net terms -- Pause dunning -- Resume dunning - -Audit fields: - -```json -{ - "admin_id": "admin_123", - "action": "void_invoice", - "invoice_id": "inv_123", - "reason": "Duplicate invoice generated during migration", - "previous_status": "open", - "new_status": "void", - "created_at": "2026-06-15T00:00:00Z" -} -``` - -Require approval for: - -- Refunds above threshold -- Manual credits above threshold -- Changing enterprise net terms -- Marking invoice uncollectible -- Voiding paid invoices -- Removing tax -- Applying large discounts - ---- - -## 24. Customer Billing Portal - -Customers should be able to: - -- View current plan -- View billing account details -- View invoices -- Download invoices as PDF -- Update payment method -- Pay open invoices -- View payment history -- View credit balance -- Update billing email -- Update tax ID -- Update billing address -- Cancel subscription if permitted -- Request plan change - -Do not expose internal statuses like: - -```text -uncollectible -dunning_paused -provider_mismatch -``` - -Map them to customer-friendly labels. - ---- - -## 25. Customer-Facing Invoice Labels - -| Internal Status | Customer Label | -|---|---| -| `draft` | Preparing | -| `open` | Due | -| `payment_pending` | Payment processing | -| `paid` | Paid | -| `partially_paid` | Partially paid | -| `past_due` | Past due | -| `void` | Canceled | -| `uncollectible` | Contact support | -| `refunded` | Refunded | -| `partially_refunded` | Partially refunded | - ---- - -## 26. Billing Notifications - -| Event | Recipient | Notification | -|---|---|---| -| Invoice finalized | Billing owner | Invoice available | -| Invoice due soon | Billing owner | Upcoming due date | -| Payment succeeded | Billing owner | Receipt | -| Payment failed | Billing owner + admins | Payment failed | -| Invoice past due | Billing owner + admins | Payment overdue | -| Account suspended | Billing owner + admins | Access restricted | -| Invoice paid after failure | Billing owner + admins | Payment recovered | -| Refund issued | Billing owner | Refund confirmation | -| Credit applied | Billing owner | Credit applied | - -Rules: - -- Every notification must include invoice number. -- Payment failure notifications must include update-payment link. -- Past due notifications must include deadline. -- Enterprise invoices should include PO number if available. -- Receipts should include amount paid and payment method summary. - ---- - -## 27. Invoice Numbering - -Invoice numbers must be sequential and immutable. - -Recommended format: - -```text -INV-2026-000001 -``` - -Rules: - -- Invoice number assigned at finalization. -- Draft invoices may not need invoice numbers. -- Invoice numbers must never be reused. -- Voided invoices keep their invoice numbers. -- Region-specific sequencing may be needed for tax compliance. - -Fields: - -```sql -invoice_number TEXT UNIQUE -invoice_sequence INTEGER UNIQUE -``` - -Do not generate invoice numbers using random UUIDs. Accounting teams will hate you, and this time they will be right. - ---- - -## 28. Data Model Summary - -### Invoices Table - -```sql -CREATE TABLE invoices ( - id UUID PRIMARY KEY, - - billing_account_id UUID NOT NULL, - subscription_id UUID, - workspace_id UUID NOT NULL, - - invoice_number TEXT UNIQUE, - invoice_type TEXT NOT NULL, - status TEXT NOT NULL, - - currency TEXT NOT NULL, - - subtotal_amount INTEGER NOT NULL DEFAULT 0, - discount_amount INTEGER NOT NULL DEFAULT 0, - credit_amount INTEGER NOT NULL DEFAULT 0, - tax_amount INTEGER NOT NULL DEFAULT 0, - total_amount INTEGER NOT NULL DEFAULT 0, - amount_paid INTEGER NOT NULL DEFAULT 0, - amount_due INTEGER NOT NULL DEFAULT 0, - - invoice_date TIMESTAMP, - due_at TIMESTAMP, - finalized_at TIMESTAMP, - paid_at TIMESTAMP, - voided_at TIMESTAMP, - marked_uncollectible_at TIMESTAMP, - - billing_name TEXT, - billing_email TEXT, - billing_address JSONB, - - provider_invoice_id TEXT, - - metadata JSONB, - - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - -### Payment Attempts Table - -```sql -CREATE TABLE payment_attempts ( - id UUID PRIMARY KEY, - - invoice_id UUID NOT NULL, - billing_account_id UUID NOT NULL, - - provider_payment_id TEXT, - payment_method_id UUID, - - status TEXT NOT NULL, - amount INTEGER NOT NULL, - currency TEXT NOT NULL, - - failure_code TEXT, - failure_message TEXT, - - attempted_at TIMESTAMP NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - -### Billing Events Table - -```sql -CREATE TABLE billing_events ( - id UUID PRIMARY KEY, - - billing_account_id UUID, - invoice_id UUID, - subscription_id UUID, - workspace_id UUID, - - event_type TEXT NOT NULL, - source TEXT NOT NULL, - - payload JSONB NOT NULL, - - occurred_at TIMESTAMP NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - ---- - -## 29. API Requirements - -### Create Invoice - -```http -POST /billing/invoices -``` - -```json -{ - "billing_account_id": "ba_123", - "invoice_type": "manual", - "line_items": [ - { - "type": "setup_fee", - "description": "Enterprise onboarding", - "quantity": 1, - "unit_amount": 500000 - } - ] -} -``` - -### Finalize Invoice - -```http -POST /billing/invoices/{invoice_id}/finalize -``` - -Behavior: - -- Assign invoice number. -- Calculate totals. -- Calculate tax. -- Apply credits. -- Set status to `open`. -- Send invoice if configured. - -### Pay Invoice - -```http -POST /billing/invoices/{invoice_id}/pay -``` - -```json -{ - "payment_method_id": "pm_123" -} -``` - -### Retry Payment - -```http -POST /billing/invoices/{invoice_id}/retry-payment -``` - -### Void Invoice - -```http -POST /billing/invoices/{invoice_id}/void -``` - -```json -{ - "reason": "Duplicate invoice" -} -``` - -### Issue Credit Note - -```http -POST /billing/invoices/{invoice_id}/credit-notes -``` - -```json -{ - "amount": 10000, - "reason": "Service credit" -} -``` - -### Issue Refund - -```http -POST /billing/invoices/{invoice_id}/refunds -``` - -```json -{ - "amount": 10000, - "reason": "Customer requested partial refund" -} -``` - ---- - -## 30. Scheduled Jobs - -| Job | Frequency | Purpose | -|---|---:|---| -| Invoice generation job | Hourly/Daily | Generate renewal invoices | -| Invoice finalization job | Hourly | Finalize pending invoices | -| Payment collection job | Hourly | Charge open invoices | -| Payment retry job | Hourly | Retry failed payments | -| Invoice due reminder job | Daily | Notify customers before due date | -| Past due job | Hourly | Mark overdue invoices as past due | -| Dunning escalation job | Daily | Escalate unpaid invoices | -| Credit application job | On invoice finalization | Apply available credits | -| Reconciliation job | Daily | Match provider and local state | -| Tax sync job | Daily/On demand | Validate tax records | -| Invoice PDF generation job | On finalization | Generate downloadable invoices | - ---- - -## 31. Subscription Integration Rules - -Billing should drive subscription changes only through defined events. - -| Billing Event | Subscription Reaction | -|---|---| -| `invoice.paid` for trial conversion | `trialing → active` | -| `invoice.payment_failed` for renewal | `active → payment_pending` | -| `invoice.past_due` | `payment_pending → past_due` | -| `invoice.marked_uncollectible` | `suspended → canceled` or admin review | -| `refund.succeeded` full current period | Entitlement review | -| `chargeback.created` | `active → suspended` | - -Important: not every invoice should affect subscription status. - -A manual invoice for onboarding should not cancel the subscription if unpaid unless policy says it is blocking. - -Add this field: - -```text -is_subscription_blocking -``` - -Example: - -```json -{ - "invoice_type": "manual", - "is_subscription_blocking": false -} -``` - ---- - -## 32. Blocking vs Non-Blocking Invoices - -Blocking invoices can affect subscription access: - -```text -subscription_initial -subscription_renewal -trial_conversion -subscription_upgrade -``` - -Non-blocking invoices should not automatically affect access: - -```text -manual -setup_fee -professional_services -correction -one_time -``` - -| Invoice Type | Blocks Subscription? | -|---|---| -| `subscription_initial` | Yes | -| `trial_conversion` | Yes | -| `subscription_renewal` | Yes | -| `subscription_upgrade` | Usually no | -| `manual` | Configurable | -| `one_time` | No by default | -| `usage_based` | Configurable | -| `setup_fee` | No by default | -| `cancellation_fee` | No, subscription already ending | - ---- - -## 33. Accounting Rules - -### Immutability - -Once finalized: - -- Invoice number cannot change. -- Invoice date cannot change. -- Line items cannot be edited directly. -- Totals cannot be edited directly. -- Billing address snapshot cannot be edited directly. - -Corrections require: - -```text -credit note -adjustment invoice -refund -void if unpaid -``` - -### Revenue Integrity - -- Paid invoices remain in history. -- Refunded invoices remain in history. -- Voided invoices remain in history. -- Deleted invoices should not exist in production. -- Admin changes must be audited. - ---- - -## 34. Testing Plan - -### Unit Tests - -Test: - -- Invoice total calculation -- Tax calculation hooks -- Credit application -- Discount application -- Invoice state transitions -- Payment state transitions -- Refund rules -- Credit note rules -- Net terms due date logic -- Blocking invoice logic - -### Integration Tests - -Test: - -- Create invoice -- Finalize invoice -- Pay invoice -- Failed payment -- Retry payment -- Apply credit -- Issue refund -- Void invoice -- Reconcile provider payment -- Handle duplicate webhook -- Handle out-of-order webhook - -### End-to-End Tests - -Test: - -```text -Trial ends → invoice created → payment succeeds → subscription active -Trial ends → invoice created → payment fails → subscription payment_pending -Renewal invoice fails → dunning starts → subscription past_due -Past due invoice paid → subscription restored -Enterprise invoice created with net_30 → due date passes → past_due -Upgrade invoice fails → existing subscription remains active -Full refund current period → entitlement review triggered -Chargeback created → subscription suspended -``` - ---- - -## 35. Monitoring and Alerts - -Track: - -```text -invoice_created_count -invoice_finalized_count -invoice_paid_count -invoice_failed_count -invoice_past_due_count -invoice_voided_count -invoice_uncollectible_count -payment_attempt_count -payment_success_rate -payment_failure_rate -refund_count -credit_note_count -reconciliation_mismatch_count -average_days_to_payment -accounts_receivable_total -past_due_amount_total -``` - -Alert on: - -```text -Payment failure spike -Invoices stuck in payment_pending -Invoices overdue beyond policy -Provider/local invoice mismatch -Provider/local payment mismatch -Refund spike -Credit abuse -Chargeback created -Invoice finalization failure -Tax calculation failure -``` - ---- - -## 36. Rollout Plan - -### Phase 1: Invoice Foundation - -Deliver: - -- Billing account model -- Invoice model -- Invoice line items -- Invoice statuses -- Invoice total calculation -- Basic invoice admin view - -Acceptance criteria: - -- Admin can create draft invoice. -- Admin can finalize invoice. -- Finalized invoice gets immutable number. -- Invoice totals are derived from line items. - -### Phase 2: Payment Collection - -Deliver: - -- Payment method support -- Payment intent model -- Payment attempt model -- Payment success handling -- Payment failure handling - -Acceptance criteria: - -- Open invoice can be paid. -- Failed payment creates payment attempt. -- Successful payment marks invoice paid. -- Payment event is auditable. - -### Phase 3: Subscription Billing Integration - -Deliver: - -- Trial conversion invoices -- Renewal invoices -- Initial subscription invoices -- Blocking invoice logic -- Subscription reaction events - -Acceptance criteria: - -- Trial conversion payment activates subscription. -- Renewal failure moves subscription to payment_pending. -- Invoice payment restores subscription when applicable. -- Non-blocking invoices do not affect subscription. - -### Phase 4: Dunning and Net Terms - -Deliver: - -- Due dates -- Net terms -- Past due logic -- Payment retries -- Dunning notifications -- Subscription escalation rules - -Acceptance criteria: - -- Net terms invoices become past due after due date. -- Failed payments retry on schedule. -- Blocking unpaid invoices escalate subscription status. -- Admin can pause dunning. - -### Phase 5: Credits, Refunds, Adjustments - -Deliver: - -- Credit balance ledger -- Credit note support -- Refund support -- Discount support -- Adjustment invoice support - -Acceptance criteria: - -- Credits apply to future invoices. -- Credit balance is ledgered. -- Refunds reference original payments. -- Finalized invoices are corrected through credit notes or adjustments. - -### Phase 6: Tax and Compliance - -Deliver: - -- Tax calculation integration -- Tax records -- Tax exemption handling -- Invoice PDF generation -- Invoice address snapshots - -Acceptance criteria: - -- Tax calculated at finalization. -- Tax result is stored. -- Finalized invoice preserves billing details. -- Customer can download invoice PDF. - -### Phase 7: Reconciliation and Hardening - -Deliver: - -- Provider reconciliation -- Webhook replay -- Dead-letter queue -- Mismatch alerts -- Admin correction workflows - -Acceptance criteria: - -- Provider and local states stay aligned. -- Failed webhooks are replayable. -- Mismatches create alerts. -- Reconciliation does not overwrite valid admin decisions. - ---- - -## 37. Final Default Billing Policy - -Use this as the baseline B2B billing policy: - -```text -Invoices are generated from subscriptions, usage, or admin action. -Invoices start as draft and become payable only after finalization. -Invoice numbers are assigned only at finalization. -Finalized invoices are immutable. -Subscription invoices are blocking by default. -Manual invoices are non-blocking by default. -Credits are applied before payment collection. -Tax is calculated during finalization. -Payment failures create payment attempts and dunning events. -Payment retries follow configured schedule. -Open invoices become past_due after due date. -Blocking past_due invoices affect subscription status. -Refunds reverse payments, not invoices. -Credit notes reduce invoice balances before or after finalization depending on accounting rules. -All billing changes are event-driven and audited. -Daily reconciliation compares local billing state with payment provider state. -``` - ---- - -## 38. Definition of Done - -The billing and invoice system is complete when: - -- Billing accounts exist and own invoices. -- Invoices have a clear lifecycle. -- Invoice totals are line-item driven. -- Finalized invoices are immutable. -- Invoice numbers are sequential and permanent. -- Payment attempts are tracked. -- Failed payments trigger dunning. -- Paid invoices update subscription status when appropriate. -- Non-blocking invoices do not affect access. -- Credits and refunds are ledgered. -- Admin billing actions are audited. -- Customers can view and pay invoices. -- Provider reconciliation is operational. -- Tests cover invoice, payment, refund, and subscription integration flows. diff --git a/docs/project-design/B2B_SUBSCRIPTION_EXECUTION_PLAN.md b/docs/project-design/B2B_SUBSCRIPTION_EXECUTION_PLAN.md deleted file mode 100644 index 0b7f673c..00000000 --- a/docs/project-design/B2B_SUBSCRIPTION_EXECUTION_PLAN.md +++ /dev/null @@ -1,1340 +0,0 @@ -# B2B Subscription Execution Plan - -## 1. Objective - -Build a reliable B2B subscription management system that supports: - -- Free trials -- Paid subscriptions -- Payment pending states -- Grace periods -- Payment retry logic -- Suspension -- Cancellation -- Reactivation -- Admin overrides -- Auditable subscription lifecycle events -- Entitlement-based access control - -The system must avoid ambiguous subscription states and must separate billing status from product access. - ---- - -## 2. Guiding Principles - -### 2.1 Subscription Status Is Not Access - -A subscription status describes the billing lifecycle. - -Access should be determined by an entitlement layer. - -Example: - -```text -subscription.status = "past_due" -entitlement.access_level = "limited" -``` - -Do not scatter billing checks throughout the product. - ---- - -### 2.2 Events Are the Source of Truth - -Every important lifecycle action must create an immutable event. - -Examples: - -```text -subscription.created -trial.started -trial.ended -payment.succeeded -payment.failed -subscription.suspended -subscription.canceled -subscription.reactivated -``` - -The current subscription state may be stored directly for performance, but every change must be explainable through historical events. - ---- - -### 2.3 Webhooks Are Unreliable Until Reconciled - -Billing provider webhooks may be delayed, duplicated, or arrive out of order. - -The system must support: - -- Idempotent webhook processing -- Event deduplication -- Provider reconciliation jobs -- Manual admin review - ---- - -## 3. Core Status Model - -Use the following subscription statuses: - -```text -trialing -active -payment_pending -past_due -suspended -canceled -expired -paused -``` - -Optional advanced statuses: - -```text -refunded -chargeback -``` - -Avoid adding more statuses unless there is a real operational need. Status bloat is how billing systems become folklore. - ---- - -## 4. Status Definitions - -| Status | Meaning | -|---|---| -| `trialing` | Customer is in a free or paid trial period. | -| `active` | Customer has a valid paid subscription. | -| `payment_pending` | Payment is expected but has not completed. | -| `past_due` | Payment is overdue after the pending window. | -| `suspended` | Access is blocked due to unresolved billing issue. | -| `canceled` | Subscription has been canceled and will not renew. | -| `expired` | Trial or subscription ended without renewal. | -| `paused` | Subscription is intentionally paused. | - ---- - -## 5. Recommended B2B Policy - -### 5.1 Trial Policy - -Default policy: - -```text -Trial length: 14 days -Payment method required: Yes -Trial eligibility: One trial per company or billing account -Trial conversion: Automatic -``` - -Trial rules: - -- A customer may start one trial per company workspace. -- A valid payment method should be required before trial activation. -- Trial start and end dates must be stored. -- Trial users should receive notifications before conversion. -- Trial abuse checks should be applied at account and company level. - -Recommended fields: - -```json -{ - "trial_start_at": "2026-05-25T00:00:00Z", - "trial_end_at": "2026-06-08T00:00:00Z", - "trial_used": true, - "payment_method_required": true -} -``` - ---- - -### 5.2 Trial Ending Policy - -At trial end: - -| Condition | Result | -|---|---| -| Payment succeeds | Move to `active` | -| Payment fails | Move to `payment_pending` | -| No payment method exists | Move to `expired` or require payment method before trial starts | - -Recommended rule: - -```text -If payment method is required, no payment method means trial cannot start. -``` - -This is stricter, but cleaner. - ---- - -### 5.3 Active Subscription Policy - -An active subscription means: - -- Payment is valid -- Current billing period is open -- Customer has access based on purchased plan -- Renewal should occur automatically - -Fields: - -```json -{ - "status": "active", - "current_period_start": "2026-06-08T00:00:00Z", - "current_period_end": "2026-07-08T00:00:00Z", - "cancel_at_period_end": false -} -``` - ---- - -### 5.4 Payment Pending Policy - -Payment pending begins when payment is required but incomplete. - -Triggers: - -- Trial conversion payment failed -- Renewal payment failed -- Upgrade payment requires confirmation -- Payment provider marks payment as incomplete -- Bank transfer or invoice payment is awaiting settlement - -Default B2B policy: - -```text -Payment pending timeout: 7 days -Access during payment pending: Full access -Notifications: Day 0, Day 3, Day 6 -``` - -B2B payments often involve finance teams, approvals, cards expiring, invoice routing, and other thrilling artifacts of corporate civilization. - ---- - -### 5.5 Past Due Policy - -After the payment pending window expires: - -```text -payment_pending → past_due -``` - -Default policy: - -```text -Past due duration: 7 days -Access: Limited access -Admin visibility: Required -Customer notification: Required -``` - -Limited access may include: - -- Read-only access -- No new projects -- No exports -- No premium actions -- Existing data remains accessible - ---- - -### 5.6 Suspension Policy - -After the past due window expires: - -```text -past_due → suspended -``` - -Default policy: - -```text -Suspension starts: 14 days after first failed payment -Access: Blocked or read-only -Data retention: Continue retaining account data -Admin notification: Required -``` - -Suspended accounts should not be deleted. - -Suspension means: - -- Billing issue is unresolved -- Customer can still update payment method -- Customer can still contact support -- Admins can manually extend access if needed - ---- - -### 5.7 Cancellation Policy - -After the suspension window expires: - -```text -suspended → canceled -``` - -Default B2B cancellation policy: - -```text -Cancel after: 30 days unpaid -Access: Removed -Renewal: Disabled -Data: Retained according to data retention policy -``` - -Cancellation should be final for that subscription instance, but the customer may create a new subscription or reactivate if allowed. - ---- - -## 6. Full Lifecycle Flow - -```text -none - ↓ -trialing - ↓ -active - ↓ -payment_pending - ↓ -past_due - ↓ -suspended - ↓ -canceled -``` - -Alternative trial expiration flow: - -```text -none - ↓ -trialing - ↓ -expired -``` - -Reactivation flow: - -```text -payment_pending / past_due / suspended / canceled - ↓ -payment succeeds - ↓ -active -``` - ---- - -## 7. Transition Table - -| Current Status | Event | New Status | Notes | -|---|---|---|---| -| `none` | Trial started | `trialing` | Customer begins trial | -| `none` | Paid subscription started | `payment_pending` | First payment required | -| `payment_pending` | Payment succeeded | `active` | Grant paid access | -| `trialing` | Trial ended and payment succeeded | `active` | Convert trial | -| `trialing` | Trial ended and payment failed | `payment_pending` | Start recovery flow | -| `trialing` | Trial ended without payment method | `expired` | No access | -| `active` | Renewal payment succeeded | `active` | Extend period | -| `active` | Renewal payment failed | `payment_pending` | Start dunning | -| `payment_pending` | Payment succeeded | `active` | Restore normal billing | -| `payment_pending` | Timeout reached | `past_due` | Payment unresolved | -| `past_due` | Payment succeeded | `active` | Restore access | -| `past_due` | Timeout reached | `suspended` | Restrict access | -| `suspended` | Payment succeeded | `active` | Restore access | -| `suspended` | Timeout reached | `canceled` | End subscription | -| `active` | User cancels | `active` with `cancel_at_period_end = true` | Access until period end | -| `active` | Admin cancels immediately | `canceled` | Used for fraud, abuse, or special cases | -| `canceled` | Customer reactivates | `payment_pending` | Payment required | -| `expired` | Customer subscribes | `payment_pending` | Payment required | -| `paused` | Resume date reached | `active` | Resume subscription | - ---- - -## 8. Timeout Policy - -Recommended B2B timeline: - -| Day | Status | Access | Action | -|---:|---|---|---| -| 0 | `payment_pending` | Full | Payment fails, notify billing owner | -| 3 | `payment_pending` | Full | Retry payment, send reminder | -| 6 | `payment_pending` | Full | Final warning before past due | -| 7 | `past_due` | Limited | Restrict risky actions | -| 14 | `suspended` | Read-only or blocked | Notify admins and billing owner | -| 30 | `canceled` | No access | Cancel subscription | - -Recommended constants: - -```json -{ - "payment_pending_timeout_days": 7, - "past_due_timeout_days": 7, - "suspension_timeout_days": 16, - "cancel_after_days_unpaid": 30 -} -``` - ---- - -## 9. Payment Retry Policy - -Use a configurable retry schedule. - -Recommended retry schedule: - -```json -{ - "retry_schedule_days": [0, 3, 6, 10, 14], - "max_retry_attempts": 5 -} -``` - -Retry rules: - -- Retry immediately after first failure. -- Retry again after 3 days. -- Retry again before moving to past due. -- Retry again before suspension. -- Retry one final time before cancellation. -- Stop retries if the customer cancels. -- Stop retries if invoice is manually voided. -- Stop retries if subscription is canceled. - ---- - -## 10. Access Control Policy - -Access must be derived through entitlements. - -### Access Levels - -```text -full -limited -read_only -none -``` - -### Mapping - -| Subscription Status | Access Level | -|---|---| -| `trialing` | `full` | -| `active` | `full` | -| `payment_pending` | `full` | -| `past_due` | `limited` | -| `suspended` | `read_only` or `none` | -| `canceled` | `none` | -| `expired` | `none` | -| `paused` | `read_only` | - -### Entitlement Decision Example - -```json -{ - "workspace_id": "workspace_123", - "subscription_id": "sub_123", - "subscription_status": "past_due", - "access_level": "limited", - "reason": "payment_overdue", - "valid_until": "2026-06-22T00:00:00Z" -} -``` - ---- - -## 11. Data Model - -### 11.1 Subscriptions Table - -```sql -CREATE TABLE subscriptions ( - id UUID PRIMARY KEY, - workspace_id UUID NOT NULL, - customer_id UUID NOT NULL, - plan_id UUID NOT NULL, - - status TEXT NOT NULL, - - billing_interval TEXT NOT NULL, - current_period_start TIMESTAMP, - current_period_end TIMESTAMP, - - trial_start_at TIMESTAMP, - trial_end_at TIMESTAMP, - - payment_pending_since TIMESTAMP, - payment_due_at TIMESTAMP, - - past_due_since TIMESTAMP, - suspended_at TIMESTAMP, - - cancel_at_period_end BOOLEAN DEFAULT FALSE, - canceled_at TIMESTAMP, - ended_at TIMESTAMP, - - retry_count INTEGER DEFAULT 0, - max_retry_count INTEGER DEFAULT 5, - - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - ---- - -### 11.2 Subscription Events Table - -```sql -CREATE TABLE subscription_events ( - id UUID PRIMARY KEY, - subscription_id UUID NOT NULL, - workspace_id UUID NOT NULL, - - event_type TEXT NOT NULL, - source TEXT NOT NULL, - payload JSONB NOT NULL, - - occurred_at TIMESTAMP NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - ---- - -### 11.3 Invoices Table - -```sql -CREATE TABLE invoices ( - id UUID PRIMARY KEY, - subscription_id UUID NOT NULL, - workspace_id UUID NOT NULL, - - provider_invoice_id TEXT, - status TEXT NOT NULL, - - amount_due INTEGER NOT NULL, - amount_paid INTEGER DEFAULT 0, - currency TEXT NOT NULL, - - due_at TIMESTAMP, - paid_at TIMESTAMP, - failed_at TIMESTAMP, - voided_at TIMESTAMP, - - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - ---- - -### 11.4 Payment Attempts Table - -```sql -CREATE TABLE payment_attempts ( - id UUID PRIMARY KEY, - invoice_id UUID NOT NULL, - subscription_id UUID NOT NULL, - - provider_payment_id TEXT, - status TEXT NOT NULL, - - failure_code TEXT, - failure_message TEXT, - - attempted_at TIMESTAMP NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - ---- - -### 11.5 Entitlements Table - -```sql -CREATE TABLE entitlements ( - id UUID PRIMARY KEY, - workspace_id UUID NOT NULL, - subscription_id UUID NOT NULL, - - feature_key TEXT NOT NULL, - access_level TEXT NOT NULL, - - starts_at TIMESTAMP NOT NULL, - ends_at TIMESTAMP, - - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); -``` - ---- - -## 12. Event Catalog - -### Subscription Events - -```text -subscription.created -subscription.updated -subscription.activated -subscription.payment_pending -subscription.past_due -subscription.suspended -subscription.canceled -subscription.expired -subscription.reactivated -subscription.paused -subscription.resumed -``` - -### Trial Events - -```text -trial.started -trial.ending_soon -trial.ended -trial.converted -trial.expired -``` - -### Payment Events - -```text -payment.started -payment.succeeded -payment.failed -payment.requires_action -payment.retry_scheduled -payment.retry_failed -payment.retry_exhausted -``` - -### Invoice Events - -```text -invoice.created -invoice.finalized -invoice.paid -invoice.payment_failed -invoice.voided -invoice.refunded -``` - -### Risk Events - -```text -chargeback.created -chargeback.resolved -fraud.review_required -admin.override_created -``` - ---- - -## 13. Notification Plan - -### 13.1 Trial Notifications - -| Timing | Message | -|---|---| -| Trial start | Welcome and trial end date | -| 7 days before trial end | Reminder | -| 3 days before trial end | Conversion reminder | -| 1 day before trial end | Final reminder | -| Trial converted | Payment receipt and activation | -| Trial expired | Upgrade prompt | - ---- - -### 13.2 Payment Failure Notifications - -| Timing | Recipient | Message | -|---|---|---| -| Day 0 | Billing owner | Payment failed | -| Day 3 | Billing owner + admins | Payment retry failed | -| Day 6 | Billing owner + admins | Final warning before past due | -| Day 7 | Billing owner + admins | Account is past due | -| Day 14 | Billing owner + admins | Account suspended | -| Day 30 | Billing owner + admins | Subscription canceled | - -Every notification should include: - -- Company name -- Plan name -- Amount due -- Due date -- Payment update link -- Consequence of non-payment -- Support contact - ---- - -## 14. Admin Console Requirements - -Admins must be able to: - -- View subscription status -- View payment history -- View invoice history -- View retry attempts -- View lifecycle events -- Extend trial -- Extend grace period -- Retry payment -- Cancel immediately -- Cancel at period end -- Suspend account -- Reactivate account -- Grant temporary access -- Change plan -- Add internal notes - -All admin actions must create audit events. - -Audit record: - -```json -{ - "admin_id": "admin_123", - "action": "extend_grace_period", - "reason": "Enterprise customer requested invoice processing time", - "previous_state": "past_due", - "new_state": "payment_pending", - "created_at": "2026-06-15T00:00:00Z" -} -``` - ---- - -## 15. Webhook Processing Requirements - -Webhook processor must support: - -- Signature verification -- Idempotency keys -- Event deduplication -- Out-of-order event handling -- Retry-safe processing -- Dead-letter queue -- Manual replay - -Webhook events to handle: - -```text -customer.subscription.created -customer.subscription.updated -customer.subscription.deleted -invoice.created -invoice.finalized -invoice.paid -invoice.payment_failed -payment_intent.succeeded -payment_intent.payment_failed -charge.dispute.created -charge.refunded -``` - -Processing rule: - -```text -Never trust webhook order. -Always compare provider timestamp, local state, and event history. -``` - ---- - -## 16. Scheduled Jobs - -Create scheduled jobs for: - -| Job | Frequency | Purpose | -|---|---:|---| -| Trial ending reminder job | Daily | Notify customers before trial ends | -| Trial expiration job | Hourly | Expire or convert ended trials | -| Payment retry job | Hourly | Retry failed invoices | -| Payment pending timeout job | Hourly | Move stale pending payments to past due | -| Past due timeout job | Hourly | Move past due subscriptions to suspended | -| Suspension timeout job | Daily | Cancel long-unpaid subscriptions | -| Provider reconciliation job | Daily | Compare local state with billing provider | -| Entitlement sync job | Hourly | Ensure access matches subscription state | - ---- - -## 17. API Requirements - -### Create Trial - -```http -POST /subscriptions/trials -``` - -Request: - -```json -{ - "workspace_id": "workspace_123", - "plan_id": "plan_pro", - "payment_method_id": "pm_123" -} -``` - -Response: - -```json -{ - "subscription_id": "sub_123", - "status": "trialing", - "trial_end_at": "2026-06-08T00:00:00Z" -} -``` - ---- - -### Start Paid Subscription - -```http -POST /subscriptions -``` - -Request: - -```json -{ - "workspace_id": "workspace_123", - "plan_id": "plan_pro", - "payment_method_id": "pm_123" -} -``` - ---- - -### Cancel Subscription - -```http -POST /subscriptions/{subscription_id}/cancel -``` - -Request: - -```json -{ - "mode": "period_end", - "reason": "customer_requested" -} -``` - ---- - -### Reactivate Subscription - -```http -POST /subscriptions/{subscription_id}/reactivate -``` - -Request: - -```json -{ - "payment_method_id": "pm_123" -} -``` - ---- - -### Get Entitlements - -```http -GET /workspaces/{workspace_id}/entitlements -``` - -Response: - -```json -{ - "workspace_id": "workspace_123", - "access_level": "full", - "features": [ - { - "feature_key": "advanced_reports", - "enabled": true - } - ] -} -``` - ---- - -## 18. Policy Configuration - -Create a centralized subscription policy config. - -```json -{ - "trial": { - "enabled": true, - "duration_days": 14, - "requires_payment_method": true, - "one_trial_per_workspace": true, - "one_trial_per_company": true - }, - "payment": { - "payment_pending_timeout_days": 7, - "retry_schedule_days": [0, 3, 6, 10, 14], - "max_retry_attempts": 5 - }, - "past_due": { - "timeout_days": 7, - "access_level": "limited" - }, - "suspension": { - "cancel_after_days": 16, - "access_level": "read_only" - }, - "cancellation": { - "default_customer_cancel_mode": "period_end", - "admin_cancel_mode": "immediate" - }, - "access": { - "trialing": "full", - "active": "full", - "payment_pending": "full", - "past_due": "limited", - "suspended": "read_only", - "canceled": "none", - "expired": "none", - "paused": "read_only" - }, - "notifications": { - "trial_ending_days_before": [7, 3, 1], - "payment_failure_days_after": [0, 3, 6], - "past_due_days_after": [7], - "suspension_days_after": [14], - "cancellation_days_after": [30] - } -} -``` - ---- - -## 19. Implementation Phases - -## Phase 1: Foundation - -### Goals - -- Define status model -- Create subscription tables -- Create event table -- Create basic policy config -- Implement basic subscription service - -### Deliverables - -- Subscription schema -- Event schema -- Subscription status enum -- Policy config -- Subscription creation endpoint -- Trial creation endpoint - -### Acceptance Criteria - -- A workspace can start a trial. -- A workspace can start a paid subscription. -- Subscription status is persisted. -- Every status change creates an event. - ---- - -## Phase 2: Billing Provider Integration - -### Goals - -- Integrate billing provider -- Create customer records -- Create subscriptions -- Track invoices -- Track payment attempts - -### Deliverables - -- Billing provider adapter -- Webhook processor -- Invoice model -- Payment attempt model -- Payment success handling -- Payment failure handling - -### Acceptance Criteria - -- Successful payment activates subscription. -- Failed payment moves subscription to `payment_pending`. -- Webhooks are idempotent. -- Duplicate webhooks do not duplicate events. - ---- - -## Phase 3: Trial Conversion - -### Goals - -- Convert trials automatically -- Handle trial expiration -- Notify users before trial end - -### Deliverables - -- Trial ending reminder job -- Trial conversion job -- Trial expiration handling -- Trial notification templates - -### Acceptance Criteria - -- Trial converts to `active` after successful payment. -- Trial moves to `payment_pending` after failed payment. -- Trial moves to `expired` if payment is not available. -- Trial ending reminders are sent on schedule. - ---- - -## Phase 4: Payment Recovery - -### Goals - -- Implement dunning flow -- Retry failed payments -- Enforce payment pending timeout -- Enforce past due timeout - -### Deliverables - -- Payment retry job -- Payment pending timeout job -- Past due timeout job -- Notification schedule -- Admin visibility for failed payments - -### Acceptance Criteria - -- Failed payments are retried on schedule. -- `payment_pending` becomes `past_due` after timeout. -- `past_due` becomes `suspended` after timeout. -- Customers and admins receive notifications. - ---- - -## Phase 5: Entitlements - -### Goals - -- Separate subscription status from access -- Create entitlement service -- Enforce access levels in product - -### Deliverables - -- Entitlement model -- Entitlement service -- Access decision API -- Feature flag integration -- Middleware or guard layer - -### Acceptance Criteria - -- `active` and `trialing` users receive full access. -- `past_due` users receive limited access. -- `suspended` users receive read-only or no access. -- Product code does not directly hardcode billing statuses. - ---- - -## Phase 6: Cancellation and Reactivation - -### Goals - -- Support customer cancellation -- Support admin cancellation -- Support reactivation -- Support cancel-at-period-end - -### Deliverables - -- Cancel subscription endpoint -- Reactivate subscription endpoint -- Period-end cancellation job -- Admin cancellation flow - -### Acceptance Criteria - -- Customer cancellation defaults to period end. -- Admin cancellation can happen immediately. -- Reactivation requires successful payment. -- Canceled subscriptions do not renew. - ---- - -## Phase 7: Admin Console - -### Goals - -- Give support and finance teams operational control -- Add audit logging for all manual actions - -### Deliverables - -- Subscription detail page -- Invoice history -- Payment attempt history -- Event timeline -- Manual retry action -- Extend trial action -- Extend grace period action -- Suspend action -- Reactivate action -- Cancel action - -### Acceptance Criteria - -- Admins can inspect complete subscription history. -- Admin actions are audited. -- High-risk actions require a reason. -- Manual overrides do not silently mutate state. - ---- - -## Phase 8: Reconciliation and Hardening - -### Goals - -- Prevent billing drift -- Detect inconsistent states -- Make webhook processing resilient - -### Deliverables - -- Provider reconciliation job -- Dead-letter queue -- Webhook replay tool -- State consistency checks -- Alerting - -### Acceptance Criteria - -- Local subscription state matches billing provider state. -- Failed webhooks are visible and replayable. -- Inconsistent states generate alerts. -- Reconciliation does not break valid admin overrides. - ---- - -## 20. State Invariants - -The system must enforce these rules: - -```text -A canceled subscription cannot renew. -A canceled subscription cannot become active without reactivation. -A subscription cannot be both active and canceled. -A trial cannot be active after trial_end_at. -A payment success must be idempotent. -A failed payment must not duplicate payment attempts. -A workspace cannot have multiple active subscriptions for the same product unless explicitly allowed. -Entitlements must be recalculated after every subscription status change. -Every manual override must create an audit event. -``` - ---- - -## 21. Monitoring and Alerts - -Track these metrics: - -```text -trial_started_count -trial_conversion_rate -payment_failed_count -payment_recovered_count -payment_pending_count -past_due_count -suspended_count -canceled_count -reactivated_count -webhook_failure_count -webhook_duplicate_count -reconciliation_mismatch_count -``` - -Alert on: - -```text -Webhook processing failures -Spike in payment failures -Subscriptions stuck in payment_pending -Subscriptions stuck in past_due -Provider/local state mismatch -Entitlement sync failures -Unexpected cancellation spike -``` - ---- - -## 22. Testing Plan - -### Unit Tests - -Test: - -- Status transitions -- Policy config -- Retry schedule -- Entitlement mapping -- Cancellation rules -- Reactivation rules - -### Integration Tests - -Test: - -- Payment success webhook -- Payment failure webhook -- Duplicate webhook -- Out-of-order webhook -- Trial conversion -- Payment retry -- Suspension -- Cancellation - -### End-to-End Tests - -Test: - -```text -Start trial → trial ends → payment succeeds → active -Start trial → trial ends → payment fails → payment_pending → past_due → suspended → canceled -Active subscription → renewal fails → payment_pending → payment succeeds → active -Active subscription → user cancels → active until period end → canceled -Suspended subscription → payment succeeds → active -``` - ---- - -## 23. Rollout Plan - -### Step 1: Internal Testing - -- Enable for internal workspaces only. -- Use test billing provider mode. -- Simulate webhooks. -- Validate event history. - -### Step 2: Limited Beta - -- Enable for selected B2B customers. -- Monitor payment failures. -- Monitor state transitions. -- Review support tickets. - -### Step 3: Full Launch - -- Enable for all new B2B customers. -- Keep legacy subscriptions on old system temporarily. -- Run reconciliation daily. - -### Step 4: Migration - -- Migrate old subscriptions into new status model. -- Generate synthetic migration events. -- Validate entitlements. -- Confirm invoice state. -- Freeze legacy billing writes. - ---- - -## 24. Migration Plan - -For each existing subscription: - -1. Identify current provider status. -2. Map provider status to internal status. -3. Create internal subscription record. -4. Create current entitlement record. -5. Import current invoice state. -6. Create `subscription.migrated` event. -7. Run reconciliation check. -8. Validate workspace access. - -Example mapping: - -| Provider State | Internal Status | -|---|---| -| `trialing` | `trialing` | -| `active` | `active` | -| `incomplete` | `payment_pending` | -| `past_due` | `past_due` | -| `unpaid` | `suspended` | -| `canceled` | `canceled` | - ---- - -## 25. Risks - -| Risk | Mitigation | -|---|---| -| Duplicate webhooks | Idempotency keys and event deduplication | -| Out-of-order webhooks | Compare timestamps and current state | -| Incorrect access removal | Entitlement layer and audit logs | -| Customers canceled too early | Grace period and admin override | -| Trial abuse | Company-level trial eligibility checks | -| Billing provider mismatch | Daily reconciliation | -| Admin misuse | Audit logs and approval rules | -| Status sprawl | Strict status model | - ---- - -## 26. Final Default Policy - -Use this as the baseline B2B policy: - -```text -Trial lasts 14 days. -Payment method is required before trial starts. -Trial converts automatically if payment succeeds. -If payment fails, subscription becomes payment_pending. -Payment pending lasts 7 days with full access. -After 7 unpaid days, subscription becomes past_due with limited access. -After 14 unpaid days, subscription becomes suspended. -After 30 unpaid days, subscription is canceled. -Customer cancellation takes effect at period end. -Admin cancellation can be immediate. -Reactivation requires successful payment. -Chargebacks suspend access immediately. -Access is controlled through entitlements. -All transitions are event-driven and audited. -``` - ---- - -## 27. Definition of Done - -The B2B subscription system is complete when: - -- All subscription statuses are implemented. -- All transition rules are enforced. -- Billing provider webhooks are idempotent. -- Failed payments enter recovery flow. -- Trial conversion works automatically. -- Entitlements control product access. -- Admins can override safely. -- Every lifecycle change has an audit event. -- Reconciliation detects billing drift. -- Monitoring and alerts are live. -- End-to-end tests cover the full lifecycle. diff --git a/docs/project-design/COOKIE_POLICY.md b/docs/project-design/COOKIE_POLICY.md deleted file mode 100644 index 790add79..00000000 --- a/docs/project-design/COOKIE_POLICY.md +++ /dev/null @@ -1,166 +0,0 @@ -# Cookie Policy - -**Platform:** RentalDriveGo -**Domain:** rentaldrivego.ma -**Last updated:** 2026-05-23 - ---- - -## 1. What Are Cookies - -Cookies are small text files that a website stores on your device when you visit. They allow the site to remember information about your visit — such as your preferred language or whether you are signed in — so you do not have to re-enter it every time. - -RentalDriveGo uses cookies only for the purposes described in this document. We do not use cookies to track your activity across third-party websites, serve advertisements, or build behavioural profiles. - ---- - -## 2. Types of Cookies We Use - -We use two categories of cookies: **strictly necessary** and **preference/functional**. - -We do not use analytics cookies, advertising cookies, or third-party tracking cookies. - ---- - -## 3. Cookie Details - -### 3.1 Authentication Cookie - -| Field | Value | -|---|---| -| **Name** | `employee_session` | -| **Category** | Strictly necessary | -| **Duration** | 8 hours (session) | -| **Scope** | All pages (`path=/`) | -| **Third-party** | No | - -**Purpose:** This cookie is set when an employee signs in to the RentalDriveGo workspace. Admins receive a separate `admin_session` cookie. It stores a cryptographically signed token (JWT) that proves your identity to the platform. Without this cookie, the dashboard and protected API endpoints cannot verify who you are and will redirect you to the sign-in page. - -**When it is set:** On a successful sign-in. -**When it is removed:** When you sign out, or automatically after 8 hours of inactivity. -**Can you opt out?** No. This cookie is required for the platform to function. Blocking it will prevent you from signing in. - ---- - -### 3.2 Language Preference Cookie - -| Field | Value | -|---|---| -| **Name** | `rentaldrivego-language` | -| **Category** | Functional / preference | -| **Duration** | 1 year | -| **Scope** | All pages (`path=/`) | -| **Third-party** | No | - -**Purpose:** Stores your chosen display language so the interface appears in your preferred language on every visit across all parts of the platform (public marketplace, dashboard, and admin panel). Supported values are English (`en`), French (`fr`), and Arabic (`ar`). - -**When it is set:** When you change the language using the language selector, or automatically on first visit based on your browser's language settings. -**Can you opt out?** You can block this cookie. If you do, the platform will fall back to a default language and you may need to select your language on every visit. - ---- - -### 3.3 Theme Preference Cookie - -| Field | Value | -|---|---| -| **Name** | `rentaldrivego-theme` | -| **Category** | Functional / preference | -| **Duration** | 1 year | -| **Scope** | All pages (`path=/`) | -| **Third-party** | No | - -**Purpose:** Stores your preferred colour scheme — light mode or dark mode. This cookie is read immediately when a page loads (before the interface is drawn) to apply the correct theme without any visible flash of the wrong colours. - -**When it is set:** When you toggle the light/dark mode switch. -**Can you opt out?** You can block this cookie. If you do, the platform will fall back to your operating system's colour scheme preference and the theme may reset on every visit. - ---- - -### 3.4 Per-User Scoped Preference Cookies - -| Field | Value | -|---|---| -| **Name** | `rentaldrivego-language--{userID}`, `rentaldrivego-theme--{userID}` | -| **Category** | Functional / preference | -| **Duration** | 1 year | -| **Scope** | All pages (`path=/`) | -| **Third-party** | No | - -**Purpose:** When you are signed in, the platform saves your language and theme preferences under a cookie that is tied to your account identifier. This allows multiple employees who share a device (for example, in a shared office environment) to each maintain independent preferences. The `{userID}` portion is derived from the authentication token and does not contain personal information beyond an internal account identifier. - -**When it is set:** On every language or theme change while you are signed in. -**Can you opt out?** You can block these cookies. If you do, your preferences will revert to the shared or default settings each time you sign in on a shared device. - ---- - -### 3.5 Legacy Preference Cookies (Transitional) - -| Field | Value | -|---|---| -| **Names** | `dashboard-language`, `marketplace-language` | -| **Category** | Functional / preference | -| **Duration** | 1 year | -| **Scope** | All pages (`path=/`) | -| **Third-party** | No | - -**Purpose:** These cookies exist for backward compatibility with older versions of the platform. They store the same language preference as `rentaldrivego-language` but were scoped to individual sub-applications. New code always reads `rentaldrivego-language` first and only falls back to these if the primary cookie is absent. - -**Planned removal:** These cookies will be removed once all users have transitioned to the unified preference system. They do not store any additional personal data. - ---- - -## 4. What We Do Not Do - -- We do **not** use cookies to track you across other websites. -- We do **not** share cookie data with advertisers or data brokers. -- We do **not** use session-replay or behavioural analytics cookies. -- We do **not** set cookies from third-party domains on our pages. - ---- - -## 5. Cookie Consent - -**Strictly necessary cookies** (section 3.1) do not require your consent because they are essential to provide the service you have requested. - -**Functional / preference cookies** (sections 3.2–3.5) are used solely to remember your choices and improve your experience. They do not process personal data for marketing or tracking purposes. Under most privacy regulations (including GDPR), these may be set without explicit consent when used exclusively to fulfil user-requested functionality. Where local law requires explicit consent, a consent prompt will be displayed on your first visit. - ---- - -## 6. How to Manage or Delete Cookies - -You can control cookies through your browser settings. Common options include: - -- **Block all cookies** — the platform will work but you will need to re-enter preferences on every visit and you will not be able to sign in. -- **Block third-party cookies** — this will have no effect on RentalDriveGo as we do not use third-party cookies. -- **Delete cookies on close** — you will be signed out and your preferences will reset each time you close the browser. -- **Delete specific cookies** — use your browser's developer tools (Application → Cookies) to remove individual cookies by name. - -Browser-specific instructions: - -| Browser | Settings location | -|---|---| -| Chrome | Settings → Privacy and security → Cookies and other site data | -| Firefox | Settings → Privacy & Security → Cookies and Site Data | -| Safari | Settings → Privacy → Manage Website Data | -| Edge | Settings → Cookies and site permissions → Cookies and site data | - ---- - -## 7. Security - -The authentication cookies (`employee_session` and `admin_session`) are signed, HttpOnly, and expire after the configured session window. They use `Secure` in production and are not readable by JavaScript. We recommend using the platform on trusted devices only and signing out when you are finished. - ---- - -## 8. Changes to This Policy - -We may update this Cookie Policy when we add new features or change how existing cookies work. The "Last updated" date at the top of this document will reflect any changes. Significant changes will be communicated through the platform or by email. - ---- - -## 9. Contact - -If you have questions about this Cookie Policy or how we handle your data, please contact us at: - -**Email:** rentaldrivego@gmail.com -**Website:** https://rentaldrivego.ma diff --git a/docs/project-design/FEATURES.md b/docs/project-design/FEATURES.md deleted file mode 100644 index 9c907b02..00000000 --- a/docs/project-design/FEATURES.md +++ /dev/null @@ -1,152 +0,0 @@ -# Features — Current Product Scope - -This document lists the features that are active in the current codebase. - -Source of truth: - -- `apps/marketplace` -- `apps/dashboard` -- `apps/admin` -- `apps/api` -- `packages/database/prisma/schema.prisma` - -## Active Applications - -The current workspace contains four active apps: - -- `apps/marketplace` -- `apps/dashboard` -- `apps/admin` -- `apps/api` - -There is no separate frontend app for a white-label company public site in this repo at the moment. - -## Active Platform Features - -### Company signup and employee access - -- Company signup through the dashboard sign-up flow backed by `POST /auth/company/signup` -- Employee sign-in with local email/password auth -- Employee password reset flow -- Team invitation flow that creates a pending employee and sends a reset-password invite link -- Employee roles: `OWNER`, `MANAGER`, `AGENT` - -### Multi-tenant company operations - -- Company-scoped vehicles, customers, reservations, offers, payments, complaints, and notifications -- API tenant isolation through employee auth plus `companyId` scoping -- Company profile, brand, contract settings, insurance policies, pricing rules, and accounting settings -- Public API key generation/regeneration for company integrations - -### Dashboard operations - -- Dashboard home with KPI cards and booking-source analytics -- Fleet management -- Vehicle photo uploads -- Vehicle publish/unpublish -- Vehicle status management -- Vehicle maintenance logs -- Vehicle calendar blocks -- Reservation list, detail, create, update, confirm, check-in, check-out, close, extend, cancel -- Reservation pickup/dropoff inspections -- Reservation photo uploads -- Additional-driver approval workflow -- Online reservation intake queue -- Customer CRM -- Offer management -- Team management -- Company billing page for rental payments -- Contract generation/viewing -- Reviews and complaints management -- Notification inbox and preferences -- Subscription management - -### Marketplace and public platform - -- Public marketing homepage -- Public pricing page -- Public features page -- Public marketplace/explore flow -- Explore company profile pages under `/explore/[slug]` -- Public review submission page via review token -- Public legal/app policy pages -- Public platform content loaded from `/site/platform/*` API endpoints - -### Subscription and billing - -- SaaS trial and subscription lifecycle -- Subscription status handling including `TRIALING`, `ACTIVE`, `PAYMENT_PENDING`, `PAST_DUE`, `SUSPENDED`, `CANCELLED`, `EXPIRED`, `PAUSED`, `UNPAID` -- Plan pricing loaded from DB or fallback config -- AmanPay and PayPal provider support for SaaS subscription checkout -- Subscription invoice history -- Platform billing accounts, billing invoices, billing events, refunds, credit notes, and tax records - -### Rental payments - -- AmanPay payment initialization and webhook handling -- PayPal payment initialization and capture handling -- Manual rental payment recording -- Reservation refund support for successful online payments -- Payment status tracking on reservations - -### Notifications - -- Email notifications -- SMS notifications -- WhatsApp notifications -- In-app notifications -- Notification templates and per-channel preferences -- Notification history for company users - -### Reservation-adjacent advanced operations - -- Insurance policy configuration -- Reservation insurance snapshots -- Additional-driver charging rules -- Driver license validation and approval states -- Pricing rules and reservation-time pricing-rule snapshots -- Damage inspections and damage points -- Contract settings for fuel policy, tax, numbering, and additional-driver behavior -- Reporting and export-oriented accounting defaults - -### Admin app - -- Admin login and password reset -- Admin dashboard -- Company management -- Renter management -- Admin-user management -- Audit-log views -- Billing operations -- Pricing configuration and promotions -- Notification review -- Marketplace/site config management - -## Present But Not Active Product Features - -These exist partially in code or schema, but they are not active end-user product flows and should not be treated as shipped features. - -- Renter self-service signup -- Renter self-service login -- Renter email verification flow -- Clerk-based employee auth -- Clerk webhooks -- Clerk invitation acceptance -- Admin impersonation through Clerk sessions -- Multi-currency SaaS checkout beyond `MAD` -- Separate white-label company-site frontend app - -## Explicitly Out Of Scope For Current Docs - -The following old design ideas should not be treated as active features unless code is added later: - -- marketing blog -- standalone about/contact page set -- Google renter auth -- automatic custom-domain provisioning -- Zapier/webhook marketplace integrations beyond the current API/webhook handlers - -## Notes - -- The database still contains legacy fields such as `Employee.clerkUserId`, but those fields are no longer evidence of active Clerk integration. -- Some renter-facing `/renter/*` pages exist in the marketplace app, but the auth entrypoints they depend on are not active. They are therefore not listed as active product scope here. diff --git a/docs/FINANCE_PLAN_IMPLEMENTATION_NOTES.md b/docs/project-design/FINANCE_PLAN_IMPLEMENTATION_NOTES.md similarity index 100% rename from docs/FINANCE_PLAN_IMPLEMENTATION_NOTES.md rename to docs/project-design/FINANCE_PLAN_IMPLEMENTATION_NOTES.md diff --git a/docs/project-design/INTEGRATION.md b/docs/project-design/INTEGRATION.md deleted file mode 100644 index 3e7afc28..00000000 --- a/docs/project-design/INTEGRATION.md +++ /dev/null @@ -1,141 +0,0 @@ -# Team Management Integration — Current Implementation - -This document describes the current team-management integration in the live codebase. - -Source of truth: - -- `apps/api/src/modules/team/team.routes.ts` -- `apps/api/src/services/teamService.ts` -- `apps/dashboard/src/hooks/useTeam.ts` -- `apps/dashboard/src/app/(dashboard)/team/page.tsx` -- `apps/dashboard/src/app/reset-password/*` - -## Current Model - -Team management is local to RentalDriveGo. It no longer depends on Clerk. - -The live flow is: - -1. an owner invites a staff member from the dashboard -2. the API creates a pending `Employee` -3. the API generates a password-reset token -4. an email is sent with a reset-password link -5. the invited employee sets a password through the dashboard reset-password page -6. the employee can then sign in through the standard employee login flow - -There is no webhook step in the active implementation. - -## Backend Integration - -The team router is already mounted by the main API app in `apps/api/src/app.ts` under: - -- `/api/v1/team` - -The public webhook placeholder under `/api/v1/webhooks/clerk` is intentionally disabled and should not be used for team onboarding. - -### Team endpoints - -- `GET /api/v1/team` -- `GET /api/v1/team/stats` -- `POST /api/v1/team/invite` -- `PATCH /api/v1/team/:id/role` -- `POST /api/v1/team/:id/deactivate` -- `POST /api/v1/team/:id/reactivate` -- `DELETE /api/v1/team/:id` - -### Auth and authorization - -These routes are protected by: - -- employee JWT auth -- tenant resolution -- subscription guard -- role checks inside the router and service - -Important rules in the current implementation: - -- only the `OWNER` can invite, change roles, deactivate/reactivate, or remove team members -- invited team members cannot be created with the `OWNER` role -- the account owner cannot be deactivated or removed through team endpoints - -## Invite Email Flow - -Invitations are generated in `apps/api/src/services/teamService.ts`. - -Current behavior: - -- a pending employee row is created -- `passwordResetToken` and `passwordResetExpiresAt` are populated -- the invite email links directly to the dashboard reset-password page - -Expected URL shape: - -```text -{DASHBOARD_URL}/reset-password?token=... -``` - -Relevant environment variables: - -```env -DASHBOARD_URL=https://your-host/dashboard -NEXT_PUBLIC_DASHBOARD_URL=https://your-host/dashboard -NEXT_PUBLIC_API_URL=https://your-host/api/v1 -``` - -## Dashboard Integration - -The team UI lives here: - -- `apps/dashboard/src/app/(dashboard)/team/page.tsx` -- `apps/dashboard/src/hooks/useTeam.ts` -- `apps/dashboard/src/components/team/InviteModal.tsx` -- `apps/dashboard/src/components/team/EditMemberModal.tsx` -- `apps/dashboard/src/components/team/PermissionsMatrix.tsx` - -The dashboard consumes the team API directly through `apiFetch(...)`. - -### Dashboard page behavior - -- loads members from `/team` -- loads summary counts from `/team/stats` -- opens invite/edit flows through local modals -- updates local state after invite, role change, deactivate/reactivate, and removal - -## Password Setup / Invite Acceptance - -The active invite-acceptance mechanism is the dashboard reset-password flow, not a Clerk callback flow. - -Relevant pages: - -- `apps/dashboard/src/app/reset-password/page.tsx` -- `apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx` - -This means: - -- there is no `__clerk_ticket` -- there is no Clerk redirect callback -- there is no employee activation webhook - -Invite completion is simply password setup using the token created by the API. - -## Request Typing - -The request object is extended in the API so team routes can rely on: - -- `req.employee` -- `req.company` -- `req.companyId` - -Those values are attached by the company auth and tenant middleware before team handlers run. - -## What Was Removed - -These older integration assumptions are no longer valid: - -- Clerk webhooks -- Clerk invitation acceptance -- Clerk `user.created` activation flow -- `/webhooks/clerk` as a required part of team onboarding -- `@clerk/nextjs` integration in the dashboard team flow - -If this functionality returns in the future, it should be documented as a new integration rather than assumed from this file. diff --git a/docs/project-design/INVENTORY_IMPROVEMENT_IMPLEMENTATION_SUMMARY.md b/docs/project-design/INVENTORY_IMPROVEMENT_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 00000000..fbb91410 --- /dev/null +++ b/docs/project-design/INVENTORY_IMPROVEMENT_IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,162 @@ +# Inventory System Improvement — Implementation Summary + +## Overview + +This implementation applies the first three sprints of the inventory improvement plan: + +1. **Stabilize Inventory Data** — Make movement ledger the source of truth +2. **Movement and Audit Cleanup** — Append-only ledger with void/correction support +3. **Procurement Integration** — Link purchase orders to inventory movements +4. **Low-Stock and Reorder Workflow** — New endpoints and features + +--- + +## Changes Made + +### Phase 1: Stabilize Inventory Data + +#### 1. Quantity removed from item updates + +- **`app/Http/Requests/Inventory/InventoryItemUpdateRequest.php`**: Removed `quantity` from validatable fields. Stock changes must go through movements (adjust, audit, etc.). +- **`app/Services/Inventory/InventoryItemService.php`**: `filterItemData()` now only includes `quantity` during creation (`$lockedType === null`), not during updates. +- **`app/Http/Requests/Inventory/InventoryItemStoreRequest.php`**: Strengthened validation — `type` uses `in:classroom,book,office,kitchen`, `condition` uses `in:good,needs_repair,need_replace,cannot_find`, `category_id` checks `exists:inventory_categories,id`. + +#### 2. Append-only movement ledger with void/correction + +New migration: **`2026_06_11_074503_add_inventory_movement_audit_fields.php`** + +Adds to `inventory_movements`: +- `voided_at` (timestamp) — when the movement was voided +- `voided_by` (bigint) — user who voided it +- `void_reason` (varchar 255) — reason for voiding +- `corrects_movement_id` (bigint) — links a correction to the original movement +- `source_type` / `source_id` (varchar/bigint) — polymorphic source reference + +**`app/Models/InventoryMovement.php`**: Added fillable fields, casts, scopes (`notVoided`, `voided`), and relationships (`voidedBy`, `correctsMovement`, `corrections`). + +**`app/Services/Inventory/InventoryMovementService.php`**: + +| Method | Status | Notes | +|--------|--------|-------| +| `create()` | ⚠️ Deprecated | Still works, but marked `@deprecated`. Use `recordMovement()` instead. | +| `update()` | ⚠️ Deprecated | Still works. Prevents editing voided movements. | +| `delete()` | ⚠️ Deprecated | Prevents deleting movements that have corrections. | +| `bulkDelete()` | ⚠️ Deprecated | Same prevention for corrections. | +| `recordMovement()` | ✅ Primary method | Append-only, creates `initial` movement automatically. | +| `voidMovement()` | ✅ NEW | Marks original as voided + creates reverse correction. | +| `correctMovement()` | ✅ NEW | Adds an adjusting entry linked to the original. | +| `recalcQuantity()` | ✅ Updated | Excludes voided movements from sum calculations. | +| `reconcile()` | ✅ NEW | Compares stored quantity vs movement sum, reports discrepancies. | + +#### 3. Reconciliation command + +**`app/Console/Commands/InventoryReconcile.php`**: `php artisan inventory:reconcile` with `--school-year` and `--fix` options. + +### Phase 2: Movement and Audit Cleanup + +#### Void and correction API endpoints + +**`app/Http/Controllers/Api/Inventory/InventoryMovementController.php`**: +- `POST movements/{id}/void` — requires `reason` +- `POST movements/{id}/correct` — requires `qty_change` and `reason` + +Both require authentication and return appropriate success/error responses. + +### Phase 3: Procurement Integration + +#### 1. inventory_item_id on purchase_order_items + +New migration: **`2026_06_11_074642_add_inventory_item_id_to_purchase_order_items.php`** + +Adds `inventory_item_id` (unsigned int, nullable) to `purchase_order_items` with an index. This allows linking PO items to inventory items. + +**`app/Models/PurchaseOrderItem.php`**: Added `inventory_item_id` to `$fillable`. + +#### 2. PurchaseOrderReceiveService links to inventory + +**`app/Services/PurchaseOrders/PurchaseOrderReceiveService.php`**: +- Injects `InventoryMovementService` +- On receiving PO items with `inventory_item_id` set, creates an inventory movement via `recordMovement()` +- Continues to update legacy `supplies.qty_on_hand` for backward compatibility + +### Phase 4: Low-Stock and Reorder Workflow + +#### New database fields + +New migration: **`2026_06_11_074621_add_inventory_item_reorder_fields.php`** + +Adds to `inventory_items`: +- `preferred_supplier_id`, `last_purchase_price`, `average_unit_cost` +- `reorder_point`, `reorder_quantity`, `lead_time_days` +- `barcode`, `qr_code`, `asset_tag` + +**`app/Models/InventoryItem.php`**: Added fillable fields, casts, relationships (`preferredSupplier`), and scopes (`lowStock`, `outOfStock`). + +**`app/Http/Resources/Inventory/InventoryItemResource.php`**: Exposes all new fields. + +#### New API endpoints + +**`app/Http/Controllers/Api/Inventory/InventoryController.php`**: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `lowStock()` | `GET inventory/low-stock` | Lists items where `quantity <= reorder_point` | +| `reorderSuggestions()` | `GET inventory/reorder-suggestions` | Calculates suggested order quantities | +| `reorderRequest()` | `POST inventory/items/{id}/reorder-request` | Single item reorder info | +| `stockStatus()` | `GET inventory/stock-status` | All items with status: ok/low_stock/out_of_stock | +| `dashboard()` | `GET inventory/dashboard` | Summary counts by type, low stock, out of stock, etc. | + +All routes are registered under `api/v1/inventory/` in `routes/api.php`. + +--- + +## Database Migrations + +| Migration | Description | +|-----------|-------------| +| `2026_06_11_074503_add_inventory_movement_audit_fields` | Adds void/correction fields to movements | +| `2026_06_11_074621_add_inventory_item_reorder_fields` | Adds reorder/supplier/barcode fields to items | +| `2026_06_11_074642_add_inventory_item_id_to_purchase_order_items` | Links PO items to inventory items | + +--- + +## Key Backward Compatibility Notes + +1. **Existing endpoints remain unchanged.** `GET/POST/PATCH/DELETE inventory/items`, `inventory/movements`, and all summary/distribution endpoints continue to work with the same payloads. + +2. **Old `create/update/delete` movement methods** work as before, but are marked `@deprecated`. They still enforce new guards (e.g., cannot edit voided movements, cannot delete movements with corrections). + +3. **Purchase order receiving** continues to update `supplies.qty_on_hand` for legacy support. The new inventory movement is added alongside, not instead. + +4. **Voided movements** are excluded from quantity calculations by default. Existing reports that call `recalcQuantity()` automatically benefit. + +--- + +## Console Commands + +```bash +# Check for discrepancies between stored quantity and movement sum +php artisan inventory:reconcile + +# With --fix to auto-correct discrepancies +php artisan inventory:reconcile --fix + +# For a specific school year +php artisan inventory:reconcile --school-year=2025-2026 +``` + +--- + +## API Reference + +### New Endpoints + +``` +GET /api/v1/inventory/low-stock +GET /api/v1/inventory/reorder-suggestions +POST /api/v1/inventory/items/{id}/reorder-request +GET /api/v1/inventory/stock-status?type=book +GET /api/v1/inventory/dashboard?school_year=2025-2026 +POST /api/v1/inventory/movements/{id}/void { reason: "..." } +POST /api/v1/inventory/movements/{id}/correct { qty_change: -5, reason: "..." } +``` diff --git a/docs/project-design/NOTIFICATION_LOCALIZATION_POLICY.md b/docs/project-design/NOTIFICATION_LOCALIZATION_POLICY.md deleted file mode 100644 index 41b8b243..00000000 --- a/docs/project-design/NOTIFICATION_LOCALIZATION_POLICY.md +++ /dev/null @@ -1,739 +0,0 @@ -# Notification Language and Localization Policy - -## 1. Objective - -The notification system must send all customer-facing notifications in the language selected by the user during account creation. - -Supported initial languages: - -```text -EN -AR -FR -``` - -Language mapping: - -| User Selection | Locale Code | Notification Language | -|---|---|---| -| `EN` | `en` | English | -| `AR` | `ar` | Arabic | -| `FR` | `fr` | French | - -This applies to account creation, workspace creation, invitations, trial notifications, subscription status notifications, billing notifications, invoice notifications, payment failure notifications, refunds, credits, in-app notifications, email notifications, SMS notifications if enabled, and customer-facing webhook labels if applicable. - -Internal admin alerts may use the internal team’s default language unless configured otherwise. - ---- - -## 2. Source of Truth for Notification Language - -The user’s selected language at account creation must be stored and used as the primary language preference. - -### Required User Field - -```sql -ALTER TABLE users -ADD COLUMN preferred_language TEXT NOT NULL DEFAULT 'en'; -``` - -Allowed values: - -```text -en -ar -fr -``` - -### Account Creation Requirement - -During account creation, the user must select or confirm a language. - -Example payload: - -```json -{ - "email": "user@example.com", - "name": "Example User", - "preferred_language": "ar" -} -``` - -Rules: - -- `preferred_language` is required at account creation. -- If not provided, default to `en`. -- The selected language must be saved on the user profile. -- The selected language must be used for all customer-facing notifications. -- Users may update their preferred language later from account settings. -- Updating the language affects future notifications only, not historical notifications. - ---- - -## 3. Language Resolution Policy - -When sending a notification, resolve the language in this order: - -```text -recipient user preferred_language -workspace default_language -billing account preferred_language -organization default_language -system default_language -``` - -Default: - -```text -en -``` - -Example: - -```text -User preferred_language = ar -Workspace default_language = fr -System default_language = en - -Selected notification language = ar -``` - -The recipient’s own language wins. Not the workspace. Not the billing account. Not a hardcoded backend default. - ---- - -## 4. Workspace and Billing Language Fields - -In addition to the user’s language, store default language on workspace and billing account. - -### Workspace Field - -```sql -ALTER TABLE workspaces -ADD COLUMN default_language TEXT NOT NULL DEFAULT 'en'; -``` - -### Billing Account Field - -```sql -ALTER TABLE billing_accounts -ADD COLUMN preferred_language TEXT NOT NULL DEFAULT 'en'; -``` - -Purpose: - -| Field | Purpose | -|---|---| -| `users.preferred_language` | Primary language for direct user notifications | -| `workspaces.default_language` | Fallback for workspace-level notifications | -| `billing_accounts.preferred_language` | Fallback for finance/billing notifications | -| `system.default_language` | Final fallback | - ---- - -## 5. Template Localization Policy - -Every customer-facing notification template must exist in all supported languages. - -Required locales: - -```text -en -ar -fr -``` - -### Template Table Update - -```sql -CREATE TABLE notification_templates ( - id UUID PRIMARY KEY, - - template_key TEXT NOT NULL, - category TEXT NOT NULL, - channel TEXT NOT NULL, - - locale TEXT NOT NULL, - - subject TEXT, - body TEXT NOT NULL, - - required_variables JSONB, - optional_variables JSONB, - - version INTEGER NOT NULL DEFAULT 1, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - - UNIQUE(template_key, channel, locale, version) -); -``` - -Preferred template structure: - -```text -template_key = account.created.email -locale = en - -template_key = account.created.email -locale = ar - -template_key = account.created.email -locale = fr -``` - -Do not encode the locale into the template key if the table already has a `locale` column. Duplicating state is how small mistakes become expensive folklore. - ---- - -## 6. Template Lookup Policy - -When rendering a notification: - -```text -1. Resolve recipient language. -2. Find active template for template_key + channel + resolved locale. -3. If unavailable, fall back to English. -4. If English template is unavailable, fail notification creation and alert admins. -``` - -Lookup flow: - -```text -event received - ↓ -resolve recipient - ↓ -resolve recipient language - ↓ -find localized template - ↓ -render template - ↓ -send notification -``` - -Fallback rules: - -| Condition | Action | -|---|---| -| `ar` template exists | Send Arabic notification | -| `fr` template exists | Send French notification | -| Requested locale missing | Fall back to English | -| English fallback missing | Fail and alert internal admins | -| Template variables missing | Fail and alert internal admins | - -Fallback to English is allowed only as a safety net. It should be monitored as a product defect. - ---- - -## 7. Arabic Language Requirements - -Arabic notifications must support right-to-left rendering. - -For locale: - -```text -ar -``` - -Apply: - -```html - -``` - -or for partial rendering: - -```html -
- ... -
-``` - -Arabic requirements: - -- Use RTL layout for email and in-app notifications. -- Align Arabic text to the right. -- Use Arabic translations for all customer-facing labels. -- Use localized date formatting where possible. -- Keep invoice numbers, amounts, and technical IDs readable. -- Avoid mixing English UI labels into Arabic notifications unless they are brand or product names. -- Test Arabic templates separately. - ---- - -## 8. French Language Requirements - -For locale: - -```text -fr -``` - -Apply: - -```html - -``` - -French requirements: - -- Use French templates for all customer-facing messages. -- Use localized date formatting. -- Use localized money formatting where appropriate. -- Avoid partial English/French mixed messages. -- Keep plan names and product names unchanged unless officially translated. - ---- - -## 9. English Language Requirements - -For locale: - -```text -en -``` - -Apply: - -```html - -``` - -English is the default fallback language. - ---- - -## 10. Localized Formatting Policy - -Notifications should localize: - -- Subject lines -- Body text -- Button labels -- Status labels -- Dates -- Currency display -- Invoice labels -- Subscription status labels -- Error messages -- Call-to-action text - -Date formatting examples: - -| Locale | Display | -|---|---| -| `en` | June 15, 2026 | -| `fr` | 15 juin 2026 | -| `ar` | ١٥ يونيو ٢٠٢٦ | - -Currency formatting should respect both billing currency and locale. - -Example for USD: - -| Locale | Display | -|---|---| -| `en` | $1,200.00 | -| `fr` | 1 200,00 $US | -| `ar` | ١٬٢٠٠٫٠٠ US$ | - -Do not store localized amounts as the source of truth. Store money as integer minor units and format at render time. - ---- - -## 11. Localized Subscription Status Labels - -### English - -| Internal Status | English Label | -|---|---| -| `trialing` | Trial active | -| `active` | Active | -| `payment_pending` | Payment pending | -| `past_due` | Payment overdue | -| `suspended` | Suspended | -| `canceled` | Canceled | -| `expired` | Expired | - -### French - -| Internal Status | French Label | -|---|---| -| `trialing` | Essai actif | -| `active` | Actif | -| `payment_pending` | Paiement en attente | -| `past_due` | Paiement en retard | -| `suspended` | Suspendu | -| `canceled` | Annulé | -| `expired` | Expiré | - -### Arabic - -| Internal Status | Arabic Label | -|---|---| -| `trialing` | الفترة التجريبية نشطة | -| `active` | نشط | -| `payment_pending` | الدفع قيد الانتظار | -| `past_due` | الدفع متأخر | -| `suspended` | معلّق | -| `canceled` | ملغى | -| `expired` | منتهي | - ---- - -## 12. Localized Invoice Labels - -### English - -| Internal Invoice Status | English Label | -|---|---| -| `draft` | Preparing | -| `open` | Due | -| `payment_pending` | Payment processing | -| `paid` | Paid | -| `partially_paid` | Partially paid | -| `past_due` | Past due | -| `void` | Canceled | -| `uncollectible` | Contact support | -| `refunded` | Refunded | -| `partially_refunded` | Partially refunded | - -### French - -| Internal Invoice Status | French Label | -|---|---| -| `draft` | En préparation | -| `open` | À payer | -| `payment_pending` | Paiement en cours | -| `paid` | Payée | -| `partially_paid` | Partiellement payée | -| `past_due` | En retard | -| `void` | Annulée | -| `uncollectible` | Contacter le support | -| `refunded` | Remboursée | -| `partially_refunded` | Partiellement remboursée | - -### Arabic - -| Internal Invoice Status | Arabic Label | -|---|---| -| `draft` | قيد الإعداد | -| `open` | مستحقة الدفع | -| `payment_pending` | الدفع قيد المعالجة | -| `paid` | مدفوعة | -| `partially_paid` | مدفوعة جزئياً | -| `past_due` | متأخرة | -| `void` | ملغاة | -| `uncollectible` | تواصل مع الدعم | -| `refunded` | مستردة | -| `partially_refunded` | مستردة جزئياً | - ---- - -## 13. Notification Creation Logic - -When an event creates a notification, include the resolved locale. - -### Notification Record Update - -```sql -ALTER TABLE notifications -ADD COLUMN locale TEXT NOT NULL DEFAULT 'en'; -``` - -Example notification record: - -```json -{ - "event_type": "invoice.finalized", - "recipient_email": "finance@example.com", - "recipient_role": "billing_owner", - "channel": "email", - "template_key": "invoice.finalized.email", - "locale": "fr", - "status": "pending" -} -``` - ---- - -## 14. Notification Rendering Pseudocode - -```ts -type SupportedLocale = "en" | "ar" | "fr"; - -function resolveNotificationLocale(recipient, workspace, billingAccount): SupportedLocale { - return ( - recipient.preferredLanguage || - workspace.defaultLanguage || - billingAccount.preferredLanguage || - "en" - ); -} - -async function createNotification(event, recipient) { - const locale = resolveNotificationLocale( - recipient.user, - event.workspace, - event.billingAccount - ); - - const template = await findTemplate({ - templateKey: event.templateKey, - channel: event.channel, - locale - }); - - const fallbackTemplate = await findTemplate({ - templateKey: event.templateKey, - channel: event.channel, - locale: "en" - }); - - const selectedTemplate = template || fallbackTemplate; - - if (!selectedTemplate) { - throw new Error("Missing notification template"); - } - - return { - eventType: event.type, - recipientEmail: recipient.email, - channel: event.channel, - templateKey: event.templateKey, - locale: selectedTemplate.locale, - renderedContent: renderTemplate(selectedTemplate, event.payload) - }; -} -``` - ---- - -## 15. Account Creation Flow Update - -During account creation: - -```text -user selects language - ↓ -system stores preferred_language - ↓ -account.created event emitted - ↓ -notification system resolves preferred_language - ↓ -localized welcome notification sent -``` - -### Account Creation API Update - -```http -POST /accounts -``` - -Request: - -```json -{ - "name": "Example User", - "email": "user@example.com", - "password": "secure_password", - "preferred_language": "ar" -} -``` - -Response: - -```json -{ - "account_id": "acct_123", - "user_id": "user_123", - "preferred_language": "ar" -} -``` - -Validation: - -```text -preferred_language must be one of: en, ar, fr -``` - ---- - -## 16. Notification Preference Update - -Language preference should be separate from notification category preferences. - -Example user settings: - -```text -preferred_language = ar -invoice.email.enabled = true -billing.email.enabled = true -onboarding.email.enabled = false -``` - -Rules: - -- Language controls the language of notifications. -- Preferences control whether allowed notifications are sent. -- Critical billing, invoice, payment, and security notifications still follow mandatory delivery rules. -- Changing language does not unsubscribe the user from notifications. -- Changing language affects future notifications only. - ---- - -## 17. Testing Requirements - -### Unit Tests - -Test: - -```text -User with preferred_language=en receives English template -User with preferred_language=ar receives Arabic template -User with preferred_language=fr receives French template -Missing Arabic template falls back to English -Missing French template falls back to English -Missing English fallback fails notification creation -Arabic email renders with dir="rtl" -French email renders with lang="fr" -English email renders with lang="en" -``` - -### Integration Tests - -Test: - -```text -Account created with EN → welcome email sent in English -Account created with AR → welcome email sent in Arabic -Account created with FR → welcome email sent in French -Invoice finalized for AR billing owner → Arabic invoice notification -Payment failed for FR billing owner → French payment failure notification -Subscription suspended for EN admin → English suspension notification -``` - -### End-to-End Tests - -Test: - -```text -User creates account and selects Arabic - ↓ -Arabic welcome email is sent - ↓ -User starts trial - ↓ -Arabic trial notification is sent - ↓ -Invoice is generated - ↓ -Arabic invoice notification is sent - ↓ -Payment fails - ↓ -Arabic payment failure notification is sent -``` - ---- - -## 18. Monitoring Requirements - -Track localization metrics: - -```text -notifications_sent_by_locale -notifications_failed_by_locale -template_missing_by_locale -fallback_to_english_count -rtl_rendering_test_failures -language_preference_update_count -``` - -Alert on: - -```text -Missing Arabic template -Missing French template -Fallback-to-English spike -Arabic rendering failure -Localized template variable error -``` - -Fallback-to-English should be treated as a defect, not a harmless convenience. - ---- - -## 19. Updated Notification Localization Section - -Replace the earlier notification localization section with this stricter version. - -### Notification Localization - -Notification templates must support the user’s selected account language. - -Supported locales: - -```text -en -ar -fr -``` - -Template lookup order: - -```text -recipient user preferred_language -workspace default_language -billing account preferred_language -system default_language -``` - -The language selected during account creation is the primary source of truth for user-facing notifications. - -Arabic notifications must render in RTL mode. - -French and English notifications must use localized date, currency, and status labels. - -Fallback to English is allowed only when a localized template is missing, and every fallback must create an internal alert. - ---- - -## 20. Final Localization Policy - -Use this as the baseline rule: - -```text -The language selected by the user during account creation is the primary language for all customer-facing notifications. -Supported languages are English, Arabic, and French. -English uses locale en. -Arabic uses locale ar and must render right-to-left. -French uses locale fr. -Each customer-facing notification template must exist in en, ar, and fr. -If a localized template is missing, the system may fall back to English, but must log and alert the missing localization. -Billing, invoice, subscription, and account notifications must use the recipient’s preferred language. -Language preference is separate from notification opt-in preferences. -Changing language affects future notifications only. -Internal admin alerts may use the internal default language unless configured otherwise. -``` - ---- - -## 21. Definition of Done - -The notification localization system is complete when: - -- Account creation stores `preferred_language`. -- Supported languages are limited to `en`, `ar`, and `fr`. -- Notification records store the resolved locale. -- Email templates exist in English, Arabic, and French. -- In-app notification templates exist in English, Arabic, and French. -- Arabic templates render correctly in RTL. -- French templates render with French labels and formatting. -- Dates and currency are localized during rendering. -- Missing localized templates fall back to English and create alerts. -- Tests cover account, subscription, billing, invoice, and payment notifications for all supported languages. diff --git a/docs/project-design/PAGES.md b/docs/project-design/PAGES.md deleted file mode 100644 index 6bd0e2f9..00000000 --- a/docs/project-design/PAGES.md +++ /dev/null @@ -1,179 +0,0 @@ -# Pages and Route Inventory — Current Apps - -This document lists the pages that are actually present in the current frontend apps. - -Source of truth: - -- `apps/marketplace/src/app` -- `apps/dashboard/src/app` -- `apps/admin/src/app` - -## Marketplace App - -App: - -- `apps/marketplace` - -### Public routes - -- `/` -- `/pricing` -- `/features` -- `/explore` -- `/explore/[slug]` -- `/review` -- `/company-workspace` -- `/platform-operations` -- `/sign-in` -- `/app-privacy-en` -- `/app-privacy-fr` -- `/app-privacy-ar` -- `/app-tc-en` -- `/app-tc-fr` -- `/app-tc-ar` -- `/footer/[slug]` - -### Active route behavior - -- `/` is the public marketing home. -- `/pricing` consumes platform pricing from `/site/platform/pricing`. -- `/explore` is the public marketplace search/discovery page. -- `/explore/[slug]` is the company marketplace profile page. -- `/review` is the review submission page driven by a reservation review token. - -### Not present in the current marketplace app - -- `/about` -- `/contact` -- `/blog` -- `/explore/[slug]/vehicles/[id]` -- a full company public-site booking frontend under its own app - -### Renter routes present in code but not active product entrypoints - -These routes exist in the marketplace app: - -- `/renter/dashboard` -- `/renter/notifications` -- `/renter/profile` -- `/renter/saved-companies` -- `/renter/sign-in` -- `/renter/sign-up` - -However: - -- `/renter/sign-in` redirects to the dashboard sign-in page -- `/renter/sign-up` redirects to the dashboard sign-in page -- API renter signup/login endpoints are disabled - -Because of that, the renter self-service area should be treated as dormant or incomplete, not as an active supported user flow. - -## Dashboard App - -App: - -- `apps/dashboard` - -Base path: - -- `/dashboard` - -### Public/auth routes - -- `/dashboard/sign-in` -- `/dashboard/sign-up` -- `/dashboard/forgot-password` -- `/dashboard/reset-password` -- `/dashboard/onboarding` -- `/dashboard/onboarding/accept-invite` - -### Private company workspace routes - -- `/dashboard` -- `/dashboard/fleet` -- `/dashboard/fleet/[id]` -- `/dashboard/reservations` -- `/dashboard/reservations/new` -- `/dashboard/reservations/[id]` -- `/dashboard/online-reservations` -- `/dashboard/customers` -- `/dashboard/offers` -- `/dashboard/team` -- `/dashboard/reports` -- `/dashboard/subscription` -- `/dashboard/billing` -- `/dashboard/contracts` -- `/dashboard/contracts/[id]` -- `/dashboard/reviews` -- `/dashboard/complaints` -- `/dashboard/notifications` -- `/dashboard/settings` - -### Route responsibilities - -- `/dashboard` shows KPIs and booking-source analytics. -- `/dashboard/fleet*` manages vehicles, maintenance, and calendar blocks. -- `/dashboard/reservations*` manages the booking lifecycle and inspection workflows. -- `/dashboard/online-reservations` handles public/marketplace booking intake. -- `/dashboard/customers` is the company CRM view. -- `/dashboard/offers` manages promotions. -- `/dashboard/team` manages employees. -- `/dashboard/reports` shows analytics/reporting views. -- `/dashboard/subscription` manages SaaS plan state and invoices. -- `/dashboard/billing` manages rental payment collection and balances. -- `/dashboard/contracts*` opens rental contracts. -- `/dashboard/reviews` and `/dashboard/complaints` handle post-rental follow-up. -- `/dashboard/notifications` shows notification inbox/history/preferences. -- `/dashboard/settings` manages brand, domains, policies, insurance, pricing, and accounting settings. - -## Admin App - -App: - -- `apps/admin` - -### Public routes - -- `/` -- `/login` -- `/forgot-password` -- `/reset-password` -- `/auth-redirect` - -### Private admin dashboard routes - -- `/dashboard` -- `/dashboard/companies` -- `/dashboard/companies/[id]` -- `/dashboard/renters` -- `/dashboard/admin-users` -- `/dashboard/audit-logs` -- `/dashboard/billing` -- `/dashboard/pricing` -- `/dashboard/notifications` -- `/dashboard/site-config` -- `/dashboard/containers` - -### Route responsibilities - -- `/dashboard` is the admin overview. -- `/dashboard/companies*` manages tenant companies. -- `/dashboard/renters` inspects and blocks/unblocks renter accounts. -- `/dashboard/admin-users` manages admin operators. -- `/dashboard/audit-logs` reviews admin activity. -- `/dashboard/billing` manages platform billing operations. -- `/dashboard/pricing` edits platform pricing, features, and promotions. -- `/dashboard/notifications` inspects platform notifications. -- `/dashboard/site-config` edits marketplace/site configuration content. - -## Deliberately Not Listed - -This document excludes: - -- API endpoints -- dormant backend-only concepts that do not have an active frontend route -- design-spec routes that are not present in the current app trees - -For backend route inventory, use: - -- `docs/project-design/api-routes.md` diff --git a/docs/STRONG_GRADING_IMPLEMENTATION_NOTES.md b/docs/project-design/STRONG_GRADING_IMPLEMENTATION_NOTES.md similarity index 100% rename from docs/STRONG_GRADING_IMPLEMENTATION_NOTES.md rename to docs/project-design/STRONG_GRADING_IMPLEMENTATION_NOTES.md diff --git a/docs/project-design/VULNERABILITY_FIXES_2026-05-22.md b/docs/project-design/VULNERABILITY_FIXES_2026-05-22.md deleted file mode 100644 index 441d28e5..00000000 --- a/docs/project-design/VULNERABILITY_FIXES_2026-05-22.md +++ /dev/null @@ -1,179 +0,0 @@ -# Security Vulnerability Fix Report - -**Date:** 2026-05-22 -**Scope:** API (`apps/api`) — automated scan + manual review -**Branch:** `develop` - ---- - -## Summary - -A full security review of the API codebase identified 3 confirmed, high-confidence vulnerabilities. All 3 were fixed in the same session. A separate frontend scan (dashboard, admin, marketplace, public-site) found no confirmed vulnerabilities above the reporting threshold. - -| # | Severity | Category | Status | -|---|----------|----------|--------| -| [VF-01](#vf-01--payment-credential-exposure) | High | Credential / Data Exposure | Fixed | -| [VF-02](#vf-02--webhook-signature-bypass) | High | Authentication Bypass | Fixed | -| [VF-03](#vf-03--idor-on-vehicle-maintenance-logs) | Medium-High | IDOR / Tenant Isolation | Fixed | - ---- - -## VF-01 — Payment Credential Exposure - -**Severity:** High -**Confidence:** 9/10 -**Category:** Credential / Data Exposure - -### Description - -`GET /api/v1/companies/me/brand` returned the full `BrandSettings` database row, including the live payment gateway credentials `amanpaySecretKey`, `amanpayMerchantId`, `paypalEmail`, and `paypalMerchantId` in the JSON response. The endpoint had no role guard, so any authenticated employee — including the lowest-privilege `AGENT` role — could call it and receive the secret key in plaintext. - -### Exploit Scenario - -An `AGENT`-role employee calls `GET /api/v1/companies/me/brand` with a valid session token. The response body includes `amanpaySecretKey`. The attacker uses the merchant ID and secret key to interact directly with the AmanPay payment gateway on behalf of the company, initiating fraudulent charges or refunds outside the application. - -### Root Cause - -`presentBrand()` in `company.presenter.ts` was a transparent passthrough (`return brand`). The DB query had no `select` clause, so Prisma returned every column. The public-facing site module correctly stripped these fields (evidenced by explicit test assertions), but the authenticated dashboard endpoint did not. - -### Fix - -**File:** `apps/api/src/modules/companies/company.presenter.ts` - -`presentBrand()` now destructures and drops all four credential fields before returning. The caller receives boolean presence indicators (`amanpayConfigured`, `paypalConfigured`) instead of the raw secrets. - -```diff - export function presentBrand(brand: any) { -- return brand -+ if (!brand) return brand -+ const { amanpaySecretKey, amanpayMerchantId, paypalEmail, paypalMerchantId, ...safe } = brand -+ return { -+ ...safe, -+ amanpayConfigured: !!(amanpayMerchantId && amanpaySecretKey), -+ paypalConfigured: !!(paypalEmail || paypalMerchantId), -+ } - } -``` - ---- - -## VF-02 — Webhook Signature Bypass - -**Severity:** High -**Confidence:** 8/10 -**Category:** Authentication Bypass / Payment Fraud - -### Description - -Both payment and subscription webhook endpoints for AmanPay and PayPal used a short-circuit AND guard for signature verification: - -```ts -if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) { - return res.status(401).json({ error: 'invalid_signature' }) -} -await service.handleAmanpayWebhook(req.body) // executed unconditionally -``` - -When `isConfigured()` returns `false` (i.e., credentials are absent or set to placeholder defaults), the entire guard short-circuits and the webhook handler executes on any unauthenticated request. The endpoints had no IP allowlist and were explicitly placed before `requireCompanyAuth`. - -This affected 4 endpoints: -- `POST /api/v1/payments/webhooks/amanpay` -- `POST /api/v1/payments/webhooks/paypal` -- `POST /api/v1/subscriptions/webhooks/amanpay` -- `POST /api/v1/subscriptions/webhooks/paypal` - -### Exploit Scenario - -On any staging or misconfigured production environment where AmanPay credentials are not set, an attacker posts `{"transaction_id": "", "status": "PAID"}` to `/api/v1/payments/webhooks/amanpay`. The handler finds the pending payment by `transactionId`, marks it succeeded, and unlocks the reservation — with no money changing hands. A valid `amanpayTransactionId` can leak through receipts, logs, or API responses visible to renters. - -The same attack against `/api/v1/subscriptions/webhooks/amanpay` activates a paid subscription tier for free. - -### Fix - -**Files:** `apps/api/src/modules/payments/payment.routes.ts`, `apps/api/src/modules/subscriptions/subscription.routes.ts` - -Inverted the guard logic: an unconfigured provider now immediately returns `401` instead of passing through. Applied to all 4 endpoints. - -```diff --if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) { -+if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) { - return res.status(401).json({ error: 'invalid_signature' }) - } -``` - -```diff --if (paypal.isConfigured() && !isValid) return res.status(401).json({ error: 'invalid_signature' }) -+if (!paypal.isConfigured() || !isValid) return res.status(401).json({ error: 'invalid_signature' }) -``` - ---- - -## VF-03 — IDOR on Vehicle Maintenance Logs - -**Severity:** Medium-High -**Confidence:** 9/10 -**Category:** Insecure Direct Object Reference (IDOR) / Tenant Isolation Bypass - -### Description - -`GET /api/v1/vehicles/:id/maintenance` was protected by `requireCompanyAuth` and `requireTenant`, making `req.companyId` available. However, `req.companyId` was never forwarded to the service or repository. The database query used only `vehicleId` with no tenant filter: - -```ts -// vehicle.repo.ts -prisma.maintenanceLog.findMany({ where: { vehicleId } }) -``` - -An authenticated employee from Company A could fetch the full maintenance history of any vehicle belonging to any other company by knowing its UUID. The write path (`POST /:id/maintenance`) already performed the correct ownership check using `repo.findById(id, companyId)` — the read path did not. - -### Exploit Scenario - -An employee of Company A calls `GET /api/v1/vehicles//maintenance`. Vehicle UUIDs can leak through marketplace listings, shared bookings, or support interactions. The response contains the complete maintenance history of Company B's vehicle — including service records, mileage data, and operational schedules — across tenant boundaries. - -### Fix - -**Files:** `apps/api/src/modules/vehicles/vehicle.routes.ts`, `apps/api/src/modules/vehicles/vehicle.service.ts` - -`req.companyId` is now threaded through to `getMaintenanceLogs`, which performs the same ownership pre-check already used by the write path. A cross-tenant request returns `404`. - -```diff - // vehicle.routes.ts --const logs = await service.getMaintenanceLogs(id) -+const logs = await service.getMaintenanceLogs(id, req.companyId) -``` - -```diff - // vehicle.service.ts --export async function getMaintenanceLogs(id: string) { -- return repo.findMaintenanceLogs(id) -+export async function getMaintenanceLogs(id: string, companyId: string) { -+ const vehicle = await repo.findById(id, companyId) -+ if (!vehicle) throw new NotFoundError('Vehicle not found') -+ return repo.findMaintenanceLogs(id) - } -``` - ---- - -## Frontend Scan Results - -A full scan of all four frontend applications (dashboard, admin, marketplace, public-site) was performed. No vulnerabilities above the reporting threshold (confidence ≥ 8/10) were confirmed. - -### Hardening Recommendations (not vulnerabilities) - -Two items were identified as best-practice improvements: - -1. **Admin auth-redirect `next` param** (`apps/admin/src/app/auth-redirect/page.tsx`) — Validate the `next` query parameter is a relative path (starts with `/`, does not contain `://`) before passing it to `router.replace`. The router itself does not perform cross-origin navigation, but defense-in-depth is recommended. - -2. **`postMessage` wildcard origin + missing `frame-ancestors` CSP** (`apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`, `apps/dashboard/next.config.js`) — Replace `targetOrigin: '*'` with the marketplace origin, and add `Content-Security-Policy: frame-ancestors 'self' ` to prevent the sign-in page from being embeddable by arbitrary third-party domains. - ---- - -## Files Changed - -| File | Change | -|------|--------| -| `apps/api/src/modules/companies/company.presenter.ts` | Strip payment credentials in `presentBrand()` | -| `apps/api/src/modules/payments/payment.routes.ts` | Fix webhook signature bypass (AmanPay + PayPal) | -| `apps/api/src/modules/subscriptions/subscription.routes.ts` | Fix webhook signature bypass (AmanPay + PayPal) | -| `apps/api/src/modules/vehicles/vehicle.routes.ts` | Pass `companyId` to maintenance log service | -| `apps/api/src/modules/vehicles/vehicle.service.ts` | Add ownership check in `getMaintenanceLogs` | diff --git a/docs/project-design/advanced-features.md b/docs/project-design/advanced-features.md deleted file mode 100644 index 2fd822ce..00000000 --- a/docs/project-design/advanced-features.md +++ /dev/null @@ -1,1308 +0,0 @@ ---- -name: rental-car-advanced-features -description: Build advanced rental operations features for RentalDriveGo. Use this skill when the user asks about: vehicle damage diagrams/inspection (contract car layout), insurance policies and charges, second driver (additional driver) with optional charge, driver license validation (expiry check, 3-month rule, flagging), age-based pricing rules (25+ discount, license+5 surcharge), gasoline/fuel policies (FULL_TO_FULL etc.), billing period reporting (weekly/monthly/yearly export for accountants), or the platform admin panel (managing tenants, staff, roles, permissions). This skill works alongside document-service.md and schema.md. ---- - -# Skill: RentalDriveGo Advanced Features - -## Features Covered - -1. Vehicle Damage Inspection Map (contract car diagram) -2. Insurance Policies & Per-Reservation Charges -3. Second / Additional Driver -4. Driver License Validation (expiry + 3-month rule + flagging) -5. Age-Based Pricing Rules (25+ discount, license+5 surcharge) -6. Fuel/Gasoline Policy (structured types) -7. Billing Period Reporting (accountant export) -8. Platform Admin Panel (tenant + staff + role management) - ---- - -## 1. Vehicle Damage Inspection Map - -### What it is -An interactive SVG car diagram embedded in the check-in/check-out workflow (dashboard + printable on contract). Staff marks pre-existing damage zones before handing over the vehicle. At return, the same diagram shows original vs new damage. - -### Data model - -```prisma -// Damage report: one per check-in, one per check-out -model DamageReport { - id String @id @default(cuid()) - reservationId String - reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade) - companyId String // denormalized for tenant scoping - type DamageReportType // CHECKIN | CHECKOUT - - // Array of marked damage zones - // Each zone: { zone: string, severity: string, note: string } - // zone values: "hood", "roof", "trunk", "front-left-door", "front-right-door", - // "rear-left-door", "rear-right-door", "front-left-fender", "front-right-fender", - // "rear-left-fender", "rear-right-fender", "front-bumper", "rear-bumper", - // "windshield", "rear-window", "left-mirror", "right-mirror", - // "front-left-wheel", "front-right-wheel", "rear-left-wheel", "rear-right-wheel" - // severity: "SCRATCH" | "DENT" | "CRACK" | "MISSING" | "OTHER" - damages Json // DamageZone[] - - // Photos of damage (Cloudinary URLs, optional) - photos String[] - - // Fuel level at this inspection point - fuelLevel FuelLevel - - // Odometer reading - mileage Int? - - // Captured at - inspectedAt DateTime @default(now()) - inspectedBy String? // Employee name who did the inspection - - // Customer acknowledgement - customerSignedAt DateTime? - customerName String? // printed name of who acknowledged - - createdAt DateTime @default(now()) - - @@unique([reservationId, type]) - @@index([companyId]) - @@map("damage_reports") -} - -enum DamageReportType { CHECKIN CHECKOUT } -enum FuelLevel { FULL THREE_QUARTERS HALF QUARTER EMPTY } -``` - -Add to `Reservation` model: -```prisma - damageReports DamageReport[] - // Remove old checkInFuelLevel/checkOutFuelLevel String? — replaced by DamageReport.fuelLevel -``` - -### Damage zone TypeScript type - -```typescript -// types/damage.ts -export interface DamageZone { - zone: VehicleZone - severity: 'SCRATCH' | 'DENT' | 'CRACK' | 'MISSING' | 'OTHER' - note?: string -} - -export type VehicleZone = - | 'hood' | 'roof' | 'trunk' - | 'front-left-door' | 'front-right-door' - | 'rear-left-door' | 'rear-right-door' - | 'front-left-fender' | 'front-right-fender' - | 'rear-left-fender' | 'rear-right-fender' - | 'front-bumper' | 'rear-bumper' - | 'windshield' | 'rear-window' - | 'left-mirror' | 'right-mirror' - | 'front-left-wheel' | 'front-right-wheel' - | 'rear-left-wheel' | 'rear-right-wheel' - | 'interior' | 'under-hood' | 'undercarriage' - -// Coordinates on the SVG diagram for each zone -// Used by the interactive damage map UI and the PDF render -export const DAMAGE_ZONE_COORDS: Record = { - 'hood': { x: 48, y: 8, w: 64, h: 28 }, - 'roof': { x: 48, y: 52, w: 64, h: 56 }, - 'trunk': { x: 48, y: 124, w: 64, h: 28 }, - 'front-bumper': { x: 48, y: 0, w: 64, h: 10 }, - 'rear-bumper': { x: 48, y: 150, w: 64, h: 10 }, - 'front-left-fender': { x: 20, y: 10, w: 30, h: 35 }, - 'front-right-fender': { x: 110, y: 10, w: 30, h: 35 }, - 'front-left-door': { x: 20, y: 52, w: 30, h: 30 }, - 'front-right-door': { x: 110, y: 52, w: 30, h: 30 }, - 'rear-left-door': { x: 20, y: 82, w: 30, h: 30 }, - 'rear-right-door': { x: 110, y: 82, w: 30, h: 30 }, - 'rear-left-fender': { x: 20, y: 112, w: 30, h: 35 }, - 'rear-right-fender': { x: 110, y: 112, w: 30, h: 35 }, - 'windshield': { x: 52, y: 38, w: 56, h: 18 }, - 'rear-window': { x: 52, y: 104, w: 56, h: 18 }, - 'left-mirror': { x: 8, y: 50, w: 14, h: 10 }, - 'right-mirror': { x: 138, y: 50, w: 14, h: 10 }, - 'front-left-wheel': { x: 10, y: 24, w: 22, h: 26 }, - 'front-right-wheel': { x: 128, y: 24, w: 22, h: 26 }, - 'rear-left-wheel': { x: 10, y: 110, w: 22, h: 26 }, - 'rear-right-wheel': { x: 128, y: 110, w: 22, h: 26 }, - 'interior': { x: 55, y: 58, w: 50, h: 44 }, - 'under-hood': { x: 52, y: 10, w: 56, h: 20 }, - 'undercarriage': { x: 52, y: 130, w: 56, h: 20 }, -} - -// Severity colors for the SVG overlay -export const SEVERITY_COLORS = { - SCRATCH: '#F59E0B', // amber - DENT: '#EF4444', // red - CRACK: '#8B5CF6', // purple - MISSING: '#374151', // dark gray - OTHER: '#6B7280', // gray -} -``` - -### Interactive Damage Map UI (React Component) - -```tsx -// components/reservations/DamageMap.tsx -import { useState } from 'react' -import { DAMAGE_ZONE_COORDS, SEVERITY_COLORS, VehicleZone, DamageZone } from '@/types/damage' - -interface DamageMapProps { - damages: DamageZone[] - onChange?: (damages: DamageZone[]) => void // undefined = read-only - readonly?: boolean - primaryColor?: string -} - -export function DamageMap({ damages, onChange, readonly, primaryColor = '#1A56DB' }: DamageMapProps) { - const [selected, setSelected] = useState(null) - const [severity, setSeverity] = useState('SCRATCH') - const [note, setNote] = useState('') - - const getDamageForZone = (zone: VehicleZone) => - damages.find(d => d.zone === zone) - - const handleZoneClick = (zone: VehicleZone) => { - if (readonly || !onChange) return - setSelected(zone) - const existing = getDamageForZone(zone) - if (existing) { - setSeverity(existing.severity) - setNote(existing.note ?? '') - } else { - setSeverity('SCRATCH') - setNote('') - } - } - - const applyDamage = () => { - if (!selected || !onChange) return - const updated = damages.filter(d => d.zone !== selected) - updated.push({ zone: selected, severity, note }) - onChange(updated) - setSelected(null) - } - - const clearZone = (zone: VehicleZone) => { - if (!onChange) return - onChange(damages.filter(d => d.zone !== zone)) - } - - return ( -
- {/* SVG Car Diagram — top-down view */} -
- - - {/* Car body — top-down silhouette */} - - - {/* Damage zone hit areas — transparent clickable rects */} - {(Object.entries(DAMAGE_ZONE_COORDS) as [VehicleZone, any][]).map(([zone, coords]) => { - const damage = getDamageForZone(zone) - return ( - handleZoneClick(zone)} className={readonly ? '' : 'cursor-pointer'}> - - {/* Damage indicator dot */} - {damage && ( - - )} - - ) - })} - - - {/* Direction labels */} -
FRONT
-
REAR
-
- - {/* Severity legend */} -
- {(Object.entries(SEVERITY_COLORS) as [DamageZone['severity'], string][]).map(([s, color]) => ( - - - {s} - - ))} -
- - {/* Zone editor (appears when zone is clicked, in edit mode) */} - {selected && !readonly && ( -
-

- {selected.replace(/-/g, ' ')} -

-
- {(['SCRATCH', 'DENT', 'CRACK', 'MISSING', 'OTHER'] as const).map(s => ( - - ))} -
- setNote(e.target.value)} - className="w-full text-sm border border-gray-200 rounded px-2 py-1 mb-2" /> -
- - - -
-
- )} - - {/* Damage list */} - {damages.length > 0 && ( -
-

Marked damages ({damages.length})

-
- {damages.map(d => ( -
-
- - {d.zone.replace(/-/g, ' ')} - — {d.severity} - {d.note && "{d.note}"} -
- {!readonly && ( - - )} -
- ))} -
-
- )} -
- ) -} - -// Simplified SVG path for top-down car silhouette -function CarTopDownSVG({ primaryColor }: { primaryColor: string }) { - return ( - - {/* Outer car body */} - - {/* Hood section */} - - {/* Windshield */} - - {/* Roof / cabin */} - - {/* Rear window */} - - {/* Trunk */} - - {/* Rear bumper */} - - {/* Front bumper */} - - {/* Mirrors */} - - - {/* Wheels */} - - - - - {/* Center line */} - - - ) -} -``` - -### Damage Map in the PDF Contract - -In `RentalContractPDF.tsx`, add a damage section after the vehicle block using `@react-pdf/renderer` SVG primitives (the interactive React SVG won't work in the PDF renderer — use static `Svg`, `Rect`, `Circle`, `Line` elements from `@react-pdf/renderer`): - -```tsx -// In RentalContractPDF.tsx — add after vehicle section -import { Svg, Rect, Circle, Line, Path, G, Text as SvgText } from '@react-pdf/renderer' - -function DamageMapPDF({ damages, label }: { damages: DamageZone[]; label: string }) { - return ( - - {label} - - {/* SVG car diagram — static top-down view */} - - {/* Repeat the CarTopDownSVG shapes using Svg primitives */} - - - - - - - - - - - {/* Damage markers */} - {damages.map((d, i) => { - const coords = DAMAGE_ZONE_COORDS[d.zone] - return ( - - - - - ) - })} - - - {/* Damage list */} - - {damages.length === 0 - ? ✓ No damage recorded - : damages.map((d, i) => ( - - - - {d.zone.replace(/-/g, ' ')} - {` — ${d.severity}${d.note ? ` (${d.note})` : ''}`} - - - )) - } - - - - ) -} - -// Usage in the contract — two damage sections: -// 1. At check-in: -// 2. At checkout: -// Both are shown on the contract, side by side if space, or stacked. -``` - ---- - -## 2. Insurance Policies - -### Data model - -```prisma -// ─── Insurance Policy ───────────────────────────────────────── -// Company configures insurance options available to renters. -// At booking, renter selects one (or NONE if all are optional). - -model InsurancePolicy { - id String @id @default(cuid()) - companyId String - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - - name String // e.g. "Basic Coverage", "Full Coverage", "CDW", "SCDW" - description String? // what it covers (shown to customer at booking) - type InsuranceType - - // Pricing — one of the following applies: - chargeType InsuranceChargeType - chargeValue Int // in smallest currency unit - // Examples: - // chargeType=PER_DAY, chargeValue=3000 → 30 MAD/day - // chargeType=PER_RENTAL, chargeValue=15000 → 150 MAD flat - // chargeType=PERCENTAGE_OF_RENTAL, chargeValue=10 → 10% of base rental - - isRequired Boolean @default(false) // if true, auto-applied, customer cannot opt out - isActive Boolean @default(true) - sortOrder Int @default(0) // display order at booking - - reservations ReservationInsurance[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([companyId]) - @@index([companyId, isActive]) - @@map("insurance_policies") -} - -enum InsuranceType { - CDW // Collision Damage Waiver - SCDW // Super CDW (lower excess) - THEFT // Theft protection - THIRD_PARTY // Third-party liability - FULL // Full coverage bundle - BASIC // Basic minimal coverage - ROADSIDE // Roadside assistance - PERSONAL // Personal accident insurance - CUSTOM // Company-defined name -} - -enum InsuranceChargeType { - PER_DAY - PER_RENTAL // flat fee per reservation - PERCENTAGE_OF_RENTAL // % of base rental amount -} - -// ─── Reservation Insurance ──────────────────────────────────── -// Junction: which insurance(s) were selected for a reservation - -model ReservationInsurance { - id String @id @default(cuid()) - reservationId String - reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade) - insurancePolicyId String - insurancePolicy InsurancePolicy @relation(fields: [insurancePolicyId], references: [id]) - - // Snapshot of policy at time of booking (prices may change later) - policyName String - policyType InsuranceType - chargeType InsuranceChargeType - chargeValue Int // locked at booking time - totalCharge Int // calculated total for this reservation (in smallest unit) - - @@unique([reservationId, insurancePolicyId]) - @@map("reservation_insurances") -} -``` - -Add to `Reservation` model: -```prisma - insurances ReservationInsurance[] - insuranceTotal Int @default(0) // sum of all insurance charges (cached) -``` - -### Insurance charge calculation - -```typescript -// services/insuranceService.ts - -export function calculateInsuranceCharge( - policy: InsurancePolicy, - totalDays: number, - baseRentalAmount: number // dailyRate × totalDays, in smallest unit -): number { - switch (policy.chargeType) { - case 'PER_DAY': - return policy.chargeValue * totalDays - case 'PER_RENTAL': - return policy.chargeValue - case 'PERCENTAGE_OF_RENTAL': - return Math.round(baseRentalAmount * policy.chargeValue / 100) - default: - return 0 - } -} - -export async function applyInsurancesToReservation( - reservationId: string, - companyId: string, - selectedPolicyIds: string[], - totalDays: number, - baseRentalAmount: number -) { - // Always include required policies - const allPolicies = await prisma.insurancePolicy.findMany({ - where: { companyId, isActive: true } - }) - - const required = allPolicies.filter(p => p.isRequired) - const selected = allPolicies.filter(p => selectedPolicyIds.includes(p.id) && !p.isRequired) - const toApply = [...required, ...selected] - - const records = toApply.map(policy => ({ - reservationId, - insurancePolicyId: policy.id, - policyName: policy.name, - policyType: policy.type, - chargeType: policy.chargeType, - chargeValue: policy.chargeValue, - totalCharge: calculateInsuranceCharge(policy, totalDays, baseRentalAmount) - })) - - const insuranceTotal = records.reduce((sum, r) => sum + r.totalCharge, 0) - - await prisma.$transaction([ - prisma.reservationInsurance.createMany({ data: records }), - prisma.reservation.update({ - where: { id: reservationId }, - data: { insuranceTotal } - }) - ]) - - return { records, insuranceTotal } -} -``` - ---- - -## 3. Second / Additional Driver - -### Data model - -```prisma -model AdditionalDriver { - id String @id @default(cuid()) - reservationId String - reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade) - companyId String // tenant scoping - - firstName String - lastName String - email String? - phone String? - driverLicense String - licenseExpiry Date? - dateOfBirth DateTime? - nationality String? - - // Charge for adding this driver - chargeType AdditionalDriverCharge @default(FREE) - chargeValue Int @default(0) // flat or per-day in smallest unit - totalCharge Int @default(0) - - // Validation flags - licenseExpired Boolean @default(false) - licenseExpiringSoon Boolean @default(false) // < 3 months validity remaining - requiresApproval Boolean @default(false) // flagged for manual approval - approvedBy String? // Employee who approved a flagged license - approvedAt DateTime? - approvalNote String? - - createdAt DateTime @default(now()) - - @@index([reservationId]) - @@index([companyId]) - @@map("additional_drivers") -} - -enum AdditionalDriverCharge { - FREE - PER_DAY // charge per rental day - FLAT // one-time flat charge per reservation -} -``` - -Add to company `ContractSettings`: -```prisma - // Additional driver pricing (company sets these) - additionalDriverCharge AdditionalDriverCharge @default(FREE) - additionalDriverDailyRate Int @default(0) - additionalDriverFlatRate Int @default(0) -``` - -Add to `Reservation`: -```prisma - additionalDrivers AdditionalDriver[] - additionalDriverTotal Int @default(0) -``` - ---- - -## 4. Driver License Validation - -### Fields on Customer model (additions) - -```prisma -// Add to model Customer { ... } - licenseExpiry DateTime? // expiry date of driver's license - licenseIssuedAt DateTime? // issue date - licenseCountry String? // issuing country - licenseNumber String? // license number (may differ from driverLicense ID) - licenseCategory String? // e.g. "B", "B+E", "A" (for motorcycle) - - // Validation flags - licenseExpired Boolean @default(false) - licenseExpiringSoon Boolean @default(false) // < 3 months validity - licenseValidationStatus LicenseStatus @default(PENDING) - licenseApprovedBy String? // Employee who approved/denied - licenseApprovedAt DateTime? - licenseApprovalNote String? -``` - -```prisma -enum LicenseStatus { - PENDING // not yet checked - VALID // checked and valid (>3 months remaining) - EXPIRING // flagged: <3 months remaining — awaiting employee decision - APPROVED // expired/expiring but manually approved by employee - DENIED // employee denied the booking due to license - EXPIRED // license has already expired -} -``` - -### License validation service - -```typescript -// services/licenseValidationService.ts - -const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000 - -export interface LicenseValidationResult { - status: 'VALID' | 'EXPIRING' | 'EXPIRED' - daysUntilExpiry: number | null - requiresApproval: boolean - message: string -} - -export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult { - if (!licenseExpiry) { - return { - status: 'VALID', // no expiry stored — cannot determine - daysUntilExpiry: null, - requiresApproval: false, - message: 'No expiry date on record' - } - } - - const now = new Date() - const expiryMs = licenseExpiry.getTime() - const daysUntilExpiry = Math.ceil((expiryMs - now.getTime()) / (1000 * 60 * 60 * 24)) - - if (expiryMs <= now.getTime()) { - return { - status: 'EXPIRED', - daysUntilExpiry: daysUntilExpiry, // negative number - requiresApproval: true, - message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` - } - } - - if (expiryMs - now.getTime() < THREE_MONTHS_MS) { - return { - status: 'EXPIRING', - daysUntilExpiry, - requiresApproval: true, - message: `License expires in ${daysUntilExpiry} day(s) — approval required` - } - } - - return { - status: 'VALID', - daysUntilExpiry, - requiresApproval: false, - message: `Valid — expires in ${daysUntilExpiry} day(s)` - } -} - -// Called when creating/updating a customer or adding an additional driver -export async function validateAndFlagLicense(customerId: string) { - const customer = await prisma.customer.findUniqueOrThrow({ - where: { id: customerId } - }) - - const result = validateLicense(customer.licenseExpiry) - - const status: LicenseStatus = - result.status === 'EXPIRED' ? 'EXPIRED' : - result.status === 'EXPIRING' ? 'EXPIRING' : 'VALID' - - await prisma.customer.update({ - where: { id: customerId }, - data: { - licenseExpired: result.status === 'EXPIRED', - licenseExpiringSoon: result.status === 'EXPIRING', - licenseValidationStatus: status, - } - }) - - return result -} -``` - -### License approval UI (dashboard) - -When a reservation is created for a customer with `licenseValidationStatus: EXPIRING | EXPIRED`: -- Show a yellow (EXPIRING) or red (EXPIRED) banner in the reservation detail page -- Banner text: "⚠️ Customer's license expires in X days — please verify before confirming" -- Two action buttons: **[Approve and Continue]** / **[Deny Reservation]** -- Clicking Approve: `PATCH /customers/:id/license-approval { decision: 'APPROVE', note: '...' }` -- Clicking Deny: opens cancel reservation flow - ---- - -## 5. Age-Based Pricing Rules - -### Data model - -```prisma -model PricingRule { - id String @id @default(cuid()) - companyId String - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - - name String // e.g. "Young Driver Surcharge", "Senior Discount" - type PricingRuleType - condition PricingCondition - conditionValue Int // e.g. 25 for "age < 25", 70 for "age > 70" - - adjustmentType PriceAdjustmentType - adjustmentValue Int // percentage or flat in smallest unit - // For LICENSE_YEARS condition: conditionValue = minimum years since license issue date - - isActive Boolean @default(true) - description String? // shown to customer at booking - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([companyId]) - @@map("pricing_rules") -} - -enum PricingRuleType { - SURCHARGE // adds cost - DISCOUNT // reduces cost -} - -enum PricingCondition { - AGE_LESS_THAN // customer age (years) < conditionValue - AGE_GREATER_THAN // customer age > conditionValue - LICENSE_YEARS_LESS_THAN // years since license issue < conditionValue - LICENSE_YEARS_GREATER_THAN -} - -enum PriceAdjustmentType { - PERCENTAGE // % of base daily rate - FLAT_PER_DAY // flat amount per day in smallest unit - FLAT_TOTAL // flat amount per reservation -} -``` - -Add to `Reservation`: -```prisma - pricingRulesApplied Json? // Array of { ruleId, name, type, amount } — snapshot at booking - pricingRulesTotal Int @default(0) // net surcharges minus discounts -``` - -### Built-in rules (pre-configured defaults companies can edit) - -```typescript -// The system ships with these as suggested defaults (companies can modify or delete): -const DEFAULT_PRICING_RULES = [ - { - name: 'Young Driver Surcharge (Under 25)', - type: 'SURCHARGE', - condition: 'AGE_LESS_THAN', - conditionValue: 25, - adjustmentType: 'PERCENTAGE', - adjustmentValue: 15, // +15% of base rate - description: 'Additional charge for drivers under 25 years of age' - }, - { - name: 'Experienced License Discount (5+ Years)', - type: 'DISCOUNT', - condition: 'LICENSE_YEARS_GREATER_THAN', - conditionValue: 5, - adjustmentType: 'PERCENTAGE', - adjustmentValue: 5, // -5% of base rate - description: 'Discount for drivers with 5+ years of driving experience' - }, -] -``` - -### Rule application service - -```typescript -// services/pricingRuleService.ts - -export async function applyPricingRules( - companyId: string, - customerId: string, - additionalDrivers: { dateOfBirth?: Date | null; licenseIssuedAt?: Date | null }[], - dailyRate: number, - totalDays: number -): Promise<{ applied: any[]; total: number }> { - - const rules = await prisma.pricingRule.findMany({ - where: { companyId, isActive: true } - }) - - const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } }) - const allDrivers = [customer, ...additionalDrivers] - - const applied: any[] = [] - const baseAmount = dailyRate * totalDays - - for (const driver of allDrivers) { - for (const rule of rules) { - const driverAge = driver.dateOfBirth - ? Math.floor((Date.now() - driver.dateOfBirth.getTime()) / (365.25 * 24 * 60 * 60 * 1000)) - : null - - const licenseYears = (driver as any).licenseIssuedAt - ? Math.floor((Date.now() - (driver as any).licenseIssuedAt.getTime()) / (365.25 * 24 * 60 * 60 * 1000)) - : null - - let conditionMet = false - if (rule.condition === 'AGE_LESS_THAN' && driverAge !== null) - conditionMet = driverAge < rule.conditionValue - else if (rule.condition === 'AGE_GREATER_THAN' && driverAge !== null) - conditionMet = driverAge > rule.conditionValue - else if (rule.condition === 'LICENSE_YEARS_LESS_THAN' && licenseYears !== null) - conditionMet = licenseYears < rule.conditionValue - else if (rule.condition === 'LICENSE_YEARS_GREATER_THAN' && licenseYears !== null) - conditionMet = licenseYears > rule.conditionValue - - if (!conditionMet) continue - - let amount = 0 - if (rule.adjustmentType === 'PERCENTAGE') - amount = Math.round(baseAmount * rule.adjustmentValue / 100) - else if (rule.adjustmentType === 'FLAT_PER_DAY') - amount = rule.adjustmentValue * totalDays - else - amount = rule.adjustmentValue - - if (rule.type === 'DISCOUNT') amount = -amount - - applied.push({ ruleId: rule.id, name: rule.name, type: rule.type, amount }) - } - } - - // Deduplicate: each rule type applied once even if multiple drivers trigger it - const deduped = applied.reduce((acc: any[], item) => { - if (!acc.find(a => a.ruleId === item.ruleId)) acc.push(item) - return acc - }, []) - - const total = deduped.reduce((sum, r) => sum + r.amount, 0) - return { applied: deduped, total } -} -``` - ---- - -## 6. Fuel / Gasoline Policy (Structured) - -Replace the free-text `fuelPolicy` string in `ContractSettings` with a structured enum. - -```prisma -// In ContractSettings — replace fuelPolicy String with: - fuelPolicyType FuelPolicyType @default(FULL_TO_FULL) - fuelPolicyNote String? // optional extra note - prepaidFuelPrice Int? // price per liter in smallest unit (for PREPAID type) -``` - -```prisma -enum FuelPolicyType { - FULL_TO_FULL // Customer receives full, must return full. Extra charges if returned low. - FULL_TO_EMPTY // Customer receives full, keeps all fuel (no refund for unused fuel). - SAME_TO_SAME // Returns vehicle with same level as received. - PREPAID // Customer pays for a full tank upfront, returns at any level. - FREE // Fuel included in rental price. -} -``` - -```typescript -// lib/fuelPolicyLabels.ts -export const FUEL_POLICY_LABELS: Record> = { - FULL_TO_FULL: { - en: 'Full-to-Full: Return vehicle with a full tank. A refueling fee applies if returned with less fuel.', - fr: 'Plein-à-Plein: Retournez le véhicule avec le plein. Des frais de carburant s\'appliquent sinon.', - ar: 'ممتلئ إلى ممتلئ: يجب إعادة السيارة بخزان ممتلئ. رسوم إضافية في حال الإعادة بمستوى أقل.' - }, - FULL_TO_EMPTY: { - en: 'Full-to-Empty: Vehicle is provided with a full tank. No refund for unused fuel upon return.', - fr: 'Plein-à-Vide: Le véhicule est fourni avec un plein. Aucun remboursement pour carburant non utilisé.', - ar: 'ممتلئ إلى فارغ: تُسلَّم السيارة بخزان ممتلئ. لا استرداد للوقود غير المستخدم.' - }, - SAME_TO_SAME: { - en: 'Same-to-Same: Return vehicle with the same fuel level as at pickup.', - fr: 'Même niveau: Retournez avec le même niveau de carburant qu\'au départ.', - ar: 'نفس المستوى: أعد السيارة بنفس مستوى الوقود عند الاستلام.' - }, - PREPAID: { - en: 'Prepaid Fuel: You pre-purchase a full tank at a fixed rate. Return at any fuel level.', - fr: 'Carburant prépayé: Vous prépayez un plein à tarif fixe. Retour à n\'importe quel niveau.', - ar: 'الوقود المدفوع مسبقاً: تدفع مقدماً لخزان كامل بسعر ثابت. العودة بأي مستوى.' - }, - FREE: { - en: 'Fuel Included: Fuel cost is included in the rental price.', - fr: 'Carburant inclus: Le coût du carburant est inclus dans le tarif de location.', - ar: 'الوقود مشمول: تكلفة الوقود مشمولة في سعر الإيجار.' - } -} -``` - ---- - -## 7. Billing Period Reporting (Accountant Export) - -Companies need to export their financial data by week, month, or year. - -### Report model (optional — or generate entirely on-demand) - -```prisma -// No model needed — reports are generated on-demand from Reservation + RentalPayment data -// The API query uses date range filters -``` - -### Report API - -```typescript -// GET /api/v1/reports/financial -// Query params: period=WEEK|MONTH|YEAR, startDate, endDate, format=JSON|CSV - -export async function generateFinancialReport( - companyId: string, - startDate: Date, - endDate: Date -) { - const reservations = await prisma.reservation.findMany({ - where: { - companyId, - status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, - startDate: { gte: startDate }, - endDate: { lte: endDate } - }, - include: { - vehicle: { select: { make: true, model: true, year: true, licensePlate: true } }, - customer: { select: { firstName: true, lastName: true, email: true } }, - rentalPayments: { where: { status: 'SUCCEEDED' } }, - insurances: true, - additionalDrivers: true, - }, - orderBy: { startDate: 'asc' } - }) - - // Aggregate totals - const summary = { - totalReservations: reservations.length, - totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0), - totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0), - totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0), - totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0), - totalPricingRulesAdjustments: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0), - totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0), - totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0), - averageRentalDays: reservations.length > 0 - ? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0, - } - - // Per-reservation rows for the table / CSV - const rows = reservations.map(r => ({ - reservationId: r.id, - contractNumber: r.contractNumber ?? '—', - invoiceNumber: r.invoiceNumber ?? '—', - customerName: `${r.customer.firstName} ${r.customer.lastName}`, - vehicle: `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`, - plate: r.vehicle.licensePlate, - startDate: r.startDate.toISOString().split('T')[0], - endDate: r.endDate.toISOString().split('T')[0], - days: r.totalDays, - dailyRate: r.dailyRate, - baseAmount: r.dailyRate * r.totalDays, - discount: r.discountAmount, - insurance: r.insuranceTotal, - additionalDriver: r.additionalDriverTotal, - pricingAdj: r.pricingRulesTotal, - deposit: r.depositAmount, - totalAmount: r.totalAmount, - paymentStatus: r.rentalPayments[0]?.status ?? 'UNPAID', - paymentMethod: r.rentalPayments[0]?.paymentMethod ?? '—', - source: r.source, - })) - - return { summary, rows, period: { startDate, endDate } } -} -``` - -### CSV export - -```typescript -// lib/csvExport.ts -export function toCsv(rows: Record[]): string { - if (rows.length === 0) return '' - const headers = Object.keys(rows[0]) - const lines = [ - headers.join(','), - ...rows.map(row => - headers.map(h => { - const val = row[h] ?? '' - return typeof val === 'string' && val.includes(',') ? `"${val}"` : String(val) - }).join(',') - ) - ] - return lines.join('\n') -} -``` - -### Dashboard: Reports Page - -Located at `/dashboard/reports` (new page). - -``` -┌─────────────────────────────────────────────────────┐ -│ Financial Reports [Export CSV] │ -│ │ -│ Period: [This Week ▼] [This Month ▼] [This Year ▼] │ -│ Or custom: [From date] → [To date] [Apply] │ -│ │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ Summary Cards (6 cards in a row) │ │ -│ │ Total Bookings · Revenue · Discounts · Insurance│ │ -│ │ Additional Drivers · Net Collected │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ Reservations Table (all in period) │ -│ Contract# · Invoice# · Customer · Vehicle · Dates │ -│ Days · Base · Discount · Insurance · AddDriver │ -│ PricingAdj · Total · Payment Status · Source │ -└─────────────────────────────────────────────────────┘ -``` - ---- - -## 8. Platform Admin Panel - -The platform administrator (RentalDriveGo team) manages all companies and their staff. - -### Admin model - -```prisma -model AdminUser { - id String @id @default(cuid()) - email String @unique - firstName String - lastName String - passwordHash String - role AdminRole @default(SUPPORT) - isActive Boolean @default(true) - lastLoginAt DateTime? - - auditLogs AdminAuditLog[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("admin_users") -} - -enum AdminRole { - SUPER_ADMIN // full access: create/edit/delete companies, manage other admins - ADMIN // create/edit companies, manage staff, cannot delete or manage other admins - SUPPORT // read-only access to companies + reservations, can impersonate -} - -model AdminAuditLog { - id String @id @default(cuid()) - adminId String - admin AdminUser @relation(fields: [adminId], references: [id]) - action String // e.g. "CREATE_COMPANY", "SUSPEND_COMPANY", "EDIT_EMPLOYEE" - targetType String // "Company" | "Employee" | "Reservation" - targetId String - before Json? // state before change - after Json? // state after change - ip String? - createdAt DateTime @default(now()) - - @@index([adminId]) - @@index([targetType, targetId]) - @@map("admin_audit_logs") -} -``` - -### Admin API routes - -``` -Base: https://admin.RentalDriveGo.com/api/v1/ (separate app) -OR: https://api.RentalDriveGo.com/api/v1/admin/ (preferred — same API, admin middleware) - -Middleware: requireAdminAuth → attaches req.admin (AdminUser) -``` - -| Method | Path | Role | Description | -|--------|------|------|-------------| -| POST | `/admin/auth/login` | — | Admin login (email + password) | -| GET | `/admin/auth/me` | Any admin | Current admin profile | -| **Companies** | | | | -| GET | `/admin/companies` | Any | List all companies + search/filter | -| POST | `/admin/companies` | ADMIN+ | Create company (name, slug, email, plan) | -| GET | `/admin/companies/:id` | Any | Full company detail | -| PATCH | `/admin/companies/:id` | ADMIN+ | Edit company profile | -| PATCH | `/admin/companies/:id/status` | ADMIN+ | Change status (ACTIVE/SUSPENDED) | -| PATCH | `/admin/companies/:id/plan` | ADMIN+ | Change plan without payment | -| DELETE | `/admin/companies/:id` | SUPER_ADMIN | Hard delete company (irreversible) | -| POST | `/admin/companies/:id/impersonate` | ADMIN+ | Generate impersonation token | -| **Staff** | | | | -| GET | `/admin/companies/:id/employees` | Any | List employees | -| POST | `/admin/companies/:id/employees` | ADMIN+ | Add employee (name, email, role) | -| PATCH | `/admin/companies/:id/employees/:eid` | ADMIN+ | Edit employee | -| PATCH | `/admin/companies/:id/employees/:eid/role` | ADMIN+ | Change role | -| DELETE | `/admin/companies/:id/employees/:eid` | ADMIN+ | Deactivate employee | -| POST | `/admin/companies/:id/employees/:eid/reset-password` | ADMIN+ | Trigger password reset email | -| **Platform** | | | | -| GET | `/admin/metrics` | Any | MRR, ARR, churn, signups, active | -| GET | `/admin/admins` | SUPER_ADMIN | List admin users | -| POST | `/admin/admins` | SUPER_ADMIN | Create admin user | -| PATCH | `/admin/admins/:id` | SUPER_ADMIN | Edit admin | -| PATCH | `/admin/admins/:id/role` | SUPER_ADMIN | Change admin role | -| DELETE | `/admin/admins/:id` | SUPER_ADMIN | Deactivate admin | -| GET | `/admin/audit-log` | SUPER_ADMIN | Full audit log | - -### Impersonation - -Admins can impersonate any company employee for support purposes: - -```typescript -// POST /admin/companies/:id/impersonate -// Returns a short-lived JWT that grants dashboard access as the company OWNER -// Duration: 30 minutes max -// All actions logged in AdminAuditLog -// Dashboard shows "⚠️ Impersonation Mode — RentalDriveGo Admin" banner - -async function impersonateCompany(req, res) { - const company = await prisma.company.findUniqueOrThrow({ - where: { id: req.params.id }, - include: { employees: { where: { role: 'OWNER' } } } - }) - - await prisma.adminAuditLog.create({ - data: { - adminId: req.admin.id, - action: 'IMPERSONATE_COMPANY', - targetType: 'Company', - targetId: company.id, - ip: req.ip - } - }) - - // Generate a short-lived JWT signed with JWT_SECRET - const token = jwt.sign( - { companyId: company.id, employeeId: company.employees[0]?.id, isImpersonation: true }, - process.env.JWT_SECRET!, - { expiresIn: '30m' } - ) - - res.json({ data: { token, expiresIn: 1800 } }) -} -``` - -### Admin UI (admin.RentalDriveGo.com) - -Separate Next.js app with its own auth (email + password, NO Clerk). - -| Page | Description | -|------|-------------| -| `/` → redirect to `/companies` | | -| `/login` | Admin login | -| `/companies` | Searchable table: name, slug, plan, status, employees count, vehicles count, MRR, created. Actions: Edit, Suspend, Impersonate | -| `/companies/new` | Create company form | -| `/companies/:id` | Company profile: overview, subscription, employees, vehicles, reservations, audit log | -| `/companies/:id/employees` | Employee list + add/edit/deactivate/change role | -| `/metrics` | Platform KPIs: MRR, ARR, churn rate, new signups chart, top companies | -| `/admins` | Admin user management (SUPER_ADMIN only) | -| `/audit-log` | Full audit log with filters | - ---- - -## Invoice: Full Price Breakdown - -The `CustomerInvoicePDF` line items now must include all charge types: - -```typescript -// Full line items array for invoice generation -const lineItems = [ - // 1. Base rental - { - description: `${vehicle.year} ${vehicle.make} ${vehicle.model} — ${totalDays} day(s) × ${fmt(dailyRate)}/day`, - qty: totalDays, unitPrice: dailyRate, total: dailyRate * totalDays, - category: 'RENTAL' - }, - - // 2. Insurance lines (one per policy selected) - ...insurances.map(ins => ({ - description: `${ins.policyName} (${insuranceChargeLabel(ins.chargeType, ins.chargeValue, totalDays, locale)})`, - qty: ins.chargeType === 'PER_DAY' ? totalDays : 1, - unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, - total: ins.totalCharge, - category: 'INSURANCE' - })), - - // 3. Additional driver(s) - ...additionalDrivers.filter(d => d.totalCharge > 0).map(d => ({ - description: `Additional Driver — ${d.firstName} ${d.lastName}`, - qty: d.chargeType === 'PER_DAY' ? totalDays : 1, - unitPrice: d.chargeType === 'PER_DAY' ? d.chargeValue : d.totalCharge, - total: d.totalCharge, - category: 'ADDITIONAL_DRIVER' - })), - - // 4. Pricing rule adjustments (surcharges as positive, discounts as negative) - ...(pricingRulesApplied ?? []).map((rule: any) => ({ - description: rule.name, - qty: 1, unitPrice: rule.amount, total: rule.amount, - category: rule.amount > 0 ? 'SURCHARGE' : 'DISCOUNT' - })), - - // 5. Deposit (always last, marked as refundable) - ...(depositAmount > 0 ? [{ - description: locale === 'ar' ? 'تأمين (قابل للاسترداد)' : locale === 'fr' ? 'Caution (remboursable)' : 'Security Deposit (refundable)', - qty: 1, unitPrice: depositAmount, total: depositAmount, - category: 'DEPOSIT' - }] : []), -] - -// Totals -const subtotal = dailyRate * totalDays -const discountTotal = -Math.abs(discountAmount + pricingDiscountsTotal) -const surchargeTotal = pricingSurchargesTotal + pricingRulesTotal -const insuranceTotal = insurances.reduce((s, i) => s + i.totalCharge, 0) -const additionalDriverTotal = additionalDrivers.reduce((s, d) => s + d.totalCharge, 0) -const taxAmount = showTax ? Math.round(subtotal * (taxRate ?? 0)) : 0 -const grandTotal = subtotal + discountTotal + surchargeTotal + insuranceTotal + additionalDriverTotal + taxAmount + depositAmount -``` - ---- - -## Updated ContractSettings model (additions to existing) - -```prisma -// ADD to model ContractSettings — replacing fuelPolicy String: - fuelPolicyType FuelPolicyType @default(FULL_TO_FULL) - fuelPolicyNote String? - - // Additional driver config - additionalDriverCharge AdditionalDriverCharge @default(FREE) - additionalDriverDailyRate Int @default(0) - additionalDriverFlatRate Int @default(0) -``` - -## Updated Reservation model (additional fields) - -```prisma -// ADD to model Reservation: - insurances ReservationInsurance[] - insuranceTotal Int @default(0) - additionalDrivers AdditionalDriver[] - additionalDriverTotal Int @default(0) - pricingRulesApplied Json? // snapshot of applied pricing rules - pricingRulesTotal Int @default(0) - damageReports DamageReport[] -``` - -## New Company relations (add to model Company) - -```prisma - insurancePolicies InsurancePolicy[] - pricingRules PricingRule[] -``` diff --git a/docs/project-design/api-routes.md b/docs/project-design/api-routes.md deleted file mode 100644 index 5a347c17..00000000 --- a/docs/project-design/api-routes.md +++ /dev/null @@ -1,396 +0,0 @@ -# API Architecture and Route Design - -This document explains how the API is structured today, how requests move through the stack, and what each route group is responsible for. - -Source of truth for the runtime wiring: - -- `apps/api/src/app.ts` -- `apps/api/src/modules/*` -- `apps/api/src/middleware/*` -- `apps/api/src/swagger/openapi.ts` - -## Runtime Overview - -The API is an Express application mounted under `/api/v1` with a few non-versioned utility endpoints: - -- `GET /health` returns process health. -- `GET /docs` serves Swagger UI. -- `GET /api/v1/openapi.json` returns the generated OpenAPI document. -- `/storage/*` serves uploaded assets such as logos, hero images, vehicle photos, and reservation/customer documents. - -The app boot sequence in `apps/api/src/app.ts` is intentionally ordered: - -1. CORS is applied first. -2. storage guards are applied before static serving so private customer license files are never anonymously retrievable. -3. Swagger UI is mounted before Helmet so the UI assets are not blocked by CSP. -4. webhook routes that require raw payload handling are mounted before `express.json()`. -5. Helmet, request logging, JSON parsing, and the module routers are mounted. -6. the centralized error middleware converts validation, Prisma, and application errors into consistent JSON responses. - -## Request Lifecycle - -Most internal company routes follow the same pattern: - -1. Express router receives the request. -2. Zod schemas validate `req.params`, `req.query`, and `req.body` through `parseParams`, `parseQuery`, and `parseBody`. -3. auth middleware resolves the caller identity. -4. tenant middleware resolves the company record. -5. subscription middleware blocks suspended or pending companies where required. -6. role middleware enforces employee or admin permissions. -7. the route handler calls a service function. -8. the service applies business rules and calls a repo or Prisma directly. -9. a presenter may normalize the payload for frontend consumption. -10. `ok()` or `created()` wraps successful responses in `{ "data": ... }`. - -Common exceptions: - -- `GET /health` and `GET /api/v1/docs` return raw JSON, not the `{ data }` envelope. -- webhook endpoints return minimal receipt payloads. -- some public endpoints return graceful fallback responses if the database is unavailable. - -## Security and Access Model - -There are four main access patterns. - -### Employee JWT - -`requireCompanyAuth` validates a Bearer token with `type === "employee"`, loads the employee, and attaches: - -- `req.employee` -- `req.company` -- `req.companyId` - -This is the default for dashboard/company management routes. - -### Tenant Resolution - -`requireTenant` reloads the company from `req.companyId` and guarantees the tenant context exists. Company-scoped modules then query by `companyId`. - -### Subscription Guard - -`requireSubscription` blocks company routes when the company status is `PENDING` or `SUSPENDED`. Public site and marketplace routes are intentionally not behind this guard. - -### Role-Based Access Control - -Employee roles are hierarchical: - -- `OWNER` -- `MANAGER` -- `AGENT` - -Admin roles are hierarchical: - -- `SUPER_ADMIN` -- `ADMIN` -- `SUPPORT` -- `FINANCE` -- `VIEWER` - -### Renter JWT - -`requireRenterAuth` validates a Bearer token with `type === "renter"` and attaches `req.renterId`. - -`optionalRenterAuth` is used on marketplace routes so public reads still work without a token. - -### API Key - -`requireApiKey` accepts a company public API key in `x-api-key`. This is the low-friction integration path for public company-site style calls. - -## Implementation Pattern - -The API codebase is organized per module. The most common pattern is: - -- `*.routes.ts`: route definitions and middleware composition -- `*.schemas.ts`: Zod input validation -- `*.service.ts`: business rules and orchestration -- `*.repo.ts`: Prisma access helpers -- `*.presenter.ts`: response shaping - -The `vehicles` module is representative: - -- `vehicle.routes.ts` mounts auth, tenant, and subscription guards once for the router. -- route handlers validate input and call service functions. -- `vehicle.service.ts` contains business rules such as publish-state normalization and availability calculation. -- `vehicle.repo.ts` owns the Prisma queries and enforces tenant filters in reads. -- `vehicle.presenter.ts` shapes the response returned to clients. - -Some modules split specialized workflows into additional services instead of one large file. The reservations module is the clearest example: - -- `reservation.service.ts` handles CRUD and list behavior -- `reservation.lifecycle.service.ts` handles confirm/checkin/checkout/close/extend/cancel transitions -- `reservation.inspection.service.ts` handles pickup/dropoff inspections -- `reservation.additional-driver.service.ts` handles approval workflows -- `reservation.photo.service.ts` handles pickup/dropoff photo uploads -- `reservation.document.service.ts` generates contract and billing payloads - -## Route Groups - -The table below describes the current route groups mounted in `app.ts`. - -| Base path | Access | Purpose | Notes | -| --- | --- | --- | --- | -| `/health` | Public | Process health | Non-versioned utility endpoint | -| `/docs` | Public | Swagger UI | Backed by `openapi.ts` | -| `/api/v1/openapi.json` | Public | OpenAPI JSON | Generated from Zod schemas and manual path entries | -| `/api/v1/auth/company` | Public | Company signup | `complete-signup` and `verify-email` now return disabled errors | -| `/api/v1/auth/employee` | Public plus employee JWT | Employee login, password reset, profile, language | Dashboard/staff authentication path | -| `/api/v1/auth/renter` | Renter JWT for profile routes | Renter profile and push token | `signup` and `login` are currently disabled in this codebase | -| `/api/v1/admin` | Admin JWT | Platform operations | Includes company admin, billing, subscriptions, pricing, audit, and admin-user management | -| `/api/v1/marketplace` | Public, optional renter JWT | Marketplace discovery and reservation intake | Designed for discovery and lead capture across companies | -| `/api/v1/site` | Public | White-label company site APIs | Drives each company-branded booking site | -| `/api/v1/subscriptions` | Mixed | Plan listing, webhooks, subscription lifecycle | Public, webhook, and authenticated sub-routers share the same prefix | -| `/api/v1/vehicles` | Employee JWT + tenant + subscription | Fleet management | Includes photos, status, calendar blocks, maintenance, and availability | -| `/api/v1/reservations` | Employee JWT + tenant + subscription | Reservation operations | Central booking lifecycle for internal staff | -| `/api/v1/team` | Employee JWT + tenant + subscription | Employee/team management | Owner-controlled invite, role, activation, removal | -| `/api/v1/customers` | Employee JWT + tenant + subscription | Company CRM and license handling | Includes protected license-image retrieval and approval flows | -| `/api/v1/offers` | Employee JWT + tenant + subscription | Promotional offers | Company marketing and promo codes | -| `/api/v1/analytics` | Employee JWT + tenant + subscription | Dashboard metrics and reports | Includes summary, dashboard, source, and report routes | -| `/api/v1/notifications` | Employee or renter JWT depending on route | Notification inbox and preferences | Separate company and renter surfaces | -| `/api/v1/companies` | Employee JWT + tenant + subscription | Company profile and settings | Brand, domains, contract settings, insurance policies, pricing rules, accounting, API key | -| `/api/v1/payments` | Mixed | Rental payment webhooks and company payment actions | Webhooks are public; operational routes are company-authenticated | -| `/api/v1/reviews` | Employee JWT + tenant + subscription | Review moderation | Company-side review visibility and replies | -| `/api/v1/complaints` | Employee JWT + tenant + subscription | Complaint handling | Internal complaint tracking | -| `/api/v1/webhooks` | Public | External webhook receivers | Currently includes `/clerk` placeholder | - -## Functional Breakdown by Module - -### Company and employee onboarding - -- `POST /api/v1/auth/company/signup` creates the company tenant, owner employee, default subscription state, and related baseline records in one transaction. -- The signup flow generates a unique company slug, hashes the owner password, starts a 90-day trial window, and sends account-created notifications. -- Employee auth supports login, forgot-password, reset-password, profile read, and language updates. - -### Company profile and business configuration - -The `companies` module owns tenant-level settings: - -- company identity -- brand settings and asset upload -- subdomain/custom-domain configuration -- contract settings used by rental documents -- insurance policies -- pricing rules -- accounting settings -- public API key generation/regeneration - -This module is where most long-lived company configuration lives. - -### Fleet management - -The `vehicles` module handles: - -- CRUD for vehicles -- publish/unpublish -- explicit status updates -- photo upload and deletion -- availability checks -- monthly vehicle calendar events -- manual calendar blocks -- maintenance logs - -Uploads are persisted through the storage service, then surfaced under `/storage/*`. - -### Reservations and rental workflow - -The reservation aggregate is the center of the operational API. - -Important transitions: - -1. create draft reservation -2. confirm reservation after license checks -3. check in and move the reservation to `ACTIVE` -4. check out and mark the reservation `COMPLETED` -5. close the reservation operationally after post-rental tasks are done - -The lifecycle service also handles: - -- extensions with conflict checks -- cancellations -- vehicle status synchronization -- review token generation -- review request email dispatch on close - -The reservations module also owns: - -- contract and billing payload generation -- pickup/dropoff inspections -- additional-driver approval -- pickup/dropoff photo upload - -### Customer CRM and license compliance - -Customers are company-scoped CRM records, even when a renter identity also exists. The `customers` module supports: - -- customer CRUD -- flag/unflag flows -- license image upload and protected retrieval -- license validation and manager approval - -This is intentionally separate from renter identity because one renter may interact with multiple companies while each company still needs its own operational customer record. - -### Marketplace and public site - -The public surface is split in two modules on purpose. - -`marketplace` is the cross-company discovery layer: - -- featured/public offers -- marketplace cities -- listed companies -- vehicle search -- marketplace reservation intake -- review submission by token -- company public pages under `/:slug` - -`site` is the white-label company booking API: - -- company brand payload -- published vehicle catalog -- booking options -- date-range availability -- promo validation -- booking creation -- payment initialization -- booking lookup -- company contact form - -Both modules deliberately include graceful degradation paths when the database is unavailable so public pages can fail softer than internal operations. - -### Payments - -Rental payments are company-side operational payments, distinct from SaaS subscription billing. - -The `payments` module handles: - -- AmanPay and PayPal rental webhooks -- listing payments by company or reservation -- initializing online charges -- capturing PayPal orders -- recording manual payments -- refunding successful online payments - -Payment success updates both the payment record and the reservation paid amount. - -### Subscriptions and SaaS billing - -The `subscriptions` module owns the platform billing lifecycle: - -- public plan/provider/feature listing -- company subscription read endpoints -- trial start -- checkout -- plan changes -- cancellation/resume/reactivation -- provider webhooks -- admin overrides - -It also exposes background-job style functions for: - -- trial expiration -- payment-pending timeout -- past-due timeout -- suspension timeout -- period-end cancellation - -### Notifications - -Notifications are multi-channel and multi-audience: - -- company inbox -- renter inbox -- unread counts -- preferences per type and channel -- notification history - -Templates and preference records live in the database; delivery orchestration lives in `services/notificationService.ts`. - -### Admin surface - -The admin router is broad because it covers platform operations across multiple domains: - -- admin authentication and 2FA -- company listing, inspection, status changes, deletion, impersonation -- renter blocking/unblocking -- platform metrics -- notification inspection -- audit log access -- admin-user and permission management -- billing account and billing invoice operations -- pricing config, plan features, and promotions -- subscription overrides and extensions -- marketplace homepage configuration - -This is the only route group allowed to work across tenants. - -## Important Workflow Examples - -### 1. Company signup - -`POST /api/v1/auth/company/signup` - -- validates the payload with Zod -- checks for duplicate company and owner email addresses -- generates a unique slug -- hashes the owner password -- creates the tenant, owner employee, and initial subscription state in one transaction -- sends localized account-created notifications - -### 2. Public booking from a company site - -`POST /api/v1/site/:slug/book` - -- resolves the company from the slug -- verifies vehicle availability for the requested date range -- upserts the company-scoped customer record -- computes total days and base rental price -- applies promo code discount if present -- applies pricing rules -- stores a draft reservation -- attaches reservation insurance snapshots and additional-driver snapshots -- triggers license validation flags - -Payment is then started with `POST /api/v1/site/:slug/booking/:id/pay`. - -### 3. Reservation lifecycle after booking - -- reservation starts in `DRAFT` -- staff confirms it after license compliance checks -- vehicle status moves to `RESERVED` -- checkin moves reservation to `ACTIVE` and vehicle to `RENTED` -- checkout moves reservation to `COMPLETED`, records mileage, and generates a review token -- close marks the operational workflow complete and can send the review request email - -## Error Handling and Response Shape - -The API standardizes error handling in `http/errors/errorMiddleware.ts`. - -Special handling exists for: - -- Zod validation errors -> `400 validation_error` -- Prisma `P2025` -> `404 not_found` -- Prisma `P2002` -> `409 conflict` -- custom `AppError` subclasses -> explicit status and machine-readable code - -Successful route handlers usually return: - -```json -{ - "data": {} -} -``` - -Common deviations: - -- `/health` -- `/api/v1/docs` -- webhook receipt payloads -- file downloads such as admin invoice PDFs - -## Documentation Artifacts - -There are two documentation layers in the codebase: - -1. runtime API docs from Swagger/OpenAPI at `/docs` and `/api/v1/openapi.json` -2. this markdown document, which explains design decisions, route grouping, and request flow - -The OpenAPI file is useful for request/response contracts, but it is not yet a perfect mirror of every newer route. This document should be updated together with `app.ts`, route files, and the OpenAPI generator whenever route groups change. diff --git a/docs/project-design/command_Create_admin_account.md b/docs/project-design/command_Create_admin_account.md deleted file mode 100644 index 8b4537e2..00000000 --- a/docs/project-design/command_Create_admin_account.md +++ /dev/null @@ -1,62 +0,0 @@ - The seed script skips creation if the email already exists. The - most flexible approach is a direct API call (if you have a token) - or a one-liner script. Here are both options: - - Option 1 — via API (requires an existing admin to be logged in - first): - - curl -s -X POST http://localhost:4000/api/v1/admin/admins \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer " \ - -d '{ - "email": "newadmin@example.com", - "firstName": "First", - "lastName": "Last", - "role": "ADMIN", - "password": "yourpassword123" - }' - - Option 2 — directly in the database (no token needed, works any - time): - - DATABASE_URL="postgresql://postgres:password@localhost:5432/renta - ldrivego" node -e " - const { PrismaClient } = - require('./packages/database/generated'); - const bcrypt = require('./node_modules/bcryptjs'); - const p = new PrismaClient(); - const EMAIL = 'newadmin@example.com'; - const PASSWORD = 'yourpassword123'; - const FIRST = 'First'; - const LAST = 'Last'; - const ROLE = 'SUPER_ADMIN'; // SUPER_ADMIN | ADMIN | SUPPORT | - FINANCE | VIEWER - bcrypt.hash(PASSWORD, 12).then(hash => - p.adminUser.create({ data: { email: EMAIL, firstName: FIRST, - lastName: LAST, passwordHash: hash, role: ROLE, isActive: true } - }) - ).then(a => { console.log('Created:', a.email, a.role); - p.\$disconnect(); }) - .catch(e => { console.error('Error:', e.message); - p.\$disconnect(); }); - " - - From inside Docker: - - docker exec rentaldrivego-dev-api-1 node -e " - const { PrismaClient } = - require('/app/packages/database/generated'); - const bcrypt = require('/app/node_modules/bcryptjs'); - const p = new PrismaClient(); - bcrypt.hash('yourpassword123', 12).then(hash => - p.adminUser.create({ data: { email: 'newadmin@example.com', - firstName: 'First', lastName: 'Last', passwordHash: hash, role: - 'SUPER_ADMIN', isActive: true } }) - ).then(a => { console.log('Created:', a.email, a.role); - p.\$disconnect(); }) - .catch(e => { console.error('Error:', e.message); - p.\$disconnect(); }); - " - - Replace email, password, firstName, lastName, and role as needed. - Available roles: SUPER_ADMIN, ADMIN, SUPPORT, FINANCE, VIEWER. \ No newline at end of file diff --git a/docs/project-design/contrat_location_voiture_maroc_with_laws.md b/docs/project-design/contrat_location_voiture_maroc_with_laws.md deleted file mode 100644 index e281b2a5..00000000 --- a/docs/project-design/contrat_location_voiture_maroc_with_laws.md +++ /dev/null @@ -1,699 +0,0 @@ -# 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/docs/project-design/inventory_system_improvement_plan.md b/docs/project-design/inventory_system_improvement_plan.md new file mode 100644 index 00000000..5acc212a --- /dev/null +++ b/docs/project-design/inventory_system_improvement_plan.md @@ -0,0 +1,866 @@ +# Inventory System Improvement Plan + +## Project Context + +This document outlines a practical improvement plan for the inventory system in the `alrahma_sunday_school_api` project. + +The current backend already includes a meaningful inventory module with items, categories, movements, classroom audits, book distribution, summaries, suppliers, supplies, and purchase-order receiving. However, the system has several architectural and operational gaps that should be fixed before adding more features. + +The main goal is to make inventory data trustworthy, auditable, and operationally useful. + +--- + +## Current Inventory Capabilities + +The project currently includes the following inventory-related capabilities: + +- Inventory item CRUD +- Inventory category CRUD +- Inventory movement tracking +- Stock adjustment +- Classroom item audit +- Teacher book distribution +- Inventory summaries by type +- Global inventory summary reports +- Supplier management +- Supply categories +- Supplies and supply transactions +- Purchase order receiving +- Inventory service tests + +Relevant areas of the codebase include: + +- `app/Models/InventoryItem.php` +- `app/Models/InventoryMovement.php` +- `app/Models/InventoryCategory.php` +- `app/Services/Inventory/InventoryItemService.php` +- `app/Services/Inventory/InventoryMovementService.php` +- `app/Services/Inventory/InventoryTeacherDistributionService.php` +- `app/Services/Inventory/InventorySummaryService.php` +- `app/Http/Controllers/Api/Inventory/*` +- `app/Http/Requests/Inventory/*` +- `database/migrations/*inventory*` +- `app/Services/PurchaseOrders/PurchaseOrderReceiveService.php` + +--- + +## Main Problems + +### 1. Inventory and Supplies Are Split + +The system has both: + +- `inventory_items` / `inventory_movements` +- `supplies` / `supply_transactions` + +Purchase order receiving updates `supplies.qty_on_hand`, while inventory reports appear to rely mainly on `inventory_items` and `inventory_movements`. + +This creates two separate sources of truth. That is dangerous because purchased supplies may not appear correctly in inventory reports. + +### 2. Quantity Can Drift From Movements + +`inventory_items.quantity` is stored directly, but it is also recalculated from inventory movements. + +This can work only if all stock changes go through the movement ledger. Currently, item updates appear capable of modifying quantity directly, which can cause inventory drift. + +The required invariant is: + +```text +inventory_items.quantity = sum(inventory_movements.qty_change) +``` + +The system should enforce this rule aggressively. + +### 3. Movement History Is Too Editable + +Inventory movements can be updated or deleted. + +For a real inventory system, this weakens auditability. Historical stock movements should generally be append-only. Corrections should be handled through reversal or correction movements, not by editing or deleting previous records. + +### 4. No Low-Stock or Reorder Workflow + +The current system tracks quantity but does not appear to include: + +- Reorder point +- Reorder quantity +- Low-stock alerts +- Out-of-stock status +- Preferred supplier per item +- Automatic reorder suggestions + +This forces admins to manually notice shortages. + +### 5. Book Distribution Is Not a Full Assignment Lifecycle + +The project has teacher book distribution, but the lifecycle is incomplete. + +Missing capabilities include: + +- Return book +- Mark book as damaged +- Mark book as lost +- Exchange book +- Track who currently has which book/item +- Prevent duplicate active assignment cleanly + +The system can record that items were distributed, but it does not fully answer where those items are now. + +### 6. No Structured Location Tracking + +Inventory has categories and item types, but no strong location model. + +Missing location support includes: + +- Storage room +- Classroom +- Shelf/bin +- Teacher possession +- Office/kitchen location +- Transfer between locations + +Quantity without location is only partially useful. + +### 7. Classroom Audits Are Too Narrow + +Classroom audits support condition checks such as good, needs repair, needs replacement, or missing. However, condition tracking should be expanded to other item types, especially books and durable assets. + +### 8. Authorization Needs to Be Stronger + +Inventory mutation actions should have explicit permissions. + +Sensitive actions include: + +- Stock adjustment +- Movement correction +- Item deletion/archive +- Purchase order receiving +- Supplier management +- Book distribution +- Inventory reports +- Cost visibility + +Authentication alone is not enough for inventory operations. + +--- + +# Recommended Improvement Plan + +## Phase 1: Stabilize Inventory Data + +### 1. Make the Movement Ledger the Source of Truth + +Inventory quantity should only change through movements. + +Rules: + +- `inventory_items.quantity` should be treated as a cached value. +- Normal item updates should not allow direct quantity changes. +- Initial quantity may be set during item creation. +- Future changes must go through movement creation. +- A reconciliation command should compare stored quantity against movement totals. + +Suggested command: + +```bash +php artisan inventory:reconcile --school-year=2026-2027 +``` + +Acceptance criteria: + +- Updating item metadata cannot change quantity. +- Every quantity change creates an inventory movement. +- Reconciliation can detect and report quantity drift. + +--- + +### 2. Replace Movement Editing and Deleting With Corrections + +Inventory movements should become append-only. + +Instead of updating or deleting a movement, create a correction movement. + +Suggested new fields: + +```php +voided_at +voided_by +void_reason +corrects_movement_id +source_type +source_id +``` + +Example correction: + +```text +Original movement: out -5 +Correction movement: adjust +5 +``` + +Acceptance criteria: + +- Normal users cannot hard-delete movements. +- Corrections preserve original records. +- Reports can exclude voided movements by default. +- Audit mode can show full movement history. + +--- + +### 3. Improve Validation + +Inventory request validation should be stricter. + +Recommended validation rules: + +- `type` must be one of the allowed inventory types. +- `condition` must be one of the allowed conditions. +- `category_id` must exist. +- Initial quantity must be an integer greater than or equal to zero. +- `semester` must use allowed values. +- `school_year` should match a valid format or existing school year record. +- `grade_min` must be less than or equal to `grade_max`. + +Acceptance criteria: + +- Invalid inventory types fail validation. +- Invalid categories fail validation. +- Negative quantities are rejected. +- Invalid grade ranges are rejected. + +--- + +## Phase 2: Unify Procurement and Inventory + +### 4. Merge or Link Supplies With Inventory Items + +The system must decide whether `supplies` are separate from inventory or part of inventory. + +Recommended approach: + +- Treat supplies as inventory items. +- Link purchase order items to `inventory_items`. +- Receiving a purchase order should create an inventory movement. + +Target flow: + +```text +Purchase Order Received + ↓ +Inventory Movement Created + ↓ +Inventory Item Quantity Updated + ↓ +Inventory Summary Reflects Purchase +``` + +Suggested receiving movement: + +```php +InventoryMovementService::recordMovement( + itemId: $inventoryItemId, + qtyChange: $toReceive, + type: 'in', + reason: 'Purchase order received', + note: 'PO ' . $purchaseOrder->po_number, + performedBy: $userId +); +``` + +Acceptance criteria: + +- Receiving a purchase order increases the correct inventory item quantity. +- Inventory reports include purchase order receipts. +- `supplies` and `inventory_items` no longer represent disconnected stock worlds. + +--- + +### 5. Add Supplier and Reorder Fields to Inventory Items + +Suggested fields: + +```php +preferred_supplier_id +last_purchase_price +average_unit_cost +reorder_point +reorder_quantity +lead_time_days +``` + +These fields allow the system to answer: + +- What needs to be reordered? +- From which supplier? +- How much should be ordered? +- How much will it likely cost? +- How long will it take to arrive? + +Acceptance criteria: + +- Items can have preferred suppliers. +- Items can define low-stock thresholds. +- Reorder reports can use these fields. + +--- + +## Phase 3: Add Missing Operational Features + +### 6. Add Low-Stock and Reorder Workflow + +New endpoints: + +```http +GET /api/v1/inventory/low-stock +GET /api/v1/inventory/reorder-suggestions +POST /api/v1/inventory/items/{id}/reorder-request +``` + +Basic reorder logic: + +```text +if current_quantity <= reorder_point: + suggest reorder_quantity +``` + +Acceptance criteria: + +- Dashboard shows low-stock items. +- Admins can view reorder suggestions. +- Reorder requests can be generated from low-stock items. + +--- + +### 7. Add Location Tracking + +Suggested tables: + +```php +inventory_locations +inventory_item_locations +``` + +Example `inventory_locations` fields: + +```php +id +name +type +class_section_id +responsible_user_id +notes +``` + +Example `inventory_item_locations` fields: + +```php +id +item_id +location_id +quantity +condition +updated_at +``` + +Suggested movement fields: + +```php +from_location_id +to_location_id +``` + +Supported movement examples: + +- Receive into storage +- Transfer from storage to classroom +- Transfer from classroom to teacher +- Return from teacher to storage +- Mark item missing from classroom + +Acceptance criteria: + +- Total quantity across locations equals item quantity. +- Admins can see where each item is located. +- Transfers create inventory movements. + +--- + +### 8. Add Inventory Assignment Lifecycle + +Create a proper assignment table: + +```php +inventory_assignments +``` + +Suggested fields: + +```php +id +item_id +student_id +teacher_id +class_section_id +quantity +status +assigned_at +returned_at +assigned_by +returned_by +condition_out +condition_in +notes +school_year +semester +``` + +Suggested statuses: + +```text +assigned +returned +lost +damaged +replaced +``` + +New endpoints: + +```http +POST /api/v1/inventory/assignments +POST /api/v1/inventory/assignments/{id}/return +POST /api/v1/inventory/assignments/{id}/mark-lost +POST /api/v1/inventory/assignments/{id}/mark-damaged +GET /api/v1/inventory/assignments +``` + +Assignment flow: + +1. Create assignment. +2. Create inventory movement out. +3. Prevent duplicate active assignment for the same item and student. + +Return flow: + +1. Mark assignment as returned. +2. Create inventory movement in. +3. Capture return condition. + +Acceptance criteria: + +- Admins can see who currently has each assigned item. +- Returned books increase available stock. +- Lost and damaged items are tracked. + +--- + +### 9. Add Barcode and QR Support + +Add fields to inventory items: + +```php +barcode +qr_code +asset_tag +``` + +For individually tracked assets, add: + +```php +inventory_item_units +``` + +Suggested fields: + +```php +id +item_id +asset_tag +barcode +serial_number +status +condition +location_id +``` + +Suggested statuses: + +```text +available +assigned +lost +damaged +retired +``` + +Use individual units for books and durable assets. Use aggregate quantities for consumables. + +Acceptance criteria: + +- Durable assets can be scanned individually. +- Consumables remain quantity-based. +- The system does not force every small consumable item to be tracked individually. + +--- + +## Phase 4: Reporting and Audit + +### 10. Add Inventory Dashboard + +Dashboard should include: + +- Total items by type +- Low-stock count +- Out-of-stock count +- Items needing repair +- Missing/lost items +- Books assigned by class +- Recent movements +- Pending reorder requests +- Reconciliation variance count + +New endpoint: + +```http +GET /api/v1/inventory/dashboard +``` + +--- + +### 11. Add Inventory Reports + +Recommended report endpoints: + +```http +GET /api/v1/inventory/reports/stock-on-hand +GET /api/v1/inventory/reports/movement-ledger +GET /api/v1/inventory/reports/assigned-items +GET /api/v1/inventory/reports/missing-damaged +GET /api/v1/inventory/reports/reorder +GET /api/v1/inventory/reports/year-end +``` + +Useful filters: + +- School year +- Semester +- Type +- Category +- Location +- Class section +- Teacher +- Student +- Movement type +- Date range + +Export options: + +```http +?format=csv +?format=pdf +``` + +Acceptance criteria: + +- Reports can be filtered by date and category. +- Admins can export CSV. +- PDF export can be added after CSV is stable. + +--- + +### 12. Add Audit Logs for Sensitive Actions + +Sensitive actions should be logged. + +Actions to log: + +- Stock adjustment +- Movement correction +- Item deletion/archive +- Purchase order receiving +- Assignment return +- Assignment lost/damaged update +- Category deletion +- Supplier changes + +Suggested audit fields: + +```php +actor_id +action +entity_type +entity_id +before_json +after_json +ip_address +user_agent +created_at +``` + +Acceptance criteria: + +- Admins can see who changed stock and why. +- No silent stock-changing operation exists. +- Deleted inventory records are archived when possible. + +--- + +## Phase 5: Permissions and Approval Workflow + +### 13. Add Inventory Permissions + +Suggested permissions: + +```text +inventory.view +inventory.item.create +inventory.item.update +inventory.item.archive +inventory.stock.adjust +inventory.stock.correct +inventory.stock.audit +inventory.book.distribute +inventory.assignment.return +inventory.supplier.manage +inventory.purchase.receive +inventory.report.view +inventory.cost.view +``` + +Acceptance criteria: + +- Inventory actions use explicit permissions. +- Stock adjustment is not available to every authenticated user. +- Cost visibility can be restricted separately. + +--- + +### 14. Add Approval Workflow for Large Adjustments + +Create: + +```php +inventory_adjustment_requests +``` + +Suggested fields: + +```php +id +item_id +requested_qty_change +reason +status +requested_by +approved_by +approved_at +rejected_reason +``` + +Suggested statuses: + +```text +pending +approved +rejected +``` + +Rules: + +- Small adjustments can be immediate for users with permission. +- Large adjustments require approval. +- Approval creates the real inventory movement. + +Acceptance criteria: + +- Large adjustments do not change stock immediately. +- Approval and rejection are logged. +- Pending adjustments appear in dashboard. + +--- + +## Phase 6: Frontend and Admin Screens + +The backend has inventory capability, but the project should add or improve admin screens for inventory operations. + +Recommended screens: + +1. Inventory dashboard +2. Inventory item list with filters +3. Inventory item detail page +4. Movement ledger +5. Add/edit item form +6. Stock adjustment modal +7. Assignment and return screen +8. Teacher book distribution screen +9. Low-stock and reorder screen +10. Supplier management screen +11. Purchase receiving screen +12. Reports and exports screen +13. Audit and reconciliation screen + +UX rules: + +- Show on-hand, assigned, available, damaged, missing, and reorder status. +- Every stock-changing action must require a reason. +- Dangerous actions should preview the resulting quantity. +- Item detail pages should show movement history. +- Low-stock items should be visible without manual searching. + +--- + +# Recommended Implementation Order + +## Sprint 1: Data Integrity + +- Remove direct quantity updates from item update logic. +- Strengthen request validation. +- Add reconciliation command. +- Add tests for quantity/movement consistency. +- Restrict hard deletion of movements. + +## Sprint 2: Movement and Audit Cleanup + +- Add movement correction fields. +- Add void/correction flow. +- Add audit logs. +- Add explicit permission checks for inventory mutation. + +## Sprint 3: Procurement Integration + +- Link purchase order receiving to inventory movements. +- Decide how `supplies` maps to `inventory_items`. +- Add supplier and reorder fields. +- Add reorder reports. + +## Sprint 4: Assignments and Returns + +- Add `inventory_assignments` table. +- Refactor book distribution to create assignments. +- Add return/lost/damaged flows. +- Add assignment reports. + +## Sprint 5: Locations + +- Add location tables. +- Add transfer movements. +- Add stock-by-location report. +- Add location filters to inventory screens and reports. + +## Sprint 6: Dashboard and Reports + +- Add inventory dashboard endpoint. +- Add low-stock endpoint. +- Add stock-on-hand report. +- Add movement ledger report. +- Add missing/damaged report. +- Add assignment report. +- Add CSV export. + +--- + +# Missing Feature Backlog + +| Feature | Priority | Reason | +|---|---:|---| +| Ledger-only stock changes | Critical | Prevents stock corruption | +| Reconciliation command | Critical | Detects existing quantity drift | +| Movement correction instead of delete | Critical | Preserves audit trail | +| Strong permissions | Critical | Prevents unauthorized stock mutation | +| Procurement integration | High | Purchase receiving must affect inventory | +| Low-stock alerts | High | Prevents shortages | +| Reorder workflow | High | Turns shortages into action | +| Item assignment and return | High | Required for books and student materials | +| Location tracking | High | Shows where inventory actually is | +| Damaged/lost lifecycle | High | Supports accountability | +| Barcode/QR support | Medium | Improves audits and distribution | +| Inventory dashboard | Medium | Makes inventory easier to operate | +| CSV/PDF reports | Medium | Supports administration and accountability | +| Approval workflow | Medium | Controls risky stock changes | +| Individual item units | Medium | Useful for books and durable assets | + +--- + +# First Code Changes to Make + +## 1. Update `InventoryItemService::update()` + +Remove quantity from normal editable fields. + +Quantity should only change through: + +- Item creation +- Stock adjustment +- Inventory movement creation +- Purchase order receiving +- Assignment return +- Audit correction + +## 2. Refactor `InventoryMovementService` + +Move toward append-only ledger behavior. + +Replace or deprecate: + +```php +update() +delete() +bulkDelete() +``` + +Add: + +```php +recordMovement() +voidMovement() +correctMovement() +``` + +## 3. Update `PurchaseOrderReceiveService` + +Receiving a purchase order should create an inventory movement, not only a supply transaction. + +Recommended target behavior: + +```php +InventoryMovementService::recordMovement( + itemId: $inventoryItemId, + qtyChange: $toReceive, + type: 'in', + reason: 'Purchase order received', + note: 'PO ' . $purchaseOrder->po_number, + performedBy: $userId +); +``` + +## 4. Add New Migrations + +Recommended migrations: + +```text +add_inventory_item_reorder_fields +add_inventory_movement_audit_fields +create_inventory_locations +create_inventory_item_locations +create_inventory_assignments +create_inventory_adjustment_requests +create_inventory_item_units +``` + +--- + +# Final Recommendation + +Do not start by adding more endpoints. The project already has enough endpoint-shaped objects wandering around. + +The first priority is to make inventory data reliable. + +Recommended order: + +1. Protect the stock invariant. +2. Stop editable/deletable movement history. +3. Unify procurement with inventory movements. +4. Add assignment and return tracking. +5. Add locations and low-stock workflows. +6. Build dashboard and reporting on top. + +This turns the inventory system from a set of stock-related APIs into an actual operational system that admins can trust. diff --git a/docs/project-design/review_management_process.md b/docs/project-design/review_management_process.md deleted file mode 100644 index 437da64d..00000000 --- a/docs/project-design/review_management_process.md +++ /dev/null @@ -1,694 +0,0 @@ -# Review Management Process After Rental Completion - -## 1. Purpose - -The review management process helps the rental company collect customer feedback after each completed rental, improve service quality, recover unhappy customers, and increase positive public reviews. - -The goal is not only to collect high ratings. The goal is to identify service problems, fix weak operations, and encourage satisfied customers to share their experience publicly. - ---- - -## 2. When the Review Process Starts - -The review process starts only after the rental is fully closed. - -A rental is considered closed when: - -- The vehicle has been returned -- The return inspection is completed -- The final invoice has been sent -- The deposit has been released or adjusted -- Any damage, billing, or dispute case is closed or clearly documented -- The customer has received the final receipt - -Do not send a review request before the final billing is clear. Customers should not be asked for a review while they are still waiting for a deposit update, refund, or charge explanation. - ---- - -## 3. Main Review Process Flow - -### Step 1: Close the Rental - -Before sending a review request, staff must confirm: - -- Vehicle returned -- Final charges completed -- Receipt sent -- Deposit status updated -- Any customer issue notes added -- Vehicle status updated - -Once complete, the booking is marked as: - -**Rental Closed** - -This status triggers the review process. - ---- - -### Step 2: Check Review Eligibility - -Before sending a review request, the system or staff must confirm that the customer is eligible. - -A review request should be sent only if: - -- Rental status is **Closed** -- Final invoice has been sent -- No open dispute exists -- No active complaint exists -- No major damage case is open -- Customer has not opted out of messages -- Customer has a valid phone number or email address - -A review request should be paused if: - -- Customer has an open damage dispute -- Customer has an unpaid balance -- Customer has an active complaint -- Customer was charged for major damage -- Rental is under insurance investigation -- Customer requested no further messages - ---- - -### Step 3: Send Review Request - -Send the review request within: - -**2 to 6 hours after rental closure** - -This timing is soon enough that the customer remembers the experience, but not so immediate that it feels automated and careless. - -The message should include: - -- Customer name -- Thank-you message -- Short review request -- Review link -- Support contact in case there was a problem - ---- - -## 4. Review Channels - -The company should define where customer reviews are collected. - -Recommended review channels: - -- Google Business Profile -- Trustpilot -- Facebook -- Yelp, if relevant -- Internal company review form -- App store review, if using a mobile app - -Recommended simple setup: - -- Satisfied customers are sent to a public review link -- Unsatisfied customers are routed to internal support first - -The company should not fake reviews, pressure customers, or hide legitimate negative feedback. The process should encourage honest reviews and give unhappy customers a clear path to resolution. - ---- - -## 5. Customer Rating Filter - -Before sending customers to a public review page, the company may use a short internal satisfaction question. - -Example question: - -**How was your rental experience?** - -Options: - -- Excellent -- Good -- Okay -- Bad -- Very bad - -Routing rules: - -| Customer Response | Action | -|---|---| -| Excellent | Send public review link | -| Good | Send public review link | -| Okay | Ask for internal feedback | -| Bad | Create support case | -| Very bad | Create urgent support case | - -This helps the company recover unhappy customers before the issue becomes a public complaint. - ---- - -## 6. Review Request Timing - -| Time After Rental Closure | Action | -|---|---| -| 2 to 6 hours | Send first review request | -| 48 hours | Send reminder if no response | -| 7 days | Send final reminder | -| After 7 days | Stop messaging | - -Maximum number of review messages: - -**3 total** - -Do not keep messaging customers after the final reminder. - ---- - -## 7. Review Message Rules - -A good review request should be: - -- Short -- Polite -- Personalized -- Easy to act on -- Sent at the right time -- Focused on one clear link - -Avoid review messages that are: - -- Too long -- Begging for 5 stars -- Sent too many times -- Sent before billing is closed -- Sent during a dispute -- Written like spam - -Do not say: - -> Please give us 5 stars. - -Better wording: - -> Your feedback helps us improve and helps other customers choose us. - ---- - -## 8. Review Request Templates - -### SMS Template - -Hi {{customer_name}}, thank you for renting with {{company_name}}. We hope everything went smoothly. Please take 30 seconds to review your experience: {{review_link}} - -If anything was not right, reply here and our team will help. - ---- - -### Email Template - -**Subject:** How was your rental experience? - -Hi {{customer_name}}, - -Thank you for renting with {{company_name}}. - -We hope your experience was smooth from pickup to return. Please take a moment to review us here: - -{{review_link}} - -If something was not right, reply to this email and our team will review it. - -Thank you, -{{company_name}} - ---- - -### Reminder Template - -Hi {{customer_name}}, just a quick reminder. Your feedback helps us improve our rental service. You can review your experience here: {{review_link}} - ---- - -## 9. Handling Positive Reviews - -When a customer leaves a positive review, staff should: - -- Thank the customer publicly -- Keep the reply short -- Mention the customer’s experience if appropriate -- Avoid copying the same reply every time -- Tag the booking as positive feedback - -Example public reply: - -> Thank you for your review. We are glad your rental experience went smoothly and appreciate you choosing us. - -If the review mentions a staff member, the manager should be notified. - -Positive reviews should be tracked by: - -- Branch -- Vehicle -- Staff member -- Booking channel -- Rental type - ---- - -## 10. Handling Negative Reviews - -Negative reviews must be handled quickly and calmly. - -Target response time: - -**Within 24 hours** - -Internal steps: - -1. Create a complaint case. -2. Link the complaint to the rental agreement. -3. Review pickup photos. -4. Review return photos. -5. Review invoice and charges. -6. Review staff notes. -7. Contact the customer privately. -8. Offer a fair solution if the company made a mistake. -9. Reply publicly with a professional response. -10. Record the final outcome. - -Public reply rules: - -- Stay calm -- Acknowledge the concern -- Do not argue -- Do not expose customer details -- Invite the customer to private support -- Show that the company takes feedback seriously - -Example public reply: - -> We are sorry to hear your experience did not meet expectations. We take this seriously and would like to review the rental details with you. Please contact our support team at {{contact_info}} so we can investigate and help resolve this. - ---- - -## 11. Complaint Severity Levels - -### Level 1: Minor Issue - -Examples: - -- Long wait time -- Car not perfectly clean -- Staff communication issue -- Confusing instructions - -Action: - -- Apologize -- Record feedback -- Offer small goodwill if appropriate -- Inform branch manager - ---- - -### Level 2: Serious Issue - -Examples: - -- Incorrect billing -- Deposit delay -- Vehicle mechanical problem -- Wrong car category -- Poor staff behavior - -Action: - -- Manager review required -- Contact customer within 24 hours -- Investigate records -- Offer correction or compensation if valid - ---- - -### Level 3: Critical Issue - -Examples: - -- Safety issue -- Accident handling failure -- Major billing dispute -- Legal threat -- Fraud accusation -- Discrimination complaint - -Action: - -- Immediate manager escalation -- Preserve all records -- Stop automated review reminders -- Senior manager or owner handles case -- Legal or insurance review if needed - ---- - -## 12. Internal Feedback Form - -The company may use an internal form before sending customers to public review platforms. - -Recommended questions: - -1. How was the booking process? -2. How was the pickup experience? -3. Was the car clean? -4. Was the car in good condition? -5. Was the return process easy? -6. Was pricing clear? -7. Would you rent from us again? -8. What should we improve? - -Recommended score scale: - -| Score | Meaning | -|---|---| -| 1 | Very bad | -| 2 | Bad | -| 3 | Okay | -| 4 | Good | -| 5 | Excellent | - ---- - -## 13. Review Data to Track - -The company should track: - -- Number of review requests sent -- Number of reviews received -- Review response rate -- Average review score -- Number of positive reviews -- Number of negative reviews -- Main complaint categories -- Branch performance -- Staff performance -- Vehicle-related complaints -- Billing-related complaints -- Deposit-related complaints -- Pickup delay complaints -- Return delay complaints - -A monthly review report should show: - -- Top 5 problems -- Top 5 positive mentions -- Best-performing branch -- Worst-performing branch -- Repeat complaint patterns -- Corrective actions taken - ---- - -## 14. Feedback Categories - -Every review or complaint should be tagged under one main category: - -- Booking -- Pickup -- Vehicle cleanliness -- Vehicle condition -- Staff service -- Pricing -- Deposit -- Insurance -- Damage claim -- Return process -- Communication -- Roadside assistance -- Billing -- Other - -This makes patterns easier to identify and fix. - -Examples: - -- Many pickup complaints = staffing or preparation issue -- Many deposit complaints = unclear deposit communication -- Many cleanliness complaints = weak cleaning control -- Many billing complaints = unclear pricing or invoice process - ---- - -## 15. Staff Responsibilities - -### Rental Agent - -Responsible for: - -- Closing rental properly -- Confirming customer contact details -- Adding notes about any issue -- Marking rental ready for review request - ---- - -### Customer Support - -Responsible for: - -- Monitoring reviews -- Replying to simple feedback -- Creating complaint cases -- Contacting unhappy customers - ---- - -### Branch Manager - -Responsible for: - -- Reviewing negative feedback -- Approving compensation -- Coaching staff -- Fixing branch-level problems - ---- - -### Owner or Senior Manager - -Responsible for: - -- Reviewing monthly review report -- Handling serious complaints -- Updating company policy -- Tracking reputation score - ---- - -## 16. Review Response Standards - -### Positive Review Response - -Target response time: - -**Within 3 business days** - -Tone: - -- Thankful -- Short -- Professional - ---- - -### Negative Review Response - -Target response time: - -**Within 24 hours** - -Tone: - -- Calm -- Responsible -- Not defensive -- No private customer details - ---- - -### False or Abusive Review - -If a review appears fake, abusive, or unrelated: - -1. Take a screenshot. -2. Check if the reviewer matches a customer. -3. Report the review to the platform if it violates rules. -4. Reply professionally if needed. -5. Do not insult the reviewer. - -Example reply: - -> We cannot locate a rental under this name, but we take feedback seriously. Please contact us at {{contact_info}} so we can review this properly. - ---- - -## 17. Compensation Rules - -Compensation should be controlled and approved based on severity. - -Possible compensation options: - -- Apology only -- Discount on next rental -- Partial refund -- Cleaning fee refund -- Upgrade on next rental -- Delivery fee refund -- Deposit correction -- Full refund, only in serious cases - -Manager approval should be required for: - -- Refunds above a set amount -- Free rental days -- Damage fee removal -- Public complaint compensation -- Legal threat situations - -Compensation should be fair, documented, and linked to the actual issue. - ---- - -## 18. Automation Rules - -The review system should be automated but controlled. - -### Automatic Review Request Trigger - -Send a review request when: - -- Rental status = Closed -- Final invoice sent -- No open dispute -- No active complaint -- Customer has valid phone or email -- Customer has not opted out - ---- - -### Automatic Reminder Trigger - -Send a reminder when: - -- No review received after 48 hours -- No complaint opened -- Customer has not opted out - ---- - -### Stop Automation When - -Stop review automation when: - -- Customer replies with complaint -- Review has already been submitted -- Dispute is opened -- Customer opts out -- Staff manually pauses request - ---- - -## 19. Simple Review Workflows - -### Normal Happy Customer - -1. Rental closed -2. Review request sent -3. Customer leaves review -4. Company replies -5. Review recorded -6. No further action - ---- - -### No Response - -1. Rental closed -2. First request sent -3. No response after 48 hours -4. Reminder sent -5. No response after 7 days -6. Final reminder sent -7. Stop - ---- - -### Unhappy Customer - -1. Rental closed -2. Customer gives low internal rating or bad public review -3. Complaint case created -4. Manager reviews rental -5. Customer contacted -6. Issue resolved or documented -7. Public reply posted -8. Root cause logged - ---- - -### Dispute Customer - -1. Rental closed with dispute -2. Review request paused -3. Dispute handled first -4. Manager decides whether review request should be sent after resolution - ---- - -## 20. Key Rules - -The review process should follow these rules: - -- Never send a review request before the final invoice. -- Never send a review request during an open dispute. -- Send the first request within 2 to 6 hours after closure. -- Send no more than 3 total review messages. -- Respond to negative reviews within 24 hours. -- Track every complaint by category. -- Use feedback to fix operations. -- Do not argue publicly. -- Do not ask directly for fake or forced 5-star reviews. -- Stop messaging customers who opt out. - ---- - -## 21. Minimum Setup for a Small Rental Company - -A small rental company can manage the process with: - -- Rental management system -- Google review link -- SMS or email tool -- Complaint spreadsheet or CRM -- Monthly review report -- Staff member assigned to monitor reviews daily - -Minimum tracking fields: - -| Field | Example | -|---|---| -| Booking ID | R-1029 | -| Customer Name | John Smith | -| Return Date | 2026-05-25 | -| Review Request Sent | Yes | -| Review Score | 5 | -| Complaint? | No | -| Category | Pickup | -| Staff Owner | Sarah | -| Status | Closed | - ---- - -## 22. Best Operating Rule - -The review process should be simple: - -**Ask every eligible customer. -Pause disputed customers. -Respond fast. -Fix repeated problems.** - -A review system that only collects praise is vanity. A review system that identifies problems and fixes them is management. diff --git a/docs/project-design/schema.md b/docs/project-design/schema.md deleted file mode 100644 index cc69976a..00000000 --- a/docs/project-design/schema.md +++ /dev/null @@ -1,778 +0,0 @@ -# Database Schema and Table Relationships - -This document explains the current Prisma schema, how the data is partitioned, and how the main tables relate to each other. - -Source of truth: - -- `packages/database/prisma/schema.prisma` - -## Design Principles - -The schema is built around a multi-tenant SaaS model with three distinct concerns: - -1. company operations for running a rental business -2. renter-facing booking and review flows -3. platform-level SaaS billing and administration - -Important design choices: - -- `Company` is the tenant root for nearly every business table. -- operational data and platform billing data are kept separate. -- reservation-time snapshots are stored explicitly so later changes to policies or prices do not rewrite history. -- some user concepts intentionally exist twice: - - `Renter` is a cross-company identity - - `Customer` is a company-owned CRM record -- status changes are modeled with enums rather than free-text fields where the workflow matters. - -## High-Level Domain Map - -```mermaid -erDiagram - Company ||--o| Subscription : has - Company ||--o| BrandSettings : has - Company ||--o| ContractSettings : has - Company ||--o| AccountingSettings : has - Company ||--o{ Employee : employs - Company ||--o{ Vehicle : owns - Company ||--o{ Offer : publishes - Company ||--o{ Customer : manages - Company ||--o{ Reservation : receives - Company ||--o{ RentalPayment : collects - Company ||--o{ InsurancePolicy : defines - Company ||--o{ PricingRule : defines - Company ||--o{ BillingAccount : bills - Company ||--o{ BillingInvoice : invoices - Company ||--o{ SubscriptionInvoice : tracks - Company ||--o{ Notification : receives - Company ||--o{ Complaint : tracks - - Vehicle ||--o{ Reservation : booked_in - Vehicle ||--o{ MaintenanceLog : has - Vehicle ||--o{ VehicleCalendarBlock : blocked_by - Offer ||--o{ OfferVehicle : applies_to - Vehicle ||--o{ OfferVehicle : offered_in - - Customer ||--o{ Reservation : books - Renter ||--o{ Reservation : owns - Reservation ||--o{ RentalPayment : paid_by - Reservation ||--o{ ReservationInsurance : includes - Reservation ||--o{ AdditionalDriver : adds - Reservation ||--o{ DamageInspection : inspected_by - Reservation ||--o{ DamageReport : reported_by - Reservation ||--o{ ReservationPhoto : photographed_by - Reservation ||--o| Review : reviewed_as - Reservation ||--o{ Complaint : disputed_in - - InsurancePolicy ||--o{ ReservationInsurance : snapshotted_into - Renter ||--o{ Review : writes - Review ||--o{ Complaint : escalates_to - - AdminUser ||--o{ AdminPermission : has - AdminUser ||--o{ AuditLog : writes -``` - -## Core Tenant Root - -### `Company` - -`Company` is the anchor row for tenant isolation. - -Key fields: - -- identity: `id`, `name`, `slug`, `email`, `phone` -- public/integration: `apiKey` -- SaaS state: `status`, `subscriptionPaymentRef` -- address: `address` JSON - -Key relations: - -- 1:1 with `Subscription` -- 1:1 with `BrandSettings` -- 1:1 with `ContractSettings` -- 1:1 with `AccountingSettings` -- 1:N with `Employee`, `Vehicle`, `Offer`, `Customer`, `Reservation`, `RentalPayment` -- 1:N with billing tables such as `BillingAccount`, `BillingInvoice`, `SubscriptionInvoice` -- 1:N with `InsurancePolicy`, `PricingRule`, `Notification`, `Complaint` - -Important uniqueness: - -- `slug` -- `email` -- `apiKey` - -## SaaS Subscription and Billing - -There are two billing layers in the schema: - -1. legacy provider-oriented subscription records -2. richer billing-account and billing-invoice records for platform finance operations - -### `Subscription` - -One row per company. - -Purpose: - -- current plan -- billing period -- lifecycle state -- trial dates -- renewal windows -- suspension and cancellation timing - -Relations: - -- belongs to one `Company` -- has many `SubscriptionInvoice` -- has many `SubscriptionEvent` -- can be referenced by many `BillingInvoice` -- can be referenced by many `BillingEvent` - -### `SubscriptionEvent` - -Append-only event log for the subscription lifecycle. - -Examples of what it represents: - -- trial started -- checkout created -- payment received -- plan changed -- grace period extended -- subscription suspended - -### `SubscriptionInvoice` - -Legacy or provider-facing subscription payment records. - -Purpose: - -- capture AmanPay or PayPal payment references -- track amount, currency, due date, paid date, and failure/void state -- optionally bridge to the richer `BillingInvoice` model via `billingInvoiceId` - -### `PaymentAttempt` - -Legacy payment-attempt rows tied to `SubscriptionInvoice`. - -This is narrower than the newer billing payment tables and exists for provider-level retry/failure tracking. - -### `BillingAccount` - -The primary finance entity for a company at the platform level. - -Purpose: - -- billing identity and address -- invoice terms -- default currency -- tax flags -- default payment method -- dunning pause state -- external provider customer ID - -Relations: - -- belongs to one `Company` -- has many `BillingPaymentMethod` -- has many `BillingInvoice` -- has many `BillingPaymentIntent` -- has many `BillingPaymentAttempt` -- has many `BillingCreditBalance`, `BillingCreditLedgerEntry`, `BillingCreditNote`, `BillingRefund`, `BillingEvent` - -### `BillingPaymentMethod` - -Stored billing method metadata for the company account. - -Examples: - -- card brand/last4/expiration -- ACH/bank/manual invoice style billing methods - -One billing account can have many payment methods; one may be selected as default. - -### `BillingInvoice` - -The main platform invoice table. - -Purpose: - -- invoice numbering and sequencing -- totals and tax math -- status transitions -- billing snapshot fields such as billing name and email -- finance/admin metadata - -Relations: - -- belongs to one `BillingAccount` -- belongs to one `Company` -- optionally belongs to one `Subscription` -- optionally has a linked legacy `SubscriptionInvoice` -- has many `BillingInvoiceLineItem` -- has many `BillingPaymentIntent` -- has many `BillingPaymentAttempt` -- has many `BillingTaxRecord` -- has many `BillingCreditNote` -- has many `BillingRefund` -- has many `BillingEvent` - -### Supporting billing tables - -- `BillingInvoiceLineItem`: immutable invoice lines with type, quantity, unit amount, and optional service period. -- `BillingPaymentIntent`: payment-intent level state for a billing invoice. -- `BillingPaymentAttempt`: actual collection attempts against an invoice. -- `BillingCreditBalance`: running credit balance per billing account and currency. -- `BillingCreditLedgerEntry`: credit ledger movement history. -- `BillingCreditNote`: credit documents issued against an invoice. -- `BillingRefund`: refund records for billing invoices. -- `BillingTaxRecord`: tax breakdown rows for an invoice. -- `BillingEvent`: append-only finance/subscription/company event log. - -## Branding and Company Configuration - -### `BrandSettings` - -One-to-one with `Company`. - -Purpose: - -- public display name, tagline, logo, favicon, hero image -- theme colors -- subdomain and custom domain -- public contact and social metadata -- default locale and currency -- company-owned payment provider settings for renter payments -- marketplace visibility and rating -- homepage/menu JSON configuration - -Important uniqueness: - -- `companyId` -- `subdomain` -- `customDomain` - -### `ContractSettings` - -One-to-one with `Company`. - -Purpose: - -- rental contract legal copy -- invoice/footer text -- fuel and late-fee policy -- tax settings -- numbering prefixes and running sequences -- additional-driver charging policy - -This table is used when generating operational rental documents. - -### `AccountingSettings` - -One-to-one with `Company`. - -Purpose: - -- reporting period -- fiscal-year start month -- accounting contact -- automatic report settings -- preferred export format - -### `PricingRule` - -Company-owned pricing rule definitions applied to reservations. - -Purpose: - -- define surcharge or discount logic -- express the rule type, condition, and adjustment - -These rules are copied into reservation snapshots through `Reservation.pricingRulesApplied` and `Reservation.pricingRulesTotal`. - -### `InsurancePolicy` - -Company-defined upsell or mandatory protection products. - -Purpose: - -- define policy name and type -- define flat, percent, or per-day charges -- mark required or optional coverage - -These are also snapshotted into reservations. - -## Staff, Fleet, and Commercial Catalog - -### `Employee` - -Company staff records. - -Purpose: - -- operational user identity -- role assignment -- preferred language -- password-reset support -- activation state - -Implementation note: - -- `clerkUserId` still exists as a required unique field in the schema, but Clerk is no longer an active auth dependency in this project. -- the field is currently populated with local/generated identifiers so existing schema constraints remain satisfied. - -Relations: - -- belongs to one `Company` -- has many `Notification` -- has many `NotificationPreference` - -Important uniqueness: - -- `clerkUserId` - -### `Vehicle` - -The core fleet entity. - -Purpose: - -- static vehicle information -- public listing content -- daily rate and status -- pickup/dropoff location rules - -Relations: - -- belongs to one `Company` -- has many `Reservation` -- has many `MaintenanceLog` -- has many `VehicleCalendarBlock` -- participates in many-to-many offers through `OfferVehicle` - -Soft deletion is implemented operationally by setting: - -- `status = OUT_OF_SERVICE` -- `isPublished = false` - -### `MaintenanceLog` - -One-to-many from `Vehicle`. - -Purpose: - -- maintenance type and description -- cost, mileage, performed date -- next due date or mileage - -### `VehicleCalendarBlock` - -One-to-many from `Vehicle`. - -Purpose: - -- manual or maintenance blocks on the reservation calendar -- date-range unavailability independent of reservations - -### `Offer` - -Company-defined promotional offers. - -Purpose: - -- percentage/fixed/free-day/special-rate discount logic -- validity window -- promo code -- public and featured visibility -- redemption caps - -Relations: - -- belongs to one `Company` -- has many `Reservation` -- many-to-many with `Vehicle` through `OfferVehicle` - -### `OfferVehicle` - -Join table between `Offer` and `Vehicle`. - -Composite primary key: - -- `(offerId, vehicleId)` - -## Renter Identity, Company CRM, and Reservation Aggregate - -The schema intentionally separates global renter identity from company-local customer records. - -### `Renter` - -Cross-company end-user identity. - -Purpose: - -- login identity -- app preferences and verification state -- push token -- reservation and review linkage across companies - -Relations: - -- has many `Reservation` -- has many `Review` -- has many `Notification` -- has many `NotificationPreference` -- has many saved companies through `RenterSavedCompany` - -### `RenterSavedCompany` - -Join table that stores renter bookmarks. - -Composite primary key: - -- `(renterId, companyId)` - -### `Customer` - -Company-owned CRM record for a person renting from that company. - -Purpose: - -- renter contact and profile data as understood by that company -- notes and flag state -- driver-license metadata -- approval and compliance state -- company-specific reservation history - -Relations: - -- belongs to one `Company` -- has many `Reservation` -- has many `Complaint` - -Important uniqueness: - -- `(companyId, email)` - -### `Reservation` - -This is the central transactional aggregate for rental operations. - -Core relations: - -- belongs to one `Company` -- belongs to one `Vehicle` -- belongs to one `Customer` -- optionally belongs to one `Renter` -- optionally belongs to one `Offer` - -Core business fields: - -- booking source -- date range -- pickup/return locations -- pricing fields: `dailyRate`, `discountAmount`, `totalDays`, `totalAmount`, `depositAmount` -- payment summary fields: `paymentStatus`, `paidAmount` -- lifecycle fields: `status`, `checkedInAt`, `checkedOutAt` -- cancellation fields -- contract and invoice numbering -- review token and review reminder timestamps -- extras and pricing-rule snapshots - -The reservation is the parent of most rental workflow records. - -### Reservation child tables - -- `RentalPayment`: individual payment rows for the reservation. -- `ReservationInsurance`: reservation-time snapshots of selected insurance products. -- `AdditionalDriver`: reservation-time records for extra drivers and approval status. -- `DamageReport`: pickup/dropoff summary damage records with photos and signatures. -- `DamageInspection`: structured inspection record per reservation and inspection type. -- `DamagePoint`: individual annotated damage points tied to one inspection. -- `ReservationPhoto`: ad hoc pickup/dropoff photo attachments. -- `Review`: at most one review per reservation. -- `Complaint`: operational or post-rental dispute record, optionally linked to reservation, review, and customer. - -This snapshot pattern is important. The reservation stores what was actually sold and reviewed at the time of booking, not just pointers to mutable company configuration. - -## Reservation-Adjacent Tables in Detail - -### `RentalPayment` - -Operational payment rows for rentals, not SaaS billing. - -Purpose: - -- amount, currency, type, status -- AmanPay or PayPal provider references -- paid timestamp - -Relations: - -- belongs to one `Company` -- belongs to one `Reservation` - -### `ReservationInsurance` - -Snapshot bridge between `Reservation` and `InsurancePolicy`. - -It copies: - -- policy name -- policy type -- charge type -- charge value -- total charge - -That prevents later edits to the base insurance policy from mutating historical reservation pricing. - -### `AdditionalDriver` - -Reservation child rows for additional drivers. - -Purpose: - -- identity and license details -- charge model -- approval requirement and approval outcome -- expiry flags - -### `DamageReport` - -One row per reservation and report type. - -Composite uniqueness: - -- `(reservationId, type)` - -Stores: - -- summary damages JSON -- photo URLs -- fuel level -- mileage -- inspection metadata - -### `DamageInspection` - -Structured pickup/dropoff inspection entity. - -Composite uniqueness: - -- `(reservationId, type)` - -Stores: - -- fuel and mileage -- general condition notes -- customer agreement state -- inspector identity - -### `DamagePoint` - -Granular diagram point rows tied to `DamageInspection`. - -Stores: - -- diagram view -- x/y coordinates -- damage type and severity -- free-text description -- whether the damage is pre-existing - -### `ReservationPhoto` - -Simple photo attachments tied to a reservation with type `PICKUP` or `DROPOFF`. - -### `Review` - -One review per reservation. - -Important uniqueness: - -- `reservationId` - -Purpose: - -- overall, vehicle, and service ratings -- comment -- moderation/publication flags -- company reply data -- optional feedback category - -Relations: - -- optionally linked to `Renter` -- can have many `Complaint` - -### `Complaint` - -Company-scoped issue-tracking table. - -Can be linked to: - -- a reservation -- a review -- a customer - -This supports both operational complaints and post-review escalations. - -## Notifications and Messaging - -### `Notification` - -A notification can target one of three audiences: - -- company-level -- employee-level -- renter-level - -Purpose: - -- delivery type -- title/body/data payload -- channel -- locale -- send/read/failure state -- provider message ID - -### `NotificationTemplate` - -Reusable message content keyed by: - -- template key -- channel -- locale -- version - -### `NotificationPreference` - -Per-recipient opt-in/out rows. - -The uniqueness rules are intentionally split: - -- unique per `(employeeId, notificationType, channel)` -- unique per `(renterId, notificationType, channel)` - -## Admin and Platform Governance - -### `AdminUser` - -Platform operator account. - -Purpose: - -- role -- password hash -- 2FA state -- login tracking -- password reset support - -Relations: - -- has many `AdminPermission` -- has many `AuditLog` - -### `AdminPermission` - -Resource/action override rows for an admin user. - -Important uniqueness: - -- `(adminUserId, resource)` - -### `AuditLog` - -Immutable log of admin-side actions. - -Stores: - -- actor -- action and resource -- optional company and resource IDs -- before/after JSON -- IP, user agent, and note - -## Pricing and Platform Content Configuration - -### `PricingConfig` - -Platform-managed plan pricing rows. - -Important uniqueness: - -- `(plan, billingPeriod)` - -### `PlanFeature` - -Platform-managed feature labels per plan, ordered by `sortOrder`. - -### `PricingPromotion` - -Platform-managed promotion codes for SaaS pricing. - -Purpose: - -- code and description -- discount type/value -- applicable plans/periods -- usage limits -- validity window - -## Relationship Summary by Cardinality - -### One-to-one - -- `Company` -> `Subscription` -- `Company` -> `BrandSettings` -- `Company` -> `ContractSettings` -- `Company` -> `AccountingSettings` - -### One-to-many - -- `Company` -> `Employee`, `Vehicle`, `Offer`, `Customer`, `Reservation`, `RentalPayment` -- `Company` -> `BillingAccount`, `BillingInvoice`, `SubscriptionInvoice`, `InsurancePolicy`, `PricingRule`, `Notification`, `Complaint` -- `Vehicle` -> `Reservation`, `MaintenanceLog`, `VehicleCalendarBlock` -- `Reservation` -> `RentalPayment`, `ReservationInsurance`, `AdditionalDriver`, `DamageReport`, `DamageInspection`, `ReservationPhoto`, `Complaint` -- `BillingAccount` -> `BillingPaymentMethod`, `BillingInvoice`, `BillingPaymentIntent`, `BillingPaymentAttempt`, `BillingCreditBalance`, `BillingCreditLedgerEntry`, `BillingCreditNote`, `BillingRefund`, `BillingEvent` - -### Many-to-many through join tables - -- `Offer` <-> `Vehicle` through `OfferVehicle` -- `Renter` <-> `Company` through `RenterSavedCompany` - -## Indexing and Uniqueness Rules Worth Remembering - -Operationally important uniqueness constraints: - -- `Company.slug` -- `Company.email` -- `Company.apiKey` -- `BrandSettings.subdomain` -- `BrandSettings.customDomain` -- `Employee.clerkUserId` -- `Customer(companyId, email)` -- `Reservation.contractNumber` -- `Reservation.invoiceNumber` -- `Reservation.reviewToken` -- `Review.reservationId` -- `OfferVehicle(offerId, vehicleId)` -- `RenterSavedCompany(renterId, companyId)` -- `ReservationInsurance(reservationId, insurancePolicyId)` -- `DamageReport(reservationId, type)` -- `DamageInspection(reservationId, type)` -- `PricingConfig(plan, billingPeriod)` -- `NotificationPreference` employee and renter uniqueness pairs - -## Practical Reading Guide - -If you need to understand the schema quickly, read it in this order: - -1. `Company` -2. `Subscription`, `BillingAccount`, `BillingInvoice` -3. `Employee`, `Vehicle`, `Offer` -4. `Customer`, `Renter` -5. `Reservation` and its child tables -6. `Notification*` -7. `Admin*`, `AuditLog`, `Pricing*` - -That order matches how the application is structured in the API: tenant first, operations second, finance/admin last. diff --git a/docs/school_api.md b/docs/project-design/school_api.md similarity index 100% rename from docs/school_api.md rename to docs/project-design/school_api.md diff --git a/docs/project-design/simple_robust_car_rental_process.md b/docs/project-design/simple_robust_car_rental_process.md deleted file mode 100644 index bfcf26b3..00000000 --- a/docs/project-design/simple_robust_car_rental_process.md +++ /dev/null @@ -1,695 +0,0 @@ -# Simple and Robust Car Rental Process - -## Purpose - -This document defines a simple, robust operating process for a rental car company. It covers the full rental lifecycle from booking to vehicle return. - -The process is designed to be easy for staff to follow, fast for customers, and strong enough to protect the company from payment issues, damage disputes, missing vehicles, and unclear responsibilities. - -## Core Process - -The rental operation should be built around four control points: - -1. Booking -2. Vehicle Preparation -3. Pickup -4. Return - -Everything in the process should support one of these goals: - -- Confirm the customer is eligible to rent. -- Confirm the vehicle is available and ready. -- Secure payment and deposit before release. -- Document vehicle condition at pickup and return. -- Close the rental clearly and fairly. - ---- - -# 1. Booking - -## Goal - -The goal of booking is to confirm who the customer is, what they need, and whether the company can safely rent to them. - -## Required Customer Information - -Collect the following information for every booking: - -- Full customer name -- Phone number -- Email address -- Driver's license details -- Pickup date and time -- Return date and time -- Pickup location -- Return location -- Vehicle class -- Payment method -- Insurance choice -- Special requests, if any - -Special requests may include: - -- Child seat -- GPS device -- Additional driver -- One-way rental -- Airport pickup -- After-hours return -- Electric vehicle request - -## Booking Checks - -Before confirming a booking, staff or the system must check: - -- Vehicle availability -- Driver age eligibility -- Driver's license validity -- Deposit requirement -- Payment method validity -- Previous unpaid balance -- Customer blacklist or restriction status - -If any check fails, the booking must not be confirmed until the issue is reviewed by a manager. - -## Booking Confirmation - -After approval, send the customer a confirmation by email or SMS. - -The confirmation must include: - -- Booking number -- Pickup date, time, and location -- Return date, time, and location -- Vehicle class -- Estimated total price -- Deposit amount -- Required documents -- Fuel or charging rule -- Late return rule -- Cancellation rule -- Company contact information - -## Booking Checklist - -- [ ] Customer details collected -- [ ] License details collected -- [ ] Vehicle availability confirmed -- [ ] Price confirmed -- [ ] Deposit amount confirmed -- [ ] Insurance choice recorded -- [ ] Special requests recorded -- [ ] Confirmation sent to customer - ---- - -# 2. Vehicle Preparation - -## Goal - -The goal of vehicle preparation is to make sure the car is ready before the customer arrives. - -## Vehicle Assignment - -Assign a specific vehicle to the booking before pickup whenever possible. - -The assigned vehicle must match: - -- Booked vehicle class -- Required seating capacity -- Transmission preference, if applicable -- Fuel or electric vehicle preference, if applicable -- Special requests, if applicable - -If the exact vehicle is unavailable, staff may assign an equal or higher-class vehicle according to company policy. - -## Vehicle Readiness Checklist - -Before pickup, staff must check: - -- [ ] Vehicle is clean inside -- [ ] Vehicle is clean outside -- [ ] Mileage recorded -- [ ] Fuel or battery level recorded -- [ ] Tires checked visually -- [ ] No dashboard warning lights -- [ ] Lights working -- [ ] Registration document available -- [ ] Insurance document available -- [ ] Keys available -- [ ] Requested accessories installed or included -- [ ] Pickup photos taken -- [ ] Existing damage recorded - -## Vehicle Statuses - -Use simple vehicle statuses only: - -| Status | Meaning | -|---|---| -| Available | Vehicle can be booked. | -| Reserved | Vehicle is assigned to a future booking. | -| Ready | Vehicle is prepared for pickup. | -| On Rent | Vehicle is currently with a customer. | -| Returned | Vehicle has been returned but not yet processed. | -| Needs Cleaning | Vehicle requires cleaning before reuse. | -| Needs Maintenance | Vehicle requires mechanical service. | -| Damage Review | Vehicle has possible damage that must be reviewed. | -| Blocked | Vehicle cannot be rented. | - -## Preparation Checklist - -- [ ] Booking reviewed -- [ ] Vehicle assigned -- [ ] Vehicle cleaned -- [ ] Mileage recorded -- [ ] Fuel or battery recorded -- [ ] Vehicle photos taken -- [ ] Documents inside vehicle -- [ ] Keys ready -- [ ] Accessories ready -- [ ] Vehicle status changed to Ready - ---- - -# 3. Pickup - -## Goal - -The goal of pickup is to verify the customer, secure payment, document the vehicle, and release the car quickly. - -A standard pickup should normally take 10 to 15 minutes. - -## Required Customer Documents - -The customer must provide: - -- Valid driver's license -- Government ID, if required -- Accepted payment card -- Proof of insurance, if using personal insurance -- Booking confirmation, if needed - -The name on the driver's license should match the booking and payment method unless manager approval is given. - -## Pickup Steps - -1. Find the booking in the system. -2. Verify the customer's identity. -3. Verify the driver's license. -4. Confirm return date, time, and location. -5. Confirm vehicle class and assigned vehicle. -6. Process rental payment. -7. Authorize or collect deposit. -8. Confirm insurance choice. -9. Walk around the vehicle with the customer. -10. Take pickup photos. -11. Record mileage. -12. Record fuel or battery level. -13. Record existing damage. -14. Have the customer sign the rental agreement. -15. Give the customer the keys and emergency contact information. -16. Change vehicle status to On Rent. - -## Pickup Inspection - -The pickup inspection must include: - -- Front of vehicle -- Rear of vehicle -- Left side -- Right side -- Roof, if visible -- Windshield -- Windows -- Mirrors -- Tires -- Wheels -- Interior seats -- Dashboard -- Trunk -- Fuel or charging area - -Photos must be uploaded to the rental record before the vehicle is released. - -## Rental Agreement Requirements - -The rental agreement must include: - -- Customer name -- Authorized drivers -- Vehicle make, model, color, and plate number -- Pickup date and time -- Expected return date and time -- Pickup and return locations -- Starting mileage -- Starting fuel or battery level -- Rental rate -- Deposit amount -- Insurance choice -- Mileage limit, if applicable -- Fuel or charging policy -- Late return policy -- Damage policy -- Cleaning policy -- Smoking policy -- Toll and fine responsibility -- Accident reporting process -- Customer signature -- Staff signature - -## Pickup Checklist - -- [ ] Booking found -- [ ] Customer ID verified -- [ ] Driver's license verified -- [ ] Return details confirmed -- [ ] Payment completed -- [ ] Deposit authorized or collected -- [ ] Insurance choice confirmed -- [ ] Vehicle inspected with customer -- [ ] Pickup photos uploaded -- [ ] Mileage recorded -- [ ] Fuel or battery level recorded -- [ ] Existing damage recorded -- [ ] Agreement signed -- [ ] Keys given -- [ ] Emergency contact provided -- [ ] Vehicle status changed to On Rent - ---- - -# 4. During the Rental - -## Goal - -The goal during the rental period is to handle changes, incidents, and support requests without confusion. - -## Customer Support - -The customer must be able to contact the company for: - -- Rental extension -- Accident -- Breakdown -- Flat tire -- Lost key -- Vehicle issue -- Return location question -- Payment issue - -## Rental Extension Process - -Before approving an extension, staff must: - -1. Check vehicle availability. -2. Confirm the new return date and time. -3. Calculate additional charges. -4. Process additional payment or authorization. -5. Update the rental agreement. -6. Send written confirmation to the customer. - -Verbal-only extensions are not allowed. - -## Accident Process - -If the customer reports an accident, staff must instruct the customer to: - -- Stop safely. -- Call emergency services if needed. -- Take photos of all vehicles and damage. -- Collect other driver details, if applicable. -- Get a police report if required. -- Contact the rental company immediately. -- Avoid driving the vehicle if it is unsafe. - -Company staff must: - -- Open an incident record. -- Collect photos and documents. -- Contact the insurer if needed. -- Arrange towing if needed. -- Arrange a replacement vehicle if approved. -- Change vehicle status if the vehicle is no longer rentable. - -## Breakdown Process - -If the vehicle breaks down, staff must: - -1. Confirm the customer's location. -2. Confirm customer safety. -3. Ask about warning lights or symptoms. -4. Contact roadside assistance. -5. Arrange towing if required. -6. Arrange a replacement vehicle if approved. -7. Record the incident. -8. Mark the vehicle as Needs Maintenance or Blocked. - ---- - -# 5. Return - -## Goal - -The goal of the return process is to close the rental fairly, document the condition of the vehicle, and prepare the car for the next customer. - -## Return Steps - -1. Find the rental agreement. -2. Record actual return time. -3. Record return mileage. -4. Record fuel or battery level. -5. Inspect the exterior. -6. Inspect the interior. -7. Compare condition with pickup photos. -8. Check keys and accessories. -9. Check for customer belongings. -10. Take return photos. -11. Calculate final charges. -12. Release or adjust the deposit. -13. Send final receipt. -14. Change vehicle status. - -## Return Inspection - -The return inspection must include: - -- Exterior damage -- Interior damage -- Glass condition -- Tire condition -- Wheel condition -- Fuel or battery level -- Mileage -- Smoking odor -- Pet hair -- Stains -- Trash -- Missing accessories -- Warning lights -- Keys and key fobs - -The return inspection should be completed with the customer present whenever possible. - -## Damage Found at Return - -If new damage is found, staff must: - -1. Compare pickup photos with return photos. -2. Show the customer the damage. -3. Take clear photos of the damage. -4. Record the damage location and description. -5. Ask the customer to sign the damage report. -6. Escalate to a manager if the customer disputes the damage. -7. Keep the vehicle in Damage Review status until resolved. - -If the customer refuses to sign, staff must write: - -> Customer refused to sign damage acknowledgment. - -The staff member must still complete the damage report and upload evidence. - -## Final Charges - -Final billing may include: - -- Base rental charge -- Extra rental time -- Late return fee -- Extra mileage fee -- Fuel refill fee -- EV recharge fee -- Cleaning fee -- Smoking fee -- Damage charge -- Lost key charge -- Missing accessory charge -- Toll charges -- Traffic fines -- Administration fees - -All charges must be supported by the rental agreement, system records, or photo evidence. - -## Deposit Handling - -If there are no additional charges: - -- Close the rental. -- Release the deposit hold. -- Send the final receipt. - -If there are additional charges: - -- Deduct approved charges. -- Send an itemized invoice. -- Attach evidence if needed. -- Release any remaining deposit. - -## Return Checklist - -- [ ] Rental agreement found -- [ ] Return time recorded -- [ ] Mileage recorded -- [ ] Fuel or battery level checked -- [ ] Exterior inspected -- [ ] Interior inspected -- [ ] Pickup photos reviewed -- [ ] Return photos taken -- [ ] Keys returned -- [ ] Accessories returned -- [ ] Lost property checked -- [ ] Final charges calculated -- [ ] Deposit released or adjusted -- [ ] Receipt sent -- [ ] Vehicle status updated - ---- - -# 6. Post-Return Processing - -## Lost Property Check - -Staff must check: - -- Glove box -- Center console -- Door pockets -- Under seats -- Trunk -- Seat pockets -- Charging cable area - -Any found item must be logged with: - -- Date found -- Vehicle plate number -- Rental agreement number -- Item description -- Staff name -- Storage location -- Customer notification status - -## Cleaning - -After return, assign the vehicle to the correct cleaning level: - -- Light cleaning -- Standard cleaning -- Deep cleaning -- Smoke treatment -- Pet hair removal -- Stain removal - -If extra cleaning is required, staff must take photos before cleaning. - -## Maintenance Check - -Staff must check for: - -- Dashboard warning lights -- Tire pressure issues -- Fluid leaks -- Brake issues -- Unusual sounds -- Service due alerts -- EV charging issues - -After cleaning and maintenance review, update the vehicle status to one of the following: - -- Available -- Needs Cleaning -- Needs Maintenance -- Damage Review -- Blocked - ---- - -# 7. Simple Policy Set - -The company must define clear written policies for: - -- Cancellation -- No-show -- Late pickup -- Late return -- Fuel return -- EV battery return -- Mileage limit -- Extra mileage -- Insurance -- Damage -- Smoking -- Pets -- Cleaning -- Lost keys -- Tolls -- Traffic fines -- Unauthorized drivers -- Accidents -- Breakdowns -- Deposit release timing - -Each policy should be written in plain language and shown to the customer before pickup. - ---- - -# 8. Staff Roles - -## Rental Agent - -The rental agent is responsible for: - -- Processing bookings -- Verifying customer documents -- Explaining rental terms -- Processing payments -- Completing pickup inspections -- Completing return inspections -- Updating vehicle status - -## Fleet Staff - -Fleet staff are responsible for: - -- Cleaning vehicles -- Fueling or charging vehicles -- Moving vehicles -- Checking readiness -- Reporting damage or maintenance issues - -## Manager - -The manager is responsible for: - -- Approving exceptions -- Handling disputes -- Reviewing damage claims -- Approving high-risk rentals -- Monitoring daily operations -- Reviewing overdue vehicles - ---- - -# 9. System Requirements - -The rental company should use one central rental system for: - -- Bookings -- Customer records -- Vehicle records -- Availability -- Payments -- Deposits -- Rental agreements -- Pickup photos -- Return photos -- Damage records -- Invoices -- Vehicle statuses - -## Core System Rule - -Each rental must have: - -- One booking -- One customer record -- One assigned vehicle -- One rental agreement -- One final invoice - -If staff need to check several different places to understand one rental, the system is too complicated. - ---- - -# 10. Non-Negotiable Controls - -The following controls must never be skipped: - -- Valid driver's license check -- Payment before vehicle release -- Deposit before vehicle release -- Signed rental agreement -- Pickup photos -- Return photos -- Mileage record at pickup and return -- Fuel or battery record at pickup and return -- Written insurance choice -- Written extension approval -- Damage evidence before charging customer - -These controls protect the company from avoidable losses and disputes. - ---- - -# 11. Standard Operating Flow - -## Before Pickup - -1. Customer books vehicle. -2. System confirms availability. -3. Staff assigns vehicle. -4. Vehicle is cleaned and checked. -5. Customer receives pickup reminder. - -## At Pickup - -1. Staff verifies customer. -2. Staff verifies license. -3. Payment and deposit are processed. -4. Vehicle is inspected. -5. Agreement is signed. -6. Keys are released. -7. Vehicle status becomes On Rent. - -## During Rental - -1. Company supports customer if needed. -2. Extensions are handled in writing. -3. Accidents and breakdowns are recorded. -4. Overdue rentals are monitored. - -## At Return - -1. Vehicle is returned. -2. Mileage and fuel or battery are recorded. -3. Vehicle is inspected. -4. Final charges are calculated. -5. Deposit is released or adjusted. -6. Receipt is sent. -7. Vehicle is cleaned and reset. - ---- - -# 12. Operating Principle - -The company should design the process so a new employee can follow it using checklists, not memory. - -The process should rely on: - -- Clear vehicle statuses -- Short checklists -- Photo evidence -- Written confirmations -- Manager approval for exceptions - -A simple rental process is not a weak process. It is a controlled process with fewer places for mistakes to hide. diff --git a/docs/rental_car_pricing_management_plan.md b/docs/rental_car_pricing_management_plan.md deleted file mode 100644 index 58385534..00000000 --- a/docs/rental_car_pricing_management_plan.md +++ /dev/null @@ -1,392 +0,0 @@ -# Rental Car Pricing Management Plan - -## Objective - -Build a rental car pricing system that allows the business to manage vehicle prices using two pricing methods: - -1. **Manual Fixed Pricing**: The user sets daily and/or weekly prices for each car throughout the year. -2. **Automatic Dynamic Pricing**: The user sets minimum and maximum daily prices, and the system automatically adjusts prices based on search activity, demand, availability, seasonality, and booking behavior. - -The system should support both methods so the business can choose between full control and automated optimization. - ---- - -## Method 1: Manual Fixed Pricing - -### Description - -Manual fixed pricing allows the user to set exact prices for each vehicle. Prices can be configured by day, week, date range, season, holiday, or special event. - -This method is useful when the business wants predictable pricing and full control. - -### Core Features - -#### 1. Car-Level Pricing - -Each vehicle should have its own pricing settings. - -| Car | Daily Price | Weekly Price | -|---|---:|---:| -| Toyota Corolla | $45 | $280 | -| Hyundai Elantra | $50 | $310 | -| Ford Mustang | $95 | $600 | -| Chevrolet Suburban | $120 | $750 | - -#### 2. Date-Based Pricing - -The user should be able to set different prices for different date ranges. - -| Date Range | Daily Price | Weekly Price | -|---|---:|---:| -| Jan 1 - Mar 31 | $45 | $280 | -| Apr 1 - Aug 31 | $60 | $380 | -| Sep 1 - Dec 15 | $50 | $320 | -| Dec 16 - Dec 31 | $75 | $475 | - -#### 3. Seasonal Pricing - -The system should support pricing rules for: - -- Low season -- High season -- Holidays -- Weekends -- Special events -- Summer travel season -- End-of-year demand - -#### 4. Daily and Weekly Price Options - -The user should be able to set: - -- Daily rental price -- Weekly rental price -- Weekend price -- Holiday price -- Monthly price, if needed -- Discounted long-term rental price - -#### 5. Bulk Price Updates - -The system should allow the user to update many prices at once. - -Examples: - -- Increase all SUV prices by 15% for summer -- Set all economy cars to $40/day for February -- Apply holiday pricing to all cars from December 20 to January 5 - -### Benefits - -Manual pricing is simple, predictable, and gives the user full control. - -### Risks - -Manual pricing can become outdated quickly. If demand rises and prices stay low, the business loses revenue. If demand drops and prices stay high, cars may remain unused. - ---- - -## Method 2: Automatic Dynamic Pricing - -### Description - -Automatic dynamic pricing allows the user to set a minimum and maximum daily price for each car. The system then calculates the best price within that range based on demand and business rules. - -The system should never price below the minimum or above the maximum unless the user manually overrides it. - -### Core Features - -#### 1. Minimum and Maximum Price Rules - -Each car should have a minimum and maximum daily price. - -| Car | Minimum Daily Price | Maximum Daily Price | -|---|---:|---:| -| Toyota Corolla | $35 | $75 | -| Hyundai Elantra | $40 | $85 | -| Ford Mustang | $75 | $160 | -| Chevrolet Suburban | $90 | $220 | - -#### 2. Demand-Based Pricing - -The system should increase prices when demand is high and lower prices when demand is weak. - -Demand signals may include: - -- Number of searches for a car type -- Number of views on a specific vehicle -- Number of booking attempts -- Number of confirmed reservations -- Number of available cars -- Time remaining before rental date -- Canceled bookings -- Competitor pricing, if available -- Local events or holidays - -#### 3. Availability-Based Pricing - -The system should adjust prices based on available inventory. - -| Availability | Pricing Action | -|---|---| -| Many cars available | Lower price to increase bookings | -| Medium availability | Keep price near normal | -| Few cars available | Increase price | -| One car left in category | Increase toward maximum price | - -#### 4. Search-Based Pricing - -If many users search for a specific car, category, or date range, the system should treat that as demand. - -Example: - -If many users search for SUVs during July 4 weekend, SUV prices should increase automatically within the allowed price range. - -Searches alone should not trigger aggressive increases. The system should compare searches with booking behavior. - -#### 5. Booking Conversion Logic - -The system should compare searches to actual bookings. - -| Search Activity | Booking Activity | Pricing Action | -|---|---|---| -| High searches | High bookings | Raise price | -| High searches | Low bookings | Hold price or reduce slightly | -| Low searches | Low bookings | Reduce price | -| Low searches | High bookings | Raise price carefully | - -#### 6. Time-Based Pricing - -The system should adjust prices based on how close the rental date is. - -| Time Before Rental | Pricing Logic | -|---|---| -| 60+ days before | Keep price moderate | -| 30-60 days before | Adjust based on demand | -| 7-30 days before | Increase if availability is low | -| 1-7 days before | Discount if many cars remain, increase if few cars remain | - -#### 7. Category-Based Pricing - -Automatic pricing should work at both the vehicle level and category level. - -Categories may include: - -- Economy -- Compact -- Sedan -- SUV -- Luxury -- Sports car -- Van -- Truck - -If SUV demand is high, SUV prices may increase while economy car prices remain unchanged. - -#### 8. Manual Override - -The user should always be able to override automatic pricing. - -Manual override options should include: - -- Lock price for a specific car -- Lock price for a date range -- Disable automatic pricing for selected vehicles -- Approve price changes manually before they go live -- Set maximum daily price movement, such as no more than 10% per day - -#### 9. Pricing Safety Rules - -To prevent bad automated pricing, the system should include safety rules: - -- Do not change prices more than once per day unless needed -- Do not increase prices above the user’s maximum price -- Do not decrease prices below the user’s minimum price -- Do not raise prices aggressively based only on searches -- Do not discount high-demand dates too early -- Notify the user when prices change significantly -- Keep a pricing history for review - -#### 10. Pricing Formula Example - -A simple automatic pricing formula: - -```text -Automatic Price = Base Price - + Demand Adjustment - + Availability Adjustment - + Seasonality Adjustment - + Time Adjustment -``` - -Then the system applies price limits: - -```text -If calculated price < minimum price: - final price = minimum price - -If calculated price > maximum price: - final price = maximum price - -Otherwise: - final price = calculated price -``` - -Example: - -| Item | Amount | -|---|---:| -| Base price | $60 | -| Demand adjustment | +$10 | -| Availability adjustment | +$15 | -| Holiday adjustment | +$20 | -| Time adjustment | +$5 | -| Calculated price | $110 | - -If the user’s maximum price is $100, the final price should be **$100**. - ---- - -## Recommended System Design - -### Pricing Dashboard - -The pricing dashboard should allow the user to: - -- View all cars -- See current prices -- Set manual prices -- Set minimum and maximum prices -- Turn automatic pricing on or off -- View pricing history -- Review demand trends -- Approve or reject suggested price changes - -### Car Pricing Page - -Each car should have a pricing page with: - -- Default daily price -- Default weekly price -- Minimum daily price -- Maximum daily price -- Seasonal price rules -- Manual pricing calendar -- Automatic pricing status -- Price change history - -### Calendar Pricing View - -The system should include a calendar view where users can see and edit prices by date. - -| Date | Price | Pricing Type | -|---|---:|---| -| June 1 | $55 | Manual | -| June 2 | $58 | Automatic | -| June 3 | $60 | Automatic | -| July 4 | $95 | Holiday Rule | - -### Reports and Analytics - -The system should generate reports showing: - -- Revenue per car -- Revenue per category -- Occupancy rate -- Average daily rate -- Search demand -- Booking conversion rate -- Lost bookings -- Price change history -- Best-performing price ranges - ---- - -## Suggested Pricing Workflow - -### Step 1: Add Cars - -The user adds each car to the system with details such as: - -- Make -- Model -- Year -- Category -- Location -- Availability -- Default daily price -- Default weekly price - -### Step 2: Choose Pricing Method - -For each car, the user chooses: - -- Manual fixed pricing -- Automatic dynamic pricing - -### Step 3: Set Pricing Rules - -For manual pricing, the user sets exact daily and weekly prices. - -For automatic pricing, the user sets: - -- Minimum daily price -- Maximum daily price -- Base price -- Weekly discount rules -- Seasonal rules -- Safety limits - -### Step 4: Monitor Demand - -The system tracks: - -- Searches -- Views -- Bookings -- Cancellations -- Availability -- Date demand -- Category demand - -### Step 5: Adjust Prices - -Manual prices remain fixed unless the user changes them. - -Automatic prices adjust based on system rules. - -### Step 6: Review Performance - -The user reviews pricing performance and updates rules when needed. - ---- - -## Best Practice Recommendation - -The strongest approach is to support both pricing methods at the same time. - -Some vehicles should use manual pricing, especially rare, luxury, sports, or specialty cars where strict control matters. - -Other vehicles should use automatic pricing, especially common vehicles where demand changes frequently. - -| Vehicle Type | Best Pricing Method | -|---|---| -| Economy cars | Automatic pricing | -| Sedans | Automatic pricing | -| SUVs | Automatic pricing | -| Luxury cars | Manual or automatic with tight limits | -| Sports cars | Manual pricing | -| Vans | Automatic pricing with seasonal rules | -| Specialty cars | Manual pricing | - ---- - -## Final Recommendation - -The rental car pricing system should include both manual and automatic pricing. - -Manual pricing gives the user full control over daily and weekly rates throughout the year. - -Automatic pricing allows the user to set minimum and maximum limits while the system adjusts prices based on demand, searches, bookings, availability, seasonality, and time before rental. - -The best system is a hybrid model: the user controls the boundaries, and the system optimizes prices inside those boundaries. diff --git a/docs/security_hardening_changed_files.txt b/docs/security_hardening_changed_files.txt deleted file mode 100644 index 8bb134d9..00000000 --- a/docs/security_hardening_changed_files.txt +++ /dev/null @@ -1,14 +0,0 @@ -if (Schema::hasColumn('student_class', 'semester')) { - $builder->where('sc.semester', $semester); -}apps/api/src/index.ts -> apps/api/src/index.ts differ -apps/api/src/middleware/requireApiKey.test.ts -> apps/api/src/middleware/requireApiKey.test.ts differ -apps/api/src/middleware/requireApiKey.ts -> apps/api/src/middleware/requireApiKey.ts differ -apps/api/src/middleware/requireCompanyAuth.test.ts -> apps/api/src/middleware/requireCompanyAuth.test.ts differ -apps/api/src/middleware/requireRenterAuth.test.ts -> apps/api/src/middleware/requireRenterAuth.test.ts differ -apps/api/src/modules/auth/auth.employee.service.ts -> apps/api/src/modules/auth/auth.employee.service.ts differ -apps/api/src/security/tokens.ts -> apps/api/src/security/tokens.ts differ -apps/api/src/tests/helpers/fixtures.ts -> apps/api/src/tests/helpers/fixtures.ts differ -packages/database/prisma/migrations: 20260609233000_drop_legacy_company_api_key -packages/database/prisma/schema.prisma -> packages/database/prisma/schema.prisma differ -packages/database/src/index.d.ts -> packages/database/src/index.d.ts differ -packages/database/src/index.ts -> packages/database/src/index.ts differ diff --git a/docs/security_hardening_incremental.diff b/docs/security_hardening_incremental.diff deleted file mode 100644 index ac4b6688..00000000 --- a/docs/security_hardening_incremental.diff +++ /dev/null @@ -1,342 +0,0 @@ -diff -ru '--exclude=node_modules' car_project_original/apps/api/src/index.ts car_project_work/apps/api/src/index.ts ---- car_project_original/apps/api/src/index.ts 2026-06-09 19:40:36.000000000 +0000 -+++ car_project_work/apps/api/src/index.ts 2026-06-09 23:18:30.521017321 +0000 -@@ -1,11 +1,11 @@ - import http from 'http' - import { Server as SocketIOServer } from 'socket.io' - import cron from 'node-cron' --import jwt from 'jsonwebtoken' - import { redis } from './lib/redis' - import { prisma } from './lib/prisma' - import { assertStorageConfiguration } from './lib/storage' - import { createApp, corsOrigins } from './app' -+import { verifyAnyActorToken } from './security/tokens' - import { sendNotification } from './services/notificationService' - import { - runTrialExpirationJob, -@@ -29,7 +29,7 @@ - const token = socket.handshake.auth?.token as string | undefined - if (!token) return next() // unauthenticated connections allowed; they just don't join rooms - try { -- const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string } -+ const payload = verifyAnyActorToken(token) - ;(socket as any).authenticatedUserId = payload.sub - next() - } catch { -diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireApiKey.test.ts car_project_work/apps/api/src/middleware/requireApiKey.test.ts ---- car_project_original/apps/api/src/middleware/requireApiKey.test.ts 2026-06-09 19:40:36.000000000 +0000 -+++ car_project_work/apps/api/src/middleware/requireApiKey.test.ts 2026-06-09 23:18:42.795559504 +0000 -@@ -1,10 +1,12 @@ - import { beforeEach, describe, expect, it, vi } from 'vitest' - import type { NextFunction, Request, Response } from 'express' -+import { generateCompanyApiKey } from '../security/apiKeys' - - vi.mock('../lib/prisma', () => ({ - prisma: { -- company: { -+ companyApiKey: { - findUnique: vi.fn(), -+ update: vi.fn(), - }, - }, - })) -@@ -42,19 +44,18 @@ - message: 'API key required in x-api-key header', - statusCode: 401, - }) -- expect(prisma.company.findUnique).not.toHaveBeenCalled() -+ expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled() - expect(next).not.toHaveBeenCalled() - }) - -- it('rejects unknown API keys', async () => { -- vi.mocked(prisma.company.findUnique).mockResolvedValue(null) -+ it('rejects malformed API keys before database lookup', async () => { - const req = { headers: { 'x-api-key': 'bad-key' } } as unknown as Request - const res = createResponseStub() - const next = vi.fn() as NextFunction - - await requireApiKey(req, res, next) - -- expect(prisma.company.findUnique).toHaveBeenCalledWith({ where: { apiKey: 'bad-key' } }) -+ expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled() - expect(res.status).toHaveBeenCalledWith(401) - expect(res.json).toHaveBeenCalledWith({ - error: 'invalid_api_key', -@@ -64,15 +65,91 @@ - expect(next).not.toHaveBeenCalled() - }) - -- it('attaches company context and calls next for valid API keys', async () => { -- const company = { id: 'company_1', apiKey: 'valid-key', name: 'Atlas Cars' } -- vi.mocked(prisma.company.findUnique).mockResolvedValue(company as any) -- const req = { headers: { 'x-api-key': 'valid-key' } } as unknown as Request -+ it('rejects unknown API key prefixes', async () => { -+ const generated = generateCompanyApiKey() -+ vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue(null) -+ const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request - const res = createResponseStub() - const next = vi.fn() as NextFunction - - await requireApiKey(req, res, next) - -+ expect((prisma as any).companyApiKey.findUnique).toHaveBeenCalledWith({ -+ where: { prefix: generated.prefix }, -+ include: { company: true }, -+ }) -+ expect(res.status).toHaveBeenCalledWith(401) -+ expect(res.json).toHaveBeenCalledWith({ -+ error: 'invalid_api_key', -+ message: 'Invalid API key', -+ statusCode: 401, -+ }) -+ expect(next).not.toHaveBeenCalled() -+ }) -+ -+ it('rejects revoked API keys', async () => { -+ const generated = generateCompanyApiKey() -+ vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({ -+ id: 'key_1', -+ companyId: 'company_1', -+ prefix: generated.prefix, -+ keyHash: generated.keyHash, -+ revokedAt: new Date('2026-06-01T00:00:00.000Z'), -+ company: { id: 'company_1', name: 'Atlas Cars' }, -+ }) -+ const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request -+ const res = createResponseStub() -+ const next = vi.fn() as NextFunction -+ -+ await requireApiKey(req, res, next) -+ -+ expect(res.status).toHaveBeenCalledWith(401) -+ expect(next).not.toHaveBeenCalled() -+ }) -+ -+ it('rejects API keys whose secret does not match the stored hash', async () => { -+ const generated = generateCompanyApiKey() -+ const other = generateCompanyApiKey() -+ vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({ -+ id: 'key_1', -+ companyId: 'company_1', -+ prefix: generated.prefix, -+ keyHash: other.keyHash, -+ revokedAt: null, -+ company: { id: 'company_1', name: 'Atlas Cars' }, -+ }) -+ const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request -+ const res = createResponseStub() -+ const next = vi.fn() as NextFunction -+ -+ await requireApiKey(req, res, next) -+ -+ expect(res.status).toHaveBeenCalledWith(401) -+ expect(next).not.toHaveBeenCalled() -+ }) -+ -+ it('attaches company context, updates lastUsedAt, and calls next for valid API keys', async () => { -+ const generated = generateCompanyApiKey() -+ const company = { id: 'company_1', name: 'Atlas Cars' } -+ vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({ -+ id: 'key_1', -+ companyId: company.id, -+ prefix: generated.prefix, -+ keyHash: generated.keyHash, -+ revokedAt: null, -+ company, -+ }) -+ vi.mocked((prisma as any).companyApiKey.update).mockResolvedValue({}) -+ const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request -+ const res = createResponseStub() -+ const next = vi.fn() as NextFunction -+ -+ await requireApiKey(req, res, next) -+ -+ expect((prisma as any).companyApiKey.update).toHaveBeenCalledWith({ -+ where: { id: 'key_1' }, -+ data: { lastUsedAt: expect.any(Date) }, -+ }) - expect(req.company).toEqual(company) - expect(req.companyId).toBe('company_1') - expect(next).toHaveBeenCalledTimes(1) -diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireApiKey.ts car_project_work/apps/api/src/middleware/requireApiKey.ts ---- car_project_original/apps/api/src/middleware/requireApiKey.ts 2026-06-09 19:44:15.000000000 +0000 -+++ car_project_work/apps/api/src/middleware/requireApiKey.ts 2026-06-09 23:18:30.518795557 +0000 -@@ -21,14 +21,6 @@ - - const hashed = hashApiKey(apiKey) - if (!keyRecord || keyRecord.revokedAt || !timingSafeEqualHex(hashed, keyRecord.keyHash)) { -- if (process.env.ALLOW_LEGACY_COMPANY_API_KEYS === 'true') { -- const company = await prisma.company.findUnique({ where: { apiKey } }) -- if (company) { -- req.company = company -- req.companyId = company.id -- return next() -- } -- } - return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 }) - } - -diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireCompanyAuth.test.ts car_project_work/apps/api/src/middleware/requireCompanyAuth.test.ts ---- car_project_original/apps/api/src/middleware/requireCompanyAuth.test.ts 2026-06-09 20:03:48.000000000 +0000 -+++ car_project_work/apps/api/src/middleware/requireCompanyAuth.test.ts 2026-06-09 23:18:50.351586123 +0000 -@@ -63,7 +63,7 @@ - await requireCompanyAuth(req, res, next) - - expect(res.status).toHaveBeenCalledWith(401) -- expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid token type for this endpoint', statusCode: 401 }) -+ expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 }) - expect(prisma.employee.findUnique).not.toHaveBeenCalled() - }) - -@@ -108,7 +108,11 @@ - - await requireCompanyDocumentAuth(req, res, next) - -- expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret') -+ expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret', { -+ algorithms: ['HS256'], -+ issuer: 'rentaldrivego-api', -+ audience: 'employee', -+ }) - expect(req.companyId).toBe('company_2') - expect(next).toHaveBeenCalledTimes(1) - }) -diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireRenterAuth.test.ts car_project_work/apps/api/src/middleware/requireRenterAuth.test.ts ---- car_project_original/apps/api/src/middleware/requireRenterAuth.test.ts 2026-06-09 19:40:36.000000000 +0000 -+++ car_project_work/apps/api/src/middleware/requireRenterAuth.test.ts 2026-06-09 23:18:50.352660214 +0000 -@@ -49,7 +49,7 @@ - await requireRenterAuth(req, res, next) - - expect(res.status).toHaveBeenCalledWith(401) -- expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 }) -+ expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 }) - expect(prisma.renter.findUnique).not.toHaveBeenCalled() - }) - -diff -ru '--exclude=node_modules' car_project_original/apps/api/src/modules/auth/auth.employee.service.ts car_project_work/apps/api/src/modules/auth/auth.employee.service.ts ---- car_project_original/apps/api/src/modules/auth/auth.employee.service.ts 2026-06-09 19:42:41.000000000 +0000 -+++ car_project_work/apps/api/src/modules/auth/auth.employee.service.ts 2026-06-09 23:19:29.323222951 +0000 -@@ -41,13 +41,22 @@ - pwdv: getEmployeePasswordResetVersion(passwordHash), - }, - process.env.JWT_SECRET!, -- { expiresIn: `${RESET_TOKEN_TTL_MINUTES}m` }, -+ { -+ algorithm: 'HS256', -+ issuer: 'rentaldrivego-api', -+ audience: 'employee_password_reset', -+ expiresIn: `${RESET_TOKEN_TTL_MINUTES}m`, -+ }, - ) - } - - function verifyEmployeePasswordResetToken(token: string): EmployeePasswordResetPayload | null { - try { -- const payload = jwt.verify(token, process.env.JWT_SECRET!) as jwt.JwtPayload -+ const payload = jwt.verify(token, process.env.JWT_SECRET!, { -+ algorithms: ['HS256'], -+ issuer: 'rentaldrivego-api', -+ audience: 'employee_password_reset', -+ }) as jwt.JwtPayload - - if ( - typeof payload.sub !== 'string' || -diff -ru '--exclude=node_modules' car_project_original/apps/api/src/security/tokens.ts car_project_work/apps/api/src/security/tokens.ts ---- car_project_original/apps/api/src/security/tokens.ts 2026-06-09 20:20:10.000000000 +0000 -+++ car_project_work/apps/api/src/security/tokens.ts 2026-06-09 23:18:30.519590128 +0000 -@@ -54,3 +54,14 @@ - - return payload as ActorTokenPayload - } -+ -+export function verifyAnyActorToken(token: string): ActorTokenPayload { -+ const decoded = jwt.decode(token) as jwt.JwtPayload | null -+ const actorType = decoded?.type -+ -+ if (actorType !== 'admin' && actorType !== 'employee' && actorType !== 'renter') { -+ throw new Error('Invalid actor token') -+ } -+ -+ return verifyActorToken(token, actorType) -+} -diff -ru '--exclude=node_modules' car_project_original/apps/api/src/tests/helpers/fixtures.ts car_project_work/apps/api/src/tests/helpers/fixtures.ts ---- car_project_original/apps/api/src/tests/helpers/fixtures.ts 2026-06-09 19:40:36.000000000 +0000 -+++ car_project_work/apps/api/src/tests/helpers/fixtures.ts 2026-06-09 23:19:11.289635569 +0000 -@@ -1,8 +1,6 @@ - import bcrypt from 'bcryptjs' --import jwt from 'jsonwebtoken' - import { prisma } from '../../lib/prisma' -- --const JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret' -+import { signActorToken } from '../../security/tokens' - - let counter = 0 - function uid() { -@@ -215,28 +213,16 @@ - }) - } - --export function signEmployeeToken(employeeId: string, companyId: string, role: string = 'OWNER') { -- return jwt.sign( -- { sub: employeeId, companyId, role, type: 'employee' }, -- JWT_SECRET, -- { expiresIn: '1h' }, -- ) -+export function signEmployeeToken(employeeId: string, _companyId: string, _role: string = 'OWNER') { -+ return signActorToken(employeeId, 'employee', { expiresIn: '1h' }) - } - - export function signAdminToken(adminId: string) { -- return jwt.sign( -- { sub: adminId, type: 'admin' }, -- JWT_SECRET, -- { expiresIn: '1h' }, -- ) -+ return signActorToken(adminId, 'admin', { expiresIn: '1h', last2faAt: Date.now() }) - } - - export function signRenterToken(renterId: string) { -- return jwt.sign( -- { sub: renterId, type: 'renter' }, -- JWT_SECRET, -- { expiresIn: '1h' }, -- ) -+ return signActorToken(renterId, 'renter', { expiresIn: '1h' }) - } - - export function authHeader(token: string) { -Only in car_project_work/packages/database/prisma/migrations: 20260609233000_drop_legacy_company_api_key -diff -ru '--exclude=node_modules' car_project_original/packages/database/prisma/schema.prisma car_project_work/packages/database/prisma/schema.prisma ---- car_project_original/packages/database/prisma/schema.prisma 2026-06-09 20:18:52.000000000 +0000 -+++ car_project_work/packages/database/prisma/schema.prisma 2026-06-09 23:18:30.516362264 +0000 -@@ -502,7 +502,6 @@ - address Json? - status CompanyStatus @default(PENDING) - subscriptionPaymentRef String? -- apiKey String @unique @default(cuid()) - - subscription Subscription? - billingAccounts BillingAccount[] -diff -ru '--exclude=node_modules' car_project_original/packages/database/src/index.d.ts car_project_work/packages/database/src/index.d.ts ---- car_project_original/packages/database/src/index.d.ts 2026-06-09 19:40:36.000000000 +0000 -+++ car_project_work/packages/database/src/index.d.ts 2026-06-09 23:18:30.517981475 +0000 -@@ -33,7 +33,6 @@ - email: string - phone: string | null - status: string -- apiKey: string - } - - export interface Employee { -diff -ru '--exclude=node_modules' car_project_original/packages/database/src/index.ts car_project_work/packages/database/src/index.ts ---- car_project_original/packages/database/src/index.ts 2026-06-09 19:40:36.000000000 +0000 -+++ car_project_work/packages/database/src/index.ts 2026-06-09 23:18:30.517228009 +0000 -@@ -33,7 +33,6 @@ - email: string - phone: string | null - status: string -- apiKey: string - } - - export interface Employee { diff --git a/docs/security_hardening_leftover.diff b/docs/security_hardening_leftover.diff deleted file mode 100644 index 9700d00b..00000000 --- a/docs/security_hardening_leftover.diff +++ /dev/null @@ -1,1498 +0,0 @@ -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md /mnt/data/car_project_leftover/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md ---- /mnt/data/car_project_leftover_before/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md 1970-01-01 00:00:00.000000000 +0000 -+++ /mnt/data/car_project_leftover/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md 2026-06-09 23:36:11.076433808 +0000 -@@ -0,0 +1,255 @@ -+# Security Hardening Leftover Application Report -+ -+Project: RentalDriveGo / Car Management System -+Input archive: `car_management_system_hardened_applied.zip` -+Output archive: `car_management_system_leftover_applied.zip` -+Date: 2026-06-09 -+ -+## Executive Summary -+ -+This pass applied the remaining source-level hardening gaps that were still practical to implement directly in the repository after the first hardening pass. The focus was on eliminating browser-readable authentication assumptions, enforcing app-layer blocking for the `x-middleware-subrequest` bypass class, improving actor-aware rate limiting, adding an admin 2FA recovery-code workflow, and bringing documentation/static checks into line with the hardened authentication model. -+ -+This does not replace production operator work such as real secret rotation, live infrastructure verification, container scanning, applying migrations to a real database, or running the complete CI/test pipeline. Those items remain launch-gate evidence requirements. -+ -+## Applied Changes -+ -+### 1. Removed remaining browser-side employee token assumptions -+ -+Changed files: -+ -+- `apps/dashboard/src/lib/api.ts` -+- `apps/dashboard/src/components/layout/TopBar.tsx` -+- `apps/dashboard/src/components/layout/Sidebar.tsx` -+- `apps/dashboard/src/app/(dashboard)/team/page.tsx` -+- `apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx` -+ -+What changed: -+ -+- Removed dashboard use of employee auth tokens from `localStorage`. -+- Dashboard API calls now use `credentials: 'include'` and depend on HttpOnly cookies. -+- Dashboard Socket.io connection now uses cookie credentials instead of script-provided auth tokens. -+- Team page no longer decodes employee identity from a localStorage JWT. It resolves the current actor through `/auth/employee/me`. -+- Admin 2FA sign-in form now accepts either a six-digit TOTP code or a recovery code value. -+ -+Security effect: -+ -+- Reduces script-readable authentication exposure. -+- Aligns the dashboard with the intended HttpOnly session-cookie model. -+- Prevents UI code from treating a readable JWT as the authority for employee identity. -+ -+### 2. Added Socket.io HttpOnly-cookie session support -+ -+Changed file: -+ -+- `apps/api/src/index.ts` -+ -+What changed: -+ -+- Added Socket.io session-token extraction from HttpOnly cookies. -+- Preserved explicit token verification for trusted non-browser/server contexts, while browser clients can now authenticate through cookies. -+- Reused centralized actor-token verification. -+ -+Security effect: -+ -+- Real-time dashboard connections no longer require JavaScript-readable employee tokens. -+- Socket authentication now follows the same actor-token validation path used elsewhere. -+ -+### 3. Added app-layer `x-middleware-subrequest` blocking -+ -+Changed files: -+ -+- `apps/api/src/app.ts` -+- `apps/dashboard/src/middleware.ts` -+- `apps/marketplace/src/middleware.ts` -+- `apps/admin/src/middleware.ts` -+- `apps/dashboard/src/middleware.test.ts` -+- `apps/marketplace/src/middleware.test.ts` -+ -+What changed: -+ -+- API now rejects requests containing `x-middleware-subrequest` before route handling. -+- Dashboard, marketplace, and admin Next middleware now reject the same header at the app layer. -+- Added/updated middleware tests for the rejection path. -+ -+Security effect: -+ -+- Adds defense in depth beyond reverse-proxy filtering. -+- Prevents the project from depending on a single infrastructure control for this bypass class. -+ -+### 4. Hardened admin/browser fetch behavior -+ -+Changed files include: -+ -+- `apps/admin/src/lib/api.ts` -+- `apps/admin/src/app/dashboard/admin-users/page.tsx` -+- `apps/admin/src/app/dashboard/renters/page.tsx` -+- `apps/admin/src/app/dashboard/companies/[id]/page.tsx` -+- `apps/admin/src/app/dashboard/containers/page.tsx` -+- `apps/admin/src/app/dashboard/pricing/page.tsx` -+- `apps/admin/src/app/forgot-password/page.tsx` -+- `apps/admin/src/app/reset-password/page.tsx` -+ -+What changed: -+ -+- Admin API wrapper uses `credentials: 'include'`. -+- Manual admin fetch calls now include credentials where they directly call the admin API. -+- Removed dead placeholder `getToken()` helpers that returned empty strings and created meaningless `Authorization: Bearer ` headers. -+ -+Security effect: -+ -+- Admin browser requests now consistently rely on the HttpOnly admin session cookie. -+- Removes misleading bearer-token scaffolding from the admin UI. -+ -+### 5. Improved actor-aware rate limiting -+ -+Changed files: -+ -+- `apps/api/src/middleware/rateLimiter.ts` -+- `apps/api/src/app.ts` -+ -+What changed: -+ -+- API rate-limit keys now prefer a verified actor identity from session cookies or valid Bearer tokens. -+- Actor-aware rate limiting falls back safely to existing request actor fields or anonymous IP keys. -+- Admin authentication routes now receive the stricter authentication limiter before the broader admin limiter. -+ -+Security effect: -+ -+- Authenticated traffic is limited by actor identity instead of only coarse IP data. -+- Login and admin-auth abuse get stricter protection. -+- Multi-container correctness still depends on Redis availability/configuration in production, which must be verified during deployment. -+ -+### 6. Added admin 2FA recovery-code backend workflow -+ -+Changed files: -+ -+- `packages/database/prisma/schema.prisma` -+- `packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql` -+- `apps/api/src/modules/admin/admin.repo.ts` -+- `apps/api/src/modules/admin/admin.service.ts` -+- `apps/api/src/modules/admin/admin.schemas.ts` -+- `apps/api/src/modules/admin/admin.routes.ts` -+ -+What changed: -+ -+- Added `AdminRecoveryCode` model. -+- Recovery codes are stored as bcrypt hashes, not plaintext. -+- TOTP enrollment now issues one-time recovery codes. -+- Recovery-code regeneration is protected by authenticated admin access and fresh 2FA. -+- Login can consume a valid unused recovery code when TOTP is enabled. -+- Recovery-code issuance and use are audited. -+ -+Security effect: -+ -+- Adds a recovery path for mandatory admin 2FA without storing backup codes in plaintext. -+- Preserves one-time-use semantics. -+- Adds auditability for recovery-code lifecycle events. -+ -+Limitation: -+ -+- The backend returns recovery codes after enrollment/regeneration. A production-ready admin UI still needs to display them once with clear save instructions and must not persist them client-side. -+ -+### 7. Strengthened static scanning for auth-token regressions -+ -+Changed file: -+ -+- `scripts/security-static-check.mjs` -+ -+What changed: -+ -+- Static scan now catches common regressions involving auth/session token names in `localStorage` and `document.cookie`. -+- Scan specifically targets auth token/session names such as employee/admin/renter tokens and sessions. -+ -+Security effect: -+ -+- Makes it harder for future code changes to quietly reintroduce script-readable authentication tokens. -+ -+### 8. Updated documentation to match the hardened model -+ -+Changed files: -+ -+- `apps/dashboard/README.md` -+- `memory/project_auth_architecture.md` -+- `docs/project-design/COOKIE_POLICY.md` -+- `apps/api/src/swagger/openapi.ts` -+ -+What changed: -+ -+- Replaced stale localStorage/JWT handoff claims with HttpOnly session-cookie wording. -+- Clarified that browser clients use HttpOnly sessions and that Bearer tokens are only for documented trusted server/mobile contexts. -+- Updated cookie policy references from legacy `employee_token` wording to `employee_session`. -+ -+Security effect: -+ -+- Reduces the odds that a future developer follows stale documentation and reintroduces the old pattern. -+ -+## Validation Performed -+ -+The following checks were run successfully in the available environment: -+ -+```bash -+npm run security:static -+node --check scripts/security-static-check.mjs -+# JSON parse validation for package.json and package-lock.json files -+# YAML parse validation for docker-compose.production.yml and .gitlab-ci.yml -+bash -n docker/entrypoint.production.sh scripts/docker-prod-*.sh scripts/docker-registry-local-up.sh scripts/setup-clerk-keys.sh -+# TypeScript/TSX syntax transpile check over 507 source files -+npm audit --package-lock-only --omit=dev --audit-level=critical -+``` -+ -+Results: -+ -+- Security static check: passed. -+- Static-check script syntax: passed. -+- Package JSON validation: passed. -+- YAML validation: passed. -+- Shell syntax validation: passed. -+- TypeScript/TSX syntax transpile validation: passed. -+- Critical production dependency audit: passed. -+ -+Audit note: -+ -+- `npm audit --audit-level=critical` exited successfully. -+- Moderate advisories remain for transitive `postcss` and `uuid` paths. The available automatic fixes require breaking/force dependency changes, so they were not applied blindly in this source pass. -+ -+## Not Fully Verified in This Environment -+ -+The following items require a real development/CI/deployment environment: -+ -+- `npm ci` from a clean checkout. -+- Full workspace typecheck. -+- Full unit, integration, security, and e2e test suites. -+- Prisma client generation. -+- Applying the new database migration to a real database. -+- Database backup/restore verification. -+- Docker image build and runtime validation. -+- Container scanning with Trivy or equivalent. -+- Live reverse-proxy validation for `x-middleware-subrequest` blocking. -+- Redis-backed distributed rate-limit validation across multiple API containers. -+- Provider webhook sandbox tests. -+- Live admin 2FA recovery-code UX verification. -+ -+## Remaining Launch-Gate Work -+ -+These are still not things source edits can prove by themselves: -+ -+1. Rotate all real production secrets. -+2. Confirm no real secrets exist in repository history or image layers. -+3. Apply and verify the new `admin_recovery_codes` migration. -+4. Run the full CI gate: lint, typecheck, unit tests, integration tests, security tests, build, dependency audit, secret scan, and container scan. -+5. Confirm Redis and PostgreSQL are private in production. -+6. Confirm DB management tools are not publicly reachable. -+7. Confirm production containers run non-root with reduced capabilities and resource limits. -+8. Confirm webhook signature/idempotency behavior against real provider sandbox payloads. -+9. Confirm private files are inaccessible through static routes in the deployed environment. -+10. Confirm the admin UI displays recovery codes once and instructs admins to save them securely. -+ -+## Changed Files -+ -+See `security_hardening_leftover_changed_files.txt` for the complete file list and `security_hardening_leftover.diff` for the unified diff. -+ -+## Final Assessment -+ -+This pass closes a meaningful set of leftover source-level gaps from the security-hardening plan. The project is closer to the intended model: API-enforced security, HttpOnly browser sessions, stronger admin 2FA recovery, app-layer bypass blocking, and less stale documentation. -+ -+However, this still should not be treated as production-ready until the remaining launch-gate evidence is collected from CI and the live deployment environment. Security that has not been tested in the actual runtime is mostly optimism with a lanyard. -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/admin-users/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/admin-users/page.tsx ---- /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/admin-users/page.tsx 2026-06-09 19:47:54.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/admin-users/page.tsx 2026-06-09 23:28:04.035058641 +0000 -@@ -33,13 +33,11 @@ - const [form, setForm] = useState(EMPTY_FORM) - const [saving, setSaving] = useState(false) - -- function getToken() { return '' } -- - async function fetchAdmins() { - try { - const res = await fetch(`${ADMIN_API_BASE}/admin/admins`, { -- headers: { Authorization: `Bearer ${getToken()}` }, - cache: 'no-store', -+ credentials: 'include', - }) - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? 'Failed') -@@ -97,7 +95,8 @@ - - const res = await fetch(`${ADMIN_API_BASE}/admin/admins${editingAdminId ? `/${editingAdminId}` : ''}`, { - method: isEditing ? 'PATCH' : 'POST', -- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, -+ headers: { 'Content-Type': 'application/json' }, -+ credentials: 'include', - body: JSON.stringify(payload), - }) - const json = await res.json() -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/companies/[id]/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/companies/[id]/page.tsx ---- /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/companies/[id]/page.tsx 2026-06-09 19:47:54.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/companies/[id]/page.tsx 2026-06-09 23:28:04.038741293 +0000 -@@ -207,10 +207,6 @@ - const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500' - const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500' - --function getToken() { -- return '' --} -- - function toDateInput(value: string | null | undefined) { - return value ? new Date(value).toISOString().slice(0, 10) : '' - } -@@ -324,11 +320,10 @@ - const [deleteConfirm, setDeleteConfirm] = useState(false) - - async function fetchData() { -- const headers = { Authorization: `Bearer ${getToken()}` } - try { - const [cRes, aRes] = await Promise.all([ -- fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { headers, cache: 'no-store' }), -- fetch(`${ADMIN_API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { headers, cache: 'no-store' }), -+ fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { cache: 'no-store', credentials: 'include' }), -+ fetch(`${ADMIN_API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { cache: 'no-store', credentials: 'include' }), - ]) - const cJson = await cRes.json() - const aJson = await aRes.json() -@@ -339,7 +334,7 @@ - - const subId = cJson.data?.subscription?.id - if (subId) { -- const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { headers, cache: 'no-store' }) -+ const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { cache: 'no-store', credentials: 'include' }) - const eJson = await eRes.json() - setSubEvents(Array.isArray(eJson.data) ? eJson.data : []) - } -@@ -365,7 +360,8 @@ - try { - const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { - method: 'PATCH', -- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, -+ headers: { 'Content-Type': 'application/json' }, -+ credentials: 'include', - body: JSON.stringify({ - company: { - name: form.company.name, -@@ -469,7 +465,8 @@ - try { - const res = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${company.subscription.id}/${action}`, { - method: 'POST', -- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, -+ headers: { 'Content-Type': 'application/json' }, -+ credentials: 'include', - body: JSON.stringify({ reason: subOverrideReason }), - }) - const json = await res.json() -@@ -490,7 +487,8 @@ - try { - const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}/status`, { - method: 'PATCH', -- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, -+ headers: { 'Content-Type': 'application/json' }, -+ credentials: 'include', - body: JSON.stringify({ status }), - }) - const json = await res.json() -@@ -508,7 +506,7 @@ - try { - const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { - method: 'DELETE', -- headers: { Authorization: `Bearer ${getToken()}` }, -+ credentials: 'include', - }) - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? 'Delete failed') -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/containers/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/containers/page.tsx ---- /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/containers/page.tsx 2026-06-09 20:06:04.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/containers/page.tsx 2026-06-09 23:28:07.120677773 +0000 -@@ -56,7 +56,7 @@ - const fetchLogs = useCallback(async (lines: number) => { - setLoading(true) - try { -- const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders() }) -+ const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders(), credentials: 'include' }) - const json = await res.json() - setLogs(json.data?.logs ?? '') - } catch { -@@ -125,7 +125,7 @@ - - const fetchContainers = useCallback(async () => { - try { -- const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders() }) -+ const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders(), credentials: 'include' }) - const json = await res.json().catch(() => null) - if (!res.ok) { - throw new Error(json?.message ?? 'Failed to load containers.') -@@ -150,7 +150,7 @@ - setProvisioning(true) - setProvisionResults(null) - try { -- const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders() }) -+ const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders(), credentials: 'include' }) - const json = await res.json().catch(() => null) - if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.') - setProvisionResults(json.data?.results ?? []) -@@ -171,7 +171,7 @@ - action === 'remove' - ? `${ADMIN_API_BASE}/admin/containers/${companyId}` - : `${ADMIN_API_BASE}/admin/containers/${companyId}/${action}` -- const res = await fetch(url, { method, headers: authHeaders() }) -+ const res = await fetch(url, { method, headers: authHeaders(), credentials: 'include' }) - const json = await res.json().catch(() => null) - if (!res.ok) { - throw new Error(json?.message ?? `Failed to ${action} container.`) -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/pricing/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/pricing/page.tsx ---- /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/pricing/page.tsx 2026-06-09 20:06:04.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/pricing/page.tsx 2026-06-09 23:28:07.124025372 +0000 -@@ -314,7 +314,7 @@ - const [deleteConfirm, setDeleteConfirm] = useState(null) - - useEffect(() => { -- fetch(`${ADMIN_API_BASE}/admin/pricing/features`, { headers: authHeaders() }) -+ fetch(`${ADMIN_API_BASE}/admin/pricing/features`, { headers: authHeaders(), credentials: 'include' }) - .then(async (r) => { - const json = await r.json() - if (!r.ok) throw new Error(json?.message ?? 'Failed to load plan features') -@@ -376,6 +376,7 @@ - const res = await fetch(url, { - method: isEdit ? 'PATCH' : 'POST', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, -+ credentials: 'include', - body: JSON.stringify(result.payload), - }) - const json = await res.json() -@@ -712,7 +713,7 @@ - const [deleteConfirm, setDeleteConfirm] = useState(null) - - useEffect(() => { -- fetch(`${ADMIN_API_BASE}/admin/pricing/promotions`, { headers: authHeaders() }) -+ fetch(`${ADMIN_API_BASE}/admin/pricing/promotions`, { headers: authHeaders(), credentials: 'include' }) - .then(async (r) => { - const json = await r.json() - if (!r.ok) throw new Error(json?.message ?? 'Failed to load promotions') -@@ -795,6 +796,7 @@ - const res = await fetch(url, { - method: isEdit ? 'PATCH' : 'POST', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, -+ credentials: 'include', - body: JSON.stringify(result.payload), - }) - const json = await res.json() -@@ -816,6 +818,7 @@ - const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${promo.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, -+ credentials: 'include', - body: JSON.stringify({ isActive: !promo.isActive }), - }) - const json = await res.json() -@@ -1039,6 +1042,7 @@ - const res = await fetch(`${ADMIN_API_BASE}/admin/pricing`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, -+ credentials: 'include', - body: JSON.stringify({ entries }), - }) - const json = await res.json() -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/renters/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/renters/page.tsx ---- /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/renters/page.tsx 2026-06-09 19:47:54.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/renters/page.tsx 2026-06-09 23:28:04.036400425 +0000 -@@ -70,13 +70,11 @@ - const [error, setError] = useState(null) - const [actioning, setActioning] = useState(null) - -- function getToken() { return '' } -- - async function fetchRenters() { - try { - const res = await fetch(`${ADMIN_API_BASE}/admin/renters`, { -- headers: { Authorization: `Bearer ${getToken()}` }, - cache: 'no-store', -+ credentials: 'include', - }) - const json = await res.json() - if (!res.ok) throw new Error(json?.message ?? 'Failed') -@@ -105,7 +103,7 @@ - const endpoint = isBlocked ? 'unblock' : 'block' - const res = await fetch(`${ADMIN_API_BASE}/admin/renters/${id}/${endpoint}`, { - method: 'POST', -- headers: { Authorization: `Bearer ${getToken()}` }, -+ credentials: 'include', - }) - if (!res.ok) throw new Error('Action failed') - await fetchRenters() -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/forgot-password/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/forgot-password/page.tsx ---- /mnt/data/car_project_leftover_before/apps/admin/src/app/forgot-password/page.tsx 2026-06-09 19:47:54.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/admin/src/app/forgot-password/page.tsx 2026-06-09 23:28:30.467977305 +0000 -@@ -19,6 +19,7 @@ - const res = await fetch(`${ADMIN_API_BASE}/admin/auth/forgot-password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, -+ credentials: 'include', - body: JSON.stringify({ email }), - }) - if (!res.ok) throw new Error() -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/reset-password/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/reset-password/page.tsx ---- /mnt/data/car_project_leftover_before/apps/admin/src/app/reset-password/page.tsx 2026-06-09 19:47:54.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/admin/src/app/reset-password/page.tsx 2026-06-09 23:28:30.468921934 +0000 -@@ -35,6 +35,7 @@ - const res = await fetch(`${ADMIN_API_BASE}/admin/auth/reset-password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, -+ credentials: 'include', - body: JSON.stringify({ token, password }), - }) - const json = await res.json() -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/middleware.ts /mnt/data/car_project_leftover/apps/admin/src/middleware.ts ---- /mnt/data/car_project_leftover_before/apps/admin/src/middleware.ts 1970-01-01 00:00:00.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/admin/src/middleware.ts 2026-06-09 23:29:06.361808166 +0000 -@@ -0,0 +1,17 @@ -+import { NextResponse } from 'next/server' -+import type { NextRequest } from 'next/server' -+ -+export function middleware(request: NextRequest) { -+ if (request.headers.has('x-middleware-subrequest')) { -+ return new NextResponse('Unsupported internal request header', { status: 400 }) -+ } -+ -+ return NextResponse.next() -+} -+ -+export const config = { -+ matcher: [ -+ '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', -+ '/(api|trpc)(.*)', -+ ], -+} -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/app.ts /mnt/data/car_project_leftover/apps/api/src/app.ts ---- /mnt/data/car_project_leftover_before/apps/api/src/app.ts 2026-06-09 22:54:05.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/api/src/app.ts 2026-06-09 23:29:48.882746714 +0000 -@@ -80,6 +80,14 @@ - } - - app.use(requestIdMiddleware) -+ -+ app.use((req, res, next) => { -+ if (req.headers['x-middleware-subrequest']) { -+ return res.status(400).json({ error: 'bad_request', message: 'Unsupported internal request header', statusCode: 400 }) -+ } -+ next() -+ }) -+ - app.use(corsMiddleware) - - // Customer identity documents must never be anonymously retrievable from the -@@ -142,6 +150,7 @@ - app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter) - app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter) - -+ app.use(`${v1}/admin/auth`, authLimiter) - app.use(`${v1}/admin`, adminLimiter, adminRouter) - - app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter) -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/index.ts /mnt/data/car_project_leftover/apps/api/src/index.ts ---- /mnt/data/car_project_leftover_before/apps/api/src/index.ts 2026-06-09 23:18:30.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/api/src/index.ts 2026-06-09 23:28:49.446712077 +0000 -@@ -1,11 +1,13 @@ - import http from 'http' - import { Server as SocketIOServer } from 'socket.io' -+import type { Socket } from 'socket.io' - import cron from 'node-cron' - import { redis } from './lib/redis' - import { prisma } from './lib/prisma' - import { assertStorageConfiguration } from './lib/storage' - import { createApp, corsOrigins } from './app' - import { verifyAnyActorToken } from './security/tokens' -+import { getSessionCookieName } from './security/sessionCookies' - import { sendNotification } from './services/notificationService' - import { - runTrialExpirationJob, -@@ -24,9 +26,34 @@ - cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] }, - }) - -+ -+function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null { -+ if (!cookieHeader) return null -+ -+ for (const chunk of cookieHeader.split(';')) { -+ const [rawName, ...rawValue] = chunk.trim().split('=') -+ if (rawName === name) return decodeURIComponent(rawValue.join('=')) -+ } -+ -+ return null -+} -+ -+function getSocketSessionToken(socket: Socket): string | undefined { -+ const explicitToken = socket.handshake.auth?.token -+ if (typeof explicitToken === 'string' && explicitToken.trim()) return explicitToken.trim() -+ -+ const cookieHeader = socket.request.headers.cookie -+ return ( -+ readCookieFromHeader(cookieHeader, getSessionCookieName('employee')) ?? -+ readCookieFromHeader(cookieHeader, getSessionCookieName('admin')) ?? -+ readCookieFromHeader(cookieHeader, getSessionCookieName('renter')) ?? -+ undefined -+ ) -+} -+ - // Authenticate socket connections via JWT before joining user rooms - io.use((socket, next) => { -- const token = socket.handshake.auth?.token as string | undefined -+ const token = getSocketSessionToken(socket) - if (!token) return next() // unauthenticated connections allowed; they just don't join rooms - try { - const payload = verifyAnyActorToken(token) -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/middleware/rateLimiter.ts /mnt/data/car_project_leftover/apps/api/src/middleware/rateLimiter.ts ---- /mnt/data/car_project_leftover_before/apps/api/src/middleware/rateLimiter.ts 2026-06-09 20:23:21.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/api/src/middleware/rateLimiter.ts 2026-06-09 23:29:25.392962826 +0000 -@@ -1,5 +1,54 @@ - import rateLimit, { ipKeyGenerator } from 'express-rate-limit' - import type { Request } from 'express' -+import { verifyAnyActorToken } from '../security/tokens' -+import { getSessionCookieName } from '../security/sessionCookies' -+ -+ -+const SESSION_COOKIE_NAMES = [ -+ getSessionCookieName('admin'), -+ getSessionCookieName('employee'), -+ getSessionCookieName('renter'), -+] -+ -+function readCookie(cookieHeader: string | undefined, name: string): string | null { -+ if (!cookieHeader) return null -+ -+ for (const chunk of cookieHeader.split(';')) { -+ const [rawName, ...rawValue] = chunk.trim().split('=') -+ if (rawName === name) return decodeURIComponent(rawValue.join('=')) -+ } -+ -+ return null -+} -+ -+function getBearerToken(req: Request): string | null { -+ const authHeader = req.headers.authorization -+ if (!authHeader?.startsWith('Bearer ')) return null -+ const token = authHeader.slice(7).trim() -+ return token || null -+} -+ -+function getRequestToken(req: Request): string | null { -+ const cookieHeader = req.headers.cookie -+ for (const name of SESSION_COOKIE_NAMES) { -+ const token = readCookie(cookieHeader, name) -+ if (token) return token -+ } -+ -+ return getBearerToken(req) -+} -+ -+function getAuthenticatedActorKey(req: Request): string | null { -+ const token = getRequestToken(req) -+ if (!token) return null -+ -+ try { -+ const payload = verifyAnyActorToken(token) -+ return `${payload.type}:${payload.sub}` -+ } catch { -+ return null -+ } -+} - - // req.ip is already the real client IP when app.set('trust proxy', 1) is configured - const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '') -@@ -25,9 +74,10 @@ - legacyHeaders: false, - keyGenerator: (req) => { - const ip = getClientIpKey(req) -+ const actorKey = getAuthenticatedActorKey(req) - const companyId = (req as any).companyId ?? '' - const renterId = (req as any).renterId ?? '' -- return `${ip}:${companyId || renterId}` -+ return `${ip}:${actorKey || companyId || renterId || 'anonymous'}` - }, - message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 }, - }) -@@ -48,7 +98,10 @@ - max: 100, - standardHeaders: 'draft-7', - legacyHeaders: false, -- keyGenerator: (req) => getClientIpKey(req), -+ keyGenerator: (req) => { -+ const ip = getClientIpKey(req) -+ return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}` -+ }, - message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 }, - }) - -@@ -62,11 +115,12 @@ - legacyHeaders: false, - keyGenerator: (req) => { - const ip = getClientIpKey(req) -+ const actorKey = getAuthenticatedActorKey(req) - const companyId = (req as any).companyId ?? '' - const employeeId = (req as any).employee?.id ?? '' - const renterId = (req as any).renterId ?? '' - const adminId = (req as any).admin?.id ?? '' -- return `${ip}:${companyId}:${employeeId || renterId || adminId || 'anonymous'}` -+ return `${ip}:${companyId}:${actorKey || employeeId || renterId || adminId || 'anonymous'}` - }, - message: { error: 'too_many_requests', message: 'Authenticated rate limit exceeded', statusCode: 429 }, - }) -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.repo.ts /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.repo.ts ---- /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.repo.ts 2026-06-09 20:16:42.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.repo.ts 2026-06-09 23:30:31.717917907 +0000 -@@ -58,6 +58,28 @@ - return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } }) - } - -+ -+export async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) { -+ return prisma.$transaction(async (tx) => { -+ await tx.adminRecoveryCode.deleteMany({ where: { adminUserId } }) -+ await tx.adminRecoveryCode.createMany({ -+ data: codeHashes.map((codeHash) => ({ adminUserId, codeHash })), -+ }) -+ }) -+} -+ -+export function listUnusedAdminRecoveryCodes(adminUserId: string) { -+ return prisma.adminRecoveryCode.findMany({ -+ where: { adminUserId, usedAt: null }, -+ select: { id: true, codeHash: true }, -+ orderBy: { createdAt: 'asc' }, -+ }) -+} -+ -+export function markAdminRecoveryCodeUsed(id: string) { -+ return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } }) -+} -+ - export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) { - return prisma.adminUser.update({ - where: { id }, -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.routes.ts /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.routes.ts ---- /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.routes.ts 2026-06-09 20:20:36.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.routes.ts 2026-06-09 23:31:00.522967384 +0000 -@@ -42,8 +42,8 @@ - - router.post('/auth/login', async (req, res, next) => { - try { -- const { email, password, totpCode } = parseBody(loginSchema, req) -- const result = await service.login(email, password, totpCode) -+ const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req) -+ const result = await service.login(email, password, totpCode, recoveryCode) - if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) - if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 }) - if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 }) -@@ -92,7 +92,13 @@ - const result = await service.verifyTotp(req.admin.id, code) - if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 }) - setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000) -- ok(res, { success: true, admin: result.admin }) -+ ok(res, { success: true, admin: result.admin, recoveryCodes: result.recoveryCodes }) -+ } catch (err) { next(err) } -+}) -+ -+router.post('/auth/2fa/recovery-codes/regenerate', requireAdminAuth, requireFreshAdmin2FA, async (req, res, next) => { -+ try { -+ ok(res, await service.regenerateRecoveryCodes(req.admin.id)) - } catch (err) { next(err) } - }) - -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.schemas.ts /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.schemas.ts ---- /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.schemas.ts 2026-06-09 19:40:36.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.schemas.ts 2026-06-09 23:31:00.520959931 +0000 -@@ -5,6 +5,7 @@ - email: z.string().email().max(255).trim().toLowerCase(), - password: z.string().max(128), - totpCode: z.string().length(6).optional(), -+ recoveryCode: z.string().min(8).max(32).optional(), - }) - - export const forgotPasswordSchema = z.object({ -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.service.ts /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.service.ts ---- /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.service.ts 2026-06-09 20:20:34.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.service.ts 2026-06-09 23:30:44.361011542 +0000 -@@ -10,6 +10,47 @@ - import * as billingService from './admin.billing.service' - - const ADMIN_RESET_TTL_MINUTES = 60 -+const ADMIN_RECOVERY_CODE_COUNT = 10 -+ -+ -+function generateRecoveryCode() { -+ const raw = crypto.randomBytes(9).toString('base64url').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 12) -+ return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}` -+} -+ -+async function issueAdminRecoveryCodes(adminId: string) { -+ const codes = Array.from({ length: ADMIN_RECOVERY_CODE_COUNT }, generateRecoveryCode) -+ const hashes = await Promise.all(codes.map((code) => bcrypt.hash(code, 12))) -+ await repo.replaceAdminRecoveryCodes(adminId, hashes) -+ await repo.createAuditLog({ -+ adminUserId: adminId, -+ action: 'ADMIN_2FA_RECOVERY_CODES_ISSUED', -+ resource: 'AdminUser', -+ resourceId: adminId, -+ }) -+ return codes -+} -+ -+async function consumeAdminRecoveryCode(adminId: string, code: string) { -+ const normalized = code.trim().toUpperCase() -+ if (!normalized) return false -+ -+ const codes = await repo.listUnusedAdminRecoveryCodes(adminId) -+ for (const candidate of codes) { -+ if (await bcrypt.compare(normalized, candidate.codeHash)) { -+ await repo.markAdminRecoveryCodeUsed(candidate.id) -+ await repo.createAuditLog({ -+ adminUserId: adminId, -+ action: 'ADMIN_2FA_RECOVERY_CODE_USED', -+ resource: 'AdminUser', -+ resourceId: adminId, -+ }) -+ return true -+ } -+ } -+ -+ return false -+} - - function signAdminToken(adminId: string, last2faAt?: number) { - return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt }) -@@ -31,7 +72,7 @@ - } - } - --export async function login(email: string, password: string, totpCode?: string) { -+export async function login(email: string, password: string, totpCode?: string, recoveryCode?: string) { - const admin = await repo.findAdminByEmail(email) - if (!admin || !admin.isActive) return null - -@@ -39,8 +80,16 @@ - if (!valid) return null - - if (admin.totpEnabled) { -- if (!totpCode) return { totpRequired: true } as const -- if (!authenticator.verify({ token: totpCode, secret: admin.totpSecret! })) { -+ if (!totpCode && !recoveryCode) return { totpRequired: true } as const -+ -+ const validTotp = totpCode -+ ? authenticator.verify({ token: totpCode, secret: admin.totpSecret! }) -+ : false -+ const validRecoveryCode = !validTotp && recoveryCode -+ ? await consumeAdminRecoveryCode(admin.id, recoveryCode) -+ : false -+ -+ if (!validTotp && !validRecoveryCode) { - return { invalidTotp: true } as const - } - } -@@ -78,7 +127,15 @@ - resource: 'AdminUser', - resourceId: adminId, - }) -- return presenter.presentAdminSession({ ...admin, totpEnabled: true }, signAdminToken(adminId, Date.now())) -+ const recoveryCodes = await issueAdminRecoveryCodes(adminId) -+ return { -+ ...presenter.presentAdminSession({ ...admin, totpEnabled: true }, signAdminToken(adminId, Date.now())), -+ recoveryCodes, -+ } -+} -+ -+export async function regenerateRecoveryCodes(adminId: string) { -+ return { recoveryCodes: await issueAdminRecoveryCodes(adminId) } - } - - export async function forgotPassword(email: string) { -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/swagger/openapi.ts /mnt/data/car_project_leftover/apps/api/src/swagger/openapi.ts ---- /mnt/data/car_project_leftover_before/apps/api/src/swagger/openapi.ts 2026-06-09 19:40:36.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/api/src/swagger/openapi.ts 2026-06-09 23:33:40.274784566 +0000 -@@ -96,7 +96,7 @@ - openapi: '3.0.3', - info: { - title: 'RentalDriveGo API', -- description: 'REST API for the RentalDriveGo car rental management platform.\n\n**Auth:** Most endpoints require a Bearer JWT. Obtain a token via `/auth/employee/login` (dashboard/admin users) or via Firebase for renters.', -+ description: 'REST API for the RentalDriveGo car rental management platform.\n\n**Auth:** Most browser endpoints use HttpOnly session cookies issued by `/auth/employee/login`, `/admin/auth/login`, or renter auth. Trusted server/mobile contexts may still use Bearer JWTs where documented.', - version: '1.0.0', - contact: { name: 'RentalDriveGo', email: 'support@rentaldrivego.com' }, - }, -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/README.md /mnt/data/car_project_leftover/apps/dashboard/README.md ---- /mnt/data/car_project_leftover_before/apps/dashboard/README.md 2026-06-09 20:06:04.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/dashboard/README.md 2026-06-09 23:33:32.875527724 +0000 -@@ -89,7 +89,7 @@ - src/hooks/ - useTeam.ts team management data/actions - src/lib/ -- api.ts fetch wrapper and auth token handling -+ api.ts cookie-aware API fetch wrapper - preferences.ts language/theme persistence scoped per employee - dashboardPaths.ts normalizes basePath-aware routes - urls.ts resolves marketplace/admin app links -@@ -102,7 +102,7 @@ - - That is a deliberate fit for the app because the dashboard relies heavily on: - --- local auth state from `localStorage` -+- HttpOnly session-cookie authentication - - cookie and embedded-host redirects - - optimistic UI updates - - direct REST fetches after mount -@@ -117,7 +117,7 @@ - - ## Auth and Session Model - --The dashboard uses employee JWTs issued by the API. -+The dashboard uses API-issued employee sessions stored in an HttpOnly cookie. - - ### Login flow - -@@ -132,7 +132,7 @@ - - - language and theme query params - - embedded usage through `postMessage` --- redirect handoff to the admin app when an admin token is returned instead of an employee token -+- redirect handoff to the admin app after the API sets the admin HttpOnly session cookie - - ### Protected-route middleware - -@@ -221,7 +221,7 @@ - - current theme: `light`, `dark` - - full in-app copy dictionaries - - persistence to cookies and `localStorage` --- per-employee scoped preferences using the decoded JWT subject -+- per-employee scoped preferences using non-sensitive cached profile metadata - - `src/lib/preferences.ts` is the key utility here. It stores both: - -@@ -238,7 +238,7 @@ - - Browser fetch behavior: - --- reads the JWT from `localStorage` -+- never reads authentication tokens from `localStorage` - - sends authenticated requests with the HttpOnly session cookie - - defaults JSON requests to `Content-Type: application/json` - - preserves `FormData` for file uploads -@@ -252,7 +252,7 @@ - - ### `apiFetchServer` - --Server-side helper for routes/components that already have a token and need `cache: 'no-store'`. -+Server-side helper for routes/components that explicitly receive a trusted server-side token and need `cache: 'no-store'`. Browser code should use HttpOnly cookies instead. - - ### Pattern used across pages - -@@ -646,7 +646,7 @@ - It also coordinates with sibling apps: - - - `marketplaceUrl` links staff back to the public marketplace domain --- `adminUrl` supports token handoff into the admin interface -+- `adminUrl` supports cookie-based handoff into the admin interface - - host-aware URL rewriting allows the same app to function under external domains, internal Docker hostnames, and proxied environments - - ## API Dependency Summary -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/app/(dashboard)/team/page.tsx /mnt/data/car_project_leftover/apps/dashboard/src/app/(dashboard)/team/page.tsx ---- /mnt/data/car_project_leftover_before/apps/dashboard/src/app/(dashboard)/team/page.tsx 2026-06-09 19:40:36.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/dashboard/src/app/(dashboard)/team/page.tsx 2026-06-09 23:27:26.237955395 +0000 -@@ -4,7 +4,7 @@ - import InviteModal from '@/components/team/InviteModal' - import EditMemberModal from '@/components/team/EditMemberModal' - import PermissionsMatrix from '@/components/team/PermissionsMatrix' --import { EMPLOYEE_TOKEN_KEY } from '@/lib/api' -+import { apiFetch } from '@/lib/api' - import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam' - import { useDashboardI18n } from '@/components/I18nProvider' - -@@ -193,18 +193,17 @@ - const [toast, setToast] = useState(null) - - useEffect(() => { -- const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY) -- if (!token) return -+ let cancelled = false - -- try { -- const encoded = token.split('.')[1] ?? '' -- const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/') -- const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') -- const payload = JSON.parse(atob(padded)) -- setCurrentEmployeeId(typeof payload?.sub === 'string' ? payload.sub : null) -- } catch { -- setCurrentEmployeeId(null) -- } -+ apiFetch<{ employee: { id: string } }>('/auth/employee/me') -+ .then(({ employee }) => { -+ if (!cancelled) setCurrentEmployeeId(employee.id) -+ }) -+ .catch(() => { -+ if (!cancelled) setCurrentEmployeeId(null) -+ }) -+ -+ return () => { cancelled = true } - }, []) - - const currentEmployee = members.find((member) => member.id === currentEmployeeId) -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx /mnt/data/car_project_leftover/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx ---- /mnt/data/car_project_leftover_before/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx 2026-06-09 19:47:19.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx 2026-06-09 23:31:15.852597731 +0000 -@@ -7,7 +7,7 @@ - import { useDashboardI18n } from '@/components/I18nProvider' - import PublicShell from '@/components/layout/PublicShell' - import { adminUrl, marketplaceUrl } from '@/lib/urls' --import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api' -+import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api' - import { toPublicDashboardPath } from '@/lib/dashboardPaths' - - const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png' -@@ -295,11 +295,16 @@ - setError(null) - - try { -+ const normalizedCode = totpCode.trim().toUpperCase() -+ const secondFactor = /^\d{6}$/.test(normalizedCode) -+ ? { totpCode: normalizedCode } -+ : { recoveryCode: normalizedCode } -+ - const adminRes = await fetch(`${API_BASE}/admin/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', -- body: JSON.stringify({ email, password, totpCode }), -+ body: JSON.stringify({ email, password, ...secondFactor }), - }) - const adminJson = await adminRes.json() - -@@ -393,13 +398,13 @@ - - setTotpCode(e.target.value.replace(/\D/g, ''))} -- placeholder="000000" -+ onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())} -+ placeholder="000000 or XXXX-XXXX-XXXX" - className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-center text-xl tracking-[0.45em] text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500" - /> - -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/components/layout/Sidebar.tsx /mnt/data/car_project_leftover/apps/dashboard/src/components/layout/Sidebar.tsx ---- /mnt/data/car_project_leftover_before/apps/dashboard/src/components/layout/Sidebar.tsx 2026-06-09 19:47:35.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/dashboard/src/components/layout/Sidebar.tsx 2026-06-09 23:27:14.281452585 +0000 -@@ -25,7 +25,7 @@ - } from 'lucide-react' - import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider' - import { marketplaceUrl } from '@/lib/urls' --import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api' -+import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api' - import { toDashboardAppPath } from '@/lib/dashboardPaths' - import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences' - -@@ -166,9 +166,6 @@ - }, []) - - useEffect(() => { -- const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY) -- if (!token) return -- - let cancelled = false - - async function loadBrand() { -@@ -202,16 +199,6 @@ - }, []) - - useEffect(() => { -- const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY) -- if (!token) { -- setUser({ -- displayName: dict.workspaceUser, -- subtitle: dict.localAuth, -- initials: 'W', -- }) -- return -- } -- - const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY) - if (cached) { - try { -@@ -344,7 +331,6 @@ - } - - function signOut() { -- localStorage.removeItem(EMPLOYEE_TOKEN_KEY) - localStorage.removeItem(EMPLOYEE_PROFILE_KEY) - void fetch('/dashboard/api/v1/auth/employee/logout', { method: 'POST', credentials: 'include' }) - window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed')) -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/components/layout/TopBar.tsx /mnt/data/car_project_leftover/apps/dashboard/src/components/layout/TopBar.tsx ---- /mnt/data/car_project_leftover_before/apps/dashboard/src/components/layout/TopBar.tsx 2026-06-09 19:40:36.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/dashboard/src/components/layout/TopBar.tsx 2026-06-09 23:27:06.243088516 +0000 -@@ -4,7 +4,7 @@ - import { usePathname, useRouter } from 'next/navigation' - import { useState, useEffect } from 'react' - import { io } from 'socket.io-client' --import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api' -+import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api' - import { useDashboardI18n } from '@/components/I18nProvider' - import { toDashboardAppPath } from '@/lib/dashboardPaths' - -@@ -52,11 +52,6 @@ - const [mounted, setMounted] = useState(false) - useEffect(() => { setMounted(true) }, []) - -- function getEmployeeToken() { -- if (typeof window === 'undefined') return null -- return window.localStorage.getItem(EMPLOYEE_TOKEN_KEY) -- } -- - const title = (() => { - if (!mounted) return dict.titles['/'] - if (dict.titles[appPath]) return dict.titles[appPath] -@@ -65,15 +60,12 @@ - return dict.titles['/'] - })() - async function refreshUnreadCount() { -- if (!getEmployeeToken()) { -- setUnreadCount(0) -- return -- } -- - try { - const data = await apiFetch<{ unread: number }>('/notifications/unread-count') - setUnreadCount(data.unread) -- } catch {} -+ } catch { -+ setUnreadCount(0) -+ } - } - - useEffect(() => { -@@ -89,15 +81,12 @@ - }, []) - - useEffect(() => { -- const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY) -- if (!token) return -- - const socketOrigin = resolveSocketOrigin() - if (!socketOrigin) return - - const socket = io(socketOrigin, { - autoConnect: false, -- auth: { token }, -+ withCredentials: true, - transports: ['polling', 'websocket'], - }) - -@@ -121,11 +110,6 @@ - - useEffect(() => { - if (!showNotifs) return -- if (!getEmployeeToken()) { -- setNotifications([]) -- setLoadingNotifs(false) -- return -- } - - let cancelled = false - setLoadingNotifs(true) -@@ -157,12 +141,6 @@ - }, [pathname]) - - useEffect(() => { -- const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY) -- if (!token) { -- setUserInitials(computeInitials(dict.workspaceUser)) -- return -- } -- - const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY) - if (cached) { - try { -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/lib/api.ts /mnt/data/car_project_leftover/apps/dashboard/src/lib/api.ts ---- /mnt/data/car_project_leftover_before/apps/dashboard/src/lib/api.ts 2026-06-09 20:04:58.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/dashboard/src/lib/api.ts 2026-06-09 23:26:56.191813135 +0000 -@@ -3,16 +3,9 @@ - ? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1') - : (process.env.NEXT_PUBLIC_API_URL || '/dashboard/api/v1') - --export const EMPLOYEE_SESSION_COOKIE = 'employee_session' --export const EMPLOYEE_TOKEN_KEY = EMPLOYEE_SESSION_COOKIE - export const EMPLOYEE_PROFILE_KEY = 'employee_profile' - --async function getAuthToken(): Promise { -- return null --} -- - export async function apiFetch(path: string, options?: RequestInit): Promise { -- const token = await getAuthToken() - const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData - - const headers: Record = { -@@ -23,10 +16,6 @@ - headers['Content-Type'] = 'application/json' - } - -- if (token) { -- headers['Authorization'] = `Bearer ${token}` -- } -- - const res = await fetch(`${API_BASE}${path}`, { - ...options, - headers, -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/middleware.test.ts /mnt/data/car_project_leftover/apps/dashboard/src/middleware.test.ts ---- /mnt/data/car_project_leftover_before/apps/dashboard/src/middleware.test.ts 2026-06-09 20:03:48.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/dashboard/src/middleware.test.ts 2026-06-09 23:32:41.502961148 +0000 -@@ -1,15 +1,20 @@ - import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - --const nextServer = vi.hoisted(() => ({ -- redirect: vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() })), -- next: vi.fn(() => ({ kind: 'next' })), --})) -+const nextServer = vi.hoisted(() => { -+ const redirect = vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() })) -+ const next = vi.fn(() => ({ kind: 'next' })) -+ const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({ -+ kind: 'response', -+ body, -+ status: init?.status, -+ })) as any -+ MockNextResponse.redirect = redirect -+ MockNextResponse.next = next -+ return { redirect, next, MockNextResponse } -+}) - - vi.mock('next/server', () => ({ -- NextResponse: { -- redirect: nextServer.redirect, -- next: nextServer.next, -- }, -+ NextResponse: nextServer.MockNextResponse, - })) - - function cloneableUrl(input: string): URL { -@@ -27,6 +32,7 @@ - }, - headers: { - get: vi.fn((name: string) => headers.get(name.toLowerCase()) ?? null), -+ has: vi.fn((name: string) => headers.has(name.toLowerCase())), - }, - } - } -@@ -48,6 +54,16 @@ - }) - - describe('dashboard middleware', () => { -+ -+ it('rejects middleware subrequest headers at the app layer', async () => { -+ const { default: middleware } = await loadMiddleware() -+ -+ const response = middleware(request('https://workspace.example.com/dashboard', { -+ headers: { 'x-middleware-subrequest': 'middleware:middleware' }, -+ }) as never) -+ -+ expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 }) -+ }) - it('deduplicates accidental repeated /dashboard prefixes before auth handling', async () => { - const { default: middleware } = await loadMiddleware() - -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/middleware.ts /mnt/data/car_project_leftover/apps/dashboard/src/middleware.ts ---- /mnt/data/car_project_leftover_before/apps/dashboard/src/middleware.ts 2026-06-09 20:03:48.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/dashboard/src/middleware.ts 2026-06-09 23:29:06.360416065 +0000 -@@ -4,6 +4,12 @@ - const PUBLIC_DASHBOARD_PREFIXES = ['/dashboard/sign-in', '/dashboard/forgot-password'] - const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000' - -+ -+function rejectInternalSubrequest(req: NextRequest): NextResponse | null { -+ if (!req.headers.has('x-middleware-subrequest')) return null -+ return new NextResponse('Unsupported internal request header', { status: 400 }) -+} -+ - function dedupeDashboardPath(pathname: string): string { - return pathname.replace(/^\/dashboard\/dashboard(?=\/|$)/, '/dashboard') - } -@@ -54,6 +60,9 @@ - } - - export default function middleware(req: NextRequest) { -+ const rejected = rejectInternalSubrequest(req) -+ if (rejected) return rejected -+ - const dedupedPath = dedupeDashboardPath(req.nextUrl.pathname) - if (dedupedPath !== req.nextUrl.pathname) { - const redirectUrl = req.nextUrl.clone() -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/marketplace/src/middleware.test.ts /mnt/data/car_project_leftover/apps/marketplace/src/middleware.test.ts ---- /mnt/data/car_project_leftover_before/apps/marketplace/src/middleware.test.ts 2026-06-09 19:56:56.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/marketplace/src/middleware.test.ts 2026-06-09 23:32:41.504284890 +0000 -@@ -1,19 +1,24 @@ - import { beforeEach, describe, expect, it, vi } from 'vitest' - --const nextServer = vi.hoisted(() => ({ -- next: vi.fn((init?: { request?: { headers?: Headers } }) => ({ -+const nextServer = vi.hoisted(() => { -+ const next = vi.fn((init?: { request?: { headers?: Headers } }) => ({ - kind: 'next', - init, - cookies: { - set: vi.fn(), - }, -- })), --})) -+ })) -+ const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({ -+ kind: 'response', -+ body, -+ status: init?.status, -+ })) as any -+ MockNextResponse.next = next -+ return { next, MockNextResponse } -+}) - - vi.mock('next/server', () => ({ -- NextResponse: { -- next: nextServer.next, -- }, -+ NextResponse: nextServer.MockNextResponse, - })) - - function request(options: { cookies?: Record; headers?: Record } = {}) { -@@ -35,6 +40,17 @@ - }) - - describe('marketplace middleware language bootstrap', () => { -+ -+ it('rejects middleware subrequest headers before language handling', async () => { -+ const { middleware } = await import('./middleware') -+ -+ const response = middleware(request({ -+ headers: { 'x-middleware-subrequest': 'middleware:middleware' }, -+ }) as never) as any -+ -+ expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 }) -+ expect(nextServer.next).not.toHaveBeenCalled() -+ }) - it('does nothing when the canonical shared language cookie is valid', async () => { - const { middleware } = await import('./middleware') - -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/marketplace/src/middleware.ts /mnt/data/car_project_leftover/apps/marketplace/src/middleware.ts ---- /mnt/data/car_project_leftover_before/apps/marketplace/src/middleware.ts 2026-06-09 19:40:36.000000000 +0000 -+++ /mnt/data/car_project_leftover/apps/marketplace/src/middleware.ts 2026-06-09 23:29:06.361354861 +0000 -@@ -4,6 +4,12 @@ - const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language' - const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language' - -+ -+function rejectInternalSubrequest(request: NextRequest): NextResponse | null { -+ if (!request.headers.has('x-middleware-subrequest')) return null -+ return new NextResponse('Unsupported internal request header', { status: 400 }) -+} -+ - function isValidLanguage(val: string | null | undefined): val is 'en' | 'fr' | 'ar' { - return val === 'en' || val === 'fr' || val === 'ar' - } -@@ -18,6 +24,9 @@ - } - - export function middleware(request: NextRequest) { -+ const rejected = rejectInternalSubrequest(request) -+ if (rejected) return rejected -+ - const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value - - // Already has the canonical language cookie — no action needed -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/docs/project-design/COOKIE_POLICY.md /mnt/data/car_project_leftover/docs/project-design/COOKIE_POLICY.md ---- /mnt/data/car_project_leftover_before/docs/project-design/COOKIE_POLICY.md 2026-06-09 19:40:36.000000000 +0000 -+++ /mnt/data/car_project_leftover/docs/project-design/COOKIE_POLICY.md 2026-06-09 23:33:22.297991839 +0000 -@@ -28,13 +28,13 @@ - - | Field | Value | - |---|---| --| **Name** | `employee_token` | -+| **Name** | `employee_session` | - | **Category** | Strictly necessary | - | **Duration** | 8 hours (session) | - | **Scope** | All pages (`path=/`) | - | **Third-party** | No | - --**Purpose:** This cookie is set when an employee or administrator signs in to the RentalDriveGo workspace. It stores a cryptographically signed token (JWT) that proves your identity to the platform. Without this cookie, the dashboard and protected API endpoints cannot verify who you are and will redirect you to the sign-in page. -+**Purpose:** This cookie is set when an employee signs in to the RentalDriveGo workspace. Admins receive a separate `admin_session` cookie. It stores a cryptographically signed token (JWT) that proves your identity to the platform. Without this cookie, the dashboard and protected API endpoints cannot verify who you are and will redirect you to the sign-in page. - - **When it is set:** On a successful sign-in. - **When it is removed:** When you sign out, or automatically after 8 hours of inactivity. -@@ -148,9 +148,7 @@ - - ## 7. Security - --The authentication cookie (`employee_token`) is signed and expires after 8 hours. It is transmitted over HTTPS in production. We recommend using the platform on trusted devices only and signing out when you are finished. -- --Note for our technical team: the `HttpOnly` and `Secure` flags should be added to `employee_token` in a future release to further limit exposure to XSS and mixed-content attacks. -+The authentication cookies (`employee_session` and `admin_session`) are signed, HttpOnly, and expire after the configured session window. They use `Secure` in production and are not readable by JavaScript. We recommend using the platform on trusted devices only and signing out when you are finished. - - --- - -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/memory/project_auth_architecture.md /mnt/data/car_project_leftover/memory/project_auth_architecture.md ---- /mnt/data/car_project_leftover_before/memory/project_auth_architecture.md 2026-06-09 19:40:36.000000000 +0000 -+++ /mnt/data/car_project_leftover/memory/project_auth_architecture.md 2026-06-09 23:33:22.297150216 +0000 -@@ -6,21 +6,21 @@ - - Three-tier SaaS auth system (RentalDriveGo car management monorepo): - --1. **Admins** — JWT auth (`type: 'admin'`), stored in `localStorage.admin_token` in admin app (port 3002) --2. **Employees (Owner/Manager/Agent)** — Clerk OAuth when configured, local JWT (`type: 'employee'`) when Clerk is not configured. Token stored in `localStorage.employee_token` + `employee_token` cookie in dashboard app (port 3001) -+1. **Admins** — JWT auth (`type: 'admin'`) issued into the HttpOnly `admin_session` cookie in the admin app (port 3002) -+2. **Employees (Owner/Manager/Agent)** — Clerk OAuth when configured, local JWT (`type: 'employee'`) when Clerk is not configured. The API stores the session in the HttpOnly `employee_session` cookie; browser code may cache profile/preferences, but not auth tokens. - 3. **Renters** — JWT infrastructure exists but login is currently disabled (returns 403) - - **Unified login page:** `apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx` - - Shows Clerk component when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` is a real key - - Otherwise shows local form that tries employee login, then admin login --- Admin users are redirected to admin app via `${ADMIN_URL}/auth-redirect#token=xxx` -+- Admin users are redirected after the API sets `admin_session`; URL-fragment token handoff is legacy and should not return - - **Key API endpoints:** --- `POST /api/v1/auth/employee/login` — local employee auth (email + password → JWT) --- `POST /api/v1/admin/auth/login` — admin auth (email + password + optional TOTP → JWT) -+- `POST /api/v1/auth/employee/login` — local employee auth (email + password → HttpOnly session cookie) -+- `POST /api/v1/admin/auth/login` — admin auth (email + password + TOTP or recovery code when enabled → HttpOnly session cookie) - - `POST /api/v1/auth/company/signup` — company signup (accepts optional `password` field) - --**requireCompanyAuth middleware** — accepts local employee JWT OR Clerk session token. -+**requireCompanyAuth middleware** — accepts the `employee_session` HttpOnly cookie, and still supports trusted Bearer tokens for server/mobile/integration contexts. - - **Why:** Clerk keys were placeholder values (`pk_test_your_real_publishable_key_here`), making the login page non-functional. Added local JWT auth as a fully working fallback. - -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql /mnt/data/car_project_leftover/packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql ---- /mnt/data/car_project_leftover_before/packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql 1970-01-01 00:00:00.000000000 +0000 -+++ /mnt/data/car_project_leftover/packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql 2026-06-09 23:30:23.805811712 +0000 -@@ -0,0 +1,16 @@ -+-- CreateTable -+CREATE TABLE "admin_recovery_codes" ( -+ "id" TEXT NOT NULL, -+ "adminUserId" TEXT NOT NULL, -+ "codeHash" TEXT NOT NULL, -+ "usedAt" TIMESTAMP(3), -+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, -+ -+ CONSTRAINT "admin_recovery_codes_pkey" PRIMARY KEY ("id") -+); -+ -+-- CreateIndex -+CREATE INDEX "admin_recovery_codes_adminUserId_usedAt_idx" ON "admin_recovery_codes"("adminUserId", "usedAt"); -+ -+-- AddForeignKey -+ALTER TABLE "admin_recovery_codes" ADD CONSTRAINT "admin_recovery_codes_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "admin_users"("id") ON DELETE CASCADE ON UPDATE CASCADE; -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/packages/database/prisma/schema.prisma /mnt/data/car_project_leftover/packages/database/prisma/schema.prisma ---- /mnt/data/car_project_leftover_before/packages/database/prisma/schema.prisma 2026-06-09 23:18:30.000000000 +0000 -+++ /mnt/data/car_project_leftover/packages/database/prisma/schema.prisma 2026-06-09 23:30:23.805117403 +0000 -@@ -1793,8 +1793,9 @@ - passwordResetToken String? @unique - passwordResetExpiresAt DateTime? - -- auditLogs AuditLog[] -- permissions AdminPermission[] -+ auditLogs AuditLog[] -+ permissions AdminPermission[] -+ recoveryCodes AdminRecoveryCode[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -@@ -1813,6 +1814,19 @@ - @@map("admin_permissions") - } - -+ -+model AdminRecoveryCode { -+ id String @id @default(cuid()) -+ adminUserId String -+ adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade) -+ codeHash String -+ usedAt DateTime? -+ createdAt DateTime @default(now()) -+ -+ @@index([adminUserId, usedAt]) -+ @@map("admin_recovery_codes") -+} -+ - model AuditLog { - id String @id @default(cuid()) - adminUserId String -diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/scripts/security-static-check.mjs /mnt/data/car_project_leftover/scripts/security-static-check.mjs ---- /mnt/data/car_project_leftover_before/scripts/security-static-check.mjs 2026-06-09 19:48:22.000000000 +0000 -+++ /mnt/data/car_project_leftover/scripts/security-static-check.mjs 2026-06-09 23:30:00.568225381 +0000 -@@ -17,7 +17,7 @@ - walk(root) - - const findings = [] --const authTokenStorage = /localStorage\.(setItem|getItem)\(['"](?:employee_token|admin_token|renter_token)['"]|document\.cookie\s*=\s*['"](?:employee_token|admin_token|renter_token)=/i -+const authTokenStorage = /localStorage\.(?:setItem|getItem|removeItem)\(\s*(?:['"](?:employee_token|admin_token|renter_token|employee_session|admin_session|renter_session)['"]|[A-Z0-9_]*TOKEN[A-Z0-9_]*)|document\.cookie\s*=\s*['"](?:employee_token|admin_token|renter_token|employee_session|admin_session|renter_session)=/i - const rawWebhookStringify = /JSON\.stringify\(req\.body\)/i - const secretAssignment = /^(JWT_SECRET|DATABASE_URL|POSTGRES_PASSWORD|SMTP_PASSWORD|REGISTRY_PASSWORD|REGISTRY_HTTP_SECRET|AMANPAY_WEBHOOK_SECRET|PAYPAL_WEBHOOK_SECRET|CLERK_SECRET_KEY|RESEND_API_KEY|MAIL_PASSWORD|FIREBASE_PRIVATE_KEY|TWILIO_AUTH_TOKEN|PAYPAL_CLIENT_SECRET|AMANPAY_SECRET_KEY|CLOUDINARY_API_SECRET|ADMIN_SEED_PASSWORD)\s*=\s*(.+)$/i - diff --git a/docs/security_hardening_leftover_changed_files.txt b/docs/security_hardening_leftover_changed_files.txt deleted file mode 100644 index 7906e6bf..00000000 --- a/docs/security_hardening_leftover_changed_files.txt +++ /dev/null @@ -1,32 +0,0 @@ -A SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md -M apps/admin/src/app/dashboard/admin-users/page.tsx -M apps/admin/src/app/dashboard/companies/[id]/page.tsx -M apps/admin/src/app/dashboard/containers/page.tsx -M apps/admin/src/app/dashboard/pricing/page.tsx -M apps/admin/src/app/dashboard/renters/page.tsx -M apps/admin/src/app/forgot-password/page.tsx -M apps/admin/src/app/reset-password/page.tsx -A apps/admin/src/middleware.ts -M apps/api/src/app.ts -M apps/api/src/index.ts -M apps/api/src/middleware/rateLimiter.ts -M apps/api/src/modules/admin/admin.repo.ts -M apps/api/src/modules/admin/admin.routes.ts -M apps/api/src/modules/admin/admin.schemas.ts -M apps/api/src/modules/admin/admin.service.ts -M apps/api/src/swagger/openapi.ts -M apps/dashboard/README.md -M apps/dashboard/src/app/(dashboard)/team/page.tsx -M apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx -M apps/dashboard/src/components/layout/Sidebar.tsx -M apps/dashboard/src/components/layout/TopBar.tsx -M apps/dashboard/src/lib/api.ts -M apps/dashboard/src/middleware.test.ts -M apps/dashboard/src/middleware.ts -M apps/marketplace/src/middleware.test.ts -M apps/marketplace/src/middleware.ts -M docs/project-design/COOKIE_POLICY.md -M memory/project_auth_architecture.md -A packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql -M packages/database/prisma/schema.prisma -M scripts/security-static-check.mjs diff --git a/docs/website-admin-menu-management-plan.md b/docs/website-admin-menu-management-plan.md deleted file mode 100644 index 08708a2a..00000000 --- a/docs/website-admin-menu-management-plan.md +++ /dev/null @@ -1,615 +0,0 @@ -# Website Admin Menu Management Plan for Company Users - -## 1. Goal - -Build a website admin system that allows platform administrators to manage menu items shown to company users. - -The menu should be controlled centrally by website admins. Companies should automatically receive default menu items based on their subscription plan. Website admins should also be able to add extra menu items for individual companies when needed. - -Company admins should not manage menus unless this is introduced later as a separate feature. - ---- - -## 2. Core Concept - -The final menu shown to a company user should come from two sources: - -```text -Final User Menu = Subscription Default Menu Items + Company-Specific Menu Items -``` - -Where: - -```text -Subscription Default Menu Items: -Managed by website admins and assigned based on subscription plan. - -Company-Specific Menu Items: -Managed by website admins and assigned to one company or selected companies. -``` - -This creates centralized control and avoids letting every company modify the navigation structure independently. - ---- - -## 3. Admin Roles - -The system should support website-level admin roles. - -Suggested roles: - -```text -Super Admin: -- Full access to all menu management features -- Can create, edit, delete, enable, and disable menu items -- Can manage subscription menu templates -- Can assign custom menu items to specific companies - -Website Admin: -- Can manage menu items -- Can assign menu items to companies -- Cannot change billing or subscription rules unless allowed - -Support Admin: -- Can view company menus -- Can preview menus for troubleshooting -- Cannot create or edit menu items - -Company User: -- Can only see the final menu generated for their company and role -``` - ---- - -## 4. Subscription-Based Default Menus - -Each subscription plan should have a menu template managed by website admins. - -Example: - -```text -Basic Plan: -- Dashboard -- Profile -- Reports - -Pro Plan: -- Dashboard -- Profile -- Reports -- Team Management -- Advanced Analytics - -Enterprise Plan: -- Dashboard -- Profile -- Reports -- Team Management -- Advanced Analytics -- Integrations -- Audit Logs -``` - -When a company is assigned to a subscription plan, the system automatically uses that plan’s menu template. - -Website admins should be able to: - -```text -- Create subscription menu templates -- Edit default menu items for a subscription -- Reorder default menu items -- Enable or disable menu items -- Mark menu items as required -- Assign menu items to one or more subscription plans -``` - ---- - -## 5. Company-Specific Menu Items - -Website admins should be able to add menu items for a specific company without changing the global subscription template. - -Example: - -```text -Company A has Pro Plan: -- Gets all Pro default menu items -- Also gets custom item: Finance Portal - -Company B has Pro Plan: -- Gets all Pro default menu items -- Does not get Finance Portal -``` - -This allows exceptions without changing the subscription plan for every company. - ---- - -## 6. Menu Item Types - -The system should support these menu item types: - -```text -Internal Page: -Links to a page inside the application. - -External Link: -Links to an outside tool or website. - -Parent Menu: -A dropdown or folder that contains child items. - -Section Label: -A visual grouping label. - -Divider: -A visual separator. -``` - -Each menu item should include: - -```text -- Name / label -- Type -- Route or URL -- Icon -- Parent menu item -- Display order -- Status: Active or Inactive -- Open behavior: Same tab or new tab -- Required or optional -- Subscription availability -- Company assignment -- Role visibility -- Created by -- Updated by -- Created date -- Updated date -``` - ---- - -## 7. Website Admin Features - -The website admin panel should include a **Menu Management** section. - -Website admins should be able to: - -```text -- View all menu items -- Create new menu items -- Edit existing menu items -- Enable or disable menu items -- Reorder menu items -- Assign menu items to subscription plans -- Assign menu items to individual companies -- Assign menu items to user roles -- Preview a company user’s menu -- View menu history and audit logs -``` - ---- - -## 8. Menu Visibility Rules - -The system should calculate visibility using this order: - -```text -1. Subscription entitlement -2. Company-specific assignment -3. User role -4. User status -5. Menu item status -``` - -A user should see a menu item only if: - -```text -- The item is active -- The user belongs to a company -- The company has access through subscription or direct assignment -- The user’s role is allowed to see it -- The user account is active -``` - ---- - -## 9. Important Rule: Menu Visibility Is Not Security - -The menu only controls what users see in navigation. - -It must not be used as the only access control layer. - -Backend APIs and protected pages must still check: - -```text -- User authentication -- Company access -- Role permissions -- Subscription access -- Feature entitlement -``` - -A hidden menu item does not mean the page is secure. Users can still guess URLs, share links, or hit APIs directly. - ---- - -## 10. Subscription Change Behavior - -When a company upgrades or downgrades, the menu should update automatically. - -### Upgrade Example - -```text -Company moves from Basic to Pro: -- Pro default menu items become visible -- Existing company-specific menu items remain unchanged -``` - -### Downgrade Example - -```text -Company moves from Pro to Basic: -- Pro-only default menu items become hidden -- Company-specific items remain only if they do not depend on Pro-only features -``` - -Do not delete menu history or company-specific assignments during subscription changes. - -Instead, mark unavailable items as: - -```text -Unavailable due to subscription -``` - -This allows website admins to understand why an item is not visible. - ---- - -## 11. Database Model - -### subscription_plans - -```text -id -name -description -status -created_at -updated_at -``` - -### menu_items - -```text -id -label -item_type -route_or_url -icon -parent_id -display_order -open_in_new_tab -is_required -status -created_by -updated_by -created_at -updated_at -``` - -This table stores all platform-managed menu items. - -### subscription_menu_items - -```text -id -subscription_plan_id -menu_item_id -display_order -status -created_at -updated_at -``` - -This table maps menu items to subscription plans. - -### company_menu_items - -```text -id -company_id -menu_item_id -display_order -status -created_at -updated_at -``` - -This table maps extra website-admin-assigned menu items to specific companies. - -### menu_item_role_visibility - -```text -id -menu_item_id -role_id -created_at -updated_at -``` - -This table controls which roles can see each item. - -### company_subscriptions - -```text -id -company_id -subscription_plan_id -status -start_date -end_date -created_at -updated_at -``` - -### menu_audit_logs - -```text -id -admin_user_id -action_type -entity_type -entity_id -old_value -new_value -created_at -``` - ---- - -## 12. Menu Generation Logic - -When a company user logs in, the backend should generate the menu. - -Steps: - -```text -1. Identify the user. -2. Identify the user’s company. -3. Get the company’s active subscription. -4. Load menu items assigned to that subscription. -5. Load menu items assigned directly to the company. -6. Merge both sets. -7. Remove duplicates. -8. Filter by user role. -9. Remove inactive items. -10. Sort by display order. -11. Return the final menu. -``` - -Example logic: - -```text -subscriptionMenu = getMenuBySubscription(company.subscription_plan_id) -companyMenu = getMenuByCompany(company.id) - -finalMenu = merge(subscriptionMenu, companyMenu) -finalMenu = removeDuplicates(finalMenu) -finalMenu = filterByRole(finalMenu, user.role) -finalMenu = filterActiveItems(finalMenu) -finalMenu = sortByDisplayOrder(finalMenu) -``` - ---- - -## 13. Duplicate Handling - -If the same menu item exists in both the subscription menu and the company-specific menu, the system should show it once. - -Recommended priority: - -```text -Company-specific assignment overrides subscription assignment for display order only. -``` - -This means: - -```text -- The item appears once -- Website admins can customize order for that company -- The item still keeps the same underlying permissions -``` - ---- - -## 14. Admin UI Requirements - -The website admin menu management screen should include: - -```text -- Menu item list -- Create menu item button -- Edit menu item form -- Status toggle -- Subscription assignment selector -- Company assignment selector -- Role visibility selector -- Drag-and-drop ordering -- Preview by company -- Preview by role -- Audit history -``` - ---- - -## 15. Preview Feature - -Website admins should be able to preview the menu before saving or while troubleshooting. - -Preview options: - -```text -- Select company -- Select user role -- View final generated menu -- See why each item is visible or hidden -``` - -The “why visible/hidden” detail is important. - -Example: - -```text -Advanced Analytics: -Visible because company has Pro subscription. - -Audit Logs: -Hidden because company has Basic subscription. - -Finance Portal: -Visible because item is assigned directly to this company. -``` - -This will save debugging time and reduce support overhead. - ---- - -## 16. Validation Rules - -The system should enforce: - -```text -- Menu label is required -- Route or URL is required unless item is a parent menu -- External URLs must use HTTPS -- Display order must be valid -- Parent item must exist -- Child item cannot be its own parent -- Required system items cannot be disabled without Super Admin access -- Menu item cannot be assigned to an inactive subscription -- Menu item cannot be assigned to a deleted company -- Duplicate route under the same parent should be prevented -``` - ---- - -## 17. Audit Logging - -Every website admin action should be logged. - -Track: - -```text -- Admin user -- Action type -- Affected menu item -- Affected company, if any -- Affected subscription, if any -- Old value -- New value -- Timestamp -``` - -Important actions to log: - -```text -- Created menu item -- Edited menu item -- Disabled menu item -- Enabled menu item -- Assigned item to subscription -- Removed item from subscription -- Assigned item to company -- Removed item from company -- Changed display order -- Changed role visibility -``` - ---- - -## 18. Recommended MVP Scope - -The first version should include: - -```text -- Website admin menu item CRUD -- Assign menu items to subscription plans -- Assign extra menu items to specific companies -- Role-based visibility -- Active/inactive status -- Backend menu generation API -- Audit logging -- Company and role preview -``` - -Avoid these in the MVP unless absolutely necessary: - -```text -- User-specific menu visibility -- Scheduled publishing -- Custom company-managed menu editing -- Approval workflows -- Menu analytics -``` - -User-specific visibility should be avoided in the first version because it adds significant permission complexity. - ---- - -## 19. API Endpoints - -Suggested admin endpoints: - -```text -GET /admin/menu-items -POST /admin/menu-items -GET /admin/menu-items/{id} -PUT /admin/menu-items/{id} -PATCH /admin/menu-items/{id}/status -DELETE /admin/menu-items/{id} - -POST /admin/menu-items/{id}/subscriptions -DELETE /admin/menu-items/{id}/subscriptions/{subscriptionPlanId} - -POST /admin/menu-items/{id}/companies -DELETE /admin/menu-items/{id}/companies/{companyId} - -POST /admin/menu-preview -GET /admin/menu-audit-logs -``` - -Suggested user endpoint: - -```text -GET /me/menu -``` - -The user endpoint should return only the final menu available to the logged-in user. - ---- - -## 20. Success Criteria - -The feature is successful if: - -```text -- Website admins can manage all menu items centrally. -- Companies receive menu items automatically based on subscription. -- Website admins can add menu items to individual companies. -- Users only see menu items available to their company and role. -- Subscription upgrades and downgrades update menus correctly. -- Hidden menu items do not create security gaps. -- Admin changes are logged. -- Support admins can preview and debug company menus. -``` - ---- - -## 21. Key Product Decision - -Menu ownership belongs to the platform, not the company. - -That means the core system should not include company-admin menu controls. Company-specific customization should be handled by website admins through direct company assignment. - -This keeps the model simpler, safer, and easier to support. diff --git a/routes/api.php b/routes/api.php index d00de437..693af476 100644 --- a/routes/api.php +++ b/routes/api.php @@ -914,6 +914,17 @@ Route::prefix('v1')->group(function () { Route::post('supply-categories', [SupplyCategoryController::class, 'store']); Route::patch('supply-categories/{id}', [SupplyCategoryController::class, 'update']); Route::delete('supply-categories/{id}', [SupplyCategoryController::class, 'destroy']); + + // New inventory improvement endpoints + Route::get('low-stock', [InventoryController::class, 'lowStock']); + Route::get('reorder-suggestions', [InventoryController::class, 'reorderSuggestions']); + Route::post('items/{id}/reorder-request', [InventoryController::class, 'reorderRequest']); + Route::get('stock-status', [InventoryController::class, 'stockStatus']); + Route::get('dashboard', [InventoryController::class, 'dashboard']); + + // Movement void/correction (append-only ledger) + Route::post('movements/{id}/void', [InventoryMovementController::class, 'void']); + Route::post('movements/{id}/correct', [InventoryMovementController::class, 'correct']); }); Route::prefix('family-admin')->group(function () { diff --git a/src/pages/inventory/InventoryDashboardPage.tsx b/src/pages/inventory/InventoryDashboardPage.tsx new file mode 100644 index 00000000..88bc91a5 --- /dev/null +++ b/src/pages/inventory/InventoryDashboardPage.tsx @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchInventoryDashboard } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Inventory Dashboard — summary counts by type, low stock, out of stock, etc. + * Backend: GET /api/v1/administrator/inventory/dashboard + */ +export function InventoryDashboardPage() { + const [searchParams] = useSearchParams() + const [data, setData] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchInventoryDashboard(searchParams) + .then((res) => { + if (res && typeof res === 'object') { + setData(res as Record) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load dashboard.') + }) + .finally(() => setLoading(false)) + }, [searchParams]) + + useEffect(() => { + load() + }, [load]) + + const byType = (data.by_type as Record) ?? {} + const totalItems = Number(data.total_items ?? 0) + const lowStockCount = Number(data.low_stock_count ?? 0) + const outOfStockCount = Number(data.out_of_stock_count ?? 0) + const needsRepairCount = Number(data.needs_repair_count ?? 0) + const missingCount = Number(data.missing_count ?? 0) + + return ( +
+
+

Inventory Dashboard

+
+ + Stock Status + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : ( +
+ {/* Summary Cards */} +
+
+
+
{totalItems}
+

Total Items

+
+
+
+ +
+
+
+
{lowStockCount}
+

Low Stock Items

+ {lowStockCount > 0 ? ( + + View Low Stock + + ) : null} +
+
+
+ +
+
+
+
{outOfStockCount}
+

Out of Stock

+
+
+
+ +
+
+
+
{needsRepairCount}
+

Needs Repair

+
+
+
+ +
+
+
+
{missingCount}
+

Missing / Cannot Find

+
+
+
+ + {/* Items by Type */} +
+
+
Items by Type
+
+ {Object.keys(byType).length === 0 ? ( +

No items found.

+ ) : ( +
+ + + + + + + + + {Object.entries(byType).map(([type, count]) => ( + + + + + ))} + +
TypeCount
{type}{Number(count).toLocaleString()}
+
+ )} +
+
+
+ + {/* Quick Links */} +
+
+
Quick Actions
+
+
+ + Classroom Equipment + + + Books + + + Office Supplies + + + Kitchen Supplies + + + Movements + + + Summary + + + Reorder Suggestions + + + Low Stock + +
+
+
+
+
+ )} +
+ ) +} diff --git a/src/pages/inventory/InventoryLowStockPage.tsx b/src/pages/inventory/InventoryLowStockPage.tsx new file mode 100644 index 00000000..446589b8 --- /dev/null +++ b/src/pages/inventory/InventoryLowStockPage.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchLowStock } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Low Stock Items — items where quantity <= reorder_point. + * Backend: GET /api/v1/administrator/inventory/low-stock + */ +export function InventoryLowStockPage() { + const [items, setItems] = useState[]>([]) + const [count, setCount] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchLowStock() + .then((res) => { + if (res && typeof res === 'object') { + const o = res as Record + const arr = Array.isArray(o.items) ? (o.items as Record[]) : [] + setItems(arr) + setCount(Number(o.count ?? arr.length)) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load low stock items.') + }) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { + load() + }, [load]) + + return ( +
+
+

Low Stock Items ({count})

+
+ + Reorder Suggestions + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : items.length === 0 ? ( +
+ All stocked up! No items are below their reorder point. +
+ ) : ( +
+
+ + + + + + + + + + + + + + + + {items.map((item) => { + const i = item as Record + const supplier = i.preferred_supplier as Record | null + return ( + + + + + + + + + + + + ) + })} + +
NameTypeCategoryCurrent QtyReorder PointReorder QtyLead Time (Days)Preferred SupplierActions
{String(i.name ?? '')}{String(i.type ?? '')}{String((i.category as Record)?.name ?? i.category ?? '')} + {Number(i.quantity ?? 0).toLocaleString()} + {Number(i.reorder_point ?? 0).toLocaleString()}{Number(i.reorder_quantity ?? 0).toLocaleString() || '—'}{Number(i.lead_time_days ?? 0) || '—'}{supplier ? String(supplier.name ?? '') : '—'} + + Adjust Stock + +
+
+
+ )} +
+ ) +} diff --git a/src/pages/inventory/InventoryReorderSuggestionsPage.tsx b/src/pages/inventory/InventoryReorderSuggestionsPage.tsx new file mode 100644 index 00000000..5e7f8df9 --- /dev/null +++ b/src/pages/inventory/InventoryReorderSuggestionsPage.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchReorderSuggestions } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Reorder Suggestions — calculated suggested order quantities for low-stock items. + * Backend: GET /api/v1/administrator/inventory/reorder-suggestions + */ +export function InventoryReorderSuggestionsPage() { + const [suggestions, setSuggestions] = useState[]>([]) + const [count, setCount] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchReorderSuggestions() + .then((res) => { + if (res && typeof res === 'object') { + const o = res as Record + const arr = Array.isArray(o.suggestions) ? (o.suggestions as Record[]) : [] + setSuggestions(arr) + setCount(Number(o.count ?? arr.length)) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load reorder suggestions.') + }) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { + load() + }, [load]) + + return ( +
+
+

Reorder Suggestions ({count})

+
+ + Low Stock + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : suggestions.length === 0 ? ( +
+ All stocked up! No reorder suggestions at this time. +
+ ) : ( +
+
+ + + + + + + + + + + + + + {suggestions.map((s) => { + const supplier = s.preferred_supplier as Record | null + return ( + + + + + + + + + + ) + })} + +
NameCategoryCurrent QtyReorder PointSuggested OrderPreferred SupplierActions
{String(s.name ?? '')}{String(s.category ?? '—')} + {Number(s.current_quantity ?? 0).toLocaleString()} + {Number(s.reorder_point ?? 0).toLocaleString()} + {Number(s.suggested_order_qty ?? 0).toLocaleString()} + {supplier ? String(supplier.name ?? '') : '—'} + + Adjust Stock + +
+
+
+ )} +
+ ) +} diff --git a/src/pages/inventory/InventoryStockStatusPage.tsx b/src/pages/inventory/InventoryStockStatusPage.tsx new file mode 100644 index 00000000..71e0917d --- /dev/null +++ b/src/pages/inventory/InventoryStockStatusPage.tsx @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { fetchStockStatus } from '../../api/inventory' +import { INVENTORY_BOOK_BASE } from './inventoryPaths' + +/** + * Stock Status — all items with status: ok/low_stock/out_of_stock. + * Backend: GET /api/v1/administrator/inventory/stock-status + */ +export function InventoryStockStatusPage() { + const [searchParams, setSearchParams] = useSearchParams() + const [items, setItems] = useState[]>([]) + const [counts, setCounts] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(() => { + setLoading(true) + fetchStockStatus(searchParams) + .then((res) => { + if (res && typeof res === 'object') { + const o = res as Record + setItems(Array.isArray(o.items) ? (o.items as Record[]) : []) + setCounts((o.counts as Record) ?? {}) + } + setError(null) + }) + .catch((e: unknown) => { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load stock status.') + }) + .finally(() => setLoading(false)) + }, [searchParams]) + + useEffect(() => { + load() + }, [load]) + + function onTypeFilter(v: string) { + const next = new URLSearchParams(searchParams) + if (v) next.set('type', v) + else next.delete('type') + setSearchParams(next) + } + + const currentType = searchParams.get('type') ?? '' + + return ( +
+
+

Stock Status

+
+ + Low Stock + + + Reorder Suggestions + + + Back to Items + +
+
+ + {error ?
{error}
: null} + + {loading ? ( +

Loading…

+ ) : ( + <> + {/* Summary Badges */} +
+ + OK: {counts.ok ?? 0} + + + Low Stock: {counts.low_stock ?? 0} + + + Out of Stock: {counts.out_of_stock ?? 0} + +
+ +
+
+ All Items + +
+
+ + + + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((item) => { + const status = String(item.status ?? 'ok') + const badgeClass = + status === 'out_of_stock' + ? 'bg-danger' + : status === 'low_stock' + ? 'bg-warning' + : 'bg-success' + const badgeLabel = + status === 'out_of_stock' + ? 'Out of Stock' + : status === 'low_stock' + ? 'Low Stock' + : 'OK' + return ( + + + + + + + + + + ) + }) + )} + +
NameTypeCategoryQuantityReorder PointStatusActions
+ No items found. +
{String(item.name ?? '')}{String(item.type ?? '')}{String(item.category ?? '—')}{Number(item.quantity ?? 0).toLocaleString()} + {item.reorder_point != null ? Number(item.reorder_point).toLocaleString() : '—'} + + {badgeLabel} + + + Adjust + +
+
+
+ + )} +
+ ) +}