fix inventory
This commit is contained in:
+183
@@ -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<Record<string, unknown>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchInventoryDashboard(searchParams)
|
||||||
|
.then((res) => {
|
||||||
|
if (res && typeof res === 'object') {
|
||||||
|
setData(res as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
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<string, number>) ?? {}
|
||||||
|
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 (
|
||||||
|
<div className="container-fluid px-0 mt-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||||
|
<h3 className="mb-0">Inventory Dashboard</h3>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/stock-status">
|
||||||
|
Stock Status
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||||
|
Back to Items
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted px-3">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="row g-3 px-3">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<div className="card border-primary h-100">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<h5 className="card-title text-primary">{totalItems}</h5>
|
||||||
|
<p className="card-text">Total Items</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<div className="card border-warning h-100">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<h5 className="card-title text-warning">{lowStockCount}</h5>
|
||||||
|
<p className="card-text">Low Stock Items</p>
|
||||||
|
{lowStockCount > 0 ? (
|
||||||
|
<Link className="btn btn-sm btn-warning" to="/app/administrator/inventory/low-stock">
|
||||||
|
View Low Stock
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<div className="card border-danger h-100">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<h5 className="card-title text-danger">{outOfStockCount}</h5>
|
||||||
|
<p className="card-text">Out of Stock</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<div className="card border-info h-100">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<h5 className="card-title text-info">{needsRepairCount}</h5>
|
||||||
|
<p className="card-text">Needs Repair</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<div className="card border-secondary h-100">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<h5 className="card-title text-secondary">{missingCount}</h5>
|
||||||
|
<p className="card-text">Missing / Cannot Find</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items by Type */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">Items by Type</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{Object.keys(byType).length === 0 ? (
|
||||||
|
<p className="text-muted mb-0">No items found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th className="text-end">Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(byType).map(([type, count]) => (
|
||||||
|
<tr key={type}>
|
||||||
|
<td style={{ textTransform: 'capitalize' }}>{type}</td>
|
||||||
|
<td className="text-end">{Number(count).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">Quick Actions</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/classroom">
|
||||||
|
Classroom Equipment
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to={INVENTORY_BOOK_BASE}>
|
||||||
|
Books
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/office">
|
||||||
|
Office Supplies
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/kitchen">
|
||||||
|
Kitchen Supplies
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/movements">
|
||||||
|
Movements
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/summary">
|
||||||
|
Summary
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/reorder-suggestions">
|
||||||
|
Reorder Suggestions
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/low-stock">
|
||||||
|
Low Stock
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+112
@@ -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<Record<string, unknown>[]>([])
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchLowStock()
|
||||||
|
.then((res) => {
|
||||||
|
if (res && typeof res === 'object') {
|
||||||
|
const o = res as Record<string, unknown>
|
||||||
|
const arr = Array.isArray(o.items) ? (o.items as Record<string, unknown>[]) : []
|
||||||
|
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 (
|
||||||
|
<div className="container-fluid px-0 mt-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||||
|
<h3 className="mb-0">Low Stock Items ({count})</h3>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/reorder-suggestions">
|
||||||
|
Reorder Suggestions
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||||
|
Back to Items
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted px-3">Loading…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="alert alert-success mx-3">
|
||||||
|
<strong>All stocked up!</strong> No items are below their reorder point.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card mx-3">
|
||||||
|
<div className="card-body table-responsive">
|
||||||
|
<table className="table table-striped align-middle" id="lowStockTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th className="text-end">Current Qty</th>
|
||||||
|
<th className="text-end">Reorder Point</th>
|
||||||
|
<th className="text-end">Reorder Qty</th>
|
||||||
|
<th className="text-end">Lead Time (Days)</th>
|
||||||
|
<th>Preferred Supplier</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => {
|
||||||
|
const i = item as Record<string, unknown>
|
||||||
|
const supplier = i.preferred_supplier as Record<string, unknown> | null
|
||||||
|
return (
|
||||||
|
<tr key={String(i.id)}>
|
||||||
|
<td>{String(i.name ?? '')}</td>
|
||||||
|
<td style={{ textTransform: 'capitalize' }}>{String(i.type ?? '')}</td>
|
||||||
|
<td>{String((i.category as Record<string, unknown>)?.name ?? i.category ?? '')}</td>
|
||||||
|
<td className="text-end text-danger fw-semibold">
|
||||||
|
{Number(i.quantity ?? 0).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">{Number(i.reorder_point ?? 0).toLocaleString()}</td>
|
||||||
|
<td className="text-end">{Number(i.reorder_quantity ?? 0).toLocaleString() || '—'}</td>
|
||||||
|
<td className="text-end">{Number(i.lead_time_days ?? 0) || '—'}</td>
|
||||||
|
<td>{supplier ? String(supplier.name ?? '') : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-warning"
|
||||||
|
to={`/app/administrator/inventory/items/${String(i.id)}/adjust`}
|
||||||
|
>
|
||||||
|
Adjust Stock
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+109
@@ -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<Record<string, unknown>[]>([])
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchReorderSuggestions()
|
||||||
|
.then((res) => {
|
||||||
|
if (res && typeof res === 'object') {
|
||||||
|
const o = res as Record<string, unknown>
|
||||||
|
const arr = Array.isArray(o.suggestions) ? (o.suggestions as Record<string, unknown>[]) : []
|
||||||
|
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 (
|
||||||
|
<div className="container-fluid px-0 mt-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||||
|
<h3 className="mb-0">Reorder Suggestions ({count})</h3>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link className="btn btn-outline-warning btn-sm" to="/app/administrator/inventory/low-stock">
|
||||||
|
Low Stock
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||||
|
Back to Items
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted px-3">Loading…</p>
|
||||||
|
) : suggestions.length === 0 ? (
|
||||||
|
<div className="alert alert-success mx-3">
|
||||||
|
<strong>All stocked up!</strong> No reorder suggestions at this time.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card mx-3">
|
||||||
|
<div className="card-body table-responsive">
|
||||||
|
<table className="table table-striped align-middle" id="reorderSuggestionsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th className="text-end">Current Qty</th>
|
||||||
|
<th className="text-end">Reorder Point</th>
|
||||||
|
<th className="text-end">Suggested Order</th>
|
||||||
|
<th>Preferred Supplier</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{suggestions.map((s) => {
|
||||||
|
const supplier = s.preferred_supplier as Record<string, unknown> | null
|
||||||
|
return (
|
||||||
|
<tr key={String(s.id)}>
|
||||||
|
<td>{String(s.name ?? '')}</td>
|
||||||
|
<td>{String(s.category ?? '—')}</td>
|
||||||
|
<td className="text-end text-danger fw-semibold">
|
||||||
|
{Number(s.current_quantity ?? 0).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">{Number(s.reorder_point ?? 0).toLocaleString()}</td>
|
||||||
|
<td className="text-end fw-semibold">
|
||||||
|
{Number(s.suggested_order_qty ?? 0).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td>{supplier ? String(supplier.name ?? '') : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-warning"
|
||||||
|
to={`/app/administrator/inventory/items/${String(s.id)}/adjust`}
|
||||||
|
>
|
||||||
|
Adjust Stock
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+167
@@ -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<Record<string, unknown>[]>([])
|
||||||
|
const [counts, setCounts] = useState<Record<string, number>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchStockStatus(searchParams)
|
||||||
|
.then((res) => {
|
||||||
|
if (res && typeof res === 'object') {
|
||||||
|
const o = res as Record<string, unknown>
|
||||||
|
setItems(Array.isArray(o.items) ? (o.items as Record<string, unknown>[]) : [])
|
||||||
|
setCounts((o.counts as Record<string, number>) ?? {})
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div className="container-fluid px-0 mt-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||||
|
<h3 className="mb-0">Stock Status</h3>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link className="btn btn-outline-warning btn-sm" to="/app/administrator/inventory/low-stock">
|
||||||
|
Low Stock
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/reorder-suggestions">
|
||||||
|
Reorder Suggestions
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||||
|
Back to Items
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted px-3">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Summary Badges */}
|
||||||
|
<div className="d-flex gap-3 mb-3 px-3 flex-wrap">
|
||||||
|
<span className="badge bg-success fs-6">
|
||||||
|
OK: {counts.ok ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="badge bg-warning fs-6">
|
||||||
|
Low Stock: {counts.low_stock ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="badge bg-danger fs-6">
|
||||||
|
Out of Stock: {counts.out_of_stock ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mx-3">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<span>All Items</span>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
value={currentType}
|
||||||
|
onChange={(e) => onTypeFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="book">Books</option>
|
||||||
|
<option value="classroom">Classroom</option>
|
||||||
|
<option value="office">Office</option>
|
||||||
|
<option value="kitchen">Kitchen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="card-body table-responsive">
|
||||||
|
<table className="table table-striped align-middle" id="stockStatusTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th className="text-end">Quantity</th>
|
||||||
|
<th className="text-end">Reorder Point</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="text-center text-muted py-4">
|
||||||
|
No items found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<tr key={String(item.id)}>
|
||||||
|
<td>{String(item.name ?? '')}</td>
|
||||||
|
<td style={{ textTransform: 'capitalize' }}>{String(item.type ?? '')}</td>
|
||||||
|
<td>{String(item.category ?? '—')}</td>
|
||||||
|
<td className="text-end">{Number(item.quantity ?? 0).toLocaleString()}</td>
|
||||||
|
<td className="text-end">
|
||||||
|
{item.reorder_point != null ? Number(item.reorder_point).toLocaleString() : '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${badgeClass}`}>{badgeLabel}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-outline-primary me-1"
|
||||||
|
to={`/app/administrator/inventory/items/${String(item.id)}/adjust`}
|
||||||
|
>
|
||||||
|
Adjust
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Inventory\InventoryMovementService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class InventoryReconcile extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'inventory:reconcile
|
||||||
|
{--school-year= : The school year to reconcile (e.g., 2025-2026). Defaults to current.}
|
||||||
|
{--fix : Automatically fix discrepancies by creating adjustment movements.}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Reconcile inventory item quantities against movement ledger totals';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(InventoryMovementService $movementService)
|
||||||
|
{
|
||||||
|
$schoolYear = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ use App\Http\Requests\Inventory\InventoryItemUpdateRequest;
|
|||||||
use App\Http\Requests\Inventory\InventoryTeacherDistributionRequest;
|
use App\Http\Requests\Inventory\InventoryTeacherDistributionRequest;
|
||||||
use App\Http\Requests\Inventory\InventoryTeacherDistributionStoreRequest;
|
use App\Http\Requests\Inventory\InventoryTeacherDistributionStoreRequest;
|
||||||
use App\Http\Resources\Inventory\InventoryItemResource;
|
use App\Http\Resources\Inventory\InventoryItemResource;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
use App\Services\Inventory\InventoryItemService;
|
use App\Services\Inventory\InventoryItemService;
|
||||||
|
use App\Services\Inventory\InventoryMovementService;
|
||||||
use App\Services\Inventory\InventorySummaryService;
|
use App\Services\Inventory\InventorySummaryService;
|
||||||
use App\Services\Inventory\InventoryTeacherDistributionService;
|
use App\Services\Inventory\InventoryTeacherDistributionService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -23,7 +25,8 @@ class InventoryController extends BaseApiController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private InventoryItemService $items,
|
private InventoryItemService $items,
|
||||||
private InventorySummaryService $summary,
|
private InventorySummaryService $summary,
|
||||||
private InventoryTeacherDistributionService $distribution
|
private InventoryTeacherDistributionService $distribution,
|
||||||
|
private InventoryMovementService $movementService
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -182,6 +185,161 @@ class InventoryController extends BaseApiController
|
|||||||
return $this->success($result);
|
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
|
* @return int|JsonResponse
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -73,6 +73,57 @@ class InventoryMovementController extends BaseApiController
|
|||||||
return $this->success($result);
|
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
|
* @return int|JsonResponse
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ class InventoryItemStoreRequest extends ApiFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'type' => ['required', 'string', 'max:20'],
|
'type' => ['required', 'string', 'in:classroom,book,office,kitchen'],
|
||||||
'category_id' => ['nullable', 'integer', 'min:1'],
|
'category_id' => ['nullable', 'integer', 'min:1', 'exists:inventory_categories,id'],
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string'],
|
||||||
'quantity' => ['nullable', 'integer'],
|
'quantity' => ['nullable', 'integer', 'min:0'],
|
||||||
'unit' => ['nullable', 'string', 'max:50'],
|
'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'],
|
'isbn' => ['nullable', 'string', 'max:50'],
|
||||||
'edition' => ['nullable', 'string', 'max:50'],
|
'edition' => ['nullable', 'string', 'max:50'],
|
||||||
'sku' => ['nullable', 'string', 'max:50'],
|
'sku' => ['nullable', 'string', 'max:50'],
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ class InventoryItemUpdateRequest extends ApiFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'category_id' => ['nullable', 'integer', 'min:1'],
|
'category_id' => ['nullable', 'integer', 'min:1', 'exists:inventory_categories,id'],
|
||||||
'name' => ['sometimes', 'string', 'max:255'],
|
'name' => ['sometimes', 'string', 'max:255'],
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string'],
|
||||||
'quantity' => ['nullable', 'integer'],
|
// quantity is intentionally excluded: stock changes must go through movements
|
||||||
'unit' => ['nullable', 'string', 'max:50'],
|
'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'],
|
'isbn' => ['nullable', 'string', 'max:50'],
|
||||||
'edition' => ['nullable', 'string', 'max:50'],
|
'edition' => ['nullable', 'string', 'max:50'],
|
||||||
'sku' => ['nullable', 'string', 'max:50'],
|
'sku' => ['nullable', 'string', 'max:50'],
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ class InventoryItemResource extends JsonResource
|
|||||||
'semester' => $this['semester'] ?? null,
|
'semester' => $this['semester'] ?? null,
|
||||||
'school_year' => $this['school_year'] ?? null,
|
'school_year' => $this['school_year'] ?? null,
|
||||||
'updated_by' => $this['updated_by'] ?? 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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ class InventoryItem extends BaseModel
|
|||||||
'needs_repair_qty',
|
'needs_repair_qty',
|
||||||
'need_replace_qty',
|
'need_replace_qty',
|
||||||
'cannot_find_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 = [
|
protected $casts = [
|
||||||
@@ -41,6 +52,13 @@ class InventoryItem extends BaseModel
|
|||||||
'needs_repair_qty' => 'integer',
|
'needs_repair_qty' => 'integer',
|
||||||
'need_replace_qty' => 'integer',
|
'need_replace_qty' => 'integer',
|
||||||
'cannot_find_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 */
|
/* Optional relationships */
|
||||||
@@ -48,4 +66,25 @@ class InventoryItem extends BaseModel
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(InventoryCategory::class, 'category_id');
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,13 @@ class InventoryMovement extends BaseModel
|
|||||||
'teacher_id',
|
'teacher_id',
|
||||||
'student_id',
|
'student_id',
|
||||||
'class_section_id',
|
'class_section_id',
|
||||||
|
// Audit/correction fields
|
||||||
|
'voided_at',
|
||||||
|
'voided_by',
|
||||||
|
'void_reason',
|
||||||
|
'corrects_movement_id',
|
||||||
|
'source_type',
|
||||||
|
'source_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -32,8 +39,23 @@ class InventoryMovement extends BaseModel
|
|||||||
'teacher_id' => 'integer',
|
'teacher_id' => 'integer',
|
||||||
'student_id' => 'integer',
|
'student_id' => 'integer',
|
||||||
'class_section_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 */
|
/* Optional relationships */
|
||||||
public function item()
|
public function item()
|
||||||
{
|
{
|
||||||
@@ -59,4 +81,19 @@ class InventoryMovement extends BaseModel
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(ClassSection::class, 'class_section_id');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ class PurchaseOrderItem extends BaseModel
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'purchase_order_id',
|
'purchase_order_id',
|
||||||
'supply_id',
|
'supply_id',
|
||||||
|
'inventory_item_id',
|
||||||
'description',
|
'description',
|
||||||
'quantity',
|
'quantity',
|
||||||
'received_qty',
|
'received_qty',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class InventoryMovementService
|
|||||||
{
|
{
|
||||||
$selectedYear = trim((string) ($filters['school_year'] ?? $this->context->schoolYear()));
|
$selectedYear = trim((string) ($filters['school_year'] ?? $this->context->schoolYear()));
|
||||||
$selectedSem = trim((string) ($filters['semester'] ?? $this->context->semester()));
|
$selectedSem = trim((string) ($filters['semester'] ?? $this->context->semester()));
|
||||||
|
$includeVoided = !empty($filters['include_voided']);
|
||||||
|
|
||||||
$builder = DB::table('inventory_movements as m')
|
$builder = DB::table('inventory_movements as m')
|
||||||
->select([
|
->select([
|
||||||
@@ -34,6 +35,11 @@ class InventoryMovementService
|
|||||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'm.class_section_id')
|
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'm.class_section_id')
|
||||||
->orderBy('m.id', 'DESC');
|
->orderBy('m.id', 'DESC');
|
||||||
|
|
||||||
|
// Exclude voided movements by default
|
||||||
|
if (!$includeVoided) {
|
||||||
|
$builder->whereNull('m.voided_at');
|
||||||
|
}
|
||||||
|
|
||||||
if ($selectedYear !== '') {
|
if ($selectedYear !== '') {
|
||||||
$builder->where('m.school_year', $selectedYear);
|
$builder->where('m.school_year', $selectedYear);
|
||||||
}
|
}
|
||||||
@@ -44,6 +50,10 @@ class InventoryMovementService
|
|||||||
return $builder->get()->map(fn ($r) => (array) $r)->all();
|
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
|
public function create(array $data, int $performedBy = 0): array
|
||||||
{
|
{
|
||||||
$itemId = (int) ($data['item_id'] ?? 0);
|
$itemId = (int) ($data['item_id'] ?? 0);
|
||||||
@@ -60,19 +70,7 @@ class InventoryMovementService
|
|||||||
return ['ok' => false, 'message' => 'Not enough stock to apply this movement.'];
|
return ['ok' => false, 'message' => 'Not enough stock to apply this movement.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = [
|
$payload = $this->buildMovementPayload($data, $itemId, $qtyChange, $movementType, $performedBy);
|
||||||
'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),
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
InventoryMovement::query()->create($payload);
|
InventoryMovement::query()->create($payload);
|
||||||
@@ -85,6 +83,10 @@ class InventoryMovementService
|
|||||||
return ['ok' => true];
|
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
|
public function update(int $id, array $data): array
|
||||||
{
|
{
|
||||||
$movement = InventoryMovement::query()->find($id);
|
$movement = InventoryMovement::query()->find($id);
|
||||||
@@ -92,6 +94,11 @@ class InventoryMovementService
|
|||||||
return ['ok' => false, 'message' => 'Movement not found.'];
|
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);
|
$movementType = (string) ($data['movement_type'] ?? $movement->movement_type);
|
||||||
$qtyChange = (int) ($data['qty_change'] ?? $movement->qty_change);
|
$qtyChange = (int) ($data['qty_change'] ?? $movement->qty_change);
|
||||||
$qtyChange = $this->normalizeMovementQty($movementType, $qtyChange);
|
$qtyChange = $this->normalizeMovementQty($movementType, $qtyChange);
|
||||||
@@ -125,6 +132,10 @@ class InventoryMovementService
|
|||||||
return ['ok' => true];
|
return ['ok' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Do NOT hard-delete movements. Use voidMovement() instead.
|
||||||
|
* Deleting historical movements destroys audit integrity.
|
||||||
|
*/
|
||||||
public function delete(int $id): array
|
public function delete(int $id): array
|
||||||
{
|
{
|
||||||
$movement = InventoryMovement::query()->find($id);
|
$movement = InventoryMovement::query()->find($id);
|
||||||
@@ -132,6 +143,14 @@ class InventoryMovementService
|
|||||||
return ['ok' => false, 'message' => 'Movement not found.'];
|
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;
|
$itemId = (int) $movement->item_id;
|
||||||
try {
|
try {
|
||||||
$movement->delete();
|
$movement->delete();
|
||||||
@@ -146,6 +165,10 @@ class InventoryMovementService
|
|||||||
return ['ok' => true];
|
return ['ok' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Do NOT bulk-delete movements. Use voidMovement() instead.
|
||||||
|
* Deleting historical movements destroys audit integrity.
|
||||||
|
*/
|
||||||
public function bulkDelete(array $ids): array
|
public function bulkDelete(array $ids): array
|
||||||
{
|
{
|
||||||
$ids = array_values(array_filter(array_map('intval', $ids), static fn ($v) => $v > 0));
|
$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.'];
|
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')
|
$itemIds = DB::table('inventory_movements')
|
||||||
->select('item_id')
|
->select('item_id')
|
||||||
->whereIn('id', $ids)
|
->whereIn('id', $ids)
|
||||||
@@ -176,6 +207,10 @@ class InventoryMovementService
|
|||||||
return ['ok' => true, 'deleted' => count($ids)];
|
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(
|
public function recordMovement(
|
||||||
int $itemId,
|
int $itemId,
|
||||||
int $qtyChange,
|
int $qtyChange,
|
||||||
@@ -220,11 +255,107 @@ class InventoryMovementService
|
|||||||
return true;
|
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
|
public function recalcQuantity(int $itemId): void
|
||||||
{
|
{
|
||||||
$sumRow = InventoryMovement::query()
|
$sumRow = InventoryMovement::query()
|
||||||
->selectRaw('SUM(qty_change) as qty_change')
|
->selectRaw('SUM(qty_change) as qty_change')
|
||||||
->where('item_id', $itemId)
|
->where('item_id', $itemId)
|
||||||
|
->whereNull('voided_at') // Exclude voided movements from calculations
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$sum = (int) ($sumRow?->qty_change ?? 0);
|
$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
|
private function ensureInitialMovement(int $itemId): void
|
||||||
{
|
{
|
||||||
$hasMov = InventoryMovement::query()->where('item_id', $itemId)->exists();
|
$hasMov = InventoryMovement::query()->where('item_id', $itemId)->exists();
|
||||||
@@ -338,4 +510,21 @@ class InventoryMovementService
|
|||||||
}
|
}
|
||||||
return (int) $val;
|
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ use App\Models\PurchaseOrder;
|
|||||||
use App\Models\PurchaseOrderItem;
|
use App\Models\PurchaseOrderItem;
|
||||||
use App\Models\Supply;
|
use App\Models\Supply;
|
||||||
use App\Models\SupplyTransaction;
|
use App\Models\SupplyTransaction;
|
||||||
|
use App\Services\Inventory\InventoryMovementService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class PurchaseOrderReceiveService
|
class PurchaseOrderReceiveService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private InventoryMovementService $inventoryMovementService
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function receive(int $poId, array $received, string $issuedBy): array
|
public function receive(int $poId, array $received, string $issuedBy): array
|
||||||
{
|
{
|
||||||
$po = PurchaseOrder::query()->find($poId);
|
$po = PurchaseOrder::query()->find($poId);
|
||||||
@@ -51,24 +57,36 @@ class PurchaseOrderReceiveService
|
|||||||
$poItem->received_qty = (int) $poItem->received_qty + $toReceive;
|
$poItem->received_qty = (int) $poItem->received_qty + $toReceive;
|
||||||
$poItem->save();
|
$poItem->save();
|
||||||
|
|
||||||
|
// Update supply qty_on_hand (legacy support)
|
||||||
$supply = Supply::query()->find($poItem->supply_id);
|
$supply = Supply::query()->find($poItem->supply_id);
|
||||||
if (!$supply) {
|
if ($supply) {
|
||||||
$completed = false;
|
$supply->qty_on_hand = (int) $supply->qty_on_hand + $toReceive;
|
||||||
continue;
|
$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;
|
// Create inventory movement if the PO item is linked to an inventory item
|
||||||
$supply->save();
|
$inventoryItemId = $poItem->inventory_item_id;
|
||||||
|
if ($inventoryItemId) {
|
||||||
SupplyTransaction::query()->create([
|
$performedBy = is_numeric($issuedBy) ? (int) $issuedBy : null;
|
||||||
'supply_id' => $supply->id,
|
$this->inventoryMovementService->recordMovement(
|
||||||
'type' => 'in',
|
itemId: (int) $inventoryItemId,
|
||||||
'quantity' => $toReceive,
|
qtyChange: $toReceive,
|
||||||
'ref' => 'PO ' . ($po->po_number ?? $po->id),
|
type: 'in',
|
||||||
'issued_to' => 'Inventory',
|
reason: 'Purchase order received',
|
||||||
'issued_by' => $issuedBy,
|
note: 'PO ' . ($po->po_number ?? $po->id),
|
||||||
'notes' => 'Received against PO',
|
performedBy: $performedBy
|
||||||
]);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($poItem->received_qty < $poItem->quantity) {
|
if ($poItem->received_qty < $poItem->quantity) {
|
||||||
$completed = false;
|
$completed = false;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('inventory_movements', function (Blueprint $table) {
|
||||||
|
// Audit/correction fields for append-only ledger
|
||||||
|
$table->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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('inventory_items', function (Blueprint $table) {
|
||||||
|
// Supplier and reorder fields
|
||||||
|
$table->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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('purchase_order_items', function (Blueprint $table) {
|
||||||
|
if (!Schema::hasColumn('purchase_order_items', 'inventory_item_id')) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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: "..." }
|
||||||
|
```
|
||||||
@@ -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
|
|
||||||
<html lang="ar" dir="rtl">
|
|
||||||
```
|
|
||||||
|
|
||||||
or for partial rendering:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div lang="ar" dir="rtl">
|
|
||||||
...
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
<html lang="fr">
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
<html lang="en">
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -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`
|
|
||||||
@@ -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": "<known_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/<vehicleId_from_company_B>/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' <marketplace-origin>` 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` |
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
|
||||||
@@ -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 <your_admin_token>" \
|
|
||||||
-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.
|
|
||||||
@@ -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 :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
### Signature du Locataire
|
|
||||||
|
|
||||||
**Nom :** [●]
|
|
||||||
**Signature :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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 :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
**Signature du Locataire :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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 :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
**Signature du Locataire :**
|
|
||||||
|
|
||||||
<br><br>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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é.
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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 {
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -914,6 +914,17 @@ Route::prefix('v1')->group(function () {
|
|||||||
Route::post('supply-categories', [SupplyCategoryController::class, 'store']);
|
Route::post('supply-categories', [SupplyCategoryController::class, 'store']);
|
||||||
Route::patch('supply-categories/{id}', [SupplyCategoryController::class, 'update']);
|
Route::patch('supply-categories/{id}', [SupplyCategoryController::class, 'update']);
|
||||||
Route::delete('supply-categories/{id}', [SupplyCategoryController::class, 'destroy']);
|
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 () {
|
Route::prefix('family-admin')->group(function () {
|
||||||
|
|||||||
@@ -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<Record<string, unknown>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchInventoryDashboard(searchParams)
|
||||||
|
.then((res) => {
|
||||||
|
if (res && typeof res === 'object') {
|
||||||
|
setData(res as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
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<string, number>) ?? {}
|
||||||
|
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 (
|
||||||
|
<div className="container-fluid px-0 mt-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||||
|
<h3 className="mb-0">Inventory Dashboard</h3>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/stock-status">
|
||||||
|
Stock Status
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||||
|
Back to Items
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted px-3">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="row g-3 px-3">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<div className="card border-primary h-100">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<h5 className="card-title text-primary">{totalItems}</h5>
|
||||||
|
<p className="card-text">Total Items</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<div className="card border-warning h-100">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<h5 className="card-title text-warning">{lowStockCount}</h5>
|
||||||
|
<p className="card-text">Low Stock Items</p>
|
||||||
|
{lowStockCount > 0 ? (
|
||||||
|
<Link className="btn btn-sm btn-warning" to="/app/administrator/inventory/low-stock">
|
||||||
|
View Low Stock
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<div className="card border-danger h-100">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<h5 className="card-title text-danger">{outOfStockCount}</h5>
|
||||||
|
<p className="card-text">Out of Stock</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<div className="card border-info h-100">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<h5 className="card-title text-info">{needsRepairCount}</h5>
|
||||||
|
<p className="card-text">Needs Repair</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-sm-6 col-lg-4">
|
||||||
|
<div className="card border-secondary h-100">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<h5 className="card-title text-secondary">{missingCount}</h5>
|
||||||
|
<p className="card-text">Missing / Cannot Find</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items by Type */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">Items by Type</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{Object.keys(byType).length === 0 ? (
|
||||||
|
<p className="text-muted mb-0">No items found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-striped align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th className="text-end">Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(byType).map(([type, count]) => (
|
||||||
|
<tr key={type}>
|
||||||
|
<td style={{ textTransform: 'capitalize' }}>{type}</td>
|
||||||
|
<td className="text-end">{Number(count).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">Quick Actions</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/classroom">
|
||||||
|
Classroom Equipment
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to={INVENTORY_BOOK_BASE}>
|
||||||
|
Books
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/office">
|
||||||
|
Office Supplies
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/kitchen">
|
||||||
|
Kitchen Supplies
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/movements">
|
||||||
|
Movements
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/summary">
|
||||||
|
Summary
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/reorder-suggestions">
|
||||||
|
Reorder Suggestions
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/low-stock">
|
||||||
|
Low Stock
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Record<string, unknown>[]>([])
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchLowStock()
|
||||||
|
.then((res) => {
|
||||||
|
if (res && typeof res === 'object') {
|
||||||
|
const o = res as Record<string, unknown>
|
||||||
|
const arr = Array.isArray(o.items) ? (o.items as Record<string, unknown>[]) : []
|
||||||
|
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 (
|
||||||
|
<div className="container-fluid px-0 mt-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||||
|
<h3 className="mb-0">Low Stock Items ({count})</h3>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/reorder-suggestions">
|
||||||
|
Reorder Suggestions
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||||
|
Back to Items
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted px-3">Loading…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="alert alert-success mx-3">
|
||||||
|
<strong>All stocked up!</strong> No items are below their reorder point.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card mx-3">
|
||||||
|
<div className="card-body table-responsive">
|
||||||
|
<table className="table table-striped align-middle" id="lowStockTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th className="text-end">Current Qty</th>
|
||||||
|
<th className="text-end">Reorder Point</th>
|
||||||
|
<th className="text-end">Reorder Qty</th>
|
||||||
|
<th className="text-end">Lead Time (Days)</th>
|
||||||
|
<th>Preferred Supplier</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => {
|
||||||
|
const i = item as Record<string, unknown>
|
||||||
|
const supplier = i.preferred_supplier as Record<string, unknown> | null
|
||||||
|
return (
|
||||||
|
<tr key={String(i.id)}>
|
||||||
|
<td>{String(i.name ?? '')}</td>
|
||||||
|
<td style={{ textTransform: 'capitalize' }}>{String(i.type ?? '')}</td>
|
||||||
|
<td>{String((i.category as Record<string, unknown>)?.name ?? i.category ?? '')}</td>
|
||||||
|
<td className="text-end text-danger fw-semibold">
|
||||||
|
{Number(i.quantity ?? 0).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">{Number(i.reorder_point ?? 0).toLocaleString()}</td>
|
||||||
|
<td className="text-end">{Number(i.reorder_quantity ?? 0).toLocaleString() || '—'}</td>
|
||||||
|
<td className="text-end">{Number(i.lead_time_days ?? 0) || '—'}</td>
|
||||||
|
<td>{supplier ? String(supplier.name ?? '') : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-warning"
|
||||||
|
to={`/app/administrator/inventory/items/${String(i.id)}/adjust`}
|
||||||
|
>
|
||||||
|
Adjust Stock
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Record<string, unknown>[]>([])
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchReorderSuggestions()
|
||||||
|
.then((res) => {
|
||||||
|
if (res && typeof res === 'object') {
|
||||||
|
const o = res as Record<string, unknown>
|
||||||
|
const arr = Array.isArray(o.suggestions) ? (o.suggestions as Record<string, unknown>[]) : []
|
||||||
|
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 (
|
||||||
|
<div className="container-fluid px-0 mt-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||||
|
<h3 className="mb-0">Reorder Suggestions ({count})</h3>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link className="btn btn-outline-warning btn-sm" to="/app/administrator/inventory/low-stock">
|
||||||
|
Low Stock
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||||
|
Back to Items
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted px-3">Loading…</p>
|
||||||
|
) : suggestions.length === 0 ? (
|
||||||
|
<div className="alert alert-success mx-3">
|
||||||
|
<strong>All stocked up!</strong> No reorder suggestions at this time.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card mx-3">
|
||||||
|
<div className="card-body table-responsive">
|
||||||
|
<table className="table table-striped align-middle" id="reorderSuggestionsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th className="text-end">Current Qty</th>
|
||||||
|
<th className="text-end">Reorder Point</th>
|
||||||
|
<th className="text-end">Suggested Order</th>
|
||||||
|
<th>Preferred Supplier</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{suggestions.map((s) => {
|
||||||
|
const supplier = s.preferred_supplier as Record<string, unknown> | null
|
||||||
|
return (
|
||||||
|
<tr key={String(s.id)}>
|
||||||
|
<td>{String(s.name ?? '')}</td>
|
||||||
|
<td>{String(s.category ?? '—')}</td>
|
||||||
|
<td className="text-end text-danger fw-semibold">
|
||||||
|
{Number(s.current_quantity ?? 0).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">{Number(s.reorder_point ?? 0).toLocaleString()}</td>
|
||||||
|
<td className="text-end fw-semibold">
|
||||||
|
{Number(s.suggested_order_qty ?? 0).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td>{supplier ? String(supplier.name ?? '') : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-warning"
|
||||||
|
to={`/app/administrator/inventory/items/${String(s.id)}/adjust`}
|
||||||
|
>
|
||||||
|
Adjust Stock
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Record<string, unknown>[]>([])
|
||||||
|
const [counts, setCounts] = useState<Record<string, number>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchStockStatus(searchParams)
|
||||||
|
.then((res) => {
|
||||||
|
if (res && typeof res === 'object') {
|
||||||
|
const o = res as Record<string, unknown>
|
||||||
|
setItems(Array.isArray(o.items) ? (o.items as Record<string, unknown>[]) : [])
|
||||||
|
setCounts((o.counts as Record<string, number>) ?? {})
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div className="container-fluid px-0 mt-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||||
|
<h3 className="mb-0">Stock Status</h3>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link className="btn btn-outline-warning btn-sm" to="/app/administrator/inventory/low-stock">
|
||||||
|
Low Stock
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/reorder-suggestions">
|
||||||
|
Reorder Suggestions
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||||
|
Back to Items
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted px-3">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Summary Badges */}
|
||||||
|
<div className="d-flex gap-3 mb-3 px-3 flex-wrap">
|
||||||
|
<span className="badge bg-success fs-6">
|
||||||
|
OK: {counts.ok ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="badge bg-warning fs-6">
|
||||||
|
Low Stock: {counts.low_stock ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="badge bg-danger fs-6">
|
||||||
|
Out of Stock: {counts.out_of_stock ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mx-3">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<span>All Items</span>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
value={currentType}
|
||||||
|
onChange={(e) => onTypeFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="book">Books</option>
|
||||||
|
<option value="classroom">Classroom</option>
|
||||||
|
<option value="office">Office</option>
|
||||||
|
<option value="kitchen">Kitchen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="card-body table-responsive">
|
||||||
|
<table className="table table-striped align-middle" id="stockStatusTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th className="text-end">Quantity</th>
|
||||||
|
<th className="text-end">Reorder Point</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="text-center text-muted py-4">
|
||||||
|
No items found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<tr key={String(item.id)}>
|
||||||
|
<td>{String(item.name ?? '')}</td>
|
||||||
|
<td style={{ textTransform: 'capitalize' }}>{String(item.type ?? '')}</td>
|
||||||
|
<td>{String(item.category ?? '—')}</td>
|
||||||
|
<td className="text-end">{Number(item.quantity ?? 0).toLocaleString()}</td>
|
||||||
|
<td className="text-end">
|
||||||
|
{item.reorder_point != null ? Number(item.reorder_point).toLocaleString() : '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${badgeClass}`}>{badgeLabel}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-outline-primary me-1"
|
||||||
|
to={`/app/administrator/inventory/items/${String(item.id)}/adjust`}
|
||||||
|
>
|
||||||
|
Adjust
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user