chore: bring the full store-product codebase under version control
POS, back office, store-server, all packages, workspace configs, README/ BUILDING, and the WIP HQ-2 plan — everything except gitignored data (client import staging, .env, local DBs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
fbf39d7a51
commit
406839659d
@ -0,0 +1,36 @@
|
||||
# SiMS Next — Store Management Suite (Rebuild)
|
||||
|
||||
A ground-up rebuild of SiMS Store Software: GST-compliant retail management for India —
|
||||
inventory, POS billing, purchases, and accounting — for everything from a single-counter
|
||||
kirana shop to multi-outlet supermarket chains.
|
||||
|
||||
**Why the rebuild:** the current product runs on Oracle Forms & Reports (hard to maintain,
|
||||
buggy, no path to cloud/multi-store), and the market is moving. Same customers, much better
|
||||
product.
|
||||
|
||||
**North star:** the fastest, easiest counter workflow in the market. The customer in line
|
||||
never waits because of the software, and purchase entry is never a burden.
|
||||
|
||||
## Planning documents
|
||||
|
||||
| Doc | What it covers |
|
||||
|-----|----------------|
|
||||
| [docs/00-VISION.md](docs/00-VISION.md) | Product vision, segments/editions, business model, success metrics |
|
||||
| [docs/01-SCOPE.md](docs/01-SCOPE.md) | Full module & feature catalog with priorities and edition mapping |
|
||||
| [docs/02-ARCHITECTURE.md](docs/02-ARCHITECTURE.md) | Offline vs online vs hybrid analysis, sync design, tech stack, multi-tenancy |
|
||||
| [docs/03-FRONTEND.md](docs/03-FRONTEND.md) | App surfaces, counter UX principles, screen inventory, design system |
|
||||
| [docs/04-ROADMAP.md](docs/04-ROADMAP.md) | Phased build plan with exit criteria, pilot & migration strategy |
|
||||
| [docs/05-CHECKLIST.md](docs/05-CHECKLIST.md) | Master build checklist, grouped by area |
|
||||
| [docs/06-DECISIONS.md](docs/06-DECISIONS.md) | Open decisions that need a founder call, with recommendations |
|
||||
| [docs/07-DB-AND-CONFIG.md](docs/07-DB-AND-CONFIG.md) | Everything-in-DB design (messages, plans, settings), local DB engine analysis (D12) |
|
||||
| [docs/08-MARKET-ARCHITECTURES.md](docs/08-MARKET-ARCHITECTURES.md) | How Tally/Marg/GoFrugal/Vyapar & global players architect; Android POS conclusion |
|
||||
| [docs/09-UX-FLOWS-AND-MENUS.md](docs/09-UX-FLOWS-AND-MENUS.md) | Screen-level UX blueprint: POS flows, back-office menu tree, Purchase Inbox, owner app |
|
||||
| [docs/10-ISSUES-REGISTER.md](docs/10-ISSUES-REGISTER.md) | Consolidated failure-mode register across 6 lenses, with Top-10 risk ranking |
|
||||
| [docs/11-ADMIN-SUPPORT-CONSOLE.md](docs/11-ADMIN-SUPPORT-CONSOLE.md) | Settings hierarchy, tenant Admin review, and the vendor-side HQ/support console |
|
||||
| docs/13-SPEC-*.md | Keystroke-level specs: billing polish + purchase entry (P1/P2 backlog) |
|
||||
| [docs/14-SPEC-HQ-CONSOLE.md](docs/14-SPEC-HQ-CONSOLE.md) | HQ ops console for the Classic client base (D15, apps/hq) |
|
||||
| [docs/15-EDITIONS.md](docs/15-EDITIONS.md) | Lite/Standard/Pro/Enterprise: privileges, limits, costs — all configurable DB rows |
|
||||
| [docs/16-PROJECT-RULES.md](docs/16-PROJECT-RULES.md) | The 26 hard rules (Tables Rule, money rules, config-over-code, counter rules) |
|
||||
| [docs/17-SCALE-AND-INTEGRATIONS.md](docs/17-SCALE-AND-INTEGRATIONS.md) | The 7 committed scale gaps + full integrations catalog with status |
|
||||
|
||||
Start with 00-VISION, then 02-ARCHITECTURE, then 04-ROADMAP.
|
||||
@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
/** Back office ↔ store-server client; session token survives reloads. */
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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')
|
||||
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)
|
||||
@ -0,0 +1,30 @@
|
||||
.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; }
|
||||
@ -0,0 +1,59 @@
|
||||
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 { clearSession } 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, ItemsPage, StockPage } from './pages/live'
|
||||
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,
|
||||
'/inventory/stock': StockPage,
|
||||
'/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={() => { clearSession(); 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 />)
|
||||
@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,304 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatINR, type BillLine, type BillTotals } from '@sims/domain'
|
||||
import { renderInvoiceHtml, type InvoiceDoc } from '@sims/printing'
|
||||
import { Badge, Button, DataTable, EmptyState, Field, Modal, Notice, PageHeader, Toolbar } from '@sims/ui'
|
||||
import { createItem, getAudit, getBills, getItems, getParties, getSeller, getStock, type Seller } from '../api'
|
||||
|
||||
/** The first live pages — real data from the store-server (the slice). */
|
||||
|
||||
const inr = (p: unknown) => formatINR(Number(p), { symbol: false })
|
||||
|
||||
function useData<T>(loader: () => Promise<T>, deps: unknown[] = []): { data?: T; error?: string; reload: () => void } {
|
||||
const [data, setData] = useState<T | undefined>()
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const reload = () => {
|
||||
setError(undefined)
|
||||
loader().then(setData).catch((e: Error) => setError(e.message))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(reload, deps)
|
||||
return { data, error, reload }
|
||||
}
|
||||
|
||||
const PAGE = 100
|
||||
|
||||
/** Offset pagination with an explicit "Load more" — R13: a cap the operator can lift, never silent. */
|
||||
function usePaged<T>(
|
||||
loader: (limit: number, offset: number) => Promise<T[]>,
|
||||
deps: unknown[],
|
||||
): { rows: T[]; error?: string; hasMore: boolean; loading: boolean; loadMore: () => void } {
|
||||
const [rows, setRows] = useState<T[]>([])
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const load = (offset: number) => {
|
||||
setLoading(true)
|
||||
setError(undefined)
|
||||
loader(PAGE, offset)
|
||||
.then((page) => {
|
||||
setRows((prev) => (offset === 0 ? page : [...prev, ...page]))
|
||||
setHasMore(page.length === PAGE)
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { setRows([]); load(0) }, deps)
|
||||
return { rows, error, hasMore, loading, loadMore: () => load(rows.length) }
|
||||
}
|
||||
|
||||
export function ItemsPage() {
|
||||
const [q, setQ] = useState('')
|
||||
const { data, error, reload } = useData(() => getItems(q), [q])
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader
|
||||
title="Items" badge="LIVE"
|
||||
desc="The item master, straight from the store DB. What you add here is billable at the counter immediately."
|
||||
actions={<Button tone="primary" onClick={() => setCreating(true)}>New item</Button>}
|
||||
/>
|
||||
<Toolbar>
|
||||
<input className="wf" style={{ maxWidth: 280 }} placeholder="Search name / code / barcode…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<Badge>{data?.length ?? '…'} items</Badge>
|
||||
</Toolbar>
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
{data !== undefined && (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' }, { key: 'hsn', label: 'HSN' },
|
||||
{ key: 'tax', label: 'GST class' }, { key: 'mrp', label: 'MRP', numeric: true },
|
||||
{ key: 'price', label: 'Price', numeric: true }, { key: 'status', label: 'Status' },
|
||||
]}
|
||||
rows={data.map((i) => ({
|
||||
code: String(i['code']), name: String(i['name']), hsn: String(i['hsn']),
|
||||
tax: String(i['taxClassCode']),
|
||||
mrp: i['mrpPaise'] !== undefined ? inr(i['mrpPaise']) : '—',
|
||||
price: `${inr(i['salePricePaise'])}${(i['unit'] as { code: string }).code === 'KG' ? '/kg' : ''}`,
|
||||
status: <Badge tone={i['status'] === 'active' ? 'ok' : 'warn'}>{String(i['status'])}</Badge>,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{creating && <NewItemModal onClose={() => setCreating(false)} onCreated={() => { setCreating(false); reload() }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewItemModal(props: { onClose: () => void; onCreated: () => void }) {
|
||||
const [f, setF] = useState({ code: '', name: '', hsn: '', taxClassCode: 'GST18', unitCode: 'PCS', priceRs: '', mrpRs: '', barcode: '' })
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const set = (k: keyof typeof f) => (e: { target: { value: string } }) => setF((p) => ({ ...p, [k]: e.target.value }))
|
||||
|
||||
const save = () => {
|
||||
createItem({
|
||||
code: f.code, name: f.name, hsn: f.hsn, taxClassCode: f.taxClassCode,
|
||||
unitCode: f.unitCode, unitDecimals: f.unitCode === 'KG' ? 3 : 0,
|
||||
salePricePaise: Math.round(Number(f.priceRs) * 100),
|
||||
...(f.mrpRs !== '' ? { mrpPaise: Math.round(Number(f.mrpRs) * 100) } : {}),
|
||||
...(f.barcode !== '' ? { barcode: f.barcode } : {}),
|
||||
}).then(props.onCreated).catch((e: Error) => setError(e.message))
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="New item" onClose={props.onClose}>
|
||||
<Field label="Code"><input className="wf" value={f.code} onChange={set('code')} /></Field>
|
||||
<Field label="Name"><input className="wf" value={f.name} onChange={set('name')} /></Field>
|
||||
<Field label="HSN"><input className="wf" value={f.hsn} onChange={set('hsn')} /></Field>
|
||||
<Field label="GST class">
|
||||
<select className="wf" value={f.taxClassCode} onChange={set('taxClassCode')}>
|
||||
{['ZERO', 'GST5', 'GST12', 'GST18', 'DEMERIT'].map((c) => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Unit">
|
||||
<select className="wf" value={f.unitCode} onChange={set('unitCode')}>
|
||||
<option>PCS</option><option>KG</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Sale price (₹, tax-inclusive)"><input className="wf num" value={f.priceRs} onChange={set('priceRs')} /></Field>
|
||||
<Field label="MRP (₹, optional)"><input className="wf num" value={f.mrpRs} onChange={set('mrpRs')} /></Field>
|
||||
<Field label="Barcode (optional)"><input className="wf num" value={f.barcode} onChange={set('barcode')} /></Field>
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button tone="primary" onClick={save}>Save item</Button>
|
||||
<Button onClick={props.onClose}>Cancel</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomersPage() {
|
||||
const { data, error } = useData(() => getParties('customer'))
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader title="Customers" badge="LIVE" desc="Phone-keyed profiles — created here or from the POS customer-attach (F6)." />
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
{data === undefined ? <EmptyState>Loading…</EmptyState> : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' },
|
||||
{ key: 'phone', label: 'Phone' }, { key: 'gstin', label: 'GSTIN' },
|
||||
{ key: 'limit', label: 'Credit limit', numeric: true },
|
||||
]}
|
||||
rows={data.map((p) => ({
|
||||
code: String(p['code']), name: String(p['name']),
|
||||
phone: p['phone'] !== undefined ? String(p['phone']) : '—',
|
||||
gstin: p['gstin'] !== undefined ? <Badge tone="accent">{String(p['gstin'])}</Badge> : '—',
|
||||
limit: p['creditLimitPaise'] !== undefined ? inr(p['creditLimitPaise']) : '—',
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Build the A4 GST invoice for a bill row and open it in a print-ready window. */
|
||||
function openInvoice(bill: Record<string, unknown>, seller: Seller | undefined): string | undefined {
|
||||
let payload: { lines: BillLine[]; totals: BillTotals; payments?: { mode: string }[] }
|
||||
try {
|
||||
payload = JSON.parse(String(bill['payload'])) as typeof payload
|
||||
} catch {
|
||||
return 'This bill has no stored line detail to invoice.'
|
||||
}
|
||||
// The B2B buyer + place of supply are snapshotted on the bill at commit (S3),
|
||||
// so the invoice reads them from the document, not from today's customer row.
|
||||
const buyerGstin = bill['buyer_gstin'] !== null && bill['buyer_gstin'] !== undefined ? String(bill['buyer_gstin']) : undefined
|
||||
const placeOfSupply = bill['place_of_supply'] !== null && bill['place_of_supply'] !== undefined ? String(bill['place_of_supply']) : undefined
|
||||
const posState = placeOfSupply ?? seller?.stateCode
|
||||
const doc: InvoiceDoc = {
|
||||
seller: {
|
||||
name: seller?.name ?? 'Store',
|
||||
...(seller?.gstin !== undefined ? { gstin: seller.gstin } : {}),
|
||||
...(seller?.stateCode !== undefined ? { stateCode: seller.stateCode } : {}),
|
||||
},
|
||||
...(bill['customer_name'] !== null && bill['customer_name'] !== undefined
|
||||
? {
|
||||
buyer: {
|
||||
name: String(bill['customer_name']),
|
||||
...(buyerGstin !== undefined ? { gstin: buyerGstin } : {}),
|
||||
...(placeOfSupply !== undefined ? { stateCode: placeOfSupply } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
invoiceNo: String(bill['doc_no']),
|
||||
invoiceDate: String(bill['business_date']),
|
||||
...(posState !== undefined ? { placeOfSupplyStateCode: posState } : {}),
|
||||
lines: payload.lines,
|
||||
totals: payload.totals,
|
||||
...(payload.payments?.[0]?.mode !== undefined ? { paymentLabel: payload.payments[0].mode } : {}),
|
||||
}
|
||||
// Serve the self-contained HTML as a blob URL — no document.write, and the new
|
||||
// window loads it as a real document (so its own Print A4 button works).
|
||||
const url = URL.createObjectURL(new Blob([renderInvoiceHtml(doc)], { type: 'text/html' }))
|
||||
const win = window.open(url, '_blank')
|
||||
if (win === null) { URL.revokeObjectURL(url); return 'Allow pop-ups to open the invoice (it prints in a new tab).' }
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function BillsPage() {
|
||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
|
||||
const { rows, error, hasMore, loading, loadMore } = usePaged<Record<string, unknown>>(
|
||||
(limit, offset) => getBills(date, limit, offset), [date],
|
||||
)
|
||||
const { data: seller } = useData(() => getSeller())
|
||||
const [printErr, setPrintErr] = useState<string | undefined>()
|
||||
const total = rows.reduce((a, b) => a + Number(b['payable_paise']), 0)
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader title="Bills" badge="LIVE" desc="Every committed bill — immutable documents from the counters. Click a row to open its A4 GST invoice." />
|
||||
<Toolbar>
|
||||
<input type="date" className="wf" style={{ maxWidth: 170 }} value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
<Badge>{rows.length}{hasMore ? '+' : ''} bills</Badge>
|
||||
<Badge tone="accent">{formatINR(total)}</Badge>
|
||||
</Toolbar>
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
{printErr !== undefined && <Notice tone="warn">{printErr}</Notice>}
|
||||
{rows.length === 0 && loading ? <EmptyState>Loading…</EmptyState>
|
||||
: rows.length === 0 && error === undefined ? <EmptyState>No bills on {date}. Make one at the POS!</EmptyState> : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'no', label: 'Bill No' }, { key: 'time', label: 'Time' }, { key: 'cashier', label: 'Cashier' },
|
||||
{ key: 'customer', label: 'Customer' }, { key: 'gst', label: 'GST', numeric: true },
|
||||
{ key: 'amount', label: 'Amount', numeric: true }, { key: 'invoice', label: 'Invoice' },
|
||||
]}
|
||||
onRowClick={(_row, i) => setPrintErr(openInvoice(rows[i]!, seller))}
|
||||
rows={rows.map((b) => ({
|
||||
no: String(b['doc_no']),
|
||||
time: new Date(String(b['created_at_wall'])).toLocaleTimeString(),
|
||||
cashier: String(b['cashier_name'] ?? ''),
|
||||
customer: b['customer_name'] !== null ? String(b['customer_name']) : 'walk-in',
|
||||
gst: inr(Number(b['cgst_paise']) + Number(b['sgst_paise']) + Number(b['igst_paise']) + Number(b['cess_paise'])),
|
||||
amount: inr(b['payable_paise']),
|
||||
invoice: <Badge tone="accent">Print A4 ↗</Badge>,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Button onClick={loadMore}>{loading ? 'Loading…' : 'Load more'}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StockPage() {
|
||||
const { data, error } = useData(() => getStock())
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader title="Stock" badge="LIVE" desc="On-hand derived from movements (invariant I4) — opening stock minus every sale." />
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
{data === undefined ? <EmptyState>Loading…</EmptyState> : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Item' },
|
||||
{ key: 'onhand', label: 'On hand', numeric: true }, { key: 'state', label: '' },
|
||||
]}
|
||||
rows={data.map((s) => {
|
||||
const onHand = Number(s['on_hand'])
|
||||
return {
|
||||
code: String(s['code']), name: String(s['name']),
|
||||
onhand: `${Math.round(onHand * 1000) / 1000} ${String(s['unit_code'])}`,
|
||||
state: onHand <= 0 ? <Badge tone="err">out</Badge> : onHand < 15 ? <Badge tone="warn">low</Badge> : <Badge tone="ok">ok</Badge>,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuditPage() {
|
||||
const { rows, error, hasMore, loading, loadMore } = usePaged<Record<string, unknown>>(
|
||||
(limit, offset) => getAudit(limit, offset), [],
|
||||
)
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader title="Audit Log" badge="LIVE" desc="Append-only — logins, failed logins, item edits, every bill commit." />
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
{rows.length === 0 && loading ? <EmptyState>Loading…</EmptyState>
|
||||
: rows.length === 0 && error === undefined ? <EmptyState>No audit entries yet.</EmptyState> : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'at', label: 'When' }, { key: 'who', label: 'Who' },
|
||||
{ key: 'action', label: 'Action' }, { key: 'detail', label: 'Detail' },
|
||||
]}
|
||||
rows={rows.map((a) => ({
|
||||
at: new Date(String(a['at_wall'])).toLocaleString(),
|
||||
who: String(a['user_name'] ?? a['user_id']),
|
||||
action: <Badge tone="accent">{String(a['action'])}</Badge>,
|
||||
// Override rows carry a before_json (who approved what change from what) —
|
||||
// show before → after so PRICE/QTY/DISCOUNT overrides read at a glance.
|
||||
detail: `${String(a['entity'])} ${a['before_json'] !== null ? `${String(a['before_json']).slice(0, 60)} → ` : ''}${a['after_json'] !== null ? String(a['after_json']).slice(0, 90) : ''}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Button onClick={loadMore}>{loading ? 'Loading…' : 'Load more'}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
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 },
|
||||
})
|
||||
@ -0,0 +1,7 @@
|
||||
# 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.
|
||||
@ -0,0 +1,16 @@
|
||||
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')
|
||||
@ -0,0 +1,62 @@
|
||||
"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());
|
||||
@ -0,0 +1,8 @@
|
||||
"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)
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
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())
|
||||
@ -0,0 +1,8 @@
|
||||
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),
|
||||
})
|
||||
@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
@ -0,0 +1,32 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Notice } from '@sims/ui'
|
||||
import { uuidv7, type Item } from '@sims/domain'
|
||||
import { fetchBootstrap, fetchItemsCache, type Bootstrap, type PosUser } from './api'
|
||||
import { Login } from './Login'
|
||||
import { ShiftOpen } from './ShiftOpen'
|
||||
import { BillingScreen } from './BillingScreen'
|
||||
|
||||
/** 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<{ id: string; floatPaise: number } | undefined>()
|
||||
|
||||
const load = () => {
|
||||
setBootError(undefined)
|
||||
const counter = new URLSearchParams(location.search).get('counter') ?? 'C2'
|
||||
fetchBootstrap(counter).then(setBoot).catch((e: Error) => setBootError(e.message))
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
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) => {
|
||||
setUser(u)
|
||||
setItems(await fetchItemsCache())
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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}
|
||||
onLock={() => {
|
||||
setShift(undefined)
|
||||
setUser(undefined)
|
||||
setItems(undefined)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,84 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,106 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
import type { 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[] }
|
||||
|
||||
let 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)')
|
||||
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 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 }),
|
||||
})
|
||||
token = s.token
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const verifySupervisorPin = (userId: string, pin: string): Promise<{ ok: true; userId: string; displayName: string }> =>
|
||||
call('/auth/verify-pin', { method: 'POST', body: JSON.stringify({ userId, pin }) })
|
||||
|
||||
// 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). */
|
||||
export interface BillOverride {
|
||||
itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: 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) })
|
||||
|
||||
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 } : {}) }),
|
||||
})
|
||||
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
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 />)
|
||||
@ -0,0 +1,74 @@
|
||||
/* 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; }
|
||||
@ -0,0 +1,59 @@
|
||||
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 }))
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
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
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
/** 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
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*", "electron/**/*"]
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
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' },
|
||||
})
|
||||
@ -0,0 +1,6 @@
|
||||
# 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.
|
||||
@ -0,0 +1,11 @@
|
||||
import { build } from 'esbuild'
|
||||
|
||||
await build({
|
||||
entryPoints: ['src/server.ts'],
|
||||
outfile: 'dist/server.cjs',
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
external: ['better-sqlite3'],
|
||||
})
|
||||
console.log('store-server built')
|
||||
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@sims/store-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build-server.mjs",
|
||||
"start": "npm run build && node dist/server.cjs",
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sims/auth": "*",
|
||||
"@sims/billing-engine": "*",
|
||||
"@sims/domain": "*",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/express": "^4.17.21",
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,226 @@
|
||||
import { Router, type Request, type Response, type NextFunction } from 'express'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { verifyPin, FRESH_LOCKOUT, isLocked, recordFailure, recordSuccess, type LockoutState } from '@sims/auth'
|
||||
import type { LineInput } from '@sims/billing-engine'
|
||||
import type { DB } from './db'
|
||||
import {
|
||||
commitBill, createCustomer, createItem, listAudit, listBills, listItems,
|
||||
listParties, listTaxClasses, stockView, writeAudit,
|
||||
} from './repos'
|
||||
import { commitPurchase, costHistory, lastCosts, listPurchases, type CommitPurchaseInput } from './repos-purchase'
|
||||
|
||||
interface Session {
|
||||
token: string; userId: string; tenantId: string; displayName: string; roles: string[]
|
||||
}
|
||||
|
||||
const sessions = new Map<string, Session>()
|
||||
const lockouts = new Map<string, LockoutState>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = (req.headers.authorization ?? '').replace('Bearer ', '')
|
||||
const session = sessions.get(token)
|
||||
if (session === undefined) {
|
||||
res.status(401).json({ error: 'Not signed in (E-6103)' })
|
||||
return
|
||||
}
|
||||
;(req as Request & { session: Session }).session = session
|
||||
next()
|
||||
}
|
||||
|
||||
const sess = (req: Request): Session => (req as Request & { session: Session }).session
|
||||
|
||||
export function apiRouter(db: DB): Router {
|
||||
const r = Router()
|
||||
|
||||
const startSession = (u: UserRow): Session => {
|
||||
const s: Session = {
|
||||
token: randomUUID(), userId: u.id, tenantId: u.tenant_id,
|
||||
displayName: u.display_name, roles: JSON.parse(u.roles) as string[],
|
||||
}
|
||||
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', (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(out) : res.status(out.status).json({ error: out.error })
|
||||
})
|
||||
|
||||
// Back office: username + password
|
||||
r.post('/auth/login', (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(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 LOGIN_FAILED-style 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, never granted.
|
||||
const APPROVER_ROLES = new Set(['supervisor', 'manager', 'owner'])
|
||||
r.post('/auth/verify-pin', (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 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)' })
|
||||
return
|
||||
}
|
||||
writeAudit(db, u.tenant_id, u.id, 'PIN_VERIFY', 'app_user', u.id)
|
||||
res.json({ ok: true, userId: u.id, displayName: u.display_name })
|
||||
})
|
||||
|
||||
// POS bootstrap: store/counter context, login tiles, tax classes
|
||||
r.get('/bootstrap', (req, res) => {
|
||||
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'], gstin: tenant['gstin'],
|
||||
counterId: counter?.['id'] ?? 'c2', counterCode,
|
||||
},
|
||||
users: users.map((u) => ({ id: u['id'], name: u['display_name'], role: (JSON.parse(u['roles'] as string) as string[])[0] })),
|
||||
taxClasses: listTaxClasses(db),
|
||||
})
|
||||
})
|
||||
|
||||
r.use(requireAuth)
|
||||
|
||||
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',
|
||||
...(req.query['limit'] !== undefined ? { limit: Number(req.query['limit']) } : {}),
|
||||
...(req.query['offset'] !== undefined ? { offset: Number(req.query['offset']) } : {}),
|
||||
}))
|
||||
})
|
||||
r.post('/items', (req, res) => {
|
||||
const item = createItem(db, sess(req).tenantId, req.body as Parameters<typeof createItem>[2])
|
||||
writeAudit(db, sess(req).tenantId, sess(req).userId, 'ITEM_CREATE', 'item', item.id, undefined, item)
|
||||
res.json(item)
|
||||
})
|
||||
|
||||
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 { name, phone, gstin, stateCode } = req.body as { name: string; phone: string; gstin?: string; stateCode?: string }
|
||||
try {
|
||||
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)
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number }).status ?? 500
|
||||
res.status(status).json({ error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
})
|
||||
|
||||
r.post('/bills', (req, res) => {
|
||||
const body = req.body as {
|
||||
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'; before: number; after: number; approvedByUserId?: string }[]
|
||||
}
|
||||
try {
|
||||
const out = commitBill(db, { ...body, tenantId: sess(req).tenantId, cashierId: sess(req).userId })
|
||||
res.json(out)
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number }).status ?? 500
|
||||
res.status(status).json({ error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
})
|
||||
r.get('/bills', (req, res) => {
|
||||
res.json(listBills(
|
||||
db, sess(req).tenantId,
|
||||
req.query['date'] as string | undefined, req.query['counterId'] as string | undefined,
|
||||
req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined,
|
||||
req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined,
|
||||
))
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
r.get('/audit', (req, res) => {
|
||||
res.json(listAudit(
|
||||
db, sess(req).tenantId,
|
||||
req.query['limit'] !== undefined ? Number(req.query['limit']) : undefined,
|
||||
req.query['offset'] !== undefined ? Number(req.query['offset']) : undefined,
|
||||
))
|
||||
})
|
||||
|
||||
r.get('/purchases', (req, res) => {
|
||||
res.json(listPurchases(db, sess(req).tenantId))
|
||||
})
|
||||
r.get('/purchases/last-costs', (req, res) => {
|
||||
res.json(lastCosts(db, sess(req).tenantId, req.query['supplierId'] as string | undefined))
|
||||
})
|
||||
r.get('/purchases/cost-history', (req, res) => {
|
||||
res.json(costHistory(db, sess(req).tenantId, String(req.query['itemId'] ?? ''), req.query['supplierId'] as string | undefined))
|
||||
})
|
||||
r.post('/purchases', (req, res) => {
|
||||
const body = req.body as Omit<CommitPurchaseInput, 'tenantId' | 'userId'>
|
||||
try {
|
||||
res.json(commitPurchase(db, { ...body, tenantId: sess(req).tenantId, userId: sess(req).userId }))
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number }).status ?? 500
|
||||
res.status(status).json({ error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
@ -0,0 +1,141 @@
|
||||
import Database from 'better-sqlite3'
|
||||
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,
|
||||
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, business_date TEXT NOT NULL, created_at_wall TEXT NOT NULL
|
||||
);
|
||||
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
|
||||
);
|
||||
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
|
||||
);
|
||||
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,
|
||||
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);
|
||||
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`)
|
||||
}
|
||||
|
||||
export function openDb(): DB {
|
||||
const dir = path.resolve(__dirname, '../data')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
const db = new Database(path.join(dir, 'sims.db'))
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
db.exec(SCHEMA)
|
||||
migrate(db)
|
||||
return db
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
import { uuidv7 } from '@sims/domain'
|
||||
import type { DB } from './db'
|
||||
import { 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
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
const move = db.prepare(
|
||||
`INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, business_date, created_at_wall)
|
||||
VALUES (?, ?, ?, ?, ?, 'PURCHASE', ?, ?, ?)`,
|
||||
)
|
||||
input.lines.forEach((l, i) => {
|
||||
const m = math[i]!
|
||||
insLine.run(id, i + 1, l.itemId, l.name, l.qty, l.unitCostPaise, l.taxRateBp, m.tax, m.total, l.newSalePricePaise ?? null, l.newMrpPaise ?? null)
|
||||
move.run(uuidv7(), input.tenantId, input.storeId, l.itemId, l.qty, id, input.businessDate, now)
|
||||
if (l.newSalePricePaise !== undefined || l.newMrpPaise !== undefined) {
|
||||
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}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
writeAudit(db, input.tenantId, input.userId, 'PURCHASE_COMMIT', 'purchase', id, undefined, {
|
||||
invoiceNo: input.invoiceNo, supplier: input.supplierId, total,
|
||||
})
|
||||
})()
|
||||
|
||||
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>[]
|
||||
}
|
||||
@ -0,0 +1,305 @@
|
||||
import {
|
||||
deriveSupply, formatDocNo, fyOf, fyShort, seriesPrefix, uuidv7, validateGstin,
|
||||
type Item, type Party,
|
||||
} from '@sims/domain'
|
||||
import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine'
|
||||
import type { DB } from './db'
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
overrides?: { itemId: string; kind: 'price' | 'qty' | 'discount'; before: number; after: number; approvedByUserId?: string }[]
|
||||
}
|
||||
|
||||
const OVERRIDE_ACTION = {
|
||||
price: 'PRICE_OVERRIDE', qty: 'QTY_OVERRIDE', discount: 'DISCOUNT_OVERRIDE',
|
||||
} as const
|
||||
|
||||
export function commitBill(db: DB, input: CommitBillInput): { id: string; docNo: string; payablePaise: number } {
|
||||
const store = db.prepare(`SELECT * FROM store WHERE id=?`).get(input.storeId) as { code: string; state_code: string }
|
||||
const counter = db.prepare(`SELECT * FROM counter WHERE id=?`).get(input.counterId) as { code: string }
|
||||
const rates = listTaxClasses(db)
|
||||
|
||||
// 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,
|
||||
)
|
||||
|
||||
// Same engine everywhere: the server recomputes and must agree to the paisa.
|
||||
const computed = computeBill(input.lines, {
|
||||
businessDate: input.businessDate,
|
||||
supplyStateCode: store.state_code,
|
||||
placeOfSupplyStateCode: supply.placeOfSupplyStateCode,
|
||||
roundToRupee: true,
|
||||
}, rates)
|
||||
if (computed.totals.payablePaise !== input.clientPayablePaise) {
|
||||
throw Object.assign(new Error('Engine mismatch: client and server computed different totals'), { status: 409 })
|
||||
}
|
||||
const paid = input.payments.reduce((a, p) => a + p.amountPaise, 0)
|
||||
if (paid !== computed.totals.payablePaise) {
|
||||
throw Object.assign(new Error('Payments do not sum to payable'), { status: 400 })
|
||||
}
|
||||
|
||||
const id = uuidv7()
|
||||
const now = new Date().toISOString()
|
||||
const fy = fyOf(input.businessDate)
|
||||
|
||||
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, fyShort(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, 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,
|
||||
payload, now,
|
||||
)
|
||||
const move = db.prepare(
|
||||
`INSERT INTO stock_movement (id, tenant_id, store_id, item_id, qty_delta, reason, ref_doc_id, business_date, created_at_wall)
|
||||
VALUES (?, ?, ?, ?, ?, 'SALE', ?, ?, ?)`,
|
||||
)
|
||||
for (const line of computed.lines) {
|
||||
move.run(uuidv7(), input.tenantId, input.storeId, line.itemId, -line.qty, id, 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 })
|
||||
// One audit row per approved override, in-transaction (R17). The approver's id
|
||||
// is resolved to a display name so the audit trail is human-readable.
|
||||
for (const o of input.overrides ?? []) {
|
||||
const approvedBy = o.approvedByUserId !== undefined
|
||||
? (db.prepare(`SELECT display_name FROM app_user WHERE id=?`).get(o.approvedByUserId) as { display_name: string } | undefined)?.display_name
|
||||
?? o.approvedByUserId
|
||||
: undefined
|
||||
writeAudit(
|
||||
db, input.tenantId, input.cashierId, OVERRIDE_ACTION[o.kind], 'bill', id,
|
||||
{ itemId: o.itemId, before: o.before },
|
||||
{ after: o.after, approvedBy, approvedByUserId: o.approvedByUserId },
|
||||
)
|
||||
}
|
||||
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,
|
||||
): Record<string, unknown>[] {
|
||||
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=?`
|
||||
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) }
|
||||
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,
|
||||
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>[]
|
||||
}
|
||||
|
||||
// ---------- audit ----------
|
||||
export function writeAudit(
|
||||
db: DB, tenantId: string, userId: string, action: string,
|
||||
entity: string, entityId: string, before?: unknown, after?: unknown,
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
uuidv7(), tenantId, new Date().toISOString(), userId, action, entity, entityId,
|
||||
before === undefined ? null : JSON.stringify(before),
|
||||
after === undefined ? null : JSON.stringify(after),
|
||||
)
|
||||
}
|
||||
|
||||
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>[]
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
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')
|
||||
|
||||
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()
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@sims/auth",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@sims/domain": "*"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export * from './pin'
|
||||
export * from './lockout'
|
||||
export * from './permissions'
|
||||
export * from './session'
|
||||
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
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'
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@sims/billing-engine",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@sims/domain": "*"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from './tax'
|
||||
export * from './compute'
|
||||
@ -0,0 +1,30 @@
|
||||
import type { IsoDate } from '@sims/domain'
|
||||
|
||||
/**
|
||||
* Tax rates are dated configuration rows, never constants (07-DB-AND-CONFIG §1;
|
||||
* the Sept-2025 GST 2.0 slab change is the precedent). Rates are basis points:
|
||||
* 18% → 1800. Resolution is by the bill's business date, so returns and credit
|
||||
* notes against old bills resolve the rate that was law on that day.
|
||||
*/
|
||||
export interface TaxClassRow {
|
||||
classCode: string
|
||||
ratePctBp: number
|
||||
cessPctBp: number
|
||||
effectiveFrom: IsoDate
|
||||
effectiveTo?: IsoDate
|
||||
}
|
||||
|
||||
export function resolveTaxClass(rows: TaxClassRow[], classCode: string, onDate: IsoDate): TaxClassRow {
|
||||
let best: TaxClassRow | undefined
|
||||
for (const row of rows) {
|
||||
if (row.classCode !== classCode) continue
|
||||
if (row.effectiveFrom > onDate) continue
|
||||
if (row.effectiveTo !== undefined && row.effectiveTo < onDate) continue
|
||||
if (best === undefined || row.effectiveFrom > best.effectiveFrom) best = row
|
||||
}
|
||||
if (best === undefined) {
|
||||
// Silent defaults on tax are how wrong GST reaches the portal — fail loudly.
|
||||
throw new Error(`No tax rate for class "${classCode}" on ${onDate}`)
|
||||
}
|
||||
return best
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@sims/config",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts"
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from './settings'
|
||||
export * from './messages'
|
||||
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Every user-facing string is a DB row (07-DB-AND-CONFIG §2) — notifications,
|
||||
* errors, labels — per language and channel. The frontend renders codes; the
|
||||
* catalog syncs like any master, so language never depends on the network.
|
||||
*/
|
||||
export interface MessageRow {
|
||||
code: string
|
||||
channel: 'ui' | 'print' | 'whatsapp' | 'sms'
|
||||
lang: string
|
||||
template: string
|
||||
}
|
||||
|
||||
export interface LanguagePrefs {
|
||||
userLang?: string
|
||||
storeLang?: string
|
||||
}
|
||||
|
||||
/** Fallback chain: user language → store default → English → undefined. */
|
||||
export function resolveMessage(
|
||||
rows: MessageRow[],
|
||||
code: string,
|
||||
channel: MessageRow['channel'],
|
||||
prefs: LanguagePrefs,
|
||||
): { template: string; lang: string } | undefined {
|
||||
const chain = [prefs.userLang, prefs.storeLang, 'en'].filter(
|
||||
(l, i, all): l is string => l !== undefined && all.indexOf(l) === i,
|
||||
)
|
||||
for (const lang of chain) {
|
||||
const row = rows.find((r) => r.code === code && r.channel === channel && r.lang === lang)
|
||||
if (row !== undefined) return { template: row.template, lang }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Minimal {placeholder} interpolation; missing vars render as the raw key. */
|
||||
export function renderTemplate(template: string, vars: Record<string, string | number>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, name: string) =>
|
||||
name in vars ? String(vars[name]) : `{${name}}`,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The settings hierarchy (11-ADMIN-SUPPORT-CONSOLE §2): nearest scope wins,
|
||||
* user → counter → store → tenant → system. Rows are effective-dated; within a
|
||||
* scope the latest effectiveFrom not after the target date applies.
|
||||
*/
|
||||
export type SettingScope = 'system' | 'tenant' | 'store' | 'counter' | 'user'
|
||||
|
||||
export interface SettingRow {
|
||||
scopeType: SettingScope
|
||||
/** '' for system scope. */
|
||||
scopeId: string
|
||||
key: string
|
||||
value: string
|
||||
effectiveFrom?: string // YYYY-MM-DD; undefined = always
|
||||
}
|
||||
|
||||
export interface SettingContext {
|
||||
tenantId?: string
|
||||
storeId?: string
|
||||
counterId?: string
|
||||
userId?: string
|
||||
}
|
||||
|
||||
const PRECEDENCE: SettingScope[] = ['user', 'counter', 'store', 'tenant', 'system']
|
||||
|
||||
export function resolveSetting(
|
||||
rows: SettingRow[],
|
||||
key: string,
|
||||
ctx: SettingContext,
|
||||
onDate: string,
|
||||
): { value: string; scope: SettingScope } | undefined {
|
||||
for (const scope of PRECEDENCE) {
|
||||
const scopeId =
|
||||
scope === 'system'
|
||||
? ''
|
||||
: scope === 'tenant'
|
||||
? ctx.tenantId
|
||||
: scope === 'store'
|
||||
? ctx.storeId
|
||||
: scope === 'counter'
|
||||
? ctx.counterId
|
||||
: ctx.userId
|
||||
if (scopeId === undefined) continue
|
||||
let best: SettingRow | undefined
|
||||
for (const row of rows) {
|
||||
if (row.key !== key || row.scopeType !== scope || row.scopeId !== scopeId) continue
|
||||
if (row.effectiveFrom !== undefined && row.effectiveFrom > onDate) continue
|
||||
if (
|
||||
best === undefined ||
|
||||
(row.effectiveFrom ?? '0000-00-00') > (best.effectiveFrom ?? '0000-00-00')
|
||||
) {
|
||||
best = row
|
||||
}
|
||||
}
|
||||
if (best !== undefined) return { value: best.value, scope }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveMessage, renderTemplate, resolveSetting, type MessageRow, type SettingRow } from '@sims/config'
|
||||
|
||||
const CTX = { tenantId: 't1', storeId: 's1', counterId: 'c1', userId: 'u1' }
|
||||
|
||||
describe('settings hierarchy — nearest scope wins', () => {
|
||||
const rows: SettingRow[] = [
|
||||
{ scopeType: 'system', scopeId: '', key: 'discount.capBp', value: '1000' },
|
||||
{ scopeType: 'tenant', scopeId: 't1', key: 'discount.capBp', value: '1500' },
|
||||
{ scopeType: 'store', scopeId: 's1', key: 'discount.capBp', value: '500' },
|
||||
]
|
||||
it('store overrides tenant overrides system', () => {
|
||||
expect(resolveSetting(rows, 'discount.capBp', CTX, '2026-07-09')).toEqual({
|
||||
value: '500',
|
||||
scope: 'store',
|
||||
})
|
||||
})
|
||||
it('falls through when the nearer scope has no row', () => {
|
||||
const noStore = { ...CTX, storeId: 's-other' }
|
||||
expect(resolveSetting(rows, 'discount.capBp', noStore, '2026-07-09')?.value).toBe('1500')
|
||||
})
|
||||
it('system default applies when nothing overrides', () => {
|
||||
expect(resolveSetting(rows, 'discount.capBp', { tenantId: 'tx' }, '2026-07-09')?.value).toBe('1000')
|
||||
})
|
||||
it('returns undefined for unknown keys', () => {
|
||||
expect(resolveSetting(rows, 'nope', CTX, '2026-07-09')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings — effective dating', () => {
|
||||
const rows: SettingRow[] = [
|
||||
{ scopeType: 'system', scopeId: '', key: 'gst.roundToRupee', value: 'true' },
|
||||
{ scopeType: 'system', scopeId: '', key: 'gst.roundToRupee', value: 'false', effectiveFrom: '2026-08-01' },
|
||||
]
|
||||
it('picks the latest row not after the target date', () => {
|
||||
expect(resolveSetting(rows, 'gst.roundToRupee', {}, '2026-07-31')?.value).toBe('true')
|
||||
expect(resolveSetting(rows, 'gst.roundToRupee', {}, '2026-08-01')?.value).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('message catalog — language fallback chain', () => {
|
||||
const rows: MessageRow[] = [
|
||||
{ code: 'BILL_ESHARE', channel: 'whatsapp', lang: 'en', template: 'Hi {name}, your bill of {amount}' },
|
||||
{ code: 'BILL_ESHARE', channel: 'whatsapp', lang: 'hi', template: 'नमस्ते {name}, आपका बिल {amount}' },
|
||||
{ code: 'BILL_ESHARE', channel: 'whatsapp', lang: 'ml', template: 'നമസ്കാരം {name}, ബിൽ {amount}' },
|
||||
]
|
||||
it('prefers user language, then store, then English', () => {
|
||||
expect(resolveMessage(rows, 'BILL_ESHARE', 'whatsapp', { userLang: 'ml' })?.lang).toBe('ml')
|
||||
expect(resolveMessage(rows, 'BILL_ESHARE', 'whatsapp', { userLang: 'ta', storeLang: 'hi' })?.lang).toBe('hi')
|
||||
expect(resolveMessage(rows, 'BILL_ESHARE', 'whatsapp', { userLang: 'ta' })?.lang).toBe('en')
|
||||
})
|
||||
it('returns undefined for unknown codes (caller surfaces the raw key in dev)', () => {
|
||||
expect(resolveMessage(rows, 'NOPE', 'whatsapp', {})).toBeUndefined()
|
||||
})
|
||||
it('renders placeholders; missing vars stay visible', () => {
|
||||
expect(renderTemplate('Hi {name}, bill {amount}', { name: 'Lakshmi', amount: '₹1,431.00' })).toBe(
|
||||
'Hi Lakshmi, bill ₹1,431.00',
|
||||
)
|
||||
expect(renderTemplate('Hi {name}', {})).toBe('Hi {name}')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@sims/domain",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts"
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The working-day concept carried over from the OG applications (Day Begin /
|
||||
* Day End in the Manager Menu): every dated record stamps the store's opened
|
||||
* business date, never the machine clock. The wall clock is display metadata.
|
||||
*/
|
||||
export type IsoDate = string // YYYY-MM-DD
|
||||
|
||||
export interface BusinessDay {
|
||||
tenantId: string
|
||||
storeId: string
|
||||
date: IsoDate
|
||||
openedAt: string
|
||||
openedBy: string
|
||||
closedAt?: string
|
||||
closedBy?: string
|
||||
}
|
||||
|
||||
export function isOpen(day: BusinessDay): boolean {
|
||||
return day.closedAt === undefined
|
||||
}
|
||||
|
||||
/** Indian financial year label: 2026-04-01 → "2026-27", 2026-03-31 → "2025-26". */
|
||||
export function fyOf(date: IsoDate, fyStartMonth = 4): string {
|
||||
const [y, m] = [Number(date.slice(0, 4)), Number(date.slice(5, 7))]
|
||||
const start = m >= fyStartMonth ? y : y - 1
|
||||
return `${start}-${String(start + 1).slice(2)}`
|
||||
}
|
||||
|
||||
/** Short FY marker for document series prefixes: "2026-27" → "26". */
|
||||
export function fyShort(fy: string): string {
|
||||
return fy.slice(2, 4)
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Per-counter, per-FY document series (02-ARCHITECTURE §3 rule 3): counters
|
||||
* number documents offline with zero coordination. GST caps a document number
|
||||
* at 16 chars from the set [A-Z a-z 0-9 - /].
|
||||
*/
|
||||
export interface DocSeries {
|
||||
tenantId: string
|
||||
storeId: string
|
||||
counterId: string
|
||||
docType: string
|
||||
fy: string
|
||||
prefix: string
|
||||
nextSeq: number
|
||||
}
|
||||
|
||||
const GST_DOC_NO = /^[A-Za-z0-9/-]{1,16}$/
|
||||
|
||||
export function assertGstDocNo(docNo: string): void {
|
||||
if (!GST_DOC_NO.test(docNo)) {
|
||||
throw new Error(`Document number "${docNo}" violates the GST 16-char [A-Za-z0-9/-] rule`)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDocNo(prefix: string, seq: number, width = 6): string {
|
||||
const docNo = `${prefix}-${String(seq).padStart(width, '0')}`
|
||||
assertGstDocNo(docNo)
|
||||
return docNo
|
||||
}
|
||||
|
||||
/** e.g. seriesPrefix("ST1", "C2", "26") → "ST1C2/26" */
|
||||
export function seriesPrefix(storeCode: string, counterCode: string, fyShortMark: string): string {
|
||||
return `${storeCode}${counterCode}/${fyShortMark}`
|
||||
}
|
||||
|
||||
export function nextDocNo(series: DocSeries): { docNo: string; series: DocSeries } {
|
||||
const docNo = formatDocNo(series.prefix, series.nextSeq)
|
||||
return { docNo, series: { ...series, nextSeq: series.nextSeq + 1 } }
|
||||
}
|
||||
|
||||
/** FY rollover starts a fresh series; the old one is retained for returns lookups. */
|
||||
export function rolloverForFy(series: DocSeries, fy: string, prefix: string): DocSeries {
|
||||
if (fy === series.fy) return series
|
||||
return { ...series, fy, prefix, nextSeq: 1 }
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import type { Paise } from './money'
|
||||
import type { IsoDate } from './business-day'
|
||||
|
||||
/**
|
||||
* Documents are immutable events (02-ARCHITECTURE §3 rule 2). A committed bill
|
||||
* is never edited; corrections are new documents referencing the original.
|
||||
*/
|
||||
export type DocType = 'SALE' | 'SALE_RETURN' | 'PURCHASE' | 'PURCHASE_RETURN' | 'STOCK_ADJUST'
|
||||
|
||||
export interface BillLine {
|
||||
itemId: string
|
||||
name: string
|
||||
hsn: string
|
||||
qty: number
|
||||
unitCode: string
|
||||
unitPricePaise: Paise
|
||||
mrpPaise?: Paise
|
||||
grossPaise: Paise
|
||||
discountPaise: Paise
|
||||
taxablePaise: Paise
|
||||
taxRateBp: number
|
||||
cgstPaise: Paise
|
||||
sgstPaise: Paise
|
||||
igstPaise: Paise
|
||||
cessPaise: Paise
|
||||
lineTotalPaise: Paise
|
||||
batchId?: string
|
||||
}
|
||||
|
||||
export type PaymentMode = 'CASH' | 'UPI' | 'CARD' | 'KHATA'
|
||||
|
||||
export interface Payment {
|
||||
mode: PaymentMode
|
||||
amountPaise: Paise
|
||||
reference?: string
|
||||
}
|
||||
|
||||
export interface BillTotals {
|
||||
grossPaise: Paise
|
||||
discountPaise: Paise
|
||||
taxablePaise: Paise
|
||||
cgstPaise: Paise
|
||||
sgstPaise: Paise
|
||||
igstPaise: Paise
|
||||
cessPaise: Paise
|
||||
roundOffPaise: Paise
|
||||
payablePaise: Paise
|
||||
savingsVsMrpPaise: Paise
|
||||
}
|
||||
|
||||
export interface BillDoc {
|
||||
id: string // uuidv7, client-generated
|
||||
tenantId: string
|
||||
storeId: string
|
||||
counterId: string
|
||||
docType: DocType
|
||||
docNo: string
|
||||
/** Working date from Day Begin — never the machine clock. */
|
||||
businessDate: IsoDate
|
||||
cashierId: string
|
||||
shiftId: string
|
||||
customerId?: string
|
||||
refDocId?: string // returns/corrections point at the original
|
||||
lines: BillLine[]
|
||||
payments: Payment[]
|
||||
totals: BillTotals
|
||||
/** Wall clock is display metadata only; ordering uses (lamport, deviceId). */
|
||||
createdAtWallClock: string
|
||||
lamport: number
|
||||
deviceId: string
|
||||
deviceSeq: number
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
const GSTIN_SHAPE = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/
|
||||
const B36 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
|
||||
/** Standard GSTIN check digit: alternating 1/2 factors over base-36 values. */
|
||||
export function gstinCheckDigit(first14: string): string {
|
||||
let sum = 0
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const v = B36.indexOf(first14[i]!)
|
||||
const product = v * (i % 2 === 0 ? 1 : 2)
|
||||
sum += Math.trunc(product / 36) + (product % 36)
|
||||
}
|
||||
return B36[(36 - (sum % 36)) % 36]!
|
||||
}
|
||||
|
||||
export function validateGstin(gstin: string): { ok: boolean; stateCode?: string; reason?: string } {
|
||||
const g = gstin.toUpperCase().trim()
|
||||
if (!GSTIN_SHAPE.test(g)) return { ok: false, reason: 'shape' }
|
||||
if (gstinCheckDigit(g.slice(0, 14)) !== g[14]) return { ok: false, reason: 'checksum' }
|
||||
return { ok: true, stateCode: g.slice(0, 2) }
|
||||
}
|
||||
|
||||
/** Minimal buyer identity a place-of-supply decision needs. */
|
||||
export interface SupplyCustomer {
|
||||
gstin?: string
|
||||
/** 2-digit GST state code; when absent it is read from the GSTIN's first two digits. */
|
||||
stateCode?: string
|
||||
}
|
||||
|
||||
export interface SupplyDecision {
|
||||
/** True when the buyer carries a GSTIN — a registered B2B sale. */
|
||||
b2b: boolean
|
||||
/** GST state code that is the place of supply (buyer's for B2B, the store's for B2C). */
|
||||
placeOfSupplyStateCode: string
|
||||
/** Place of supply differs from the store ⇒ the IGST path; else CGST+SGST. */
|
||||
interState: boolean
|
||||
/** Buyer GSTIN snapshot (upper-cased) when this is a B2B sale. */
|
||||
buyerGstin?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Place-of-supply decision at the counter — the single rule the POS and the
|
||||
* store server must agree on so their engine runs match to the paisa (R5).
|
||||
* A buyer with a GSTIN is B2B: place of supply is the buyer's state (an explicit
|
||||
* stateCode, else the GSTIN's first two digits). Everyone else is a B2C
|
||||
* over-the-counter sale — place of supply is the store. Inter-state ⇒ IGST.
|
||||
*/
|
||||
export function deriveSupply(customer: SupplyCustomer | undefined, storeStateCode: string): SupplyDecision {
|
||||
const gstin = customer?.gstin?.toUpperCase().trim()
|
||||
const b2b = gstin !== undefined && gstin !== ''
|
||||
const buyerState = customer?.stateCode !== undefined && customer.stateCode !== ''
|
||||
? customer.stateCode
|
||||
: b2b ? gstin.slice(0, 2) : undefined
|
||||
const placeOfSupplyStateCode = b2b && buyerState !== undefined ? buyerState : storeStateCode
|
||||
return {
|
||||
b2b,
|
||||
placeOfSupplyStateCode,
|
||||
interState: placeOfSupplyStateCode !== storeStateCode,
|
||||
...(b2b ? { buyerGstin: gstin } : {}),
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* UUIDv7: 48-bit unix-ms timestamp + random. Time-ordered so ids sort roughly
|
||||
* by creation across devices — the property the sync design (02-ARCHITECTURE §3)
|
||||
* relies on for cheap cursor pagination. Client-generated, never server-assigned.
|
||||
* Web Crypto keeps this package loadable in both Node and the POS renderer.
|
||||
*/
|
||||
export function uuidv7(now: number = Date.now()): string {
|
||||
const b = new Uint8Array(16)
|
||||
globalThis.crypto.getRandomValues(b)
|
||||
b[0] = (now / 2 ** 40) & 0xff
|
||||
b[1] = (now / 2 ** 32) & 0xff
|
||||
b[2] = (now / 2 ** 24) & 0xff
|
||||
b[3] = (now / 2 ** 16) & 0xff
|
||||
b[4] = (now / 2 ** 8) & 0xff
|
||||
b[5] = now & 0xff
|
||||
b[6] = 0x70 | (b[6]! & 0x0f)
|
||||
b[8] = 0x80 | (b[8]! & 0x3f)
|
||||
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('')
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
|
||||
}
|
||||
|
||||
export function timestampOfUuidv7(id: string): number {
|
||||
const hex = id.replaceAll('-', '').slice(0, 12)
|
||||
return Number.parseInt(hex, 16)
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
export * from './ids'
|
||||
export * from './money'
|
||||
export * from './tenancy'
|
||||
export * from './business-day'
|
||||
export * from './doc-series'
|
||||
export * from './gstin'
|
||||
export * from './masters'
|
||||
export * from './documents'
|
||||
export * from './outbox'
|
||||
@ -0,0 +1,40 @@
|
||||
import type { Paise } from './money'
|
||||
|
||||
export interface Unit {
|
||||
code: string // PCS, KG, BOX…
|
||||
/** Decimal places allowed in a quantity of this unit (KG → 3, PCS → 0). */
|
||||
decimals: number
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string
|
||||
tenantId: string
|
||||
code: string
|
||||
name: string
|
||||
/** Optional regional-script secondary name, shown under the primary on POS. */
|
||||
nameSecondary?: string
|
||||
hsn: string
|
||||
taxClassCode: string
|
||||
unit: Unit
|
||||
mrpPaise?: Paise
|
||||
salePricePaise: Paise
|
||||
/** Sale price semantics; Indian B2C retail is normally tax-inclusive. */
|
||||
priceIncludesTax: boolean
|
||||
barcodes: string[]
|
||||
batchTracked: boolean
|
||||
/** Draft items come from the POS unknown-barcode quick-add, pending completion. */
|
||||
status: 'active' | 'draft' | 'inactive'
|
||||
}
|
||||
|
||||
export interface Party {
|
||||
id: string
|
||||
tenantId: string
|
||||
code: string
|
||||
name: string
|
||||
kind: 'customer' | 'supplier'
|
||||
phone?: string
|
||||
gstin?: string
|
||||
/** 2-digit GST state code; drives place-of-supply on B2B documents. */
|
||||
stateCode?: string
|
||||
creditLimitPaise?: Paise
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
/** All money is integer paise. Fractional-rupee floats never enter the domain. */
|
||||
export type Paise = number
|
||||
|
||||
export function fromRupees(rupees: number): Paise {
|
||||
return Math.round(rupees * 100)
|
||||
}
|
||||
|
||||
/** Indian digit grouping: 12,34,567.89 */
|
||||
export function formatINR(amount: Paise, opts: { symbol?: boolean } = {}): string {
|
||||
const sign = amount < 0 ? '-' : ''
|
||||
const abs = Math.abs(Math.round(amount))
|
||||
const whole = String(Math.trunc(abs / 100))
|
||||
const frac = String(abs % 100).padStart(2, '0')
|
||||
const grouped =
|
||||
whole.length <= 3
|
||||
? whole
|
||||
: whole.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ',') + ',' + whole.slice(-3)
|
||||
return `${sign}${opts.symbol === false ? '' : '₹'}${grouped}.${frac}`
|
||||
}
|
||||
|
||||
const ONES = [
|
||||
'', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten',
|
||||
'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen',
|
||||
]
|
||||
const TENS = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety']
|
||||
|
||||
function below100(n: number): string {
|
||||
if (n < 20) return ONES[n]!
|
||||
const t = TENS[Math.floor(n / 10)]!
|
||||
const o = n % 10
|
||||
return o === 0 ? t : `${t} ${ONES[o]!}`
|
||||
}
|
||||
|
||||
function below1000(n: number): string {
|
||||
const h = Math.floor(n / 100)
|
||||
const r = n % 100
|
||||
const parts: string[] = []
|
||||
if (h > 0) parts.push(`${ONES[h]!} Hundred`)
|
||||
if (r > 0) parts.push(below100(r))
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/** Whole non-negative rupees to Indian-numbering words (crore/lakh/thousand). */
|
||||
function indianWords(rupees: number): string {
|
||||
if (rupees === 0) return 'Zero'
|
||||
let n = rupees
|
||||
const crore = Math.floor(n / 10_000_000); n %= 10_000_000
|
||||
const lakh = Math.floor(n / 100_000); n %= 100_000
|
||||
const thousand = Math.floor(n / 1000); n %= 1000
|
||||
const parts: string[] = []
|
||||
if (crore > 0) parts.push(`${below1000(crore)} Crore`)
|
||||
if (lakh > 0) parts.push(`${below100(lakh)} Lakh`)
|
||||
if (thousand > 0) parts.push(`${below100(thousand)} Thousand`)
|
||||
if (n > 0) parts.push(below1000(n))
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer paise to an Indian-numbering "amount in words" string for GST invoices
|
||||
* (e.g. 1,00,00,000 paise → "Rupees One Lakh Only"). Rupees and paise are named
|
||||
* separately; "Paisa" is singular. The `formatINR` sibling for the numeric form.
|
||||
*/
|
||||
export function amountInWordsINR(paise: Paise): string {
|
||||
const sign = paise < 0 ? 'Minus ' : ''
|
||||
const abs = Math.abs(Math.round(paise))
|
||||
const rupees = Math.trunc(abs / 100)
|
||||
const p = abs % 100
|
||||
const paiseWord = p === 1 ? 'Paisa' : 'Paise'
|
||||
if (rupees > 0 && p > 0) return `${sign}Rupees ${indianWords(rupees)} and ${below100(p)} ${paiseWord} Only`
|
||||
if (rupees > 0) return `${sign}Rupees ${indianWords(rupees)} Only`
|
||||
if (p > 0) return `${sign}${below100(p)} ${paiseWord} Only`
|
||||
return 'Rupees Zero Only'
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Outbox rows are written in the same local transaction as every business
|
||||
* change from the FIRST local-only build (D14): dormant until the cloud tier
|
||||
* exists, at which point a sync agent drains them. Cloud enablement must be a
|
||||
* switch-on, never a data migration.
|
||||
*/
|
||||
export interface OutboxRow {
|
||||
opId: string // uuidv7 — the idempotency key
|
||||
tenantId: string
|
||||
deviceId: string
|
||||
deviceSeq: number
|
||||
docType: string
|
||||
docId: string
|
||||
/** Whole-document changeset envelope — row-level sync is forbidden by design. */
|
||||
envelope: unknown
|
||||
createdAtWallClock: string
|
||||
lamport: number
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Tenancy is a data property from the first row, not a cloud feature (D14 guardrail).
|
||||
* Classic has no tenant concept; here every record carries tenantId even though a
|
||||
* local v1 install holds exactly one tenant — minted at provisioning, invisible in
|
||||
* the UI, and the thing that makes the later cloud merge a switch-on instead of a
|
||||
* re-keying migration.
|
||||
*/
|
||||
export interface Tenant {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
gstin?: string
|
||||
gstScheme: 'regular' | 'composite'
|
||||
/** Indian FY starts April; kept as data for the day that assumption breaks. */
|
||||
fyStartMonth: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Store {
|
||||
id: string
|
||||
tenantId: string
|
||||
code: string
|
||||
name: string
|
||||
/** 2-digit GST state code; decides CGST/SGST vs IGST at billing time. */
|
||||
stateCode: string
|
||||
address?: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface Counter {
|
||||
id: string
|
||||
tenantId: string
|
||||
storeId: string
|
||||
code: string
|
||||
name: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type RoleCode =
|
||||
| 'owner'
|
||||
| 'manager'
|
||||
| 'supervisor'
|
||||
| 'cashier'
|
||||
| 'purchaser'
|
||||
| 'accountant'
|
||||
| 'auditor'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
tenantId: string
|
||||
username: string
|
||||
displayName: string
|
||||
roles: RoleCode[]
|
||||
/** 'all' or explicit store scoping. */
|
||||
storeIds: 'all' | string[]
|
||||
language: string
|
||||
active: boolean
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatINR, fyOf, fyShort, uuidv7, timestampOfUuidv7,
|
||||
formatDocNo, nextDocNo, rolloverForFy, seriesPrefix, assertGstDocNo,
|
||||
gstinCheckDigit, validateGstin, deriveSupply,
|
||||
type DocSeries,
|
||||
} from '@sims/domain'
|
||||
|
||||
describe('money — Indian grouping', () => {
|
||||
it('groups lakh/crore style', () => {
|
||||
expect(formatINR(123_456_789)).toBe('₹12,34,567.89')
|
||||
expect(formatINR(100)).toBe('₹1.00')
|
||||
expect(formatINR(-4_50)).toBe('-₹4.50')
|
||||
expect(formatINR(99_999, { symbol: false })).toBe('999.99')
|
||||
})
|
||||
})
|
||||
|
||||
describe('financial year (Day Begin idiom)', () => {
|
||||
it('splits at 1 April', () => {
|
||||
expect(fyOf('2026-03-31')).toBe('2025-26')
|
||||
expect(fyOf('2026-04-01')).toBe('2026-27')
|
||||
expect(fyShort('2026-27')).toBe('26')
|
||||
})
|
||||
})
|
||||
|
||||
describe('uuidv7', () => {
|
||||
it('is well-formed and carries its timestamp', () => {
|
||||
const at = 1_750_000_000_000
|
||||
const id = uuidv7(at)
|
||||
expect(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(timestampOfUuidv7(id)).toBe(at)
|
||||
})
|
||||
it('sorts by creation time', () => {
|
||||
expect(uuidv7(1000) < uuidv7(2000)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document series — offline per-counter numbering', () => {
|
||||
const series: DocSeries = {
|
||||
tenantId: 't', storeId: 's', counterId: 'c', docType: 'SALE',
|
||||
fy: '2026-27', prefix: seriesPrefix('ST1', 'C2', '26'), nextSeq: 123,
|
||||
}
|
||||
it('formats within the GST 16-char rule', () => {
|
||||
const { docNo, series: bumped } = nextDocNo(series)
|
||||
expect(docNo).toBe('ST1C2/26-000123')
|
||||
expect(docNo.length).toBeLessThanOrEqual(16)
|
||||
expect(bumped.nextSeq).toBe(124)
|
||||
})
|
||||
it('rejects numbers that break the GST rule', () => {
|
||||
expect(() => assertGstDocNo('THIS/PREFIX/IS/TOO/LONG-000001')).toThrow(/16-char/)
|
||||
expect(() => formatDocNo('BAD_CHAR', 1)).toThrow(/16-char/) // underscore not allowed
|
||||
})
|
||||
it('FY rollover restarts the sequence', () => {
|
||||
const rolled = rolloverForFy(series, '2027-28', seriesPrefix('ST1', 'C2', '27'))
|
||||
expect(rolled.nextSeq).toBe(1)
|
||||
expect(rolloverForFy(series, '2026-27', series.prefix)).toBe(series)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GSTIN validation', () => {
|
||||
const body = '32ABCDE1234F1Z'
|
||||
const valid = body + gstinCheckDigit(body)
|
||||
it('accepts a checksum-correct GSTIN and extracts the state', () => {
|
||||
expect(validateGstin(valid)).toEqual({ ok: true, stateCode: '32' })
|
||||
})
|
||||
it('rejects wrong checksum and wrong shape', () => {
|
||||
const flipped = body + (valid[14] === 'A' ? 'B' : 'A')
|
||||
expect(validateGstin(flipped).ok).toBe(false)
|
||||
expect(validateGstin('not-a-gstin').ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSupply — place of supply at the counter', () => {
|
||||
const store = '32' // MegaMart's state
|
||||
const intraGstin = '32ABCDE1234F1Z' + gstinCheckDigit('32ABCDE1234F1Z')
|
||||
const interGstin = '27ABCDE1234F1Z' + gstinCheckDigit('27ABCDE1234F1Z')
|
||||
|
||||
it('no GSTIN → B2C, place of supply is the store state (intra)', () => {
|
||||
expect(deriveSupply(undefined, store)).toMatchObject({ b2b: false, placeOfSupplyStateCode: '32', interState: false })
|
||||
// A walk-in with only a state on file is still B2C — place of supply stays the store.
|
||||
expect(deriveSupply({ stateCode: '27' }, store)).toMatchObject({ b2b: false, placeOfSupplyStateCode: '32', interState: false })
|
||||
})
|
||||
it('GSTIN in the same state → B2B intra (CGST/SGST)', () => {
|
||||
expect(deriveSupply({ gstin: intraGstin }, store)).toMatchObject({
|
||||
b2b: true, placeOfSupplyStateCode: '32', interState: false, buyerGstin: intraGstin,
|
||||
})
|
||||
})
|
||||
it('GSTIN in another state → B2B inter (IGST)', () => {
|
||||
expect(deriveSupply({ gstin: interGstin }, store)).toMatchObject({
|
||||
b2b: true, placeOfSupplyStateCode: '27', interState: true, buyerGstin: interGstin,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@sims/printing",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@sims/domain": "*",
|
||||
"@sims/scanning": "*"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export * from './escpos'
|
||||
export * from './receipt'
|
||||
export * from './invoice-html'
|
||||
export * from './labels'
|
||||
export * from './raster'
|
||||
@ -0,0 +1,245 @@
|
||||
import { amountInWordsINR, formatINR, type BillLine, type BillTotals } from '@sims/domain'
|
||||
|
||||
/**
|
||||
* A4 GST tax invoice as a self-contained, printable HTML document (09-UX §5.3:
|
||||
* "bilingual default for A4"). Pure — no DOM, no fetch — so the back office can
|
||||
* open it in a new window and the same string is unit-testable. The thermal
|
||||
* receipt (receipt.ts) stays the B2C fast path; this is the B2B/GST document.
|
||||
*/
|
||||
|
||||
export interface InvoiceParty {
|
||||
name: string
|
||||
gstin?: string
|
||||
address?: string
|
||||
stateCode?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export interface InvoiceDoc {
|
||||
seller: InvoiceParty
|
||||
buyer?: InvoiceParty
|
||||
/** GST invoice number (the per-counter series doc no). */
|
||||
invoiceNo: string
|
||||
invoiceDate: string
|
||||
/** State code of supply; drives CGST/SGST vs IGST reading on the print. */
|
||||
placeOfSupplyStateCode?: string
|
||||
lines: BillLine[]
|
||||
totals: BillTotals
|
||||
paymentLabel?: string
|
||||
}
|
||||
|
||||
export interface InvoiceOptions {
|
||||
/** Document heading; "Tax Invoice" for registered sellers, else "Bill of Supply". */
|
||||
title?: string
|
||||
/** Bilingual footer note; a sensible GST default is used when omitted. */
|
||||
footerNote?: string
|
||||
/** Set false to omit the on-page Print button (e.g. embedding). Default true. */
|
||||
printButton?: boolean
|
||||
}
|
||||
|
||||
/** GST state codes → names, for a readable "Place of Supply" line. */
|
||||
const STATE_NAMES: Record<string, string> = {
|
||||
'01': 'Jammu & Kashmir', '02': 'Himachal Pradesh', '03': 'Punjab', '04': 'Chandigarh',
|
||||
'05': 'Uttarakhand', '06': 'Haryana', '07': 'Delhi', '08': 'Rajasthan', '09': 'Uttar Pradesh',
|
||||
'10': 'Bihar', '11': 'Sikkim', '12': 'Arunachal Pradesh', '13': 'Nagaland', '14': 'Manipur',
|
||||
'15': 'Mizoram', '16': 'Tripura', '17': 'Meghalaya', '18': 'Assam', '19': 'West Bengal',
|
||||
'20': 'Jharkhand', '21': 'Odisha', '22': 'Chhattisgarh', '23': 'Madhya Pradesh', '24': 'Gujarat',
|
||||
'26': 'Dadra & Nagar Haveli and Daman & Diu', '27': 'Maharashtra', '28': 'Andhra Pradesh (old)',
|
||||
'29': 'Karnataka', '30': 'Goa', '31': 'Lakshadweep', '32': 'Kerala', '33': 'Tamil Nadu',
|
||||
'34': 'Puducherry', '35': 'Andaman & Nicobar Islands', '36': 'Telangana', '37': 'Andhra Pradesh',
|
||||
'38': 'Ladakh', '97': 'Other Territory',
|
||||
}
|
||||
|
||||
const esc = (s: string): string =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
const inr = (p: number): string => formatINR(p, { symbol: false })
|
||||
|
||||
function placeOfSupply(code: string | undefined): string {
|
||||
if (code === undefined || code === '') return '—'
|
||||
const name = STATE_NAMES[code]
|
||||
return name !== undefined ? `${code} — ${name}` : code
|
||||
}
|
||||
|
||||
interface HsnBucket {
|
||||
hsn: string
|
||||
taxRateBp: number
|
||||
taxablePaise: number
|
||||
cgstPaise: number
|
||||
sgstPaise: number
|
||||
igstPaise: number
|
||||
cessPaise: number
|
||||
}
|
||||
|
||||
/** Collapse lines into an HSN + rate summary — the table the GST return wants. */
|
||||
function hsnBuckets(lines: BillLine[]): HsnBucket[] {
|
||||
const map = new Map<string, HsnBucket>()
|
||||
for (const l of lines) {
|
||||
const key = `${l.hsn}|${l.taxRateBp}`
|
||||
const b = map.get(key) ?? { hsn: l.hsn, taxRateBp: l.taxRateBp, taxablePaise: 0, cgstPaise: 0, sgstPaise: 0, igstPaise: 0, cessPaise: 0 }
|
||||
b.taxablePaise += l.taxablePaise
|
||||
b.cgstPaise += l.cgstPaise
|
||||
b.sgstPaise += l.sgstPaise
|
||||
b.igstPaise += l.igstPaise
|
||||
b.cessPaise += l.cessPaise
|
||||
map.set(key, b)
|
||||
}
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
const STYLE = `
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; color: #111; margin: 0; background: #f4f4f5; }
|
||||
.sheet { width: 210mm; min-height: 297mm; margin: 8mm auto; padding: 12mm; background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.15); }
|
||||
h1 { font-size: 15pt; margin: 0 0 2mm; letter-spacing: .5px; }
|
||||
.title { text-align: center; font-size: 12pt; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; border: 1px solid #111; padding: 2mm; margin-bottom: 4mm; }
|
||||
.head { display: flex; justify-content: space-between; gap: 6mm; margin-bottom: 4mm; }
|
||||
.head .blk { flex: 1; }
|
||||
.muted { color: #555; font-size: 9pt; }
|
||||
.grid2 { display: flex; gap: 6mm; margin-bottom: 4mm; }
|
||||
.grid2 .box { flex: 1; border: 1px solid #ccc; padding: 3mm; font-size: 9.5pt; }
|
||||
.box h3 { margin: 0 0 1.5mm; font-size: 9pt; text-transform: uppercase; color: #666; letter-spacing: .5px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 4mm; }
|
||||
th, td { border: 1px solid #bbb; padding: 1.6mm 2mm; text-align: left; }
|
||||
th { background: #f0f0f0; font-size: 8.5pt; text-transform: uppercase; letter-spacing: .3px; }
|
||||
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
tfoot td { font-weight: 600; }
|
||||
.totals { width: 46%; margin-left: auto; }
|
||||
.totals td { border: none; padding: 1mm 2mm; }
|
||||
.totals .grand td { border-top: 2px solid #111; font-size: 12pt; font-weight: 700; padding-top: 2mm; }
|
||||
.words { border: 1px solid #ccc; padding: 3mm; font-size: 10pt; margin-bottom: 4mm; }
|
||||
.words b { display: block; font-size: 8.5pt; text-transform: uppercase; color: #666; margin-bottom: 1mm; }
|
||||
.foot { border-top: 1px solid #ccc; padding-top: 3mm; font-size: 9pt; color: #444; display: flex; justify-content: space-between; gap: 6mm; }
|
||||
.sign { text-align: right; }
|
||||
.no-print { position: fixed; top: 10px; right: 14px; }
|
||||
.no-print button { font: inherit; padding: 8px 16px; border: 0; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
|
||||
@media print { body { background: #fff; } .sheet { margin: 0; box-shadow: none; width: auto; } .no-print { display: none; } }
|
||||
@page { size: A4; margin: 10mm; }
|
||||
`
|
||||
|
||||
function partyBlock(label: string, p: InvoiceParty | undefined): string {
|
||||
if (p === undefined) return `<div class="box"><h3>${esc(label)}</h3><div>Walk-in / Cash sale</div></div>`
|
||||
return `<div class="box">
|
||||
<h3>${esc(label)}</h3>
|
||||
<div><strong>${esc(p.name)}</strong></div>
|
||||
${p.address !== undefined && p.address !== '' ? `<div class="muted">${esc(p.address)}</div>` : ''}
|
||||
${p.gstin !== undefined && p.gstin !== '' ? `<div>GSTIN: ${esc(p.gstin)}</div>` : '<div class="muted">Unregistered</div>'}
|
||||
${p.stateCode !== undefined && p.stateCode !== '' ? `<div class="muted">State: ${esc(placeOfSupply(p.stateCode))}</div>` : ''}
|
||||
${p.phone !== undefined && p.phone !== '' ? `<div class="muted">Ph: ${esc(p.phone)}</div>` : ''}
|
||||
</div>`
|
||||
}
|
||||
|
||||
export function renderInvoiceHtml(doc: InvoiceDoc, opts: InvoiceOptions = {}): string {
|
||||
const title = opts.title ?? (doc.seller.gstin !== undefined && doc.seller.gstin !== '' ? 'Tax Invoice' : 'Bill of Supply')
|
||||
const t = doc.totals
|
||||
const interState = t.igstPaise > 0
|
||||
const buckets = hsnBuckets(doc.lines)
|
||||
const footer = opts.footerNote ??
|
||||
'This is a computer-generated invoice and needs no signature. / यह कंप्यूटर द्वारा जनित बिल है, हस्ताक्षर की आवश्यकता नहीं।'
|
||||
|
||||
const lineRows = doc.lines.map((l, i) => `
|
||||
<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td>${esc(l.name)}</td>
|
||||
<td>${esc(l.hsn)}</td>
|
||||
<td class="num">${l.qty} ${esc(l.unitCode)}</td>
|
||||
<td class="num">${inr(l.unitPricePaise)}</td>
|
||||
<td class="num">${inr(l.taxablePaise)}</td>
|
||||
<td class="num">${(l.taxRateBp / 100).toFixed(l.taxRateBp % 100 === 0 ? 0 : 2)}%</td>
|
||||
<td class="num">${inr(l.lineTotalPaise)}</td>
|
||||
</tr>`).join('')
|
||||
|
||||
const taxHead = interState
|
||||
? '<th class="num">IGST</th>'
|
||||
: '<th class="num">CGST</th><th class="num">SGST</th>'
|
||||
const taxRows = buckets.map((b) => `
|
||||
<tr>
|
||||
<td>${esc(b.hsn)}</td>
|
||||
<td class="num">${(b.taxRateBp / 100).toFixed(b.taxRateBp % 100 === 0 ? 0 : 2)}%</td>
|
||||
<td class="num">${inr(b.taxablePaise)}</td>
|
||||
${interState ? `<td class="num">${inr(b.igstPaise)}</td>` : `<td class="num">${inr(b.cgstPaise)}</td><td class="num">${inr(b.sgstPaise)}</td>`}
|
||||
<td class="num">${inr(b.cessPaise)}</td>
|
||||
</tr>`).join('')
|
||||
|
||||
const totalsRows: string[] = [
|
||||
`<tr><td>Taxable value</td><td class="num">${inr(t.taxablePaise)}</td></tr>`,
|
||||
]
|
||||
if (t.discountPaise > 0) totalsRows.push(`<tr><td>Discount</td><td class="num">-${inr(t.discountPaise)}</td></tr>`)
|
||||
if (interState) {
|
||||
totalsRows.push(`<tr><td>IGST</td><td class="num">${inr(t.igstPaise)}</td></tr>`)
|
||||
} else {
|
||||
totalsRows.push(`<tr><td>CGST</td><td class="num">${inr(t.cgstPaise)}</td></tr>`)
|
||||
totalsRows.push(`<tr><td>SGST</td><td class="num">${inr(t.sgstPaise)}</td></tr>`)
|
||||
}
|
||||
if (t.cessPaise > 0) totalsRows.push(`<tr><td>Cess</td><td class="num">${inr(t.cessPaise)}</td></tr>`)
|
||||
if (t.roundOffPaise !== 0) totalsRows.push(`<tr><td>Round off</td><td class="num">${inr(t.roundOffPaise)}</td></tr>`)
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${esc(title)} ${esc(doc.invoiceNo)}</title>
|
||||
<style>${STYLE}</style>
|
||||
</head>
|
||||
<body>
|
||||
${opts.printButton === false ? '' : '<div class="no-print"><button type="button" onclick="window.print()">Print A4</button></div>'}
|
||||
<div class="sheet">
|
||||
<div class="title">${esc(title)}</div>
|
||||
<div class="head">
|
||||
<div class="blk">
|
||||
<h1>${esc(doc.seller.name)}</h1>
|
||||
${doc.seller.address !== undefined && doc.seller.address !== '' ? `<div class="muted">${esc(doc.seller.address)}</div>` : ''}
|
||||
${doc.seller.gstin !== undefined && doc.seller.gstin !== '' ? `<div>GSTIN: <strong>${esc(doc.seller.gstin)}</strong></div>` : ''}
|
||||
${doc.seller.phone !== undefined && doc.seller.phone !== '' ? `<div class="muted">Ph: ${esc(doc.seller.phone)}</div>` : ''}
|
||||
</div>
|
||||
<div class="blk sign">
|
||||
<div><span class="muted">Invoice No</span><br /><strong>${esc(doc.invoiceNo)}</strong></div>
|
||||
<div><span class="muted">Date</span><br />${esc(doc.invoiceDate)}</div>
|
||||
<div><span class="muted">Place of Supply</span><br />${esc(placeOfSupply(doc.placeOfSupplyStateCode ?? doc.seller.stateCode))}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
${partyBlock('Billed To', doc.buyer)}
|
||||
${partyBlock('Supplier', doc.seller)}
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num">#</th><th>Description</th><th>HSN</th><th class="num">Qty</th>
|
||||
<th class="num">Rate</th><th class="num">Taxable</th><th class="num">GST%</th><th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${lineRows}</tbody>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>HSN</th><th class="num">Rate</th><th class="num">Taxable</th>${taxHead}<th class="num">Cess</th></tr>
|
||||
</thead>
|
||||
<tbody>${taxRows}</tbody>
|
||||
</table>
|
||||
|
||||
<table class="totals">
|
||||
<tbody>
|
||||
${totalsRows.join('')}
|
||||
<tr class="grand"><td>Payable${doc.paymentLabel !== undefined ? ` (${esc(doc.paymentLabel)})` : ''}</td><td class="num">${formatINR(t.payablePaise)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="words">
|
||||
<b>Amount in words</b>
|
||||
${esc(amountInWordsINR(t.payablePaise))}
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<div>${esc(footer)}</div>
|
||||
<div class="sign">For <strong>${esc(doc.seller.name)}</strong><br /><br />Authorised Signatory</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Raster (bit-image) receipt bytes. ESC/POS text mode can't render Devanagari
|
||||
* or Malayalam (09-UX §5.3), so non-Latin receipts print as a 1-bit raster via
|
||||
* GS v 0. This module is the pure part — thresholding pixels and packing bits;
|
||||
* the canvas that draws the glyphs lives in the app (apps/pos/src/raster.ts),
|
||||
* because canvas is a browser API and byte-packing must stay testable.
|
||||
*/
|
||||
const GS = 0x1d
|
||||
|
||||
/** 1 bit per pixel, row-major; `bits[y*width + x]` is 1 for a printed (black) dot. */
|
||||
export interface Bitmap {
|
||||
width: number
|
||||
height: number
|
||||
bits: Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack one pixel row (0/1 values) into bytes, most-significant bit = leftmost
|
||||
* pixel, padded with zeros to a whole byte. 9 pixels → 2 bytes.
|
||||
*/
|
||||
export function packRasterRow(row: ArrayLike<number>): number[] {
|
||||
const bytes = new Array<number>(Math.ceil(row.length / 8)).fill(0)
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
if (row[i]) bytes[i >> 3]! |= 0x80 >> (i & 7)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold RGBA pixel data (canvas `ImageData.data`) into a 1-bpp bitmap.
|
||||
* A dot prints when it is opaque and darker than `threshold` (Rec.601 luma).
|
||||
*/
|
||||
export function bitonalFromRGBA(
|
||||
data: ArrayLike<number>, width: number, height: number, threshold = 128,
|
||||
): Bitmap {
|
||||
const bits = new Uint8Array(width * height)
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const r = data[i * 4]!, g = data[i * 4 + 1]!, b = data[i * 4 + 2]!, a = data[i * 4 + 3]!
|
||||
const luma = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
bits[i] = a >= 128 && luma < threshold ? 1 : 0
|
||||
}
|
||||
return { width, height, bits }
|
||||
}
|
||||
|
||||
/** GS v 0 raster bit-image command for a bitmap (m=0, normal density). */
|
||||
export function rasterToEscPos(bmp: Bitmap): Uint8Array {
|
||||
const bytesPerRow = Math.ceil(bmp.width / 8)
|
||||
const out: number[] = [GS, 0x76, 0x30, 0, bytesPerRow & 0xff, (bytesPerRow >> 8) & 0xff, bmp.height & 0xff, (bmp.height >> 8) & 0xff]
|
||||
for (let y = 0; y < bmp.height; y++) {
|
||||
const row = bmp.bits.subarray(y * bmp.width, (y + 1) * bmp.width)
|
||||
out.push(...packRasterRow(row))
|
||||
}
|
||||
return Uint8Array.from(out)
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
import { formatINR, type BillLine, type BillTotals } from '@sims/domain'
|
||||
import { EscPos, hr, twoCol } from './escpos'
|
||||
|
||||
export interface ReceiptDoc {
|
||||
storeName: string
|
||||
storeAddress?: string
|
||||
gstin?: string
|
||||
docNo: string
|
||||
businessDate: string
|
||||
counterCode: string
|
||||
cashierName: string
|
||||
/** Attached customer (B2B or a named B2C profile), printed under the bill head. */
|
||||
buyerName?: string
|
||||
/** Buyer GSTIN — its presence makes this a B2B receipt (IGST when inter-state). */
|
||||
buyerGstin?: string
|
||||
lines: BillLine[]
|
||||
totals: BillTotals
|
||||
paymentLabel: string
|
||||
footer?: string
|
||||
}
|
||||
|
||||
export interface ReceiptOptions {
|
||||
/** Characters per line: 32 (2"), 42/48 (3"). */
|
||||
width: number
|
||||
kickDrawer: boolean
|
||||
}
|
||||
|
||||
const inr = (p: number) => formatINR(p, { symbol: false })
|
||||
|
||||
/**
|
||||
* The receipt as plain text lines — the shared content model for both the
|
||||
* ASCII path (renderReceipt) and the Indic raster path (canvas → GS v 0). Item
|
||||
* names print verbatim here so a Devanagari/Malayalam name survives to the
|
||||
* raster renderer; the ASCII builder collapses non-Latin to '?' on its own.
|
||||
*/
|
||||
export function receiptLines(doc: ReceiptDoc, opts: ReceiptOptions): string[] {
|
||||
const w = opts.width
|
||||
const out: string[] = []
|
||||
out.push(doc.storeName)
|
||||
if (doc.storeAddress !== undefined) out.push(doc.storeAddress)
|
||||
if (doc.gstin !== undefined) out.push(`GSTIN: ${doc.gstin}`)
|
||||
out.push(hr(w))
|
||||
out.push(twoCol(`Bill: ${doc.docNo}`, doc.businessDate, w))
|
||||
out.push(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w))
|
||||
if (doc.buyerName !== undefined) out.push(`Buyer: ${doc.buyerName}`)
|
||||
if (doc.buyerGstin !== undefined) out.push(`Buyer GSTIN: ${doc.buyerGstin}`)
|
||||
out.push(hr(w))
|
||||
for (const l of doc.lines) {
|
||||
out.push(l.name)
|
||||
out.push(twoCol(` ${l.qty} ${l.unitCode} x ${inr(l.unitPricePaise)}`, inr(l.lineTotalPaise), w))
|
||||
}
|
||||
out.push(hr(w))
|
||||
out.push(twoCol('Taxable', inr(doc.totals.taxablePaise), w))
|
||||
if (doc.totals.igstPaise > 0) {
|
||||
out.push(twoCol('IGST', inr(doc.totals.igstPaise), w))
|
||||
} else {
|
||||
out.push(twoCol('CGST', inr(doc.totals.cgstPaise), w))
|
||||
out.push(twoCol('SGST', inr(doc.totals.sgstPaise), w))
|
||||
}
|
||||
if (doc.totals.cessPaise > 0) out.push(twoCol('Cess', inr(doc.totals.cessPaise), w))
|
||||
if (doc.totals.discountPaise > 0) out.push(twoCol('Discount', '-' + inr(doc.totals.discountPaise), w))
|
||||
if (doc.totals.roundOffPaise !== 0) out.push(twoCol('Round off', inr(doc.totals.roundOffPaise), w))
|
||||
out.push(twoCol('PAYABLE', inr(doc.totals.payablePaise), w))
|
||||
out.push(twoCol('Paid by', doc.paymentLabel, w))
|
||||
if (doc.totals.savingsVsMrpPaise > 0) out.push(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`)
|
||||
out.push(doc.footer ?? 'Thank you! Visit again')
|
||||
return out
|
||||
}
|
||||
|
||||
/** Thermal receipt bytes. Pure — golden-testable without a printer. */
|
||||
export function renderReceipt(doc: ReceiptDoc, opts: ReceiptOptions): Uint8Array {
|
||||
const w = opts.width
|
||||
const p = new EscPos().init()
|
||||
|
||||
if (opts.kickDrawer) p.drawerKick()
|
||||
|
||||
p.align('center').bold(true).size(2, 2).line(doc.storeName).size(1, 1).bold(false)
|
||||
if (doc.storeAddress !== undefined) p.line(doc.storeAddress)
|
||||
if (doc.gstin !== undefined) p.line(`GSTIN: ${doc.gstin}`)
|
||||
p.line(hr(w))
|
||||
p.align('left')
|
||||
p.line(twoCol(`Bill: ${doc.docNo}`, doc.businessDate, w))
|
||||
p.line(twoCol(`Counter: ${doc.counterCode}`, doc.cashierName, w))
|
||||
if (doc.buyerName !== undefined) p.line(`Buyer: ${doc.buyerName}`)
|
||||
if (doc.buyerGstin !== undefined) p.line(`Buyer GSTIN: ${doc.buyerGstin}`)
|
||||
p.line(hr(w))
|
||||
|
||||
for (const l of doc.lines) {
|
||||
p.line(l.name)
|
||||
p.line(twoCol(` ${l.qty} ${l.unitCode} x ${inr(l.unitPricePaise)}`, inr(l.lineTotalPaise), w))
|
||||
}
|
||||
|
||||
p.line(hr(w))
|
||||
p.line(twoCol('Taxable', inr(doc.totals.taxablePaise), w))
|
||||
if (doc.totals.igstPaise > 0) {
|
||||
p.line(twoCol('IGST', inr(doc.totals.igstPaise), w))
|
||||
} else {
|
||||
p.line(twoCol('CGST', inr(doc.totals.cgstPaise), w))
|
||||
p.line(twoCol('SGST', inr(doc.totals.sgstPaise), w))
|
||||
}
|
||||
if (doc.totals.cessPaise > 0) p.line(twoCol('Cess', inr(doc.totals.cessPaise), w))
|
||||
if (doc.totals.discountPaise > 0) p.line(twoCol('Discount', '-' + inr(doc.totals.discountPaise), w))
|
||||
if (doc.totals.roundOffPaise !== 0) p.line(twoCol('Round off', inr(doc.totals.roundOffPaise), w))
|
||||
p.bold(true).size(1, 2).line(twoCol('PAYABLE', inr(doc.totals.payablePaise), w)).size(1, 1).bold(false)
|
||||
p.line(twoCol('Paid by', doc.paymentLabel, w))
|
||||
if (doc.totals.savingsVsMrpPaise > 0) {
|
||||
p.align('center').line(`You saved ${inr(doc.totals.savingsVsMrpPaise)} vs MRP`)
|
||||
}
|
||||
p.align('center').line(doc.footer ?? 'Thank you! Visit again')
|
||||
p.feed(4).cut()
|
||||
return p.build()
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EscPos, hr, twoCol, renderReceipt } from '@sims/printing'
|
||||
import type { BillLine, BillTotals } from '@sims/domain'
|
||||
|
||||
describe('twoCol', () => {
|
||||
it('pads to exact width and truncates long names', () => {
|
||||
expect(twoCol('Atta 5kg', '270.00', 32)).toHaveLength(32)
|
||||
const long = twoCol('A very very long item name indeed', '1,431.00', 32)
|
||||
expect(long).toHaveLength(32)
|
||||
expect(long.endsWith('1,431.00')).toBe(true)
|
||||
})
|
||||
it('always keeps one space between columns', () => {
|
||||
expect(twoCol('X'.repeat(40), '99.00', 32)).toContain(' 99.00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EscPos builder', () => {
|
||||
it('starts with init and encodes control sequences', () => {
|
||||
const b = new EscPos().init().bold(true).text('HI').cut().build()
|
||||
expect([...b.slice(0, 2)]).toEqual([0x1b, 0x40])
|
||||
expect([...b]).toContain(0x48) // 'H'
|
||||
expect([...b.slice(-4)]).toEqual([0x1d, 0x56, 66, 3])
|
||||
})
|
||||
it('emits the standard drawer-kick pulse', () => {
|
||||
const b = new EscPos().drawerKick().build()
|
||||
expect([...b]).toEqual([0x1b, 0x70, 0, 25, 250])
|
||||
})
|
||||
it('collapses non-ASCII to ? in text mode (raster path comes later)', () => {
|
||||
const b = new EscPos().text('चाय').build()
|
||||
expect([...b]).toEqual([0x3f, 0x3f, 0x3f])
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderReceipt', () => {
|
||||
const line: BillLine = {
|
||||
itemId: 'i1', name: 'Aashirvaad Atta 5kg', hsn: '1101', qty: 1, unitCode: 'PCS',
|
||||
unitPricePaise: 27_000, mrpPaise: 28_500, grossPaise: 27_000, discountPaise: 0,
|
||||
taxablePaise: 22_881, taxRateBp: 1800, cgstPaise: 2_059, sgstPaise: 2_060,
|
||||
igstPaise: 0, cessPaise: 0, lineTotalPaise: 27_000,
|
||||
}
|
||||
const totals: BillTotals = {
|
||||
grossPaise: 27_000, discountPaise: 0, taxablePaise: 22_881, cgstPaise: 2_059,
|
||||
sgstPaise: 2_060, igstPaise: 0, cessPaise: 0, roundOffPaise: 0,
|
||||
payablePaise: 27_000, savingsVsMrpPaise: 1_500,
|
||||
}
|
||||
const doc = {
|
||||
storeName: 'MegaMart', docNo: 'ST1C2/26-000123', businessDate: '2026-07-09',
|
||||
counterCode: 'C2', cashierName: 'Ramesh', lines: [line], totals, paymentLabel: 'CASH',
|
||||
}
|
||||
|
||||
it('renders a complete receipt with cut at the end', () => {
|
||||
const bytes = renderReceipt(doc, { width: 42, kickDrawer: false })
|
||||
const text = new TextDecoder('ascii').decode(bytes)
|
||||
expect(text).toContain('PAYABLE')
|
||||
expect(text).toContain('ST1C2/26-000123')
|
||||
expect(text).toContain('You saved 15.00 vs MRP')
|
||||
expect([...bytes.slice(-4)]).toEqual([0x1d, 0x56, 66, 3])
|
||||
})
|
||||
it('kicks the drawer before printing only when asked', () => {
|
||||
const withKick = renderReceipt(doc, { width: 42, kickDrawer: true })
|
||||
expect([...withKick.slice(2, 7)]).toEqual([0x1b, 0x70, 0, 25, 250])
|
||||
const noKick = renderReceipt(doc, { width: 42, kickDrawer: false })
|
||||
expect([...noKick.slice(2, 7)]).not.toEqual([0x1b, 0x70, 0, 25, 250])
|
||||
})
|
||||
it(`shows IGST instead of the split for inter-state bills`, () => {
|
||||
const inter = renderReceipt(
|
||||
{ ...doc, totals: { ...totals, cgstPaise: 0, sgstPaise: 0, igstPaise: 4_119 } },
|
||||
{ width: 42, kickDrawer: false },
|
||||
)
|
||||
const text = new TextDecoder('ascii').decode(inter)
|
||||
expect(text).toContain('IGST')
|
||||
expect(text).not.toContain('CGST')
|
||||
})
|
||||
it('prints the buyer GSTIN line for a B2B receipt', () => {
|
||||
const b2b = renderReceipt(
|
||||
{ ...doc, buyerName: 'Anand Traders', buyerGstin: '27ABCDE1234F1Z0' },
|
||||
{ width: 42, kickDrawer: false },
|
||||
)
|
||||
const text = new TextDecoder('ascii').decode(b2b)
|
||||
expect(text).toContain('Buyer: Anand Traders')
|
||||
expect(text).toContain('Buyer GSTIN: 27ABCDE1234F1Z0')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { amountInWordsINR, type BillLine, type BillTotals } from '@sims/domain'
|
||||
import {
|
||||
renderInvoiceHtml, type InvoiceDoc,
|
||||
ean13Modules, ean13Svg, normalizeEan13, renderLabelSheetHtml,
|
||||
packRasterRow, bitonalFromRGBA, rasterToEscPos,
|
||||
} from '@sims/printing'
|
||||
|
||||
describe('amountInWordsINR — Indian numbering', () => {
|
||||
it('zero', () => {
|
||||
expect(amountInWordsINR(0)).toBe('Rupees Zero Only')
|
||||
})
|
||||
it('paisa only', () => {
|
||||
expect(amountInWordsINR(50)).toBe('Fifty Paise Only')
|
||||
})
|
||||
it('a single paisa is singular', () => {
|
||||
expect(amountInWordsINR(1)).toBe('One Paisa Only')
|
||||
})
|
||||
it('one lakh rupees', () => {
|
||||
expect(amountInWordsINR(1_00_000 * 100)).toBe('Rupees One Lakh Only')
|
||||
})
|
||||
it('one crore rupees', () => {
|
||||
expect(amountInWordsINR(1_00_00_000 * 100)).toBe('Rupees One Crore Only')
|
||||
})
|
||||
it('mixed rupees and paise', () => {
|
||||
expect(amountInWordsINR(1431_50)).toBe('Rupees One Thousand Four Hundred Thirty One and Fifty Paise Only')
|
||||
})
|
||||
})
|
||||
|
||||
const line: BillLine = {
|
||||
itemId: 'i1', name: 'Surf Excel 1kg', hsn: '3402', qty: 1, unitCode: 'PCS',
|
||||
unitPricePaise: 14_500, mrpPaise: 15_000, grossPaise: 14_500, discountPaise: 0,
|
||||
taxablePaise: 12_288, taxRateBp: 1800, cgstPaise: 1_106, sgstPaise: 1_106,
|
||||
igstPaise: 0, cessPaise: 0, lineTotalPaise: 14_500,
|
||||
}
|
||||
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 invoice: InvoiceDoc = {
|
||||
seller: { name: 'MegaMart T.Nagar', gstin: '32ABCDE1234F1Z5', stateCode: '32', address: 'T.Nagar, Chennai' },
|
||||
buyer: { name: 'Lakshmi', phone: '9840012345' },
|
||||
invoiceNo: 'ST1C2/26-000001', invoiceDate: '2026-07-10',
|
||||
placeOfSupplyStateCode: '32', lines: [line], totals, paymentLabel: 'CASH',
|
||||
}
|
||||
|
||||
describe('renderInvoiceHtml — A4 GST invoice', () => {
|
||||
it('contains the key GST-invoice fields', () => {
|
||||
const html = renderInvoiceHtml(invoice)
|
||||
expect(html).toContain('Tax Invoice')
|
||||
expect(html).toContain('MegaMart T.Nagar')
|
||||
expect(html).toContain('32ABCDE1234F1Z5')
|
||||
expect(html).toContain('ST1C2/26-000001')
|
||||
expect(html).toContain('Surf Excel 1kg')
|
||||
expect(html).toContain('3402') // HSN
|
||||
expect(html).toContain('CGST')
|
||||
expect(html).toContain('SGST')
|
||||
expect(html).toContain('Place of Supply')
|
||||
expect(html).toContain('Kerala') // state 32 resolved
|
||||
})
|
||||
it('renders the amount in words', () => {
|
||||
const html = renderInvoiceHtml(invoice)
|
||||
expect(html).toContain('Amount in words')
|
||||
expect(html).toContain(amountInWordsINR(totals.payablePaise)) // Rupees One Hundred Forty Five Only
|
||||
})
|
||||
it('shows IGST (not CGST/SGST) for an inter-state bill', () => {
|
||||
const inter = renderInvoiceHtml({
|
||||
...invoice,
|
||||
totals: { ...totals, cgstPaise: 0, sgstPaise: 0, igstPaise: 2_212 },
|
||||
lines: [{ ...line, cgstPaise: 0, sgstPaise: 0, igstPaise: 2_212 }],
|
||||
placeOfSupplyStateCode: '29',
|
||||
})
|
||||
expect(inter).toContain('IGST')
|
||||
expect(inter).not.toContain('CGST')
|
||||
})
|
||||
it('is a self-contained HTML document', () => {
|
||||
const html = renderInvoiceHtml(invoice)
|
||||
expect(html.startsWith('<!DOCTYPE html>')).toBe(true)
|
||||
expect(html).toContain('</html>')
|
||||
expect(html).toContain('window.print()')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ean13 encoding', () => {
|
||||
it('encodes the standard 95-module structure (all-zero symbol)', () => {
|
||||
const m = ean13Modules('000000000000') // 12 digits → check digit appended
|
||||
expect(m).toHaveLength(95)
|
||||
expect(m.startsWith('101')).toBe(true) // start guard
|
||||
expect(m.endsWith('101')).toBe(true) // end guard
|
||||
expect(m.slice(45, 50)).toBe('01010') // centre guard
|
||||
// first digit 0 → all L; L(0) = 0001101, so the first data element follows the start guard
|
||||
expect(m.startsWith('101' + '0001101')).toBe(true)
|
||||
})
|
||||
it('computes the check digit via ean13CheckDigit for a 12-digit body', () => {
|
||||
expect(normalizeEan13('890106301435')).toBe('8901063014350')
|
||||
})
|
||||
it('rejects a 13-digit code with a bad checksum (strict path)', () => {
|
||||
expect(() => normalizeEan13('8901063014357')).toThrow(/checksum/)
|
||||
})
|
||||
it('still renders an SVG for legacy 13-digit data (lenient path, R18)', () => {
|
||||
const svg = ean13Svg('8901063014357')
|
||||
expect(svg.startsWith('<svg')).toBe(true)
|
||||
expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"')
|
||||
expect(svg.trimEnd().endsWith('</svg>')).toBe(true)
|
||||
expect(svg).toContain('8901063014357') // human-readable matches stored code
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderLabelSheetHtml', () => {
|
||||
it('lays out labels with barcode, name and MRP', () => {
|
||||
const html = renderLabelSheetHtml(
|
||||
[{ name: 'Surf Excel 1kg', mrpPaise: 15_000, code13: '8901030704833', pricePaise: 14_500 }],
|
||||
{ cols: 3, labelWmm: 38, labelHmm: 25 },
|
||||
)
|
||||
expect(html).toContain('Surf Excel 1kg')
|
||||
expect(html).toContain('MRP')
|
||||
expect(html).toContain('<svg')
|
||||
expect(html).toContain('Rate') // price differs from MRP
|
||||
expect(html).toContain('grid-template-columns: repeat(3')
|
||||
})
|
||||
it('renders text-only labels when no barcode is given', () => {
|
||||
const html = renderLabelSheetHtml([{ name: 'Loose Tomato', mrpPaise: 3_200 }], { cols: 2, labelWmm: 50, labelHmm: 30 })
|
||||
expect(html).toContain('Loose Tomato')
|
||||
expect(html).not.toContain('<svg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('raster bit packing', () => {
|
||||
it('packs a byte MSB-first', () => {
|
||||
expect(packRasterRow([1, 0, 1, 0, 0, 0, 0, 0])).toEqual([0xa0])
|
||||
expect(packRasterRow([0, 0, 0, 0, 0, 0, 0, 1])).toEqual([0x01])
|
||||
})
|
||||
it('pads a partial trailing byte with zeros', () => {
|
||||
expect(packRasterRow([1, 1, 1, 1, 1, 1, 1, 1, 1])).toEqual([0xff, 0x80])
|
||||
})
|
||||
it('thresholds RGBA to bitonal (dark = printed dot)', () => {
|
||||
// two pixels: black then white
|
||||
const data = [0, 0, 0, 255, 255, 255, 255, 255]
|
||||
const bmp = bitonalFromRGBA(data, 2, 1)
|
||||
expect([...bmp.bits]).toEqual([1, 0])
|
||||
})
|
||||
it('emits a GS v 0 raster command with the right header', () => {
|
||||
const bytes = rasterToEscPos({ width: 8, height: 1, bits: Uint8Array.from([1, 0, 1, 0, 0, 0, 0, 0]) })
|
||||
expect([...bytes]).toEqual([0x1d, 0x76, 0x30, 0, 1, 0, 1, 0, 0xa0])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@sims/scanning",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts"
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
/** EAN-13 check digit: weights alternate 1,3 over the first 12 digits. */
|
||||
export function ean13CheckDigit(first12: string): number {
|
||||
if (!/^\d{12}$/.test(first12)) throw new Error('EAN-13 body must be 12 digits')
|
||||
let sum = 0
|
||||
for (let i = 0; i < 12; i++) {
|
||||
sum += Number(first12[i]) * (i % 2 === 0 ? 1 : 3)
|
||||
}
|
||||
return (10 - (sum % 10)) % 10
|
||||
}
|
||||
|
||||
export function isValidEan13(code: string): boolean {
|
||||
return /^\d{13}$/.test(code) && ean13CheckDigit(code.slice(0, 12)) === Number(code[12])
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export * from './barcode'
|
||||
export * from './scale'
|
||||
export * from './entry'
|
||||
export * from './wedge'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue