feat(accounting): bill editor VAT dropdown + GET returns vat_code snapshot

Slice 2 + 1b:
- Bill editor: VAT-rate field → VatRateSelect dropdown (mirrors the quote
  editor); snapshots vatCode on create + carries it from a source quote.
- getQuoteById + the invoice serializer now return vat_code, so re-editing a
  saved document preserves the snapshot instead of falling back to the
  rate→code map. Payload types (quotes + bills) carry vatCode.

72 tests pass; build green.
This commit is contained in:
Luca
2026-06-16 00:20:59 +02:00
parent 6e1924bae8
commit 2479d87afc
4 changed files with 18 additions and 2 deletions
+2
View File
@@ -1780,6 +1780,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
totals: {
netAmountMinor: invoice.net_amount_minor,
vatRate: invoice.vat_rate,
// Migration 130 — VAT-code snapshot (so re-editing preserves it).
vatCode: invoice.vat_code ?? null,
vatAmountMinor: invoice.vat_amount_minor,
shippingAmountMinor: invoice.shipping_amount_minor,
totalAmountMinor: invoice.total_amount_minor,
+2
View File
@@ -1562,6 +1562,8 @@ async function duplicateQuote(id, adminId) {
expectedDurationHours: quote.expected_duration_hours,
paymentTermTemplateId: quote.payment_term_template_id,
vatRate: quote.vat_rate,
// Migration 130 — VAT-code snapshot (so re-editing preserves it).
vatCode: quote.vat_code ?? null,
shippingAmountMinor: quote.shipping_amount_minor,
introText: quote.intro_text,
outroText: quote.outro_text,
@@ -14,6 +14,7 @@ import { quotesService } from '../../../services/quotes.service';
import { contractsService } from '../../../services/contracts.service';
import { businessProfileService } from '../../../services/businessProfile.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
@@ -58,6 +59,8 @@ export const BillEditorPage: React.FC = () => {
// effect; new invoices start as null so they pick up profile.)
const [qrFormat, setQrFormat] = useState<InvoiceQrFormat | null>(null);
const [vatRate, setVatRate] = useState(0);
// Migration 130 — snapshot of the chosen output VAT code (null = custom rate).
const [vatCode, setVatCode] = useState<string | null>(null);
const [shipping, setShipping] = useState(0);
const [ccPdfEmail, setCcPdfEmail] = useState('');
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
@@ -137,6 +140,8 @@ export const BillEditorPage: React.FC = () => {
// it inherits the profile default at render time.
setQrFormat((inv.qrFormat as InvoiceQrFormat | null) || null);
setVatRate(Number(inv.vatRate || 0));
setVatCode(((inv as { vatCode?: string | null }).vatCode
?? (inv as { totals?: { vatCode?: string | null } }).totals?.vatCode) ?? null);
setShipping(Number(inv.shippingAmountMinor || 0) / 100);
setCcPdfEmail(inv.ccPdfEmail || '');
setPaymentTermTemplateId(inv.paymentTermTemplateId ?? null);
@@ -280,6 +285,7 @@ export const BillEditorPage: React.FC = () => {
const { quote, lineItems: quoteLineItems } = await quotesService.get(contract.sourceQuoteId);
if (quote.currency) setCurrency(quote.currency);
if (quote.vatRate != null) setVatRate(Number(quote.vatRate));
setVatCode((quote as { vatCode?: string | null }).vatCode ?? null);
if (quote.shippingAmountMinor != null) {
setShipping(Number(quote.shippingAmountMinor) / 100);
}
@@ -373,6 +379,7 @@ export const BillEditorPage: React.FC = () => {
// which is the same outcome but pollutes the column.
qrFormat: qrFormat || undefined,
vatRate,
vatCode,
shippingAmountMinor: toMinor(shipping),
ccPdfEmail: ccPdfEmail || undefined,
// Payment-term template id (migration 113). null = no template
@@ -651,8 +658,11 @@ export const BillEditorPage: React.FC = () => {
<LineItemsTable items={lineItems} currency={currency} showDiscount={false}
vatRate={vatRate / 100} shippingAmount={shipping} onChange={setLineItems} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
<Input type="number" step="0.1" label={t('bills.field.vatRate', 'VAT rate %') as string}
value={vatRate} onChange={(e) => setVatRate(Number(e.target.value))} />
<VatRateSelect
label={t('bills.field.vatRate', 'VAT rate %') as string}
rate={vatRate}
code={vatCode}
onChange={(rate, code) => { setVatRate(rate); setVatCode(code); }} />
<Input type="number" step="0.01" label={t('bills.field.shipping', 'Shipping') as string}
value={shipping} onChange={(e) => setShipping(Number(e.target.value))} />
<div>
+2
View File
@@ -180,6 +180,8 @@ export interface InvoiceCreatePayload {
installmentLabel?: string;
installmentTrigger?: string;
vatRate?: number;
/** Migration 130 — snapshot of the chosen output VAT code (null = custom rate). */
vatCode?: string | null;
shippingAmountMinor?: number;
ccPdfEmail?: string;
// null = explicitly clear the per-invoice override (back to the