feat(docs): require a reason when cancelling a document

Cancelling an issued document now needs a reason: cancelDocument takes it, refuses an
empty one, and records it on the 'cancelled' event (visible in the doc timeline) and in
the audit after-image. The Cancel dialog gains a required reason box (the confirm stays
open with an error until one is given). Supersede & recreate cancels with 'Superseded &
recreated'. Documents are never hard-deleted (immutability) — cancel is the path, now
always explained. Full suite 416 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent d68fc257a2
commit f0bba301e9

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { formatINR } from '@sims/domain' import { formatINR } from '@sims/domain'
import { import {
@ -14,7 +14,7 @@ import { DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
import { PaymentForm } from './client-forms' import { PaymentForm } from './client-forms'
/** One pending confirmation — replaces the browser confirm() popup with the styled dialog. */ /** One pending confirmation — replaces the browser confirm() popup with the styled dialog. */
interface Confirm { title: string; body: string; label: string; tone?: 'danger'; run: () => void } interface Confirm { title: string; body: ReactNode; label: string; tone?: 'danger'; run: () => void | Promise<void> }
const TITLE: Record<Doc['docType'], string> = { const TITLE: Record<Doc['docType'], string> = {
QUOTATION: 'Quotation', PROFORMA: 'Proforma Invoice', INVOICE: 'Tax Invoice', QUOTATION: 'Quotation', PROFORMA: 'Proforma Invoice', INVOICE: 'Tax Invoice',
@ -43,6 +43,7 @@ export function DocumentView() {
const [paying, setPaying] = useState(false) const [paying, setPaying] = useState(false)
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [confirm, setConfirm] = useState<Confirm | undefined>() const [confirm, setConfirm] = useState<Confirm | undefined>()
const cancelReasonRef = useRef('') // read in the confirm's run() closure (state would be stale there)
// PDF preview via blob URL — an <iframe src> can't carry the bearer header. // PDF preview via blob URL — an <iframe src> can't carry the bearer header.
const [pdfUrl, setPdfUrl] = useState<string | undefined>() const [pdfUrl, setPdfUrl] = useState<string | undefined>()
@ -182,13 +183,25 @@ export function DocumentView() {
<Button <Button
tone="danger" tone="danger"
disabled={acting} disabled={acting}
onClick={() => setConfirm({ onClick={() => { cancelReasonRef.current = ''; setConfirm({
title: 'Cancel document', title: 'Cancel document',
body: `Cancel ${doc.docNo}? The number stays consumed.`, body: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span>Cancel <strong>{doc.docNo}</strong>? The number stays consumed this can't be undone.</span>
<label style={{ fontSize: 13 }}>
Reason for cancelling <span style={{ color: 'var(--err)' }}>*</span>
<textarea className="wf" rows={2} autoFocus placeholder="e.g. wrong client, duplicate, billing error…"
onChange={(e) => { cancelReasonRef.current = e.target.value }} style={{ marginTop: 4 }} />
</label>
</div>
),
label: 'Cancel document', label: 'Cancel document',
tone: 'danger', tone: 'danger',
run: () => act('cancel', undefined, { ok: 'Cancelled' }), run: () => {
})} if (cancelReasonRef.current.trim() === '') return Promise.reject(new Error('Please add a reason for cancelling'))
act('cancel', { reason: cancelReasonRef.current.trim() }, { ok: 'Cancelled' })
},
}) }}
> >
Cancel Cancel
</Button> </Button>

@ -978,8 +978,8 @@ export function apiRouter(
return convertDocument(db, staffId(res), id, to) return convertDocument(db, staffId(res), id, to)
})) }))
// Cancel is financially destructive (consumes a document number) — managerial only (D24). // Cancel is financially destructive (consumes a document number) — managerial only (D24).
r.post('/documents/:id/cancel', requireAuth, requireManagerial, docAction((id, res) => r.post('/documents/:id/cancel', requireAuth, requireManagerial, docAction((id, res, req) =>
cancelDocument(db, staffId(res), id))) cancelDocument(db, staffId(res), id, String((req.body as { reason?: unknown }).reason ?? ''))))
// Supersede & recreate (spec §8): proforma-only cancel + fresh linked draft. // Supersede & recreate (spec §8): proforma-only cancel + fresh linked draft.
r.post('/documents/:id/supersede', requireAuth, docAction((id, res) => r.post('/documents/:id/supersede', requireAuth, docAction((id, res) =>
supersedeProforma(db, staffId(res), id))) supersedeProforma(db, staffId(res), id)))

@ -462,18 +462,20 @@ export async function convertDocument(db: DB, userId: string, docId: string, to:
} }
/** Only issued, unpaid documents; the consumed number is never reused. */ /** Only issued, unpaid documents; the consumed number is never reused. */
export async function cancelDocument(db: DB, userId: string, docId: string): Promise<Doc> { export async function cancelDocument(db: DB, userId: string, docId: string, reason = ''): Promise<Doc> {
return db.transaction(async () => { return db.transaction(async () => {
const before = await getDocument(db, docId) const before = await getDocument(db, docId)
if (before === null) throw new Error('Document not found') if (before === null) throw new Error('Document not found')
if (before.docNo === null) throw new Error('Only issued documents can be cancelled') if (before.docNo === null) throw new Error('Only issued documents can be cancelled')
if (before.status === 'cancelled') throw new Error('Document is already cancelled') if (before.status === 'cancelled') throw new Error('Document is already cancelled')
const why = reason.trim()
if (why === '') throw new Error('A reason is required to cancel a document')
const alloc = (await db.get<{ n: number }>( const alloc = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=?`, docId))! `SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=?`, docId))!
if (alloc.n > 0) throw new Error('Cannot cancel a document with payments allocated to it') if (alloc.n > 0) throw new Error('Cannot cancel a document with payments allocated to it')
await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId) await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId)
await addEvent(db, docId, 'cancelled') await addEvent(db, docId, 'cancelled', { reason: why })
await writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled' }) await writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled', reason: why })
if (before.docType === 'QUOTATION') { if (before.docType === 'QUOTATION') {
// STOP cleanup (spec §7): cancelled paper is dead — its open follow-up nudges // STOP cleanup (spec §7): cancelled paper is dead — its open follow-up nudges
// must not chase the client. Same transaction, one audited row each. // must not chase the client. Same transaction, one audited row each.
@ -507,7 +509,7 @@ export async function supersedeProforma(db: DB, userId: string, docId: string):
throw new Error(`Already converted: a live ${child.doc_type} exists for this document`) throw new Error(`Already converted: a live ${child.doc_type} exists for this document`)
} }
if (src.docNo !== null) { if (src.docNo !== null) {
await cancelDocument(db, userId, docId) // issued: number stays consumed (rule 4) await cancelDocument(db, userId, docId, 'Superseded & recreated') // issued: number stays consumed (rule 4)
} else { } else {
// Unissued draft: no number to preserve; retire it the same audited way. // Unissued draft: no number to preserve; retire it the same audited way.
await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId) await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId)

@ -92,7 +92,7 @@ describe('documents', () => {
await expect(convertDocument(db, 'u1', qt.id, 'PROFORMA')).rejects.toThrow(/already converted/i) await expect(convertDocument(db, 'u1', qt.id, 'PROFORMA')).rejects.toThrow(/already converted/i)
// a cancelled child is dead paper: the path re-opens // a cancelled child is dead paper: the path re-opens
await issueDocument(db, 'u1', inv.id) await issueDocument(db, 'u1', inv.id)
await cancelDocument(db, 'u1', inv.id) await cancelDocument(db, 'u1', inv.id, 'test cancel')
const inv2 = await convertDocument(db, 'u1', qt.id, 'INVOICE') const inv2 = await convertDocument(db, 'u1', qt.id, 'INVOICE')
expect(inv2.refDocId).toBe(qt.id) expect(inv2.refDocId).toBe(qt.id)
}) })
@ -134,7 +134,7 @@ describe('documents', () => {
const cn = await createCreditNote(db, 'u1', inv.id) const cn = await createCreditNote(db, 'u1', inv.id)
expect(cn.docType).toBe('CREDIT_NOTE') expect(cn.docType).toBe('CREDIT_NOTE')
expect(cn.payablePaise).toBe(inv.payablePaise) expect(cn.payablePaise).toBe(inv.payablePaise)
const cancelled = await cancelDocument(db, 'u1', inv.id) const cancelled = await cancelDocument(db, 'u1', inv.id, 'billing error')
expect(cancelled.status).toBe('cancelled') expect(cancelled.status).toBe('cancelled')
expect(cancelled.docNo).toBe(inv.docNo) // number consumed, never reused expect(cancelled.docNo).toBe(inv.docNo) // number consumed, never reused
}) })

@ -326,7 +326,7 @@ describe('quote_followup STOP cleanup', () => {
it('cancelDocument dismisses the quotations open nudges — dead paper is not chased', async () => { it('cancelDocument dismisses the quotations open nudges — dead paper is not chased', async () => {
const { db, q, remId } = await withOpenNudge() const { db, q, remId } = await withOpenNudge()
await issueDocument(db, 'u1', q.id) // only issued documents can cancel await issueDocument(db, 'u1', q.id) // only issued documents can cancel
await cancelDocument(db, 'u1', q.id) await cancelDocument(db, 'u1', q.id, 'lost deal')
expect((await getReminder(db, remId))!.status).toBe('dismissed') expect((await getReminder(db, remId))!.status).toBe('dismissed')
}) })
it('leaves already-sent follow-ups untouched', async () => { it('leaves already-sent follow-ups untouched', async () => {

Loading…
Cancel
Save