feat(hq): document lifecycle with GST compute and credit notes (HQ-1 task 8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
2710455b78
commit
21ccaff363
@ -0,0 +1,316 @@
|
|||||||
|
import { fyOf, uuidv7, type BillLine, type BillTotals } from '@sims/domain'
|
||||||
|
import { computeBill, type LineInput, type TaxClassRow } from '@sims/billing-engine'
|
||||||
|
import { writeAudit } from './audit'
|
||||||
|
import type { DB } from './db'
|
||||||
|
import { getClient } from './repos-clients'
|
||||||
|
import { getModule, priceOn, type Kind } from './repos-modules'
|
||||||
|
import { nextDocNo } from './series'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HQ document lifecycle (QT/PI/INV/CN) — plain functions over the handle
|
||||||
|
* (D12 portable-repo pattern). Issued documents are never edited or deleted:
|
||||||
|
* corrections are credit notes; cancel keeps the number consumed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DocType = 'QUOTATION' | 'PROFORMA' | 'INVOICE' | 'RECEIPT' | 'CREDIT_NOTE'
|
||||||
|
export type DocStatus =
|
||||||
|
| 'draft' | 'sent' | 'accepted' | 'invoiced' | 'part_paid' | 'paid' | 'lost' | 'cancelled'
|
||||||
|
|
||||||
|
/** HQ services are B2B, GST-exclusive, one flat class (seeded at install). */
|
||||||
|
const TAX_CLASS = 'GST18'
|
||||||
|
|
||||||
|
export interface DocPayload { lines: BillLine[]; totals: BillTotals; terms?: string }
|
||||||
|
|
||||||
|
export interface Doc {
|
||||||
|
id: string; docType: DocType; docNo: string | null; fy: string; clientId: string
|
||||||
|
docDate: string; status: DocStatus; refDocId: string | null
|
||||||
|
taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number
|
||||||
|
roundOffPaise: number; payablePaise: number
|
||||||
|
payload: DocPayload; source: string; createdBy: string; createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocRow {
|
||||||
|
id: string; doc_type: string; doc_no: string | null; fy: string; client_id: string
|
||||||
|
doc_date: string; status: string; ref_doc_id: string | null
|
||||||
|
taxable_paise: number; cgst_paise: number; sgst_paise: number; igst_paise: number
|
||||||
|
round_off_paise: number; payable_paise: number
|
||||||
|
payload: string; source: string; created_by: string; created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDoc(r: DocRow): Doc {
|
||||||
|
return {
|
||||||
|
id: r.id, docType: r.doc_type as DocType, docNo: r.doc_no, fy: r.fy,
|
||||||
|
clientId: r.client_id, docDate: r.doc_date, status: r.status as DocStatus,
|
||||||
|
refDocId: r.ref_doc_id,
|
||||||
|
taxablePaise: r.taxable_paise, cgstPaise: r.cgst_paise, sgstPaise: r.sgst_paise,
|
||||||
|
igstPaise: r.igst_paise, roundOffPaise: r.round_off_paise, payablePaise: r.payable_paise,
|
||||||
|
payload: JSON.parse(r.payload) as DocPayload,
|
||||||
|
source: r.source, createdBy: r.created_by, createdAt: r.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDocument(db: DB, id: string): Doc | null {
|
||||||
|
const row = db.prepare(`SELECT * FROM document WHERE id=?`).get(id) as DocRow | undefined
|
||||||
|
return row === undefined ? null : toDoc(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentFilter { type?: string; status?: string; clientId?: string }
|
||||||
|
|
||||||
|
export function listDocuments(db: DB, filter: DocumentFilter = {}): Doc[] {
|
||||||
|
let sql = `SELECT * FROM document WHERE 1=1`
|
||||||
|
const args: unknown[] = []
|
||||||
|
if (filter.type !== undefined) { sql += ` AND doc_type=?`; args.push(filter.type) }
|
||||||
|
if (filter.status !== undefined) { sql += ` AND status=?`; args.push(filter.status) }
|
||||||
|
if (filter.clientId !== undefined) { sql += ` AND client_id=?`; args.push(filter.clientId) }
|
||||||
|
sql += ` ORDER BY id DESC` // uuidv7 ids sort by creation time
|
||||||
|
return (db.prepare(sql).all(...args) as DocRow[]).map(toDoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- document events ----------
|
||||||
|
|
||||||
|
export interface DocumentEvent {
|
||||||
|
id: string; documentId: string; atWall: string; kind: string; meta: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventRow { id: string; document_id: string; at_wall: string; kind: string; meta: string }
|
||||||
|
|
||||||
|
function addEvent(db: DB, documentId: string, kind: string, meta: Record<string, unknown> = {}): void {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO document_event (id, document_id, at_wall, kind, meta) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
).run(uuidv7(), documentId, new Date().toISOString(), kind, JSON.stringify(meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDocumentEvents(db: DB, documentId: string): DocumentEvent[] {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT * FROM document_event WHERE document_id=? ORDER BY id`,
|
||||||
|
).all(documentId) as EventRow[]
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id, documentId: r.document_id, atWall: r.at_wall, kind: r.kind,
|
||||||
|
meta: JSON.parse(r.meta) as Record<string, unknown>,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- draft creation ----------
|
||||||
|
|
||||||
|
export interface DraftLineInput {
|
||||||
|
moduleId: string; description?: string; qty: number
|
||||||
|
unitPricePaise?: number /* default: priceOn today */; kind: Kind; edition?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftInput {
|
||||||
|
docType: 'QUOTATION' | 'PROFORMA' | 'INVOICE'
|
||||||
|
clientId: string
|
||||||
|
lines: DraftLineInput[]
|
||||||
|
terms?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayIso = (): string => new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
function supplyStateCode(db: DB): string {
|
||||||
|
const row = db.prepare(`SELECT value FROM setting WHERE key='company.state_code'`)
|
||||||
|
.get() as { value: string } | undefined
|
||||||
|
// Silent defaults on place-of-supply are how wrong GST reaches the portal — fail loudly.
|
||||||
|
if (row === undefined) throw new Error(`Setting 'company.state_code' is not configured`)
|
||||||
|
return row.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function taxRates(db: DB): TaxClassRow[] {
|
||||||
|
const rows = db.prepare(`SELECT * FROM tax_class`).all() as {
|
||||||
|
class_code: string; rate_pct_bp: number; cess_pct_bp: number
|
||||||
|
effective_from: string; effective_to: string | null
|
||||||
|
}[]
|
||||||
|
return rows.map((r) => ({
|
||||||
|
classCode: r.class_code, ratePctBp: r.rate_pct_bp, cessPctBp: r.cess_pct_bp,
|
||||||
|
effectiveFrom: r.effective_from,
|
||||||
|
...(r.effective_to !== null ? { effectiveTo: r.effective_to } : {}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLines(db: DB, inputs: DraftLineInput[], onDate: string): LineInput[] {
|
||||||
|
return inputs.map((line) => {
|
||||||
|
const mod = getModule(db, line.moduleId)
|
||||||
|
if (mod === null) throw new Error(`Module not found: ${line.moduleId}`)
|
||||||
|
const edition = line.edition ?? 'standard'
|
||||||
|
const unitPricePaise =
|
||||||
|
line.unitPricePaise ?? priceOn(db, line.moduleId, line.kind, edition, onDate)
|
||||||
|
if (unitPricePaise === null) {
|
||||||
|
throw new Error(`No price for module ${mod.code} (${line.kind}/${edition}) on ${onDate}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
itemId: mod.id,
|
||||||
|
name: mod.name + (line.description !== undefined && line.description !== '' ? ` — ${line.description}` : ''),
|
||||||
|
hsn: mod.sac, // SAC code for our service lines; PDFs label the column "SAC"
|
||||||
|
qty: line.qty,
|
||||||
|
unitCode: 'NOS',
|
||||||
|
unitPricePaise,
|
||||||
|
priceIncludesTax: false, // HQ B2B convention: prices are GST-exclusive
|
||||||
|
taxClassCode: TAX_CLASS,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a draft row + created event + audit. Callers wrap in a transaction. */
|
||||||
|
function insertDocRow(db: DB, userId: string, a: {
|
||||||
|
docType: DocType; clientId: string; refDocId: string | null; docDate: string
|
||||||
|
totals: BillTotals; payload: DocPayload
|
||||||
|
}): Doc {
|
||||||
|
const id = uuidv7()
|
||||||
|
const t = a.totals
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, status, ref_doc_id,
|
||||||
|
taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise,
|
||||||
|
payload, created_by, created_at)
|
||||||
|
VALUES (?, ?, NULL, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.refDocId,
|
||||||
|
t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise,
|
||||||
|
JSON.stringify(a.payload), userId, new Date().toISOString(),
|
||||||
|
)
|
||||||
|
addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {})
|
||||||
|
const doc = getDocument(db, id)!
|
||||||
|
writeAudit(db, userId, 'create', 'document', id, undefined, {
|
||||||
|
docType: doc.docType, clientId: doc.clientId,
|
||||||
|
payablePaise: doc.payablePaise, refDocId: doc.refDocId,
|
||||||
|
})
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDraft(db: DB, userId: string, input: DraftInput): Doc {
|
||||||
|
if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(input.docType)) {
|
||||||
|
throw new Error(`Cannot draft doc type: ${input.docType}`)
|
||||||
|
}
|
||||||
|
const client = getClient(db, input.clientId)
|
||||||
|
if (client === null) throw new Error('Client not found')
|
||||||
|
const today = todayIso()
|
||||||
|
const computed = computeBill(buildLines(db, input.lines, today), {
|
||||||
|
businessDate: today,
|
||||||
|
supplyStateCode: supplyStateCode(db),
|
||||||
|
placeOfSupplyStateCode: client.stateCode, // client state ≠ ours ⇒ IGST
|
||||||
|
roundToRupee: true,
|
||||||
|
}, taxRates(db))
|
||||||
|
const payload: DocPayload = {
|
||||||
|
lines: computed.lines, totals: computed.totals,
|
||||||
|
...(input.terms !== undefined ? { terms: input.terms } : {}),
|
||||||
|
}
|
||||||
|
return db.transaction(() => insertDocRow(db, userId, {
|
||||||
|
docType: input.docType, clientId: input.clientId, refDocId: null,
|
||||||
|
docDate: today, totals: computed.totals, payload,
|
||||||
|
}))()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- lifecycle ----------
|
||||||
|
|
||||||
|
/** Issue = number assignment only; status stays draft until sent/marked. */
|
||||||
|
export function issueDocument(db: DB, userId: string, docId: string): Doc {
|
||||||
|
return db.transaction(() => {
|
||||||
|
const before = getDocument(db, docId)
|
||||||
|
if (before === null) throw new Error('Document not found')
|
||||||
|
if (before.docNo !== null) throw new Error(`Document already issued as ${before.docNo}`)
|
||||||
|
if (before.status === 'cancelled') throw new Error('Cannot issue a cancelled document')
|
||||||
|
const docNo = nextDocNo(db, before.docType, before.fy)
|
||||||
|
db.prepare(`UPDATE document SET doc_no=? WHERE id=?`).run(docNo, docId)
|
||||||
|
addEvent(db, docId, 'issued', { docNo })
|
||||||
|
writeAudit(db, userId, 'issue', 'document', docId, { docNo: null }, { docNo })
|
||||||
|
return getDocument(db, docId)!
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGAL_MARKS: Partial<Record<DocStatus, DocStatus[]>> = {
|
||||||
|
draft: ['sent'],
|
||||||
|
sent: ['accepted', 'lost'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markStatus(db: DB, userId: string, docId: string, status: 'sent' | 'accepted' | 'lost'): Doc {
|
||||||
|
return db.transaction(() => {
|
||||||
|
const before = getDocument(db, docId)
|
||||||
|
if (before === null) throw new Error('Document not found')
|
||||||
|
if (!(LEGAL_MARKS[before.status] ?? []).includes(status)) {
|
||||||
|
throw new Error(`Illegal status transition: ${before.status} → ${status}`)
|
||||||
|
}
|
||||||
|
db.prepare(`UPDATE document SET status=? WHERE id=?`).run(status, docId)
|
||||||
|
addEvent(db, docId, status)
|
||||||
|
writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status })
|
||||||
|
return getDocument(db, docId)!
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAIN: Record<string, number> = { QUOTATION: 0, PROFORMA: 1, INVOICE: 2 }
|
||||||
|
|
||||||
|
/** New draft carrying the source's lines/totals verbatim, linked via ref_doc_id. */
|
||||||
|
export function convertDocument(db: DB, userId: string, docId: string, to: 'PROFORMA' | 'INVOICE'): Doc {
|
||||||
|
return db.transaction(() => {
|
||||||
|
const src = getDocument(db, docId)
|
||||||
|
if (src === null) throw new Error('Document not found')
|
||||||
|
const from = CHAIN[src.docType]
|
||||||
|
const target = CHAIN[to]
|
||||||
|
if (from === undefined || target === undefined || target <= from) {
|
||||||
|
throw new Error(`Cannot convert ${src.docType} to ${to}`)
|
||||||
|
}
|
||||||
|
if (src.status === 'cancelled') throw new Error('Cannot convert a cancelled document')
|
||||||
|
const doc = insertDocRow(db, userId, {
|
||||||
|
docType: to, clientId: src.clientId, refDocId: src.id,
|
||||||
|
docDate: todayIso(), totals: src.payload.totals, payload: src.payload,
|
||||||
|
})
|
||||||
|
addEvent(db, src.id, 'converted', { to, newDocId: doc.id })
|
||||||
|
if (to === 'INVOICE') {
|
||||||
|
db.prepare(`UPDATE document SET status='invoiced' WHERE id=?`).run(src.id)
|
||||||
|
writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' })
|
||||||
|
}
|
||||||
|
return doc
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only issued, unpaid documents; the consumed number is never reused. */
|
||||||
|
export function cancelDocument(db: DB, userId: string, docId: string): Doc {
|
||||||
|
return db.transaction(() => {
|
||||||
|
const before = getDocument(db, docId)
|
||||||
|
if (before === null) throw new Error('Document not found')
|
||||||
|
if (before.docNo === null) throw new Error('Only issued documents can be cancelled')
|
||||||
|
if (before.status === 'cancelled') throw new Error('Document is already cancelled')
|
||||||
|
const alloc = db.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=?`,
|
||||||
|
).get(docId) as { n: number }
|
||||||
|
if (alloc.n > 0) throw new Error('Cannot cancel a document with payments allocated to it')
|
||||||
|
db.prepare(`UPDATE document SET status='cancelled' WHERE id=?`).run(docId)
|
||||||
|
addEvent(db, docId, 'cancelled')
|
||||||
|
writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled' })
|
||||||
|
return getDocument(db, docId)!
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credit note against an issued invoice; defaults to full value. Amounts are
|
||||||
|
* positive here — CN semantics are "negative" only in settlement math (Task 9).
|
||||||
|
*/
|
||||||
|
export function createCreditNote(db: DB, userId: string, invoiceId: string, lines?: DraftLineInput[]): Doc {
|
||||||
|
const inv = getDocument(db, invoiceId)
|
||||||
|
if (inv === null) throw new Error('Invoice not found')
|
||||||
|
if (inv.docType !== 'INVOICE') throw new Error('Credit notes can only be raised against invoices')
|
||||||
|
if (inv.docNo === null) throw new Error('Invoice must be issued before raising a credit note')
|
||||||
|
if (inv.status === 'cancelled') throw new Error('Cannot credit a cancelled invoice')
|
||||||
|
const client = getClient(db, inv.clientId)
|
||||||
|
if (client === null) throw new Error('Client not found')
|
||||||
|
// Recompute rather than copy: rates resolve on the invoice's date, so a CN
|
||||||
|
// against an old bill uses the rate that was law on that day.
|
||||||
|
const lineInputs = lines !== undefined
|
||||||
|
? buildLines(db, lines, inv.docDate)
|
||||||
|
: inv.payload.lines.map((l): LineInput => ({
|
||||||
|
itemId: l.itemId, name: l.name, hsn: l.hsn, qty: l.qty, unitCode: l.unitCode,
|
||||||
|
unitPricePaise: l.unitPricePaise, priceIncludesTax: false, taxClassCode: TAX_CLASS,
|
||||||
|
}))
|
||||||
|
const computed = computeBill(lineInputs, {
|
||||||
|
businessDate: inv.docDate,
|
||||||
|
supplyStateCode: supplyStateCode(db),
|
||||||
|
placeOfSupplyStateCode: client.stateCode,
|
||||||
|
roundToRupee: true,
|
||||||
|
}, taxRates(db))
|
||||||
|
return db.transaction(() => {
|
||||||
|
const doc = insertDocRow(db, userId, {
|
||||||
|
docType: 'CREDIT_NOTE', clientId: inv.clientId, refDocId: inv.id,
|
||||||
|
docDate: todayIso(), totals: computed.totals,
|
||||||
|
payload: { lines: computed.lines, totals: computed.totals },
|
||||||
|
})
|
||||||
|
addEvent(db, inv.id, 'credit_note', { creditNoteId: doc.id })
|
||||||
|
return doc
|
||||||
|
})()
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
// 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 } from '../src/repos-documents'
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
|
||||||
|
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
|
||||||
|
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
|
||||||
|
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
|
||||||
|
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', () => {
|
||||||
|
const { db, c, m } = setup()
|
||||||
|
const d = 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', () => {
|
||||||
|
const { db, m } = setup()
|
||||||
|
const db2 = db
|
||||||
|
const kar = createClient(db2, 'u1', { name: 'BLR Co', stateCode: '29' })
|
||||||
|
const d = 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', () => {
|
||||||
|
const { db, c, m } = setup()
|
||||||
|
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
||||||
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
||||||
|
const issued = issueDocument(db, 'u1', qt.id)
|
||||||
|
expect(issued.docNo).toMatch(/^QT\/\d{2}-\d{2}-\d{4}$/)
|
||||||
|
const inv = convertDocument(db, 'u1', qt.id, 'INVOICE')
|
||||||
|
expect(inv.refDocId).toBe(qt.id)
|
||||||
|
expect(inv.payablePaise).toBe(qt.payablePaise)
|
||||||
|
})
|
||||||
|
it('credit note defaults to full value against the invoice; cancel keeps the number', () => {
|
||||||
|
const { db, c, m } = setup()
|
||||||
|
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id,
|
||||||
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id)
|
||||||
|
const cn = createCreditNote(db, 'u1', inv.id)
|
||||||
|
expect(cn.docType).toBe('CREDIT_NOTE')
|
||||||
|
expect(cn.payablePaise).toBe(inv.payablePaise)
|
||||||
|
const cancelled = cancelDocument(db, 'u1', inv.id)
|
||||||
|
expect(cancelled.status).toBe('cancelled')
|
||||||
|
expect(cancelled.docNo).toBe(inv.docNo) // number consumed, never reused
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue