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)
+ })
+})