Der Plan ging davon aus, Umbuchungen stünden zweimal im Export und müssten zusammengeführt werden. Der Vollexport zeigt das Gegenteil: Eine Umbuchung steht einmal, in dem Konto, in dem sie erfasst wurde; die Gegenseite ist nur durch L[Zielkonto] benannt und muss beim Import erzeugt werden. Von 965 Umbuchungen hat keine ein zweites Vorkommen. Mit erzeugter Gegenseite stimmen acht gegen den FinanzManager geprüfte Kontosalden auf den Cent. Datenmodell: Migration 003 mit buchungen, buchung_splits und buchungsadressen. Eine Umbuchung ist ein Datensatz mit gegenkonto_id, kein gespiegeltes Paar - zwei Zeilen laufen früher oder später auseinander. Das Kontoblatt des Gegenkontos zeigt sie mit umgekehrtem Vorzeichen. Auch einzelne Splitzeilen dürfen ein Gegenkonto tragen. Konten führen zusätzlich in_summe, die Sigma-Spalte der Kontenübersicht, und saldo_online für Phase 5. Oberfläche: Kontoblatt mit laufendem Saldo, Filter auf Zeitraum, Kategorie, Klasse und Abgleich, Volltextsuche und Seitenblättern. Buchungsmaske für Einnahme, Ausgabe und Umbuchung in beide Richtungen, mit Splitzeilen und Restbetragsanzeige. Import: QifBuchungsImport arbeitet mit Stichtag. Alles davor wird je Konto zum Anfangssaldo aufsummiert, Gegenbuchungen eingerechnet; alles danach bis zum Importtag kommt einzeln in die Datenbank. Spätere Buchungen sind Vorausberechnungen des FinanzManagers und bleiben draußen. Wertpapiergeschäfte schlagen vorerst nur mit ihrem Geldfluss auf dem Verrechnungskonto zu Buche. Am Vollexport geprüft: 833 Buchungen ab 01.01.2026, 7946 in die Anfangssalden von 35 Konten verrechnet. Behoben: Die confirm()-Rückfragen vor dem Löschen waren wirkungslos. public/static/app.js brachte den Mechanismus mit, war aber nie im Layout eingebunden; die ersatzweise gesetzten onsubmit-Attribute verstoßen gegen die eigene Content-Security-Policy und wurden vom Browser verworfen - gelöscht wurde ohne Rückfrage. Ein E2E-Test besteht jetzt auf dem Dialog. Tests: 307 PHP-Prüfungen und 21 Playwright-Tests, alle grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
511 lines
18 KiB
PHP
511 lines
18 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Klasse;
|
||
use Finanzmanager\Models\Konto;
|
||
|
||
/**
|
||
* Übernimmt die Buchungen eines QIF-Exports.
|
||
*
|
||
* Zwei Eigenheiten des Formats bestimmen den Ablauf:
|
||
*
|
||
* 1. **Umbuchungen stehen nur einmal in der Datei** – in dem Konto, in dem sie
|
||
* erfasst wurden; die Gegenseite ist allein durch `L[Zielkonto]` benannt.
|
||
* Hier wird daraus eine Buchung mit `gegenkonto_id`, die auf beiden Konten
|
||
* erscheint (auf dem Gegenkonto mit umgekehrtem Vorzeichen).
|
||
* 2. **Der Export reicht weiter zurück, als gebraucht wird.** Alles vor dem
|
||
* Stichtag fließt nicht als Buchung ein, sondern wird je Konto zum
|
||
* Anfangssaldo aufsummiert – einschließlich der Gegenseiten. Die Salden
|
||
* stimmen dadurch exakt, ohne die ganze Historie mitzuschleppen.
|
||
*
|
||
* Buchungen nach dem Enddatum sind Vorausberechnungen des FinanzManagers und
|
||
* werden übersprungen.
|
||
*/
|
||
class QifBuchungsImport
|
||
{
|
||
/** Sektionen mit Geldbuchungen. Invst wird gesondert behandelt. */
|
||
private const GELDTYPEN = ['Bank', 'CCard', 'Cash', 'Oth A', 'Oth L', 'Oth T', 'Oth P', 'Oth E', 'Oth F'];
|
||
|
||
private QifParser $parser;
|
||
private string $stichtag;
|
||
private string $bis;
|
||
|
||
/** @var array<string,int> Kontoname → id */
|
||
private array $konten = [];
|
||
/** @var array<string,int> Kategoriepfad → id */
|
||
private array $kategorien = [];
|
||
/** @var array<string,int> Klassenname → id */
|
||
private array $klassen = [];
|
||
/** @var array<string,int> Basis-Hash → wie oft schon gesehen */
|
||
private array $hashZaehler = [];
|
||
|
||
/** @var list<array{typ: string, text: string}> */
|
||
private array $meldungen = [];
|
||
|
||
/**
|
||
* @param array{stichtag?: string, bis?: string} $optionen
|
||
*/
|
||
public function __construct(QifParser $parser, array $optionen = [])
|
||
{
|
||
$this->parser = $parser;
|
||
$this->stichtag = $optionen['stichtag'] ?? '2026-01-01';
|
||
$this->bis = $optionen['bis'] ?? date('Y-m-d');
|
||
|
||
if (!Buchung::istDatum($this->stichtag) || !Buchung::istDatum($this->bis)) {
|
||
throw new \InvalidArgumentException('Stichtag und Enddatum müssen als JJJJ-MM-TT vorliegen.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return array<string,mixed>
|
||
*/
|
||
public function ausfuehren(bool $trockenlauf = false): array
|
||
{
|
||
$this->meldungen = [];
|
||
$this->hashZaehler = [];
|
||
$this->ladeStammdaten();
|
||
|
||
$lauf = function () use ($trockenlauf): array {
|
||
$anfang = $this->anfangssalden($trockenlauf);
|
||
$buchungen = $this->buchungen($trockenlauf);
|
||
|
||
return [
|
||
'anfangssalden' => $anfang,
|
||
'buchungen' => $buchungen,
|
||
'stichtag' => $this->stichtag,
|
||
'bis' => $this->bis,
|
||
'trockenlauf' => $trockenlauf,
|
||
];
|
||
};
|
||
|
||
$ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf);
|
||
$ergebnis['meldungen'] = $this->meldungen;
|
||
|
||
return $ergebnis;
|
||
}
|
||
|
||
public function vorschau(): array
|
||
{
|
||
return $this->ausfuehren(true);
|
||
}
|
||
|
||
/**
|
||
* Summiert alles vor dem Stichtag je Konto auf – die Gegenseiten der
|
||
* Umbuchungen eingeschlossen – und schreibt das Ergebnis als Anfangssaldo.
|
||
*
|
||
* @return array{konten: int, summe: float, salden: array<string,float>}
|
||
*/
|
||
private function anfangssalden(bool $trockenlauf): array
|
||
{
|
||
$salden = [];
|
||
|
||
$bucheAuf = static function (string $konto, float $betrag) use (&$salden): void {
|
||
$salden[$konto] = ($salden[$konto] ?? 0.0) + $betrag;
|
||
};
|
||
|
||
foreach ($this->parser->eintraege() as $eintrag) {
|
||
if (!$this->istGeldbuchung($eintrag) && $eintrag['typ'] !== 'Invst') {
|
||
continue;
|
||
}
|
||
|
||
$datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat());
|
||
if ($datum === null || $datum >= $this->stichtag) {
|
||
continue;
|
||
}
|
||
|
||
$konto = (string)$eintrag['konto'];
|
||
|
||
if ($eintrag['typ'] === 'Invst') {
|
||
// Vor dem Stichtag zählt vom Wertpapiergeschäft nur der
|
||
// Geldfluss auf dem Verrechnungskonto.
|
||
$ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L'));
|
||
$wert = QifParser::feld($eintrag, '$');
|
||
if ($ziel !== null && $wert !== null) {
|
||
$bucheAuf($ziel, -QifParser::betrag($wert));
|
||
}
|
||
continue;
|
||
}
|
||
|
||
$betrag = QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T'));
|
||
$bucheAuf($konto, $betrag);
|
||
|
||
foreach ($this->gegenbuchungen($eintrag, $konto, $betrag) as [$zielKonto, $zielBetrag]) {
|
||
$bucheAuf($zielKonto, $zielBetrag);
|
||
}
|
||
}
|
||
|
||
$gesetzt = 0;
|
||
foreach ($salden as $name => $betrag) {
|
||
$kontoId = $this->kontoId($name);
|
||
if ($kontoId === null) {
|
||
$this->melde('fehler', 'Anfangssaldo für unbekanntes Konto „' . $name . '“ verworfen.');
|
||
continue;
|
||
}
|
||
if (!$trockenlauf) {
|
||
Database::execute(
|
||
'UPDATE konten SET anfangssaldo = ? WHERE id = ?',
|
||
[round($betrag, 2), $kontoId]
|
||
);
|
||
}
|
||
$gesetzt++;
|
||
}
|
||
|
||
return [
|
||
'konten' => $gesetzt,
|
||
'summe' => round(array_sum($salden), 2),
|
||
'salden' => $salden,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<string,int>
|
||
*/
|
||
private function buchungen(bool $trockenlauf): array
|
||
{
|
||
$zaehler = [
|
||
'neu' => 0, 'vorhanden' => 0, 'vor_stichtag' => 0,
|
||
'zukunft' => 0, 'wertpapier' => 0, 'fehler' => 0,
|
||
];
|
||
|
||
foreach ($this->parser->eintraege() as $eintrag) {
|
||
$istGeld = $this->istGeldbuchung($eintrag);
|
||
if (!$istGeld && $eintrag['typ'] !== 'Invst') {
|
||
continue;
|
||
}
|
||
|
||
$datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat());
|
||
if ($datum === null) {
|
||
$zaehler['fehler']++;
|
||
$this->melde('fehler', 'Buchung ohne lesbares Datum übersprungen (' . $eintrag['konto'] . ').');
|
||
continue;
|
||
}
|
||
if ($datum < $this->stichtag) {
|
||
$zaehler['vor_stichtag']++;
|
||
continue;
|
||
}
|
||
if ($datum > $this->bis) {
|
||
$zaehler['zukunft']++;
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
$angelegt = $istGeld
|
||
? $this->geldbuchung($eintrag, $datum, $trockenlauf)
|
||
: $this->wertpapierbuchung($eintrag, $datum, $trockenlauf);
|
||
|
||
if ($angelegt === null) {
|
||
$zaehler['vorhanden']++;
|
||
continue;
|
||
}
|
||
|
||
$zaehler['neu']++;
|
||
if (!$istGeld) {
|
||
$zaehler['wertpapier']++;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$zaehler['fehler']++;
|
||
$this->melde('fehler', $datum . ' ' . $eintrag['konto'] . ': ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
if ($zaehler['wertpapier'] > 0) {
|
||
$this->melde('hinweis', $zaehler['wertpapier'] . ' Wertpapiergeschäfte: übernommen wurde nur der '
|
||
. 'Geldfluss auf dem Verrechnungskonto. Bestände und Kurse folgen in Phase 6.');
|
||
}
|
||
|
||
return $zaehler;
|
||
}
|
||
|
||
/**
|
||
* @param array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>} $eintrag
|
||
* @return int|null Kennung der neuen Buchung, oder null wenn sie schon da war.
|
||
*/
|
||
private function geldbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int
|
||
{
|
||
$kontoName = (string)$eintrag['konto'];
|
||
$kontoId = $this->kontoId($kontoName);
|
||
if ($kontoId === null) {
|
||
throw new \RuntimeException('Unbekanntes Konto „' . $kontoName . '“.');
|
||
}
|
||
|
||
$betrag = QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T'));
|
||
$text = (string)QifParser::feld($eintrag, 'M');
|
||
$nummer = (string)QifParser::feld($eintrag, 'N');
|
||
|
||
$hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text);
|
||
if (Buchung::findeNachHash($hash) !== null) {
|
||
return null;
|
||
}
|
||
|
||
$zuordnung = $this->zuordnung((string)QifParser::feld($eintrag, 'L'), $kontoId);
|
||
$splits = $this->splitzeilen($eintrag, $kontoId);
|
||
|
||
$daten = [
|
||
'konto_id' => $kontoId,
|
||
'datum' => $datum,
|
||
'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()),
|
||
'betrag' => $betrag,
|
||
'empfaenger' => (string)QifParser::feld($eintrag, 'P'),
|
||
'buchungstext' => $text,
|
||
'nummer' => $nummer,
|
||
'abgeglichen' => QifParser::feld($eintrag, 'C') !== null,
|
||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||
'klasse_id' => $zuordnung['klasse_id'],
|
||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||
'quelle' => 'qif',
|
||
'import_hash' => $hash,
|
||
];
|
||
|
||
if ($trockenlauf) {
|
||
return 0;
|
||
}
|
||
|
||
return Buchung::anlegen($daten, $splits, QifParser::felder($eintrag, 'A'));
|
||
}
|
||
|
||
/**
|
||
* Wertpapiergeschäfte schlagen in Phase 2 nur auf dem Verrechnungskonto zu
|
||
* Buche. Bestand, Einstandswert und Kurse folgen in Phase 6 – bis dahin
|
||
* bliebe ein Betrag auf dem Depot ohne Gegenwert und würde die Summe der
|
||
* Kontostände verfälschen.
|
||
*/
|
||
private function wertpapierbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int
|
||
{
|
||
$ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L'));
|
||
$wert = QifParser::feld($eintrag, '$');
|
||
if ($ziel === null || $wert === null) {
|
||
return null;
|
||
}
|
||
|
||
$kontoId = $this->kontoId($ziel);
|
||
if ($kontoId === null) {
|
||
throw new \RuntimeException('Unbekanntes Verrechnungskonto „' . $ziel . '“.');
|
||
}
|
||
|
||
$betrag = -QifParser::betrag($wert);
|
||
$text = (string)QifParser::feld($eintrag, 'M');
|
||
$vorgang = (string)QifParser::feld($eintrag, 'N');
|
||
$papier = (string)QifParser::feld($eintrag, 'Y');
|
||
|
||
$hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $vorgang, $text);
|
||
if (Buchung::findeNachHash($hash) !== null) {
|
||
return null;
|
||
}
|
||
|
||
if ($trockenlauf) {
|
||
return 0;
|
||
}
|
||
|
||
return Buchung::anlegen([
|
||
'konto_id' => $kontoId,
|
||
'datum' => $datum,
|
||
'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()),
|
||
'betrag' => $betrag,
|
||
'empfaenger' => $papier !== '' ? $papier : (string)$eintrag['konto'],
|
||
'buchungstext' => trim($vorgang . ' ' . $text),
|
||
'nummer' => '',
|
||
'abgeglichen' => QifParser::feld($eintrag, 'C') !== null,
|
||
'quelle' => 'qif',
|
||
'import_hash' => $hash,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Welche Konten bekommen aus dieser Buchung eine Gegenbewegung, und mit
|
||
* welchem Betrag? Ohne Splits genau eines, mit Splits je Zeile eines.
|
||
*
|
||
* @return list<array{0: string, 1: float}>
|
||
*/
|
||
private function gegenbuchungen(array $eintrag, string $konto, float $betrag): array
|
||
{
|
||
$ziele = [];
|
||
$splits = QifParser::felder($eintrag, 'S');
|
||
|
||
if ($splits !== []) {
|
||
$betraege = QifParser::felder($eintrag, '$');
|
||
foreach ($splits as $nr => $angabe) {
|
||
$ziel = self::umbuchungsziel($angabe);
|
||
if ($ziel !== null && $ziel !== $konto) {
|
||
$ziele[] = [$ziel, -QifParser::betrag($betraege[$nr] ?? '0')];
|
||
}
|
||
}
|
||
return $ziele;
|
||
}
|
||
|
||
$ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L'));
|
||
if ($ziel !== null && $ziel !== $konto) {
|
||
$ziele[] = [$ziel, -$betrag];
|
||
}
|
||
|
||
return $ziele;
|
||
}
|
||
|
||
/**
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
private function splitzeilen(array $eintrag, int $kontoId): array
|
||
{
|
||
$kategorien = QifParser::felder($eintrag, 'S');
|
||
if ($kategorien === []) {
|
||
return [];
|
||
}
|
||
|
||
$betraege = QifParser::felder($eintrag, '$');
|
||
$notizen = QifParser::felder($eintrag, 'E');
|
||
$zeilen = [];
|
||
|
||
foreach ($kategorien as $nr => $angabe) {
|
||
$zuordnung = $this->zuordnung($angabe, $kontoId);
|
||
$zeilen[] = [
|
||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||
'klasse_id' => $zuordnung['klasse_id'],
|
||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||
'betrag' => QifParser::betrag($betraege[$nr] ?? '0'),
|
||
'notiz' => $notizen[$nr] ?? '',
|
||
];
|
||
}
|
||
|
||
return $zeilen;
|
||
}
|
||
|
||
/**
|
||
* Löst eine L- oder S-Angabe auf: Kategorie und Klasse, oder ein Gegenkonto.
|
||
*
|
||
* Verweist die Buchung auf ihr eigenes Konto, ist es die Eröffnungsbuchung
|
||
* des FinanzManagers – daraus wird keine Umbuchung, sonst hübe sie sich auf.
|
||
*
|
||
* @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int}
|
||
*/
|
||
private function zuordnung(string $angabe, int $kontoId): array
|
||
{
|
||
$leer = ['kategorie_id' => null, 'klasse_id' => null, 'gegenkonto_id' => null];
|
||
if (trim($angabe) === '') {
|
||
return $leer;
|
||
}
|
||
|
||
$teile = Kategorie::zerlegeQifAngabe($angabe);
|
||
$klasseId = $teile['klasse'] !== '' ? $this->klasseId($teile['klasse']) : null;
|
||
|
||
$ziel = self::umbuchungsziel($teile['kategorie']);
|
||
if ($ziel !== null) {
|
||
$gegen = $this->kontoId($ziel);
|
||
if ($gegen === null) {
|
||
$this->melde('fehler', 'Umbuchung auf unbekanntes Konto „' . $ziel . '“ – ohne Gegenkonto übernommen.');
|
||
return ['kategorie_id' => null, 'klasse_id' => $klasseId, 'gegenkonto_id' => null];
|
||
}
|
||
return [
|
||
'kategorie_id' => null,
|
||
'klasse_id' => $klasseId,
|
||
'gegenkonto_id' => $gegen === $kontoId ? null : $gegen,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'kategorie_id' => $teile['kategorie'] !== '' ? $this->kategorieId($teile['kategorie']) : null,
|
||
'klasse_id' => $klasseId,
|
||
'gegenkonto_id' => null,
|
||
];
|
||
}
|
||
|
||
/** `[Konto]` oder `|[Konto]` (Wertpapiere) → Kontoname, sonst null. */
|
||
public static function umbuchungsziel(?string $angabe): ?string
|
||
{
|
||
if ($angabe === null) {
|
||
return null;
|
||
}
|
||
$angabe = ltrim(trim($angabe), '|');
|
||
if (!str_starts_with($angabe, '[')) {
|
||
return null;
|
||
}
|
||
$ende = strpos($angabe, ']');
|
||
return $ende === false ? null : substr($angabe, 1, $ende - 1);
|
||
}
|
||
|
||
/**
|
||
* Identische Buchungen am selben Tag kommen vor (zweimal dasselbe im selben
|
||
* Laden). Der laufende Zähler hält sie auseinander und bleibt über Läufe
|
||
* hinweg stabil, weil die Datei immer dieselbe Reihenfolge hat.
|
||
*/
|
||
private function eindeutigerHash(int $kontoId, string $datum, float $betrag, string $nummer, string $text): string
|
||
{
|
||
$basis = Buchung::hash($kontoId, $datum, $betrag, $nummer, $text);
|
||
$n = $this->hashZaehler[$basis] = ($this->hashZaehler[$basis] ?? 0) + 1;
|
||
|
||
return $n === 1 ? $basis : sha1($basis . '#' . $n);
|
||
}
|
||
|
||
private function ladeStammdaten(): void
|
||
{
|
||
$this->konten = [];
|
||
$this->kategorien = [];
|
||
$this->klassen = [];
|
||
|
||
foreach (Konto::alle() as $konto) {
|
||
$this->konten[$konto['name']] = (int)$konto['id'];
|
||
}
|
||
foreach (Kategorie::alle() as $kategorie) {
|
||
$this->kategorien[$kategorie['pfad']] = (int)$kategorie['id'];
|
||
}
|
||
foreach (Klasse::alle() as $klasse) {
|
||
$this->klassen[$klasse['name']] = (int)$klasse['id'];
|
||
}
|
||
}
|
||
|
||
private function kontoId(string $name): ?int
|
||
{
|
||
return $this->konten[trim($name)] ?? null;
|
||
}
|
||
|
||
/** Fehlt die Kategorie, wird sie angelegt – der Export nennt nicht immer alle. */
|
||
private function kategorieId(string $pfad): ?int
|
||
{
|
||
$pfad = Kategorie::normalisierePfad($pfad);
|
||
if ($pfad === '') {
|
||
return null;
|
||
}
|
||
if (isset($this->kategorien[$pfad])) {
|
||
return $this->kategorien[$pfad];
|
||
}
|
||
|
||
$id = Kategorie::findenOderAnlegenNachPfad($pfad);
|
||
$this->kategorien[$pfad] = $id;
|
||
$this->melde('hinweis', 'Kategorie „' . $pfad . '“ war nicht angelegt und wurde ergänzt.');
|
||
|
||
return $id;
|
||
}
|
||
|
||
private function klasseId(string $name): ?int
|
||
{
|
||
$name = trim($name);
|
||
if ($name === '') {
|
||
return null;
|
||
}
|
||
if (!isset($this->klassen[$name])) {
|
||
$this->klassen[$name] = Klasse::findenOderAnlegen($name);
|
||
}
|
||
|
||
return $this->klassen[$name];
|
||
}
|
||
|
||
private function istGeldbuchung(array $eintrag): bool
|
||
{
|
||
return in_array($eintrag['typ'], self::GELDTYPEN, true) && $eintrag['konto'] !== null;
|
||
}
|
||
|
||
private function melde(string $typ, string $text): void
|
||
{
|
||
// Dieselbe Meldung nicht hundertfach wiederholen.
|
||
foreach ($this->meldungen as $vorhanden) {
|
||
if ($vorhanden['text'] === $text) {
|
||
return;
|
||
}
|
||
}
|
||
$this->meldungen[] = ['typ' => $typ, 'text' => $text];
|
||
}
|
||
}
|