CluPilotCloud/app/Services/Billing/InvoiceArchive.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;
}
}