Add Stock catalog (items/warehouses) and fix session persistence

Stock is scoped to a catalog + cost centers only, per direction that
physical GRV/stock-take/recipe transactions belong in an external
system (Matrix), not this accounting app. Stock Control/GRN/Variance/
PPV GL accounts are now plain postable accounts rather than locked
control accounts.

Also fixes session loss on page refresh: auth stores now persist to
localStorage, and ProtectedRoute waits for rehydration before deciding
to redirect to login.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWqiUSojMLUoN7mrJqdEbN
This commit is contained in:
Joshua Oosthuizen
2026-09-04 10:35:04 +02:00
co-authored by Claude Sonnet 5
parent 4acbc03b35
commit b137176c40
35 changed files with 1199 additions and 46 deletions
+44
View File
@@ -0,0 +1,44 @@
# Phase 4 — Stock Simplification + Session Persistence: Manual Test Checklist
This replaces the earlier version of this checklist. On review, the full transactional Stock
module (GRV, Stock Takes, Recipe production) was removed — this is accounting software, not a
stock/warehouse system, and those physical stock transactions belong in an external system
(Matrix) instead. What remains: a Stock Item catalog, Warehouses as cost centers, and a
Stock-on-Hand view that a future external feed will populate (currently empty — nothing in this
app writes to it). This checklist also covers the session-persistence fix.
## 1. Session persistence (the "logged out on refresh" fix)
- [ ] Sign in through the Manager, land in the company app
- [ ] Refresh the browser tab (F5) — you should **stay logged in**, not bounce back to the
Manager
- [ ] Close the browser tab entirely and reopen `http://localhost:5173` — you should still be
signed in
- [ ] Sign out — refreshing afterward should correctly show you signed out (not silently
restore the old session)
- [ ] Same checks on the Manager itself (`http://localhost:5174`): refresh keeps you logged in,
sign out actually signs out
## 2. Simplified Stock module
- [ ] Sidebar's **Stock** section now shows only **Stock Items** (Recipes, Goods Received,
Stock Takes are gone)
- [ ] Administration still has **Warehouses**
- [ ] "New Stock Item" only asks for code, name, and unit of measure — no costing method
- [ ] "New Warehouse" is unchanged (code, name, branch)
- [ ] Stock Items page still has an "Items" tab and a "Stock Levels" tab; the Stock Levels tab
shows a note that quantities come from an external system, and the grid is empty (that's
expected — nothing populates it yet)
- [ ] No "Match to GRV" button appears anywhere on a supplier's page
- [ ] AP invoice lines only ever offer a plain expense account — no GRV-matching option
- [ ] `GET /api/stock/grv`, `/api/stock/adjustments`, `/api/stock/recipes` all now 404 (removed)
## 3. Regression: everything else unaffected
- [ ] GL, AR, and AP all work exactly as before — Stock Control, Stock Variance, and the
account previously called "Goods Received Not Invoiced" / "Purchase Price Variance" are
now plain, normal GL accounts (no longer locked as control accounts), so they can be
posted to like any other account via a manual journal entry if you want to record a
stock-value adjustment by hand
- [ ] The manual-journal control-account lockout still correctly blocks direct postings to AR
Control, AP Control, Tax accounts, and Bank accounts
+33
View File
@@ -0,0 +1,33 @@
import { apiClient } from './client';
import type { StockItem, StockLevel } from '../types/api';
export async function fetchStockItems(): Promise<StockItem[]> {
const response = await apiClient.get<StockItem[]>('/stock/items');
return response.data;
}
export async function fetchStockItem(id: number): Promise<StockItem> {
const response = await apiClient.get<StockItem>(`/stock/items/${id}`);
return response.data;
}
export async function fetchStockLevels(warehouseId?: number): Promise<StockLevel[]> {
const response = await apiClient.get<StockLevel[]>('/stock/items/levels', { params: { warehouseId } });
return response.data;
}
export interface StockItemInput {
code: string;
name: string;
unitOfMeasure: string;
}
export async function createStockItem(input: StockItemInput): Promise<StockItem> {
const response = await apiClient.post<StockItem>('/stock/items', input);
return response.data;
}
export async function updateStockItem(id: number, input: Partial<StockItemInput> & { isActive?: boolean }): Promise<StockItem> {
const response = await apiClient.put<StockItem>(`/stock/items/${id}`, input);
return response.data;
}
+26
View File
@@ -0,0 +1,26 @@
import { apiClient } from './client';
import type { Warehouse } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
export async function fetchWarehouses(branchScope: BranchSelection): Promise<Warehouse[]> {
const response = await apiClient.get<Warehouse[]>('/stock/warehouses', {
params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope) },
});
return response.data;
}
export interface WarehouseInput {
code: string;
name: string;
branchSegmentValueId: number;
}
export async function createWarehouse(input: WarehouseInput): Promise<Warehouse> {
const response = await apiClient.post<Warehouse>('/stock/warehouses', input);
return response.data;
}
export async function updateWarehouse(id: number, input: Partial<WarehouseInput> & { isActive?: boolean }): Promise<Warehouse> {
const response = await apiClient.put<Warehouse>(`/stock/warehouses/${id}`, input);
return response.data;
}
@@ -17,7 +17,7 @@ import type { GLAccountType, DebitCredit, ControlAccountType } from '../../../ty
const ACCOUNT_TYPES: GLAccountType[] = ['ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE'];
const DEBIT_CREDIT: DebitCredit[] = ['DEBIT', 'CREDIT'];
const CONTROL_TYPES: ControlAccountType[] = ['AR', 'AP', 'STOCK', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS'];
const CONTROL_TYPES: ControlAccountType[] = ['AR', 'AP', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS'];
export function GLAccountDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { data: definitions } = useSegmentDefinitions();
@@ -0,0 +1,42 @@
import { useState } from 'react';
import { Box, Button, Card, Stack, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useWarehouses } from '../../../hooks/useWarehouses';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import type { Warehouse } from '../../../types/api';
import { WarehouseDialog } from './WarehouseDialog';
export function AdminWarehousesPage() {
const { data: warehouses, isLoading } = useWarehouses('MAIN');
const { hasPermission } = useAuth();
const [dialogOpen, setDialogOpen] = useState(false);
const columns: GridColDef<Warehouse>[] = [
{ field: 'code', headerName: 'Code', width: 140 },
{ field: 'name', headerName: 'Name', flex: 1 },
{
field: 'isActive',
headerName: 'Status',
width: 100,
renderCell: (params) => <StatusChip status={params.row.isActive ? 'ACTIVE' : 'CLOSED'} />,
},
];
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Warehouses</Typography>
{hasPermission('stock.warehouse.manage') && (
<Button variant="contained" onClick={() => setDialogOpen(true)}>
New Warehouse
</Button>
)}
</Stack>
<Card sx={{ height: 500 }}>
<DataGrid rows={warehouses ?? []} columns={columns} loading={isLoading} density="compact" disableRowSelectionOnClick sx={{ border: 'none' }} />
</Card>
<WarehouseDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
</Box>
);
}
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField } from '@mui/material';
import { useCreateWarehouse } from '../../../hooks/useWarehouses';
import { useSegmentDefinitions } from '../../../hooks/useSegments';
export function WarehouseDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const createWarehouse = useCreateWarehouse();
const { data: definitions } = useSegmentDefinitions();
const branchDef = (definitions ?? []).find((def) => def.isBranchSegment);
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [branchSegmentValueId, setBranchSegmentValueId] = useState<number | ''>('');
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
setError(null);
if (!branchSegmentValueId) {
setError('Select a branch');
return;
}
try {
await createWarehouse.mutateAsync({ code, name, branchSegmentValueId });
onClose();
setCode('');
setName('');
setBranchSegmentValueId('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create warehouse');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>New Warehouse</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Code" value={code} onChange={(e) => setCode(e.target.value)} />
<TextField label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<TextField
select
label="Branch"
value={branchSegmentValueId}
onChange={(e) => setBranchSegmentValueId(Number(e.target.value))}
>
{(branchDef?.values ?? []).map((value) => (
<MenuItem key={value.id} value={value.id}>
{value.code} — {value.description}
</MenuItem>
))}
</TextField>
{error && <span style={{ color: 'red' }}>{error}</span>}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleSubmit} disabled={createWarehouse.isPending}>
Create
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,45 @@
import { useState } from 'react';
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material';
import { useCreateStockItem } from '../../../hooks/useStockItems';
export function StockItemDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const createStockItem = useCreateStockItem();
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [unitOfMeasure, setUnitOfMeasure] = useState('');
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
setError(null);
try {
await createStockItem.mutateAsync({ code, name, unitOfMeasure });
onClose();
setCode('');
setName('');
setUnitOfMeasure('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create stock item');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>New Stock Item</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Code" value={code} onChange={(e) => setCode(e.target.value)} />
<TextField label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<TextField label="Unit of measure" value={unitOfMeasure} onChange={(e) => setUnitOfMeasure(e.target.value)} helperText="e.g. EACH, KG, LOAF" />
{error && <span style={{ color: 'red' }}>{error}</span>}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleSubmit} disabled={createStockItem.isPending}>
Create
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,78 @@
import { useState } from 'react';
import { Box, Button, Card, Stack, Tab, Tabs, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useStockItems, useStockLevels } from '../../../hooks/useStockItems';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import type { StockItem, StockLevel } from '../../../types/api';
import { StockItemDialog } from './StockItemDialog';
export function StockItemsListPage() {
const { data: items, isLoading: itemsLoading } = useStockItems();
const { data: levels, isLoading: levelsLoading } = useStockLevels();
const { hasPermission } = useAuth();
const [dialogOpen, setDialogOpen] = useState(false);
const [tab, setTab] = useState(0);
const itemColumns: GridColDef<StockItem>[] = [
{ field: 'code', headerName: 'Code', width: 140 },
{ field: 'name', headerName: 'Name', flex: 1 },
{ field: 'unitOfMeasure', headerName: 'UoM', width: 100 },
{
field: 'isActive',
headerName: 'Status',
width: 100,
renderCell: (params) => <StatusChip status={params.row.isActive ? 'ACTIVE' : 'CLOSED'} />,
},
];
const levelColumns: GridColDef<StockLevel>[] = [
{ field: 'item', headerName: 'Item', flex: 1, valueGetter: (_v, row) => row.item?.name },
{ field: 'warehouse', headerName: 'Warehouse', width: 200, valueGetter: (_v, row) => row.warehouse?.name },
{
field: 'quantityOnHand',
headerName: 'Qty On Hand',
width: 140,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.quantityOnHand} />,
},
];
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Stock Items</Typography>
{hasPermission('stock.item.manage') && (
<Button variant="contained" onClick={() => setDialogOpen(true)}>
New Stock Item
</Button>
)}
</Stack>
<Tabs value={tab} onChange={(_e, value) => setTab(value)} sx={{ mb: 2 }}>
<Tab label="Items" />
<Tab label="Stock Levels" />
</Tabs>
{tab === 0 && (
<Card sx={{ height: 650 }}>
<DataGrid rows={items ?? []} columns={itemColumns} loading={itemsLoading} density="compact" disableRowSelectionOnClick sx={{ border: 'none' }} />
</Card>
)}
{tab === 1 && (
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Quantities on hand are reported by your external stock system — nothing is entered here.
</Typography>
<Card sx={{ height: 600 }}>
<DataGrid rows={levels ?? []} columns={levelColumns} loading={levelsLoading} density="compact" disableRowSelectionOnClick sx={{ border: 'none' }} />
</Card>
</Box>
)}
<StockItemDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
</Box>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
createStockItem,
fetchStockItem,
fetchStockItems,
fetchStockLevels,
updateStockItem,
type StockItemInput,
} from '../api/stockItems.api';
export function useStockItems() {
return useQuery({ queryKey: ['stockItems'], queryFn: fetchStockItems });
}
export function useStockItem(id: number | undefined) {
return useQuery({
queryKey: ['stockItem', id],
queryFn: () => fetchStockItem(id as number),
enabled: id !== undefined,
});
}
export function useStockLevels(warehouseId?: number) {
return useQuery({ queryKey: ['stockLevels', warehouseId], queryFn: () => fetchStockLevels(warehouseId) });
}
export function useCreateStockItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createStockItem,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['stockItems'] }),
});
}
export function useUpdateStockItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: Partial<StockItemInput> & { isActive?: boolean } }) => updateStockItem(id, input),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['stockItems'] }),
});
}
+23
View File
@@ -0,0 +1,23 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createWarehouse, fetchWarehouses, updateWarehouse, type WarehouseInput } from '../api/warehouses.api';
import type { BranchSelection } from '../store/branchStore';
export function useWarehouses(branchScope: BranchSelection) {
return useQuery({ queryKey: ['warehouses', branchScope], queryFn: () => fetchWarehouses(branchScope) });
}
export function useCreateWarehouse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createWarehouse,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['warehouses'] }),
});
}
export function useUpdateWarehouse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: Partial<WarehouseInput> & { isActive?: boolean } }) => updateWarehouse(id, input),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['warehouses'] }),
});
}
+12
View File
@@ -33,6 +33,8 @@ import PercentOutlinedIcon from '@mui/icons-material/PercentOutlined';
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
import RequestQuoteOutlinedIcon from '@mui/icons-material/RequestQuoteOutlined';
import PriceCheckOutlinedIcon from '@mui/icons-material/PriceCheckOutlined';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined';
import { BranchSwitcher } from '../components/common/BranchSwitcher';
import { useAuth } from '../hooks/useAuth';
import { NAVY, NAVY_SURFACE, ACCENT_BLUE } from '../theme';
@@ -68,6 +70,10 @@ const NAV_SECTIONS: { title: string; items: NavItem[] }[] = [
{ label: 'Payments', path: '/ap/payments', icon: <PriceCheckOutlinedIcon fontSize="small" /> },
],
},
{
title: 'Stock',
items: [{ label: 'Stock Items', path: '/stock/items', icon: <Inventory2OutlinedIcon fontSize="small" /> }],
},
{
title: 'General Ledger',
items: [
@@ -108,6 +114,12 @@ const NAV_SECTIONS: { title: string; items: NavItem[] }[] = [
},
{ label: 'Users & Roles', path: '/admin/users', icon: <GroupOutlinedIcon fontSize="small" />, permission: 'admin.users.manage' },
{ label: 'Tax Codes', path: '/admin/tax-codes', icon: <PercentOutlinedIcon fontSize="small" />, permission: 'admin.tax.manage' },
{
label: 'Warehouses',
path: '/admin/warehouses',
icon: <WarehouseOutlinedIcon fontSize="small" />,
permission: 'stock.warehouse.manage',
},
],
},
];
+7
View File
@@ -33,6 +33,8 @@ import { DebitNoteDetailPage } from '../features/ap/debitNotes/DebitNoteDetailPa
import { PaymentsListPage } from '../features/ap/payments/PaymentsListPage';
import { PaymentFormPage } from '../features/ap/payments/PaymentFormPage';
import { PaymentDetailPage } from '../features/ap/payments/PaymentDetailPage';
import { AdminWarehousesPage } from '../features/admin/warehouses/AdminWarehousesPage';
import { StockItemsListPage } from '../features/stock/items/StockItemsListPage';
export function AppRouter() {
return (
@@ -68,6 +70,9 @@ export function AppRouter() {
<Route element={<ProtectedRoute permission="admin.tax.manage" />}>
<Route path="/admin/tax-codes" element={<AdminTaxCodesPage />} />
</Route>
<Route element={<ProtectedRoute permission="stock.warehouse.manage" />}>
<Route path="/admin/warehouses" element={<AdminWarehousesPage />} />
</Route>
<Route path="/ar/customers" element={<CustomersListPage />} />
<Route path="/ar/customers/:id" element={<CustomerDetailPage />} />
@@ -102,6 +107,8 @@ export function AppRouter() {
<Route element={<ProtectedRoute permission="ap.payment.manage" />}>
<Route path="/ap/payments/new" element={<PaymentFormPage />} />
</Route>
<Route path="/stock/items" element={<StockItemsListPage />} />
</Route>
</Route>
+14 -4
View File
@@ -1,7 +1,8 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Alert, Box, CircularProgress } from '@mui/material';
import { useAuth } from '../hooks/useAuth';
import { useAuthStore } from '../store/authStore';
interface ProtectedRouteProps {
permission?: string;
@@ -11,14 +12,23 @@ const managerUrl = import.meta.env.VITE_MANAGER_URL ?? 'http://localhost:5174';
export function ProtectedRoute({ permission }: ProtectedRouteProps) {
const { isAuthenticated, hasPermission } = useAuth();
// The auth store rehydrates from localStorage asynchronously — without waiting for that,
// isAuthenticated reads false on the very first render after a page reload and this would
// bounce straight to the Manager before the persisted session ever gets a chance to load.
const [hasHydrated, setHasHydrated] = useState(useAuthStore.persist.hasHydrated());
useEffect(() => {
if (!isAuthenticated) {
if (hasHydrated) return;
return useAuthStore.persist.onFinishHydration(() => setHasHydrated(true));
}, [hasHydrated]);
useEffect(() => {
if (hasHydrated && !isAuthenticated) {
window.location.href = managerUrl;
}
}, [isAuthenticated]);
}, [hasHydrated, isAuthenticated]);
if (!isAuthenticated) {
if (!hasHydrated || !isAuthenticated) {
return (
<Box sx={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
+9 -4
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface AuthUser {
id: number;
@@ -19,9 +20,10 @@ interface AuthState {
hasPermission: (permissionKey: string) => boolean;
}
// Tokens are kept in memory only (not localStorage) to limit XSS exposure — a page reload
// requires signing in again in Phase 1. See the implementation plan §4.5.
export const useAuthStore = create<AuthState>((set, get) => ({
// Persisted to localStorage so a page reload doesn't force signing in again via the Manager.
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
accessToken: null,
refreshToken: null,
user: null,
@@ -29,4 +31,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
setAccessToken: (accessToken) => set({ accessToken }),
clearSession: () => set({ accessToken: null, refreshToken: null, user: null }),
hasPermission: (permissionKey) => get().user?.permissions.includes(permissionKey) ?? false,
}));
}),
{ name: 'cfm-auth-session' },
),
);
+32 -1
View File
@@ -1,6 +1,6 @@
export type GLAccountType = 'ASSET' | 'LIABILITY' | 'EQUITY' | 'INCOME' | 'EXPENSE';
export type DebitCredit = 'DEBIT' | 'CREDIT';
export type ControlAccountType = 'AR' | 'AP' | 'STOCK' | 'TAX_OUTPUT' | 'TAX_INPUT' | 'BANK' | 'RETAINED_EARNINGS';
export type ControlAccountType = 'AR' | 'AP' | 'TAX_OUTPUT' | 'TAX_INPUT' | 'BANK' | 'RETAINED_EARNINGS';
export type SegmentFormat = 'NUMERIC' | 'ALPHA' | 'ALPHANUMERIC';
export type JournalType = 'GENERAL' | 'SALES' | 'PURCHASES' | 'CASH_RECEIPT' | 'CASH_PAYMENT' | 'STOCK' | 'BANK' | 'CUSTOM';
export type JournalEntryStatus = 'DRAFT' | 'POSTED' | 'REVERSED';
@@ -377,3 +377,34 @@ export interface AppUser {
roles: { role: Role }[];
branchRestrictions: { branchSegmentValueId: number }[];
}
// Deliberately minimal: a catalog (StockItem) + cost centers (Warehouse) + a SOH view
// (StockLevel) for a future external stock system to populate — no transactional stock
// documents (GRV, stock takes, recipes) live in this app. See prisma/schema.prisma.
export interface Warehouse {
id: number;
code: string;
name: string;
branchSegmentValueId: number;
isActive: boolean;
}
export interface StockLevel {
id: number;
itemId: number;
item?: StockItem;
warehouseId: number;
warehouse?: Warehouse;
quantityOnHand: string;
updatedAt: string;
}
export interface StockItem {
id: number;
code: string;
name: string;
unitOfMeasure: string;
isActive: boolean;
stockLevels?: StockLevel[];
createdAt: string;
}
@@ -1,8 +1,27 @@
import { useEffect, useState } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { Box, CircularProgress } from '@mui/material';
import { useAuth } from '../hooks/useAuth';
import { useAuthStore } from '../store/authStore';
export function ProtectedRoute() {
const { isAuthenticated } = useAuth();
// Wait for the persisted session to rehydrate from localStorage before deciding to redirect —
// otherwise a page reload always bounces to /login for a frame before the session loads.
const [hasHydrated, setHasHydrated] = useState(useAuthStore.persist.hasHydrated());
useEffect(() => {
if (hasHydrated) return;
return useAuthStore.persist.onFinishHydration(() => setHasHydrated(true));
}, [hasHydrated]);
if (!hasHydrated) {
return (
<Box sx={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
+9 -4
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface AuthPerson {
id: number;
@@ -16,13 +17,17 @@ interface AuthState {
clearSession: () => void;
}
// Tokens kept in memory only, same rationale as the company app: limits XSS exposure at the
// cost of needing to sign in again on a hard page reload.
export const useAuthStore = create<AuthState>((set) => ({
// Persisted to localStorage so a page reload doesn't force signing in again.
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
person: null,
setSession: ({ accessToken, refreshToken, person }) => set({ accessToken, refreshToken, person }),
setAccessToken: (accessToken) => set({ accessToken }),
clearSession: () => set({ accessToken: null, refreshToken: null, person: null }),
}));
}),
{ name: 'account-manager-auth-session' },
),
);
@@ -0,0 +1,285 @@
-- CreateEnum
CREATE TYPE "CostingMethod" AS ENUM ('MANUAL', 'LATEST', 'MOVING_AVERAGE');
-- CreateEnum
CREATE TYPE "StockMovementType" AS ENUM ('RECEIPT', 'ISSUE', 'TRANSFER_IN', 'TRANSFER_OUT', 'CONSUMPTION', 'PRODUCTION_IN', 'PRODUCTION_OUT', 'ADJUSTMENT_IN', 'ADJUSTMENT_OUT');
-- CreateEnum
CREATE TYPE "StockDocumentStatus" AS ENUM ('DRAFT', 'POSTED', 'VOID');
-- AlterEnum
ALTER TYPE "ControlAccountType" ADD VALUE 'GRN';
-- DropForeignKey
ALTER TABLE "APInvoiceLine" DROP CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey";
-- AlterTable
ALTER TABLE "APInvoiceLine" ADD COLUMN "grvLineId" INTEGER,
ALTER COLUMN "expenseGLAccountId" DROP NOT NULL;
-- CreateTable
CREATE TABLE "Warehouse" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Warehouse_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockItem" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"costingMethod" "CostingMethod" NOT NULL DEFAULT 'MOVING_AVERAGE',
"manualCost" DECIMAL(18,4),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockLevel" (
"id" SERIAL NOT NULL,
"itemId" INTEGER NOT NULL,
"warehouseId" INTEGER NOT NULL,
"quantityOnHand" DECIMAL(18,4) NOT NULL DEFAULT 0,
"averageCost" DECIMAL(18,4) NOT NULL DEFAULT 0,
"lastCost" DECIMAL(18,4) NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StockLevel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockMovement" (
"id" SERIAL NOT NULL,
"itemId" INTEGER NOT NULL,
"warehouseId" INTEGER NOT NULL,
"movementType" "StockMovementType" NOT NULL,
"quantity" DECIMAL(18,4) NOT NULL,
"unitCost" DECIMAL(18,4) NOT NULL,
"totalCost" DECIMAL(18,2) NOT NULL,
"movementDate" TIMESTAMP(3) NOT NULL,
"description" TEXT,
"branchSegmentValueId" INTEGER NOT NULL,
"sourceType" TEXT,
"sourceId" INTEGER,
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Recipe" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"outputItemId" INTEGER NOT NULL,
"yieldQuantity" DECIMAL(18,4) NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Recipe_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RecipeComponent" (
"id" SERIAL NOT NULL,
"recipeId" INTEGER NOT NULL,
"componentItemId" INTEGER NOT NULL,
"quantity" DECIMAL(18,4) NOT NULL,
CONSTRAINT "RecipeComponent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockAdjustment" (
"id" SERIAL NOT NULL,
"adjustmentNumber" TEXT NOT NULL,
"warehouseId" INTEGER NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"adjustmentDate" TIMESTAMP(3) NOT NULL,
"reference" TEXT,
"status" "StockDocumentStatus" NOT NULL DEFAULT 'DRAFT',
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockAdjustment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockAdjustmentLine" (
"id" SERIAL NOT NULL,
"adjustmentId" INTEGER NOT NULL,
"itemId" INTEGER NOT NULL,
"systemQuantity" DECIMAL(18,4) NOT NULL,
"countedQuantity" DECIMAL(18,4) NOT NULL,
"unitCost" DECIMAL(18,4) NOT NULL,
"varianceValue" DECIMAL(18,2) NOT NULL,
CONSTRAINT "StockAdjustmentLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GoodsReceivedVoucher" (
"id" SERIAL NOT NULL,
"grvNumber" TEXT NOT NULL,
"supplierId" INTEGER NOT NULL,
"warehouseId" INTEGER NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"grvDate" TIMESTAMP(3) NOT NULL,
"reference" TEXT,
"status" "StockDocumentStatus" NOT NULL DEFAULT 'DRAFT',
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GoodsReceivedVoucher_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GoodsReceivedVoucherLine" (
"id" SERIAL NOT NULL,
"grvId" INTEGER NOT NULL,
"itemId" INTEGER NOT NULL,
"quantity" DECIMAL(18,4) NOT NULL,
"unitPrice" DECIMAL(18,4) NOT NULL,
"lineTotal" DECIMAL(18,2) NOT NULL,
"matchedQuantity" DECIMAL(18,4) NOT NULL DEFAULT 0,
CONSTRAINT "GoodsReceivedVoucherLine_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Warehouse_code_key" ON "Warehouse"("code");
-- CreateIndex
CREATE INDEX "Warehouse_branchSegmentValueId_idx" ON "Warehouse"("branchSegmentValueId");
-- CreateIndex
CREATE UNIQUE INDEX "StockItem_code_key" ON "StockItem"("code");
-- CreateIndex
CREATE UNIQUE INDEX "StockLevel_itemId_warehouseId_key" ON "StockLevel"("itemId", "warehouseId");
-- CreateIndex
CREATE INDEX "StockMovement_itemId_warehouseId_idx" ON "StockMovement"("itemId", "warehouseId");
-- CreateIndex
CREATE INDEX "StockMovement_sourceType_sourceId_idx" ON "StockMovement"("sourceType", "sourceId");
-- CreateIndex
CREATE UNIQUE INDEX "Recipe_code_key" ON "Recipe"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Recipe_outputItemId_key" ON "Recipe"("outputItemId");
-- CreateIndex
CREATE INDEX "RecipeComponent_recipeId_idx" ON "RecipeComponent"("recipeId");
-- CreateIndex
CREATE UNIQUE INDEX "StockAdjustment_adjustmentNumber_key" ON "StockAdjustment"("adjustmentNumber");
-- CreateIndex
CREATE INDEX "StockAdjustment_warehouseId_idx" ON "StockAdjustment"("warehouseId");
-- CreateIndex
CREATE INDEX "StockAdjustment_branchSegmentValueId_idx" ON "StockAdjustment"("branchSegmentValueId");
-- CreateIndex
CREATE INDEX "StockAdjustment_status_idx" ON "StockAdjustment"("status");
-- CreateIndex
CREATE INDEX "StockAdjustmentLine_adjustmentId_idx" ON "StockAdjustmentLine"("adjustmentId");
-- CreateIndex
CREATE UNIQUE INDEX "GoodsReceivedVoucher_grvNumber_key" ON "GoodsReceivedVoucher"("grvNumber");
-- CreateIndex
CREATE INDEX "GoodsReceivedVoucher_supplierId_idx" ON "GoodsReceivedVoucher"("supplierId");
-- CreateIndex
CREATE INDEX "GoodsReceivedVoucher_warehouseId_idx" ON "GoodsReceivedVoucher"("warehouseId");
-- CreateIndex
CREATE INDEX "GoodsReceivedVoucher_branchSegmentValueId_idx" ON "GoodsReceivedVoucher"("branchSegmentValueId");
-- CreateIndex
CREATE INDEX "GoodsReceivedVoucher_status_idx" ON "GoodsReceivedVoucher"("status");
-- CreateIndex
CREATE INDEX "GoodsReceivedVoucherLine_grvId_idx" ON "GoodsReceivedVoucherLine"("grvId");
-- CreateIndex
CREATE INDEX "APInvoiceLine_grvLineId_idx" ON "APInvoiceLine"("grvLineId");
-- AddForeignKey
ALTER TABLE "JournalEntryLine" ADD CONSTRAINT "JournalEntryLine_stockMovementId_fkey" FOREIGN KEY ("stockMovementId") REFERENCES "StockMovement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey" FOREIGN KEY ("expenseGLAccountId") REFERENCES "GLAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_grvLineId_fkey" FOREIGN KEY ("grvLineId") REFERENCES "GoodsReceivedVoucherLine"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Warehouse" ADD CONSTRAINT "Warehouse_branchSegmentValueId_fkey" FOREIGN KEY ("branchSegmentValueId") REFERENCES "AccountSegmentValue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockLevel" ADD CONSTRAINT "StockLevel_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockLevel" ADD CONSTRAINT "StockLevel_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Recipe" ADD CONSTRAINT "Recipe_outputItemId_fkey" FOREIGN KEY ("outputItemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RecipeComponent" ADD CONSTRAINT "RecipeComponent_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipe"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RecipeComponent" ADD CONSTRAINT "RecipeComponent_componentItemId_fkey" FOREIGN KEY ("componentItemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustmentLine" ADD CONSTRAINT "StockAdjustmentLine_adjustmentId_fkey" FOREIGN KEY ("adjustmentId") REFERENCES "StockAdjustment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustmentLine" ADD CONSTRAINT "StockAdjustmentLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GoodsReceivedVoucher" ADD CONSTRAINT "GoodsReceivedVoucher_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Supplier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GoodsReceivedVoucher" ADD CONSTRAINT "GoodsReceivedVoucher_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GoodsReceivedVoucher" ADD CONSTRAINT "GoodsReceivedVoucher_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GoodsReceivedVoucherLine" ADD CONSTRAINT "GoodsReceivedVoucherLine_grvId_fkey" FOREIGN KEY ("grvId") REFERENCES "GoodsReceivedVoucher"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GoodsReceivedVoucherLine" ADD CONSTRAINT "GoodsReceivedVoucherLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "ControlAccountType" ADD VALUE 'STOCK_VARIANCE';
ALTER TYPE "ControlAccountType" ADD VALUE 'PURCHASE_PRICE_VARIANCE';
@@ -0,0 +1,96 @@
-- Simplifies Stock down to a catalog (StockItem) + cost centers (Warehouse) + a SOH view
-- (StockLevel) for a future Matrix feed to populate. Removes the GRV / Stock Take / Recipe
-- production transactional workflows entirely — physical stock transactions belong in Matrix,
-- not in this accounting system.
-- Data cleanup before structural changes (dev/test fixtures from the removed GRV-matching flow).
DELETE FROM "APInvoiceLine" WHERE "expenseGLAccountId" IS NULL;
-- Demote the Stock-related control accounts (1200 Stock, 2050 GRN Accrual, 5200 Stock Variance,
-- 5300 Purchase Price Variance) back to plain, manually-postable accounts rather than deleting
-- them — several already have real posted JournalEntryLine rows against them from testing, and
-- posted ledger history is never deleted. With the GRV/Stock-Take/Recipe workflows gone, nothing
-- else in this app owns these accounts, and leaving them locked as control accounts would make
-- them permanently unpostable by anyone.
UPDATE "GLAccount" SET "isControlAccount" = false, "controlType" = NULL
WHERE "controlType" IN ('STOCK', 'GRN', 'STOCK_VARIANCE', 'PURCHASE_PRICE_VARIANCE');
-- AlterEnum
BEGIN;
CREATE TYPE "ControlAccountType_new" AS ENUM ('AR', 'AP', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS');
ALTER TABLE "GLAccount" ALTER COLUMN "controlType" TYPE "ControlAccountType_new" USING ("controlType"::text::"ControlAccountType_new");
ALTER TYPE "ControlAccountType" RENAME TO "ControlAccountType_old";
ALTER TYPE "ControlAccountType_new" RENAME TO "ControlAccountType";
DROP TYPE "ControlAccountType_old";
COMMIT;
-- DropForeignKey
ALTER TABLE "APInvoiceLine" DROP CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey";
ALTER TABLE "APInvoiceLine" DROP CONSTRAINT "APInvoiceLine_grvLineId_fkey";
ALTER TABLE "GoodsReceivedVoucher" DROP CONSTRAINT "GoodsReceivedVoucher_createdById_fkey";
ALTER TABLE "GoodsReceivedVoucher" DROP CONSTRAINT "GoodsReceivedVoucher_supplierId_fkey";
ALTER TABLE "GoodsReceivedVoucher" DROP CONSTRAINT "GoodsReceivedVoucher_warehouseId_fkey";
ALTER TABLE "GoodsReceivedVoucherLine" DROP CONSTRAINT "GoodsReceivedVoucherLine_grvId_fkey";
ALTER TABLE "GoodsReceivedVoucherLine" DROP CONSTRAINT "GoodsReceivedVoucherLine_itemId_fkey";
ALTER TABLE "JournalEntryLine" DROP CONSTRAINT "JournalEntryLine_stockMovementId_fkey";
ALTER TABLE "Recipe" DROP CONSTRAINT "Recipe_outputItemId_fkey";
ALTER TABLE "RecipeComponent" DROP CONSTRAINT "RecipeComponent_componentItemId_fkey";
ALTER TABLE "RecipeComponent" DROP CONSTRAINT "RecipeComponent_recipeId_fkey";
ALTER TABLE "StockAdjustment" DROP CONSTRAINT "StockAdjustment_createdById_fkey";
ALTER TABLE "StockAdjustment" DROP CONSTRAINT "StockAdjustment_warehouseId_fkey";
ALTER TABLE "StockAdjustmentLine" DROP CONSTRAINT "StockAdjustmentLine_adjustmentId_fkey";
ALTER TABLE "StockAdjustmentLine" DROP CONSTRAINT "StockAdjustmentLine_itemId_fkey";
ALTER TABLE "StockMovement" DROP CONSTRAINT "StockMovement_createdById_fkey";
ALTER TABLE "StockMovement" DROP CONSTRAINT "StockMovement_itemId_fkey";
ALTER TABLE "StockMovement" DROP CONSTRAINT "StockMovement_warehouseId_fkey";
-- DropIndex
DROP INDEX "APInvoiceLine_grvLineId_idx";
-- AlterTable
ALTER TABLE "APInvoiceLine" DROP COLUMN "grvLineId",
ALTER COLUMN "expenseGLAccountId" SET NOT NULL;
-- AlterTable
ALTER TABLE "JournalEntryLine" DROP COLUMN "stockMovementId";
-- AlterTable
ALTER TABLE "StockItem" DROP COLUMN "costingMethod",
DROP COLUMN "manualCost";
-- AlterTable
ALTER TABLE "StockLevel" DROP COLUMN "averageCost",
DROP COLUMN "lastCost";
-- DropTable
DROP TABLE "GoodsReceivedVoucher";
-- DropTable
DROP TABLE "GoodsReceivedVoucherLine";
-- DropTable
DROP TABLE "Recipe";
-- DropTable
DROP TABLE "RecipeComponent";
-- DropTable
DROP TABLE "StockAdjustment";
-- DropTable
DROP TABLE "StockAdjustmentLine";
-- DropTable
DROP TABLE "StockMovement";
-- DropEnum
DROP TYPE "CostingMethod";
-- DropEnum
DROP TYPE "StockDocumentStatus";
-- DropEnum
DROP TYPE "StockMovementType";
-- AddForeignKey
ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey" FOREIGN KEY ("expenseGLAccountId") REFERENCES "GLAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+48 -4
View File
@@ -105,6 +105,7 @@ model AccountSegmentValue {
glAccountLinks GLAccountSegmentValue[]
customers Customer[]
suppliers Supplier[]
warehouses Warehouse[]
@@unique([segmentDefId, code])
}
@@ -134,7 +135,6 @@ enum DebitCredit {
enum ControlAccountType {
AR
AP
STOCK
TAX_OUTPUT
TAX_INPUT
BANK
@@ -253,13 +253,11 @@ model JournalEntryLine {
credit Decimal @default(0) @db.Decimal(18, 2)
description String?
branchSegmentValueId Int?
// Sub-ledger cross-references for drill-down. arInvoiceId/apInvoiceId are real FKs now that
// AR/AP exist; stockMovementId stays a plain id until Stock lands in its own phase.
// Sub-ledger cross-references for drill-down.
arInvoiceId Int?
arInvoice ARInvoice? @relation(fields: [arInvoiceId], references: [id])
apInvoiceId Int?
apInvoice APInvoice? @relation(fields: [apInvoiceId], references: [id])
stockMovementId Int?
currencyCode String @default("BASE")
exchangeRateToBase Decimal @default(1) @db.Decimal(18, 6)
@@ -615,6 +613,52 @@ model APAllocation {
@@index([debitNoteId])
}
// ---------------------------------------------------------------------------
// Stock (catalog + cost centers only — see the comment on Warehouse below)
// ---------------------------------------------------------------------------
// Deliberately minimal: this is accounting software, not a stock/warehouse system. Physical
// stock transactions (goods received, stock takes, recipe production) belong in Matrix (the
// external hospitality/retail stock system this app is designed to eventually integrate with)
// — this app only needs a catalog of items, a set of cost centers to view SOH by, and a place
// for whatever quantities Matrix eventually reports. No costing engine, no movement ledger, no
// document workflow live here.
model Warehouse {
id Int @id @default(autoincrement())
code String @unique
name String
branchSegmentValueId Int
branchSegmentValue AccountSegmentValue @relation(fields: [branchSegmentValueId], references: [id])
isActive Boolean @default(true)
stockLevels StockLevel[]
@@index([branchSegmentValueId])
}
model StockItem {
id Int @id @default(autoincrement())
code String @unique
name String
unitOfMeasure String
isActive Boolean @default(true)
stockLevels StockLevel[]
createdAt DateTime @default(now())
}
// Quantity on hand per item/warehouse — a read target for a future Matrix feed, not written to
// from any UI in this app today.
model StockLevel {
id Int @id @default(autoincrement())
itemId Int
item StockItem @relation(fields: [itemId], references: [id])
warehouseId Int
warehouse Warehouse @relation(fields: [warehouseId], references: [id])
quantityOnHand Decimal @default(0) @db.Decimal(18, 4)
updatedAt DateTime @updatedAt
@@unique([itemId, warehouseId])
}
// ---------------------------------------------------------------------------
// Auth / RBAC
// ---------------------------------------------------------------------------
+36 -3
View File
@@ -18,6 +18,8 @@ const PERMISSIONS = [
'ap.invoice.manage',
'ap.debitnote.manage',
'ap.payment.manage',
'stock.warehouse.manage',
'stock.item.manage',
] as const;
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
@@ -35,6 +37,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
'ap.invoice.manage',
'ap.debitnote.manage',
'ap.payment.manage',
'stock.warehouse.manage',
'stock.item.manage',
],
AR_AP_CLERK: [
'ar.customer.manage',
@@ -46,6 +50,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
'ap.debitnote.manage',
'ap.payment.manage',
],
STOCK_CLERK: ['stock.warehouse.manage', 'stock.item.manage'],
BRANCH_USER: [],
READ_ONLY: [],
};
@@ -98,14 +103,17 @@ async function main() {
{ code: '1100', description: 'Accounts Receivable Control' },
{ code: '1200', description: 'Inventory / Stock Control' },
{ code: '2000', description: 'Accounts Payable Control' },
{ code: '2050', description: 'Goods Received Not Invoiced' },
{ code: '2100', description: 'Output Tax Payable' },
{ code: '2200', description: 'Input Tax Receivable' },
{ code: '2300', description: 'Accrued Expenses' },
{ code: '3000', description: 'Retained Earnings' },
{ code: '3100', description: 'Share Capital' },
{ code: '4000', description: 'Sales Income' },
{ code: '5000', description: 'Cost of Goods Sold' },
{ code: '5100', description: 'Operating Expenses' },
{ code: '5200', description: 'Stock Variance' },
{ code: '5300', description: 'Purchase Price Variance' },
];
const masterByCode: Record<string, { id: number }> = {};
for (const value of masterValues) {
@@ -157,14 +165,19 @@ async function main() {
}> = [
{ masterCode: '1000', accountName: 'Bank / Cash', accountType: 'ASSET', normalBalance: 'DEBIT', controlType: 'BANK' },
{ masterCode: '1100', accountName: 'Accounts Receivable Control', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'AR' },
{ masterCode: '1200', accountName: 'Inventory / Stock Control', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'STOCK' },
// Not a control account: no subledger in this app owns stock value — see the Stock section
// of prisma/schema.prisma. Postable directly via manual journal like any other GL account.
{ masterCode: '1200', accountName: 'Inventory / Stock Control', accountType: 'ASSET', normalBalance: 'DEBIT' },
{ masterCode: '2000', accountName: 'Accounts Payable Control', accountType: 'LIABILITY', normalBalance: 'CREDIT', isControlAccount: true, controlType: 'AP' },
{ masterCode: '2050', accountName: 'Goods Received Not Invoiced', accountType: 'LIABILITY', normalBalance: 'CREDIT' },
{ masterCode: '2100', accountName: 'Output Tax Payable', accountType: 'LIABILITY', normalBalance: 'CREDIT', isControlAccount: true, controlType: 'TAX_OUTPUT' },
{ masterCode: '2200', accountName: 'Input Tax Receivable', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'TAX_INPUT' },
{ masterCode: '2300', accountName: 'Accrued Expenses', accountType: 'LIABILITY', normalBalance: 'CREDIT' },
{ masterCode: '4000', accountName: 'Sales Income', accountType: 'INCOME', normalBalance: 'CREDIT' },
{ masterCode: '5000', accountName: 'Cost of Goods Sold', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
{ masterCode: '5100', accountName: 'Operating Expenses', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
{ masterCode: '5200', accountName: 'Stock Variance', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
{ masterCode: '5300', accountName: 'Purchase Price Variance', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
];
for (const branchCode of Object.keys(branchByCode)) {
@@ -189,7 +202,13 @@ async function main() {
],
},
},
update: {},
update: {
accountName: `${account.accountName} — ${branchCode}`,
accountType: account.accountType,
normalBalance: account.normalBalance,
isControlAccount: account.isControlAccount ?? false,
controlType: account.controlType ?? null,
},
});
}
}
@@ -218,7 +237,7 @@ async function main() {
],
},
},
update: {},
update: { accountName: account.accountName, controlType: account.controlType ?? null },
});
}
@@ -258,6 +277,20 @@ async function main() {
create: { code: 'CASH_PAYMENT', name: 'Cash Payments Journal', journalType: JournalType.CASH_PAYMENT },
update: {},
});
await prisma.journal.upsert({
where: { code: 'STOCK' },
create: { code: 'STOCK', name: 'Stock Journal', journalType: JournalType.STOCK },
update: {},
});
console.log('Seeding warehouses...');
for (const branchCode of Object.keys(branchByCode)) {
await prisma.warehouse.upsert({
where: { code: `WH-${branchCode}` },
create: { code: `WH-${branchCode}`, name: `Main Warehouse — ${branchCode}`, branchSegmentValueId: branchByCode[branchCode].id },
update: {},
});
}
console.log('Seeding tax codes...');
const taxCodes = [
+4
View File
@@ -19,6 +19,8 @@ import { suppliersRouter } from './modules/ap/suppliers.routes';
import { apInvoicesRouter } from './modules/ap/apInvoices.routes';
import { apDebitNotesRouter } from './modules/ap/apDebitNotes.routes';
import { apPaymentsRouter } from './modules/ap/apPayments.routes';
import { warehousesRouter } from './modules/stock/warehouses.routes';
import { stockItemsRouter } from './modules/stock/stockItems.routes';
export function createApp(): Express {
const app = express();
@@ -46,6 +48,8 @@ export function createApp(): Express {
app.use('/api/ap/invoices', apInvoicesRouter);
app.use('/api/ap/debit-notes', apDebitNotesRouter);
app.use('/api/ap/payments', apPaymentsRouter);
app.use('/api/stock/warehouses', warehousesRouter);
app.use('/api/stock/items', stockItemsRouter);
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
app.use(errorHandler);
+4 -2
View File
@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from 'express';
import { Prisma } from '@prisma/client';
import { UnbalancedEntryError } from '../modules/gl/posting.types';
import { ClosedPeriodError, InvalidLineError, UnbalancedEntryError } from '../modules/gl/posting.types';
import { ForbiddenBranchScopeError } from '../lib/branchFilter';
import { ControlAccountNotFoundError } from '../lib/controlAccounts';
import { ARDocumentStateError, ARAllocationError } from '../modules/ar/ar.errors';
@@ -21,7 +21,9 @@ export function errorHandler(error: unknown, req: Request, res: Response, next:
error instanceof ARDocumentStateError ||
error instanceof ARAllocationError ||
error instanceof APDocumentStateError ||
error instanceof APAllocationError
error instanceof APAllocationError ||
error instanceof InvalidLineError ||
error instanceof ClosedPeriodError
) {
res.status(422).json({ error: error.message });
return;
+4 -4
View File
@@ -1,6 +1,6 @@
import { APDebitNote, APDebitNoteLine, APInvoice, APInvoiceLine, APPayment, JournalEntry, Supplier } from '@prisma/client';
import { prisma } from '../../lib/prisma';
import { money, sum, ZERO } from '../../lib/money';
import { money, sum, ZERO, Money } from '../../lib/money';
import { resolveControlAccount } from '../../lib/controlAccounts';
import { PostingService } from '../gl/posting.service';
import { JournalEntryDraft, JournalLineDraft } from '../gl/posting.types';
@@ -9,8 +9,8 @@ type InvoiceForPosting = APInvoice & { lines: APInvoiceLine[]; supplier: Supplie
type DebitNoteForPosting = APDebitNote & { lines: APDebitNoteLine[]; supplier: Supplier };
type PaymentForPosting = APPayment & { supplier: Supplier };
function groupByAccount(entries: { glAccountId: number; amount: import('../../lib/money').Money }[]) {
const byAccount = new Map<number, import('../../lib/money').Money>();
function groupByAccount(entries: { glAccountId: number; amount: Money }[]) {
const byAccount = new Map<number, Money>();
for (const entry of entries) {
byAccount.set(entry.glAccountId, (byAccount.get(entry.glAccountId) ?? ZERO).plus(entry.amount));
}
@@ -156,6 +156,6 @@ export function calcLine(quantity: number, unitPrice: number, ratePercent: numbe
return { subtotal, tax, total: subtotal.plus(tax) };
}
export function sumLines(values: import('../../lib/money').Money[]) {
export function sumLines(values: Money[]) {
return sum(values);
}
+1 -1
View File
@@ -4,7 +4,7 @@ import * as glAccountsService from './glAccounts.service';
const glAccountTypeSchema = z.enum(['ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE']);
const debitCreditSchema = z.enum(['DEBIT', 'CREDIT']);
const controlAccountTypeSchema = z.enum(['AR', 'AP', 'STOCK', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS']);
const controlAccountTypeSchema = z.enum(['AR', 'AP', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS']);
export const createGLAccountSchema = z.object({
accountName: z.string().min(1),
+22 -2
View File
@@ -1,7 +1,7 @@
import { prisma } from '../../lib/prisma';
import { BranchScope } from '../../lib/branchFilter';
import { PostingService } from './posting.service';
import { JournalEntryDraft } from './posting.types';
import { InvalidLineError, JournalEntryDraft } from './posting.types';
export interface ManualJournalLineInput {
glAccountId: number;
@@ -18,7 +18,27 @@ export interface CreateManualEntryInput {
lines: ManualJournalLineInput[];
}
export function createManualEntry(input: CreateManualEntryInput, createdById: number) {
/**
* Control accounts (AR/AP/GRN/Tax/Stock/Bank) are only ever written by their owning module's
* posting service — a manual GL journal can't touch them directly. Otherwise a manual entry
* could silently desync a control account balance from its sub-ledger (e.g. debiting AR Control
* by hand with no corresponding invoice), which the sub-ledger screens would never reflect.
*/
async function assertNoControlAccountLines(lines: ManualJournalLineInput[]): Promise<void> {
const accountIds = [...new Set(lines.map((line) => line.glAccountId))];
const accounts = await prisma.gLAccount.findMany({ where: { id: { in: accountIds } } });
const controlAccounts = accounts.filter((account) => account.isControlAccount);
if (controlAccounts.length > 0) {
const names = controlAccounts.map((account) => `${account.accountCode} (${account.controlType})`).join(', ');
throw new InvalidLineError(
`Manual journal entries cannot post directly to a control account: ${names}. Use the AR/AP/Stock module that owns it instead.`,
);
}
}
export async function createManualEntry(input: CreateManualEntryInput, createdById: number) {
await assertNoControlAccountLines(input.lines);
const draft: JournalEntryDraft = {
journalCode: input.journalCode,
entryDate: new Date(input.entryDate),
-2
View File
@@ -73,7 +73,6 @@ export async function postEntry(draft: JournalEntryDraft): Promise<JournalEntry>
branchSegmentValueId: line.branchSegmentValueId,
arInvoiceId: line.arInvoiceId,
apInvoiceId: line.apInvoiceId,
stockMovementId: line.stockMovementId,
currencyCode: line.currencyCode ?? 'BASE',
exchangeRateToBase: line.exchangeRateToBase ? money(line.exchangeRateToBase) : money(1),
})),
@@ -124,7 +123,6 @@ export async function reverseEntry(journalEntryId: number, createdById: number,
branchSegmentValueId: line.branchSegmentValueId ?? undefined,
arInvoiceId: line.arInvoiceId ?? undefined,
apInvoiceId: line.apInvoiceId ?? undefined,
stockMovementId: line.stockMovementId ?? undefined,
currencyCode: line.currencyCode,
exchangeRateToBase: line.exchangeRateToBase,
})),
-1
View File
@@ -29,7 +29,6 @@ export interface JournalLineDraft {
branchSegmentValueId?: number;
arInvoiceId?: number;
apInvoiceId?: number;
stockMovementId?: number;
currencyCode?: string;
exchangeRateToBase?: Prisma.Decimal.Value;
}
@@ -0,0 +1,39 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import * as stockItemsService from './stockItems.service';
export const createStockItemSchema = z.object({
code: z.string().min(1),
name: z.string().min(1),
unitOfMeasure: z.string().min(1),
});
export const updateStockItemSchema = createStockItemSchema.partial().extend({ isActive: z.boolean().optional() });
export async function listStockItemsHandler(_req: Request, res: Response): Promise<void> {
res.json(await stockItemsService.listStockItems());
}
export async function createStockItemHandler(req: Request, res: Response): Promise<void> {
const input = req.body as z.infer<typeof createStockItemSchema>;
res.status(201).json(await stockItemsService.createStockItem(input));
}
export async function updateStockItemHandler(req: Request, res: Response): Promise<void> {
const input = req.body as z.infer<typeof updateStockItemSchema>;
res.json(await stockItemsService.updateStockItem(Number(req.params.id), input));
}
export async function getStockItemHandler(req: Request, res: Response): Promise<void> {
const item = await stockItemsService.getStockItem(Number(req.params.id));
if (!item) {
res.status(404).json({ error: 'Stock item not found' });
return;
}
res.json(item);
}
export async function listStockLevelsHandler(req: Request, res: Response): Promise<void> {
const warehouseId = req.query.warehouseId ? Number(req.query.warehouseId) : undefined;
res.json(await stockItemsService.listStockLevels(warehouseId));
}
+34
View File
@@ -0,0 +1,34 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth';
import { requirePermission } from '../../middleware/rbac';
import { validate } from '../../middleware/validate';
import { asyncHandler } from '../../lib/asyncHandler';
import {
createStockItemHandler,
createStockItemSchema,
getStockItemHandler,
listStockItemsHandler,
listStockLevelsHandler,
updateStockItemHandler,
updateStockItemSchema,
} from './stockItems.controller';
export const stockItemsRouter = Router();
stockItemsRouter.use(requireAuth);
stockItemsRouter.get('/', asyncHandler(listStockItemsHandler));
stockItemsRouter.get('/levels', asyncHandler(listStockLevelsHandler));
stockItemsRouter.get('/:id', asyncHandler(getStockItemHandler));
stockItemsRouter.post(
'/',
requirePermission('stock.item.manage'),
validate(createStockItemSchema),
asyncHandler(createStockItemHandler),
);
stockItemsRouter.put(
'/:id',
requirePermission('stock.item.manage'),
validate(updateStockItemSchema),
asyncHandler(updateStockItemHandler),
);
+34
View File
@@ -0,0 +1,34 @@
import { prisma } from '../../lib/prisma';
export interface StockItemInput {
code: string;
name: string;
unitOfMeasure: string;
}
export function listStockItems() {
return prisma.stockItem.findMany({ orderBy: { name: 'asc' } });
}
export function createStockItem(input: StockItemInput) {
return prisma.stockItem.create({ data: input });
}
export function updateStockItem(id: number, input: Partial<StockItemInput> & { isActive?: boolean }) {
return prisma.stockItem.update({ where: { id }, data: input });
}
export function getStockItem(id: number) {
return prisma.stockItem.findUnique({
where: { id },
include: { stockLevels: { include: { warehouse: true } } },
});
}
export function listStockLevels(warehouseId?: number) {
return prisma.stockLevel.findMany({
where: warehouseId ? { warehouseId } : {},
include: { item: true, warehouse: true },
orderBy: [{ warehouse: { name: 'asc' } }, { item: { name: 'asc' } }],
});
}
@@ -0,0 +1,25 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import * as warehousesService from './warehouses.service';
export const createWarehouseSchema = z.object({
code: z.string().min(1),
name: z.string().min(1),
branchSegmentValueId: z.number().int(),
});
export const updateWarehouseSchema = createWarehouseSchema.partial().extend({ isActive: z.boolean().optional() });
export async function listWarehousesHandler(req: Request, res: Response): Promise<void> {
res.json(await warehousesService.listWarehouses(req.branchScope!));
}
export async function createWarehouseHandler(req: Request, res: Response): Promise<void> {
const input = req.body as z.infer<typeof createWarehouseSchema>;
res.status(201).json(await warehousesService.createWarehouse(input));
}
export async function updateWarehouseHandler(req: Request, res: Response): Promise<void> {
const input = req.body as z.infer<typeof updateWarehouseSchema>;
res.json(await warehousesService.updateWarehouse(Number(req.params.id), input));
}
+25
View File
@@ -0,0 +1,25 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth';
import { requirePermission } from '../../middleware/rbac';
import { attachBranchScope } from '../../middleware/branchScope';
import { validate } from '../../middleware/validate';
import { asyncHandler } from '../../lib/asyncHandler';
import { createWarehouseHandler, createWarehouseSchema, listWarehousesHandler, updateWarehouseHandler, updateWarehouseSchema } from './warehouses.controller';
export const warehousesRouter = Router();
warehousesRouter.use(requireAuth);
warehousesRouter.get('/', attachBranchScope, asyncHandler(listWarehousesHandler));
warehousesRouter.post(
'/',
requirePermission('stock.warehouse.manage'),
validate(createWarehouseSchema),
asyncHandler(createWarehouseHandler),
);
warehousesRouter.put(
'/:id',
requirePermission('stock.warehouse.manage'),
validate(updateWarehouseSchema),
asyncHandler(updateWarehouseHandler),
);
+20
View File
@@ -0,0 +1,20 @@
import { prisma } from '../../lib/prisma';
import { BranchScope, branchWhere } from '../../lib/branchFilter';
export interface WarehouseInput {
code: string;
name: string;
branchSegmentValueId: number;
}
export function listWarehouses(branchScope: BranchScope) {
return prisma.warehouse.findMany({ where: branchWhere(branchScope), orderBy: { name: 'asc' } });
}
export function createWarehouse(input: WarehouseInput) {
return prisma.warehouse.create({ data: input });
}
export function updateWarehouse(id: number, input: Partial<WarehouseInput> & { isActive?: boolean }) {
return prisma.warehouse.update({ where: { id }, data: input });
}