CluPilotCloud/app/Services/Billing/InvoiceDocument.php

151 lines
5.6 KiB
PHP

<?php
namespace App\Services\Billing;
use TCPDF;
/**
* The page furniture: the logo block at the top and the footer at the bottom.
*
* A subclass because TCPDF draws headers and footers through overridden
* methods, and the footer has to repeat on every page — an invoice that runs to
* two pages must carry the company's register and VAT numbers on both, or the
* second page is not part of a valid invoice.
*
* The body is not built here. It is a Blade template rendered to HTML and
* handed to writeHTML(), because a table of line items expressed as a hundred
* Cell() calls with millimetre coordinates is a table nobody will ever adjust.
*/
final class InvoiceDocument extends TCPDF
{
/** @var array<string, mixed> */
private array $issuer = [];
private string $logoFile = '';
private string $pageLabel = 'Seite';
/**
* What to call the issuer's own VAT number in the footer.
*
* It used to be printed bare, between the register number and the court, and
* a bare `ATU12345678` is only recognisable to somebody who already knows
* what they are looking at. An auditor scanning a document for "UID" found
* the RECIPIENT's, which is labelled, and not ours — so the one number an
* Austrian invoice is legally worthless without read as a reference of some
* kind. The document was valid; it was unreadable.
*/
private string $vatLabel = 'UID';
/** @param array<string, mixed> $issuer */
public function setIssuer(array $issuer, string $logoFile = '', string $pageLabel = 'Seite', string $vatLabel = 'UID'): void
{
$this->issuer = $issuer;
$this->logoFile = $logoFile;
$this->pageLabel = $pageLabel;
$this->vatLabel = $vatLabel;
}
public function Header(): void // phpcs:ignore
{
if ($this->logoFile === '' || ! is_file($this->logoFile)) {
return;
}
// Top right, height-constrained. Width 0 lets TCPDF keep the aspect
// ratio — a logo squeezed to a fixed box is worse than none.
$this->Image($this->logoFile, 140, 12, 0, 18, '', '', 'T', false, 300, 'R');
}
public function Footer(): void // phpcs:ignore
{
$this->SetY(-26);
$this->SetFont('dejavusans', '', 6.5);
$this->SetTextColor(110, 110, 122);
$columns = self::footerColumns($this->issuer, $this->vatLabel);
// A hairline above it, the same one the totals block uses, so the page
// reads as one document rather than two halves.
$this->SetDrawColor(233, 233, 238);
$this->Line(20, $this->GetY() - 3, 190, $this->GetY() - 3);
$width = 42.5;
foreach ($columns as $index => $lines) {
$this->SetXY(20 + ($index * $width), -24);
$this->MultiCell($width - 2, 3, implode("\n", $lines), 0, 'L', false, 0, '', '', true, 0, false, true, 0, 'T');
}
$this->SetXY(150, -30);
$this->SetFont('dejavusans', '', 6.5);
$this->Cell(40, 3, $this->pageLabel.' '.$this->getAliasNumPage().'/'.$this->getAliasNbPages(), 0, 0, 'R');
}
/**
* What the four footer columns SAY, apart from where they are drawn.
*
* Separated from Footer() because the two are different kinds of decision and
* only one of them can be read: what belongs on the bottom of an invoice is a
* legal question with a right answer, while millimetre coordinates are a
* layout. Inside a TCPDF method the answer is only observable by rendering a
* PDF and un-subsetting its fonts, which is why the VAT number went unlabelled
* for months without anything noticing.
*
* Empty entries are dropped rather than printed as blank lines: a company with
* no register number should not have a gap where one would be.
*
* @param array<string, mixed> $issuer
* @return array<int, array<int, string>>
*/
public static function footerColumns(array $issuer, string $vatLabel = 'UID'): array
{
$columns = [
[
$issuer['name'] ?? '',
$issuer['address'] ?? '',
trim(($issuer['postcode'] ?? '').' '.($issuer['city'] ?? '')),
],
[
$issuer['phone'] ?? '',
$issuer['email'] ?? '',
$issuer['website'] ?? '',
],
[
$issuer['register_number'] ?? '',
// Labelled, unlike its neighbours, because this is the one the
// reader is looking FOR — and an unlabelled ATU number between a
// register number and a court reads as a reference of some kind.
self::labelled($vatLabel, $issuer['vat_id'] ?? ''),
$issuer['register_court'] ?? '',
],
[
$issuer['bank_name'] ?? '',
$issuer['iban'] ?? '',
$issuer['bic'] ?? '',
],
];
return array_map(
fn (array $lines) => array_values(array_filter(
array_map(fn ($line) => trim((string) $line), $lines),
fn (string $line) => $line !== '',
)),
$columns,
);
}
/**
* "UID: ATU12345678", or nothing at all when there is no number.
*
* An empty string rather than a label on its own, so a company with no VAT
* number recorded keeps the blank line it had before this label existed.
*/
private static function labelled(string $label, mixed $value): string
{
$value = trim((string) $value);
return $value === '' ? '' : $label.': '.$value;
}
}