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');