Das Überweisungsformular fragt nur noch das Auftragskonto ab; die passende Bankverbindung ermittelt Bankzugang::zugangJeKonto() aus der Kontenzuordnung. Konten ohne konfigurierte Bank stehen in der Auswahl nicht mehr zur Verfügung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
573 lines
23 KiB
PHP
573 lines
23 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Models;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
use Finanzmanager\Services\Crypto;
|
||
|
||
/**
|
||
* Ein Bankzugang: Zugangsdaten einer Bank samt der Konten, die dort geführt
|
||
* werden.
|
||
*
|
||
* Es gibt ihn in zwei Ausführungen (`typ`): den **HBCI/FinTS**-Zugang mit
|
||
* Bankleitzahl, Adresse, Benutzerkennung und PIN, und den **PayPal**-Zugang mit
|
||
* den drei Angaben der Classic-NVP-API. Beide teilen sich Tabelle, Konten,
|
||
* Umsätze und Abrufprotokoll – nur der Weg zur Bank ist ein anderer.
|
||
*
|
||
* PIN, TAN-Medium, API-Kennwort und API-Signatur stehen verschlüsselt in der
|
||
* Datenbank (`Services\Crypto`) und werden ausschließlich über
|
||
* `zugangsdaten()` wieder herausgereicht – wer die Zeile über `find()` holt,
|
||
* bekommt sie im Geheimtext.
|
||
*/
|
||
class Bankzugang
|
||
{
|
||
/** Die Arten von Bankverbindung, Schlüssel → Beschriftung. */
|
||
public const TYPEN = [
|
||
'fints' => 'HBCI/FinTS',
|
||
'paypal' => 'PayPal',
|
||
];
|
||
|
||
/** Kontonummer der einen Kontozeile eines PayPal-Zugangs. */
|
||
public const PAYPAL_KENNUNG = 'PAYPAL';
|
||
|
||
/** Vorgaben für den Abrufzeitraum, Schlüssel → Beschriftung. */
|
||
public const ZEITRAEUME = [
|
||
'letzter_abruf' => 'Seit dem letzten Abruf',
|
||
'sieben_tage' => 'Letzte 7 Tage',
|
||
'dreissig_tage' => 'Letzte 30 Tage',
|
||
'jahr' => 'Letzte 365 Tage',
|
||
'stichtag' => 'Ab Stichtag',
|
||
];
|
||
|
||
/** @return list<array<string,mixed>> */
|
||
public static function alle(bool $nurAktive = false): array
|
||
{
|
||
$sql = 'SELECT * FROM bank_zugaenge WHERE benutzer_id = ?';
|
||
if ($nurAktive) {
|
||
$sql .= ' AND aktiv = 1';
|
||
}
|
||
|
||
return Database::fetchAll($sql . ' ORDER BY name', [Mandant::id()]);
|
||
}
|
||
|
||
public static function find(int $id): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM bank_zugaenge WHERE id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
/**
|
||
* Der Zugang mit entschlüsselter PIN. Nur der Abrufdienst ruft das auf.
|
||
*
|
||
* @return array<string,mixed>|null
|
||
*/
|
||
public static function zugangsdaten(int $id): ?array
|
||
{
|
||
$zugang = self::find($id);
|
||
if ($zugang === null) {
|
||
return null;
|
||
}
|
||
|
||
$zugang['pin'] = Crypto::entschluessle((string)($zugang['pin'] ?? ''));
|
||
$zugang['tan_medium'] = Crypto::entschluessle((string)($zugang['tan_medium'] ?? ''));
|
||
$zugang['api_kennwort'] = Crypto::entschluessle((string)($zugang['api_kennwort'] ?? ''));
|
||
$zugang['api_signatur'] = Crypto::entschluessle((string)($zugang['api_signatur'] ?? ''));
|
||
|
||
return $zugang;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $daten
|
||
*/
|
||
public static function anlegen(array $daten): int
|
||
{
|
||
$felder = self::normalisiere($daten);
|
||
self::verlangeAdresse((string)$felder['typ'], (string)$felder['fints_url']);
|
||
|
||
Database::execute(
|
||
'INSERT INTO bank_zugaenge
|
||
(benutzer_id, typ, name, bankname, blz, fints_url, benutzerkennung, pin,
|
||
api_benutzer, api_kennwort, api_signatur, sandbox,
|
||
tan_verfahren_id, tan_verfahren_name, tan_medium, abrufzeitraum,
|
||
abruf_stichtag, aktiv)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||
[
|
||
Mandant::id(), $felder['typ'], $felder['name'], $felder['bankname'], $felder['blz'],
|
||
$felder['fints_url'], $felder['benutzerkennung'], $felder['pin'],
|
||
$felder['api_benutzer'], $felder['api_kennwort'], $felder['api_signatur'],
|
||
$felder['sandbox'],
|
||
$felder['tan_verfahren_id'], $felder['tan_verfahren_name'], $felder['tan_medium'],
|
||
$felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'],
|
||
]
|
||
);
|
||
|
||
return Database::lastInsertId();
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $daten
|
||
*/
|
||
public static function speichern(int $id, array $daten): void
|
||
{
|
||
$bisher = self::find($id);
|
||
if ($bisher === null) {
|
||
throw new \RuntimeException('Den Bankzugang gibt es nicht.');
|
||
}
|
||
|
||
$felder = self::normalisiere($daten);
|
||
|
||
// Der Typ steht beim Anlegen fest und wird nicht mehr umgestellt: Ein
|
||
// FinTS-Zugang, der plötzlich PayPal wäre, hätte weder passende
|
||
// Zugangsdaten noch passende Konten.
|
||
$felder['typ'] = (string)$bisher['typ'];
|
||
self::verlangeAdresse($felder['typ'], (string)$felder['fints_url']);
|
||
|
||
// Ein leeres PIN-Feld heißt „unverändert lassen“, nicht „PIN löschen“ –
|
||
// sonst verlöre jedes Speichern des Formulars die Zugangsdaten.
|
||
// Für die beiden PayPal-Geheimnisse gilt dasselbe.
|
||
foreach (['pin', 'api_kennwort', 'api_signatur'] as $geheimnis) {
|
||
if ($felder[$geheimnis] === null) {
|
||
$felder[$geheimnis] = $bisher[$geheimnis];
|
||
}
|
||
}
|
||
|
||
// Dasselbe für das TAN-Verfahren: Es steht nicht im Zugangsformular,
|
||
// sondern wird eigens abgefragt. Ohne diesen Vorbehalt setzte jedes
|
||
// Speichern die Wahl zurück.
|
||
foreach (['tan_verfahren_id', 'tan_verfahren_name', 'tan_medium'] as $feld) {
|
||
if (!array_key_exists($feld, $daten)) {
|
||
$felder[$feld] = $bisher[$feld];
|
||
}
|
||
}
|
||
|
||
Database::execute(
|
||
'UPDATE bank_zugaenge SET name = ?, bankname = ?, blz = ?, fints_url = ?,
|
||
benutzerkennung = ?, pin = ?, api_benutzer = ?, api_kennwort = ?,
|
||
api_signatur = ?, sandbox = ?, tan_verfahren_id = ?, tan_verfahren_name = ?,
|
||
tan_medium = ?, abrufzeitraum = ?, abruf_stichtag = ?, aktiv = ?
|
||
WHERE id = ? AND benutzer_id = ?',
|
||
[
|
||
$felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'],
|
||
$felder['benutzerkennung'], $felder['pin'], $felder['api_benutzer'],
|
||
$felder['api_kennwort'], $felder['api_signatur'], $felder['sandbox'],
|
||
$felder['tan_verfahren_id'],
|
||
$felder['tan_verfahren_name'], $felder['tan_medium'],
|
||
$felder['abrufzeitraum'], $felder['abruf_stichtag'], $felder['aktiv'],
|
||
$id, Mandant::id(),
|
||
]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Ohne Adresse hat ein FinTS-Zugang niemanden, den er fragen könnte.
|
||
*
|
||
* Geprüft wird erst, wenn der Typ feststeht: Beim Bearbeiten stammt er aus
|
||
* der Datenbank und nicht aus dem Formular, und ein PayPal-Zugang führt
|
||
* überhaupt keine FinTS-Adresse.
|
||
*
|
||
* Das Formular darf sie leer lassen – dann schlägt der Controller sie im
|
||
* Bankleitzahlen-Verzeichnis nach. Findet auch das nichts, endet es hier,
|
||
* und zwar mit einer Meldung, die den Grund nennt.
|
||
*/
|
||
private static function verlangeAdresse(string $typ, string $url): void
|
||
{
|
||
if ($typ === 'fints' && trim($url) === '') {
|
||
throw new \InvalidArgumentException(
|
||
'Die FinTS-Adresse fehlt und steht auch nicht im Bankleitzahlen-Verzeichnis '
|
||
. '– sie muss von Hand eingetragen werden.'
|
||
);
|
||
}
|
||
}
|
||
|
||
public static function loeschen(int $id): void
|
||
{
|
||
Database::execute('DELETE FROM bank_zugaenge WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||
}
|
||
|
||
public static function merkeAbruf(int $id): void
|
||
{
|
||
Database::execute(
|
||
'UPDATE bank_zugaenge SET zuletzt_am = NOW() WHERE id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
}
|
||
|
||
public static function merkeTanVerfahren(
|
||
int $id,
|
||
?int $verfahren,
|
||
string $medium = '',
|
||
string $bezeichnung = ''
|
||
): void {
|
||
Database::execute(
|
||
'UPDATE bank_zugaenge
|
||
SET tan_verfahren_id = ?, tan_verfahren_name = ?, tan_medium = ?
|
||
WHERE id = ? AND benutzer_id = ?',
|
||
[
|
||
$verfahren,
|
||
mb_substr($bezeichnung, 0, 128),
|
||
Crypto::verschluessle($medium),
|
||
$id,
|
||
Mandant::id(),
|
||
]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Ab wann für diesen Zugang abgerufen wird.
|
||
*
|
||
* Ein **beim Abruf angegebener Stichtag** geht der gespeicherten Vorgabe vor
|
||
* und wird nirgends festgehalten: Er gilt für diesen einen Lauf – etwa um
|
||
* einmalig weiter zurückzugreifen, ohne die Einstellung des Zugangs zu
|
||
* ändern.
|
||
*
|
||
* @param array<string,mixed> $zugang
|
||
* @return array{0: \DateTimeImmutable, 1: string} Startdatum und Beschreibung
|
||
*/
|
||
public static function abrufBeginn(
|
||
array $zugang,
|
||
?string $stichtag = null,
|
||
?\DateTimeImmutable $heute = null
|
||
): array {
|
||
if (trim((string)$stichtag) !== '') {
|
||
return self::startdatum('stichtag', null, $heute, $stichtag);
|
||
}
|
||
|
||
return self::startdatum(
|
||
(string)($zugang['abrufzeitraum'] ?? ''),
|
||
($zugang['zuletzt_am'] ?? null) === null ? null : (string)$zugang['zuletzt_am'],
|
||
$heute,
|
||
($zugang['abruf_stichtag'] ?? null) === null ? null : (string)$zugang['abruf_stichtag']
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Ab wann abgerufen wird. „Seit dem letzten Abruf“ nimmt den Tag des letzten
|
||
* Laufs – bewusst den ganzen Tag und nicht die Uhrzeit: Banken buchen
|
||
* nachträglich, und ein Umsatz, der schon da war, fällt ohnehin über seine
|
||
* Kennung heraus.
|
||
*
|
||
* @return array{0: \DateTimeImmutable, 1: string} Startdatum und Beschreibung
|
||
*/
|
||
public static function startdatum(
|
||
?string $zeitraum,
|
||
?string $zuletztAm,
|
||
?\DateTimeImmutable $heute = null,
|
||
?string $stichtag = null
|
||
): array {
|
||
$heute = $heute ?? new \DateTimeImmutable('today');
|
||
$zeitraum = array_key_exists((string)$zeitraum, self::ZEITRAEUME) ? (string)$zeitraum : 'letzter_abruf';
|
||
|
||
return match ($zeitraum) {
|
||
'sieben_tage' => [$heute->modify('-7 days'), 'letzte 7 Tage'],
|
||
'dreissig_tage' => [$heute->modify('-30 days'), 'letzte 30 Tage'],
|
||
'jahr' => [$heute->modify('-365 days'), 'letzte 365 Tage'],
|
||
// Ohne brauchbares Datum wäre „Ab Stichtag“ eine Angabe ohne Inhalt –
|
||
// dann gilt dieselbe Regel wie ohne Vorgabe.
|
||
'stichtag' => self::abStichtag($heute, $stichtag) ?? self::seitLetztemAbruf($heute, $zuletztAm),
|
||
default => self::seitLetztemAbruf($heute, $zuletztAm),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Ein Stichtag als Datum – oder null, wenn sich keines daraus lesen lässt.
|
||
*
|
||
* Angenommen werden das Format des Datumsfelds (`2026-08-13`) und die
|
||
* deutsche Schreibweise (`13.08.2026`); alles andere gilt als Fehleingabe
|
||
* statt als „irgendein Tag“.
|
||
*/
|
||
public static function stichtagDatum(?string $roh): ?\DateTimeImmutable
|
||
{
|
||
$roh = trim((string)$roh);
|
||
if ($roh === '') {
|
||
return null;
|
||
}
|
||
|
||
foreach (['Y-m-d', 'd.m.Y'] as $format) {
|
||
$datum = \DateTimeImmutable::createFromFormat('!' . $format, $roh);
|
||
$fehler = \DateTimeImmutable::getLastErrors();
|
||
|
||
// `createFromFormat` biegt Unsinn wie den 31.02. still zurecht – die
|
||
// Warnungen sind das Einzige, woran sich das erkennen lässt.
|
||
if ($datum !== false
|
||
&& ($fehler === false || ($fehler['warning_count'] === 0 && $fehler['error_count'] === 0))) {
|
||
return $datum;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** @return array{0: \DateTimeImmutable, 1: string}|null */
|
||
private static function abStichtag(\DateTimeImmutable $heute, ?string $stichtag): ?array
|
||
{
|
||
$datum = self::stichtagDatum($stichtag);
|
||
if ($datum === null) {
|
||
return null;
|
||
}
|
||
|
||
// Aus der Zukunft liefert keine Bank etwas; ein verrutschter Stichtag
|
||
// soll den Abruf nicht ergebnislos machen.
|
||
if ($datum > $heute) {
|
||
return [$heute, 'ab Stichtag ' . $heute->format('d.m.Y') . ' (in der Zukunft, auf heute gesetzt)'];
|
||
}
|
||
|
||
return [$datum, 'ab Stichtag ' . $datum->format('d.m.Y')];
|
||
}
|
||
|
||
/** @return array{0: \DateTimeImmutable, 1: string} */
|
||
private static function seitLetztemAbruf(\DateTimeImmutable $heute, ?string $zuletztAm): array
|
||
{
|
||
if ($zuletztAm === null || $zuletztAm === '') {
|
||
return [$heute->modify('-365 days'), 'erster Abruf – die letzten 365 Tage'];
|
||
}
|
||
|
||
$start = new \DateTimeImmutable(substr($zuletztAm, 0, 10));
|
||
|
||
return [$start, 'seit dem letzten Abruf am ' . $start->format('d.m.Y')];
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $daten
|
||
* @return array<string,mixed>
|
||
*/
|
||
private static function normalisiere(array $daten): array
|
||
{
|
||
$name = trim((string)($daten['name'] ?? ''));
|
||
if ($name === '') {
|
||
throw new \InvalidArgumentException('Der Bankzugang braucht einen Namen.');
|
||
}
|
||
|
||
$typ = (string)($daten['typ'] ?? 'fints');
|
||
$typ = array_key_exists($typ, self::TYPEN) ? $typ : 'fints';
|
||
|
||
$url = trim((string)($daten['fints_url'] ?? ''));
|
||
if ($url !== '' && !str_starts_with($url, 'https://')) {
|
||
throw new \InvalidArgumentException('Die FinTS-Adresse muss mit https:// beginnen.');
|
||
}
|
||
|
||
// Ein leeres PIN-Feld bedeutet beim Bearbeiten „unverändert“; beim
|
||
// Anlegen wird daraus schlicht eine leere PIN. Für die beiden
|
||
// PayPal-Geheimnisse gilt dieselbe Regel.
|
||
//
|
||
// Getrimmt wird, bevor verschlüsselt wird: Eine aus der Zwischenablage
|
||
// eingefügte PayPal-Signatur bringt gern ein Leerzeichen mit, und PayPal
|
||
// antwortet darauf mit „Security header is not valid“ – einer Meldung,
|
||
// die alles Mögliche vermuten lässt, nur nicht das.
|
||
$geheim = static function (mixed $wert): ?string {
|
||
$wert = trim((string)($wert ?? ''));
|
||
return $wert === '' ? null : Crypto::verschluessle($wert);
|
||
};
|
||
|
||
$pin = $geheim($daten['pin'] ?? '');
|
||
|
||
$verfahren = $daten['tan_verfahren_id'] ?? null;
|
||
$verfahren = ($verfahren === null || $verfahren === '') ? null : (int)$verfahren;
|
||
|
||
$zeitraum = (string)($daten['abrufzeitraum'] ?? 'letzter_abruf');
|
||
$zeitraum = array_key_exists($zeitraum, self::ZEITRAEUME) ? $zeitraum : 'letzter_abruf';
|
||
|
||
// Ein leeres Datumsfeld ist kein Fehler; „Ab Stichtag“ ohne Datum schon –
|
||
// sonst stünde eine Vorgabe da, die nichts vorgibt.
|
||
$stichtag = self::stichtagDatum((string)($daten['abruf_stichtag'] ?? ''));
|
||
if ($stichtag === null && trim((string)($daten['abruf_stichtag'] ?? '')) !== '') {
|
||
throw new \InvalidArgumentException('Der Stichtag ist kein gültiges Datum.');
|
||
}
|
||
if ($stichtag === null && $zeitraum === 'stichtag') {
|
||
throw new \InvalidArgumentException('Für „Ab Stichtag“ fehlt das Datum.');
|
||
}
|
||
|
||
return [
|
||
'typ' => $typ,
|
||
'name' => mb_substr($name, 0, 128),
|
||
'bankname' => mb_substr(trim((string)($daten['bankname'] ?? '')), 0, 128),
|
||
'blz' => preg_replace('/\D/', '', (string)($daten['blz'] ?? '')) ?? '',
|
||
'fints_url' => mb_substr($url, 0, 255),
|
||
'benutzerkennung' => mb_substr(trim((string)($daten['benutzerkennung'] ?? '')), 0, 128),
|
||
'pin' => $pin,
|
||
'api_benutzer' => mb_substr(trim((string)($daten['api_benutzer'] ?? '')), 0, 191),
|
||
'api_kennwort' => $geheim($daten['api_kennwort'] ?? ''),
|
||
'api_signatur' => $geheim($daten['api_signatur'] ?? ''),
|
||
'sandbox' => !empty($daten['sandbox']) ? 1 : 0,
|
||
'tan_verfahren_id' => $verfahren,
|
||
'tan_verfahren_name' => mb_substr(trim((string)($daten['tan_verfahren_name'] ?? '')), 0, 128),
|
||
'tan_medium' => Crypto::verschluessle(trim((string)($daten['tan_medium'] ?? ''))),
|
||
'abrufzeitraum' => $zeitraum,
|
||
'abruf_stichtag' => $stichtag?->format('Y-m-d'),
|
||
'aktiv' => !empty($daten['aktiv']) ? 1 : 0,
|
||
];
|
||
}
|
||
|
||
// ── Zugeordnete Bankkonten ───────────────────────────────────────────────
|
||
|
||
/**
|
||
* Konto-ID → Zugang-ID, für jedes eigene Konto mit konfigurierter Bank.
|
||
*
|
||
* Grundlage für die automatische Bankauswahl im Zahlungsverkehr: Nur
|
||
* Konten mit einem Eintrag hier tauchen dort in der Auswahl auf, und die
|
||
* Bank steht damit fest, sobald das Konto gewählt ist. Nur aktive
|
||
* Zugänge zählen – ein deaktivierter kann ohnehin nicht senden.
|
||
*
|
||
* @return array<int,int>
|
||
*/
|
||
public static function zugangJeKonto(): array
|
||
{
|
||
$zeilen = Database::fetchAll(
|
||
'SELECT bk.konto_id, MIN(bk.zugang_id) AS zugang_id
|
||
FROM bank_konten bk
|
||
JOIN bank_zugaenge z ON z.id = bk.zugang_id
|
||
WHERE z.benutzer_id = ? AND z.aktiv = 1 AND bk.konto_id IS NOT NULL
|
||
GROUP BY bk.konto_id',
|
||
[Mandant::id()]
|
||
);
|
||
|
||
$zuordnung = [];
|
||
foreach ($zeilen as $zeile) {
|
||
$zuordnung[(int)$zeile['konto_id']] = (int)$zeile['zugang_id'];
|
||
}
|
||
|
||
return $zuordnung;
|
||
}
|
||
|
||
/** @return list<array<string,mixed>> */
|
||
public static function konten(int $zugangId): array
|
||
{
|
||
return Database::fetchAll(
|
||
'SELECT bk.*, k.name AS kontoname
|
||
FROM bank_konten bk
|
||
LEFT JOIN konten k ON k.id = bk.konto_id
|
||
WHERE bk.zugang_id = ?
|
||
ORDER BY bk.iban, bk.kontonummer',
|
||
[$zugangId]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Das eine Konto eines PayPal-Zugangs – angelegt, falls es noch keines gibt.
|
||
*
|
||
* PayPal führt je Konto genau ein Guthabenkonto; eine Kontenabfrage wie bei
|
||
* FinTS gibt es dort nicht und wäre auch nichts zu wählen. Statt der IBAN
|
||
* steht die feste Kennung `PAYPAL` in der Zeile: Sie hält den
|
||
* Fingerabdruck der Umsätze stabil und macht die UNIQUE-Bedingung über
|
||
* (Zugang, IBAN, Kontonummer) eindeutig.
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
public static function paypalKonto(int $zugangId): array
|
||
{
|
||
return self::findeOderLegeKontoAn($zugangId, '', self::PAYPAL_KENNUNG, 'PayPal-Guthaben');
|
||
}
|
||
|
||
/**
|
||
* Legt ein bei der Bank gefundenes Konto an oder findet es wieder. Beim
|
||
* ersten Auftauchen bleibt es ohne Zuordnung – der Nutzer entscheidet, auf
|
||
* welches eigene Konto die Umsätze laufen.
|
||
*
|
||
* **Die Kontonummer entscheidet, nicht die IBAN.** Manche Banken liefern die
|
||
* IBAN nicht bei jedem Dialog mit – die Comdirect etwa nennt sie beim
|
||
* Kontenabruf, beim Umsatzabruf aber nicht. Suchte man über beide Felder
|
||
* zusammen, entstünde beim zweiten Abruf ein zweiter Eintrag fürs selbe
|
||
* Konto: einmal mit IBAN, einmal ohne. Die Folge wären doppelte Umsätze,
|
||
* denn beide Einträge haben eine eigene Zuordnung und einen eigenen
|
||
* Fingerabdruck.
|
||
*/
|
||
public static function findeOderLegeKontoAn(
|
||
int $zugangId,
|
||
string $iban,
|
||
string $kontonummer,
|
||
string $bezeichnung = ''
|
||
): array {
|
||
$iban = strtoupper(str_replace(' ', '', $iban));
|
||
$kontonummer = trim($kontonummer);
|
||
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM bank_konten
|
||
WHERE zugang_id = ?
|
||
AND (kontonummer <> \'\' AND kontonummer = ? OR iban <> \'\' AND iban = ?)
|
||
ORDER BY iban = ? DESC, id
|
||
LIMIT 1',
|
||
[$zugangId, $kontonummer, $iban, $iban]
|
||
);
|
||
|
||
if ($zeile !== false) {
|
||
// Liefert die Bank die IBAN erst jetzt, wird sie nachgetragen –
|
||
// sonst bliebe der Eintrag für immer ohne.
|
||
if ($iban !== '' && (string)$zeile['iban'] === '') {
|
||
Database::execute('UPDATE bank_konten SET iban = ? WHERE id = ?', [$iban, (int)$zeile['id']]);
|
||
$zeile['iban'] = $iban;
|
||
}
|
||
|
||
return $zeile;
|
||
}
|
||
|
||
// Ein Konto mit passender IBAN gibt es vielleicht schon in der eigenen
|
||
// Kontenliste – dann ist die Zuordnung offensichtlich und wird gleich
|
||
// mit angelegt.
|
||
$konto = $iban === '' ? null : Database::fetchOne(
|
||
'SELECT id FROM konten WHERE benutzer_id = ? AND iban = ? LIMIT 1',
|
||
[Mandant::id(), $iban]
|
||
);
|
||
|
||
Database::execute(
|
||
'INSERT INTO bank_konten (zugang_id, konto_id, iban, kontonummer, bezeichnung)
|
||
VALUES (?, ?, ?, ?, ?)',
|
||
[
|
||
$zugangId,
|
||
$konto === false || $konto === null ? null : (int)$konto['id'],
|
||
$iban,
|
||
mb_substr($kontonummer, 0, 34),
|
||
mb_substr($bezeichnung, 0, 128),
|
||
]
|
||
);
|
||
|
||
return Database::fetchOne('SELECT * FROM bank_konten WHERE id = ?', [Database::lastInsertId()]) ?: [];
|
||
}
|
||
|
||
/**
|
||
* Ordnet ein Bankkonto einem eigenen Konto zu.
|
||
*
|
||
* Die schon abgerufenen **offenen** Umsätze ziehen mit: Wer die Zuordnung
|
||
* erst nach einem Abruf trifft, hätte sonst einen Stapel Umsätze ohne Konto,
|
||
* die sich nicht buchen lassen. Übernommene bleiben, wo sie sind – ihre
|
||
* Buchung steht bereits in einem Kontoblatt.
|
||
*/
|
||
public static function ordneKontoZu(int $bankKontoId, ?int $kontoId, bool $abrufen = true): void
|
||
{
|
||
Database::execute(
|
||
'UPDATE bank_konten bk
|
||
JOIN bank_zugaenge z ON z.id = bk.zugang_id
|
||
SET bk.konto_id = ?, bk.abrufen = ?
|
||
WHERE bk.id = ? AND z.benutzer_id = ?',
|
||
[$kontoId, $abrufen ? 1 : 0, $bankKontoId, Mandant::id()]
|
||
);
|
||
|
||
Database::execute(
|
||
"UPDATE bank_umsaetze
|
||
SET konto_id = ?
|
||
WHERE bank_konto_id = ? AND benutzer_id = ? AND status = 'offen'",
|
||
[$kontoId, $bankKontoId, Mandant::id()]
|
||
);
|
||
}
|
||
|
||
public static function merkeSaldo(int $bankKontoId, float $saldo): void
|
||
{
|
||
Database::execute(
|
||
'UPDATE bank_konten SET saldo = ?, saldo_am = NOW() WHERE id = ?',
|
||
[$saldo, $bankKontoId]
|
||
);
|
||
|
||
// Der Online-Saldo der Kontenübersicht kommt aus genau diesem Wert.
|
||
Database::execute(
|
||
'UPDATE konten k
|
||
JOIN bank_konten bk ON bk.konto_id = k.id
|
||
SET k.saldo_online = ?, k.saldo_online_am = NOW()
|
||
WHERE bk.id = ? AND k.benutzer_id = ?',
|
||
[$saldo, $bankKontoId, Mandant::id()]
|
||
);
|
||
}
|
||
}
|