diff --git a/.env.example b/.env.example index 130ad0af..3913a499 100644 --- a/.env.example +++ b/.env.example @@ -13,10 +13,14 @@ APP_MAINTENANCE_STORE=database BCRYPT_ROUNDS=12 -LOG_CHANNEL=stack -LOG_STACK=single +LOG_CHANNEL=daily +# Only used when LOG_CHANNEL=stack. +LOG_STACK=daily LOG_DEPRECATIONS_CHANNEL=null +# In production use "warning" — "debug" fills the disk with request noise. LOG_LEVEL=debug +# Days of rotated log files to keep (daily channel). +LOG_DAILY_DAYS=30 DB_CONNECTION=mariadb DB_HOST=127.0.0.1 @@ -31,7 +35,11 @@ FILESYSTEM_DISK=local QUEUE_CONNECTION=sync SESSION_DRIVER=file SESSION_LIFETIME=120 -SESSION_ENCRYPT=false +# With the file driver the session is a PHP-serialized file under +# storage/framework/sessions/. During a FinTS dialog it holds the online-banking PIN and the +# bank's session state - FintsConnectionHandler keeps the password in the session on purpose, +# so that it never reaches the database - which would otherwise sit there in cleartext. +SESSION_ENCRYPT=true SESSION_PATH=/ SESSION_DOMAIN=null diff --git a/README.md b/README.md index d7bfad76..985824f3 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ above with the right PHP/Composer/Node toolchain (versioned `php8.4`, local `composer.phar`, nvm) and put the app into maintenance mode during updates: - `stufis-setup` — first-time setup *and* idempotent toolchain refresh -- `stufis-update [tag|branch]` — deploy a release tag (or branch tip): self-update toolchain, migrate and rebuild a running instance +- `stufis-update [tag|branch]` — deploy a release tag (or branch tip): self-update toolchain, migrate, refresh the FinTS bank list and rebuild a running instance - `stufis-rebuild` — re-warm production caches and rebuild assets (no pull, no dependency install) - `stufis-down` / `stufis-up` — toggle maintenance mode (`stufis-down` prints a secret bypass URL) diff --git a/app/Console/Commands/UpdateFintsInstitutesCommand.php b/app/Console/Commands/UpdateFintsInstitutesCommand.php new file mode 100644 index 00000000..a2575f61 --- /dev/null +++ b/app/Console/Commands/UpdateFintsInstitutesCommand.php @@ -0,0 +1,267 @@ +option('file') ?: ($this->option('url') ?: config('stufis.fints.institute_list_url')); + + $contents = $this->option('file') + ? $this->readFile((string) $this->option('file')) + : $this->download((string) $source); + + if ($contents === null) { + return self::FAILURE; + } + + $parser = new InstituteListParser; + $upstream = $parser->parse($contents); + + $this->line(sprintf('Gelesen: %d Institute aus %s', count($upstream), $source)); + if ($parser->skipped > 0) { + $this->comment(sprintf('%d unbrauchbare Zeilen übersprungen.', $parser->skipped)); + } + if ($parser->insecureEndpoints > 0) { + $this->warn(sprintf( + '%d PIN/TAN-Adressen verworfen, weil sie nicht mit https:// beginnen.', + $parser->insecureEndpoints, + )); + } + + // A truncated download or an HTML error page would otherwise wipe the table. + $minEntries = (int) $this->option('min-entries'); + if (count($upstream) < $minEntries) { + $this->error(sprintf( + 'Nur %d Institute gefunden, erwartet mindestens %d - Quelle sieht unvollständig aus, Abbruch.', + count($upstream), + $minEntries, + )); + + return self::FAILURE; + } + + $existing = FintsInstitute::query() + ->get(['blz', ...FintsInstitute::SYNCED_FIELDS]) + ->keyBy('blz'); + + $new = []; + $changed = []; + foreach ($upstream as $blz => $institute) { + $current = $existing->get($blz); + + if ($current === null) { + $new[$blz] = $institute; + + continue; + } + + foreach (FintsInstitute::SYNCED_FIELDS as $field) { + if ($current->{$field} !== $institute[$field]) { + $changed[$blz] = $institute; + + break; + } + } + } + + $vanished = $existing->keys()->diff(array_keys($upstream)); + + $this->newLine(); + $this->table(['', 'Institute'], [ + ['neu', count($new)], + ['geändert', count($changed)], + ['unverändert', count($upstream) - count($new) - count($changed)], + ['nicht mehr in der Liste', $vanished->count()], + ]); + + $this->reportChanges($changed, $existing); + + if ($this->option('dry-run')) { + $this->comment('--dry-run: nichts geschrieben.'); + + return self::SUCCESS; + } + + $syncedAt = Date::now(); + $this->persist($upstream, $syncedAt); + + if ($vanished->isNotEmpty()) { + $this->handleVanished($vanished); + } + + $this->info(sprintf('Bankenliste aktualisiert (Stand %s).', $syncedAt->format('d.m.Y H:i'))); + + return self::SUCCESS; + } + + private function readFile(string $path): ?string + { + if (! is_readable($path)) { + $this->error("Datei nicht lesbar: $path"); + + return null; + } + + return (string) file_get_contents($path); + } + + private function download(string $url): ?string + { + if ($url === '') { + $this->error('Keine Quelle konfiguriert (stufis.fints.institute_list_url).'); + + return null; + } + + $this->line("Lade $url ..."); + + try { + $response = Http::timeout(30)->retry(2, 500, throw: false)->get($url); + } catch (ConnectionException $e) { + $this->error('Download fehlgeschlagen: '.$e->getMessage()); + + return null; + } + + if (! $response->successful()) { + $this->error(sprintf('Download fehlgeschlagen: HTTP %d', $response->status())); + + return null; + } + + return $response->body(); + } + + /** + * @param array> $upstream + */ + private function persist(array $upstream, Carbon $syncedAt): void + { + $rows = []; + foreach ($upstream as $blz => $institute) { + $rows[] = [ + 'blz' => $blz, + ...$institute, + 'synced_at' => $syncedAt, + 'created_at' => $syncedAt, + 'updated_at' => $syncedAt, + ]; + } + + DB::transaction(function () use ($rows): void { + foreach (array_chunk($rows, self::CHUNK_SIZE) as $chunk) { + FintsInstitute::query()->upsert( + $chunk, + ['blz'], + [...FintsInstitute::SYNCED_FIELDS, 'synced_at', 'updated_at'], + ); + } + }); + } + + /** + * @param Collection $vanished + */ + private function handleVanished(Collection $vanished): void + { + if (! $this->option('prune')) { + $this->comment(sprintf( + '%d Institute sind nicht mehr in der Liste, bleiben aber erhalten (--prune zum Löschen).', + $vanished->count(), + )); + + return; + } + + // A BLZ someone has a bank access for must survive, list or no list: konto_credentials + // references it, so deleting would either fail on the foreign key or take the access + // with it. Reported rather than swallowed - a bank leaving the list while we still + // bank there is worth a look. + $inUse = DB::table('konto_credentials') + ->whereIn('blz', $vanished->all()) + ->pluck('blz') + ->unique(); + + if ($inUse->isNotEmpty()) { + $this->warn(sprintf( + 'Nicht gelöscht, weil Bankzugänge darauf verweisen: %s', + $inUse->implode(', '), + )); + } + + // Delete exactly the BLZs we found to be absent, rather than everything with an + // older synced_at: that column has second precision, so two runs within the same + // second would silently prune nothing. + $deleted = 0; + foreach ($vanished->diff($inUse)->chunk(self::CHUNK_SIZE) as $chunk) { + $deleted += FintsInstitute::query()->whereIn('blz', $chunk->all())->delete(); + } + + $this->comment(sprintf('%d veraltete Institute gelöscht.', $deleted)); + } + + /** + * @param array> $changed + * @param Collection $existing + */ + private function reportChanges(array $changed, Collection $existing): void + { + if ($changed === []) { + return; + } + + // The interesting drift is the endpoint moving, not a bank renaming itself. + $movedEndpoints = []; + foreach ($changed as $blz => $institute) { + $before = $existing->get($blz)?->pin_tan_address; + if ($before !== $institute['pin_tan_address']) { + $movedEndpoints[] = [$blz, $institute['name'], $before ?? '-', $institute['pin_tan_address'] ?? '-']; + } + } + + if ($movedEndpoints !== []) { + $this->newLine(); + $this->line('Geänderte PIN/TAN-Endpunkte:'); + $this->table(['BLZ', 'Bank', 'vorher', 'nachher'], $movedEndpoints); + } + } +} diff --git a/app/Exceptions/LegacyDownloadException.php b/app/Exceptions/LegacyDownloadException.php new file mode 100644 index 00000000..c4cda765 --- /dev/null +++ b/app/Exceptions/LegacyDownloadException.php @@ -0,0 +1,21 @@ +bootstrap(); @@ -39,18 +47,38 @@ public function render(Request $request) // 'sectionTabs' => $this->resolveSectionTabs($request), ]); } catch (LegacyRedirectException $e) { + // Whatever the page printed before deciding to redirect is of no use. + $this->discardBufferedOutput($bufferLevel); + return $e->redirect; } catch (LegacyJsonException $e) { - ob_get_clean(); // throw away all output + $this->discardBufferedOutput($bufferLevel); return response()->json($e->content); + } catch (LegacyDownloadException $e) { + // A download must not be wrapped in the app layout - drop whatever the page + // buffered and hand the file response straight back. + $this->discardBufferedOutput($bufferLevel); + + return $e->response; } catch (\Exception $exception) { // get rid of the already printed html - ob_get_clean(); + $this->discardBufferedOutput($bufferLevel); throw $exception; } } + /** + * Drops every output buffer opened since $downToLevel, leaving that level intact - it + * belongs to whoever called us (PHPUnit opens one per test, for instance). + */ + private function discardBufferedOutput(int $downToLevel): void + { + while (ob_get_level() > $downToLevel) { + ob_end_clean(); + } + } + /** * The section sub-navigation tabs (Übersicht/TODO/Buchungen) used to be rendered * inside the legacy iframe; they are now lifted into the Laravel shell's tab bar. diff --git a/app/Models/FintsInstitute.php b/app/Models/FintsInstitute.php new file mode 100644 index 00000000..7c940fa6 --- /dev/null +++ b/app/Models/FintsInstitute.php @@ -0,0 +1,158 @@ + 'datetime', + ]; + } + + /** + * The bank accesses configured for this institute. Their existence is what keeps + * `--prune` from dropping an institute that is actually in use. + */ + public function credentials(): HasMany + { + return $this->hasMany(BankAccountCredential::class, 'blz', 'blz'); + } + + /** + * When the bank list was last pulled, or null while the table is still empty. + */ + public static function listDate(): ?Carbon + { + $latest = static::max('synced_at'); + + return $latest === null ? null : Date::parse($latest); + } + + public static function findByBlz(string|int $blz): ?static + { + return static::query()->where('blz', self::normaliseBlz($blz))->first(); + } + + /** + * Resolves a German IBAN to its institute: DE + 2 check digits + 8 digit BLZ + account. + * Foreign IBANs have no BLZ to extract, so they yield null. + */ + public static function findByIban(string $iban): ?static + { + $iban = strtoupper(preg_replace('/\s+/', '', $iban) ?? ''); + + if (! preg_match('/^DE\d{20}$/', $iban)) { + return null; + } + + return static::findByBlz(substr($iban, 4, 8)); + } + + /** + * Only institutes we could actually open a PIN/TAN dialog with. + */ + #[Scope] + protected function pinTanCapable(Builder $query): Builder + { + return $query->whereNotNull('pin_tan_address'); + } + + /** + * Whether a PIN/TAN endpoint is safe to open a dialog against. + * + * The PIN and every TAN travel over this URL, so plain `http://` would hand them to + * anyone on the path. phpFinTS points this out in `FinTsOptions` but does not check + * it, and the addresses reach us from two unverified places: the upstream bank list, + * and - for instances upgrading - the URL somebody once typed into `konto_bank` by + * hand, which the retiring migration carries over as-is. + */ + public static function hasSecurePinTanAddress(?string $address): bool + { + return $address !== null && str_starts_with(strtolower(trim($address)), 'https://'); + } + + /** + * Free-text lookup for a bank picker: name, BLZ or BIC. + */ + #[Scope] + protected function search(Builder $query, string $term): Builder + { + $term = trim($term); + + if ($term === '') { + return $query; + } + + return $query->where(function (Builder $query) use ($term): void { + $query->where('name', 'like', '%'.$term.'%') + ->orWhere('blz', 'like', $term.'%') + ->orWhere('bic', 'like', $term.'%'); + }); + } + + /** + * Bankleitzahlen are stored as the 8 digit string the list uses, but callers may + * hand us an int, as the legacy code does when it comes from a form. + */ + public static function normaliseBlz(string|int $blz): string + { + return str_pad(trim((string) $blz), 8, '0', STR_PAD_LEFT); + } +} diff --git a/app/Models/Legacy/Bank.php b/app/Models/Legacy/Bank.php deleted file mode 100644 index ba9d667c..00000000 --- a/app/Models/Legacy/Bank.php +++ /dev/null @@ -1,51 +0,0 @@ - $kontoCredentials - * @property-read int|null $konto_credentials_count - * - * @method static Builder|Bank newModelQuery() - * @method static Builder|Bank newQuery() - * @method static Builder|Bank query() - * @method static Builder|Bank whereBlz($value) - * @method static Builder|Bank whereId($value) - * @method static Builder|Bank whereName($value) - * @method static Builder|Bank whereUrl($value) - * - * @mixin \Eloquent - */ -class Bank extends Model -{ - /** - * The table associated with the model. - * - * @var string - */ - protected $table = 'konto_bank'; - - public $timestamps = false; - - /** - * @var array - */ - protected $fillable = ['url', 'blz', 'name']; - - public function kontoCredentials(): HasMany - { - return $this->hasMany(BankAccountCredential::class, 'bank_id'); - } -} diff --git a/app/Models/Legacy/BankAccount.php b/app/Models/Legacy/BankAccount.php index 5bc455b3..feab3a2f 100644 --- a/app/Models/Legacy/BankAccount.php +++ b/app/Models/Legacy/BankAccount.php @@ -90,4 +90,27 @@ public function bankTransactions(): HasMany { return $this->hasMany(BankTransaction::class, 'konto_id'); } + + /** + * The account behind the shortened IBAN the FinTS URLs carry (first four characters plus + * last four, see FintsConnectionHandler::shortenIban()). Null when no account is registered + * for it - which is a normal state there, the bank lists accounts this installation does not + * know yet. + * + * Several accounts can in principle share the four-and-four pattern; the lowest id wins, so + * a label built from this at least stays the same between two page loads. + */ + public static function findByShortIban(string $shortIban): ?static + { + // Guards the LIKE below against a route parameter carrying % or _ as much as it rejects + // anything that is not shaped like a shortened IBAN in the first place. + if (in_array(preg_match('/^[A-Z]{2}[A-Z0-9]{6}$/', $shortIban), [0, false], true)) { + return null; + } + + return static::query() + ->where('iban', 'like', substr($shortIban, 0, 4).'%'.substr($shortIban, -4)) + ->orderBy('id') + ->first(); + } } diff --git a/app/Models/Legacy/BankAccountCredential.php b/app/Models/Legacy/BankAccountCredential.php index 1c2327a7..a3f367f3 100644 --- a/app/Models/Legacy/BankAccountCredential.php +++ b/app/Models/Legacy/BankAccountCredential.php @@ -2,6 +2,7 @@ namespace App\Models\Legacy; +use App\Models\FintsInstitute; use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -11,20 +12,20 @@ * App\Models\Legacy\KontoCredential * * @property int $id - * @property int $bank_id + * @property string $blz * @property int $owner_id * @property string $name * @property string $bank_username * @property int $tan_mode * @property string $tan_medium_name * @property string $tan_mode_name - * @property Bank $kontoBank + * @property FintsInstitute $institute * @property User $user * * @method static Builder|BankAccountCredential newModelQuery() * @method static Builder|BankAccountCredential newQuery() * @method static Builder|BankAccountCredential query() - * @method static Builder|BankAccountCredential whereBankId($value) + * @method static Builder|BankAccountCredential whereBlz($value) * @method static Builder|BankAccountCredential whereBankUsername($value) * @method static Builder|BankAccountCredential whereId($value) * @method static Builder|BankAccountCredential whereName($value) @@ -37,16 +38,25 @@ */ class BankAccountCredential extends Model { + /** + * Never set, so Eloquent guessed "bank_account_credentials" and every query failed. The + * model had no callers to notice; it does now. + */ + protected $table = 'konto_credentials'; + public $timestamps = false; /** * @var array */ - protected $fillable = ['bank_id', 'owner_id', 'name', 'bank_username', 'tan_mode', 'tan_medium_name', 'tan_mode_name']; + protected $fillable = ['blz', 'owner_id', 'name', 'bank_username', 'tan_mode', 'tan_medium_name', 'tan_mode_name']; - public function kontoBank(): BelongsTo + /** + * The bank this access belongs to, straight from the synced bank list. + */ + public function institute(): BelongsTo { - return $this->belongsTo(Bank::class, 'bank_id'); + return $this->belongsTo(FintsInstitute::class, 'blz', 'blz'); } public function user(): BelongsTo diff --git a/app/Support/Fints/InstituteListParser.php b/app/Support/Fints/InstituteListParser.php new file mode 100644 index 00000000..c2b9da4f --- /dev/null +++ b/app/Support/Fints/InstituteListParser.php @@ -0,0 +1,104 @@ +> keyed by BLZ + */ + public function parse(string $contents): array + { + $this->skipped = 0; + $this->insecureEndpoints = 0; + $institutes = []; + + foreach (preg_split('/\R/', $contents) ?: [] as $line) { + $line = trim($line); + + // Properties comments. Blank lines fall out here too. + if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, '!')) { + continue; + } + + [$blz, $fields] = array_pad(explode('=', $line, 2), 2, null); + $blz = trim((string) $blz); + + if (! preg_match('/^\d{8}$/', $blz)) { + $this->skipped++; + + continue; + } + + $values = array_pad(explode('|', (string) $fields), count(self::COLUMNS), null); + $institute = []; + foreach (self::COLUMNS as $index => $column) { + $value = trim((string) $values[$index]); + $institute[$column] = $value === '' ? null : $value; + } + + // A row without a name carries nothing we could show a user. + if ($institute['name'] === null) { + $this->skipped++; + + continue; + } + + // Drop an endpoint we would not be allowed to send a PIN to rather than storing + // it and refusing later: without an address the institute is simply not offered + // as PIN/TAN capable, which is the truth of the matter. + if ($institute['pin_tan_address'] !== null + && ! FintsInstitute::hasSecurePinTanAddress($institute['pin_tan_address'])) { + $institute['pin_tan_address'] = null; + $this->insecureEndpoints++; + } + + // Later entries win, as they would when Java loads the properties file. + $institutes[$blz] = $institute; + } + + return $institutes; + } +} diff --git a/bin/stufis-update b/bin/stufis-update index 33d2049e..8ccb1c15 100755 --- a/bin/stufis-update +++ b/bin/stufis-update @@ -50,6 +50,18 @@ npm ci step "Running database migrations" php artisan migrate --force +# Refresh the FinTS bank list (bank names, BLZ, PIN/TAN endpoints). Runs after the +# migrations because they create the table it fills. Deliberately non-fatal despite +# `set -e`: the list is reference data fetched from an external source, and a network +# hiccup must not abort a deployment that is otherwise fine and leave the instance +# sitting in maintenance mode. Whatever was synced before stays in place, existing +# bank accesses keep working, and the next run picks the update up. +step "Updating the FinTS bank list" +php artisan stufis:fints-institutes-update || { + echo "Warning: could not update the FinTS bank list - continuing with the previous one." >&2 + echo " Re-run 'php artisan stufis:fints-institutes-update' once the cause is fixed." >&2 +} + # clear stale caches and warm the production caches step "Rebuilding caches" rebuild_caches diff --git a/composer.json b/composer.json index 380973bc..0bdfa8b2 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "openadministration/stufis", "type": "project", "description": "Webinterface für das Management und Digitalisierung von Finanzanträgen und deren Buchung für Studierendenschaften nach Deutschem Recht", - "version": "4.4.3", + "version": "4.4.4", "license": "AGPL", "require": { "php": "^8.4", diff --git a/composer.lock b/composer.lock index b6b1c9f3..3bff2443 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "38165806c793067ee49a86c65e14bfdc", + "content-hash": "c4d4ec3e9686eeb4b4c4cc6cdb8902c5", "packages": [ { "name": "ameax/datev-xml", @@ -196,16 +196,16 @@ }, { "name": "blade-ui-kit/blade-icons", - "version": "1.10.0", + "version": "1.10.1", "source": { "type": "git", "url": "https://github.com/driesvints/blade-icons.git", - "reference": "74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a" + "reference": "6e072d021ea6249986c330b93293c33d0c4f0e34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a", - "reference": "74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a", + "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/6e072d021ea6249986c330b93293c33d0c4f0e34", + "reference": "6e072d021ea6249986c330b93293c33d0c4f0e34", "shasum": "" }, "require": { @@ -273,7 +273,7 @@ "type": "paypal" } ], - "time": "2026-04-23T19:03:45+00:00" + "time": "2026-06-30T09:44:12+00:00" }, { "name": "brick/math", @@ -1479,16 +1479,16 @@ }, { "name": "giggsey/libphonenumber-for-php-lite", - "version": "9.0.32", + "version": "9.0.36", "source": { "type": "git", "url": "https://github.com/giggsey/libphonenumber-for-php-lite.git", - "reference": "8e3b2cfd8fb77b3922dad43f9a70d516c28570d2" + "reference": "f21237b7df458b326cd5b1bac44d64cc7b1dcb2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/8e3b2cfd8fb77b3922dad43f9a70d516c28570d2", - "reference": "8e3b2cfd8fb77b3922dad43f9a70d516c28570d2", + "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/f21237b7df458b326cd5b1bac44d64cc7b1dcb2f", + "reference": "f21237b7df458b326cd5b1bac44d64cc7b1dcb2f", "shasum": "" }, "require": { @@ -1553,7 +1553,7 @@ "issues": "https://github.com/giggsey/libphonenumber-for-php-lite/issues", "source": "https://github.com/giggsey/libphonenumber-for-php-lite" }, - "time": "2026-06-05T07:33:50+00:00" + "time": "2026-08-03T07:53:53+00:00" }, { "name": "globalcitizen/php-iban", @@ -1734,21 +1734,21 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.15.1", + "version": "7.15.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f" + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", - "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/promises": "^2.5.2", "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", @@ -1842,7 +1842,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.1" + "source": "https://github.com/guzzle/guzzle/tree/7.15.3" }, "funding": [ { @@ -1858,20 +1858,20 @@ "type": "tidelift" } ], - "time": "2026-07-18T11:23:11+00:00" + "time": "2026-08-05T19:48:21+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.1", + "version": "2.5.2", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", + "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", "shasum": "" }, "require": { @@ -1926,7 +1926,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.1" + "source": "https://github.com/guzzle/promises/tree/2.5.2" }, "funding": [ { @@ -1942,7 +1942,7 @@ "type": "tidelift" } ], - "time": "2026-07-08T15:48:39+00:00" + "time": "2026-08-05T19:30:54+00:00" }, { "name": "guzzlehttp/psr7", @@ -2065,21 +2065,21 @@ }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.7", + "version": "v1.0.10", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529" + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/7fe811c23a9e3cd712b4389eaeb50b5456d8c529", - "reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -2131,7 +2131,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.7" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" }, "funding": [ { @@ -2147,20 +2147,20 @@ "type": "tidelift" } ], - "time": "2026-06-12T21:33:43+00:00" + "time": "2026-07-17T13:53:03+00:00" }, { "name": "intervention/validation", - "version": "4.6.3", + "version": "4.7.0", "source": { "type": "git", "url": "https://github.com/Intervention/validation.git", - "reference": "ef4dac8b8ba2880bf91b184e98090dda0be51c67" + "reference": "ee0d1c3de8c30e685c8ff7fae05ca8d537d828ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/validation/zipball/ef4dac8b8ba2880bf91b184e98090dda0be51c67", - "reference": "ef4dac8b8ba2880bf91b184e98090dda0be51c67", + "url": "https://api.github.com/repos/Intervention/validation/zipball/ee0d1c3de8c30e685c8ff7fae05ca8d537d828ab", + "reference": "ee0d1c3de8c30e685c8ff7fae05ca8d537d828ab", "shasum": "" }, "require": { @@ -2219,7 +2219,7 @@ ], "support": { "issues": "https://github.com/Intervention/validation/issues", - "source": "https://github.com/Intervention/validation/tree/4.6.3" + "source": "https://github.com/Intervention/validation/tree/4.7.0" }, "funding": [ { @@ -2235,7 +2235,7 @@ "type": "ko_fi" } ], - "time": "2026-05-31T09:01:40+00:00" + "time": "2026-07-11T08:10:07+00:00" }, { "name": "jschaedl/iban-validation", @@ -2340,16 +2340,16 @@ }, { "name": "laravel/framework", - "version": "v12.62.0", + "version": "v12.65.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "f7e61eb1e0e06a38996802b769bce9127aec227c" + "reference": "99a8fb3153f962a323377d6742be08da86bcccb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/f7e61eb1e0e06a38996802b769bce9127aec227c", - "reference": "f7e61eb1e0e06a38996802b769bce9127aec227c", + "url": "https://api.github.com/repos/laravel/framework/zipball/99a8fb3153f962a323377d6742be08da86bcccb8", + "reference": "99a8fb3153f962a323377d6742be08da86bcccb8", "shasum": "" }, "require": { @@ -2558,20 +2558,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-06-09T13:50:13+00:00" + "time": "2026-08-05T15:33:16+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.18", + "version": "v0.3.22", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", - "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", + "url": "https://api.github.com/repos/laravel/prompts/zipball/02b89b39e8972a998db4d5d4ad4719239dd4aee4", + "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4", "shasum": "" }, "require": { @@ -2615,22 +2615,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.18" + "source": "https://github.com/laravel/prompts/tree/v0.3.22" }, - "time": "2026-05-19T00:47:18+00:00" + "time": "2026-08-04T14:50:50+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.13", + "version": "v2.0.15", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", - "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", "shasum": "" }, "require": { @@ -2678,20 +2678,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-04-16T14:03:50+00:00" + "time": "2026-07-21T16:49:22+00:00" }, { "name": "laravel/socialite", - "version": "v5.28.0", + "version": "v5.29.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "4c131ff4b24d8881a9c8fe4eecb5ffeff9803f26" + "reference": "cd343a5841f02292af119ee607edc71300c9ae4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/4c131ff4b24d8881a9c8fe4eecb5ffeff9803f26", - "reference": "4c131ff4b24d8881a9c8fe4eecb5ffeff9803f26", + "url": "https://api.github.com/repos/laravel/socialite/zipball/cd343a5841f02292af119ee607edc71300c9ae4f", + "reference": "cd343a5841f02292af119ee607edc71300c9ae4f", "shasum": "" }, "require": { @@ -2750,20 +2750,20 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-06-12T03:24:05+00:00" + "time": "2026-07-01T13:50:23+00:00" }, { "name": "league/commonmark", - "version": "2.8.2", + "version": "2.9.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + "reference": "72e9a87efcf41a8e83be3ed0866b69d77565cb12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/72e9a87efcf41a8e83be3ed0866b69d77565cb12", + "reference": "72e9a87efcf41a8e83be3ed0866b69d77565cb12", "shasum": "" }, "require": { @@ -2785,8 +2785,8 @@ "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4 || ^2.0", "nyholm/psr7": "^1.5", - "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", "scrutinizer/ocular": "^1.8.1", "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", @@ -2800,7 +2800,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "2.10-dev" } }, "autoload": { @@ -2857,7 +2857,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T13:16:38+00:00" + "time": "2026-08-11T00:58:45+00:00" }, { "name": "league/config", @@ -2943,16 +2943,16 @@ }, { "name": "league/flysystem", - "version": "3.34.0", + "version": "3.35.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + "reference": "b277b5dc3d56650b68904117124e79c851e12376" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", - "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", + "reference": "b277b5dc3d56650b68904117124e79c851e12376", "shasum": "" }, "require": { @@ -3020,9 +3020,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" }, - "time": "2026-05-14T10:28:08+00:00" + "time": "2026-07-06T14:42:07+00:00" }, { "name": "league/flysystem-local", @@ -3075,16 +3075,16 @@ }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -3094,7 +3094,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -3115,7 +3115,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -3127,7 +3127,7 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { "name": "league/oauth1-client", @@ -3389,16 +3389,16 @@ }, { "name": "livewire/flux", - "version": "v2.15.0", + "version": "v2.16.0", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "c570fb836278e9be881d08b7e386a1a62ab9faf7" + "reference": "b7e993d567dd7ffdcba42dcc7cdcb2163183b7f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/c570fb836278e9be881d08b7e386a1a62ab9faf7", - "reference": "c570fb836278e9be881d08b7e386a1a62ab9faf7", + "url": "https://api.github.com/repos/livewire/flux/zipball/b7e993d567dd7ffdcba42dcc7cdcb2163183b7f8", + "reference": "b7e993d567dd7ffdcba42dcc7cdcb2163183b7f8", "shasum": "" }, "require": { @@ -3406,7 +3406,7 @@ "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "illuminate/view": "^10.0|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1|^0.2|^0.3", - "livewire/livewire": "^3.7.4|^4.0", + "livewire/livewire": "^3.7.4|^4.0|dev-main", "php": "^8.1", "symfony/console": "^6.0|^7.0|^8.0" }, @@ -3449,26 +3449,26 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.15.0" + "source": "https://github.com/livewire/flux/tree/v2.16.0" }, - "time": "2026-06-19T02:25:18+00:00" + "time": "2026-08-09T17:23:35+00:00" }, { "name": "livewire/flux-pro", - "version": "2.15.0", + "version": "2.16.0", "dist": { "type": "zip", - "url": "https://composer.fluxui.dev/download/a20e9b56-b5e2-4704-a8b6-afad144c9932/flux-pro-2.15.0.zip", - "reference": "2b48353fa99cc68f6ce0ee77cf5eadaeb9cb572f", - "shasum": "a7e2520751aa7e5f49b44d537bfc70ebda71a7f9" + "url": "https://composer.fluxui.dev/download/a2785ad0-57b4-455c-8f7d-4f2810c5080b/flux-pro-2.16.0.zip", + "reference": "60a4c24de8676775a18c672561796f105a80bf72", + "shasum": "dbfe0b2c330b271e828e828ee69bcf5a34639d3a" }, "require": { "illuminate/console": "^10.0|^11.0|^12.0|^13.0", "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "illuminate/view": "^10.0|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1.24|^0.2|^0.3", - "livewire/flux": "2.15.0|dev-main", - "livewire/livewire": "^3.7.4|^4.0", + "livewire/flux": "2.16.0|dev-main", + "livewire/livewire": "^3.7.4|^4.0|dev-main", "php": "^8.1", "symfony/console": "^6.0|^7.0|^8.0" }, @@ -3524,20 +3524,20 @@ "livewire", "ui" ], - "time": "2026-06-19T02:29:55+00:00" + "time": "2026-08-10T16:07:15+00:00" }, { "name": "livewire/livewire", - "version": "v4.3.1", + "version": "v4.4.0", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "6a9dd03f45a4b200abfd0ff644745b23fa7baaaa" + "reference": "514b29d5a23594d4e4846494f580268b20c2f11e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/6a9dd03f45a4b200abfd0ff644745b23fa7baaaa", - "reference": "6a9dd03f45a4b200abfd0ff644745b23fa7baaaa", + "url": "https://api.github.com/repos/livewire/livewire/zipball/514b29d5a23594d4e4846494f580268b20c2f11e", + "reference": "514b29d5a23594d4e4846494f580268b20c2f11e", "shasum": "" }, "require": { @@ -3592,7 +3592,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v4.3.1" + "source": "https://github.com/livewire/livewire/tree/v4.4.0" }, "funding": [ { @@ -3600,7 +3600,7 @@ "type": "github" } ], - "time": "2026-06-02T08:58:52+00:00" + "time": "2026-08-10T15:24:22+00:00" }, { "name": "maatwebsite/excel", @@ -4121,12 +4121,12 @@ "source": { "type": "git", "url": "https://github.com/nemiah/phpFinTS.git", - "reference": "4800d6a75471af4c6bc314f7b87b141273969e11" + "reference": "751436372724f0a4f2ff28dcd886b48bb03da83e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nemiah/phpFinTS/zipball/4800d6a75471af4c6bc314f7b87b141273969e11", - "reference": "4800d6a75471af4c6bc314f7b87b141273969e11", + "url": "https://api.github.com/repos/nemiah/phpFinTS/zipball/751436372724f0a4f2ff28dcd886b48bb03da83e", + "reference": "751436372724f0a4f2ff28dcd886b48bb03da83e", "shasum": "" }, "require": { @@ -4162,20 +4162,20 @@ "issues": "https://github.com/nemiah/phpFinTS/issues", "source": "https://github.com/nemiah/phpFinTS/tree/master" }, - "time": "2026-05-23T09:39:57+00:00" + "time": "2026-07-23T15:46:08+00:00" }, { "name": "nesbot/carbon", - "version": "3.13.0", + "version": "3.13.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "40f6618f052df16b545f626fbf9a878e6497d16a" + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", - "reference": "40f6618f052df16b545f626fbf9a878e6497d16a", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", "shasum": "" }, "require": { @@ -4267,7 +4267,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T13:49:15+00:00" + "time": "2026-08-08T11:40:35+00:00" }, { "name": "nette/schema", @@ -4338,16 +4338,16 @@ }, { "name": "nette/utils", - "version": "v4.1.4", + "version": "v4.1.5", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", "shasum": "" }, "require": { @@ -4367,7 +4367,7 @@ }, "suggest": { "ext-gd": "to use Image", - "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", "ext-json": "to use Nette\\Utils\\Json", "ext-mbstring": "to use Strings::lower() etc...", @@ -4423,9 +4423,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.4" + "source": "https://github.com/nette/utils/tree/v4.1.5" }, - "time": "2026-05-11T20:49:54+00:00" + "time": "2026-07-17T23:02:45+00:00" }, { "name": "nunomaduro/termwind", @@ -4564,16 +4564,16 @@ }, { "name": "owenvoke/blade-fontawesome", - "version": "v3.2.2", + "version": "v3.3.1", "source": { "type": "git", "url": "https://github.com/owenvoke/blade-fontawesome.git", - "reference": "5a199630dd70c5133d58c158a974e12bcd8b3a71" + "reference": "e1f679a1da22e75bdad9fe19865c3b0bff83c5e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/owenvoke/blade-fontawesome/zipball/5a199630dd70c5133d58c158a974e12bcd8b3a71", - "reference": "5a199630dd70c5133d58c158a974e12bcd8b3a71", + "url": "https://api.github.com/repos/owenvoke/blade-fontawesome/zipball/e1f679a1da22e75bdad9fe19865c3b0bff83c5e3", + "reference": "e1f679a1da22e75bdad9fe19865c3b0bff83c5e3", "shasum": "" }, "require": { @@ -4608,7 +4608,7 @@ "description": "A package to easily make use of Font Awesome in your Laravel Blade views", "support": { "issues": "https://github.com/owenvoke/blade-fontawesome/issues", - "source": "https://github.com/owenvoke/blade-fontawesome/tree/v3.2.2" + "source": "https://github.com/owenvoke/blade-fontawesome/tree/v3.3.1" }, "funding": [ { @@ -4620,7 +4620,7 @@ "type": "github" } ], - "time": "2026-03-20T08:37:22+00:00" + "time": "2026-07-16T08:22:20+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -4743,16 +4743,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "1.30.5", + "version": "1.30.6", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "97bcabd32a64924688487dcd64aceaf158affb5c" + "reference": "a416375ffc8bf5b661c1bb4e6c60d8f3fddbe5ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/97bcabd32a64924688487dcd64aceaf158affb5c", - "reference": "97bcabd32a64924688487dcd64aceaf158affb5c", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/a416375ffc8bf5b661c1bb4e6c60d8f3fddbe5ce", + "reference": "a416375ffc8bf5b661c1bb4e6c60d8f3fddbe5ce", "shasum": "" }, "require": { @@ -4845,9 +4845,9 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.5" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.6" }, - "time": "2026-05-31T05:13:11+00:00" + "time": "2026-07-12T19:59:31+00:00" }, { "name": "phpoption/phpoption", @@ -4926,16 +4926,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.55", + "version": "3.0.56", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af" + "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/7adbbe38cde25e2df2116dbf2673c407e24fa305", + "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305", "shasum": "" }, "require": { @@ -5016,7 +5016,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.55" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.56" }, "funding": [ { @@ -5032,7 +5032,7 @@ "type": "tidelift" } ], - "time": "2026-06-14T23:24:10+00:00" + "time": "2026-08-03T04:36:50+00:00" }, { "name": "propaganistas/laravel-phone", @@ -6040,16 +6040,16 @@ }, { "name": "spatie/laravel-backup", - "version": "10.3.0", + "version": "10.3.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-backup.git", - "reference": "c026344f7e26321d6e1d214cd3e566ce5c54715d" + "reference": "1160cf6a6faa262586f47e2bd20c59512bb1a77e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-backup/zipball/c026344f7e26321d6e1d214cd3e566ce5c54715d", - "reference": "c026344f7e26321d6e1d214cd3e566ce5c54715d", + "url": "https://api.github.com/repos/spatie/laravel-backup/zipball/1160cf6a6faa262586f47e2bd20c59512bb1a77e", + "reference": "1160cf6a6faa262586f47e2bd20c59512bb1a77e", "shasum": "" }, "require": { @@ -6124,7 +6124,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-backup/issues", - "source": "https://github.com/spatie/laravel-backup/tree/10.3.0" + "source": "https://github.com/spatie/laravel-backup/tree/10.3.1" }, "funding": [ { @@ -6136,7 +6136,7 @@ "type": "other" } ], - "time": "2026-06-10T08:25:59+00:00" + "time": "2026-07-28T13:19:06+00:00" }, { "name": "spatie/laravel-csp", @@ -6225,16 +6225,16 @@ }, { "name": "spatie/laravel-model-states", - "version": "2.14.1", + "version": "2.14.2", "source": { "type": "git", "url": "https://github.com/spatie/laravel-model-states.git", - "reference": "772703a62478f578c1476aa2e6dcdc25339542df" + "reference": "a4a8451a28181005b90f1d79ed4e502573f50309" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-model-states/zipball/772703a62478f578c1476aa2e6dcdc25339542df", - "reference": "772703a62478f578c1476aa2e6dcdc25339542df", + "url": "https://api.github.com/repos/spatie/laravel-model-states/zipball/a4a8451a28181005b90f1d79ed4e502573f50309", + "reference": "a4a8451a28181005b90f1d79ed4e502573f50309", "shasum": "" }, "require": { @@ -6290,7 +6290,7 @@ "state" ], "support": { - "source": "https://github.com/spatie/laravel-model-states/tree/2.14.1" + "source": "https://github.com/spatie/laravel-model-states/tree/2.14.2" }, "funding": [ { @@ -6302,7 +6302,7 @@ "type": "github" } ], - "time": "2026-04-22T07:41:05+00:00" + "time": "2026-07-22T06:47:35+00:00" }, { "name": "spatie/laravel-package-tools", @@ -6642,16 +6642,16 @@ }, { "name": "spatie/temporary-directory", - "version": "2.3.1", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/spatie/temporary-directory.git", - "reference": "662e481d6ec07ef29fd05010433428851a42cd07" + "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/662e481d6ec07ef29fd05010433428851a42cd07", - "reference": "662e481d6ec07ef29fd05010433428851a42cd07", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/32cbb9645b28839cf4f476708e99a2c70e6802c9", + "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9", "shasum": "" }, "require": { @@ -6687,7 +6687,7 @@ ], "support": { "issues": "https://github.com/spatie/temporary-directory/issues", - "source": "https://github.com/spatie/temporary-directory/tree/2.3.1" + "source": "https://github.com/spatie/temporary-directory/tree/2.4.0" }, "funding": [ { @@ -6699,7 +6699,7 @@ "type": "github" } ], - "time": "2026-01-12T07:42:22+00:00" + "time": "2026-06-22T07:55:44+00:00" }, { "name": "staudenmeir/eloquent-has-many-deep-contracts", @@ -6952,16 +6952,16 @@ }, { "name": "symfony/console", - "version": "v7.4.13", + "version": "v7.4.16", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + "reference": "f4c69c9aed03abf933b294257d618bdd9b30a06d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "url": "https://api.github.com/repos/symfony/console/zipball/f4c69c9aed03abf933b294257d618bdd9b30a06d", + "reference": "f4c69c9aed03abf933b294257d618bdd9b30a06d", "shasum": "" }, "require": { @@ -7026,7 +7026,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.13" + "source": "https://github.com/symfony/console/tree/v7.4.16" }, "funding": [ { @@ -7046,7 +7046,7 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:56:14+00:00" + "time": "2026-07-31T12:37:14+00:00" }, { "name": "symfony/css-selector", @@ -7190,16 +7190,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.8", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/d49f6a19f326db41ae7103bdc38e3eb35a791261", + "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261", "shasum": "" }, "require": { @@ -7248,7 +7248,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + "source": "https://github.com/symfony/error-handler/tree/v7.4.15" }, "funding": [ { @@ -7268,20 +7268,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-07-21T15:13:06+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" + "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", + "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", "shasum": "" }, "require": { @@ -7334,7 +7334,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.2" }, "funding": [ { @@ -7354,20 +7354,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -7414,7 +7414,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -7434,20 +7434,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/finder", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e0be088d22278583a82da281886e8c3592fbf149" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", - "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { @@ -7482,7 +7482,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.8" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -7502,20 +7502,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.13", + "version": "v7.4.16", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "bc354f47c62301e990b7874fa662326368508e2c" + "reference": "b676451bb638e99a7d34d8a2be90406822e301eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", - "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/b676451bb638e99a7d34d8a2be90406822e301eb", + "reference": "b676451bb638e99a7d34d8a2be90406822e301eb", "shasum": "" }, "require": { @@ -7564,7 +7564,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.16" }, "funding": [ { @@ -7584,20 +7584,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:20:33+00:00" + "time": "2026-08-07T11:50:27+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.13", + "version": "v7.4.16", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "9df847980c436451f4f51d1284491bb4356dd989" + "reference": "f5e728670fa2218ae8be8ea91f2b44b7d6e5304c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", - "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f5e728670fa2218ae8be8ea91f2b44b7d6e5304c", + "reference": "f5e728670fa2218ae8be8ea91f2b44b7d6e5304c", "shasum": "" }, "require": { @@ -7655,7 +7655,7 @@ "symfony/validator": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0", "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "type": "library", "autoload": { @@ -7683,7 +7683,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.16" }, "funding": [ { @@ -7703,20 +7703,20 @@ "type": "tidelift" } ], - "time": "2026-05-27T08:31:43+00:00" + "time": "2026-08-07T18:00:13+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.12", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" + "reference": "68c1f27c97edd0222eb8d440a6c8c4da5354ab46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", + "url": "https://api.github.com/repos/symfony/mailer/zipball/68c1f27c97edd0222eb8d440a6c8c4da5354ab46", + "reference": "68c1f27c97edd0222eb8d440a6c8c4da5354ab46", "shasum": "" }, "require": { @@ -7767,7 +7767,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.12" + "source": "https://github.com/symfony/mailer/tree/v7.4.15" }, "funding": [ { @@ -7787,20 +7787,20 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-07-28T07:33:02+00:00" }, { "name": "symfony/mime", - "version": "v7.4.13", + "version": "v7.4.16", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + "reference": "20094b76a7106dbe978d31cd3bd7aa1ed248d0e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", - "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "url": "https://api.github.com/repos/symfony/mime/zipball/20094b76a7106dbe978d31cd3bd7aa1ed248d0e5", + "reference": "20094b76a7106dbe978d31cd3bd7aa1ed248d0e5", "shasum": "" }, "require": { @@ -7856,7 +7856,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.13" + "source": "https://github.com/symfony/mime/tree/v7.4.16" }, "funding": [ { @@ -7876,7 +7876,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:22:37+00:00" + "time": "2026-08-07T14:56:57+00:00" }, { "name": "symfony/options-resolver", @@ -8034,16 +8034,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -8092,7 +8092,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -8112,7 +8112,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-idn", @@ -8457,16 +8457,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.2", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", "shasum": "" }, "require": { @@ -8513,7 +8513,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" }, "funding": [ { @@ -8533,7 +8533,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:51:48+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-php84", @@ -8617,16 +8617,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -8673,7 +8673,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -8693,7 +8693,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-uuid", @@ -8845,16 +8845,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.13", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", - "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "url": "https://api.github.com/repos/symfony/routing/zipball/80c0a93d3f8e7499f716204a1fb38ead942a7a2b", + "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b", "shasum": "" }, "require": { @@ -8906,7 +8906,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.13" + "source": "https://github.com/symfony/routing/tree/v7.4.15" }, "funding": [ { @@ -8926,20 +8926,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:20:33+00:00" + "time": "2026-07-21T15:13:06+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -8993,7 +8993,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -9013,20 +9013,20 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", "shasum": "" }, "require": { @@ -9083,7 +9083,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/string/tree/v8.1.2" }, "funding": [ { @@ -9103,20 +9103,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-28T07:35:25+00:00" }, { "name": "symfony/translation", - "version": "v8.1.0", + "version": "v8.1.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", - "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "url": "https://api.github.com/repos/symfony/translation/zipball/c0955eb4aa417a110e65c8162237b8a5c7d910bf", + "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf", "shasum": "" }, "require": { @@ -9176,7 +9176,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.0" + "source": "https://github.com/symfony/translation/tree/v8.1.4" }, "funding": [ { @@ -9196,20 +9196,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-30T12:40:56+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -9258,7 +9258,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -9278,7 +9278,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/uid", @@ -9360,16 +9360,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.8", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", + "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", "shasum": "" }, "require": { @@ -9385,7 +9385,7 @@ "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/uid": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -9423,7 +9423,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.15" }, "funding": [ { @@ -9443,7 +9443,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:44:50+00:00" + "time": "2026-07-21T15:13:06+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -9502,16 +9502,16 @@ }, { "name": "twig/twig", - "version": "v3.27.1", + "version": "v3.28.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "shasum": "" }, "require": { @@ -9566,7 +9566,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.27.1" + "source": "https://github.com/twigphp/Twig/tree/v3.28.0" }, "funding": [ { @@ -9578,20 +9578,20 @@ "type": "tidelift" } ], - "time": "2026-05-30T17:09:26+00:00" + "time": "2026-07-03T20:44:34+00:00" }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.6.4", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", "shasum": "" }, "require": { @@ -9650,7 +9650,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" }, "funding": [ { @@ -9662,7 +9662,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2026-07-06T19:11:50+00:00" }, { "name": "voku/portable-ascii", @@ -9742,16 +9742,16 @@ "packages-dev": [ { "name": "barryvdh/laravel-debugbar", - "version": "v4.3.0", + "version": "v4.4.1", "source": { "type": "git", "url": "https://github.com/fruitcake/laravel-debugbar.git", - "reference": "3d76ea8d78b82225b92789de65fc630c1cd8e80c" + "reference": "389157fb616e5c5d19d16a88fd9bfcf3e3248e9b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/3d76ea8d78b82225b92789de65fc630c1cd8e80c", - "reference": "3d76ea8d78b82225b92789de65fc630c1cd8e80c", + "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/389157fb616e5c5d19d16a88fd9bfcf3e3248e9b", + "reference": "389157fb616e5c5d19d16a88fd9bfcf3e3248e9b", "shasum": "" }, "require": { @@ -9759,11 +9759,12 @@ "illuminate/session": "^11|^12|^13.0", "illuminate/support": "^11|^12|^13.0", "php": "^8.2", - "php-debugbar/php-debugbar": "^3.7.2", + "php-debugbar/php-debugbar": "^3.8.0", "php-debugbar/symfony-bridge": "^1.1" }, "require-dev": { "larastan/larastan": "^3", + "laravel/ai": "^0.8", "laravel/octane": "^2", "laravel/pennant": "^1", "laravel/pint": "^1", @@ -9825,7 +9826,7 @@ ], "support": { "issues": "https://github.com/fruitcake/laravel-debugbar/issues", - "source": "https://github.com/fruitcake/laravel-debugbar/tree/v4.3.0" + "source": "https://github.com/fruitcake/laravel-debugbar/tree/v4.4.1" }, "funding": [ { @@ -9837,7 +9838,7 @@ "type": "github" } ], - "time": "2026-06-04T07:54:01+00:00" + "time": "2026-08-03T06:23:16+00:00" }, { "name": "barryvdh/laravel-ide-helper", @@ -10817,16 +10818,16 @@ }, { "name": "laravel/pint", - "version": "v1.29.3", + "version": "v1.30.5", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14" + "reference": "fe4148c503a0e266353d61396b79bbf7f35122df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14", - "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "url": "https://api.github.com/repos/laravel/pint/zipball/fe4148c503a0e266353d61396b79bbf7f35122df", + "reference": "fe4148c503a0e266353d61396b79bbf7f35122df", "shasum": "" }, "require": { @@ -10834,17 +10835,19 @@ "ext-mbstring": "*", "ext-tokenizer": "*", "ext-xml": "*", - "php": "^8.2.0" + "php": "^8.3.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.95.8", - "illuminate/view": "^12.62.0", + "composer/semver": "^3.4.4", + "friendsofphp/php-cs-fixer": "^3.95.18", + "illuminate/view": "^13.24.0", "larastan/larastan": "^3.10.0", - "laravel-zero/framework": "^12.1.0", + "laravel-zero/framework": "^13.0.0", "laravel/agent-detector": "^2.0.2", + "laravel/prompts": "^0.3.22", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", - "pestphp/pest": "^3.8.6" + "pestphp/pest": "^4.7.8" }, "bin": [ "builds/pint" @@ -10881,20 +10884,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-06-16T15:34:04+00:00" + "time": "2026-08-10T15:35:50+00:00" }, { "name": "laravel/sail", - "version": "v1.62.0", + "version": "v1.65.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e" + "reference": "d4b92139858d2a189a6302ca133e494f58afe20b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", - "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "url": "https://api.github.com/repos/laravel/sail/zipball/d4b92139858d2a189a6302ca133e494f58afe20b", + "reference": "d4b92139858d2a189a6302ca133e494f58afe20b", "shasum": "" }, "require": { @@ -10944,7 +10947,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-05-27T04:02:01+00:00" + "time": "2026-08-03T18:00:17+00:00" }, { "name": "laravel/tinker", @@ -11017,26 +11020,26 @@ }, { "name": "larswiegers/laravel-translations-checker", - "version": "v0.9.3", + "version": "v0.9.4", "source": { "type": "git", "url": "https://github.com/LarsWiegers/laravel-translations-checker.git", - "reference": "205cb106dd0d96be1a117ca14c75958eb6aaae86" + "reference": "04637f5aecbf08908c00f7dbd77e6604b70d8581" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/LarsWiegers/laravel-translations-checker/zipball/205cb106dd0d96be1a117ca14c75958eb6aaae86", - "reference": "205cb106dd0d96be1a117ca14c75958eb6aaae86", + "url": "https://api.github.com/repos/LarsWiegers/laravel-translations-checker/zipball/04637f5aecbf08908c00f7dbd77e6604b70d8581", + "reference": "04637f5aecbf08908c00f7dbd77e6604b70d8581", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^7.4|^8.0|^8.1|^8.2" }, "require-dev": { "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "phpunit/phpunit": "^9.0|^10.0|^11.0" + "phpunit/phpunit": "^9.0|^10.0|^11.0|^12.5.12" }, "type": "library", "extra": { @@ -11073,9 +11076,9 @@ ], "support": { "issues": "https://github.com/LarsWiegers/laravel-translations-checker/issues", - "source": "https://github.com/LarsWiegers/laravel-translations-checker/tree/v0.9.3" + "source": "https://github.com/LarsWiegers/laravel-translations-checker/tree/v0.9.4" }, - "time": "2025-02-20T18:40:31+00:00" + "time": "2026-07-01T15:39:31+00:00" }, { "name": "magentron/laravel-blade-lint", @@ -11234,20 +11237,20 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -11282,32 +11285,31 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -11346,29 +11348,29 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "nunomaduro/collision", - "version": "v8.9.4", + "version": "v8.9.5", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.8 || ^8.0.8" + "symfony/console": "^7.4.14 || ^8.1.1" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -11376,12 +11378,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.6", - "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", - "laravel/pint": "^1.29.1", - "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", - "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" }, "type": "library", "extra": { @@ -11444,47 +11446,47 @@ "type": "patreon" } ], - "time": "2026-04-21T14:04:20+00:00" + "time": "2026-07-15T19:09:14+00:00" }, { "name": "pestphp/pest", - "version": "v4.7.3", + "version": "v4.7.8", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "87882a8561bf3ddf230b9a6b764f367f687d5b2f" + "reference": "5b2293f67adcf1b2320b33f521b94a692d18f360" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/87882a8561bf3ddf230b9a6b764f367f687d5b2f", - "reference": "87882a8561bf3ddf230b9a6b764f367f687d5b2f", + "url": "https://api.github.com/repos/pestphp/pest/zipball/5b2293f67adcf1b2320b33f521b94a692d18f360", + "reference": "5b2293f67adcf1b2320b33f521b94a692d18f360", "shasum": "" }, "require": { "brianium/paratest": "^7.20.0", "composer/xdebug-handler": "^3.0.5", - "nunomaduro/collision": "^8.9.4", + "nunomaduro/collision": "^8.9.5", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.29", + "phpunit/phpunit": "^12.5.33", "symfony/process": "^7.4.13|^8.1.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.29", + "phpunit/phpunit": ">12.5.33", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "mrpunyapal/peststan": "^0.2.10", + "mrpunyapal/peststan": "^0.2.12", "pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-type-coverage": "^4.0.4", - "psy/psysh": "^0.12.23" + "psy/psysh": "^0.12.24" }, "bin": [ "bin/pest" @@ -11511,7 +11513,6 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", - "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, @@ -11551,7 +11552,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.7.3" + "source": "https://github.com/pestphp/pest/tree/v4.7.8" }, "funding": [ { @@ -11563,7 +11564,7 @@ "type": "github" } ], - "time": "2026-06-12T05:57:27+00:00" + "time": "2026-08-03T20:49:44+00:00" }, { "name": "pestphp/pest-plugin", @@ -12097,16 +12098,16 @@ }, { "name": "php-debugbar/php-debugbar", - "version": "v3.7.6", + "version": "v3.8.0", "source": { "type": "git", "url": "https://github.com/php-debugbar/php-debugbar.git", - "reference": "1690ee1728827f9deb4b60457fa387cf44672c56" + "reference": "18ced90d4b882ed449b2278fea8692f8f7d1c13c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/1690ee1728827f9deb4b60457fa387cf44672c56", - "reference": "1690ee1728827f9deb4b60457fa387cf44672c56", + "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/18ced90d4b882ed449b2278fea8692f8f7d1c13c", + "reference": "18ced90d4b882ed449b2278fea8692f8f7d1c13c", "shasum": "" }, "require": { @@ -12148,7 +12149,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "3.8-dev" } }, "autoload": { @@ -12183,7 +12184,7 @@ ], "support": { "issues": "https://github.com/php-debugbar/php-debugbar/issues", - "source": "https://github.com/php-debugbar/php-debugbar/tree/v3.7.6" + "source": "https://github.com/php-debugbar/php-debugbar/tree/v3.8.0" }, "funding": [ { @@ -12195,7 +12196,7 @@ "type": "github" } ], - "time": "2026-04-30T07:31:44+00:00" + "time": "2026-07-02T12:38:20+00:00" }, { "name": "php-debugbar/symfony-bridge", @@ -12441,16 +12442,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "version": "2.3.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "shasum": "" }, "require": { @@ -12482,17 +12483,17 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2026-07-08T07:01:06+00:00" }, { "name": "phpstan/phpstan", - "version": "2.2.2", + "version": "2.2.8", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", - "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e285254e60f33c21902efef4a926ca0987c06804", + "reference": "e285254e60f33c21902efef4a926ca0987c06804", "shasum": "" }, "require": { @@ -12548,7 +12549,7 @@ "type": "github" } ], - "time": "2026-06-05T09:00:01+00:00" + "time": "2026-08-04T22:21:45+00:00" }, { "name": "phpunit/php-code-coverage", @@ -12897,24 +12898,24 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.29", + "version": "12.5.33", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a" + "reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9aa66a47db3ea70f1a468e66dd969f67e594945a", - "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b98e028a26c5c5ba7e4a54be96ccf35f2914d184", + "reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", @@ -12975,7 +12976,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.29" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.33" }, "funding": [ { @@ -12983,20 +12984,20 @@ "type": "other" } ], - "time": "2026-06-04T06:14:42+00:00" + "time": "2026-07-28T13:58:09+00:00" }, { "name": "psy/psysh", - "version": "v0.12.23", + "version": "v0.12.24", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", - "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", "shasum": "" }, "require": { @@ -13060,27 +13061,27 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" }, - "time": "2026-05-23T13:41:31+00:00" + "time": "2026-06-29T15:41:09+00:00" }, { "name": "rector/rector", - "version": "2.4.6", + "version": "2.6.1", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee" + "reference": "b8e68f058bca43e01a2e1caa51ef022d6551ed95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/9b9e5c76618e4d359f65b54ca2eabcad3d1761ee", - "reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/b8e68f058bca43e01a2e1caa51ef022d6551ed95", + "reference": "b8e68f058bca43e01a2e1caa51ef022d6551ed95", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.2.2" + "phpstan/phpstan": "^2.2.6" }, "conflict": { "rector/rector-doctrine": "*", @@ -13114,7 +13115,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.6" + "source": "https://github.com/rectorphp/rector/tree/2.6.1" }, "funding": [ { @@ -13122,7 +13123,7 @@ "type": "github" } ], - "time": "2026-06-17T11:56:28+00:00" + "time": "2026-08-03T17:30:34+00:00" }, { "name": "roave/security-advisories", @@ -13130,18 +13131,19 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "d59bd7f09761435c5818e64cab019ca56e0137cd" + "reference": "f4f38b8750fb0db0242fb03d4727517e3611da8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/d59bd7f09761435c5818e64cab019ca56e0137cd", - "reference": "d59bd7f09761435c5818e64cab019ca56e0137cd", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/f4f38b8750fb0db0242fb03d4727517e3611da8b", + "reference": "f4f38b8750fb0db0242fb03d4727517e3611da8b", "shasum": "" }, "conflict": { "3f/pygmentize": "<1.2", "adaptcms/adaptcms": "<=1.3", - "admidio/admidio": "<=5.0.9", + "adawolfa/isdoc": "<1.4.3|>=1.5,<1.5.1|>=1.6,<1.6.1", + "admidio/admidio": "<=5.0.11", "adodb/adodb-php": "<=5.22.9", "aheinze/cockpit": "<2.2", "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", @@ -13152,6 +13154,7 @@ "aimeos/aimeos-core": ">=2022.04.1,<2022.10.17|>=2023.04.1,<2023.10.17|>=2024.04.1,<2024.04.7", "aimeos/aimeos-laravel": "==2021.10", "aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5", + "aimeos/pagible": "<0.10.4", "airesvsg/acf-to-rest-api": "<=3.1", "akaunting/akaunting": "<2.1.13", "akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53", @@ -13174,8 +13177,10 @@ "aoe/restler": "<1.7.1", "apache-solr-for-typo3/solr": "<2.8.3", "apereo/phpcas": "<1.6", - "api-platform/core": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", + "api-platform/core": "<4.1.30|>=4.2,<4.2.26|>=4.3,<4.3.12", "api-platform/graphql": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", + "api-platform/hal": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", + "api-platform/json-api": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", "appwrite/server-ce": "<=1.2.1", "arc/web": "<3", "area17/twill": "<1.2.5|>=2,<2.5.3", @@ -13188,7 +13193,7 @@ "austintoddj/canvas": "<=3.4.2", "auth0/auth0-php": ">=3.3,<=8.18", "auth0/login": "<=7.20", - "auth0/symfony": "<=5.7", + "auth0/symfony": "<=5.8", "auth0/wordpress": "<=5.5", "automad/automad": "<=2.0.0.0-beta27", "automattic/jetpack": "<9.8", @@ -13237,13 +13242,13 @@ "cachethq/cachet": "<2.5.1", "cadmium-org/cadmium-cms": "<=0.4.9", "cakephp/authentication": "<3.3.6|>=4,<4.1.1", - "cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.1,<4.1.4|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10|>=5.2.10,<5.2.12|==5.3", + "cakephp/cakephp": "<4.5.11|>=4.6,<4.6.4|>=5,<5.1.7|>=5.2,<5.2.13|>=5.3,<5.3.6", "cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", "cardgate/magento2": "<2.0.33", "cardgate/woocommerce": "<=3.1.15", - "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4", + "cart2quote/module-quotation": ">=4.1.6,<4.4.6|>=5,<5.4.4", "cart2quote/module-quotation-encoded": ">=4.1.6,<=4.4.5|>=5,<5.4.4", - "cartalyst/sentry": "<=2.1.6", + "cartalyst/sentry": "<2.1.7", "catfan/medoo": "<1.7.5", "causal/oidc": "<4", "cecil/cecil": "<7.47.1", @@ -13258,35 +13263,36 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22", + "code16/sharp": "<9.22.3", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", - "codeigniter4/framework": "<4.7.2", + "codeigniter4/framework": "<4.7.4", "codeigniter4/shield": "<1.0.0.0-beta8", "codiad/codiad": "<=2.8.4", "codingms/additional-tca": ">=1.7,<1.15.17|>=1.16,<1.16.9", "codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5", "commerceteam/commerce": ">=0.9.6,<0.9.9", "components/jquery": ">=1.0.3,<3.5", - "composer/composer": "<2.2.28|>=2.3,<2.9.8", - "concrete5/concrete5": "<9.4.8", + "composer/composer": "<2.2.29|>=2.3,<2.10.2", + "concrete5/concrete5": "<9.5.2", "concrete5/core": "<8.5.8|>=9,<9.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", - "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<4.13.56|>=5,<5.3.38|>=5.4.0.0-RC1-dev,<5.6.1", + "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<5.3.48|>=5.4,<5.7.9", "contao/core": "<3.5.39", - "contao/core-bundle": "<4.13.57|>=5,<5.3.42|>=5.4,<5.6.5", + "contao/core-bundle": "<5.3.48|>=5.4,<5.7.9", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "coreshop/core-shop": "<4.1.9|==5", "corveda/phpsandbox": "<1.3.5", "cosenary/instagram": "<=2.3", + "cotonti/cotonti": "<=1", "couleurcitron/tarteaucitron-wp": "<0.3", "cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2", "craftcms/aws-s3": ">=2.0.2,<=2.2.4", "craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1", - "craftcms/cms": "<4.17.12|>=5,<5.9.18", - "craftcms/commerce": ">=4,<4.11|>=5,<5.6", + "craftcms/cms": "<4.18.3|>=5,<5.10.8", + "craftcms/commerce": ">=4,<=4.11.1|>=5,<=5.6.4", "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21", "craftcms/google-cloud": ">=2.0.0.0-beta1,<=2.2", @@ -13327,7 +13333,7 @@ "doctrine/mongodb-odm-bundle": "<3.0.1", "doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", "dolibarr/dolibarr": "<=23.0.2", - "dompdf/dompdf": "<2.0.4", + "dompdf/dompdf": "<3.1.6", "doublethreedigital/guest-entries": "<3.1.2", "dreamfactory/df-core": "<1.0.4", "drupal-pattern-lab/unified-twig-extensions": "<=0.1", @@ -13372,7 +13378,7 @@ "ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.3.1", "ecodev/newsletter": "<=4", "ectouch/ectouch": "<=2.7.2", - "egroupware/egroupware": "<23.1.20260113|>=26.0.20251208,<26.0.20260113", + "egroupware/egroupware": "<23.1.20260601|>=26.0.20251208,<26.5.20260507", "elefant/cms": "<2.0.7", "elgg/elgg": "<3.3.24|>=4,<4.0.5", "elijaa/phpmemcacheadmin": "<=1.3", @@ -13407,16 +13413,16 @@ "ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15", "ezyang/htmlpurifier": "<=4.2", "facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2", - "facturascripts/facturascripts": "<=2025.92|>=2026,<=2026.1", + "facturascripts/facturascripts": "<=2026.2", "fastly/magento2": "<1.2.26", "feehi/cms": "<=2.1.1", "feehi/feehicms": "<=2.1.1", "fenom/fenom": "<=2.12.1", "filament/actions": ">=3.2,<3.2.123|>=4,<=4.11.3|>=5,<=5.6.3", - "filament/filament": ">=4,<4.3.1", + "filament/filament": ">=3,<=3.3.51|>=4,<4.11.5|>=5,<5.6.5", "filament/forms": ">=3,<=3.3.52", - "filament/infolists": ">=3,<3.2.115", - "filament/tables": ">=3,<=3.3.50|>=4,<4.8.5|>=5,<5.3.5", + "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", + "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", "filegator/filegator": "<7.8", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", @@ -13482,10 +13488,10 @@ "gregwar/rst": "<1.0.3", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", - "guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5", + "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", "guzzlehttp/guzzle-services": "<1.5.4", "guzzlehttp/oauth-subscriber": "<0.8.1", - "guzzlehttp/psr7": "<2.10.2", + "guzzlehttp/psr7": "<2.12.3", "haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2", "handcraftedinthealps/goodby-csv": "<1.4.3", "harvesthq/chosen": "<1.8.7", @@ -13540,6 +13546,7 @@ "jasig/phpcas": "<1.3.3", "jbartels/wec-map": "<3.0.3", "jcbrand/converse.js": "<3.3.3", + "jleehr/canto-saas-api": "<=2", "joedolson/my-calendar": "<3.7.7", "joelbutcher/socialstream": "<5.6|>=6,<6.2", "johnbillion/query-monitor": "<3.20.4", @@ -13566,7 +13573,7 @@ "kelvinmo/simplexrd": "<3.1.1", "kevinpapst/kimai2": "<1.16.7", "khodakhah/nodcms": "<=3.4.1", - "kimai/kimai": "<=2.55", + "kimai/kimai": "<2.59", "kitodo/presentation": "<3.2.3|>=3.3,<3.3.4", "klaviyo/magento2-extension": ">=1,<3", "knplabs/knp-snappy": "<=1.7", @@ -13593,7 +13600,7 @@ "lavalite/cms": "<=10.1", "lavitto/typo3-form-to-database": "<2.2.5|>=3,<3.2.2|>=4,<4.2.3|>=5,<5.0.2", "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", - "league/commonmark": "<=2.8.1", + "league/commonmark": "<2.9", "league/flysystem": "<1.1.4|>=2,<2.1.1", "league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3", "leantime/leantime": "<3.3", @@ -13602,7 +13609,7 @@ "librenms/librenms": "<26.3", "liftkit/database": "<2.13.2", "lightsaml/lightsaml": "<1.3.5", - "limesurvey/limesurvey": "<6.15.4", + "limesurvey/limesurvey": "<=7.0.0.0-beta1", "livehelperchat/livehelperchat": "<=3.91", "livewire-filemanager/filemanager": "<=1.0.4", "livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.6.4", @@ -13625,13 +13632,13 @@ "maikuolan/phpmussel": ">=1,<1.6", "mainwp/mainwp": "<=4.4.3.3", "manogi/nova-tiptap": "<=3.2.6", - "mantisbt/mantisbt": "<2.28.2", + "mantisbt/mantisbt": "<=2.28.3", "marcwillmann/turn": "<0.3.3", "markhuot/craftql": "<=1.3.7", "marshmallow/nova-tiptap": "<5.7", "matomo/matomo": "<1.11", "matyhtf/framework": "<3.0.6", - "mautic/core": "<5.2.10|>=6,<6.0.8|>=7.0.0.0-alpha,<7.0.1", + "mautic/core": "<5.2.11|>=6,<6.0.9|>=7,<7.1.2", "mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1", "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", @@ -13641,6 +13648,7 @@ "mediawiki/cargo": "<3.8.3", "mediawiki/core": "<1.39.5|==1.40", "mediawiki/data-transfer": ">=1.39,<1.39.11|>=1.41,<1.41.3|>=1.42,<1.42.2", + "mediawiki/maps": "<12.1.3", "mediawiki/matomo": "<2.4.3", "mediawiki/semantic-media-wiki": "<4.0.2", "mehrwert/phpmyadmin": "<3.2", @@ -13702,11 +13710,11 @@ "nilsteampassnet/teampass": "<3.1.3.1-dev", "nitsan/ns-backup": "<13.0.1", "nonfiction/nterchange": "<4.1.1", - "notrinos/notrinos-erp": "<=0.7", + "notrinos/notrinos-erp": "<=1", "noumo/easyii": "<=0.9", "novaksolutions/infusionsoft-php-sdk": "<1", "novosga/novosga": "<=2.2.12", - "nukeviet/nukeviet": "<4.5.02", + "nukeviet/nukeviet": "<4.6.00", "nyholm/psr7": "<1.6.1", "nystudio107/craft-seomatic": "<3.4.12", "nzedb/nzedb": "<0.8", @@ -13734,8 +13742,10 @@ "oro/customer-portal": ">=4.1,<=4.1.13|>=4.2,<=4.2.10|>=5,<=5.0.11|>=5.1,<=5.1.3", "oro/platform": ">=1.7,<1.7.4|>=3.1,<3.1.29|>=4.1,<4.1.17|>=4.2,<=4.2.10|>=5,<=5.0.12|>=5.1,<=5.1.3", "oveleon/contao-cookiebar": "<1.16.3|>=2,<2.1.3", - "oxid-esales/oxideshop-ce": "<=7.0.5", + "oxid-esales/oxideshop-ce": "<4.5|>=6,<6.14.4", + "oxid-esales/oxideshop-metapackage-ce": ">=6,<6.5.5", "oxid-esales/paymorrow-module": ">=1,<1.0.2|>=2,<2.0.1", + "oxid-esales/smarty-component": "<1.0.1", "packbackbooks/lti-1-3-php-library": "<5", "padraic/humbug_get_contents": "<1.1.2", "pagarme/pagarme-php": "<3", @@ -13744,6 +13754,7 @@ "paragonie/random_compat": "<2", "paragonie/sodium_compat": "<1.24|>=2,<2.5", "passbolt/passbolt_api": "<4.6.2", + "paymenter/paymenter": "<=1.5.4", "paypal/adaptivepayments-sdk-php": "<=3.9.2", "paypal/invoice-sdk-php": "<=3.9", "paypal/merchant-sdk-php": "<3.12", @@ -13756,22 +13767,24 @@ "pegasus/google-for-jobs": "<1.5.1|>=2,<2.1.1", "personnummer/personnummer": "<3.0.2", "ph7software/ph7builder": "<=17.9.1", - "phanan/koel": "<=9.3.4", - "pheditor/pheditor": ">=2.0.1,<=2.0.3", + "phanan/koel": "<=9.7", + "pheditor/pheditor": "<2.0.8", "phenx/php-svg-lib": "<0.5.2", "php-censor/php-censor": "<2.0.13|>=2.1,<2.1.5", "php-mod/curl": "<2.3.2", + "php-standard-library/h2": ">=6.1,<6.1.2|>=6.2,<6.2.1", + "php-standard-library/php-standard-library": ">=6.1,<6.1.2|>=6.2,<6.2.1", "phpbb/phpbb": "<3.3.16|==4.0.0.0-alpha1", "phpems/phpems": ">=6,<=6.1.3", "phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7", "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", "phpmyadmin/phpmyadmin": "<5.2.2", - "phpmyfaq/phpmyfaq": "<4.1.3", + "phpmyfaq/phpmyfaq": "<4.1.4", "phpoffice/common": "<0.2.9", "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", - "phpoffice/phpspreadsheet": "<=1.30.4|>=2,<=2.1.15|>=2.2,<=2.4.4|>=3,<=3.10.4|>=4,<=5.6", + "phpoffice/phpspreadsheet": "<=1.30.5|>=2,<=2.1.17|>=2.2,<=2.4.6|>=3,<=3.10.6|>=4,<=5.8", "phppgadmin/phppgadmin": "<=7.13", "phpseclib/phpseclib": "<=2.0.54|>=3,<=3.0.53", "phpservermon/phpservermon": "<3.6", @@ -13782,14 +13795,14 @@ "phpxmlrpc/phpxmlrpc": "<4.9.2", "phraseanet/phraseanet": "==4.0.3", "pi/pi": "<=2.5", - "pimcore/admin-ui-classic-bundle": "<=2.3.5", + "pimcore/admin-ui-classic-bundle": "<1.7.18|>=2.0.0.0-RC1-dev,<=2.3.5", "pimcore/customer-management-framework-bundle": "<4.2.1", "pimcore/data-hub": "<1.2.4", "pimcore/data-importer": "<1.8.9|>=1.9,<1.9.3", "pimcore/demo": "<10.3", "pimcore/ecommerce-framework-bundle": "<1.0.10", "pimcore/perspective-editor": "<1.5.1", - "pimcore/pimcore": "<=12.3.6", + "pimcore/pimcore": "<=12.3.8|>=2026.1,<2026.1.3", "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1", "piwik/piwik": "<1.11", "pixelfed/pixelfed": "<0.12.5", @@ -13797,7 +13810,8 @@ "pocketmine/bedrock-protocol": "<8.0.2", "pocketmine/pocketmine-mp": "<5.42.1", "pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1", - "poweradmin/poweradmin": "<4.2.4|>=4.3,<4.3.3", + "pontedilana/php-weasyprint": "<=2.5.1", + "poweradmin/poweradmin": "<4.2.5|>=4.3,<4.3.4", "pressbooks/pressbooks": "<5.18", "prestashop/autoupgrade": ">=4,<4.10.1", "prestashop/blockreassurance": "<=5.1.3", @@ -13809,14 +13823,14 @@ "prestashop/ps_checkout": "<5.3", "prestashop/ps_contactinfo": "<=3.3.2", "prestashop/ps_emailsubscription": "<2.6.1", - "prestashop/ps_facetedsearch": "<3.4.1", + "prestashop/ps_facetedsearch": "<4.0.4", "prestashop/ps_linklist": "<3.1", "privatebin/privatebin": "<1.4|>=1.5,<1.7.4|>=1.7.7,<2.0.3", "processwire/processwire": "<=3.0.255", - "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7", - "propel/propel1": ">=1,<=1.7.1", + "propel/propel": ">=2.0.0.0-alpha1,<2.0.0.0-alpha8", + "propel/propel1": ">=1,<1.7.2", "psy/psysh": "<=0.11.22|>=0.12,<=0.12.18", - "pterodactyl/panel": "<1.12.3", + "pterodactyl/panel": "<=1.12.4", "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", "ptrofimov/beanstalk_console": "<1.7.14", "pubnub/pubnub": "<6.1", @@ -13836,7 +13850,7 @@ "rap2hpoutre/laravel-log-viewer": "<0.13", "react/http": ">=0.7,<1.9", "really-simple-plugins/complianz-gdpr": "<6.4.2", - "redaxo/source": "<5.21", + "redaxo/source": "<5.21.1", "remdex/livehelperchat": "<4.29", "renolit/reint-downloadmanager": "<4.0.2|>=5,<5.0.1", "reportico-web/reportico": "<=8.1", @@ -13875,10 +13889,10 @@ "silverstripe-australia/advancedreports": ">=1,<=2", "silverstripe/admin": "<1.13.19|>=2,<2.1.8", "silverstripe/assets": "<2.4.5|>=3,<3.1.3", - "silverstripe/cms": "<4.11.3", + "silverstripe/cms": "<6.2.1", "silverstripe/comments": ">=1.3,<3.1.1", - "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3", - "silverstripe/framework": "<5.3.23", + "silverstripe/forum": "<0.6.2|>=0.7,<0.7.4", + "silverstripe/framework": "<6.2.2", "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.8.2|>=4,<4.3.7|>=5,<5.1.3", "silverstripe/hybridsessions": ">=1,<2.4.1|>=2.5,<2.5.1", "silverstripe/recipe-cms": ">=4.5,<4.5.3", @@ -13888,13 +13902,14 @@ "silverstripe/silverstripe-omnipay": "<2.5.2|>=3,<3.0.2|>=3.1,<3.1.4|>=3.2,<3.2.1", "silverstripe/subsites": ">=2,<2.6.1", "silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1", - "silverstripe/userforms": "<3|>=5,<5.4.2", + "silverstripe/userforms": "<6.4.9|>=7,<7.0.7|>=7.1,<7.1.1", + "silverstripe/versioned": "<3.2.1", "silverstripe/versioned-admin": ">=1,<1.11.1", "simogeo/filemanager": "<=2.5", "simple-updates/phpwhois": "<=1", - "simplesamlphp/saml2": "<=4.16.15|>=5.0.0.0-alpha1,<=5.0.0.0-alpha19", - "simplesamlphp/saml2-legacy": "<=4.16.15", - "simplesamlphp/simplesamlphp": "<1.18.6", + "simplesamlphp/saml2": "<4.19.3|>=4.20,<=4.20.2|>=5,<5.0.6|>=6,<6.2.1", + "simplesamlphp/saml2-legacy": "<4.19.3|>=4.20,<=4.20.2", + "simplesamlphp/simplesamlphp": "<=2.4.6|>=2.5,<=2.5.1", "simplesamlphp/simplesamlphp-module-casserver": "<=7.0.2", "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", "simplesamlphp/simplesamlphp-module-openid": "<1", @@ -13907,16 +13922,18 @@ "sjbr/sr-freecap": "<2.4.6|>=2.5,<2.5.3", "sjbr/static-info-tables": "<2.3.1", "slim/psr7": "<1.4.1|>=1.5,<1.5.1|>=1.6,<1.6.1", - "slim/slim": "<2.6", + "slim/slim": "<2.6|>=4.4,<=4.15.1", "slub/slub-events": "<3.0.3", - "smarty/smarty": "<4.5.3|>=5,<5.1.1", - "snipe/snipe-it": "<8.4.1", + "smarty/smarty": "<4.5.7|>=5,<5.8.4", + "snipe/snipe-it": "<=8.6.1", "socalnick/scn-social-auth": "<1.15.2", "socialiteproviders/steam": "<1.1", + "solidinvoice/solidinvoice": "<=2.3.15", "solspace/craft-freeform": "<4.1.29|>=5,<=5.14.6", "soosyze/soosyze": "<=2", "spatie/browsershot": "<5.0.5", "spatie/image-optimizer": "<1.7.3", + "spatie/laravel-medialibrary": "<11.23", "spatie/schema-org": ">=3.23.1,<3.23.2|>=4,<4.0.2", "spencer14420/sp-php-email-handler": "<1", "spipu/html2pdf": "<5.2.8", @@ -13924,13 +13941,13 @@ "spomky-labs/otphp": "<11.4.3", "spoon/library": "<1.4.1", "spoonity/tcpdf": "<6.2.22", - "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1", + "squizlabs/php_codesniffer": "<3.13.6|>=4,<4.0.2", "ssddanbrown/bookstack": "<24.05.1", "starcitizentools/citizen-skin": ">=1.9.4,<3.9", "starcitizentools/short-description": ">=4,<4.0.1", "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", "starcitizenwiki/embedvideo": "<=4", - "statamic/cms": "<5.73.22|>=6,<6.18.1", + "statamic/cms": "<5.74.3|>=6,<6.24.2", "stormpath/sdk": "<9.9.99", "studio-42/elfinder": "<=2.1.67", "studiomitte/friendlycaptcha": "<0.1.4", @@ -13947,9 +13964,11 @@ "sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2", "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", "sylius/grid-bundle": "<1.10.1", + "sylius/mollie-plugin": "<2.2.8|>=3,<3.2.4|>=3.3,<3.3.1", "sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2", "sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", - "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<=2.0.15|>=2.1,<=2.1.11|>=2.2,<=2.2.2", + "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<2.0.18|>=2.1,<2.1.15|>=2.2,<2.2.6", + "symbiote/silverstripe-advancedworkflow": "<6.4.5|>=7,<7.1.3|>=7.2,<7.2.1", "symbiote/silverstripe-multivaluefield": ">=3,<3.1", "symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4", "symbiote/silverstripe-seed": "<6.0.3", @@ -13995,7 +14014,9 @@ "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8|>=6.4.24,<6.4.40", "symfony/twilio-notifier": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symfony/ux-autocomplete": "<2.36|>=3,<3.1", + "symfony/ux-icons": ">=2.17,<2.36.1|>=3,<3.2", "symfony/ux-live-component": "<2.36|>=3,<3.1", + "symfony/ux-toolkit": ">=2.32,<2.36.1|>=3,<3.2", "symfony/ux-twig-component": "<2.25.1", "symfony/validator": "<5.4.43|>=6,<6.4.11|>=7,<7.1.4", "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", @@ -14012,10 +14033,10 @@ "tecnickcom/tcpdf": "<6.8", "terminal42/contao-tablelookupwizard": "<3.3.5", "thelia/backoffice-default-template": ">=2.1,<2.1.2", - "thelia/thelia": ">=2.1,<2.1.3", + "thelia/thelia": ">=2.0.0.0-beta1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<6.0.8", - "thorsten/phpmyfaq": "<4.1.3", + "thorsten/phpmyfaq": "<4.1.4", "tikiwiki/tiki-manager": "<=17.1", "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", "tinymce/tinymce": "<7.9.3|>=8,<8.5.1", @@ -14037,7 +14058,7 @@ "twig/intl-extra": "<3.26", "twig/markdown-extra": "<3.26", "twig/twig": "<3.27", - "typicms/core": "<16.1.7", + "typicms/core": "<12.0.5|>=13,<13.0.9|>=14,<14.0.27|>=15,<15.0.29|>=16,<16.1.7", "typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", "typo3/cms-backend": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", "typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", @@ -14048,8 +14069,8 @@ "typo3/cms-extensionmanager": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-felogin": ">=4.2,<4.2.3", "typo3/cms-filelist": ">=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", - "typo3/cms-fluid": "<4.3.4|>=4.4,<4.4.1", - "typo3/cms-form": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", + "typo3/cms-fluid": "<4.3.4|>=4.4,<4.4.1|>=8,<8.7.23|>=9,<9.5.4", + "typo3/cms-form": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.5", "typo3/cms-frontend": "<4.3.9|>=4.4,<4.4.5", "typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<13.4.31|>=14,<14.3.3", "typo3/cms-install": "<4.1.14|>=4.2,<4.2.16|>=4.3,<4.3.9|>=4.4,<4.4.5|>=12.2,<12.4.8|==13.4.2", @@ -14079,7 +14100,7 @@ "uvdesk/core-framework": "<=1.1.1", "vanilla/safecurl": "<0.9.2", "verbb/comments": "<1.5.5", - "verbb/formie": "<2.2.21|>=3,<3.1.26", + "verbb/formie": "<3.1.28", "verbb/image-resizer": "<2.0.9", "verbb/knock-knock": "<1.2.8", "verot/class.upload.php": "<=2.1.6", @@ -14094,11 +14115,12 @@ "wanglelecc/laracms": "<=1.0.3", "wapplersystems/a21glossary": "<=0.4.10", "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4|>=5.3,<5.3.1", - "web-auth/webauthn-lib": ">=4.5,<4.9|>=5.2,<5.2.4", - "web-auth/webauthn-symfony-bundle": ">=5.2,<5.2.4", + "web-auth/webauthn-lib": ">=4.5,<5.3.5", + "web-auth/webauthn-symfony-bundle": "<5.3.4", "web-feet/coastercms": "==5.5", - "web-token/jwt-experimental": "<=4.1.6", - "web-token/jwt-framework": "<=4.2.99", + "web-token/jwt-bundle": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7", + "web-token/jwt-experimental": "<4.1.7", + "web-token/jwt-framework": "<4.1.7", "web-token/jwt-library": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7", "web-tp3/wec_map": "<3.0.3", "webbuilders-group/silverstripe-kapost-bridge": "<0.4", @@ -14117,9 +14139,11 @@ "winter/wn-system-module": "<1.2.4", "wintercms/winter": "<=1.2.3", "wireui/wireui": "<1.19.3|>=2,<2.1.3", + "wnx/laravel-backup-restore": "<=1.9.3", "woocommerce/woocommerce": "<6.6|>=8.8,<8.8.5|>=8.9,<8.9.3", "wp-cli/wp-cli": ">=0.12,<2.5", - "wp-graphql/wp-graphql": "<=1.14.5", + "wp-coding-standards/wpcs": ">=0.14.1,<3.4.1", + "wp-graphql/wp-graphql": "<=2.6", "wp-premium/gravityforms": "<2.4.21", "wpanel/wpanel4-cms": "<=4.3.1", "wpcloud/wp-stateless": "<3.2", @@ -14130,7 +14154,7 @@ "xpressengine/xpressengine": "<3.0.15", "yab/quarx": "<2.4.5", "yansongda/pay": "<=3.7.19", - "yeswiki/yeswiki": "<4.6.4", + "yeswiki/yeswiki": "<4.6.6", "yetiforce/yetiforce-crm": "<6.5", "yidashi/yii2cmf": "<=2", "yii2mod/yii2-cms": "<1.9.2", @@ -14225,7 +14249,7 @@ "type": "tidelift" } ], - "time": "2026-06-18T21:44:25+00:00" + "time": "2026-08-07T18:55:46+00:00" }, { "name": "sebastian/cli-parser", @@ -15190,16 +15214,16 @@ }, { "name": "symfony/yaml", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" + "reference": "faabdbe998e8c5c599dceffa27aa265b185c0736" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", - "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "url": "https://api.github.com/repos/symfony/yaml/zipball/faabdbe998e8c5c599dceffa27aa265b185c0736", + "reference": "faabdbe998e8c5c599dceffa27aa265b185c0736", "shasum": "" }, "require": { @@ -15242,7 +15266,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.0" + "source": "https://github.com/symfony/yaml/tree/v8.1.2" }, "funding": [ { @@ -15262,7 +15286,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", diff --git a/config/backup.php b/config/backup.php index 903473e5..a978c521 100644 --- a/config/backup.php +++ b/config/backup.php @@ -41,6 +41,9 @@ 'exclude' => [ base_path('vendor'), base_path('node_modules'), + // Logs are rotated and pruned by the daily channel; copying + // them into every pre-update backup only multiplies them. + storage_path('logs'), ], /* diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 00000000..798845ba --- /dev/null +++ b/config/logging.php @@ -0,0 +1,113 @@ + env('LOG_CHANNEL', 'daily'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | StuFiS instances run on shared hosting without root access, so there is + | no system logrotate to fall back on. Both LOG_CHANNEL and LOG_STACK + | therefore default to the "daily" driver, which rotates the file itself and + | prunes anything older than LOG_DAILY_DAYS on write. Never point a + | production instance at the "single" channel: it writes one file that grows + | without bound. + | + | Available drivers: "single", "daily", "syslog", "errorlog", "monolog", + | "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'daily')), + 'ignore_exceptions' => false, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 30), + 'replace_placeholders' => true, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/stufis.php b/config/stufis.php index 14d24ea0..2d041bd4 100644 --- a/config/stufis.php +++ b/config/stufis.php @@ -14,6 +14,16 @@ 'fints' => [ 'registration_number' => env('FINTS_REG_NR'), + + /* + * Source for `stufis:fints-institutes-update`. Die Deutsche Kreditwirtschaft hands + * its own FinTS-Bankenliste to registered vendors only and forbids shipping it as + * part of a software product, so we pull hbci4java's public equivalent instead. + */ + 'institute_list_url' => env( + 'FINTS_INSTITUTE_LIST_URL', + 'https://raw.githubusercontent.com/hbci4j/hbci4java/master/src/main/resources/blz.properties', + ), ], 'version' => InstalledVersions::getPrettyVersion('openadministration/stufis'), diff --git a/database/migrations/2026_08_11_120000_create_fints_institutes_table.php b/database/migrations/2026_08_11_120000_create_fints_institutes_table.php new file mode 100644 index 00000000..947ce854 --- /dev/null +++ b/database/migrations/2026_08_11_120000_create_fints_institutes_table.php @@ -0,0 +1,48 @@ +char('blz', 8)->primary(); + + $table->string('name'); + $table->string('location')->nullable(); + $table->string('bic', 11)->nullable()->index(); + $table->string('checksum_method', 2)->nullable(); + + $table->string('rdh_address')->nullable(); + $table->string('pin_tan_address')->nullable(); + + // Not numeric: besides "300"/"220" the list also carries ids like "plus". + $table->string('rdh_version', 16)->nullable(); + $table->string('pin_tan_version', 16)->nullable(); + + // Touched on every successful sync, so rows that vanished upstream are the + // ones left behind with an older stamp, and max(synced_at) is the list date. + $table->timestamp('synced_at')->index(); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fints_institutes'); + } +}; diff --git a/database/migrations/2026_08_11_130000_point_konto_credentials_at_fints_institutes.php b/database/migrations/2026_08_11_130000_point_konto_credentials_at_fints_institutes.php new file mode 100644 index 00000000..74729868 --- /dev/null +++ b/database/migrations/2026_08_11_130000_point_konto_credentials_at_fints_institutes.php @@ -0,0 +1,151 @@ +char('blz', 8)->nullable()->after('id'); + }); + } + + // Carry the existing accesses over before konto_bank goes away. konto_bank.blz is an + // INT, so 8-digit-pad it into the char column the institute list uses. + foreach (DB::table('konto_bank')->pluck('blz', 'id') as $bankId => $blz) { + DB::table('konto_credentials') + ->where('bank_id', $bankId) + ->update(['blz' => str_pad((string) $blz, 8, '0', STR_PAD_LEFT)]); + } + + $orphaned = DB::table('konto_credentials')->whereNull('blz')->count(); + if ($orphaned > 0) { + throw new RuntimeException( + "$orphaned Bankzugänge verweisen auf keine Bank in konto_bank - bitte vor der Migration klären." + ); + } + + // The foreign key needs every referenced BLZ to exist, and the sync cannot have run + // yet - the table it fills is created by the migration right before this one. So seed + // the institutes in use from the konto_bank rows being retired: same name, same URL, + // so existing accesses keep working exactly as before until the first real sync + // replaces these rows with authoritative data. + $seededAt = DB::table('konto_credentials')->exists() ? Date::now() : null; + + foreach (DB::table('konto_bank')->get() as $bank) { + $blz = str_pad((string) $bank->blz, 8, '0', STR_PAD_LEFT); + + $stillInUse = DB::table('konto_credentials')->where('blz', $blz)->exists(); + $alreadySynced = DB::table('fints_institutes')->where('blz', $blz)->exists(); + + if (! $stillInUse || $alreadySynced) { + continue; + } + + DB::table('fints_institutes')->insert([ + 'blz' => $blz, + 'name' => $bank->name, + 'pin_tan_address' => $bank->url, + 'synced_at' => $seededAt, + 'created_at' => $seededAt, + 'updated_at' => $seededAt, + ]); + } + + // The legacy migration hardcoded the name "dev__konto_credentials_ibfk_2" whatever the + // table prefix, but a database restored from an older dump may carry a different one. + $foreignKey = $this->foreignKeyOn('konto_credentials', 'bank_id'); + + Schema::table('konto_credentials', function (Blueprint $table) use ($foreignKey) { + if ($foreignKey !== null) { + $table->dropForeign($foreignKey); + } + if (Schema::hasColumn('konto_credentials', 'bank_id')) { + $table->dropColumn('bank_id'); + } + $table->char('blz', 8)->nullable(false)->change(); + $table->foreign('blz')->references('blz')->on('fints_institutes'); + }); + + Schema::dropIfExists('konto_bank'); + } + + /** + * Name of the foreign key on the given column, or null when there is none. + */ + private function foreignKeyOn(string $table, string $column): ?string + { + // Raw, because the query builder would prefix "information_schema.…" as a table name. + $row = DB::selectOne( + 'SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? + AND REFERENCED_TABLE_NAME IS NOT NULL', + [DB::connection()->getTablePrefix().$table, $column], + ); + + return $row->CONSTRAINT_NAME ?? null; + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::create('konto_bank', function (Blueprint $table) { + $table->integer('id', true); + $table->string('url', 256); + $table->integer('blz'); + $table->string('name', 256); + }); + + // Rebuild one konto_bank row per BLZ still in use, from the synced list. + $banks = DB::table('konto_credentials') + ->distinct() + ->pluck('blz') + ->values() + ->mapWithKeys(function (string $blz, int $index) { + $institute = DB::table('fints_institutes')->where('blz', $blz)->first(); + + DB::table('konto_bank')->insert([ + 'id' => $index + 1, + 'blz' => (int) $blz, + 'name' => $institute->name ?? "BLZ $blz", + 'url' => $institute->pin_tan_address ?? '', + ]); + + return [$blz => $index + 1]; + }); + + Schema::table('konto_credentials', function (Blueprint $table) { + $table->dropForeign(['blz']); + $table->integer('bank_id')->nullable()->after('name'); + }); + + foreach ($banks as $blz => $bankId) { + DB::table('konto_credentials')->where('blz', $blz)->update(['bank_id' => $bankId]); + } + + Schema::table('konto_credentials', function (Blueprint $table) { + $table->integer('bank_id')->nullable(false)->change(); + $table->dropColumn('blz'); + $table->foreign(['bank_id'], 'dev__konto_credentials_ibfk_2')->references(['id'])->on('konto_bank'); + }); + } +}; diff --git a/docs/feature-changelog.md b/docs/feature-changelog.md index 0aeca5e6..bf0ac24b 100644 --- a/docs/feature-changelog.md +++ b/docs/feature-changelog.md @@ -1,3 +1,41 @@ +# v4.4.4 +Verschiedene Fehler beim FinTS Bankimport behoben. u.a.: +* Das Absenden der Formulare auf den Seiten des Bankzugangs führte zu einer Fehlerseite. Betroffen waren das Anlegen eines Zugangs, die Auswahl des TAN-Verfahrens und jede TAN-Eingabe. +* Die Login Daten werden nun konsequent und korrekt an die Bank übergeben. +* In der Bezeichnung eines Bankzugangs und eines Kontos sind nun auch Leerzeichen und Ziffern nutzbar. +* Der Import erkennt die bekannten Umsätze nun zuverlässig und bricht mit einer klaren Meldung ab, wenn er den Anknüpfungspunkt nicht findet, anstatt Buchungen zu verdoppeln. +* Die Formulare der Bankzugang-Seiten sind nun gegen Anfragen von fremden Seiten abgesichert (CSRF-Schutz). +* Ein Konto aus dem Bankzugang wird nun über die normale Seite „Konto anlegen“ eingerichtet. +* Bei einem Konto, das aus einem Bankzugang übernommen wird, sind die IBAN und der Schalter „Manuelles Eintragen möglich“ nun gesperrt. Manuelles Eintragen würde die automatische Synchronisation ausschließen, die für dieses Konto ja gerade eingerichtet wird. +* Der Aufruf des Umsatzimports für ein noch nicht eingerichtetes Konto führte zu einer Fehlerseite; nun wird auf das Anlegen des Kontos hingewiesen. Gehört ein Konto nicht zum gewählten Bankzugang, wird das ebenfalls verständlich gemeldet. +* Bei Konten ohne hinterlegtes Startdatum wird der Zeitraum nun der Bank überlassen, statt ein Datum zu erfinden. Zuvor führte ein fehlendes Startdatum zum Abbruch. +* Wurde ein Umsatzabruf durch die TAN-Abfrage unterbrochen und danach ein anderes Konto geöffnet, konnten die Umsätze des ursprünglich abgefragten Kontos dem falschen Konto zugeordnet werden. Ein unterbrochener Abruf wird nun nur noch für genau das Konto und den Zeitraum fortgesetzt, für den er begonnen wurde. +* Bei Banken, die für den Umsatzabruf eine TAN verlangen, wurde nach deren Eingabe erneut eine TAN angefordert, statt den Abruf abzuschließen – der Import kam dadurch nie zum Ende. Nach der TAN-Eingabe werden die Umsätze nun tatsächlich übernommen. +* Eine von der Bank abgelehnte TAN führte zu einer Fehlerseite, sodass der Vorgang abgebrochen war. Nun erscheint der Hinweis „TAN nicht akzeptiert“ und die Eingabe kann wiederholt werden. Ebenso führen gestörte Antworten der Bank beim Abrufen der TAN-Verfahren, der TAN-Medien und beim Abmelden nicht mehr zu einer Fehlerseite. +* Freigabe-Verfahren ohne TAN-Eingabe (z. B. pushTAN-Freigabe in der Banking-App) werden nun unterstützt. Statt eines TAN-Feldes erscheint ein Hinweis, die Freigabe in der Banking-App zu erteilen, und ein Knopf „Ich habe die Freigabe erteilt“. Ein Klick darauf fragt einmalig bei der Bank nach, ob die Freigabe angekommen ist; ist sie es noch nicht, kann nach kurzer Wartezeit erneut geklickt werden. +* Während einer TAN-Abfrage beim Umsatzabruf war nirgends zu sehen, für welches Konto sie eigentlich gilt. Der Pfad oben auf der Seite nennt das Konto nun beim Namen. +* War die Sitzung abgelaufen, endete jeder Klick auf den Seiten des Bankzugangs in einer Fehlerseite. Stattdessen erscheint nun ein Hinweis und die erneute Anmeldung beim Bankzugang wird angeboten. +* Der Kontostand der Bank wird nun gegen den zuletzt gespeicherten Stand geprüft. +* Beim Anlegen eines Bankzugangs lässt sich nun jede FinTS-fähige deutsche Bank auswählen (mit Suche nach Name, BLZ oder BIC). Bisher standen nur die Banken zur Auswahl, die vorher von Hand in der Datenbank eingetragen worden waren. Name und FinTS-Adresse kommen jetzt aus einer gepflegten Bankenliste: Stellt eine Bank ihren Zugang auf eine neue Adresse um – was regelmäßig vorkommt –, wird das beim nächsten Aktualisieren der Liste automatisch übernommen. Zuvor blieb ein Bankzugang auf der alten Adresse stehen, bis jemand sie direkt in der Datenbank korrigiert hat. +* Wird ein neues Konto aus einem Bankzugang übernommen, ist auf der Seite „Konto anlegen“ nicht mehr von einer „Kasse“ die Rede – ein solches Konto ist immer ein echtes Bankkonto. Der Knopf heißt dort jetzt „Speichern und weiter zum automatischen Abruf“, weil es danach mit der Einrichtung des Abrufs weitergeht. +* Fehlte in einem Formular der Name, lautete die Meldung überall „Bitte gib einen Namen für das Projekt an.“ – auch beim Anlegen eines Kontos. Sie ist nun neutral formuliert und passt zu jedem Formular. +* Ein Bankzugang lässt sich nun wieder löschen. Das Papierkorb-Symbol in der Übersicht war vorhanden, führte aber ins Leere – Zugangsdaten waren über die Oberfläche überhaupt nicht entfernbar. Vor dem Löschen wird nachgefragt und dabei erklärt, was verschwindet und was bleibt: die Konten und ihre bereits importierten Buchungen bleiben erhalten, nur der automatische Abruf endet. Das Symbol erscheint jetzt auch dann, wenn man am Bankzugang nicht angemeldet ist – der häufigste Grund zum Löschen ist ja, dass die Anmeldung nicht funktioniert. +* Beim Anlegen eines Bankzugangs war im Feld „Bank“ immer schon die erste Bank der Liste vorausgewählt. Wer das Feld nicht anfasste, legte den Zugang unbemerkt bei dieser Bank an. Das Feld steht nun auf „Bank auswählen“; wird ohne Auswahl abgesendet, weist das Formular darauf hin. +* Ist für eine Bank eine FinTS-Adresse hinterlegt, die nicht mit `https://` beginnt, wird der Abruf nun abgebrochen, statt PIN und TAN unverschlüsselt zu übertragen. Aus der Bankenliste kommen ausschließlich `https://`-Adressen; betreffen kann das nur eine von Hand eingetragene Adresse aus der Zeit vor der Bankenliste, die der erste Abgleich ohnehin korrigiert. + +Buchungen: +* Der Knopf „als .zip“ in der Buchungshistorie führte zu einer Fehlerseite, statt das Archiv herunterzuladen. Der Download funktioniert nun wieder und enthält für jeden Haushaltstitel eine CSV-Datei mit den Buchungen des Haushaltsjahres. +* Unter der Buchungshistorie steht jetzt auch der DATEV-Export zur Verfügung – derselbe Knopf, der schon in der Ansicht eines Haushaltsplans sitzt. Er erscheint nur, wenn der DATEV-Export in den Einstellungen aktiviert ist. + +Betrieb der Instanz: +* Die Protokolldateien (Logs) wachsen nicht mehr unbegrenzt. StuFiS schreibt nun für jeden Tag eine eigene Datei und löscht alles, was älter als 30 Tage ist. Bisher lief alles in eine einzige Datei, die nie gekürzt wurde – auf den Servern gibt es keine automatische Rotation. Außerdem werden die Logs nicht mehr in jede Sicherung mitkopiert. Für bestehende Instanzen: in der `.env` `LOG_CHANNEL=daily` setzen (ein dort noch eingetragenes `LOG_STACK=single` sticht die neue Voreinstellung) und die alte große Datei `storage/logs/laravel.log` einmalig löschen. +* Neu: die Liste der FinTS-fähigen Banken (rund 4000 Institute) in der Tabelle `fints_institutes`. `bin/stufis-update` liest sie ab jetzt bei jedem Deployment selbst ein, es ist also kein zusätzlicher Handgriff nötig. Schlägt der Abruf fehl (z. B. keine Internetverbindung), bricht das Update **nicht** ab: es erscheint eine Warnung, die bisherige Liste bleibt stehen, und der Befehl `php artisan stufis:fints-institutes-update` kann später von Hand nachgeholt werden. Wer nur selten deployt, sollte diesen Befehl zusätzlich etwa monatlich per Cron laufen lassen, weil die Banken ihre FinTS-Adressen regelmäßig umstellen. `--dry-run` zeigt vorab, was sich ändern würde; mit `--file=/pfad/blz.properties` lässt sich die Liste auch aus einer lokalen Datei einlesen. Solange die Liste leer ist, lässt sich kein *neuer* Bankzugang anlegen – die Seite weist darauf hin; bestehende Bankzugänge funktionieren weiter (siehe nächster Punkt). +* Die Tabelle `konto_bank` wurde entfernt. Sie enthielt eine von Hand gepflegte Kopie derselben Daten (Name, BLZ, FinTS-Adresse), die nun aus der Bankenliste kommen. Bestehende Bankzugänge werden bei der Migration automatisch übernommen: Sie verweisen danach über die BLZ auf die Bankenliste und behalten zunächst genau die bisher eingetragene FinTS-Adresse. Erst der erste Abgleich mit der Bankenliste korrigiert eine veraltete Adresse – und meldet die Änderung im Ausgabeprotokoll des Befehls. +* Hinweis zur Herkunft der Liste: Die offizielle FinTS-Bankenliste der Deutschen Kreditwirtschaft wird nur an registrierte FinTS-Hersteller herausgegeben und darf nicht als Teil einer Software weitergegeben werden. StuFiS verwendet daher die öffentlich gepflegte, gleichwertige Liste des Projekts hbci4java. Die Quelle ist über `FINTS_INSTITUTE_LIST_URL` in der `.env` austauschbar. +* **Empfohlen für bestehende Instanzen: in der `.env` `SESSION_ENCRYPT=true` setzen.** Während eines Bankdialogs liegen das Online-Banking-Passwort und der Sitzungszustand der Bank in der Sitzung – absichtlich, damit das Passwort nie in der Datenbank landet. Bei `SESSION_DRIVER=file` ist diese Sitzung aber eine Datei unter `storage/framework/sessions/`, und ohne diese Einstellung stehen die Daten dort im Klartext. Beim Umstellen werden alle offenen Sitzungen ungültig, d. h. alle Anmeldungen müssen einmalig erneuert werden; ein laufender Bankdialog bricht dabei ab. Neue Installationen bekommen die Einstellung aus der `.env.example` mit. + +--- + # v4.4.3 * Projekte mit mehreren Posten ließen sich nicht mehr speichern, wenn vor dem Speichern in jeder Postenzeile etwas geändert wurde – das Speichern brach mit einer Fehlerseite ab. Die Beträge gingen dabei auf dem Weg zum Server verloren; sie werden nun wieder zuverlässig als Geldbeträge erkannt. * Fehlermeldungen beim Speichern eines Projekts erscheinen jetzt direkt an dem Feld, das sie ausgelöst hat – und zwar alle. Bisher wurde nur die erste Meldung als einzelne Zeile über dem Formular angezeigt, sodass unklar blieb, welche Zeile oder welches Feld gemeint war. diff --git a/docs/hostsharing-installation.md b/docs/hostsharing-installation.md index aecb903e..0e48e6d1 100644 --- a/docs/hostsharing-installation.md +++ b/docs/hostsharing-installation.md @@ -73,7 +73,55 @@ In hs-admin set the default PHP to `/usr/lib/cgi-bin/php8.4`, options Import your Budgetplan and everything should work :) -Optional: add a bank for the bank-import feature. +The banks available for the FinTS bank import come from the synced bank list, which +`stufis-update` fills on every deployment — nothing to add by hand (see *Updating +later* below). + +## Sessions + +Set in `.env`: + +```dotenv +SESSION_ENCRYPT=true +``` + +With the default `SESSION_DRIVER=file` a session is a PHP-serialized file under +`storage/framework/sessions/`. During a FinTS dialog it holds the online-banking +PIN and the bank's session state — deliberately, so the password never reaches +the database — and without this setting they sit there in cleartext. + +Instances set up before v4.4.4 have `SESSION_ENCRYPT=false` pinned in their `.env` +and must be switched by hand. Turning it on invalidates every open session, so +everyone has to log in again once and any bank dialog in flight is lost; pick a +quiet moment. Run `stufis-rebuild` afterwards so the config cache picks it up. + +## Logging + +There is no root access on hostsharing, so no system logrotate. StuFiS rotates +its own log instead: the `daily` channel writes `storage/logs/laravel-.log` +and deletes files older than `LOG_DAILY_DAYS` (default 30, i.e. a month) as it +writes. For +production set in `.env`: + +```dotenv +LOG_CHANNEL=daily +LOG_LEVEL=warning +``` + +`LOG_STACK` only applies when `LOG_CHANNEL=stack`; it defaults to `daily` too, +so either setting rotates. What does not rotate is `single` — instances set up +before v4.4.4 have `LOG_STACK=single` pinned in their `.env` and must be +switched by hand, since an explicit value beats the new default. After +changing `.env` run `stufis-rebuild` so the config cache picks it up, and delete +the leftover unrotated file once: + +```bash +rm storage/logs/laravel.log +``` + +Rotation caps the log directory, it does not make the entries smaller. If the +files are still large, the cause is `LOG_LEVEL=debug` or a recurring exception — +check the newest file before raising `LOG_DAILY_DAYS`. ## Updating later @@ -86,8 +134,16 @@ stufis-update main # or switch to a branch and follow its tip ``` Each run goes into maintenance mode, backs up, fetches, self-updates the -toolchain, migrates and rebuilds. Deploying a **tag** pins the instance to that -exact release (detached HEAD); pass the next release tag to move it forward. +toolchain, migrates, refreshes the FinTS bank list and rebuilds. Deploying a +**tag** pins the instance to that exact release (detached HEAD); pass the next +release tag to move it forward. + +The bank list step is allowed to fail: it fetches from an external source, so a +network problem only prints a warning and keeps the previously synced list +instead of aborting the deployment. If you see that warning, re-run +`php artisan stufis:fints-institutes-update` once the cause is fixed. Banks move +their FinTS endpoints every few weeks, so on an instance that is deployed rarely +it is worth running that command from cron monthly as well. Use `stufis-rebuild` to re-warm the production caches and rebuild assets without pulling or reinstalling dependencies (e.g. after a local config change). diff --git a/lang/de/budget-plan.php b/lang/de/budget-plan.php index cfe92e40..cf74d7d0 100644 --- a/lang/de/budget-plan.php +++ b/lang/de/budget-plan.php @@ -29,5 +29,4 @@ 'edit.table.headline.name-hint' => 'Der Titelname soll kurz aber beschreibend sein. Er sollte die Verwendung der Mittel widerspiegeln und beim Bearbeiten nicht zu weit abgewandelt werden, um eine Nachvollziehbarkeit über mehrere Haushaltsjahre hinweg gewährleisten zu können.', 'edit.table.headline.value-hint' => 'Der Wert eines Haushaltstitels sollte angemessen festgelegt werden. Beachte, ob du dich im "Einnahmen"- oder "Ausgaben"-Tab befindest. In der Regel sollen die Werte nach den Landeshaushaltsordnungen auf volle 10 EUR gerundet sein. Titelgruppen summieren sich immer automatisch aus den darunterliegenden Titeln und Titelgruppen. In Titelgruppen kann nicht direkt gebucht werden.', 'edit.save' => 'Speichern und zum nächsten Schritt', - '' => '', ]; diff --git a/lang/de/general.php b/lang/de/general.php index a0e74f37..9793f0bd 100644 --- a/lang/de/general.php +++ b/lang/de/general.php @@ -17,10 +17,12 @@ 'import-csv' => 'CSV-Import', 'credentials' => 'Bankimport', 'credentials-new' => 'Neuen Zugang anlegen', + 'credentials-delete' => 'Zugang löschen', 'login' => 'Login', 'tan-mode' => 'Tan-Modus', 'sepa' => 'Konten', 'import-konto' => 'Neu', + 'import-transactions' => 'Aktualisieren', ], 'sitzung' => 'Sitzung', 'budget-plan' => 'Haushaltsplan', diff --git a/lang/de/konto.php b/lang/de/konto.php index 361f7b79..8217a8ec 100644 --- a/lang/de/konto.php +++ b/lang/de/konto.php @@ -9,7 +9,6 @@ 'camt-recommendation-heading' => 'Tipp: CAMT ist stabiler als CSV', 'camt-recommendation-text' => 'Wenn deine Bank das CAMT-Format anbietet (camt.052 oder camt.053 als XML), lade dieses statt einer CSV-Datei hoch. CAMT ist ein standardisiertes Format: Die fehleranfällige Spaltenzuordnung entfällt, IBAN, Name und Verwendungszweck werden zuverlässig erkannt und der Saldo wird automatisch gegen den Kontostand der Datei geprüft.', 'csv-draganddrop-fat-text' => 'Füge hier die Datei hinzu!', - 'csv-draganddrop-light-text' => '', 'csv-draganddrop-sub-text' => 'Ziehe die Datei hier in das Feld oder wähle sie über den Knopf aus. Es kann einen Moment dauern, bis die Informationen geladen werden.', 'manual-button-reverse-csv-order' => 'Reihenfolge der Einträge umkehren', 'manual-button-reverse-csv-order-sub' => 'Einige Banken exportieren Transaktionsdaten chronologisch aufsteigend, andere absteigend. StuFiS hätte gern den ältesten Eintrag zuerst. Falls die Tabelle falsch herum sortiert ist oder z. B. die Saldo-Validierung fehlschlägt, kannst du hier über den Knopf die Reihenfolge ändern.', @@ -58,7 +57,7 @@ 'label.transaction.zweck' => 'Verwendungszweck', 'hint.transaction.zweck' => 'z. B. Gute Lehre Abo', 'label.transaction.comment' => 'Kommentar', - 'hint.transaction.comment' => ' ', + 'hint.transaction.comment' => 'z. B. eigene Notiz zur Buchung', 'label.transaction.customer_ref' => 'Kundenreferenz', 'hint.transaction.customer_ref' => 'ggfs. weitere Angaben zum Auftrag', 'csv-verify-iban-error' => 'Validierungsfehler: Enthält ungültige IBANs', @@ -77,7 +76,25 @@ 'new.date-start-headline-sub' => 'Wann wurde das Konto / die Kasse eröffnet bzw. ab wann soll das Konto/ die Kasse in StuFiS geführt werden? Frühere Verwendung nicht möglich.', 'new.date-end-headline' => 'Verwenden bis', 'new.date-end-headline-sub' => 'Wann wurde das Konto / die Kasse geschlossen bzw. nicht mehr in StuFiS geführt? Spätere Verwendung/Eintragungen sind nicht mehr möglich.', + 'new.submit' => 'Speichern', + + /* + * Shown instead of the keys above when a bank access hands the account over. Such an + * account is synchronised with a real bank account, so it can never be a Kasse - every + * mention of one would only be noise at that point. Each of these mirrors the plain key + * with "-bank" appended; see the component's label() helper. + */ + 'new.headline-bank' => 'Neues Konto anlegen', + 'new.prefix-headline-sub-bank' => 'Gib hier ein eineindeutiges Kürzel (max. 2 Zeichen) an. Dieses wird später zur schnelleren Identifizierung von Zahlungs-IDs verwendet. Standardmäßig wird Z für das Konto verwendet.', + 'new.name-headline-bank' => 'Name des Kontos', + 'new.date-start-headline-sub-bank' => 'Wann wurde das Konto eröffnet bzw. ab wann soll es in StuFiS geführt werden? Frühere Verwendung nicht möglich. Umsätze werden erst ab diesem Datum abgerufen.', + 'new.date-end-headline-sub-bank' => 'Wann wurde das Konto geschlossen bzw. nicht mehr in StuFiS geführt? Spätere Verwendung/Eintragungen sind nicht mehr möglich.', + 'new.submit-bank' => 'Speichern und weiter zum automatischen Abruf', 'new.iban' => 'Konto-IBAN', + 'new.from-bank-access' => 'aus Bankzugang', + 'new.iban-sub' => 'Wird für die automatische Zuordnung beim Kontoimport per Bankzugang (FinTS) verwendet: Die abgerufenen Umsätze werden anhand der IBAN diesem Konto zugeordnet. Ohne IBAN ist kein automatischer Import möglich – für Bar-Kassen kann das Feld leer bleiben. Auch beim Datei-Import (CSV/CAMT) wird die IBAN zum Abgleich genutzt.', + 'new.iban-locked-sub' => 'Die IBAN stammt aus dem gewählten Bankzugang und kann hier nicht geändert werden. Anhand dieser IBAN werden die abgerufenen Umsätze automatisch diesem Konto zugeordnet.', + 'new.manual-locked-sub' => 'Nicht möglich: Dieses Konto wird über den Bankzugang automatisch synchronisiert. Manuelles Eintragen würde die Synchronisation ausschließen.', 'new.manual-headline' => 'Manuelles Eintragen möglich', 'new.manual-headline-sub' => 'Empfohlen für Bar-Kassen. Bei Aktivierung ist es nicht mehr möglich eine automatische Kontosynchronisation einzurichten, stattdessen können Eintragungen direkt vorgenommen werden. Der Datei-Import (CSV/CAMT) ist in beiden Fällen möglich.', 'transaction.headline' => 'Umsatzdetails', diff --git a/lang/de/validation.php b/lang/de/validation.php index 774bd88a..f814683f 100644 --- a/lang/de/validation.php +++ b/lang/de/validation.php @@ -157,8 +157,11 @@ */ 'custom' => [ + // Careful: these apply to every form with a field of that name, not just to projects. + // "name" in particular is shared with the account form, so the message must not name + // one of them - the field's own label supplies the context. 'name' => [ - 'required' => 'Bitte gib einen Namen für das Projekt an.', + 'required' => 'Bitte gib einen Namen an.', 'max' => 'Der Name darf maximal :max Zeichen lang sein.', ], 'responsible' => [ diff --git a/legacy/config/config.routing.php b/legacy/config/config.routing.php index 068bd2c8..baf45e3c 100644 --- a/legacy/config/config.routing.php +++ b/legacy/config/config.routing.php @@ -111,6 +111,8 @@ 'path' => 'delete', 'type' => 'path', 'action' => 'delete-credentials', + // GET renders the confirmation, POST performs the deletion. + 'method' => ['GET', 'POST'], ], [ 'path' => 'change-password', @@ -463,13 +465,6 @@ 'controller' => 'rest', 'action' => 'chat', ], - [ - 'path' => 'hibiscus', - 'type' => 'path', - 'controller' => 'rest', - 'groups' => 'ref-finanzen-kv', - 'action' => 'update-konto', - ], [ 'path' => 'booking', 'type' => 'path', diff --git a/legacy/lib/booking/BookingHandler.php b/legacy/lib/booking/BookingHandler.php index 3c62e2e5..229dd7e2 100644 --- a/legacy/lib/booking/BookingHandler.php +++ b/legacy/lib/booking/BookingHandler.php @@ -3,6 +3,8 @@ namespace booking; use App\Exceptions\LegacyDieException; +use App\Exceptions\LegacyDownloadException; +use App\Models\Setting; use framework\auth\AuthHandler; use framework\baseclass\TextStyle; use framework\CSVBuilder; @@ -179,15 +181,22 @@ private function renderFullBookingZip(): void $zip->addFromString($titel_nr.'.csv', $csvString); } - if ($zip->close() === true && ($content = file_get_contents($zipFilePath)) !== false) { - header('Content-Type: application/zip'); - header('Content-disposition: attachment; filename='.$zipFileName); - header('Content-Length: '.filesize($zipFileName)); - echo $content; - unlink($zipFilePath); - } else { - echo 'Error :('; + if ($zip->close() !== true || ($content = file_get_contents($zipFilePath)) === false) { + @unlink($zipFilePath); + throw new LegacyDieException(500, 'Zip kann nicht erstellt werden.'); } + + unlink($zipFilePath); + + // Handed back as a response instead of echoed: the surrounding output buffer ends up + // inside the app layout, which would wrap the archive in HTML rather than download it. + throw new LegacyDownloadException( + response($content, 200, [ + 'Content-Type' => 'application/zip', + 'Content-Disposition' => 'attachment; filename="'.$zipFileName.'"', + 'Content-Length' => strlen($content), + ]) + ); } private function fetchBookingHistoryDataFromDB($hhp_id, $sortBy = ['timestamp' => true, 'id' => true]): array @@ -361,6 +370,17 @@ class="fa fa-fw fa-question-circle" aria-hidden="true"> title="CSV ist WINDOWS-1252 encoded (für Excel optimiert)"> als .zip + + + DATEV Export + + renderClearFix(); diff --git a/legacy/lib/booking/konto/FintsConnectionHandler.php b/legacy/lib/booking/konto/FintsConnectionHandler.php index 481797e3..ca92ed7a 100644 --- a/legacy/lib/booking/konto/FintsConnectionHandler.php +++ b/legacy/lib/booking/konto/FintsConnectionHandler.php @@ -3,7 +3,7 @@ namespace booking\konto; use App\Exceptions\LegacyDieException; -use Composer\InstalledVersions; +use App\Models\FintsInstitute; use DateTime; use Fhp\Action\GetSEPAAccounts; use Fhp\Action\GetStatementOfAccount; @@ -63,12 +63,12 @@ public function __construct( $this->logger->info('FINTS request for credential', ['credentialId' => $this->credentialId]); } - public static function saveCredentials(mixed $bankId, mixed $bankuser, mixed $name): int + public static function saveCredentials(mixed $blz, mixed $bankuser, mixed $name): int { $db = DBConnector::getInstance(); return (int) $db->dbInsert('konto_credentials', [ - 'bank_id' => $bankId, + 'blz' => $blz, 'owner_id' => $db->getUser()['id'], 'bank_username' => $bankuser, 'name' => $name, @@ -135,9 +135,12 @@ public function logout(): bool $this->finTs->close(); // logout @ server $this->forgetCachedCredentials($this->credentialId); HTMLPageRenderer::addFlash(BT::TYPE_SUCCESS, 'Erfolgreich ausgeloggt'); - } catch (ServerException $e) { + } catch (CurlException|ServerException|UnexpectedResponseException $e) { + // A logout that cannot reach the bank is not worth an error page - the local + // session data is dropped either way below. $this->logger->error('Logout failed', ['exception' => $e]); HTMLPageRenderer::addFlash(BT::TYPE_DANGER, 'Logout fehlgeschlagen', $e->getMessage()); + $this->forgetCachedCredentials($this->credentialId); return false; } @@ -156,7 +159,7 @@ public function getUserTanModes(): array try { $this->logger->info('Fetch TAN Modes', ['credId' => $this->credentialId]); $tanModes = $this->finTs->getTanModes(); - } catch (CurlException|ServerException $e) { + } catch (CurlException|ServerException|UnexpectedResponseException $e) { $this->logger->info('Fetch TAN Modes failed', ['exception' => $e]); ErrorHandler::handleException($e, 'TAN Modi können nicht empfangen werden - Verbringung zur Bank gestört'); } @@ -185,7 +188,7 @@ public function getTanMedias(int $tanModeId): array } return $tanMediumNames; - } catch (CurlException|ServerException $e) { + } catch (CurlException|ServerException|UnexpectedResponseException $e) { $this->logger->error('Tan kann nicht empfangen werden - Verbindung zur Bank gestört', ['exception' => $e]); ErrorHandler::handleException($e, 'TAN Modi können nicht empfangen werden - Verbindung zur Bank gestört'); } @@ -281,14 +284,52 @@ private function saveAction(?BaseAction $action = null): void // chache it if tan is missing $this->logger->info('Save Action - TAN needed', ['credId' => $this->credentialId, 'action' => $action::class]); $this->setCache('action', $action); + if ($this->isDecoupledTanMode() && $this->getCache('decoupled-next-check') === null) { + // Seeded once per pending action, not on every call through here: a decoupled + // TanRequest gets refreshed on every failed checkDecoupledSubmission() (see + // confirmDecoupledTan()), which re-enters this same branch, and re-seeding on + // each of those would keep pushing the earliest allowed check into the future + // instead of counting down towards it. + $tanMode = $this->finTs->getSelectedTanMode(); + $this->setCache('decoupled-checks', 0); + $this->setCache('decoupled-next-check', time() + $tanMode->getFirstDecoupledCheckDelaySeconds()); + } } else { // delete it from cache otherwise $this->setCache('action', null); + $this->setCache('decoupled-checks', null); + $this->setCache('decoupled-next-check', null); + if ($action === null) { + // Only dropping the action outright - a fresh start, or the "belongs to + // something else" branch in getStatements() - drops the scope with it. A + // *finished* action that is still handed in here (submitTan() or + // checkDecoupledSubmission() just completed it) must keep its scope: that is + // what lets getStatements() recognise it as its own action on the next call and + // return its result, instead of discarding it as belonging to another request + // and starting a fresh one that needs another TAN. + $this->setCache('action-scope', null); + } } // save persist in cache $this->setCache('persist', $this->finTs->persist()); } + /** + * Whether the bank access's selected TAN mode is a decoupled one, i.e. the user confirms + * on their banking app instead of typing a TAN. getSelectedTanMode() can throw + * InvalidArgumentException if the persisted mode id no longer matches anything in a + * refreshed BPD; that is not this method's problem to raise, so it is treated the same as + * "no mode selected yet". + */ + public function isDecoupledTanMode(): bool + { + try { + return $this->finTs->getSelectedTanMode()?->isDecoupled() ?? false; + } catch (InvalidArgumentException) { + return false; + } + } + private function isCached(string|int $key): bool { return request()?->session()->exists("fints.$this->credentialId.$key"); @@ -306,7 +347,17 @@ private function getCache(string|int $key) private function forgetCachedCredentials(int $credential_id): void { - request()?->session()->forget("fints.$this->credentialId"); + self::forgetSession($credential_id); + } + + /** + * Drops everything the session holds for a bank access: password, the persisted dialog + * state and the logged-in marker. Static because deleting a bank access has to clear it + * whether or not there is a live connection to hang the call off. + */ + public static function forgetSession(int $credentialId): void + { + request()?->session()->forget("fints.$credentialId"); } /** @@ -321,12 +372,16 @@ public static function load(int $credentialId): self $db = DBConnector::getInstance(); $res = $db->dbFetchAll('konto_credentials', [DBConnector::FETCH_ASSOC], - ['konto_credentials.*', 'bank' => 'konto_bank.*'], + ['konto_credentials.*', 'bank' => 'fints_institutes.*'], [ 'konto_credentials.owner_id' => $db->getUser()['id'], 'konto_credentials.id' => $credentialId, ], - [['type' => 'inner', 'table' => 'konto_bank', 'on' => ['konto_credentials.bank_id', 'konto_bank.id']]] + [[ + 'type' => 'inner', + 'table' => 'fints_institutes', + 'on' => ['konto_credentials.blz', 'fints_institutes.blz'], + ]] ); if (count($res) === 1) { @@ -342,11 +397,47 @@ public static function load(int $credentialId): self $credentials = Credentials::create($username, self::getPassword($credentialId)); + if (trim((string) FINTS_REGNR) === '') { + // FinTsOptions::validate() would raise "Product name required!" as an + // uncaught InvalidArgumentException, i.e. an error page with no clue. + throw new LegacyDieException( + 500, + 'Für den Bankzugang fehlt die FinTS-Registrierungsnummer (FINTS_REG_NR in der Konfiguration). '. + 'Bitte wende dich an die Administration.' + ); + } + + if (empty($res['bank.pin_tan_address'])) { + throw new LegacyDieException( + 500, + "Für die BLZ {$res['blz']} führt die Bankenliste keinen FinTS-Zugang (PIN/TAN). ". + 'Dieses Institut unterstützt den Abruf nicht oder die Bankenliste ist veraltet.' + ); + } + + // Refuse before the PIN is on the wire. The synced list only ever yields HTTPS, so + // in practice this catches an address carried over from the retired konto_bank + // table, which nobody ever validated - until the first sync overwrites it. + if (! FintsInstitute::hasSecurePinTanAddress($res['bank.pin_tan_address'])) { + throw new LegacyDieException( + 500, + "Die hinterlegte FinTS-Adresse für die BLZ {$res['blz']} ist nicht mit https:// ". + 'gesichert; PIN und TAN würden unverschlüsselt übertragen. Der Abruf wurde '. + 'abgebrochen. Bitte aktualisiere die Bankenliste (php artisan '. + 'stufis:fints-institutes-update) oder wende dich an die Administration.' + ); + } + $options = new FinTsOptions; - $options->url = $res['bank.url']; - $options->bankCode = $res['bank.blz']; + $options->url = $res['bank.pin_tan_address']; + $options->bankCode = $res['blz']; $options->productName = FINTS_REGNR; - $options->productVersion = InstalledVersions::getRootPackage()['version'].DEV ? '-dev' : ''; + // The concatenation binds tighter than ?:, so this used to evaluate as + // (('4.4.3'.DEV) ? '-dev' : '') - an always-truthy string, which reported the + // version to the bank as literally "-dev" regardless of what is installed. + // config('stufis.version') is the pretty version; getRootPackage()['version'] + // would be the normalised one ("4.4.4.0" rather than "4.4.4"). + $options->productVersion = config('stufis.version').(DEV ? '-dev' : ''); $tanModeInt = null; if ($res['tan_mode'] !== 'null' && ! is_null($res['tan_mode'])) { @@ -374,7 +465,7 @@ private function execute(BaseAction $action): void // TODO decoupled tan stuff here throw new NeedsTanException($action); } - } catch (CurlException|ServerException $e) { + } catch (CurlException|ServerException|UnexpectedResponseException $e) { $this->logger->error('Aktion nicht ausgeführt', ['exception' => $e]); ErrorHandler::handleException($e, 'Verbindung zur Bank gestört - Aktion nicht ausgeführt'); } @@ -423,16 +514,128 @@ public function submitTan(string $tan): bool HTMLPageRenderer::addFlash(BT::TYPE_DANGER, 'Konnte keine Verbindung zum Server aufbauen', $e->getMessage()); return false; - } catch (ServerException $e) { + } catch (ServerException|UnexpectedResponseException $e) { + // A rejected TAN arrives as UnexpectedResponseException("Bank has not accepted + // TAN: ...") from FinTs::submitTan(). That extends RuntimeException, while + // ServerException extends Exception - two unrelated hierarchies, so catching + // only the latter turned a mistyped TAN into an error page. $this->logger->error('Wrong Tan', ['exception' => $e]); HTMLPageRenderer::addFlash(BT::TYPE_DANGER, 'TAN nicht akzeptiert', $e->getMessage()); + return false; + } catch (InvalidArgumentException $e) { + // The library refuses to take a TAN for a decoupled TAN mode. Reaching here in + // practice would mean a stale page (e.g. still open in another tab) posted a 'tan' + // field even though the current TAN mode is decoupled - the confirmation page + // renders no such field. This is a safety net for that edge case, not the expected + // path. + $this->logger->error('TAN submission rejected by the library', ['exception' => $e]); + HTMLPageRenderer::addFlash( + BT::TYPE_DANGER, + 'Für dieses TAN-Verfahren wird keine TAN eingegeben', + 'Bitte lade die Seite neu und bestätige die Anfrage stattdessen in der Banking-App.' + ); + return false; } return true; } + /** + * For a decoupled TAN mode, asks the bank whether the user has confirmed the pending + * action on their banking app yet - the counterpart to submitTan() for modes that carry no + * TAN at all. Modelled closely on submitTan(): same logging, same three catch arms. + * + * @return bool true once the bank confirms the action is done + */ + public function confirmDecoupledTan(): bool + { + $this->logger->info('Confirm decoupled TAN', ['credId' => $this->credentialId]); + $action = $this->getCache('action'); + if ($action === null) { + HTMLPageRenderer::addFlash(BT::TYPE_INFO, 'Es liegt keine offene Anfrage vor, die bestätigt werden könnte'); + + return false; + } + try { + $tanMode = $this->finTs->getSelectedTanMode(); + $maxChecks = $tanMode->getMaxDecoupledChecks(); + $usedChecks = $this->getCache('decoupled-checks') ?? 0; + if ($maxChecks > 0 && $usedChecks >= $maxChecks) { + HTMLPageRenderer::addFlash( + BT::TYPE_DANGER, + 'Die Freigabe wurde nicht rechtzeitig bestätigt', + 'Die Bank hat innerhalb der erlaubten Versuche keine Freigabe gemeldet. Die Anfrage muss erneut gestartet werden.' + ); + $this->saveAction(); // drops the pending action, it cannot be resumed anymore + + return false; + } + + $nextCheck = $this->getCache('decoupled-next-check'); + if ($nextCheck !== null && time() < $nextCheck) { + // No sleep() here: this runs inside a request that holds the session lock, and + // sleeping while holding it would block every other tab/request of the same + // user for the same duration. + HTMLPageRenderer::addFlash( + BT::TYPE_INFO, + 'Bitte noch '.($nextCheck - time()).' Sekunden warten, bevor erneut bei der Bank nachgefragt werden kann' + ); + + return false; + } + + $done = $this->finTs->checkDecoupledSubmission($action); + $this->setCache('decoupled-checks', $usedChecks + 1); + $this->setCache('decoupled-next-check', time() + $tanMode->getPeriodicDecoupledCheckDelaySeconds()); + // The library requires the FinTs instance to be persist()-ed again after every + // checkDecoupledSubmission() call, whether or not it returned true - its internal + // dialog state moves on regardless. saveAction() is what does that persist() call. + $this->saveAction($action); + + if (! $done) { + HTMLPageRenderer::addFlash(BT::TYPE_INFO, 'Die Bank hat die Freigabe noch nicht gesehen - bitte in der Banking-App bestätigen und erneut versuchen'); + } + + return $done; + } catch (CurlException $e) { + $this->logger->error('Confirm decoupled TAN: no Connection', ['exception' => $e]); + HTMLPageRenderer::addFlash(BT::TYPE_DANGER, 'Konnte keine Verbindung zum Server aufbauen', $e->getMessage()); + + return false; + } catch (ServerException|UnexpectedResponseException $e) { + $this->logger->error('Confirm decoupled TAN failed', ['exception' => $e]); + HTMLPageRenderer::addFlash(BT::TYPE_DANGER, 'Anfrage bei der Bank fehlgeschlagen', $e->getMessage()); + + return false; + } catch (InvalidArgumentException $e) { + // The library refuses checkDecoupledSubmission() for anything other than a + // decoupled mode with a pending TanRequest. Reaching here would mean the TAN mode + // changed underneath an open confirmation page - a safety net, not the expected + // path. + $this->logger->error('Confirm decoupled TAN rejected by the library', ['exception' => $e]); + HTMLPageRenderer::addFlash(BT::TYPE_DANGER, 'Diese Anfrage kann nicht bestätigt werden', $e->getMessage()); + + return false; + } + } + + /** + * How many of the bank's allowed confirmDecoupledTan() attempts are left, for display on + * the confirmation page. Null when the mode does not cap them at all (getMaxDecoupledChecks() + * returning 0 means unlimited). + */ + public function decoupledChecksRemaining(): ?int + { + $maxChecks = $this->finTs->getSelectedTanMode()?->getMaxDecoupledChecks() ?? 0; + if ($maxChecks <= 0) { + return null; + } + + return max(0, $maxChecks - ($this->getCache('decoupled-checks') ?? 0)); + } + public function resumableAction(): ?BaseAction { return $this->activeAction ?? $this->getCache('action') ?? null; @@ -447,7 +650,7 @@ public function setTanMode(int $tanModeId, ?string $tanMediumName = null): bool } $this->saveAction(); $this->logger->info('Set TAN Mode', ['credId' => $this->credentialId, 'tanMode' => $tanModeId, 'tanMedium' => $tanMediumName]); - } catch (CurlException|ServerException $e) { + } catch (CurlException|ServerException|UnexpectedResponseException $e) { $this->logger->error('BPB fetch failed', ['exception' => $e]); ErrorHandler::handleException($e, 'Kann keine Verbindung zum Bank Server aufbauen', 'BPB fetch failed'); } @@ -464,27 +667,59 @@ public function setTanMode(int $tanModeId, ?string $tanMediumName = null): bool ) === 1; } - public function getStatements(string $iban, DateTime $start, DateTime $end): StatementOfAccount + /** + * @param DateTime|null $start null asks the bank for its own default range, which is + * what an account without a configured sync_from gets + */ + public function getStatements(string $iban, ?DateTime $start, DateTime $end): StatementOfAccount { + // What a pending statement request was created for. While it waits for a TAN the + // action sits in the session, and it used to be resumed on nothing but its type: + // asking for account A, then opening account B's import URL and entering the TAN + // there returned A's statements, which the caller then stored under B's konto_id. + $scope = $this->statementScope($iban, $start, $end); $action = $this->resumableAction(); if ($action instanceof GetStatementOfAccount) { - if ($action->isDone()) { - $this->saveAction(); + if ($this->getCache('action-scope') === $scope) { + if ($action->isDone()) { + $this->saveAction(); - return $action->getStatement(); + return $action->getStatement(); + } + throw new NeedsTanException($action); } - throw new NeedsTanException($action); + $this->logger->warning('Discarding a pending statement request made for something else', [ + 'credId' => $this->credentialId, + 'requested' => $scope, + ]); + // Say it out loud as well: the log lands in legacy/runtime/logs/fints.log, which + // is not somewhere anyone looks, and from the user's side the TAN they were about + // to enter simply stops applying. + HTMLPageRenderer::addFlash( + BT::TYPE_INFO, + 'Der noch offene Umsatzabruf gehörte zu einem anderen Konto oder Zeitraum und wurde verworfen', + 'Der Abruf für dieses Konto wird neu gestartet - dafür ist eine neue TAN nötig.' + ); + $this->saveAction(); // drops the stale action and its scope } $this->logger->info('Start Get SEPA Statements', ['credId' => $this->credentialId, $iban]); $account = $this->getSepaAccount($iban); $account = clone $account; // weird fix, without the clone the session var is changed to DateTime object // might be a bug in fints TODO: see if minimal example with the same bug can be found $action = GetStatementOfAccount::create($account, $start, $end); + // Has to be recorded before execute(), which caches the action and then throws + // NeedsTanException, ending this request. + $this->setCache('action-scope', $scope); $this->execute($action); return $action->getStatement(); } + private function statementScope(string $iban, ?DateTime $start, DateTime $end): string + { + return $iban.'|'.($start?->format('Y-m-d') ?? 'bank-default').'|'.$end->format('Y-m-d'); + } + public function getLogger(): LoggerInterface { return $this->finTs->getLogger(); diff --git a/legacy/lib/booking/konto/FintsController.php b/legacy/lib/booking/konto/FintsController.php index f3ac4623..eb72e9f6 100644 --- a/legacy/lib/booking/konto/FintsController.php +++ b/legacy/lib/booking/konto/FintsController.php @@ -3,6 +3,8 @@ namespace booking\konto; use App\Exceptions\LegacyRedirectException; +use App\Models\FintsInstitute; +use App\Models\Legacy\BankAccount; use booking\konto\tan\FlickerGenerator; use Fhp\Model\StatementOfAccount\Statement; use Fhp\Model\StatementOfAccount\StatementOfAccount; @@ -12,7 +14,6 @@ use framework\ArrayHelper; use framework\DateHelper; use framework\DBConnector; -use framework\NewValidator; use framework\render\html\BT; use framework\render\html\FA; use framework\render\html\Html; @@ -28,14 +29,23 @@ class FintsController extends Renderer { - private ?FintsConnectionHandler $fintsHandler; + private ?FintsConnectionHandler $fintsHandler = null; private ?int $credentialId; + /** + * Actions that must stay reachable even when no connection can be built. load() refuses + * a bank whose FinTS address is missing or not HTTPS, and that is precisely the access + * somebody needs to delete - so deleting must not depend on it succeeding. + */ + private const array ACTIONS_WITHOUT_CONNECTION = ['delete-credentials']; + public function __construct(array $routeInfo = []) { $this->credentialId = $routeInfo['credential-id'] ?? null; - if ($this->credentialId !== null && FintsConnectionHandler::hasPassword($this->credentialId)) { + if ($this->credentialId !== null + && FintsConnectionHandler::hasPassword($this->credentialId) + && ! in_array($routeInfo['action'] ?? null, self::ACTIONS_WITHOUT_CONNECTION, true)) { $this->fintsHandler = FintsConnectionHandler::load($this->credentialId); } parent::__construct($routeInfo); @@ -43,16 +53,115 @@ public function __construct(array $routeInfo = []) public function render(): void { + $this->requireValidNonce(); $post = $this->request->request; if ($post->has('tan')) { - $this->fintsHandler->submitTan($post->getAlnum('tan')); + // Banks print TANs in groups ("123 456"), so drop whitespace - but nothing + // else: some TAN schemes are alphanumeric, and silently dropping characters + // would burn one of the three attempts the bank grants. + $this->requireFintsHandler()->submitTan(preg_replace('/\s+/', '', $post->get('tan', ''))); + } + if ($post->has('decoupled-confirm')) { + // Same placement and the same reasoning as the 'tan' branch above: whether or not + // the bank confirms is deliberately ignored here. If it does, the action underneath + // is now done and parent::render() below resumes and finishes it like any other + // completed action; if not, the action handler throws NeedsTanException again and + // the confirmation screen is simply redrawn. + $this->requireFintsHandler()->confirmDecoupledTan(); } try { parent::render(); } catch (NeedsTanException $e) { - $this->fintsHandler->logger->info('Tan needed', ['exception' => $e]); - $this->renderTanInput($e->getMessage(), $e->getTanRequest()); + $this->requireFintsHandler()->logger->info('Tan needed', ['exception' => $e]); + if ($this->requireFintsHandler()->isDecoupledTanMode()) { + $this->renderDecoupledConfirmation($e->getMessage(), $e->getTanRequest()); + } else { + $this->renderTanInput($e->getMessage(), $e->getTanRequest()); + } + } + } + + /** + * The legacy route group runs without Laravel's CSRF middleware (see bootstrap/app.php), + * and although every form here ships a `nonce` field holding csrf_token(), only + * RestHandler ever checked it - the actions in this controller did not. That left the + * bank access open to cross-site requests: forced login attempts (three failures lock + * the online banking access at the bank), creating credentials, and registering an + * arbitrary account for synchronisation. + * + * Verifying it here keeps the fix to the FinTS pages instead of switching the middleware + * for the whole legacy group, which is not a patch-release-sized change. + */ + private function requireValidNonce(): void + { + if ($this->request->getMethod() !== 'POST') { + return; + } + + $nonce = (string) $this->request->request->get('nonce', ''); + if ($nonce !== '' && hash_equals((string) csrf_token(), $nonce)) { + return; } + + // LegacyDieException would surface as a bare 500 page (LegacyController rethrows it + // and it carries no HTTP status of its own), so the request is refused with an + // explanation instead. Either way the action does not run. + HTMLPageRenderer::addFlash( + BT::TYPE_DANGER, + 'Die Anfrage wurde abgelehnt', + 'Das Formular war nicht mehr gültig - vermutlich ist die Sitzung abgelaufen. '. + 'Bitte lade die Seite neu und versuche es erneut.' + ); + + throw new LegacyRedirectException(redirect()->route('legacy.konto.credentials')); + } + + /** + * The bank password is only ever held in the session, so it is gone as soon as the + * session expires - and every action below needs it. Dereferencing the handler + * regardless used to raise "Typed property must not be accessed before + * initialization", i.e. an error page. Send the user back to the login instead. + */ + private function requireFintsHandler(): FintsConnectionHandler + { + if ($this->fintsHandler instanceof FintsConnectionHandler) { + return $this->fintsHandler; + } + + HTMLPageRenderer::addFlash( + BT::TYPE_INFO, + 'Die Verbindung zur Bank ist nicht mehr aktiv - vermutlich ist die Sitzung abgelaufen. Bitte melde dich erneut an.' + ); + + throw new LegacyRedirectException($this->credentialId === null + ? redirect()->route('legacy.konto.credentials') + : redirect()->route('legacy.konto.credentials.login', $this->credentialId)); + } + + /** + * Names the account a TAN is being asked for. Both TAN pages are drawn from the exception + * handler in render(), i.e. under whatever URL the interrupted action was started from, and + * neither says anything about the account by itself - so a TAN prompt for a statement import + * looked exactly like one for any other account. + * + * Only the import routes carry an account; a TAN asked for during login or while picking a + * TAN mode belongs to the whole bank access, and then there is nothing to name. + */ + private function renderRequestedAccount(): void + { + $shortIban = $this->routeInfo['short-iban'] ?? null; + if (! is_string($shortIban) || $shortIban === '') { + return; + } + + $account = BankAccount::findByShortIban($shortIban); + + // The full IBAN is deliberately taken from the account we know rather than resolved + // through the bank access: reaching for it there would fetch the SEPA account list, + // i.e. talk to the bank in the middle of drawing a TAN prompt. + echo Html::p()->body($account instanceof BankAccount + ? "Umsatzabruf für das Konto $account->name ($account->iban)" + : "Umsatzabruf für das Konto mit der IBAN $shortIban"); } private function renderTanInput(string $msg, TanRequest $tanRequest): void @@ -61,6 +170,7 @@ private function renderTanInput(string $msg, TanRequest $tanRequest): void $challengeText = $tanRequest->getChallenge(); echo Html::headline(1)->body($msg); + $this->renderRequestedAccount(); echo Html::headline(3)->body($mediumName); echo Html::p()->body($challengeText, false); @@ -89,6 +199,42 @@ private function renderTanInput(string $msg, TanRequest $tanRequest): void ->addSubmitButton(); } + /** + * Counterpart to renderTanInput() for a decoupled TAN mode: the approval happens on the + * user's banking app, so there is no TAN field to render - just a button that makes StuFiS + * ask the bank once whether the approval has arrived yet. Deliberately no JavaScript, no + * timer, no automated polling: the user confirms manually, in the banking app and then here. + */ + private function renderDecoupledConfirmation(string $msg, TanRequest $tanRequest): void + { + $mediumName = $tanRequest->getTanMediumName() ?? ''; + $challengeText = $tanRequest->getChallenge(); + + echo Html::headline(1)->body($msg); + $this->renderRequestedAccount(); + + echo Html::headline(3)->body($mediumName); + echo Html::p()->body($challengeText, false); + echo Html::p()->body( + 'Die Freigabe erfolgt in der Banking-App auf deinem Gerät. Wenn du sie dort erteilt hast, '. + 'fragt der folgende Knopf einmalig bei der Bank nach, ob die Freigabe angekommen ist.' + ); + + $remaining = $this->requireFintsHandler()->decoupledChecksRemaining(); + if ($remaining !== null) { + echo Html::p()->body("Noch $remaining von der Bank erlaubte Versuche."); + } + + echo HtmlForm::make('POST', false) + ->urlTarget(request()?->url()) + ->addHtmlEntity( + HtmlButton::make('submit') + ->style('primary') + ->attr('name', 'decoupled-confirm') + ->body('Ich habe die Freigabe erteilt') + ); + } + /** * Action to render fints home screen */ @@ -100,13 +246,17 @@ protected function actionViewCredentials(): void [ 'konto_credentials.id', 'konto_credentials.name', - 'bank_name' => 'konto_bank.name', + 'bank_name' => 'fints_institutes.name', 'tan_mode', 'tan_mode_name', 'tan_medium_name', ], ['owner_id' => \Auth::user()->id], - [['type' => 'inner', 'table' => 'konto_bank', 'on' => ['konto_bank.id', 'konto_credentials.bank_id']]] + [[ + 'type' => 'inner', + 'table' => 'fints_institutes', + 'on' => ['fints_institutes.blz', 'konto_credentials.blz'], + ]] ); echo HtmlButton::make() ->asLink(URIBASE.'konto/credentials/new') @@ -135,14 +285,19 @@ static function ($tanMode, $tanModeName, $tanMediumName, $id) use ($obj) { return $tanString; }, static function ($id) { // action + // Deleting stays offered either way: the usual reason to remove a bank + // access is that logging in with it does not work, and hiding the + // action behind an active session made exactly that case a dead end. + $delete = ""; + if (FintsConnectionHandler::hasActiveSession($id)) { return " ". - "". + $delete. ""; } - return ""; + return " ".$delete; }, ] ); @@ -162,32 +317,67 @@ static function ($id) { // action protected function actionNewCredentials() { $post = $this->request->request; - if (ArrayHelper::allIn($post->keys(), ['name', 'bank-id', 'bank-username'])) { + if (ArrayHelper::allIn($post->keys(), ['name', 'blz', 'bank-username'])) { + // The dropdown starts on its placeholder, so an untouched form posts an empty BLZ. + // Saying so beats normalising it into "00000000" and reporting that as unknown. + if (trim((string) $post->get('blz')) === '') { + HTMLPageRenderer::addFlash(BT::TYPE_DANGER, 'Bitte wähle die Bank aus, bei der der Zugang besteht.'); + HTMLPageRenderer::redirect(URIBASE.'konto/credentials/new'); + } + + $blz = FintsInstitute::normaliseBlz((string) $post->get('blz')); + + // The foreign key guarantees the BLZ exists; it cannot guarantee the institute + // actually offers PIN/TAN, which is the only thing we can talk to. + if (FintsInstitute::query()->pinTanCapable()->whereKey($blz)->doesntExist()) { + HTMLPageRenderer::addFlash(BT::TYPE_DANGER, "Für die BLZ $blz ist kein FinTS-Zugang bekannt."); + HTMLPageRenderer::redirect(URIBASE.'konto/credentials/new'); + } + DBConnector::getInstance()->dbInsert('konto_credentials', [ - 'name' => $post->getAlpha('name'), - 'bank_id' => $post->getInt('bank-id'), + // konto_credentials.name is varchar(63), so cut rather than let the insert fail. + 'name' => mb_substr(trim(strip_tags((string) $post->get('name'))), 0, 63), + 'blz' => $blz, 'bank_username' => trim(strip_tags($post->get('bank-username'))), 'owner_id' => DBConnector::getInstance()->getUser()['id'], ] ); HTMLPageRenderer::redirect(URIBASE.'konto/credentials'); } - $banks = DBConnector::getInstance()->dbFetchAll('konto_bank'); + + // Straight from the synced bank list, so there is no bank to maintain by hand. + $banks = FintsInstitute::query()->pinTanCapable()->orderBy('name')->get(['blz', 'name', 'location']); + $this->renderHeadline('Lege neue Zugangsdaten an'); + + if ($banks->isEmpty()) { + $this->renderAlert( + 'Keine Bankenliste vorhanden', + 'Die Liste der FinTS-fähigen Banken ist leer. Die Administration muss sie einmalig einlesen '. + '(php artisan stufis:fints-institutes-update), danach kann hier eine Bank gewählt werden.', + 'danger' + ); + + return; + } + $this->renderAlert('Hinweis', 'Die hier geforderten Daten werden (bis zur manuellen Löschung) gespeichert. Das Online-Banking Passwort wird immer nur zur Laufzeit verwendet und nicht permanent gespeichert', 'info'); - $liveSearch = count($banks) > 5; echo HtmlForm::make('POST', false) ->urlTarget(URIBASE.'konto/credentials/new') ->addHtmlEntity(HtmlInput::make('text')->label('Name des Zugangs')->name('name')) ->addHtmlEntity(HtmlDropdown::make() ->label('Bank') - ->liveSearch($liveSearch) - ->name('bank-id') - ->setItems(array_combine(array_column($banks, 'id'), array_map(static function ($el) { - return [$el['name'], "BLZ: {$el['blz']}"]; - }, $banks))) + ->liveSearch(true) + ->name('blz') + // Without this the browser preselects the first bank of the list, and a form + // submitted without touching the dropdown would quietly pick that one. The + // selectpicker turns a title into a placeholder option with an empty value. + ->title('Bank auswählen') + ->setItems($banks->mapWithKeys(static fn (FintsInstitute $bank): array => [ + $bank->blz => [$bank->name, "BLZ: $bank->blz".($bank->location ? ", $bank->location" : '')], + ])->all()) ) ->addHtmlEntity(HtmlInput::make('text')->label('Bank Username')->name('bank-username')) ->addSubmitButton(); @@ -198,7 +388,7 @@ protected function actionPickTanMode(): void if (isset($_POST['tan-mode-id'])) { $tanModeId = (int) $_POST['tan-mode-id']; try { - $success = $this->fintsHandler->setTanMode($tanModeId); + $success = $this->requireFintsHandler()->setTanMode($tanModeId); if ($success) { HTMLPageRenderer::addFlash(BT::TYPE_SUCCESS, 'TAN Modus gespeichert'); HTMLPageRenderer::redirect(URIBASE.'konto/credentials'); @@ -210,7 +400,7 @@ protected function actionPickTanMode(): void HTMLPageRenderer::redirect(URIBASE."konto/credentials/$this->credentialId/tan-mode/$tanModeId/medium"); } } - $tanModes = $this->fintsHandler->getUserTanModes(); + $tanModes = $this->requireFintsHandler()->getUserTanModes(); $form = HtmlForm::make('POST', false)->urlTarget(URIBASE."konto/credentials/$this->credentialId/tan-mode"); echo $form->begin(); $this->renderHeadline('Bitte TAN-Modus auswählen'); @@ -227,7 +417,7 @@ protected function actionPickTanMedium(): void $post = $this->request->request; $tanModeInt = (int) $this->routeInfo['tan-mode-id']; if ($post->has('tan-medium-name')) { - $success = $this->fintsHandler->setTanMode($tanModeInt, $post->get('tan-medium-name')); + $success = $this->requireFintsHandler()->setTanMode($tanModeInt, $post->get('tan-medium-name')); if ($success) { HTMLPageRenderer::addFlash(BT::TYPE_SUCCESS, 'TAN Medium gespeichert'); HTMLPageRenderer::redirect(URIBASE.'konto/credentials'); @@ -236,7 +426,7 @@ protected function actionPickTanMedium(): void } } - $tanMedien = $this->fintsHandler->getTanMedias($tanModeInt); + $tanMedien = $this->requireFintsHandler()->getTanMedias($tanModeInt); echo "
"; $this->renderHeadline('Bitte TAN-Medium auswählen'); @@ -262,14 +452,17 @@ protected function actionLogin(): void )[0]; $post = $this->request->request; if ($post->has('bank-password')) { - // a PW was sent - $pw = $post->getAlnum('bank-password'); + // a PW was sent. Take it verbatim: do not strip every special + // character and umlaut, banks do allow those in a PIN (see the docs on + // Fhp\Options\Credentials::create). A mangled PIN is indistinguishable from + // a wrong one, and three wrong ones lock the online-banking access. + $pw = (string) $post->get('bank-password'); FintsConnectionHandler::setLoginPassword($credentialId, $pw); $this->fintsHandler = FintsConnectionHandler::load($credentialId); } if (FintsConnectionHandler::hasPassword($credentialId)) { // pw set - $success = $this->fintsHandler->login(); // throws if Tan needed + $success = $this->requireFintsHandler()->login(); // throws if Tan needed if ($success) { throw new LegacyRedirectException(redirect()->route('legacy.konto.credentials')); } @@ -301,8 +494,8 @@ protected function actionLogin(): void protected function actionViewSepa() { - $accounts = $this->fintsHandler->getSepaAccounts(); - $ibans = $this->fintsHandler->getIbans(); + $accounts = $this->requireFintsHandler()->getSepaAccounts(); + $ibans = $this->requireFintsHandler()->getIbans(); $dbAccounts = DBConnector::getInstance()->dbFetchAll( 'konto_type', @@ -362,53 +555,84 @@ function ($actionName, $iban) use ($credId): string { ->asLink(URIBASE.'konto/credentials'); } + /** + * Registering an account is the Livewire page's job (pages::new-banking-account), which + * validates with Laravel rules, knows about sync_until and manually_enterable, and is + * the same form used everywhere else. This hands the account over to it with the IBAN + * prefilled instead of keeping a second, hand-written create form here. + */ protected function actionNewSepaKonto(): void { - if ($this->request->request->count() > 0) { - $post = $this->request->request; - $syncFrom = date_create($post->get('sync-from'))->format('Y-m-d'); - $kontoIban = $post->getAlnum('iban'); - [, $iban] = (new NewValidator)->validate($kontoIban, 'iban'); - $kontoName = substr(htmlspecialchars(strip_tags(trim($post->getAlpha('konto-name')))), 0, 32); - $kontoShort = strtoupper(substr($post->getAlpha('konto-short'), 0, 2)); - $ret = DBConnector::getInstance()->dbInsert('konto_type', [ - 'name' => $kontoName, - 'short' => $kontoShort, - 'sync_from' => $syncFrom, - 'iban' => $iban, - ]); - // TODO: use $ret - HTMLPageRenderer::addFlash(BT::TYPE_SUCCESS, 'Erfolgreich gespeichert'); + $shortIban = $this->routeInfo['short-iban']; + // Resolved from the account list of this bank access, not from user input, so the + // prefilled value is one of the accounts the credential actually holds. + $iban = $this->requireFintsHandler()->lengthenIban($shortIban); + + if ($iban === null) { + HTMLPageRenderer::addFlash( + BT::TYPE_DANGER, + 'Zu diesem Kürzel gehört kein Konto dieses Bankzugangs.' + ); HTMLPageRenderer::redirect(URIBASE."konto/credentials/$this->credentialId/sepa"); } - $shortIban = $this->routeInfo['short-iban']; - $iban = $this->fintsHandler->lengthenIban($shortIban); - - $this->renderHeadline('Neues Konto Importieren'); - echo HtmlForm::make('POST', false) - ->urlTarget(URIBASE."konto/credentials/$this->credentialId/$shortIban/import") - ->addHtmlEntity(HtmlInput::make()->name('iban')->label('IBAN')->value($iban)->readOnly()) - ->addHtmlEntity(HtmlInput::make()->name('konto-name')->label('Bezeichnung Konto')) - ->addHtmlEntity(HtmlInput::make()->name('konto-short')->label('Eindeutiges Buchstabenkürzel für das Konto (intern)')) - ->addHtmlEntity(HtmlInput::make('date')->name('sync-from')->label('Startdatum der Synchronisation')) - ->addSubmitButton('Speichern'); + throw new LegacyRedirectException(redirect()->route('bank-account.new', [ + 'iban' => $iban, + // Marks this as a synced account: the page locks the IBAN and the manual-entry + // switch for it. + 'bankSynced' => 1, + // Built as a relative path from a named route, so what the page gets handed can + // only ever point back into this application. + 'returnTo' => route('legacy.konto.credentials.sepa', ['credential_id' => $this->credentialId], false), + ])); } protected function actionImportNewSepaStatements() { $shortIban = $this->routeInfo['short-iban']; - $iban = $this->fintsHandler->lengthenIban($shortIban); + $iban = $this->requireFintsHandler()->lengthenIban($shortIban); - $dbKonto = DBConnector::getInstance()->dbFetchAll( + if ($iban === null) { + HTMLPageRenderer::addFlash( + BT::TYPE_DANGER, + 'Zu diesem Kürzel gehört kein Konto dieses Bankzugangs.' + ); + HTMLPageRenderer::redirect(URIBASE."konto/credentials/$this->credentialId/sepa"); + } + + $dbKontos = DBConnector::getInstance()->dbFetchAll( 'konto_type', [DBConnector::FETCH_UNIQUE_FIRST_COL_AS_KEY], ['iban', '*'] - )[$iban]; + ); + + // Reaching this URL for an account that was never registered used to be an + // undefined-array-key error page. + if (! isset($dbKontos[$iban])) { + HTMLPageRenderer::addFlash( + BT::TYPE_WARNING, + 'Dieses Konto ist noch nicht für den Import eingerichtet. Bitte lege es zuerst an.' + ); + HTMLPageRenderer::redirect(URIBASE."konto/credentials/$this->credentialId/$shortIban/import"); + } + $dbKonto = $dbKontos[$iban]; [$startDate, $syncUntil] = DateHelper::fromUntilLast($dbKonto['sync_from'], $dbKonto['sync_until'], $dbKonto['last_sync']); - $statements = $this->fintsHandler->getStatements($iban, $startDate, $syncUntil); + try { + $statements = $this->requireFintsHandler()->getStatements($iban, $startDate, $syncUntil); + } catch (InvalidArgumentException $e) { + // getSepaAccount() throws when the bank access does not hold this IBAN, which is + // reachable because an account may also be registered by hand (or for a cash box) + // on the Livewire page. That is a wrong-page situation, not a server error. + $this->requireFintsHandler()->logger->warning('Statement request for an IBAN this credential does not hold', ['exception' => $e]); + HTMLPageRenderer::addFlash( + BT::TYPE_DANGER, + 'Dieses Konto gehört nicht zu diesem Bankzugang', + 'Bitte rufe die Umsätze über den Bankzugang ab, dem das Konto gehört.' + ); + HTMLPageRenderer::redirect(URIBASE."konto/credentials/$this->credentialId/sepa"); + } [$success, $msg] = $this->saveStatements($statements, $dbKonto['id']); @@ -419,7 +643,7 @@ protected function actionImportNewSepaStatements() protected function saveStatements(StatementOfAccount $statements, int $kontoId): array { $db = DBConnector::getInstance(); - $logger = $this->fintsHandler->getLogger(); + $logger = $this->requireFintsHandler()->getLogger(); $lastKontoRow = $db->dbFetchAll( tables: 'konto', where: ['konto_id' => $kontoId], @@ -431,6 +655,11 @@ protected function saveStatements(StatementOfAccount $statements, int $kontoId): $tryRewind = false; $rewindDiff = 0; $skipped = false; + // Was the resume point in the already-stored data established? Without stored rows + // there is nothing to resume from, so everything the bank sent is new. + $anchorFound = true; + $lastStoredSaldoCent = null; + $stoppedAtSyncUntil = false; $kontoRow = $db->dbFetchAll(tables: 'konto_type', where: ['id' => $kontoId])[0]; $syncUntil = DateHelper::fromDb($kontoRow['sync_until']); @@ -440,7 +669,11 @@ protected function saveStatements(StatementOfAccount $statements, int $kontoId): $lastKontoId = $lastKontoRow['id']; $lastKontoSaldo = $lastKontoRow['saldo']; $oldSaldoCent = $this->convertToCent($lastKontoSaldo); + // Kept separately: $oldSaldoCent is reused below as the running statement-to- + // statement saldo, so it no longer holds the stored value once the loop starts. + $lastStoredSaldoCent = $oldSaldoCent; $tryRewind = true; + $anchorFound = false; $logger->debug('Found last entry', $lastKontoRow); } @@ -452,11 +685,16 @@ protected function saveStatements(StatementOfAccount $statements, int $kontoId): $dateString = $statement->getDate()->format(DBConnector::SQL_DATE_FORMAT); $saldoCent = $this->convertToCent($statement->getStartBalance(), $statement->getCreditDebit()); $logger->debug('Statement', ['date' => $dateString, 'saldo' => $saldoCent]); + // Continuity between two consecutive statements: the closing saldo of the + // previous one has to be the opening balance of this one. if ($tryRewind === false && $oldSaldoCent !== null && $oldSaldoCent !== $saldoCent) { $db->dbRollBack(); - $logger->debug("Wrong saldo $oldSaldoCent !== $saldoCent at statement from $dateString", [var_export($statements, true)]); + $logger->error("Wrong saldo $oldSaldoCent !== $saldoCent at statement from $dateString"); + $msg = 'Die Kontoauszüge der Bank sind nicht lückenlos: Der Auszug vom '.$dateString. + ' beginnt mit '.$this->convertCentForDB($saldoCent).' €, der vorherige endete mit '. + $this->convertCentForDB($oldSaldoCent).' €. Es wurde nichts importiert.'; - return [false, "$oldSaldoCent !== $saldoCent at statement from $dateString"]; + return [false, $msg]; } // echo "Statement $dateString Saldo: $saldoCent"; foreach ($statement->getTransactions() as $transaction) { @@ -468,7 +706,11 @@ protected function saveStatements(StatementOfAccount $statements, int $kontoId): 'date' => $transaction->getBookingDate()?->format('Y-m-d'), ]); if ($tryRewind === true) { - // do rewind if necessary + // Do rewind if necessary. customer_ref is deliberately NOT part of the + // criteria: it holds the SEPA end-to-end id, which MT940 usually leaves + // empty or reports as NOTPROVIDED, so matching on it made the anchor + // unfindable - and an unfound anchor used to mean a silent re-import of + // the whole range. The running saldo is a far stronger key anyway. $rewindRow = $db->dbFetchAll( tables: 'konto', showColumns: ['id'], @@ -478,7 +720,6 @@ protected function saveStatements(StatementOfAccount $statements, int $kontoId): 'saldo' => $this->convertCentForDB($saldoCent), 'date' => $transaction->getBookingDate()?->format('Y-m-d'), 'valuta' => $transaction->getValutaDate()?->format('Y-m-d'), - 'customer_ref' => $transaction->getEndToEndID(), ], sort: ['id' => false], limit: 1 @@ -495,11 +736,32 @@ protected function saveStatements(StatementOfAccount $statements, int $kontoId): $skipped = $skipped === false ? 1 : $skipped + 1; $logger->debug('SKIP TRANSACTION - found in DB'); + if ($rewindDiff === 0) { + // Last already-stored transaction consumed: this is the one place + // where the freshly computed saldo can be held against the stored + // one. Previously this comparison never ran - it was guarded by + // $tryRewind === false, and by the time that was true the stored + // value had already been overwritten by the running saldo. + if ($saldoCent !== $lastStoredSaldoCent) { + $db->dbRollBack(); + $msg = 'Der Kontostand der Bank passt nicht zum gespeicherten Stand ('. + $this->convertCentForDB($saldoCent).' € statt '. + $this->convertCentForDB($lastStoredSaldoCent).' € nach dem letzten bekannten Umsatz vom '. + $dateString.'). Es wurde nichts importiert.'; + $logger->error($msg, ['konto_id' => $kontoId]); + + return [false, $msg]; + } + $anchorFound = true; + } + continue; // skip this entry, it was in the db before } // are we exceeding sync_until? if ($syncUntil && $transaction->getValutaDate()?->diff($syncUntil)->invert === 1) { + $stoppedAtSyncUntil = true; + break 2; } @@ -525,6 +787,20 @@ protected function saveStatements(StatementOfAccount $statements, int $kontoId): $oldSaldoCent = $saldoCent; } + // The bank sends a range that overlaps what is already stored, so the already-known + // transactions have to be identified and skipped. If that resume point was never + // reached, every transaction of the range looks new - which is how a re-import used + // to duplicate months of bookings while reporting success. Refuse instead. + if ($anchorFound === false && $stoppedAtSyncUntil === false) { + $db->dbRollBack(); + $msg = 'Der letzte bereits importierte Umsatz wurde in den Daten der Bank nicht wiedergefunden. '. + 'Es wurde nichts importiert, um doppelte Buchungen zu vermeiden. '. + 'Bitte prüfe, ob Umsätze nachträglich verändert wurden, und wende dich an die Administration.'; + $logger->error($msg, ['konto_id' => $kontoId, 'last_stored_saldo_cent' => $lastStoredSaldoCent]); + + return [false, $msg]; + } + if (count($transactionData) > 0) { $db->dbInsertMultiple('konto', array_keys($transactionData[0]), ...$transactionData); $db->dbUpdate('konto_type', ['id' => $kontoId], ['last_sync' => $dateString]); @@ -545,9 +821,125 @@ protected function saveStatements(StatementOfAccount $statements, int $kontoId): return [$ret, $msg]; } + /** + * Two steps on purpose: GET renders the confirmation, POST carries it out. That keeps the + * icon in the overview a plain link - a GET that only renders a page - while the + * irreversible half is a nonce-checked POST, and whoever clicks it gets told beforehand + * what does and does not disappear. + */ + protected function actionDeleteCredentials(): void + { + $credentialId = (int) $this->credentialId; + $credential = $this->ownCredential($credentialId); + + if ($this->request->getMethod() !== 'POST') { + $this->renderDeleteConfirmation($credentialId, $credential); + + return; + } + + // Best effort, and only when a live dialog exists: the bank drops an abandoned + // session by itself, so a logout that cannot be reached must not stop the deletion. + if ($this->fintsHandler instanceof FintsConnectionHandler) { + $this->fintsHandler->logout(); + } + + // Before the row goes, so a stale password cannot linger under an id the next access + // might be handed. + FintsConnectionHandler::forgetSession($credentialId); + + DBConnector::getInstance()->dbDelete('konto_credentials', [ + 'id' => $credentialId, + 'owner_id' => \Auth::user()->id, + ]); + + HTMLPageRenderer::addFlash( + BT::TYPE_SUCCESS, + 'Zugangsdaten gelöscht', + "Der Bankzugang „{$credential['name']}“ wurde entfernt. Die Konten und ihre Buchungen sind unverändert." + ); + + throw new LegacyRedirectException(redirect()->route('legacy.konto.credentials')); + } + + /** + * The bank access with this id belonging to the logged-in user. The id comes out of the + * URL, so the ownership filter is what keeps one user off another's bank access. + */ + private function ownCredential(int $credentialId): array + { + $rows = DBConnector::getInstance()->dbFetchAll( + 'konto_credentials', + [DBConnector::FETCH_ASSOC], + [ + 'konto_credentials.id', + 'konto_credentials.name', + 'konto_credentials.bank_username', + 'bank_name' => 'fints_institutes.name', + ], + [ + 'konto_credentials.owner_id' => \Auth::user()->id, + 'konto_credentials.id' => $credentialId, + ], + [[ + 'type' => 'inner', + 'table' => 'fints_institutes', + 'on' => ['fints_institutes.blz', 'konto_credentials.blz'], + ]] + ); + + if (count($rows) !== 1) { + HTMLPageRenderer::addFlash(BT::TYPE_DANGER, 'Diesen Bankzugang gibt es nicht.'); + + throw new LegacyRedirectException(redirect()->route('legacy.konto.credentials')); + } + + return $rows[0]; + } + + private function renderDeleteConfirmation(int $credentialId, array $credential): void + { + $this->renderHeadline('Zugangsdaten löschen'); + + $this->renderAlert( + 'Wirklich löschen?', + 'Der Bankzugang wird samt hinterlegtem TAN-Verfahren entfernt und kann nicht '. + 'wiederhergestellt werden. Die Konten und ihre bereits importierten Buchungen '. + 'bleiben erhalten - für sie werden ab dann aber keine Umsätze mehr abgerufen, '. + 'bis ein neuer Bankzugang eingerichtet ist.', + BT::TYPE_WARNING + ); + + echo HtmlCard::make() + ->cardHeadline($this->defaultEscapeFunction($credential['name'])) + ->appendBody( + HtmlInput::make('text')->label('Bank')->value($credential['bank_name'])->disable(), + false + ) + ->appendBody( + HtmlInput::make('text')->label('Bank Username')->value($credential['bank_username'])->disable(), + false + ) + ->appendBody( + HtmlForm::make('POST', false) + ->urlTarget(URIBASE."konto/credentials/$credentialId/delete") + ->addSubmitButton('Endgültig löschen'), + false + ); + + echo HtmlButton::make() + ->style('primary') + ->body('Abbrechen') + ->icon('chevron-left') + ->asLink(URIBASE.'konto/credentials'); + } + protected function actionLogout(): void { - if (isset($this->fintsHandler)) { + // Logging out of a connection that is already gone is not an error worth a + // redirect to the login, so this keeps its own handling instead of using + // requireFintsHandler(). + if ($this->fintsHandler instanceof FintsConnectionHandler) { $this->fintsHandler->logout(); } else { HTMLPageRenderer::addFlash(BT::TYPE_WARNING, 'FINTS war nicht verbunden.'); @@ -559,18 +951,20 @@ protected function actionLogout(): void * @param string|null $creditDebit either @see Statement::CD_DEBIT or @see Statement::CD_CREDIT, if null its * assumed by sign of $amount */ - private function convertToCent(string|float $amount, ?string $creditDebit = null): float|int + private function convertToCent(string|float $amount, ?string $creditDebit = null): int { - $float = (float) $amount; - $cents = (int) round($float * 100); + $cents = (int) round(((float) $amount) * 100); if (is_null($creditDebit)) { - $sign = ($float > 0) - ($float < 0); - - return $sign * $cents; + // $cents already carries the sign of $amount. Multiplying by sign($amount) + // on top of that flipped every negative value to positive. + return $cents; } - return ($creditDebit === Statement::CD_DEBIT ? -1 : 1) * $cents; + // Bank statements carry an unsigned magnitude plus a separate credit/debit mark + // (MT940 fields 60F/61), so abs() is a no-op on real bank data. It only guards + // against a caller handing in an already-signed amount together with a mark. + return ($creditDebit === Statement::CD_DEBIT ? -1 : 1) * abs($cents); } private function convertCentForDB(int $amount): string diff --git a/legacy/lib/booking/konto/HibiscusXMLRPCConnector.php b/legacy/lib/booking/konto/HibiscusXMLRPCConnector.php deleted file mode 100644 index 1a02bc1e..00000000 --- a/legacy/lib/booking/konto/HibiscusXMLRPCConnector.php +++ /dev/null @@ -1,409 +0,0 @@ -fetchableKontos = []; - $this->lastFetchedKontos = []; - /*$xmlClient = new \xmlrpc\xrpcClient( - self::$HIBISCUS_BASE_URL, - self::$HIBISCUS_USERNAME, - self::$HIBISCUS_PASSWORD, - self::$HIBISCUS_RPCPATH - );*/ - } - - /** - * @throws Exception - */ - final protected static function static__set($name, $value): void - { - if (property_exists(__CLASS__, $name)) { - self::$$name = $value; - } else { - throw new Exception("$name ist keine Variable in ".__CLASS__); - } - } - - /** - * @return array [bool $success, string array $msgs] - */ - public function updateKontoIDs(): array - { - if (! empty($this->fetchableKontos) && ! empty($this->lastFetchedKontos)) { - return [true, []]; - } - $ret = true; - $msgs = []; - $ktos = []; - try { - $client = XML_RPC2_Client::create( - 'https://'.rawurldecode(self::$HIBISCUS_USERNAME).':'.rawurlencode(self::$HIBISCUS_PASSWORD). - '@'.rawurldecode(self::$HIBISCUS_BASE_URL).'xmlrpc/hibiscus.xmlrpc.konto', - ['sslverify' => false, 'debug' => false, 'prefix' => 'hibiscus.xmlrpc.konto.'] - ); - $ktos = $client->find(); - if (count($ktos) === 0) { - return [false, ['Konte kein Konto auf FINTS finden, bitte kontaktiere den Systemadministrator!']]; - } - - $dbKtos = DBConnector::getInstance()->dbFetchAll( - 'konto_type', - [DBConnector::FETCH_UNIQUE_FIRST_COL_AS_KEY] - ); - - foreach ($ktos as $kto) { - // available keys: saldo_available, bezeichnung, saldo, unterkonto, blz, kundennummer, kontonummer, iban, - // name, waehrung, saldo_datum, id, bic, kommentar - $ktoId = $kto['id']; - $ktoName = $kto['bezeichnung']; - $ktoIBAN = $kto['iban']; - $syncFrom = date_create('@0'); // frist Timestamp available - foreach ($dbKtos as $dbKto) { - if ($ktoIBAN === $dbKto['iban'] && date_create($dbKto['sync_until'])->diff($syncFrom)->invert) { - $syncFrom = date_create($dbKto['sync_until'])->add(DateInterval::createFromDateString('1 day')); - } - } - - if (! isset($dbKtos[$ktoId])) { - $short = strtoupper(substr($ktoName, 0, 2)); - $ret_tmp = DBConnector::getInstance()->dbInsert( - 'konto_type', - [ - 'id' => $ktoId, - 'name' => $ktoName, - 'short' => $short, - 'iban' => $ktoIBAN, - 'sync_from' => $syncFrom->format('Y-m-d'), - ] - ); - $msgs[] = "Konto $ktoId: $ktoName ($short) (IBAN: $ktoIBAN) wurde neu gefunden". - (($ret_tmp > 0) ? ' und hinzugefügt' : ' konnte aber nicht hinzugefügt werden!'); - $ret = $ret && ($ret_tmp > 0); - } - $this->lastFetchedKontos[$ktoId] = $kto; - } - $deletedInHibiscus = array_diff( - array_keys($dbKtos), - array_keys($this->lastFetchedKontos), - [0] // 0 ist reserviert für die Handkasse - ); - foreach ($deletedInHibiscus as $id) { - $dbKto = $dbKtos[$id]; - $affectedRows = DBConnector::getInstance()->dbUpdate( - 'konto_type', - ['id' => $id, 'sync_until' => ['IS', null]], - [ - 'sync_until' => date_create() - ->sub(DateInterval::createFromDateString('1 day')) - ->format('Y-m-d'), - ] - ); - if ($affectedRows > 0) { - $msgs[] = "Konto $id: {$dbKto['name']} (IBAN: {$dbKto['iban']})". - 'kann im FINTS nicht mehr gefunden werden. Die Synchronisation wird eingestellt.'; - } - } - - // get Updated dbKtos - $dbKtos = DBConnector::getInstance()->dbFetchAll( - 'konto_type', - [DBConnector::FETCH_UNIQUE_FIRST_COL_AS_KEY] - ); - - foreach ($this->lastFetchedKontos as $hibKtoId => $row) { - $sign = date_create($dbKtos[$hibKtoId]['sync_until'])->diff(date_create(date('Y-m-d')))->invert; - if ($sign === 1) { - $this->fetchableKontos[$hibKtoId] = $dbKtos[$hibKtoId]; - } - } - } catch (Exception $exception) { - $ret = false; - $msgs[] = 'Ein Fehler ist aufgetreten. Bitte benachrichtige den Systemadministrator.'; - if (DEV) { - $msgs[] = $exception->getMessage(); - } - } - - return [$ret, $msgs]; - } - - /** - * @return array returns umsätze or Error Code - */ - public function fetchAllUmsatz(): array - { - [$success, $msgs] = $this->updateKontoIDs(); - if (! $success) { - return [false, $msgs, []]; - } - // get data from RPC - try { - $umsatz = []; - foreach ($this->fetchableKontos as $ktoid => $kto) { - $letzteBankSync = date_create($this->lastFetchedKontos[$ktoid]['saldo_datum']); - if ($letzteBankSync->diff(date_create('yesterday'))->days > 1) { - $msgs[] = 'FINTS hat die letzten 24h keine Synchronisation mit der Bank durchgeführt. Die angezeigten - Umsätze können unvollständig sein.'; - } - - $umsopt = ['konto_id' => $ktoid]; - - /*$url = "https://" . rawurldecode(self::$HIBISCUS_USERNAME) . ":" . rawurlencode(self::$HIBISCUS_PASSWORD) . "@" . rawurldecode(self::$HIBISCUS_URL) . "/webadmin/rest/system/status"; - echo htmlspecialchars($url); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - $response = curl_exec($ch); - curl_close($ch); - - if ($response === false){ - echo '
FINTS hat keinen Status geliefert.
'; - }else{ - print_r($response); - - $response = json_decode($response, true); - if ($response === null || !is_array($response) || !isset($response["type"]) || !isset($response["title"]) || !isset($response["text"])){ - echo '
FINTS hat keinen parsbaren Status geliefert.
'; - }else{ - switch ($response["type"]){ # see src/de/willuhn/jameica/messaging/StatusBarMessage.java - case 0: # OK - case 2: # INFO - $cls = "success"; - break; - case 1: # ERROR - default: - $cls = "danger"; - $showStatus = true; - } - if ($showStatus){ - echo '
' . htmlspecialchars("FINTS (" . $response["title"] . "): " . $response["text"]) . '
'; - } - } - }*/ - - // letzter abgerufener Umsatz - $lastUmsatzId = DBConnector::getInstance()->dbFetchAll( - 'konto', - [DBConnector::FETCH_ASSOC], - ['max-id' => ['id', DBConnector::GROUP_MAX]], - ['konto_id' => $ktoid] - ); - if (isset($lastUmsatzId[0]['max-id'])) { - $umsopt['id:min'] = 1 + $lastUmsatzId[0]['max-id']; - } - $sync_from = $this->fetchableKontos[$ktoid]['sync_from']; - if (strtolower($sync_from) !== 'null' && ! is_null($sync_from) && strtotime($sync_from) > 0) { - $umsopt['datum:min'] = $this->fetchableKontos[$ktoid]['sync_from']; - } else { - $umsopt['datum:min'] = '2017-01-01'; - } - if (strtolower($this->fetchableKontos[$ktoid]['sync_until']) !== 'null' - && ! is_null($this->fetchableKontos[$ktoid]['sync_until'])) { - $umsopt['datum:max'] = $this->fetchableKontos[$ktoid]['sync_until']; - } - - $client = XML_RPC2_Client::create( - 'https://'.rawurldecode(self::$HIBISCUS_USERNAME).':'.rawurlencode( - self::$HIBISCUS_PASSWORD - ).'@'.rawurldecode(self::$HIBISCUS_BASE_URL).'/xmlrpc/hibiscus.xmlrpc.umsatz', - ['sslverify' => false, 'debug' => false, 'prefix' => 'hibiscus.xmlrpc.umsatz.'] - ); - $newUmsatz = $client->list($umsopt); - array_push($umsatz, ...$newUmsatz); - usort( - $umsatz, - static function ($a, $b) { - return $a['id'] <=> $b['id']; - } - ); - } - - return [true, $msgs, $umsatz]; - } catch (XML_RPC2_CurlException $e) { - return [false, $msgs, $umsatz]; - } - } - - public function fetchFromHibiscusAnfangsbestand(): array - { - $year = date('Y'); - /* - $f = ["type" => "kontenplan"]; - $f["state"] = "final"; - $f["revision"] = $year; - $al = DBConnector::getInstance()->dbFetchAll("antrag", [], $f); - if (count($al) != 1) die("Kontenplan nicht gefunden: " . print_r($f, true)); - $kpId = $al[0]["id"]; - - // check anfangsbestand already saved - if (DBConnector::getInstance()->dbHasAnfangsbestand("01 01", $kpId)){ - return []; - }*/ - - $client = XML_RPC2_Client::create( - 'https://'.rawurldecode(self::$HIBISCUS_USERNAME).':'.rawurlencode( - self::$HIBISCUS_PASSWORD - ).'@'.rawurldecode(self::$HIBISCUS_BASE_URL).'/xmlrpc/hibiscus.xmlrpc.konto', - ['sslverify' => false, 'debug' => false, 'prefix' => 'hibiscus.xmlrpc.konto.'] - ); - $kto = $client->find(); - - if (count($kto) === 0) { - exit('Kein Bankkonto auf FINTS eingerichtet.'); - } - if (count($kto) > 1) { - exit('Mehr als ein Bankkonto auf FINTS eingerichtet.'); - } - - $kto = $kto[0]; - $ktoid = $kto['id']; - $showStatus = false; - - $umsopt = ['konto_id' => $ktoid]; - - /*$url = "https://" . rawurldecode(self::$HIBISCUS_USERNAME) . ":" . rawurlencode(self::$HIBISCUS_PASSWORD) . "@" . rawurldecode(self::$HIBISCUS_URL) . "/webadmin/rest/system/status"; - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_POST, 1); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - $response = curl_exec($ch); - curl_close($ch); - - if ($response === false){ - echo '
FINTS hat keinen Status geliefert.
'; - }else{ - echo "terst"; - echo $response; - echo "3ews"; - $response = json_decode($response, true); - if ($response === null || !is_array($response) || !isset($response["type"]) || !isset($response["title"]) || !isset($response["text"])){ - echo '
FINTS hat keinen parsbaren Status geliefert.
'; - }else{ - switch ($response["type"]){ # see src/de/willuhn/jameica/messaging/StatusBarMessage.java - case 0: # OK - case 2: # INFO - $cls = "success"; - break; - case 1: # ERROR - default: - $cls = "danger"; - $showStatus = true; - } - if ($showStatus){ - echo '
' . htmlspecialchars("FINTS (" . $response["title"] . "): " . $response["text"]) . '
'; - } - } - }*/ - /* - // brauche umsatz vor $year-01-01 und nach $(year-1)-12-31 - $umsopt["datum:min"] = "$year-01-01"; - $umsopt["datum:max"] = "$year-12-31"; - - $client = XML_RPC2_Client::create("https://" . rawurldecode(self::$HIBISCUS_USERNAME) . ":" . rawurlencode(self::$HIBISCUS_PASSWORD) . "@" . rawurldecode(self::$HIBISCUS_URL) . "/xmlrpc/hibiscus.xmlrpc.umsatz", - ["sslverify" => false, "debug" => false, "prefix" => "hibiscus.xmlrpc.umsatz."]); - $umsatzImJahr = $client->list($umsopt); - - $yearBefore = $year - 1; - $umsopt["datum:min"] = "$yearBefore-01-01"; - $umsopt["datum:max"] = "$yearBefore-12-31"; - $umsatzImJahrDavor = $client->list($umsopt); - - if (count($umsatzImJahr) == 0 || count($umsatzImJahrDavor) == 0) // noch keine Umsätze - return []; - - // lezter Umsatz im Jahr davor - usort($umsatzImJahrDavor, function($a, $b){ - if ($a["id"] < $b["id"]) return -1; - if ($a["id"] > $b["id"]) return 1; - return 0; - }); - - $uLastBefore = array_pop($umsatzImJahrDavor); - $saldo = $this->tofloatHibiscus($uLastBefore['saldo']); - echo $saldo; - /* - $newForms = []; - - $datum = "$year-01-01"; - - $inhalt = []; - - $inhalt[] = ["fieldname" => "zahlung.einnahmen", "contenttype" => "money", "value" => $saldo]; - - $inhalt[] = ["fieldname" => "zahlung.datum", "contenttype" => "date", "value" => $datum]; - - $inhalt[] = ["fieldname" => "zahlung.konto", "contenttype" => "ref", "value" => "01 01"]; - - $inhalt[] = ["fieldname" => "kontenplan.otherForm", "contenttype" => "otherForm", "value" => $kpId]; - - $newForms[] = $inhalt; - */ - $newForms = []; - - return $newForms; - } - - private function tofloatHibiscus($num) - { - $dotPos = strrpos($num, '.'); - $commaPos = strrpos($num, ','); - - if (($dotPos === false) && ($commaPos === false)) { - $sep = false; - } elseif ($dotPos !== false) { - $sep = $dotPos; - } elseif ($commaPos !== false) { - $sep = $commaPos; - } elseif ($commaPos > $dotPos) { - $sep = $commaPos; - exit('impossible'); - } else { - $sep = $dotPos; - exit('impossible'); - } - - if ($sep === false) { - return (float) preg_replace("/[^0-9+\-]/", '', $num); - } - - return (float) (preg_replace("/[^0-9+\-]/", '', substr($num, 0, $sep)).'.'. - preg_replace("/[^0-9+\-]/", '', substr($num, $sep + 1, strlen($num)))); - } -} diff --git a/legacy/lib/forms/RestHandler.php b/legacy/lib/forms/RestHandler.php index accb64d4..ac933a12 100644 --- a/legacy/lib/forms/RestHandler.php +++ b/legacy/lib/forms/RestHandler.php @@ -26,8 +26,6 @@ use App\Models\TaxBudget; use booking\BookingTableManager; use booking\HHPHandler; -use booking\konto\FintsConnectionHandler; -use booking\konto\HibiscusXMLRPCConnector; use Exception; use forms\chat\ChatHandler; use forms\projekte\auslagen\AuslagenHandler2; @@ -61,9 +59,6 @@ public function handlePost(?array $routeInfo = null): void case 'chat': $this->handleChat($routeInfo); break; - case 'update-konto': - $this->updateKonto($routeInfo); - break; case 'new-booking-instruct': $this->newBookingInstruct($routeInfo); break; @@ -85,33 +80,6 @@ public function handlePost(?array $routeInfo = null): void case 'add-tax-budgets': $this->saveTaxBudgets($routeInfo); break; - case 'save-new-konto-credentials': - $this->newKontoCredentials($routeInfo); - break; - case 'save-default-tan-mode': - $this->saveDefaultTanMode($routeInfo); - break; - /*case "login-credentials": - $this->loginCredentials($routeInfo); - break;*/ - case 'lock-credentials': - $this->lockCredentials($routeInfo); - break; - case 'submit-tan': - $this->submitTan($routeInfo); - break; - case 'abort-tan': - $this->abortTan($routeInfo); - break; - case 'change-credential-password': - $this->changeCredentialPassword($routeInfo); - break; - case 'delete-credentials': - $this->deleteCredentials($routeInfo); - break; - case 'import-konto': - $this->importKonto($routeInfo); - break; case 'mirror': $this->mirrorInput(); break; @@ -763,150 +731,6 @@ private function handleChat($routeInfo): void } - private function updateKonto($routeInfo): void - { - $auth = AuthHandler::getInstance(); - $auth->requireGroup('ref-finanzen-kv'); - - $ret = true; - if (! DBConnector::getInstance()->dbBegin()) { - throw new LegacyDieException(500, - 'Kann keine Verbindung zur SQL-Datenbank aufbauen. Bitte versuche es später erneut!' - ); - } - [$success, $msg_xmlrpc, $allZahlungen] = HibiscusXMLRPCConnector::getInstance()->fetchAllUmsatz(); - - if ($success === false) { - JsonController::print_json( - [ - 'success' => false, - 'status' => '500', - 'msg' => 'Konnte keine Verbindung mit Onlinebanking Service aufbauen', - 'type' => 'modal', - 'subtype' => 'server-error', - ] - ); - } - /*$lastId = DBConnector::getInstance()->dbFetchAll( - "konto", - [DBConnector::FETCH_ASSOC], - ["id" => ["id", DBConnector::GROUP_MAX]] - ); - if (is_array($lastId)){ - $lastId = $lastId[0]["id"]; - }*/ - $msg = []; - $inserted = []; - foreach ($allZahlungen as $zahlung) { - $fields = []; - $fields['id'] = $zahlung['id']; - $fields['konto_id'] = $zahlung['konto_id']; - $fields['date'] = $zahlung['datum']; - $fields['type'] = $zahlung['art']; - $fields['valuta'] = $zahlung['valuta']; - $fields['primanota'] = $zahlung['primanota']; - $fields['value'] = DBConnector::getInstance()->convertUserValueToDBValue($zahlung['betrag'], 'money'); - $fields['empf_name'] = $zahlung['empfaenger_name']; - $fields['empf_iban'] = $zahlung['empfaenger_konto']; - $fields['empf_bic'] = $zahlung['empfaenger_blz']; - $fields['saldo'] = $zahlung['saldo']; - // $fields['gvcode'] = $zahlung['gvcode']; # deprecated since csv import - $fields['zweck'] = $zahlung['zweck']; - $fields['comment'] = $zahlung['kommentar']; - $fields['customer_ref'] = $zahlung['customer_ref']; - // $msgs[]= print_r($zahlung,true); - DBConnector::getInstance()->dbInsert('konto', $fields); - if (isset($inserted[$zahlung['konto_id']])) { - $inserted[$zahlung['konto_id']]++; - } else { - $inserted[$zahlung['konto_id']] = 1; - } - - $matches = []; - if (preg_match("/IP-[\d]{2,4}-[\d]+-A[\d]+/u", $zahlung['zweck'], $matches)) { - $beleg_sum = 0; - $ahs = []; - foreach ($matches as $match) { - $arr = explode('-', $match); - $auslagen_id = substr(array_pop($arr), 1); - $projekt_id = array_pop($arr); - $ah = new AuslagenHandler2(['pid' => $projekt_id, 'aid' => $auslagen_id, 'action' => 'none']); - $pps = $ah->getBelegPostenFiles(); - foreach ($pps as $pp) { - foreach ($pp['posten'] as $posten) { - if ($posten['einnahmen']) { - $beleg_sum += $posten['einnahmen']; - } - if ($posten['ausgaben']) { - $beleg_sum -= $posten['ausgaben']; - } - } - } - $ahs[] = $ah; - } - if (abs($beleg_sum - $fields['value']) < 0.01) { - foreach ($ahs as $ah) { - $ret = $ah->state_change('payed', $ah->getAuslagenEtag()); - if ($ret !== true) { - $msg[] = 'Konnte IP'.$ah->getProjektID().'-A'.$ah->getID(). - " nicht in den Status 'gezahlt' überführen. ". - 'Bitte ändere das noch (per Hand) nachträglich!'. - $fields['date']; - } - } - } else { - $msg[] = 'In Zahlung '.$zahlung['id'].' wurden folgende Projekte/Auslagen im Verwendungszweck gefunden: '.implode( - ' & ', - $matches - ).'. Dort stimmt die Summe der Belegposten ('.$beleg_sum.') nicht mit der Summe der Zahlung ('.$fields['value'].') überein. Bitte prüfe das noch per Hand, und setze ggf. die passenden Projekte auf bezahlt, so das es später keine Probleme beim Buchen gibt (nur gezahlte Auslagen können gebucht werden)'; - } - } - } - - $ret = DBConnector::getInstance()->dbCommit(); - - if (! $ret) { - DBConnector::getInstance()->dbRollBack(); - JsonController::print_json( - [ - 'success' => false, - 'status' => '500', - 'msg' => array_merge($msg_xmlrpc, $msg), - 'type' => 'modal', - 'subtype' => 'server-error', - 'headline' => 'Ein Datenbank Fehler ist aufgetreten! (Rollback)', - ] - ); - } elseif (! empty($inserted)) { - $type = (count($msg_xmlrpc) + count($msg)) > 1 ? 'warning' : 'success'; - - foreach ($inserted as $konto_id => $number) { - $msg[] = "$number neue Umsätze auf Konto $konto_id gefunden und hinzugefügt!"; - } - $msg = array_reverse($msg); - JsonController::print_json( - [ - 'success' => true, - 'status' => '200', - 'msg' => array_merge($msg_xmlrpc, $msg), - 'type' => 'modal', - 'subtype' => 'server-'.$type, - ] - ); - } else { - $msg = array_merge(['Keine neuen Umsätze gefunden.'], $msg); - JsonController::print_json( - [ - 'success' => false, - 'status' => '200', - 'msg' => array_merge($msg_xmlrpc, $msg), - 'type' => 'modal', - 'subtype' => 'server-warning', - ] - ); - } - } - private function deleteBookingInstruction($routeInfo): void { $instructId = $routeInfo['instruct-id']; @@ -1446,147 +1270,6 @@ private function saveTaxBudgets($routeInfo): void ); } - private function saveDefaultTanMode($routeInfo): void - { - if (! isset($_POST['tan-mode-id'])) { - JsonController::print_json( - [ - 'success' => false, - 'status' => '500', - 'msg' => 'Es wurde keine TAN Methode ausgewählt', - 'type' => 'modal', - 'subtype' => 'server-error', - 'headline' => 'Daten nicht gespeichert', - ] - ); - } - - $credId = (int) $_POST['credential-id']; - $fHandler = FintsConnectionHandler::load($credId); - $tanMode = (int) $_POST['tan-mode-id']; - $tanMediumName = $_POST['tan-medium-name'] ?? null; - $ret = $fHandler->saveDefaultTanMode($credId, $tanMode, $tanMediumName); - - if ($ret === true) { - if ($tanClosed = $fHandler->hasTanSessionInformation()) { - $fHandler->deleteTanSessionInformation(); - } - $redirectUrl = $tanMediumName === null ? URIBASE."konto/credentials/$credId/tan-mode/$tanMode/medium" : URIBASE.'konto/credentials/'; - JsonController::print_json( - [ - 'success' => true, - 'status' => '200', - 'msg' => "Tan $tanMode für Zugangsdaten $credId gespeichert".($tanClosed ? ' - offene Tans wurden abgebrochen' : ''), - 'type' => 'modal', - 'subtype' => 'server-success', - 'reload' => 1000, - 'headline' => 'Daten gespeichert', - 'redirect' => $redirectUrl, - ] - ); - } else { - JsonController::print_json( - [ - 'success' => false, - 'status' => '500', - 'msg' => 'Default Tan-Methode kann nicht gespeichert werden.', - 'type' => 'modal', - 'subtype' => 'server-error', - 'headline' => 'Daten nicht gespeichert', - ] - ); - } - } - - private function submitTan(array $routeInfo): void - { - $credId = (int) $_POST['credential-id']; - $fHandler = FintsConnectionHandler::load($credId); - - $tan = $_POST['tan']; - [$ret, $msg] = $fHandler->submitTan($tan); - if ($ret === true) { - JsonController::print_json( - [ - 'success' => true, - 'status' => '200', - 'msg' => $msg, - 'type' => 'modal', - 'subtype' => 'server-success', - 'reload' => 1000, - 'headline' => 'Daten erfolgreich übertragen', - ] - ); - } else { - JsonController::print_json( - [ - 'success' => false, - 'status' => '500', - 'msg' => $msg, - 'type' => 'modal', - 'subtype' => 'server-error', - 'headline' => 'Daten nicht korrekt', - ] - ); - } - } - - private function importKonto(array $routeInfo): void - { - $syncFrom = date_create($_POST['sync-from'])->format('Y-m-d'); - $kontoIban = $_POST['konto-iban']; - $ibanCorrect = Validator::_checkIBAN($kontoIban, false); - $kontoName = substr(htmlspecialchars(strip_tags(trim($_POST['konto-name']))), 0, 32); - $kontoShort = strtoupper(substr((string) $_POST['konto-short'], 0, 2)); - $credId = (int) $_POST['credential-id']; - - if ($ibanCorrect === false) { - JsonController::print_json( - [ - 'success' => false, - 'status' => '500', - 'msg' => 'IBAN nicht korrekt', - 'type' => 'modal', - 'subtype' => 'server-error', - 'headline' => 'Daten nicht gespeichert', - ] - ); - } - - $ret = DBConnector::getInstance()->dbInsert('konto_type', [ - 'name' => $kontoName, - 'short' => $kontoShort, - 'sync_from' => $syncFrom, - 'iban' => $kontoIban, - ]); - - if ((int) $ret === 1) { - JsonController::print_json( - [ - 'success' => true, - 'status' => '200', - 'msg' => 'Meta Daten des Kontos für den Import vorbereitet', - 'type' => 'modal', - 'subtype' => 'server-success', - 'reload' => 1000, - 'headline' => 'Daten gespeichert', - 'redirect' => URIBASE."konto/credentials/$credId/sepa", - ] - ); - } else { - JsonController::print_json( - [ - 'success' => false, - 'status' => '500', - 'msg' => 'Eingabe konnte nicht gesichert werden', - 'type' => 'modal', - 'subtype' => 'server-error', - 'headline' => 'Daten nicht gespeichert', - ] - ); - } - } - private function clearFintsSession(): void { session()->forget('fints'); @@ -1602,107 +1285,4 @@ private function clearFintsSession(): void ] ); } - - private function lockCredentials(array $routeInfo): void - { - $credId = (int) $_POST['credential-id']; - [$ret, $msg] = FintsConnectionHandler::lockCredentials($credId); - - if ($ret === true) { - JsonController::print_json( - [ - 'success' => true, - 'status' => '200', - 'msg' => $msg, - 'type' => 'modal', - 'subtype' => 'server-success', - 'reload' => 1000, - 'headline' => 'Zugangsdaten gesperrt', - ] - ); - } else { - JsonController::print_json( - [ - 'success' => false, - 'status' => '500', - 'msg' => $msg, - 'type' => 'modal', - 'subtype' => 'server-error', - 'headline' => 'Ein Fehler ist aufgetreten', - ] - ); - } - } - - private function abortTan(array $routeInfo): void - { - $credId = (int) $_POST['credential-id']; - $fHandler = FintsConnectionHandler::load($credId); - $fHandler->deleteTanSessionInformation(); - - JsonController::print_json( - [ - 'success' => true, - 'status' => '200', - 'msg' => 'Du wirst gleich weitergeleitet', - 'type' => 'modal', - 'subtype' => 'server-success', - 'reload' => 1000, - 'redirect' => URIBASE.'konto/credentials/'.$credId.'/sepa', - 'headline' => 'Tan Verfahren abgebrochen', - ] - ); - } - - private function deleteCredentials(array $routeInfo): void - { - $credId = (int) $_POST['credential-id']; - $ret = FintsConnectionHandler::deleteCredential($credId); - - if ($ret === true) { - JsonController::print_json( - [ - 'success' => true, - 'status' => '200', - 'msg' => 'Zugangsdaten wurden erfolgreich gelöscht', - 'type' => 'modal', - 'subtype' => 'server-success', - 'reload' => 1000, - 'headline' => 'Zugangsdaten gelöscht', - ] - ); - } else { - JsonController::print_json( - [ - 'success' => false, - 'status' => '500', - 'msg' => 'Zugangsdaten konnten nicht gelöscht werden', - 'type' => 'modal', - 'subtype' => 'server-error', - 'headline' => 'Ein Fehler ist aufgetreten', - ] - ); - } - } - - private function changeCredentialPassword(array $routeInfo): void - { - $credId = (int) $_POST['credential-id']; - $pw = (string) $_POST['password']; - $pw_repeat = (string) $_POST['password-repeat']; - if ($pw !== $pw_repeat) { - JsonController::print_json( - [ - 'success' => false, - 'status' => '500', - 'msg' => 'Passwörter stimmen nicht überein', - 'type' => 'modal', - 'subtype' => 'server-error', - 'headline' => 'Ein Fehler ist aufgetreten', - ] - ); - } - $fHandler = FintsConnectionHandler::load($credId); - [$success, $msg] = $fHandler->changePassword($pw); - } } diff --git a/legacy/lib/framework/CSVBuilder.php b/legacy/lib/framework/CSVBuilder.php index ec2c1bb8..be3c3d93 100644 --- a/legacy/lib/framework/CSVBuilder.php +++ b/legacy/lib/framework/CSVBuilder.php @@ -2,6 +2,8 @@ namespace framework; +use App\Exceptions\LegacyDownloadException; + class CSVBuilder { public const LANG_DE = 1; @@ -52,14 +54,23 @@ private function buildCSV(): string return implode(self::ROW_SEPARATOR, $ret); } - public function echoCSV($fileName = '', $withRowHeader = true, $encoding = 'WINDOWS-1252'): void + /** + * Hands the CSV over as a download and unwinds out of the legacy renderer - it never returns. + * + * LegacyController wraps whatever a page buffered in the app layout, so the file has to leave + * as a response instead of being echoed into that buffer. + */ + public function echoCSV($fileName = '', $withRowHeader = true, $encoding = 'WINDOWS-1252'): never { + // The body is converted to $encoding below - say so, or Laravel labels it utf-8 + $headers = ['Content-Type' => 'text/csv; charset='.strtolower($encoding)]; if (! empty($fileName)) { - header('Content-type: text/csv'); - header("Content-disposition: attachment;filename=$fileName.csv"); + $headers['Content-Disposition'] = 'attachment; filename="'.$fileName.'.csv"'; } - echo $this->getCSV($withRowHeader, $encoding); - exit(); + + throw new LegacyDownloadException( + response($this->getCSV($withRowHeader, $encoding), 200, $headers) + ); } public function getCSV($withRowHeader = true, $encoding = 'WINDOWS-1252'): string diff --git a/legacy/lib/framework/DBConnector.php b/legacy/lib/framework/DBConnector.php index 62382a05..76a9be74 100644 --- a/legacy/lib/framework/DBConnector.php +++ b/legacy/lib/framework/DBConnector.php @@ -406,20 +406,31 @@ private function initScheme(): void ], ]; - $scheme['konto_bank'] = [ - 'id' => 'INT NOT NULL', - 'url' => 'VARCHAR(256) NOT NULL', - 'blz' => 'INT NOT NULL', - 'name' => 'VARCHAR(256) NOT NULL', + // The bank itself is no longer stored here: name and FinTS endpoint come from + // fints_institutes, which stufis:fints-institutes-update syncs from the public + // bank list. konto_bank held a hand-maintained copy of the same four columns. + $scheme['fints_institutes'] = [ + 'blz' => 'CHAR(8) NOT NULL', + 'name' => 'VARCHAR(255) NOT NULL', + 'location' => 'VARCHAR(255) NULL', + 'bic' => 'VARCHAR(11) NULL', + 'checksum_method' => 'VARCHAR(2) NULL', + 'rdh_address' => 'VARCHAR(255) NULL', + 'pin_tan_address' => 'VARCHAR(255) NULL', + 'rdh_version' => 'VARCHAR(16) NULL', + 'pin_tan_version' => 'VARCHAR(16) NULL', + 'synced_at' => 'TIMESTAMP NOT NULL', + 'created_at' => 'TIMESTAMP NULL', + 'updated_at' => 'TIMESTAMP NULL', ]; - $keys['konto_bank'] = [ - 'primary' => ['id'], + $keys['fints_institutes'] = [ + 'primary' => ['blz'], ]; $scheme['konto_credentials'] = [ 'id' => 'INT NOT NULL', 'name' => 'VARCHAR(63) NOT NULL', - 'bank_id' => 'INT NOT NULL', + 'blz' => 'CHAR(8) NOT NULL', 'owner_id' => 'INT NOT NULL', 'bank_username' => 'VARCHAR(32) NOT NULL', 'tan_mode' => 'INT NULL', @@ -430,7 +441,7 @@ private function initScheme(): void 'primary' => ['id'], 'foreign' => [ 'owner_id' => ['user', 'id'], - 'bank_id' => ['konto_bank', 'id'], + 'blz' => ['fints_institutes', 'blz'], ], ]; diff --git a/legacy/lib/framework/DateHelper.php b/legacy/lib/framework/DateHelper.php index 46196a9c..b77b924d 100644 --- a/legacy/lib/framework/DateHelper.php +++ b/legacy/lib/framework/DateHelper.php @@ -7,22 +7,34 @@ class DateHelper { /** - * @return array [DateTime, DateTime] + * @return array{0: ?DateTime, 1: DateTime} start date (null = let the bank decide how far + * back it goes) and end date */ public static function fromUntilLast(?string $from, ?string $until, ?string $last): array { - $syncFrom = DateTime::createFromFormat(DBConnector::SQL_DATE_FORMAT, $from); - $lastSync = DateTime::createFromFormat(DBConnector::SQL_DATE_FORMAT, $last); - $syncUntil = DateTime::createFromFormat(DBConnector::SQL_DATE_FORMAT, $until); + $syncFrom = $from === null ? false : DateTime::createFromFormat(DBConnector::SQL_DATE_FORMAT, $from); + $lastSync = $last === null ? false : DateTime::createFromFormat(DBConnector::SQL_DATE_FORMAT, $last); + $syncUntil = $until === null ? false : DateTime::createFromFormat(DBConnector::SQL_DATE_FORMAT, $until); + + // if unset or in the future, cut it down to now - some banks do not like dates in the future + if ($syncUntil === false || $syncUntil > date_create()) { + $syncUntil = date_create(); + } + + // konto_type.sync_from is nullable (three of six accounts here have no start date), + // and "clone false" on it is a fatal error. Rather than inventing a date - banks + // are picky about them and only retain a limited history anyway - no start date is + // reported at all, which leaves the range to the bank's own default. + if ($syncFrom === false && $lastSync === false) { + return [null, $syncUntil]; + } // set default for lastsync if unset if ($lastSync === false) { $lastSync = clone $syncFrom; } - - // if unset or in the future, cut it down to now - some banks do not like dates in the future - if ($syncUntil === false || $syncUntil > date_create()) { - $syncUntil = date_create(); + if ($syncFrom === false) { + $syncFrom = clone $lastSync; } // find older date diff --git a/package-lock.json b/package-lock.json index b47e2026..62985b49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "StuFis", "dependencies": { "@alpinejs/sort": "^3.14.9", "@fontsource-variable/inter": "^5.2.6" @@ -443,49 +444,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", - "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", + "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.1" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", - "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-x64": "4.3.1", - "@tailwindcss/oxide-freebsd-x64": "4.3.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-x64-musl": "4.3.1", - "@tailwindcss/oxide-wasm32-wasi": "4.3.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", - "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -500,9 +501,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", - "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -517,9 +518,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", - "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -534,9 +535,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", - "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -551,9 +552,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", - "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -568,9 +569,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", - "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -585,9 +586,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", - "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -602,9 +603,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", - "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -619,9 +620,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", - "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -636,9 +637,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", - "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -654,9 +655,9 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" @@ -666,9 +667,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", - "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -683,9 +684,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", - "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -700,17 +701,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.1.tgz", - "integrity": "sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.3.1", - "@tailwindcss/oxide": "4.3.1", - "postcss": "8.5.15", - "tailwindcss": "4.3.1" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/typography": { @@ -864,9 +865,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -1481,9 +1482,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -1520,9 +1521,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -1540,7 +1541,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1623,9 +1624,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, diff --git "a/resources/views/pages/\342\232\241new-banking-account/new-banking-account.blade.php" "b/resources/views/pages/\342\232\241new-banking-account/new-banking-account.blade.php" index 6ecec456..aba02a50 100644 --- "a/resources/views/pages/\342\232\241new-banking-account/new-banking-account.blade.php" +++ "b/resources/views/pages/\342\232\241new-banking-account/new-banking-account.blade.php" @@ -1,6 +1,6 @@
- {{ __('konto.new.headline') }} + {{ $this->label('headline') }} {{ __('konto.new.headline-sub') }}
@@ -8,28 +8,38 @@ + :description="$this->label('prefix-headline-sub')"/>
+ :description="$this->label('date-start-headline-sub')"/> + :description="$this->label('date-end-headline-sub')"/>
- + {{-- Handed over by a bank access: the IBAN is the bank's own, and a synced account + must not be switched to manual entry. Both stay visible but locked. --}} +
- +
- Speichern + {{ $this->label('submit') }}
diff --git "a/resources/views/pages/\342\232\241new-banking-account/new-banking-account.php" "b/resources/views/pages/\342\232\241new-banking-account/new-banking-account.php" index 03f5965e..8fd38a72 100644 --- "a/resources/views/pages/\342\232\241new-banking-account/new-banking-account.php" +++ "b/resources/views/pages/\342\232\241new-banking-account/new-banking-account.php" @@ -2,6 +2,7 @@ use App\Models\Legacy\BankAccount; use Livewire\Attributes\Layout; +use Livewire\Attributes\Url; use Livewire\Attributes\Validate; use Livewire\Component; @@ -19,12 +20,40 @@ #[Validate] public $sync_until; + // The FinTS account list links here with ?iban=... prefilled, so a bank access hands + // the account over to this page instead of carrying its own create form. #[Validate] + #[Url] public $iban; #[Validate] public $manually_enterable = false; + /** + * Set when a FinTS bank access hands an account over. Its IBAN then comes from the + * bank's own account list rather than from typing, and its transactions arrive by + * synchronisation - so neither the IBAN nor the manual-entry switch may be changed here. + */ + #[Url] + public bool $bankSynced = false; + + /** + * Where to go after saving, so the bank access gets its user back. Only same-origin + * paths are honoured - see returnUrl(). + */ + #[Url] + public ?string $returnTo = null; + + /** + * Picks the wording for a label or description. An account handed over by a bank access is + * tied to a real bank account and can never be a Kasse, so those keys have a "-bank" + * variant that leaves the Kasse out. Both keys must exist - see lang/de/konto.php. + */ + public function label(string $key): string + { + return __('konto.new.'.$key.($this->bankSynced ? '-bank' : '')); + } + public function rules(): array { return [ @@ -40,7 +69,29 @@ public function rules(): array public function store(): void { $data = $this->validate(); + + if ($this->bankSynced) { + // Switching manual entry on rules out automatic synchronisation (see the field's + // own description), which is exactly what an account handed over by a bank access + // is for. The switch is disabled in the form; this makes it hold for a tampered + // request too. + $data['manually_enterable'] = false; + } + BankAccount::create($data); - $this->redirectRoute('legacy.konto'); + $this->redirect($this->returnUrl()); + } + + private function returnUrl(): string + { + // Same-origin paths only. Anything absolute - and "//host", which a browser reads as + // a protocol-relative URL - would turn this into an open redirect. + if (is_string($this->returnTo) + && str_starts_with($this->returnTo, '/') + && ! str_starts_with($this->returnTo, '//')) { + return $this->returnTo; + } + + return route('legacy.konto'); } }; diff --git a/routes/breadcrumbs.php b/routes/breadcrumbs.php index 12e3397d..59a34d5b 100644 --- a/routes/breadcrumbs.php +++ b/routes/breadcrumbs.php @@ -4,6 +4,7 @@ // Note: Laravel will automatically resolve `Breadcrumbs::` without // this import. This is nice for IDE syntax and refactoring. +use App\Models\Legacy\BankAccount; use Diglactic\Breadcrumbs\Breadcrumbs; // This import is also not required, and you could replace `BreadcrumbTrail $trail` // with `$trail`. This is nice for IDE type checking and completion. @@ -108,18 +109,35 @@ $trail->push(__('general.breadcrumb.konto.tan-mode')); }); +// Home > Konto > Credentials > Löschen +Breadcrumbs::for('legacy.konto.credentials.delete', static function (BreadcrumbTrail $trail): void { + $trail->parent('legacy.konto.credentials'); + $trail->push(__('general.breadcrumb.konto.credentials-delete')); +}); + // Home > Konto > Credentials > Sepa Breadcrumbs::for('legacy.konto.credentials.sepa', static function (BreadcrumbTrail $trail, $credential_id): void { $trail->parent('legacy.konto.credentials'); $trail->push(__('general.breadcrumb.konto.sepa'), route('legacy.konto.credentials.sepa', $credential_id)); }); -// Home > Konto > Credentials > Sepa +// Home > Konto > Credentials > Sepa > Neu Breadcrumbs::for('legacy.konto.credentials.import-konto', static function (BreadcrumbTrail $trail, $credential_id, $shortIban): void { $trail->parent('legacy.konto.credentials.sepa', $credential_id); $trail->push(__('general.breadcrumb.konto.import-konto')); }); +// Home > Konto > Credentials > Sepa > Konto > Aktualisieren +Breadcrumbs::for('legacy.konto.credentials.import-transactions', static function (BreadcrumbTrail $trail, $credential_id, $shortIban): void { + $trail->parent('legacy.konto.credentials.sepa', $credential_id); + // The TAN prompt runs under this route too, and its page says nothing about the account it + // belongs to - so without this the person entering a TAN cannot see which account they are + // importing. Falls back to the shortened IBAN from the URL for an account this installation + // has not registered. + $trail->push(BankAccount::findByShortIban($shortIban)?->name ?? $shortIban); + $trail->push(__('general.breadcrumb.konto.import-transactions')); +}); + // Home > Sitzung Breadcrumbs::for('legacy.sitzung', static function (BreadcrumbTrail $trail): void { $trail->parent('legacy.dashboard'); diff --git a/routes/legacy.php b/routes/legacy.php index bcd6302d..f67e7308 100644 --- a/routes/legacy.php +++ b/routes/legacy.php @@ -19,13 +19,22 @@ // legacy hhp-picker needs that url schema as a easy forward - route names are here not usable :( Route::redirect('konto/{hhp_id}/new', '/bank-account/new'); Route::get('konto/{hhp_id?}/{konto_id?}', [LegacyController::class, 'render'])->name('konto'); - Route::get('konto/credentials', [LegacyController::class, 'render'])->name('konto.credentials'); - Route::get('konto/credentials/new', [LegacyController::class, 'render'])->name('konto.credentials.new'); + // Every FinTS page posts back to its own URL: the new-credentials form targets + // itself, and any action can interrupt with the TAN prompt, which posts to + // request()->url() (FintsController::renderTanInput). The legacy router already + // allows POST here ('method' => ['GET', 'POST'] on the credentials node, inherited + // by its children), so these must accept POST too - otherwise the submit falls + // through to the catch-all below and loses its route name. + Route::match(['GET', 'POST'], 'konto/credentials', [LegacyController::class, 'render'])->name('konto.credentials'); + Route::match(['GET', 'POST'], 'konto/credentials/new', [LegacyController::class, 'render'])->name('konto.credentials.new'); Route::any('konto/credentials/{credential_id}/login', [LegacyController::class, 'render'])->name('konto.credentials.login'); - Route::get('konto/credentials/{credential_id}/tan-mode', [LegacyController::class, 'render'])->name('konto.credentials.tan-mode'); - Route::get('konto/credentials/{credential_id}/sepa', [LegacyController::class, 'render'])->name('konto.credentials.sepa'); - Route::get('konto/credentials/{credential_id}/{short_iban}', [LegacyController::class, 'render'])->name('konto.credentials.import-transactions'); - Route::get('konto/credentials/{credential_id}/{short_iban}/import', [LegacyController::class, 'render'])->name('konto.credentials.import-konto'); + Route::match(['GET', 'POST'], 'konto/credentials/{credential_id}/tan-mode', [LegacyController::class, 'render'])->name('konto.credentials.tan-mode'); + Route::match(['GET', 'POST'], 'konto/credentials/{credential_id}/sepa', [LegacyController::class, 'render'])->name('konto.credentials.sepa'); + // Ahead of the catch-all below, which would otherwise swallow "delete" as a short IBAN + // and hand the request a route name meant for the statement import. + Route::match(['GET', 'POST'], 'konto/credentials/{credential_id}/delete', [LegacyController::class, 'render'])->name('konto.credentials.delete'); + Route::match(['GET', 'POST'], 'konto/credentials/{credential_id}/{short_iban}', [LegacyController::class, 'render'])->name('konto.credentials.import-transactions'); + Route::match(['GET', 'POST'], 'konto/credentials/{credential_id}/{short_iban}/import', [LegacyController::class, 'render'])->name('konto.credentials.import-konto'); Route::get('booking', [LegacyController::class, 'render'])->name('booking'); Route::get('booking/{hhp_id}/instruct', [LegacyController::class, 'render'])->name('booking.instruct'); Route::get('booking/{hhp_id}/text', [LegacyController::class, 'render'])->name('booking.text'); diff --git a/storage/demo/stufis-demo-data.sql b/storage/demo/stufis-demo-data.sql index 9ceb01e4..e933d6b8 100644 --- a/storage/demo/stufis-demo-data.sql +++ b/storage/demo/stufis-demo-data.sql @@ -1038,22 +1038,6 @@ INSERT INTO `demo__konto` (`id`,`konto_id`,`date`,`valuta`,`type`,`empf_iban`,`e (74,1,'2024-10-15','2024-10-15','GUTSCHR. UEBERWEISUNG','DE02500105170137075030','INGDDEFF','Hostsharing',0,-80.00,109255.00,'IP-25-26-A77 - Oktober - Hosting','Umsatz gebucht','none'), (75,1,'2024-11-15','2024-11-15','GUTSCHR. UEBERWEISUNG','DE02500105170137075030','INGDDEFF','Hostsharing',0,-80.00,109175.00,'IP-25-26-A78 - November - Hosting','Umsatz gebucht','none'); --- --- Daten für Tabelle `demo__konto_bank` --- - -INSERT INTO `demo__konto_bank` (`id`,`url`,`blz`,`name`) VALUES - (1,'https://hbci11.fiducia.de/cgi-bin/hbciservlet',50031000,'Triodos Bank Deutschland'), - (2,'https://hbci11.fiducia.de/cgi-bin/hbciservlet',79330111,'Bankhaus Max Flessa KG (Flessabank)'); - --- --- Daten für Tabelle `demo__konto_credentials` --- - -INSERT INTO `demo__konto_credentials` (`id`,`name`,`bank_id`,`owner_id`,`bank_username`,`tan_mode`,`tan_mode_name`,`tan_medium_name`) VALUES -(1,'Test',1,4,'dgdf',NULL,NULL,NULL), -(2,'Test',2,4,'test',NULL,NULL,NULL); - -- -- Daten für Tabelle `demo__konto_type` -- diff --git a/tests/Pest.php b/tests/Pest.php index 534360e1..a846d02e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,7 +1,9 @@ 'admin'])->first(); } +/* + * The session token legacyPost() pins. Legacy pages compare a posted `nonce` against + * csrf_token() themselves - the legacy route group runs without Laravel's CSRF middleware - + * so the token has to be known in advance rather than read out of the session. + */ +const LEGACY_NONCE = 'valid-test-nonce'; + +/** + * Posts to a legacy page. + * + * `Renderer` builds its own request with `Request::createFromGlobals()` rather than taking + * Laravel's - deliberately, because the global middleware trims strings and a trimmed bank + * PIN is indistinguishable from a wrong one. The upshot for a test is that the superglobals + * have to carry the request too, or the legacy side sees the CLI's GET with an empty body. + */ +function legacyPost(TestCase $test, string $uri, array $data): TestResponse +{ + // Reached through $GLOBALS rather than the superglobals directly: Rector rewrites a plain + // `$_POST` read into the Request facade, which is the very thing the legacy side does not + // look at. + $serverBefore = $GLOBALS['_SERVER']; + $postBefore = $GLOBALS['_POST'] ?? []; + + $GLOBALS['_SERVER']['REQUEST_METHOD'] = 'POST'; + $GLOBALS['_SERVER']['REQUEST_URI'] = $uri; + $GLOBALS['_POST'] = $data; + + try { + return $test->withSession(['_token' => LEGACY_NONCE])->post($uri, $data); + } finally { + $GLOBALS['_SERVER'] = $serverBefore; + $GLOBALS['_POST'] = $postBefore; + } +} + +/** + * The legacy document out of a response. + * + * Legacy pages are handed to the browser inside the `srcdoc` of an iframe (see + * resources/views/legacy/main.blade.php), so their markup arrives htmlspecialchars-encoded and + * an assertion on a tag or an attribute would never match the raw response body. Text still + * matches either way - this is for the markup. + */ +function legacyHtml(TestResponse $response): string +{ + // htmlspecialchars turns every `"` into `"`, so the attribute cannot end early. + if (preg_match('/srcdoc="([^"]*)"/s', (string) $response->getContent(), $match) !== 1) { + return (string) $response->getContent(); + } + + return html_entity_decode($match[1], ENT_QUOTES); +} + +/** + * Drops the legacy singletons, DBConnector above all. + * + * It grabs `DB::getPdo()` once and keeps it for the life of the process. Rolling back a test + * makes Laravel reconnect, so that cached handle goes stale and the legacy side stops seeing + * anything the test wrote - which shows up as "this bank access does not exist" from the + * second test onwards. A real request never notices: one process, one request, one handle. + */ +function resetLegacySingletons(): void +{ + if (! class_exists(Singleton::class, false)) { + // inc.all.php has not been pulled in yet, so there is nothing to reset. + return; + } + + new ReflectionProperty(Singleton::class, 'instances')->setValue(null, []); +} + /** * @return File the by livewire expected filetype */ diff --git a/tests/Pest/Accounting/BankAccountShortIbanTest.php b/tests/Pest/Accounting/BankAccountShortIbanTest.php new file mode 100644 index 00000000..ce676325 --- /dev/null +++ b/tests/Pest/Accounting/BankAccountShortIbanTest.php @@ -0,0 +1,47 @@ +create(['iban' => 'DE51200411330641363700', 'name' => 'Comdirekt']); + + expect(BankAccount::findByShortIban('DE513700')?->id)->toBe($account->id); +}); + +it('has no account for a shortened IBAN nobody registered', function (): void { + BankAccount::factory()->create(['iban' => 'DE51200411330641363700']); + + expect(BankAccount::findByShortIban('DE999999'))->toBeNull(); +}); + +it('does not let a wildcard in the URL match an account', function (): void { + BankAccount::factory()->create(['iban' => 'DE51200411330641363700']); + + // Reaches the model straight from a route parameter, so a `%` must not be able to widen the + // LIKE into "any account at all". + expect(BankAccount::findByShortIban('DE%13700'))->toBeNull() + ->and(BankAccount::findByShortIban('%'))->toBeNull(); +}); + +it('names the account being imported in the breadcrumb', function (): void { + BankAccount::factory()->create(['iban' => 'DE51200411330641363700', 'name' => 'Comdirekt']); + + // The TAN prompt runs under this route as well, and its own page says nothing about the + // account - the breadcrumb is where that has to be visible. + $trail = Breadcrumbs::generate('legacy.konto.credentials.import-transactions', 4, 'DE513700'); + + expect($trail->pluck('title'))->toContain('Comdirekt'); +}); + +it('falls back to the shortened IBAN for an unregistered account', function (): void { + $trail = Breadcrumbs::generate('legacy.konto.credentials.import-transactions', 4, 'DE999999'); + + expect($trail->pluck('title'))->toContain('DE999999'); +}); diff --git a/tests/Pest/Accounting/FintsDecoupledTanTest.php b/tests/Pest/Accounting/FintsDecoupledTanTest.php new file mode 100644 index 00000000..a6e59601 --- /dev/null +++ b/tests/Pest/Accounting/FintsDecoupledTanTest.php @@ -0,0 +1,210 @@ +actingAs(cashOfficer()); + resetLegacySingletons(); + + if (! defined('DEV')) { + require base_path('legacy/lib/inc.all.php'); + } + + $session = $this->app['session']->driver(); + if (! $session->isStarted()) { + $session->start(); + } + $request = Request::create('/'); + $request->setLaravelSession($session); + $this->app->instance('request', $request); +}); + +afterEach(function (): void { + Mockery::close(); +}); + +/** + * A FintsConnectionHandler whose private $finTs is the given mock, built without running the + * constructor - see FintsStatementResumeTest for why. + */ +function handlerForDecoupledTest(FinTs $finTs, int $credentialId): FintsConnectionHandler +{ + $handler = new ReflectionClass(FintsConnectionHandler::class)->newInstanceWithoutConstructor(); + new ReflectionProperty(FintsConnectionHandler::class, 'finTs')->setValue($handler, $finTs); + new ReflectionProperty(FintsConnectionHandler::class, 'credentialId')->setValue($handler, $credentialId); + $handler->logger = new Logger('test'); + + return $handler; +} + +/** + * A decoupled TanMode mock with sensible defaults for the methods confirmDecoupledTan() may + * touch. Individual tests override getMaxDecoupledChecks()/getPeriodicDecoupledCheckDelaySeconds() + * where the scenario cares about them. + */ +function decoupledTanModeMock(int $maxChecks = 0): TanMode +{ + $tanMode = Mockery::mock(TanMode::class); + $tanMode->shouldReceive('isDecoupled')->andReturn(true); + $tanMode->shouldReceive('getMaxDecoupledChecks')->andReturn($maxChecks); + $tanMode->shouldReceive('getFirstDecoupledCheckDelaySeconds')->andReturn(60); + $tanMode->shouldReceive('getPeriodicDecoupledCheckDelaySeconds')->andReturn(30); + + return $tanMode; +} + +/** + * The most recently flashed message, as plain text (the alert renders itself as HTML via + * AbstractHtmlTag::__toString()). + */ +function lastDecoupledFlashText(): string +{ + $flashes = request()->session()->get('flash', []); + expect($flashes)->not->toBeEmpty(); + + // body() escapes for HTML output, so an umlaut comes back as "ä" rather than "ä" - + // decode it the same way legacyHtml() in tests/Pest.php does for the same reason. + return html_entity_decode((string) end($flashes), ENT_QUOTES); +} + +it('completes the action once the bank confirms the approval', function (): void { + $credentialId = 601; + $action = Mockery::mock(BaseAction::class); + $action->shouldReceive('needsTan')->andReturn(false); + $action->shouldReceive('isDone')->andReturn(true); + + $tanMode = decoupledTanModeMock(maxChecks: 3); + + $finTs = Mockery::mock(FinTs::class); + $finTs->shouldReceive('getSelectedTanMode')->andReturn($tanMode); + $finTs->shouldReceive('checkDecoupledSubmission')->once()->with($action)->andReturn(true); + $finTs->shouldReceive('persist')->once()->andReturn('persisted-after-confirm'); + + $handler = handlerForDecoupledTest($finTs, $credentialId); + request()->session()->put("fints.$credentialId.action", $action); + request()->session()->put("fints.$credentialId.decoupled-checks", 0); + request()->session()->put("fints.$credentialId.decoupled-next-check", time() - 1); + + expect($handler->confirmDecoupledTan())->toBeTrue(); + // saveAction() re-persisted the now-completed FinTs state and dropped the finished action - + // there is nothing left to confirm again. + expect(request()->session()->get("fints.$credentialId.action"))->toBeNull(); + expect(request()->session()->get("fints.$credentialId.persist"))->toBe('persisted-after-confirm'); +}); + +it('reports that the bank has not seen the approval yet', function (): void { + $credentialId = 602; + $action = Mockery::mock(BaseAction::class); + $action->shouldReceive('needsTan')->andReturn(true); + $action->shouldReceive('isDone')->andReturn(false); + + $tanMode = decoupledTanModeMock(maxChecks: 3); + + $finTs = Mockery::mock(FinTs::class); + $finTs->shouldReceive('getSelectedTanMode')->andReturn($tanMode); + $finTs->shouldReceive('checkDecoupledSubmission')->once()->with($action)->andReturn(false); + $finTs->shouldReceive('persist')->andReturn('persisted-still-waiting'); + + $handler = handlerForDecoupledTest($finTs, $credentialId); + request()->session()->put("fints.$credentialId.action", $action); + request()->session()->put("fints.$credentialId.decoupled-checks", 0); + request()->session()->put("fints.$credentialId.decoupled-next-check", time() - 1); + + expect($handler->confirmDecoupledTan())->toBeFalse(); + expect(lastDecoupledFlashText())->toContain('noch nicht gesehen'); + // The action is still pending, so it has to stay resumable, and the used-check counter + // moves on so the attempt limit is eventually reached even if the bank never confirms. + expect(request()->session()->get("fints.$credentialId.action"))->toBe($action); + expect(request()->session()->get("fints.$credentialId.decoupled-checks"))->toBe(1); +}); + +it('refuses to ask the bank again before the earliest allowed check', function (): void { + $credentialId = 603; + $action = Mockery::mock(BaseAction::class); + + $tanMode = decoupledTanModeMock(maxChecks: 3); + + $finTs = Mockery::mock(FinTs::class); + $finTs->shouldReceive('getSelectedTanMode')->andReturn($tanMode); + // The whole point of the pacing guard: no request may go out before the bank's earliest + // allowed check, no matter how often the user clicks the button. + $finTs->shouldReceive('checkDecoupledSubmission')->never()->andReturnUsing(function (): void { + throw new LogicException('checkDecoupledSubmission() must not run before the earliest allowed check'); + }); + $finTs->shouldNotReceive('persist'); + + $handler = handlerForDecoupledTest($finTs, $credentialId); + request()->session()->put("fints.$credentialId.action", $action); + request()->session()->put("fints.$credentialId.decoupled-checks", 0); + request()->session()->put("fints.$credentialId.decoupled-next-check", time() + 120); + + expect($handler->confirmDecoupledTan())->toBeFalse(); + expect(lastDecoupledFlashText())->toContain('Sekunden'); +}); + +it('drops the pending action once the allowed number of checks is used up', function (): void { + $credentialId = 604; + $action = Mockery::mock(BaseAction::class); + // Hit only if the (missing) attempt-limit guard lets the drop fall through to saveAction() + // with the action still attached instead of null. + $action->shouldReceive('needsTan')->andReturn(true)->byDefault(); + $action->shouldReceive('isDone')->andReturn(false)->byDefault(); + + $tanMode = decoupledTanModeMock(maxChecks: 3); + + $finTs = Mockery::mock(FinTs::class); + $finTs->shouldReceive('getSelectedTanMode')->andReturn($tanMode); + $finTs->shouldReceive('checkDecoupledSubmission')->never()->andReturnUsing(function (): void { + throw new LogicException('checkDecoupledSubmission() must not run once the attempt limit is reached'); + }); + $finTs->shouldReceive('persist')->once()->andReturn('persisted-after-drop'); + + $handler = handlerForDecoupledTest($finTs, $credentialId); + request()->session()->put("fints.$credentialId.action", $action); + request()->session()->put("fints.$credentialId.decoupled-checks", 3); + request()->session()->put("fints.$credentialId.decoupled-next-check", time() - 1); + + expect($handler->confirmDecoupledTan())->toBeFalse(); + expect(lastDecoupledFlashText())->toContain('nicht rechtzeitig bestätigt'); + expect(request()->session()->get("fints.$credentialId.action"))->toBeNull(); +}); + +it('reports a connection failure instead of letting the exception escape', function (): void { + $credentialId = 605; + $action = Mockery::mock(BaseAction::class); + + $tanMode = decoupledTanModeMock(maxChecks: 0); + + $finTs = Mockery::mock(FinTs::class); + $finTs->shouldReceive('getSelectedTanMode')->andReturn($tanMode); + $finTs->shouldReceive('checkDecoupledSubmission') + ->once() + ->with($action) + ->andThrow(new CurlException('Verbindung fehlgeschlagen', null)); + $finTs->shouldNotReceive('persist'); + + $handler = handlerForDecoupledTest($finTs, $credentialId); + request()->session()->put("fints.$credentialId.action", $action); + request()->session()->put("fints.$credentialId.decoupled-next-check", time() - 1); + + expect($handler->confirmDecoupledTan())->toBeFalse(); + expect(lastDecoupledFlashText())->toContain('Konnte keine Verbindung zum Server aufbauen'); +}); diff --git a/tests/Pest/Accounting/FintsDeleteCredentialsTest.php b/tests/Pest/Accounting/FintsDeleteCredentialsTest.php new file mode 100644 index 00000000..68072535 --- /dev/null +++ b/tests/Pest/Accounting/FintsDeleteCredentialsTest.php @@ -0,0 +1,101 @@ +updateOrCreate( + ['blz' => DELETE_TEST_BLZ], + [ + 'name' => 'Testbank', + 'pin_tan_address' => $pinTanAddress, + 'synced_at' => now(), + ], + ); + + return BankAccountCredential::create([ + 'blz' => DELETE_TEST_BLZ, + 'owner_id' => $ownerId, + 'name' => 'Testzugang', + 'bank_username' => 'testuser', + ]); +} + +beforeEach(function (): void { + $this->actingAs(cashOfficer()); + resetLegacySingletons(); +}); + +it('asks before deleting and leaves the bank access alone on the way there', function (): void { + $access = bankAccess(cashOfficer()->id); + + $this->get("/konto/credentials/$access->id/delete") + ->assertOk() + ->assertSee('Wirklich löschen?') + ->assertSee('Testzugang') + // The accounts and their bookings are the reassuring half of the message. + ->assertSee('bleiben erhalten'); + + expect(BankAccountCredential::find($access->id))->not->toBeNull(); +}); + +it('deletes the bank access when the confirmation is posted', function (): void { + $access = bankAccess(cashOfficer()->id); + + legacyPost($this, "/konto/credentials/$access->id/delete", ['nonce' => LEGACY_NONCE]) + ->assertRedirect(route('legacy.konto.credentials')); + + expect(BankAccountCredential::find($access->id))->toBeNull(); +}); + +it('refuses a delete that carries no valid nonce', function (): void { + $access = bankAccess(cashOfficer()->id); + + legacyPost($this, "/konto/credentials/$access->id/delete", ['nonce' => 'not-the-token']) + ->assertRedirect(route('legacy.konto.credentials')); + + expect(BankAccountCredential::find($access->id))->not->toBeNull(); +}); + +it('never deletes a bank access belonging to somebody else', function (): void { + $access = bankAccess(budgetManager()->id); + + legacyPost($this, "/konto/credentials/$access->id/delete", ['nonce' => LEGACY_NONCE]) + ->assertRedirect(route('legacy.konto.credentials')); + + expect(BankAccountCredential::find($access->id))->not->toBeNull(); +}); + +it('offers deleting even while nobody is logged in at the bank', function (): void { + $access = bankAccess(cashOfficer()->id); + + // The overview renders no active session here, which used to hide the action - and the + // usual reason to delete an access is that logging in with it does not work. + $this->get('/konto/credentials') + ->assertOk() + ->assertSee("konto/credentials/$access->id/delete", false); +}); + +it('deletes a bank access whose FinTS address is not usable at all', function (): void { + // The endpoint guard refuses to build a connection for this one, and building it is what + // the controller does for every other action. Deleting must not depend on it. + $access = bankAccess(cashOfficer()->id, 'http://fints.example.de/servlet'); + + legacyPost($this, "/konto/credentials/$access->id/delete", ['nonce' => LEGACY_NONCE]) + ->assertRedirect(route('legacy.konto.credentials')); + + expect(BankAccountCredential::find($access->id))->toBeNull(); +}); diff --git a/tests/Pest/Accounting/FintsInstituteListTest.php b/tests/Pest/Accounting/FintsInstituteListTest.php new file mode 100644 index 00000000..63fa20db --- /dev/null +++ b/tests/Pest/Accounting/FintsInstituteListTest.php @@ -0,0 +1,329 @@ +delete(); + FintsInstitute::query()->delete(); +} + +const LIST_URL = 'https://raw.githubusercontent.com/hbci4j/hbci4java/master/src/main/resources/blz.properties'; + +/** + * Two real lines from the upstream list, one with a PIN/TAN endpoint and one without. + */ +function propertiesFixture(array $lines = []): string +{ + return implode("\r\n", $lines === [] ? [ + '50031000=Triodos Bank Deutschland|Frankfurt am Main|TRODDEF1XXX|88|fints2.atruvia.de|https://fints2.atruvia.de/cgi-bin/hbciservlet|300|300|', + '29000000=Bundesbank|Bremen|MARKDEF1290|09|||||', + ] : $lines)."\r\n"; +} + +it('parses the pipe separated columns in hbci4java field order', function (): void { + $institutes = (new InstituteListParser)->parse(propertiesFixture()); + + expect($institutes)->toHaveCount(2) + ->and($institutes['50031000'])->toBe([ + 'name' => 'Triodos Bank Deutschland', + 'location' => 'Frankfurt am Main', + 'bic' => 'TRODDEF1XXX', + 'checksum_method' => '88', + 'rdh_address' => 'fints2.atruvia.de', + 'pin_tan_address' => 'https://fints2.atruvia.de/cgi-bin/hbciservlet', + 'rdh_version' => '300', + 'pin_tan_version' => '300', + ]); +}); + +it('turns empty columns into null instead of empty strings', function (): void { + $institutes = (new InstituteListParser)->parse(propertiesFixture()); + + expect($institutes['29000000'])->toMatchArray([ + 'name' => 'Bundesbank', + 'rdh_address' => null, + 'pin_tan_address' => null, + 'pin_tan_version' => null, + ]); +}); + +it('keeps non numeric protocol version ids such as "plus"', function (): void { + $institutes = (new InstituteListParser)->parse(propertiesFixture([ + '44351380=Sparkasse UnnaKamen|Unna|WELADED1KAM|00|w019.s-hbci.de|https://hbci-pintan-wf.s-hbci.de/PinTanServlet|220|plus|', + ])); + + expect($institutes['44351380']['pin_tan_version'])->toBe('plus'); +}); + +it('skips comments, blank lines and anything that is not an 8 digit BLZ', function (): void { + $parser = new InstituteListParser; + $institutes = $parser->parse(implode("\n", [ + '# Aktualisierte BLZ-Datei vom 20.05.2026', + '! also a properties comment', + '', + 'notablz=Some Bank|Ort|BICBICBICXX|00|||||', + '1234=Too short|Ort|BICBICBICXX|00|||||', + '29000000=Bundesbank|Bremen|MARKDEF1290|09|||||', + ])); + + expect($institutes)->toHaveCount(1) + ->and($institutes)->toHaveKey('29000000') + ->and($parser->skipped)->toBe(2); +}); + +it('tolerates lines that stop early instead of padding every column', function (): void { + $institutes = (new InstituteListParser)->parse('29000000=Bundesbank|Bremen'); + + expect($institutes['29000000'])->toMatchArray([ + 'name' => 'Bundesbank', + 'location' => 'Bremen', + 'bic' => null, + 'pin_tan_address' => null, + ]); +}); + +it('drops a PIN/TAN endpoint that is not https, keeping the institute itself', function (): void { + $parser = new InstituteListParser; + $institutes = $parser->parse(propertiesFixture([ + '50031000=Plain HTTP Bank|Ort|TRODDEF1XXX|88|fints.example.de|http://fints.example.de/servlet|300|300|', + '29000000=Scheme Missing Bank|Ort|MARKDEF1290|09||fints.example.de/servlet|300|300|', + '44351380=Proper Bank|Unna|WELADED1KAM|00|w019.s-hbci.de|HTTPS://hbci.example.de/PinTanServlet|220|300|', + ])); + + expect($institutes['50031000']['pin_tan_address'])->toBeNull() + ->and($institutes['50031000']['name'])->toBe('Plain HTTP Bank') + ->and($institutes['29000000']['pin_tan_address'])->toBeNull() + // Only the scheme has to be https; its casing is the bank's business. + ->and($institutes['44351380']['pin_tan_address'])->toBe('HTTPS://hbci.example.de/PinTanServlet') + ->and($parser->insecureEndpoints)->toBe(2); +}); + +it('reports discarded insecure endpoints and never offers them as PIN/TAN capable', function (): void { + Http::fake([LIST_URL => Http::response(propertiesFixture([ + '50031000=Plain HTTP Bank|Ort|TRODDEF1XXX|88|fints.example.de|http://fints.example.de/servlet|300|300|', + '29000000=Bundesbank|Bremen|MARKDEF1290|09|||||', + ]))]); + clearInstitutes(); + + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1]) + ->expectsOutputToContain('1 PIN/TAN-Adressen verworfen') + ->assertSuccessful(); + + expect(FintsInstitute::count())->toBe(2) + ->and(FintsInstitute::findByBlz('50031000')->pin_tan_address)->toBeNull() + ->and(FintsInstitute::query()->pinTanCapable()->count())->toBe(0); +}); + +it('accepts only an https PIN/TAN address as safe to send a PIN to', function (): void { + expect(FintsInstitute::hasSecurePinTanAddress('https://fints.example.de/servlet'))->toBeTrue() + ->and(FintsInstitute::hasSecurePinTanAddress(' https://fints.example.de/servlet'))->toBeTrue() + ->and(FintsInstitute::hasSecurePinTanAddress('HttpS://fints.example.de/servlet'))->toBeTrue() + ->and(FintsInstitute::hasSecurePinTanAddress('http://fints.example.de/servlet'))->toBeFalse() + // No scheme at all: phpFinTS would default to something, and we will not guess. + ->and(FintsInstitute::hasSecurePinTanAddress('fints.example.de/servlet'))->toBeFalse() + ->and(FintsInstitute::hasSecurePinTanAddress(''))->toBeFalse() + ->and(FintsInstitute::hasSecurePinTanAddress(null))->toBeFalse(); +}); + +it('lets a later duplicate BLZ win, as loading a properties file would', function (): void { + $institutes = (new InstituteListParser)->parse(propertiesFixture([ + '29000000=Alter Name|Bremen|MARKDEF1290|09|||||', + '29000000=Neuer Name|Bremen|MARKDEF1290|09|||||', + ])); + + expect($institutes)->toHaveCount(1) + ->and($institutes['29000000']['name'])->toBe('Neuer Name'); +}); + +it('pulls the list into the database', function (): void { + Http::fake([LIST_URL => Http::response(propertiesFixture())]); + clearInstitutes(); + + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1]) + ->expectsOutputToContain('Gelesen: 2 Institute') + ->assertSuccessful(); + + expect(FintsInstitute::count())->toBe(2) + ->and(FintsInstitute::findByBlz('50031000')->pin_tan_address) + ->toBe('https://fints2.atruvia.de/cgi-bin/hbciservlet') + ->and(FintsInstitute::listDate())->not->toBeNull(); +}); + +it('is idempotent and updates only what actually changed', function (): void { + // A sequence, not two Http::fake() calls: repeated fake() calls append stubs and the + // first matching one keeps winning, so the second run would re-read the old body. + Http::fakeSequence() + ->push(propertiesFixture()) + // Triodos moved its endpoint, the Bundesbank row is untouched. + ->push(propertiesFixture([ + '50031000=Triodos Bank Deutschland|Frankfurt am Main|TRODDEF1XXX|88|fints2.atruvia.de|https://fints3.atruvia.de/cgi-bin/hbciservlet|300|300|', + '29000000=Bundesbank|Bremen|MARKDEF1290|09|||||', + ])); + clearInstitutes(); + + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1]) + ->expectsOutputToContain('| neu | 2') + ->assertSuccessful(); + + // synced_at has second precision and both runs land in the same second, so backdate + // to show that the second run stamps unchanged rows too. + $backdated = Date::parse('2026-01-01 00:00:00'); + FintsInstitute::query()->update(['synced_at' => $backdated]); + + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1]) + ->expectsOutputToContain('Geänderte PIN/TAN-Endpunkte') + ->expectsOutputToContain('| geändert | 1') + ->expectsOutputToContain('| unverändert | 1') + ->assertSuccessful(); + + expect(FintsInstitute::count())->toBe(2) + ->and(FintsInstitute::findByBlz('50031000')->pin_tan_address) + ->toBe('https://fints3.atruvia.de/cgi-bin/hbciservlet') + // The untouched Bundesbank row still counts as seen in this run. + ->and(FintsInstitute::findByBlz('29000000')->synced_at->greaterThan($backdated))->toBeTrue() + ->and(FintsInstitute::listDate()->greaterThan($backdated))->toBeTrue(); +}); + +it('refuses a suspiciously short list rather than emptying the table', function (): void { + Http::fake([LIST_URL => Http::response(propertiesFixture())]); + clearInstitutes(); + + // Default --min-entries is 1000, the fixture has 2. + $this->artisan('stufis:fints-institutes-update') + ->expectsOutputToContain('Quelle sieht unvollständig aus') + ->assertFailed(); + + expect(FintsInstitute::count())->toBe(0); +}); + +it('writes nothing on a dry run', function (): void { + Http::fake([LIST_URL => Http::response(propertiesFixture())]); + clearInstitutes(); + + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1, '--dry-run' => true]) + ->expectsOutputToContain('nichts geschrieben') + ->assertSuccessful(); + + expect(FintsInstitute::count())->toBe(0); +}); + +it('keeps institutes that vanished upstream unless asked to prune', function (): void { + $shrunk = propertiesFixture(['29000000=Bundesbank|Bremen|MARKDEF1290|09|||||']); + Http::fakeSequence() + ->push(propertiesFixture()) + ->push($shrunk) + ->push($shrunk); + clearInstitutes(); + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1])->assertSuccessful(); + + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1]) + ->expectsOutputToContain('bleiben aber erhalten') + ->assertSuccessful(); + expect(FintsInstitute::count())->toBe(2); + + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1, '--prune' => true]) + ->expectsOutputToContain('veraltete Institute gelöscht') + ->assertSuccessful(); + expect(FintsInstitute::count())->toBe(1) + ->and(FintsInstitute::findByBlz('50031000'))->toBeNull(); +}); + +it('never prunes an institute that a bank access still points at', function (): void { + $shrunk = propertiesFixture(['29000000=Bundesbank|Bremen|MARKDEF1290|09|||||']); + Http::fakeSequence()->push(propertiesFixture())->push($shrunk); + clearInstitutes(); + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1])->assertSuccessful(); + + // Somebody banks with Triodos, and Triodos then drops out of the list. + DB::table('konto_credentials')->insert([ + 'name' => 'Test', 'blz' => '50031000', 'owner_id' => user()->id, 'bank_username' => 'test', + ]); + + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1, '--prune' => true]) + ->expectsOutputToContain('Nicht gelöscht, weil Bankzugänge darauf verweisen: 50031000') + ->assertSuccessful(); + + // Kept, so neither the foreign key nor the bank access breaks. + expect(FintsInstitute::count())->toBe(2) + ->and(FintsInstitute::findByBlz('50031000'))->not->toBeNull(); +}); + +it('reads a local file instead of downloading', function (): void { + Http::fake(); + clearInstitutes(); + + $path = tempnam(sys_get_temp_dir(), 'blz').'.properties'; + file_put_contents($path, propertiesFixture()); + + $this->artisan('stufis:fints-institutes-update', ['--file' => $path, '--min-entries' => 1]) + ->assertSuccessful(); + + unlink($path); + Http::assertNothingSent(); + expect(FintsInstitute::count())->toBe(2); +}); + +it('fails loudly when the source cannot be fetched', function (): void { + Http::fake([LIST_URL => Http::response('not found', 404)]); + clearInstitutes(); + + $this->artisan('stufis:fints-institutes-update') + ->expectsOutputToContain('HTTP 404') + ->assertFailed(); + + expect(FintsInstitute::count())->toBe(0); +}); + +it('fails when a local file is missing', function (): void { + $this->artisan('stufis:fints-institutes-update', ['--file' => '/nope/blz.properties']) + ->expectsOutputToContain('Datei nicht lesbar') + ->assertFailed(); +}); + +it('resolves a German IBAN to its institute and ignores foreign ones', function (): void { + Http::fake([LIST_URL => Http::response(propertiesFixture())]); + clearInstitutes(); + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1])->assertSuccessful(); + + expect(FintsInstitute::findByIban('DE89 5003 1000 0123 4567 89')?->name) + ->toBe('Triodos Bank Deutschland') + ->and(FintsInstitute::findByIban('de89500310000123456789')?->blz)->toBe('50031000') + ->and(FintsInstitute::findByIban('AT611904300234573201'))->toBeNull() + ->and(FintsInstitute::findByIban('DE8950031000'))->toBeNull(); +}); + +it('accepts an integer BLZ, as the legacy code hands it over', function (): void { + Http::fake([LIST_URL => Http::response(propertiesFixture())]); + clearInstitutes(); + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1])->assertSuccessful(); + + expect(FintsInstitute::findByBlz(50031000)?->name)->toBe('Triodos Bank Deutschland'); +}); + +it('finds institutes by name, BLZ or BIC and only lists PIN/TAN capable ones on demand', function (): void { + Http::fake([LIST_URL => Http::response(propertiesFixture())]); + clearInstitutes(); + $this->artisan('stufis:fints-institutes-update', ['--min-entries' => 1])->assertSuccessful(); + + expect(FintsInstitute::query()->search('Triodos')->pluck('blz')->all())->toBe(['50031000']) + ->and(FintsInstitute::query()->search('2900')->pluck('blz')->all())->toBe(['29000000']) + ->and(FintsInstitute::query()->search('TRODDEF1')->pluck('blz')->all())->toBe(['50031000']) + ->and(FintsInstitute::query()->search('')->count())->toBe(2) + ->and(FintsInstitute::query()->pinTanCapable()->pluck('blz')->all())->toBe(['50031000']); +}); diff --git a/tests/Pest/Accounting/FintsNewCredentialsTest.php b/tests/Pest/Accounting/FintsNewCredentialsTest.php new file mode 100644 index 00000000..fb0f0c09 --- /dev/null +++ b/tests/Pest/Accounting/FintsNewCredentialsTest.php @@ -0,0 +1,98 @@ +updateOrCreate( + ['blz' => NEW_TEST_BLZ], + [ + 'name' => $name, + 'pin_tan_address' => 'https://fints.example.de/servlet', + 'synced_at' => now(), + ], + ); +} + +beforeEach(function (): void { + $this->actingAs(cashOfficer()); + resetLegacySingletons(); +}); + +it('offers the bank list without preselecting a bank', function (): void { + pinTanInstitute(); + + $response = $this->get('/konto/credentials/new') + ->assertOk() + ->assertSee('Lege neue Zugangsdaten an') + ->assertSee('Zweitbank'); + + $form = legacyHtml($response); + + // The selectpicker turns the select's title into a placeholder option with an empty value, + // which is what keeps the first bank of the list from being posted unnoticed. Attribute + // values are run through htmlentities(), so the umlaut arrives as an entity. + expect($form)->toContain("title='Bank auswählen'") + // And no option may carry `selected`: the form starts on that placeholder. + ->and($form)->not->toContain('selected'); +}); + +it('refuses to create a bank access when no bank was chosen', function (): void { + pinTanInstitute(); + + legacyPost($this, '/konto/credentials/new', [ + 'nonce' => LEGACY_NONCE, + 'name' => 'Zugang ohne Bank', + 'blz' => '', + 'bank-username' => 'testuser', + ])->assertRedirect(route('legacy.konto.credentials.new')); + + expect(BankAccountCredential::query()->where('name', 'Zugang ohne Bank')->exists())->toBeFalse(); +}); + +it('creates the bank access for the chosen bank', function (): void { + pinTanInstitute(); + + legacyPost($this, '/konto/credentials/new', [ + 'nonce' => LEGACY_NONCE, + 'name' => 'Neuer Zugang', + 'blz' => NEW_TEST_BLZ, + 'bank-username' => 'testuser', + ])->assertRedirect(route('legacy.konto.credentials')); + + $credential = BankAccountCredential::query()->where('name', 'Neuer Zugang')->first(); + + expect($credential)->not->toBeNull() + ->and($credential->blz)->toBe(NEW_TEST_BLZ) + ->and($credential->bank_username)->toBe('testuser') + ->and($credential->owner_id)->toBe(cashOfficer()->id); +}); + +it('refuses a bank that does not speak PIN/TAN', function (): void { + FintsInstitute::query()->updateOrCreate( + ['blz' => NEW_TEST_BLZ], + ['name' => 'Nur HBCI', 'pin_tan_address' => null, 'synced_at' => now()], + ); + + legacyPost($this, '/konto/credentials/new', [ + 'nonce' => LEGACY_NONCE, + 'name' => 'Zugang ohne PIN/TAN', + 'blz' => NEW_TEST_BLZ, + 'bank-username' => 'testuser', + ])->assertRedirect(route('legacy.konto.credentials.new')); + + expect(BankAccountCredential::query()->where('name', 'Zugang ohne PIN/TAN')->exists())->toBeFalse(); +}); diff --git a/tests/Pest/Accounting/FintsStatementResumeTest.php b/tests/Pest/Accounting/FintsStatementResumeTest.php new file mode 100644 index 00000000..d068ae9f --- /dev/null +++ b/tests/Pest/Accounting/FintsStatementResumeTest.php @@ -0,0 +1,124 @@ +session(), which + * Laravel only attaches to the request once StartSession has run on a real HTTP request. + * These tests call the handler directly (no controller, no HTTP round trip), so a session + * store has to be wired onto the request by hand - the same store the legacyPost() helper's + * withSession() would otherwise attach for us. + */ +beforeEach(function (): void { + $this->actingAs(cashOfficer()); + resetLegacySingletons(); + + // HTMLPageRenderer::addFlash() (reached from the "belongs to something else" branch this + // test exercises) reads the DEV constant, which a real request only gets because the + // legacy dispatcher requires this file first. Nothing does that for a direct, non-HTTP + // call into the handler, so it has to be pulled in by hand - once per process, like a real + // request would. + if (! defined('DEV')) { + require base_path('legacy/lib/inc.all.php'); + } + + $session = $this->app['session']->driver(); + if (! $session->isStarted()) { + $session->start(); + } + $request = Request::create('/'); + $request->setLaravelSession($session); + $this->app->instance('request', $request); +}); + +afterEach(function (): void { + Mockery::close(); +}); + +/** + * A FintsConnectionHandler whose private $finTs is the given mock. FintsConnectionHandler::load() + * needs a DB row and a real Fhp\FinTs connection to the bank, neither of which is available or + * wanted here, so the handler is built without running its constructor at all. + */ +function handlerForResumeTest(FinTs $finTs, int $credentialId): FintsConnectionHandler +{ + $handler = new ReflectionClass(FintsConnectionHandler::class)->newInstanceWithoutConstructor(); + new ReflectionProperty(FintsConnectionHandler::class, 'finTs')->setValue($handler, $finTs); + new ReflectionProperty(FintsConnectionHandler::class, 'credentialId')->setValue($handler, $credentialId); + $handler->logger = new Logger('test'); + + return $handler; +} + +it('resumes a statement request that just got its TAN, instead of starting a new one', function (): void { + // Regression for OP#608's fix (commit 927f0c18): saveAction() used to clear the + // 'action-scope' session key the moment an action stopped needing a TAN - which also + // covers the instant submitTan() finishes it. getStatements() then found no scope left to + // match against, discarded the just-completed action as "belonging to something else" and + // fired off a brand new statement request, which asked for another TAN. Against a bank + // that requires one, a statement import could never complete. + $credentialId = 501; + $iban = 'DE02100100109307118603'; + $start = new DateTime('2026-01-01'); + $end = new DateTime('2026-01-31'); + + $isDone = false; + $action = Mockery::mock(GetStatementOfAccount::class); + $action->shouldReceive('isDone')->andReturnUsing(function () use (&$isDone) { + return $isDone; + }); + $action->shouldReceive('needsTan')->andReturnUsing(function () use (&$isDone) { + return ! $isDone; + }); + $statement = new StatementOfAccount; + $action->shouldReceive('getStatement')->andReturn($statement); + + $finTs = Mockery::mock(FinTs::class); + $finTs->shouldReceive('persist')->andReturn('persisted-state'); + // saveAction() consults the selected TAN mode while an action is still pending, to seed + // the decoupled-confirmation pacing state - irrelevant here, but the mock has to answer + // something rather than the "no matching expectation" that an untouched mock would throw. + $finTs->shouldReceive('getSelectedTanMode')->andReturn(null); + // The whole point of the fix: no new request may be sent for an action that is already + // resolved and merely waiting to be picked back up. andReturnUsing() (rather than plain + // shouldNotReceive()) makes the violation the visible failure instead of whatever + // half-initialised state a silently swallowed execute() call would leave behind. + $finTs->shouldReceive('execute')->never()->andReturnUsing(function (): void { + throw new LogicException('execute() must not run for an action that is already resolved'); + }); + $finTs->shouldReceive('submitTan') + ->once() + ->with($action, '123456') + ->andReturnUsing(function () use (&$isDone): void { + $isDone = true; + }); + + $handler = handlerForResumeTest($finTs, $credentialId); + + // Seed the state a first getStatements() call would have left behind: a pending action, + // cached under the scope it was created for. + new ReflectionMethod(FintsConnectionHandler::class, 'saveAction')->invoke($handler, $action); + $scope = new ReflectionMethod(FintsConnectionHandler::class, 'statementScope')->invoke($handler, $iban, $start, $end); + request()->session()->put("fints.$credentialId.action-scope", $scope); + + expect($handler->submitTan('123456'))->toBeTrue(); + + expect($handler->getStatements($iban, $start, $end))->toBe($statement); + // The completed action's own scope is consumed by its success branch inside + // getStatements(), so nothing lingers behind for the next, unrelated statement request to + // trip over. + expect(request()->session()->has("fints.$credentialId.action-scope"))->toBeFalse(); +}); diff --git a/tests/Pest/Accounting/FintsTanPageAccountTest.php b/tests/Pest/Accounting/FintsTanPageAccountTest.php new file mode 100644 index 00000000..ebc226ad --- /dev/null +++ b/tests/Pest/Accounting/FintsTanPageAccountTest.php @@ -0,0 +1,55 @@ +newInstanceWithoutConstructor(); + new ReflectionProperty(FintsController::class, 'routeInfo')->setValue($controller, $routeInfo); + + ob_start(); + try { + new ReflectionMethod(FintsController::class, 'renderRequestedAccount')->invoke($controller); + + return ob_get_contents(); + } finally { + ob_end_clean(); + } +} + +it('names the account a TAN is being asked for', function (): void { + BankAccount::factory()->create(['iban' => 'DE51200411330641363700', 'name' => 'Comdirekt']); + + expect(requestedAccountMarkup(['short-iban' => 'DE513700'])) + ->toContain('Comdirekt') + ->toContain('DE51200411330641363700'); +}); + +it('falls back to the shortened IBAN for an account it does not know', function (): void { + expect(requestedAccountMarkup(['short-iban' => 'DE999999']))->toContain('DE999999'); +}); + +it('says nothing when the TAN belongs to the bank access rather than an account', function (): void { + // The login and TAN-mode routes carry no account, and inventing one there would be a lie. + expect(requestedAccountMarkup([]))->toBe(''); +}); diff --git a/tests/Pest/Accounting/NewBankingAccountTest.php b/tests/Pest/Accounting/NewBankingAccountTest.php new file mode 100644 index 00000000..344a6405 --- /dev/null +++ b/tests/Pest/Accounting/NewBankingAccountTest.php @@ -0,0 +1,154 @@ +orWhere('short', TEST_SHORT)->delete(); +}); + +it('prefills the iban handed over by a FinTS bank access', function (): void { + Livewire::withQueryParams(['iban' => TEST_IBAN]) + ->test('pages::new-banking-account') + ->assertSet('iban', TEST_IBAN); +}); + +it('drops the Kasse wording when a bank access hands the account over', function (): void { + Livewire::withQueryParams(['iban' => TEST_IBAN, 'bankSynced' => 1]) + ->test('pages::new-banking-account') + ->assertSee('Neues Konto anlegen') + ->assertDontSee('Kasse') + // The save button says where it leads, because it hands the user back to the + // bank access to set the retrieval up. + ->assertSee('Speichern und weiter'); +}); + +it('keeps the Kasse wording when no bank access is involved', function (): void { + Livewire::test('pages::new-banking-account') + ->assertSee('Neues Konto bzw. neue Kasse anlegen') + ->assertSee('Speichern') + ->assertDontSee('weiter zum automatischen Abruf'); +}); + +it('stores an account that a bank access handed over', function (): void { + Livewire::withQueryParams(['iban' => TEST_IBAN]) + ->test('pages::new-banking-account') + ->set('short', TEST_SHORT) + ->set('name', 'FinTS Testkonto') + ->set('sync_from', '2026-01-01') + ->call('store') + ->assertHasNoErrors(); + + $account = BankAccount::where('iban', TEST_IBAN)->sole(); + expect($account->short)->toBe(TEST_SHORT) + ->and($account->name)->toBe('FinTS Testkonto') + // FinTS-synced accounts must not be hand-editable; the column defaults to false, + // which the legacy insert this replaced never set at all. + ->and((bool) $account->manually_enterable)->toBeFalse(); +}); + +it('rejects an invalid iban instead of storing an unusable account', function (): void { + Livewire::withQueryParams(['iban' => 'DE00120300000000202051']) + ->test('pages::new-banking-account') + ->set('short', TEST_SHORT) + ->set('name', 'FinTS Testkonto') + ->set('sync_from', '2026-01-01') + ->call('store') + ->assertHasErrors('iban'); + + expect(BankAccount::where('short', TEST_SHORT)->count())->toBe(0); +}); + +it('returns to the bank access it was handed over from', function (): void { + $returnTo = '/konto/credentials/7/sepa'; + + Livewire::withQueryParams(['iban' => TEST_IBAN, 'bankSynced' => 1, 'returnTo' => $returnTo]) + ->test('pages::new-banking-account') + ->set('short', TEST_SHORT) + ->set('name', 'FinTS Testkonto') + ->set('sync_from', '2026-01-01') + ->call('store') + ->assertHasNoErrors() + ->assertRedirect($returnTo); +}); + +it('falls back to the konto page when no return path was given', function (): void { + Livewire::withQueryParams(['iban' => TEST_IBAN]) + ->test('pages::new-banking-account') + ->set('short', TEST_SHORT) + ->set('name', 'FinTS Testkonto') + ->set('sync_from', '2026-01-01') + ->call('store') + ->assertRedirect(route('legacy.konto')); +}); + +it('refuses to be turned into an open redirect', function (string $hostile): void { + Livewire::withQueryParams(['iban' => TEST_IBAN, 'returnTo' => $hostile]) + ->test('pages::new-banking-account') + ->set('short', TEST_SHORT) + ->set('name', 'FinTS Testkonto') + ->set('sync_from', '2026-01-01') + ->call('store') + ->assertRedirect(route('legacy.konto')); +})->with([ + 'absolute url' => ['https://evil.example/phish'], + 'protocol relative' => ['//evil.example/phish'], + 'scheme only' => ['javascript:alert(1)'], +]); + +it('locks manual entry off for an account handed over by a bank access', function (): void { + // The switch is disabled in the form; this proves a tampered request cannot flip it, + // because manual entry would rule out the synchronisation the account exists for. + Livewire::withQueryParams(['iban' => TEST_IBAN, 'bankSynced' => 1]) + ->test('pages::new-banking-account') + // The form says why, rather than hiding the switch. + ->assertSee(__('konto.new.manual-locked-sub')) + ->assertSee(__('konto.new.iban-locked-sub')) + ->set('short', TEST_SHORT) + ->set('name', 'FinTS Testkonto') + ->set('sync_from', '2026-01-01') + ->set('manually_enterable', true) + ->call('store') + ->assertHasNoErrors(); + + expect((bool) BankAccount::where('iban', TEST_IBAN)->sole()->manually_enterable)->toBeFalse(); +}); + +it('still allows a manually entered account when no bank access is involved', function (): void { + Livewire::test('pages::new-banking-account') + ->set('short', TEST_SHORT) + ->set('name', 'Barkasse Test') + ->set('sync_from', '2026-01-01') + ->set('manually_enterable', true) + ->call('store') + ->assertHasNoErrors(); + + expect((bool) BankAccount::where('short', TEST_SHORT)->sole()->manually_enterable)->toBeTrue(); +}); + +it('rejects a short that is already taken', function (): void { + $taken = BankAccount::query()->whereNotNull('short')->where('short', '!=', '')->first(); + + Livewire::test('pages::new-banking-account') + ->set('iban', TEST_IBAN) + ->set('short', $taken->short) + ->set('name', 'FinTS Testkonto') + ->set('sync_from', '2026-01-01') + ->call('store') + ->assertHasErrors('short'); +}); + +it('requires a start date for the synchronisation', function (): void { + Livewire::withQueryParams(['iban' => TEST_IBAN]) + ->test('pages::new-banking-account') + ->set('short', TEST_SHORT) + ->set('name', 'FinTS Testkonto') + ->call('store') + ->assertHasErrors('sync_from'); +}); diff --git a/tests/Pest/Legacy/BookingHistoryDatevButtonTest.php b/tests/Pest/Legacy/BookingHistoryDatevButtonTest.php new file mode 100644 index 00000000..ae876a2c --- /dev/null +++ b/tests/Pest/Legacy/BookingHistoryDatevButtonTest.php @@ -0,0 +1,32 @@ +actingAs($user)->get(route('legacy.booking.history', ['hhp_id' => 1]))); +} + +it('offers the DATEV export when the setting is on', function (): void { + Setting::set('datev', true); + + $html = bookingHistory(budgetManager()); + + expect($html)->toContain('DATEV Export') + ->and($html)->toContain(route('datev.export', ['hhpId' => 1])); +}); + +it('hides the DATEV export while the setting is off', function (): void { + Setting::set('datev', false); + + expect(bookingHistory(budgetManager()))->not->toContain('DATEV Export'); +}); diff --git a/tests/Pest/Legacy/BookingZipExportTest.php b/tests/Pest/Legacy/BookingZipExportTest.php new file mode 100644 index 00000000..cf8892d4 --- /dev/null +++ b/tests/Pest/Legacy/BookingZipExportTest.php @@ -0,0 +1,59 @@ +actingAs(budgetManager())->get('export/booking/1/zip'); + + $response->assertOk() + ->assertHeader('Content-Type', 'application/zip') + ->assertHeader('Content-Disposition', 'attachment; filename="HHA.zip"'); + + $content = $response->getContent(); + + // "PK" are the magic bytes of a zip - no HTML wrapper snuck in around the archive + expect(substr((string) $content, 0, 2))->toBe('PK'); + + $path = tempnam(sys_get_temp_dir(), 'hha-test'); + file_put_contents($path, $content); + + $zip = new ZipArchive; + expect($zip->open($path))->toBeTrue(); + + $names = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $names[] = $zip->getNameIndex($i); + } + $zip->close(); + unlink($path); + + expect($names)->not->toBeEmpty() + ->and($names)->each->toEndWith('.csv'); +}); + +it('downloads the booking list of a budget plan as a csv', function (): void { + $response = $this->actingAs(budgetManager())->get('export/booking/1/csv'); + + $response->assertOk()->assertHeader('Content-Type', 'text/csv; charset=windows-1252'); + + expect($response->headers->get('Content-Disposition')) + ->toStartWith('attachment; filename="') + ->toEndWith('-Buchungsliste-2025-04-bis-2026-03.csv"'); + + $content = mb_convert_encoding((string) $response->getContent(), 'UTF-8', 'WINDOWS-1252'); + + expect($content)->toStartWith('Buchungsnummer;Betrag in Euro;') + // a data row followed, and no layout markup came with it + ->and(substr_count($content, PHP_EOL))->toBeGreaterThan(0) + ->and($content)->not->toContain('newInstanceWithoutConstructor(); + } + + return $method->invokeArgs($controller, [$amount, $creditDebit]); +} + +it('keeps the sign of an already signed amount when no credit/debit mark is given', function (string $amount, int $expected): void { + // Regression: the sign was applied twice, so every negative amount came back positive. + expect(convertToCent($amount))->toBe($expected); +})->with([ + 'negative' => ['-123.45', -12345], + 'positive' => ['123.45', 12345], + 'zero' => ['0.00', 0], + 'smallest negative' => ['-0.01', -1], + 'large negative' => ['-10000.99', -1000099], +]); + +it('takes the sign from the credit/debit mark of a bank statement', function (string $amount, string $creditDebit, int $expected): void { + expect(convertToCent($amount, $creditDebit))->toBe($expected); +})->with([ + 'credit' => ['123.45', Statement::CD_CREDIT, 12345], + 'debit' => ['123.45', Statement::CD_DEBIT, -12345], + 'credit zero' => ['0.00', Statement::CD_CREDIT, 0], + // Banks send unsigned magnitudes; if a signed one ever arrives, the mark still wins + // instead of cancelling out against the sign. + 'signed amount does not cancel the mark' => ['-123.45', Statement::CD_DEBIT, -12345], +]); + +it('converts two-decimal amounts exactly, without float drift', function (string $amount, int $expected): void { + // 8.20 * 100 is 819.9999... in binary floating point, so this only holds because the + // conversion rounds instead of casting the product straight to int. + expect(convertToCent($amount))->toBe($expected); +})->with([ + ['8.20', 820], + ['-8.20', -820], + ['0.07', 7], + ['0.29', 29], + ['1234567.89', 123456789], +]);