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:
2026-08-06 13:49:35 +02:00
co-authored by Claude Opus 4.8
parent 642fb05c76
commit f24c4308aa
7 changed files with 151 additions and 10 deletions
+50 -5
View File
@@ -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).
+14 -2
View File
@@ -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 ----