Gutschrift: Sachwert ohne Betrag = keine Rechnung (kein ZUGFeRD)

Eine Sachwert-Gutschrift darf betragslos sein (Betrag leer/0): dann
findet keine Rechnungsstellung statt - der Kunde hat den Gegenstand
einfach als Subvention erhalten.

- Backend: leeres amount -> 0; Sachwert erlaubt 0, Geld verlangt > 0
  (400 sonst). vatRelevant bei 0 erzwungen-false.
- PDF: betragsloser Sachwert -> Titel 'Sachwert-Uebergabe', kein
  Betrags-/USt-Block (Hinweis keine Rechnungsstellung), KEIN ZUGFeRD-
  Embedding. Mit Betrag -> unveraendert ZUGFeRD.
- Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet ohne
  Betrag; Liste zeigt 'Sachwert ohne Betrag (keine Rechnung)'.

Verifiziert: Sachwert 0 -> kein factur-x.xml; Geld 0 -> 400;
Sachwert 200 -> ZUGFeRD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 13:22:13 +02:00
co-authored by Claude Opus 4.8
parent 4d14390b2a
commit 6f95005530
4 changed files with 131 additions and 73 deletions
+11 -2
View File
@@ -83,12 +83,21 @@ function validateAndNormalize(input: CreateCreditNoteInput) {
? input.amountBasis ? input.amountBasis
: 'BRUTTO') as CreditNoteAmountBasis; : 'BRUTTO') as CreditNoteAmountBasis;
const amount = Number(input.amount); // Leeres/fehlendes Betragsfeld = 0. Sachwerte dürfen betragslos sein
// (reine Übergabe des Gegenstands als Subvention → keine Rechnung, s.u.);
// Geld-Gutschriften brauchen dagegen einen echten Betrag.
const amount = input.amount === undefined || input.amount === null || (input.amount as unknown) === ''
? 0
: Number(input.amount);
if (!Number.isFinite(amount) || amount < 0) { if (!Number.isFinite(amount) || amount < 0) {
throw new ApiError(400, 'Ungültiger Betrag'); throw new ApiError(400, 'Ungültiger Betrag');
} }
if (type === 'GELD' && amount <= 0) {
throw new ApiError(400, 'Bei Geld-Gutschriften bitte einen Betrag größer 0 angeben.');
}
const vatRelevant = !!input.vatRelevant; // Ohne Betrag gibt es keine USt (nicht-monetärer Sachwert).
const vatRelevant = amount > 0 && !!input.vatRelevant;
const vatRate = Number.isFinite(Number(input.vatRate)) ? Number(input.vatRate) : 19; const vatRate = Number.isFinite(Number(input.vatRate)) ? Number(input.vatRate) : 19;
if (vatRate < 0 || vatRate > 100) { if (vatRate < 0 || vatRate > 100) {
throw new ApiError(400, 'Ungültiger USt-Satz'); throw new ApiError(400, 'Ungültiger USt-Satz');
+54 -40
View File
@@ -87,10 +87,14 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
// ---- Titel + Meta ---- // ---- Titel + Meta ----
doc.moveDown(2); doc.moveDown(2);
doc.fontSize(18).text('Gutschrift', { align: 'left' }); // Sachwert ohne Betrag = reine Übergabe des Gegenstands als Subvention,
// KEINE Rechnung (kein Betrag, keine USt, kein ZUGFeRD).
const nonMonetary = cn.type === 'SACHWERT' && cn.amountGross === 0;
doc.fontSize(18).text(nonMonetary ? 'Sachwert-Übergabe' : 'Gutschrift', { align: 'left' });
doc.moveDown(0.3); doc.moveDown(0.3);
doc.fontSize(10); doc.fontSize(10);
doc.text(`Gutschrift-Nr.: ${cn.number}`); doc.text(`Nr.: ${cn.number}`);
doc.text(`Datum: ${ddmmyyyy(new Date(cn.creditDate))}`); doc.text(`Datum: ${ddmmyyyy(new Date(cn.creditDate))}`);
// ---- Beschreibung / Betrag ---- // ---- Beschreibung / Betrag ----
@@ -113,7 +117,11 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
doc.font('Body'); doc.font('Body');
}; };
if (cn.vatRelevant) { if (nonMonetary) {
doc.fontSize(10).fillColor('#333')
.text('Der Sachwert wurde als Subvention übergeben. Es erfolgt keine Rechnungsstellung / kein Rechnungsbetrag.', startX, doc.y, { width: 495 });
doc.fillColor('#000');
} else if (cn.vatRelevant) {
line('Nettobetrag', euro(cn.amountNet, cn.currency)); line('Nettobetrag', euro(cn.amountNet, cn.currency));
line(`Umsatzsteuer ${cn.vatRate}%`, euro(cn.amountVat, cn.currency)); line(`Umsatzsteuer ${cn.vatRate}%`, euro(cn.amountVat, cn.currency));
doc.moveDown(0.2); doc.moveDown(0.2);
@@ -186,44 +194,50 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
doc.end(); doc.end();
const baseBuffer = await done; const baseBuffer = await done;
// ---- ZUGFeRD-XML erzeugen + als hybrides PDF/A-3 einbetten ---- // Nicht-monetärer Sachwert = keine Rechnung → KEIN ZUGFeRD-Embedding,
const xml = buildZugferdXml({ // nur das reine PDF (Empfangs-/Übergabebeleg).
number: cn.number, let xml = '';
issueDate: new Date(cn.creditDate), let buffer = baseBuffer;
currency: cn.currency, if (!nonMonetary) {
seller: { // ---- ZUGFeRD-XML erzeugen + als hybrides PDF/A-3 einbetten ----
name: company.name, xml = buildZugferdXml({
street: company.street, number: cn.number,
houseNumber: company.houseNumber, issueDate: new Date(cn.creditDate),
postalCode: company.postalCode, currency: cn.currency,
city: company.city, seller: {
country: company.country || 'DE', name: company.name,
vatId: company.vatId || undefined, street: company.street,
taxNumber: company.taxNumber || undefined, houseNumber: company.houseNumber,
}, postalCode: company.postalCode,
buyer: { city: company.city,
name: recipientName, country: company.country || 'DE',
street: addr?.street || '', vatId: company.vatId || undefined,
houseNumber: addr?.houseNumber || '', taxNumber: company.taxNumber || undefined,
postalCode: addr?.postalCode || '', },
city: addr?.city || '', buyer: {
country: 'DE', name: recipientName,
}, street: addr?.street || '',
lineName: houseNumber: addr?.houseNumber || '',
cn.type === 'SACHWERT' postalCode: addr?.postalCode || '',
? `Sachwert / Subvention: ${cn.sachwertDescription || ''}`.trim() city: addr?.city || '',
: 'Subvention (Auszahlung per Überweisung)', country: 'DE',
vatRelevant: cn.vatRelevant, },
vatRatePercent: cn.vatRate, lineName:
amountNet: cn.amountNet, cn.type === 'SACHWERT'
amountVat: cn.amountVat, ? `Sachwert / Subvention: ${cn.sachwertDescription || ''}`.trim()
amountGross: cn.amountGross, : 'Subvention (Auszahlung per Überweisung)',
}); vatRelevant: cn.vatRelevant,
vatRatePercent: cn.vatRate,
amountNet: cn.amountNet,
amountVat: cn.amountVat,
amountGross: cn.amountGross,
});
const buffer = await embedZugferd(baseBuffer, xml, { buffer = await embedZugferd(baseBuffer, xml, {
title: `Gutschrift ${cn.number}`, title: `Gutschrift ${cn.number}`,
date: new Date(cn.creditDate), date: new Date(cn.creditDate),
}); });
}
// ---- Speichern ---- // ---- Speichern ----
const dir = path.join(process.cwd(), 'uploads', 'credit-notes'); const dir = path.join(process.cwd(), 'uploads', 'credit-notes');
+14
View File
@@ -97,6 +97,20 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **🎁 Gutschrift: Sachwert ohne Betrag = keine Rechnung** (2026-08-09)
- Eine Sachwert-Gutschrift darf jetzt **betragslos** sein (Betrag leer/0):
dann findet **keine Rechnungsstellung** statt der Kunde hat den
Gegenstand einfach als Subvention erhalten. Beträge bleiben 0, keine USt.
- Backend: `amount` leer → 0; Sachwert erlaubt 0, **Geld verlangt Betrag > 0**
(400 sonst). `vatRelevant` wird bei 0 erzwungen-false.
- PDF: bei betragslosem Sachwert Titel „Sachwert-Übergabe", kein
Betrags-/USt-Block (Hinweis „keine Rechnungsstellung") und **kein ZUGFeRD-
Embedding** (es ist keine Rechnung). Mit Betrag → unverändert ZUGFeRD.
- Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet solange
kein Betrag (Hinweis); Liste zeigt „Sachwert ohne Betrag (keine Rechnung)".
- Verifiziert (Sachwert 0 → kein factur-x.xml; Geld 0 → 400; Sachwert 200 →
ZUGFeRD).
- [x] **🔗 Bug: Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal** (2026-08-07) - [x] **🔗 Bug: Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal** (2026-08-07)
- **Folgevertrag-Bug:** Backend legt den Folgevertrag korrekt mit - **Folgevertrag-Bug:** Backend legt den Folgevertrag korrekt mit
`previousContractId` an der Verlust passierte im Frontend: das `previousContractId` an der Verlust passierte im Frontend: das
@@ -142,14 +142,20 @@ function CreditNoteFormModal({
onError: (err: Error) => toast.error(err.message || 'Speichern fehlgeschlagen'), onError: (err: Error) => toast.error(err.message || 'Speichern fehlgeschlagen'),
}); });
const amountLabel = form.vatRelevant const amountNum = Number(form.amount) || 0;
? `Betrag (${form.amountBasis === 'NETTO' ? 'netto' : 'brutto'})` // Sachwert darf betragslos sein (reine Übergabe → keine Rechnung).
: 'Betrag'; const isSachwert = form.type === 'SACHWERT';
const nonMonetary = isSachwert && amountNum <= 0;
const canSave = const amountLabel = isSachwert
!!form.amount && ? 'Wert (optional)'
Number(form.amount) >= 0 && : form.vatRelevant
(form.type !== 'SACHWERT' || form.sachwertDescription.trim().length > 0); ? `Betrag (${form.amountBasis === 'NETTO' ? 'netto' : 'brutto'})`
: 'Betrag';
const canSave = isSachwert
? form.sachwertDescription.trim().length > 0 // Betrag optional
: amountNum > 0; // Geld: Betrag > 0 Pflicht
return ( return (
<Modal isOpen onClose={onClose} title={editing ? `Gutschrift ${editing.number} bearbeiten` : 'Gutschrift anlegen'} size="lg"> <Modal isOpen onClose={onClose} title={editing ? `Gutschrift ${editing.number} bearbeiten` : 'Gutschrift anlegen'} size="lg">
@@ -184,18 +190,8 @@ function CreditNoteFormModal({
/> />
)} )}
{/* USt-Block */} {/* Betrag + USt-Block */}
<div className="rounded-lg border border-gray-200 p-3 space-y-3 bg-gray-50"> <div className="rounded-lg border border-gray-200 p-3 space-y-3 bg-gray-50">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={form.vatRelevant}
onChange={(e) => set('vatRelevant', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
USt-relevant (mit Umsatzsteuer ausweisen)
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Input <Input
label={amountLabel} label={amountLabel}
@@ -205,7 +201,7 @@ function CreditNoteFormModal({
onChange={(e) => set('amount', e.target.value)} onChange={(e) => set('amount', e.target.value)}
placeholder="0,00" placeholder="0,00"
/> />
{form.vatRelevant && ( {amountNum > 0 && form.vatRelevant && (
<> <>
<Select <Select
label="Basis" label="Basis"
@@ -227,11 +223,28 @@ function CreditNoteFormModal({
)} )}
</div> </div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm"> {nonMonetary ? (
<span>Netto: <strong>{formatEuro(preview.net)}</strong></span> <p className="text-sm text-gray-600">
<span>USt: <strong>{formatEuro(preview.vat)}</strong></span> Ohne Betrag: reine Sachwert-Übergabe <strong>keine Rechnung, keine USt</strong>. Es wird nur der Empfang des Gegenstands als Subvention dokumentiert.
<span>Brutto: <strong>{formatEuro(preview.gross)}</strong></span> </p>
</div> ) : (
<>
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={form.vatRelevant}
onChange={(e) => set('vatRelevant', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
USt-relevant (mit Umsatzsteuer ausweisen)
</label>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
<span>Netto: <strong>{formatEuro(preview.net)}</strong></span>
<span>USt: <strong>{formatEuro(preview.vat)}</strong></span>
<span>Brutto: <strong>{formatEuro(preview.gross)}</strong></span>
</div>
</>
)}
</div> </div>
{form.type === 'GELD' && ( {form.type === 'GELD' && (
@@ -439,10 +452,12 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId
<Badge variant={cn.type === 'GELD' ? 'info' : 'default'}> <Badge variant={cn.type === 'GELD' ? 'info' : 'default'}>
{cn.type === 'GELD' ? 'Geld' : 'Sachwert'} {cn.type === 'GELD' ? 'Geld' : 'Sachwert'}
</Badge> </Badge>
{cn.vatRelevant ? ( {!(cn.type === 'SACHWERT' && cn.amountGross === 0) && (
<Badge variant="default">USt {cn.vatRate}%</Badge> cn.vatRelevant ? (
) : ( <Badge variant="default">USt {cn.vatRate}%</Badge>
<Badge variant="default">ohne USt</Badge> ) : (
<Badge variant="default">ohne USt</Badge>
)
)} )}
<Badge variant="default">{cn.customerType === 'FIRMA' ? 'Firma' : 'Privat'}</Badge> <Badge variant="default">{cn.customerType === 'FIRMA' ? 'Firma' : 'Privat'}</Badge>
{cn.type === 'SACHWERT' && cn.goodsReceived && ( {cn.type === 'SACHWERT' && cn.goodsReceived && (
@@ -452,9 +467,15 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId
)} )}
</div> </div>
<div className="text-sm text-gray-700 mt-1"> <div className="text-sm text-gray-700 mt-1">
<strong>{formatEuro(cn.amountGross, cn.currency)}</strong> {cn.type === 'SACHWERT' && cn.amountGross === 0 ? (
{cn.vatRelevant && ( <span className="italic text-gray-600">Sachwert ohne Betrag (keine Rechnung)</span>
<span className="text-gray-500"> (netto {formatEuro(cn.amountNet, cn.currency)} + USt {formatEuro(cn.amountVat, cn.currency)})</span> ) : (
<>
<strong>{formatEuro(cn.amountGross, cn.currency)}</strong>
{cn.vatRelevant && (
<span className="text-gray-500"> (netto {formatEuro(cn.amountNet, cn.currency)} + USt {formatEuro(cn.amountVat, cn.currency)})</span>
)}
</>
)} )}
<span className="text-gray-500"> · {formatDate(cn.creditDate)}</span> <span className="text-gray-500"> · {formatDate(cn.creditDate)}</span>
{cn.place && <span className="text-gray-500"> · {cn.place}</span>} {cn.place && <span className="text-gray-500"> · {cn.place}</span>}