You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/store-server/src/db.ts

286 lines
16 KiB
TypeScript

// H3 (red-team): better-sqlite3-multiple-ciphers is an API-compatible drop-in for
// better-sqlite3 that adds SQLCipher, so the whole DB file can be encrypted at rest with a
// single `pragma('key=...')`. It ships its own DefinitelyTyped-derived declarations (identical
// shape to @types/better-sqlite3), so `Database.Database` and `db.pragma()` type exactly as
// before — no shim needed. See openDb() for the keying / dev-fallback logic.
import Database from 'better-sqlite3-multiple-ciphers'
import fs from 'node:fs'
import path from 'node:path'
/**
* Store DB — SQLite for the slice, behind portable repositories (repos.ts).
* The Oracle 12c adapter (D12) implements the same functions when the Classic
* DDL arrives; SQL here stays standard where possible, quirks are commented.
*/
export type DB = Database.Database
const SCHEMA = `
CREATE TABLE IF NOT EXISTS tenant (
id TEXT PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL,
gstin TEXT, gst_scheme TEXT NOT NULL DEFAULT 'regular',
fy_start_month INTEGER NOT NULL DEFAULT 4, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS store (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL,
name TEXT NOT NULL, state_code TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS counter (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
code TEXT NOT NULL, name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS app_user (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, roles TEXT NOT NULL, store_ids TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'en', active INTEGER NOT NULL DEFAULT 1,
pin_salt TEXT, pin_hash TEXT, pw_salt TEXT, pw_hash TEXT
);
CREATE TABLE IF NOT EXISTS item (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL,
name TEXT NOT NULL, name_secondary TEXT, hsn TEXT NOT NULL DEFAULT '',
tax_class_code TEXT NOT NULL, unit_code TEXT NOT NULL DEFAULT 'PCS',
unit_decimals INTEGER NOT NULL DEFAULT 0, mrp_paise INTEGER,
sale_price_paise INTEGER NOT NULL, price_includes_tax INTEGER NOT NULL DEFAULT 1,
barcodes TEXT NOT NULL DEFAULT '[]', batch_tracked INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
UNIQUE (tenant_id, code)
);
CREATE TABLE IF NOT EXISTS party (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, code TEXT NOT NULL,
name TEXT NOT NULL, kind TEXT NOT NULL, phone TEXT, gstin TEXT,
state_code TEXT, credit_limit_paise INTEGER
);
CREATE TABLE IF NOT EXISTS tax_class (
class_code TEXT NOT NULL, rate_pct_bp INTEGER NOT NULL,
cess_pct_bp INTEGER NOT NULL DEFAULT 0,
effective_from TEXT NOT NULL, effective_to TEXT
);
CREATE TABLE IF NOT EXISTS doc_series (
tenant_id TEXT NOT NULL, store_id TEXT NOT NULL, counter_id TEXT NOT NULL,
doc_type TEXT NOT NULL, fy TEXT NOT NULL, prefix TEXT NOT NULL,
next_seq INTEGER NOT NULL,
PRIMARY KEY (tenant_id, store_id, counter_id, doc_type, fy)
);
CREATE TABLE IF NOT EXISTS bill (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
counter_id TEXT NOT NULL, doc_type TEXT NOT NULL, doc_no TEXT NOT NULL,
business_date TEXT NOT NULL, cashier_id TEXT NOT NULL, shift_id TEXT NOT NULL,
customer_id TEXT, ref_doc_id TEXT,
gross_paise INTEGER NOT NULL, discount_paise INTEGER NOT NULL,
taxable_paise INTEGER NOT NULL, cgst_paise INTEGER NOT NULL,
sgst_paise INTEGER NOT NULL, igst_paise INTEGER NOT NULL,
cess_paise INTEGER NOT NULL, round_off_paise INTEGER NOT NULL,
payable_paise INTEGER NOT NULL, savings_paise INTEGER NOT NULL,
buyer_gstin TEXT, place_of_supply TEXT,
client_id TEXT,
payload TEXT NOT NULL, created_at_wall TEXT NOT NULL,
lamport INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '',
device_seq INTEGER NOT NULL DEFAULT 0,
UNIQUE (tenant_id, doc_no)
);
CREATE TABLE IF NOT EXISTS stock_movement (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
item_id TEXT NOT NULL, qty_delta REAL NOT NULL, reason TEXT NOT NULL,
ref_doc_id TEXT, batch_id TEXT, business_date TEXT NOT NULL, created_at_wall TEXT NOT NULL
);
-- S5 batch/expiry: a batch is an item's lot with an optional expiry (nullable —
-- non-perishables are batch-tracked without a date). Stock stays derived (R7):
-- a batch's on-hand = SUM(stock_movement.qty_delta WHERE batch_id = the batch).
CREATE TABLE IF NOT EXISTS batch (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT,
created_at TEXT NOT NULL,
UNIQUE (tenant_id, store_id, item_id, batch_no)
);
CREATE TABLE IF NOT EXISTS setting (
scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, key TEXT NOT NULL,
value TEXT NOT NULL, effective_from TEXT
);
CREATE TABLE IF NOT EXISTS message_catalog (
code TEXT NOT NULL, channel TEXT NOT NULL, lang TEXT NOT NULL, template TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, at_wall TEXT NOT NULL,
user_id TEXT NOT NULL, action TEXT NOT NULL, entity TEXT NOT NULL,
entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT,
-- H2 (red-team): per-tenant tamper-evident hash chain. entry_hash =
-- sha256(canonical(prev_hash, tenant_id, at_wall, user_id, action, entity,
-- entity_id, before_json, after_json)); prev_hash = the previous entry's
-- entry_hash for the tenant (genesis = ''). Any out-of-band UPDATE/DELETE/INSERT
-- to this table breaks the chain, which GET /api/audit/verify recomputes. This is
-- tamper-EVIDENCE, not prevention — the file is still writable (that is H3's job).
prev_hash TEXT, entry_hash TEXT
);
CREATE TABLE IF NOT EXISTS outbox (
op_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, device_id TEXT NOT NULL,
device_seq INTEGER NOT NULL, doc_type TEXT NOT NULL, doc_id TEXT NOT NULL,
envelope TEXT NOT NULL, created_at_wall TEXT NOT NULL, lamport INTEGER NOT NULL
);
-- Single-use supervisor-approval tokens (SEC fix): /auth/verify-pin mints one on a
-- valid supervisor PIN; the bill commit redeems it (used=0 -> 1, age < 5 min, approver
-- role re-checked) so the client can never forge who approved an override.
-- H4 fix: the token is BOUND at mint to the exact override it authorises —
-- item_id, kind, before_paise (server-computed master value) and after_paise. The
-- commit re-verifies these against the specific line's server-computed deviation, so
-- a "Milk ₹50→45" approval can never be re-attached to "Whiskey ₹2000→200".
CREATE TABLE IF NOT EXISTS override_approval (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
item_id TEXT, kind TEXT, before_paise INTEGER, after_paise INTEGER,
cashier_user_id TEXT
);
CREATE TABLE IF NOT EXISTS purchase (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
supplier_id TEXT NOT NULL, invoice_no TEXT NOT NULL, invoice_date TEXT NOT NULL,
business_date TEXT NOT NULL, taxable_paise INTEGER NOT NULL, tax_paise INTEGER NOT NULL,
total_paise INTEGER NOT NULL, created_by TEXT NOT NULL, created_at_wall TEXT NOT NULL,
payload TEXT NOT NULL,
UNIQUE (tenant_id, supplier_id, invoice_no)
);
CREATE TABLE IF NOT EXISTS purchase_line (
purchase_id TEXT NOT NULL, line_no INTEGER NOT NULL, item_id TEXT NOT NULL,
name TEXT NOT NULL, qty REAL NOT NULL, unit_cost_paise INTEGER NOT NULL,
tax_rate_bp INTEGER NOT NULL, tax_paise INTEGER NOT NULL, line_total_paise INTEGER NOT NULL,
new_sale_price_paise INTEGER, new_mrp_paise INTEGER, batch_id TEXT,
PRIMARY KEY (purchase_id, line_no)
);
CREATE INDEX IF NOT EXISTS idx_pline_item ON purchase_line (item_id);
CREATE INDEX IF NOT EXISTS idx_item_name ON item (tenant_id, name);
CREATE INDEX IF NOT EXISTS idx_bill_date ON bill (tenant_id, business_date);
CREATE INDEX IF NOT EXISTS idx_move_item ON stock_movement (tenant_id, store_id, item_id);
-- idx_move_batch lives in migrate() only: on an upgraded dev DB the batch_id column is
-- added by ALTER there, so indexing it in SCHEMA (which runs first) would fail. The
-- batch table is fully defined above, so its index is safe to keep here.
CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log (tenant_id, at_wall);
`
/**
* Additive migrations for dev DBs seeded before a column existed. Fresh DBs get
* the columns from CREATE TABLE above; this only fills the gap for the running
* slice. Each step is guarded by a PRAGMA table_info check so it is idempotent.
* The Oracle adapter (D12) manages its own DDL versioning.
*/
function migrate(db: DB): void {
const cols = db.prepare(`PRAGMA table_info(bill)`).all() as { name: string }[]
const has = (c: string): boolean => cols.some((x) => x.name === c)
// S3 B2B: buyer GSTIN + place-of-supply snapshot on the immutable bill.
if (!has('buyer_gstin')) db.exec(`ALTER TABLE bill ADD COLUMN buyer_gstin TEXT`)
if (!has('place_of_supply')) db.exec(`ALTER TABLE bill ADD COLUMN place_of_supply TEXT`)
// S4 offline queue: the client bill id is the idempotency key for the reconnect
// drain. It is NULL for normal (online) bills and non-null only for a bill that
// was queued offline; a partial unique index enforces one committed bill per
// (tenant, client id) so a retried drain can never duplicate a bill or its stock.
// (A partial index, not a table UNIQUE, because SQLite ALTER TABLE cannot add a
// constraint and NULLs must stay non-conflicting for the online path.)
if (!has('client_id')) db.exec(`ALTER TABLE bill ADD COLUMN client_id TEXT`)
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bill_client ON bill (tenant_id, client_id) WHERE client_id IS NOT NULL`)
// SEC fix — single-use supervisor-approval tokens. A whole new table, so the
// idempotent guard is the table itself (CREATE TABLE IF NOT EXISTS, which SCHEMA
// also runs on every open); re-stated here so the migration is explicit for dev
// DBs seeded before the table existed.
db.exec(`CREATE TABLE IF NOT EXISTS override_approval (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
approver_user_id TEXT NOT NULL, created_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0
)`)
// H4 fix — bind the approval token to the override it authorises. Additive columns
// for dev DBs seeded before the binding existed; fresh DBs get them from SCHEMA above.
const oaCols = db.prepare(`PRAGMA table_info(override_approval)`).all() as { name: string }[]
const oaHas = (c: string): boolean => oaCols.some((x) => x.name === c)
if (!oaHas('item_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN item_id TEXT`)
if (!oaHas('kind')) db.exec(`ALTER TABLE override_approval ADD COLUMN kind TEXT`)
if (!oaHas('before_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN before_paise INTEGER`)
if (!oaHas('after_paise')) db.exec(`ALTER TABLE override_approval ADD COLUMN after_paise INTEGER`)
if (!oaHas('cashier_user_id')) db.exec(`ALTER TABLE override_approval ADD COLUMN cashier_user_id TEXT`)
// S5 batch/expiry — the batch table (SCHEMA also creates it; re-stated so the
// migration is explicit for dev DBs seeded before it existed), plus the nullable
// batch_id columns on stock_movement and purchase_line. Per-batch on-hand stays
// derived from movements (R7); batch_id is NULL for un-batched (non-tracked) stock.
db.exec(`CREATE TABLE IF NOT EXISTS batch (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
item_id TEXT NOT NULL, batch_no TEXT NOT NULL, expiry_date TEXT,
created_at TEXT NOT NULL,
UNIQUE (tenant_id, store_id, item_id, batch_no)
)`)
const moveCols = db.prepare(`PRAGMA table_info(stock_movement)`).all() as { name: string }[]
if (!moveCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE stock_movement ADD COLUMN batch_id TEXT`)
const plineCols = db.prepare(`PRAGMA table_info(purchase_line)`).all() as { name: string }[]
if (!plineCols.some((x) => x.name === 'batch_id')) db.exec(`ALTER TABLE purchase_line ADD COLUMN batch_id TEXT`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_move_batch ON stock_movement (batch_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_batch_item ON batch (tenant_id, store_id, item_id)`)
// H2 — tamper-evident audit hash chain. Additive columns for dev DBs seeded before the
// chain existed; fresh DBs get them from SCHEMA above. Pre-existing rows stay NULL (they are
// not retro-hashed) — on a real upgrade the chain is consistent from the first row written
// after this migration; the dev DB is reset for this slice so every row is chained from genesis.
const auditCols = db.prepare(`PRAGMA table_info(audit_log)`).all() as { name: string }[]
const auditHas = (c: string): boolean => auditCols.some((x) => x.name === c)
if (!auditHas('prev_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN prev_hash TEXT`)
if (!auditHas('entry_hash')) db.exec(`ALTER TABLE audit_log ADD COLUMN entry_hash TEXT`)
}
/**
* H3 (red-team): full-DB encryption at rest via SQLCipher. When SIMS_DB_KEY is set we key the
* database BEFORE any read, so the file on disk — main db AND its WAL/SHM sidecars — is ciphertext.
* A copied or snooped sims.db then yields no customer phone numbers/GSTINs (DPDP), no item/purchase
* costs & margins, no sales history, no party ledgers and no PIN salts/hashes: every page is
* encrypted under a key held ONLY in the server's environment — a machine-scoped secret (Windows
* DPAPI / OS keychain), NEVER in the repo or the DB itself.
*
* When SIMS_DB_KEY is UNSET we open unencrypted exactly as before (dev fallback, so :5181 keeps
* working) and warn ONCE that production MUST set it. Mirrors the SIMS_PIN_PEPPER pattern.
*
* The key must be applied immediately after opening, before journal_mode / schema touch the file
* (SQLCipher derives the page key from it on first access). Single quotes in the secret are escaped
* by doubling so an arbitrary passphrase is a safe SQL string literal.
*
* One-time migration of an EXISTING plaintext sims.db to encrypted (not needed for the empty dev DB,
* which is simply recreated by deleting data/): open the plaintext file, then
* ATTACH DATABASE 'sims-enc.db' AS enc KEY '<SIMS_DB_KEY>';
* SELECT sqlcipher_export('enc'); DETACH DATABASE enc;
* and swap sims-enc.db in. Do this offline, once, before first encrypted boot.
*/
let dbKeyWarned = false
function applyKeyIfSet(db: DB): void {
const key = process.env['SIMS_DB_KEY']
if (key === undefined || key === '') {
if (!dbKeyWarned) {
dbKeyWarned = true
// eslint-disable-next-line no-console
console.warn(
'[store-server] SIMS_DB_KEY is not set — the store DB is written UNENCRYPTED on disk. A copied '
+ 'sims.db then yields customer PII (phones/GSTINs), costs & margins, the full sales history, party '
+ 'ledgers and PIN hashes. Production MUST set a machine-scoped SIMS_DB_KEY (SQLCipher at rest).',
)
}
return
}
db.pragma(`cipher='sqlcipher'`)
db.pragma(`key='${key.replace(/'/g, "''")}'`)
}
/**
* Open the store DB. Defaults to the on-disk WAL file; pass an explicit path
* (e.g. `:memory:`) for hermetic tests. Same schema + migrations either way.
*/
export function openDb(dbPath?: string): DB {
let file: string
if (dbPath !== undefined) {
file = dbPath
} else {
// SIMS_DATA_DIR lets a second instance run against an isolated data dir (e.g. the
// S7 load target) without touching the dev DB; unset ⇒ the standard ../data path.
const envDir = process.env['SIMS_DATA_DIR']
const dir = envDir !== undefined && envDir !== '' ? path.resolve(envDir) : path.resolve(__dirname, '../data')
fs.mkdirSync(dir, { recursive: true })
file = path.join(dir, 'sims.db')
}
const db = new Database(file)
applyKeyIfSet(db) // H3 — key BEFORE any read (journal_mode/schema touch the file)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.exec(SCHEMA)
migrate(db)
return db
}