diff --git a/public/static/js/app.js b/public/static/js/app.js
index cb668b8..6cd6f03 100644
--- a/public/static/js/app.js
+++ b/public/static/js/app.js
@@ -524,17 +524,18 @@ document.addEventListener('input', function(e) {
if (!inp.matches('#vorlage-modal .vorlage-netto, #vorlage-modal .vorlage-brutto')) return;
const row = inp.closest('.vorlage-ust-row');
if (!row) return;
+ const pDec = v => parseFloat(String(v).replace(',', '.')) || 0;
const satz = parseFloat(row.dataset.ustSatz) / 100;
const netto = row.querySelector('.vorlage-netto');
const brutto = row.querySelector('.vorlage-brutto');
const ust = row.querySelector('.vorlage-ust');
const ustAnzeige = row.querySelector('.vorlage-ust-anzeige');
if (inp.classList.contains('vorlage-netto')) {
- const n = parseFloat(netto.value) || 0;
+ const n = pDec(netto.value);
brutto.value = (n * (1 + satz)).toFixed(2);
ust.value = (n * satz).toFixed(2);
} else {
- const b = parseFloat(brutto.value) || 0;
+ const b = pDec(brutto.value);
const n = b / (1 + satz);
netto.value = n.toFixed(2);
ust.value = (b - n).toFixed(2);
@@ -547,7 +548,7 @@ function vorlageUpdateBruttoGesamt() {
const modal = document.getElementById('vorlage-modal');
if (!modal) return;
const bruttoFelder = modal.querySelectorAll('.vorlage-brutto');
- const summe = Array.from(bruttoFelder).reduce((s, el) => s + (parseFloat(el.value) || 0), 0);
+ const summe = Array.from(bruttoFelder).reduce((s, el) => s + (parseFloat(String(el.value).replace(',', '.')) || 0), 0);
const anzeige = modal.querySelector('[data-feldname="brutto_gesamt"]');
if (anzeige) anzeige.textContent = summe.toFixed(2).replace('.', ',') + ' €';
}
diff --git a/src/Controllers/BankController.php b/src/Controllers/BankController.php
index 4cd9185..d04235d 100644
--- a/src/Controllers/BankController.php
+++ b/src/Controllers/BankController.php
@@ -211,7 +211,12 @@ class BankController extends BaseController
View::redirect('/bank/upload', 'Import fehlgeschlagen: ' . $e->getMessage(), 'error');
}
- View::redirect('/bank', count($rows) . ' Buchungen importiert.');
+ $paired = \Steuerkram\Services\PayPalNvpFetchService::applyBankImportRules($batchId);
+ $msg = count($rows) . ' Buchungen importiert.';
+ if ($paired > 0) {
+ $msg .= ' ' . $paired . ' PayPal-Gegenbuchung(en) automatisch ignoriert.';
+ }
+ View::redirect('/bank', $msg);
}
public function show(array $params): void
diff --git a/src/Services/PayPalNvpFetchService.php b/src/Services/PayPalNvpFetchService.php
index a5a180a..06c09ef 100644
--- a/src/Services/PayPalNvpFetchService.php
+++ b/src/Services/PayPalNvpFetchService.php
@@ -115,6 +115,144 @@ class PayPalNvpFetchService
$this->updateLastFetched();
($this->log)(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip));
+
+ ($this->log)('');
+ ($this->log)('Automatische Regeln werden angewendet…');
+ $this->applyAutoRules();
+ }
+
+ /**
+ * Wird nach einem Bank-CSV-Import aufgerufen.
+ * Findet Abbuchungen mit „paypal" im Verwendungszweck und setzt sie + den
+ * passenden PayPal-Transfer auf „ignoriert", wenn das Paar existiert.
+ */
+ public static function applyBankImportRules(int $batchId): int
+ {
+ $debits = Database::fetchAll(
+ "SELECT bt.id, bt.buchungstag, bt.wert
+ FROM bank_transactions bt
+ WHERE bt.batch_id = ?
+ AND bt.wert < 0
+ AND LOWER(bt.buchungstext) LIKE '%paypal%'
+ AND bt.status = 'offen'",
+ [$batchId]
+ );
+
+ $paired = 0;
+ foreach ($debits as $debit) {
+ $dateFrom = date('Y-m-d', strtotime($debit['buchungstag'] . ' -7 days'));
+ $dateTo = date('Y-m-d', strtotime($debit['buchungstag'] . ' +2 days'));
+ $amount = abs((float)$debit['wert']);
+
+ // Passenden PayPal-Transfer suchen (positiver Betrag gleicher Höhe)
+ $transfer = Database::fetchOne(
+ "SELECT bt.id FROM bank_transactions bt
+ JOIN bank_import_batches bib ON bt.batch_id = bib.id
+ JOIN bank_sources bs ON bib.source_id = bs.id
+ WHERE bs.type = 'api'
+ AND bt.vorgang = 'Transfer'
+ AND bt.buchungstext = 'Bank Account (direct debit)'
+ AND bt.wert = ?
+ AND bt.buchungstag BETWEEN ? AND ?
+ AND bt.status != 'ignoriert'
+ LIMIT 1",
+ [$amount, $dateFrom, $dateTo]
+ );
+
+ if (!$transfer) {
+ continue;
+ }
+
+ Database::execute(
+ "UPDATE bank_transactions SET status = 'ignoriert' WHERE id = ?",
+ [$debit['id']]
+ );
+ Database::execute(
+ "UPDATE bank_transactions SET status = 'ignoriert' WHERE id = ?",
+ [$transfer['id']]
+ );
+ $paired++;
+ }
+
+ return $paired;
+ }
+
+ private function applyAutoRules(): void
+ {
+ // Regel 1: Authorization → ignoriert
+ $auths = Database::fetchAll(
+ "SELECT id FROM bank_transactions bt
+ JOIN bank_import_batches bib ON bt.batch_id = bib.id
+ WHERE bib.source_id = ? AND bt.vorgang = 'Authorization' AND bt.status = 'offen'",
+ [$this->sourceId]
+ );
+ foreach ($auths as $row) {
+ Database::execute(
+ "UPDATE bank_transactions SET status = 'ignoriert' WHERE id = ?",
+ [$row['id']]
+ );
+ }
+ if ($auths) {
+ ($this->log)(count($auths) . ' Authorization-Buchung(en) auf „ignoriert" gesetzt.');
+ }
+
+ // Regel 2: Transfer „Bank Account (direct debit)" + passende Bank-Gegenbuchung
+ // Suche alle noch offenen Transfers dieser PayPal-Quelle
+ $transfers = Database::fetchAll(
+ "SELECT bt.id, bt.buchungstag, bt.wert
+ FROM bank_transactions bt
+ JOIN bank_import_batches bib ON bt.batch_id = bib.id
+ WHERE bib.source_id = ?
+ AND bt.vorgang = 'Transfer'
+ AND bt.buchungstext = 'Bank Account (direct debit)'
+ AND bt.wert > 0
+ AND bt.status = 'offen'",
+ [$this->sourceId]
+ );
+
+ $pairedCount = 0;
+ foreach ($transfers as $transfer) {
+ // Passende Bank-Abbuchung in allen anderen Quellen suchen (+/- 7 Tage, gleicher Betrag negiert)
+ $dateFrom = date('Y-m-d', strtotime($transfer['buchungstag'] . ' -2 days'));
+ $dateTo = date('Y-m-d', strtotime($transfer['buchungstag'] . ' +7 days'));
+ $amount = -abs((float)$transfer['wert']);
+
+ $bankDebit = Database::fetchOne(
+ "SELECT bt.id FROM bank_transactions bt
+ JOIN bank_import_batches bib ON bt.batch_id = bib.id
+ WHERE bib.source_id != ?
+ AND bt.wert = ?
+ AND bt.buchungstag BETWEEN ? AND ?
+ AND LOWER(bt.buchungstext) LIKE '%paypal%'
+ AND bt.status != 'ignoriert'
+ LIMIT 1",
+ [$this->sourceId, $amount, $dateFrom, $dateTo]
+ );
+
+ if (!$bankDebit) {
+ continue;
+ }
+
+ Database::execute(
+ "UPDATE bank_transactions SET status = 'ignoriert' WHERE id = ?",
+ [$transfer['id']]
+ );
+ Database::execute(
+ "UPDATE bank_transactions SET status = 'ignoriert' WHERE id = ?",
+ [$bankDebit['id']]
+ );
+ ($this->log)(sprintf(
+ 'Gegenbuchung: PayPal-Transfer +%s€ (ID %d) + Bank-Abbuchung (ID %d) → ignoriert',
+ number_format((float)$transfer['wert'], 2, ',', '.'),
+ $transfer['id'],
+ $bankDebit['id']
+ ));
+ $pairedCount++;
+ }
+
+ if ($pairedCount === 0 && $transfers) {
+ ($this->log)(count($transfers) . ' Transfer-Buchung(en) warten noch auf Bank-Gegenbuchung.');
+ }
}
// ── NVP-API ──────────────────────────────────────────────────
@@ -183,7 +321,6 @@ class PayPalNvpFetchService
'METHOD' => 'TransactionSearch',
'STARTDATE' => $from,
'ENDDATE' => $to,
- 'STATUS' => 'All',
]);
if ($result === null) {
@@ -193,9 +330,20 @@ class PayPalNvpFetchService
$ack = $result['ACK'] ?? '';
if (!in_array($ack, ['Success', 'SuccessWithWarning'], true)) {
($this->log)(' API-Fehler: ' . ($result['L_LONGMESSAGE0'] ?? $ack));
+ ($this->log)(' ACK: ' . $ack);
+ ($this->log)(' Felder in Antwort: ' . implode(', ', array_keys($result)));
return [];
}
+ // Debug: Anzahl gefundener Felder in der Antwort
+ $txCount = 0;
+ while (isset($result['L_TRANSACTIONID' . $txCount])) {
+ $txCount++;
+ }
+ if ($txCount === 0) {
+ ($this->log)(' Debug: Antwort-Felder: ' . implode(', ', array_slice(array_keys($result), 0, 10)));
+ }
+
$rows = [];
$i = 0;
while (isset($result['L_TRANSACTIONID' . $i])) {
diff --git a/src/Services/VorlageBuchungsService.php b/src/Services/VorlageBuchungsService.php
index ad69907..446464b 100644
--- a/src/Services/VorlageBuchungsService.php
+++ b/src/Services/VorlageBuchungsService.php
@@ -58,7 +58,7 @@ class VorlageBuchungsService
continue;
}
foreach ($regel['buchungen'] as $buchung) {
- $betrag = round((float)($inputDaten[$buchung['betrag_ref']] ?? 0), 2);
+ $betrag = round((float)str_replace(',', '.', (string)($inputDaten[$buchung['betrag_ref']] ?? 0)), 2);
if ($betrag == 0.0) {
continue;
}
diff --git a/src/Views/buchungsvorlagen/modal.php b/src/Views/buchungsvorlagen/modal.php
index 79e5ea7..9b55cff 100644
--- a/src/Views/buchungsvorlagen/modal.php
+++ b/src/Views/buchungsvorlagen/modal.php
@@ -2,10 +2,14 @@
declare(strict_types=1);
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
-// Felder nach Gruppen aufteilen
-$gruppe1 = array_filter($felder, fn($f) => $f['gruppe_nr'] == 1);
-$gruppe2 = array_filter($felder, fn($f) => $f['gruppe_nr'] == 2);
-$gruppe3 = array_filter($felder, fn($f) => $f['gruppe_nr'] == 3);
+// Felder nach Gruppe_nr sortiert gruppieren (dynamisch, unabhängig von festen Nummern)
+$gruppenFelder = [];
+foreach ($felder as $feld) {
+ $gruppenFelder[(int)$feld['gruppe_nr']][] = $feld;
+}
+ksort($gruppenFelder);
+
+$gruppenLabel = [1 => 'Belegdaten', 2 => 'Beträge', 3 => 'Zahlung'];
// Vorbelegen: Vorlage-Standard-Lieferant hat Vorrang vor Beleg-Lieferant
$standardLieferant = trim($vorlage['standard_lieferant'] ?? '');
@@ -44,19 +48,83 @@ if (!$defaultKontoId && !empty($zahlungskonten)) {
-
-
+ $felderInGruppe): ?>
-
Belegdaten
-
-
+
+ = $h($gruppenLabel[$gruppeNr] ?? 'Abschnitt ' . $gruppeNr) ?>
+
+
+
+
+
+ | Steuer |
+ Netto (€) |
+ Brutto (€) |
+ USt (€) |
+
+
+
+
+
+
+
+
+
+
+ | = $h($feld['label']) ?> |
+
+
+ |
+
+
+ |
+
+ 0,00
+
+ |
+
+
+
+
+
+
+
0,00 €
-
+
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
Zahlung
-
-
-
-
-
-
-
-
-
-
+
diff --git a/src/version.txt b/src/version.txt
index 4be5dc2..aef261c 100644
--- a/src/version.txt
+++ b/src/version.txt
@@ -1 +1 @@
-1-260601
+1-260608