feat(drafts): DB drafts with deferred numbering — create-as-draft (no number), assign number on finalize (offers/invoices/issued-orders)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 18:04:11 +02:00
parent 28186397a0
commit 473f7c4a8c
8 changed files with 494 additions and 37 deletions

View File

@@ -3,10 +3,13 @@ import { toCzk } from "./exchange-rates";
import {
generateInvoiceNumber,
releaseInvoiceNumber,
assignInvoiceNumber,
} from "./numbering.service";
// Status transition rules matching PHP
// Status transition rules matching PHP.
// draft -> issued is the finalize step (consumes + assigns the invoice number).
const VALID_TRANSITIONS: Record<string, string[]> = {
draft: ["issued"],
issued: ["paid"],
overdue: ["paid"],
paid: [],
@@ -411,17 +414,26 @@ export async function getInvoice(id: number) {
}
export async function createInvoice(body: InvoiceInput) {
const invoiceNumber =
const status = body.status ? String(body.status) : "draft";
// Deferred numbering: a draft carries NO invoice_number (the column is
// nullable-unique so many drafts coexist). The official number is consumed
// only when the draft is finalized (assignInvoiceNumber on draft->issued).
// A caller-supplied number, or an invoice created already-finalized, numbers now.
const explicit =
body.invoice_number !== undefined && body.invoice_number !== null
? String(body.invoice_number)
: (await generateInvoiceNumber()).number;
: null;
const invoiceNumber =
explicit ??
(status === "draft" ? null : (await generateInvoiceNumber()).number);
const invoice = await prisma.invoices.create({
data: {
invoice_number: invoiceNumber,
order_id: body.order_id ? Number(body.order_id) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
status: body.status ? String(body.status) : "issued",
status,
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
@@ -479,9 +491,12 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
const data: Record<string, unknown> = { modified_at: new Date() };
// Allow full editing in 'issued' and 'overdue' states
const isDraft = currentStatus === "issued" || currentStatus === "overdue";
if (isDraft) {
// Allow full editing in 'draft', 'issued' and 'overdue' states.
const editable =
currentStatus === "draft" ||
currentStatus === "issued" ||
currentStatus === "overdue";
if (editable) {
const strFields = [
"currency",
"payment_method",
@@ -515,8 +530,8 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
}
// Notes editable in issued/overdue
if (currentStatus === "issued" || currentStatus === "overdue") {
// Notes editable in draft/issued/overdue
if (editable) {
if (body.notes !== undefined)
data.notes = body.notes ? String(body.notes) : null;
if (body.internal_notes !== undefined)
@@ -537,10 +552,27 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
if (body.paid_date !== undefined && currentStatus !== "paid")
data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null;
await prisma.invoices.update({ where: { id }, data });
// Finalize transition draft -> issued: consume + write the official invoice
// number ATOMICALLY with the status change, so there is never a window where
// the invoice is "issued" but still has a null invoice_number.
// assignInvoiceNumber is idempotent (won't re-number an already-numbered row).
const finalizing =
currentStatus === "draft" &&
body.status !== undefined &&
String(body.status) === "issued";
// Only allow items update in draft state
if (isDraft && Array.isArray(body.items)) {
let assignedNumber: string | null = null;
if (finalizing) {
await prisma.$transaction(async (tx) => {
await tx.invoices.update({ where: { id }, data });
assignedNumber = await assignInvoiceNumber(id, tx);
});
} else {
await prisma.invoices.update({ where: { id }, data });
}
// Allow items update while editable (draft/issued/overdue).
if (editable && Array.isArray(body.items)) {
await prisma.$transaction(async (tx) => {
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
await tx.invoice_items.createMany({
@@ -558,7 +590,12 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
});
}
return { id, invoice_number: existing.invoice_number };
// Reflect the number the finalize transition just assigned (existing was read
// before the assign, so its invoice_number is still the pre-finalize null).
return {
id,
invoice_number: assignedNumber ?? existing.invoice_number,
};
}
export async function deleteInvoice(id: number) {