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 @@ +
{{ __('finance.target.driver_hint') }}
+{{ __('finance.target.active_hint') }}
+- @if ($exportFailures > 0) - {{ __('finance.archive_failed', ['n' => $exportFailures]) }} - @elseif ($unexported > 0) - {{ __('finance.archive_pending', ['n' => $unexported]) }} - @else - {{ __('finance.archive_clear') }} - @endif -
- @endif -{{ __('finance.targets_sub') }}
+{{ __('finance.targets_empty') }}
+ @else ++ {{ $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 +
+