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
- - +
+ +
+ + + + + + + + + + + + + +
SteuerNetto (€)Brutto (€)USt (€)
+ + + + + + + + + + + + + 0,00 + + + + +
+ + +
+
0,00 €
- +
- +
- + -
- - - - -
-
Beträge
- - - - - - - - - - - - - - - - - - - - - +
SteuerNetto (€)Brutto (€)USt (€)
- - - - - 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