From b3081a544b39c4f4db91de487494053324a68ba1 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 02:43:15 +0200 Subject: [PATCH] Let the export have as many destinations as somebody wants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One path and one exported_at could not say "on the office NAS, not yet on the off-site box" — which is the only interesting question once there is more than one destination, and the reason for a second is precisely that any single one can fail. A row per destination now, and a row per (invoice, destination) pair. Two kinds, and the difference is where the protocol lives. 'local' is a directory this machine can already write to: a NAS mounted over NFS 4.1, a bind mount, a disk. The application knows nothing about the protocol, the target changes without code changing with it, and the whole thing stays testable against a temporary directory. 'sftp' is for something reachable with credentials — a Hetzner Storage Box being the obvious one — and the password goes into the secret vault with only its key on the row, because a credential in a settings table is a credential in every database dump. An empty password field on an existing target means unchanged, not cleared: the field cannot show what is stored, so blank-means-delete would wipe it on every unrelated edit. One job per pair rather than one per invoice. A job writing to both would retry the one that worked every time the other did not, and report one outcome for two different things. The ordering fix the tests found: Laravel's local adapter creates its root when it is CONSTRUCTED, so the "never create the root" guard was checking a directory the disk had just made for it. It runs before the disk is built now. That guard is the difference between an archive and a hole in the ground — mkdir on an absent mountpoint succeeds, writes onto the container's own disk and reports success, and an archive that silently is not the archive is worse than none. The single path from the previous commit is migrated into a destination called "Archiv" rather than dropped. Co-Authored-By: Claude Opus 5 --- .../Commands/ArchiveUnexportedInvoices.php | 34 ++- app/Jobs/ArchiveInvoice.php | 56 ++--- app/Livewire/Admin/EditExportTarget.php | 120 +++++++++++ app/Livewire/Admin/Finance.php | 31 ++- app/Models/ExportTarget.php | 46 ++++ app/Models/Invoice.php | 7 +- app/Models/InvoiceExport.php | 34 +++ app/Services/Billing/InvoiceArchive.php | 198 ++++++++++-------- app/Services/Billing/IssueInvoice.php | 15 +- ...026_07_29_150000_create_export_targets.php | 106 ++++++++++ lang/de/finance.php | 27 +++ lang/en/finance.php | 27 +++ .../admin/edit-export-target.blade.php | 42 ++++ .../views/livewire/admin/finance.blade.php | 68 ++++-- tests/Feature/Billing/InvoiceArchiveTest.php | 57 +++-- 15 files changed, 685 insertions(+), 183 deletions(-) create mode 100644 app/Livewire/Admin/EditExportTarget.php create mode 100644 app/Models/ExportTarget.php create mode 100644 app/Models/InvoiceExport.php create mode 100644 database/migrations/2026_07_29_150000_create_export_targets.php create mode 100644 resources/views/livewire/admin/edit-export-target.blade.php diff --git a/app/Console/Commands/ArchiveUnexportedInvoices.php b/app/Console/Commands/ArchiveUnexportedInvoices.php index f5e56c4..ed92d87 100644 --- a/app/Console/Commands/ArchiveUnexportedInvoices.php +++ b/app/Console/Commands/ArchiveUnexportedInvoices.php @@ -4,7 +4,7 @@ namespace App\Console\Commands; use App\Jobs\ArchiveInvoice; use App\Models\Invoice; -use App\Services\Billing\InvoiceArchive; +use App\Models\ExportTarget; use Illuminate\Console\Command; /** @@ -28,23 +28,35 @@ class ArchiveUnexportedInvoices extends Command public function handle(): int { - if (! InvoiceArchive::enabled()) { - $this->line('No archive path configured — nothing to do.'); + $targets = ExportTarget::query()->where('active', true)->get(); + + if ($targets->isEmpty()) { + $this->line('No export target configured — nothing to do.'); return self::SUCCESS; } - $pending = Invoice::query() - ->whereNull('exported_at') - ->orderBy('id') - ->limit((int) $this->option('limit')) - ->get(); + $queued = 0; - foreach ($pending as $invoice) { - ArchiveInvoice::dispatch($invoice); + foreach ($targets as $target) { + // Per destination, because "arrived" is a fact about a pair. An + // invoice on the office NAS and not on the off-site box is exactly + // the case this exists to catch. + $pending = Invoice::query() + ->whereDoesntHave('exports', fn ($q) => $q + ->where('export_target_id', $target->id) + ->whereNotNull('exported_at')) + ->orderBy('id') + ->limit((int) $this->option('limit')) + ->get(); + + foreach ($pending as $invoice) { + ArchiveInvoice::dispatch($invoice, $target); + $queued++; + } } - $this->line("Queued {$pending->count()} invoice(s) for the archive."); + $this->line("Queued {$queued} copy/copies for {$targets->count()} destination(s)."); return self::SUCCESS; } diff --git a/app/Jobs/ArchiveInvoice.php b/app/Jobs/ArchiveInvoice.php index 94e66a4..b4b52a3 100644 --- a/app/Jobs/ArchiveInvoice.php +++ b/app/Jobs/ArchiveInvoice.php @@ -2,23 +2,25 @@ namespace App\Jobs; +use App\Models\ExportTarget; use App\Models\Invoice; +use App\Models\InvoiceExport; use App\Services\Billing\InvoiceArchive; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Throwable; /** - * Put one invoice on the archive, and keep trying if the archive is not there. + * Put one invoice on ONE destination, and keep trying if that one is away. + * + * One job per pair rather than one per invoice: the reason for having a second + * destination is that the first can fail, and a job that writes to both would + * retry the one that worked every time the other did not — and would report a + * single outcome for two different things. * * Queued rather than done during the request that issued the invoice: a network - * mount can block for as long as its timeout allows, and an invoice must not - * fail to be issued because a NAS in an office is rebooting. - * - * Retries with a backoff and, when it finally gives up, records why on the - * invoice itself. An export that fails silently is the failure mode that - * matters here — nobody looks at an archive to check whether last Tuesday is - * in it, and the sweep needs something to find. + * mount can block for its whole timeout, and an invoice must not fail to be + * issued because a NAS in an office is rebooting. */ class ArchiveInvoice implements ShouldQueue { @@ -26,37 +28,43 @@ class ArchiveInvoice implements ShouldQueue public int $tries = 5; - /** Minutes, roughly: a mount that is gone is usually gone for a while. */ + /** A destination that is gone is usually gone for a while. */ public array $backoff = [60, 300, 900, 3600]; - public function __construct(public Invoice $invoice) {} + public function __construct(public Invoice $invoice, public ExportTarget $target) {} public function handle(InvoiceArchive $archive): void { - if (! InvoiceArchive::enabled()) { + if (! $this->target->active) { return; } - // Already there. A retry after a timeout that actually succeeded must - // not write it twice, and the file name is the same either way. - if ($this->invoice->exported_at !== null) { + $record = InvoiceExport::query()->firstOrNew([ + 'invoice_id' => $this->invoice->id, + 'export_target_id' => $this->target->id, + ]); + + // Already there. A retry after a timeout that in fact succeeded must + // not write it a second time. + if ($record->exists && $record->exported_at !== null) { return; } - $archive->store($this->invoice); + $record->attempts = (int) $record->attempts + 1; + $record->save(); - $this->invoice->forceFill([ - 'exported_at' => now(), - 'export_error' => null, - ])->save(); + $archive->store($this->invoice, $this->target); + + $record->forceFill(['exported_at' => now(), 'error' => null])->save(); } public function failed(?Throwable $e): void { - // On the invoice, not only in the log. The console shows it there, and - // a log line nobody reads is not a report. - $this->invoice->forceFill([ - 'export_error' => mb_substr((string) $e?->getMessage(), 0, 250), - ])->save(); + // Against the pair, not the invoice: "on the office NAS, not on the + // off-site box" is the thing somebody needs to be told. + InvoiceExport::query()->updateOrCreate( + ['invoice_id' => $this->invoice->id, 'export_target_id' => $this->target->id], + ['error' => mb_substr((string) $e?->getMessage(), 0, 250)], + ); } } diff --git a/app/Livewire/Admin/EditExportTarget.php b/app/Livewire/Admin/EditExportTarget.php new file mode 100644 index 0000000..2233709 --- /dev/null +++ b/app/Livewire/Admin/EditExportTarget.php @@ -0,0 +1,120 @@ +authorize('site.manage'); // modals bypass the route middleware + + if ($uuid === null) { + return; + } + + $target = ExportTarget::query()->where('uuid', $uuid)->firstOrFail(); + + $this->uuid = $uuid; + $this->name = $target->name; + $this->driver = $target->driver; + $this->path = $target->path; + $this->host = (string) $target->host; + $this->port = $target->port ?: 22; + $this->username = (string) $target->username; + $this->active = (bool) $target->active; + $this->hasPassword = $target->secret_key !== null + && app(SecretVault::class)->has($target->secret_key); + } + + public function save() + { + $this->authorize('site.manage'); + + $rules = [ + 'name' => 'required|string|max:120', + 'driver' => 'required|in:local,sftp', + 'path' => 'required|string|max:255', + 'active' => 'boolean', + ]; + + if ($this->driver === ExportTarget::SFTP) { + $rules += [ + 'host' => 'required|string|max:190', + 'port' => 'required|integer|min:1|max:65535', + 'username' => 'required|string|max:120', + // Required only when there is nothing stored yet. Blank on an + // existing target means "unchanged" — the field cannot show + // what is stored, so treating blank as a deletion would wipe + // the credential on every unrelated edit. + 'password' => $this->hasPassword ? 'nullable|string|max:255' : 'required|string|max:255', + ]; + } + + $data = $this->validate($rules); + + $target = $this->uuid !== '' + ? ExportTarget::query()->where('uuid', $this->uuid)->firstOrFail() + : new ExportTarget; + + $target->fill([ + 'name' => $data['name'], + 'driver' => $data['driver'], + 'path' => rtrim($data['path'], '/') ?: '/', + 'host' => $this->driver === ExportTarget::SFTP ? $this->host : null, + 'port' => $this->driver === ExportTarget::SFTP ? $this->port : null, + 'username' => $this->driver === ExportTarget::SFTP ? $this->username : null, + 'active' => $data['active'] ?? true, + ]); + + $target->save(); + + if ($this->driver === ExportTarget::SFTP && $this->password !== '') { + $key = 'export_target.'.$target->uuid; + app(SecretVault::class)->put($key, $this->password, auth('operator')->user()); + $target->forceFill(['secret_key' => $key])->save(); + } + + $this->dispatch('notify', message: __('finance.targets_saved')); + + return $this->redirectRoute('admin.finance', navigate: true); + } + + public function render() + { + return view('livewire.admin.edit-export-target'); + } +} diff --git a/app/Livewire/Admin/Finance.php b/app/Livewire/Admin/Finance.php index 355aa8a..62f10e0 100644 --- a/app/Livewire/Admin/Finance.php +++ b/app/Livewire/Admin/Finance.php @@ -2,9 +2,10 @@ namespace App\Livewire\Admin; +use App\Models\ExportTarget; use App\Models\Invoice; +use App\Models\InvoiceExport; use App\Models\InvoiceSeries; -use App\Services\Billing\InvoiceArchive; use App\Support\CompanyProfile; use App\Support\Settings; use Illuminate\Support\Facades\Storage; @@ -34,14 +35,6 @@ class Finance extends Component public float $taxRate = 20.0; - /** - * Where a copy of every invoice is written, or empty for none. - * - * A plain directory. Whether it is a local disk, a bind mount or a NAS over - * NFS 4.1 is the mount's business — nothing here knows or needs to. - */ - public string $archivePath = ''; - /** New logo upload, validated on save. */ public $logo = null; @@ -54,7 +47,6 @@ class Finance extends Component $this->company = CompanyProfile::all(); $this->logoPath = (string) ($this->company['logo_path'] ?? ''); $this->taxRate = CompanyProfile::taxRate(); - $this->archivePath = InvoiceArchive::path(); } public function saveCompany(): void @@ -83,7 +75,6 @@ class Finance extends Component // ever rendered from then on. 'logo' => 'nullable|image|mimes:png,webp|max:1024', 'taxRate' => 'required|numeric|min:0|max:100', - 'archivePath' => 'nullable|string|max:255', ]); if ($this->logo !== null) { @@ -104,7 +95,6 @@ class Finance extends Component CompanyProfile::put($data['company']); Settings::set('company.tax_rate', (float) $data['taxRate']); - Settings::set(InvoiceArchive::PATH_KEY, trim((string) ($data['archivePath'] ?? ''))); $this->dispatch('notify', message: __('finance.company_saved')); } @@ -132,12 +122,17 @@ class Finance extends Component // invoice without a name, an address or a VAT number is not a valid // invoice here, and issuing one is worse than refusing to. 'missing' => CompanyProfile::missingForInvoicing(), - // Written from here rather than checked on every render: a stat() - // on a network mount that has gone away blocks for the mount's - // timeout, and this is a page an operator opens when something is - // already wrong. - 'unexported' => Invoice::query()->whereNull('exported_at')->count(), - 'exportFailures' => Invoice::query()->whereNotNull('export_error')->count(), + // Counted from the join table, never by asking the destinations. + // A stat() on a network mount that has gone away blocks for the + // mount's whole timeout, and this is a page an operator opens when + // something is already wrong. + 'targets' => ExportTarget::query()->orderBy('name')->get()->map(fn (ExportTarget $t) => [ + 'model' => $t, + 'pending' => Invoice::query()->whereDoesntHave('exports', fn ($q) => $q + ->where('export_target_id', $t->id)->whereNotNull('exported_at'))->count(), + 'failed' => InvoiceExport::query()->where('export_target_id', $t->id) + ->whereNotNull('error')->whereNull('exported_at')->count(), + ]), ]); } } diff --git a/app/Models/ExportTarget.php b/app/Models/ExportTarget.php new file mode 100644 index 0000000..451262f --- /dev/null +++ b/app/Models/ExportTarget.php @@ -0,0 +1,46 @@ + 'boolean', 'port' => 'integer']; + } + + public function exports(): HasMany + { + return $this->hasMany(InvoiceExport::class); + } + + /** Human-readable, for a list and for an error message. */ + public function describe(): string + { + return $this->driver === self::SFTP + ? $this->username.'@'.$this->host.':'.$this->path + : $this->path; + } +} diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 1cf409a..34c15ca 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -32,7 +32,6 @@ class Invoice extends Model 'issued_on' => 'date', 'due_on' => 'date', 'sent_at' => 'datetime', - 'exported_at' => 'datetime', 'net_cents' => 'integer', 'tax_cents' => 'integer', 'gross_cents' => 'integer', @@ -54,6 +53,12 @@ class Invoice extends Model return $this->belongsTo(Order::class); } + /** Where this document has been copied to, and where it has not. */ + public function exports(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(InvoiceExport::class); + } + /** What this document cancels, if it is a cancellation. */ public function cancels(): BelongsTo { diff --git a/app/Models/InvoiceExport.php b/app/Models/InvoiceExport.php new file mode 100644 index 0000000..a9cc973 --- /dev/null +++ b/app/Models/InvoiceExport.php @@ -0,0 +1,34 @@ + 'datetime', 'attempts' => 'integer']; + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function target(): BelongsTo + { + return $this->belongsTo(ExportTarget::class, 'export_target_id'); + } +} diff --git a/app/Services/Billing/InvoiceArchive.php b/app/Services/Billing/InvoiceArchive.php index a68b5e6..01e70fb 100644 --- a/app/Services/Billing/InvoiceArchive.php +++ b/app/Services/Billing/InvoiceArchive.php @@ -2,110 +2,142 @@ namespace App\Services\Billing; +use App\Models\ExportTarget; use App\Models\Invoice; -use App\Support\Settings; +use App\Services\Secrets\SecretVault; +use Illuminate\Filesystem\FilesystemAdapter; +use Illuminate\Support\Facades\Storage; use RuntimeException; /** - * A copy of every invoice on a filesystem outside this machine. + * A copy of an invoice on one destination. * - * The application knows nothing about NFS, and that is deliberate. It writes to - * a directory; whether that directory is a local disk, a bind mount or a NAS - * mounted over NFS 4.1 is the mount's business. Nothing here needs a protocol - * library, and the target can change without a line of code changing with it. + * Two kinds, and the difference is where the protocol lives. * - * Written to a temporary name in the same directory and then renamed. A rename - * within one filesystem is atomic, so whatever is watching that folder — a - * backup job, a person — never sees a half-written PDF and mistakes it for a - * finished one. Writing straight to the final name is how a truncated file ends - * up in an archive looking exactly like an invoice. + * 'local' is a directory this machine can already write to — a NAS mounted over + * NFS 4.1, a bind mount, a disk. The application knows nothing about the + * protocol and does not want to: the mount is the operator's business, the + * target can change without a line of code changing with it, and the whole + * thing stays testable against a temporary directory. + * + * 'sftp' is for something reachable over the network with credentials — a + * Hetzner Storage Box being the obvious one. The password lives in the secret + * vault and only its KEY is stored here: a credential in a settings table is a + * credential in every database dump. + * + * Neither is allowed to create its own root. See ensureRoot(). */ final class InvoiceArchive { - public const PATH_KEY = 'invoices.export_path'; + public function __construct( + private readonly InvoiceRenderer $renderer, + private readonly SecretVault $vault, + ) {} - public function __construct(private readonly InvoiceRenderer $renderer) {} - - /** Where copies go, or empty when archiving is switched off. */ - public static function path(): string + /** + * Write one invoice to one destination. + * + * Throws rather than returning false: the caller is a queued job, and a + * throw is what makes the queue retry. A silent false would leave the + * invoice unarchived and the job marked done — the failure nobody notices. + */ + public function store(Invoice $invoice, ExportTarget $target): string { - return trim((string) Settings::get(self::PATH_KEY, '')); - } + // BEFORE the disk is built, and that order is the point: Laravel's + // local adapter creates its root when it is constructed, so a check + // afterwards always finds the directory it just made — which is exactly + // the silent-success this guard exists to prevent. + $this->ensureRoot($target); - public static function enabled(): bool - { - return self::path() !== ''; + $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'; + + $pdf = $this->renderer->forInvoice($invoice); + + // Written beside the final name and moved into place. A move within one + // filesystem is atomic, so whatever watches that folder never sees a + // truncated PDF and takes it for a finished invoice. + $temporary = $relative.'.part'; + + if (! $disk->put($temporary, $pdf)) { + throw new RuntimeException("Could not write to {$target->name}: {$temporary}"); + } + + $disk->delete($relative); + + if (! $disk->move($temporary, $relative)) { + $disk->delete($temporary); + + throw new RuntimeException("Could not move the finished file into place on {$target->name}: {$relative}"); + } + + // Read the size back rather than trusting the write. Over a network + // mount a short write can report success, and an archive of truncated + // files looks exactly like one that worked. + $written = $disk->size($relative); + + if ($written < 1000) { + throw new RuntimeException("The archived file on {$target->name} is too small to be an invoice: {$relative}"); + } + + return $relative; } /** - * Write one invoice into the archive. + * The root is used, never created. * - * Throws rather than returning false: the caller is a queued job, and a - * throw is what makes the queue retry it. A silent false would leave the - * invoice unarchived and the job marked done. + * This is the whole difference between an archive and a hole in the ground. + * mkdir -p on a path whose mount is not there SUCCEEDS: it creates the empty + * directory beneath the mountpoint, writes the invoice onto the container's + * own disk and reports success — so an unmounted NAS would quietly collect + * invoices locally while every check said it worked. A missing root means a + * missing mount, and that is a failure to report, not a directory to make. + * + * Only for 'local'. On sftp the root is a remote directory that the server + * genuinely may need to create, and there is no mountpoint to be confused + * with. */ - public function store(Invoice $invoice): string + private function ensureRoot(ExportTarget $target): void { - $root = self::path(); - - if ($root === '') { - throw new RuntimeException('No archive path is configured.'); + if ($target->driver !== ExportTarget::LOCAL) { + return; } - // The root is never created, only used. This is the whole difference - // between an archive and a hole in the ground: mkdir -p on a path whose - // mount is not there succeeds, writes into the empty directory - // underneath the mountpoint, and reports success — so an unmounted NAS - // would silently collect invoices on the container's own disk and every - // check would say it worked. A missing root means the mount is missing, - // and that is a failure, not something to repair by creating it. - if (! is_dir($root)) { - throw new RuntimeException("The archive path does not exist — is the mount there? {$root}"); + if (! is_dir($target->path)) { + throw new RuntimeException( + "The path for {$target->name} does not exist — is the mount there? {$target->path}" + ); + } + } + + private function disk(ExportTarget $target): FilesystemAdapter + { + if ($target->driver === ExportTarget::SFTP) { + $password = $target->secret_key ? $this->vault->get($target->secret_key) : null; + + if ($password === null || $password === '') { + throw new RuntimeException("No usable credential for {$target->name}."); + } + + return Storage::build([ + 'driver' => 'sftp', + 'host' => (string) $target->host, + 'port' => (int) ($target->port ?: 22), + 'username' => (string) $target->username, + 'password' => $password, + 'root' => $target->path, + 'timeout' => 30, + ]); } - // Grouped by year, because seven years of invoices in one directory is - // a directory nobody opens twice. This one IS created: it lives inside - // a root that has already been proven to exist. - $directory = rtrim($root, '/').'/'.($invoice->issued_on?->format('Y') ?? 'unbekannt'); - - if (! is_dir($directory) && ! @mkdir($directory, 0o775, true) && ! is_dir($directory)) { - throw new RuntimeException("Cannot create the archive directory: {$directory}"); - } - - if (! is_writable($directory)) { - // Said plainly. On a mount that has gone away this is the first - // thing that fails, and "permission denied" on a path nobody - // recognises is a worse message than the path itself. - throw new RuntimeException("The archive directory is not writable: {$directory}"); - } - - $target = $directory.'/'.$invoice->number.'.pdf'; - $pdf = $this->renderer->forInvoice($invoice); - - // Same directory, so the rename below stays within one filesystem and - // is therefore atomic. A temp file in /tmp would make it a copy. - $temporary = $target.'.part'; - - if (@file_put_contents($temporary, $pdf) === false) { - throw new RuntimeException("Could not write {$temporary}"); - } - - if (! @rename($temporary, $target)) { - @unlink($temporary); - - throw new RuntimeException("Could not move the finished file into place: {$target}"); - } - - // Read back the size rather than trusting the write. Over a network - // mount a short write can report success, and an archive of truncated - // files is worse than an empty one: it looks like it worked. - clearstatcache(true, $target); - $written = @filesize($target); - - if ($written === false || $written < 1000) { - throw new RuntimeException("The archived file is too small to be an invoice: {$target}"); - } - - return $target; + return Storage::build([ + 'driver' => 'local', + 'root' => $target->path, + 'throw' => false, + ]); } } diff --git a/app/Services/Billing/IssueInvoice.php b/app/Services/Billing/IssueInvoice.php index 88d5e58..88da548 100644 --- a/app/Services/Billing/IssueInvoice.php +++ b/app/Services/Billing/IssueInvoice.php @@ -4,6 +4,7 @@ namespace App\Services\Billing; use App\Jobs\ArchiveInvoice; use App\Models\Customer; +use App\Models\ExportTarget; use App\Models\Invoice; use App\Models\InvoiceSeries; use App\Models\Order; @@ -110,11 +111,15 @@ final class IssueInvoice // the transaction lands, and it would then look for an invoice that // does not exist yet. // - // Dispatched rather than done here because the archive is a network - // mount: it can block for as long as its timeout allows, and an invoice - // must not fail to be issued because a NAS in an office is rebooting. - if (InvoiceArchive::enabled()) { - ArchiveInvoice::dispatch($invoice); + // One job per destination. The reason for having a second is that the + // first can fail, and a single job writing to both would retry the one + // that worked every time the other did not. + // + // Dispatched rather than done here because a destination can be a + // network mount that blocks for its whole timeout, and an invoice must + // not fail to be issued because a NAS in an office is rebooting. + foreach (ExportTarget::query()->where('active', true)->get() as $target) { + ArchiveInvoice::dispatch($invoice, $target); } return $invoice; diff --git a/database/migrations/2026_07_29_150000_create_export_targets.php b/database/migrations/2026_07_29_150000_create_export_targets.php new file mode 100644 index 0000000..970e82b --- /dev/null +++ b/database/migrations/2026_07_29_150000_create_export_targets.php @@ -0,0 +1,106 @@ +id(); + $table->uuid()->unique(); + + $table->string('name'); + + // 'local' is a directory this machine can already write to — a NAS + // mounted over NFS, a bind mount, a disk. The protocol is the + // mount's business and deliberately not this application's. + // + // 'sftp' is for something reachable over the network with + // credentials, a Hetzner Storage Box being the obvious one. + $table->string('driver', 16)->default('local'); + + // Where inside the target. For 'local' this is an absolute path + // and the mount point itself; for 'sftp' the remote directory. + $table->string('path'); + + $table->string('host')->nullable(); + $table->unsignedSmallInteger('port')->nullable(); + $table->string('username')->nullable(); + + // The KEY in the secret vault, never the secret. A password in a + // settings table is a password in every database dump. + $table->string('secret_key')->nullable(); + + $table->boolean('active')->default(true); + $table->timestamps(); + }); + + Schema::create('invoice_exports', function (Blueprint $table) { + $table->id(); + $table->foreignId('invoice_id')->constrained()->cascadeOnDelete(); + $table->foreignId('export_target_id')->constrained()->cascadeOnDelete(); + + $table->timestamp('exported_at')->nullable(); + $table->string('error')->nullable(); + $table->unsignedSmallInteger('attempts')->default(0); + $table->timestamps(); + + // One row per pair, so a retry updates rather than accumulates. + $table->unique(['invoice_id', 'export_target_id']); + // The sweep asks for "not yet arrived" on every tick. + $table->index('exported_at'); + }); + + // Carry the single path over, so an installation that already set one + // keeps its destination instead of quietly losing it. + $existing = DB::table('app_settings')->where('key', 'invoices.export_path')->value('value'); + $path = trim((string) json_decode((string) $existing, true)); + + if ($path !== '') { + DB::table('export_targets')->insert([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Archiv', + 'driver' => 'local', + 'path' => $path, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + Schema::table('invoices', function (Blueprint $table) { + $table->dropIndex(['exported_at']); + $table->dropColumn(['exported_at', 'export_error']); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->timestamp('exported_at')->nullable(); + $table->string('export_error')->nullable(); + $table->index('exported_at'); + }); + + Schema::dropIfExists('invoice_exports'); + Schema::dropIfExists('export_targets'); + } +}; diff --git a/lang/de/finance.php b/lang/de/finance.php index 9950116..f179c7b 100644 --- a/lang/de/finance.php +++ b/lang/de/finance.php @@ -55,6 +55,33 @@ return [ 'series_floor' => 'Kann nur erhöht werden. Eine niedrigere Nummer wäre schon vergeben — mindestens :n.', 'cancel' => 'Abbrechen', + 'targets_title' => 'Exportziele', + 'targets_sub' => 'Wohin eine Kopie jeder Rechnung geschrieben wird. Mehrere sind der Sinn der Sache — der Grund für ein zweites Ziel ist, dass das erste ausfallen kann.', + 'targets_add' => 'Ziel hinzufügen', + 'targets_edit' => 'Ziel bearbeiten', + 'targets_empty' => 'Noch kein Ziel eingetragen — es wird nichts kopiert.', + 'targets_saved' => 'Exportziel gespeichert.', + 'targets_inactive' => 'inaktiv', + + 'target' => [ + 'name' => 'Bezeichnung', + 'driver' => 'Art', + 'driver_local' => 'Verzeichnis auf diesem Server (Mount)', + 'driver_sftp' => 'SFTP (z. B. Hetzner Storage Box)', + 'driver_hint' => 'Ein Verzeichnis ist alles, was dieser Server schon beschreiben kann — eine NAS über NFS, ein Bind-Mount, eine Platte. Welches Protokoll dahinter steckt, geht die Anwendung nichts an.', + '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.', + 'host' => 'Host', + 'port' => 'Port', + 'username' => 'Benutzer', + 'password' => 'Passwort', + 'password_hint' => 'Wird im Zugangsdaten-Tresor abgelegt, nicht in dieser Tabelle.', + 'password_stored' => 'Ein Passwort ist hinterlegt. Leer lassen heißt: unverändert.', + 'active_label' => 'Kopien hierher schreiben', + 'active_hint' => 'Abgeschaltet bleibt alles Bestehende liegen, es wird nur nichts Neues mehr hingeschrieben.', + ], + 'series_title' => 'Rechnungskreise', 'series_sub' => 'Jeder Kreis hat sein eigenes Kürzel und seinen eigenen lückenlosen Zähler. Eine Gutschrift ist keine Rechnung mit Minus davor.', 'series' => [ diff --git a/lang/en/finance.php b/lang/en/finance.php index ebc193a..7568899 100644 --- a/lang/en/finance.php +++ b/lang/en/finance.php @@ -55,6 +55,33 @@ return [ 'series_floor' => 'Can only be raised. A lower number has already been issued — at least :n.', 'cancel' => 'Cancel', + 'targets_title' => 'Export destinations', + 'targets_sub' => 'Where a copy of every invoice is written. Several is the point — the reason for a second is that the first can fail.', + 'targets_add' => 'Add destination', + 'targets_edit' => 'Edit destination', + 'targets_empty' => 'No destination yet — nothing is copied.', + 'targets_saved' => 'Destination saved.', + 'targets_inactive' => 'inactive', + + 'target' => [ + 'name' => 'Name', + 'driver' => 'Kind', + 'driver_local' => 'Directory on this server (mount)', + 'driver_sftp' => 'SFTP (a Hetzner Storage Box, for instance)', + 'driver_hint' => 'A directory is anything this server can already write to — a NAS over NFS, a bind mount, a disk. Which protocol is behind it is no business of the application.', + '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.', + 'host' => 'Host', + 'port' => 'Port', + 'username' => 'Username', + 'password' => 'Password', + 'password_hint' => 'Stored in the credential vault, not in this table.', + 'password_stored' => 'A password is stored. Leave empty to keep it.', + 'active_label' => 'Write copies here', + 'active_hint' => 'Switched off, everything already written stays; only nothing new is added.', + ], + 'series_title' => 'Invoice series', 'series_sub' => 'Each series keeps its own prefix and its own gapless counter. A credit note is not an invoice with a minus sign in front of it.', 'series' => [ diff --git a/resources/views/livewire/admin/edit-export-target.blade.php b/resources/views/livewire/admin/edit-export-target.blade.php new file mode 100644 index 0000000..69c877e --- /dev/null +++ b/resources/views/livewire/admin/edit-export-target.blade.php @@ -0,0 +1,42 @@ +
+

{{ $uuid === '' ? __('finance.targets_add') : __('finance.targets_edit') }}

+ +
+ + +
+ + +

{{ __('finance.target.driver_hint') }}

+
+ + + + @if ($driver === 'sftp') +
+
+ +
+ + + @endif + +
+ +

{{ __('finance.target.active_hint') }}

+
+
+ +
+ {{ __('finance.cancel') }} + {{ __('finance.save') }} +
+
diff --git a/resources/views/livewire/admin/finance.blade.php b/resources/views/livewire/admin/finance.blade.php index 81e7b90..04651c9 100644 --- a/resources/views/livewire/admin/finance.blade.php +++ b/resources/views/livewire/admin/finance.blade.php @@ -82,30 +82,60 @@ - {{-- The archive. A directory, nothing more: the mount behind it is the - operator's business, and keeping it that way means the target can - change without a line of code changing with it. --}} -
- - @if ($archivePath !== '') -

- @if ($exportFailures > 0) - {{ __('finance.archive_failed', ['n' => $exportFailures]) }} - @elseif ($unexported > 0) - {{ __('finance.archive_pending', ['n' => $unexported]) }} - @else - {{ __('finance.archive_clear') }} - @endif -

- @endif -
-
{{ __('finance.save') }}
+ {{-- Where copies go. As many as somebody wants, because the reason for + having a second is that the first can fail. --}} +
+
+
+

{{ __('finance.targets_title') }}

+

{{ __('finance.targets_sub') }}

+
+ + {{ __('finance.targets_add') }} + +
+ + @if ($targets->isEmpty()) +

{{ __('finance.targets_empty') }}

+ @else +
    + @foreach ($targets as $row) +
  • +
    +

    + {{ $row['model']->name }} + {{ strtoupper($row['model']->driver) }} + @unless ($row['model']->active) + {{ __('finance.targets_inactive') }} + @endunless +

    +

    {{ $row['model']->describe() }}

    +

    + @if ($row['failed'] > 0) + {{ __('finance.archive_failed', ['n' => $row['failed']]) }} + @elseif ($row['pending'] > 0) + {{ __('finance.archive_pending', ['n' => $row['pending']]) }} + @else + {{ __('finance.archive_clear') }} + @endif +

    +
    + + {{ __('finance.series.edit') }} + +
  • + @endforeach +
+ @endif +
+ {{-- The Rechnungskreise. Each has its own prefix and its own gapless counter — a credit note is not an invoice with a minus sign. --}}
diff --git a/tests/Feature/Billing/InvoiceArchiveTest.php b/tests/Feature/Billing/InvoiceArchiveTest.php index 5ae5437..bc1a115 100644 --- a/tests/Feature/Billing/InvoiceArchiveTest.php +++ b/tests/Feature/Billing/InvoiceArchiveTest.php @@ -4,10 +4,11 @@ use App\Jobs\ArchiveInvoice; use App\Models\Customer; use App\Models\Invoice; use App\Models\Order; +use App\Models\ExportTarget; +use App\Models\InvoiceExport; use App\Services\Billing\InvoiceArchive; use App\Services\Billing\IssueInvoice; use App\Support\CompanyProfile; -use App\Support\Settings; use Illuminate\Support\Facades\Queue; /** @@ -26,7 +27,10 @@ beforeEach(function () { $this->archive = sys_get_temp_dir().'/clupilot-archive-'.bin2hex(random_bytes(6)); mkdir($this->archive, 0o775, true); - Settings::set(InvoiceArchive::PATH_KEY, $this->archive); + + $this->target = ExportTarget::create([ + 'name' => 'Büro-NAS', 'driver' => ExportTarget::LOCAL, 'path' => $this->archive, 'active' => true, + ]); }); afterEach(function () { @@ -44,17 +48,25 @@ function anInvoice(): Invoice ])); } -it('queues a copy the moment an invoice is issued', function () { +it('queues one copy per destination the moment an invoice is issued', function () { + // One job per pair, not one per invoice: the reason for a second + // destination is that the first can fail, and a job writing to both would + // retry the one that worked every time the other did not. Queue::fake(); + ExportTarget::create([ + 'name' => 'Storage Box', 'driver' => ExportTarget::LOCAL, + 'path' => $this->archive, 'active' => true, + ]); + anInvoice(); - Queue::assertPushed(ArchiveInvoice::class); + Queue::assertPushed(ArchiveInvoice::class, 2); }); -it('queues nothing when no archive is configured', function () { +it('queues nothing to a destination that is switched off', function () { Queue::fake(); - Settings::set(InvoiceArchive::PATH_KEY, ''); + $this->target->update(['active' => false]); anInvoice(); @@ -66,13 +78,13 @@ it('writes the invoice under its number, grouped by year', function () { // twice. $invoice = anInvoice(); - (new ArchiveInvoice($invoice))->handle(app(InvoiceArchive::class)); + (new ArchiveInvoice($invoice, $this->target))->handle(app(InvoiceArchive::class)); $expected = $this->archive.'/'.now()->format('Y').'/'.$invoice->number.'.pdf'; expect(is_file($expected))->toBeTrue() ->and(substr(file_get_contents($expected), 0, 5))->toBe('%PDF-') - ->and($invoice->refresh()->exported_at)->not->toBeNull(); + ->and(InvoiceExport::query()->where('invoice_id', $invoice->id)->value('exported_at'))->not->toBeNull(); }); it('leaves no half-written file behind under the final name', function () { @@ -81,7 +93,7 @@ it('leaves no half-written file behind under the final name', function () { // it for a finished invoice. $invoice = anInvoice(); - (new ArchiveInvoice($invoice))->handle(app(InvoiceArchive::class)); + (new ArchiveInvoice($invoice, $this->target))->handle(app(InvoiceArchive::class)); $year = $this->archive.'/'.now()->format('Y'); @@ -92,12 +104,12 @@ it('does not write the same invoice twice', function () { // A retry after a timeout that actually succeeded must not do it again. $invoice = anInvoice(); - (new ArchiveInvoice($invoice))->handle(app(InvoiceArchive::class)); - $first = $invoice->refresh()->exported_at; + (new ArchiveInvoice($invoice, $this->target))->handle(app(InvoiceArchive::class)); + $first = InvoiceExport::query()->where('invoice_id', $invoice->id)->value('exported_at'); - (new ArchiveInvoice($invoice))->handle(app(InvoiceArchive::class)); + (new ArchiveInvoice($invoice, $this->target))->handle(app(InvoiceArchive::class)); - expect($invoice->refresh()->exported_at->toIso8601String())->toBe($first->toIso8601String()); + expect(InvoiceExport::query()->where('invoice_id', $invoice->id)->value('exported_at'))->toEqual($first); }); it('refuses to create the archive root, so an unmounted NAS is a failure and not a hole', function () { @@ -111,14 +123,15 @@ it('refuses to create the archive root, so an unmounted NAS is a failure and not Queue::fake(); $invoice = anInvoice(); - Settings::set(InvoiceArchive::PATH_KEY, sys_get_temp_dir().'/clupilot-not-mounted-'.bin2hex(random_bytes(4))); + $gone = sys_get_temp_dir().'/clupilot-not-mounted-'.bin2hex(random_bytes(4)); + $this->target->update(['path' => $gone]); - expect(fn () => app(InvoiceArchive::class)->store($invoice)) + expect(fn () => app(InvoiceArchive::class)->store($invoice, $this->target->refresh())) ->toThrow(RuntimeException::class); - expect($invoice->refresh()->exported_at)->toBeNull() + expect(InvoiceExport::query()->whereNotNull('exported_at')->count())->toBe(0) // And nothing was left on disk pretending to be an archive. - ->and(is_dir(InvoiceArchive::path()))->toBeFalse(); + ->and(is_dir($gone))->toBeFalse(); }); it('records why it gave up, on the invoice rather than only in a log', function () { @@ -126,19 +139,19 @@ it('records why it gave up, on the invoice rather than only in a log', function // failure has to be somewhere a person will pass. $invoice = anInvoice(); - (new ArchiveInvoice($invoice))->failed(new RuntimeException('mount is gone')); + (new ArchiveInvoice($invoice, $this->target))->failed(new RuntimeException('mount is gone')); - expect($invoice->refresh()->export_error)->toContain('mount is gone'); + expect(InvoiceExport::query()->where('invoice_id', $invoice->id)->value('error'))->toContain('mount is gone'); }); it('sweeps up whatever the queue gave up on', function () { Queue::fake(); $invoice = anInvoice(); - Queue::assertPushed(ArchiveInvoice::class); + Queue::assertPushed(ArchiveInvoice::class, 1); - // The office connection was down; nothing reached the archive. - expect($invoice->exported_at)->toBeNull(); + // The office connection was down; nothing reached the destination. + expect(InvoiceExport::query()->whereNotNull('exported_at')->count())->toBe(0); $this->artisan('clupilot:archive-invoices')->assertExitCode(0);