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.
142 lines
7.5 KiB
TypeScript
142 lines
7.5 KiB
TypeScript
// apps/hq/test/documents.test.ts
|
|
import { describe, it, expect } from 'vitest'
|
|
import { openDb } from '../src/db'
|
|
import { createClient } from '../src/repos-clients'
|
|
import { createModule, setPrice } from '../src/repos-modules'
|
|
import {
|
|
createDraft, issueDocument, convertDocument, createCreditNote, cancelDocument, getDocument,
|
|
} from '../src/repos-documents'
|
|
|
|
async function setup() {
|
|
const db = openDb(':memory:')
|
|
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
|
|
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
|
|
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
|
|
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
|
|
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
|
|
return { db, c, m }
|
|
}
|
|
|
|
describe('documents', () => {
|
|
it('quotation: ₹10,000 + 18% intra-state = CGST 900 + SGST 900, payable ₹11,800', async () => {
|
|
const { db, c, m } = await setup()
|
|
const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
|
expect(d.taxablePaise).toBe(10_000_00)
|
|
expect(d.cgstPaise).toBe(900_00)
|
|
expect(d.sgstPaise).toBe(900_00)
|
|
expect(d.igstPaise).toBe(0)
|
|
expect(d.payablePaise).toBe(11_800_00)
|
|
expect(d.status).toBe('draft')
|
|
expect(d.docNo).toBeNull()
|
|
})
|
|
it('inter-state client gets IGST', async () => {
|
|
const { db, m } = await setup()
|
|
const db2 = db
|
|
const kar = await createClient(db2, 'u1', { name: 'BLR Co', stateCode: '29' })
|
|
const d = await createDraft(db2, 'u1', { docType: 'INVOICE', clientId: kar.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
|
expect(d.igstPaise).toBe(1_800_00)
|
|
expect(d.cgstPaise).toBe(0)
|
|
})
|
|
it('issue assigns a series number; convert QT→INV carries lines and links back', async () => {
|
|
const { db, c, m } = await setup()
|
|
const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
|
const issued = await issueDocument(db, 'u1', qt.id)
|
|
expect(issued.docNo).toMatch(/^QT\/\d{2}-\d{2}-\d{4}$/)
|
|
const inv = await convertDocument(db, 'u1', qt.id, 'INVOICE')
|
|
expect(inv.refDocId).toBe(qt.id)
|
|
expect(inv.payablePaise).toBe(qt.payablePaise)
|
|
})
|
|
it('line contents default to the module quote content, and can be overridden per line', async () => {
|
|
const { db, c } = await setup()
|
|
const sms = await createModule(db, 'u1', {
|
|
code: 'SMS', name: 'SMS Gateway', quoteContent: ['Bulk SMS gateway', 'DLT registration'],
|
|
})
|
|
await setPrice(db, 'u1', { moduleId: sms.id, kind: 'yearly', pricePaise: 5_000_00, effectiveFrom: '2026-04-01' })
|
|
const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
|
lines: [{ moduleId: sms.id, qty: 1, kind: 'yearly' }] })
|
|
expect(d.payload.lineContents).toEqual([['Bulk SMS gateway', 'DLT registration']])
|
|
const d2 = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
|
lines: [{ moduleId: sms.id, qty: 1, kind: 'yearly', contentLines: ['Custom scope only'] }] })
|
|
expect(d2.payload.lineContents).toEqual([['Custom scope only']])
|
|
})
|
|
|
|
it('a module without quote content contributes an empty content array', async () => {
|
|
const { db, c, m } = await setup()
|
|
const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
|
expect(d.payload.lineContents).toEqual([[]])
|
|
})
|
|
|
|
it('a pack (non-standard edition) prices from that edition and prints the pack name on the line', async () => {
|
|
const { db, c, m } = await setup()
|
|
await setPrice(db, 'u1', { moduleId: m.id, edition: 'SMS-50K', kind: 'yearly', pricePaise: 5_000_00, effectiveFrom: '2026-04-01' })
|
|
const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly', edition: 'SMS-50K' }] })
|
|
expect(d.taxablePaise).toBe(5_000_00)
|
|
expect(d.payload.lines[0]!.name).toContain('— SMS-50K')
|
|
const std = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
|
expect(std.payload.lines[0]!.name).not.toContain('—')
|
|
})
|
|
|
|
it('convert is one-shot: a live forward child blocks re-convert; cancelling it re-opens (rule 4 / F4)', async () => {
|
|
const { db, c, m } = await setup()
|
|
const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
|
const inv = await convertDocument(db, 'u1', qt.id, 'INVOICE')
|
|
// one sale, one invoice — a double-click cannot mint a second legal document
|
|
await expect(convertDocument(db, 'u1', qt.id, 'INVOICE')).rejects.toThrow(/already converted/i)
|
|
await expect(convertDocument(db, 'u1', qt.id, 'PROFORMA')).rejects.toThrow(/already converted/i)
|
|
// a cancelled child is dead paper: the path re-opens
|
|
await issueDocument(db, 'u1', inv.id)
|
|
await cancelDocument(db, 'u1', inv.id, 'test cancel')
|
|
const inv2 = await convertDocument(db, 'u1', qt.id, 'INVOICE')
|
|
expect(inv2.refDocId).toBe(qt.id)
|
|
})
|
|
|
|
it('QT→PROFORMA moves the quotation out of the sent set (status → invoiced)', async () => {
|
|
const { db, c, m } = await setup()
|
|
const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
|
const pi = await convertDocument(db, 'u1', qt.id, 'PROFORMA')
|
|
expect(pi.docType).toBe('PROFORMA')
|
|
expect(pi.status).toBe('draft')
|
|
expect((await getDocument(db, qt.id))!.status).toBe('invoiced') // never re-enters scan/pipeline 'sent' sets
|
|
})
|
|
|
|
it('→INVOICE recomputes GST on the invoice date (rule 2 / F3); QT→PI copies verbatim', async () => {
|
|
const { db, c, m } = await setup()
|
|
const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) // 18% → ₹11,800
|
|
const pi = await convertDocument(db, 'u1', qt.id, 'PROFORMA')
|
|
expect(pi.payablePaise).toBe(11_800_00) // copy-only: both are non-legal paper
|
|
// The rate changes (new dated tax_class row) before the invoice is drawn.
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
await db.run(
|
|
`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 2800, ?)`,
|
|
today,
|
|
)
|
|
const inv = await convertDocument(db, 'u1', pi.id, 'INVOICE')
|
|
expect(inv.taxablePaise).toBe(10_000_00)
|
|
expect(inv.cgstPaise).toBe(1_400_00)
|
|
expect(inv.sgstPaise).toBe(1_400_00)
|
|
expect(inv.payablePaise).toBe(12_800_00) // the rate that is law on the invoice's own date
|
|
expect((await getDocument(db, pi.id))!.payablePaise).toBe(11_800_00) // source untouched
|
|
})
|
|
|
|
it('credit note defaults to full value against the invoice; cancel keeps the number', async () => {
|
|
const { db, c, m } = await setup()
|
|
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
|
|
const cn = await createCreditNote(db, 'u1', inv.id)
|
|
expect(cn.docType).toBe('CREDIT_NOTE')
|
|
expect(cn.payablePaise).toBe(inv.payablePaise)
|
|
const cancelled = await cancelDocument(db, 'u1', inv.id, 'billing error')
|
|
expect(cancelled.status).toBe('cancelled')
|
|
expect(cancelled.docNo).toBe(inv.docNo) // number consumed, never reused
|
|
})
|
|
})
|