diff --git a/backend/prisma/migrations/20260806130000_credit_note_payout_bankcard/migration.sql b/backend/prisma/migrations/20260806130000_credit_note_payout_bankcard/migration.sql new file mode 100644 index 00000000..5f773473 --- /dev/null +++ b/backend/prisma/migrations/20260806130000_credit_note_payout_bankcard/migration.sql @@ -0,0 +1,20 @@ +-- Auszahlungskonto (Bankkarte des Kunden) fuer Geld-Gutschriften. +-- ON DELETE SET NULL: Loeschen der Bankkarte reisst die Gutschrift nicht mit. +ALTER TABLE `CreditNote` ADD COLUMN IF NOT EXISTS `payoutBankCardId` INT NULL; + +-- FK nur anlegen, wenn noch nicht vorhanden (idempotent via information_schema). +SET @fk := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote' + AND CONSTRAINT_NAME = 'CreditNote_payoutBankCardId_fkey'); +SET @sql := IF(@fk = 0, + 'ALTER TABLE `CreditNote` ADD CONSTRAINT `CreditNote_payoutBankCardId_fkey` FOREIGN KEY (`payoutBankCardId`) REFERENCES `BankCard`(`id`) ON DELETE SET NULL ON UPDATE CASCADE', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote' + AND INDEX_NAME = 'CreditNote_payoutBankCardId_idx'); +SET @sql2 := IF(@idx = 0, + 'CREATE INDEX `CreditNote_payoutBankCardId_idx` ON `CreditNote`(`payoutBankCardId`)', + 'SELECT 1'); +PREPARE stmt2 FROM @sql2; EXECUTE stmt2; DEALLOCATE PREPARE stmt2; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a1b41428..dc35891a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -360,6 +360,7 @@ model BankCard { description String? @db.Text // Freitext-Notiz (z.B. "Geschäftskonto", "gemeinsames Konto mit Partner") isActive Boolean @default(true) contracts Contract[] + creditNotePayouts CreditNote[] @relation("CreditNotePayoutBankCard") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -1008,6 +1009,13 @@ model CreditNote { // Sachwert: Bestätigung "Ware erhalten". goodsReceived Boolean @default(false) + // Auszahlungskonto (nur GELD): Bankkarte des Kunden, auf die überwiesen + // wird. Kann ein anderes Konto sein als das Abbuchkonto des Vertrags. + // ON DELETE SET NULL, damit das Löschen einer Bankkarte die Gutschrift + // (historischer Beleg) nicht mitreißt. + payoutBankCardId Int? + payoutBankCard BankCard? @relation("CreditNotePayoutBankCard", fields: [payoutBankCardId], references: [id], onDelete: SetNull) + // Überweisungsbeleg (nur GELD) – hochgeladene Datei. receiptPath String? // Generiertes Gutschrift-Dokument (PDF bzw. ZUGFeRD-Hybrid) – Phase 3. diff --git a/backend/src/services/creditNote.service.ts b/backend/src/services/creditNote.service.ts index 3049047f..d5803b96 100644 --- a/backend/src/services/creditNote.service.ts +++ b/backend/src/services/creditNote.service.ts @@ -60,6 +60,7 @@ export interface CreateCreditNoteInput { place?: string | null; signedAt?: string | null; goodsReceived?: boolean; + payoutBankCardId?: number | null; // nur GELD: Auszahlungskonto des Kunden notes?: string | null; } @@ -104,6 +105,16 @@ function validateAndNormalize(input: CreateCreditNoteInput) { const amounts = computeAmounts({ amount, vatRelevant, amountBasis, vatRate }); + // Auszahlungskonto nur bei GELD relevant; bei Sachwert immer leeren. + let payoutBankCardId: number | null = null; + if (type === 'GELD' && input.payoutBankCardId != null && input.payoutBankCardId !== ('' as unknown)) { + const parsed = Number(input.payoutBankCardId); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new ApiError(400, 'Ungültiges Auszahlungskonto'); + } + payoutBankCardId = parsed; + } + return { type, sachwertDescription: type === 'SACHWERT' ? input.sachwertDescription!.trim() : null, @@ -117,10 +128,23 @@ function validateAndNormalize(input: CreateCreditNoteInput) { place: input.place?.trim() || null, signedAt, goodsReceived: !!input.goodsReceived, + payoutBankCardId, notes: input.notes?.trim() || null, }; } +// Stellt sicher, dass die gewählte Bankkarte dem Kunden des Vertrags gehört +// (kein Fremdkonto unterschieben). +async function assertBankCardBelongsToContract(contractId: number, bankCardId: number) { + const [contract, card] = await Promise.all([ + prisma.contract.findUnique({ where: { id: contractId }, select: { customerId: true } }), + prisma.bankCard.findUnique({ where: { id: bankCardId }, select: { customerId: true } }), + ]); + if (!card || !contract || card.customerId !== contract.customerId) { + throw new ApiError(400, 'Das gewählte Auszahlungskonto gehört nicht zum Kunden dieses Vertrags.'); + } +} + export async function getCreditNotesByContract(contractId: number) { return prisma.creditNote.findMany({ where: { contractId }, @@ -139,18 +163,33 @@ export async function getCreditNoteById(id: number) { // WICHTIG: Das ist nur die Vorbelegung. Jede angelegte Gutschrift speichert // ihren eigenen Snapshot; ein späterer Statuswechsel des Kunden ändert // bestehende Gutschriften nicht. -export async function getCreditNoteDefaults( - contractId: number, -): Promise<{ customerType: CreditNoteCustomerType; vatRelevant: boolean }> { +export async function getCreditNoteDefaults(contractId: number) { const contract = await prisma.contract.findUnique({ where: { id: contractId }, - select: { customer: { select: { type: true, vatExempt: true } } }, + select: { + bankCardId: true, + customer: { + select: { + type: true, + vatExempt: true, + bankCards: { + where: { isActive: true }, + select: { id: true, iban: true, accountHolder: true, bankName: true, description: true }, + orderBy: { createdAt: 'asc' }, + }, + }, + }, + }, }); const isBusiness = contract?.customer?.type === 'BUSINESS'; const vatExempt = !!contract?.customer?.vatExempt; return { - customerType: isBusiness ? 'FIRMA' : 'PRIVAT', + customerType: (isBusiness ? 'FIRMA' : 'PRIVAT') as CreditNoteCustomerType, vatRelevant: isBusiness && !vatExempt, + // Bankkarten des Kunden für das Auszahlungskonto-Dropdown; die + // Vertrags-Abbuchkarte als Default-Vorschlag markiert. + bankCards: contract?.customer?.bankCards ?? [], + contractBankCardId: contract?.bankCardId ?? null, }; } @@ -165,6 +204,9 @@ export async function createCreditNote( } const normalized = validateAndNormalize(input); + if (normalized.payoutBankCardId) { + await assertBankCardBelongsToContract(contractId, normalized.payoutBankCardId); + } const number = await assignNextNumber(); return prisma.creditNote.create({ @@ -183,6 +225,9 @@ export async function updateCreditNote(id: number, input: CreateCreditNoteInput) throw new ApiError(404, 'Gutschrift nicht gefunden'); } const normalized = validateAndNormalize(input); + if (normalized.payoutBankCardId) { + await assertBankCardBelongsToContract(existing.contractId, normalized.payoutBankCardId); + } // Nummer bleibt unverändert (einmal vergeben = fix). Ein evtl. schon // erzeugtes PDF ist nach inhaltlicher Änderung veraltet → Pfad leeren // (der User erzeugt es bei Bedarf neu). diff --git a/backend/src/services/creditNotePdf.service.ts b/backend/src/services/creditNotePdf.service.ts index ae96a52f..cbb0deb5 100644 --- a/backend/src/services/creditNotePdf.service.ts +++ b/backend/src/services/creditNotePdf.service.ts @@ -33,6 +33,7 @@ async function loadData(creditNoteId: number) { address: true, }, }, + payoutBankCard: { select: { iban: true, accountHolder: true, bankName: true } }, }, }); if (!cn) throw new ApiError(404, 'Gutschrift nicht gefunden'); @@ -121,8 +122,19 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf doc.fontSize(10); if (cn.type === 'SACHWERT') { doc.text(`Ware erhalten: ${cn.goodsReceived ? 'Ja' : 'Nein'}`); - } else if (company.iban) { - doc.text(`Auszahlung per Überweisung. Bankverbindung: ${company.bankName} · IBAN ${company.iban} · BIC ${company.bic}`.trim()); + } else { + doc.text('Auszahlung per Überweisung.'); + if (company.iban) { + doc.fontSize(9).fillColor('#333') + .text(`Unsere Bankverbindung: ${[company.bankName, `IBAN ${company.iban}`, company.bic ? `BIC ${company.bic}` : ''].filter(Boolean).join(' · ')}`); + } + // Auszahlungskonto des Kunden (kann vom Abbuchkonto abweichen). + if (cn.payoutBankCard) { + const holder = cn.payoutBankCard.accountHolder ? ` (${cn.payoutBankCard.accountHolder})` : ''; + doc.fontSize(9).fillColor('#333') + .text(`an Bankkonto: ${cn.payoutBankCard.iban}${holder}${cn.payoutBankCard.bankName ? ` · ${cn.payoutBankCard.bankName}` : ''}`); + } + doc.fillColor('#000'); } // ---- Unterschriftsblock ---- diff --git a/docs/todo.md b/docs/todo.md index ae453186..f9ff99ee 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -137,6 +137,13 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung `POST /credit-notes/:id/pdf`, „PDF"-Button in der Section (Ansehen/Erzeugen). PDF-Pfad wird bei inhaltlicher Änderung geleert (Neu-Erzeugung). Lokal verifiziert (valides PDF). + - **Auszahlungskonto (nur GELD):** `CreditNote.payoutBankCardId` + + Migration (FK ON DELETE SET NULL). Dropdown im Formular mit ALLEN + Bankkonten des Kunden (Default = Vertrags-Abbuchkonto), da die Auszahlung + auf ein anderes Konto gehen kann. Server prüft, dass die Bankkarte dem + Kunden des Vertrags gehört. PDF zeigt bei Überweisung „Unsere + Bankverbindung" + „an Bankkonto: ". Section-Zeile zeigt das + Auszahlungskonto. - **Offen:** Phase 3b Teil 2 (ZUGFeRD-XML EN 16931, Typ 381, in PDF/A-3 einbetten – **muss gegen ZUGFeRD-Validator** geprüft werden). USt- Einordnung (Vermittlung vs. Abschlussbonus) mit Steuerberater klären – diff --git a/frontend/src/components/contracts/CreditNotesSection.tsx b/frontend/src/components/contracts/CreditNotesSection.tsx index 26fb6284..e18f4c67 100644 --- a/frontend/src/components/contracts/CreditNotesSection.tsx +++ b/frontend/src/components/contracts/CreditNotesSection.tsx @@ -10,7 +10,7 @@ import Input from '../ui/Input'; import Select from '../ui/Select'; import Badge from '../ui/Badge'; import { creditNoteApi } from '../../services/api'; -import type { CreditNote, CreditNoteType, CreditNoteCustomerType, CreditNoteAmountBasis } from '../../types'; +import type { CreditNote, CreditNoteType, CreditNoteCustomerType, CreditNoteAmountBasis, CreditNotePayoutBankCard } from '../../types'; const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100; @@ -35,6 +35,13 @@ function calcAmounts(amount: number, vatRelevant: boolean, basis: CreditNoteAmou const todayIso = () => new Date().toISOString().split('T')[0]; +interface FormDefaults { + customerType: CreditNoteCustomerType; + vatRelevant: boolean; + bankCards: CreditNotePayoutBankCard[]; + contractBankCardId: number | null; +} + interface FormState { type: CreditNoteType; sachwertDescription: string; @@ -47,10 +54,11 @@ interface FormState { place: string; signedAt: string; goodsReceived: boolean; + payoutBankCardId: string; notes: string; } -function emptyForm(defaults?: { customerType: CreditNoteCustomerType; vatRelevant: boolean }): FormState { +function emptyForm(defaults?: FormDefaults): FormState { return { type: 'GELD', sachwertDescription: '', @@ -63,6 +71,8 @@ function emptyForm(defaults?: { customerType: CreditNoteCustomerType; vatRelevan place: '', signedAt: '', goodsReceived: false, + // Vorschlag: das Abbuchkonto des Vertrags (kann umgestellt werden). + payoutBankCardId: defaults?.contractBankCardId ? String(defaults.contractBankCardId) : '', notes: '', }; } @@ -81,6 +91,7 @@ function formFromCreditNote(cn: CreditNote): FormState { place: cn.place ?? '', signedAt: cn.signedAt ? cn.signedAt.split('T')[0] : '', goodsReceived: cn.goodsReceived, + payoutBankCardId: cn.payoutBankCardId ? String(cn.payoutBankCardId) : '', notes: cn.notes ?? '', }; } @@ -95,11 +106,12 @@ function CreditNoteFormModal({ }: { contractId: number; editing: CreditNote | null; - defaults?: { customerType: CreditNoteCustomerType; vatRelevant: boolean }; + defaults?: FormDefaults; onClose: () => void; onSaved: () => void; }) { const [form, setForm] = useState(editing ? formFromCreditNote(editing) : emptyForm(defaults)); + const bankCards = defaults?.bankCards ?? []; const set = (key: K, value: FormState[K]) => setForm((f) => ({ ...f, [key]: value })); const preview = calcAmounts(parseFloat(form.amount), form.vatRelevant, form.amountBasis, parseFloat(form.vatRate) || 0); @@ -118,6 +130,7 @@ function CreditNoteFormModal({ place: form.place || null, signedAt: form.signedAt || null, goodsReceived: form.type === 'SACHWERT' ? form.goodsReceived : false, + payoutBankCardId: form.type === 'GELD' && form.payoutBankCardId ? Number(form.payoutBankCardId) : null, notes: form.notes || null, }; return editing ? creditNoteApi.update(editing.id, payload) : creditNoteApi.create(contractId, payload); @@ -221,6 +234,25 @@ function CreditNoteFormModal({ + {form.type === 'GELD' && ( + bankCards.length > 0 ? ( + {cn.sachwertDescription} )} {cn.notes &&
{cn.notes}
} + {cn.type === 'GELD' && cn.payoutBankCardId && (() => { + const card = defaults?.bankCards.find((bc) => bc.id === cn.payoutBankCardId); + return card ? ( +
an Bankkonto: {card.iban} ({card.accountHolder})
+ ) : null; + })()} {cn.type === 'GELD' && (
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a939c510..98eb4743 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -81,6 +81,7 @@ export interface CreditNote { place?: string | null; signedAt?: string | null; goodsReceived: boolean; + payoutBankCardId?: number | null; receiptPath?: string | null; pdfPath?: string | null; notes?: string | null; @@ -89,10 +90,20 @@ export interface CreditNote { updatedAt: string; } +export interface CreditNotePayoutBankCard { + id: number; + iban: string; + accountHolder: string; + bankName?: string | null; + description?: string | null; +} + export interface CreditNoteDefaults { customerType: CreditNoteCustomerType; vatRelevant: boolean; nextNumber: string; + bankCards: CreditNotePayoutBankCard[]; + contractBankCardId: number | null; } export interface CreditNoteNumberRange {