feat(documents): one-click convert-and-send + proforma supersede (Phases 7-8, spec §8)
- POST /documents/:id/convert-and-send: guards before any write (proforma-only, Gmail alive, recipient resolved), ONE txn convert+issue, email strictly after commit — failed send never rolls back the issued invoice (warning instead) - supersedeProforma: cancel (number stays consumed) + fresh linked PROFORMA draft carrying the payload verbatim; INVOICE hard-rejected to credit-note path - POST /documents/:id/supersede route; DocumentView gains both buttons - 10 tests; suite 280 green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
1db0a8c6b7
commit
fffcc2a05d
@ -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<string, unknown> & { 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue