diff --git a/apps/hq-web/src/pages/DocumentView.tsx b/apps/hq-web/src/pages/DocumentView.tsx index eaf898f..0860d5d 100644 --- a/apps/hq-web/src/pages/DocumentView.tsx +++ b/apps/hq-web/src/pages/DocumentView.tsx @@ -130,6 +130,31 @@ export function DocumentView() { {(doc.docType === 'QUOTATION' || doc.docType === 'PROFORMA') && !cancelled && ( )} + {doc.docType === 'PROFORMA' && !cancelled && doc.status !== 'invoiced' && ( + <> + {/* Spec §8 one-click: convert → issue → email, in one action. */} + + {/* Spec §8 cancel & recreate: retire this proforma, reopen its lines as a fresh draft. */} + + + )} {doc.docType === 'INVOICE' && issued && !cancelled && doc.status !== 'paid' && ( )} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 68c73a0..55bd7c9 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -19,6 +19,7 @@ import { import { cancelDocument, convertDocument, createCreditNote, createDraft, getDocument, issueDocument, listDocumentEvents, listDocuments, markStatus, prepareDraft, + supersedeProforma, type Doc, type DocumentFilter, type DraftInput, type DraftLineInput, } from './repos-documents' import { @@ -445,6 +446,65 @@ export function apiRouter( })) r.post('/documents/:id/cancel', requireAuth, docAction((id, res) => cancelDocument(db, staffId(res), id))) + // Supersede & recreate (spec §8): proforma-only cancel + fresh linked draft. + r.post('/documents/:id/supersede', requireAuth, docAction((id, res) => + supersedeProforma(db, staffId(res), id))) + // One-click convert & send (spec §8): every guard runs BEFORE any write; then ONE + // transaction converts + issues; the email goes strictly AFTER commit — a failed + // send never rolls back an issued invoice, it surfaces as a warning instead. + r.post('/documents/:id/convert-and-send', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + const src = getDocument(db, id) + if (src === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } + if (src.docType !== 'PROFORMA') { + res.status(400).json({ ok: false, error: 'Only proformas can be converted & sent' }); return + } + const status = emailStatus(db) + if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return } + if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return } + const client = getClient(db, src.clientId) + if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + const body = req.body as { to?: string } + const to = body.to ?? client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email + if (to === undefined || to === '') { + res.status(400).json({ ok: false, error: 'No recipient: pass "to" or add a contact email to the client' }) + return + } + let invoice: Doc + try { + invoice = db.transaction(() => { + const draft = convertDocument(db, staffId(res), id, 'INVOICE') // F3 recompute + F4 dup-guard live here + return issueDocument(db, staffId(res), draft.id) + })() + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + return + } + const company = companySettings() + const companyName = company['company.name'] ?? '' + void (async () => { + const issued = (): Doc => getDocument(db, invoice.id)! + try { + const pdf = await renderPdfImpl(documentHtml(issued(), client, company)) + const out = await sendDocumentEmail(db, gmail(), { + documentId: invoice.id, to, + subject: `INVOICE ${invoice.docNo} — ${companyName}`, + bodyText: `Dear ${client.name},\n\nPlease find attached INVOICE ${invoice.docNo}.\n\nRegards,\n${companyName}`, + pdf, userId: staffId(res), + }) + if (!out.ok) { + res.json({ ok: true, document: issued(), warning: `Invoice issued, email failed: ${out.error}` }) + return + } + res.json({ ok: true, document: issued() }) + } catch (err) { + res.json({ + ok: true, document: issued(), + warning: `Invoice issued, email failed: ${err instanceof Error ? err.message : String(err)}`, + }) + } + })() + }) r.post('/documents/:id/credit-note', requireAuth, docAction((id, res, req) => { const { lines } = req.body as { lines?: DraftLineInput[] } return createCreditNote(db, staffId(res), id, lines) diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts index 96211bd..ab26454 100644 --- a/apps/hq/src/repos-documents.ts +++ b/apps/hq/src/repos-documents.ts @@ -396,6 +396,46 @@ export function cancelDocument(db: DB, userId: string, docId: string): Doc { })() } +/** + * Supersede a proforma (spec §8): cancel it — the consumed number is never reused + * when issued — and open a fresh PROFORMA draft carrying the payload verbatim, + * linked via ref_doc_id. The "trivially easy cancel & recreate". Proforma-only: + * an INVOICE is immutable legal paper (cancel if unpaid, else credit note), and + * a QUOTATION re-drafts through the normal composer. + */ +export function supersedeProforma(db: DB, userId: string, docId: string): Doc { + return db.transaction(() => { + const src = getDocument(db, docId) + if (src === null) throw new Error('Document not found') + if (src.docType === 'INVOICE') { + throw new Error('Invoices are immutable — cancel if unpaid, or raise a credit note') + } + if (src.docType !== 'PROFORMA') throw new Error('Only proformas can be superseded') + if (src.status === 'cancelled') throw new Error('Document is already cancelled') + // A live forward child means the sale already moved on — superseding would fork it. + const child = db.prepare( + `SELECT doc_type FROM document WHERE ref_doc_id=? AND status!='cancelled' LIMIT 1`, + ).get(src.id) as { doc_type: string } | undefined + if (child !== undefined) { + throw new Error(`Already converted: a live ${child.doc_type} exists for this document`) + } + if (src.docNo !== null) { + cancelDocument(db, userId, docId) // issued: number stays consumed (rule 4) + } else { + // Unissued draft: no number to preserve; retire it the same audited way. + db.prepare(`UPDATE document SET status='cancelled' WHERE id=?`).run(docId) + addEvent(db, docId, 'cancelled') + writeAudit(db, userId, 'update', 'document', docId, { status: src.status }, { status: 'cancelled' }) + } + const doc = insertDocRow(db, userId, { + docType: 'PROFORMA', clientId: src.clientId, refDocId: src.id, + docDate: todayIso(), totals: src.payload.totals, payload: src.payload, + }) + addEvent(db, src.id, 'superseded', { newDocId: doc.id }) + return doc + })() +} + /** * Credit note against an issued invoice; defaults to full value. Amounts are * positive here — CN semantics are "negative" only in settlement math (Task 9). diff --git a/apps/hq/test/convert-and-send.test.ts b/apps/hq/test/convert-and-send.test.ts new file mode 100644 index 0000000..bdcd89b --- /dev/null +++ b/apps/hq/test/convert-and-send.test.ts @@ -0,0 +1,193 @@ +// 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) + }) +})