Files
2026-09-15 10:35:56 +08:00

390 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace Esun\VatInvoice\Model\Pdf;
use Magento\Framework\App\Area;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\Filesystem;
use Magento\Framework\Locale\ResolverInterface;
use Magento\Framework\Translate;
use Magento\Sales\Api\Data\InvoiceInterface;
use Magento\Directory\Model\CountryFactory;
use Magento\Directory\Model\RegionFactory;
use Magento\Store\Model\ScopeInterface;
use Magento\Store\Model\StoreManagerInterface;
class VatInvoice
{
private const CONFIG_LOCALE = 'sales/esun_vat_invoice/locale';
private const CONFIG_TITLE = 'sales/esun_vat_invoice/title';
private const CONFIG_COMPANY_NAME = 'sales/esun_vat_invoice/company_name';
private const ACCENT = [0.0902, 0.6627, 0.3412];
private const DARK = [0.10, 0.10, 0.10];
private const GREY = [0.38, 0.38, 0.38];
private const PAPER = [0.985, 0.978, 0.965];
private \Zend_Pdf_Resource_Font $regular;
private \Zend_Pdf_Resource_Font $bold;
public function __construct(
private readonly ScopeConfigInterface $scopeConfig,
private readonly StoreManagerInterface $storeManager,
private readonly ResolverInterface $localeResolver,
private readonly Translate $translate,
private readonly CountryFactory $countryFactory,
private readonly RegionFactory $regionFactory,
Filesystem $filesystem
) {
$root = $filesystem->getDirectoryRead(DirectoryList::ROOT);
$this->regular = \Zend_Pdf_Font::fontWithPath($root->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerif.ttf'));
$this->bold = \Zend_Pdf_Font::fontWithPath($root->getAbsolutePath('lib/internal/GnuFreeFont/FreeSerifBold.ttf'));
}
public function getPdf(InvoiceInterface $invoice): \Zend_Pdf
{
$order = $invoice->getOrder();
$store = $this->storeManager->getStore((int)$order->getStoreId());
$websiteId = (int)$store->getWebsiteId();
$locale = (string)$this->scopeConfig->getValue(
self::CONFIG_LOCALE,
ScopeInterface::SCOPE_WEBSITE,
$websiteId
);
if ($locale === '') {
$locale = (string)$this->scopeConfig->getValue(
'general/locale/code',
ScopeInterface::SCOPE_STORE,
(int)$store->getId()
);
}
$previousLocale = $this->translate->getLocale();
$this->localeResolver->setLocale($locale);
$this->translate->setLocale($locale)->loadData(Area::AREA_FRONTEND, true);
try {
return $this->render($invoice, $locale);
} finally {
$this->localeResolver->setLocale($previousLocale);
$this->translate->setLocale($previousLocale)->loadData(Area::AREA_ADMINHTML, true);
}
}
private function render(InvoiceInterface $invoice, string $locale): \Zend_Pdf
{
$pdf = new \Zend_Pdf();
$page = $this->newPage($pdf);
$order = $invoice->getOrder();
$storeId = (int)$order->getStoreId();
$websiteId = (int)$this->storeManager->getStore($storeId)->getWebsiteId();
$companyName = trim((string)$this->scopeConfig->getValue(
self::CONFIG_COMPANY_NAME,
ScopeInterface::SCOPE_WEBSITE,
$websiteId
));
if ($companyName === '') {
$companyName = $this->config('general/store_information/name', $storeId)
?: (string)$this->storeManager->getStore($storeId)->getFrontendName();
}
$invoiceTitle = trim((string)$this->scopeConfig->getValue(
self::CONFIG_TITLE,
ScopeInterface::SCOPE_WEBSITE,
$websiteId
)) ?: $companyName;
$this->text($page, $this->truncate($invoiceTitle, 38), 298, 785, 21, true, self::DARK, 'center');
$this->text($page, (string)__('VAT INVOICE'), 540, 810, 11, true, self::GREY, 'right');
$this->line($page, 46, 764, 549, 764, self::DARK, 1.8);
$this->labelValue($page, (string)__('INVOICE NO.'), '#' . $invoice->getIncrementId(), 46, 730);
$this->labelValue(
$page,
(string)__('INVOICE DATE'),
$this->formatDate((string)$invoice->getCreatedAt(), $locale),
316,
730
);
$this->text($page, (string)__('Order #%1', $order->getIncrementId()), 46, 691, 10, true, self::ACCENT);
$billing = $order->getBillingAddress();
$shippingAddress = $order->getShippingAddress();
$customerAddress = $billing && $billing->getId() ? $billing : $shippingAddress;
$customerName = $customerAddress ? $customerAddress->getName() : $order->getCustomerName();
$this->labelValue($page, (string)__('CUSTOMER NAME'), (string)$customerName, 46, 662);
$this->labelValue($page, (string)__('COMPANY NAME'), $this->truncate($companyName, 32), 316, 662);
$this->sectionTitle($page, (string)__('CUSTOMER ADDRESS DETAILS'), 46, 605, 232);
$this->sectionTitle($page, (string)__('COMPANY ADDRESS DETAILS'), 316, 605, 233);
$this->drawLines($page, $this->customerAddress($customerAddress, $locale), 46, 575, 232);
$companyLines = $this->companyAddress($storeId, $locale);
$vat = $this->config('general/store_information/merchant_vat_number', $storeId);
if ($vat !== '') {
$companyLines[] = (string)__('Company VAT Number: %1', $vat);
}
$this->drawLines($page, $companyLines, 316, 575, 233);
$this->line($page, 46, 477, 549, 477, [0.82, 0.80, 0.76], 0.45);
$paymentTitle = '';
try {
$paymentTitle = (string)$order->getPayment()->getMethodInstance()->getTitle();
} catch (\Throwable $exception) {
$paymentTitle = (string)$order->getPayment()->getMethod();
}
$this->labelValue($page, (string)__('PAYMENT METHOD'), $paymentTitle, 46, 450);
$shipping = $order->getIsVirtual() ? (string)__('N/A') : (string)$order->getShippingDescription();
$this->labelValue($page, (string)__('SHIPPING METHOD'), $shipping, 316, 450);
$y = 390;
$this->tableHeader($page, $y);
$y -= 38;
foreach ($invoice->getAllItems() as $item) {
if ($item->getOrderItem() && $item->getOrderItem()->getParentItem()) {
continue;
}
if ($y < 145) {
$page = $this->newPage($pdf);
$this->text($page, (string)__('VAT INVOICE #%1', $invoice->getIncrementId()), 46, 805, 13, true);
$y = 770;
$this->tableHeader($page, $y);
$y -= 38;
}
$this->itemRow($page, $item, $order, $y);
$y -= 56;
}
if ((float)$invoice->getShippingAmount() !== 0.0) {
$this->shippingRow($page, $invoice, $order, $y);
$y -= 56;
}
if ($y < 135) {
$page = $this->newPage($pdf);
$y = 760;
}
$this->totals($page, $invoice, $order, $y - 8);
$this->footer($page, $storeId);
return $pdf;
}
private function newPage(\Zend_Pdf $pdf): \Zend_Pdf_Page
{
$page = $pdf->newPage(\Zend_Pdf_Page::SIZE_A4);
$pdf->pages[] = $page;
$page->setFillColor($this->color(self::PAPER));
$page->drawRectangle(0, 0, 595, 842);
return $page;
}
private function tableHeader(\Zend_Pdf_Page $page, float $y): void
{
$page->setFillColor($this->color(self::DARK));
$page->drawRectangle(46, $y, 549, $y - 28);
$headers = [
[(string)__('ORDER I.D.'), 57], [(string)__('DESCRIPTION'), 137],
[(string)__('QTY'), 320], [(string)__('ITEM PRICE'), 400],
[(string)__('VAT RATE'), 466], [(string)__('TOTAL PRICE'), 537],
];
foreach ($headers as [$label, $x]) {
$this->text($page, $label, $x, $y - 18, 8, true, [1, 1, 1], $x > 300 ? 'right' : 'left');
}
}
private function itemRow($page, $item, $order, float $y): void
{
$this->text($page, '#' . $order->getIncrementId(), 57, $y - 13, 9, false, self::GREY);
$name = $this->truncate(html_entity_decode((string)$item->getName(), ENT_QUOTES | ENT_HTML5), 35);
$this->text($page, $name, 137, $y - 12, 9, true);
$this->text($page, (string)__('SKU: %1', $item->getSku()), 137, $y - 28, 8, false, self::GREY);
$this->text($page, $this->qty((float)$item->getQty()), 320, $y - 13, 9, false, self::DARK, 'right');
$qty = max(1.0, (float)$item->getQty());
$this->text($page, $this->money($order, (float)$item->getRowTotal() / $qty), 400, $y - 13, 9, false, self::DARK, 'right');
$this->text($page, $this->percentage((float)$item->getTaxPercent()), 466, $y - 13, 9, false, self::DARK, 'right');
$total = (float)$item->getRowTotal() + (float)$item->getTaxAmount() - (float)$item->getDiscountAmount();
$this->text($page, $this->money($order, $total), 537, $y - 13, 9, false, self::DARK, 'right');
$this->line($page, 46, $y - 43, 549, $y - 43, [0.86, 0.84, 0.80], 0.35);
}
private function shippingRow($page, $invoice, $order, float $y): void
{
$this->text($page, (string)__('Shipping'), 137, $y - 12, 9, true);
$this->text($page, '1', 320, $y - 13, 9, false, self::DARK, 'right');
$this->text($page, $this->money($order, (float)$invoice->getShippingAmount()), 400, $y - 13, 9, false, self::DARK, 'right');
$shippingAmount = (float)$invoice->getShippingAmount();
$shippingTaxRate = $shippingAmount > 0
? (float)$invoice->getShippingTaxAmount() / $shippingAmount * 100
: 0.0;
$this->text($page, $this->percentage($shippingTaxRate), 466, $y - 13, 9, false, self::DARK, 'right');
$total = (float)$invoice->getShippingAmount() + (float)$invoice->getShippingTaxAmount();
$this->text($page, $this->money($order, $total), 537, $y - 13, 9, false, self::DARK, 'right');
$this->line($page, 46, $y - 43, 549, $y - 43, [0.86, 0.84, 0.80], 0.35);
}
private function totals($page, $invoice, $order, float $y): void
{
$x = 330;
$net = (float)$invoice->getGrandTotal() - (float)$invoice->getTaxAmount();
$this->text($page, (string)__('Total Excl. VAT'), $x, $y, 10, false, self::GREY);
$this->text($page, $this->money($order, $net), 547, $y, 10, false, self::DARK, 'right');
$this->text($page, (string)__('VAT Amount'), $x, $y - 26, 10, false, self::GREY);
$this->text($page, $this->money($order, (float)$invoice->getTaxAmount()), 547, $y - 26, 10, false, self::DARK, 'right');
$this->line($page, $x, $y - 36, 549, $y - 36, self::DARK, 0.8);
$this->text($page, (string)__('Total Price (incl. VAT)'), $x, $y - 60, 12, true, self::GREY);
$this->text($page, $this->money($order, (float)$invoice->getGrandTotal()), 547, $y - 60, 14, true, self::ACCENT, 'right');
}
private function footer($page, int $storeId): void
{
$this->line($page, 46, 60, 549, 60, [0.82, 0.80, 0.76], 0.4);
$this->text($page, (string)__('Thank you for your order.'), 46, 38, 9, true);
$email = $this->config('trans_email/ident_support/email', $storeId);
$phone = $this->config('general/store_information/phone', $storeId);
$this->text($page, trim($email . ($phone ? ' | ' . $phone : '')), 549, 38, 8, false, self::GREY, 'right');
}
private function sectionTitle($page, string $title, float $x, float $y, float $width): void
{
$this->text($page, $title, $x, $y, 9, true, self::ACCENT);
$this->line($page, $x, $y - 10, $x + $width, $y - 10, [0.82, 0.80, 0.76], 0.4);
}
private function labelValue($page, string $label, string $value, float $x, float $y): void
{
$this->text($page, $label . ':', $x, $y, 9, false, self::GREY);
$this->text($page, $value, $x, $y - 16, 11, true, self::DARK);
}
private function drawLines($page, array $lines, float $x, float $y, int $width): void
{
foreach (array_slice($lines, 0, 6) as $line) {
$this->text($page, $this->truncate((string)$line, (int)($width / 5.2)), $x, $y, 9);
$y -= 18;
}
}
private function customerAddress($address, string $locale): array
{
if (!$address) {
return [];
}
$lines = array_values(array_filter([
$address->getCompany(),
...((array)$address->getStreet()),
trim((string)$address->getCity() . ' ' . (string)$address->getPostcode()),
$this->regionName((string)$address->getRegionId()) ?: $address->getRegion(),
$this->countryName((string)$address->getCountryId(), $locale),
]));
return array_map('strval', $lines);
}
private function companyAddress(int $storeId, string $locale): array
{
$regionId = $this->config('general/store_information/region_id', $storeId);
$countryId = $this->config('general/store_information/country_id', $storeId);
return array_values(array_filter([
$this->config('general/store_information/street_line1', $storeId),
$this->config('general/store_information/street_line2', $storeId),
trim($this->config('general/store_information/city', $storeId) . ' ' . $this->config('general/store_information/postcode', $storeId)),
$this->regionName($regionId),
$this->countryName($countryId, $locale),
]));
}
private function countryName(string $countryId, string $locale): string
{
if ($countryId === '') {
return '';
}
$country = $this->countryFactory->create()->loadByCode($countryId);
return $country->getId() ? (string)$country->getName($locale) : $countryId;
}
private function regionName(string $regionId): string
{
if ($regionId === '') {
return '';
}
$region = $this->regionFactory->create()->load($regionId);
return $region->getId() ? (string)$region->getName() : $regionId;
}
private function config(string $path, int $storeId): string
{
return trim((string)$this->scopeConfig->getValue($path, ScopeInterface::SCOPE_STORE, $storeId));
}
private function money($order, float $amount): string
{
return (string)$order->getOrderCurrency()->format($amount, [], false);
}
private function qty(float $qty): string
{
return rtrim(rtrim(number_format($qty, 4, '.', ''), '0'), '.');
}
private function percentage(float $rate): string
{
return rtrim(rtrim(number_format($rate, 4, '.', ''), '0'), '.') . '%';
}
private function formatDate(string $date, string $locale): string
{
$formatter = new \IntlDateFormatter($locale, \IntlDateFormatter::MEDIUM, \IntlDateFormatter::NONE);
return (string)$formatter->format(new \DateTime($date));
}
private function truncate(string $value, int $length): string
{
return mb_strlen($value) > $length ? mb_substr($value, 0, $length - 1) . '…' : $value;
}
private function text(
$page,
string $text,
float $x,
float $y,
float $size = 9,
bool $bold = false,
array $color = self::DARK,
string $align = 'left'
): void {
$font = $bold ? $this->bold : $this->regular;
$page->setFont($font, $size)->setFillColor($this->color($color));
if ($align !== 'left') {
$width = $this->textWidth($text, $font, $size);
$x -= $align === 'center' ? $width / 2 : $width;
}
$page->drawText($text, $x, $y, 'UTF-8');
}
private function textWidth(string $text, $font, float $size): float
{
$encoded = @iconv('UTF-8', 'UTF-16BE//IGNORE', $text) ?: '';
$chars = [];
for ($i = 0, $length = strlen($encoded); $i < $length; $i += 2) {
$chars[] = (ord($encoded[$i]) << 8) | ord($encoded[$i + 1]);
}
if (!$chars) {
return 0.0;
}
return array_sum($font->widthsForGlyphs($font->glyphNumbersForCharacters($chars)))
/ $font->getUnitsPerEm() * $size;
}
private function line($page, float $x1, float $y1, float $x2, float $y2, array $color, float $width): void
{
$page->setLineColor($this->color($color))->setLineWidth($width)->drawLine($x1, $y1, $x2, $y2);
}
private function color(array $rgb): \Zend_Pdf_Color_Rgb
{
return new \Zend_Pdf_Color_Rgb($rgb[0], $rgb[1], $rgb[2]);
}
}