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
: '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) {
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;
if (vatRate < 0 || vatRate > 100) {
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 ----
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.fontSize(10);
doc.text(`Gutschrift-Nr.: ${cn.number}`);
doc.text(`Nr.: ${cn.number}`);
doc.text(`Datum: ${ddmmyyyy(new Date(cn.creditDate))}`);
// ---- Beschreibung / Betrag ----
@@ -113,7 +117,11 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
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(`Umsatzsteuer ${cn.vatRate}%`, euro(cn.amountVat, cn.currency));
doc.moveDown(0.2);
@@ -186,44 +194,50 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
doc.end();
const baseBuffer = await done;
// ---- ZUGFeRD-XML erzeugen + als hybrides PDF/A-3 einbetten ----
const xml = buildZugferdXml({
number: cn.number,
issueDate: new Date(cn.creditDate),
currency: cn.currency,
seller: {
name: company.name,
street: company.street,
houseNumber: company.houseNumber,
postalCode: company.postalCode,
city: company.city,
country: company.country || 'DE',
vatId: company.vatId || undefined,
taxNumber: company.taxNumber || undefined,
},
buyer: {
name: recipientName,
street: addr?.street || '',
houseNumber: addr?.houseNumber || '',
postalCode: addr?.postalCode || '',
city: addr?.city || '',
country: 'DE',
},
lineName:
cn.type === 'SACHWERT'
? `Sachwert / Subvention: ${cn.sachwertDescription || ''}`.trim()
: 'Subvention (Auszahlung per Überweisung)',
vatRelevant: cn.vatRelevant,
vatRatePercent: cn.vatRate,
amountNet: cn.amountNet,
amountVat: cn.amountVat,
amountGross: cn.amountGross,
});
// Nicht-monetärer Sachwert = keine Rechnung → KEIN ZUGFeRD-Embedding,
// nur das reine PDF (Empfangs-/Übergabebeleg).
let xml = '';
let buffer = baseBuffer;
if (!nonMonetary) {
// ---- ZUGFeRD-XML erzeugen + als hybrides PDF/A-3 einbetten ----
xml = buildZugferdXml({
number: cn.number,
issueDate: new Date(cn.creditDate),
currency: cn.currency,
seller: {
name: company.name,
street: company.street,
houseNumber: company.houseNumber,
postalCode: company.postalCode,
city: company.city,
country: company.country || 'DE',
vatId: company.vatId || undefined,
taxNumber: company.taxNumber || undefined,
},
buyer: {
name: recipientName,
street: addr?.street || '',
houseNumber: addr?.houseNumber || '',
postalCode: addr?.postalCode || '',
city: addr?.city || '',
country: 'DE',
},
lineName:
cn.type === 'SACHWERT'
? `Sachwert / Subvention: ${cn.sachwertDescription || ''}`.trim()
: '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, {
title: `Gutschrift ${cn.number}`,
date: new Date(cn.creditDate),
});
buffer = await embedZugferd(baseBuffer, xml, {
title: `Gutschrift ${cn.number}`,
date: new Date(cn.creditDate),
});
}
// ---- Speichern ----
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
- [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)
- **Folgevertrag-Bug:** Backend legt den Folgevertrag korrekt mit
`previousContractId` an der Verlust passierte im Frontend: das
@@ -142,14 +142,20 @@ function CreditNoteFormModal({
onError: (err: Error) => toast.error(err.message || 'Speichern fehlgeschlagen'),
});
const amountLabel = form.vatRelevant
? `Betrag (${form.amountBasis === 'NETTO' ? 'netto' : 'brutto'})`
: 'Betrag';
const amountNum = Number(form.amount) || 0;
// Sachwert darf betragslos sein (reine Übergabe → keine Rechnung).
const isSachwert = form.type === 'SACHWERT';
const nonMonetary = isSachwert && amountNum <= 0;
const canSave =
!!form.amount &&
Number(form.amount) >= 0 &&
(form.type !== 'SACHWERT' || form.sachwertDescription.trim().length > 0);
const amountLabel = isSachwert
? 'Wert (optional)'
: form.vatRelevant
? `Betrag (${form.amountBasis === 'NETTO' ? 'netto' : 'brutto'})`
: 'Betrag';
const canSave = isSachwert
? form.sachwertDescription.trim().length > 0 // Betrag optional
: amountNum > 0; // Geld: Betrag > 0 Pflicht
return (
<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">
<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">
<Input
label={amountLabel}
@@ -205,7 +201,7 @@ function CreditNoteFormModal({
onChange={(e) => set('amount', e.target.value)}
placeholder="0,00"
/>
{form.vatRelevant && (
{amountNum > 0 && form.vatRelevant && (
<>
<Select
label="Basis"
@@ -227,11 +223,28 @@ function CreditNoteFormModal({
)}
</div>
<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>
{nonMonetary ? (
<p className="text-sm text-gray-600">
Ohne Betrag: reine Sachwert-Übergabe <strong>keine Rechnung, keine USt</strong>. Es wird nur der Empfang des Gegenstands als Subvention dokumentiert.
</p>
) : (
<>
<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>
{form.type === 'GELD' && (
@@ -439,10 +452,12 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId
<Badge variant={cn.type === 'GELD' ? 'info' : 'default'}>
{cn.type === 'GELD' ? 'Geld' : 'Sachwert'}
</Badge>
{cn.vatRelevant ? (
<Badge variant="default">USt {cn.vatRate}%</Badge>
) : (
<Badge variant="default">ohne USt</Badge>
{!(cn.type === 'SACHWERT' && cn.amountGross === 0) && (
cn.vatRelevant ? (
<Badge variant="default">USt {cn.vatRate}%</Badge>
) : (
<Badge variant="default">ohne USt</Badge>
)
)}
<Badge variant="default">{cn.customerType === 'FIRMA' ? 'Firma' : 'Privat'}</Badge>
{cn.type === 'SACHWERT' && cn.goodsReceived && (
@@ -452,9 +467,15 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId
)}
</div>
<div className="text-sm text-gray-700 mt-1">
<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>
{cn.type === 'SACHWERT' && cn.amountGross === 0 ? (
<span className="italic text-gray-600">Sachwert ohne Betrag (keine Rechnung)</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>
{cn.place && <span className="text-gray-500"> · {cn.place}</span>}