diff --git a/backend/src/services/creditNote.service.ts b/backend/src/services/creditNote.service.ts index fa2ee111..c5c7c976 100644 --- a/backend/src/services/creditNote.service.ts +++ b/backend/src/services/creditNote.service.ts @@ -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'); diff --git a/backend/src/services/creditNotePdf.service.ts b/backend/src/services/creditNotePdf.service.ts index 8d143d95..257f3bdc 100644 --- a/backend/src/services/creditNotePdf.service.ts +++ b/backend/src/services/creditNotePdf.service.ts @@ -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'); diff --git a/docs/todo.md b/docs/todo.md index aa738276..c6130446 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 diff --git a/frontend/src/components/contracts/CreditNotesSection.tsx b/frontend/src/components/contracts/CreditNotesSection.tsx index 86c3cfde..4881e816 100644 --- a/frontend/src/components/contracts/CreditNotesSection.tsx +++ b/frontend/src/components/contracts/CreditNotesSection.tsx @@ -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 ( @@ -184,18 +190,8 @@ function CreditNoteFormModal({ /> )} - {/* USt-Block */} + {/* Betrag + USt-Block */}
- -
set('amount', e.target.value)} placeholder="0,00" /> - {form.vatRelevant && ( + {amountNum > 0 && form.vatRelevant && ( <> 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) + +
+ Netto: {formatEuro(preview.net)} + USt: {formatEuro(preview.vat)} + Brutto: {formatEuro(preview.gross)} +
+ + )}
{form.type === 'GELD' && ( @@ -439,10 +452,12 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId {cn.type === 'GELD' ? 'Geld' : 'Sachwert'} - {cn.vatRelevant ? ( - USt {cn.vatRate}% - ) : ( - ohne USt + {!(cn.type === 'SACHWERT' && cn.amountGross === 0) && ( + cn.vatRelevant ? ( + USt {cn.vatRate}% + ) : ( + ohne USt + ) )} {cn.customerType === 'FIRMA' ? 'Firma' : 'Privat'} {cn.type === 'SACHWERT' && cn.goodsReceived && ( @@ -452,9 +467,15 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId )}
- {formatEuro(cn.amountGross, cn.currency)} - {cn.vatRelevant && ( - (netto {formatEuro(cn.amountNet, cn.currency)} + USt {formatEuro(cn.amountVat, cn.currency)}) + {cn.type === 'SACHWERT' && cn.amountGross === 0 ? ( + Sachwert ohne Betrag (keine Rechnung) + ) : ( + <> + {formatEuro(cn.amountGross, cn.currency)} + {cn.vatRelevant && ( + (netto {formatEuro(cn.amountNet, cn.currency)} + USt {formatEuro(cn.amountVat, cn.currency)}) + )} + )} · {formatDate(cn.creditDate)} {cn.place && · {cn.place}}