Merge pull request #796 from Luca-Timo/feat/invoice-vat-note
feat(invoices): configurable VAT note under MwSt. line + fix multi-page page-number overlap (#794)
This commit is contained in:
@@ -234,3 +234,66 @@ describe('renderInvoiceToBuffer — Storno branch', () => {
|
|||||||
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
|
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// VAT free-text note (#794) + multi-page page-number placement. Same
|
||||||
|
// constraint as the Storno tests: PDFKit Flate-compresses content streams,
|
||||||
|
// so we can't grep the note text — but the page-TREE objects are NOT
|
||||||
|
// compressed, so `/Type /Page` (not `/Pages`) is countable to assert
|
||||||
|
// pagination, and a byte-size delta proves the note actually rendered.
|
||||||
|
describe('renderInvoiceToBuffer — VAT note + multi-page footer (#794)', () => {
|
||||||
|
function baseCtx(overrides = {}) {
|
||||||
|
return {
|
||||||
|
locale: 'de', currency: 'CHF',
|
||||||
|
issuer: { companyName: 'AcmeCo' },
|
||||||
|
recipient: {
|
||||||
|
companyName: 'KundenCo', addressLine1: 'Strasse 1',
|
||||||
|
city: 'Bern', postalCode: '3000',
|
||||||
|
},
|
||||||
|
lineItems: [{
|
||||||
|
quantity: 1, description: 'Photo session',
|
||||||
|
unitPriceMinor: 30000, lineTotalMinor: 30000,
|
||||||
|
parentLineItemId: null, parentPosition: null,
|
||||||
|
}],
|
||||||
|
totals: {
|
||||||
|
netAmountMinor: 30000, vatRate: 0, vatAmountMinor: 0,
|
||||||
|
shippingAmountMinor: 0, totalAmountMinor: 30000,
|
||||||
|
},
|
||||||
|
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||||
|
qrFormat: 'none',
|
||||||
|
paymentTerm: { netDays: 30 },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pageCount = (buf) => (buf.toString('latin1').match(/\/Type\s*\/Page(?![s])/g) || []).length;
|
||||||
|
const VAT_NOTE = 'Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).';
|
||||||
|
|
||||||
|
it('renders the VAT note on a single-page invoice (adds content, valid PDF)', async () => {
|
||||||
|
const withNote = await pdfService.renderInvoiceToBuffer(baseCtx({ vatNote: VAT_NOTE }));
|
||||||
|
const without = await pdfService.renderInvoiceToBuffer(baseCtx());
|
||||||
|
expect(withNote.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||||
|
expect(pageCount(withNote)).toBe(1);
|
||||||
|
expect(withNote.length).toBeGreaterThan(without.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates a long invoice (with the note) across multiple pages without a stray blank page', async () => {
|
||||||
|
const manyItems = Array.from({ length: 60 }, (_, i) => ({
|
||||||
|
quantity: 1, description: `Position ${i + 1} — fotografische Leistung`,
|
||||||
|
unitPriceMinor: 3225, lineTotalMinor: 3225,
|
||||||
|
parentLineItemId: null, parentPosition: null,
|
||||||
|
}));
|
||||||
|
const buf = await pdfService.renderInvoiceToBuffer(baseCtx({
|
||||||
|
lineItems: manyItems,
|
||||||
|
totals: {
|
||||||
|
netAmountMinor: 193500, vatRate: 0, vatAmountMinor: 0,
|
||||||
|
shippingAmountMinor: 0, totalAmountMinor: 193500,
|
||||||
|
},
|
||||||
|
vatNote: VAT_NOTE,
|
||||||
|
}));
|
||||||
|
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||||
|
const pages = pageCount(buf);
|
||||||
|
expect(pages).toBeGreaterThanOrEqual(2);
|
||||||
|
// 60 short rows fit in 2–3 pages; a stray blank page (the old margin
|
||||||
|
// bug) or a runaway loop would blow past this.
|
||||||
|
expect(pages).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -174,6 +174,14 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
|
|||||||
? 0
|
? 0
|
||||||
: ensureInt(invoice.net_amount_minor) - displayedNetMinor;
|
: ensureInt(invoice.net_amount_minor) - displayedNetMinor;
|
||||||
|
|
||||||
|
// Optional free-text VAT / legal note printed directly under the MwSt. line
|
||||||
|
// on the invoice PDF (#794). Configured globally in Settings → CRM → Invoices.
|
||||||
|
// Data-driven: the admin types the exact wording (e.g. the Austrian
|
||||||
|
// Kleinunternehmer statement, § 6 Abs. 1 Z 27 UStG 1994), so no jurisdiction
|
||||||
|
// is hardcoded. Empty/whitespace → null (row omitted).
|
||||||
|
const vatNoteRaw = await getAppSetting('crm_invoices_vat_note_text');
|
||||||
|
const vatNote = typeof vatNoteRaw === 'string' && vatNoteRaw.trim() ? vatNoteRaw.trim() : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale: invoice.language || profile?.default_locale || 'de',
|
locale: invoice.language || profile?.default_locale || 'de',
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
@@ -189,6 +197,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
|
|||||||
iban: bank.iban, bic: bank.bic, currency: bank.currency,
|
iban: bank.iban, bic: bank.bic, currency: bank.currency,
|
||||||
} : null,
|
} : null,
|
||||||
paymentTerm,
|
paymentTerm,
|
||||||
|
// Free-text VAT/legal note (#794) — rendered under the MwSt. line by drawTotals.
|
||||||
|
vatNote,
|
||||||
lineItems: lineItems.map((li) => ({
|
lineItems: lineItems.map((li) => ({
|
||||||
quantity: li.quantity,
|
quantity: li.quantity,
|
||||||
description: li.description,
|
description: li.description,
|
||||||
|
|||||||
@@ -851,6 +851,18 @@ function drawTotals(doc, ctx, x, y, width) {
|
|||||||
doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
|
doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
|
||||||
y = doc.y + 4;
|
y = doc.y + 4;
|
||||||
|
|
||||||
|
// Free-text VAT / legal note (#794) — printed directly under the MwSt. line
|
||||||
|
// (Benedikt's requested spot). The admin sets the exact wording in
|
||||||
|
// Settings → CRM → Invoices (e.g. the Austrian Kleinunternehmer statement).
|
||||||
|
// Optional; wraps across the totals column. Font size is restored to the row
|
||||||
|
// scale so the Mahngebühr / Rundung / grand-total rows below are unaffected.
|
||||||
|
if (ctx.vatNote) {
|
||||||
|
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#555');
|
||||||
|
doc.text(ctx.vatNote, labelX, y, { width: right - labelX });
|
||||||
|
doc.fillColor('#000').fontSize(10);
|
||||||
|
y = doc.y + 4;
|
||||||
|
}
|
||||||
|
|
||||||
// Mahngebühr row — only rendered when a late fee has been added
|
// Mahngebühr row — only rendered when a late fee has been added
|
||||||
// (second reminder onwards). Sits between VAT and the grand-total
|
// (second reminder onwards). Sits between VAT and the grand-total
|
||||||
// divider so the customer sees a clear "VAT + late fee → Total"
|
// divider so the customer sees a clear "VAT + late fee → Total"
|
||||||
@@ -1670,7 +1682,16 @@ function renderDocument(type, context) {
|
|||||||
// VAT + middle divider + Total)
|
// VAT + middle divider + Total)
|
||||||
const FOOTER_RESERVE = 30;
|
const FOOTER_RESERVE = 30;
|
||||||
const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50;
|
const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50;
|
||||||
const TOTALS_BLOCK_HEIGHT = 90;
|
let TOTALS_BLOCK_HEIGHT = 90;
|
||||||
|
// A free-text VAT note (#794) adds a wrapped row under the MwSt. line —
|
||||||
|
// grow the reserved totals height by its measured height so a long note
|
||||||
|
// can't push the grand total / payment block into the footer.
|
||||||
|
if (ctx.vatNote) {
|
||||||
|
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8);
|
||||||
|
const noteWidth = PAGE.contentWidth - ((PAGE.contentWidth - 20) / 2 + 20);
|
||||||
|
TOTALS_BLOCK_HEIGHT += doc.heightOfString(ctx.vatNote, { width: noteWidth }) + 4;
|
||||||
|
doc.fontSize(10);
|
||||||
|
}
|
||||||
const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT;
|
const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT;
|
||||||
const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT;
|
const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT;
|
||||||
|
|
||||||
@@ -1746,14 +1767,23 @@ function renderDocument(type, context) {
|
|||||||
// grey line in the bottom corner) is negligible.
|
// grey line in the bottom corner) is negligible.
|
||||||
for (let i = 0; i < total; i++) {
|
for (let i = 0; i < total; i++) {
|
||||||
doc.switchToPage(range.start + i);
|
doc.switchToPage(range.start + i);
|
||||||
|
// Drop this page's bottom margin to 0 so writing the label INTO the
|
||||||
|
// margin band (below the content area the line-item table fills) can't
|
||||||
|
// trigger PDFKit's auto-page-break. Previously the label sat at
|
||||||
|
// marginBottom-12 — INSIDE the content area — so on a full multi-page
|
||||||
|
// invoice the table's last row overlapped the "Seite X von Y" stamp
|
||||||
|
// (#794). The page is already fully laid out (buffered), so zeroing the
|
||||||
|
// margin here is safe.
|
||||||
|
doc.page.margins.bottom = 0;
|
||||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
|
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
|
||||||
const label = t(ctx.locale, 'page_of', {
|
const label = t(ctx.locale, 'page_of', {
|
||||||
current: i + 1,
|
current: i + 1,
|
||||||
total,
|
total,
|
||||||
});
|
});
|
||||||
// Bottom-right corner, just above the bottom margin so
|
// Bottom-right corner, INSIDE the bottom margin (below the content
|
||||||
// it doesn't trigger PDFKit's auto-paging.
|
// edge the table fills), so a full continuation page's last row can't
|
||||||
const labelY = doc.page.height - PAGE.marginBottom - 12;
|
// overlap it.
|
||||||
|
const labelY = doc.page.height - PAGE.marginBottom + 8;
|
||||||
const labelW = 120;
|
const labelW = 120;
|
||||||
const labelX = doc.page.width - PAGE.marginRight - labelW;
|
const labelX = doc.page.width - PAGE.marginRight - labelW;
|
||||||
doc.text(label, labelX, labelY, {
|
doc.text(label, labelX, labelY, {
|
||||||
@@ -1793,6 +1823,8 @@ function normaliseContext(type, ctx) {
|
|||||||
totals: ctx.totals || {},
|
totals: ctx.totals || {},
|
||||||
doc: ctx.doc || {},
|
doc: ctx.doc || {},
|
||||||
qrFormat: ctx.qrFormat || 'none',
|
qrFormat: ctx.qrFormat || 'none',
|
||||||
|
// Free-text VAT/legal note printed under the MwSt. line on invoices (#794).
|
||||||
|
vatNote: (typeof ctx.vatNote === 'string' && ctx.vatNote.trim()) ? ctx.vatNote.trim() : null,
|
||||||
// Date-format config from the `general_date_format` app setting.
|
// Date-format config from the `general_date_format` app setting.
|
||||||
// Shape: `{ format: 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' |
|
// Shape: `{ format: 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' |
|
||||||
// 'YYYY-MM-DD', locale?: string }`. The service layer hydrates
|
// 'YYYY-MM-DD', locale?: string }`. The service layer hydrates
|
||||||
|
|||||||
@@ -5233,6 +5233,11 @@
|
|||||||
"crm_invoices_late_fee_label": {
|
"crm_invoices_late_fee_label": {
|
||||||
"label": "Bezeichnung Mahngebühr"
|
"label": "Bezeichnung Mahngebühr"
|
||||||
},
|
},
|
||||||
|
"crm_invoices_vat_note_text": {
|
||||||
|
"label": "MwSt.- / Freitext-Hinweis auf Rechnungen",
|
||||||
|
"placeholder": "z. B. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).",
|
||||||
|
"help": "Wird direkt unter der MwSt.-Zeile auf jeder Rechnungs-PDF gedruckt. Leer lassen zum Ausblenden. Bitte den genauen Wortlaut mit deinem Steuerberater abstimmen."
|
||||||
|
},
|
||||||
"crm_invoices_skonto_percent_default": {
|
"crm_invoices_skonto_percent_default": {
|
||||||
"label": "Standard-Skonto %"
|
"label": "Standard-Skonto %"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5231,6 +5231,11 @@
|
|||||||
"crm_invoices_late_fee_label": {
|
"crm_invoices_late_fee_label": {
|
||||||
"label": "Late fee label"
|
"label": "Late fee label"
|
||||||
},
|
},
|
||||||
|
"crm_invoices_vat_note_text": {
|
||||||
|
"label": "VAT / free-text note on invoices",
|
||||||
|
"placeholder": "e.g. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).",
|
||||||
|
"help": "Printed directly under the MwSt. line on every invoice PDF. Leave empty to hide. Please confirm the exact wording with your tax advisor."
|
||||||
|
},
|
||||||
"crm_invoices_skonto_percent_default": {
|
"crm_invoices_skonto_percent_default": {
|
||||||
"label": "Skonto rate (default %)"
|
"label": "Skonto rate (default %)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const SETTING_KEYS = [
|
|||||||
'crm_quotes_tos_url',
|
'crm_quotes_tos_url',
|
||||||
'crm_invoices_qr_enabled',
|
'crm_invoices_qr_enabled',
|
||||||
'crm_invoice_round_total',
|
'crm_invoice_round_total',
|
||||||
|
// Free-text VAT/legal note printed under the MwSt. line on invoice PDFs (#794).
|
||||||
|
'crm_invoices_vat_note_text',
|
||||||
'crm_invoices_reminders_enabled',
|
'crm_invoices_reminders_enabled',
|
||||||
'crm_invoices_reminder_first_days',
|
'crm_invoices_reminder_first_days',
|
||||||
'crm_invoices_reminder_second_days',
|
'crm_invoices_reminder_second_days',
|
||||||
@@ -249,6 +251,25 @@ export const CrmSettingsPage: React.FC = () => {
|
|||||||
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
|
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
|
||||||
{checkbox('crm_invoice_round_total', 'Reconcile sub-cent rounding to a clean total (adds a "Rundung" row when per-line rounding drifts from qty × rate)')}
|
{checkbox('crm_invoice_round_total', 'Reconcile sub-cent rounding to a clean total (adds a "Rundung" row when per-line rounding drifts from qty × rate)')}
|
||||||
|
|
||||||
|
{/* Free-text VAT / legal note (#794) — printed directly under the MwSt.
|
||||||
|
line on every invoice PDF. Data-driven: the admin types the exact
|
||||||
|
wording (Austrian Kleinunternehmer, German §19, reverse-charge, …). */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('crmSettings.crm_invoices_vat_note_text.label', 'VAT / free-text note on invoices')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={values.crm_invoices_vat_note_text ?? ''}
|
||||||
|
onChange={(e) => setVal('crm_invoices_vat_note_text', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder={t('crmSettings.crm_invoices_vat_note_text.placeholder', 'e.g. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).') as string}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('crmSettings.crm_invoices_vat_note_text.help', 'Printed directly under the MwSt. line on every invoice PDF. Leave empty to hide. Please confirm the exact wording with your tax advisor.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Reminder TIMING: owned by the Invoice dunning workflow when the
|
{/* Reminder TIMING: owned by the Invoice dunning workflow when the
|
||||||
engine is live (callout); otherwise the legacy schedule controls. The
|
engine is live (callout); otherwise the legacy schedule controls. The
|
||||||
late-fee math below is configured here in both cases — it's the fee
|
late-fee math below is configured here in both cases — it's the fee
|
||||||
|
|||||||
Reference in New Issue
Block a user