Finanzmanager/src/Services/Depotabgleich.php
fds2610-bot 9a3472f4b7 Fix n.n.: Depot-Zuordnung, Bestand einbuchen mit Stichtag
- Depot der Bank wird einem Depot im Finanzmanager zugeordnet (Migration 022)
- Bestand einbuchen: Ein-/Auslieferungen aus dem Bankbestand, Stichtag per Dialog
- Zuordnungsliste: Depots nur noch als Wertpapiergeschäft

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 15:10:43 +02:00

222 lines
8.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Wertpapier;
/**
* Der Depotbestand laut Bank (FinTS-Depotaufstellung) im Vergleich mit dem
* eigenen Bestand.
*
* Der Abruf übernimmt nur, was sicher ist: der Bestand landet in
* `depot_bankbestaende`, und die Bewertungskurse der Papiere, die es im
* Finanzmanager schon gibt, werden als Kurs des Abrufdatums eingetragen. Ein
* Unterschied im Bestand ist zunächst ein Hinweis; erst `einbuchen()` macht
* daraus Depotbuchungen (auf ausdrücklichen Knopfdruck).
*/
class Depotabgleich
{
/**
* Ersetzt den Bankbestand eines Depots durch den frischen Abruf.
*
* @param list<\Fhp\Model\StatementOfHoldings\Holding> $positionen
* @return array{positionen: int, kurse: int}
*/
public static function uebernehmen(int $kontoId, array $positionen): array
{
return Database::transaktion(static function () use ($kontoId, $positionen): array {
Database::execute(
'DELETE FROM depot_bankbestaende WHERE konto_id = ? AND benutzer_id = ?',
[$kontoId, Mandant::id()]
);
$kurse = 0;
foreach ($positionen as $position) {
$isin = strtoupper(trim((string)$position->getISIN()));
$wkn = trim((string)$position->getWKN());
$name = trim((string)$position->getName());
$stand = $position->getDate()?->format('Y-m-d');
$kurs = $position->getPrice();
Database::execute(
'INSERT INTO depot_bankbestaende
(benutzer_id, konto_id, isin, wkn, name, stueck, einstiegskurs, kurs, wert, waehrung, stand)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(),
$kontoId,
$isin,
$wkn,
mb_substr($name, 0, 255),
(float)$position->getAmount(),
$position->getAcquisitionPrice(),
$kurs,
$position->getValue() === null ? null : round($position->getValue(), 2),
strtoupper((string)$position->getCurrency()) ?: 'EUR',
$stand,
]
);
if ($kurs === null || $kurs <= 0.0) {
continue;
}
$papier = Wertpapier::finde($isin, $wkn, $name);
if ($papier !== null) {
Wertpapier::kursSetzen(
(int)$papier['id'],
$stand ?? date('Y-m-d'),
$kurs,
'bank',
strtoupper((string)$position->getCurrency()) ?: 'EUR'
);
$kurse++;
}
}
return ['positionen' => count($positionen), 'kurse' => $kurse];
});
}
/**
* Bankbestand und eigener Bestand nebeneinander, je Papier eine Zeile.
* Verglichen wird über die ISIN, ohne ISIN über den Namen.
*
* @return list<array<string,mixed>> mit `bank`, `eigen` und `abweichung`
* (Stück laut Bank minus eigene Stück)
*/
public static function vergleich(int $kontoId): array
{
$bank = Database::fetchAll(
'SELECT * FROM depot_bankbestaende WHERE konto_id = ? AND benutzer_id = ? ORDER BY name',
[$kontoId, Mandant::id()]
);
$eigen = [];
foreach (Depot::bestand($kontoId)['positionen'] as $position) {
if (abs((float)$position['stueck']) < 0.000001) {
continue;
}
$eigen[self::schluessel((string)$position['isin'], (string)$position['name'])] = $position;
}
$zeilen = [];
foreach ($bank as $eintrag) {
$schluessel = self::schluessel((string)$eintrag['isin'], (string)$eintrag['name']);
$mein = $eigen[$schluessel] ?? null;
unset($eigen[$schluessel]);
$zeilen[] = [
'name' => (string)$eintrag['name'],
'isin' => (string)$eintrag['isin'],
'bank' => $eintrag,
'eigen' => $mein,
'abweichung' => round((float)$eintrag['stueck'] - (float)($mein['stueck'] ?? 0.0), 6),
];
}
// Was nur im Finanzmanager steht, hat die Bank nicht (mehr) im Depot.
foreach ($eigen as $mein) {
$zeilen[] = [
'name' => (string)$mein['name'],
'isin' => (string)$mein['isin'],
'bank' => null,
'eigen' => $mein,
'abweichung' => round(-(float)$mein['stueck'], 6),
];
}
return $zeilen;
}
/**
* Bucht die Unterschiede zwischen Bankbestand und eigenem Bestand ein:
* Mehrbestand als Einlieferung, Minderbestand als Auslieferung, jeweils
* ohne Geldseite (kein Verrechnungskonto). Unbekannte Wertpapiere werden
* angelegt. Der Einlieferungskurs ist der Einstandskurs der Bank, ohne
* den der Bewertungskurs. Danach stimmen beide Bestände überein.
*
* Der Stichtag (`Y-m-d`) ist das Datum aller Buchungen; ohne Angabe gilt
* der Stand des Bankabrufs, sonst heute.
*
* @return array{eingeliefert: int, ausgeliefert: int, angelegt: int}
*/
public static function einbuchen(int $kontoId, ?string $stichtag = null): array
{
return Database::transaktion(static function () use ($kontoId, $stichtag): array {
$ergebnis = ['eingeliefert' => 0, 'ausgeliefert' => 0, 'angelegt' => 0];
foreach (self::vergleich($kontoId) as $zeile) {
$diff = (float)$zeile['abweichung'];
if (abs($diff) < 0.000001) {
continue;
}
$bank = $zeile['bank'];
$stand = (string)($bank['stand'] ?? '') !== '' ? (string)$bank['stand'] : date('Y-m-d');
$datum = $stichtag ?? $stand;
if ($diff > 0) {
$papier = Wertpapier::finde((string)$zeile['isin'], (string)($bank['wkn'] ?? ''), (string)$zeile['name']);
if ($papier === null) {
$ergebnis['angelegt']++;
}
$wertpapierId = $papier !== null ? (int)$papier['id'] : Wertpapier::findenOderAnlegen([
'name' => $zeile['name'],
'isin' => $zeile['isin'],
'wkn' => $bank['wkn'] ?? '',
'art' => 'aktie',
'waehrung' => $bank['waehrung'] ?? 'EUR',
]);
$kurs = (float)($bank['einstiegskurs'] ?? 0) > 0
? (float)$bank['einstiegskurs'] : (float)($bank['kurs'] ?? 0);
$vorgang = 'einlage';
} else {
$wertpapierId = (int)Wertpapier::finde((string)$zeile['isin'], '', (string)$zeile['name'])['id'];
$kurs = 0.0;
$vorgang = 'entnahme';
}
Depotbuchung::anlegen([
'konto_id' => $kontoId,
'wertpapier_id' => $wertpapierId,
'datum' => $datum,
'vorgang' => $vorgang,
'stueck' => abs($diff),
'kurs' => $kurs > 0 ? $kurs : null,
'betrag' => round(abs($diff) * $kurs, 2),
'waehrung' => $bank['waehrung'] ?? 'EUR',
'notiz' => 'Bestand laut Bank vom ' . date('d.m.Y', strtotime($stand))
. ($datum !== $stand ? ', gebucht zum ' . date('d.m.Y', strtotime($datum)) : ''),
'wertpapiername' => $zeile['name'],
'quelle' => 'fints',
]);
$ergebnis[$vorgang === 'einlage' ? 'eingeliefert' : 'ausgeliefert']++;
}
return $ergebnis;
});
}
/** Wann der Bankbestand des Depots zuletzt abgerufen wurde. */
public static function abgerufenAm(int $kontoId): ?string
{
$wert = Database::fetchValue(
'SELECT MAX(abgerufen_am) FROM depot_bankbestaende WHERE konto_id = ? AND benutzer_id = ?',
[$kontoId, Mandant::id()]
);
return $wert === false || $wert === null ? null : (string)$wert;
}
private static function schluessel(string $isin, string $name): string
{
$isin = strtoupper(trim($isin));
return $isin !== '' ? $isin : 'name:' . mb_strtolower(trim($name));
}
}