Gutschriften: Auszahlungskonto (Bankkonto des Kunden) fuer Ueberweisungen
Bei Geld-Gutschriften kann die Auszahlung auf ein anderes Konto gehen als das Vertrags-Abbuchkonto: - CreditNote.payoutBankCardId + Migration (FK ON DELETE SET NULL, damit Loeschen einer Bankkarte die Gutschrift nicht mitreisst). - Formular: Dropdown mit allen Bankkonten des Kunden (Default = Vertrags-Abbuchkonto). getCreditNoteDefaults liefert bankCards + contractBankCardId. - Server prueft, dass die gewaehlte Bankkarte dem Kunden des Vertrags gehoert (kein Fremdkonto unterschieben). - PDF: bei Ueberweisung 'Unsere Bankverbindung' (Absender) + darunter 'an Bankkonto: <Kunden-IBAN> (<Inhaber>)'. Section-Zeile zeigt das Auszahlungskonto. Lokal verifiziert (Anlage + PDF). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
Reference in New Issue
Block a user