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
@@ -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<FormState>(editing ? formFromCreditNote(editing) : emptyForm(defaults));
const bankCards = defaults?.bankCards ?? [];
const set = <K extends keyof FormState>(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({
</div>
</div>
{form.type === 'GELD' && (
bankCards.length > 0 ? (
<Select
label="Auszahlungskonto (Bankkonto des Kunden)"
value={form.payoutBankCardId}
onChange={(e) => set('payoutBankCardId', e.target.value)}
options={bankCards.map((bc) => ({
value: String(bc.id),
label: `${bc.iban} (${bc.accountHolder})${bc.description ? ` ${bc.description}` : ''}`,
}))}
placeholder="Kein Konto angeben"
/>
) : (
<p className="text-xs text-gray-500">
Für diesen Kunden sind keine Bankkonten hinterlegt Auszahlungskonto kann daher nicht gewählt werden.
</p>
)
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Datum der Gutschrift"
@@ -431,6 +463,12 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId
<div className="text-sm text-gray-600 mt-0.5">{cn.sachwertDescription}</div>
)}
{cn.notes && <div className="text-xs text-gray-500 mt-0.5 italic">{cn.notes}</div>}
{cn.type === 'GELD' && cn.payoutBankCardId && (() => {
const card = defaults?.bankCards.find((bc) => bc.id === cn.payoutBankCardId);
return card ? (
<div className="text-xs text-gray-500 mt-0.5">an Bankkonto: <span className="font-mono">{card.iban}</span> ({card.accountHolder})</div>
) : null;
})()}
{cn.type === 'GELD' && (
<div className="mt-1">
<ReceiptControls cn={cn} canEdit={canEdit} onChanged={refresh} />
+11
View File
@@ -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 {