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:
@@ -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');
|
||||
|
||||
@@ -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,8 +194,13 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
|
||||
doc.end();
|
||||
const baseBuffer = await done;
|
||||
|
||||
// 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 ----
|
||||
const xml = buildZugferdXml({
|
||||
xml = buildZugferdXml({
|
||||
number: cn.number,
|
||||
issueDate: new Date(cn.creditDate),
|
||||
currency: cn.currency,
|
||||
@@ -220,10 +233,11 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
|
||||
amountGross: cn.amountGross,
|
||||
});
|
||||
|
||||
const buffer = await embedZugferd(baseBuffer, xml, {
|
||||
buffer = await embedZugferd(baseBuffer, xml, {
|
||||
title: `Gutschrift ${cn.number}`,
|
||||
date: new Date(cn.creditDate),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Speichern ----
|
||||
const dir = path.join(process.cwd(), 'uploads', 'credit-notes');
|
||||
|
||||
@@ -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
|
||||
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 amountLabel = isSachwert
|
||||
? 'Wert (optional)'
|
||||
: form.vatRelevant
|
||||
? `Betrag (${form.amountBasis === 'NETTO' ? 'netto' : 'brutto'})`
|
||||
: 'Betrag';
|
||||
|
||||
const canSave =
|
||||
!!form.amount &&
|
||||
Number(form.amount) >= 0 &&
|
||||
(form.type !== 'SACHWERT' || form.sachwertDescription.trim().length > 0);
|
||||
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>
|
||||
|
||||
{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 ? (
|
||||
{!(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,10 +467,16 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 mt-1">
|
||||
{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>}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user