feat(accounting): re-bill proof attachment, CRM panel & hours↔re-bills cross-add (#979)

Closes #866.

Three features, all behind the `incomingInvoices` feature flag:

1. Attach the stored supplier proof PDF to the client-invoice email when a
   captured invoice is re-billed/passed through, as a SEPARATE attachment so
   invoice immutability holds. Global default (off), per-customer tri-state
   override, and per-file selection in a new Send dialog. A missing proof at
   issue time stamps inbound_documents.proof_attach_error rather than silently
   dropping, and never blocks the send. Proof filename is a configurable
   template with {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd} tokens.

2. Re-bills & passthrough panel under CRM → Customer, grouped Open/Sent/Paid
   with status derived from the linked invoice lifecycle rather than a
   duplicated column.

3. Cross-add dialog rolling open hours and open re-bills into one invoice,
   symmetric from both entry points. The two stay distinct, contiguous line
   groups — never merged into shared line items.

Migration 169 is additive, hasColumn-guarded and idempotent.

Review (two rounds) closed two concerns:

- Storno stranding: nothing cleared inbound_documents.billed_invoice_id when a
  covering invoice was cancelled, so a Storno'd re-bill showed as Open in the
  new panel while every billing path filters on that column being NULL — the
  supplier cost could never be re-billed. releaseRebillsForCancelledInvoice now
  detaches the linkage on both invoice-cancel paths, with a regression test on
  the issued-cancel path.

- Permission gating: the new controls rendered on data presence alone while
  their endpoints require accounting.view / accounting.manage / customers.edit.
  Now gated at both the query and render layers.

Known follow-up: two cross-add counter queries are gated on a permission their
endpoint does not check (HoursSection.tsx:174, CustomerCrmPanels.tsx:270) —
degrades safely, one line each.
This commit is contained in:
Luca
2026-08-03 22:03:31 +02:00
committed by GitHub
parent d66425c8ee
commit 165cebdb5c
25 changed files with 1645 additions and 99 deletions
+59 -5
View File
@@ -2042,7 +2042,11 @@
"hourlyRate": "Standard-Stundensatz",
"hourlyRatePlaceholder": "z. B. 120.00",
"hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen."
}
},
"rebillAttachProof": "Lieferantenbeleg bei Weiterverrechnungen standardmäßig anhängen",
"rebillAttachProofHint": "Wenn eine erfasste Lieferantenrechnung weiterverrechnet oder durchlaufend berechnet wird, wird ihr gespeichertes PDF der Rechnungs-E-Mail als separater Beleg beigefügt. Dies ist die Voreinstellung — eine kundenspezifische Einstellung und eine Auswahl pro Datei im Senden-Dialog können sie jederzeit ändern.",
"rebillProofNameFormat": "Dateiname-Format für Belege",
"rebillProofNameFormatHint": "Dateiname des angehängten Beleg-PDFs. Platzhalter: {INVOICE}, {SUPPLIER}, {YEAR}, {MONTH}, {SEQ} (oder {SEQ:03d}). Leer lassen für den Standard „Beleg-{INVOICE}“. Bei mehreren Belegen pro Rechnung wird automatisch ein Index angehängt. Behalten Sie ein Präfix wie „Beleg-“, damit der Beleg nicht genauso heißt wie das Rechnungs-PDF."
},
"slideshow": {
"title": "Diashow",
@@ -4119,7 +4123,8 @@
"toast": {
"created": "Eintrag erfasst",
"deleted": "Eintrag gelöscht",
"billed": "Stunden verrechnet"
"billed": "Stunden verrechnet",
"billedCombined": "Rechnung aus Stunden und Weiterverrechnungen erstellt."
},
"noRate": {
"title": "Kein Stundensatz hinterlegt",
@@ -4272,7 +4277,9 @@
"contractsSection": "Verträge",
"noContracts": "Noch keine Verträge für diesen Kunden.",
"billsSection": "Rechnungen",
"noBills": "Noch keine Rechnungen für diesen Kunden."
"noBills": "Noch keine Rechnungen für diesen Kunden.",
"rebillsSection": "Weiterverrechnungen & Durchlaufposten",
"noRebills": "Noch keine weiterverrechneten oder durchlaufenden Lieferantenrechnungen für diesen Kunden."
},
"billing": {
"section": "Abrechnungsrhythmus",
@@ -4297,7 +4304,12 @@
"title": "Offen für die Rechnung dieses Monats",
"titleManual": "Offen wird auf manuelle Auslösung versendet",
"periodRange": "{{number}} · {{from}} {{to}}"
}
},
"rebillAttachProof": "Lieferantenbeleg bei Weiterverrechnungen anhängen",
"rebillAttachProofInherit": "Mandanten-Standard verwenden",
"rebillAttachProofOn": "Immer anhängen",
"rebillAttachProofOff": "Nie anhängen",
"rebillAttachProofHint": "Überschreibt die globale Voreinstellung für diesen Kunden. Der Senden-Dialog erlaubt weiterhin die Auswahl einzelner Belege bei jedem Versand."
},
"reactivate": {
"button": "Reaktivieren",
@@ -4483,7 +4495,8 @@
"pendingCount_other": "{{count}} Posten",
"billPending": "Verrechnen",
"bundledToast": "{{count}} Weiterverrechnung zu einer Rechnung gebündelt.",
"bundledToast_other": "{{count}} Weiterverrechnungen zu einer Rechnung gebündelt."
"bundledToast_other": "{{count}} Weiterverrechnungen zu einer Rechnung gebündelt.",
"amountRequired": "Rechnungsbetrag vor der Weiterverrechnung eingeben (0 ist erlaubt)."
},
"expense": {
"kind": "Art",
@@ -5213,6 +5226,18 @@
"swiss": "Swiss QR-Bill",
"epc": "EPC QR (SEPA)",
"profileDefault": "Standard aus Geschäftsprofil verwenden"
},
"send": {
"title": "Rechnung senden",
"proofIntro": "Diese Rechnung verrechnet erfasste Lieferantenrechnungen weiter. Wählen Sie, welche Lieferantenbelege der E-Mail beigefügt werden — die Rechnung als PDF wird immer angehängt.",
"proofsLabel": "Lieferantenbelege",
"selectAll": "Alle auswählen",
"selectNone": "Keine",
"unknownSupplier": "Lieferant",
"modePassthrough": "durchlaufend",
"modeRebill": "Weiterverrechnung",
"noProofFile": "Keine gespeicherte Belegdatei",
"sendWithCount": "Mit {{count}} Beleg(en) senden"
}
},
"businessProfile": {
@@ -5784,5 +5809,34 @@
"syncBusy": "Es läuft bereits eine Synchronisierung.",
"syncFailed": "Synchronisierung fehlgeschlagen.",
"actionFailed": "Aktion fehlgeschlagen."
},
"rebills": {
"createInvoice": "Rechnung aus Weiterverrechnungen erstellen",
"unknownSupplier": "Lieferant",
"costLabel": "Kosten {{amount}}",
"proofError": "Beleg nicht angehängt: {{err}}",
"status": {
"open": "Offen",
"sent": "Versendet",
"paid": "Bezahlt"
},
"mode": {
"passthrough": "Durchlaufend",
"rebill": "Weiterverrechnung"
},
"toast": {
"billed": "Rechnung aus Weiterverrechnungen erstellt.",
"billedCombined": "Rechnung aus Weiterverrechnungen und Stunden erstellt.",
"billFailed": "Rechnung konnte nicht erstellt werden"
}
},
"crossAdd": {
"title": "Weitere offene Posten hinzufügen?",
"body": "Dieser Kunde hat außerdem {{count}} {{label}}. Zur selben Rechnung hinzufügen? Sie bleiben eine separate Gruppe — Stunden und Weiterverrechnungen werden nie in einer Position vermischt.",
"addBoth": "Beide hinzufügen",
"hours": "offene Stunden",
"rebills": "offene Weiterverrechnungen",
"hoursOnly": "Nur die Stunden",
"rebillsOnly": "Nur die Weiterverrechnungen"
}
}
+59 -5
View File
@@ -1587,7 +1587,11 @@
"hourlyRate": "Default hourly rate",
"hourlyRatePlaceholder": "e.g. 120.00",
"hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate."
}
},
"rebillAttachProof": "Attach the supplier proof to re-billed invoices by default",
"rebillAttachProofHint": "When a captured supplier invoice is re-billed or passed through, attach its stored PDF to the client-invoice email as a separate proof. This is the default — a per-customer override and a per-file choice in the Send dialog can change it each time.",
"rebillProofNameFormat": "Proof filename format",
"rebillProofNameFormatHint": "Filename for the attached proof PDF. Tokens: {INVOICE}, {SUPPLIER}, {YEAR}, {MONTH}, {SEQ} (or {SEQ:03d}). Leave blank for the default “Beleg-{INVOICE}”. When several proofs ride one invoice, an index is appended automatically. Keep a prefix like “Beleg-” so the proof isnt named identically to the invoice PDF."
},
"slideshow": {
"title": "Slideshow",
@@ -4119,7 +4123,8 @@
"toast": {
"created": "Entry logged",
"deleted": "Entry deleted",
"billed": "Hours billed"
"billed": "Hours billed",
"billedCombined": "Invoice created from hours and re-bills."
},
"noRate": {
"title": "No hourly rate configured",
@@ -4272,7 +4277,9 @@
"noContracts": "No contracts for this customer yet.",
"billsSection": "Invoices",
"noBills": "No invoices for this customer yet.",
"manageEvents": "Manage galleries"
"manageEvents": "Manage galleries",
"rebillsSection": "Re-bills & passthrough",
"noRebills": "No re-billed or passed-through supplier invoices for this customer yet."
},
"billing": {
"section": "Billing cadence",
@@ -4297,7 +4304,12 @@
"title": "Pending in this month's bill",
"titleManual": "Pending — ships on manual trigger",
"periodRange": "{{number}} · {{from}} {{to}}"
}
},
"rebillAttachProof": "Attach supplier proof to re-billed invoices",
"rebillAttachProofInherit": "Use tenant default",
"rebillAttachProofOn": "Always attach",
"rebillAttachProofOff": "Never attach",
"rebillAttachProofHint": "Overrides the global default for this customer. The Send dialog still lets you pick individual proofs each time an invoice goes out."
},
"reactivate": {
"button": "Reactivate",
@@ -4483,7 +4495,8 @@
"pendingCount_other": "{{count}} items",
"billPending": "Bill these",
"bundledToast": "Bundled {{count}} re-bill into one invoice.",
"bundledToast_other": "Bundled {{count}} re-bills into one invoice."
"bundledToast_other": "Bundled {{count}} re-bills into one invoice.",
"amountRequired": "Enter the invoice amount before re-billing (0 is allowed)."
},
"expense": {
"kind": "Type",
@@ -5211,6 +5224,18 @@
"overdue": "Overdue",
"cancelled": "Cancelled",
"skipped": "Skipped (empty month)"
},
"send": {
"title": "Send invoice",
"proofIntro": "This invoice re-bills captured supplier invoices. Choose which supplier proofs to attach to the email — the invoice PDF is always attached.",
"proofsLabel": "Supplier proofs",
"selectAll": "Select all",
"selectNone": "None",
"unknownSupplier": "Supplier",
"modePassthrough": "passthrough",
"modeRebill": "re-bill",
"noProofFile": "No stored proof file",
"sendWithCount": "Send with {{count}} proof(s)"
}
},
"businessProfile": {
@@ -5782,5 +5807,34 @@
"syncBusy": "A sync is already running.",
"syncFailed": "Sync failed.",
"actionFailed": "Action failed."
},
"rebills": {
"createInvoice": "Create invoice from re-bills",
"unknownSupplier": "Supplier",
"costLabel": "cost {{amount}}",
"proofError": "Proof not attached: {{err}}",
"status": {
"open": "Open",
"sent": "Sent",
"paid": "Paid"
},
"mode": {
"passthrough": "Passthrough",
"rebill": "Re-bill"
},
"toast": {
"billed": "Invoice created from re-bills.",
"billedCombined": "Invoice created from re-bills and hours.",
"billFailed": "Failed to create invoice"
}
},
"crossAdd": {
"title": "Add other open items?",
"body": "This customer also has {{count}} {{label}}. Add them to the same invoice? They stay as a separate group — hours and re-bills are never mixed into one line.",
"addBoth": "Add both",
"hours": "open hours",
"rebills": "open re-bills",
"hoursOnly": "Just the hours",
"rebillsOnly": "Just the re-bills"
}
}