151 lines
5.6 KiB
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;
|
|
}
|
|
}
|