// apps/hq/test/convert-and-send.test.ts — Phases 7+8: one-click convert & send, proforma supersede (spec §8) import express from 'express' import { describe, it, expect, afterAll } from 'vitest' import { openDb, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { createStaff } from '../src/auth' import { createClient } from '../src/repos-clients' import { createModule, setPrice } from '../src/repos-modules' import { createDraft, getDocument, issueDocument, listDocuments, supersedeProforma, } from '../src/repos-documents' import { saveAccount } from '../src/repos-email' import { encrypt } from '../src/crypto' import { apiRouter } from '../src/api' const KEY = '11'.repeat(32) const fakePdf = async () => Buffer.from('%PDF-fake') function base(db: DB) { seedIfEmpty(db) const c = createClient(db, 'u1', { name: 'Acme Bank', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }], }) const m = createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' }) setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) return { c, m } } // ---------- supersedeProforma (repo) ---------- describe('supersedeProforma', () => { it('cancels an issued proforma (number stays consumed) and opens a linked draft carrying the payload', () => { const db = openDb(':memory:') const { c, m } = base(db) const pi = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }], }).id) const fresh = supersedeProforma(db, 'u1', pi.id) const old = getDocument(db, pi.id)! expect(old.status).toBe('cancelled') expect(old.docNo).toBe(pi.docNo) // consumed number stays on the corpse expect(fresh.docType).toBe('PROFORMA') expect(fresh.status).toBe('draft') expect(fresh.docNo).toBeNull() expect(fresh.refDocId).toBe(pi.id) expect(fresh.payload.lines).toEqual(pi.payload.lines) // carried verbatim expect(fresh.payablePaise).toBe(pi.payablePaise) }) it('supersedes an unissued proforma draft too (retired, no number involved)', () => { const db = openDb(':memory:') const { c, m } = base(db) const draft = createDraft(db, 'u1', { docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], }) const fresh = supersedeProforma(db, 'u1', draft.id) expect(getDocument(db, draft.id)!.status).toBe('cancelled') expect(fresh.refDocId).toBe(draft.id) }) it('hard-rejects an INVOICE with credit-note guidance, and non-proformas generally', () => { const db = openDb(':memory:') const { c, m } = base(db) const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], }).id) expect(() => supersedeProforma(db, 'u1', inv.id)) .toThrow(/immutable.*credit note/i) const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], }) expect(() => supersedeProforma(db, 'u1', qt.id)).toThrow(/only proformas/i) }) it('rejects an already-cancelled proforma and one with a live forward child', () => { const db = openDb(':memory:') const { c, m } = base(db) const pi = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], }).id) supersedeProforma(db, 'u1', pi.id) expect(() => supersedeProforma(db, 'u1', pi.id)).toThrow(/already cancelled/i) }) }) // ---------- POST /documents/:id/convert-and-send (route) ---------- function appWith(fetchImpl: typeof fetch) { const db = openDb(':memory:') const { c, m } = base(db) createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY)) const pi = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], }).id) const app = express(); app.use(express.json()); app.locals['db'] = db app.use('/api', apiRouter(db, { f: fetchImpl, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf)) const server = app.listen(0) const baseUrl = `http://localhost:${(server.address() as { port: number }).port}/api` return { db, server, baseUrl, pi, c } } const okFetch = (async (url: string) => new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }), { status: 200 })) as typeof fetch const sendFailFetch = (async (url: string) => String(url).includes('/token') ? new Response(JSON.stringify({ access_token: 'at' }), { status: 200 }) : new Response('{}', { status: 500 })) as typeof fetch async function loginAndPost(baseUrl: string, path: string, body?: unknown) { const token = (await (await fetch(`${baseUrl}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }), })).json() as { token: string }).token const res = await fetch(`${baseUrl}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(body ?? {}), }) return { status: res.status, json: await res.json() as Record & { document?: { id: string } } } } describe('POST /documents/:id/convert-and-send', () => { const servers: { close: () => void }[] = [] afterAll(() => { for (const s of servers) s.close() }) it('happy path: one call → issued INVOICE, proforma flipped invoiced, email logged sent, invoice marked sent', async () => { const ctx = appWith(okFetch); servers.push(ctx.server) const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`) expect(out.status).toBe(200) const invoice = getDocument(ctx.db, out.json.document!.id)! expect(invoice.docType).toBe('INVOICE') expect(invoice.docNo).toMatch(/^INV\//) expect(invoice.status).toBe('sent') // sendDocumentEmail flips draft→sent expect(invoice.refDocId).toBe(ctx.pi.id) expect(getDocument(ctx.db, ctx.pi.id)!.status).toBe('invoiced') const log = ctx.db.prepare(`SELECT status, document_id, to_addr FROM email_log ORDER BY id DESC LIMIT 1`) .get() as { status: string; document_id: string; to_addr: string } expect(log).toMatchObject({ status: 'sent', document_id: invoice.id, to_addr: 'ravi@acme.in' }) }) it('double-click cannot mint a second invoice: retry 400s, exactly one live INVOICE exists', async () => { const ctx = appWith(okFetch); servers.push(ctx.server) await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`) const again = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`) expect(again.status).toBe(400) const invoices = listDocuments(ctx.db, { type: 'INVOICE' }).filter((d) => d.status !== 'cancelled') expect(invoices).toHaveLength(1) }) it('a failed send leaves the issued invoice intact and returns a warning', async () => { const ctx = appWith(sendFailFetch); servers.push(ctx.server) const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`) expect(out.status).toBe(200) expect(String(out.json['warning'])).toMatch(/email failed/i) const invoice = getDocument(ctx.db, out.json.document!.id)! expect(invoice.docNo).toMatch(/^INV\//) // number assigned and kept expect(invoice.status).toBe('draft') // send never happened; nothing rolled back const log = ctx.db.prepare(`SELECT status FROM email_log ORDER BY id DESC LIMIT 1`).get() as { status: string } expect(log.status).toBe('failed') }) it('guards fire BEFORE any write: gmail disconnected → 409 and no invoice created', async () => { const ctx = appWith(okFetch); servers.push(ctx.server) ctx.db.prepare(`DELETE FROM email_account`).run() // disconnect const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`) expect(out.status).toBe(409) expect(listDocuments(ctx.db, { type: 'INVOICE' })).toHaveLength(0) expect(getDocument(ctx.db, ctx.pi.id)!.status).not.toBe('invoiced') }) it('rejects non-proformas up front', async () => { const ctx = appWith(okFetch); servers.push(ctx.server) const qt = createDraft(ctx.db, 'u1', { docType: 'QUOTATION', clientId: ctx.c.id, lines: [{ moduleId: listDocuments(ctx.db, {})[0]!.payload.lines[0]!.itemId, qty: 1, kind: 'yearly' }], }) const out = await loginAndPost(ctx.baseUrl, `/documents/${qt.id}/convert-and-send`) expect(out.status).toBe(400) }) it('POST /documents/:id/supersede round-trips over HTTP', async () => { const ctx = appWith(okFetch); servers.push(ctx.server) const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/supersede`) expect(out.status).toBe(200) expect(getDocument(ctx.db, ctx.pi.id)!.status).toBe('cancelled') const fresh = getDocument(ctx.db, out.json.document!.id)! expect(fresh.docType).toBe('PROFORMA') expect(fresh.refDocId).toBe(ctx.pi.id) }) })