diff --git a/app/Console/Commands/PruneExportFolders.php b/app/Console/Commands/PruneExportFolders.php new file mode 100644 index 0000000..9bee72c --- /dev/null +++ b/app/Console/Commands/PruneExportFolders.php @@ -0,0 +1,129 @@ +whereNotNull('keep_days') + ->where('keep_days', '>', 0) + // Only where this machine can see the files. An SFTP destination is + // somebody else's disk with somebody else's retention on it. + ->where('driver', ExportTarget::LOCAL) + ->get(); + + foreach ($targets as $target) { + $this->prune($target); + } + + return self::SUCCESS; + } + + private function prune(ExportTarget $target): void + { + $root = rtrim($target->path, '/'); + + if (! is_dir($root)) { + // The mount is gone. Saying nothing and doing nothing is right: + // this is a cleanup, and a cleanup must never be the thing that + // reports a broken mount — nor act on whatever is underneath it. + $this->warn("{$target->name}: path is not there, skipping ({$root})"); + + return; + } + + $cutoff = now()->subDays((int) $target->keep_days)->startOfDay(); + $removed = 0; + + foreach ((array) scandir($root) as $entry) { + if ($entry === '.' || $entry === '..' || ! is_dir($root.'/'.$entry)) { + continue; + } + + $date = $this->dateFrom($entry); + + // Not a dated folder: left alone, deliberately. A prune that + // deletes what it does not recognise is how an archive loses + // something nobody was watching. + if ($date === null || $date->gte($cutoff)) { + continue; + } + + if ($this->option('dry-run')) { + $this->line("would remove {$root}/{$entry}"); + $removed++; + + continue; + } + + $this->removeTree($root.'/'.$entry); + $removed++; + } + + $this->line("{$target->name}: {$removed} folder(s) older than {$target->keep_days} days."); + } + + /** + * Only the exact shapes this application writes. + * + * Matched with a pattern BEFORE parsing, not by asking Carbon and catching + * what it does with nonsense: under strict mode createFromFormat throws + * rather than returning false, so a folder somebody made by hand would end + * the whole prune instead of being skipped. The pattern also says plainly + * which two shapes are recognised, which is the thing a reader wants to + * know here. + */ + private function dateFrom(string $name): ?Carbon + { + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $name, $m)) { + return Carbon::createFromFormat('Y-m-d', $name)?->startOfDay(); + } + + if (preg_match('/^\d{4}$/', $name)) { + return Carbon::createFromFormat('Y-m-d', $name.'-12-31')?->startOfDay(); + } + + return null; + } + + private function removeTree(string $directory): void + { + foreach ((array) scandir($directory) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory.'/'.$entry; + is_dir($path) ? $this->removeTree($path) : @unlink($path); + } + + @rmdir($directory); + } +} diff --git a/app/Livewire/Admin/EditExportTarget.php b/app/Livewire/Admin/EditExportTarget.php index 57195b5..ad281a5 100644 --- a/app/Livewire/Admin/EditExportTarget.php +++ b/app/Livewire/Admin/EditExportTarget.php @@ -26,6 +26,12 @@ class EditExportTarget extends ModalComponent public string $path = ''; + /** 'year' for an archive somebody keeps, 'day' for a handover point. */ + public string $layout = ExportTarget::BY_YEAR; + + /** Empty means keep for ever, which is the default and the safe one. */ + public ?int $keepDays = null; + public string $host = ''; public ?int $port = 22; @@ -52,6 +58,8 @@ class EditExportTarget extends ModalComponent $this->name = $target->name; $this->driver = $target->driver; $this->path = $target->path; + $this->layout = $target->layout; + $this->keepDays = $target->keep_days; $this->host = (string) $target->host; $this->port = $target->port ?: 22; $this->username = (string) $target->username; @@ -67,6 +75,10 @@ class EditExportTarget extends ModalComponent 'name' => 'required|string|max:120', 'driver' => 'required|in:local,sftp', 'path' => 'required|string|max:255', + 'layout' => 'required|in:year,day', + // Nothing, or a real number of days. Zero would read as "delete + // immediately" to somebody who typed it meaning "never". + 'keepDays' => 'nullable|integer|min:1|max:3650', 'active' => 'boolean', ]; @@ -93,6 +105,8 @@ class EditExportTarget extends ModalComponent 'name' => $data['name'], 'driver' => $data['driver'], 'path' => rtrim($data['path'], '/') ?: '/', + 'layout' => $data['layout'], + 'keep_days' => $data['keepDays'] ?: null, 'host' => $this->driver === ExportTarget::SFTP ? $this->host : null, 'port' => $this->driver === ExportTarget::SFTP ? $this->port : null, 'username' => $this->driver === ExportTarget::SFTP ? $this->username : null, diff --git a/app/Models/ExportTarget.php b/app/Models/ExportTarget.php index 451262f..cf88638 100644 --- a/app/Models/ExportTarget.php +++ b/app/Models/ExportTarget.php @@ -22,13 +22,19 @@ class ExportTarget extends Model public const SFTP = 'sftp'; + /** One folder per year — an archive somebody keeps. */ + public const BY_YEAR = 'year'; + + /** One folder per day — a handover point somebody collects from. */ + public const BY_DAY = 'day'; + protected $fillable = [ - 'name', 'driver', 'path', 'host', 'port', 'username', 'secret_key', 'active', + 'name', 'driver', 'path', 'layout', 'keep_days', 'host', 'port', 'username', 'secret_key', 'active', ]; protected function casts(): array { - return ['active' => 'boolean', 'port' => 'integer']; + return ['active' => 'boolean', 'port' => 'integer', 'keep_days' => 'integer']; } public function exports(): HasMany @@ -36,6 +42,16 @@ class ExportTarget extends Model return $this->hasMany(InvoiceExport::class); } + /** The folder an invoice issued on this date belongs in. */ + public function folderFor(?\Illuminate\Support\Carbon $issuedOn): string + { + $date = $issuedOn ?? now(); + + return $this->layout === self::BY_DAY + ? $date->format('Y-m-d') + : $date->format('Y'); + } + /** Human-readable, for a list and for an error message. */ public function describe(): string { diff --git a/app/Services/Billing/InvoiceArchive.php b/app/Services/Billing/InvoiceArchive.php index 40115a3..90dc632 100644 --- a/app/Services/Billing/InvoiceArchive.php +++ b/app/Services/Billing/InvoiceArchive.php @@ -51,10 +51,11 @@ final class InvoiceArchive $disk = $this->disk($target); - // Grouped by year, because seven years of invoices in one directory is - // a directory nobody opens twice. - $year = $invoice->issued_on?->format('Y') ?? 'unbekannt'; - $relative = $year.'/'.$invoice->number.'.pdf'; + // Per year for an archive somebody keeps, per day for a handover point + // somebody collects from — the destination says which, because the two + // do different jobs. Either way it is grouped: seven years of invoices + // in one directory is a directory nobody opens twice. + $relative = $target->folderFor($invoice->issued_on).'/'.$invoice->number.'.pdf'; $pdf = $this->renderer->forInvoice($invoice); diff --git a/database/migrations/2026_07_29_170000_add_layout_and_retention_to_export_targets.php b/database/migrations/2026_07_29_170000_add_layout_and_retention_to_export_targets.php new file mode 100644 index 0000000..105183e --- /dev/null +++ b/database/migrations/2026_07_29_170000_add_layout_and_retention_to_export_targets.php @@ -0,0 +1,40 @@ +string('layout', 8)->default('year')->after('path'); + $table->unsignedSmallInteger('keep_days')->nullable()->after('layout'); + }); + } + + public function down(): void + { + Schema::table('export_targets', function (Blueprint $table) { + $table->dropColumn(['layout', 'keep_days']); + }); + } +}; diff --git a/lang/de/finance.php b/lang/de/finance.php index a2daf44..3eab77e 100644 --- a/lang/de/finance.php +++ b/lang/de/finance.php @@ -72,6 +72,12 @@ return [ 'path' => 'Pfad', 'path_local_hint' => 'Absoluter Pfad. Der Einhängepunkt selbst wird nie angelegt — fehlt er, gilt der Mount als weg und es wird nichts geschrieben.', 'path_sftp_hint' => 'Verzeichnis auf der Gegenstelle.', + 'layout' => 'Ordnerstruktur', + 'layout_year' => 'Ein Ordner je Jahr', + 'layout_day' => 'Ein Ordner je Tag', + 'layout_hint' => 'Jahr für ein Archiv, das bleibt. Tag für einen Übergabeort, den jemand abholt.', + 'keep_days' => 'Aufbewahrung (Tage)', + 'keep_days_hint' => 'Leer heißt: nichts wird gelöscht. Ein Wert löscht Ordner, die älter sind — ungefährlich, weil jede Rechnung aus der Datenbank neu erzeugt werden kann.', 'host' => 'Host', 'port' => 'Port', 'username' => 'Benutzer', diff --git a/lang/en/finance.php b/lang/en/finance.php index 0f1834c..51964a1 100644 --- a/lang/en/finance.php +++ b/lang/en/finance.php @@ -72,6 +72,12 @@ return [ 'path' => 'Path', 'path_local_hint' => 'Absolute path. The mount point itself is never created — if it is missing, the mount counts as gone and nothing is written.', 'path_sftp_hint' => 'Directory on the far side.', + 'layout' => 'Folder layout', + 'layout_year' => 'One folder per year', + 'layout_day' => 'One folder per day', + 'layout_hint' => 'Year for an archive that stays. Day for a handover point somebody collects from.', + 'keep_days' => 'Keep for (days)', + 'keep_days_hint' => 'Empty means nothing is deleted. A value removes folders older than that — safe, because every invoice can be produced again from the database.', 'host' => 'Host', 'port' => 'Port', 'username' => 'Username', diff --git a/resources/views/livewire/admin/edit-export-target.blade.php b/resources/views/livewire/admin/edit-export-target.blade.php index 8861379..f0c0809 100644 --- a/resources/views/livewire/admin/edit-export-target.blade.php +++ b/resources/views/livewire/admin/edit-export-target.blade.php @@ -18,6 +18,20 @@ :hint="$driver === 'local' ? __('finance.target.path_local_hint') : __('finance.target.path_sftp_hint')" :placeholder="$driver === 'local' ? '/mnt/rechnungen' : '/rechnungen'" /> +
{{ __('finance.target.layout_hint') }}
+