112 lines
4.4 KiB
PHP
112 lines
4.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Models\Invoice;
|
|
use App\Support\Settings;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* A copy of every invoice on a filesystem outside this machine.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
final class InvoiceArchive
|
|
{
|
|
public const PATH_KEY = 'invoices.export_path';
|
|
|
|
public function __construct(private readonly InvoiceRenderer $renderer) {}
|
|
|
|
/** Where copies go, or empty when archiving is switched off. */
|
|
public static function path(): string
|
|
{
|
|
return trim((string) Settings::get(self::PATH_KEY, ''));
|
|
}
|
|
|
|
public static function enabled(): bool
|
|
{
|
|
return self::path() !== '';
|
|
}
|
|
|
|
/**
|
|
* Write one invoice into the archive.
|
|
*
|
|
* 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.
|
|
*/
|
|
public function store(Invoice $invoice): string
|
|
{
|
|
$root = self::path();
|
|
|
|
if ($root === '') {
|
|
throw new RuntimeException('No archive path is configured.');
|
|
}
|
|
|
|
// 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}");
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|