import { afterEach, describe, expect, it } from 'vitest' import express from 'express' import type { AddressInfo } from 'node:net' import type { Server } from 'node:http' import { openDb, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { commitBill, createCustomer } from '../src/repos' import { commitReturn } from '../src/repos-returns' import { gstr1, gstr3b, hsnSummary, gstr1PortalJson } from '../src/repos-gst' import { apiRouter } from '../src/api' /** * GST returns — READ-ONLY compliance reporting over the period's immutable documents. These lock: * the B2B/B2CS split by buyer GSTIN, B2CS aggregation by rate, a credit note reducing its rate * bucket, HSN totals, the GSTR-3B 3.1(a) outward figures hand-checked against the bills, the * reconciliation identity (sections == source == HSN == 3B), the REPORT_VIEW gate, and valid JSON. * * Hermetic in-memory seed: item 103 Surf Excel (GST18, ₹145 incl), 107 Amul Butter (GST12, ₹275), * 104 Parle-G (GST5, ₹90). Store state 32. u1 Ramesh/cashier (4728), u3 Thomas/owner (9174). */ const PERIOD = '2026-07' const DATE = '2026-07-10' const CTIN = '27ABCDE1234F1Z0' // registered buyer in state 27 → inter-state B2B (IGST) function itemId(db: DB, code: string): string { return (db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id } interface Built { custA: string; bill1: string; bill2: string; bill3: string } /** Build the known document set for a single month and return the ids. */ function buildMonth(db: DB): Built { const custA = createCustomer(db, 't1', 'Anand Traders', '9876543210', { gstin: CTIN }).id const line = (code: string, qty: number, price: number, cls: string, hsn: string, name: string) => ({ itemId: itemId(db, code), name, hsn, qty, unitCode: 'PCS', unitPricePaise: price, priceIncludesTax: true, taxClassCode: cls, }) const commit = (lines: ReturnType[], payable: number, customerId?: string) => commitBill(db, { tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: DATE, lines, payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable, ...(customerId !== undefined ? { customerId } : {}), }) // Bill 1 — B2B: 2 × Surf Excel to the GSTIN buyer (inter-state → IGST). const bill1 = commit([line('103', 2, 14_500, 'GST18', '3402', 'Surf Excel 1kg')], 29_000, custA).id // Bill 2 — B2C cash: 1 × Surf Excel + 1 × Amul Butter (intra → CGST/SGST). const bill2 = commit([line('103', 1, 14_500, 'GST18', '3402', 'Surf Excel 1kg'), line('107', 1, 27_500, 'GST12', '0405', 'Amul Butter 500g')], 42_000).id // Bill 3 — B2C cash: 3 × Parle-G. const bill3 = commit([line('104', 3, 9_000, 'GST5', '1905', 'Parle-G 800g')], 27_000).id // Return — the full Amul line from bill 2 (index 1) → nets into B2CS at GST12. commitReturn(db, { tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: DATE, originalBillId: bill2, lines: [{ lineIndex: 1, qty: 1 }], reason: 'Damaged', refundMode: 'CASH', }) return { custA, bill1, bill2, bill3 } } function fresh(): DB { const db = openDb(':memory:') seedIfEmpty(db) return db } describe('gstr1 — sections over the known month', () => { it('B2B shows the GSTIN invoice; B2CS aggregates the cash bills by rate; the return nets out', () => { const db = fresh() buildMonth(db) const r = gstr1(db, 't1', PERIOD) // B2B: one party, one invoice, GST18, taxable 24576 + IGST 4424 (₹290 incl of 2×₹145). expect(r.b2b).toHaveLength(1) expect(r.b2b[0]!.ctin).toBe(CTIN) expect(r.b2b[0]!.invoiceCount).toBe(1) expect(r.b2b[0]!.totals.taxablePaise).toBe(24_576) expect(r.b2b[0]!.totals.igstPaise).toBe(4_424) // B2CS: Surf (GST18) and Parle (GST5) survive; the Amul (GST12) bucket nets to zero and is dropped. const b18 = r.b2cs.find((b) => b.rateBp === 1800)! expect(b18.taxablePaise).toBe(12_288) expect(b18.cgstPaise).toBe(1_106) expect(r.b2cs.find((b) => b.rateBp === 500)!.taxablePaise).toBe(25_714) expect(r.b2cs.some((b) => b.rateBp === 1200)).toBe(false) // No B2CL / CDNR / CDNUR in this set (nothing large, no registered return). expect(r.b2cl).toHaveLength(0) expect(r.cdnr).toHaveLength(0) expect(r.cdnur).toHaveLength(0) // HSN: Surf 3402 aggregates B2B + B2C (qty 3); Amul 0405 nets to zero and drops. const surf = r.hsn.find((h) => h.hsn === '3402')! expect(surf.taxablePaise).toBe(36_864) expect(surf.qty).toBe(3) expect(r.hsn.some((h) => h.hsn === '0405')).toBe(false) expect(r.totals.hsn.taxablePaise).toBe(62_578) // Documents issued: 3 invoices + 1 credit note. expect(r.docs.find((d) => d.natureOfDocument === 'Invoices for outward supply')!.totalNumber).toBe(3) expect(r.docs.find((d) => d.natureOfDocument === 'Credit notes')!.totalNumber).toBe(1) }) }) describe('gstr3b — 3.1(a) hand-checked against the bills', () => { it('outward = Σ bill tax − credit-note tax, at captured rates', () => { const db = fresh() buildMonth(db) const r = gstr3b(db, 't1', PERIOD) // Sales tax: IGST 4424 + CGST 3222 + SGST 3222 = 10868; credit note: CGST 1473 + SGST 1473 = 2946. expect(r.salesTax.igstPaise + r.salesTax.cgstPaise + r.salesTax.sgstPaise).toBe(10_868) expect(r.returnsTax.cgstPaise + r.returnsTax.sgstPaise).toBe(2_946) expect(r.outward.taxablePaise).toBe(62_578) expect(r.outward.igstPaise).toBe(4_424) expect(r.outward.cgstPaise).toBe(1_749) expect(r.outward.sgstPaise).toBe(1_749) expect(r.outward.igstPaise + r.outward.cgstPaise + r.outward.sgstPaise).toBe(7_922) // 10868 − 2946 expect(r.itc.igstPaise).toBe(0) // ITC stub }) }) describe('reconciliation — the compliance gate ties out', () => { it('GSTR-1 sections == source == HSN == GSTR-3B (taxable and tax)', () => { const db = fresh() buildMonth(db) const r = gstr1(db, 't1', PERIOD) expect(r.reconciliation.ok).toBe(true) expect(r.reconciliation.mismatches).toEqual([]) expect(r.reconciliation.sourceTaxablePaise).toBe(62_578) expect(r.reconciliation.gstr1NetTaxablePaise).toBe(62_578) expect(r.reconciliation.hsnTaxablePaise).toBe(62_578) expect(r.reconciliation.gstr3bTaxablePaise).toBe(62_578) expect(r.reconciliation.sourceTaxPaise).toBe(7_922) expect(r.reconciliation.gstr3bTaxPaise).toBe(7_922) }) it('an empty period reconciles to zero, not an error', () => { const db = fresh() const r = gstr1(db, 't1', '2026-01') expect(r.reconciliation.ok).toBe(true) expect(r.reconciliation.sourceTaxablePaise).toBe(0) expect(r.b2b).toHaveLength(0) expect(r.docs).toHaveLength(0) }) }) describe('gstr1PortalJson — pragmatic GSTN-shaped export', () => { it('produces valid, serialisable JSON with the seller GSTIN and section keys', () => { const db = fresh() buildMonth(db) const j = gstr1PortalJson(db, 't1', PERIOD) // Round-trips through JSON.stringify/parse (no NaN, no cycles) and carries the shape. const round = JSON.parse(JSON.stringify(j)) as Record expect(round['gstin']).toBe('32ABCDE1234F1Z5') expect(round['fp']).toBe('072026') expect(Array.isArray(round['b2b'])).toBe(true) expect((round['b2b'] as unknown[]).length).toBe(1) expect(round['_note']).toMatch(/NOT a certified/i) const hsn = round['hsn'] as { data: unknown[] } expect(Array.isArray(hsn.data)).toBe(true) }) }) // ---- HTTP surface: the REPORT_VIEW gate + period validation ---- interface Harness { db: DB; url: string; server: Server } async function start(): Promise { const db = openDb(':memory:') seedIfEmpty(db) buildMonth(db) const app = express() app.use(express.json({ limit: '1mb' })) app.use('/api', apiRouter(db)) app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { const e = (err ?? {}) as { status?: number; code?: string; message?: string } const status = typeof e.status === 'number' ? e.status : 500 if (res.headersSent) return res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' }) }) const server: Server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)) }) const port = (server.address() as AddressInfo).port return { db, url: `http://127.0.0.1:${port}`, server } } async function login(url: string, userId: string, pin: string): Promise { const res = await fetch(`${url}/api/auth/pin`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userId, pin }), }) const body = (await res.json()) as { token?: string } if (body.token === undefined) throw new Error(`login failed for ${userId}`) return body.token } const get = (url: string, path: string, token?: string) => fetch(`${url}${path}`, { headers: { ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}) } }) describe('GST HTTP routes — REPORT_VIEW gate + validation', () => { let h: Harness afterEach(() => { h.server.close() }) it('owner (REPORT_VIEW) reads the returns; a cashier is 403; unauth is 401; a bad period is 400', async () => { h = await start() // unauthenticated expect((await get(h.url, `/api/gst/gstr1?period=${PERIOD}`)).status).toBe(401) // a cashier lacks REPORT_VIEW → 403 E-6105 const cashier = await login(h.url, 'u1', '4728') const denied = await get(h.url, `/api/gst/gstr1?period=${PERIOD}`, cashier) expect(denied.status).toBe(403) expect(((await denied.json()) as { code?: string }).code).toBe('E-6105') // the owner reads it → 200 with the reconciled numbers const owner = await login(h.url, 'u3', '9174') const ok = await get(h.url, `/api/gst/gstr1?period=${PERIOD}`, owner) expect(ok.status).toBe(200) const body = (await ok.json()) as { reconciliation: { ok: boolean }; totals: { net: { taxablePaise: number } } } expect(body.reconciliation.ok).toBe(true) expect(body.totals.net.taxablePaise).toBe(62_578) // 3B + hsn + json all 200 for the owner expect((await get(h.url, `/api/gst/gstr3b?period=${PERIOD}`, owner)).status).toBe(200) expect((await get(h.url, `/api/gst/hsn?period=${PERIOD}`, owner)).status).toBe(200) const j = await get(h.url, `/api/gst/gstr1.json?period=${PERIOD}`, owner) expect(j.status).toBe(200) expect(((await j.json()) as { fp?: string }).fp).toBe('072026') // a malformed period → 400 E-1330 const bad = await get(h.url, `/api/gst/gstr1?period=2026-13`, owner) expect(bad.status).toBe(400) expect(((await bad.json()) as { code?: string }).code).toBe('E-1330') }) })