You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
194 lines
9.1 KiB
TypeScript
194 lines
9.1 KiB
TypeScript
// 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)
|
|
})
|
|
})
|