|
|
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<typeof line>[], 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<string, unknown>
|
|
|
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<Harness> {
|
|
|
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<string> {
|
|
|
const res = await fetch(`${url}/api/auth/pin`, {
|
|
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userId, pin }),
|
|
|
})
|
|
|
const body = (await res.json()) as { token?: string }
|
|
|
if (body.token === undefined) throw new Error(`login failed for ${userId}`)
|
|
|
return body.token
|
|
|
}
|
|
|
const 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')
|
|
|
})
|
|
|
})
|