Let the export have as many destinations as somebody wants
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 <noreply@anthropic.com>feat/granted-plans
parent
ab4b0311d4
commit
b3081a544b
|
|
@ -4,7 +4,7 @@ namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Jobs\ArchiveInvoice;
|
use App\Jobs\ArchiveInvoice;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
use App\Services\Billing\InvoiceArchive;
|
use App\Models\ExportTarget;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -28,23 +28,35 @@ class ArchiveUnexportedInvoices extends Command
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
if (! InvoiceArchive::enabled()) {
|
$targets = ExportTarget::query()->where('active', true)->get();
|
||||||
$this->line('No archive path configured — nothing to do.');
|
|
||||||
|
if ($targets->isEmpty()) {
|
||||||
|
$this->line('No export target configured — nothing to do.');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$queued = 0;
|
||||||
|
|
||||||
|
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()
|
$pending = Invoice::query()
|
||||||
->whereNull('exported_at')
|
->whereDoesntHave('exports', fn ($q) => $q
|
||||||
|
->where('export_target_id', $target->id)
|
||||||
|
->whereNotNull('exported_at'))
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->limit((int) $this->option('limit'))
|
->limit((int) $this->option('limit'))
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach ($pending as $invoice) {
|
foreach ($pending as $invoice) {
|
||||||
ArchiveInvoice::dispatch($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;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,25 @@
|
||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\ExportTarget;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
|
use App\Models\InvoiceExport;
|
||||||
use App\Services\Billing\InvoiceArchive;
|
use App\Services\Billing\InvoiceArchive;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Throwable;
|
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
|
* 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
|
* mount can block for its whole timeout, and an invoice must not fail to be
|
||||||
* fail to be issued because a NAS in an office is rebooting.
|
* 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.
|
|
||||||
*/
|
*/
|
||||||
class ArchiveInvoice implements ShouldQueue
|
class ArchiveInvoice implements ShouldQueue
|
||||||
{
|
{
|
||||||
|
|
@ -26,37 +28,43 @@ class ArchiveInvoice implements ShouldQueue
|
||||||
|
|
||||||
public int $tries = 5;
|
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 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
|
public function handle(InvoiceArchive $archive): void
|
||||||
{
|
{
|
||||||
if (! InvoiceArchive::enabled()) {
|
if (! $this->target->active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already there. A retry after a timeout that actually succeeded must
|
$record = InvoiceExport::query()->firstOrNew([
|
||||||
// not write it twice, and the file name is the same either way.
|
'invoice_id' => $this->invoice->id,
|
||||||
if ($this->invoice->exported_at !== null) {
|
'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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$archive->store($this->invoice);
|
$record->attempts = (int) $record->attempts + 1;
|
||||||
|
$record->save();
|
||||||
|
|
||||||
$this->invoice->forceFill([
|
$archive->store($this->invoice, $this->target);
|
||||||
'exported_at' => now(),
|
|
||||||
'export_error' => null,
|
$record->forceFill(['exported_at' => now(), 'error' => null])->save();
|
||||||
])->save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function failed(?Throwable $e): void
|
public function failed(?Throwable $e): void
|
||||||
{
|
{
|
||||||
// On the invoice, not only in the log. The console shows it there, and
|
// Against the pair, not the invoice: "on the office NAS, not on the
|
||||||
// a log line nobody reads is not a report.
|
// off-site box" is the thing somebody needs to be told.
|
||||||
$this->invoice->forceFill([
|
InvoiceExport::query()->updateOrCreate(
|
||||||
'export_error' => mb_substr((string) $e?->getMessage(), 0, 250),
|
['invoice_id' => $this->invoice->id, 'export_target_id' => $this->target->id],
|
||||||
])->save();
|
['error' => mb_substr((string) $e?->getMessage(), 0, 250)],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Admin;
|
||||||
|
|
||||||
|
use App\Models\ExportTarget;
|
||||||
|
use App\Services\Secrets\SecretVault;
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adding or changing one destination.
|
||||||
|
*
|
||||||
|
* The password never lives here. It goes into the secret vault under a key
|
||||||
|
* derived from the target, and only that key is stored on the row — a
|
||||||
|
* credential in a settings table is a credential in every database dump. An
|
||||||
|
* empty password field on an existing target means "leave it alone", not
|
||||||
|
* "clear it": the field cannot show what is stored, so treating blank as a
|
||||||
|
* deletion would wipe the credential every time somebody renamed the target.
|
||||||
|
*/
|
||||||
|
class EditExportTarget extends ModalComponent
|
||||||
|
{
|
||||||
|
public string $uuid = '';
|
||||||
|
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
public string $driver = ExportTarget::LOCAL;
|
||||||
|
|
||||||
|
public string $path = '';
|
||||||
|
|
||||||
|
public string $host = '';
|
||||||
|
|
||||||
|
public ?int $port = 22;
|
||||||
|
|
||||||
|
public string $username = '';
|
||||||
|
|
||||||
|
public string $password = '';
|
||||||
|
|
||||||
|
public bool $hasPassword = false;
|
||||||
|
|
||||||
|
public bool $active = true;
|
||||||
|
|
||||||
|
public function mount(?string $uuid = null): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
namespace App\Livewire\Admin;
|
||||||
|
|
||||||
|
use App\Models\ExportTarget;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
|
use App\Models\InvoiceExport;
|
||||||
use App\Models\InvoiceSeries;
|
use App\Models\InvoiceSeries;
|
||||||
use App\Services\Billing\InvoiceArchive;
|
|
||||||
use App\Support\CompanyProfile;
|
use App\Support\CompanyProfile;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
@ -34,14 +35,6 @@ class Finance extends Component
|
||||||
|
|
||||||
public float $taxRate = 20.0;
|
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. */
|
/** New logo upload, validated on save. */
|
||||||
public $logo = null;
|
public $logo = null;
|
||||||
|
|
||||||
|
|
@ -54,7 +47,6 @@ class Finance extends Component
|
||||||
$this->company = CompanyProfile::all();
|
$this->company = CompanyProfile::all();
|
||||||
$this->logoPath = (string) ($this->company['logo_path'] ?? '');
|
$this->logoPath = (string) ($this->company['logo_path'] ?? '');
|
||||||
$this->taxRate = CompanyProfile::taxRate();
|
$this->taxRate = CompanyProfile::taxRate();
|
||||||
$this->archivePath = InvoiceArchive::path();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveCompany(): void
|
public function saveCompany(): void
|
||||||
|
|
@ -83,7 +75,6 @@ class Finance extends Component
|
||||||
// ever rendered from then on.
|
// ever rendered from then on.
|
||||||
'logo' => 'nullable|image|mimes:png,webp|max:1024',
|
'logo' => 'nullable|image|mimes:png,webp|max:1024',
|
||||||
'taxRate' => 'required|numeric|min:0|max:100',
|
'taxRate' => 'required|numeric|min:0|max:100',
|
||||||
'archivePath' => 'nullable|string|max:255',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($this->logo !== null) {
|
if ($this->logo !== null) {
|
||||||
|
|
@ -104,7 +95,6 @@ class Finance extends Component
|
||||||
|
|
||||||
CompanyProfile::put($data['company']);
|
CompanyProfile::put($data['company']);
|
||||||
Settings::set('company.tax_rate', (float) $data['taxRate']);
|
Settings::set('company.tax_rate', (float) $data['taxRate']);
|
||||||
Settings::set(InvoiceArchive::PATH_KEY, trim((string) ($data['archivePath'] ?? '')));
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('finance.company_saved'));
|
$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 without a name, an address or a VAT number is not a valid
|
||||||
// invoice here, and issuing one is worse than refusing to.
|
// invoice here, and issuing one is worse than refusing to.
|
||||||
'missing' => CompanyProfile::missingForInvoicing(),
|
'missing' => CompanyProfile::missingForInvoicing(),
|
||||||
// Written from here rather than checked on every render: a stat()
|
// Counted from the join table, never by asking the destinations.
|
||||||
// on a network mount that has gone away blocks for the mount's
|
// A stat() on a network mount that has gone away blocks for the
|
||||||
// timeout, and this is a page an operator opens when something is
|
// mount's whole timeout, and this is a page an operator opens when
|
||||||
// already wrong.
|
// something is already wrong.
|
||||||
'unexported' => Invoice::query()->whereNull('exported_at')->count(),
|
'targets' => ExportTarget::query()->orderBy('name')->get()->map(fn (ExportTarget $t) => [
|
||||||
'exportFailures' => Invoice::query()->whereNotNull('export_error')->count(),
|
'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(),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\HasUuid;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One place a copy of every invoice is written to.
|
||||||
|
*
|
||||||
|
* As many as somebody wants, because the reason for having a second is that
|
||||||
|
* the first can fail — an office NAS is unreachable while the office router
|
||||||
|
* reboots, and an off-site box is unreachable while somebody else's data centre
|
||||||
|
* has a bad morning.
|
||||||
|
*/
|
||||||
|
class ExportTarget extends Model
|
||||||
|
{
|
||||||
|
use HasUuid;
|
||||||
|
|
||||||
|
public const LOCAL = 'local';
|
||||||
|
|
||||||
|
public const SFTP = 'sftp';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name', 'driver', 'path', 'host', 'port', 'username', 'secret_key', 'active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['active' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,6 @@ class Invoice extends Model
|
||||||
'issued_on' => 'date',
|
'issued_on' => 'date',
|
||||||
'due_on' => 'date',
|
'due_on' => 'date',
|
||||||
'sent_at' => 'datetime',
|
'sent_at' => 'datetime',
|
||||||
'exported_at' => 'datetime',
|
|
||||||
'net_cents' => 'integer',
|
'net_cents' => 'integer',
|
||||||
'tax_cents' => 'integer',
|
'tax_cents' => 'integer',
|
||||||
'gross_cents' => 'integer',
|
'gross_cents' => 'integer',
|
||||||
|
|
@ -54,6 +53,12 @@ class Invoice extends Model
|
||||||
return $this->belongsTo(Order::class);
|
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. */
|
/** What this document cancels, if it is a cancellation. */
|
||||||
public function cancels(): BelongsTo
|
public function cancels(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether one invoice reached one destination.
|
||||||
|
*
|
||||||
|
* A row per pair rather than a flag on the invoice: an invoice is not
|
||||||
|
* "exported", it is on this target and not yet on that one — which is the only
|
||||||
|
* interesting question once there is more than one, and the only thing the
|
||||||
|
* sweep can act on.
|
||||||
|
*/
|
||||||
|
class InvoiceExport extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['invoice_id', 'export_target_id', 'exported_at', 'error', 'attempts'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['exported_at' => 'datetime', 'attempts' => 'integer'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoice(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Invoice::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function target(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ExportTarget::class, 'export_target_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,110 +2,142 @@
|
||||||
|
|
||||||
namespace App\Services\Billing;
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use App\Models\ExportTarget;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
use App\Support\Settings;
|
use App\Services\Secrets\SecretVault;
|
||||||
|
use Illuminate\Filesystem\FilesystemAdapter;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use RuntimeException;
|
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
|
* Two kinds, and the difference is where the protocol lives.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Written to a temporary name in the same directory and then renamed. A rename
|
* 'local' is a directory this machine can already write to — a NAS mounted over
|
||||||
* within one filesystem is atomic, so whatever is watching that folder — a
|
* NFS 4.1, a bind mount, a disk. The application knows nothing about the
|
||||||
* backup job, a person — never sees a half-written PDF and mistakes it for a
|
* protocol and does not want to: the mount is the operator's business, the
|
||||||
* finished one. Writing straight to the final name is how a truncated file ends
|
* target can change without a line of code changing with it, and the whole
|
||||||
* up in an archive looking exactly like an invoice.
|
* 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
|
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) {}
|
/**
|
||||||
|
* Write one invoice to one destination.
|
||||||
/** Where copies go, or empty when archiving is switched off. */
|
*
|
||||||
public static function path(): string
|
* 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);
|
||||||
|
|
||||||
|
$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}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function enabled(): bool
|
$disk->delete($relative);
|
||||||
{
|
|
||||||
return self::path() !== '';
|
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
|
* This is the whole difference between an archive and a hole in the ground.
|
||||||
* throw is what makes the queue retry it. A silent false would leave the
|
* mkdir -p on a path whose mount is not there SUCCEEDS: it creates the empty
|
||||||
* invoice unarchived and the job marked done.
|
* 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 ($target->driver !== ExportTarget::LOCAL) {
|
||||||
|
return;
|
||||||
if ($root === '') {
|
|
||||||
throw new RuntimeException('No archive path is configured.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The root is never created, only used. This is the whole difference
|
if (! is_dir($target->path)) {
|
||||||
// between an archive and a hole in the ground: mkdir -p on a path whose
|
throw new RuntimeException(
|
||||||
// mount is not there succeeds, writes into the empty directory
|
"The path for {$target->name} does not exist — is the mount there? {$target->path}"
|
||||||
// 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}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grouped by year, because seven years of invoices in one directory is
|
private function disk(ExportTarget $target): FilesystemAdapter
|
||||||
// a directory nobody opens twice. This one IS created: it lives inside
|
{
|
||||||
// a root that has already been proven to exist.
|
if ($target->driver === ExportTarget::SFTP) {
|
||||||
$directory = rtrim($root, '/').'/'.($invoice->issued_on?->format('Y') ?? 'unbekannt');
|
$password = $target->secret_key ? $this->vault->get($target->secret_key) : null;
|
||||||
|
|
||||||
if (! is_dir($directory) && ! @mkdir($directory, 0o775, true) && ! is_dir($directory)) {
|
if ($password === null || $password === '') {
|
||||||
throw new RuntimeException("Cannot create the archive directory: {$directory}");
|
throw new RuntimeException("No usable credential for {$target->name}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! is_writable($directory)) {
|
return Storage::build([
|
||||||
// Said plainly. On a mount that has gone away this is the first
|
'driver' => 'sftp',
|
||||||
// thing that fails, and "permission denied" on a path nobody
|
'host' => (string) $target->host,
|
||||||
// recognises is a worse message than the path itself.
|
'port' => (int) ($target->port ?: 22),
|
||||||
throw new RuntimeException("The archive directory is not writable: {$directory}");
|
'username' => (string) $target->username,
|
||||||
|
'password' => $password,
|
||||||
|
'root' => $target->path,
|
||||||
|
'timeout' => 30,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$target = $directory.'/'.$invoice->number.'.pdf';
|
return Storage::build([
|
||||||
$pdf = $this->renderer->forInvoice($invoice);
|
'driver' => 'local',
|
||||||
|
'root' => $target->path,
|
||||||
// Same directory, so the rename below stays within one filesystem and
|
'throw' => false,
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Services\Billing;
|
||||||
|
|
||||||
use App\Jobs\ArchiveInvoice;
|
use App\Jobs\ArchiveInvoice;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
|
use App\Models\ExportTarget;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
use App\Models\InvoiceSeries;
|
use App\Models\InvoiceSeries;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
|
|
@ -110,11 +111,15 @@ final class IssueInvoice
|
||||||
// the transaction lands, and it would then look for an invoice that
|
// the transaction lands, and it would then look for an invoice that
|
||||||
// does not exist yet.
|
// does not exist yet.
|
||||||
//
|
//
|
||||||
// Dispatched rather than done here because the archive is a network
|
// One job per destination. The reason for having a second is that the
|
||||||
// mount: it can block for as long as its timeout allows, and an invoice
|
// first can fail, and a single job writing to both would retry the one
|
||||||
// must not fail to be issued because a NAS in an office is rebooting.
|
// that worked every time the other did not.
|
||||||
if (InvoiceArchive::enabled()) {
|
//
|
||||||
ArchiveInvoice::dispatch($invoice);
|
// 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;
|
return $invoice;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As many places to copy an invoice to as somebody wants, each with its own
|
||||||
|
* record of what arrived.
|
||||||
|
*
|
||||||
|
* The first version had one path and one `exported_at` on the invoice. That
|
||||||
|
* cannot 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 having more than one is precisely that any single one can fail.
|
||||||
|
*
|
||||||
|
* So: a row per destination, and a row per (invoice, destination) pair. An
|
||||||
|
* invoice is not "exported"; it is on this target and not on that one, and the
|
||||||
|
* sweep can tell the difference.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('export_targets', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -55,6 +55,33 @@ return [
|
||||||
'series_floor' => 'Kann nur erhöht werden. Eine niedrigere Nummer wäre schon vergeben — mindestens :n.',
|
'series_floor' => 'Kann nur erhöht werden. Eine niedrigere Nummer wäre schon vergeben — mindestens :n.',
|
||||||
'cancel' => 'Abbrechen',
|
'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_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_sub' => 'Jeder Kreis hat sein eigenes Kürzel und seinen eigenen lückenlosen Zähler. Eine Gutschrift ist keine Rechnung mit Minus davor.',
|
||||||
'series' => [
|
'series' => [
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,33 @@ return [
|
||||||
'series_floor' => 'Can only be raised. A lower number has already been issued — at least :n.',
|
'series_floor' => 'Can only be raised. A lower number has already been issued — at least :n.',
|
||||||
'cancel' => 'Cancel',
|
'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_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_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' => [
|
'series' => [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<div class="rounded-lg bg-surface p-6">
|
||||||
|
<h3 class="text-base font-semibold text-ink">{{ $uuid === '' ? __('finance.targets_add') : __('finance.targets_edit') }}</h3>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<x-ui.input name="name" wire:model="name" :label="__('finance.target.name')" placeholder="Büro-NAS" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-body" for="driver">{{ __('finance.target.driver') }}</label>
|
||||||
|
<select id="driver" wire:model.live="driver"
|
||||||
|
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
|
||||||
|
<option value="local">{{ __('finance.target.driver_local') }}</option>
|
||||||
|
<option value="sftp">{{ __('finance.target.driver_sftp') }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-muted">{{ __('finance.target.driver_hint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-ui.input name="path" wire:model="path" :label="__('finance.target.path')"
|
||||||
|
:hint="$driver === 'local' ? __('finance.target.path_local_hint') : __('finance.target.path_sftp_hint')"
|
||||||
|
:placeholder="$driver === 'local' ? '/mnt/rechnungen' : '/rechnungen'" />
|
||||||
|
|
||||||
|
@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="uXXXXXX.your-storagebox.de" /></div>
|
||||||
|
<x-ui.input name="port" wire:model="port" type="number" :label="__('finance.target.port')" />
|
||||||
|
</div>
|
||||||
|
<x-ui.input name="username" wire:model="username" :label="__('finance.target.username')" />
|
||||||
|
<x-ui.input name="password" wire:model="password" type="password" autocomplete="new-password"
|
||||||
|
:label="__('finance.target.password')"
|
||||||
|
:hint="$hasPassword ? __('finance.target.password_stored') : __('finance.target.password_hint')" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-line bg-surface-2 p-4">
|
||||||
|
<x-ui.checkbox name="active" wire:model="active" :label="__('finance.target.active_label')" />
|
||||||
|
<p class="mt-1.5 pl-7 text-xs text-muted">{{ __('finance.target.active_hint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('finance.cancel') }}</x-ui.button>
|
||||||
|
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('finance.save') }}</x-ui.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -82,30 +82,60 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- 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. --}}
|
|
||||||
<div class="border-t border-line pt-5">
|
|
||||||
<x-ui.input name="archivePath" wire:model="archivePath" :label="__('finance.field.archive_path')"
|
|
||||||
:hint="__('finance.hint.archive_path')" placeholder="/mnt/rechnungen" />
|
|
||||||
@if ($archivePath !== '')
|
|
||||||
<p class="mt-2 text-xs {{ $exportFailures > 0 ? 'text-danger' : 'text-muted' }}">
|
|
||||||
@if ($exportFailures > 0)
|
|
||||||
{{ __('finance.archive_failed', ['n' => $exportFailures]) }}
|
|
||||||
@elseif ($unexported > 0)
|
|
||||||
{{ __('finance.archive_pending', ['n' => $unexported]) }}
|
|
||||||
@else
|
|
||||||
{{ __('finance.archive_clear') }}
|
|
||||||
@endif
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end border-t border-line pt-5">
|
<div class="flex justify-end border-t border-line pt-5">
|
||||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">{{ __('finance.save') }}</x-ui.button>
|
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">{{ __('finance.save') }}</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{{-- Where copies go. As many as somebody wants, because the reason for
|
||||||
|
having a second is that the first can fail. --}}
|
||||||
|
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:75ms]">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-line p-6">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="font-semibold text-ink">{{ __('finance.targets_title') }}</h2>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm text-muted">{{ __('finance.targets_sub') }}</p>
|
||||||
|
</div>
|
||||||
|
<x-ui.button variant="secondary"
|
||||||
|
x-on:click="$dispatch('openModal', { component: 'admin.edit-export-target' })">
|
||||||
|
<x-ui.icon name="plus" class="size-4" />{{ __('finance.targets_add') }}
|
||||||
|
</x-ui.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($targets->isEmpty())
|
||||||
|
<p class="p-8 text-center text-sm text-muted">{{ __('finance.targets_empty') }}</p>
|
||||||
|
@else
|
||||||
|
<ul class="divide-y divide-line">
|
||||||
|
@foreach ($targets as $row)
|
||||||
|
<li wire:key="target-{{ $row['model']->uuid }}" class="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="flex flex-wrap items-center gap-2 text-sm font-medium text-body">
|
||||||
|
{{ $row['model']->name }}
|
||||||
|
<span class="rounded-pill border border-line-strong bg-surface-2 px-2 py-0.5 text-xs font-medium text-muted">{{ strtoupper($row['model']->driver) }}</span>
|
||||||
|
@unless ($row['model']->active)
|
||||||
|
<span class="rounded-pill border border-line-strong bg-surface-2 px-2 py-0.5 text-xs font-medium text-muted">{{ __('finance.targets_inactive') }}</span>
|
||||||
|
@endunless
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 font-mono text-xs text-muted">{{ $row['model']->describe() }}</p>
|
||||||
|
<p class="mt-1 text-xs {{ $row['failed'] > 0 ? 'text-danger' : 'text-muted' }}">
|
||||||
|
@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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<x-ui.button variant="secondary"
|
||||||
|
x-on:click="$dispatch('openModal', { component: 'admin.edit-export-target', arguments: { uuid: '{{ $row['model']->uuid }}' } })">
|
||||||
|
{{ __('finance.series.edit') }}
|
||||||
|
</x-ui.button>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- The Rechnungskreise. Each has its own prefix and its own gapless
|
{{-- The Rechnungskreise. Each has its own prefix and its own gapless
|
||||||
counter — a credit note is not an invoice with a minus sign. --}}
|
counter — a credit note is not an invoice with a minus sign. --}}
|
||||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:90ms]">
|
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:90ms]">
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ use App\Jobs\ArchiveInvoice;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
|
use App\Models\ExportTarget;
|
||||||
|
use App\Models\InvoiceExport;
|
||||||
use App\Services\Billing\InvoiceArchive;
|
use App\Services\Billing\InvoiceArchive;
|
||||||
use App\Services\Billing\IssueInvoice;
|
use App\Services\Billing\IssueInvoice;
|
||||||
use App\Support\CompanyProfile;
|
use App\Support\CompanyProfile;
|
||||||
use App\Support\Settings;
|
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -26,7 +27,10 @@ beforeEach(function () {
|
||||||
|
|
||||||
$this->archive = sys_get_temp_dir().'/clupilot-archive-'.bin2hex(random_bytes(6));
|
$this->archive = sys_get_temp_dir().'/clupilot-archive-'.bin2hex(random_bytes(6));
|
||||||
mkdir($this->archive, 0o775, true);
|
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 () {
|
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();
|
Queue::fake();
|
||||||
|
|
||||||
|
ExportTarget::create([
|
||||||
|
'name' => 'Storage Box', 'driver' => ExportTarget::LOCAL,
|
||||||
|
'path' => $this->archive, 'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
anInvoice();
|
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();
|
Queue::fake();
|
||||||
Settings::set(InvoiceArchive::PATH_KEY, '');
|
$this->target->update(['active' => false]);
|
||||||
|
|
||||||
anInvoice();
|
anInvoice();
|
||||||
|
|
||||||
|
|
@ -66,13 +78,13 @@ it('writes the invoice under its number, grouped by year', function () {
|
||||||
// twice.
|
// twice.
|
||||||
$invoice = anInvoice();
|
$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';
|
$expected = $this->archive.'/'.now()->format('Y').'/'.$invoice->number.'.pdf';
|
||||||
|
|
||||||
expect(is_file($expected))->toBeTrue()
|
expect(is_file($expected))->toBeTrue()
|
||||||
->and(substr(file_get_contents($expected), 0, 5))->toBe('%PDF-')
|
->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 () {
|
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.
|
// it for a finished invoice.
|
||||||
$invoice = anInvoice();
|
$invoice = anInvoice();
|
||||||
|
|
||||||
(new ArchiveInvoice($invoice))->handle(app(InvoiceArchive::class));
|
(new ArchiveInvoice($invoice, $this->target))->handle(app(InvoiceArchive::class));
|
||||||
|
|
||||||
$year = $this->archive.'/'.now()->format('Y');
|
$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.
|
// A retry after a timeout that actually succeeded must not do it again.
|
||||||
$invoice = anInvoice();
|
$invoice = anInvoice();
|
||||||
|
|
||||||
(new ArchiveInvoice($invoice))->handle(app(InvoiceArchive::class));
|
(new ArchiveInvoice($invoice, $this->target))->handle(app(InvoiceArchive::class));
|
||||||
$first = $invoice->refresh()->exported_at;
|
$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 () {
|
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();
|
Queue::fake();
|
||||||
|
|
||||||
$invoice = anInvoice();
|
$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);
|
->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 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 () {
|
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.
|
// failure has to be somewhere a person will pass.
|
||||||
$invoice = anInvoice();
|
$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 () {
|
it('sweeps up whatever the queue gave up on', function () {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
$invoice = anInvoice();
|
$invoice = anInvoice();
|
||||||
Queue::assertPushed(ArchiveInvoice::class);
|
Queue::assertPushed(ArchiveInvoice::class, 1);
|
||||||
|
|
||||||
// The office connection was down; nothing reached the archive.
|
// The office connection was down; nothing reached the destination.
|
||||||
expect($invoice->exported_at)->toBeNull();
|
expect(InvoiceExport::query()->whereNotNull('exported_at')->count())->toBe(0);
|
||||||
|
|
||||||
$this->artisan('clupilot:archive-invoices')->assertExitCode(0);
|
$this->artisan('clupilot:archive-invoices')->assertExitCode(0);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue