Split: make this the standalone HQ project (remove Store)
This repo keeps its 60-commit HQ history and becomes independent of the Store product. Removed the Store apps (pos, store-server, backoffice), the Store-only packages (config, scanning, search-core, printing — printing was already a dead dependency in hq-web), and the Store planning docs. Shared packages are forked and trimmed to exactly what HQ uses: domain keeps ids/money/business-day/doc-series/gstin/documents; auth keeps pin; billing-engine keeps compute/tax. Store-only tests for the removed modules were dropped, and auth.test.ts was trimmed to its PIN blocks. Package renamed sims-hq; root tsconfig/vitest paths narrowed to the four kept packages. Verified independently: npm install + typecheck clean, 177 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/client-detail-redesign
parent
85adbb553a
commit
7e1c93cee0
@ -1,5 +0,0 @@
|
|||||||
# apps/backoffice — Back Office (React SPA)
|
|
||||||
|
|
||||||
Masters, purchases, inventory, reports, GST, Admin (users/roles, settings hierarchy,
|
|
||||||
stores/counters). Served by store-server locally in v1; the same build runs from the
|
|
||||||
cloud tier later. Menu tree and page anatomy: docs/09-UX-FLOWS-AND-MENUS.md §2.
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>SiMS Next — Back Office</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@sims/backoffice",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"typecheck": "tsc -p tsconfig.json"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@sims/billing-engine": "*",
|
|
||||||
"@sims/domain": "*",
|
|
||||||
"@sims/printing": "*",
|
|
||||||
"@sims/scanning": "*",
|
|
||||||
"@sims/ui": "*",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"react-router-dom": "^7.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^19.0.0",
|
|
||||||
"@types/react-dom": "^19.0.0",
|
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
|
||||||
"vite": "^6.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { NavLink, Outlet } from 'react-router-dom'
|
|
||||||
import { Badge, ThemeSwitcher } from '@sims/ui'
|
|
||||||
import { NAV, isLocked } from './nav'
|
|
||||||
|
|
||||||
export function Layout(props: { userName: string; onLogout: () => void }) {
|
|
||||||
const searchRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 'k') {
|
|
||||||
e.preventDefault()
|
|
||||||
searchRef.current?.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKey)
|
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bo-shell">
|
|
||||||
<nav className="bo-side">
|
|
||||||
<div className="bo-brand">
|
|
||||||
SiMS Next
|
|
||||||
<small>Back Office · wireframe</small>
|
|
||||||
</div>
|
|
||||||
{NAV.map((mod) => (
|
|
||||||
<div key={mod.key}>
|
|
||||||
<div className="bo-module">{mod.icon} {mod.label}</div>
|
|
||||||
{mod.pages.map((p) => (
|
|
||||||
<NavLink key={p.path} to={p.path} className={({ isActive }) => (isActive ? 'active' : '')} end={p.path === '/'}>
|
|
||||||
{p.label}
|
|
||||||
{isLocked(p) && <span className="lock">🔒 {p.edition}</span>}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div className="bo-main">
|
|
||||||
<header className="bo-top">
|
|
||||||
<input ref={searchRef} className="wf" placeholder="Search everything… (Ctrl+K)" />
|
|
||||||
<span style={{ flex: 1 }} />
|
|
||||||
<select className="wf" style={{ width: 170 }} defaultValue="ST1">
|
|
||||||
<option value="ST1">ST1 · T.Nagar</option>
|
|
||||||
<option value="ST2">ST2 · Velachery</option>
|
|
||||||
<option value="all">All stores</option>
|
|
||||||
</select>
|
|
||||||
<ThemeSwitcher />
|
|
||||||
<Badge tone="accent">Pro</Badge>
|
|
||||||
<Badge>{props.userName}</Badge>
|
|
||||||
<button type="button" className="wf" onClick={props.onLogout}>Logout</button>
|
|
||||||
</header>
|
|
||||||
<div className="bo-content">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Button, Field, Notice } from '@sims/ui'
|
|
||||||
import { login } from './api'
|
|
||||||
|
|
||||||
/** Back-office login — server-side scrypt verification + lockout. */
|
|
||||||
export function Login(props: { onLogin: (name: string) => void }) {
|
|
||||||
const [user, setUser] = useState('thomas')
|
|
||||||
const [pw, setPw] = useState('')
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [error, setError] = useState<string | undefined>()
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (busy) return
|
|
||||||
setBusy(true)
|
|
||||||
setError(undefined)
|
|
||||||
try {
|
|
||||||
props.onLogin(await login(user.trim(), pw))
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="login-wrap">
|
|
||||||
<div className="login-card">
|
|
||||||
<h2 style={{ marginTop: 0 }}>SiMS Next — Back Office</h2>
|
|
||||||
<Field label="Username">
|
|
||||||
<input className="wf" value={user} onChange={(e) => setUser(e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Password">
|
|
||||||
<input
|
|
||||||
className="wf" type="password" value={pw}
|
|
||||||
onChange={(e) => setPw(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && void submit()}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Button tone="primary" onClick={() => void submit()}>{busy ? 'Signing in…' : 'Sign in'}</Button>
|
|
||||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
/** Back office ↔ store-server client; session token survives reloads. */
|
|
||||||
import type { Gstr1, Gstr3b, HsnRow, Reconciliation, TaxAmounts } from '@sims/billing-engine'
|
|
||||||
|
|
||||||
let token = sessionStorage.getItem('bo.token') ?? ''
|
|
||||||
|
|
||||||
export function hasSession(): boolean {
|
|
||||||
return token !== ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearSession(): void {
|
|
||||||
token = ''
|
|
||||||
sessionStorage.removeItem('bo.token')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
const res = await fetch(`/api${path}`, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json',
|
|
||||||
...(token !== '' ? { authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
}).catch(() => undefined)
|
|
||||||
if (res === undefined) throw new Error('Store-server unreachable (E-3301)')
|
|
||||||
if (res.status === 401) clearSession()
|
|
||||||
const body = (await res.json().catch(() => ({}))) as T & { error?: string }
|
|
||||||
if (!res.ok) throw new Error(body.error ?? `Server error HTTP ${res.status}`)
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(username: string, password: string): Promise<string> {
|
|
||||||
const s = await call<{ token: string; displayName: string }>('/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
})
|
|
||||||
token = s.token
|
|
||||||
sessionStorage.setItem('bo.token', s.token)
|
|
||||||
return s.displayName
|
|
||||||
}
|
|
||||||
|
|
||||||
/** End the session server-side (M4) then clear it locally. Best-effort on the network call. */
|
|
||||||
export async function logout(): Promise<void> {
|
|
||||||
try { await call('/auth/logout', { method: 'POST' }) } catch { /* server down: clear locally anyway */ }
|
|
||||||
clearSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getItems = (q?: string): Promise<Record<string, unknown>[]> =>
|
|
||||||
call(`/items${q !== undefined && q !== '' ? `?q=${encodeURIComponent(q)}` : ''}`)
|
|
||||||
export const createItem = (body: Record<string, unknown>): Promise<Record<string, unknown>> =>
|
|
||||||
call('/items', { method: 'POST', body: JSON.stringify(body) })
|
|
||||||
export const getParties = (kind?: string): Promise<Record<string, unknown>[]> =>
|
|
||||||
call(`/parties${kind !== undefined ? `?kind=${kind}` : ''}`)
|
|
||||||
export const getBills = (date?: string, limit?: number, offset?: number): Promise<Record<string, unknown>[]> => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (date !== undefined) qs.set('date', date)
|
|
||||||
if (limit !== undefined) qs.set('limit', String(limit))
|
|
||||||
if (offset !== undefined) qs.set('offset', String(offset))
|
|
||||||
return call(`/bills${qs.toString() !== '' ? `?${qs}` : ''}`)
|
|
||||||
}
|
|
||||||
export const getStock = (): Promise<Record<string, unknown>[]> => call('/stock')
|
|
||||||
/** Credit notes (SALE_RETURN docs) — tenant-scoped, paginated; feeds the Returns page. */
|
|
||||||
export const getReturns = (limit?: number, offset?: number): Promise<Record<string, unknown>[]> => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (limit !== undefined) qs.set('limit', String(limit))
|
|
||||||
if (offset !== undefined) qs.set('offset', String(offset))
|
|
||||||
return call(`/returns${qs.toString() !== '' ? `?${qs}` : ''}`)
|
|
||||||
}
|
|
||||||
/** S5: per-batch on-hand + value inputs joined to the item — feeds the expiry
|
|
||||||
* dashboard and the Stock page's batch breakdown. */
|
|
||||||
export const getExpiry = (): Promise<Record<string, unknown>[]> => call('/expiry')
|
|
||||||
export const getAudit = (limit?: number, offset?: number): Promise<Record<string, unknown>[]> => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (limit !== undefined) qs.set('limit', String(limit))
|
|
||||||
if (offset !== undefined) qs.set('offset', String(offset))
|
|
||||||
return call(`/audit${qs.toString() !== '' ? `?${qs}` : ''}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPurchases = (): Promise<Record<string, unknown>[]> => call('/purchases')
|
|
||||||
export const getLastCosts = (supplierId?: string): Promise<Record<string, { last?: number; supplierLast?: number }>> =>
|
|
||||||
call(`/purchases/last-costs${supplierId !== undefined ? `?supplierId=${supplierId}` : ''}`)
|
|
||||||
export const postPurchase = (body: Record<string, unknown>): Promise<{ id: string; totalPaise: number }> =>
|
|
||||||
call('/purchases', { method: 'POST', body: JSON.stringify(body) })
|
|
||||||
export const getCostHistory = (itemId: string, supplierId?: string): Promise<{ cost: number; date: string; supplier: string }[]> =>
|
|
||||||
call(`/purchases/cost-history?itemId=${itemId}${supplierId !== undefined ? `&supplierId=${supplierId}` : ''}`)
|
|
||||||
export const getBootstrapPublic = (): Promise<{ taxClasses: { classCode: string; ratePctBp: number; cessPctBp: number; effectiveFrom: string; effectiveTo?: string }[] }> =>
|
|
||||||
call('/bootstrap?counter=C1')
|
|
||||||
|
|
||||||
export interface Seller { id: string; name: string; storeCode: string; stateCode: string; gstin?: string }
|
|
||||||
/** Seller header for GST invoices/labels — store name, GSTIN, state (from bootstrap). */
|
|
||||||
export const getSeller = (): Promise<Seller> =>
|
|
||||||
call<{ store: Seller }>('/bootstrap?counter=C1').then((b) => b.store)
|
|
||||||
|
|
||||||
// GST returns — READ-ONLY reporting over a period's immutable documents. The server folds them
|
|
||||||
// through the shared engine and returns each section plus a reconciliation proof (see repos-gst).
|
|
||||||
export interface Gstr1Response extends Gstr1 { period: string; storeStateCode: string; reconciliation: Reconciliation }
|
|
||||||
export interface Gstr3bResponse extends Gstr3b { period: string; storeStateCode: string; reconciliation: Reconciliation }
|
|
||||||
export interface HsnResponse { period: string; storeStateCode: string; rows: HsnRow[]; totals: TaxAmounts; reconciliation: Reconciliation }
|
|
||||||
|
|
||||||
export const getGstr1 = (period: string): Promise<Gstr1Response> => call(`/gst/gstr1?period=${encodeURIComponent(period)}`)
|
|
||||||
export const getGstr3b = (period: string): Promise<Gstr3bResponse> => call(`/gst/gstr3b?period=${encodeURIComponent(period)}`)
|
|
||||||
export const getGstHsn = (period: string): Promise<HsnResponse> => call(`/gst/hsn?period=${encodeURIComponent(period)}`)
|
|
||||||
/** The pragmatic GSTN-shaped JSON export (documented in-payload as NOT a certified filing). */
|
|
||||||
export const getGstr1Json = (period: string): Promise<Record<string, unknown>> => call(`/gst/gstr1.json?period=${encodeURIComponent(period)}`)
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
.bo-shell { display: grid; grid-template-columns: 230px 1fr; height: 100vh; }
|
|
||||||
.bo-side { background: var(--bg-raised); border-right: 1px solid var(--border); overflow-y: auto; padding: 12px 0; }
|
|
||||||
.bo-brand { font-weight: 700; font-size: 16px; padding: 4px 16px 12px; }
|
|
||||||
.bo-brand small { display: block; color: var(--text-dim); font-weight: 400; }
|
|
||||||
.bo-module { margin: 10px 0 2px; padding: 0 16px; color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
|
|
||||||
.bo-side a {
|
|
||||||
display: flex; align-items: center; gap: 8px; padding: 6px 16px; color: var(--text);
|
|
||||||
text-decoration: none; font-size: 13.5px;
|
|
||||||
}
|
|
||||||
.bo-side a:hover { background: var(--bg-hover); }
|
|
||||||
.bo-side a.active { background: var(--bg-hover); box-shadow: inset 3px 0 0 var(--accent); }
|
|
||||||
.bo-side a .lock { margin-left: auto; font-size: 11px; color: var(--warn); }
|
|
||||||
.bo-main { display: flex; flex-direction: column; min-width: 0; }
|
|
||||||
.bo-top {
|
|
||||||
display: flex; gap: 12px; align-items: center; padding: 8px 20px;
|
|
||||||
border-bottom: 1px solid var(--border); background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
.bo-top input { max-width: 380px; }
|
|
||||||
.bo-content { overflow-y: auto; flex: 1; }
|
|
||||||
.upsell {
|
|
||||||
border: 1px solid var(--warn); border-radius: var(--radius); padding: 24px; margin: 16px 0;
|
|
||||||
background: color-mix(in srgb, var(--warn) 6%, var(--bg-raised));
|
|
||||||
}
|
|
||||||
.inbox-split { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 12px; }
|
|
||||||
.inbox-doc {
|
|
||||||
background: var(--bg-raised); border: 1px dashed var(--border); border-radius: var(--radius);
|
|
||||||
padding: 18px; font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.matrix td:first-child { white-space: nowrap; }
|
|
||||||
.matrix .cell { text-align: center; }
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import { HashRouter, Route, Routes } from 'react-router-dom'
|
|
||||||
import '@fontsource-variable/inter'
|
|
||||||
import '@fontsource-variable/jetbrains-mono'
|
|
||||||
import '../../../packages/ui/src/tokens.css'
|
|
||||||
import './app.css'
|
|
||||||
import { initTheme } from '@sims/ui'
|
|
||||||
import { logout } from './api'
|
|
||||||
import { Layout } from './Layout'
|
|
||||||
import { Login } from './Login'
|
|
||||||
import { Dashboard } from './pages/Dashboard'
|
|
||||||
import { GenericPage } from './pages/GenericPage'
|
|
||||||
import { PurchaseInbox } from './pages/PurchaseInbox'
|
|
||||||
import { SettingsPage } from './pages/SettingsPage'
|
|
||||||
import { UsersRoles } from './pages/UsersRoles'
|
|
||||||
import { AuditPage, BillsPage, CustomersPage, ExpiryPage, ItemsPage, ReturnsPage, StockPage } from './pages/live'
|
|
||||||
import { Gstr1Page, Gstr3bPage, HsnPage } from './pages/gst'
|
|
||||||
import { LabelsPage } from './pages/labels'
|
|
||||||
import { PurchaseEntryPage, PurchasesListPage } from './pages/purchase-entry'
|
|
||||||
import { PAGES } from './pages/registry'
|
|
||||||
|
|
||||||
/** Custom/live pages override registry configs; everything else renders generically. */
|
|
||||||
const CUSTOM: Record<string, () => React.JSX.Element> = {
|
|
||||||
'/purchases/inbox': PurchaseInbox,
|
|
||||||
'/admin/settings': SettingsPage,
|
|
||||||
'/admin/users': UsersRoles,
|
|
||||||
'/catalog/items': ItemsPage,
|
|
||||||
'/catalog/labels': LabelsPage,
|
|
||||||
'/sales/customers': CustomersPage,
|
|
||||||
'/sales/bills': BillsPage,
|
|
||||||
'/sales/returns': ReturnsPage,
|
|
||||||
'/inventory/stock': StockPage,
|
|
||||||
'/inventory/expiry': ExpiryPage,
|
|
||||||
'/gst/gstr1': Gstr1Page,
|
|
||||||
'/gst/gstr3b': Gstr3bPage,
|
|
||||||
'/gst/hsn': HsnPage,
|
|
||||||
'/admin/audit': AuditPage,
|
|
||||||
'/purchases/entry': PurchaseEntryPage,
|
|
||||||
'/purchases/list': PurchasesListPage,
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [userName, setUserName] = useState<string | undefined>()
|
|
||||||
if (userName === undefined) return <Login onLogin={setUserName} />
|
|
||||||
return (
|
|
||||||
<HashRouter>
|
|
||||||
<Routes>
|
|
||||||
<Route element={<Layout userName={userName} onLogout={() => { void logout(); setUserName(undefined) }} />}>
|
|
||||||
<Route path="/" element={<Dashboard />} />
|
|
||||||
{Object.entries(CUSTOM).map(([path, Page]) => (
|
|
||||||
<Route key={path} path={path} element={<Page />} />
|
|
||||||
))}
|
|
||||||
{PAGES.filter((p) => !(p.path in CUSTOM)).map((p) => (
|
|
||||||
<Route key={p.path} path={p.path} element={<GenericPage cfg={p} />} />
|
|
||||||
))}
|
|
||||||
<Route path="*" element={<Dashboard />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</HashRouter>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
initTheme()
|
|
||||||
createRoot(document.getElementById('root')!).render(<App />)
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* The back-office menu tree (09-UX §2). Editions gate visibility: locked pages
|
|
||||||
* render as discoverable upsells, never hidden (09-UX §5.5). This is data on
|
|
||||||
* purpose — in production it comes from the DB with role filtering applied.
|
|
||||||
*/
|
|
||||||
export type Edition = 'L' | 'S' | 'P' | 'E'
|
|
||||||
|
|
||||||
export interface NavPage {
|
|
||||||
path: string
|
|
||||||
label: string
|
|
||||||
edition: Edition
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavModule {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
icon: string
|
|
||||||
pages: NavPage[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NAV: NavModule[] = [
|
|
||||||
{
|
|
||||||
key: 'dashboard', label: 'Dashboard', icon: '◧',
|
|
||||||
pages: [{ path: '/', label: 'Overview', edition: 'L' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'catalog', label: 'Catalog', icon: '⌘',
|
|
||||||
pages: [
|
|
||||||
{ path: '/catalog/items', label: 'Items', edition: 'L' },
|
|
||||||
{ path: '/catalog/categories', label: 'Categories', edition: 'L' },
|
|
||||||
{ path: '/catalog/price-lists', label: 'Price Lists', edition: 'S' },
|
|
||||||
{ path: '/catalog/schemes', label: 'Schemes & Offers', edition: 'S' },
|
|
||||||
{ path: '/catalog/labels', label: 'Label Printing', edition: 'S' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'sales', label: 'Sales', icon: '₹',
|
|
||||||
pages: [
|
|
||||||
{ path: '/sales/bills', label: 'Bills', edition: 'L' },
|
|
||||||
{ path: '/sales/returns', label: 'Returns & Credit Notes', edition: 'L' },
|
|
||||||
{ path: '/sales/customers', label: 'Customers', edition: 'L' },
|
|
||||||
{ path: '/sales/khata', label: 'Khata & Collections', edition: 'L' },
|
|
||||||
{ path: '/sales/loyalty', label: 'Loyalty', edition: 'P' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'purchases', label: 'Purchases', icon: '⇩',
|
|
||||||
pages: [
|
|
||||||
{ path: '/purchases/list', label: 'Purchase List', edition: 'L' },
|
|
||||||
{ path: '/purchases/entry', label: 'Purchase Entry', edition: 'L' },
|
|
||||||
{ path: '/purchases/inbox', label: 'Purchase Inbox', edition: 'P' },
|
|
||||||
{ path: '/purchases/suppliers', label: 'Suppliers', edition: 'L' },
|
|
||||||
{ path: '/purchases/gstr2b', label: 'GSTR-2B Reconciliation', edition: 'P' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'inventory', label: 'Inventory', icon: '▤',
|
|
||||||
pages: [
|
|
||||||
{ path: '/inventory/stock', label: 'Stock', edition: 'L' },
|
|
||||||
{ path: '/inventory/adjustments', label: 'Adjustments', edition: 'L' },
|
|
||||||
{ path: '/inventory/transfers', label: 'Transfers', edition: 'E' },
|
|
||||||
{ path: '/inventory/stock-take', label: 'Stock Take', edition: 'P' },
|
|
||||||
{ path: '/inventory/expiry', label: 'Expiry Dashboard', edition: 'P' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'accounting', label: 'Accounting', icon: '≡',
|
|
||||||
pages: [
|
|
||||||
{ path: '/accounting/vouchers', label: 'Vouchers', edition: 'S' },
|
|
||||||
{ path: '/accounting/ledgers', label: 'Ledgers', edition: 'L' },
|
|
||||||
{ path: '/accounting/day-book', label: 'Day Book', edition: 'S' },
|
|
||||||
{ path: '/accounting/pnl', label: 'P&L / Balance Sheet', edition: 'P' },
|
|
||||||
{ path: '/accounting/tally', label: 'Tally Export', edition: 'S' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'gst', label: 'GST', icon: '§',
|
|
||||||
pages: [
|
|
||||||
{ path: '/gst/gstr1', label: 'GSTR-1', edition: 'S' },
|
|
||||||
{ path: '/gst/gstr3b', label: 'GSTR-3B', edition: 'S' },
|
|
||||||
{ path: '/gst/einvoice', label: 'e-Invoice Queue', edition: 'P' },
|
|
||||||
{ path: '/gst/eway', label: 'e-Way Bills', edition: 'P' },
|
|
||||||
{ path: '/gst/hsn', label: 'HSN Summary', edition: 'L' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reports', label: 'Reports', icon: '◔',
|
|
||||||
pages: [
|
|
||||||
{ path: '/reports/sales', label: 'Sales Reports', edition: 'L' },
|
|
||||||
{ path: '/reports/margins', label: 'Margins', edition: 'S' },
|
|
||||||
{ path: '/reports/stock-aging', label: 'Stock Aging', edition: 'S' },
|
|
||||||
{ path: '/reports/day-end', label: 'Day-End Digest', edition: 'L' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'admin', label: 'Admin', icon: '⚙',
|
|
||||||
pages: [
|
|
||||||
{ path: '/admin/users', label: 'Users & Roles', edition: 'S' },
|
|
||||||
{ path: '/admin/stores', label: 'Stores & Counters', edition: 'L' },
|
|
||||||
{ path: '/admin/settings', label: 'Settings', edition: 'L' },
|
|
||||||
{ path: '/admin/templates', label: 'Print Templates', edition: 'L' },
|
|
||||||
{ path: '/admin/integrations', label: 'Integrations', edition: 'L' },
|
|
||||||
{ path: '/admin/subscription', label: 'Subscription & Plan', edition: 'L' },
|
|
||||||
{ path: '/admin/audit', label: 'Audit Log', edition: 'S' },
|
|
||||||
{ path: '/admin/sync', label: 'Sync & Devices', edition: 'L' },
|
|
||||||
{ path: '/admin/backups', label: 'Backups', edition: 'L' },
|
|
||||||
{ path: '/admin/import', label: 'Data Import', edition: 'L' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/** The wireframe tenant runs Pro; Enterprise pages render as upsells. */
|
|
||||||
export const CURRENT_EDITION: Edition = 'P'
|
|
||||||
export const EDITION_ORDER: Edition[] = ['L', 'S', 'P', 'E']
|
|
||||||
|
|
||||||
export function isLocked(page: NavPage): boolean {
|
|
||||||
return EDITION_ORDER.indexOf(page.edition) > EDITION_ORDER.indexOf(CURRENT_EDITION)
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import { Badge, DataTable, PageHeader, StatCard, Stats } from '@sims/ui'
|
|
||||||
|
|
||||||
export function Dashboard() {
|
|
||||||
return (
|
|
||||||
<div className="wf-page">
|
|
||||||
<PageHeader
|
|
||||||
title="Dashboard"
|
|
||||||
desc="Today across counters and stores — alerts first, numbers second (09-UX §2)."
|
|
||||||
/>
|
|
||||||
<Stats>
|
|
||||||
<StatCard label="Today's sales" value="₹1,84,320" hint="412 bills · +8% vs last Wed" />
|
|
||||||
<StatCard label="Avg bill value" value="₹447" hint="items/bill 6.2" />
|
|
||||||
<StatCard label="Cash / UPI split" value="50 / 33%" hint="card 10% · khata 7%" />
|
|
||||||
<StatCard label="Khata outstanding" value="₹86,420" hint="₹12,340 over 30 days" />
|
|
||||||
<StatCard label="Low stock items" value="23" hint="6 out of stock" />
|
|
||||||
<StatCard label="Near expiry (7d)" value="₹4,320" hint="18 batches" />
|
|
||||||
</Stats>
|
|
||||||
<h3>Alerts</h3>
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{ key: 'sev', label: '' },
|
|
||||||
{ key: 'what', label: 'Alert' },
|
|
||||||
{ key: 'where', label: 'Where' },
|
|
||||||
{ key: 'age', label: 'Age' },
|
|
||||||
]}
|
|
||||||
rows={[
|
|
||||||
{ sev: <Badge tone="err">high</Badge>, what: 'Coca-Cola 1.25L out of stock, weekend ahead', where: 'ST1', age: '2 h' },
|
|
||||||
{ sev: <Badge tone="warn">med</Badge>, what: 'ST2-C1 sync lag 22 min (LAN?)', where: 'ST2', age: '25 min' },
|
|
||||||
{ sev: <Badge tone="warn">med</Badge>, what: '17 draft items from POS quick-add need completion', where: 'Catalog', age: 'today' },
|
|
||||||
{ sev: <Badge tone="warn">med</Badge>, what: 'e-Invoice GSP error on ST1C1/26-000355', where: 'GST', age: '3 h' },
|
|
||||||
{ sev: <Badge>info</Badge>, what: 'Onam scheme starts 20 Aug — stock plan?', where: 'Schemes', age: '—' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<h3 style={{ marginTop: 20 }}>Counters now</h3>
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{ key: 'counter', label: 'Counter' },
|
|
||||||
{ key: 'cashier', label: 'Cashier' },
|
|
||||||
{ key: 'bills', label: 'Bills', numeric: true },
|
|
||||||
{ key: 'last', label: 'Last bill' },
|
|
||||||
{ key: 'state', label: 'State' },
|
|
||||||
]}
|
|
||||||
rows={[
|
|
||||||
{ counter: 'ST1-C1', cashier: 'Divya', bills: '198', last: '18:41', state: <Badge tone="ok">billing</Badge> },
|
|
||||||
{ counter: 'ST1-C2', cashier: 'Ramesh', bills: '214', last: '18:42', state: <Badge tone="ok">billing</Badge> },
|
|
||||||
{ counter: 'ST2-C1', cashier: 'Manu', bills: '86', last: '18:20', state: <Badge tone="warn">sync lag</Badge> },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { useLocation } from 'react-router-dom'
|
|
||||||
import { Button, DataTable, EmptyState, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui'
|
|
||||||
import { NAV, isLocked } from '../nav'
|
|
||||||
import type { PageConfig } from './registry'
|
|
||||||
|
|
||||||
/** Renders any list-page config; locked pages render the upsell, never a wall. */
|
|
||||||
export function GenericPage(props: { cfg: PageConfig }) {
|
|
||||||
const { cfg } = props
|
|
||||||
const location = useLocation()
|
|
||||||
const navPage = NAV.flatMap((m) => m.pages).find((p) => p.path === location.pathname)
|
|
||||||
const locked = navPage !== undefined && isLocked(navPage)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="wf-page">
|
|
||||||
<PageHeader
|
|
||||||
title={cfg.title}
|
|
||||||
desc={cfg.desc}
|
|
||||||
badge={locked ? `${navPage.edition === 'E' ? 'Enterprise' : 'Higher plan'}` : undefined}
|
|
||||||
actions={<>{cfg.actions.map((a) => <Button key={a}>{a}</Button>)}</>}
|
|
||||||
/>
|
|
||||||
{locked && (
|
|
||||||
<div className="upsell">
|
|
||||||
<b>This is a {navPage.edition === 'E' ? 'Enterprise' : 'higher-plan'} feature.</b>
|
|
||||||
<p style={{ color: 'var(--text-dim)' }}>
|
|
||||||
You can see exactly what it does below — upgrading unlocks it in place, no reinstall
|
|
||||||
(paywall-as-demo, 09-UX §5.5). The preview uses sample data.
|
|
||||||
</p>
|
|
||||||
<Button tone="primary">See upgrade options</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{cfg.filters !== undefined && (
|
|
||||||
<Toolbar>
|
|
||||||
{cfg.filters.map((f) => (
|
|
||||||
<select key={f} className="wf" style={{ width: 'auto', minWidth: 120 }}>
|
|
||||||
<option>{f}: All</option>
|
|
||||||
</select>
|
|
||||||
))}
|
|
||||||
<span className="spacer" />
|
|
||||||
<input className="wf" style={{ maxWidth: 240 }} placeholder="Filter rows… ( / )" />
|
|
||||||
</Toolbar>
|
|
||||||
)}
|
|
||||||
{cfg.stats !== undefined && (
|
|
||||||
<Stats>
|
|
||||||
{cfg.stats.map((s) => <StatCard key={s.label} label={s.label} value={s.value} hint={s.hint} />)}
|
|
||||||
</Stats>
|
|
||||||
)}
|
|
||||||
{cfg.rows.length === 0 ? (
|
|
||||||
<EmptyState>No records yet — wireframe.</EmptyState>
|
|
||||||
) : (
|
|
||||||
<DataTable columns={cfg.columns} rows={cfg.rows} onRowClick={() => undefined} />
|
|
||||||
)}
|
|
||||||
<Notice>
|
|
||||||
Wireframe page — every record opens a detail drawer with its audit trail in the real build
|
|
||||||
(cross-page conventions, 09-UX §2).
|
|
||||||
</Notice>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { Badge, Button, DataTable, Notice, PageHeader, Toolbar } from '@sims/ui'
|
|
||||||
|
|
||||||
/** The flagship differentiator — side-by-side review of AI-parsed supplier bills (09-UX §3). */
|
|
||||||
export function PurchaseInbox() {
|
|
||||||
return (
|
|
||||||
<div className="wf-page">
|
|
||||||
<PageHeader
|
|
||||||
title="Purchase Inbox"
|
|
||||||
desc="Suppliers email bills to bills@st1.sims.in (or drag a PDF/photo here). AI drafts the purchase; you confirm."
|
|
||||||
actions={<><Button>Upload bill</Button><Button>Email-in settings</Button></>}
|
|
||||||
/>
|
|
||||||
<Toolbar>
|
|
||||||
<Badge tone="warn">3 to review</Badge>
|
|
||||||
<Badge tone="ok">38 posted this month</Badge>
|
|
||||||
<Badge>item-match memory: 1,204 learned</Badge>
|
|
||||||
</Toolbar>
|
|
||||||
<div className="inbox-split">
|
|
||||||
<div>
|
|
||||||
<h3>Source — HUL_Invoice_HD2651.pdf</h3>
|
|
||||||
<div className="inbox-doc">{`HINDUSTAN UNILEVER DISTRIBUTOR
|
|
||||||
Invoice HD/2651 · 09-07-2026 · GSTIN 32AABCH1234K1Z6
|
|
||||||
|
|
||||||
SURF EXCEL MATIC 1KG x48 @122.00 5,856.00
|
|
||||||
RIN BAR 250G B12 x144 @10.20 1,468.80
|
|
||||||
CLINIC+ SHAMPOO 340ML x24 @168.00 4,032.00
|
|
||||||
|
|
||||||
Taxable 11,356.80
|
|
||||||
CGST 9% 1,022.11
|
|
||||||
SGST 9% 1,022.11
|
|
||||||
TOTAL 13,401.02`}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Draft purchase — review & post</h3>
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{ key: 'supplier', label: 'Supplier line' },
|
|
||||||
{ key: 'ours', label: 'Our item' },
|
|
||||||
{ key: 'qty', label: 'Qty', numeric: true },
|
|
||||||
{ key: 'cost', label: 'Cost', numeric: true },
|
|
||||||
{ key: 'conf', label: 'Match' },
|
|
||||||
]}
|
|
||||||
rows={[
|
|
||||||
{ supplier: 'SURF EXCEL MATIC 1KG', ours: 'Surf Excel Matic 1kg (103)', qty: '48', cost: '122.00', conf: <Badge tone="ok">remembered</Badge> },
|
|
||||||
{ supplier: 'RIN BAR 250G B12', ours: 'Rin Bar 250g (207)', qty: '144', cost: '10.20', conf: <Badge tone="ok">98%</Badge> },
|
|
||||||
{ supplier: 'CLINIC+ SHAMPOO 340ML', ours: <em>choose item…</em>, qty: '24', cost: '168.00', conf: <Badge tone="warn">new mapping</Badge> },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Notice tone="warn">
|
|
||||||
Total check: parsed ₹13,401.02 = draft ₹13,401.02 ✓ · Tax check: CGST+SGST @9% ✓ ·
|
|
||||||
Duplicate check: HD/2651 not seen before ✓
|
|
||||||
</Notice>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button tone="primary">Post purchase</Button>
|
|
||||||
<Button>Save draft</Button>
|
|
||||||
<Button tone="danger">Reject</Button>
|
|
||||||
</div>
|
|
||||||
<Notice>
|
|
||||||
Confirming the shampoo mapping teaches the matcher — next month it posts with one click.
|
|
||||||
MRP changed on Surf (₹150 → ₹152): label reprint will be queued automatically.
|
|
||||||
</Notice>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Badge, Button, DataTable, Notice, PageHeader } from '@sims/ui'
|
|
||||||
|
|
||||||
const SCOPES = ['System default', 'Tenant', 'Store: ST1', 'Counter: ST1-C2', 'User: Ramesh'] as const
|
|
||||||
|
|
||||||
/** The settings hierarchy made visible — nearest scope wins (11-ADMIN §2). */
|
|
||||||
export function SettingsPage() {
|
|
||||||
const [scope, setScope] = useState<(typeof SCOPES)[number]>('Store: ST1')
|
|
||||||
return (
|
|
||||||
<div className="wf-page">
|
|
||||||
<PageHeader
|
|
||||||
title="Settings"
|
|
||||||
desc="One mechanism, five scopes: system → tenant → store → counter → user. Every override shows what it inherits; every change is effective-dated and audit-logged."
|
|
||||||
/>
|
|
||||||
<div className="wf-toolbar">
|
|
||||||
{SCOPES.map((s) => (
|
|
||||||
<button key={s} type="button" className={`wf${s === scope ? ' primary' : ''}`} onClick={() => setScope(s)}>
|
|
||||||
{s}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<span className="spacer" />
|
|
||||||
<Button>Show changed-from-default only</Button>
|
|
||||||
</div>
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{ key: 'key', label: 'Setting' },
|
|
||||||
{ key: 'value', label: `Value at ${scope}` },
|
|
||||||
{ key: 'from', label: 'Inherited from' },
|
|
||||||
{ key: 'act', label: '' },
|
|
||||||
]}
|
|
||||||
rows={[
|
|
||||||
{ key: 'discount.capBp (cashier max %)', value: '15%', from: <Badge tone="accent">override here</Badge>, act: <Button>Reset to inherited</Button> },
|
|
||||||
{ key: 'gst.roundToRupee', value: 'true', from: <Badge>System</Badge>, act: <Button>Override…</Button> },
|
|
||||||
{ key: 'negativeStock.policy', value: 'warn (allow with supervisor)', from: <Badge>Tenant</Badge>, act: <Button>Override…</Button> },
|
|
||||||
{ key: 'print.language', value: 'English + Malayalam', from: <Badge tone="accent">override here</Badge>, act: <Button>Reset to inherited</Button> },
|
|
||||||
{ key: 'khata.defaultLimit', value: '₹5,000', from: <Badge>Tenant</Badge>, act: <Button>Override…</Button> },
|
|
||||||
{ key: 'approval.timeoutSec', value: '45', from: <Badge>System</Badge>, act: <Button>Override…</Button> },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Notice>
|
|
||||||
Wireframe of `setting(scope_type, scope_id, key, value, effective_from)` resolved by
|
|
||||||
@sims/config — already implemented and tested in packages/config.
|
|
||||||
</Notice>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import { Badge, Button, DataTable, Notice, PageHeader, Toolbar } from '@sims/ui'
|
|
||||||
|
|
||||||
const CHECK = '✓'
|
|
||||||
const PIN = 'PIN'
|
|
||||||
|
|
||||||
/** Users + the role→action matrix with supervisor-PIN gates (09-UX §2, @sims/auth). */
|
|
||||||
export function UsersRoles() {
|
|
||||||
return (
|
|
||||||
<div className="wf-page">
|
|
||||||
<PageHeader
|
|
||||||
title="Users & Roles"
|
|
||||||
desc="Deactivate, never delete. Sensitive in-flow actions stay visible to cashiers but raise the supervisor-PIN overlay."
|
|
||||||
actions={<><Button>New user</Button><Button>New role</Button></>}
|
|
||||||
/>
|
|
||||||
<h3>Users</h3>
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{ key: 'name', label: 'Name' }, { key: 'roles', label: 'Roles' },
|
|
||||||
{ key: 'surfaces', label: 'Surfaces' }, { key: 'stores', label: 'Stores' },
|
|
||||||
{ key: 'lang', label: 'Language' }, { key: 'status', label: 'Status' },
|
|
||||||
]}
|
|
||||||
rows={[
|
|
||||||
{ name: 'Thomas', roles: 'owner', surfaces: 'POS · Back office · Owner app', stores: 'all', lang: 'English', status: <Badge tone="ok">active</Badge> },
|
|
||||||
{ name: 'Suresh', roles: 'supervisor', surfaces: 'POS', stores: 'ST1', lang: 'Malayalam', status: <Badge tone="ok">active</Badge> },
|
|
||||||
{ name: 'Ramesh', roles: 'cashier', surfaces: 'POS', stores: 'ST1', lang: 'Malayalam', status: <Badge tone="ok">active</Badge> },
|
|
||||||
{ name: 'Divya', roles: 'cashier', surfaces: 'POS', stores: 'ST1', lang: 'Tamil', status: <Badge tone="ok">active</Badge> },
|
|
||||||
{ name: 'Old Cashier', roles: 'cashier', surfaces: '—', stores: '—', lang: '—', status: <Badge>deactivated</Badge> },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<h3 style={{ marginTop: 20 }}>Permission matrix</h3>
|
|
||||||
<Toolbar>
|
|
||||||
<Badge tone="warn">PIN = allowed via supervisor PIN overlay</Badge>
|
|
||||||
<Badge>matrix rows are DB data (config-driven) — editable without release</Badge>
|
|
||||||
</Toolbar>
|
|
||||||
<div className="matrix">
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{ key: 'action', label: 'Action' },
|
|
||||||
{ key: 'cashier', label: 'Cashier' }, { key: 'supervisor', label: 'Supervisor' },
|
|
||||||
{ key: 'purchaser', label: 'Purchaser' }, { key: 'accountant', label: 'Accountant' },
|
|
||||||
{ key: 'manager', label: 'Manager' }, { key: 'owner', label: 'Owner' },
|
|
||||||
]}
|
|
||||||
rows={[
|
|
||||||
{ action: 'Create bill', cashier: CHECK, supervisor: CHECK, purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
|
|
||||||
{ action: 'Price override', cashier: PIN, supervisor: CHECK, purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
|
|
||||||
{ action: 'Discount above cap', cashier: PIN, supervisor: CHECK, purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
|
|
||||||
{ action: 'Void bill', cashier: PIN, supervisor: CHECK, purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
|
|
||||||
{ action: 'Drawer no-sale', cashier: PIN, supervisor: CHECK, purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
|
|
||||||
{ action: 'Purchase entry', cashier: '—', supervisor: '—', purchaser: CHECK, accountant: '—', manager: CHECK, owner: CHECK },
|
|
||||||
{ action: 'Stock adjustment', cashier: '—', supervisor: '—', purchaser: CHECK, accountant: '—', manager: CHECK, owner: CHECK },
|
|
||||||
{ action: 'Day Begin / Day End', cashier: PIN, supervisor: '—', purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
|
|
||||||
{ action: 'Settings edit', cashier: '—', supervisor: '—', purchaser: '—', accountant: '—', manager: CHECK, owner: CHECK },
|
|
||||||
{ action: 'Audit log view', cashier: '—', supervisor: '—', purchaser: '—', accountant: CHECK, manager: '—', owner: CHECK },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Notice>
|
|
||||||
Matrix edits show “affects N users” before saving and land in the audit log. Backed by
|
|
||||||
@sims/auth `DEFAULT_ROLE_GRANTS` + `gateFor()` — implemented and tested.
|
|
||||||
</Notice>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,351 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { formatINR } from '@sims/domain'
|
|
||||||
import { taxOf, type CreditNote, type Gstr1SectionTotals, type HsnRow, type Reconciliation, type TaxAmounts } from '@sims/billing-engine'
|
|
||||||
import { Badge, Button, DataTable, EmptyState, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui'
|
|
||||||
import { getGstHsn, getGstr1, getGstr1Json, getGstr3b, type Gstr1Response } from '../api'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GST returns — LIVE pages (GSTR-1, GSTR-3B, HSN summary). Every number is folded server-side by
|
|
||||||
* the shared engine from the period's immutable documents and re-verified by a reconciliation proof
|
|
||||||
* shown at the top of each page. These are decision-support / export views for a CA or GSP, NOT a
|
|
||||||
* certified filing (stated on every page). Replaces the registry wireframes via the CUSTOM map.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const inr = (p: unknown): string => formatINR(Number(p), { symbol: false })
|
|
||||||
const rateLabel = (bp: number): string => (bp === 0 ? '0%' : `${bp / 100}%`)
|
|
||||||
const thisMonth = (): string => new Date().toISOString().slice(0, 7)
|
|
||||||
|
|
||||||
function useData<T>(loader: () => Promise<T>, deps: unknown[]): { data?: T; error?: string } {
|
|
||||||
const [data, setData] = useState<T | undefined>()
|
|
||||||
const [error, setError] = useState<string | undefined>()
|
|
||||||
useEffect(() => {
|
|
||||||
setData(undefined)
|
|
||||||
setError(undefined)
|
|
||||||
loader().then(setData).catch((e: Error) => setError(e.message))
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, deps)
|
|
||||||
return { data, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
function MonthInput(props: { period: string; onChange: (p: string) => void }) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="month" className="wf" style={{ maxWidth: 170 }}
|
|
||||||
value={props.period} max={thisMonth()} onChange={(e) => props.onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The reconciliation proof — green when sections == source == HSN == 3B, red (loud) otherwise. */
|
|
||||||
function ReconBanner(props: { recon: Reconciliation }) {
|
|
||||||
const r = props.recon
|
|
||||||
if (r.ok) {
|
|
||||||
return (
|
|
||||||
<Notice tone="ok">
|
|
||||||
Reconciled: GSTR-1 sections = source documents = HSN summary = GSTR-3B outward.
|
|
||||||
Net taxable {formatINR(r.sourceTaxablePaise)}, net tax {formatINR(r.sourceTaxPaise)} (returns reduce liability at their original rate).
|
|
||||||
</Notice>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Notice tone="err">
|
|
||||||
DOES NOT RECONCILE — do not file. {r.mismatches.join('; ')}. Source taxable {formatINR(r.sourceTaxablePaise)} vs
|
|
||||||
GSTR-1 {formatINR(r.gstr1NetTaxablePaise)} vs HSN {formatINR(r.hsnTaxablePaise)} vs 3B {formatINR(r.gstr3bTaxablePaise)}.
|
|
||||||
</Notice>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotCertified() {
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 18 }}>
|
|
||||||
<Notice tone="warn">
|
|
||||||
Not a certified filing. These figures are computed from your counters' immutable documents for review and
|
|
||||||
hand-off to your CA / GSP; real e-filing goes through a GSP. Nil-rated/exempt/export splits, ITC (GSTR-2A/2B),
|
|
||||||
amendments and the cross-period filing-freeze are follow-ups — see the deferred list.
|
|
||||||
</Notice>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadJson(obj: unknown, filename: string): void {
|
|
||||||
const url = URL.createObjectURL(new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }))
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = filename
|
|
||||||
a.click()
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 10_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const AMOUNT_COLS = [
|
|
||||||
{ key: 'taxable', label: 'Taxable', numeric: true }, { key: 'igst', label: 'IGST', numeric: true },
|
|
||||||
{ key: 'cgst', label: 'CGST', numeric: true }, { key: 'sgst', label: 'SGST', numeric: true },
|
|
||||||
{ key: 'cess', label: 'Cess', numeric: true },
|
|
||||||
]
|
|
||||||
const amountCells = (a: TaxAmounts): Record<string, string> => ({
|
|
||||||
taxable: inr(a.taxablePaise), igst: inr(a.igstPaise), cgst: inr(a.cgstPaise), sgst: inr(a.sgstPaise), cess: inr(a.cessPaise),
|
|
||||||
})
|
|
||||||
|
|
||||||
/** GSTR-1 section-total summary (returns shown as reductions), ending in the reconciled Net row. */
|
|
||||||
function SectionSummary(props: { totals: Gstr1SectionTotals }) {
|
|
||||||
const t = props.totals
|
|
||||||
const neg = (a: TaxAmounts): TaxAmounts => ({ taxablePaise: -a.taxablePaise, igstPaise: -a.igstPaise, cgstPaise: -a.cgstPaise, sgstPaise: -a.sgstPaise, cessPaise: -a.cessPaise })
|
|
||||||
const rows = [
|
|
||||||
{ section: 'B2B (4A) — registered', ...amountCells(t.b2b) },
|
|
||||||
{ section: 'B2C Large (5) — inter-state', ...amountCells(t.b2cl) },
|
|
||||||
{ section: 'B2C Small (7)', ...amountCells(t.b2cs) },
|
|
||||||
{ section: 'CDNR (9B) — reg. credit notes', ...amountCells(neg(t.cdnr)) },
|
|
||||||
{ section: 'CDNUR (9B) — unreg. credit notes', ...amountCells(neg(t.cdnur)) },
|
|
||||||
{ section: 'Net outward', ...amountCells(t.net) },
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3 style={{ margin: '18px 0 8px' }}>Section summary</h3>
|
|
||||||
<DataTable columns={[{ key: 'section', label: 'Section' }, ...AMOUNT_COLS]} rows={rows} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function B2BTable(props: { groups: Gstr1Response['b2b'] }) {
|
|
||||||
const rows = props.groups.flatMap((g) =>
|
|
||||||
g.rates.map((r, i) => ({
|
|
||||||
gstin: i === 0 ? g.ctin : '', invoices: i === 0 ? String(g.invoiceCount) : '',
|
|
||||||
rate: rateLabel(r.rateBp), ...amountCells(r),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3 style={{ margin: '18px 0 8px' }}>B2B — invoices to registered buyers (Table 4A)</h3>
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{ key: 'gstin', label: 'Buyer GSTIN' }, { key: 'invoices', label: 'Invoices', numeric: true },
|
|
||||||
{ key: 'rate', label: 'Rate' }, ...AMOUNT_COLS,
|
|
||||||
]}
|
|
||||||
rows={rows}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function B2CSTable(props: { buckets: Gstr1Response['b2cs'] }) {
|
|
||||||
const rows = props.buckets.map((b) => ({
|
|
||||||
pos: b.pos, type: b.supplyType === 'INTER' ? 'Inter' : 'Intra', rate: rateLabel(b.rateBp), ...amountCells(b),
|
|
||||||
}))
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3 style={{ margin: '18px 0 8px' }}>B2C Small — aggregated by place of supply + rate (Table 7)</h3>
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{ key: 'pos', label: 'Place of supply' }, { key: 'type', label: 'Type' },
|
|
||||||
{ key: 'rate', label: 'Rate' }, ...AMOUNT_COLS,
|
|
||||||
]}
|
|
||||||
rows={rows}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function B2CLTable(props: { invoices: Gstr1Response['b2cl'] }) {
|
|
||||||
const rows = props.invoices.flatMap((inv) =>
|
|
||||||
inv.rates.map((r, i) => ({
|
|
||||||
no: i === 0 ? inv.docNo : '', date: i === 0 ? inv.businessDate : '', pos: inv.pos,
|
|
||||||
value: i === 0 ? inr(inv.invoiceValuePaise) : '', rate: rateLabel(r.rateBp),
|
|
||||||
taxable: inr(r.taxablePaise), igst: inr(r.igstPaise), cess: inr(r.cessPaise),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3 style={{ margin: '18px 0 8px' }}>B2C Large — inter-state invoices above ₹2,50,000 (Table 5)</h3>
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{ key: 'no', label: 'Invoice' }, { key: 'date', label: 'Date' }, { key: 'pos', label: 'POS' },
|
|
||||||
{ key: 'value', label: 'Value', numeric: true }, { key: 'rate', label: 'Rate' },
|
|
||||||
{ key: 'taxable', label: 'Taxable', numeric: true }, { key: 'igst', label: 'IGST', numeric: true },
|
|
||||||
{ key: 'cess', label: 'Cess', numeric: true },
|
|
||||||
]}
|
|
||||||
rows={rows}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CDNTable(props: { title: string; notes: CreditNote[]; showCtin?: boolean }) {
|
|
||||||
const rows = props.notes.flatMap((n) =>
|
|
||||||
n.rates.map((r, i) => ({
|
|
||||||
no: i === 0 ? n.noteDocNo : '', ctin: i === 0 ? (n.ctin ?? '—') : '',
|
|
||||||
against: i === 0 ? (n.originalDocNo ?? '—') : '', rate: rateLabel(r.rateBp), ...amountCells(r),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const cols = [
|
|
||||||
{ key: 'no', label: 'Credit Note' },
|
|
||||||
...(props.showCtin === true ? [{ key: 'ctin', label: 'Buyer GSTIN' }] : []),
|
|
||||||
{ key: 'against', label: 'Against Bill' }, { key: 'rate', label: 'Rate' }, ...AMOUNT_COLS,
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3 style={{ margin: '18px 0 8px' }}>{props.title}</h3>
|
|
||||||
<DataTable columns={cols} rows={rows} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function HsnTable(props: { rows: HsnRow[]; totals: TaxAmounts }) {
|
|
||||||
const rows: Record<string, string>[] = props.rows.map((h) => ({
|
|
||||||
hsn: h.hsn, rate: rateLabel(h.rateBp), uqc: h.uqc,
|
|
||||||
qty: String(Math.round(h.qty * 1000) / 1000), ...amountCells(h),
|
|
||||||
}))
|
|
||||||
rows.push({ hsn: 'Total', rate: '', uqc: '', qty: '', ...amountCells(props.totals) })
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3 style={{ margin: '18px 0 8px' }}>HSN summary (Table 12)</h3>
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{ key: 'hsn', label: 'HSN' }, { key: 'rate', label: 'Rate' }, { key: 'uqc', label: 'UQC' },
|
|
||||||
{ key: 'qty', label: 'Qty', numeric: true }, ...AMOUNT_COLS,
|
|
||||||
]}
|
|
||||||
rows={rows}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DocsTable(props: { docs: Gstr1Response['docs'] }) {
|
|
||||||
if (props.docs.length === 0) return null
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3 style={{ margin: '18px 0 8px' }}>Documents issued (Table 13)</h3>
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{ key: 'nature', label: 'Nature' }, { key: 'from', label: 'From' }, { key: 'to', label: 'To' },
|
|
||||||
{ key: 'total', label: 'Total', numeric: true }, { key: 'cancel', label: 'Cancelled', numeric: true },
|
|
||||||
{ key: 'net', label: 'Net', numeric: true },
|
|
||||||
]}
|
|
||||||
rows={props.docs.map((d) => ({
|
|
||||||
nature: d.natureOfDocument, from: d.fromDocNo, to: d.toDocNo,
|
|
||||||
total: String(d.totalNumber), cancel: String(d.cancelled), net: String(d.net),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Gstr1Page() {
|
|
||||||
const [period, setPeriod] = useState(thisMonth())
|
|
||||||
const { data, error } = useData(() => getGstr1(period), [period])
|
|
||||||
const [jsonErr, setJsonErr] = useState<string | undefined>()
|
|
||||||
const download = (): void => {
|
|
||||||
setJsonErr(undefined)
|
|
||||||
getGstr1Json(period).then((obj) => downloadJson(obj, `GSTR1-${period}.json`)).catch((e: Error) => setJsonErr(e.message))
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="wf-page">
|
|
||||||
<PageHeader
|
|
||||||
title="GSTR-1" badge="LIVE"
|
|
||||||
desc="Outward supplies for the month — B2B, B2C, credit notes and HSN — folded live from the counters' immutable documents. A return reduces liability at its original bill-date rate."
|
|
||||||
actions={<Button tone="primary" onClick={download}>Download JSON</Button>}
|
|
||||||
/>
|
|
||||||
<Toolbar>
|
|
||||||
<MonthInput period={period} onChange={setPeriod} />
|
|
||||||
<Badge tone="accent">Period {period}</Badge>
|
|
||||||
{data !== undefined && <Badge>{data.counts.saleDocs} invoices · {data.counts.returnDocs} credit notes</Badge>}
|
|
||||||
</Toolbar>
|
|
||||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
|
||||||
{jsonErr !== undefined && <Notice tone="warn">{jsonErr}</Notice>}
|
|
||||||
{data === undefined && error === undefined ? <EmptyState>Loading…</EmptyState> : data !== undefined && (
|
|
||||||
<>
|
|
||||||
<ReconBanner recon={data.reconciliation} />
|
|
||||||
<Stats>
|
|
||||||
<StatCard label="Net taxable value" value={formatINR(data.totals.net.taxablePaise)} hint="net of credit notes" />
|
|
||||||
<StatCard label="Net tax" value={formatINR(taxOf(data.totals.net))} hint="IGST + CGST + SGST + cess" />
|
|
||||||
<StatCard label="B2B · B2CS · B2CL" value={`${data.counts.b2bInvoices} · ${data.b2cs.length} · ${data.counts.b2clInvoices}`} hint={`${data.counts.b2bParties} registered buyers`} />
|
|
||||||
</Stats>
|
|
||||||
<SectionSummary totals={data.totals} />
|
|
||||||
{data.b2b.length > 0 && <B2BTable groups={data.b2b} />}
|
|
||||||
{data.b2cs.length > 0 && <B2CSTable buckets={data.b2cs} />}
|
|
||||||
{data.b2cl.length > 0 && <B2CLTable invoices={data.b2cl} />}
|
|
||||||
{data.cdnr.length > 0 && <CDNTable title="CDNR — credit notes to registered buyers (Table 9B)" notes={data.cdnr} showCtin />}
|
|
||||||
{data.cdnur.length > 0 && <CDNTable title="CDNUR — credit notes, unregistered inter-state large (Table 9B)" notes={data.cdnur} />}
|
|
||||||
<HsnTable rows={data.hsn} totals={data.totals.hsn} />
|
|
||||||
<DocsTable docs={data.docs} />
|
|
||||||
<NotCertified />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Gstr3bPage() {
|
|
||||||
const [period, setPeriod] = useState(thisMonth())
|
|
||||||
const { data, error } = useData(() => getGstr3b(period), [period])
|
|
||||||
return (
|
|
||||||
<div className="wf-page">
|
|
||||||
<PageHeader
|
|
||||||
title="GSTR-3B" badge="LIVE"
|
|
||||||
desc="Summary return — Table 3.1(a) outward taxable supplies, net of credit notes at their original rates. ITC / inward (Table 4) is a follow-up."
|
|
||||||
/>
|
|
||||||
<Toolbar>
|
|
||||||
<MonthInput period={period} onChange={setPeriod} />
|
|
||||||
<Badge tone="accent">Period {period}</Badge>
|
|
||||||
</Toolbar>
|
|
||||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
|
||||||
{data === undefined && error === undefined ? <EmptyState>Loading…</EmptyState> : data !== undefined && (
|
|
||||||
<>
|
|
||||||
<ReconBanner recon={data.reconciliation} />
|
|
||||||
<Stats>
|
|
||||||
<StatCard label="Total taxable value" value={formatINR(data.outward.taxablePaise)} hint="3.1(a), net of returns" />
|
|
||||||
<StatCard label="Total tax" value={formatINR(taxOf(data.outward))} hint="IGST + CGST + SGST + cess" />
|
|
||||||
<StatCard label="Net liability" value={formatINR(taxOf(data.outward))} hint="before ITC set-off (Table 4 — follow-up)" />
|
|
||||||
</Stats>
|
|
||||||
<h3 style={{ margin: '18px 0 8px' }}>3.1 Details of outward supplies</h3>
|
|
||||||
<DataTable
|
|
||||||
columns={[{ key: 'nature', label: 'Nature of supply' }, ...AMOUNT_COLS]}
|
|
||||||
rows={[{ nature: '(a) Outward taxable supplies (other than zero-rated, nil, exempt)', ...amountCells(data.outward) }]}
|
|
||||||
/>
|
|
||||||
<h3 style={{ margin: '18px 0 8px' }}>How 3.1(a) is derived</h3>
|
|
||||||
<DataTable
|
|
||||||
columns={[{ key: 'step', label: '' }, ...AMOUNT_COLS]}
|
|
||||||
rows={[
|
|
||||||
{ step: 'Gross outward (sales)', ...amountCells(data.salesTax) },
|
|
||||||
{ step: 'Less: credit notes (returns)', ...amountCells({ taxablePaise: -data.returnsTax.taxablePaise, igstPaise: -data.returnsTax.igstPaise, cgstPaise: -data.returnsTax.cgstPaise, sgstPaise: -data.returnsTax.sgstPaise, cessPaise: -data.returnsTax.cessPaise }) },
|
|
||||||
{ step: '= Net outward (3.1a)', ...amountCells(data.outward) },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div style={{ marginTop: 14 }}>
|
|
||||||
<Notice tone="warn">{data.itc.note}</Notice>
|
|
||||||
</div>
|
|
||||||
<NotCertified />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HsnPage() {
|
|
||||||
const [period, setPeriod] = useState(thisMonth())
|
|
||||||
const { data, error } = useData(() => getGstHsn(period), [period])
|
|
||||||
return (
|
|
||||||
<div className="wf-page">
|
|
||||||
<PageHeader
|
|
||||||
title="HSN Summary" badge="LIVE"
|
|
||||||
desc="HSN-wise outward summary (GSTR-1 Table 12), net of returns. Quantity and amounts are derived from the period's immutable document lines."
|
|
||||||
/>
|
|
||||||
<Toolbar>
|
|
||||||
<MonthInput period={period} onChange={setPeriod} />
|
|
||||||
<Badge tone="accent">Period {period}</Badge>
|
|
||||||
{data !== undefined && <Badge>{data.rows.length} HSN rows</Badge>}
|
|
||||||
</Toolbar>
|
|
||||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
|
||||||
{data === undefined && error === undefined ? <EmptyState>Loading…</EmptyState> : data !== undefined && (
|
|
||||||
<>
|
|
||||||
<ReconBanner recon={data.reconciliation} />
|
|
||||||
<Stats>
|
|
||||||
<StatCard label="Taxable value" value={formatINR(data.totals.taxablePaise)} hint="all HSN, net of returns" />
|
|
||||||
<StatCard label="Total tax" value={formatINR(taxOf(data.totals))} hint="IGST + CGST + SGST + cess" />
|
|
||||||
</Stats>
|
|
||||||
{data.rows.length === 0 ? <EmptyState>No outward supply in {period}.</EmptyState>
|
|
||||||
: <HsnTable rows={data.rows} totals={data.totals} />}
|
|
||||||
<NotCertified />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"include": ["src/**/*"]
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const p = (rel: string) => fileURLToPath(new URL(rel, import.meta.url))
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
base: './',
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@sims/domain': p('../../packages/domain/src/index.ts'),
|
|
||||||
'@sims/ui': p('../../packages/ui/src/index.ts'),
|
|
||||||
'@sims/scanning': p('../../packages/scanning/src/index.ts'),
|
|
||||||
'@sims/search-core': p('../../packages/search-core/src/index.ts'),
|
|
||||||
'@sims/billing-engine': p('../../packages/billing-engine/src/index.ts'),
|
|
||||||
'@sims/printing': p('../../packages/printing/src/index.ts'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: { port: 5180 },
|
|
||||||
})
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
# apps/pos — POS Counter (Electron + React)
|
|
||||||
|
|
||||||
The counter shell. Lands with the Phase-0 hardware spike (ESC/POS printing, drawer kick,
|
|
||||||
wedge scanner, scale). It is deliberately thin: billing math lives in
|
|
||||||
`@sims/billing-engine`, scan handling in `@sims/scanning`, login/PIN/gates in
|
|
||||||
`@sims/auth`, settings/messages in `@sims/config` — all already built and tested.
|
|
||||||
Screen specs: docs/09-UX-FLOWS-AND-MENUS.md §1.
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { build } from 'esbuild'
|
|
||||||
|
|
||||||
for (const [entry, out] of [
|
|
||||||
['electron/main.ts', 'dist-electron/main.cjs'],
|
|
||||||
['electron/preload.ts', 'dist-electron/preload.cjs'],
|
|
||||||
]) {
|
|
||||||
await build({
|
|
||||||
entryPoints: [entry],
|
|
||||||
outfile: out,
|
|
||||||
bundle: true,
|
|
||||||
platform: 'node',
|
|
||||||
format: 'cjs',
|
|
||||||
external: ['electron'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
console.log('electron main + preload built')
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
var __create = Object.create;
|
|
||||||
var __defProp = Object.defineProperty;
|
|
||||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
||||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
||||||
var __getProtoOf = Object.getPrototypeOf;
|
|
||||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
||||||
var __copyProps = (to, from, except, desc) => {
|
|
||||||
if (from && typeof from === "object" || typeof from === "function") {
|
|
||||||
for (let key of __getOwnPropNames(from))
|
|
||||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
||||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
||||||
}
|
|
||||||
return to;
|
|
||||||
};
|
|
||||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
||||||
// If the importer is in node compatibility mode or this is not an ESM
|
|
||||||
// file that has been converted to a CommonJS file using a Babel-
|
|
||||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
||||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
||||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
||||||
mod
|
|
||||||
));
|
|
||||||
|
|
||||||
// electron/main.ts
|
|
||||||
var import_electron = require("electron");
|
|
||||||
var import_node_net = __toESM(require("node:net"), 1);
|
|
||||||
var import_node_path = __toESM(require("node:path"), 1);
|
|
||||||
function createWindow() {
|
|
||||||
const win = new import_electron.BrowserWindow({
|
|
||||||
width: 1366,
|
|
||||||
height: 820,
|
|
||||||
backgroundColor: "#0f1216",
|
|
||||||
autoHideMenuBar: true,
|
|
||||||
webPreferences: {
|
|
||||||
preload: import_node_path.default.join(__dirname, "preload.cjs"),
|
|
||||||
contextIsolation: true,
|
|
||||||
nodeIntegration: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
void win.loadFile(import_node_path.default.join(__dirname, "../dist/index.html"));
|
|
||||||
}
|
|
||||||
import_electron.ipcMain.handle("printers:list", async (event) => {
|
|
||||||
const printers = await event.sender.getPrintersAsync();
|
|
||||||
return printers.map((p) => ({ name: p.name, displayName: p.displayName }));
|
|
||||||
});
|
|
||||||
import_electron.ipcMain.handle("print:raw9100", (_event, host, port, bytes) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const socket = import_node_net.default.createConnection({ host, port, timeout: 3e3 });
|
|
||||||
socket.on("connect", () => {
|
|
||||||
socket.end(Buffer.from(bytes));
|
|
||||||
});
|
|
||||||
socket.on("close", () => resolve("sent"));
|
|
||||||
socket.on("timeout", () => {
|
|
||||||
socket.destroy();
|
|
||||||
reject(new Error(`Printer ${host}:${port} timed out`));
|
|
||||||
});
|
|
||||||
socket.on("error", (err) => reject(err));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
import_electron.app.whenReady().then(createWindow);
|
|
||||||
import_electron.app.on("window-all-closed", () => import_electron.app.quit());
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
// electron/preload.ts
|
|
||||||
var import_electron = require("electron");
|
|
||||||
import_electron.contextBridge.exposeInMainWorld("pos", {
|
|
||||||
listPrinters: () => import_electron.ipcRenderer.invoke("printers:list"),
|
|
||||||
printRaw9100: (host, port, bytes) => import_electron.ipcRenderer.invoke("print:raw9100", host, port, bytes)
|
|
||||||
});
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
|
||||||
import net from 'node:net'
|
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase-0 spike main process. Print transport is network ESC/POS (port 9100)
|
|
||||||
* — the zero-driver path every thermal printer brand supports. USB-shared
|
|
||||||
* printers come later via the raw-spool decision (see BUILDING.md).
|
|
||||||
*/
|
|
||||||
function createWindow(): void {
|
|
||||||
const win = new BrowserWindow({
|
|
||||||
width: 1366,
|
|
||||||
height: 820,
|
|
||||||
backgroundColor: '#0f1216',
|
|
||||||
autoHideMenuBar: true,
|
|
||||||
webPreferences: {
|
|
||||||
preload: path.join(__dirname, 'preload.cjs'),
|
|
||||||
contextIsolation: true,
|
|
||||||
nodeIntegration: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
void win.loadFile(path.join(__dirname, '../dist/index.html'))
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('printers:list', async (event) => {
|
|
||||||
const printers = await event.sender.getPrintersAsync()
|
|
||||||
return printers.map((p) => ({ name: p.name, displayName: p.displayName }))
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('print:raw9100', (_event, host: string, port: number, bytes: Uint8Array) => {
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
const socket = net.createConnection({ host, port, timeout: 3000 })
|
|
||||||
socket.on('connect', () => {
|
|
||||||
socket.end(Buffer.from(bytes))
|
|
||||||
})
|
|
||||||
socket.on('close', () => resolve('sent'))
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy()
|
|
||||||
reject(new Error(`Printer ${host}:${port} timed out`))
|
|
||||||
})
|
|
||||||
socket.on('error', (err) => reject(err))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
app.whenReady().then(createWindow)
|
|
||||||
app.on('window-all-closed', () => app.quit())
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('pos', {
|
|
||||||
listPrinters: (): Promise<{ name: string; displayName: string }[]> =>
|
|
||||||
ipcRenderer.invoke('printers:list'),
|
|
||||||
printRaw9100: (host: string, port: number, bytes: Uint8Array): Promise<string> =>
|
|
||||||
ipcRenderer.invoke('print:raw9100', host, port, bytes),
|
|
||||||
})
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>SiMS Next POS</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@sims/pos",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist-electron/main.cjs",
|
|
||||||
"scripts": {
|
|
||||||
"build": "vite build && node build-main.mjs",
|
|
||||||
"start": "npm run build && electron .",
|
|
||||||
"dev": "vite",
|
|
||||||
"typecheck": "tsc -p tsconfig.json"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@sims/auth": "*",
|
|
||||||
"@sims/billing-engine": "*",
|
|
||||||
"@sims/config": "*",
|
|
||||||
"@sims/domain": "*",
|
|
||||||
"@sims/printing": "*",
|
|
||||||
"@sims/scanning": "*",
|
|
||||||
"@sims/ui": "*",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^19.0.0",
|
|
||||||
"@types/react-dom": "^19.0.0",
|
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
|
||||||
"electron": "^43.0.0",
|
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"vite": "^6.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
/*
|
|
||||||
* SiMS POS app-shell cache (S4 deliverable 4). Dead-simple cache-first for
|
|
||||||
* same-origin GET so /pos/ still loads when the store-server is down.
|
|
||||||
*
|
|
||||||
* Deliberate limitations (documented, not bugs):
|
|
||||||
* - The data API is NEVER cached: any /api/* request goes straight to the network
|
|
||||||
* and is allowed to fail when the server is down, so the app's offline queue and
|
|
||||||
* the health-ping drain logic stay in control (a cached /api/health would break
|
|
||||||
* the drain). Only GET is handled; bill POSTs pass through untouched.
|
|
||||||
* - Hashed assets are cached on first fetch (runtime), not precached at build time
|
|
||||||
* — the first load must happen online. The navigation root is best-effort
|
|
||||||
* precached on install.
|
|
||||||
* - Cache-first means a new deploy is picked up only after CACHE is bumped below;
|
|
||||||
* for a counter that values offline resilience over instant updates this is the
|
|
||||||
* right trade, and hashed asset names make stale bundles self-correcting.
|
|
||||||
*/
|
|
||||||
const CACHE = 'sims-pos-shell-v1'
|
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.open(CACHE).then((c) => c.addAll(['./', './index.html']).catch(() => undefined)),
|
|
||||||
)
|
|
||||||
self.skipWaiting()
|
|
||||||
})
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))),
|
|
||||||
)
|
|
||||||
self.clients.claim()
|
|
||||||
})
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const req = event.request
|
|
||||||
if (req.method !== 'GET') return
|
|
||||||
const url = new URL(req.url)
|
|
||||||
if (url.origin !== self.location.origin) return
|
|
||||||
if (url.pathname.startsWith('/api/')) return // never cache the data/print/health API
|
|
||||||
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(req).then((hit) => {
|
|
||||||
if (hit !== undefined) return hit
|
|
||||||
return fetch(req)
|
|
||||||
.then((res) => {
|
|
||||||
if (res.ok && res.type === 'basic') {
|
|
||||||
const copy = res.clone()
|
|
||||||
void caches.open(CACHE).then((c) => c.put(req, copy))
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
})
|
|
||||||
.catch(() => hit ?? Response.error())
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { Button, Notice } from '@sims/ui'
|
|
||||||
import { uuidv7, type Item } from '@sims/domain'
|
|
||||||
import { fetchBootstrap, fetchItemsCache, hasSession, logout, type Bootstrap, type PosUser } from './api'
|
|
||||||
import { clearCatalogCache, loadCatalogCache, saveCatalogCache } from './offline'
|
|
||||||
import { Login } from './Login'
|
|
||||||
import { ShiftOpen } from './ShiftOpen'
|
|
||||||
import { BillingScreen } from './BillingScreen'
|
|
||||||
|
|
||||||
/** Shift context kept small enough to resume from sessionStorage after a refresh. */
|
|
||||||
interface Shift { id: string; floatPaise: number }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resume context (S4): the authenticated user + open shift, persisted in
|
|
||||||
* sessionStorage so a mid-shift browser refresh with the server DOWN lands back on
|
|
||||||
* the billing screen (paired with the cached catalog in IndexedDB and the session
|
|
||||||
* token, also in sessionStorage). Dies with the tab — a fresh login still needs
|
|
||||||
* the server (a locally-cached PIN hash login is deliberately OUT OF SCOPE).
|
|
||||||
*/
|
|
||||||
const RESUME_KEY = 'pos.resume'
|
|
||||||
const readResume = (): { user: PosUser; shift: Shift } | undefined => {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(RESUME_KEY)
|
|
||||||
return raw !== null ? (JSON.parse(raw) as { user: PosUser; shift: Shift }) : undefined
|
|
||||||
} catch { return undefined }
|
|
||||||
}
|
|
||||||
const writeResume = (user: PosUser, shift: Shift): void => {
|
|
||||||
try { sessionStorage.setItem(RESUME_KEY, JSON.stringify({ user, shift })) } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
const clearResume = (): void => {
|
|
||||||
try { sessionStorage.removeItem(RESUME_KEY) } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POS flow: bootstrap from store-server → login (PIN) → shift open → billing. */
|
|
||||||
export function App() {
|
|
||||||
const [boot, setBoot] = useState<Bootstrap | undefined>()
|
|
||||||
const [bootError, setBootError] = useState<string | undefined>()
|
|
||||||
const [user, setUser] = useState<PosUser | undefined>()
|
|
||||||
const [items, setItems] = useState<Item[] | undefined>()
|
|
||||||
const [shift, setShift] = useState<Shift | undefined>()
|
|
||||||
// True when this session booted from the IndexedDB cache (server was unreachable).
|
|
||||||
const [cachedCatalog, setCachedCatalog] = useState(false)
|
|
||||||
|
|
||||||
const load = () => {
|
|
||||||
setBootError(undefined)
|
|
||||||
const counter = new URLSearchParams(location.search).get('counter') ?? 'C2'
|
|
||||||
fetchBootstrap(counter)
|
|
||||||
.then(setBoot)
|
|
||||||
.catch(async () => {
|
|
||||||
// Server unreachable. If we still hold an authenticated session and a cached
|
|
||||||
// catalog + resume context, reopen the billing screen offline (S4). Otherwise
|
|
||||||
// surface the error — a fresh login needs the server.
|
|
||||||
const [cache, resume] = [await loadCatalogCache().catch(() => undefined), readResume()]
|
|
||||||
if (cache !== undefined && resume !== undefined && hasSession()) {
|
|
||||||
setBoot({ store: cache.store, users: cache.users, taxClasses: cache.taxClasses })
|
|
||||||
setItems(cache.items)
|
|
||||||
setUser(resume.user)
|
|
||||||
setShift(resume.shift)
|
|
||||||
setCachedCatalog(true)
|
|
||||||
} else {
|
|
||||||
setBootError('Store-server unreachable (E-3301)')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
useEffect(load, [])
|
|
||||||
|
|
||||||
// Keep the resume context current while a shift is open; clear it on lock.
|
|
||||||
useEffect(() => {
|
|
||||||
if (user !== undefined && shift !== undefined) writeResume(user, shift)
|
|
||||||
}, [user, shift])
|
|
||||||
|
|
||||||
if (bootError !== undefined) {
|
|
||||||
return (
|
|
||||||
<div className="login-wrap">
|
|
||||||
<div className="login-card">
|
|
||||||
<h2 style={{ marginTop: 0 }}>SiMS Next POS</h2>
|
|
||||||
<Notice tone="err">{bootError} — is the store-server running?</Notice>
|
|
||||||
<Button tone="primary" onClick={load}>Retry</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (boot === undefined) return <div className="login-wrap">Connecting to store-server…</div>
|
|
||||||
|
|
||||||
if (user === undefined || items === undefined) {
|
|
||||||
return (
|
|
||||||
<Login
|
|
||||||
store={boot.store}
|
|
||||||
users={boot.users}
|
|
||||||
onLogin={async (u) => {
|
|
||||||
// M1: the pre-auth bootstrap omits each user's role and the seller GSTIN. Now that we
|
|
||||||
// hold a session, re-fetch it AUTHENTICATED so the supervisor gate (user.role), the
|
|
||||||
// approver list (users[].role) and the receipt's seller GSTIN have real data. Falls
|
|
||||||
// back to the pre-auth boot if the refetch fails (offline right after login).
|
|
||||||
const counter = new URLSearchParams(location.search).get('counter') ?? 'C2'
|
|
||||||
const authedBoot = await fetchBootstrap(counter).catch(() => boot)
|
|
||||||
setBoot(authedBoot)
|
|
||||||
const enrichedUser = authedBoot.users.find((x) => x.id === u.id) ?? u
|
|
||||||
setUser(enrichedUser)
|
|
||||||
const its = await fetchItemsCache()
|
|
||||||
setItems(its)
|
|
||||||
// Persist the catalog snapshot so a mid-shift refresh with the server down
|
|
||||||
// can still reach billing (S4 deliverable 3).
|
|
||||||
void saveCatalogCache({
|
|
||||||
store: authedBoot.store, users: authedBoot.users, taxClasses: authedBoot.taxClasses,
|
|
||||||
items: its, savedAt: new Date().toISOString(),
|
|
||||||
}).catch(() => undefined)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (shift === undefined) {
|
|
||||||
return (
|
|
||||||
<ShiftOpen
|
|
||||||
userName={user.name}
|
|
||||||
onOpen={(floatPaise) => setShift({ id: uuidv7(), floatPaise })}
|
|
||||||
onBack={() => {
|
|
||||||
setUser(undefined)
|
|
||||||
setItems(undefined)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<BillingScreen
|
|
||||||
user={user}
|
|
||||||
users={boot.users}
|
|
||||||
store={boot.store}
|
|
||||||
taxClasses={boot.taxClasses}
|
|
||||||
items={items}
|
|
||||||
shiftId={shift.id}
|
|
||||||
floatPaise={shift.floatPaise}
|
|
||||||
cachedCatalog={cachedCatalog}
|
|
||||||
onLock={() => {
|
|
||||||
// M4: end the session server-side so the bearer token stops working immediately
|
|
||||||
// (best-effort — the local state is cleared regardless of whether the server answers).
|
|
||||||
void logout()
|
|
||||||
clearResume()
|
|
||||||
// M11: wipe the cached catalog + staff roster from IndexedDB so no customer/staff
|
|
||||||
// data lingers on this shared PC after lock. Un-drained offline bills are kept
|
|
||||||
// (a lock must never lose a sale) and the catalog is re-fetched on the next login.
|
|
||||||
void clearCatalogCache().catch(() => undefined)
|
|
||||||
setShift(undefined)
|
|
||||||
setUser(undefined)
|
|
||||||
setItems(undefined)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,84 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Notice } from '@sims/ui'
|
|
||||||
import { pinLogin, type BootstrapStore, type PosUser } from './api'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POS login (09-UX §1): name tiles + PIN pad. Verification is server-side —
|
|
||||||
* scrypt hash + lockout reducer in the store-server. Submit on ✓ or Enter.
|
|
||||||
*/
|
|
||||||
export function Login(props: { store: BootstrapStore; users: PosUser[]; onLogin: (u: PosUser) => Promise<void> }) {
|
|
||||||
const [selected, setSelected] = useState<PosUser>(props.users[0]!)
|
|
||||||
const [pin, setPin] = useState('')
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [error, setError] = useState<string | undefined>()
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (pin.length < 4 || busy) return
|
|
||||||
setBusy(true)
|
|
||||||
setError(undefined)
|
|
||||||
try {
|
|
||||||
await pinLogin(selected.id, pin)
|
|
||||||
await props.onLogin(selected)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
|
||||||
setPin('')
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const press = (d: string) => {
|
|
||||||
setError(undefined)
|
|
||||||
if (d === '⌫') return setPin((p) => p.slice(0, -1))
|
|
||||||
if (d === '✓') return void submit()
|
|
||||||
if (pin.length < 6) setPin((p) => p + d)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="login-wrap"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (/^\d$/.test(e.key)) press(e.key)
|
|
||||||
if (e.key === 'Backspace') press('⌫')
|
|
||||||
if (e.key === 'Enter') void submit()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="login-card">
|
|
||||||
<h2 style={{ marginTop: 0 }}>{props.store.name}</h2>
|
|
||||||
<div style={{ color: 'var(--text-dim)', fontSize: 13 }}>
|
|
||||||
Counter {props.store.counterCode} · SiMS Next POS
|
|
||||||
</div>
|
|
||||||
<div className="user-tiles">
|
|
||||||
{props.users.map((u) => (
|
|
||||||
<button
|
|
||||||
key={u.id}
|
|
||||||
type="button"
|
|
||||||
className={`wf${u.id === selected.id ? ' selected' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
setSelected(u)
|
|
||||||
setPin('')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{u.name}
|
|
||||||
<div style={{ color: 'var(--text-dim)', fontSize: 12 }}>{u.role}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="pin-dots">
|
|
||||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
|
||||||
<span key={i} className={i < pin.length ? 'filled' : ''} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="pin-pad">
|
|
||||||
{['1', '2', '3', '4', '5', '6', '7', '8', '9', '⌫', '0', '✓'].map((d) => (
|
|
||||||
<button key={d} type="button" className={`wf${d === '✓' ? ' primary' : ''}`} disabled={busy} onClick={() => press(d)}>
|
|
||||||
{d}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
|
||||||
{busy && <Notice>Signing in…</Notice>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Button, Field, Modal, Notice } from '@sims/ui'
|
|
||||||
import { EscPos } from '@sims/printing'
|
|
||||||
import { loadPrinterCfg, savePrinterCfg, sendRasterReceipt, sendToPrinter, type PrinterCfg } from './print'
|
|
||||||
|
|
||||||
/** POS settings (Ctrl+,) — printer transport, test print, drawer kick. */
|
|
||||||
export function SettingsModal(props: { onClose: () => void }) {
|
|
||||||
const [cfg, setCfg] = useState<PrinterCfg>(loadPrinterCfg())
|
|
||||||
const [status, setStatus] = useState<{ tone: 'ok' | 'err'; text: string } | undefined>()
|
|
||||||
|
|
||||||
const save = (next: PrinterCfg) => {
|
|
||||||
setCfg(next)
|
|
||||||
savePrinterCfg(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const attempt = async (bytes: Uint8Array, okText: string) => {
|
|
||||||
try {
|
|
||||||
await sendToPrinter({ ...cfg, enabled: true }, bytes)
|
|
||||||
setStatus({ tone: 'ok', text: okText })
|
|
||||||
} catch (err) {
|
|
||||||
setStatus({ tone: 'err', text: err instanceof Error ? err.message : String(err) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const testPrint = async () => {
|
|
||||||
try {
|
|
||||||
if (cfg.script === 'raster') {
|
|
||||||
await sendRasterReceipt(
|
|
||||||
{ ...cfg, enabled: true },
|
|
||||||
['SiMS NEXT TEST PRINT', 'रास्टर / റാസ്റ്റർ / Raster', new Date().toLocaleString()],
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
await sendToPrinter(
|
|
||||||
{ ...cfg, enabled: true },
|
|
||||||
new EscPos().init().align('center').bold(true).line('SiMS NEXT TEST PRINT').bold(false)
|
|
||||||
.line(new Date().toLocaleString()).feed(4).cut().build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
setStatus({ tone: 'ok', text: `Test page sent (${cfg.script})` })
|
|
||||||
} catch (err) {
|
|
||||||
setStatus({ tone: 'err', text: err instanceof Error ? err.message : String(err) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title="Counter settings — printing" onClose={props.onClose}>
|
|
||||||
<Field label="Thermal printer IP (ESC/POS over network, port 9100)">
|
|
||||||
<input className="wf" value={cfg.host} onChange={(e) => save({ ...cfg, host: e.target.value })} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Port">
|
|
||||||
<input
|
|
||||||
className="wf num"
|
|
||||||
type="number"
|
|
||||||
value={cfg.port}
|
|
||||||
onChange={(e) => save({ ...cfg, port: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Paper width">
|
|
||||||
<select
|
|
||||||
className="wf"
|
|
||||||
value={cfg.width}
|
|
||||||
onChange={(e) => save({ ...cfg, width: Number(e.target.value) as 32 | 42 })}
|
|
||||||
>
|
|
||||||
<option value={32}>2 inch (32 chars)</option>
|
|
||||||
<option value={42}>3 inch (42 chars)</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label="Receipt script">
|
|
||||||
<select
|
|
||||||
className="wf"
|
|
||||||
value={cfg.script}
|
|
||||||
onChange={(e) => save({ ...cfg, script: e.target.value as PrinterCfg['script'] })}
|
|
||||||
>
|
|
||||||
<option value="ascii">ASCII (fast, Latin only)</option>
|
|
||||||
<option value="raster">Raster (Indic — Hindi/Malayalam item names)</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label="Printing">
|
|
||||||
<select
|
|
||||||
className="wf"
|
|
||||||
value={cfg.enabled ? 'on' : 'off'}
|
|
||||||
onChange={(e) => save({ ...cfg, enabled: e.target.value === 'on' })}
|
|
||||||
>
|
|
||||||
<option value="on">Enabled</option>
|
|
||||||
<option value="off">Disabled (bills still commit)</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
|
||||||
<Button tone="primary" onClick={() => void testPrint()}>
|
|
||||||
Test print
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void attempt(new EscPos().drawerKick().build(), 'Drawer kick sent')}>
|
|
||||||
Kick drawer
|
|
||||||
</Button>
|
|
||||||
<Button onClick={props.onClose}>Close</Button>
|
|
||||||
</div>
|
|
||||||
{status !== undefined && <Notice tone={status.tone}>{status.text}</Notice>}
|
|
||||||
<Notice>
|
|
||||||
Printing route: {window.pos !== undefined ? 'Electron kiosk bridge (direct)' : 'store-server /api/print (web app)'}.
|
|
||||||
USB printers: share via a print server or use the printer's network port —
|
|
||||||
driver-spooled USB raw printing is a Phase-1 decision (see BUILDING.md).
|
|
||||||
</Notice>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,229 +0,0 @@
|
|||||||
import type { BillLine, Item } from '@sims/domain'
|
|
||||||
import type { LineInput, TaxClassRow } from '@sims/billing-engine'
|
|
||||||
|
|
||||||
/** POS ↔ store-server client. Same origin in production (served under /pos/). */
|
|
||||||
|
|
||||||
export interface BootstrapStore {
|
|
||||||
id: string; name: string; storeCode: string; stateCode: string
|
|
||||||
gstin: string; counterId: string; counterCode: string
|
|
||||||
}
|
|
||||||
export interface PosUser { id: string; name: string; role: string }
|
|
||||||
export interface Bootstrap { store: BootstrapStore; users: PosUser[]; taxClasses: TaxClassRow[] }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown ONLY when the store-server is physically unreachable (fetch rejected) —
|
|
||||||
* as opposed to a server that answered with an error (4xx/5xx). The counter's
|
|
||||||
* offline path (S4) keys off this: a NetworkError means "queue the bill and keep
|
|
||||||
* billing"; any other error means the server rejected the bill and the cart is
|
|
||||||
* kept on screen instead (R18: the cart is never lost, either way).
|
|
||||||
*/
|
|
||||||
export class NetworkError extends Error {
|
|
||||||
constructor(message = 'Store-server unreachable (E-3301)') {
|
|
||||||
super(message)
|
|
||||||
this.name = 'NetworkError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The POS session token survives a browser refresh in sessionStorage (S4): a
|
|
||||||
// mid-shift reload while the server is down can resume the already-authenticated
|
|
||||||
// session. sessionStorage (not localStorage) so it dies with the tab, never
|
|
||||||
// outlives the shift. Guarded for non-browser contexts (unit tests).
|
|
||||||
const TOKEN_KEY = 'pos.token'
|
|
||||||
const readStoredToken = (): string => {
|
|
||||||
try { return sessionStorage.getItem(TOKEN_KEY) ?? '' } catch { return '' }
|
|
||||||
}
|
|
||||||
let token = readStoredToken()
|
|
||||||
|
|
||||||
function setToken(t: string): void {
|
|
||||||
token = t
|
|
||||||
try { sessionStorage.setItem(TOKEN_KEY, t) } catch { /* non-browser: in-memory only */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when an authenticated session token is present (used by offline resume). */
|
|
||||||
export const hasSession = (): boolean => token !== ''
|
|
||||||
|
|
||||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
const res = await fetch(`/api${path}`, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json',
|
|
||||||
...(token !== '' ? { authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
}).catch(() => undefined)
|
|
||||||
if (res === undefined) throw new NetworkError()
|
|
||||||
const body = (await res.json().catch(() => ({}))) as T & { error?: string }
|
|
||||||
if (!res.ok) throw new Error(body.error ?? `Server error HTTP ${res.status}`)
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unauthenticated liveness probe — the drain loop pings this before draining. */
|
|
||||||
export const pingHealth = (): Promise<boolean> =>
|
|
||||||
fetch('/api/health').then((r) => r.ok).catch(() => false)
|
|
||||||
|
|
||||||
export const fetchBootstrap = (counter: string): Promise<Bootstrap> =>
|
|
||||||
call(`/bootstrap?counter=${encodeURIComponent(counter)}`)
|
|
||||||
|
|
||||||
export async function pinLogin(userId: string, pin: string): Promise<void> {
|
|
||||||
const s = await call<{ token: string }>('/auth/pin', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ userId, pin }),
|
|
||||||
})
|
|
||||||
setToken(s.token)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End the session server-side (M4) then drop the local token. Best-effort: if the store-server
|
|
||||||
* is unreachable we still forget the token locally so the counter locks. Called from "Lock".
|
|
||||||
*/
|
|
||||||
export async function logout(): Promise<void> {
|
|
||||||
try { await call('/auth/logout', { method: 'POST' }) } catch { /* server down: drop the token anyway */ }
|
|
||||||
setToken('')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The exact deviation a supervisor is approving. It is sent to /auth/verify-pin so the
|
|
||||||
* minted token is BOUND to this item+kind+before+after (H4 fix) — the server re-checks it
|
|
||||||
* against the line's own server-computed deviation at commit, so an approval for one line
|
|
||||||
* can never be redirected to another.
|
|
||||||
*/
|
|
||||||
export interface OverrideContext {
|
|
||||||
itemId: string
|
|
||||||
kind: 'price' | 'qty' | 'discount' | 'batch'
|
|
||||||
/** Server-anchored "before" value (the item master price for a price override; 0 otherwise). */
|
|
||||||
before: number
|
|
||||||
after: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-flow supervisor gate (spec P0-2): verify a supervisor's PIN without touching
|
|
||||||
* the module-level `token` — the cashier stays logged in and no session is created.
|
|
||||||
* Distinct from `pinLogin` on purpose; reusing that would hijack the cashier's session.
|
|
||||||
* The bound override context rides along so the server mints a token pinned to it.
|
|
||||||
*/
|
|
||||||
export const verifySupervisorPin = (userId: string, pin: string, override: OverrideContext): Promise<{ ok: true; userId: string; displayName: string; approvalId: string }> =>
|
|
||||||
call('/auth/verify-pin', { method: 'POST', body: JSON.stringify({ userId, pin, override }) })
|
|
||||||
|
|
||||||
// The counter caches the whole sellable catalog (no silent cap — R13); ?all=1
|
|
||||||
// returns every non-inactive item unpaginated.
|
|
||||||
export const fetchItemsCache = (): Promise<Item[]> => call('/items?all=1')
|
|
||||||
|
|
||||||
export const fetchStock = (): Promise<{ code: string; on_hand: number }[]> => call('/stock')
|
|
||||||
|
|
||||||
export interface CommitResult { id: string; docNo: string; payablePaise: number }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supervisor-approved override, audited server-side in the commit transaction (spec
|
|
||||||
* P0-2). `approvalId` is the single-use token returned by verifySupervisorPin — the
|
|
||||||
* server redeems it and records the approver from its own row, so the client never
|
|
||||||
* asserts who approved (SEC fix).
|
|
||||||
*/
|
|
||||||
export interface BillOverride {
|
|
||||||
itemId: string; kind: 'price' | 'qty' | 'discount' | 'batch'; before: number; after: number; approvalId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const postBill = (body: {
|
|
||||||
storeId: string; counterId: string; shiftId: string; customerId?: string
|
|
||||||
businessDate: string; lines: LineInput[]
|
|
||||||
payments: { mode: string; amountPaise: number }[]
|
|
||||||
clientPayablePaise: number
|
|
||||||
overrides?: BillOverride[]
|
|
||||||
}): Promise<CommitResult> => call('/bills', { method: 'POST', body: JSON.stringify(body) })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batches with derived on-hand > 0 for the FEFO picker (S5). The POS caches these
|
|
||||||
* keyed by item at login and refreshes after each commit so per-batch on-hand stays
|
|
||||||
* live. Raw server rows; the billing screen maps them to the domain BatchStock shape.
|
|
||||||
*/
|
|
||||||
export interface PosBatchRow { id: string; item_id: string; batch_no: string; expiry_date: string | null; on_hand: number }
|
|
||||||
export const fetchBatches = (): Promise<PosBatchRow[]> => call('/batches')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The drain payload for a bill queued offline (S4). Unlike the online postBill it
|
|
||||||
* carries its own `id` (client uuidv7, the idempotency key). Tenant and cashier are
|
|
||||||
* NOT in the body — the drain runs under the cashier's live session and the server
|
|
||||||
* takes both from it (SEC fix); if the session has expired the drain 401s and the
|
|
||||||
* queue waits for re-login.
|
|
||||||
*/
|
|
||||||
export interface OfflineBillBody {
|
|
||||||
id: string
|
|
||||||
storeId: string; counterId: string; shiftId: string; customerId?: string
|
|
||||||
businessDate: string; lines: LineInput[]
|
|
||||||
payments: { mode: string; amountPaise: number }[]
|
|
||||||
clientPayablePaise: number
|
|
||||||
overrides?: BillOverride[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drain one queued bill; the server assigns/returns its real GST doc no. */
|
|
||||||
export const postBillOffline = (body: OfflineBillBody): Promise<CommitResult & { duplicate?: boolean }> =>
|
|
||||||
call('/bills/offline', { method: 'POST', body: JSON.stringify(body) })
|
|
||||||
|
|
||||||
export const fetchBills = (date: string, counterId: string): Promise<Record<string, unknown>[]> =>
|
|
||||||
call(`/bills?date=${date}&counterId=${encodeURIComponent(counterId)}`)
|
|
||||||
|
|
||||||
export const createDraftItem = (body: {
|
|
||||||
code: string; name: string; hsn: string; taxClassCode: string
|
|
||||||
unitCode: string; unitDecimals: number; salePricePaise: number
|
|
||||||
barcode?: string; status: string
|
|
||||||
}): Promise<Item> => call('/items', { method: 'POST', body: JSON.stringify(body) })
|
|
||||||
|
|
||||||
/** Attached-customer identity the counter needs: name plus B2B place-of-supply inputs. */
|
|
||||||
export interface PosCustomer { id: string; name: string; phone?: string; gstin?: string; stateCode?: string }
|
|
||||||
|
|
||||||
export const findCustomers = (phone: string): Promise<PosCustomer[]> =>
|
|
||||||
call(`/parties?phone=${encodeURIComponent(phone)}`)
|
|
||||||
|
|
||||||
export const createCustomer = (name: string, phone: string, gstin?: string): Promise<PosCustomer> =>
|
|
||||||
call('/parties', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ name, phone, ...(gstin !== undefined && gstin !== '' ? { gstin } : {}) }),
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- returns / credit notes ----------
|
|
||||||
|
|
||||||
/** A found original SALE bill for the return lookup (doc-no or phone search). */
|
|
||||||
export interface BillLookupResult {
|
|
||||||
id: string; doc_no: string; business_date: string; payable_paise: number
|
|
||||||
cashier_name?: string; customer_name?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One original line annotated with sold / already-returned / still-returnable quantities. */
|
|
||||||
export interface ReturnableLine {
|
|
||||||
lineIndex: number
|
|
||||||
itemId: string; name: string; hsn: string; unitCode: string
|
|
||||||
unitPricePaise: number; taxRateBp: number; lineTotalPaise: number
|
|
||||||
soldQty: number; returnedQty: number; returnableQty: number
|
|
||||||
batchId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The original bill + its returnable lines + the raw lines for the shared refund preview. */
|
|
||||||
export interface ReturnableBill {
|
|
||||||
id: string; docNo: string; businessDate: string
|
|
||||||
customerId?: string; customerName?: string
|
|
||||||
lines: ReturnableLine[]
|
|
||||||
originalLines: BillLine[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReturnResult { id: string; docNo: string; refundPaise: number }
|
|
||||||
|
|
||||||
/** Look up an original SALE bill by its exact doc no (returns 0 or 1 result). */
|
|
||||||
export const lookupBillByDocNo = (docNo: string): Promise<BillLookupResult[]> =>
|
|
||||||
call(`/bills/lookup?docNo=${encodeURIComponent(docNo)}`)
|
|
||||||
|
|
||||||
/** Look up recent original SALE bills for a customer phone. */
|
|
||||||
export const lookupBillsByPhone = (phone: string): Promise<BillLookupResult[]> =>
|
|
||||||
call(`/bills/lookup?phone=${encodeURIComponent(phone)}`)
|
|
||||||
|
|
||||||
/** Fetch the original bill's returnable lines (sold / returned / returnable + raw lines). */
|
|
||||||
export const fetchReturnable = (billId: string): Promise<ReturnableBill> =>
|
|
||||||
call(`/bills/${encodeURIComponent(billId)}/returnable`)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Commit a return / credit note. The server anchors everything to OUR stored original bill,
|
|
||||||
* blocks over-return, computes the refund itself and re-verifies clientRefundPaise (409 on
|
|
||||||
* mismatch). Tenant + cashier come from the session, never the body.
|
|
||||||
*/
|
|
||||||
export const postReturn = (body: {
|
|
||||||
storeId: string; counterId: string; shiftId: string; businessDate: string
|
|
||||||
originalBillId: string; lines: { lineIndex: number; qty: number }[]
|
|
||||||
reason: string; refundMode: string; clientRefundPaise: number
|
|
||||||
}): Promise<ReturnResult> => call('/returns', { method: 'POST', body: JSON.stringify(body) })
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Audio feedback (09-UX §5.6): cashiers scan without looking — sound is the
|
|
||||||
* primary channel. WebAudio oscillators, zero assets. Counter-mute later.
|
|
||||||
*/
|
|
||||||
let ctx: AudioContext | undefined
|
|
||||||
|
|
||||||
function tone(freqHz: number, ms: number, delayMs = 0): void {
|
|
||||||
try {
|
|
||||||
ctx ??= new AudioContext()
|
|
||||||
const osc = ctx.createOscillator()
|
|
||||||
const gain = ctx.createGain()
|
|
||||||
osc.frequency.value = freqHz
|
|
||||||
gain.gain.value = 0.08
|
|
||||||
osc.connect(gain).connect(ctx.destination)
|
|
||||||
const at = ctx.currentTime + delayMs / 1000
|
|
||||||
osc.start(at)
|
|
||||||
osc.stop(at + ms / 1000)
|
|
||||||
} catch {
|
|
||||||
/* no audio device — silent is fine */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const beepOk = (): void => tone(880, 60)
|
|
||||||
export const beepWarn = (): void => tone(440, 100)
|
|
||||||
export const beepErr = (): void => {
|
|
||||||
tone(220, 120)
|
|
||||||
tone(220, 120, 180)
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import '@fontsource-variable/inter'
|
|
||||||
import '@fontsource-variable/jetbrains-mono'
|
|
||||||
import '../../../packages/ui/src/tokens.css'
|
|
||||||
import './pos.css'
|
|
||||||
import { initTheme } from '@sims/ui'
|
|
||||||
import { App } from './App'
|
|
||||||
|
|
||||||
initTheme()
|
|
||||||
createRoot(document.getElementById('root')!).render(<App />)
|
|
||||||
|
|
||||||
// S4 app-shell offline: register the cache-first service worker (scope /pos/) so the
|
|
||||||
// POS loads from cache when the store-server is down. Best-effort — a failure here
|
|
||||||
// never blocks billing (the IndexedDB queue + cached catalog do the real work).
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
void navigator.serviceWorker.register('./sw.js').catch(() => undefined)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
import { uuidv7 } from '@sims/domain'
|
|
||||||
import type { BillTotals, Item } from '@sims/domain'
|
|
||||||
import type { LineInput, TaxClassRow } from '@sims/billing-engine'
|
|
||||||
import type { BillOverride, BootstrapStore, OfflineBillBody, PosUser } from './api'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* S4 counter resilience — the browser POS survives the store-server dying mid-shift.
|
|
||||||
* Two responsibilities, one IndexedDB database (`sims-pos-offline`):
|
|
||||||
*
|
|
||||||
* 1. `bills` — bills committed while the server was unreachable. Each is a client-
|
|
||||||
* UUIDv7 fact with a provisional token (OFF-1, OFF-2…); the reconnect drain
|
|
||||||
* POSTs them oldest-first and the server assigns the real GST doc no.
|
|
||||||
* 2. `cache` — the catalog snapshot (items + tax classes + store/users) persisted
|
|
||||||
* at login so a mid-shift refresh with the server down still reaches billing.
|
|
||||||
*
|
|
||||||
* Raw IndexedDB, no new dependency (R-parity with the zero-runtime-dep discipline).
|
|
||||||
* The pure helpers below (token/record/body shaping) hold the serialization logic
|
|
||||||
* and are unit-tested without a browser; everything that touches `indexedDB` stays
|
|
||||||
* inside a function body so this module still imports in Node (vitest).
|
|
||||||
*
|
|
||||||
* M11 (offline PII): the queued bill persists only `customerId` — never the buyer's
|
|
||||||
* name or GSTIN — and the server re-resolves buyer identity from that id at drain, so
|
|
||||||
* no customer PII is written to a shared counter PC. The catalog `cache` (which also
|
|
||||||
* holds the staff roster) is wiped on Lock/logout via `clearCatalogCache`. NOTE: this
|
|
||||||
* removes the standing PII, not the queue itself — true at-rest ENCRYPTION of the
|
|
||||||
* remaining offline queue is a follow-up (server-tier SQLCipher is finding H3).
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------- queued bill record ----------
|
|
||||||
|
|
||||||
export interface QueuedBill {
|
|
||||||
/** Client-generated bill id (uuidv7) — the server's idempotency key on drain. */
|
|
||||||
id: string
|
|
||||||
/** Monotonic provisional sequence (1, 2, 3…) — drives OFF-n and drain order. */
|
|
||||||
seq: number
|
|
||||||
/** Human token shown on the provisional receipt and in drain notices ("OFF-1"). */
|
|
||||||
provisionalToken: string
|
|
||||||
storeId: string
|
|
||||||
counterId: string
|
|
||||||
/** Local provenance only — who rang it up on this device. The server attributes the
|
|
||||||
* drained bill to the session that drains it, never to this field (SEC fix). */
|
|
||||||
cashierId: string
|
|
||||||
shiftId: string
|
|
||||||
/** The ONLY buyer identity persisted offline (M11): the server re-resolves the name,
|
|
||||||
* GSTIN and place-of-supply from this id + tenant at drain, exactly as for an online
|
|
||||||
* bill — so no customer PII (name/GSTIN) is ever written to this device. A walk-in
|
|
||||||
* bill has no customerId and stores nothing extra. */
|
|
||||||
customerId?: string
|
|
||||||
businessDate: string
|
|
||||||
lines: LineInput[]
|
|
||||||
payments: { mode: string; amountPaise: number }[]
|
|
||||||
/** Full totals snapshot (immutable fact); `payablePaise` becomes clientPayablePaise on drain. */
|
|
||||||
totals: BillTotals
|
|
||||||
overrides?: BillOverride[]
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- pure helpers (unit-tested; no IndexedDB) ----------
|
|
||||||
|
|
||||||
/** The provisional token printed instead of a GST doc no while offline. */
|
|
||||||
export function nextProvisionalToken(seq: number): string {
|
|
||||||
return `OFF-${seq}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the immutable queued-bill record from the cart at commit time. */
|
|
||||||
export function buildQueuedBill(input: {
|
|
||||||
seq: number
|
|
||||||
storeId: string; counterId: string; cashierId: string; shiftId: string
|
|
||||||
customerId?: string
|
|
||||||
businessDate: string
|
|
||||||
lines: LineInput[]
|
|
||||||
payments: { mode: string; amountPaise: number }[]
|
|
||||||
totals: BillTotals
|
|
||||||
overrides?: BillOverride[]
|
|
||||||
/** Injectable clock for deterministic tests; defaults to now. */
|
|
||||||
now?: number
|
|
||||||
}): QueuedBill {
|
|
||||||
const at = input.now ?? Date.now()
|
|
||||||
return {
|
|
||||||
id: uuidv7(at),
|
|
||||||
seq: input.seq,
|
|
||||||
provisionalToken: nextProvisionalToken(input.seq),
|
|
||||||
storeId: input.storeId,
|
|
||||||
counterId: input.counterId,
|
|
||||||
cashierId: input.cashierId,
|
|
||||||
shiftId: input.shiftId,
|
|
||||||
...(input.customerId !== undefined ? { customerId: input.customerId } : {}),
|
|
||||||
businessDate: input.businessDate,
|
|
||||||
lines: input.lines,
|
|
||||||
payments: input.payments,
|
|
||||||
totals: input.totals,
|
|
||||||
...(input.overrides !== undefined && input.overrides.length > 0 ? { overrides: input.overrides } : {}),
|
|
||||||
createdAt: new Date(at).toISOString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shape a queued bill into the /api/bills/offline drain payload. Drops display-only
|
|
||||||
* fields AND the local cashierId — the server takes tenant + cashier from the live
|
|
||||||
* session, never from the body (SEC fix).
|
|
||||||
*/
|
|
||||||
export function toOfflineBillBody(q: QueuedBill): OfflineBillBody {
|
|
||||||
return {
|
|
||||||
id: q.id,
|
|
||||||
storeId: q.storeId,
|
|
||||||
counterId: q.counterId,
|
|
||||||
shiftId: q.shiftId,
|
|
||||||
...(q.customerId !== undefined ? { customerId: q.customerId } : {}),
|
|
||||||
businessDate: q.businessDate,
|
|
||||||
lines: q.lines,
|
|
||||||
payments: q.payments,
|
|
||||||
clientPayablePaise: q.totals.payablePaise,
|
|
||||||
...(q.overrides !== undefined ? { overrides: q.overrides } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The provisional-token sequence, persisted in localStorage so OFF-n stays
|
|
||||||
* monotonic across refreshes and never reuses a number after a bill drains and
|
|
||||||
* leaves the queue (deriving from the queue's max seq would recycle numbers).
|
|
||||||
*/
|
|
||||||
const SEQ_KEY = 'pos.offSeq'
|
|
||||||
export function nextOffSeq(): number {
|
|
||||||
let n = 0
|
|
||||||
try { n = Number(localStorage.getItem(SEQ_KEY) ?? '0') } catch { n = 0 }
|
|
||||||
const next = (Number.isFinite(n) ? n : 0) + 1
|
|
||||||
try { localStorage.setItem(SEQ_KEY, String(next)) } catch { /* non-browser: caller supplies seq */ }
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- IndexedDB access (browser-only) ----------
|
|
||||||
|
|
||||||
const DB_NAME = 'sims-pos-offline'
|
|
||||||
const DB_VERSION = 1
|
|
||||||
const BILLS = 'bills'
|
|
||||||
const CACHE = 'cache'
|
|
||||||
|
|
||||||
function openIdb(): Promise<IDBDatabase> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
|
||||||
req.onupgradeneeded = () => {
|
|
||||||
const db = req.result
|
|
||||||
if (!db.objectStoreNames.contains(BILLS)) db.createObjectStore(BILLS, { keyPath: 'id' })
|
|
||||||
if (!db.objectStoreNames.contains(CACHE)) db.createObjectStore(CACHE, { keyPath: 'key' })
|
|
||||||
}
|
|
||||||
req.onsuccess = () => resolve(req.result)
|
|
||||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed'))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run one request in its own transaction; resolves on transaction commit (durable). */
|
|
||||||
function run<T>(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
|
||||||
return openIdb().then((db) => new Promise<T>((resolve, reject) => {
|
|
||||||
const tx = db.transaction(store, mode)
|
|
||||||
const req = fn(tx.objectStore(store))
|
|
||||||
let result: T
|
|
||||||
req.onsuccess = () => { result = req.result }
|
|
||||||
tx.oncomplete = () => { db.close(); resolve(result) }
|
|
||||||
tx.onerror = () => { db.close(); reject(tx.error ?? new Error('IndexedDB tx failed')) }
|
|
||||||
tx.onabort = () => { db.close(); reject(tx.error ?? new Error('IndexedDB tx aborted')) }
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enqueueBill(bill: QueuedBill): Promise<void> {
|
|
||||||
await run(BILLS, 'readwrite', (s) => s.put(bill))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All queued bills, oldest-first (the drain order). */
|
|
||||||
export async function listQueuedBills(): Promise<QueuedBill[]> {
|
|
||||||
const all = await run<QueuedBill[]>(BILLS, 'readonly', (s) => s.getAll() as IDBRequest<QueuedBill[]>)
|
|
||||||
return all.sort((a, b) => a.seq - b.seq)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeQueuedBill(id: string): Promise<void> {
|
|
||||||
await run(BILLS, 'readwrite', (s) => s.delete(id) as unknown as IDBRequest<undefined>)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function countQueuedBills(): Promise<number> {
|
|
||||||
return run<number>(BILLS, 'readonly', (s) => s.count())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- catalog cache (browser-only) ----------
|
|
||||||
|
|
||||||
export interface CatalogCache {
|
|
||||||
key: 'catalog'
|
|
||||||
store: BootstrapStore
|
|
||||||
users: PosUser[]
|
|
||||||
taxClasses: TaxClassRow[]
|
|
||||||
items: Item[]
|
|
||||||
savedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveCatalogCache(data: Omit<CatalogCache, 'key'>): Promise<void> {
|
|
||||||
await run(CACHE, 'readwrite', (s) => s.put({ key: 'catalog', ...data }))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadCatalogCache(): Promise<CatalogCache | undefined> {
|
|
||||||
return run<CatalogCache | undefined>(CACHE, 'readonly', (s) => s.get('catalog') as IDBRequest<CatalogCache | undefined>)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wipe the catalog/roster cache on Lock/logout (M11). Clears the whole `cache` store —
|
|
||||||
* store details, staff roster and item master — so nothing about the shop or its staff
|
|
||||||
* lingers on a shared counter PC once the cashier locks. Un-drained queued bills live in
|
|
||||||
* a SEPARATE store (`bills`) and are intentionally left intact: a lock must never lose a
|
|
||||||
* sale (R18); the catalog is re-fetched fresh on the next login. Post-lock the resume
|
|
||||||
* context is cleared too (App.onLock), so this cache would never be read again anyway.
|
|
||||||
*/
|
|
||||||
export async function clearCatalogCache(): Promise<void> {
|
|
||||||
await run(CACHE, 'readwrite', (s) => s.clear() as unknown as IDBRequest<undefined>)
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
/* POS-specific layout on top of @sims/ui tokens — dense, keyboard-first. */
|
|
||||||
.pos-shell { display: flex; flex-direction: column; height: 100vh; }
|
|
||||||
.pos-header {
|
|
||||||
display: flex; gap: 16px; align-items: center; padding: 6px 14px;
|
|
||||||
background: var(--bg-raised); border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 13px; color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.pos-header .store { color: var(--text); font-weight: 600; }
|
|
||||||
.pos-main { flex: 1; display: grid; grid-template-columns: 1fr 380px; min-height: 0; }
|
|
||||||
.pos-left { display: flex; flex-direction: column; padding: 12px; min-width: 0; }
|
|
||||||
.pos-right { border-left: 1px solid var(--border); display: flex; flex-direction: column; padding: 12px; gap: 10px; }
|
|
||||||
|
|
||||||
.scanbox {
|
|
||||||
font-size: 20px; padding: 12px 14px !important; font-family: var(--mono);
|
|
||||||
}
|
|
||||||
.sugg-list {
|
|
||||||
position: absolute; z-index: 30; margin-top: 4px; min-width: 480px;
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius); box-shadow: var(--shadow-lg); overflow: hidden;
|
|
||||||
}
|
|
||||||
.sugg-row {
|
|
||||||
display: flex; gap: 16px; justify-content: space-between; align-items: baseline;
|
|
||||||
padding: 8px 12px; cursor: pointer; font-size: 15px;
|
|
||||||
}
|
|
||||||
.sugg-row.hi { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
|
|
||||||
.sugg-row:hover { background: var(--bg-hover); }
|
|
||||||
.pos-left { position: relative; }
|
|
||||||
|
|
||||||
.lines-wrap { flex: 1; overflow: auto; margin-top: 10px; }
|
|
||||||
.lines-wrap table.wf { font-size: 15px; }
|
|
||||||
.lines-wrap tr.selected td { background: var(--bg-hover); box-shadow: inset 3px 0 0 var(--accent); }
|
|
||||||
|
|
||||||
.quick-keys { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; margin-top: 10px; }
|
|
||||||
.quick-keys button.wf { padding: 10px 6px; font-size: 13px; }
|
|
||||||
.quick-keys button.wf kbd { display: block; margin: 4px 0 0; }
|
|
||||||
|
|
||||||
.customer-strip {
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius);
|
|
||||||
padding: 10px 12px; font-size: 13px; color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.totals {
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius);
|
|
||||||
padding: 12px; display: flex; flex-direction: column; gap: 4px;
|
|
||||||
}
|
|
||||||
.totals .row { display: flex; justify-content: space-between; color: var(--text-dim); }
|
|
||||||
.totals .row .num { color: var(--text); }
|
|
||||||
.payable {
|
|
||||||
background: var(--bg); border: 2px solid var(--accent); border-radius: var(--radius);
|
|
||||||
padding: 10px 14px; display: flex; justify-content: space-between; align-items: baseline;
|
|
||||||
}
|
|
||||||
.payable .amount { font-size: 44px; font-weight: 700; }
|
|
||||||
.saved { text-align: center; color: var(--ok); font-size: 13px; }
|
|
||||||
.pay-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
||||||
.pay-buttons button.wf { padding: 14px; font-size: 16px; }
|
|
||||||
|
|
||||||
.pos-status {
|
|
||||||
display: flex; gap: 18px; padding: 5px 14px; font-size: 12px; color: var(--text-dim);
|
|
||||||
background: var(--bg-raised); border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; }
|
|
||||||
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.err { background: var(--err); }
|
|
||||||
|
|
||||||
/* Login */
|
|
||||||
.login-wrap { height: 100vh; display: flex; align-items: center; justify-content: center; gap: 40px; }
|
|
||||||
.login-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 12px; padding: 28px; width: 380px; }
|
|
||||||
.user-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0; }
|
|
||||||
.user-tiles button.wf { padding: 18px 10px; font-size: 15px; }
|
|
||||||
.user-tiles button.wf.selected { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
||||||
.pin-dots { display: flex; gap: 10px; justify-content: center; margin: 14px 0; }
|
|
||||||
.pin-dots span { width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--text-dim); }
|
|
||||||
.pin-dots span.filled { background: var(--accent); border-color: var(--accent); }
|
|
||||||
.pin-pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
||||||
.pin-pad button.wf { padding: 16px; font-size: 18px; }
|
|
||||||
.denoms { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 16px; }
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
export interface PrinterCfg {
|
|
||||||
host: string
|
|
||||||
port: number
|
|
||||||
width: 32 | 42
|
|
||||||
enabled: boolean
|
|
||||||
/** ASCII text mode (fast, Latin only) vs raster bit-image (Indic scripts). */
|
|
||||||
script: 'ascii' | 'raster'
|
|
||||||
}
|
|
||||||
|
|
||||||
const KEY = 'pos.printer'
|
|
||||||
const DEFAULTS: PrinterCfg = { host: '192.168.1.100', port: 9100, width: 42, enabled: false, script: 'ascii' }
|
|
||||||
|
|
||||||
export function loadPrinterCfg(): PrinterCfg {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(KEY)
|
|
||||||
// Merge over defaults so configs saved before `script` existed still load.
|
|
||||||
if (raw !== null) return { ...DEFAULTS, ...(JSON.parse(raw) as Partial<PrinterCfg>) }
|
|
||||||
} catch {
|
|
||||||
/* corrupted config falls back to defaults */
|
|
||||||
}
|
|
||||||
return { ...DEFAULTS }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function savePrinterCfg(cfg: PrinterCfg): void {
|
|
||||||
localStorage.setItem(KEY, JSON.stringify(cfg))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends ESC/POS bytes to the printer. Web-first (D2 revision): the browser
|
|
||||||
* can't open sockets, so the store-server does it (/api/print). The Electron
|
|
||||||
* kiosk shell keeps its direct bridge when present.
|
|
||||||
*/
|
|
||||||
export async function sendToPrinter(cfg: PrinterCfg, bytes: Uint8Array): Promise<void> {
|
|
||||||
if (!cfg.enabled) throw new Error('Printer disabled in settings (Ctrl+,)')
|
|
||||||
if (window.pos !== undefined) {
|
|
||||||
await window.pos.printRaw9100(cfg.host, cfg.port, bytes)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const res = await fetch('/api/print', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ host: cfg.host, port: cfg.port, bytes: Array.from(bytes) }),
|
|
||||||
}).catch(() => undefined)
|
|
||||||
if (res === undefined) throw new Error('Store-server unreachable — bill is saved, print queued')
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = (await res.json().catch(() => undefined)) as { error?: string } | undefined
|
|
||||||
throw new Error(body?.error ?? `Print failed (HTTP ${res.status})`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print a receipt as an Indic raster bit-image (GS v 0). Same transport as
|
|
||||||
* sendToPrinter; the content is drawn to a canvas first (raster.ts) so
|
|
||||||
* non-Latin item names survive. Used when the counter's script = 'raster'.
|
|
||||||
*/
|
|
||||||
export async function sendRasterReceipt(cfg: PrinterCfg, lines: string[], kickDrawer: boolean): Promise<void> {
|
|
||||||
const { renderRasterReceipt } = await import('./raster')
|
|
||||||
await sendToPrinter(cfg, renderRasterReceipt(lines, { width: cfg.width, kickDrawer }))
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import { EscPos, bitonalFromRGBA, rasterToEscPos } from '@sims/printing'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indic raster receipt (09-UX §5.3): ESC/POS text mode can't render Devanagari
|
|
||||||
* or Malayalam, so non-Latin receipts are drawn to an offscreen canvas and sent
|
|
||||||
* as a GS v 0 bit-image. Canvas is a browser API, so this lives in the app; the
|
|
||||||
* thresholding + bit-packing are the pure @sims/printing package (raster.ts).
|
|
||||||
*
|
|
||||||
* v1 caveat (documented): browsers fall back to the OS fonts for canvas text, so
|
|
||||||
* Devanagari/Malayalam coverage depends on the shop PC having a suitable font
|
|
||||||
* (Windows ships Nirmala UI). The raster *pipeline* is what S2 delivers; bundled
|
|
||||||
* canvas webfonts are a follow-up. Latin/ASCII always renders.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Print-head pixel width: 384 dots for 2" (32 col), 576 for 3" (42 col). */
|
|
||||||
export function headWidth(cols: 32 | 42): number {
|
|
||||||
return cols === 32 ? 384 : 576
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderRasterReceipt(lines: string[], opts: { width: 32 | 42; kickDrawer?: boolean }): Uint8Array {
|
|
||||||
const W = headWidth(opts.width)
|
|
||||||
const fontPx = opts.width === 32 ? 20 : 22
|
|
||||||
const lineH = Math.round(fontPx * 1.35)
|
|
||||||
const padTop = 8
|
|
||||||
const padBottom = 24
|
|
||||||
const H = padTop + lines.length * lineH + padBottom
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
canvas.width = W
|
|
||||||
canvas.height = H
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
if (ctx === null) throw new Error('Canvas 2D unavailable — cannot rasterize receipt')
|
|
||||||
ctx.fillStyle = '#fff'
|
|
||||||
ctx.fillRect(0, 0, W, H)
|
|
||||||
ctx.fillStyle = '#000'
|
|
||||||
ctx.textBaseline = 'top'
|
|
||||||
// A font stack the browser can fall back to for Indic scripts on the shop PC.
|
|
||||||
ctx.font = `${fontPx}px "Noto Sans", "Noto Sans Devanagari", "Nirmala UI", "Segoe UI", monospace`
|
|
||||||
lines.forEach((text, i) => {
|
|
||||||
const y = padTop + i * lineH
|
|
||||||
if (i === 0) {
|
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.fillText(text, W / 2, y)
|
|
||||||
} else {
|
|
||||||
ctx.textAlign = 'left'
|
|
||||||
ctx.fillText(text, 4, y)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const img = ctx.getImageData(0, 0, W, H)
|
|
||||||
const body = rasterToEscPos(bitonalFromRGBA(img.data, W, H))
|
|
||||||
|
|
||||||
const head = new EscPos().init()
|
|
||||||
if (opts.kickDrawer === true) head.drawerKick()
|
|
||||||
const prefix = head.align('left').build()
|
|
||||||
const tail = new EscPos().feed(4).cut().build()
|
|
||||||
|
|
||||||
const out = new Uint8Array(prefix.length + body.length + tail.length)
|
|
||||||
out.set(prefix, 0)
|
|
||||||
out.set(body, prefix.length)
|
|
||||||
out.set(tail, prefix.length + body.length)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
/** Bridge exposed by electron/preload.ts; absent when running in a plain browser. */
|
|
||||||
interface PosBridge {
|
|
||||||
listPrinters(): Promise<{ name: string; displayName: string }[]>
|
|
||||||
printRaw9100(host: string, port: number, bytes: Uint8Array): Promise<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Window {
|
|
||||||
pos?: PosBridge
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import type { BillTotals } from '@sims/domain'
|
|
||||||
import type { LineInput } from '@sims/billing-engine'
|
|
||||||
import { buildQueuedBill, nextProvisionalToken, toOfflineBillBody, type QueuedBill } from '../src/offline'
|
|
||||||
|
|
||||||
const TOTALS: BillTotals = {
|
|
||||||
grossPaise: 14_500, discountPaise: 0, taxablePaise: 12_288,
|
|
||||||
cgstPaise: 1_106, sgstPaise: 1_106, igstPaise: 0, cessPaise: 0,
|
|
||||||
roundOffPaise: 0, payablePaise: 14_500, savingsVsMrpPaise: 500,
|
|
||||||
}
|
|
||||||
const LINES: LineInput[] = [{
|
|
||||||
itemId: 'i-103', name: 'Surf Excel 1kg', hsn: '3402', qty: 1,
|
|
||||||
unitCode: 'PCS', unitPricePaise: 14_500, priceIncludesTax: true, taxClassCode: 'GST18',
|
|
||||||
}]
|
|
||||||
|
|
||||||
describe('S4 offline queue — provisional tokens', () => {
|
|
||||||
it('formats OFF-n from the sequence', () => {
|
|
||||||
expect(nextProvisionalToken(1)).toBe('OFF-1')
|
|
||||||
expect(nextProvisionalToken(42)).toBe('OFF-42')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('S4 offline queue — buildQueuedBill', () => {
|
|
||||||
const base = {
|
|
||||||
seq: 2, storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
|
|
||||||
businessDate: '2026-07-10', lines: LINES,
|
|
||||||
payments: [{ mode: 'CASH', amountPaise: 14_500 }],
|
|
||||||
totals: TOTALS, now: 1_700_000_000_000,
|
|
||||||
}
|
|
||||||
|
|
||||||
it('assigns a client uuidv7 id, a matching provisional token, and an ISO createdAt', () => {
|
|
||||||
const q = buildQueuedBill(base)
|
|
||||||
expect(q.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
|
||||||
expect(q.provisionalToken).toBe('OFF-2')
|
|
||||||
expect(q.seq).toBe(2)
|
|
||||||
expect(q.createdAt).toBe(new Date(1_700_000_000_000).toISOString())
|
|
||||||
expect(q.totals.payablePaise).toBe(14_500)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits optional customer / override fields when absent', () => {
|
|
||||||
const q = buildQueuedBill(base)
|
|
||||||
expect('customerId' in q).toBe(false)
|
|
||||||
expect('customerName' in q).toBe(false)
|
|
||||||
expect('buyerGstin' in q).toBe(false)
|
|
||||||
expect('overrides' in q).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps only the customer id and non-empty overrides — never customer PII (M11)', () => {
|
|
||||||
const q = buildQueuedBill({
|
|
||||||
...base,
|
|
||||||
customerId: 'p1',
|
|
||||||
overrides: [{ itemId: 'i-103', kind: 'price', before: 15_000, after: 14_500, approvalId: 'a1' }],
|
|
||||||
})
|
|
||||||
expect(q.customerId).toBe('p1')
|
|
||||||
// M11: the queued record must NOT carry the buyer's name or GSTIN — the server
|
|
||||||
// re-resolves both from customerId at drain, so no PII lands on the shared counter PC.
|
|
||||||
expect('customerName' in q).toBe(false)
|
|
||||||
expect('buyerGstin' in q).toBe(false)
|
|
||||||
expect(q.overrides).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('drops an empty overrides array rather than persisting it', () => {
|
|
||||||
const q = buildQueuedBill({ ...base, overrides: [] })
|
|
||||||
expect('overrides' in q).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('S4 offline queue — toOfflineBillBody', () => {
|
|
||||||
it('maps to the drain payload: keeps the client id, sets clientPayablePaise, drops display-only fields', () => {
|
|
||||||
const q: QueuedBill = buildQueuedBill({
|
|
||||||
seq: 1, storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
|
|
||||||
customerId: 'p1',
|
|
||||||
businessDate: '2026-07-10', lines: LINES,
|
|
||||||
payments: [{ mode: 'CASH', amountPaise: 14_500 }], totals: TOTALS,
|
|
||||||
})
|
|
||||||
const body = toOfflineBillBody(q)
|
|
||||||
expect(body.id).toBe(q.id)
|
|
||||||
expect(body.clientPayablePaise).toBe(14_500)
|
|
||||||
expect(body.customerId).toBe('p1')
|
|
||||||
// SEC fix: cashier/tenant come from the server session, never the body
|
|
||||||
expect('cashierId' in body).toBe(false)
|
|
||||||
// M11: no customer PII is stored offline, so none can travel in the drain body
|
|
||||||
expect('customerName' in body).toBe(false)
|
|
||||||
expect('buyerGstin' in body).toBe(false)
|
|
||||||
expect('totals' in body).toBe(false)
|
|
||||||
expect('provisionalToken' in body).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"include": ["src/**/*", "electron/**/*"]
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const p = (rel: string) => fileURLToPath(new URL(rel, import.meta.url))
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
base: './',
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@sims/domain': p('../../packages/domain/src/index.ts'),
|
|
||||||
'@sims/billing-engine': p('../../packages/billing-engine/src/index.ts'),
|
|
||||||
'@sims/config': p('../../packages/config/src/index.ts'),
|
|
||||||
// Browser-safe subpath (pure role→gate matrix) — must precede the barrel alias,
|
|
||||||
// whose index re-exports scrypt (node:crypto) and cannot enter the web bundle.
|
|
||||||
'@sims/auth/permissions': p('../../packages/auth/src/permissions.ts'),
|
|
||||||
'@sims/auth': p('../../packages/auth/src/index.ts'),
|
|
||||||
'@sims/scanning': p('../../packages/scanning/src/index.ts'),
|
|
||||||
'@sims/search-core': p('../../packages/search-core/src/index.ts'),
|
|
||||||
'@sims/printing': p('../../packages/printing/src/index.ts'),
|
|
||||||
'@sims/ui': p('../../packages/ui/src/index.ts'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: { outDir: 'dist' },
|
|
||||||
})
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
# apps/store-server — Store Service (Node)
|
|
||||||
|
|
||||||
Runs on the store server beside the existing Oracle 12c (D12): owns Oracle access via
|
|
||||||
node-oracledb thin mode behind the repository layer, receives document envelopes from
|
|
||||||
counters over LAN, serves the back-office web app locally, and (when the cloud tier
|
|
||||||
arrives, D14) hosts the sync agent that drains the outbox upward.
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import { build } from 'esbuild'
|
|
||||||
|
|
||||||
await build({
|
|
||||||
entryPoints: ['src/server.ts'],
|
|
||||||
outfile: 'dist/server.cjs',
|
|
||||||
bundle: true,
|
|
||||||
platform: 'node',
|
|
||||||
format: 'cjs',
|
|
||||||
// Native modules can't be bundled. db.ts imports better-sqlite3-multiple-ciphers (the
|
|
||||||
// SQLCipher-compatible drop-in — H3 encryption at rest); better-sqlite3 stays externalled
|
|
||||||
// too in case any transitive path still references it.
|
|
||||||
external: ['better-sqlite3-multiple-ciphers', 'better-sqlite3'],
|
|
||||||
})
|
|
||||||
console.log('store-server built')
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
// Dev launcher: enables the demo seed (M10) then runs the built server. Production must NOT use
|
|
||||||
// this — it runs `node dist/server.cjs` directly with SIMS_SEED_DEMO unset, so a real empty DB is
|
|
||||||
// never auto-seeded with the known-credential demo owner. Pure Node so it works on Windows + POSIX
|
|
||||||
// without a cross-env dependency. An explicit SIMS_SEED_DEMO (e.g. '0') is respected.
|
|
||||||
process.env.SIMS_SEED_DEMO = process.env.SIMS_SEED_DEMO ?? '1'
|
|
||||||
await import('./dist/server.cjs')
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@sims/store-server",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "node build-server.mjs",
|
|
||||||
"start": "npm run build && node dev-start.mjs",
|
|
||||||
"start:prod": "npm run build && node dist/server.cjs",
|
|
||||||
"typecheck": "tsc -p tsconfig.json"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@sims/auth": "*",
|
|
||||||
"@sims/billing-engine": "*",
|
|
||||||
"@sims/domain": "*",
|
|
||||||
"better-sqlite3-multiple-ciphers": "^11.10.0",
|
|
||||||
"express": "^4.21.0",
|
|
||||||
"helmet": "^8.3.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"esbuild": "^0.25.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,610 +0,0 @@
|
|||||||
import { Router, type Request, type Response, type NextFunction } from 'express'
|
|
||||||
import { randomUUID } from 'node:crypto'
|
|
||||||
import net from 'node:net'
|
|
||||||
import dns from 'node:dns'
|
|
||||||
import { verifyPin, FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, can, type LockoutState, type ActionCode } from '@sims/auth'
|
|
||||||
import type { RoleCode } from '@sims/domain'
|
|
||||||
import type { LineInput } from '@sims/billing-engine'
|
|
||||||
import type { DB } from './db'
|
|
||||||
import {
|
|
||||||
batchesForStore, commitBill, createCustomer, createItem, createOverrideApproval, expiryView,
|
|
||||||
listAudit, listBills, listItems, listParties, listTaxClasses, resolveDefaultTaxClass,
|
|
||||||
resolveDraftMaxPricePaise, stockView, verifyAuditChain, writeAudit,
|
|
||||||
} from './repos'
|
|
||||||
import { commitPurchase, costHistory, lastCosts, listPurchases, type CommitPurchaseInput } from './repos-purchase'
|
|
||||||
import { commitReturn, listReturns, lookupBillByDocNo, lookupBillsByPhone, returnableLines } from './repos-returns'
|
|
||||||
import { gstr1, gstr3b, hsnSummary, gstr1PortalJson } from './repos-gst'
|
|
||||||
import {
|
|
||||||
ValidationError, assertString, optString, assertInt, assertNonNegInt, assertPositiveInt,
|
|
||||||
assertPaise, assertQty, assertBusinessDate, assertPeriod, assertArray, clampLimit, clampOffset, PAYMENT_MODES,
|
|
||||||
} from './validate'
|
|
||||||
import {
|
|
||||||
createRateLimiter, authRatePerIpFromEnv, authRateGlobalFromEnv, type RateLimitPolicy,
|
|
||||||
} from './rate-limit'
|
|
||||||
import { isSessionExpired, sessionTtlFromEnv, type SessionTtl } from './session-policy'
|
|
||||||
import { checkPrintEnvelope, checkPrintAddress, parsePrinterPorts } from './print-guard'
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
token: string; userId: string; tenantId: string; displayName: string; roles: string[]
|
|
||||||
// M4: sessions now have a lifetime — created-at (absolute cap) + last-seen (idle cap).
|
|
||||||
createdMs: number; lastSeenMs: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserRow {
|
|
||||||
id: string; tenant_id: string; username: string; display_name: string
|
|
||||||
roles: string; active: number
|
|
||||||
pin_salt: string | null; pin_hash: string | null
|
|
||||||
pw_salt: string | null; pw_hash: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Injection points so tests can force tiny TTLs / rate caps / a stub DNS resolver. */
|
|
||||||
export interface ApiRouterOpts {
|
|
||||||
sessionTtl?: SessionTtl
|
|
||||||
authRatePerIp?: RateLimitPolicy
|
|
||||||
authRateGlobal?: RateLimitPolicy
|
|
||||||
printerPorts?: ReadonlySet<number>
|
|
||||||
resolveHost?: (host: string) => Promise<string[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The bearer token carried on a request, if any. */
|
|
||||||
const bearer = (req: Request): string => (req.headers.authorization ?? '').replace('Bearer ', '')
|
|
||||||
|
|
||||||
const sess = (req: Request): Session => (req as Request & { session: Session }).session
|
|
||||||
const asUser = (s: Session): { roles: RoleCode[]; active: boolean } => ({ roles: s.roles as RoleCode[], active: true })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-route server-side authorization (C1/C4), deny-by-default. Each mutating or sensitive
|
|
||||||
* route names the ActionCode(s) that grant it; a session whose roles grant NONE is 403'd
|
|
||||||
* (E-6105) before the handler runs. The same role→action matrix the React app uses
|
|
||||||
* (permissions.ts) is now enforced on the server — the client is no longer trusted to gate.
|
|
||||||
*/
|
|
||||||
const requireAnyPerm = (...actions: ActionCode[]) =>
|
|
||||||
(req: Request, res: Response, next: NextFunction): void => {
|
|
||||||
const user = asUser(sess(req))
|
|
||||||
if (actions.some((a) => can(user, a))) { next(); return }
|
|
||||||
res.status(403).json({ error: 'You are not permitted to do that (E-6105)', code: 'E-6105' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const OVERRIDE_KINDS = new Set(['price', 'qty', 'discount', 'batch'])
|
|
||||||
|
|
||||||
/** Validate the shared bill body shape (M7) — deep money truth is re-derived in commitBill. */
|
|
||||||
function validateBillBody(raw: unknown): {
|
|
||||||
storeId: string; counterId: string; shiftId: string; customerId?: string
|
|
||||||
businessDate: string; lines: LineInput[]
|
|
||||||
payments: { mode: string; amountPaise: number; reference?: string }[]
|
|
||||||
clientPayablePaise: number
|
|
||||||
overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount' | 'batch'; before: number; after: number; approvalId?: string }[]
|
|
||||||
} {
|
|
||||||
const b = (raw ?? {}) as Record<string, unknown>
|
|
||||||
const storeId = assertString(b['storeId'], 'storeId')
|
|
||||||
const counterId = assertString(b['counterId'], 'counterId')
|
|
||||||
const shiftId = assertString(b['shiftId'], 'shiftId')
|
|
||||||
const customerId = optString(b['customerId'], 'customerId')
|
|
||||||
const businessDate = assertBusinessDate(b['businessDate'])
|
|
||||||
const rawLines = assertArray<Record<string, unknown>>(b['lines'], 'lines', 500)
|
|
||||||
const lines = rawLines.map((l): LineInput => {
|
|
||||||
assertString(l['itemId'], 'line itemId')
|
|
||||||
assertQty(l['qty'], 'line qty')
|
|
||||||
assertPaise(l['unitPricePaise'], 'line price')
|
|
||||||
return l as unknown as LineInput
|
|
||||||
})
|
|
||||||
const rawPayments = assertArray<Record<string, unknown>>(b['payments'], 'payments', 50)
|
|
||||||
const payments = rawPayments.map((p) => {
|
|
||||||
const amountPaise = assertInt(p['amountPaise'], 'payment amount')
|
|
||||||
if (amountPaise <= 0) throw new ValidationError('Every payment must be a positive amount', 'E-1107')
|
|
||||||
const mode = assertString(p['mode'], 'payment mode')
|
|
||||||
if (!PAYMENT_MODES.has(mode)) throw new ValidationError('Unknown payment mode', 'E-1108')
|
|
||||||
return { mode, amountPaise, ...(optString(p['reference'], 'reference') !== undefined ? { reference: optString(p['reference'], 'reference')! } : {}) }
|
|
||||||
})
|
|
||||||
const clientPayablePaise = assertPaise(b['clientPayablePaise'], 'clientPayablePaise')
|
|
||||||
const overrides = b['overrides'] === undefined ? undefined
|
|
||||||
: (assertArray<Record<string, unknown>>(b['overrides'], 'overrides', 500)).map((o) => {
|
|
||||||
const kind = assertString(o['kind'], 'override kind')
|
|
||||||
if (!OVERRIDE_KINDS.has(kind)) throw new ValidationError('Unknown override kind', 'E-1001')
|
|
||||||
return {
|
|
||||||
itemId: assertString(o['itemId'], 'override itemId'),
|
|
||||||
kind: kind as 'price' | 'qty' | 'discount' | 'batch',
|
|
||||||
before: assertInt(o['before'], 'override before'),
|
|
||||||
after: assertInt(o['after'], 'override after'),
|
|
||||||
...(optString(o['approvalId'], 'approvalId') !== undefined ? { approvalId: optString(o['approvalId'], 'approvalId')! } : {}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
storeId, counterId, shiftId, ...(customerId !== undefined ? { customerId } : {}),
|
|
||||||
businessDate, lines, payments, clientPayablePaise, ...(overrides !== undefined ? { overrides } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate the return / credit-note body (M7). Deep money truth is re-derived in commitReturn. */
|
|
||||||
function validateReturnBody(raw: unknown): {
|
|
||||||
storeId: string; counterId: string; shiftId: string; businessDate: string
|
|
||||||
originalBillId: string; lines: { lineIndex: number; qty: number }[]
|
|
||||||
reason: string; refundMode: string; clientRefundPaise?: number
|
|
||||||
} {
|
|
||||||
const b = (raw ?? {}) as Record<string, unknown>
|
|
||||||
const storeId = assertString(b['storeId'], 'storeId')
|
|
||||||
const counterId = assertString(b['counterId'], 'counterId')
|
|
||||||
const shiftId = assertString(b['shiftId'], 'shiftId')
|
|
||||||
const businessDate = assertBusinessDate(b['businessDate'])
|
|
||||||
const originalBillId = assertString(b['originalBillId'], 'originalBillId')
|
|
||||||
const rawLines = assertArray<Record<string, unknown>>(b['lines'], 'lines', 500)
|
|
||||||
const lines = rawLines.map((l) => ({
|
|
||||||
lineIndex: assertNonNegInt(l['lineIndex'], 'line index'),
|
|
||||||
qty: assertQty(l['qty'], 'return qty'),
|
|
||||||
}))
|
|
||||||
const reason = assertString(b['reason'], 'reason')
|
|
||||||
const refundMode = assertString(b['refundMode'], 'refundMode')
|
|
||||||
if (!PAYMENT_MODES.has(refundMode)) throw new ValidationError('Unknown refund mode', 'E-1326')
|
|
||||||
const clientRefundPaise = b['clientRefundPaise'] === undefined ? undefined : assertPaise(b['clientRefundPaise'], 'clientRefundPaise')
|
|
||||||
return {
|
|
||||||
storeId, counterId, shiftId, businessDate, originalBillId, lines, reason, refundMode,
|
|
||||||
...(clientRefundPaise !== undefined ? { clientRefundPaise } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function apiRouter(db: DB, opts: ApiRouterOpts = {}): Router {
|
|
||||||
const r = Router()
|
|
||||||
|
|
||||||
// Edge state is per-router-instance (not module-global) so each mounted server — the prod
|
|
||||||
// process or a test harness — is fully isolated: its own sessions, lockouts and rate-limit
|
|
||||||
// windows, with no cross-instance bleed.
|
|
||||||
const sessions = new Map<string, Session>()
|
|
||||||
const lockouts = new Map<string, LockoutState>()
|
|
||||||
const ttl = opts.sessionTtl ?? sessionTtlFromEnv()
|
|
||||||
const authPerIp = createRateLimiter(opts.authRatePerIp ?? authRatePerIpFromEnv())
|
|
||||||
const authGlobal = createRateLimiter(opts.authRateGlobal ?? authRateGlobalFromEnv())
|
|
||||||
const printerPorts = opts.printerPorts ?? parsePrinterPorts(process.env['SIMS_PRINTER_PORTS'])
|
|
||||||
const resolveHost = opts.resolveHost ?? (async (h: string): Promise<string[]> =>
|
|
||||||
(await dns.promises.lookup(h, { all: true })).map((a) => a.address))
|
|
||||||
|
|
||||||
const sessionDto = (s: Session): Record<string, unknown> =>
|
|
||||||
({ token: s.token, userId: s.userId, tenantId: s.tenantId, displayName: s.displayName, roles: s.roles })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticated-session gate with lifetime enforcement (M4): a token must map to a live
|
|
||||||
* session inside BOTH its idle and absolute TTL, else it is evicted and the caller gets 401
|
|
||||||
* (E-6110) and re-logs in. Each authenticated hit slides the idle window (refreshes last-seen).
|
|
||||||
*/
|
|
||||||
const requireAuth = (req: Request, res: Response, next: NextFunction): void => {
|
|
||||||
const token = bearer(req)
|
|
||||||
const session = sessions.get(token)
|
|
||||||
if (session === undefined) {
|
|
||||||
res.status(401).json({ error: 'Not signed in (E-6103)', code: 'E-6103' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const now = Date.now()
|
|
||||||
if (isSessionExpired(session, now, ttl)) {
|
|
||||||
sessions.delete(token)
|
|
||||||
res.status(401).json({ error: 'Session expired — sign in again (E-6110)', code: 'E-6110' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
session.lastSeenMs = now
|
|
||||||
;(req as Request & { session: Session }).session = session
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global + per-IP throttle for /auth/* (M2/M4, rt-F7). Layered ON TOP of the per-user scrypt
|
|
||||||
* lockout: the lockout stops repeated failures against ONE user; this blunts a parallel grind
|
|
||||||
* across the whole roster from one source, whichever user each attempt names.
|
|
||||||
*/
|
|
||||||
const authRateLimit = (req: Request, res: Response, next: NextFunction): void => {
|
|
||||||
const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown'
|
|
||||||
const now = Date.now()
|
|
||||||
if (!authPerIp.hit(ip, now) || !authGlobal.hit('*', now)) {
|
|
||||||
res.status(429).json({ error: 'Too many attempts — try again shortly (E-6109)', code: 'E-6109' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
|
|
||||||
const startSession = (u: UserRow): Session => {
|
|
||||||
const now = Date.now()
|
|
||||||
const s: Session = {
|
|
||||||
token: randomUUID(), userId: u.id, tenantId: u.tenant_id,
|
|
||||||
displayName: u.display_name, roles: JSON.parse(u.roles) as string[],
|
|
||||||
createdMs: now, lastSeenMs: now,
|
|
||||||
}
|
|
||||||
sessions.set(s.token, s)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
const attempt = (u: UserRow | undefined, secret: string, salt: string | null, hash: string | null): Session | { error: string; status: number } => {
|
|
||||||
const key = u?.id ?? 'unknown'
|
|
||||||
const now = Date.now()
|
|
||||||
const lock = lockouts.get(key) ?? FRESH_LOCKOUT
|
|
||||||
if (isLocked(lock, now)) return { error: 'Too many attempts — locked for a minute (E-6104)', status: 429 }
|
|
||||||
if (u === undefined || u.active !== 1 || salt === null || hash === null || !verifyPin(secret, { salt, hash })) {
|
|
||||||
lockouts.set(key, recordFailure(lock, now))
|
|
||||||
if (u !== undefined) writeAudit(db, u.tenant_id, u.id, 'LOGIN_FAILED', 'app_user', u.id)
|
|
||||||
return { error: 'Wrong credentials (E-6101)', status: 401 }
|
|
||||||
}
|
|
||||||
lockouts.set(key, recordSuccess())
|
|
||||||
return startSession(u)
|
|
||||||
}
|
|
||||||
|
|
||||||
// POS: name-tile + PIN
|
|
||||||
r.post('/auth/pin', authRateLimit, (req, res) => {
|
|
||||||
const { userId, pin } = req.body as { userId?: string; pin?: string }
|
|
||||||
const u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined
|
|
||||||
const out = attempt(u, pin ?? '', u?.pin_salt ?? null, u?.pin_hash ?? null)
|
|
||||||
'token' in out ? res.json(sessionDto(out)) : res.status(out.status).json({ error: out.error })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Back office: username + password
|
|
||||||
r.post('/auth/login', authRateLimit, (req, res) => {
|
|
||||||
const { username, password } = req.body as { username?: string; password?: string }
|
|
||||||
const u = db.prepare(`SELECT * FROM app_user WHERE username=?`).get((username ?? '').toLowerCase()) as UserRow | undefined
|
|
||||||
const out = attempt(u, password ?? '', u?.pw_salt ?? null, u?.pw_hash ?? null)
|
|
||||||
'token' in out ? res.json(sessionDto(out)) : res.status(out.status).json({ error: out.error })
|
|
||||||
})
|
|
||||||
|
|
||||||
// In-flow supervisor gate (spec P0-2): verify a PIN WITHOUT creating a session —
|
|
||||||
// the cashier's login must never be disturbed and `sessions` must not grow. Shares
|
|
||||||
// the same lockout + scrypt verification as attempt(); audits PIN_VERIFY on success
|
|
||||||
// and a PIN_VERIFY_FAILED row on a wrong PIN. Only supervisor / manager / owner roles
|
|
||||||
// may approve an override (09-UX §5.5) — a valid-PIN cashier is rejected loudly.
|
|
||||||
//
|
|
||||||
// H4 fix: the minted token is BOUND to the exact override being approved
|
|
||||||
// ({itemId, kind, before, after}) — the commit re-verifies this against the line's
|
|
||||||
// server-computed deviation so an approval can never be re-attached to another line.
|
|
||||||
const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner'])
|
|
||||||
// M2: verify-pin is only ever called mid-billing by an already-logged-in cashier, so it now
|
|
||||||
// REQUIRES a valid caller session (requireAuth) and is rate-limited — closing its use as an
|
|
||||||
// UNAUTHENTICATED PIN oracle / token-mint vector. The approver's PIN still rides in the body;
|
|
||||||
// the caller session just proves a real counter is asking.
|
|
||||||
r.post('/auth/verify-pin', authRateLimit, requireAuth, (req, res) => {
|
|
||||||
const body = (req.body ?? {}) as { userId?: string; pin?: string; override?: Record<string, unknown> }
|
|
||||||
// Bound override context is required — a PIN verification with nothing to authorise is
|
|
||||||
// rejected rather than minting an unbound (redirectable) token.
|
|
||||||
const ovRaw = body.override
|
|
||||||
if (ovRaw === undefined || ovRaw === null || typeof ovRaw !== 'object') {
|
|
||||||
res.status(400).json({ error: 'Missing the override this PIN is approving (E-1001)', code: 'E-1001' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let ov: { itemId: string; kind: string; before: number; after: number }
|
|
||||||
try {
|
|
||||||
const kind = assertString(ovRaw['kind'], 'override kind')
|
|
||||||
if (!OVERRIDE_KINDS.has(kind)) throw new ValidationError('Unknown override kind', 'E-1001')
|
|
||||||
ov = {
|
|
||||||
itemId: assertString(ovRaw['itemId'], 'override itemId'),
|
|
||||||
kind,
|
|
||||||
before: assertInt(ovRaw['before'], 'override before'),
|
|
||||||
after: assertInt(ovRaw['after'], 'override after'),
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const code = err instanceof ValidationError ? err.code : 'E-1001'
|
|
||||||
res.status(400).json({ error: 'Override context is invalid (E-1001)', code }); return
|
|
||||||
}
|
|
||||||
const { userId, pin } = body
|
|
||||||
const u = db.prepare(`SELECT * FROM app_user WHERE id=?`).get(userId ?? '') as UserRow | undefined
|
|
||||||
const key = u?.id ?? 'unknown'
|
|
||||||
const now = Date.now()
|
|
||||||
const lock = lockouts.get(key) ?? FRESH_LOCKOUT
|
|
||||||
if (isLocked(lock, now)) {
|
|
||||||
res.status(429).json({ error: 'Too many attempts — locked for a minute (E-6104)' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (u === undefined || u.active !== 1 || u.pin_salt === null || u.pin_hash === null
|
|
||||||
|| !verifyPin(pin ?? '', { salt: u.pin_salt, hash: u.pin_hash })) {
|
|
||||||
lockouts.set(key, recordFailure(lock, now))
|
|
||||||
if (u !== undefined) writeAudit(db, u.tenant_id, u.id, 'PIN_VERIFY_FAILED', 'app_user', u.id)
|
|
||||||
res.status(401).json({ error: 'Wrong PIN (E-6101)' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Identity proven — clear the lockout — but authorisation is a separate gate.
|
|
||||||
lockouts.set(key, recordSuccess())
|
|
||||||
const roles = JSON.parse(u.roles) as string[]
|
|
||||||
if (!roles.some((role) => APPROVER_ROLES.has(role))) {
|
|
||||||
res.status(403).json({ error: 'This user cannot approve overrides — pick a supervisor, manager or owner (E-6105)', code: 'E-6105' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const approvalId = createOverrideApproval(db, u.tenant_id, u.id, {
|
|
||||||
itemId: ov.itemId, kind: ov.kind as 'price' | 'qty' | 'discount' | 'batch',
|
|
||||||
beforePaise: ov.before, afterPaise: ov.after,
|
|
||||||
})
|
|
||||||
writeAudit(db, u.tenant_id, u.id, 'PIN_VERIFY', 'app_user', u.id)
|
|
||||||
res.json({ ok: true, userId: u.id, displayName: u.display_name, approvalId })
|
|
||||||
})
|
|
||||||
|
|
||||||
// POS bootstrap: store/counter context, login tiles, tax classes. This is UNAUTHENTICATED so
|
|
||||||
// the POS can paint its login screen — which means it must NOT leak targeting data (M1). The
|
|
||||||
// pre-auth payload therefore DROPS each user's role and the tenant GSTIN. Both are restored
|
|
||||||
// when the SAME endpoint is called WITH a valid session (the POS re-fetches right after login;
|
|
||||||
// back office is always authed), so the supervisor gate, the approver list and the receipt's
|
|
||||||
// seller GSTIN keep working — only the anonymous, pre-login caller is starved of the roster's
|
|
||||||
// roles and the store's GSTIN.
|
|
||||||
r.get('/bootstrap', (req, res) => {
|
|
||||||
const authed = sessions.has(bearer(req))
|
|
||||||
const counterCode = String(req.query['counter'] ?? 'C2')
|
|
||||||
const store = db.prepare(`SELECT * FROM store LIMIT 1`).get() as Record<string, unknown>
|
|
||||||
const counter = db.prepare(`SELECT * FROM counter WHERE store_id=? AND code=?`).get(store['id'], counterCode) as Record<string, unknown> | undefined
|
|
||||||
const tenant = db.prepare(`SELECT * FROM tenant WHERE id=?`).get(store['tenant_id']) as Record<string, unknown>
|
|
||||||
const users = db.prepare(`SELECT id, display_name, roles FROM app_user WHERE active=1 AND pin_hash IS NOT NULL`).all() as Record<string, unknown>[]
|
|
||||||
res.json({
|
|
||||||
store: {
|
|
||||||
id: store['id'], name: store['name'], storeCode: store['code'],
|
|
||||||
stateCode: store['state_code'], counterId: counter?.['id'] ?? 'c2', counterCode,
|
|
||||||
...(authed ? { gstin: tenant['gstin'] } : {}),
|
|
||||||
},
|
|
||||||
users: users.map((u) => ({
|
|
||||||
id: u['id'], name: u['display_name'],
|
|
||||||
...(authed ? { role: (JSON.parse(u['roles'] as string) as string[])[0] } : {}),
|
|
||||||
})),
|
|
||||||
taxClasses: listTaxClasses(db),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
r.use(requireAuth)
|
|
||||||
|
|
||||||
// M4: end a session on demand — POS "Lock" and back-office "Logout" call this so the token
|
|
||||||
// stops working immediately (a leaked/rotated token is cut off without waiting for a restart).
|
|
||||||
r.post('/auth/logout', (req, res) => {
|
|
||||||
sessions.delete(bearer(req))
|
|
||||||
res.json({ ok: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
// H1: ESC/POS print relay, now constrained to a LAN printer on an allowlisted port with a
|
|
||||||
// capped payload (print-guard.ts). Authenticated (it sits below requireAuth). The host is
|
|
||||||
// resolved and EVERY resolved IP must be private/LAN; we then connect to the resolved IP —
|
|
||||||
// not the original name — so a hostname can't DNS-rebind between the check and the connect.
|
|
||||||
r.post('/print', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const { host, port, bytes } = req.body as { host?: unknown; port?: unknown; bytes?: unknown }
|
|
||||||
if (typeof host !== 'string' || host === '' || typeof port !== 'number' || !Array.isArray(bytes)) {
|
|
||||||
res.status(400).json({ ok: false, error: 'Expected { host, port, bytes[] }', code: 'E-2200' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const envelope = checkPrintEnvelope(bytes.length, port, printerPorts)
|
|
||||||
if (!envelope.ok) { res.status(envelope.status).json({ ok: false, error: envelope.error, code: envelope.code }); return }
|
|
||||||
let addrs: string[]
|
|
||||||
try { addrs = await resolveHost(host) } catch { addrs = [] }
|
|
||||||
if (addrs.length === 0) {
|
|
||||||
res.status(400).json({ ok: false, error: 'Printer host is not a private/LAN address', code: 'E-2203' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const ip of addrs) {
|
|
||||||
const chk = checkPrintAddress(ip)
|
|
||||||
if (!chk.ok) { res.status(chk.status).json({ ok: false, error: chk.error, code: chk.code }); return }
|
|
||||||
}
|
|
||||||
const target = addrs[0]!
|
|
||||||
// 'close' always follows 'error'/'timeout' on net sockets — respond exactly once.
|
|
||||||
let done = false
|
|
||||||
const finish = (status: number, body: object): void => { if (done) return; done = true; res.status(status).json(body) }
|
|
||||||
const socket = net.createConnection({ host: target, port, timeout: 3000 })
|
|
||||||
socket.on('connect', () => socket.end(Buffer.from(bytes as number[])))
|
|
||||||
socket.on('close', () => finish(200, { ok: true }))
|
|
||||||
socket.on('timeout', () => { socket.destroy(); finish(504, { ok: false, error: `Printer ${host}:${port} timed out` }) })
|
|
||||||
socket.on('error', (e: Error) => finish(502, { ok: false, error: e.message }))
|
|
||||||
} catch (e) { next(e) }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Catalog reads are needed to bill, so they stay open to any authenticated session —
|
|
||||||
// but pagination is clamped so a hostile limit can never dump the whole table (M6).
|
|
||||||
r.get('/items', (req, res) => {
|
|
||||||
res.json(listItems(db, sess(req).tenantId, {
|
|
||||||
...(req.query['q'] !== undefined ? { query: String(req.query['q']) } : {}),
|
|
||||||
all: req.query['all'] === '1',
|
|
||||||
limit: clampLimit(req.query['limit'], 5000, 5000),
|
|
||||||
offset: clampOffset(req.query['offset']),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
// A cashier (BILL_CREATE) may quick-add a DRAFT item mid-bill; any non-draft item — or a
|
|
||||||
// caller without BILL_CREATE — requires MASTER_EDIT (C1: no editing the master as cashier).
|
|
||||||
r.post('/items', (req, res) => {
|
|
||||||
const s = sess(req)
|
|
||||||
const b = (req.body ?? {}) as Record<string, unknown>
|
|
||||||
const status = optString(b['status'], 'status') ?? 'active'
|
|
||||||
const user = asUser(s)
|
|
||||||
const isMasterEditor = can(user, 'MASTER_EDIT')
|
|
||||||
if (!(isMasterEditor || (status === 'draft' && can(user, 'BILL_CREATE')))) {
|
|
||||||
res.status(403).json({ error: 'You are not permitted to add or edit items (E-6105)', code: 'E-6105' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const salePricePaise = assertPositiveInt(b['salePricePaise'], 'salePricePaise')
|
|
||||||
// C3 fix: a cashier-created draft's tax class is FORCED to the store default — the
|
|
||||||
// client's taxClassCode is ignored so a taxable good can't be self-added as ZERO to
|
|
||||||
// evade GST. A MASTER_EDIT role (manager/owner) still sets the class explicitly.
|
|
||||||
const taxClassCode = isMasterEditor
|
|
||||||
? assertString(b['taxClassCode'], 'taxClassCode')
|
|
||||||
: resolveDefaultTaxClass(db, s.tenantId)
|
|
||||||
// C2 bound: a cashier-created draft's price IS its sell price, so cap it — an
|
|
||||||
// expensive unknown must go through a supervisor / back office, not be self-priced
|
|
||||||
// at the counter. (E-1108 here is the draft-cap denial, distinct endpoint from the
|
|
||||||
// bill-path payment-mode use of the same code.)
|
|
||||||
if (!isMasterEditor && salePricePaise > resolveDraftMaxPricePaise(db, s.tenantId)) {
|
|
||||||
res.status(400).json({
|
|
||||||
error: 'This price is too high to quick-add at the counter — call a supervisor to add it in back office (E-1108)',
|
|
||||||
code: 'E-1108',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const input = {
|
|
||||||
code: assertString(b['code'], 'code'),
|
|
||||||
name: assertString(b['name'], 'name'),
|
|
||||||
hsn: optString(b['hsn'], 'hsn') ?? '',
|
|
||||||
taxClassCode,
|
|
||||||
unitCode: optString(b['unitCode'], 'unitCode') ?? 'PCS',
|
|
||||||
unitDecimals: assertNonNegInt(b['unitDecimals'] ?? 0, 'unitDecimals'),
|
|
||||||
salePricePaise,
|
|
||||||
...(b['mrpPaise'] !== undefined ? { mrpPaise: assertNonNegInt(b['mrpPaise'], 'mrpPaise') } : {}),
|
|
||||||
...(optString(b['barcode'], 'barcode') !== undefined ? { barcode: optString(b['barcode'], 'barcode')! } : {}),
|
|
||||||
status,
|
|
||||||
}
|
|
||||||
const item = createItem(db, s.tenantId, input)
|
|
||||||
writeAudit(db, s.tenantId, s.userId, 'ITEM_CREATE', 'item', item.id, undefined, item)
|
|
||||||
res.json(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Party lookup/create is part of billing (attach a customer at the counter) — open to any
|
|
||||||
// authenticated session; the GSTIN is validated server-side in createCustomer.
|
|
||||||
r.get('/parties', (req, res) => {
|
|
||||||
res.json(listParties(db, sess(req).tenantId, req.query['kind'] as string | undefined, req.query['phone'] as string | undefined))
|
|
||||||
})
|
|
||||||
r.post('/parties', (req, res) => {
|
|
||||||
const b = (req.body ?? {}) as Record<string, unknown>
|
|
||||||
const name = assertString(b['name'], 'name')
|
|
||||||
const phone = assertString(b['phone'], 'phone')
|
|
||||||
const gstin = optString(b['gstin'], 'gstin')
|
|
||||||
const stateCode = optString(b['stateCode'], 'stateCode')
|
|
||||||
const p = createCustomer(db, sess(req).tenantId, name, phone, {
|
|
||||||
...(gstin !== undefined ? { gstin } : {}),
|
|
||||||
...(stateCode !== undefined ? { stateCode } : {}),
|
|
||||||
})
|
|
||||||
writeAudit(db, sess(req).tenantId, sess(req).userId, 'PARTY_CREATE', 'party', p.id, undefined, p)
|
|
||||||
res.json(p)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.post('/bills', requireAnyPerm('BILL_CREATE'), (req, res) => {
|
|
||||||
const body = validateBillBody(req.body)
|
|
||||||
res.json(commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId }))
|
|
||||||
})
|
|
||||||
|
|
||||||
// S4 reconnect drain (authenticated): bills a browser POS queued while the store-server
|
|
||||||
// was unreachable are drained here under the cashier's live session — tenant and cashier
|
|
||||||
// come from the session, NEVER the body. The client bill id is the idempotency key.
|
|
||||||
r.post('/bills/offline', requireAnyPerm('BILL_CREATE'), (req, res) => {
|
|
||||||
const b = (req.body ?? {}) as Record<string, unknown>
|
|
||||||
const clientId = assertString(b['id'], 'id')
|
|
||||||
const body = validateBillBody(req.body)
|
|
||||||
res.json(commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId, clientId }))
|
|
||||||
})
|
|
||||||
|
|
||||||
// A cashier may read bills, but only their OWN — a caller without REPORT_VIEW is pinned to
|
|
||||||
// the session user id server-side, so a BILL_CREATE-only cashier can never enumerate other
|
|
||||||
// counters'/cashiers' bills (residual cross-counter read fix). A REPORT_VIEW/manager role
|
|
||||||
// keeps the full store-wide view. Pagination is clamped (M6).
|
|
||||||
r.get('/bills', requireAnyPerm('BILL_CREATE', 'REPORT_VIEW'), (req, res) => {
|
|
||||||
const s = sess(req)
|
|
||||||
const ownBillsOnly = !can(asUser(s), 'REPORT_VIEW')
|
|
||||||
res.json(listBills(
|
|
||||||
db, s.tenantId,
|
|
||||||
req.query['date'] as string | undefined, req.query['counterId'] as string | undefined,
|
|
||||||
clampLimit(req.query['limit'], 100, 1000),
|
|
||||||
clampOffset(req.query['offset']),
|
|
||||||
ownBillsOnly ? s.userId : undefined,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Returns / credit notes. A cashier has RETURN_CREATE (permissions.ts), so returns ride the
|
|
||||||
// same money/commit path the red-team hardened: commitReturn anchors everything to OUR stored
|
|
||||||
// original bill (never the client), blocks over-return, computes the refund server-side, and
|
|
||||||
// writes an immutable SALE_RETURN doc + credit-note series + stock-back movements + refund leg
|
|
||||||
// + hash-chained audit row in ONE transaction.
|
|
||||||
r.post('/returns', requireAnyPerm('RETURN_CREATE'), (req, res) => {
|
|
||||||
const body = validateReturnBody(req.body)
|
|
||||||
res.json(commitReturn(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId }))
|
|
||||||
})
|
|
||||||
// Credit-note list — tenant-scoped, paginated (M6). A caller without REPORT_VIEW is pinned to
|
|
||||||
// their own credit notes, the same scoping the sales list uses.
|
|
||||||
r.get('/returns', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => {
|
|
||||||
const s = sess(req)
|
|
||||||
const ownOnly = !can(asUser(s), 'REPORT_VIEW')
|
|
||||||
res.json(listReturns(
|
|
||||||
db, s.tenantId, clampLimit(req.query['limit'], 100, 1000), clampOffset(req.query['offset']),
|
|
||||||
ownOnly ? s.userId : undefined,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
// The return lookup: find the original SALE by exact doc no, or recent sales for a phone. A
|
|
||||||
// targeted, tenant-scoped lookup for issuing a return (not the bills-list enumeration) so a
|
|
||||||
// cashier can return a bill a colleague rang up.
|
|
||||||
r.get('/bills/lookup', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => {
|
|
||||||
const s = sess(req)
|
|
||||||
const docNo = optString(req.query['docNo'], 'docNo')
|
|
||||||
const phone = optString(req.query['phone'], 'phone')
|
|
||||||
if (docNo !== undefined) {
|
|
||||||
const bill = lookupBillByDocNo(db, s.tenantId, docNo)
|
|
||||||
res.json(bill === undefined ? [] : [bill])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (phone !== undefined) { res.json(lookupBillsByPhone(db, s.tenantId, phone)); return }
|
|
||||||
res.status(400).json({ error: 'Provide a docNo or phone to look up (E-1327)', code: 'E-1327' })
|
|
||||||
})
|
|
||||||
// The original bill's lines with sold / already-returned / returnable quantities + the raw
|
|
||||||
// lines for the POS refund preview.
|
|
||||||
r.get('/bills/:id/returnable', requireAnyPerm('RETURN_CREATE', 'REPORT_VIEW'), (req, res) => {
|
|
||||||
res.json(returnableLines(db, sess(req).tenantId, assertString(req.params['id'], 'id')))
|
|
||||||
})
|
|
||||||
|
|
||||||
// GST returns — READ-ONLY compliance reporting, gated REPORT_VIEW, tenant-scoped. Each folds the
|
|
||||||
// period's immutable SALE + SALE_RETURN documents through the shared engine (never recomputes tax)
|
|
||||||
// and returns the sections + a reconciliation proof. The .json route is a pragmatic GSTN-shaped
|
|
||||||
// export for a CA / GSP — documented in the payload as NOT a certified filing.
|
|
||||||
r.get('/gst/gstr1', requireAnyPerm('REPORT_VIEW'), (req, res) => {
|
|
||||||
res.json(gstr1(db, sess(req).tenantId, assertPeriod(req.query['period'])))
|
|
||||||
})
|
|
||||||
r.get('/gst/gstr3b', requireAnyPerm('REPORT_VIEW'), (req, res) => {
|
|
||||||
res.json(gstr3b(db, sess(req).tenantId, assertPeriod(req.query['period'])))
|
|
||||||
})
|
|
||||||
r.get('/gst/hsn', requireAnyPerm('REPORT_VIEW'), (req, res) => {
|
|
||||||
res.json(hsnSummary(db, sess(req).tenantId, assertPeriod(req.query['period'])))
|
|
||||||
})
|
|
||||||
r.get('/gst/gstr1.json', requireAnyPerm('REPORT_VIEW'), (req, res) => {
|
|
||||||
res.json(gstr1PortalJson(db, sess(req).tenantId, assertPeriod(req.query['period'])))
|
|
||||||
})
|
|
||||||
|
|
||||||
r.get('/stock', (req, res) => {
|
|
||||||
const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string }
|
|
||||||
res.json(stockView(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id))
|
|
||||||
})
|
|
||||||
|
|
||||||
// S5: batches with derived on-hand > 0 (POS FEFO picker cache, keyed by item).
|
|
||||||
r.get('/batches', (req, res) => {
|
|
||||||
const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string }
|
|
||||||
res.json(batchesForStore(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id))
|
|
||||||
})
|
|
||||||
// S5 expiry dashboard feed — a reporting view (stock value/expiry), not a counter path.
|
|
||||||
r.get('/expiry', requireAnyPerm('REPORT_VIEW'), (req, res) => {
|
|
||||||
const store = db.prepare(`SELECT id FROM store LIMIT 1`).get() as { id: string }
|
|
||||||
res.json(expiryView(db, sess(req).tenantId, (req.query['storeId'] as string | undefined) ?? store.id))
|
|
||||||
})
|
|
||||||
|
|
||||||
r.get('/audit', requireAnyPerm('AUDIT_VIEW'), (req, res) => {
|
|
||||||
res.json(listAudit(
|
|
||||||
db, sess(req).tenantId,
|
|
||||||
clampLimit(req.query['limit'], 100, 1000),
|
|
||||||
clampOffset(req.query['offset']),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
// H2 — recompute the tenant's tamper-evident audit hash chain. {ok:true} means untampered;
|
|
||||||
// {ok:false, brokenAtId} names the first row whose content or prev-link no longer verifies.
|
|
||||||
r.get('/audit/verify', requireAnyPerm('AUDIT_VIEW'), (req, res) => {
|
|
||||||
res.json(verifyAuditChain(db, sess(req).tenantId))
|
|
||||||
})
|
|
||||||
|
|
||||||
r.get('/purchases', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => {
|
|
||||||
res.json(listPurchases(db, sess(req).tenantId))
|
|
||||||
})
|
|
||||||
r.get('/purchases/last-costs', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => {
|
|
||||||
res.json(lastCosts(db, sess(req).tenantId, req.query['supplierId'] as string | undefined))
|
|
||||||
})
|
|
||||||
r.get('/purchases/cost-history', requireAnyPerm('PURCHASE_ENTRY', 'REPORT_VIEW'), (req, res) => {
|
|
||||||
res.json(costHistory(db, sess(req).tenantId, String(req.query['itemId'] ?? ''), req.query['supplierId'] as string | undefined))
|
|
||||||
})
|
|
||||||
r.post('/purchases', requireAnyPerm('PURCHASE_ENTRY'), (req, res) => {
|
|
||||||
const b = (req.body ?? {}) as Record<string, unknown>
|
|
||||||
const supplierId = assertString(b['supplierId'], 'supplierId')
|
|
||||||
const invoiceNo = assertString(b['invoiceNo'], 'invoiceNo')
|
|
||||||
const invoiceDate = assertBusinessDate(b['invoiceDate'], 'invoiceDate')
|
|
||||||
const businessDate = assertBusinessDate(b['businessDate'])
|
|
||||||
const rawLines = assertArray<Record<string, unknown>>(b['lines'], 'lines', 500)
|
|
||||||
const lines = rawLines.map((l) => {
|
|
||||||
assertString(l['itemId'], 'line itemId')
|
|
||||||
assertString(l['name'], 'line name')
|
|
||||||
assertQty(l['qty'], 'line qty')
|
|
||||||
assertNonNegInt(l['unitCostPaise'], 'line cost')
|
|
||||||
assertNonNegInt(l['taxRateBp'], 'line tax rate')
|
|
||||||
if (l['newSalePricePaise'] !== undefined) assertPositiveInt(l['newSalePricePaise'], 'new sale price')
|
|
||||||
if (l['newMrpPaise'] !== undefined) assertPositiveInt(l['newMrpPaise'], 'new MRP')
|
|
||||||
return l as unknown as CommitPurchaseInput['lines'][number]
|
|
||||||
})
|
|
||||||
const clientTotalPaise = assertPaise(b['clientTotalPaise'], 'clientTotalPaise')
|
|
||||||
res.json(commitPurchase(db, {
|
|
||||||
tenantId: sess(req).tenantId, userId: sess(req).userId,
|
|
||||||
storeId: assertString(b['storeId'], 'storeId'),
|
|
||||||
supplierId, invoiceNo, invoiceDate, businessDate, lines, clientTotalPaise,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
@ -1,285 +0,0 @@
|
|||||||
// H3 (red-team): better-sqlite3-multiple-ciphers is an API-compatible drop-in for
|
|
||||||
// better-sqlite3 that adds SQLCipher, so the whole DB file can be encrypted at rest with a
|
|
||||||
// single `pragma('key=...')`. It ships its own DefinitelyTyped-derived declarations (identical
|
|
||||||
// shape to @types/better-sqlite3), so `Database.Database` and `db.pragma()` type exactly as
|
|
||||||
// before — no shim needed. See openDb() for the keying / dev-fallback logic.
|
|
||||||
import Database from 'better-sqlite3-multiple-ciphers'
|
|
||||||
import fs from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store DB — SQLite for the slice, behind portable repositories (repos.ts).
|
|
||||||
* The Oracle 12c adapter (D12) implements the same functions when the Classic
|
|
||||||
* DDL arrives; SQL here stays standard where possible, quirks are commented.
|
|
||||||
*/
|
|
||||||
export type DB = Database.Database
|
|
||||||
|
|
||||||
const SCHEMA = `
|
|
||||||
CREATE TABLE IF NOT EXISTS tenant (
|
|
||||||
id TEXT PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL,
|
|
||||||
gstin TEXT, gst_scheme TEXT NOT NULL DEFAULT 'regular',
|
|
||||||
fy_start_month INTEGER NOT NULL DEFAULT 4, created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS store (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL, state_code TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS counter (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
|
|
||||||
code TEXT NOT NULL, name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS app_user (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, username TEXT NOT NULL UNIQUE,
|
|
||||||
display_name TEXT NOT NULL, roles TEXT NOT NULL, store_ids TEXT NOT NULL,
|
|
||||||
language TEXT NOT NULL DEFAULT 'en', active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
pin_salt TEXT, pin_hash TEXT, pw_salt TEXT, pw_hash TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS item (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL, name_secondary TEXT, hsn TEXT NOT NULL DEFAULT '',
|
|
||||||
tax_class_code TEXT NOT NULL, unit_code TEXT NOT NULL DEFAULT 'PCS',
|
|
||||||
unit_decimals INTEGER NOT NULL DEFAULT 0, mrp_paise INTEGER,
|
|
||||||
sale_price_paise INTEGER NOT NULL, price_includes_tax INTEGER NOT NULL DEFAULT 1,
|
|
||||||
barcodes TEXT NOT NULL DEFAULT '[]', batch_tracked INTEGER NOT NULL DEFAULT 0,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
UNIQUE (tenant_id, code)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS party (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL, kind TEXT NOT NULL, phone TEXT, gstin TEXT,
|
|
||||||
state_code TEXT, credit_limit_paise INTEGER
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS tax_class (
|
|
||||||
class_code TEXT NOT NULL, rate_pct_bp INTEGER NOT NULL,
|
|
||||||
cess_pct_bp INTEGER NOT NULL DEFAULT 0,
|
|
||||||
effective_from TEXT NOT NULL, effective_to TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS doc_series (
|
|
||||||
tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, counter_id TEXT NOT NULL,
|
|
||||||
doc_type TEXT NOT NULL, fy TEXT NOT NULL, prefix TEXT NOT NULL,
|
|
||||||
next_seq INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (tenant_id, store_id, counter_id, doc_type, fy)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS bill (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
|
|
||||||
counter_id TEXT NOT NULL, doc_type TEXT NOT NULL, doc_no TEXT NOT NULL,
|
|
||||||
business_date TEXT NOT NULL, cashier_id TEXT NOT NULL, shift_id TEXT NOT NULL,
|
|
||||||
customer_id TEXT, ref_doc_id TEXT,
|
|
||||||
gross_paise INTEGER NOT NULL, discount_paise INTEGER NOT NULL,
|
|
||||||
taxable_paise INTEGER NOT NULL, cgst_paise INTEGER NOT NULL,
|
|
||||||
sgst_paise INTEGER NOT NULL, igst_paise INTEGER NOT NULL,
|
|
||||||
cess_paise INTEGER NOT NULL, round_off_paise INTEGER NOT NULL,
|
|
||||||
payable_paise INTEGER NOT NULL, savings_paise INTEGER NOT NULL,
|
|
||||||
buyer_gstin TEXT, place_of_supply TEXT,
|
|
||||||
client_id TEXT,
|
|
||||||
payload TEXT NOT NULL, created_at_wall TEXT NOT NULL,
|
|
||||||
lamport INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '',
|
|
||||||
device_seq INTEGER NOT NULL DEFAULT 0,
|
|
||||||
UNIQUE (tenant_id, doc_no)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS stock_movement (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
|
|
||||||
item_id TEXT NOT NULL, qty_delta REAL NOT NULL, reason TEXT NOT NULL,
|
|
||||||
ref_doc_id TEXT, batch_id TEXT, business_date TEXT NOT NULL, created_at_wall TEXT NOT NULL
|
|
||||||
);
|
|
||||||
-- S5 batch/expiry: a batch is an item's lot with an optional expiry (nullable —
|
|
||||||
-- non-perishables are batch-tracked without a date). Stock stays derived (R7):
|
|
||||||
-- a batch's on-hand = SUM(stock_movement.qty_delta WHERE batch_id = the batch).
|
|
||||||
CREATE TABLE IF NOT EXISTS batch (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
|
|
||||||
item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
UNIQUE (tenant_id, store_id, item_id, batch_no)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS setting (
|
|
||||||
scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, key TEXT NOT NULL,
|
|
||||||
value TEXT NOT NULL, effective_from TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS message_catalog (
|
|
||||||
code TEXT NOT NULL, channel TEXT NOT NULL, lang TEXT NOT NULL, template TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, at_wall TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL, action TEXT NOT NULL, entity TEXT NOT NULL,
|
|
||||||
entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT,
|
|
||||||
-- H2 (red-team): per-tenant tamper-evident hash chain. entry_hash =
|
|
||||||
-- sha256(canonical(prev_hash, tenant_id, at_wall, user_id, action, entity,
|
|
||||||
-- entity_id, before_json, after_json)); prev_hash = the previous entry's
|
|
||||||
-- entry_hash for the tenant (genesis = ''). Any out-of-band UPDATE/DELETE/INSERT
|
|
||||||
-- to this table breaks the chain, which GET /api/audit/verify recomputes. This is
|
|
||||||
-- tamper-EVIDENCE, not prevention — the file is still writable (that is H3's job).
|
|
||||||
prev_hash TEXT, entry_hash TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS outbox (
|
|
||||||
op_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, device_id TEXT NOT NULL,
|
|
||||||
device_seq INTEGER NOT NULL, doc_type TEXT NOT NULL, doc_id TEXT NOT NULL,
|
|
||||||
envelope TEXT NOT NULL, created_at_wall TEXT NOT NULL, lamport INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
-- Single-use supervisor-approval tokens (SEC fix): /auth/verify-pin mints one on a
|
|
||||||
-- valid supervisor PIN; the bill commit redeems it (used=0 -> 1, age < 5 min, approver
|
|
||||||
-- role re-checked) so the client can never forge who approved an override.
|
|
||||||
-- H4 fix: the token is BOUND at mint to the exact override it authorises —
|
|
||||||
-- item_id, kind, before_paise (server-computed master value) and after_paise. The
|
|
||||||
-- commit re-verifies these against the specific line's server-computed deviation, so
|
|
||||||
-- a "Milk ₹50→45" approval can never be re-attached to "Whiskey ₹2000→200".
|
|
||||||
CREATE TABLE IF NOT EXISTS override_approval (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
|
|
||||||
approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
||||||
used INTEGER NOT NULL DEFAULT 0,
|
|
||||||
item_id TEXT, kind TEXT, before_paise INTEGER, after_paise INTEGER,
|
|
||||||
cashier_user_id TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS purchase (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
|
|
||||||
supplier_id TEXT NOT NULL, invoice_no TEXT NOT NULL, invoice_date TEXT NOT NULL,
|
|
||||||
business_date TEXT NOT NULL, taxable_paise INTEGER NOT NULL, tax_paise INTEGER NOT NULL,
|
|
||||||
total_paise INTEGER NOT NULL, created_by TEXT NOT NULL, created_at_wall TEXT NOT NULL,
|
|
||||||
payload TEXT NOT NULL,
|
|
||||||
UNIQUE (tenant_id, supplier_id, invoice_no)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS purchase_line (
|
|
||||||
purchase_id TEXT NOT NULL, line_no INTEGER NOT NULL, item_id TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL, qty REAL NOT NULL, unit_cost_paise INTEGER NOT NULL,
|
|
||||||
tax_rate_bp INTEGER NOT NULL, tax_paise INTEGER NOT NULL, line_total_paise INTEGER NOT NULL,
|
|
||||||
new_sale_price_paise INTEGER, new_mrp_paise INTEGER, batch_id TEXT,
|
|
||||||
PRIMARY KEY (purchase_id, line_no)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pline_item ON purchase_line (item_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_item_name ON item (tenant_id, name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bill_date ON bill (tenant_id, business_date);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_move_item ON stock_movement (tenant_id, store_id, item_id);
|
|
||||||
-- idx_move_batch lives in migrate() only: on an upgraded dev DB the batch_id column is
|
|
||||||
-- added by ALTER there, so indexing it in SCHEMA (which runs first) would fail. The
|
|
||||||
-- batch table is fully defined above, so its index is safe to keep here.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log (tenant_id, at_wall);
|
|
||||||
`
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Additive migrations for dev DBs seeded before a column existed. Fresh DBs get
|
|
||||||
* the columns from CREATE TABLE above; this only fills the gap for the running
|
|
||||||
* slice. Each step is guarded by a PRAGMA table_info check so it is idempotent.
|
|
||||||
* The Oracle adapter (D12) manages its own DDL versioning.
|
|
||||||
*/
|
|
||||||
function migrate(db: DB): void {
|
|
||||||
const cols = db.prepare(`PRAGMA table_info(bill)`).all() as { name: string }[]
|
|
||||||
const has = (c: string): boolean => cols.some((x) => x.name === c)
|
|
||||||
// S3 B2B: buyer GSTIN + place-of-supply snapshot on the immutable bill.
|
|
||||||
if (!has('buyer_gstin')) db.exec(`ALTER TABLE bill ADD COLUMN buyer_gstin TEXT`)
|
|
||||||
if (!has('place_of_supply')) db.exec(`ALTER TABLE bill ADD COLUMN place_of_supply TEXT`)
|
|
||||||
// S4 offline queue: the client bill id is the idempotency key for the reconnect
|
|
||||||
// drain. It is NULL for normal (online) bills and non-null only for a bill that
|
|
||||||
// was queued offline; a partial unique index enforces one committed bill per
|
|
||||||
// (tenant, client id) so a retried drain can never duplicate a bill or its stock.
|
|
||||||
// (A partial index, not a table UNIQUE, because SQLite ALTER TABLE cannot add a
|
|
||||||
// constraint and NULLs must stay non-conflicting for the online path.)
|
|
||||||
if (!has('client_id')) db.exec(`ALTER TABLE bill ADD COLUMN client_id TEXT`)
|
|
||||||
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bill_client ON bill (tenant_id, client_id) WHERE client_id IS NOT NULL`)
|
|
||||||
// SEC fix — single-use supervisor-approval tokens. A whole new table, so the
|
|
||||||
// idempotent guard is the table itself (CREATE TABLE IF NOT EXISTS, which SCHEMA
|
|
||||||
// also runs on every open); re-stated here so the migration is explicit for dev
|
|
||||||
// DBs seeded before the table existed.
|
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS override_approval (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
|
|
||||||
approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
||||||
used INTEGER NOT NULL DEFAULT 0
|
|
||||||
)`)
|
|
||||||
// H4 fix — bind the approval token to the override it authorises. Additive columns
|
|
||||||
// for dev DBs seeded before the binding existed; fresh DBs get them from SCHEMA above.
|
|
||||||
const oaCols = db.prepare(`PRAGMA table_info(override_approval)`).all() as { name: string }[]
|
|
||||||
const oaHas = (c: string): boolean => oaCols.some((x) => x.name === c)
|
|
||||||
if (!oaHas('item_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN item_id TEXT`)
|
|
||||||
if (!oaHas('kind')) db.exec(`ALTER TABLE override_approval ADD COLUMN kind TEXT`)
|
|
||||||
if (!oaHas('before_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN before_paise INTEGER`)
|
|
||||||
if (!oaHas('after_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN after_paise INTEGER`)
|
|
||||||
if (!oaHas('cashier_user_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN cashier_user_id TEXT`)
|
|
||||||
// S5 batch/expiry — the batch table (SCHEMA also creates it; re-stated so the
|
|
||||||
// migration is explicit for dev DBs seeded before it existed), plus the nullable
|
|
||||||
// batch_id columns on stock_movement and purchase_line. Per-batch on-hand stays
|
|
||||||
// derived from movements (R7); batch_id is NULL for un-batched (non-tracked) stock.
|
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS batch (
|
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
|
|
||||||
item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
UNIQUE (tenant_id, store_id, item_id, batch_no)
|
|
||||||
)`)
|
|
||||||
const moveCols = db.prepare(`PRAGMA table_info(stock_movement)`).all() as { name: string }[]
|
|
||||||
if (!moveCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE stock_movement ADD COLUMN batch_id TEXT`)
|
|
||||||
const plineCols = db.prepare(`PRAGMA table_info(purchase_line)`).all() as { name: string }[]
|
|
||||||
if (!plineCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE purchase_line ADD COLUMN batch_id TEXT`)
|
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_move_batch ON stock_movement (batch_id)`)
|
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id)`)
|
|
||||||
// H2 — tamper-evident audit hash chain. Additive columns for dev DBs seeded before the
|
|
||||||
// chain existed; fresh DBs get them from SCHEMA above. Pre-existing rows stay NULL (they are
|
|
||||||
// not retro-hashed) — on a real upgrade the chain is consistent from the first row written
|
|
||||||
// after this migration; the dev DB is reset for this slice so every row is chained from genesis.
|
|
||||||
const auditCols = db.prepare(`PRAGMA table_info(audit_log)`).all() as { name: string }[]
|
|
||||||
const auditHas = (c: string): boolean => auditCols.some((x) => x.name === c)
|
|
||||||
if (!auditHas('prev_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN prev_hash TEXT`)
|
|
||||||
if (!auditHas('entry_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN entry_hash TEXT`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* H3 (red-team): full-DB encryption at rest via SQLCipher. When SIMS_DB_KEY is set we key the
|
|
||||||
* database BEFORE any read, so the file on disk — main db AND its WAL/SHM sidecars — is ciphertext.
|
|
||||||
* A copied or snooped sims.db then yields no customer phone numbers/GSTINs (DPDP), no item/purchase
|
|
||||||
* costs & margins, no sales history, no party ledgers and no PIN salts/hashes: every page is
|
|
||||||
* encrypted under a key held ONLY in the server's environment — a machine-scoped secret (Windows
|
|
||||||
* DPAPI / OS keychain), NEVER in the repo or the DB itself.
|
|
||||||
*
|
|
||||||
* When SIMS_DB_KEY is UNSET we open unencrypted exactly as before (dev fallback, so :5181 keeps
|
|
||||||
* working) and warn ONCE that production MUST set it. Mirrors the SIMS_PIN_PEPPER pattern.
|
|
||||||
*
|
|
||||||
* The key must be applied immediately after opening, before journal_mode / schema touch the file
|
|
||||||
* (SQLCipher derives the page key from it on first access). Single quotes in the secret are escaped
|
|
||||||
* by doubling so an arbitrary passphrase is a safe SQL string literal.
|
|
||||||
*
|
|
||||||
* One-time migration of an EXISTING plaintext sims.db to encrypted (not needed for the empty dev DB,
|
|
||||||
* which is simply recreated by deleting data/): open the plaintext file, then
|
|
||||||
* ATTACH DATABASE 'sims-enc.db' AS enc KEY '<SIMS_DB_KEY>';
|
|
||||||
* SELECT sqlcipher_export('enc'); DETACH DATABASE enc;
|
|
||||||
* and swap sims-enc.db in. Do this offline, once, before first encrypted boot.
|
|
||||||
*/
|
|
||||||
let dbKeyWarned = false
|
|
||||||
function applyKeyIfSet(db: DB): void {
|
|
||||||
const key = process.env['SIMS_DB_KEY']
|
|
||||||
if (key === undefined || key === '') {
|
|
||||||
if (!dbKeyWarned) {
|
|
||||||
dbKeyWarned = true
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(
|
|
||||||
'[store-server] SIMS_DB_KEY is not set — the store DB is written UNENCRYPTED on disk. A copied '
|
|
||||||
+ 'sims.db then yields customer PII (phones/GSTINs), costs & margins, the full sales history, party '
|
|
||||||
+ 'ledgers and PIN hashes. Production MUST set a machine-scoped SIMS_DB_KEY (SQLCipher at rest).',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
db.pragma(`cipher='sqlcipher'`)
|
|
||||||
db.pragma(`key='${key.replace(/'/g, "''")}'`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the store DB. Defaults to the on-disk WAL file; pass an explicit path
|
|
||||||
* (e.g. `:memory:`) for hermetic tests. Same schema + migrations either way.
|
|
||||||
*/
|
|
||||||
export function openDb(dbPath?: string): DB {
|
|
||||||
let file: string
|
|
||||||
if (dbPath !== undefined) {
|
|
||||||
file = dbPath
|
|
||||||
} else {
|
|
||||||
// SIMS_DATA_DIR lets a second instance run against an isolated data dir (e.g. the
|
|
||||||
// S7 load target) without touching the dev DB; unset ⇒ the standard ../data path.
|
|
||||||
const envDir = process.env['SIMS_DATA_DIR']
|
|
||||||
const dir = envDir !== undefined && envDir !== '' ? path.resolve(envDir) : path.resolve(__dirname, '../data')
|
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
|
||||||
file = path.join(dir, 'sims.db')
|
|
||||||
}
|
|
||||||
const db = new Database(file)
|
|
||||||
applyKeyIfSet(db) // H3 — key BEFORE any read (journal_mode/schema touch the file)
|
|
||||||
db.pragma('journal_mode = WAL')
|
|
||||||
db.pragma('foreign_keys = ON')
|
|
||||||
db.exec(SCHEMA)
|
|
||||||
migrate(db)
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* /api/print SSRF hardening (red-team H1). The print relay opens a raw TCP socket to a
|
|
||||||
* caller-named host:port and writes caller-supplied bytes — an authenticated cashier could
|
|
||||||
* turn it into an internal port scanner or an egress channel to any public host. These pure
|
|
||||||
* guards constrain the destination to a LAN printer:
|
|
||||||
* (a) the target IP must be private/loopback (127/8, 10/8, 172.16/12, 192.168/16) —
|
|
||||||
* public IPs and link-local (169.254/16) are rejected; a hostname is resolved by the
|
|
||||||
* caller (server.ts) and every resolved address is run through `isLanIPv4` here;
|
|
||||||
* (b) the port must be on a small allowlist (ESC/POS raw ports, env-overridable);
|
|
||||||
* (c) the payload is capped so the socket can't be used to stream unbounded data.
|
|
||||||
* No new deps — all of this is a few string/number checks.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Max ESC/POS payload accepted by /api/print (64 KiB — a receipt is a few KB). */
|
|
||||||
export const MAX_PRINT_BYTES = 64 * 1024
|
|
||||||
|
|
||||||
/** ESC/POS raw-printing ports allowed by default (JetDirect 9100 and its two siblings). */
|
|
||||||
export const DEFAULT_PRINTER_PORTS: readonly number[] = [9100, 9101, 9102]
|
|
||||||
|
|
||||||
export interface PrintGuardOk { ok: true }
|
|
||||||
export interface PrintGuardErr { ok: false; status: number; code: string; error: string }
|
|
||||||
export type PrintGuardResult = PrintGuardOk | PrintGuardErr
|
|
||||||
|
|
||||||
const ok: PrintGuardOk = { ok: true }
|
|
||||||
const err = (code: string, error: string, status = 400): PrintGuardErr => ({ ok: false, status, code, error })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse SIMS_PRINTER_PORTS ("9100,9101,9200") into an allowlist, falling back to the
|
|
||||||
* default set when unset/empty/garbage. Only 1..65535 integers survive.
|
|
||||||
*/
|
|
||||||
export function parsePrinterPorts(raw: string | undefined): Set<number> {
|
|
||||||
if (raw === undefined || raw.trim() === '') return new Set(DEFAULT_PRINTER_PORTS)
|
|
||||||
const ports = raw.split(',')
|
|
||||||
.map((s) => Number(s.trim()))
|
|
||||||
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 65535)
|
|
||||||
return ports.length > 0 ? new Set(ports) : new Set(DEFAULT_PRINTER_PORTS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True only for a dotted-quad IPv4 literal (no hostnames, no IPv6). */
|
|
||||||
function parseIPv4(ip: string): [number, number, number, number] | undefined {
|
|
||||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip)
|
|
||||||
if (m === null) return undefined
|
|
||||||
const parts = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])] as [number, number, number, number]
|
|
||||||
return parts.every((n) => n >= 0 && n <= 255) ? parts : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when `ip` is a private/loopback IPv4 address a store printer can legitimately have:
|
|
||||||
* 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. Everything else — public
|
|
||||||
* addresses, link-local 169.254/16, 0.0.0.0, broadcast — is rejected. IPv6 loopback ::1
|
|
||||||
* is treated as LAN by the caller; every other non-IPv4 string is off-LAN here.
|
|
||||||
*/
|
|
||||||
export function isLanIPv4(ip: string): boolean {
|
|
||||||
const p = parseIPv4(ip)
|
|
||||||
if (p === undefined) return false
|
|
||||||
const [a, b] = p
|
|
||||||
if (a === 127) return true // loopback
|
|
||||||
if (a === 10) return true // private class A
|
|
||||||
if (a === 172 && b >= 16 && b <= 31) return true // private class B
|
|
||||||
if (a === 192 && b === 168) return true // private class C
|
|
||||||
return false // public / link-local / everything else
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ::1 is loopback; a bracketless IPv6 loopback string also passes. Nothing else IPv6 is LAN. */
|
|
||||||
export function isLanAddress(ip: string): boolean {
|
|
||||||
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') return true
|
|
||||||
return isLanIPv4(ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the parts of a print request that need no DNS: payload size and destination port.
|
|
||||||
* Host reachability is checked separately (an IP literal via `isLanAddress`, a hostname via
|
|
||||||
* DNS resolution in the route). Returns the first violation as a safe { status, code, error }.
|
|
||||||
*/
|
|
||||||
export function checkPrintEnvelope(bytesLen: number, port: number, allowedPorts: ReadonlySet<number>): PrintGuardResult {
|
|
||||||
if (!Number.isInteger(bytesLen) || bytesLen < 0) return err('E-2201', 'Bad print payload')
|
|
||||||
if (bytesLen > MAX_PRINT_BYTES) return err('E-2201', `Print payload too large (max ${MAX_PRINT_BYTES} bytes)`)
|
|
||||||
if (!Number.isInteger(port) || !allowedPorts.has(port)) return err('E-2202', 'Printer port not allowed')
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate a resolved IP as a permitted LAN printer address. */
|
|
||||||
export function checkPrintAddress(ip: string): PrintGuardResult {
|
|
||||||
return isLanAddress(ip) ? ok : err('E-2203', 'Printer host is not a private/LAN address')
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* A tiny in-memory sliding-window rate limiter (red-team M2/M4 rt-F7). It layers on top of
|
|
||||||
* the existing per-user scrypt lockout to blunt *parallel* credential grinding across the
|
|
||||||
* roster from one source: the lockout stops N failures against ONE user, this stops a flood
|
|
||||||
* of attempts from one IP (or globally) regardless of which user each one targets. Pure and
|
|
||||||
* clock-injectable so it unit-tests without timers; no new deps.
|
|
||||||
*/
|
|
||||||
export interface RateLimitPolicy {
|
|
||||||
windowMs: number
|
|
||||||
max: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RateLimiter {
|
|
||||||
/** Record an attempt for `key`; returns true if it is within the limit, false if it exceeds it. */
|
|
||||||
hit(key: string, now?: number): boolean
|
|
||||||
/** Test/inspection helper: attempts remaining in the current window for `key`. */
|
|
||||||
remaining(key: string, now?: number): number
|
|
||||||
reset(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRateLimiter(policy: RateLimitPolicy): RateLimiter {
|
|
||||||
const hits = new Map<string, number[]>()
|
|
||||||
const prune = (arr: number[], now: number): number[] => arr.filter((t) => now - t < policy.windowMs)
|
|
||||||
return {
|
|
||||||
hit(key, now = Date.now()) {
|
|
||||||
const arr = prune(hits.get(key) ?? [], now)
|
|
||||||
if (arr.length >= policy.max) {
|
|
||||||
hits.set(key, arr) // keep the pruned window; do NOT count the rejected attempt
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
arr.push(now)
|
|
||||||
hits.set(key, arr)
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
remaining(key, now = Date.now()) {
|
|
||||||
const arr = prune(hits.get(key) ?? [], now)
|
|
||||||
return Math.max(0, policy.max - arr.length)
|
|
||||||
},
|
|
||||||
reset() { hits.clear() },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default auth throttle: 20 attempts/min/IP with a 200/min global ceiling. Comfortably above
|
|
||||||
* an honest counter's login + in-flow supervisor-PIN rate, far below what a scripted roster
|
|
||||||
* grind needs. Env-overridable (SIMS_AUTH_RATE_PER_MIN / SIMS_AUTH_RATE_GLOBAL_PER_MIN).
|
|
||||||
*/
|
|
||||||
export const DEFAULT_AUTH_RATE_PER_IP: RateLimitPolicy = { windowMs: 60_000, max: 20 }
|
|
||||||
export const DEFAULT_AUTH_RATE_GLOBAL: RateLimitPolicy = { windowMs: 60_000, max: 200 }
|
|
||||||
|
|
||||||
export function authRatePerIpFromEnv(env: NodeJS.ProcessEnv = process.env): RateLimitPolicy {
|
|
||||||
const max = Number(env['SIMS_AUTH_RATE_PER_MIN'])
|
|
||||||
return { windowMs: 60_000, max: Number.isFinite(max) && max > 0 ? Math.floor(max) : DEFAULT_AUTH_RATE_PER_IP.max }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function authRateGlobalFromEnv(env: NodeJS.ProcessEnv = process.env): RateLimitPolicy {
|
|
||||||
const max = Number(env['SIMS_AUTH_RATE_GLOBAL_PER_MIN'])
|
|
||||||
return { windowMs: 60_000, max: Number.isFinite(max) && max > 0 ? Math.floor(max) : DEFAULT_AUTH_RATE_GLOBAL.max }
|
|
||||||
}
|
|
||||||
@ -1,263 +0,0 @@
|
|||||||
import {
|
|
||||||
B2CL_THRESHOLD_PAISE, computeGstr1, computeGstr3b, reconcile, taxOf,
|
|
||||||
type GstDocLine, type GstDocument, type Gstr1, type Gstr3b, type Reconciliation,
|
|
||||||
} from '@sims/billing-engine'
|
|
||||||
import type { DB } from './db'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GST returns — the READ-ONLY reporting repo (COMPLIANCE-CRITICAL). It reads the period's
|
|
||||||
* IMMUTABLE outward documents (SALE + SALE_RETURN) straight from the `bill` table and folds them
|
|
||||||
* through the shared pure engine (@sims/billing-engine/gst-returns). It writes nothing and
|
|
||||||
* recomputes no tax — every amount is the one captured on the document (R5/R6, GST-2/GST-3).
|
|
||||||
*
|
|
||||||
* Not certified filing: the JSON export is a pragmatic approximation of the GSTN GSTR-1 schema for
|
|
||||||
* a CA / GSP to consume, NOT a signed return. Real e-filing goes via a GSP (doc 17 §S2), deferred.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface GstBillRow {
|
|
||||||
doc_type: string
|
|
||||||
doc_no: string
|
|
||||||
business_date: string
|
|
||||||
buyer_gstin: string | null
|
|
||||||
place_of_supply: string | null
|
|
||||||
payable_paise: number
|
|
||||||
store_state: string
|
|
||||||
customer_name: string | null
|
|
||||||
original_doc_no: string | null
|
|
||||||
payload: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoredLine {
|
|
||||||
hsn?: string; qty?: number; unitCode?: string
|
|
||||||
taxablePaise?: number; cgstPaise?: number; sgstPaise?: number
|
|
||||||
igstPaise?: number; cessPaise?: number; taxRateBp?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [start, end) ISO-date bounds for a 'YYYY-MM' period (returns are filed by calendar month). */
|
|
||||||
export function periodBounds(period: string): { start: string; end: string } {
|
|
||||||
const y = Number(period.slice(0, 4))
|
|
||||||
const m = Number(period.slice(5, 7))
|
|
||||||
const nextY = m === 12 ? y + 1 : y
|
|
||||||
const nextM = m === 12 ? 1 : m + 1
|
|
||||||
return {
|
|
||||||
start: `${period}-01`,
|
|
||||||
end: `${String(nextY).padStart(4, '0')}-${String(nextM).padStart(2, '0')}-01`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The B2CL threshold as a dated tenant setting (R9/R10); default ₹2,50,000 if no row. */
|
|
||||||
export function resolveB2clThresholdPaise(db: DB, tenantId: string): number {
|
|
||||||
const row = db.prepare(
|
|
||||||
`SELECT value FROM setting WHERE key='gst.b2clThresholdPaise'
|
|
||||||
AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?))
|
|
||||||
ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`,
|
|
||||||
).get(tenantId) as { value: string } | undefined
|
|
||||||
const n = row !== undefined ? Number(row.value) : NaN
|
|
||||||
return Number.isFinite(n) && n > 0 ? n : B2CL_THRESHOLD_PAISE
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the period's outward documents as engine `GstDocument[]`. Each bill carries its own store
|
|
||||||
* state (so intra/inter is decided per document) and its snapshotted buyer GSTIN + place of supply.
|
|
||||||
*/
|
|
||||||
export function loadGstDocuments(
|
|
||||||
db: DB, tenantId: string, period: string,
|
|
||||||
): { docs: GstDocument[]; storeStateCode: string } {
|
|
||||||
const { start, end } = periodBounds(period)
|
|
||||||
const rows = db.prepare(
|
|
||||||
`SELECT b.doc_type, b.doc_no, b.business_date, b.buyer_gstin, b.place_of_supply,
|
|
||||||
b.payable_paise, s.state_code AS store_state, p.name AS customer_name,
|
|
||||||
o.doc_no AS original_doc_no, b.payload
|
|
||||||
FROM bill b
|
|
||||||
JOIN store s ON s.id = b.store_id
|
|
||||||
LEFT JOIN party p ON p.id = b.customer_id
|
|
||||||
LEFT JOIN bill o ON o.id = b.ref_doc_id
|
|
||||||
WHERE b.tenant_id = ? AND b.doc_type IN ('SALE','SALE_RETURN')
|
|
||||||
AND b.business_date >= ? AND b.business_date < ?
|
|
||||||
ORDER BY b.business_date, b.doc_no`,
|
|
||||||
).all(tenantId, start, end) as GstBillRow[]
|
|
||||||
|
|
||||||
const storeStateRow = db.prepare(
|
|
||||||
`SELECT state_code FROM store WHERE tenant_id=? ORDER BY code LIMIT 1`,
|
|
||||||
).get(tenantId) as { state_code: string } | undefined
|
|
||||||
const storeStateCode = storeStateRow?.state_code ?? '00'
|
|
||||||
|
|
||||||
const docs: GstDocument[] = rows.map((r) => {
|
|
||||||
let lines: GstDocLine[] = []
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(r.payload) as { lines?: StoredLine[] }
|
|
||||||
lines = (payload.lines ?? []).map((l) => ({
|
|
||||||
hsn: typeof l.hsn === 'string' && l.hsn !== '' ? l.hsn : 'UNCLASSIFIED',
|
|
||||||
qty: Number(l.qty ?? 0),
|
|
||||||
unitCode: typeof l.unitCode === 'string' && l.unitCode !== '' ? l.unitCode : 'NA',
|
|
||||||
taxablePaise: Number(l.taxablePaise ?? 0),
|
|
||||||
cgstPaise: Number(l.cgstPaise ?? 0),
|
|
||||||
sgstPaise: Number(l.sgstPaise ?? 0),
|
|
||||||
igstPaise: Number(l.igstPaise ?? 0),
|
|
||||||
cessPaise: Number(l.cessPaise ?? 0),
|
|
||||||
taxRateBp: Number(l.taxRateBp ?? 0),
|
|
||||||
}))
|
|
||||||
} catch { lines = [] }
|
|
||||||
return {
|
|
||||||
docType: r.doc_type === 'SALE_RETURN' ? 'SALE_RETURN' : 'SALE',
|
|
||||||
docNo: r.doc_no,
|
|
||||||
businessDate: r.business_date,
|
|
||||||
...(r.buyer_gstin !== null && r.buyer_gstin !== '' ? { buyerGstin: r.buyer_gstin } : {}),
|
|
||||||
placeOfSupply: r.place_of_supply !== null && r.place_of_supply !== '' ? r.place_of_supply : r.store_state,
|
|
||||||
supplyStateCode: r.store_state,
|
|
||||||
...(r.customer_name !== null ? { customerName: r.customer_name } : {}),
|
|
||||||
invoiceValuePaise: Number(r.payable_paise),
|
|
||||||
...(r.original_doc_no !== null ? { originalDocNo: r.original_doc_no } : {}),
|
|
||||||
lines,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return { docs, storeStateCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Gstr1Report extends Gstr1 {
|
|
||||||
period: string
|
|
||||||
storeStateCode: string
|
|
||||||
reconciliation: Reconciliation
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GSTR-1 for a period: all sections + the reconciliation proof. */
|
|
||||||
export function gstr1(db: DB, tenantId: string, period: string): Gstr1Report {
|
|
||||||
const { docs, storeStateCode } = loadGstDocuments(db, tenantId, period)
|
|
||||||
const opts = { storeStateCode, b2clThresholdPaise: resolveB2clThresholdPaise(db, tenantId) }
|
|
||||||
const g1 = computeGstr1(docs, opts)
|
|
||||||
const g3 = computeGstr3b(docs)
|
|
||||||
return { period, storeStateCode, ...g1, reconciliation: reconcile(docs, g1, g3) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Gstr3bReport extends Gstr3b {
|
|
||||||
period: string
|
|
||||||
storeStateCode: string
|
|
||||||
reconciliation: Reconciliation
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GSTR-3B 3.1(a) for a period + the same reconciliation proof. */
|
|
||||||
export function gstr3b(db: DB, tenantId: string, period: string): Gstr3bReport {
|
|
||||||
const { docs, storeStateCode } = loadGstDocuments(db, tenantId, period)
|
|
||||||
const opts = { storeStateCode, b2clThresholdPaise: resolveB2clThresholdPaise(db, tenantId) }
|
|
||||||
const g1 = computeGstr1(docs, opts)
|
|
||||||
const g3 = computeGstr3b(docs)
|
|
||||||
return { period, storeStateCode, ...g3, reconciliation: reconcile(docs, g1, g3) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** HSN summary projection (GSTR-1 Table 12) for the dedicated /gst/hsn page. */
|
|
||||||
export function hsnSummary(db: DB, tenantId: string, period: string): {
|
|
||||||
period: string; storeStateCode: string; rows: Gstr1['hsn']; totals: Gstr1['totals']['hsn']; reconciliation: Reconciliation
|
|
||||||
} {
|
|
||||||
const rep = gstr1(db, tenantId, period)
|
|
||||||
return { period, storeStateCode: rep.storeStateCode, rows: rep.hsn, totals: rep.totals.hsn, reconciliation: rep.reconciliation }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- GST-portal-shaped JSON (pragmatic approximation, NOT a certified filing) ----------
|
|
||||||
|
|
||||||
interface JsonItm { num: number; itm_det: { rt: number; txval: number; iamt: number; camt: number; samt: number; csamt: number } }
|
|
||||||
interface RateAcc { txval: number; iamt: number; camt: number; samt: number; csamt: number }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A best-effort GSTN GSTR-1 JSON. Amounts are in RUPEES with 2 decimals (the portal convention),
|
|
||||||
* derived once from the integer-paise ledger at the edge — the internal math stays in paise (R4).
|
|
||||||
* Schema keys mirror the offline-tool format (b2b/b2cl/b2cs/cdnr/cdnur/hsn/doc_issue) but this is
|
|
||||||
* an APPROXIMATION for a CA / GSP to import and validate, not a signed return.
|
|
||||||
*/
|
|
||||||
export function gstr1PortalJson(db: DB, tenantId: string, period: string): Record<string, unknown> {
|
|
||||||
const { docs, storeStateCode } = loadGstDocuments(db, tenantId, period)
|
|
||||||
const g1 = computeGstr1(docs, { storeStateCode, b2clThresholdPaise: resolveB2clThresholdPaise(db, tenantId) })
|
|
||||||
const tenant = db.prepare(`SELECT gstin FROM tenant WHERE id=?`).get(tenantId) as { gstin: string | null } | undefined
|
|
||||||
|
|
||||||
const rs = (paise: number): number => Number((paise / 100).toFixed(2))
|
|
||||||
const rt = (bp: number): number => bp / 100
|
|
||||||
const idt = (iso: string): string => `${iso.slice(8, 10)}-${iso.slice(5, 7)}-${iso.slice(0, 4)}`
|
|
||||||
const fp = `${period.slice(5, 7)}${period.slice(0, 4)}`
|
|
||||||
|
|
||||||
const ratesToItms = (m: Map<number, RateAcc>): JsonItm[] =>
|
|
||||||
[...m.entries()].sort((a, b) => a[0] - b[0]).map(([bp, a], i) => ({
|
|
||||||
num: i + 1,
|
|
||||||
itm_det: { rt: rt(bp), txval: rs(a.txval), iamt: rs(a.iamt), camt: rs(a.camt), samt: rs(a.samt), csamt: rs(a.csamt) },
|
|
||||||
}))
|
|
||||||
const accLines = (d: GstDocument): Map<number, RateAcc> => {
|
|
||||||
const m = new Map<number, RateAcc>()
|
|
||||||
for (const l of d.lines) {
|
|
||||||
let a = m.get(l.taxRateBp)
|
|
||||||
if (a === undefined) { a = { txval: 0, iamt: 0, camt: 0, samt: 0, csamt: 0 }; m.set(l.taxRateBp, a) }
|
|
||||||
a.txval += l.taxablePaise; a.iamt += l.igstPaise; a.camt += l.cgstPaise; a.samt += l.sgstPaise; a.csamt += l.cessPaise
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// b2b: per counterparty GSTIN → per invoice → rate-wise items (built from raw docs).
|
|
||||||
const b2bByCtin = new Map<string, Record<string, unknown>[]>()
|
|
||||||
for (const d of docs) {
|
|
||||||
if (d.docType !== 'SALE' || d.buyerGstin === undefined) continue
|
|
||||||
const inv = { inum: d.docNo, idt: idt(d.businessDate), val: rs(d.invoiceValuePaise), pos: d.placeOfSupply, rchrg: 'N', inv_typ: 'R', itms: ratesToItms(accLines(d)) }
|
|
||||||
const arr = b2bByCtin.get(d.buyerGstin) ?? []
|
|
||||||
arr.push(inv)
|
|
||||||
b2bByCtin.set(d.buyerGstin, arr)
|
|
||||||
}
|
|
||||||
const b2b = [...b2bByCtin.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([ctin, inv]) => ({ ctin, inv }))
|
|
||||||
|
|
||||||
// b2cl: inter-state B2C invoices above threshold, grouped by place of supply.
|
|
||||||
const b2clByPos = new Map<string, Record<string, unknown>[]>()
|
|
||||||
for (const d of docs) {
|
|
||||||
if (d.docType !== 'SALE' || d.buyerGstin !== undefined) continue
|
|
||||||
if (!g1.b2cl.some((i) => i.docNo === d.docNo)) continue
|
|
||||||
const inv = { inum: d.docNo, idt: idt(d.businessDate), val: rs(d.invoiceValuePaise), itms: ratesToItms(accLines(d)) }
|
|
||||||
const arr = b2clByPos.get(d.placeOfSupply) ?? []
|
|
||||||
arr.push(inv)
|
|
||||||
b2clByPos.set(d.placeOfSupply, arr)
|
|
||||||
}
|
|
||||||
const b2cl = [...b2clByPos.entries()].map(([pos, inv]) => ({ pos, inv }))
|
|
||||||
|
|
||||||
// b2cs: aggregated by supply type + place of supply + rate.
|
|
||||||
const b2cs = g1.b2cs.map((b) => ({
|
|
||||||
sply_ty: b.supplyType, pos: b.pos, typ: 'OE', rt: rt(b.rateBp),
|
|
||||||
txval: rs(b.taxablePaise), iamt: rs(b.igstPaise), camt: rs(b.cgstPaise), samt: rs(b.sgstPaise), csamt: rs(b.cessPaise),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// cdnr: registered credit notes, grouped by counterparty GSTIN.
|
|
||||||
const cdnrByCtin = new Map<string, Record<string, unknown>[]>()
|
|
||||||
for (const n of g1.cdnr) {
|
|
||||||
const rateMap = new Map<number, RateAcc>()
|
|
||||||
for (const r of n.rates) rateMap.set(r.rateBp, { txval: r.taxablePaise, iamt: r.igstPaise, camt: r.cgstPaise, samt: r.sgstPaise, csamt: r.cessPaise })
|
|
||||||
const nt = { ntty: 'C', nt_num: n.noteDocNo, nt_dt: idt(n.noteDate), val: rs(n.noteValuePaise), itms: ratesToItms(rateMap) }
|
|
||||||
const ctin = n.ctin ?? ''
|
|
||||||
const arr = cdnrByCtin.get(ctin) ?? []
|
|
||||||
arr.push(nt)
|
|
||||||
cdnrByCtin.set(ctin, arr)
|
|
||||||
}
|
|
||||||
const cdnr = [...cdnrByCtin.entries()].map(([ctin, nt]) => ({ ctin, nt }))
|
|
||||||
|
|
||||||
// cdnur: unregistered inter-state large credit notes.
|
|
||||||
const cdnur = g1.cdnur.map((n) => {
|
|
||||||
const rateMap = new Map<number, RateAcc>()
|
|
||||||
for (const r of n.rates) rateMap.set(r.rateBp, { txval: r.taxablePaise, iamt: r.igstPaise, camt: r.cgstPaise, samt: r.sgstPaise, csamt: r.cessPaise })
|
|
||||||
return { typ: 'B2CL', ntty: 'C', nt_num: n.noteDocNo, nt_dt: idt(n.noteDate), pos: n.pos, val: rs(n.noteValuePaise), itms: ratesToItms(rateMap) }
|
|
||||||
})
|
|
||||||
|
|
||||||
// hsn: Table 12 data rows.
|
|
||||||
const hsn = {
|
|
||||||
data: g1.hsn.map((h, i) => ({
|
|
||||||
num: i + 1, hsn_sc: h.hsn, uqc: h.uqc, qty: Number(h.qty.toFixed(3)), rt: rt(h.rateBp),
|
|
||||||
txval: rs(h.taxablePaise), iamt: rs(h.igstPaise), camt: rs(h.cgstPaise), samt: rs(h.sgstPaise), csamt: rs(h.cessPaise),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
// doc_issue: Table 13 document ranges.
|
|
||||||
const doc_issue = {
|
|
||||||
doc_det: g1.docs.map((d, i) => ({
|
|
||||||
doc_num: i + 1,
|
|
||||||
docs: [{ num: 1, from: d.fromDocNo, to: d.toDocNo, totnum: d.totalNumber, cancel: d.cancelled, net_issue: d.net }],
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
_note: 'Pragmatic approximation of the GSTN GSTR-1 offline-tool JSON — for CA / GSP import and review, NOT a certified/signed filing. Amounts in rupees. Real e-filing rides a GSP (doc 17 §S2).',
|
|
||||||
gstin: tenant?.gstin ?? '',
|
|
||||||
fp,
|
|
||||||
gt: rs(taxOf(g1.totals.net) + g1.totals.net.taxablePaise),
|
|
||||||
b2b, b2cl, b2cs, cdnr, cdnur, hsn, doc_issue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
import { uuidv7, type RoleCode } from '@sims/domain'
|
|
||||||
import { can } from '@sims/auth'
|
|
||||||
import type { DB } from './db'
|
|
||||||
import { upsertBatch, writeAudit } from './repos'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Purchases — stock IN, cost memory, and price updates in one transaction.
|
|
||||||
* Same portable-repository contract as repos.ts (D12).
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface PurchaseLineInput {
|
|
||||||
itemId: string
|
|
||||||
name: string
|
|
||||||
qty: number
|
|
||||||
unitCostPaise: number
|
|
||||||
taxRateBp: number
|
|
||||||
/** Optional price revisions decided at entry time (cost-changed prompt). */
|
|
||||||
newSalePricePaise?: number
|
|
||||||
newMrpPaise?: number
|
|
||||||
/** S5: batch/expiry captured for a batch-tracked item. batchNo upserts the batch
|
|
||||||
* (found by unique key or created); expiryDate is optional (undated lots allowed). */
|
|
||||||
batchNo?: string
|
|
||||||
expiryDate?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommitPurchaseInput {
|
|
||||||
tenantId: string
|
|
||||||
storeId: string
|
|
||||||
userId: string
|
|
||||||
supplierId: string
|
|
||||||
invoiceNo: string
|
|
||||||
invoiceDate: string
|
|
||||||
businessDate: string
|
|
||||||
lines: PurchaseLineInput[]
|
|
||||||
clientTotalPaise: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function lineMath(l: PurchaseLineInput): { taxable: number; tax: number; total: number } {
|
|
||||||
const taxable = Math.round(l.qty * l.unitCostPaise)
|
|
||||||
const tax = Math.round((taxable * l.taxRateBp) / 10_000)
|
|
||||||
return { taxable, tax, total: taxable + tax }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commitPurchase(db: DB, input: CommitPurchaseInput): { id: string; totalPaise: number } {
|
|
||||||
if (input.lines.length === 0) throw Object.assign(new Error('Purchase has no lines'), { status: 400 })
|
|
||||||
if (input.invoiceNo.trim() === '') throw Object.assign(new Error('Supplier invoice number is required'), { status: 400 })
|
|
||||||
for (const l of input.lines) {
|
|
||||||
if (!(l.qty > 0)) throw Object.assign(new Error(`Quantity must be positive for "${l.name}"`), { status: 400 })
|
|
||||||
if (!Number.isInteger(l.unitCostPaise) || l.unitCostPaise < 0) {
|
|
||||||
throw Object.assign(new Error(`Cost must be non-negative for "${l.name}"`), { status: 400 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const math = input.lines.map(lineMath)
|
|
||||||
const taxable = math.reduce((a, m) => a + m.taxable, 0)
|
|
||||||
const tax = math.reduce((a, m) => a + m.tax, 0)
|
|
||||||
const total = taxable + tax
|
|
||||||
// Same-math-everywhere idiom: the entry screen and the server must agree.
|
|
||||||
if (total !== input.clientTotalPaise) {
|
|
||||||
throw Object.assign(new Error('Totals mismatch between entry screen and server'), { status: 409 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const dup = db.prepare(
|
|
||||||
`SELECT id FROM purchase WHERE tenant_id=? AND supplier_id=? AND invoice_no=?`,
|
|
||||||
).get(input.tenantId, input.supplierId, input.invoiceNo)
|
|
||||||
if (dup !== undefined) {
|
|
||||||
throw Object.assign(new Error(`Invoice ${input.invoiceNo} from this supplier is already entered (duplicate)`), { status: 409 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// C4 fix: receiving stock (PURCHASE_ENTRY, gated at the route) is separate from moving
|
|
||||||
// the item master. Only a MASTER_EDIT holder may push newSalePricePaise / newMrpPaise;
|
|
||||||
// for anyone else the stock still comes in but the price revision is ignored and noted.
|
|
||||||
const buyer = db.prepare(`SELECT roles FROM app_user WHERE id=? AND tenant_id=?`).get(input.userId, input.tenantId) as { roles: string } | undefined
|
|
||||||
const canEditMaster = buyer !== undefined && can({ roles: JSON.parse(buyer.roles) as RoleCode[], active: true }, 'MASTER_EDIT')
|
|
||||||
let priceUpdatesSkipped = 0
|
|
||||||
|
|
||||||
const id = uuidv7()
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
|
|
||||||
db.transaction(() => {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO purchase (id, tenant_id, store_id, supplier_id, invoice_no, invoice_date, business_date,
|
|
||||||
taxable_paise, tax_paise, total_paise, created_by, created_at_wall, payload)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
id, input.tenantId, input.storeId, input.supplierId, input.invoiceNo, input.invoiceDate,
|
|
||||||
input.businessDate, taxable, tax, total, input.userId, now, JSON.stringify(input.lines),
|
|
||||||
)
|
|
||||||
const insLine = db.prepare(
|
|
||||||
`INSERT INTO purchase_line (purchase_id, line_no, item_id, name, qty, unit_cost_paise, tax_rate_bp, tax_paise, line_total_paise, new_sale_price_paise, new_mrp_paise, batch_id)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
const move = db.prepare(
|
|
||||||
`INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, batch_id, business_date, created_at_wall)
|
|
||||||
VALUES (?, ?, ?, ?, ?, 'PURCHASE', ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
input.lines.forEach((l, i) => {
|
|
||||||
const m = math[i]!
|
|
||||||
// S5: a batch-tracked line names its batch — upsert it (unique key or new) and
|
|
||||||
// carry the batch_id onto both the purchase line and the stock-IN movement so
|
|
||||||
// per-batch on-hand derives from movements (R7). Un-batched lines pass NULL.
|
|
||||||
const batchId = l.batchNo !== undefined && l.batchNo.trim() !== ''
|
|
||||||
? upsertBatch(db, input.tenantId, input.storeId, l.itemId, l.batchNo.trim(), l.expiryDate)
|
|
||||||
: null
|
|
||||||
const requestedPriceChange = l.newSalePricePaise !== undefined || l.newMrpPaise !== undefined
|
|
||||||
const applyPriceChange = requestedPriceChange && canEditMaster
|
|
||||||
insLine.run(
|
|
||||||
id, i + 1, l.itemId, l.name, l.qty, l.unitCostPaise, l.taxRateBp, m.tax, m.total,
|
|
||||||
applyPriceChange ? (l.newSalePricePaise ?? null) : null,
|
|
||||||
applyPriceChange ? (l.newMrpPaise ?? null) : null,
|
|
||||||
batchId,
|
|
||||||
)
|
|
||||||
move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, batchId, input.businessDate, now)
|
|
||||||
if (applyPriceChange) {
|
|
||||||
const before = db.prepare(`SELECT sale_price_paise, mrp_paise FROM item WHERE id=?`).get(l.itemId)
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE item SET sale_price_paise = COALESCE(?, sale_price_paise), mrp_paise = COALESCE(?, mrp_paise) WHERE id=? AND tenant_id=?`,
|
|
||||||
).run(l.newSalePricePaise ?? null, l.newMrpPaise ?? null, l.itemId, input.tenantId)
|
|
||||||
writeAudit(db, input.tenantId, input.userId, 'ITEM_PRICE_UPDATE', 'item', l.itemId, before, {
|
|
||||||
salePricePaise: l.newSalePricePaise, mrpPaise: l.newMrpPaise, source: `purchase ${input.invoiceNo}`,
|
|
||||||
})
|
|
||||||
} else if (requestedPriceChange) {
|
|
||||||
priceUpdatesSkipped++
|
|
||||||
}
|
|
||||||
})
|
|
||||||
writeAudit(db, input.tenantId, input.userId, 'PURCHASE_COMMIT', 'purchase', id, undefined, {
|
|
||||||
invoiceNo: input.invoiceNo, supplier: input.supplierId, total,
|
|
||||||
...(priceUpdatesSkipped > 0 ? { priceUpdatesSkipped, reason: 'caller lacks MASTER_EDIT' } : {}),
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
|
|
||||||
return { id, totalPaise: total }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Last known unit cost per item — overall and for one supplier (prefill + change detection). */
|
|
||||||
export function lastCosts(db: DB, tenantId: string, supplierId?: string): Record<string, { last?: number; supplierLast?: number }> {
|
|
||||||
const out: Record<string, { last?: number; supplierLast?: number }> = {}
|
|
||||||
const rows = db.prepare(
|
|
||||||
`SELECT pl.item_id, pl.unit_cost_paise, p.supplier_id
|
|
||||||
FROM purchase_line pl JOIN purchase p ON p.id = pl.purchase_id
|
|
||||||
WHERE p.tenant_id=? ORDER BY p.created_at_wall ASC`,
|
|
||||||
).all(tenantId) as { item_id: string; unit_cost_paise: number; supplier_id: string }[]
|
|
||||||
for (const r of rows) {
|
|
||||||
const e = (out[r.item_id] ??= {})
|
|
||||||
e.last = r.unit_cost_paise
|
|
||||||
if (supplierId !== undefined && r.supplier_id === supplierId) e.supplierLast = r.unit_cost_paise
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Last 3 costs for one item (right-rail glanceability, spec P1-1). */
|
|
||||||
export function costHistory(db: DB, tenantId: string, itemId: string, supplierId?: string): Record<string, unknown>[] {
|
|
||||||
const args: unknown[] = [tenantId, itemId]
|
|
||||||
let sql = `SELECT pl.unit_cost_paise AS cost, p.invoice_date AS date, pa.name AS supplier
|
|
||||||
FROM purchase_line pl JOIN purchase p ON p.id = pl.purchase_id
|
|
||||||
LEFT JOIN party pa ON pa.id = p.supplier_id
|
|
||||||
WHERE p.tenant_id=? AND pl.item_id=?`
|
|
||||||
if (supplierId !== undefined) { sql += ` AND p.supplier_id=?`; args.push(supplierId) }
|
|
||||||
sql += ` ORDER BY p.created_at_wall DESC LIMIT 3`
|
|
||||||
return db.prepare(sql).all(...args) as Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listPurchases(db: DB, tenantId: string): Record<string, unknown>[] {
|
|
||||||
return db.prepare(
|
|
||||||
`SELECT pu.*, pa.name AS supplier_name,
|
|
||||||
(SELECT COUNT(*) FROM purchase_line pl WHERE pl.purchase_id = pu.id) AS line_count
|
|
||||||
FROM purchase pu LEFT JOIN party pa ON pa.id = pu.supplier_id
|
|
||||||
WHERE pu.tenant_id=? ORDER BY pu.created_at_wall DESC LIMIT 300`,
|
|
||||||
).all(tenantId) as Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
@ -1,307 +0,0 @@
|
|||||||
import {
|
|
||||||
CREDIT_NOTE_SEQ_WIDTH, creditNotePrefix, formatDocNo, fyOf, uuidv7, type BillLine, type BillTotals,
|
|
||||||
} from '@sims/domain'
|
|
||||||
import { computeReturn, type ReturnLineRequest } from '@sims/billing-engine'
|
|
||||||
import type { DB } from './db'
|
|
||||||
import { PAYMENT_MODES, assertBusinessDate } from './validate'
|
|
||||||
import { writeAudit } from './repos'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns / credit notes — the money/commit half of the SALE_RETURN document. This rides the
|
|
||||||
* same trust boundary the red-team hardened (docs/18): it anchors EVERYTHING to OUR stored
|
|
||||||
* original bill, never the client. Prices, tax rates and the CGST/SGST/IGST split come from the
|
|
||||||
* original bill's payload, so a return reverses tax at the rate on the bill (R5/R10). The refund
|
|
||||||
* is computed server-side and re-verified against any client-declared total (409 on mismatch,
|
|
||||||
* mirroring commitBill). Over-return is blocked. Everything lands in ONE transaction (R17): the
|
|
||||||
* immutable SALE_RETURN doc, a per-counter credit-note series number, stock ADDED BACK, a refund
|
|
||||||
* leg, a dormant outbox row, and a hash-chained audit row.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Tag an error with an HTTP status + a safe client code (the error handler never leaks a stack). */
|
|
||||||
const httpErr = (status: number, message: string, code: string): Error =>
|
|
||||||
Object.assign(new Error(message), { status, code })
|
|
||||||
|
|
||||||
interface BillRow {
|
|
||||||
id: string; tenant_id: string; store_id: string; counter_id: string
|
|
||||||
doc_no: string; business_date: string; cashier_id: string
|
|
||||||
customer_id: string | null; buyer_gstin: string | null; place_of_supply: string | null
|
|
||||||
payload: string; created_at_wall: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoredPayload {
|
|
||||||
lines: BillLine[]
|
|
||||||
payments?: { mode: string; amountPaise: number }[]
|
|
||||||
totals: BillTotals
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load an original SALE bill for the tenant (never cross-tenant, never a credit note), or 400. */
|
|
||||||
function loadOriginalSale(db: DB, tenantId: string, billId: string): { row: BillRow; payload: StoredPayload } {
|
|
||||||
const row = db.prepare(
|
|
||||||
`SELECT * FROM bill WHERE id=? AND tenant_id=? AND doc_type='SALE'`,
|
|
||||||
).get(billId, tenantId) as BillRow | undefined
|
|
||||||
if (row === undefined) {
|
|
||||||
throw httpErr(400, 'Original bill not found for this store (E-1320)', 'E-1320')
|
|
||||||
}
|
|
||||||
let payload: StoredPayload
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(row.payload) as StoredPayload
|
|
||||||
} catch {
|
|
||||||
throw httpErr(400, 'Original bill has no stored line detail to return (E-1320)', 'E-1320')
|
|
||||||
}
|
|
||||||
if (!Array.isArray(payload.lines) || payload.lines.length === 0) {
|
|
||||||
throw httpErr(400, 'Original bill has no returnable lines (E-1320)', 'E-1320')
|
|
||||||
}
|
|
||||||
return { row, payload }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much of each original line has ALREADY been credited by prior SALE_RETURN documents
|
|
||||||
* referencing this bill — summed per original line index (each credit note's payload lines
|
|
||||||
* carry `originalLineIndex`). This is the returnable-quantity ledger: returned-so-far + a new
|
|
||||||
* return's qty must never exceed the quantity originally sold.
|
|
||||||
*/
|
|
||||||
function returnedByIndex(db: DB, tenantId: string, originalBillId: string): Map<number, number> {
|
|
||||||
const rows = db.prepare(
|
|
||||||
`SELECT payload FROM bill WHERE tenant_id=? AND ref_doc_id=? AND doc_type='SALE_RETURN'`,
|
|
||||||
).all(tenantId, originalBillId) as { payload: string }[]
|
|
||||||
const m = new Map<number, number>()
|
|
||||||
for (const r of rows) {
|
|
||||||
let p: { lines?: { originalLineIndex?: number; qty?: number }[] }
|
|
||||||
try { p = JSON.parse(r.payload) as typeof p } catch { continue }
|
|
||||||
for (const l of p.lines ?? []) {
|
|
||||||
if (typeof l.originalLineIndex === 'number' && typeof l.qty === 'number') {
|
|
||||||
m.set(l.originalLineIndex, (m.get(l.originalLineIndex) ?? 0) + l.qty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReturnableLine {
|
|
||||||
lineIndex: number
|
|
||||||
itemId: string; name: string; hsn: string; unitCode: string
|
|
||||||
unitPricePaise: number; taxRateBp: number; lineTotalPaise: number
|
|
||||||
soldQty: number; returnedQty: number; returnableQty: number
|
|
||||||
batchId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReturnableBill {
|
|
||||||
id: string; docNo: string; businessDate: string
|
|
||||||
customerId?: string; customerName?: string
|
|
||||||
lines: ReturnableLine[]
|
|
||||||
/** The raw original bill lines, so the POS can run the SHARED computeReturn for a live
|
|
||||||
* refund preview that matches the server to the paisa (R5). */
|
|
||||||
originalLines: BillLine[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The original bill's lines annotated with sold / already-returned / still-returnable quantities
|
|
||||||
* — drives the POS return screen and the back-office return detail. Read-only; the authoritative
|
|
||||||
* returnable check is re-run inside commitReturn against the same ledger.
|
|
||||||
*/
|
|
||||||
export function returnableLines(db: DB, tenantId: string, billId: string): ReturnableBill {
|
|
||||||
const { row, payload } = loadOriginalSale(db, tenantId, billId)
|
|
||||||
const returned = returnedByIndex(db, tenantId, billId)
|
|
||||||
const customer = row.customer_id !== null
|
|
||||||
? db.prepare(`SELECT name FROM party WHERE id=? AND tenant_id=?`).get(row.customer_id, tenantId) as { name: string } | undefined
|
|
||||||
: undefined
|
|
||||||
const lines: ReturnableLine[] = payload.lines.map((o, i) => {
|
|
||||||
const returnedQty = returned.get(i) ?? 0
|
|
||||||
return {
|
|
||||||
lineIndex: i, itemId: o.itemId, name: o.name, hsn: o.hsn, unitCode: o.unitCode,
|
|
||||||
unitPricePaise: o.unitPricePaise, taxRateBp: o.taxRateBp, lineTotalPaise: o.lineTotalPaise,
|
|
||||||
soldQty: o.qty, returnedQty, returnableQty: o.qty - returnedQty,
|
|
||||||
...(o.batchId !== undefined ? { batchId: o.batchId } : {}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
id: row.id, docNo: row.doc_no, businessDate: row.business_date,
|
|
||||||
...(row.customer_id !== null ? { customerId: row.customer_id } : {}),
|
|
||||||
...(customer !== undefined ? { customerName: customer.name } : {}),
|
|
||||||
lines, originalLines: payload.lines,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommitReturnInput {
|
|
||||||
tenantId: string; storeId: string; counterId: string
|
|
||||||
cashierId: string; shiftId: string; businessDate: string
|
|
||||||
originalBillId: string
|
|
||||||
lines: ReturnLineRequest[]
|
|
||||||
reason: string
|
|
||||||
refundMode: string
|
|
||||||
/** If present, must equal the SERVER-computed refund or the commit is rejected 409 (E-1325). */
|
|
||||||
clientRefundPaise?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commitReturn(
|
|
||||||
db: DB, input: CommitReturnInput,
|
|
||||||
): { id: string; docNo: string; refundPaise: number } {
|
|
||||||
assertBusinessDate(input.businessDate)
|
|
||||||
if (!PAYMENT_MODES.has(input.refundMode)) throw httpErr(400, 'Unknown refund mode (E-1326)', 'E-1326')
|
|
||||||
|
|
||||||
// Tenant-scope the store/counter exactly like commitBill (M5): unknown-for-tenant → 400 E-1110.
|
|
||||||
const store = db.prepare(`SELECT * FROM store WHERE id=? AND tenant_id=?`).get(input.storeId, input.tenantId) as { code: string } | undefined
|
|
||||||
const counter = db.prepare(`SELECT * FROM counter WHERE id=? AND tenant_id=?`).get(input.counterId, input.tenantId) as { code: string } | undefined
|
|
||||||
if (store === undefined || counter === undefined) {
|
|
||||||
throw httpErr(400, 'Unknown store or counter for this tenant (E-1110)', 'E-1110')
|
|
||||||
}
|
|
||||||
const committer = db.prepare(`SELECT display_name FROM app_user WHERE id=? AND tenant_id=?`).get(input.cashierId, input.tenantId) as { display_name: string } | undefined
|
|
||||||
if (committer === undefined) throw httpErr(403, 'Unknown cashier (E-6103)', 'E-6103')
|
|
||||||
|
|
||||||
// Anchor to OUR original bill (cross-tenant / nonexistent / non-sale → 400 E-1320).
|
|
||||||
const { row: orig, payload } = loadOriginalSale(db, input.tenantId, input.originalBillId)
|
|
||||||
const returned = returnedByIndex(db, input.tenantId, input.originalBillId)
|
|
||||||
|
|
||||||
if (!Array.isArray(input.lines) || input.lines.length === 0) {
|
|
||||||
throw httpErr(400, 'Select at least one line to return (E-1322)', 'E-1322')
|
|
||||||
}
|
|
||||||
// Validate each requested line against the original: a real, unique index; a positive qty;
|
|
||||||
// a whole quantity on a whole-unit line; and returned-so-far + this qty ≤ sold (over-return).
|
|
||||||
const seen = new Set<number>()
|
|
||||||
const requested: ReturnLineRequest[] = input.lines.map((r) => {
|
|
||||||
const lineIndex = r.lineIndex
|
|
||||||
if (!Number.isInteger(lineIndex) || lineIndex < 0 || lineIndex >= payload.lines.length) {
|
|
||||||
throw httpErr(400, 'Return references a line that is not on the bill (E-1321)', 'E-1321')
|
|
||||||
}
|
|
||||||
if (seen.has(lineIndex)) throw httpErr(400, 'A line can appear only once in a return (E-1321)', 'E-1321')
|
|
||||||
seen.add(lineIndex)
|
|
||||||
const o = payload.lines[lineIndex]!
|
|
||||||
const qty = r.qty
|
|
||||||
if (typeof qty !== 'number' || !Number.isFinite(qty) || qty <= 0) {
|
|
||||||
throw httpErr(400, `Return quantity for "${o.name}" must be positive (E-1323)`, 'E-1323')
|
|
||||||
}
|
|
||||||
// The original line qty tells us the unit shape: a whole-unit item was sold as an integer,
|
|
||||||
// so it must be returned whole; a weighed (KG) line was sold fractional and may be partial.
|
|
||||||
if (Number.isInteger(o.qty) && !Number.isInteger(qty)) {
|
|
||||||
throw httpErr(400, `"${o.name}" is sold whole — return a whole quantity (E-1323)`, 'E-1323')
|
|
||||||
}
|
|
||||||
const already = returned.get(lineIndex) ?? 0
|
|
||||||
if (already + qty > o.qty) {
|
|
||||||
throw httpErr(400, `Cannot return ${qty} of "${o.name}" — only ${o.qty - already} left to return (E-1324)`, 'E-1324')
|
|
||||||
}
|
|
||||||
return { lineIndex, qty }
|
|
||||||
})
|
|
||||||
|
|
||||||
// The refund is computed by the SHARED engine from the ORIGINAL bill's amounts (R5). The client
|
|
||||||
// is never trusted for it: if it declared a total, it must match to the paisa.
|
|
||||||
const computed = computeReturn(payload.lines, requested, { roundToRupee: true })
|
|
||||||
if (input.clientRefundPaise !== undefined && input.clientRefundPaise !== computed.totals.payablePaise) {
|
|
||||||
throw httpErr(409, 'Refund total does not match the original bill — refresh and retry (E-1325)', 'E-1325')
|
|
||||||
}
|
|
||||||
|
|
||||||
const reason = typeof input.reason === 'string' && input.reason.trim() !== ''
|
|
||||||
? input.reason.trim().slice(0, 60) : 'Unspecified'
|
|
||||||
const id = uuidv7()
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const fy = fyOf(input.businessDate)
|
|
||||||
const t = computed.totals
|
|
||||||
|
|
||||||
const txn = db.transaction(() => {
|
|
||||||
// Per-counter, per-FY credit-note series, allocated inside the commit transaction — the same
|
|
||||||
// gap-free, offline-safe pattern as the sale series, on its own CN-prefixed doc_type.
|
|
||||||
let series = db.prepare(
|
|
||||||
`SELECT prefix, next_seq FROM doc_series WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE_RETURN' AND fy=?`,
|
|
||||||
).get(input.tenantId, input.storeId, input.counterId, fy) as { prefix: string; next_seq: number } | undefined
|
|
||||||
if (series === undefined) {
|
|
||||||
series = { prefix: creditNotePrefix(store.code, counter.code, fy), next_seq: 1 }
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO doc_series (tenant_id, store_id, counter_id, doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 'SALE_RETURN', ?, ?, 1)`,
|
|
||||||
).run(input.tenantId, input.storeId, input.counterId, fy, series.prefix)
|
|
||||||
}
|
|
||||||
const docNo = formatDocNo(series.prefix, series.next_seq, CREDIT_NOTE_SEQ_WIDTH)
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE doc_series SET next_seq = next_seq + 1 WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE_RETURN' AND fy=?`,
|
|
||||||
).run(input.tenantId, input.storeId, input.counterId, fy)
|
|
||||||
|
|
||||||
const storedPayload = JSON.stringify({
|
|
||||||
lines: computed.lines,
|
|
||||||
// The refund leg: the amount paid back, in the chosen tender.
|
|
||||||
payments: [{ mode: input.refundMode, amountPaise: t.payablePaise }],
|
|
||||||
totals: t, reason, refundMode: input.refundMode, originalDocNo: orig.doc_no,
|
|
||||||
})
|
|
||||||
// The immutable SALE_RETURN document: its own row, refDocId → the original bill, the buyer +
|
|
||||||
// place-of-supply snapshotted FROM THE ORIGINAL (so the reversal is attributed to the same
|
|
||||||
// place of supply the sale used).
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO bill (id, tenant_id, store_id, counter_id, doc_type, doc_no, business_date, cashier_id, shift_id, customer_id, ref_doc_id,
|
|
||||||
gross_paise, discount_paise, taxable_paise, cgst_paise, sgst_paise, igst_paise, cess_paise, round_off_paise, payable_paise, savings_paise,
|
|
||||||
buyer_gstin, place_of_supply, client_id, payload, created_at_wall)
|
|
||||||
VALUES (?, ?, ?, ?, 'SALE_RETURN', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
id, input.tenantId, input.storeId, input.counterId, docNo, input.businessDate,
|
|
||||||
input.cashierId, input.shiftId, orig.customer_id, input.originalBillId,
|
|
||||||
t.grossPaise, t.discountPaise, t.taxablePaise, t.cgstPaise, t.sgstPaise,
|
|
||||||
t.igstPaise, t.cessPaise, t.roundOffPaise, t.payablePaise, t.savingsVsMrpPaise,
|
|
||||||
orig.buyer_gstin, orig.place_of_supply, null, storedPayload, now,
|
|
||||||
)
|
|
||||||
// Stock is ADDED BACK (positive qty_delta) so on-hand derives correctly (R7); each movement
|
|
||||||
// carries the ORIGINAL line's batch id (from our stored payload, not the client) so a
|
|
||||||
// batch-tracked return credits the exact lot it was sold from.
|
|
||||||
const move = db.prepare(
|
|
||||||
`INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, batch_id, business_date, created_at_wall)
|
|
||||||
VALUES (?, ?, ?, ?, ?, 'SALE_RETURN', ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
for (const l of computed.lines) {
|
|
||||||
move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, l.batchId ?? null, input.businessDate, now)
|
|
||||||
}
|
|
||||||
// Dormant outbox row (D14/R15), same transaction.
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO outbox (op_id, tenant_id, device_id, device_seq, doc_type, doc_id, envelope, created_at_wall, lamport)
|
|
||||||
VALUES (?, ?, 'store-server', 0, 'SALE_RETURN', ?, ?, ?, 0)`,
|
|
||||||
).run(uuidv7(), input.tenantId, id, storedPayload, now)
|
|
||||||
// Hash-chained audit row (H2/R17), in the same transaction.
|
|
||||||
writeAudit(db, input.tenantId, input.cashierId, 'RETURN_COMMIT', 'bill', id,
|
|
||||||
{ originalDocNo: orig.doc_no, originalBillId: input.originalBillId },
|
|
||||||
{ docNo, refundPaise: t.payablePaise, reason, refundMode: input.refundMode })
|
|
||||||
return docNo
|
|
||||||
})
|
|
||||||
|
|
||||||
const docNo = txn()
|
|
||||||
return { id, docNo, refundPaise: t.payablePaise }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List credit notes (SALE_RETURN docs), tenant-scoped, newest-first, paginated. The original
|
|
||||||
* sale's doc no is joined in for the "against bill" column. A caller without REPORT_VIEW is
|
|
||||||
* pinned to their own credit notes (cashierId set) — the same scoping the sales list uses.
|
|
||||||
*/
|
|
||||||
export function listReturns(
|
|
||||||
db: DB, tenantId: string, limit = 100, offset = 0, cashierId?: string,
|
|
||||||
): Record<string, unknown>[] {
|
|
||||||
let sql = `SELECT b.*, u.display_name AS cashier_name, p.name AS customer_name, o.doc_no AS original_doc_no
|
|
||||||
FROM bill b
|
|
||||||
LEFT JOIN app_user u ON u.id=b.cashier_id
|
|
||||||
LEFT JOIN party p ON p.id=b.customer_id
|
|
||||||
LEFT JOIN bill o ON o.id=b.ref_doc_id
|
|
||||||
WHERE b.tenant_id=? AND b.doc_type='SALE_RETURN'`
|
|
||||||
const args: unknown[] = [tenantId]
|
|
||||||
if (cashierId !== undefined) { sql += ` AND b.cashier_id=?`; args.push(cashierId) }
|
|
||||||
sql += ` ORDER BY b.created_at_wall DESC LIMIT ? OFFSET ?`
|
|
||||||
args.push(limit, offset)
|
|
||||||
return db.prepare(sql).all(...args) as Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find one original SALE bill by its exact doc no (the POS return lookup). Tenant-scoped and
|
|
||||||
* SALE-only — a targeted lookup for issuing a return, not the cross-counter enumeration the
|
|
||||||
* bills-list scoping guards against.
|
|
||||||
*/
|
|
||||||
export function lookupBillByDocNo(db: DB, tenantId: string, docNo: string): Record<string, unknown> | undefined {
|
|
||||||
return db.prepare(
|
|
||||||
`SELECT b.id, b.doc_no, b.business_date, b.payable_paise, b.customer_id,
|
|
||||||
u.display_name AS cashier_name, p.name AS customer_name
|
|
||||||
FROM bill b LEFT JOIN app_user u ON u.id=b.cashier_id LEFT JOIN party p ON p.id=b.customer_id
|
|
||||||
WHERE b.tenant_id=? AND b.doc_type='SALE' AND b.doc_no=?`,
|
|
||||||
).get(tenantId, docNo) as Record<string, unknown> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recent original SALE bills for customers matching a phone (the POS phone-lookup return path). */
|
|
||||||
export function lookupBillsByPhone(db: DB, tenantId: string, phone: string, limit = 20): Record<string, unknown>[] {
|
|
||||||
return db.prepare(
|
|
||||||
`SELECT b.id, b.doc_no, b.business_date, b.payable_paise,
|
|
||||||
u.display_name AS cashier_name, p.name AS customer_name
|
|
||||||
FROM bill b JOIN party p ON p.id=b.customer_id LEFT JOIN app_user u ON u.id=b.cashier_id
|
|
||||||
WHERE b.tenant_id=? AND b.doc_type='SALE' AND p.phone LIKE ?
|
|
||||||
ORDER BY b.created_at_wall DESC LIMIT ?`,
|
|
||||||
).all(tenantId, `%${phone}%`, limit) as Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
@ -1,721 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto'
|
|
||||||
import {
|
|
||||||
deriveSupply, formatDocNo, fyOf, seriesPrefix, uuidv7, validateGstin,
|
|
||||||
type Item, type Party, type RoleCode,
|
|
||||||
} from '@sims/domain'
|
|
||||||
import { can, type ActionCode } from '@sims/auth'
|
|
||||||
import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine'
|
|
||||||
import type { DB } from './db'
|
|
||||||
import { PAYMENT_MODES, assertBusinessDate } from './validate'
|
|
||||||
|
|
||||||
/** Tag an error with an HTTP status + a safe client code (the error handler never leaks a stack). */
|
|
||||||
const httpErr = (status: number, message: string, code: string): Error =>
|
|
||||||
Object.assign(new Error(message), { status, code })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repositories — the portable data layer (D12 guardrail). Plain functions over
|
|
||||||
* a handle; the Oracle adapter reimplements these signatures, nothing above
|
|
||||||
* this file changes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------- items ----------
|
|
||||||
interface ItemRow {
|
|
||||||
id: string; tenant_id: string; code: string; name: string; name_secondary: string | null
|
|
||||||
hsn: string; tax_class_code: string; unit_code: string; unit_decimals: number
|
|
||||||
mrp_paise: number | null; sale_price_paise: number; price_includes_tax: number
|
|
||||||
barcodes: string; batch_tracked: number; status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function toItem(r: ItemRow): Item {
|
|
||||||
return {
|
|
||||||
id: r.id, tenantId: r.tenant_id, code: r.code, name: r.name,
|
|
||||||
...(r.name_secondary !== null ? { nameSecondary: r.name_secondary } : {}),
|
|
||||||
hsn: r.hsn, taxClassCode: r.tax_class_code,
|
|
||||||
unit: { code: r.unit_code, decimals: r.unit_decimals },
|
|
||||||
...(r.mrp_paise !== null ? { mrpPaise: r.mrp_paise } : {}),
|
|
||||||
salePricePaise: r.sale_price_paise, priceIncludesTax: r.price_includes_tax === 1,
|
|
||||||
barcodes: JSON.parse(r.barcodes) as string[], batchTracked: r.batch_tracked === 1,
|
|
||||||
status: r.status as Item['status'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The item master. `all` returns every non-inactive item unpaginated — the POS
|
|
||||||
* cache path, which must never be silently truncated (R13); every other path
|
|
||||||
* paginates via limit/offset (default 5000, generous but bounded).
|
|
||||||
*/
|
|
||||||
export function listItems(db: DB, tenantId: string, opts: {
|
|
||||||
query?: string; all?: boolean; limit?: number; offset?: number
|
|
||||||
} = {}): Item[] {
|
|
||||||
const { query, all = false } = opts
|
|
||||||
const limit = opts.limit ?? 5000
|
|
||||||
const offset = opts.offset ?? 0
|
|
||||||
let rows: ItemRow[]
|
|
||||||
if (all) {
|
|
||||||
rows = db.prepare(
|
|
||||||
`SELECT * FROM item WHERE tenant_id=? AND status <> 'inactive' ORDER BY name`,
|
|
||||||
).all(tenantId) as ItemRow[]
|
|
||||||
} else if (query !== undefined && query !== '') {
|
|
||||||
rows = db.prepare(
|
|
||||||
`SELECT * FROM item WHERE tenant_id=? AND (name LIKE ? OR code=? OR barcodes LIKE ?) ORDER BY name LIMIT ? OFFSET ?`,
|
|
||||||
).all(tenantId, `%${query}%`, query, `%"${query}"%`, limit, offset) as ItemRow[]
|
|
||||||
} else {
|
|
||||||
rows = db.prepare(`SELECT * FROM item WHERE tenant_id=? ORDER BY name LIMIT ? OFFSET ?`).all(tenantId, limit, offset) as ItemRow[]
|
|
||||||
}
|
|
||||||
return rows.map(toItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createItem(db: DB, tenantId: string, input: {
|
|
||||||
code: string; name: string; hsn: string; taxClassCode: string
|
|
||||||
unitCode: string; unitDecimals: number; mrpPaise?: number
|
|
||||||
salePricePaise: number; barcode?: string; status?: string
|
|
||||||
}): Item {
|
|
||||||
const id = uuidv7()
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO item (id, tenant_id, code, name, hsn, tax_class_code, unit_code, unit_decimals, mrp_paise, sale_price_paise, price_includes_tax, barcodes, status)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
id, tenantId, input.code, input.name, input.hsn, input.taxClassCode,
|
|
||||||
input.unitCode, input.unitDecimals, input.mrpPaise ?? null, input.salePricePaise,
|
|
||||||
JSON.stringify(input.barcode !== undefined && input.barcode !== '' ? [input.barcode] : []),
|
|
||||||
input.status ?? 'active',
|
|
||||||
)
|
|
||||||
return toItem(db.prepare(`SELECT * FROM item WHERE id=?`).get(id) as ItemRow)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- parties ----------
|
|
||||||
export function listParties(db: DB, tenantId: string, kind?: string, phone?: string): Party[] {
|
|
||||||
let rows: unknown[]
|
|
||||||
if (phone !== undefined && phone !== '') {
|
|
||||||
rows = db.prepare(`SELECT * FROM party WHERE tenant_id=? AND phone LIKE ?`).all(tenantId, `%${phone}%`)
|
|
||||||
} else if (kind !== undefined) {
|
|
||||||
rows = db.prepare(`SELECT * FROM party WHERE tenant_id=? AND kind=? ORDER BY name`).all(tenantId, kind)
|
|
||||||
} else {
|
|
||||||
rows = db.prepare(`SELECT * FROM party WHERE tenant_id=? ORDER BY name`).all(tenantId)
|
|
||||||
}
|
|
||||||
return (rows as Record<string, unknown>[]).map((r) => ({
|
|
||||||
id: r['id'] as string, tenantId: r['tenant_id'] as string, code: r['code'] as string,
|
|
||||||
name: r['name'] as string, kind: r['kind'] as Party['kind'],
|
|
||||||
...(r['phone'] !== null ? { phone: r['phone'] as string } : {}),
|
|
||||||
...(r['gstin'] !== null ? { gstin: r['gstin'] as string } : {}),
|
|
||||||
...(r['state_code'] !== null ? { stateCode: r['state_code'] as string } : {}),
|
|
||||||
...(r['credit_limit_paise'] !== null ? { creditLimitPaise: r['credit_limit_paise'] as number } : {}),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCustomer(
|
|
||||||
db: DB, tenantId: string, name: string, phone: string,
|
|
||||||
opts: { gstin?: string; stateCode?: string } = {},
|
|
||||||
): Party {
|
|
||||||
// B2B identity is validated server-side (never trust the client): a bad GSTIN
|
|
||||||
// checksum is rejected loudly; the state code is derived from the GSTIN's first
|
|
||||||
// two digits when the caller didn't pass one.
|
|
||||||
let gstin: string | null = null
|
|
||||||
let stateCode: string | null = opts.stateCode !== undefined && opts.stateCode !== '' ? opts.stateCode : null
|
|
||||||
if (opts.gstin !== undefined && opts.gstin !== '') {
|
|
||||||
const v = validateGstin(opts.gstin)
|
|
||||||
if (!v.ok) {
|
|
||||||
throw Object.assign(
|
|
||||||
new Error(v.reason === 'checksum'
|
|
||||||
? 'GSTIN checksum is invalid — re-check the number (E-1401)'
|
|
||||||
: 'GSTIN format is invalid — 15 chars, e.g. 27ABCDE1234F1Z5 (E-1402)'),
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
gstin = opts.gstin.toUpperCase().trim()
|
|
||||||
if (stateCode === null) stateCode = v.stateCode ?? null
|
|
||||||
}
|
|
||||||
const id = uuidv7()
|
|
||||||
const n = (db.prepare(`SELECT COUNT(*) AS n FROM party WHERE tenant_id=?`).get(tenantId) as { n: number }).n
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO party (id, tenant_id, code, name, kind, phone, gstin, state_code) VALUES (?, ?, ?, ?, 'customer', ?, ?, ?)`,
|
|
||||||
).run(id, tenantId, `CU${String(n + 1).padStart(3, '0')}`, name, phone, gstin, stateCode)
|
|
||||||
return listParties(db, tenantId, undefined, phone)[0]!
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- tax ----------
|
|
||||||
export function listTaxClasses(db: DB): TaxClassRow[] {
|
|
||||||
const rows = db.prepare(`SELECT * FROM tax_class`).all() as Record<string, unknown>[]
|
|
||||||
return rows.map((r) => ({
|
|
||||||
classCode: r['class_code'] as string,
|
|
||||||
ratePctBp: r['rate_pct_bp'] as number,
|
|
||||||
cessPctBp: r['cess_pct_bp'] as number,
|
|
||||||
effectiveFrom: r['effective_from'] as string,
|
|
||||||
...(r['effective_to'] !== null ? { effectiveTo: r['effective_to'] as string } : {}),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- bills ----------
|
|
||||||
export interface CommitBillInput {
|
|
||||||
tenantId: string; storeId: string; counterId: string
|
|
||||||
cashierId: string; shiftId: string; customerId?: string
|
|
||||||
businessDate: string
|
|
||||||
lines: LineInput[]
|
|
||||||
payments: { mode: string; amountPaise: number; reference?: string }[]
|
|
||||||
clientPayablePaise: number
|
|
||||||
/**
|
|
||||||
* Supervisor-approved counter overrides (spec P0-2 / P1-3): each price / qty /
|
|
||||||
* discount deviation that raised the supervisor PIN gate rides along with the
|
|
||||||
* bill so its audit row is written in the SAME transaction as the commit (R17).
|
|
||||||
* `approvalId` is the single-use token minted by /auth/verify-pin — the server
|
|
||||||
* redeems it and reads the approver identity from ITS OWN row, never trusting a
|
|
||||||
* client-supplied user id (SEC fix).
|
|
||||||
*/
|
|
||||||
overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount' | 'batch'; before: number; after: number; approvalId?: string }[]
|
|
||||||
/**
|
|
||||||
* S4 offline queue: the client-generated bill id (uuidv7). Present only when a
|
|
||||||
* bill made while the store-server was unreachable is drained on reconnect; it
|
|
||||||
* is the idempotency key — a re-drained payload returns the already-assigned doc
|
|
||||||
* no instead of committing a second bill (02-ARCHITECTURE §3 outbox pattern).
|
|
||||||
*/
|
|
||||||
clientId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const OVERRIDE_ACTION = {
|
|
||||||
price: 'PRICE_OVERRIDE', qty: 'QTY_OVERRIDE', discount: 'DISCOUNT_OVERRIDE',
|
|
||||||
// S5: a supervisor approved selling an EXPIRED batch (E-1310 gate). before/after
|
|
||||||
// are 0 — the audited fact is "who approved the expired-batch sale for this item".
|
|
||||||
batch: 'BATCH_OVERRIDE',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/** Roles allowed to approve a counter override (09-UX §5.5). */
|
|
||||||
const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner'])
|
|
||||||
/** A supervisor-approval token is valid for five minutes after it is minted. */
|
|
||||||
const APPROVAL_TTL_MS = 5 * 60 * 1000
|
|
||||||
|
|
||||||
/** The bound context a supervisor is approving — re-verified at redeem (H4). */
|
|
||||||
export interface OverrideContext {
|
|
||||||
itemId: string
|
|
||||||
kind: 'price' | 'qty' | 'discount' | 'batch'
|
|
||||||
beforePaise: number
|
|
||||||
afterPaise: number
|
|
||||||
/** The cashier the approval was granted to (advisory; not enforced at redeem). */
|
|
||||||
cashierUserId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mint a single-use supervisor-approval token (SEC fix), BOUND to the exact override it
|
|
||||||
* authorises (H4 fix). Called by /auth/verify-pin once a supervisor PIN is proven; the
|
|
||||||
* bill commit later redeems it and re-verifies item/kind/before/after against the line's
|
|
||||||
* server-computed deviation, so a token minted for one line can never move to another.
|
|
||||||
*/
|
|
||||||
export function createOverrideApproval(
|
|
||||||
db: DB, tenantId: string, approverUserId: string, ctx: OverrideContext,
|
|
||||||
): string {
|
|
||||||
const id = uuidv7()
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO override_approval (id, tenant_id, approver_user_id, created_at, used, item_id, kind, before_paise, after_paise, cashier_user_id)
|
|
||||||
VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(id, tenantId, approverUserId, new Date().toISOString(), ctx.itemId, ctx.kind, ctx.beforePaise, ctx.afterPaise, ctx.cashierUserId ?? null)
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The discount cap (basis points) beyond which a line discount is a deviation needing a
|
|
||||||
* supervisor override. A dated tenant setting (R9/R10): tenant row wins over the system
|
|
||||||
* default; 1000 bp (10%) if neither is present.
|
|
||||||
*/
|
|
||||||
function resolveDiscountCapBp(db: DB, tenantId: string): number {
|
|
||||||
const row = db.prepare(
|
|
||||||
`SELECT value FROM setting WHERE key='discount.capBp'
|
|
||||||
AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?))
|
|
||||||
ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`,
|
|
||||||
).get(tenantId) as { value: string } | undefined
|
|
||||||
const n = row !== undefined ? Number(row.value) : NaN
|
|
||||||
return Number.isFinite(n) && n >= 0 ? n : 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The tax class the server FORCES onto a cashier-created draft item (C3 fix). A cashier
|
|
||||||
* quick-adding an unknown good must not be able to choose ZERO (or any class) to evade GST;
|
|
||||||
* the server ignores the client's taxClassCode and assigns the store's standard rate. A
|
|
||||||
* dated tenant setting (R9/R10): tenant row wins over the system default; 'GST18' if neither
|
|
||||||
* is present. A MASTER_EDIT role (manager/owner) keeps its chosen class — back office
|
|
||||||
* completes the draft's real HSN/tax class later.
|
|
||||||
*/
|
|
||||||
export function resolveDefaultTaxClass(db: DB, tenantId: string): string {
|
|
||||||
const row = db.prepare(
|
|
||||||
`SELECT value FROM setting WHERE key='catalog.defaultTaxClass'
|
|
||||||
AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?))
|
|
||||||
ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`,
|
|
||||||
).get(tenantId) as { value: string } | undefined
|
|
||||||
return row !== undefined && row.value !== '' ? row.value : 'GST18'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The price ceiling (paise) a cashier-created draft may carry (C2 bound). A quick-add's
|
|
||||||
* price IS its sell price (inherent to the flow), so a genuinely expensive unknown must go
|
|
||||||
* through a supervisor / back office rather than be self-priced arbitrarily at the counter.
|
|
||||||
* A dated tenant setting (R9/R10): tenant row wins over the system default; ₹5,000
|
|
||||||
* (500000 paise) if neither is present.
|
|
||||||
*/
|
|
||||||
export function resolveDraftMaxPricePaise(db: DB, tenantId: string): number {
|
|
||||||
const row = db.prepare(
|
|
||||||
`SELECT value FROM setting WHERE key='catalog.draftMaxPricePaise'
|
|
||||||
AND (scope_type='system' OR (scope_type='tenant' AND scope_id=?))
|
|
||||||
ORDER BY CASE scope_type WHEN 'tenant' THEN 0 ELSE 1 END LIMIT 1`,
|
|
||||||
).get(tenantId) as { value: string } | undefined
|
|
||||||
const n = row !== undefined ? Number(row.value) : NaN
|
|
||||||
return Number.isFinite(n) && n > 0 ? n : 500_000
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commitBill(
|
|
||||||
db: DB, input: CommitBillInput,
|
|
||||||
): { id: string; docNo: string; payablePaise: number; duplicate?: boolean } {
|
|
||||||
// Idempotent drain (S4): if this client bill id already committed, return the
|
|
||||||
// doc no it was assigned — never allocate a new series number or a second stock
|
|
||||||
// movement. The partial unique index on (tenant_id, client_id) is the backstop.
|
|
||||||
if (input.clientId !== undefined) {
|
|
||||||
const existing = db.prepare(
|
|
||||||
`SELECT id, doc_no, payable_paise FROM bill WHERE tenant_id=? AND client_id=?`,
|
|
||||||
).get(input.tenantId, input.clientId) as { id: string; doc_no: string; payable_paise: number } | undefined
|
|
||||||
if (existing !== undefined) {
|
|
||||||
return { id: existing.id, docNo: existing.doc_no, payablePaise: existing.payable_paise, duplicate: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The business date is a real, sanely-bounded ISO day (H6) even when a caller reaches
|
|
||||||
// commitBill directly — a garbage date must never mint a doc series or postdate a bill.
|
|
||||||
assertBusinessDate(input.businessDate)
|
|
||||||
const store = db.prepare(`SELECT * FROM store WHERE id=? AND tenant_id=?`).get(input.storeId, input.tenantId) as { code: string; state_code: string } | undefined
|
|
||||||
const counter = db.prepare(`SELECT * FROM counter WHERE id=? AND tenant_id=?`).get(input.counterId, input.tenantId) as { code: string } | undefined
|
|
||||||
if (store === undefined || counter === undefined) {
|
|
||||||
throw httpErr(400, 'Unknown store or counter for this tenant (E-1110)', 'E-1110')
|
|
||||||
}
|
|
||||||
const rates = listTaxClasses(db)
|
|
||||||
|
|
||||||
// The committing user's authority (C2/H7): owner/manager/supervisor may self-authorise a
|
|
||||||
// deviation from the master; everyone else needs a bound supervisor token per deviation.
|
|
||||||
const committer = db.prepare(
|
|
||||||
`SELECT display_name, roles FROM app_user WHERE id=? AND tenant_id=?`,
|
|
||||||
).get(input.cashierId, input.tenantId) as { display_name: string; roles: string } | undefined
|
|
||||||
if (committer === undefined) throw httpErr(403, 'Unknown cashier (E-6103)', 'E-6103')
|
|
||||||
const committerRoles = JSON.parse(committer.roles) as RoleCode[]
|
|
||||||
const committerCan = (action: ActionCode): boolean => can({ roles: committerRoles, active: true }, action)
|
|
||||||
const capBp = resolveDiscountCapBp(db, input.tenantId)
|
|
||||||
|
|
||||||
// Place of supply is decided from OUR copy of the customer row, never the
|
|
||||||
// client's claim: a B2B buyer's GSTIN state drives IGST vs CGST/SGST (R5).
|
|
||||||
const customer = input.customerId !== undefined
|
|
||||||
? db.prepare(`SELECT gstin, state_code FROM party WHERE id=? AND tenant_id=?`).get(input.customerId, input.tenantId) as { gstin: string | null; state_code: string | null } | undefined
|
|
||||||
: undefined
|
|
||||||
const supply = deriveSupply(
|
|
||||||
customer !== undefined
|
|
||||||
? { ...(customer.gstin !== null ? { gstin: customer.gstin } : {}), ...(customer.state_code !== null ? { stateCode: customer.state_code } : {}) }
|
|
||||||
: undefined,
|
|
||||||
store.state_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Item-master anchoring (C2, C3, H5, M8) ----
|
|
||||||
// Every line is rebuilt from OUR item row: tax class, HSN, MRP, unit and the inclusive-
|
|
||||||
// price flag come from the master; the client's values for them are ignored. The unit
|
|
||||||
// price is accepted only when it equals the master sale price — any other value is a
|
|
||||||
// deviation that must be authorised below (a bound token, or the committer's own
|
|
||||||
// authority). A price above MRP is a hard block even with a token (E-1301).
|
|
||||||
interface Deviation { kind: 'price' | 'discount'; itemId: string; before: number; after: number; name: string }
|
|
||||||
const deviations: Deviation[] = []
|
|
||||||
// Draft-item sales (C2/C3 review trail): a cashier-authored quick-add item is sold on the
|
|
||||||
// same session that created it, so its only master-side trace is ITEM_CREATE. Every line
|
|
||||||
// whose master row is still status='draft' is flagged here and audited DRAFT_SALE in the
|
|
||||||
// commit transaction below, so a self-authored-item sale is never invisible in a review.
|
|
||||||
interface DraftSale { itemId: string; name: string; pricePaise: number }
|
|
||||||
const draftSales: DraftSale[] = []
|
|
||||||
const anchored: LineInput[] = input.lines.map((line) => {
|
|
||||||
const item = db.prepare(`SELECT * FROM item WHERE id=? AND tenant_id=?`).get(line.itemId, input.tenantId) as ItemRow | undefined
|
|
||||||
if (item === undefined) {
|
|
||||||
throw httpErr(400, 'A billed item is not in this store catalogue (E-1105)', 'E-1105')
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(line.qty) || line.qty <= 0 || line.qty > 1e12) {
|
|
||||||
throw httpErr(400, `Quantity is out of range for "${item.name}" (E-1104)`, 'E-1104')
|
|
||||||
}
|
|
||||||
if (item.unit_decimals === 0 && !Number.isInteger(line.qty)) {
|
|
||||||
throw httpErr(400, `"${item.name}" is sold whole — a fractional quantity is not allowed (E-1104)`, 'E-1104')
|
|
||||||
}
|
|
||||||
const effPrice = line.unitPricePaise
|
|
||||||
if (!Number.isInteger(effPrice) || effPrice < 0) {
|
|
||||||
throw httpErr(400, `Unit price is invalid for "${item.name}" (E-1103)`, 'E-1103')
|
|
||||||
}
|
|
||||||
if (item.status === 'draft') {
|
|
||||||
draftSales.push({ itemId: line.itemId, name: item.name, pricePaise: effPrice })
|
|
||||||
}
|
|
||||||
const masterPrice = item.sale_price_paise
|
|
||||||
if (effPrice !== masterPrice) {
|
|
||||||
if (item.mrp_paise !== null && effPrice > item.mrp_paise) {
|
|
||||||
throw httpErr(400, `Price for "${item.name}" is above its MRP — not allowed (E-1301)`, 'E-1301')
|
|
||||||
}
|
|
||||||
deviations.push({ kind: 'price', itemId: line.itemId, before: masterPrice, after: effPrice, name: item.name })
|
|
||||||
}
|
|
||||||
if (line.discount !== undefined) {
|
|
||||||
const gross = Math.round(effPrice * line.qty)
|
|
||||||
const discPaise = line.discount.kind === 'percentBp'
|
|
||||||
? Math.round((gross * line.discount.value) / 10_000)
|
|
||||||
: line.discount.paise
|
|
||||||
if (discPaise > Math.floor((gross * capBp) / 10_000)) {
|
|
||||||
deviations.push({ kind: 'discount', itemId: line.itemId, before: 0, after: discPaise, name: item.name })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
itemId: line.itemId,
|
|
||||||
name: item.name,
|
|
||||||
hsn: item.hsn,
|
|
||||||
qty: line.qty,
|
|
||||||
unitCode: item.unit_code,
|
|
||||||
unitPricePaise: effPrice,
|
|
||||||
priceIncludesTax: item.price_includes_tax === 1,
|
|
||||||
taxClassCode: item.tax_class_code,
|
|
||||||
...(item.mrp_paise !== null ? { mrpPaise: item.mrp_paise } : {}),
|
|
||||||
...(line.discount !== undefined ? { discount: line.discount } : {}),
|
|
||||||
...(line.batchId !== undefined ? { batchId: line.batchId } : {}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Same engine everywhere, now on the ANCHORED lines: the server recomputes from its own
|
|
||||||
// prices/tax and must still agree with the client to the paisa (R5) — a client that
|
|
||||||
// under-computed GST (C3) or forged a total is rejected here.
|
|
||||||
const computed = computeBill(anchored, {
|
|
||||||
businessDate: input.businessDate,
|
|
||||||
supplyStateCode: store.state_code,
|
|
||||||
placeOfSupplyStateCode: supply.placeOfSupplyStateCode,
|
|
||||||
roundToRupee: true,
|
|
||||||
}, rates)
|
|
||||||
if (computed.totals.payablePaise !== input.clientPayablePaise) {
|
|
||||||
throw httpErr(409, 'Bill total does not match the item master — refresh and retry (E-1302)', 'E-1302')
|
|
||||||
}
|
|
||||||
// Payments (M8): every leg a positive amount with an allowlisted mode, summing to payable.
|
|
||||||
for (const p of input.payments) {
|
|
||||||
if (!Number.isInteger(p.amountPaise) || p.amountPaise <= 0) {
|
|
||||||
throw httpErr(400, 'Every payment must be a positive amount (E-1107)', 'E-1107')
|
|
||||||
}
|
|
||||||
if (!PAYMENT_MODES.has(p.mode)) throw httpErr(400, 'Unknown payment mode (E-1108)', 'E-1108')
|
|
||||||
}
|
|
||||||
const paid = input.payments.reduce((a, p) => a + p.amountPaise, 0)
|
|
||||||
if (paid !== computed.totals.payablePaise) {
|
|
||||||
throw httpErr(400, 'Payments do not sum to the payable amount (E-1109)', 'E-1109')
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = uuidv7()
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const fy = fyOf(input.businessDate)
|
|
||||||
|
|
||||||
// Redeem a bound supervisor token for a specific server-computed deviation (H4): the
|
|
||||||
// token must exist, be unused, be < 5 min old, belong to an approver role, AND match the
|
|
||||||
// exact item/kind/before/after — then it is marked used in THIS transaction and the
|
|
||||||
// approver identity is read from the server's own row, never the client's.
|
|
||||||
const redeemToken = (
|
|
||||||
approvalId: string, kind: Deviation['kind'] | 'batch', itemId: string, before: number, after: number,
|
|
||||||
): { name: string; userId: string } => {
|
|
||||||
const appr = db.prepare(
|
|
||||||
`SELECT approver_user_id, created_at, used, item_id, kind, before_paise, after_paise FROM override_approval WHERE id=? AND tenant_id=?`,
|
|
||||||
).get(approvalId, input.tenantId) as {
|
|
||||||
approver_user_id: string; created_at: string; used: number
|
|
||||||
item_id: string | null; kind: string | null; before_paise: number | null; after_paise: number | null
|
|
||||||
} | undefined
|
|
||||||
if (appr === undefined) throw httpErr(403, 'Override approval is not recognised (E-6107)', 'E-6107')
|
|
||||||
if (appr.used !== 0) throw httpErr(403, 'Override approval was already used (E-6108)', 'E-6108')
|
|
||||||
if (Date.now() - Date.parse(appr.created_at) > APPROVAL_TTL_MS) {
|
|
||||||
throw httpErr(403, 'Override approval has expired — re-approve at the counter (E-6109)', 'E-6109')
|
|
||||||
}
|
|
||||||
if (appr.item_id !== itemId || appr.kind !== kind || appr.before_paise !== before || appr.after_paise !== after) {
|
|
||||||
throw httpErr(403, 'Override approval does not match this line — re-approve at the counter (E-6108)', 'E-6108')
|
|
||||||
}
|
|
||||||
const approver = db.prepare(
|
|
||||||
`SELECT display_name, roles FROM app_user WHERE id=? AND tenant_id=?`,
|
|
||||||
).get(appr.approver_user_id, input.tenantId) as { display_name: string; roles: string } | undefined
|
|
||||||
if (approver === undefined || !(JSON.parse(approver.roles) as string[]).some((role) => APPROVER_ROLES.has(role))) {
|
|
||||||
throw httpErr(403, 'Override approver is not authorised (E-6110)', 'E-6110')
|
|
||||||
}
|
|
||||||
const redeemed = db.prepare(
|
|
||||||
`UPDATE override_approval SET used=1 WHERE id=? AND tenant_id=? AND used=0`,
|
|
||||||
).run(approvalId, input.tenantId)
|
|
||||||
if (redeemed.changes !== 1) throw httpErr(403, 'Override approval was already used (E-6108)', 'E-6108')
|
|
||||||
return { name: approver.display_name, userId: appr.approver_user_id }
|
|
||||||
}
|
|
||||||
|
|
||||||
const txn = db.transaction(() => {
|
|
||||||
// per-counter, per-FY series allocated inside the commit transaction
|
|
||||||
let series = db.prepare(
|
|
||||||
`SELECT * FROM doc_series WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE' AND fy=?`,
|
|
||||||
).get(input.tenantId, input.storeId, input.counterId, fy) as { prefix: string; next_seq: number } | undefined
|
|
||||||
if (series === undefined) {
|
|
||||||
series = { prefix: seriesPrefix(store.code, counter.code, fy), next_seq: 1 }
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO doc_series (tenant_id, store_id, counter_id, doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 'SALE', ?, ?, 1)`,
|
|
||||||
).run(input.tenantId, input.storeId, input.counterId, fy, series.prefix)
|
|
||||||
}
|
|
||||||
const docNo = formatDocNo(series.prefix, series.next_seq)
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE doc_series SET next_seq = next_seq + 1 WHERE tenant_id=? AND store_id=? AND counter_id=? AND doc_type='SALE' AND fy=?`,
|
|
||||||
).run(input.tenantId, input.storeId, input.counterId, fy)
|
|
||||||
|
|
||||||
const t = computed.totals
|
|
||||||
const payload = JSON.stringify({ lines: computed.lines, payments: input.payments, totals: t })
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO bill (id, tenant_id, store_id, counter_id, doc_type, doc_no, business_date, cashier_id, shift_id, customer_id, ref_doc_id,
|
|
||||||
gross_paise, discount_paise, taxable_paise, cgst_paise, sgst_paise, igst_paise, cess_paise, round_off_paise, payable_paise, savings_paise,
|
|
||||||
buyer_gstin, place_of_supply, client_id, payload, created_at_wall) VALUES (?, ?, ?, ?, 'SALE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
id, input.tenantId, input.storeId, input.counterId, docNo, input.businessDate,
|
|
||||||
input.cashierId, input.shiftId, input.customerId ?? null,
|
|
||||||
t.grossPaise, t.discountPaise, t.taxablePaise, t.cgstPaise, t.sgstPaise,
|
|
||||||
t.igstPaise, t.cessPaise, t.roundOffPaise, t.payablePaise, t.savingsVsMrpPaise,
|
|
||||||
supply.buyerGstin ?? null, supply.placeOfSupplyStateCode, input.clientId ?? null,
|
|
||||||
payload, now,
|
|
||||||
)
|
|
||||||
const move = db.prepare(
|
|
||||||
`INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, batch_id, business_date, created_at_wall)
|
|
||||||
VALUES (?, ?, ?, ?, ?, 'SALE', ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
for (const line of computed.lines) {
|
|
||||||
// S5: a batch-picked line carries a batchId; the sale movement is attributed to
|
|
||||||
// it so per-batch on-hand derives correctly (R7). Trust nothing — the batch must
|
|
||||||
// belong to THIS item and store, or the whole commit rolls back (E-1311).
|
|
||||||
let batchId: string | null = null
|
|
||||||
if (line.batchId !== undefined) {
|
|
||||||
const belongs = db.prepare(
|
|
||||||
`SELECT 1 FROM batch WHERE id=? AND tenant_id=? AND store_id=? AND item_id=?`,
|
|
||||||
).get(line.batchId, input.tenantId, input.storeId, line.itemId)
|
|
||||||
if (belongs === undefined) {
|
|
||||||
throw Object.assign(new Error(`Batch does not belong to "${line.name}" at this store (E-1311)`), { status: 400 })
|
|
||||||
}
|
|
||||||
batchId = line.batchId
|
|
||||||
}
|
|
||||||
move.run(uuidv7(), input.tenantId, input.storeId, line.itemId, -line.qty, id, batchId, input.businessDate, now)
|
|
||||||
}
|
|
||||||
// dormant outbox row (D14): written in the same transaction, drained by nothing yet
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO outbox (op_id, tenant_id, device_id, device_seq, doc_type, doc_id, envelope, created_at_wall, lamport)
|
|
||||||
VALUES (?, ?, 'store-server', 0, 'SALE', ?, ?, ?, 0)`,
|
|
||||||
).run(uuidv7(), input.tenantId, id, payload, now)
|
|
||||||
writeAudit(db, input.tenantId, input.cashierId, 'BILL_COMMIT', 'bill', id, undefined, { docNo, payable: t.payablePaise })
|
|
||||||
|
|
||||||
// Flag every draft-item line for back-office review (C2/C3): one DRAFT_SALE row per
|
|
||||||
// draft sold, in THIS transaction, carrying the item + the price it sold at — so a
|
|
||||||
// cashier who quick-adds and sells a self-priced item leaves an explicit trail.
|
|
||||||
for (const d of draftSales) {
|
|
||||||
writeAudit(db, input.tenantId, input.cashierId, 'DRAFT_SALE', 'bill', id,
|
|
||||||
undefined, { itemId: d.itemId, name: d.name, pricePaise: d.pricePaise, docNo })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authorise + audit every SERVER-detected price/discount deviation (C2, H4, H7). Each
|
|
||||||
// deviation is approved by a bound token that matches it exactly, OR — for owner /
|
|
||||||
// manager / supervisor — the committer's own authority; either way an override audit
|
|
||||||
// row is written in-transaction (R17). An unauthorised deviation throws and the whole
|
|
||||||
// commit rolls back, so a mispriced line can never reach the ledger unaudited.
|
|
||||||
const usedOverride = new Set<number>()
|
|
||||||
for (const dev of deviations) {
|
|
||||||
const action: ActionCode = dev.kind === 'price' ? 'PRICE_OVERRIDE' : 'DISCOUNT_OVER_CAP'
|
|
||||||
const auditAction = dev.kind === 'price' ? OVERRIDE_ACTION.price : OVERRIDE_ACTION.discount
|
|
||||||
const idx = (input.overrides ?? []).findIndex((o, i) =>
|
|
||||||
!usedOverride.has(i) && o.approvalId !== undefined && o.kind === dev.kind && o.itemId === dev.itemId && o.after === dev.after)
|
|
||||||
if (idx >= 0) {
|
|
||||||
usedOverride.add(idx)
|
|
||||||
const approvalId = input.overrides![idx]!.approvalId!
|
|
||||||
const appr = redeemToken(approvalId, dev.kind, dev.itemId, dev.before, dev.after)
|
|
||||||
writeAudit(db, input.tenantId, input.cashierId, auditAction, 'bill', id,
|
|
||||||
{ before: dev.before, itemId: dev.itemId },
|
|
||||||
{ after: dev.after, approvedBy: appr.name, approvedByUserId: appr.userId, approvalId })
|
|
||||||
} else if (committerCan(action)) {
|
|
||||||
writeAudit(db, input.tenantId, input.cashierId, auditAction, 'bill', id,
|
|
||||||
{ before: dev.before, itemId: dev.itemId },
|
|
||||||
{ after: dev.after, approvedBy: committer.display_name, approvedByUserId: input.cashierId, selfAuthorized: true })
|
|
||||||
} else {
|
|
||||||
const code = dev.kind === 'price' ? 'E-1302' : 'E-1303'
|
|
||||||
throw httpErr(403, `"${dev.name}": ${dev.kind} change needs supervisor approval (${code})`, code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client-declared batch overrides (expired-batch sale, S5): no server-side price
|
|
||||||
// deviation to detect, but the approval is bound + audited the same way (H4/H7).
|
|
||||||
for (const o of input.overrides ?? []) {
|
|
||||||
if (o.kind !== 'batch') continue
|
|
||||||
if (o.approvalId !== undefined) {
|
|
||||||
const appr = redeemToken(o.approvalId, 'batch', o.itemId, 0, 0)
|
|
||||||
writeAudit(db, input.tenantId, input.cashierId, OVERRIDE_ACTION.batch, 'bill', id,
|
|
||||||
{ before: 0, itemId: o.itemId },
|
|
||||||
{ after: 0, approvedBy: appr.name, approvedByUserId: appr.userId, approvalId: o.approvalId })
|
|
||||||
} else if (committerCan('PRICE_OVERRIDE')) {
|
|
||||||
writeAudit(db, input.tenantId, input.cashierId, OVERRIDE_ACTION.batch, 'bill', id,
|
|
||||||
{ before: 0, itemId: o.itemId },
|
|
||||||
{ after: 0, approvedBy: committer.display_name, approvedByUserId: input.cashierId, selfAuthorized: true })
|
|
||||||
} else {
|
|
||||||
throw httpErr(403, 'Expired-batch sale needs supervisor approval (E-1310)', 'E-1310')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return docNo
|
|
||||||
})
|
|
||||||
|
|
||||||
const docNo = txn()
|
|
||||||
return { id, docNo, payablePaise: computed.totals.payablePaise }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listBills(
|
|
||||||
db: DB, tenantId: string, date?: string, counterId?: string, limit = 100, offset = 0,
|
|
||||||
cashierId?: string,
|
|
||||||
): Record<string, unknown>[] {
|
|
||||||
// SALE only — credit notes (SALE_RETURN) are their own document and live on the Returns list,
|
|
||||||
// never mixed into the sales list or the POS day history.
|
|
||||||
let sql = `SELECT b.*, u.display_name AS cashier_name, p.name AS customer_name
|
|
||||||
FROM bill b LEFT JOIN app_user u ON u.id=b.cashier_id LEFT JOIN party p ON p.id=b.customer_id
|
|
||||||
WHERE b.tenant_id=? AND b.doc_type='SALE'`
|
|
||||||
const args: unknown[] = [tenantId]
|
|
||||||
if (date !== undefined) { sql += ` AND b.business_date=?`; args.push(date) }
|
|
||||||
if (counterId !== undefined) { sql += ` AND b.counter_id=?`; args.push(counterId) }
|
|
||||||
// Cross-counter read scope (residual LOW/MED): when the caller lacks REPORT_VIEW the route
|
|
||||||
// pins this to the session's own user id, so a cashier sees only their OWN bills — never
|
|
||||||
// every counter's/cashier's. A REPORT_VIEW/manager role passes undefined and keeps the
|
|
||||||
// full store-wide view.
|
|
||||||
if (cashierId !== undefined) { sql += ` AND b.cashier_id=?`; args.push(cashierId) }
|
|
||||||
sql += ` ORDER BY b.created_at_wall DESC LIMIT ? OFFSET ?`
|
|
||||||
args.push(limit, offset)
|
|
||||||
return db.prepare(sql).all(...args) as Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- stock ----------
|
|
||||||
export function stockView(db: DB, tenantId: string, storeId: string): Record<string, unknown>[] {
|
|
||||||
return db.prepare(
|
|
||||||
`SELECT i.code, i.name, i.unit_code, i.sale_price_paise, i.batch_tracked,
|
|
||||||
COALESCE(SUM(m.qty_delta), 0) AS on_hand
|
|
||||||
FROM item i LEFT JOIN stock_movement m ON m.item_id=i.id AND m.store_id=?
|
|
||||||
WHERE i.tenant_id=? GROUP BY i.id ORDER BY i.name`,
|
|
||||||
).all(storeId, tenantId) as Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- batches (S5) ----------
|
|
||||||
/**
|
|
||||||
* Find a batch by its unique key (tenant, store, item, batch_no); create it if new.
|
|
||||||
* Returns the batch id either way. Called at purchase commit. When an expiry is
|
|
||||||
* supplied and the existing lot had none (or a different date), the date is filled
|
|
||||||
* in — a later invoice can date a batch first captured undated. Client-generated
|
|
||||||
* ids per R14. Callers run this inside their own transaction.
|
|
||||||
*/
|
|
||||||
export function upsertBatch(
|
|
||||||
db: DB, tenantId: string, storeId: string, itemId: string, batchNo: string, expiryDate?: string,
|
|
||||||
): string {
|
|
||||||
const existing = db.prepare(
|
|
||||||
`SELECT id, expiry_date FROM batch WHERE tenant_id=? AND store_id=? AND item_id=? AND batch_no=?`,
|
|
||||||
).get(tenantId, storeId, itemId, batchNo) as { id: string; expiry_date: string | null } | undefined
|
|
||||||
if (existing !== undefined) {
|
|
||||||
if (expiryDate !== undefined && expiryDate !== '' && expiryDate !== existing.expiry_date) {
|
|
||||||
db.prepare(`UPDATE batch SET expiry_date=? WHERE id=?`).run(expiryDate, existing.id)
|
|
||||||
}
|
|
||||||
return existing.id
|
|
||||||
}
|
|
||||||
const id = uuidv7()
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO batch (id, tenant_id, store_id, item_id, batch_no, expiry_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(id, tenantId, storeId, itemId, batchNo, expiryDate !== undefined && expiryDate !== '' ? expiryDate : null, new Date().toISOString())
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every batch at a store with derived on-hand > 0 (R7) — the POS caches this keyed
|
|
||||||
* by item to drive the FEFO picker. Stock is SUM(movements) per batch, never stored.
|
|
||||||
*/
|
|
||||||
export function batchesForStore(db: DB, tenantId: string, storeId: string): Record<string, unknown>[] {
|
|
||||||
return db.prepare(
|
|
||||||
`SELECT b.id, b.item_id, b.batch_no, b.expiry_date,
|
|
||||||
COALESCE(SUM(m.qty_delta), 0) AS on_hand
|
|
||||||
FROM batch b LEFT JOIN stock_movement m ON m.batch_id=b.id AND m.store_id=b.store_id
|
|
||||||
WHERE b.tenant_id=? AND b.store_id=?
|
|
||||||
GROUP BY b.id HAVING on_hand > 0
|
|
||||||
ORDER BY b.item_id, b.expiry_date`,
|
|
||||||
).all(tenantId, storeId) as Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The expiry dashboard feed: each in-stock batch with its item, derived on-hand and
|
|
||||||
* value at the item's current sale price. Buckets (≤7d, ≤30d, expired) are computed
|
|
||||||
* by the page against the business date so the numbers stay live.
|
|
||||||
*/
|
|
||||||
export function expiryView(db: DB, tenantId: string, storeId: string): Record<string, unknown>[] {
|
|
||||||
return db.prepare(
|
|
||||||
`SELECT b.id, b.item_id, b.batch_no, b.expiry_date,
|
|
||||||
i.code, i.name, i.unit_code, i.sale_price_paise,
|
|
||||||
COALESCE(SUM(m.qty_delta), 0) AS on_hand
|
|
||||||
FROM batch b
|
|
||||||
JOIN item i ON i.id=b.item_id
|
|
||||||
LEFT JOIN stock_movement m ON m.batch_id=b.id AND m.store_id=b.store_id
|
|
||||||
WHERE b.tenant_id=? AND b.store_id=?
|
|
||||||
GROUP BY b.id HAVING on_hand > 0
|
|
||||||
ORDER BY b.expiry_date IS NULL, b.expiry_date`,
|
|
||||||
).all(tenantId, storeId) as Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- audit (H2: tamper-evident hash chain) ----------
|
|
||||||
/**
|
|
||||||
* The canonical bytes an audit entry hashes over (H2). A JSON array is an injective,
|
|
||||||
* delimiter-safe encoding: `null` stays distinct from an empty string, and every field is
|
|
||||||
* escaped, so no two different entries can serialise to the same bytes. `beforeJson`/`afterJson`
|
|
||||||
* are the EXACT stored column values (a JSON string or SQL NULL→JS null) so verify recomputes
|
|
||||||
* byte-identically to what writeAudit hashed. The chain is per-tenant: prevHash is the previous
|
|
||||||
* entry's entry_hash for the tenant, genesis '' for the first.
|
|
||||||
*/
|
|
||||||
function auditEntryHash(
|
|
||||||
prevHash: string, tenantId: string, atWall: string, userId: string, action: string,
|
|
||||||
entity: string, entityId: string, beforeJson: string | null, afterJson: string | null,
|
|
||||||
): string {
|
|
||||||
const canonical = JSON.stringify([prevHash, tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson])
|
|
||||||
return createHash('sha256').update(canonical, 'utf8').digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append one audit entry, linked into the tenant's hash chain (H2). The previous entry is the
|
|
||||||
* tenant's last-inserted row (SQLite rowid order = insertion order under the single-writer
|
|
||||||
* model); verifyAuditChain recomputes in the same order. Called inside the existing commit
|
|
||||||
* transactions — the prev-row read sees earlier in-transaction inserts on the same connection,
|
|
||||||
* so several audit rows written in one commit chain correctly among themselves.
|
|
||||||
*/
|
|
||||||
export function writeAudit(
|
|
||||||
db: DB, tenantId: string, userId: string, action: string,
|
|
||||||
entity: string, entityId: string, before?: unknown, after?: unknown,
|
|
||||||
): void {
|
|
||||||
const prev = db.prepare(
|
|
||||||
`SELECT entry_hash FROM audit_log WHERE tenant_id=? ORDER BY rowid DESC LIMIT 1`,
|
|
||||||
).get(tenantId) as { entry_hash: string | null } | undefined
|
|
||||||
const prevHash = prev?.entry_hash ?? ''
|
|
||||||
const atWall = new Date().toISOString()
|
|
||||||
const beforeJson = before === undefined ? null : JSON.stringify(before)
|
|
||||||
const afterJson = after === undefined ? null : JSON.stringify(after)
|
|
||||||
const entryHash = auditEntryHash(prevHash, tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson)
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(uuidv7(), tenantId, atWall, userId, action, entity, entityId, beforeJson, afterJson, prevHash, entryHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recompute the tenant's audit hash chain and report the first entry that fails (H2). Returns
|
|
||||||
* `{ ok: true }` when every row's stored prev_hash matches the running chain AND its entry_hash
|
|
||||||
* matches a recompute over its own content; otherwise `{ ok: false, brokenAtId }` names the first
|
|
||||||
* broken row. This makes any out-of-band tamper VISIBLE: an UPDATE changes a row's content so its
|
|
||||||
* entry_hash no longer recomputes; a DELETE breaks the next row's prev_hash link; a naive INSERT
|
|
||||||
* lands with a hash the chain can't reproduce. It is tamper-EVIDENCE, not prevention — an attacker
|
|
||||||
* with file write access could re-chain forward (the chain is unsigned); sealing the file is H3.
|
|
||||||
*/
|
|
||||||
export function verifyAuditChain(db: DB, tenantId: string): { ok: boolean; brokenAtId?: string } {
|
|
||||||
const rows = db.prepare(
|
|
||||||
`SELECT id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash
|
|
||||||
FROM audit_log WHERE tenant_id=? ORDER BY rowid ASC`,
|
|
||||||
).all(tenantId) as {
|
|
||||||
id: string; at_wall: string; user_id: string; action: string; entity: string
|
|
||||||
entity_id: string; before_json: string | null; after_json: string | null
|
|
||||||
prev_hash: string | null; entry_hash: string | null
|
|
||||||
}[]
|
|
||||||
let prevHash = ''
|
|
||||||
for (const r of rows) {
|
|
||||||
if ((r.prev_hash ?? '') !== prevHash) return { ok: false, brokenAtId: r.id }
|
|
||||||
const expected = auditEntryHash(
|
|
||||||
prevHash, tenantId, r.at_wall, r.user_id, r.action, r.entity, r.entity_id, r.before_json, r.after_json,
|
|
||||||
)
|
|
||||||
if (expected !== r.entry_hash) return { ok: false, brokenAtId: r.id }
|
|
||||||
prevHash = r.entry_hash
|
|
||||||
}
|
|
||||||
return { ok: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listAudit(db: DB, tenantId: string, limit = 100, offset = 0): Record<string, unknown>[] {
|
|
||||||
return db.prepare(
|
|
||||||
`SELECT a.*, u.display_name AS user_name FROM audit_log a
|
|
||||||
LEFT JOIN app_user u ON u.id=a.user_id
|
|
||||||
WHERE a.tenant_id=? ORDER BY a.at_wall DESC LIMIT ? OFFSET ?`,
|
|
||||||
).all(tenantId, limit, offset) as Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
import { hashPin } from '@sims/auth'
|
|
||||||
import { uuidv7 } from '@sims/domain'
|
|
||||||
import type { DB } from './db'
|
|
||||||
|
|
||||||
/** Idempotent demo seed — real installs get the onboarding wizard instead. */
|
|
||||||
export function seedIfEmpty(db: DB): void {
|
|
||||||
const has = db.prepare('SELECT COUNT(*) AS n FROM tenant').get() as { n: number }
|
|
||||||
if (has.n > 0) return
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO tenant (id, code, name, gstin, gst_scheme, fy_start_month, created_at)
|
|
||||||
VALUES ('t1', 'MEGA', 'MegaMart Retail', '32ABCDE1234F1Z5', 'regular', 4, ?)`,
|
|
||||||
).run(now)
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO store (id, tenant_id, code, name, state_code) VALUES ('s1', 't1', 'ST1', 'MegaMart T.Nagar', '32')`,
|
|
||||||
).run()
|
|
||||||
db.prepare(`INSERT INTO counter (id, tenant_id, store_id, code, name) VALUES ('c1','t1','s1','C1','Counter 1')`).run()
|
|
||||||
db.prepare(`INSERT INTO counter (id, tenant_id, store_id, code, name) VALUES ('c2','t1','s1','C2','Counter 2')`).run()
|
|
||||||
|
|
||||||
const user = db.prepare(
|
|
||||||
`INSERT INTO app_user (id, tenant_id, username, display_name, roles, store_ids, language, pin_salt, pin_hash, pw_salt, pw_hash)
|
|
||||||
VALUES (?, 't1', ?, ?, ?, '"all"', ?, ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
const mk = (id: string, uname: string, name: string, roles: string[], lang: string, pin?: string, pw?: string) => {
|
|
||||||
const p = pin !== undefined ? hashPin(pin) : undefined
|
|
||||||
const w = pw !== undefined ? hashPin(pw) : undefined
|
|
||||||
user.run(id, uname, name, JSON.stringify(roles), lang, p?.salt ?? null, p?.hash ?? null, w?.salt ?? null, w?.hash ?? null)
|
|
||||||
}
|
|
||||||
mk('u1', 'ramesh', 'Ramesh', ['cashier'], 'ml', '4728')
|
|
||||||
mk('u2', 'suresh', 'Suresh', ['supervisor'], 'ml', '8265')
|
|
||||||
mk('u3', 'thomas', 'Thomas', ['owner'], 'en', '9174', 'sims')
|
|
||||||
mk('u4', 'divya', 'Divya', ['cashier'], 'ta', '3591')
|
|
||||||
|
|
||||||
const tax = db.prepare(
|
|
||||||
`INSERT INTO tax_class (class_code, rate_pct_bp, cess_pct_bp, effective_from) VALUES (?, ?, ?, '2017-07-01')`,
|
|
||||||
)
|
|
||||||
for (const [c, r, x] of [['ZERO', 0, 0], ['GST5', 500, 0], ['GST12', 1200, 0], ['GST18', 1800, 0], ['DEMERIT', 2800, 1200]] as const) {
|
|
||||||
tax.run(c, r, x)
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = db.prepare(
|
|
||||||
`INSERT INTO item (id, tenant_id, code, name, hsn, tax_class_code, unit_code, unit_decimals, mrp_paise, sale_price_paise, price_includes_tax, barcodes)
|
|
||||||
VALUES (?, 't1', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
|
|
||||||
)
|
|
||||||
const rows: [string, string, string, string, string, number, number | null, number, string[]][] = [
|
|
||||||
['101', 'Aashirvaad Atta 5kg', '1101', 'ZERO', 'PCS', 0, 28_500, 27_000, ['8901063014357']],
|
|
||||||
['102', 'Tata Salt 1kg', '2501', 'ZERO', 'PCS', 0, 3_000, 2_800, ['8904063202016']],
|
|
||||||
['103', 'Surf Excel 1kg', '3402', 'GST18', 'PCS', 0, 15_000, 14_500, ['8901030704833']],
|
|
||||||
['104', 'Parle-G 800g', '1905', 'GST5', 'PCS', 0, 9_500, 9_000, ['8901063092730']],
|
|
||||||
['105', 'Maggi Noodles 12-pack', '1902', 'GST12', 'PCS', 0, 14_400, 14_000, ['8901058851298']],
|
|
||||||
['106', 'Coca-Cola 1.25L', '2202', 'DEMERIT', 'PCS', 0, 6_500, 6_500, ['8901764012341']],
|
|
||||||
['107', 'Amul Butter 500g', '0405', 'GST12', 'PCS', 0, 28_000, 27_500, ['8901262010337']],
|
|
||||||
['108', 'Carry Bag', '3923', 'GST18', 'PCS', 0, null, 500, []],
|
|
||||||
['42', 'Tomato (loose)', '0702', 'ZERO', 'KG', 3, null, 3_200, []],
|
|
||||||
['43', 'Onion (loose)', '0703', 'ZERO', 'KG', 3, null, 4_500, []],
|
|
||||||
['44', 'Potato (loose)', '0701', 'ZERO', 'KG', 3, null, 2_600, []],
|
|
||||||
]
|
|
||||||
for (const [code, name, hsn, cls, unit, dec, mrp, price, barcodes] of rows) {
|
|
||||||
item.run(uuidv7(), code, name, hsn, cls, unit, dec, mrp, price, JSON.stringify(barcodes))
|
|
||||||
// opening stock so the Stock page has something honest to derive
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, business_date, created_at_wall)
|
|
||||||
SELECT ?, 't1', 's1', id, ?, 'OPENING', ?, ? FROM item WHERE tenant_id='t1' AND code=?`,
|
|
||||||
).run(uuidv7(), unit === 'KG' ? 50 : 120, now.slice(0, 10), now, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
const party = db.prepare(
|
|
||||||
`INSERT INTO party (id, tenant_id, code, name, kind, phone, gstin, state_code, credit_limit_paise)
|
|
||||||
VALUES (?, 't1', ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
party.run(uuidv7(), 'CU001', 'Lakshmi', 'customer', '9840012345', null, '32', 500_000)
|
|
||||||
party.run(uuidv7(), 'CU002', 'Joseph', 'customer', '9847055210', null, '32', null)
|
|
||||||
party.run(uuidv7(), 'SU001', 'HUL Distributor', 'supplier', '9847100001', '32AABCH1234K1Z6', '32', null)
|
|
||||||
party.run(uuidv7(), 'SU002', 'Amul Agency', 'supplier', '9847100002', '32AAACA5678L1Z2', '32', null)
|
|
||||||
|
|
||||||
const setting = db.prepare(
|
|
||||||
`INSERT INTO setting (scope_type, scope_id, key, value) VALUES (?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
setting.run('system', '', 'gst.roundToRupee', 'true')
|
|
||||||
setting.run('system', '', 'discount.capBp', '1000')
|
|
||||||
setting.run('tenant', 't1', 'discount.capBp', '1500')
|
|
||||||
// Cashier quick-add draft guards (C2/C3): the tax class the server forces onto a
|
|
||||||
// cashier-created draft (never the client's — no ZERO-tax GST evasion), and the price
|
|
||||||
// ceiling above which a draft must go through a supervisor / back office. Tenant rows may
|
|
||||||
// override; the resolvers fall back to these same defaults if a row is missing.
|
|
||||||
setting.run('system', '', 'catalog.defaultTaxClass', 'GST18')
|
|
||||||
setting.run('system', '', 'catalog.draftMaxPricePaise', '500000')
|
|
||||||
// GST returns: the B2C-Large threshold (a B2C inter-state invoice ABOVE this is B2CL, else
|
|
||||||
// B2CS). Dated config (R10) — the classic ₹2,50,000; a tenant row overrides per period.
|
|
||||||
setting.run('system', '', 'gst.b2clThresholdPaise', '25000000')
|
|
||||||
|
|
||||||
db.prepare(`INSERT INTO message_catalog (code, channel, lang, template) VALUES ('BILL_ESHARE','whatsapp','en','Hi {name}, your bill of {amount} from {store}. Thank you!')`).run()
|
|
||||||
|
|
||||||
ensureBatchTrackingSeed(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* S5 demo batches. Idempotent and safe on ANY db — it runs both on a fresh seed and
|
|
||||||
* against the already-populated dev db (where seedIfEmpty is a no-op, so batches must
|
|
||||||
* be backfilled here). Marks the two demo perishables batch-tracked, then, if no batch
|
|
||||||
* exists yet, splits each item's opening stock into dated lots. Total on-hand is
|
|
||||||
* preserved (R7): the per-lot OPENING movements are offset by one negative un-batched
|
|
||||||
* OPENING adjustment per item, so re-attributing stock into lots never inflates the
|
|
||||||
* derived quantity. One lot per item is deliberately expired-with-stock to exercise the
|
|
||||||
* E-1310 gate and the dashboard's expired section.
|
|
||||||
*/
|
|
||||||
export function ensureBatchTrackingSeed(db: DB): void {
|
|
||||||
db.prepare(`UPDATE item SET batch_tracked=1 WHERE tenant_id='t1' AND code IN ('105','107')`).run()
|
|
||||||
const existing = (db.prepare(`SELECT COUNT(*) AS n FROM batch WHERE tenant_id='t1'`).get() as { n: number }).n
|
|
||||||
if (existing > 0) return
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const iso = (d: Date): string => d.toISOString().slice(0, 10)
|
|
||||||
const plusDays = (days: number): string => {
|
|
||||||
const d = new Date(now)
|
|
||||||
d.setDate(d.getDate() + days)
|
|
||||||
return iso(d)
|
|
||||||
}
|
|
||||||
// code → lots to create: batch_no, expiry offset in days (negative = already
|
|
||||||
// expired), opening qty. Quantities are re-attributed from the item's opening 120.
|
|
||||||
const plan: Record<string, { no: string; days: number; qty: number }[]> = {
|
|
||||||
// Amul Butter 500g (₹275): an expired lot WITH stock (proves the block + the
|
|
||||||
// expired-with-stock section), a ≤7-day lot, and a ~6-month lot.
|
|
||||||
'107': [
|
|
||||||
{ no: 'B-107X', days: -3, qty: 8 },
|
|
||||||
{ no: 'B-107A', days: 5, qty: 12 },
|
|
||||||
{ no: 'B-107B', days: 180, qty: 30 },
|
|
||||||
],
|
|
||||||
// Maggi 12-pack (₹140): a ≤7-day lot and a ~6-month lot.
|
|
||||||
'105': [
|
|
||||||
{ no: 'B-105A', days: 4, qty: 10 },
|
|
||||||
{ no: 'B-105B', days: 175, qty: 25 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const nowIso = now.toISOString()
|
|
||||||
const today = iso(now)
|
|
||||||
const insBatch = db.prepare(
|
|
||||||
`INSERT INTO batch (id, tenant_id, store_id, item_id, batch_no, expiry_date, created_at) VALUES (?, 't1', 's1', ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
const insMove = db.prepare(
|
|
||||||
`INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, batch_id, business_date, created_at_wall)
|
|
||||||
VALUES (?, 't1', 's1', ?, ?, 'OPENING', ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const [code, lots] of Object.entries(plan)) {
|
|
||||||
const item = db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string } | undefined
|
|
||||||
if (item === undefined) continue
|
|
||||||
let batched = 0
|
|
||||||
for (const lot of lots) {
|
|
||||||
const batchId = uuidv7()
|
|
||||||
insBatch.run(batchId, item.id, lot.no, plusDays(lot.days), nowIso)
|
|
||||||
insMove.run(uuidv7(), item.id, lot.qty, batchId, today, nowIso)
|
|
||||||
batched += lot.qty
|
|
||||||
}
|
|
||||||
// Offsetting un-batched OPENING adjustment: total on-hand stays exactly what it
|
|
||||||
// was (R7) — we split existing stock into lots, we never conjure new stock.
|
|
||||||
insMove.run(uuidv7(), item.id, -batched, null, today, nowIso)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* Session lifetime policy (red-team M4). Sessions were valid until the process restarted —
|
|
||||||
* a leaked bearer token never expired and a fired employee could not be cut off. Every
|
|
||||||
* session now carries a creation time and a last-seen time; `requireAuth` evicts it once it
|
|
||||||
* passes either bound. Pure + clock-injectable so the check unit-tests without waiting.
|
|
||||||
*/
|
|
||||||
export interface SessionTtl {
|
|
||||||
/** Hard cap from login, regardless of activity. */
|
|
||||||
absoluteMs: number
|
|
||||||
/** Idle cap since the last authenticated request. */
|
|
||||||
idleMs: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionTiming {
|
|
||||||
createdMs: number
|
|
||||||
lastSeenMs: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 12-hour absolute lifetime, 30-minute idle timeout — a trading shift with a lunch gap. */
|
|
||||||
export const DEFAULT_SESSION_TTL: SessionTtl = {
|
|
||||||
absoluteMs: 12 * 60 * 60 * 1000,
|
|
||||||
idleMs: 30 * 60 * 1000,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True once a session has outlived its absolute lifetime OR sat idle past the idle timeout. */
|
|
||||||
export function isSessionExpired(s: SessionTiming, now: number, ttl: SessionTtl): boolean {
|
|
||||||
if (now - s.createdMs >= ttl.absoluteMs) return true
|
|
||||||
if (now - s.lastSeenMs >= ttl.idleMs) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read TTL overrides from env (ms), else the defaults. Lets prod/tests tighten without code. */
|
|
||||||
export function sessionTtlFromEnv(env: NodeJS.ProcessEnv = process.env): SessionTtl {
|
|
||||||
const abs = Number(env['SIMS_SESSION_ABS_MS'])
|
|
||||||
const idle = Number(env['SIMS_SESSION_IDLE_MS'])
|
|
||||||
return {
|
|
||||||
absoluteMs: Number.isFinite(abs) && abs > 0 ? abs : DEFAULT_SESSION_TTL.absoluteMs,
|
|
||||||
idleMs: Number.isFinite(idle) && idle > 0 ? idle : DEFAULT_SESSION_TTL.idleMs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hand-rolled request validators (SEC / red-team M6, M7, M8, M12, H6). No new runtime
|
|
||||||
* deps by design (16-PROJECT-RULES: keep the slice lean) — small guards that each throw
|
|
||||||
* a `{ status: 400, code }` error with a SAFE, generic message. The Express error handler
|
|
||||||
* (server.ts) turns any un-tagged throw into a generic 500 with no stack, so a wrong type
|
|
||||||
* can never leak an install path again (M7).
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class ValidationError extends Error {
|
|
||||||
status = 400
|
|
||||||
code: string
|
|
||||||
constructor(message: string, code: string) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'ValidationError'
|
|
||||||
this.code = code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fail = (message: string, code = 'E-1001'): never => {
|
|
||||||
throw new ValidationError(message, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertString(v: unknown, field: string, code = 'E-1001'): string {
|
|
||||||
if (typeof v !== 'string' || v === '') fail(`${field} is required`, code)
|
|
||||||
return v as string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optString(v: unknown, field: string, code = 'E-1001'): string | undefined {
|
|
||||||
if (v === undefined || v === null) return undefined
|
|
||||||
if (typeof v !== 'string') fail(`${field} must be text`, code)
|
|
||||||
return v as string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertInt(v: unknown, field: string, code = 'E-1001'): number {
|
|
||||||
if (typeof v !== 'number' || !Number.isInteger(v)) fail(`${field} must be a whole number`, code)
|
|
||||||
return v as number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertNonNegInt(v: unknown, field: string, code = 'E-1001'): number {
|
|
||||||
const n = assertInt(v, field, code)
|
|
||||||
if (n < 0) fail(`${field} must not be negative`, code)
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertPositiveInt(v: unknown, field: string, code = 'E-1001'): number {
|
|
||||||
const n = assertInt(v, field, code)
|
|
||||||
if (n <= 0) fail(`${field} must be positive`, code)
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A finite, positive quantity, clamped well under 2^53 so ledger math never loses precision (M8). */
|
|
||||||
export function assertQty(v: unknown, field: string, code = 'E-1001'): number {
|
|
||||||
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) fail(`${field} must be a positive number`, code)
|
|
||||||
if ((v as number) > 1e12) fail(`${field} is implausibly large`, code)
|
|
||||||
return v as number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A paise amount: a finite integer within a sane ledger range (no NaN, no 2^53 blow-ups). */
|
|
||||||
export function assertPaise(v: unknown, field: string, code = 'E-1001'): number {
|
|
||||||
const n = assertInt(v, field, code)
|
|
||||||
if (n < 0) fail(`${field} must not be negative`, code)
|
|
||||||
if (n > 1e15) fail(`${field} is out of range`, code)
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An ISO YYYY-MM-DD business date within a sane window (H6): a real calendar date, not
|
|
||||||
* before 2015, not after tomorrow. Rejects "not-a-date", "2050-07-10", and NaN-FY inputs.
|
|
||||||
*/
|
|
||||||
export function assertBusinessDate(v: unknown, field = 'businessDate', code = 'E-1106'): string {
|
|
||||||
const s = assertString(v, field, code)
|
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) fail(`${field} must be an ISO date (YYYY-MM-DD)`, code)
|
|
||||||
const t = Date.parse(`${s}T00:00:00Z`)
|
|
||||||
if (Number.isNaN(t)) fail(`${field} is not a real date`, code)
|
|
||||||
// Round-trip to reject impossible days like 2026-02-31 that Date.parse would roll over.
|
|
||||||
if (new Date(t).toISOString().slice(0, 10) !== s) fail(`${field} is not a real date`, code)
|
|
||||||
const tomorrow = Date.now() + 36 * 60 * 60 * 1000 // today + ~1 day of slack for TZ
|
|
||||||
if (t > tomorrow) fail(`${field} is in the future`, code)
|
|
||||||
if (t < Date.parse('2015-01-01T00:00:00Z')) fail(`${field} is implausibly old`, code)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A GST return period 'YYYY-MM' within a sane window (a real month, 2017-07..next month). GST
|
|
||||||
* returns are filed monthly, so the period is the unit of a GSTR-1/3B run.
|
|
||||||
*/
|
|
||||||
export function assertPeriod(v: unknown, field = 'period', code = 'E-1330'): string {
|
|
||||||
const s = assertString(v, field, code)
|
|
||||||
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(s)) fail(`${field} must be a month (YYYY-MM)`, code)
|
|
||||||
// Not before GST (July 2017); not more than ~one month ahead of today.
|
|
||||||
if (s < '2017-07') fail(`${field} is before GST commenced`, code)
|
|
||||||
const now = new Date()
|
|
||||||
const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString().slice(0, 7)
|
|
||||||
if (s > next) fail(`${field} is in the future`, code)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertArray<T>(v: unknown, field: string, max: number, code = 'E-1001'): T[] {
|
|
||||||
if (!Array.isArray(v)) fail(`${field} must be a list`, code)
|
|
||||||
const a = v as T[]
|
|
||||||
if (a.length === 0) fail(`${field} must not be empty`, code)
|
|
||||||
if (a.length > max) fail(`${field} has too many entries (max ${max})`, code)
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clamp a client-supplied limit to [1, max] (M6/M13): NaN/negative/huge never dump a table. */
|
|
||||||
export function clampLimit(raw: unknown, def: number, max = 1000): number {
|
|
||||||
const n = Number(raw)
|
|
||||||
if (!Number.isFinite(n)) return def
|
|
||||||
return Math.min(Math.max(Math.floor(n), 1), max)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clamp a client-supplied offset to >= 0 (M6): NaN/negative floor to 0. */
|
|
||||||
export function clampOffset(raw: unknown): number {
|
|
||||||
const n = Number(raw)
|
|
||||||
if (!Number.isFinite(n) || n < 0) return 0
|
|
||||||
return Math.floor(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PAYMENT_MODES = new Set(['CASH', 'UPI', 'CARD', 'KHATA'])
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { openDb, type DB } from '../src/db'
|
|
||||||
import { seedIfEmpty } from '../src/seed'
|
|
||||||
import { commitBill } from '../src/repos'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Item-master anchoring (red-team C2, C3, H5, H7 + MRP/unit guards). commitBill is the
|
|
||||||
* trust boundary: it rebuilds every line from OUR item row and treats any price the client
|
|
||||||
* couldn't have gotten from the master as a deviation that needs a bound token or the
|
|
||||||
* committer's own authority. Hermetic in-memory DB; seed items: 107 Amul Butter (master
|
|
||||||
* sale 27500, MRP 28000, GST12, whole unit), 42 Tomato loose (KG, 3 decimals).
|
|
||||||
*/
|
|
||||||
function fresh(): DB {
|
|
||||||
const db = openDb(':memory:')
|
|
||||||
seedIfEmpty(db)
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
const itemId = (db: DB, code: string): string =>
|
|
||||||
(db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
|
|
||||||
const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
|
|
||||||
|
|
||||||
const line = (id: string, over: Partial<{ unitPricePaise: number; taxClassCode: string; qty: number }> = {}) => ({
|
|
||||||
itemId: id, name: 'Amul Butter 500g', hsn: '0405', qty: over.qty ?? 1, unitCode: 'PCS',
|
|
||||||
unitPricePaise: over.unitPricePaise ?? 27_500, priceIncludesTax: true, taxClassCode: over.taxClassCode ?? 'GST12',
|
|
||||||
})
|
|
||||||
const bill = (cashierId: string, l: ReturnType<typeof line>, payable: number): Parameters<typeof commitBill>[1] => ({
|
|
||||||
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId, shiftId: 'sh1', businessDate: '2026-07-10',
|
|
||||||
lines: [l], payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable,
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('item-master anchoring', () => {
|
|
||||||
it('commits a normal bill at the master price (happy path)', () => {
|
|
||||||
const db = fresh()
|
|
||||||
const out = commitBill(db, bill('u1', line(itemId(db, '107')), 27_500))
|
|
||||||
expect(out.docNo).toMatch(/^ST1C22026-/) // 4-digit-FY prefix (M9); business date 2026-07-10 → FY 2026-27
|
|
||||||
expect(billCount(db)).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('C2: a cashier underpricing a ₹275 item to ₹1 without approval is rejected (4xx)', () => {
|
|
||||||
const db = fresh()
|
|
||||||
const err = (() => { try { commitBill(db, bill('u1', line(itemId(db, '107'), { unitPricePaise: 100 }), 100)); return undefined } catch (e) { return e as Error & { status?: number } } })()
|
|
||||||
expect(err).toBeDefined()
|
|
||||||
expect(err!.status).toBeGreaterThanOrEqual(400)
|
|
||||||
expect(err!.status).toBeLessThan(500)
|
|
||||||
expect(billCount(db)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('C3: a forged ZERO tax class is ignored — the bill is taxed at the master GST12 rate', () => {
|
|
||||||
const db = fresh()
|
|
||||||
// Inclusive price, so the ₹275 total is identical under ZERO or GST12; only the split
|
|
||||||
// differs. The server anchors GST12, so the committed bill still carries CGST+SGST.
|
|
||||||
commitBill(db, bill('u1', line(itemId(db, '107'), { taxClassCode: 'ZERO' }), 27_500))
|
|
||||||
const b = db.prepare(`SELECT cgst_paise, sgst_paise, taxable_paise FROM bill LIMIT 1`).get() as { cgst_paise: number; sgst_paise: number; taxable_paise: number }
|
|
||||||
expect(b.cgst_paise + b.sgst_paise).toBeGreaterThan(0)
|
|
||||||
expect(b.taxable_paise).toBeLessThan(27_500) // some of the sticker price is GST, not taxable base
|
|
||||||
})
|
|
||||||
|
|
||||||
it('H5: a line for a non-existent item is rejected, nothing committed', () => {
|
|
||||||
const db = fresh()
|
|
||||||
expect(() => commitBill(db, bill('u1', line('DOES-NOT-EXIST'), 27_500))).toThrow(/E-1105|catalogue/i)
|
|
||||||
expect(billCount(db)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('H7: an owner self-authorises a price change and it is audited from their identity', () => {
|
|
||||||
const db = fresh()
|
|
||||||
commitBill(db, bill('u3', line(itemId(db, '107'), { unitPricePaise: 25_000 }), 25_000)) // u3 = owner
|
|
||||||
const audit = db.prepare(`SELECT after_json FROM audit_log WHERE action='PRICE_OVERRIDE'`).get() as { after_json: string } | undefined
|
|
||||||
expect(audit).toBeDefined()
|
|
||||||
const after = JSON.parse(audit!.after_json) as { approvedByUserId: string; selfAuthorized?: boolean }
|
|
||||||
expect(after.approvedByUserId).toBe('u3')
|
|
||||||
expect(after.selfAuthorized).toBe(true)
|
|
||||||
expect(billCount(db)).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hard-blocks a price above MRP even for an owner (E-1301)', () => {
|
|
||||||
const db = fresh()
|
|
||||||
expect(() => commitBill(db, bill('u3', line(itemId(db, '107'), { unitPricePaise: 30_000 }), 30_000))).toThrow(/E-1301|MRP/i)
|
|
||||||
expect(billCount(db)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a fractional quantity on a whole-unit item (E-1104)', () => {
|
|
||||||
const db = fresh()
|
|
||||||
expect(() => commitBill(db, bill('u1', line(itemId(db, '107'), { qty: 1.5 }), 41_300))).toThrow(/E-1104|whole/i)
|
|
||||||
expect(billCount(db)).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
||||||
import express from 'express'
|
|
||||||
import type { AddressInfo } from 'node:net'
|
|
||||||
import type { Server } from 'node:http'
|
|
||||||
import { openDb, type DB } from '../src/db'
|
|
||||||
import { seedIfEmpty } from '../src/seed'
|
|
||||||
import { apiRouter } from '../src/api'
|
|
||||||
import { commitBill, createItem } from '../src/repos'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cashier draft-item quick-add guards (red-team follow-up: the draft path reproduced C2/C3).
|
|
||||||
* A cashier (BILL_CREATE, no MASTER_EDIT) could POST /items status:'draft' with an arbitrary
|
|
||||||
* price AND an arbitrary taxClassCode, then bill it with the server anchoring to that just-
|
|
||||||
* authored master — arbitrary price + GST evasion (ZERO tax on a taxable good) with no
|
|
||||||
* deviation audit row. These tests drive REAL HTTP against a mounted apiRouter (the exact
|
|
||||||
* surface of the hole) plus commitBill directly for the audit-row assertion.
|
|
||||||
*
|
|
||||||
* Seed users: u1 Ramesh/cashier (PIN 4728), u4 Divya/cashier (PIN 3591), u3 Thomas/owner
|
|
||||||
* (PIN 9174, MASTER_EDIT). Store s1, counter c2. Surf Excel (103) master sale 14500, GST18.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Harness { db: DB; url: string; server: Server }
|
|
||||||
|
|
||||||
async function start(): Promise<Harness> {
|
|
||||||
const db = openDb(':memory:')
|
|
||||||
seedIfEmpty(db)
|
|
||||||
const app = express()
|
|
||||||
app.use(express.json())
|
|
||||||
app.use('/api', apiRouter(db))
|
|
||||||
// Mirror server.ts's generic error handler so tagged throws surface as { error, code }.
|
|
||||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
||||||
const e = (err ?? {}) as { status?: number; code?: string; message?: string }
|
|
||||||
const status = typeof e.status === 'number' ? e.status : 500
|
|
||||||
if (res.headersSent) return
|
|
||||||
res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' })
|
|
||||||
})
|
|
||||||
const server: Server = await new Promise((resolve) => {
|
|
||||||
const s = app.listen(0, () => resolve(s))
|
|
||||||
})
|
|
||||||
const port = (server.address() as AddressInfo).port
|
|
||||||
return { db, url: `http://127.0.0.1:${port}`, server }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login(url: string, userId: string, pin: string): Promise<string> {
|
|
||||||
const res = await fetch(`${url}/api/auth/pin`, {
|
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ userId, pin }),
|
|
||||||
})
|
|
||||||
const body = (await res.json()) as { token?: string }
|
|
||||||
if (body.token === undefined) throw new Error(`login failed for ${userId}`)
|
|
||||||
return body.token
|
|
||||||
}
|
|
||||||
|
|
||||||
const post = (url: string, path: string, token: string, body: unknown) =>
|
|
||||||
fetch(`${url}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
|
|
||||||
const get = (url: string, path: string, token: string) =>
|
|
||||||
fetch(`${url}${path}`, { headers: { authorization: `Bearer ${token}` } })
|
|
||||||
|
|
||||||
const itemIdOf = (db: DB, code: string): string =>
|
|
||||||
(db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
|
|
||||||
|
|
||||||
const sellLine = (itemId: string, price: number) =>
|
|
||||||
({ itemId, name: 'x', hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: price, priceIncludesTax: true, taxClassCode: 'GST18' })
|
|
||||||
|
|
||||||
let h: Harness
|
|
||||||
beforeEach(async () => { h = await start() })
|
|
||||||
afterEach(() => { h.server.close() })
|
|
||||||
|
|
||||||
describe('cashier draft tax-class forcing (C3)', () => {
|
|
||||||
it('IGNORES a cashier-chosen ZERO tax class and assigns the store default (GST18)', async () => {
|
|
||||||
const token = await login(h.url, 'u1', '4728')
|
|
||||||
const res = await post(h.url, '/api/items', token, {
|
|
||||||
code: 'RT-WHISKEY', name: 'Redteam Whiskey', taxClassCode: 'ZERO',
|
|
||||||
salePricePaise: 200_000, status: 'draft',
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const item = (await res.json()) as { taxClassCode: string; status: string }
|
|
||||||
expect(item.taxClassCode).toBe('GST18') // server default, NOT the client's ZERO
|
|
||||||
expect(item.status).toBe('draft')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lets a MASTER_EDIT role (owner) keep its chosen tax class', async () => {
|
|
||||||
const token = await login(h.url, 'u3', '9174') // owner
|
|
||||||
const res = await post(h.url, '/api/items', token, {
|
|
||||||
code: 'OWN-ZERO', name: 'Owner Zero Good', taxClassCode: 'ZERO',
|
|
||||||
salePricePaise: 5_000, status: 'draft',
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const item = (await res.json()) as { taxClassCode: string }
|
|
||||||
expect(item.taxClassCode).toBe('ZERO') // owner's explicit choice honoured
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a ZERO-forged draft, once billed, is taxed at the forced GST18 (GST evasion closed)', async () => {
|
|
||||||
const token = await login(h.url, 'u1', '4728')
|
|
||||||
const created = await (await post(h.url, '/api/items', token, {
|
|
||||||
code: 'RT-TAXED', name: 'Redteam Taxed Good', taxClassCode: 'ZERO',
|
|
||||||
salePricePaise: 10_000, status: 'draft',
|
|
||||||
})).json() as { id: string }
|
|
||||||
const bill = await post(h.url, '/api/bills', token, {
|
|
||||||
storeId: 's1', counterId: 'c2', shiftId: 'sh1', businessDate: '2026-07-13',
|
|
||||||
lines: [sellLine(created.id, 10_000)],
|
|
||||||
payments: [{ mode: 'CASH', amountPaise: 10_000 }], clientPayablePaise: 10_000,
|
|
||||||
})
|
|
||||||
expect(bill.status).toBe(200)
|
|
||||||
const row = h.db.prepare(`SELECT cgst_paise, sgst_paise FROM bill LIMIT 1`).get() as { cgst_paise: number; sgst_paise: number }
|
|
||||||
expect(row.cgst_paise + row.sgst_paise).toBeGreaterThan(0) // real GST charged, not ₹0
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('cashier draft price cap (C2 bound)', () => {
|
|
||||||
it('rejects a draft priced above the ₹5,000 cap with E-1108', async () => {
|
|
||||||
const token = await login(h.url, 'u1', '4728')
|
|
||||||
const res = await post(h.url, '/api/items', token, {
|
|
||||||
code: 'RT-EXP', name: 'Expensive Unknown', taxClassCode: 'GST18',
|
|
||||||
salePricePaise: 999_900, status: 'draft', // ₹9,999 > ₹5,000
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { code: string }
|
|
||||||
expect(body.code).toBe('E-1108')
|
|
||||||
expect(h.db.prepare(`SELECT COUNT(*) n FROM item WHERE code='RT-EXP'`).get()).toMatchObject({ n: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows a reasonable draft under the cap', async () => {
|
|
||||||
const token = await login(h.url, 'u1', '4728')
|
|
||||||
const res = await post(h.url, '/api/items', token, {
|
|
||||||
code: 'RT-OK', name: 'Reasonable Unknown', taxClassCode: 'GST18',
|
|
||||||
salePricePaise: 45_000, status: 'draft', // ₹450 < ₹5,000
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lets a MASTER_EDIT role (owner) create above the cap', async () => {
|
|
||||||
const token = await login(h.url, 'u3', '9174') // owner
|
|
||||||
const res = await post(h.url, '/api/items', token, {
|
|
||||||
code: 'OWN-EXP', name: 'Owner Expensive', taxClassCode: 'GST18',
|
|
||||||
salePricePaise: 999_900, status: 'draft',
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('draft sale is audited DRAFT_SALE', () => {
|
|
||||||
it('writes one DRAFT_SALE row (itemId, name, price) in the commit transaction', () => {
|
|
||||||
// Direct repos: a draft item sold at its own price is not a deviation, so absent this
|
|
||||||
// flag the only trace would be ITEM_CREATE — prove the explicit sale trail exists.
|
|
||||||
const draft = createItem(h.db, 't1', {
|
|
||||||
code: 'RT-DRAFT', name: 'Redteam Draft', hsn: '', taxClassCode: 'GST18',
|
|
||||||
unitCode: 'PCS', unitDecimals: 0, salePricePaise: 10_000, status: 'draft',
|
|
||||||
})
|
|
||||||
commitBill(h.db, {
|
|
||||||
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
|
|
||||||
businessDate: '2026-07-13',
|
|
||||||
lines: [{ itemId: draft.id, name: draft.name, hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: 10_000, priceIncludesTax: true, taxClassCode: 'GST18' }],
|
|
||||||
payments: [{ mode: 'CASH', amountPaise: 10_000 }], clientPayablePaise: 10_000,
|
|
||||||
})
|
|
||||||
const audit = h.db.prepare(`SELECT after_json FROM audit_log WHERE action='DRAFT_SALE'`).get() as { after_json: string } | undefined
|
|
||||||
expect(audit).toBeDefined()
|
|
||||||
const after = JSON.parse(audit!.after_json) as { itemId: string; name: string; pricePaise: number }
|
|
||||||
expect(after.itemId).toBe(draft.id)
|
|
||||||
expect(after.name).toBe('Redteam Draft')
|
|
||||||
expect(after.pricePaise).toBe(10_000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('an active (non-draft) item sale writes NO DRAFT_SALE row', () => {
|
|
||||||
const surf = itemIdOf(h.db, '103')
|
|
||||||
commitBill(h.db, {
|
|
||||||
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
|
|
||||||
businessDate: '2026-07-13',
|
|
||||||
lines: [{ itemId: surf, name: 'Surf', hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: 14_500, priceIncludesTax: true, taxClassCode: 'GST18' }],
|
|
||||||
payments: [{ mode: 'CASH', amountPaise: 14_500 }], clientPayablePaise: 14_500,
|
|
||||||
})
|
|
||||||
expect(h.db.prepare(`SELECT COUNT(*) n FROM audit_log WHERE action='DRAFT_SALE'`).get()).toMatchObject({ n: 0 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('GET /bills is scoped for a cashier (cross-counter read residual)', () => {
|
|
||||||
it('a cashier sees only their OWN bills; an owner (REPORT_VIEW) sees all', async () => {
|
|
||||||
const surf = itemIdOf(h.db, '103')
|
|
||||||
const t1 = await login(h.url, 'u1', '4728') // Ramesh
|
|
||||||
const t4 = await login(h.url, 'u4', '3591') // Divya
|
|
||||||
const billBody = {
|
|
||||||
storeId: 's1', counterId: 'c2', shiftId: 'sh1', businessDate: '2026-07-13',
|
|
||||||
lines: [sellLine(surf, 14_500)],
|
|
||||||
payments: [{ mode: 'CASH', amountPaise: 14_500 }], clientPayablePaise: 14_500,
|
|
||||||
}
|
|
||||||
expect((await post(h.url, '/api/bills', t1, billBody)).status).toBe(200) // Ramesh's bill
|
|
||||||
expect((await post(h.url, '/api/bills', t4, billBody)).status).toBe(200) // Divya's bill
|
|
||||||
|
|
||||||
const rameshView = (await (await get(h.url, '/api/bills', t1)).json()) as { cashier_id: string }[]
|
|
||||||
expect(rameshView.length).toBe(1)
|
|
||||||
expect(rameshView.every((b) => b.cashier_id === 'u1')).toBe(true)
|
|
||||||
|
|
||||||
const divyaView = (await (await get(h.url, '/api/bills', t4)).json()) as { cashier_id: string }[]
|
|
||||||
expect(divyaView.length).toBe(1)
|
|
||||||
expect(divyaView.every((b) => b.cashier_id === 'u4')).toBe(true)
|
|
||||||
|
|
||||||
// Owner has REPORT_VIEW → full cross-counter/cashier view (both bills).
|
|
||||||
const owner = await login(h.url, 'u3', '9174')
|
|
||||||
const ownerView = (await (await get(h.url, '/api/bills', owner)).json()) as unknown[]
|
|
||||||
expect(ownerView.length).toBe(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import {
|
|
||||||
isLanIPv4, isLanAddress, parsePrinterPorts, checkPrintEnvelope, checkPrintAddress,
|
|
||||||
DEFAULT_PRINTER_PORTS, MAX_PRINT_BYTES,
|
|
||||||
} from '../src/print-guard'
|
|
||||||
import { createRateLimiter } from '../src/rate-limit'
|
|
||||||
import { isSessionExpired, DEFAULT_SESSION_TTL } from '../src/session-policy'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure guards behind the P1 edge hardening (red-team H1, M2, M4): the LAN/port/byte print
|
|
||||||
* validator, the sliding-window rate limiter, and the session-TTL check. Clock-injectable, so
|
|
||||||
* they test without timers or sockets. The HTTP-surface behaviour lives in edge.test.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---- H1: /api/print SSRF allowlist ----------------------------------------------------------
|
|
||||||
describe('print-guard: LAN address classification (H1)', () => {
|
|
||||||
it('accepts loopback + RFC-1918 private ranges', () => {
|
|
||||||
for (const ip of ['127.0.0.1', '127.255.255.254', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.100']) {
|
|
||||||
expect(isLanIPv4(ip)).toBe(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
it('rejects public IPs, link-local, and the 172.16/12 edges outside the block', () => {
|
|
||||||
for (const ip of ['8.8.8.8', '1.1.1.1', '169.254.1.1', '172.15.255.255', '172.32.0.1', '0.0.0.0', '255.255.255.255', 'not-an-ip', '']) {
|
|
||||||
expect(isLanIPv4(ip)).toBe(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
it('treats IPv6 loopback ::1 as LAN, other IPv6 as off-LAN', () => {
|
|
||||||
expect(isLanAddress('::1')).toBe(true)
|
|
||||||
expect(isLanAddress('2001:4860:4860::8888')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('print-guard: port allowlist + payload cap (H1)', () => {
|
|
||||||
it('defaults to {9100,9101,9102} and parses SIMS_PRINTER_PORTS', () => {
|
|
||||||
expect([...parsePrinterPorts(undefined)].sort()).toEqual([...DEFAULT_PRINTER_PORTS])
|
|
||||||
expect([...parsePrinterPorts('')].sort()).toEqual([...DEFAULT_PRINTER_PORTS])
|
|
||||||
expect([...parsePrinterPorts('abc,,-1,70000')].sort()).toEqual([...DEFAULT_PRINTER_PORTS]) // all invalid → default
|
|
||||||
expect([...parsePrinterPorts('9100, 9200')].sort()).toEqual([9100, 9200])
|
|
||||||
})
|
|
||||||
it('checkPrintEnvelope enforces byte cap and port allowlist', () => {
|
|
||||||
const ports = new Set(DEFAULT_PRINTER_PORTS)
|
|
||||||
expect(checkPrintEnvelope(100, 9100, ports)).toEqual({ ok: true })
|
|
||||||
expect(checkPrintEnvelope(MAX_PRINT_BYTES + 1, 9100, ports)).toMatchObject({ ok: false, code: 'E-2201' })
|
|
||||||
expect(checkPrintEnvelope(100, 80, ports)).toMatchObject({ ok: false, code: 'E-2202' })
|
|
||||||
})
|
|
||||||
it('checkPrintAddress rejects a public IP, accepts a private one', () => {
|
|
||||||
expect(checkPrintAddress('127.0.0.1')).toEqual({ ok: true })
|
|
||||||
expect(checkPrintAddress('8.8.8.8')).toMatchObject({ ok: false, code: 'E-2203' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Rate limiter (M2/M4) -------------------------------------------------------------------
|
|
||||||
describe('rate limiter: sliding window (M2/M4)', () => {
|
|
||||||
it('allows up to max within the window then rejects, and slides', () => {
|
|
||||||
const rl = createRateLimiter({ windowMs: 1000, max: 2 })
|
|
||||||
expect(rl.hit('ip', 0)).toBe(true)
|
|
||||||
expect(rl.hit('ip', 100)).toBe(true)
|
|
||||||
expect(rl.hit('ip', 200)).toBe(false) // 3rd within window blocked
|
|
||||||
expect(rl.remaining('ip', 200)).toBe(0)
|
|
||||||
expect(rl.hit('ip', 1101)).toBe(true) // first attempt (t=0) aged out → allowed again
|
|
||||||
})
|
|
||||||
it('keys are independent', () => {
|
|
||||||
const rl = createRateLimiter({ windowMs: 1000, max: 1 })
|
|
||||||
expect(rl.hit('a', 0)).toBe(true)
|
|
||||||
expect(rl.hit('b', 0)).toBe(true)
|
|
||||||
expect(rl.hit('a', 0)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Session TTL (M4) -----------------------------------------------------------------------
|
|
||||||
describe('session TTL check (M4)', () => {
|
|
||||||
const ttl = { absoluteMs: 1000, idleMs: 100 }
|
|
||||||
it('is live within both bounds', () => {
|
|
||||||
expect(isSessionExpired({ createdMs: 0, lastSeenMs: 0 }, 50, ttl)).toBe(false)
|
|
||||||
})
|
|
||||||
it('expires past the idle timeout even with recent creation', () => {
|
|
||||||
expect(isSessionExpired({ createdMs: 0, lastSeenMs: 0 }, 150, ttl)).toBe(true)
|
|
||||||
})
|
|
||||||
it('expires past the absolute lifetime even when recently seen', () => {
|
|
||||||
expect(isSessionExpired({ createdMs: 0, lastSeenMs: 990 }, 1001, ttl)).toBe(true)
|
|
||||||
})
|
|
||||||
it('ships sane defaults (12h absolute / 30m idle)', () => {
|
|
||||||
expect(DEFAULT_SESSION_TTL.absoluteMs).toBe(12 * 60 * 60 * 1000)
|
|
||||||
expect(DEFAULT_SESSION_TTL.idleMs).toBe(30 * 60 * 1000)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
||||||
import express from 'express'
|
|
||||||
import type { AddressInfo } from 'node:net'
|
|
||||||
import type { Server } from 'node:http'
|
|
||||||
import { openDb, type DB } from '../src/db'
|
|
||||||
import { seedIfEmpty } from '../src/seed'
|
|
||||||
import { apiRouter, type ApiRouterOpts } from '../src/api'
|
|
||||||
import { MAX_PRINT_BYTES } from '../src/print-guard'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* P1 edge / transport hardening (red-team H1, M1, M2, M4) over the REAL HTTP surface: a mounted
|
|
||||||
* apiRouter, driven with fetch — the exact surface each finding lives on. Pure guards are in
|
|
||||||
* edge-guards.test.ts. Seed users: u1 Ramesh/cashier (4728), u2 Suresh/supervisor (8265),
|
|
||||||
* u3 Thomas/owner (9174).
|
|
||||||
*/
|
|
||||||
interface Harness { db: DB; url: string; server: Server }
|
|
||||||
|
|
||||||
async function start(opts?: ApiRouterOpts): Promise<Harness> {
|
|
||||||
const db = openDb(':memory:')
|
|
||||||
seedIfEmpty(db)
|
|
||||||
const app = express()
|
|
||||||
app.use(express.json({ limit: '1mb' }))
|
|
||||||
app.use('/api', apiRouter(db, opts))
|
|
||||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
||||||
const e = (err ?? {}) as { status?: number; code?: string; message?: string }
|
|
||||||
const status = typeof e.status === 'number' ? e.status : 500
|
|
||||||
if (res.headersSent) return
|
|
||||||
res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' })
|
|
||||||
})
|
|
||||||
const server: Server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)) })
|
|
||||||
const port = (server.address() as AddressInfo).port
|
|
||||||
return { db, url: `http://127.0.0.1:${port}`, server }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login(url: string, userId: string, pin: string): Promise<string> {
|
|
||||||
const res = await fetch(`${url}/api/auth/pin`, {
|
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userId, pin }),
|
|
||||||
})
|
|
||||||
const body = (await res.json()) as { token?: string }
|
|
||||||
if (body.token === undefined) throw new Error(`login failed for ${userId}`)
|
|
||||||
return body.token
|
|
||||||
}
|
|
||||||
|
|
||||||
const post = (url: string, path: string, token: string | undefined, body: unknown) =>
|
|
||||||
fetch(`${url}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json', ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}) },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
|
|
||||||
const get = (url: string, path: string, token?: string) =>
|
|
||||||
fetch(`${url}${path}`, { headers: token !== undefined ? { authorization: `Bearer ${token}` } : {} })
|
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
||||||
|
|
||||||
let h: Harness
|
|
||||||
afterEach(() => { h.server.close() })
|
|
||||||
|
|
||||||
describe('M1 — /bootstrap minimize', () => {
|
|
||||||
beforeEach(async () => { h = await start() })
|
|
||||||
it('unauthenticated payload leaks NO role and NO gstin', async () => {
|
|
||||||
const boot = (await (await get(h.url, '/api/bootstrap')).json()) as { store: Record<string, unknown>; users: Record<string, unknown>[] }
|
|
||||||
expect(boot.store['gstin']).toBeUndefined()
|
|
||||||
expect(boot.users.length).toBeGreaterThan(0)
|
|
||||||
for (const u of boot.users) {
|
|
||||||
expect(u['role']).toBeUndefined()
|
|
||||||
expect(u['name']).toBeDefined() // display name kept
|
|
||||||
expect(u['id']).toBeDefined() // id kept (PIN login target)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
it('an authenticated caller gets role + gstin back', async () => {
|
|
||||||
const token = await login(h.url, 'u1', '4728')
|
|
||||||
const boot = (await (await get(h.url, '/api/bootstrap', token)).json()) as { store: Record<string, unknown>; users: Record<string, unknown>[] }
|
|
||||||
expect(boot.store['gstin']).toBeDefined()
|
|
||||||
expect(boot.users.every((u) => u['role'] !== undefined)).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('M2 — /auth/verify-pin behind auth', () => {
|
|
||||||
beforeEach(async () => { h = await start() })
|
|
||||||
const override = { itemId: 'i-x', kind: 'price', before: 10000, after: 9000 }
|
|
||||||
it('is 401 UNAUTHENTICATED (no caller session)', async () => {
|
|
||||||
const res = await post(h.url, '/api/auth/verify-pin', undefined, { userId: 'u2', pin: '8265', override })
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
it('still mints a bound token when the caller is a logged-in cashier', async () => {
|
|
||||||
const cashier = await login(h.url, 'u1', '4728')
|
|
||||||
const res = await post(h.url, '/api/auth/verify-pin', cashier, { userId: 'u2', pin: '8265', override })
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as { approvalId?: string }
|
|
||||||
expect(body.approvalId).toBeDefined()
|
|
||||||
})
|
|
||||||
it('a wrong approver PIN is still rejected (401) even with a valid caller', async () => {
|
|
||||||
const cashier = await login(h.url, 'u1', '4728')
|
|
||||||
const res = await post(h.url, '/api/auth/verify-pin', cashier, { userId: 'u2', pin: '0000', override })
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('M4 — session TTL + logout', () => {
|
|
||||||
it('evicts a session past its idle TTL (401 E-6110)', async () => {
|
|
||||||
h = await start({ sessionTtl: { absoluteMs: 3_600_000, idleMs: 20 } })
|
|
||||||
const token = await login(h.url, 'u1', '4728')
|
|
||||||
expect((await get(h.url, '/api/items', token)).status).toBe(200) // fresh: allowed
|
|
||||||
await sleep(80)
|
|
||||||
const res = await get(h.url, '/api/items', token)
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
expect(((await res.json()) as { code: string }).code).toBe('E-6110')
|
|
||||||
})
|
|
||||||
it('logout makes the token stop working immediately', async () => {
|
|
||||||
h = await start()
|
|
||||||
const token = await login(h.url, 'u1', '4728')
|
|
||||||
expect((await get(h.url, '/api/items', token)).status).toBe(200)
|
|
||||||
expect((await post(h.url, '/api/auth/logout', token, {})).status).toBe(200)
|
|
||||||
expect((await get(h.url, '/api/items', token)).status).toBe(401)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('M4/M2 — auth rate limit', () => {
|
|
||||||
it('429s once the per-IP cap is exceeded, before the handler runs', async () => {
|
|
||||||
h = await start({ authRatePerIp: { windowMs: 60_000, max: 2 } })
|
|
||||||
const r1 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
|
||||||
const r2 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
|
||||||
const r3 = await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
|
||||||
expect(r1.status).toBe(401) // wrong pin, but processed
|
|
||||||
expect(r2.status).toBe(401)
|
|
||||||
expect(r3.status).toBe(429) // rate-limited before reaching the handler
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('H1 — /api/print allowlist over HTTP', () => {
|
|
||||||
beforeEach(async () => { h = await start() })
|
|
||||||
it('rejects an unauthenticated caller (401)', async () => {
|
|
||||||
const res = await post(h.url, '/api/print', undefined, { host: '127.0.0.1', port: 9100, bytes: [1] })
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
it('rejects a PUBLIC IP even on an allowed port (E-2203, no socket opened)', async () => {
|
|
||||||
const token = await login(h.url, 'u1', '4728')
|
|
||||||
const res = await post(h.url, '/api/print', token, { host: '8.8.8.8', port: 9100, bytes: [1, 2, 3] })
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
expect(((await res.json()) as { code: string }).code).toBe('E-2203')
|
|
||||||
})
|
|
||||||
it('rejects a disallowed port (E-2202)', async () => {
|
|
||||||
const token = await login(h.url, 'u1', '4728')
|
|
||||||
const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 80, bytes: [1] })
|
|
||||||
expect(((await res.json()) as { code: string }).code).toBe('E-2202')
|
|
||||||
})
|
|
||||||
it('rejects an over-cap payload (E-2201)', async () => {
|
|
||||||
const token = await login(h.url, 'u1', '4728')
|
|
||||||
const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 9100, bytes: new Array(MAX_PRINT_BYTES + 1).fill(0) })
|
|
||||||
expect(((await res.json()) as { code: string }).code).toBe('E-2201')
|
|
||||||
})
|
|
||||||
it('ALLOWS a LAN target on an allowed port through the guard (reaches the socket → connection refused)', async () => {
|
|
||||||
const token = await login(h.url, 'u1', '4728')
|
|
||||||
// Nothing is listening on 127.0.0.1:9100 in the test, so the guard passing is proven by the
|
|
||||||
// response being a socket outcome (502 refused / 504 timeout), NOT a 400 guard rejection.
|
|
||||||
const res = await post(h.url, '/api/print', token, { host: '127.0.0.1', port: 9100, bytes: [27, 64] })
|
|
||||||
expect([502, 504, 200]).toContain(res.status)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('H2 — GET /audit/verify (AUDIT_VIEW gated)', () => {
|
|
||||||
beforeEach(async () => { h = await start() })
|
|
||||||
it('an owner gets ok:true on an untampered chain (with a real audit row); a cashier is 403', async () => {
|
|
||||||
// a wrong-PIN attempt writes a real LOGIN_FAILED row, so the chain isn't empty
|
|
||||||
await post(h.url, '/api/auth/pin', undefined, { userId: 'u1', pin: '0000' })
|
|
||||||
const owner = await login(h.url, 'u3', '9174')
|
|
||||||
const res = await get(h.url, '/api/audit/verify', owner)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(((await res.json()) as { ok: boolean }).ok).toBe(true)
|
|
||||||
const cashier = await login(h.url, 'u1', '4728')
|
|
||||||
expect((await get(h.url, '/api/audit/verify', cashier)).status).toBe(403)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest'
|
|
||||||
import fs from 'node:fs'
|
|
||||||
import os from 'node:os'
|
|
||||||
import path from 'node:path'
|
|
||||||
import { openDb, type DB } from '../src/db'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* H3 (red-team): the store DB is encrypted at rest with SQLCipher (better-sqlite3-multiple-ciphers)
|
|
||||||
* when SIMS_DB_KEY is set. These tests write a customer PII row (a phone number) through the real
|
|
||||||
* openDb() path, then prove against the ON-DISK bytes that:
|
|
||||||
* - a keyed file's header is NOT the plaintext "SQLite format 3\0" magic, and the phone number
|
|
||||||
* never appears in cleartext ANYWHERE in the file (main db + WAL);
|
|
||||||
* - the keyed data round-trips (reopen WITH the key reads the phone back);
|
|
||||||
* - opening WITHOUT the key, or with the WRONG key, fails ("file is not a database");
|
|
||||||
* - with SIMS_DB_KEY UNSET the file IS plaintext (the dev fallback) AND the phone IS present in
|
|
||||||
* cleartext — a positive control proving the byte-scan above genuinely detects plaintext.
|
|
||||||
* Tamper-evidence (H2) proves the file wasn't edited; H3 proves a stolen copy is unreadable.
|
|
||||||
*/
|
|
||||||
const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1') // 16 bytes
|
|
||||||
const PHONE = '9998887776' // stand-in customer PII we hunt for in the raw file
|
|
||||||
const GSTIN = '27ABCDE1234F1Z0'
|
|
||||||
|
|
||||||
const tmpFiles: string[] = []
|
|
||||||
function tmpDbPath(): string {
|
|
||||||
const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sims-enc-')), 'sims.db')
|
|
||||||
tmpFiles.push(p)
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
afterEach(() => {
|
|
||||||
for (const p of tmpFiles.splice(0)) {
|
|
||||||
for (const f of [p, `${p}-wal`, `${p}-shm`]) { try { fs.unlinkSync(f) } catch { /* ignore */ } }
|
|
||||||
try { fs.rmdirSync(path.dirname(p)) } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Run fn with SIMS_DB_KEY = key (or unset when key === null), restoring the prior env after. */
|
|
||||||
function withKey<T>(key: string | null, fn: () => T): T {
|
|
||||||
const prev = process.env['SIMS_DB_KEY']
|
|
||||||
if (key === null) delete process.env['SIMS_DB_KEY']
|
|
||||||
else process.env['SIMS_DB_KEY'] = key
|
|
||||||
try {
|
|
||||||
return fn()
|
|
||||||
} finally {
|
|
||||||
if (prev === undefined) delete process.env['SIMS_DB_KEY']
|
|
||||||
else process.env['SIMS_DB_KEY'] = prev
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Insert one party carrying PII, checkpoint so it lands on disk, close. */
|
|
||||||
function writePartyAndClose(db: DB): void {
|
|
||||||
db.prepare(
|
|
||||||
"INSERT INTO party (id, tenant_id, code, name, kind, phone, gstin, state_code) "
|
|
||||||
+ "VALUES ('p-enc', 't1', 'C-ENC', 'Encrypted Customer', 'customer', ?, ?, '27')",
|
|
||||||
).run(PHONE, GSTIN)
|
|
||||||
db.pragma('wal_checkpoint(TRUNCATE)') // force the row out of the WAL into the main file we read
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Concatenated raw bytes of the main db file and any WAL sidecar. */
|
|
||||||
function rawBytes(file: string): Buffer {
|
|
||||||
const parts = [fs.readFileSync(file)]
|
|
||||||
if (fs.existsSync(`${file}-wal`)) parts.push(fs.readFileSync(`${file}-wal`))
|
|
||||||
return Buffer.concat(parts)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('H3 — full-DB encryption at rest (SQLCipher)', () => {
|
|
||||||
it('a keyed DB is ciphertext on disk: no SQLite magic header, no cleartext PII', () => {
|
|
||||||
const file = tmpDbPath()
|
|
||||||
withKey('unit-test-secret-key', () => {
|
|
||||||
writePartyAndClose(openDb(file))
|
|
||||||
})
|
|
||||||
const head = fs.readFileSync(file).subarray(0, 16)
|
|
||||||
expect(head.equals(SQLITE_MAGIC)).toBe(false) // not "SQLite format 3\0"
|
|
||||||
const raw = rawBytes(file)
|
|
||||||
expect(raw.includes(Buffer.from(PHONE, 'latin1'))).toBe(false) // phone never in cleartext
|
|
||||||
expect(raw.includes(Buffer.from(GSTIN, 'latin1'))).toBe(false) // nor the GSTIN
|
|
||||||
})
|
|
||||||
|
|
||||||
it('the keyed data round-trips — reopening WITH the key reads the PII back', () => {
|
|
||||||
const file = tmpDbPath()
|
|
||||||
withKey('unit-test-secret-key', () => {
|
|
||||||
writePartyAndClose(openDb(file))
|
|
||||||
const db2 = openDb(file)
|
|
||||||
const row = db2.prepare("SELECT phone, gstin FROM party WHERE id='p-enc'").get() as
|
|
||||||
{ phone: string; gstin: string } | undefined
|
|
||||||
db2.close()
|
|
||||||
expect(row?.phone).toBe(PHONE)
|
|
||||||
expect(row?.gstin).toBe(GSTIN)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('opening a keyed file WITHOUT the key fails (a stolen copy is unreadable)', () => {
|
|
||||||
const file = tmpDbPath()
|
|
||||||
withKey('unit-test-secret-key', () => { writePartyAndClose(openDb(file)) })
|
|
||||||
expect(() => withKey(null, () => openDb(file))).toThrow(/not a database/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('opening a keyed file with the WRONG key fails', () => {
|
|
||||||
const file = tmpDbPath()
|
|
||||||
withKey('unit-test-secret-key', () => { writePartyAndClose(openDb(file)) })
|
|
||||||
expect(() => withKey('the-wrong-key', () => openDb(file))).toThrow(/not a database/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('dev fallback (key UNSET) writes plaintext — positive control for the byte-scan', () => {
|
|
||||||
const file = tmpDbPath()
|
|
||||||
withKey(null, () => { writePartyAndClose(openDb(file)) })
|
|
||||||
const head = fs.readFileSync(file).subarray(0, 16)
|
|
||||||
expect(head.equals(SQLITE_MAGIC)).toBe(true) // unencrypted: real SQLite magic
|
|
||||||
expect(rawBytes(file).includes(Buffer.from(PHONE, 'latin1'))).toBe(true) // scan really finds plaintext
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { openDb, type DB } from '../src/db'
|
|
||||||
import { seedIfEmpty } from '../src/seed'
|
|
||||||
import { commitBill, createOverrideApproval } from '../src/repos'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SEC-2 / H4: supervisor-override approvals are server-minted, single-use tokens BOUND to
|
|
||||||
* the exact override they authorise. The client sends an `approvalId`, never an approver
|
|
||||||
* identity — commitBill re-detects the deviation from the item master, redeems the token
|
|
||||||
* only if item/kind/before/after match, and reads the approver from its own row. These
|
|
||||||
* tests drive commitBill against a hermetic in-memory DB (seed users: u1 Ramesh/cashier,
|
|
||||||
* u2 Suresh/supervisor). Surf Excel (103) has master sale 14500, MRP 15000.
|
|
||||||
*/
|
|
||||||
function fresh(): DB {
|
|
||||||
const db = openDb(':memory:')
|
|
||||||
seedIfEmpty(db)
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
const surfId = (db: DB): string =>
|
|
||||||
(db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='103'").get() as { id: string }).id
|
|
||||||
|
|
||||||
/** A bill that sells Surf Excel at 12000 (a deviation from the 14500 master), overridden. */
|
|
||||||
const billWith = (db: DB, approvalId: string): Parameters<typeof commitBill>[1] => {
|
|
||||||
const itemId = surfId(db)
|
|
||||||
return {
|
|
||||||
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
|
|
||||||
businessDate: '2026-07-10',
|
|
||||||
lines: [{ itemId, name: 'Surf Excel 1kg', hsn: '3402', qty: 1, unitCode: 'PCS', unitPricePaise: 12_000, priceIncludesTax: true, taxClassCode: 'GST18' }],
|
|
||||||
payments: [{ mode: 'CASH', amountPaise: 12_000 }],
|
|
||||||
clientPayablePaise: 12_000,
|
|
||||||
overrides: [{ itemId, kind: 'price', before: 14_500, after: 12_000, approvalId }],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const priceCtx = (db: DB) => ({ itemId: surfId(db), kind: 'price' as const, beforePaise: 14_500, afterPaise: 12_000 })
|
|
||||||
const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
|
|
||||||
|
|
||||||
describe('SEC-2 / H4 bound override approval tokens', () => {
|
|
||||||
it('rejects a commit whose override carries a fabricated approvalId — nothing is committed', () => {
|
|
||||||
const db = fresh()
|
|
||||||
expect(() => commitBill(db, billWith(db, '00000000-0000-7000-8000-000000000000'))).toThrow(/approval/i)
|
|
||||||
expect(billCount(db)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts a genuine bound token, audits the SERVER-side approver, and rejects a replay', () => {
|
|
||||||
const db = fresh()
|
|
||||||
const approvalId = createOverrideApproval(db, 't1', 'u2', priceCtx(db)) // Suresh (supervisor)
|
|
||||||
const out = commitBill(db, billWith(db, approvalId))
|
|
||||||
expect(out.docNo).toMatch(/^ST1C22026-/) // 4-digit-FY prefix (M9)
|
|
||||||
const audit = db.prepare("SELECT after_json FROM audit_log WHERE action='PRICE_OVERRIDE'").get() as { after_json: string }
|
|
||||||
const after = JSON.parse(audit.after_json) as { approvedBy: string; approvedByUserId: string }
|
|
||||||
expect(after.approvedByUserId).toBe('u2') // read from the server's own row, not the client
|
|
||||||
expect(after.approvedBy).toBe('Suresh')
|
|
||||||
// token is single-use: a replay is rejected and no second bill is written
|
|
||||||
expect(() => commitBill(db, billWith(db, approvalId))).toThrow(/already used/i)
|
|
||||||
expect(billCount(db)).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a token minted for a non-approver role (a cashier)', () => {
|
|
||||||
const db = fresh()
|
|
||||||
const cashierToken = createOverrideApproval(db, 't1', 'u1', priceCtx(db)) // u1 is a cashier
|
|
||||||
expect(() => commitBill(db, billWith(db, cashierToken))).toThrow(/not authorised/i)
|
|
||||||
expect(billCount(db)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects an override with no approvalId at all (deviation unauthorised)', () => {
|
|
||||||
const db = fresh()
|
|
||||||
const itemId = surfId(db)
|
|
||||||
const input = billWith(db, 'placeholder')
|
|
||||||
input.overrides = [{ itemId, kind: 'price', before: 14_500, after: 12_000 }]
|
|
||||||
expect(() => commitBill(db, input)).toThrow(/supervisor approval|E-1302/i)
|
|
||||||
expect(billCount(db)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('H4: a token minted for item A cannot be redirected to item B', () => {
|
|
||||||
const db = fresh()
|
|
||||||
// Mint a genuine supervisor token bound to a DIFFERENT item (105 Maggi 14000→10000)...
|
|
||||||
const maggiId = (db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='105'").get() as { id: string }).id
|
|
||||||
const strayToken = createOverrideApproval(db, 't1', 'u2', { itemId: maggiId, kind: 'price', beforePaise: 14_000, afterPaise: 10_000 })
|
|
||||||
// ...and try to spend it on the Surf Excel deviation. The bound item mismatches → reject.
|
|
||||||
expect(() => commitBill(db, billWith(db, strayToken))).toThrow(/does not match|not recognised|approval/i)
|
|
||||||
expect(billCount(db)).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { openDb, type DB } from '../src/db'
|
|
||||||
import { seedIfEmpty } from '../src/seed'
|
|
||||||
import { commitBill } from '../src/repos'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* M5 (red-team): commitBill's store/counter lookup is tenant-scoped — `WHERE id=? AND tenant_id=?`
|
|
||||||
* for both — and an unknown-for-tenant store or counter is rejected 400 (E-1110) BEFORE any money
|
|
||||||
* logic, which also removes the old undefined-deref 500. (E-1110, not the task's suggested E-1109,
|
|
||||||
* because E-1109 is already the "payments do not sum to payable" code — reusing it would be
|
|
||||||
* ambiguous.) The /bills and /bills/offline routes both funnel through commitBill, so both paths
|
|
||||||
* are covered. Hermetic in-memory DB; Carry Bag (108) has master sale 500, no MRP, GST18.
|
|
||||||
*/
|
|
||||||
function fresh(): DB {
|
|
||||||
const db = openDb(':memory:')
|
|
||||||
seedIfEmpty(db)
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
const carryBag = (db: DB): string =>
|
|
||||||
(db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='108'").get() as { id: string }).id
|
|
||||||
const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
|
|
||||||
const base = (db: DB): Omit<Parameters<typeof commitBill>[1], 'storeId'> => ({
|
|
||||||
tenantId: 't1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10',
|
|
||||||
lines: [{ itemId: carryBag(db), name: 'Carry Bag', hsn: '3923', qty: 1, unitCode: 'PCS', unitPricePaise: 500, priceIncludesTax: true, taxClassCode: 'GST18' }],
|
|
||||||
payments: [{ mode: 'CASH', amountPaise: 500 }], clientPayablePaise: 500,
|
|
||||||
})
|
|
||||||
const thrown = (fn: () => unknown): (Error & { status?: number; code?: string }) | undefined => {
|
|
||||||
try { fn(); return undefined } catch (e) { return e as Error & { status?: number; code?: string } }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('M5 — commitBill store/counter are tenant-scoped', () => {
|
|
||||||
it('a nonexistent storeId is rejected 400 (E-1110) — no 500, nothing committed', () => {
|
|
||||||
const db = fresh()
|
|
||||||
const err = thrown(() => commitBill(db, { ...base(db), storeId: 'does-not-exist' }))
|
|
||||||
expect(err).toBeDefined()
|
|
||||||
expect(err!.status).toBe(400)
|
|
||||||
expect(err!.code).toBe('E-1110')
|
|
||||||
expect(billCount(db)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a nonexistent counterId is rejected 400 (E-1110)', () => {
|
|
||||||
const db = fresh()
|
|
||||||
const err = thrown(() => commitBill(db, { ...base(db), storeId: 's1', counterId: 'nope' }))
|
|
||||||
expect(err!.status).toBe(400)
|
|
||||||
expect(err!.code).toBe('E-1110')
|
|
||||||
expect(billCount(db)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a store/counter belonging to ANOTHER tenant is rejected (true cross-tenant scoping)', () => {
|
|
||||||
const db = fresh()
|
|
||||||
db.prepare("INSERT INTO tenant (id, code, name, gst_scheme, fy_start_month, created_at) VALUES ('t2','T2','Other','regular',4,?)").run(new Date().toISOString())
|
|
||||||
db.prepare("INSERT INTO store (id, tenant_id, code, name, state_code) VALUES ('s2','t2','ST9','Other Store','29')").run()
|
|
||||||
db.prepare("INSERT INTO counter (id, tenant_id, store_id, code, name) VALUES ('cc2','t2','s2','C1','C')").run()
|
|
||||||
// commit as tenant t1 but point at t2's store/counter → must reject, never bill against t2's store
|
|
||||||
const err = thrown(() => commitBill(db, { ...base(db), storeId: 's2', counterId: 'cc2' }))
|
|
||||||
expect(err!.code).toBe('E-1110')
|
|
||||||
expect(billCount(db)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('the honest same-tenant store/counter still commits (4-digit-FY doc no)', () => {
|
|
||||||
const db = fresh()
|
|
||||||
const out = commitBill(db, { ...base(db), storeId: 's1' })
|
|
||||||
expect(out.docNo).toMatch(/^ST1C22026-/)
|
|
||||||
expect(out.docNo.length).toBeLessThanOrEqual(16)
|
|
||||||
expect(billCount(db)).toBe(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"include": ["src/**/*"]
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
# 00 — Vision & Positioning
|
|
||||||
|
|
||||||
## The one-line vision
|
|
||||||
|
|
||||||
**The fastest counter in India, with a back office that runs itself.**
|
|
||||||
|
|
||||||
Every design decision gets tested against two questions:
|
|
||||||
1. Does this make billing at the counter faster or slower?
|
|
||||||
2. Does this reduce the daily data-entry burden on the shop, or add to it?
|
|
||||||
|
|
||||||
## Where we are today (SiMS Classic)
|
|
||||||
|
|
||||||
- Oracle Forms & Reports, fully offline, client-server on the shop's own machine.
|
|
||||||
- Proven domain coverage: inventory, billing, accounting, GST — customers pay for it today.
|
|
||||||
- Problems: aging tech nobody can hire for, accumulated bugs, no multi-store story,
|
|
||||||
no remote visibility for owners, every support issue needs someone at the machine,
|
|
||||||
purchase entry is manual and slow, and the market is shifting to connected software.
|
|
||||||
|
|
||||||
## What carries over (non-negotiable)
|
|
||||||
|
|
||||||
These are the things existing customers would riot over if lost:
|
|
||||||
|
|
||||||
1. **Counter speed.** Oracle Forms was keyboard-driven and instant. The new POS must be
|
|
||||||
keyboard-first and *feel* faster than Classic, not just look prettier.
|
|
||||||
2. **Works with no internet.** Billing can never stop because the connection dropped.
|
|
||||||
3. **Full GST compliance** — invoices, returns data, e-Invoice, e-Way bill.
|
|
||||||
4. **Depth of the domain model** — batches, expiry, MRP, multi-unit packs, schemes,
|
|
||||||
party ledgers, credit (khata) — everything Classic already handles.
|
|
||||||
5. **Their data.** Every existing customer migrates with full history via a Classic importer.
|
|
||||||
|
|
||||||
## What changes (the upgrade)
|
|
||||||
|
|
||||||
| Classic | Next |
|
|
||||||
|---------|------|
|
|
||||||
| Offline only, data trapped in the shop | Offline-first with cloud sync — bill offline, see everything online |
|
|
||||||
| Manual purchase entry, item by item | **Purchase Inbox**: supplier bills arrive by email/WhatsApp/upload, AI parses them, staff just confirms |
|
|
||||||
| Single store, single machine mindset | Multi-store, multi-counter, head-office console |
|
|
||||||
| Owner must be in the shop to know anything | Owner mobile app: live sales, stock alerts, approvals from anywhere |
|
|
||||||
| Support = remote desktop into the shop | Central telemetry, remote diagnostics, silent auto-updates |
|
|
||||||
| Piracy/licensing enforced awkwardly | Subscription tied to cloud account, editions unlocked by plan |
|
|
||||||
| Paper receipts only | WhatsApp e-bills, UPI dynamic QR at counter, loyalty |
|
|
||||||
|
|
||||||
## Segments & editions — one codebase, four plans
|
|
||||||
|
|
||||||
Do **not** build separate products per store size. Build one codebase with feature flags;
|
|
||||||
the plan unlocks features. This keeps mini stores cheap to serve and gives them a natural
|
|
||||||
upgrade path as they grow — which is exactly the "serve them better as they get bigger" goal.
|
|
||||||
|
|
||||||
| Edition | Target | Shape |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| **Lite** | Mini/small shops, single counter | POS + inventory + GST invoices + khata + WhatsApp bills. Dead simple. |
|
|
||||||
| **Standard** | Medium stores | + multi-counter, multi-user roles, schemes/offers, barcode label printing, full purchase cycle |
|
|
||||||
| **Pro** | Large stores & supermarkets | + weighing scale integration, batch/expiry at scale, loyalty, accounting module, e-Invoice/e-Way, Purchase Inbox |
|
|
||||||
| **Enterprise** | Supermarket chains, multi-outlet | + head-office console, central pricing, inter-store transfers, consolidated GST & reporting, API access |
|
|
||||||
|
|
||||||
## Business model
|
|
||||||
|
|
||||||
- **Subscription, priced per store + per additional counter**, billed annually
|
|
||||||
(Indian retail strongly prefers yearly; offer monthly only on Lite).
|
|
||||||
- **In-app upgrades**: hitting a locked feature shows what it does and upgrades in place —
|
|
||||||
the paywall is a demo, not a wall.
|
|
||||||
- **Dealer/partner channel**: Indian retail software sells through local dealers who install,
|
|
||||||
train, and support (the Tally/Marg model). Plan dealer margins, a dealer portal, and
|
|
||||||
white-label invoicing for partners from the start — this is a distribution decision,
|
|
||||||
not an afterthought.
|
|
||||||
- **Migration offer**: existing Classic customers get assisted migration + a loyalty price.
|
|
||||||
They are the beachhead — reference customers for everyone else.
|
|
||||||
|
|
||||||
## Differentiators to lead with
|
|
||||||
|
|
||||||
1. **Purchase Inbox (AI)** — forward the supplier's bill, confirm, done. Nobody in the
|
|
||||||
Indian SMB segment does this well yet. This is the demo moment.
|
|
||||||
2. **Counter speed** — publish the numbers: scan-to-line < 150 ms, bill closed in one key.
|
|
||||||
3. **True offline-first** — competitors are either offline-only (like Classic) or
|
|
||||||
cloud-first-with-excuses. Being genuinely both is the moat.
|
|
||||||
4. **Owner app** — live business in the owner's pocket, in their language.
|
|
||||||
5. **WhatsApp-native** — e-bills, payment reminders for khata credit, offer broadcasts.
|
|
||||||
|
|
||||||
## Success metrics
|
|
||||||
|
|
||||||
- Time per billed item at counter (target: ≤ Classic, measured, not vibes)
|
|
||||||
- % of purchase lines entered via Purchase Inbox vs manually (target: 70%+ by Phase 3)
|
|
||||||
- Classic → Next migration rate (target: 80% of active base in year 1)
|
|
||||||
- Sync health: % of stores fully synced within 5 min of connectivity (target: 99%)
|
|
||||||
- Support tickets per store per month (target: half of Classic's rate)
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
# 01 — Product Scope: Modules & Features
|
|
||||||
|
|
||||||
Priorities: **M** = MVP (Phase 1), **2/3/4** = later phase, per [04-ROADMAP.md](04-ROADMAP.md).
|
|
||||||
Edition column shows the lowest plan that includes it (L=Lite, S=Standard, P=Pro, E=Enterprise).
|
|
||||||
|
|
||||||
## 1. Master data
|
|
||||||
|
|
||||||
| Feature | Priority | Edition |
|
|
||||||
|---------|----------|---------|
|
|
||||||
| Items: name (multi-language), HSN, GST rate, MRP, cost, margin, barcode(s) | M | L |
|
|
||||||
| Multi-unit packs (box ↔ piece ↔ kg conversions, buy in one unit sell in another) | M | L |
|
|
||||||
| Batch & expiry tracking (FMCG, pharma, agri-input) | M | P (flag off below) |
|
|
||||||
| Item variants (size/color) for apparel & footwear | 2 | S |
|
|
||||||
| Price lists: MRP-based, margin-based, customer-category pricing | M | S |
|
|
||||||
| GST rate masters with **effective dates** (rates change — Sept 2025 GST 2.0 proved it) | M | L |
|
|
||||||
| Parties: customers & suppliers with GSTIN validation, credit limits, ledgers | M | L |
|
|
||||||
| Schemes & offers: buy-X-get-Y, slab discounts, happy hours, date-bound | 2 | S |
|
|
||||||
| Bulk import/export (Excel/CSV) for every master | M | L |
|
|
||||||
| Item images, shelf location, rack labels | 3 | S |
|
|
||||||
|
|
||||||
## 2. POS / Counter billing (the crown jewel)
|
|
||||||
|
|
||||||
| Feature | Priority | Edition |
|
|
||||||
|---------|----------|---------|
|
|
||||||
| Keyboard-first billing: scan/type → line added, one key to close bill | M | L |
|
|
||||||
| Barcode scanner (keyboard wedge) support | M | L |
|
|
||||||
| Fast fuzzy item search (name, code, partial, phonetic) — results < 150 ms | M | L |
|
|
||||||
| Hold / resume multiple bills per counter | M | L |
|
|
||||||
| Weighing-scale barcodes (EAN-13 `2x` prefix embedding PLU + weight/price) | 2 | P |
|
|
||||||
| Direct weighing-scale port integration (weight into line) | 3 | P |
|
|
||||||
| Payment split: cash / UPI / card / credit(khata) / mixed | M | L |
|
|
||||||
| UPI dynamic QR on screen & customer display, auto-confirm on webhook (online) | 2 | S |
|
|
||||||
| Thermal print (ESC/POS 2"/3"), A4/A5 invoice formats, print profiles per counter | M | L |
|
|
||||||
| WhatsApp/SMS e-bill instead of (or with) paper | 2 | L |
|
|
||||||
| Returns & exchanges against original bill; credit notes | M | L |
|
|
||||||
| Salesman tagging per bill/line (commission reporting) | 2 | S |
|
|
||||||
| Customer display (second screen: lines, total, QR, promos) | 3 | P |
|
|
||||||
| Counter shift open/close, cash denominations count, cashier reconciliation | M | S |
|
|
||||||
| Offline always: every one of the above works with zero internet | M | L |
|
|
||||||
| Multi-counter per store, per-counter GST-compliant invoice series (e.g. `ST1C2-00123`) | M | S |
|
|
||||||
| Day-end closing report (sales, payments, cash variance) auto-generated | M | L |
|
|
||||||
|
|
||||||
## 3. Purchases
|
|
||||||
|
|
||||||
| Feature | Priority | Edition |
|
|
||||||
|---------|----------|---------|
|
|
||||||
| Purchase invoice entry with margin/MRP capture and auto price update prompts | M | L |
|
|
||||||
| PO → GRN → Invoice flow with pending-PO tracking | 2 | P |
|
|
||||||
| **Purchase Inbox**: email-in address + upload + WhatsApp forward; AI parses PDF/image/e-invoice JSON into draft purchase; staff reviews & confirms | 3 | P |
|
|
||||||
| Item matching memory (supplier's item name ↔ our item, learns once, reuses forever) | 3 | P |
|
|
||||||
| GSTR-2B reconciliation: pull portal data, match against entered purchases | 3 | P |
|
|
||||||
| Barcode label printing from purchase (batch, MRP, packed date) | M | S |
|
|
||||||
| Supplier price history & last-cost warnings | 2 | S |
|
|
||||||
| Purchase returns / debit notes | M | L |
|
|
||||||
|
|
||||||
## 4. Inventory
|
|
||||||
|
|
||||||
| Feature | Priority | Edition |
|
|
||||||
|---------|----------|---------|
|
|
||||||
| Live stock by store/location, valuation (FIFO / weighted avg) | M | L |
|
|
||||||
| Stock adjustments with reason codes & approval | M | L |
|
|
||||||
| Stock take: full & cycle counts, mobile scanning app for counting | 3 | P |
|
|
||||||
| Reorder levels, auto reorder suggestions (by sales velocity) | 2 | S |
|
|
||||||
| Inter-store transfer with in-transit state, e-Way bill where applicable | 3 | E |
|
|
||||||
| Expiry dashboard: near-expiry, dead stock, fast/slow movers | 2 | P |
|
|
||||||
| Wastage/damage logging | M | S |
|
|
||||||
|
|
||||||
## 5. Accounting & GST compliance
|
|
||||||
|
|
||||||
| Feature | Priority | Edition |
|
|
||||||
|---------|----------|---------|
|
|
||||||
| Party ledgers, receivables/payables, ageing, collection reminders (WhatsApp) | M | L |
|
|
||||||
| Cash & bank books, payment/receipt vouchers, expense entry | M | S |
|
|
||||||
| Day book, trial balance, P&L, balance sheet | 2 | P |
|
|
||||||
| **Tally export** (masters + vouchers) for customers whose CA lives in Tally | 2 | S |
|
|
||||||
| GST invoice formats (B2B/B2C), HSN summary | M | L |
|
|
||||||
| GSTR-1 & GSTR-3B report/JSON export | 2 | S |
|
|
||||||
| e-Invoice (IRN via GSP API) — mandatory for B2B above turnover threshold | 2 | P |
|
|
||||||
| e-Way bill generation | 2 | P |
|
|
||||||
| Tamper-evident audit trail / edit log (MCA audit-trail rule for company clients) | M | S |
|
|
||||||
| Financial year close, data lock dates | 2 | S |
|
|
||||||
|
|
||||||
## 6. Multi-store & head office
|
|
||||||
|
|
||||||
| Feature | Priority | Edition |
|
|
||||||
|---------|----------|---------|
|
|
||||||
| Store onboarding under one tenant, central item/price masters with store overrides | 3 | E |
|
|
||||||
| HO console: consolidated sales, stock, GST across outlets | 3 | E |
|
|
||||||
| Central purchasing & distribution to outlets | 4 | E |
|
|
||||||
| Franchise/outlet-wise P&L | 4 | E |
|
|
||||||
|
|
||||||
## 7. Users, roles, audit
|
|
||||||
|
|
||||||
| Feature | Priority | Edition |
|
|
||||||
|---------|----------|---------|
|
|
||||||
| Role-based permissions (cashier, supervisor, purchaser, accountant, owner, auditor) | M | S |
|
|
||||||
| Sensitive-action gates: discount above X%, bill void, price override → supervisor PIN | M | S |
|
|
||||||
| Append-only audit log of every edit/delete with before/after values | M | S |
|
|
||||||
| Per-user activity reports | 2 | S |
|
|
||||||
|
|
||||||
## 8. Reports, analytics, owner app
|
|
||||||
|
|
||||||
| Feature | Priority | Edition |
|
|
||||||
|---------|----------|---------|
|
|
||||||
| Core reports: sales, margins, GST, stock, party outstanding — with export | M | L |
|
|
||||||
| Owner mobile app: today's sales live, payments, stock alerts, approvals | 3 | S |
|
|
||||||
| Dashboards: trends, category performance, counter productivity | 3 | P |
|
|
||||||
| Scheduled reports to email/WhatsApp (daily digest) | 3 | S |
|
|
||||||
|
|
||||||
## 9. Customer engagement
|
|
||||||
|
|
||||||
| Feature | Priority | Edition |
|
|
||||||
|---------|----------|---------|
|
|
||||||
| Customer profiles with purchase history (phone-number keyed) | M | L |
|
|
||||||
| Loyalty points, redemption at counter | 3 | P |
|
|
||||||
| Khata credit with WhatsApp payment reminders + UPI collect link | 2 | L |
|
|
||||||
| Offer broadcasts (WhatsApp, opt-in) | 4 | P |
|
|
||||||
|
|
||||||
## 10. Platform (invisible but decisive)
|
|
||||||
|
|
||||||
| Feature | Priority | Edition |
|
|
||||||
|---------|----------|---------|
|
|
||||||
| Licensing & subscription service; editions via feature flags; in-app upgrade | M | — |
|
|
||||||
| Silent auto-update with staged rollout & rollback | M | — |
|
|
||||||
| Sync engine + sync health dashboard (see 02-ARCHITECTURE) | M | — |
|
|
||||||
| Automatic encrypted cloud backup of every store DB | M | — |
|
|
||||||
| Telemetry & crash reporting (respecting data privacy / DPDP Act) | M | — |
|
|
||||||
| **SiMS Classic migration tool** (Oracle schema → Next), with verification report | M | — |
|
|
||||||
| Multi-language UI: English + Hindi + regional (start with your customer base's top 2) | 2 | L |
|
|
||||||
| Onboarding wizard: GSTIN fetch → store profile → import items → first bill in 15 min | 2 | — |
|
|
||||||
|
|
||||||
## Explicitly out of scope for v1
|
|
||||||
|
|
||||||
- Restaurant/QSR mode (tables, KOT) — different product shape; revisit after Phase 3.
|
|
||||||
- E-commerce storefront / ONDC integration — Phase 4+ candidate.
|
|
||||||
- Manufacturing/BOM — only if the existing base demands it.
|
|
||||||
- Payroll — never; integrate instead.
|
|
||||||
@ -1,196 +0,0 @@
|
|||||||
# 02 — Architecture: Offline vs Online, Sync, Stack
|
|
||||||
|
|
||||||
## 1. The offline/online question (the brainstorm you asked for)
|
|
||||||
|
|
||||||
### Option A — Pure offline (what Classic is)
|
|
||||||
|
|
||||||
**Benefits**
|
|
||||||
- Counter never depends on internet; zero latency; works in tier-3 towns with bad connectivity.
|
|
||||||
- No hosting cost; customer feels "my data is mine, in my shop."
|
|
||||||
- Simple mental model, simple debugging (one machine, one DB).
|
|
||||||
|
|
||||||
**Defects**
|
|
||||||
- No multi-store, no owner-away-from-shop visibility, no Purchase Inbox (needs cloud).
|
|
||||||
- Support requires reaching into the shop machine; updates are a door-to-door problem.
|
|
||||||
- Data loss when the shop PC dies and backups were "supposed to happen."
|
|
||||||
- Licensing/piracy enforcement is weak; subscriptions are hard to enforce.
|
|
||||||
- No telemetry: you learn about bugs from angry calls.
|
|
||||||
|
|
||||||
### Option B — Pure cloud (browser POS, server is truth)
|
|
||||||
|
|
||||||
**Benefits**
|
|
||||||
- One deployment, instant updates, central data, easy multi-store, easy licensing.
|
|
||||||
- Any device with a browser becomes a counter.
|
|
||||||
|
|
||||||
**Defects**
|
|
||||||
- **Internet dies → billing dies. Fatal in India.** This alone disqualifies pure cloud
|
|
||||||
for the counter. Competitors who went cloud-first all retrofitted awkward offline modes.
|
|
||||||
- Every scan pays network latency; "fast counter" becomes a fight with physics.
|
|
||||||
- Browser hardware access (thermal printers, cash drawers, scales) is painful.
|
|
||||||
- Customer trust: "my sales data lives on your server?" is a real sales objection.
|
|
||||||
|
|
||||||
### Option C — Local-first hybrid (the answer)
|
|
||||||
|
|
||||||
**The rule: the counter reads and writes a local database, always. The cloud is a
|
|
||||||
sync target, a backup, a reporting brain, and a licensing authority — never a
|
|
||||||
dependency for billing.**
|
|
||||||
|
|
||||||
**Benefits**
|
|
||||||
- Billing works identically with or without internet — offline is not a "mode," it's
|
|
||||||
the architecture. Sync happens in the background when connectivity exists.
|
|
||||||
- Gets everything cloud promises: multi-store, owner app, Purchase Inbox, central
|
|
||||||
reporting, subscriptions, telemetry, auto-update, off-site backup.
|
|
||||||
- Latency at counter is local-DB latency (~0 ms network).
|
|
||||||
|
|
||||||
**Defects (and how we contain them)**
|
|
||||||
- *Sync is genuinely hard.* → Contain it with strict design rules (§3) and buy, don't
|
|
||||||
build, where possible. Sync is the single biggest technical risk of this project —
|
|
||||||
it gets a proof-of-concept before anything else (see roadmap Phase 0).
|
|
||||||
- *Conflicts.* → Design them away: transactional documents are append-only and
|
|
||||||
counter-scoped (they cannot conflict); only master-data edits can conflict, and those
|
|
||||||
get last-write-wins + full audit trail + rare-enough-to-review.
|
|
||||||
- *Version skew* (store app v2.3 talking to cloud v2.5). → Versioned sync protocol,
|
|
||||||
additive-only schema changes between forced upgrades.
|
|
||||||
- *Harder debugging.* → Sync health dashboard per store, op logs, replayability.
|
|
||||||
|
|
||||||
**Decision: Option C.** Back office can lean online (it tolerates a spinner; the counter
|
|
||||||
cannot), owner app is online-first with cached reads, POS is local-first, period.
|
|
||||||
|
|
||||||
## 2. System topology
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────┐
|
|
||||||
│ CLOUD │
|
|
||||||
│ Multi-tenant Postgres (RLS by tenant) │
|
|
||||||
│ API + Sync service Licensing/Plans │
|
|
||||||
│ Purchase Inbox (email ⇢ AI parse) │
|
|
||||||
│ Reporting/analytics Backups │
|
|
||||||
│ GSP bridge (e-Invoice / e-Way) │
|
|
||||||
│ WhatsApp/SMS/UPI integrations │
|
|
||||||
└─────────▲──────────────────▲─────────────┘
|
|
||||||
│ sync (queued ops) │ https
|
|
||||||
┌────────────────────┴───┐ ┌────┴─────────────┐
|
|
||||||
│ STORE (each) │ │ Back-office Web │
|
|
||||||
│ ┌──────────────────┐ │ │ (browser, admin │
|
|
||||||
│ │ Store Hub (opt.) │ │ │ & reports) │
|
|
||||||
│ │ local sync fan-in│ │ └──────────────────┘
|
|
||||||
│ └───▲────────▲─────┘ │ ┌──────────────────┐
|
|
||||||
│ ┌───┴───┐ ┌──┴────┐ │ │ Owner Mobile │
|
|
||||||
│ │ POS 1 │ │ POS 2 │… │ │ (online + cache)│
|
|
||||||
│ │SQLite │ │SQLite │ │ └──────────────────┘
|
|
||||||
│ └───────┘ └───────┘ │
|
|
||||||
│ printers, scanner, │
|
|
||||||
│ scale, cash drawer, │
|
|
||||||
│ customer display │
|
|
||||||
└────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- **POS client**: desktop app, embedded SQLite is the source of truth for that counter.
|
|
||||||
- **Store Hub** (optional, Standard+): one machine in the store acts as LAN relay so
|
|
||||||
counters see each other's bills/stock instantly even when the internet is down;
|
|
||||||
it also fan-ins sync to cloud. For single-counter Lite, the POS syncs direct to cloud.
|
|
||||||
- **Cloud**: multi-tenant; per-tenant row isolation; all cross-store logic lives here.
|
|
||||||
|
|
||||||
## 3. Sync design rules (write these on the wall)
|
|
||||||
|
|
||||||
1. **Client-generated IDs everywhere** (UUIDv7). No "wait for server to get an ID."
|
|
||||||
2. **Transactional documents are immutable events.** A bill, once closed, is an
|
|
||||||
append-only fact. Corrections are new documents (credit note, amendment) referencing
|
|
||||||
the original. This kills 90% of conflict scenarios and satisfies audit requirements
|
|
||||||
in one stroke.
|
|
||||||
3. **Invoice numbering is per-counter series** — e.g. `ST1C2-000123` (GST allows multiple
|
|
||||||
series; ≤16 chars, unique & consecutive within a series per FY). Counters can therefore
|
|
||||||
number bills offline with zero coordination.
|
|
||||||
4. **Outbox/inbox pattern**: every local write that must reach the cloud goes into a local
|
|
||||||
outbox table in the same SQLite transaction as the write itself; a background worker
|
|
||||||
drains it with retries + idempotency keys. Downstream (cloud→store) is a cursor-based
|
|
||||||
pull of changes since last sync.
|
|
||||||
5. **Master data flows down, transactions flow up.** Masters are edited in back office
|
|
||||||
(cloud) and pushed to stores; store-side master edits (allowed on lower plans) sync up
|
|
||||||
with last-write-wins + audit log.
|
|
||||||
6. **Stock is derived, never synced as a number.** Each store's stock = fold of its local
|
|
||||||
events; cloud recomputes global views. Never ship "stock = 47" between nodes — ship the
|
|
||||||
movements.
|
|
||||||
7. **Clocks lie.** Order by (lamport counter, device id), keep wall-clock only for display.
|
|
||||||
8. **Sync must be observable**: per-store last-sync time, pending-op count, error surface
|
|
||||||
in both the POS status bar and a support dashboard.
|
|
||||||
|
|
||||||
**Build vs buy:** evaluate PowerSync and ElectricSQL (Postgres ⇄ SQLite sync engines)
|
|
||||||
against a hand-rolled outbox in the Phase-0 spike. Rule of thumb: buy the pipe if it fits,
|
|
||||||
but the *semantic* rules above stay ours either way.
|
|
||||||
|
|
||||||
## 4. Multi-tenancy & audit
|
|
||||||
|
|
||||||
- Single Postgres cluster, `tenant_id` on every row, enforced with Row-Level Security;
|
|
||||||
the API sets tenant context from the auth token. (Schema-per-tenant only if a large
|
|
||||||
Enterprise chain demands isolation — don't start there.)
|
|
||||||
- Tenant = the client business; stores, counters, users hang off it. All client-specific
|
|
||||||
compliance settings (GST scheme, FY, invoice formats, rounding rules) are tenant config.
|
|
||||||
- **Audit**: append-only `audit_log` (who, what, before, after, when, where) written in the
|
|
||||||
same transaction as the change, synced up, never deletable — this covers the MCA
|
|
||||||
audit-trail requirement for company clients and your own support forensics.
|
|
||||||
- Backups: continuous cloud PITR + nightly encrypted per-tenant export; store-side SQLite
|
|
||||||
snapshots shipped to cloud so a dead shop PC costs minutes, not the business.
|
|
||||||
- DPDP Act hygiene: customer phone numbers are personal data — encrypt at rest, purge on
|
|
||||||
request, keep telemetry free of PII.
|
|
||||||
|
|
||||||
## 5. GST/compliance layer (data-driven, never hard-coded)
|
|
||||||
|
|
||||||
- GST rates, cess, and slab structures are **dated configuration**, not code — the
|
|
||||||
Sept 2025 GST 2.0 slab overhaul is the proof that rates change under you.
|
|
||||||
- e-Invoice (IRN) and e-Way bill via a **GSP aggregator API** (ClearTax / Masters India /
|
|
||||||
similar) rather than direct IRP integration — one decision in [06-DECISIONS.md](06-DECISIONS.md).
|
|
||||||
- Queue-and-forward: bills that need IRN but were made offline are queued and registered
|
|
||||||
when online, within the reporting window; the POS shows pending-IRN status.
|
|
||||||
|
|
||||||
## 6. Tech stack — recommendation
|
|
||||||
|
|
||||||
**Primary recommendation: TypeScript end-to-end.** One language, the largest hiring pool
|
|
||||||
in India, one skill set across all four surfaces, and the web back office shares
|
|
||||||
components with the POS UI.
|
|
||||||
|
|
||||||
| Layer | Choice | Why |
|
|
||||||
|-------|--------|-----|
|
|
||||||
| POS desktop | **Electron + React + SQLite** | Mature hardware story on Windows (ESC/POS, serialport for scales/drawers/displays), offline SQLite, auto-update solved, huge ecosystem. Tauri is the leaner alternative — decide after the Phase-0 hardware spike. |
|
|
||||||
| Back office | **React SPA** (same design system as POS) | Browser-only, no install; admins tolerate online-only. |
|
|
||||||
| Owner app | **React Native** | Shares TS models/API client; Android-first (your market), iOS later. |
|
|
||||||
| API/backend | **Node.js + NestJS + PostgreSQL + Redis** | Structured enough for a long-lived product; trivially hireable. |
|
|
||||||
| Sync | PowerSync / ElectricSQL **or** custom outbox — Phase-0 spike decides | Biggest risk, prove it first. |
|
|
||||||
| AI (Purchase Inbox) | Claude API (vision + structured output) behind our own parsing service | PDF/photo invoice → structured draft purchase; item-matching memory in Postgres. |
|
|
||||||
| Infra | Managed Postgres + containers on an Indian region (data residency comfort) | Mumbai region; boring and reliable. |
|
|
||||||
|
|
||||||
> **Note (D12, decided 2026-07-09):** the store-tier DB remains the existing **Oracle 12c**
|
|
||||||
> for now — it is shared infrastructure running other systems. Counters keep their local
|
|
||||||
> SQLite buffer (offline bill queue + item cache); the Store Hub role is played by the
|
|
||||||
> Oracle box; sync is a custom outbox (D3 → build). Adapted topology, guardrails, and
|
|
||||||
> revisit triggers: [07-DB-AND-CONFIG.md](07-DB-AND-CONFIG.md) §3.
|
|
||||||
>
|
|
||||||
> **Staging (D14, 2026-07-09):** the cloud tier in the topology above is switched on
|
|
||||||
> *after* the local-only v1 ships. From the first build, all writes carry client UUIDs
|
|
||||||
> and dormant outbox rows so cloud enablement is a switch-on, not a migration.
|
|
||||||
|
|
||||||
**Considered alternatives** (kept honest in one line each):
|
|
||||||
- **Flutter everywhere**: one codebase POS-desktop + Android-POS + owner app; weaker
|
|
||||||
Windows-desktop/hardware maturity and you'd still want a web back office. Strong option
|
|
||||||
if Android POS terminals are a v1 target — see 06-DECISIONS.
|
|
||||||
- **.NET (WPF/WinUI + ASP.NET Core)**: best raw Windows hardware story; smaller shared-code
|
|
||||||
story across web/mobile, second hiring pool. Solid, less leverage.
|
|
||||||
|
|
||||||
## 7. Hardware integration checklist (POS)
|
|
||||||
|
|
||||||
Barcode scanners (keyboard wedge — free), ESC/POS thermal printers 2"/3" (network/USB),
|
|
||||||
A4/A5 laser printing, cash drawers (printer-kick), weighing scales (RS-232/USB, and
|
|
||||||
label-scale EAN-13 `2x` barcodes), customer-facing display (second monitor or pole
|
|
||||||
display), UPS-friendly crash recovery (journaled SQLite, resume mid-bill after power cut).
|
|
||||||
|
|
||||||
## 8. Non-functional targets
|
|
||||||
|
|
||||||
| Metric | Target |
|
|
||||||
|--------|--------|
|
|
||||||
| Scan/type → line rendered | < 150 ms |
|
|
||||||
| Item search (50k SKUs) | < 150 ms |
|
|
||||||
| Bill close + print spool | < 1 s |
|
|
||||||
| POS cold start to billable | < 5 s |
|
|
||||||
| Offline duration tolerated | Unlimited (sync catches up) |
|
|
||||||
| Store→cloud sync lag when online | < 5 min p99 |
|
|
||||||
| POS crash data loss | Zero committed lines (journaled writes) |
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
# 03 — Frontend Plan
|
|
||||||
|
|
||||||
Four surfaces, one design system, one TypeScript codebase philosophy.
|
|
||||||
|
|
||||||
## Surfaces
|
|
||||||
|
|
||||||
| Surface | Tech | Mode | Users |
|
|
||||||
|---------|------|------|-------|
|
|
||||||
| **POS Counter** (desktop) | Electron + React | Local-first, offline always | Cashiers, supervisors |
|
|
||||||
| **Back Office** (web) | React SPA | Online (tolerates it) | Owner, manager, purchaser, accountant |
|
|
||||||
| **Owner App** (mobile) | React Native, Android-first | Online + cached | Owner on the move |
|
|
||||||
| **Customer Display** | Second window/screen from POS | Local | The person in the queue |
|
|
||||||
|
|
||||||
## The counter creed (UX principles for POS)
|
|
||||||
|
|
||||||
1. **Keyboard is king.** Every billing action reachable without a mouse. Classic users'
|
|
||||||
muscle memory is the migration moat — publish the keymap, keep it configurable,
|
|
||||||
offer a "Classic keys" preset that mirrors the old Oracle Forms bindings.
|
|
||||||
2. **The scan line is sacred.** Focus always returns to the scan/search box. No dialog,
|
|
||||||
toast, or sync event may steal focus mid-bill. Ever.
|
|
||||||
3. **One screen, no navigation.** Billing is a single screen: item entry, bill lines,
|
|
||||||
totals, payment. Payment is an overlay, not a page. Target: close a cash bill with
|
|
||||||
**one keystroke** from the last scanned item.
|
|
||||||
4. **Errors never trap.** Unknown barcode → inline quick-add or skip, keep billing.
|
|
||||||
Printer jam → bill saves, reprint later. Sync down → icon changes color, nothing else.
|
|
||||||
5. **Speed is visible.** Show per-bill timing internally (telemetry); we optimize what
|
|
||||||
we measure. The queue is the enemy.
|
|
||||||
6. **Touch is secondary but real** — large hit targets on the right rail for
|
|
||||||
touch-counter deployments; never at the cost of keyboard flow.
|
|
||||||
7. **Language switch per user** — cashier sees Hindi/regional, owner sees English,
|
|
||||||
same install.
|
|
||||||
8. **Every state is recoverable** — power cut mid-bill → app reopens with the bill
|
|
||||||
intact (journaled local writes).
|
|
||||||
|
|
||||||
## POS screen inventory
|
|
||||||
|
|
||||||
| Screen | Notes |
|
|
||||||
|--------|-------|
|
|
||||||
| Login / shift open | PIN-fast; denomination count on open |
|
|
||||||
| **Billing** (the screen) | Scan box, lines grid, totals, customer picker (phone), hold/resume tray, payment overlay (cash/UPI-QR/card/khata/split), quick-keys row for loose produce |
|
|
||||||
| Returns/exchange | Lookup by bill no / phone / scan of e-bill QR |
|
|
||||||
| Bill history & reprint | Day-scoped by default |
|
|
||||||
| Item quick-add | Minimal fields, flagged for back-office completion |
|
|
||||||
| Shift close / day-end | Cash count, variance, day report print |
|
|
||||||
| Supervisor overlay | PIN-gated: void, discount override, price change |
|
|
||||||
| Settings/diagnostics | Printers, scale, display, sync status, keymap |
|
|
||||||
|
|
||||||
## Back office screen inventory (by module)
|
|
||||||
|
|
||||||
- **Dashboard**: today across counters/stores, alerts (low stock, near expiry, khata due, sync health).
|
|
||||||
- **Catalog**: items list (bulk edit, import), item editor, categories, price lists, schemes, label printing queue.
|
|
||||||
- **Purchases**: purchase list, entry form, **Purchase Inbox review queue** (parsed draft ⇄ source PDF side-by-side, item-match confirmations), supplier ledger, GSTR-2B reconciliation.
|
|
||||||
- **Inventory**: stock view (filters: store/category/batch/expiry), adjustments, transfers, stock-take sessions.
|
|
||||||
- **Sales**: bill browser, returns, customer profiles, loyalty, khata & collections (WhatsApp reminder actions).
|
|
||||||
- **Accounting**: vouchers, ledgers, day book, P&L/BS, Tally export, GST returns (GSTR-1/3B), e-Invoice/e-Way queues.
|
|
||||||
- **HO console** (Enterprise): store comparison, central pricing pushes, consolidated GST.
|
|
||||||
- **Admin**: users/roles/permission matrix, audit log browser, stores & counters, subscription/plan, backup status.
|
|
||||||
|
|
||||||
## Owner app (v1 scope, deliberately small)
|
|
||||||
|
|
||||||
Today's sales (live), payments breakdown, top items, stock alerts, khata outstanding,
|
|
||||||
approval requests (big discounts/voids), daily digest notification. Read-mostly; one
|
|
||||||
write action: approve/deny.
|
|
||||||
|
|
||||||
## Design system
|
|
||||||
|
|
||||||
- **Tokens first** (color/spacing/type in one package) — POS, web, and mobile consume the
|
|
||||||
same tokens; theme per surface (POS: high-contrast, dense; back office: comfortable).
|
|
||||||
- Dark and light from day one (night-shift counters love dark).
|
|
||||||
- Numerals: tabular figures everywhere money appears; ₹ formatting with Indian digit
|
|
||||||
grouping (1,23,456.78) in one shared formatter.
|
|
||||||
- Grid/table component gets the most investment — half the back office is tables.
|
|
||||||
Virtualized, keyboard-navigable, exportable.
|
|
||||||
- Print templates (thermal 2"/3", A4/A5 invoice, labels) as data-driven templates with a
|
|
||||||
visual preview editor in settings — per-tenant customization without code. This was a
|
|
||||||
Reports-era pain point; make it a strength.
|
|
||||||
- Component stack suggestion: Radix primitives + Tailwind + our tokens (or Mantine if
|
|
||||||
speed beats control). Icon set: Lucide. Charts: lightweight (recharts) in back office only.
|
|
||||||
|
|
||||||
## Frontend engineering rules
|
|
||||||
|
|
||||||
- Local SQLite is the POS source of truth; UI reads via a thin repository layer —
|
|
||||||
no component ever awaits the network on the billing path.
|
|
||||||
- State: keep it boring — TanStack Query for server state (back office), Zustand for
|
|
||||||
POS session state; no global state kitchen-sink.
|
|
||||||
- Every screen testable headless: business logic in plain TS modules, UI is a shell.
|
|
||||||
Billing engine (pricing, tax, rounding, schemes) is a **pure, versioned package** with
|
|
||||||
golden-file tests — the same engine runs in POS, back office, and cloud so a bill
|
|
||||||
computes identically everywhere.
|
|
||||||
- Feature flags evaluated client-side from the license token; locked features render
|
|
||||||
as discoverable upsells, not hidden.
|
|
||||||
- i18n from the first commit (string files, no literals) — retrofitting is misery.
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
# 14 — Editions: One Codebase, Four Configurable Plans
|
|
||||||
|
|
||||||
**Hard rule:** every cell below is a **DB row** (`plan`, `plan_feature`, `price_book`,
|
|
||||||
`setting` — 07-DB-AND-CONFIG §2), never code. Changing a limit, price, or privilege is
|
|
||||||
configuration; new editions are new rows. Upgrades apply in place — the paywall is a demo.
|
|
||||||
|
|
||||||
## Identity & positioning
|
|
||||||
|
|
||||||
| Dimension | Lite | Standard | Pro | Enterprise |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Target business | Mini/small shop, kirana | Medium store | Supermarket, pharmacy | Chains, multi-outlet |
|
|
||||||
| Positioning | "Bill in 15 minutes" | "Run the whole store" | "Run it at scale" | "Run every store" |
|
|
||||||
| Price posture (₹ = D5, rows in `price_book`) | Entry; monthly allowed | Mid; annual preferred | Premium; annual | Custom quote |
|
|
||||||
| Billing basis | Per store | Per store + per counter | Per store + per counter | Per org, negotiated |
|
|
||||||
| Support privilege | Dealer/WhatsApp | Dealer priority | Priority + remote fix | Dedicated + SLA |
|
|
||||||
|
|
||||||
## Capacity limits (rows in `plan_feature`, enforced at runtime)
|
|
||||||
|
|
||||||
| Limit | Lite | Standard | Pro | Enterprise |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Counters | 1 | 3 | 8 | Unlimited |
|
|
||||||
| Stores | 1 | 1 | 2 | Unlimited |
|
|
||||||
| Users | 2 | 6 | 15 | Unlimited |
|
|
||||||
| SKUs (validated target) | 5,000 | 15,000 | 100,000 | 100,000+ |
|
|
||||||
| Bills/day (validated target) | 200 | 600 | 2,500 | 2,500/store |
|
|
||||||
| Held bills per counter | 3 | 10 | 20 | 20 |
|
|
||||||
|
|
||||||
## Feature privileges (rows in `plan_feature`; ✗ renders as upsell, never hidden)
|
|
||||||
|
|
||||||
| Feature | Lite | Standard | Pro | Enterprise |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| POS billing, suggestions, quick-add, khata, customer attach | ✓ | ✓ | ✓ | ✓ |
|
|
||||||
| GST invoices, HSN summary, core reports | ✓ | ✓ | ✓ | ✓ |
|
|
||||||
| Purchase entry + last-cost + drafts | ✓ | ✓ | ✓ | ✓ |
|
|
||||||
| Multi-counter + per-counter series | ✗ | ✓ | ✓ | ✓ |
|
|
||||||
| Schemes & offers, salesman tagging | ✗ | ✓ | ✓ | ✓ |
|
|
||||||
| Label printing | ✗ | ✓ | ✓ | ✓ |
|
|
||||||
| GSTR-1/3B exports, Tally export | ✗ | ✓ | ✓ | ✓ |
|
|
||||||
| Owner app | ✗ | ✓ | ✓ | ✓ |
|
|
||||||
| Weighing-scale barcodes & scale port | ✗ | ✗ | ✓ | ✓ |
|
|
||||||
| Batch/expiry tracking | ✗ | ✗ | ✓ | ✓ |
|
|
||||||
| Loyalty | ✗ | ✗ | ✓ | ✓ |
|
|
||||||
| Purchase Inbox (AI) | ✗ | ✗ | ✓ | ✓ |
|
|
||||||
| e-Invoice (IRN) + e-Way | ✗ | ✗ | ✓ | ✓ |
|
|
||||||
| Full accounting (P&L/BS) | ✗ | ✗ | ✓ | ✓ |
|
|
||||||
| B2B counter billing (GSTIN, IGST) | ✗ | ✓ | ✓ | ✓ |
|
|
||||||
| Multi-store HO console, transfers, central pricing | ✗ | ✗ | ✗ | ✓ |
|
|
||||||
| API access | ✗ | ✗ | ✗ | ✓ |
|
|
||||||
|
|
||||||
## Advantages story (what the dealer sells)
|
|
||||||
|
|
||||||
| Edition | The advantage in one line |
|
|
||||||
|---|---|
|
|
||||||
| Lite | Cheapest way to bill legally, fast, with khata and WhatsApp — zero training |
|
|
||||||
| Standard | Everything the counter and the owner need for one busy store |
|
|
||||||
| Pro | Supermarket-grade: scale barcodes, batch/expiry, AI purchases, compliance automation |
|
|
||||||
| Enterprise | One brain for every outlet: central pricing, consolidated GST, APIs |
|
|
||||||
|
|
||||||
## Configurability rules
|
|
||||||
|
|
||||||
| Rule | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| Cells are rows | Every value above lives in `plan`/`plan_feature`/`price_book` — no release to change |
|
|
||||||
| Per-tenant overrides | A specific tenant can be granted any single privilege via a tenant-scope row |
|
|
||||||
| In-place upgrade | Locked feature → upsell panel → plan change → unlock without reinstall |
|
|
||||||
| Grace, never strand | Expiry enters grace (setting row); billing never stops mid-day |
|
|
||||||
| New editions | A fifth edition = new rows; the UI renders it automatically |
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* Brute-force lockout as a pure reducer so POS and back office share the exact
|
|
||||||
* policy and it is testable without a clock. Attempts are audit-logged by the
|
|
||||||
* caller (I7: every mutation audit-logged in the same transaction).
|
|
||||||
*/
|
|
||||||
export interface LockoutPolicy {
|
|
||||||
maxFailures: number
|
|
||||||
lockMs: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_LOCKOUT: LockoutPolicy = { maxFailures: 5, lockMs: 60_000 }
|
|
||||||
|
|
||||||
export interface LockoutState {
|
|
||||||
failures: number
|
|
||||||
lockedUntilMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FRESH_LOCKOUT: LockoutState = { failures: 0 }
|
|
||||||
|
|
||||||
export function isLocked(state: LockoutState, nowMs: number): boolean {
|
|
||||||
return state.lockedUntilMs !== undefined && nowMs < state.lockedUntilMs
|
|
||||||
}
|
|
||||||
|
|
||||||
export function recordFailure(
|
|
||||||
state: LockoutState,
|
|
||||||
nowMs: number,
|
|
||||||
policy: LockoutPolicy = DEFAULT_LOCKOUT,
|
|
||||||
): LockoutState {
|
|
||||||
const failures = state.failures + 1
|
|
||||||
if (failures >= policy.maxFailures) {
|
|
||||||
return { failures, lockedUntilMs: nowMs + policy.lockMs }
|
|
||||||
}
|
|
||||||
return { failures }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function recordSuccess(): LockoutState {
|
|
||||||
return FRESH_LOCKOUT
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import type { RoleCode, User } from '@sims/domain'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Action codes gate everything sensitive. Role→action grants ship as defaults
|
|
||||||
* below but live as DB rows in production (config-driven principle) so a
|
|
||||||
* tenant can tighten or loosen without a release. Gate semantics follow
|
|
||||||
* 09-UX §5.5: role gates hide navigation but in-flow actions raise the
|
|
||||||
* supervisor-PIN overlay instead of disappearing.
|
|
||||||
*/
|
|
||||||
export type ActionCode =
|
|
||||||
| 'BILL_CREATE'
|
|
||||||
| 'BILL_VOID'
|
|
||||||
| 'PRICE_OVERRIDE'
|
|
||||||
| 'DISCOUNT_OVER_CAP'
|
|
||||||
| 'DRAWER_NOSALE'
|
|
||||||
| 'REPRINT_EXCESS'
|
|
||||||
| 'RETURN_CREATE'
|
|
||||||
| 'DAY_BEGIN'
|
|
||||||
| 'DAY_END'
|
|
||||||
| 'SHIFT_OPEN'
|
|
||||||
| 'SHIFT_CLOSE'
|
|
||||||
| 'PURCHASE_ENTRY'
|
|
||||||
| 'STOCK_ADJUST'
|
|
||||||
| 'MASTER_EDIT'
|
|
||||||
| 'SETTINGS_EDIT'
|
|
||||||
| 'USER_MANAGE'
|
|
||||||
| 'REPORT_VIEW'
|
|
||||||
| 'AUDIT_VIEW'
|
|
||||||
|
|
||||||
export type Gate = 'allow' | 'supervisor-pin' | 'deny'
|
|
||||||
|
|
||||||
export const DEFAULT_ROLE_GRANTS: Record<RoleCode, ActionCode[]> = {
|
|
||||||
owner: [
|
|
||||||
'BILL_CREATE', 'BILL_VOID', 'PRICE_OVERRIDE', 'DISCOUNT_OVER_CAP', 'DRAWER_NOSALE',
|
|
||||||
'REPRINT_EXCESS', 'RETURN_CREATE', 'DAY_BEGIN', 'DAY_END', 'SHIFT_OPEN', 'SHIFT_CLOSE',
|
|
||||||
'PURCHASE_ENTRY', 'STOCK_ADJUST', 'MASTER_EDIT', 'SETTINGS_EDIT', 'USER_MANAGE',
|
|
||||||
'REPORT_VIEW', 'AUDIT_VIEW',
|
|
||||||
],
|
|
||||||
manager: [
|
|
||||||
'BILL_CREATE', 'BILL_VOID', 'PRICE_OVERRIDE', 'DISCOUNT_OVER_CAP', 'DRAWER_NOSALE',
|
|
||||||
'REPRINT_EXCESS', 'RETURN_CREATE', 'DAY_BEGIN', 'DAY_END', 'SHIFT_OPEN', 'SHIFT_CLOSE',
|
|
||||||
'PURCHASE_ENTRY', 'STOCK_ADJUST', 'MASTER_EDIT', 'SETTINGS_EDIT', 'REPORT_VIEW',
|
|
||||||
],
|
|
||||||
supervisor: [
|
|
||||||
'BILL_CREATE', 'BILL_VOID', 'PRICE_OVERRIDE', 'DISCOUNT_OVER_CAP', 'DRAWER_NOSALE',
|
|
||||||
'REPRINT_EXCESS', 'RETURN_CREATE', 'SHIFT_OPEN', 'SHIFT_CLOSE', 'REPORT_VIEW',
|
|
||||||
],
|
|
||||||
cashier: ['BILL_CREATE', 'RETURN_CREATE', 'SHIFT_OPEN', 'SHIFT_CLOSE'],
|
|
||||||
purchaser: ['PURCHASE_ENTRY', 'STOCK_ADJUST', 'MASTER_EDIT', 'REPORT_VIEW'],
|
|
||||||
accountant: ['REPORT_VIEW', 'AUDIT_VIEW'],
|
|
||||||
auditor: ['REPORT_VIEW', 'AUDIT_VIEW'],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function can(
|
|
||||||
user: Pick<User, 'roles' | 'active'>,
|
|
||||||
action: ActionCode,
|
|
||||||
grants: Record<RoleCode, ActionCode[]> = DEFAULT_ROLE_GRANTS,
|
|
||||||
): boolean {
|
|
||||||
if (!user.active) return false
|
|
||||||
return user.roles.some((role) => grants[role]?.includes(action))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-flow gate: allowed directly, escalatable via supervisor PIN, or denied. */
|
|
||||||
export function gateFor(
|
|
||||||
user: Pick<User, 'roles' | 'active'>,
|
|
||||||
action: ActionCode,
|
|
||||||
grants: Record<RoleCode, ActionCode[]> = DEFAULT_ROLE_GRANTS,
|
|
||||||
): Gate {
|
|
||||||
if (can(user, action, grants)) return 'allow'
|
|
||||||
const supervisorCould = (['supervisor', 'manager', 'owner'] as RoleCode[]).some((r) =>
|
|
||||||
grants[r]?.includes(action),
|
|
||||||
)
|
|
||||||
return supervisorCould ? 'supervisor-pin' : 'deny'
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import type { BusinessDay, IsoDate } from '@sims/domain'
|
|
||||||
import { isOpen } from '@sims/domain'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A POS session binds user + counter + shift + the Day-Begin business date
|
|
||||||
* (the OG working-day idiom). Billing requires an open day and an open shift.
|
|
||||||
*/
|
|
||||||
export interface PosSession {
|
|
||||||
tenantId: string
|
|
||||||
storeId: string
|
|
||||||
counterId: string
|
|
||||||
userId: string
|
|
||||||
shiftId: string
|
|
||||||
businessDate: IsoDate
|
|
||||||
startedAtWallClock: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openSession(args: {
|
|
||||||
tenantId: string
|
|
||||||
storeId: string
|
|
||||||
counterId: string
|
|
||||||
userId: string
|
|
||||||
shiftId: string
|
|
||||||
day: BusinessDay
|
|
||||||
nowWallClock: string
|
|
||||||
}): PosSession {
|
|
||||||
if (!isOpen(args.day)) throw new Error('Day is closed — run Day Begin before opening a shift')
|
|
||||||
if (args.day.storeId !== args.storeId) throw new Error('Business day belongs to another store')
|
|
||||||
return {
|
|
||||||
tenantId: args.tenantId,
|
|
||||||
storeId: args.storeId,
|
|
||||||
counterId: args.counterId,
|
|
||||||
userId: args.userId,
|
|
||||||
shiftId: args.shiftId,
|
|
||||||
businessDate: args.day.date,
|
|
||||||
startedAtWallClock: args.nowWallClock,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue