Let a destination say how it is laid out and how long anything stays
tests / pest (push) Failing after 7m29s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details

Two destinations doing two jobs. A handover directory a NAS collects from wants
a folder per day and wants emptying — thirty days is enough to notice a NAS that
stopped collecting, and keeping more only hides it. An archive on the NAS itself
wants a folder per year and wants nothing deleted, ever. So both are per
destination rather than global.

Deleting is safe here in a way it usually is not, and that is worth saying
plainly: the invoice is a frozen document in the database and its PDF is
rendered from that on demand. Nothing is moved anywhere — it is copied, and
anything removed from a directory can be produced again and re-exported. The
directory is a handover point, not the record.

keep_days is null by default and stays null unless somebody sets it: a retention
rule nobody asked for is a deletion nobody expected. The prune only touches
folders whose NAME is one of the two shapes this application writes, and leaves
everything else alone — a prune that deletes what it does not recognise is how
an archive loses something nobody was watching. It also skips a destination
whose path is missing rather than acting on whatever is underneath an absent
mountpoint.

The shape is matched with a pattern before parsing rather than by handing
nonsense to Carbon: under strict mode createFromFormat throws instead of
returning false, so one folder somebody made by hand would have ended the whole
prune instead of being skipped. The test with a foreign folder in it found that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/granted-plans
nexxo 2026-07-29 03:08:27 +02:00
parent 0528b56a32
commit d8ff717cb5
10 changed files with 298 additions and 6 deletions

View File

@ -0,0 +1,129 @@
<?php
namespace App\Console\Commands;
use App\Models\ExportTarget;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Empty a handover directory of folders nobody needs any more.
*
* Only where a destination asks for it keep_days is null by default, because
* a retention rule nobody configured is a deletion nobody expected.
*
* Safe in a way deleting usually is not: the invoice lives in the database as a
* frozen document and its PDF is rendered from that on demand, so anything
* removed here can be produced again and re-exported. The directory is a
* handover point, not the record.
*
* Only ever removes folders whose NAME is a date it can parse and that are old
* enough. Anything else in there a stray file, a directory somebody made by
* hand, a folder from a different layout is left alone. A prune that deletes
* what it does not recognise is how an archive loses something nobody was
* watching.
*/
class PruneExportFolders extends Command
{
protected $signature = 'clupilot:prune-exports {--dry-run}';
protected $description = 'Remove dated folders older than a destination keeps them';
public function handle(): int
{
$targets = ExportTarget::query()
->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);
}
}

View File

@ -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,

View File

@ -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
{

View File

@ -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);

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* How a destination is laid out, and how long anything stays there.
*
* Both per destination, because the two destinations do different jobs. A
* handover directory that a NAS collects from wants a folder per day and wants
* emptying thirty days of folders is enough to notice a NAS that stopped
* collecting, and keeping more only hides it. The archive on the NAS itself
* wants a folder per year and wants nothing deleted, ever.
*
* Deleting is safe here in a way it usually is not: the invoice lives in the
* database as a frozen document and the PDF is rendered from it on demand, so
* anything removed from a directory can be produced again. The directory is a
* handover point, not the record.
*
* keep_days NULL means keep for ever, and that is the default: a retention rule
* nobody asked for is a deletion nobody expected.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('export_targets', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -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',

View File

@ -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',

View File

@ -18,6 +18,20 @@
:hint="$driver === 'local' ? __('finance.target.path_local_hint') : __('finance.target.path_sftp_hint')"
:placeholder="$driver === 'local' ? '/mnt/rechnungen' : '/rechnungen'" />
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="text-sm font-medium text-body" for="layout">{{ __('finance.target.layout') }}</label>
<select id="layout" wire:model="layout"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="year">{{ __('finance.target.layout_year') }}</option>
<option value="day">{{ __('finance.target.layout_day') }}</option>
</select>
<p class="mt-1 text-xs text-muted">{{ __('finance.target.layout_hint') }}</p>
</div>
<x-ui.input name="keepDays" wire:model="keepDays" type="number"
:label="__('finance.target.keep_days')" :hint="__('finance.target.keep_days_hint')" placeholder="30" />
</div>
@if ($driver === 'sftp')
<div class="grid gap-4 sm:grid-cols-3">
<div class="sm:col-span-2"><x-ui.input name="host" wire:model="host" :label="__('finance.target.host')" placeholder="backup.example.com" /></div>

View File

@ -56,3 +56,10 @@ Schedule::call(fn () => \App\Models\StripePendingEvent::query()
Schedule::command('clupilot:archive-invoices')
->hourly()
->withoutOverlapping();
// Empty a handover directory of folders past their keep-by. Only where a
// destination asks for it, and only folders whose name is a date this
// application wrote — see the command for why it leaves everything else alone.
Schedule::command('clupilot:prune-exports')
->dailyAt('03:20')
->withoutOverlapping();

View File

@ -157,3 +157,62 @@ it('sweeps up whatever the queue gave up on', function () {
Queue::assertPushed(ArchiveInvoice::class, 2);
});
it('puts a handover destination into a folder per day', function () {
// A NAS that collects wants today's folder. A folder per year is for an
// archive somebody keeps, and the two do different jobs.
$this->target->update(['layout' => ExportTarget::BY_DAY]);
$invoice = anInvoice();
(new ArchiveInvoice($invoice, $this->target->refresh()))->handle(app(InvoiceArchive::class));
expect(is_file($this->archive.'/'.now()->format('Y-m-d').'/'.$invoice->number.'.pdf'))->toBeTrue();
});
it('removes handover folders past their keep-by, and only those', function () {
// Safe in a way deleting usually is not: the invoice is a frozen document
// in the database and its PDF is rendered on demand, so anything removed
// here can be produced again.
$this->target->update(['layout' => ExportTarget::BY_DAY, 'keep_days' => 30]);
$old = $this->archive.'/'.now()->subDays(40)->format('Y-m-d');
$recent = $this->archive.'/'.now()->subDays(3)->format('Y-m-d');
$foreign = $this->archive.'/nicht-von-uns';
foreach ([$old, $recent, $foreign] as $dir) {
mkdir($dir, 0o775, true);
file_put_contents($dir.'/RE-2026-0001.pdf', 'x');
}
$this->artisan('clupilot:prune-exports')->assertExitCode(0);
expect(is_dir($old))->toBeFalse()
->and(is_dir($recent))->toBeTrue()
// A prune that deletes what it does not recognise is how an archive
// loses something nobody was watching.
->and(is_dir($foreign))->toBeTrue();
});
it('deletes nothing at all where no retention is configured', function () {
// The default, and deliberately so: a retention rule nobody asked for is a
// deletion nobody expected.
$this->target->update(['layout' => ExportTarget::BY_DAY, 'keep_days' => null]);
$old = $this->archive.'/'.now()->subYears(3)->format('Y-m-d');
mkdir($old, 0o775, true);
$this->artisan('clupilot:prune-exports')->assertExitCode(0);
expect(is_dir($old))->toBeTrue();
});
it('does not prune a destination whose mount has gone away', function () {
// A cleanup must never be the thing that acts on whatever is underneath an
// absent mountpoint.
$gone = sys_get_temp_dir().'/clupilot-absent-'.bin2hex(random_bytes(4));
$this->target->update(['path' => $gone, 'layout' => ExportTarget::BY_DAY, 'keep_days' => 1]);
$this->artisan('clupilot:prune-exports')->assertExitCode(0);
expect(is_dir($gone))->toBeFalse();
});