Finanzmanager/public/static/qrimport.js
fds2610-bot 970e3e086e Fix n.n.: Überweisung erfassen – QR-Code (EPC-QR, BezahlCode) aus Bild einlesen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:30:00 +02:00

245 lines
9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* QR-Import im Dialog „Überweisung erfassen“.
*
* Eine Rechnung oder ein Screenshot wird auf den Bereich neben „Empfänger“
* gezogen (oder per Klick im Dialog aus der Zwischenablage eingefügt). Das
* Bild wird im Browser nach einem QR-Code durchsucht (jsQR, siehe jsQR.js);
* enthält er einen EPC-QR (GiroCode) oder einen BezahlCode, füllt das Skript
* die Felder der Überweisung. Das Bild verlässt den Browser nicht.
*/
(function () {
'use strict';
// ---- Formate -----------------------------------------------------------
function betragNormalisieren(text) {
// „260,61“, „1.234,56“, „260.61“ → „260,61“
let zahl = text.replace(/[^\d.,]/g, '');
if (zahl.indexOf(',') >= 0) {
zahl = zahl.replace(/\./g, '');
} else if (/\.\d{3}$/.test(zahl) && zahl.split('.').length > 2) {
zahl = zahl.replace(/\./g, '');
} else {
zahl = zahl.replace('.', ',');
}
return zahl;
}
function datumNormalisieren(text) {
// „02.10.2026“ oder „2026-10-02“ → „2026-10-02“ (Wert für <input type="date">)
let treffer = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text);
if (treffer) {
return treffer[3] + '-' + treffer[2] + '-' + treffer[1];
}
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
}
// EPC-QR: zeilenweise, Kennung „BCD“ (European Payments Council, EPC069-12).
function epcLesen(text) {
const zeilen = text.split(/\r?\n/);
if (zeilen[0].trim() !== 'BCD' || zeilen.length < 7) {
return null;
}
const betrag = /^[A-Z]{3}(\d+(?:\.\d{1,2})?)$/.exec((zeilen[7] || '').trim());
return {
empfaenger: (zeilen[5] || '').trim(),
iban: (zeilen[6] || '').replace(/\s+/g, ''),
bic: (zeilen[4] || '').trim(),
betrag: betrag ? betragNormalisieren(betrag[1]) : '',
// Zeile 10 = Referenz, Zeile 11 = Text; je eines von beiden ist gefüllt.
verwendungszweck: ((zeilen[10] || '') || (zeilen[9] || '')).trim(),
ausfuehrung_am: ''
};
}
// BezahlCode: URI „bank://singlepaymentsepa?name=…&iban=…“. Die ältere
// Variante „singlepayment“ trägt Kontonummer und BLZ statt IBAN und lässt
// sich in eine SEPA-Überweisung nicht verlässlich umrechnen.
function bezahlcodeLesen(text) {
const treffer = /^bank:\/\/([a-z]+)\?(.*)$/i.exec(text.trim());
if (!treffer || treffer[1].toLowerCase() !== 'singlepaymentsepa') {
return null;
}
const parameter = new URLSearchParams(treffer[2]);
const wert = function (name) {
return (parameter.get(name) || '').trim();
};
return {
empfaenger: wert('name'),
iban: wert('iban').replace(/\s+/g, ''),
bic: wert('bic'),
betrag: wert('amount') ? betragNormalisieren(wert('amount')) : '',
verwendungszweck: [wert('reason'), wert('reason1')].filter(Boolean).join(' '),
ausfuehrung_am: datumNormalisieren(wert('executiondate'))
};
}
function zahlungLesen(text) {
return epcLesen(text) || bezahlcodeLesen(text);
}
// ---- Bild → Text -------------------------------------------------------
function qrAusBild(bild) {
// Mehrere Maßstäbe: Ein kleiner Code in einem großen Screenshot wird
// oft erst beim Vergrößern erkannt, ein sehr feiner Code beim Verkleinern.
const faktoren = [1, 0.5, 2, 0.35];
for (let i = 0; i < faktoren.length; i += 1) {
const breite = Math.round(bild.width * faktoren[i]);
const hoehe = Math.round(bild.height * faktoren[i]);
if (breite < 40 || hoehe < 40 || breite * hoehe > 36e6) {
continue;
}
const leinwand = document.createElement('canvas');
leinwand.width = breite;
leinwand.height = hoehe;
const kontext = leinwand.getContext('2d', { willReadFrequently: true });
kontext.imageSmoothingEnabled = faktoren[i] < 1;
kontext.drawImage(bild, 0, 0, breite, hoehe);
const daten = kontext.getImageData(0, 0, breite, hoehe);
const code = window.jsQR(daten.data, breite, hoehe);
if (code && code.data) {
return code.data;
}
}
return null;
}
async function dateiAuslesen(datei) {
if (!datei.type.startsWith('image/')) {
throw new Error('Nur Bilder werden gelesen (PNG, JPG, WebP …) – bei einem PDF bitte einen Screenshot der Seite einfügen.');
}
let bild;
try {
bild = await createImageBitmap(datei);
} catch (fehler) {
throw new Error('Das Bild lässt sich nicht öffnen.');
}
const text = qrAusBild(bild);
bild.close();
if (text === null) {
throw new Error('Im Bild wurde kein QR-Code gefunden.');
}
const zahlung = zahlungLesen(text);
if (zahlung === null) {
throw new Error('Der QR-Code ist weder ein EPC-QR (GiroCode) noch ein BezahlCode für eine SEPA-Überweisung.');
}
return zahlung;
}
// ---- Oberfläche --------------------------------------------------------
const bereich = document.querySelector('[data-qrbereich]');
const dialog = document.getElementById('qrdialog');
if (!bereich || !dialog) {
return;
}
const meldung = document.querySelector('[data-qrmeldung]');
const dateiwahl = dialog.querySelector('[data-qrdatei]');
function melden(text, fehler) {
meldung.textContent = text;
meldung.hidden = text === '';
meldung.className = 'qrmeldung meldung ' + (fehler ? 'meldung-fehler' : 'meldung-erfolg');
}
function uebernehmen(zahlung) {
const gesetzt = [];
Object.keys(zahlung).forEach(function (name) {
const feld = document.getElementById(name);
if (feld && zahlung[name] !== '') {
feld.value = zahlung[name];
feld.dispatchEvent(new Event('change', { bubbles: true }));
gesetzt.push(name);
}
});
// Eine zuvor gewählte Adresse passt nach dem Import nicht mehr zum Namen.
const adresse = document.getElementById('adresse');
if (adresse && adresse.value !== '') {
adresse.value = '';
}
melden('QR-Code gelesen – bitte die Felder prüfen.', false);
}
async function verarbeiten(datei) {
if (dialog.open) {
dialog.close();
}
melden('Wird gelesen …', false);
try {
uebernehmen(await dateiAuslesen(datei));
} catch (fehler) {
melden(fehler.message, true);
}
}
function dateiAusListe(liste) {
for (let i = 0; i < liste.length; i += 1) {
const datei = liste[i].kind === undefined ? liste[i] : (liste[i].kind === 'file' ? liste[i].getAsFile() : null);
if (datei) {
return datei;
}
}
return null;
}
function oeffnen() {
if (!dialog.open) {
dialog.showModal();
}
}
bereich.addEventListener('click', oeffnen);
bereich.addEventListener('keydown', function (ereignis) {
if (ereignis.key === 'Enter' || ereignis.key === ' ') {
ereignis.preventDefault();
oeffnen();
}
});
['dragenter', 'dragover'].forEach(function (name) {
bereich.addEventListener(name, function (ereignis) {
ereignis.preventDefault();
bereich.classList.add('ueberfahren');
});
});
['dragleave', 'drop'].forEach(function (name) {
bereich.addEventListener(name, function () {
bereich.classList.remove('ueberfahren');
});
});
bereich.addEventListener('drop', function (ereignis) {
ereignis.preventDefault();
const datei = dateiAusListe(ereignis.dataTransfer.files);
if (datei) {
verarbeiten(datei);
}
});
// Einfügen: Strg+V im geöffneten Dialog.
dialog.addEventListener('paste', function (ereignis) {
const datei = dateiAusListe(ereignis.clipboardData.items);
if (datei) {
ereignis.preventDefault();
verarbeiten(datei);
}
});
// Der Dialog nimmt auch eine Datei per Ziehen oder Dateiauswahl.
dialog.addEventListener('dragover', function (ereignis) {
ereignis.preventDefault();
});
dialog.addEventListener('drop', function (ereignis) {
ereignis.preventDefault();
const datei = dateiAusListe(ereignis.dataTransfer.files);
if (datei) {
verarbeiten(datei);
}
});
dateiwahl.addEventListener('change', function () {
if (dateiwahl.files.length > 0) {
verarbeiten(dateiwahl.files[0]);
dateiwahl.value = '';
}
});
})();