Bankkarte: IBAN pruefen + BIC/Bank offline aus Bundesbank-BLZ ausfuellen

Neuer Button "BIC & Bank aus IBAN abrufen" im Bankkarten-Modal fuellt BIC +
Banknamen automatisch und validiert dabei die IBAN-Pruefziffer (mod-97).
Leeres IBAN-Feld -> OK-Messagebox statt Anfrage.

Datenschutzfreundlich/offline: kein Dritt-Dienst. Nachschlag im eigenen
Backend ueber die Bundesbank-Bankleitzahlendatei (bankdata-germany) +
ibantools fuer die Pruefziffer. Die IBAN verlaesst nie den Server; zurueck
kommen nur oeffentliche Bankverzeichnis-Daten.

Endpoint: POST /api/bank-cards/iban-lookup (nur eingeloggt).
Wartung: bankdata-germany/ibantools ~quartalsweise per npm update ziehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 12:32:53 +02:00
co-authored by Claude Opus 4.8
parent aff3f7e84a
commit 73bd72bd9f
7 changed files with 196 additions and 6 deletions
+17
View File
@@ -12,6 +12,7 @@
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"bankdata-germany": "^2.2602.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -21,6 +22,7 @@
"express-rate-limit": "^8.4.0", "express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0", "express-validator": "^7.2.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ibantools": "^4.5.4",
"imapflow": "^1.2.8", "imapflow": "^1.2.8",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.3", "mailparser": "^3.9.3",
@@ -984,6 +986,15 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
}, },
"node_modules/bankdata-germany": {
"version": "2.2602.0",
"resolved": "https://registry.npmjs.org/bankdata-germany/-/bankdata-germany-2.2602.0.tgz",
"integrity": "sha512-3iBD/cIKHZsmAaQIYEMsORhvMjILDTJ4gDQJYNVm+YXCzA5U96mUoB7KBy01rtwpFt/rKrNuCVNc8KZVx+jwpQ==",
"license": "MIT OR MPL-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/bare-events": { "node_modules/bare-events": {
"version": "2.8.2", "version": "2.8.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
@@ -1992,6 +2003,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/ibantools": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/ibantools/-/ibantools-4.5.4.tgz",
"integrity": "sha512-6jX1gh4aH6XH+o0ey+wtkMTzkcvsEta7DakIOZSng9voZYpMw3U+gK1+tZChk3aRcPcloEt0NOzksjaRZiqXbw==",
"license": "MIT or MPL-2.0"
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+2
View File
@@ -24,6 +24,7 @@
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"bankdata-germany": "^2.2602.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -33,6 +34,7 @@
"express-rate-limit": "^8.4.0", "express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0", "express-validator": "^7.2.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ibantools": "^4.5.4",
"imapflow": "^1.2.8", "imapflow": "^1.2.8",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.3", "mailparser": "^3.9.3",
@@ -0,0 +1,54 @@
import { Response } from 'express';
import { isValidIBAN } from 'ibantools';
import { bankDataByIBAN } from 'bankdata-germany';
import { ApiResponse, AuthRequest } from '../types/index.js';
/**
* IBAN-Lookup: prüft die Prüfziffer (mod-97, offline über ibantools) und liefert
* für deutsche IBANs BIC und Banknamen aus der Bundesbank-Bankleitzahlen-
* datei (offline über bankdata-germany). Die IBAN verlässt NICHT den Server; es
* findet KEIN externer Request statt. Zurückgegeben werden nur öffentliche
* Bankverzeichnis-Daten (BIC/Name), keine kundenbezogenen Informationen.
*
* Antwortformen (immer HTTP 200, sofern eine IBAN übergeben wurde):
* { valid: false } → Prüfziffer falsch (Tippfehler)
* { valid: true, bic, bankName, blz } → gültig + DE-Bank gefunden
* { valid: true, bic: null, bankName: null } → gültig, aber keine DE-Bankdaten
* (Nicht-DE-IBAN oder unbekannte BLZ)
*/
export async function lookupIban(req: AuthRequest, res: Response): Promise<void> {
try {
const raw = typeof req.body?.iban === 'string' ? req.body.iban : '';
const iban = raw.replace(/\s+/g, '').toUpperCase();
if (!iban) {
res.status(400).json({ success: false, error: 'Feld IBAN ist erforderlich.' } as ApiResponse);
return;
}
if (!isValidIBAN(iban)) {
res.json({ success: true, data: { valid: false, iban } } as ApiResponse);
return;
}
const country = iban.slice(0, 2);
const bank = country === 'DE' ? bankDataByIBAN(iban) : null;
res.json({
success: true,
data: {
valid: true,
iban,
country,
bic: bank?.bic ?? null,
bankName: bank?.bankName ?? null,
blz: bank?.blz ?? null,
},
} as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim IBAN-Nachschlagen',
} as ApiResponse);
}
}
+5
View File
@@ -1,9 +1,14 @@
import { Router } from 'express'; import { Router } from 'express';
import * as customerController from '../controllers/customer.controller.js'; import * as customerController from '../controllers/customer.controller.js';
import * as bankLookupController from '../controllers/bankLookup.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js'; import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router(); const router = Router();
// IBAN-Prüfung + BIC/Bank-Nachschlag (offline, Bundesbank-BLZ). Liefert nur
// öffentliche Bankverzeichnis-Daten reicht daher ein eingeloggter Nutzer.
router.post('/iban-lookup', authenticate, bankLookupController.lookupIban);
router.put('/:id', authenticate, requirePermission('customers:update'), customerController.updateBankCard); router.put('/:id', authenticate, requirePermission('customers:update'), customerController.updateBankCard);
router.delete('/:id', authenticate, requirePermission('customers:delete'), customerController.deleteBankCard); router.delete('/:id', authenticate, requirePermission('customers:delete'), customerController.deleteBankCard);
+22
View File
@@ -97,6 +97,28 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **🏦 Bankkarte-Modal: IBAN-Prüfung + BIC/Bank-Auto-Ausfüllen** (2026-08-12)
- Button „BIC & Bank aus IBAN abrufen" unter dem IBAN-Feld (Bankkarte
hinzufügen/bearbeiten). Füllt BIC + Banknamen automatisch aus und prüft
dabei die **IBAN-Prüfziffer (mod-97)** → erkennt Tippfehler.
- **Offline / datenschutzfreundlich:** kein Dritt-Dienst. Nachschlag im
eigenen Backend über die **Bundesbank-Bankleitzahlendatei**
(`bankdata-germany`) + `ibantools` für die Prüfziffer. Die IBAN verlässt
nie den Server; zurück kommen nur öffentliche Bankverzeichnis-Daten.
- Endpoint `POST /api/bank-cards/iban-lookup` (nur eingeloggt). Antwort:
`{valid:false}` (Prüfziffer falsch) · `{valid:true, bic, bankName, blz}`
(DE-Bank gefunden) · `{valid:true, bic:null,…}` (gültig, aber Nicht-DE/
unbekannte BLZ → Info-Toast).
- Leeres IBAN-Feld → **OK-Messagebox** „IBAN erforderlich" statt Anfrage.
- **Wartung:** `bankdata-germany` + `ibantools` müssen ~quartalsweise per
`npm update` aktualisiert werden (Bundesbank-BLZ ändert sich mehrmals/Jahr).
- [x] **📌 Update-Banner klebt oben fest (sticky)** (2026-08-12)
- „Neue Version verfügbar"-Banner scrollte weg; jetzt `sticky` an der
Viewport-Oberkante. Banner meldet seine Höhe als CSS-Var `--app-banner-h`,
an der die Sticky-Header von ContractDetail/ContractForm einrasten
(`top-[var(--app-banner-h,0px)]`) → keine Überdeckung.
- [x] **📋 Hauptmenü: Gutschriften/Lieferscheine-Gesamtübersicht** (2026-08-12) - [x] **📋 Hauptmenü: Gutschriften/Lieferscheine-Gesamtübersicht** (2026-08-12)
- Neuer Menüpunkt „Gutschriften" (Sidebar, `show: contracts:read`) → Seite - Neuer Menüpunkt „Gutschriften" (Sidebar, `show: contracts:read`) → Seite
`/credit-notes` mit Tabelle aller Belege (Beleg-Nr., Art, Kunde, Vertrag, `/credit-notes` mit Tabelle aller Belege (Beleg-Nr., Art, Kunde, Vertrag,
@@ -2692,12 +2692,54 @@ function BankCardModal({
}); });
const [formData, setFormData] = useState(getInitialFormData); const [formData, setFormData] = useState(getInitialFormData);
const [ibanLookupLoading, setIbanLookupLoading] = useState(false);
const [showIbanRequired, setShowIbanRequired] = useState(false);
// Reset form when bankCard changes // Reset form when bankCard changes
useState(() => { useState(() => {
setFormData(getInitialFormData()); setFormData(getInitialFormData());
}); });
// BIC + Bankname aus der IBAN ableiten (offline über Bundesbank-BLZ im
// Backend). Ist das IBAN-Feld leer, kommt eine OK-Messagebox statt einer
// Anfrage. Gleichzeitig prüft der Server die IBAN-Prüfziffer (Tippfehler).
const handleIbanLookup = async () => {
const iban = formData.iban.replace(/\s+/g, '').trim();
if (!iban) {
setShowIbanRequired(true);
return;
}
setIbanLookupLoading(true);
try {
const res = await bankCardApi.ibanLookup(iban);
const d = res.data;
if (!d) {
toast.error('IBAN-Prüfung fehlgeschlagen.');
return;
}
if (!d.valid) {
toast.error('Die IBAN ist ungültig bitte Eingabe/Prüfziffer kontrollieren.');
return;
}
if (d.bic || d.bankName) {
setFormData((prev) => ({
...prev,
bic: d.bic || prev.bic,
bankName: d.bankName || prev.bankName,
}));
toast.success('IBAN gültig BIC und Bank übernommen.');
} else {
toast('IBAN ist gültig, aber es wurden keine Bankdaten gefunden (nur deutsche IBANs werden aufgelöst).', {
icon: '️',
});
}
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Fehler beim IBAN-Nachschlagen.');
} finally {
setIbanLookupLoading(false);
}
};
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: any) => bankCardApi.create(customerId, data), mutationFn: (data: any) => bankCardApi.create(customerId, data),
onSuccess: () => { onSuccess: () => {
@@ -2738,6 +2780,7 @@ function BankCardModal({
}, [bankCard?.id]); }, [bankCard?.id]);
return ( return (
<>
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Bankkarte bearbeiten' : 'Bankkarte hinzufügen'}> <Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Bankkarte bearbeiten' : 'Bankkarte hinzufügen'}>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<Input <Input
@@ -2747,12 +2790,30 @@ function BankCardModal({
required required
/> />
<div>
<Input <Input
label="IBAN" label="IBAN"
value={formData.iban} value={formData.iban}
onChange={(e) => setFormData({ ...formData, iban: e.target.value })} onChange={(e) => setFormData({ ...formData, iban: e.target.value })}
required required
/> />
<div className="flex justify-end mt-1.5">
<button
type="button"
onClick={handleIbanLookup}
disabled={ibanLookupLoading}
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 hover:text-blue-800 disabled:opacity-50 disabled:cursor-not-allowed"
title="Prüft die IBAN und füllt BIC + Bank aus (offline, Bundesbank-Bankleitzahlen)"
>
{ibanLookupLoading ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
BIC &amp; Bank aus IBAN abrufen
</button>
</div>
</div>
<Input <Input
label="BIC" label="BIC"
@@ -2813,6 +2874,20 @@ function BankCardModal({
</div> </div>
</form> </form>
</Modal> </Modal>
{showIbanRequired && (
<Modal isOpen onClose={() => setShowIbanRequired(false)} title="IBAN erforderlich" size="sm">
<div className="space-y-4">
<p className="text-sm text-gray-700">
Bitte trage zuerst eine IBAN im Feld IBAN ein. BIC und Bank werden daraus ermittelt.
</p>
<div className="flex justify-end">
<Button onClick={() => setShowIbanRequired(false)}>OK</Button>
</div>
</div>
</Modal>
)}
</>
); );
} }
+15
View File
@@ -377,8 +377,23 @@ export const bankCardApi = {
const res = await api.delete<ApiResponse<void>>(`/bank-cards/${id}`); const res = await api.delete<ApiResponse<void>>(`/bank-cards/${id}`);
return res.data; return res.data;
}, },
// IBAN-Prüfung + BIC/Bank-Nachschlag (offline, Bundesbank-BLZ) die IBAN
// verlässt nur unser eigenes Backend, kein Dritt-Dienst.
ibanLookup: async (iban: string) => {
const res = await api.post<ApiResponse<IbanLookupResult>>(`/bank-cards/iban-lookup`, { iban });
return res.data;
},
}; };
export interface IbanLookupResult {
valid: boolean;
iban: string;
country?: string;
bic?: string | null;
bankName?: string | null;
blz?: string | null;
}
// Identity Documents // Identity Documents
export const documentApi = { export const documentApi = {
getByCustomer: async (customerId: number, showInactive = false) => { getByCustomer: async (customerId: number, showInactive = false) => {