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/test/anchoring.test.ts

87 lines
4.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { commitBill } from '../src/repos'
/**
* Item-master anchoring (red-team C2, C3, H5, H7 + MRP/unit guards). commitBill is the
* trust boundary: it rebuilds every line from OUR item row and treats any price the client
* couldn't have gotten from the master as a deviation that needs a bound token or the
* committer's own authority. Hermetic in-memory DB; seed items: 107 Amul Butter (master
* sale 27500, MRP 28000, GST12, whole unit), 42 Tomato loose (KG, 3 decimals).
*/
function fresh(): DB {
const db = openDb(':memory:')
seedIfEmpty(db)
return db
}
const itemId = (db: DB, code: string): string =>
(db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id
const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
const line = (id: string, over: Partial<{ unitPricePaise: number; taxClassCode: string; qty: number }> = {}) => ({
itemId: id, name: 'Amul Butter 500g', hsn: '0405', qty: over.qty ?? 1, unitCode: 'PCS',
unitPricePaise: over.unitPricePaise ?? 27_500, priceIncludesTax: true, taxClassCode: over.taxClassCode ?? 'GST12',
})
const bill = (cashierId: string, l: ReturnType<typeof line>, payable: number): Parameters<typeof commitBill>[1] => ({
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId, shiftId: 'sh1', businessDate: '2026-07-10',
lines: [l], payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable,
})
describe('item-master anchoring', () => {
it('commits a normal bill at the master price (happy path)', () => {
const db = fresh()
const out = commitBill(db, bill('u1', line(itemId(db, '107')), 27_500))
expect(out.docNo).toMatch(/^ST1C22026-/) // 4-digit-FY prefix (M9); business date 2026-07-10 → FY 2026-27
expect(billCount(db)).toBe(1)
})
it('C2: a cashier underpricing a ₹275 item to ₹1 without approval is rejected (4xx)', () => {
const db = fresh()
const err = (() => { try { commitBill(db, bill('u1', line(itemId(db, '107'), { unitPricePaise: 100 }), 100)); return undefined } catch (e) { return e as Error & { status?: number } } })()
expect(err).toBeDefined()
expect(err!.status).toBeGreaterThanOrEqual(400)
expect(err!.status).toBeLessThan(500)
expect(billCount(db)).toBe(0)
})
it('C3: a forged ZERO tax class is ignored — the bill is taxed at the master GST12 rate', () => {
const db = fresh()
// Inclusive price, so the ₹275 total is identical under ZERO or GST12; only the split
// differs. The server anchors GST12, so the committed bill still carries CGST+SGST.
commitBill(db, bill('u1', line(itemId(db, '107'), { taxClassCode: 'ZERO' }), 27_500))
const b = db.prepare(`SELECT cgst_paise, sgst_paise, taxable_paise FROM bill LIMIT 1`).get() as { cgst_paise: number; sgst_paise: number; taxable_paise: number }
expect(b.cgst_paise + b.sgst_paise).toBeGreaterThan(0)
expect(b.taxable_paise).toBeLessThan(27_500) // some of the sticker price is GST, not taxable base
})
it('H5: a line for a non-existent item is rejected, nothing committed', () => {
const db = fresh()
expect(() => commitBill(db, bill('u1', line('DOES-NOT-EXIST'), 27_500))).toThrow(/E-1105|catalogue/i)
expect(billCount(db)).toBe(0)
})
it('H7: an owner self-authorises a price change and it is audited from their identity', () => {
const db = fresh()
commitBill(db, bill('u3', line(itemId(db, '107'), { unitPricePaise: 25_000 }), 25_000)) // u3 = owner
const audit = db.prepare(`SELECT after_json FROM audit_log WHERE action='PRICE_OVERRIDE'`).get() as { after_json: string } | undefined
expect(audit).toBeDefined()
const after = JSON.parse(audit!.after_json) as { approvedByUserId: string; selfAuthorized?: boolean }
expect(after.approvedByUserId).toBe('u3')
expect(after.selfAuthorized).toBe(true)
expect(billCount(db)).toBe(1)
})
it('hard-blocks a price above MRP even for an owner (E-1301)', () => {
const db = fresh()
expect(() => commitBill(db, bill('u3', line(itemId(db, '107'), { unitPricePaise: 30_000 }), 30_000))).toThrow(/E-1301|MRP/i)
expect(billCount(db)).toBe(0)
})
it('rejects a fractional quantity on a whole-unit item (E-1104)', () => {
const db = fresh()
expect(() => commitBill(db, bill('u1', line(itemId(db, '107'), { qty: 1.5 }), 41_300))).toThrow(/E-1104|whole/i)
expect(billCount(db)).toBe(0)
})
})