feat(hq): extract pure prepareDraft from createDraft (permissive preview path)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 1 week ago
parent 7afd769a14
commit 90df8b0e9f

@ -140,15 +140,20 @@ function taxRates(db: DB): TaxClassRow[] {
})) }))
} }
function buildLines(db: DB, inputs: DraftLineInput[], onDate: string): LineInput[] { function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?: string[]): LineInput[] {
return inputs.map((line) => { return inputs.map((line) => {
const mod = getModule(db, line.moduleId) const mod = getModule(db, line.moduleId)
if (mod === null) throw new Error(`Module not found: ${line.moduleId}`) if (mod === null) throw new Error(`Module not found: ${line.moduleId}`)
const edition = line.edition ?? 'standard' const edition = line.edition ?? 'standard'
const unitPricePaise = let unitPricePaise = line.unitPricePaise ?? priceOn(db, line.moduleId, line.kind, edition, onDate)
line.unitPricePaise ?? priceOn(db, line.moduleId, line.kind, edition, onDate)
if (unitPricePaise === null) { if (unitPricePaise === null) {
throw new Error(`No price for module ${mod.code} (${line.kind}/${edition}) on ${onDate}`) // Strict (no warnings sink): refuse — same message save has always thrown.
if (warnings === undefined) {
throw new Error(`No price for module ${mod.code} (${line.kind}/${edition}) on ${onDate}`)
}
// Permissive preview: keep the staffer typing — ₹0 now, reported so the cell can flag.
warnings.push(`No price for ${mod.code} (${line.kind}/${edition}) — enter Unit ₹`)
unitPricePaise = 0
} }
const packSuffix = edition !== 'standard' ? `${edition}` : '' // pack name prints on the line const packSuffix = edition !== 'standard' ? `${edition}` : '' // pack name prints on the line
return { return {
@ -192,29 +197,70 @@ function insertDocRow(db: DB, userId: string, a: {
return doc return doc
} }
export function createDraft(db: DB, userId: string, input: DraftInput): Doc { /** Zero totals for the empty-composer preview (computeBill throws on an empty bill). */
function zeroTotals(): BillTotals {
return {
grossPaise: 0, discountPaise: 0, taxablePaise: 0, cgstPaise: 0, sgstPaise: 0,
igstPaise: 0, cessPaise: 0, roundOffPaise: 0, payablePaise: 0, savingsVsMrpPaise: 0,
}
}
export interface PreparedDraft {
docType: DocType; clientId: string; docDate: string
totals: BillTotals; payload: DocPayload; warnings: string[]
}
/**
* The compute half of a draft pure, persists nothing. `createDraft` calls this
* then inserts; `POST /documents/preview` calls it with { permissive: true }. So
* save and preview run the ONE tax path and cannot drift. Permissive forgives a
* price gap (0 + warning) and a not-yet-picked client (intra-state default);
* strict (the default, used by save) throws exactly as before.
*/
export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boolean } = {}): PreparedDraft {
const permissive = opts.permissive === true
if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(input.docType)) { if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(input.docType)) {
throw new Error(`Cannot draft doc type: ${input.docType}`) throw new Error(`Cannot draft doc type: ${input.docType}`)
} }
const client = getClient(db, input.clientId) const client = getClient(db, input.clientId)
if (client === null) throw new Error('Client not found') if (client === null && !permissive) throw new Error('Client not found')
const today = todayIso() const today = todayIso()
const computed = computeBill(buildLines(db, input.lines, today), { const lineInputs = Array.isArray(input.lines) ? input.lines : []
const warnings: string[] = []
if (client === null && permissive) warnings.push('No client selected — GST split shown as pending')
// Empty composer under preview: real letterhead, empty table, ₹0 (design §3).
if (permissive && lineInputs.length === 0) {
const zero = zeroTotals()
return {
docType: input.docType, clientId: input.clientId, docDate: today,
totals: zero, payload: { lines: [], totals: zero, lineContents: [] }, warnings,
}
}
const supply = supplyStateCode(db)
const computed = computeBill(buildLines(db, lineInputs, today, permissive ? warnings : undefined), {
businessDate: today, businessDate: today,
supplyStateCode: supplyStateCode(db), supplyStateCode: supply,
placeOfSupplyStateCode: client.stateCode, // client state ≠ ours ⇒ IGST placeOfSupplyStateCode: client?.stateCode ?? supply, // no client yet ⇒ intra-state default
roundToRupee: true, roundToRupee: true,
}, taxRates(db)) }, taxRates(db))
// Parallel to lines: caller override, else the module's quoteContent (buildLines proved each exists). // Parallel to lines: caller override, else the module's quoteContent (buildLines proved each module exists).
const lineContents = input.lines.map((line) => const lineContents = lineInputs.map((line) =>
line.contentLines ?? getModule(db, line.moduleId)!.quoteContent) line.contentLines ?? getModule(db, line.moduleId)!.quoteContent)
const payload: DocPayload = { const payload: DocPayload = {
lines: computed.lines, totals: computed.totals, lineContents, lines: computed.lines, totals: computed.totals, lineContents,
...(input.terms !== undefined ? { terms: input.terms } : {}), ...(input.terms !== undefined ? { terms: input.terms } : {}),
} }
return {
docType: input.docType, clientId: input.clientId, docDate: today,
totals: computed.totals, payload, warnings,
}
}
export function createDraft(db: DB, userId: string, input: DraftInput): Doc {
const prepared = prepareDraft(db, input) // strict — save refuses a bad document
return db.transaction(() => insertDocRow(db, userId, { return db.transaction(() => insertDocRow(db, userId, {
docType: input.docType, clientId: input.clientId, refDocId: null, docType: prepared.docType, clientId: prepared.clientId, refDocId: null,
docDate: today, totals: computed.totals, payload, docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload,
}))() }))()
} }

@ -0,0 +1,71 @@
// apps/hq/test/prepare-draft.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 { prepareDraft, createDraft, type DraftInput } 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 kar = createClient(db, 'u1', { name: 'BLR Co', stateCode: '29' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS'] })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const noPrice = createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' }) // no price row
return { db, c, kar, m, noPrice }
}
describe('prepareDraft ↔ createDraft parity', () => {
it('createDraft persists exactly what prepareDraft computes (totals + payload deep-equal)', () => {
const { db, c, m } = setup()
const input: DraftInput = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15',
lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] }
const prepared = prepareDraft(db, input)
const saved = createDraft(db, 'u1', input)
expect(saved.payload).toEqual(prepared.payload)
expect({
taxable: saved.taxablePaise, cgst: saved.cgstPaise, sgst: saved.sgstPaise,
igst: saved.igstPaise, roundOff: saved.roundOffPaise, payable: saved.payablePaise,
}).toEqual({
taxable: prepared.totals.taxablePaise, cgst: prepared.totals.cgstPaise, sgst: prepared.totals.sgstPaise,
igst: prepared.totals.igstPaise, roundOff: prepared.totals.roundOffPaise, payable: prepared.totals.payablePaise,
})
expect(prepared.warnings).toEqual([])
})
it('resolves IGST for an out-of-state client, exactly as createDraft would', () => {
const { db, kar, m } = setup()
const input: DraftInput = { docType: 'INVOICE', clientId: kar.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }
expect(prepareDraft(db, input).totals.igstPaise).toBe(1_800_00)
})
})
describe('prepareDraft strict vs permissive', () => {
it('STRICT: a line with no price throws (save must refuse the same document)', () => {
const { db, c, noPrice } = setup()
expect(() => prepareDraft(db, { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: noPrice.id, qty: 1, kind: 'yearly' }] }))
.toThrow(/No price for module ANALYTICS/)
})
it('PERMISSIVE: the same line computes at ₹0 and is reported in warnings', () => {
const { db, c, noPrice } = setup()
const out = prepareDraft(db, { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: noPrice.id, qty: 1, kind: 'yearly' }] }, { permissive: true })
expect(out.totals.payablePaise).toBe(0)
expect(out.warnings.some((w) => /ANALYTICS/.test(w))).toBe(true)
})
it('PERMISSIVE: zero lines → empty payload, ₹0, no computeBill throw', () => {
const { db, c } = setup()
const out = prepareDraft(db, { docType: 'QUOTATION', clientId: c.id, lines: [] }, { permissive: true })
expect(out.payload.lines).toEqual([])
expect(out.totals.payablePaise).toBe(0)
})
it('PERMISSIVE: unknown client does not throw; split defaults intra-state with a pending warning', () => {
const { db, m } = setup()
const out = prepareDraft(db, { docType: 'QUOTATION', clientId: '', lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }, { permissive: true })
expect(out.totals.cgstPaise).toBe(900_00) // intra-state default (our state 32)
expect(out.totals.igstPaise).toBe(0)
expect(out.warnings.some((w) => /client/i.test(w))).toBe(true)
})
})
Loading…
Cancel
Save