Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
96 / 96
100.00% covered (success)
100.00%
18 / 18
CRAP
100.00% covered (success)
100.00%
1 / 1
TemplateTwigExtension
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
18 / 18
35
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFunctions
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getFilters
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 companyLogo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 relatedTable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderComponent
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
12
 qrCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 barcode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 bankTransferQr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verificationBadge
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 chartSparkline
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translate
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 pageBreak
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 statusBadge
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatCurrency
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 formatDate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 formatDateTime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fieldLabel
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Template\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use DateTimeImmutable;
12use Throwable;
13use Twig\Extension\AbstractExtension;
14use Twig\TwigFilter;
15use Twig\TwigFunction;
16
17/**
18 * Custom Twig Functions and Filters for Email & PDF Templates.
19 *
20 * Provides specialized components: logos, dynamic line item tables,
21 * QR codes, barcodes, EPC/ZBP bank transfer QR, digital verification badges,
22 * lightweight SVG sparkline charts, page breaks, status badges, translations,
23 * and currency/date formatting.
24 *
25 * @package App\Core\Template\Application\Service
26 */
27final class TemplateTwigExtension extends AbstractExtension
28{
29    /**
30     * Dictionary of multi-lingual document phrases for t() function.
31     *
32     * @var array<string, array<string, string>>
33     */
34    private const array TRANSLATION_MAP = [
35        'invoice'        => ['pl' => 'Faktura VAT', 'en' => 'Tax Invoice', 'de' => 'Rechnung'],
36        'proforma'       => ['pl' => 'Faktura Proforma', 'en' => 'Proforma Invoice', 'de' => 'Proforma-Rechnung'],
37        'total_net'      => ['pl' => 'Razem netto', 'en' => 'Total Net', 'de' => 'Gesamt Netto'],
38        'tax_vat'        => ['pl' => 'Podatek VAT', 'en' => 'VAT Amount', 'de' => 'MwSt.'],
39        'total_gross'    => ['pl' => 'Razem brutto', 'en' => 'Total Gross', 'de' => 'Gesamt Brutto'],
40        'due_date'       => ['pl' => 'Termin płatności', 'en' => 'Payment Due Date', 'de' => 'Fälligkeitsdatum'],
41        'issue_date'     => ['pl' => 'Data wystawienia', 'en' => 'Date of Issue', 'de' => 'Ausstellungsdatum'],
42        'bank_account'   => ['pl' => 'Konto bankowe', 'en' => 'Bank Account', 'de' => 'Bankverbindung'],
43        'seller'         => ['pl' => 'Sprzedawca', 'en' => 'Seller', 'de' => 'Verkäufer'],
44        'buyer'          => ['pl' => 'Nabywca', 'en' => 'Buyer', 'de' => 'Käufer'],
45        'nip'            => ['pl' => 'NIP', 'en' => 'Tax ID', 'de' => 'USt-IdNr.'],
46        'document_no'    => ['pl' => 'Numer dokumentu', 'en' => 'Document No.', 'de' => 'Dokumentennummer'],
47        'page'           => ['pl' => 'Strona', 'en' => 'Page', 'de' => 'Seite'],
48        'verified_doc'   => [
49            'pl' => 'Dokument Zweryfikowany Cyfrowo',
50            'en' => 'Digitally Verified Document',
51            'de' => 'Digital Verifiziertes Dokument',
52        ],
53    ];
54
55    private TemplateVisualComponentRenderer $renderer;
56
57    /**
58     * TemplateTwigExtension constructor.
59     *
60     * @param TemplateVisualComponentRenderer|null $renderer Optional visual renderer.
61     */
62    public function __construct(?TemplateVisualComponentRenderer $renderer = null)
63    {
64        $this->renderer = $renderer ?? new TemplateVisualComponentRenderer([$this, 'formatCurrency']);
65    }
66
67    /**
68     * Returns custom functions available in templates.
69     *
70     * @return array<int, TwigFunction> List of Twig functions.
71     */
72    public function getFunctions(): array
73    {
74        return [
75            new TwigFunction('company_logo', [$this, 'companyLogo'], ['is_safe' => ['html']]),
76            new TwigFunction('related_table', [$this, 'relatedTable'], ['is_safe' => ['html']]),
77            new TwigFunction('render_component', [$this, 'renderComponent'], ['is_safe' => ['html']]),
78            new TwigFunction('qr_code', [$this, 'qrCode'], ['is_safe' => ['html']]),
79            new TwigFunction('barcode', [$this, 'barcode'], ['is_safe' => ['html']]),
80            new TwigFunction('bank_transfer_qr', [$this, 'bankTransferQr'], ['is_safe' => ['html']]),
81            new TwigFunction('verification_badge', [$this, 'verificationBadge'], ['is_safe' => ['html']]),
82            new TwigFunction('chart_sparkline', [$this, 'chartSparkline'], ['is_safe' => ['html']]),
83            new TwigFunction('page_break', [$this, 'pageBreak'], ['is_safe' => ['html']]),
84            new TwigFunction('status_badge', [$this, 'statusBadge'], ['is_safe' => ['html']]),
85            new TwigFunction('field_label', [$this, 'fieldLabel'], ['is_safe' => ['html']]),
86            new TwigFunction('t', [$this, 'translate'], ['is_safe' => ['html']]),
87        ];
88    }
89
90    /**
91     * Returns custom filters available in templates.
92     *
93     * @return array<int, TwigFilter> List of Twig filters.
94     */
95    public function getFilters(): array
96    {
97        return [
98            new TwigFilter('format_currency', [$this, 'formatCurrency']),
99            new TwigFilter('format_date', [$this, 'formatDate']),
100            new TwigFilter('format_datetime', [$this, 'formatDateTime']),
101            new TwigFilter('t', [$this, 'translate']),
102        ];
103    }
104
105    /**
106     * @see TemplateVisualComponentRenderer::companyLogo()
107     */
108    public function companyLogo(int $maxWidth = 180, int $maxHeight = 60, string $alt = 'Company Logo'): string
109    {
110        return $this->renderer->companyLogo($maxWidth, $maxHeight, $alt);
111    }
112
113    /**
114     * @see TemplateVisualComponentRenderer::relatedTable()
115     */
116    public function relatedTable(string $relationName, array $columns = [], array $options = []): string
117    {
118        return $this->renderer->relatedTable($relationName, $columns, $options);
119    }
120
121    /**
122     * Unified visual component rendering gateway for Twig templates.
123     *
124     * @param string               $name    Component name (e.g. 'related_table', 'company_logo').
125     * @param array<string, mixed> $options Component options.
126     * @return string Rendered component HTML.
127     */
128    public function renderComponent(string $name, array $options = []): string
129    {
130        if ($name === 'related_table') {
131            $relationName = (string)($options['relation'] ?? 'items');
132            $columns = (array)($options['columns'] ?? [
133                'name', 'quantity', 'unit_price', 'net_amount', 'tax_percent', 'tax_amount', 'gross_amount',
134            ]);
135            return $this->relatedTable($relationName, $columns, $options);
136        }
137
138        return match ($name) {
139            'company_logo'       => $this->companyLogo(
140                (int)($options['max_width'] ?? 180),
141                (int)($options['max_height'] ?? 60),
142                (string)($options['alt'] ?? 'Company Logo')
143            ),
144            'qr_code'            => $this->qrCode(
145                (string)($options['data'] ?? ''),
146                (int)($options['size'] ?? 120)
147            ),
148            'barcode'            => $this->barcode(
149                (string)($options['data'] ?? ''),
150                (string)($options['type'] ?? 'C128')
151            ),
152            'bank_transfer_qr'   => $this->bankTransferQr(
153                (string)($options['account'] ?? ''),
154                (string)($options['recipient'] ?? ''),
155                $options['amount'] ?? 0,
156                (string)($options['title'] ?? ''),
157                (string)($options['currency'] ?? 'PLN')
158            ),
159            'verification_badge' => $this->verificationBadge(
160                (string)($options['hash'] ?? ''),
161                isset($options['base_url']) ? (string)$options['base_url'] : null
162            ),
163            'chart_sparkline'    => $this->chartSparkline(
164                (array)($options['values'] ?? []),
165                (string)($options['type'] ?? 'bar'),
166                (int)($options['width'] ?? 120),
167                (int)($options['height'] ?? 32),
168                (string)($options['color'] ?? '#2563eb')
169            ),
170            'page_break'         => $this->pageBreak(),
171            'status_badge'       => $this->statusBadge(
172                (string)($options['status'] ?? ''),
173                (string)($options['label'] ?? '')
174            ),
175            default              => '',
176        };
177    }
178
179    /**
180     * @see TemplateVisualComponentRenderer::qrCode()
181     */
182    public function qrCode(string $data, int $size = 120): string
183    {
184        return $this->renderer->qrCode($data, $size);
185    }
186
187    /**
188     * @see TemplateVisualComponentRenderer::barcode()
189     */
190    public function barcode(string $code, string $type = 'C128'): string
191    {
192        return $this->renderer->barcode($code, $type);
193    }
194
195    /**
196     * @see TemplateVisualComponentRenderer::bankTransferQr()
197     */
198    public function bankTransferQr(
199        string $account,
200        string $recipient,
201        float|int|string $amount,
202        string $title,
203        string $currency = 'PLN'
204    ): string {
205        return $this->renderer->bankTransferQr($account, $recipient, $amount, $title, $currency);
206    }
207
208    /**
209     * @see TemplateVisualComponentRenderer::verificationBadge()
210     */
211    public function verificationBadge(string $hash, ?string $baseUrl = null): string
212    {
213        return $this->renderer->verificationBadge($hash, $baseUrl);
214    }
215
216    /**
217     * @see TemplateVisualComponentRenderer::chartSparkline()
218     */
219    public function chartSparkline(
220        array $values,
221        string $type = 'bar',
222        int $width = 120,
223        int $height = 32,
224        string $color = '#2563eb'
225    ): string {
226        return $this->renderer->chartSparkline($values, $type, $width, $height, $color);
227    }
228
229    /**
230     * Translates document phrase key into requested language with parameter binding.
231     *
232     * @param string               $key          Translation phrase key.
233     * @param string               $locale       Target 2-letter language code (pl, en, de).
234     * @param array<string, mixed> $replacements Key-value replacement parameters.
235     * @return string Localized translated string.
236     */
237    public function translate(string $key, string $locale = 'pl', array $replacements = []): string
238    {
239        $lang = strtolower(trim($locale));
240        $text = self::TRANSLATION_MAP[$key][$lang]
241            ?? self::TRANSLATION_MAP[$key]['en']
242            ?? self::TRANSLATION_MAP[$key]['pl']
243            ?? $key;
244
245        foreach ($replacements as $param => $value) {
246            $text = str_replace(':' . $param, (string)$value, $text);
247            $text = str_replace('{' . $param . '}', (string)$value, $text);
248        }
249
250        return $text;
251    }
252
253    /**
254     * @see TemplateVisualComponentRenderer::pageBreak()
255     */
256    public function pageBreak(): string
257    {
258        return $this->renderer->pageBreak();
259    }
260
261    /**
262     * @see TemplateVisualComponentRenderer::statusBadge()
263     */
264    public function statusBadge(string|int|null $status, string $label = ''): string
265    {
266        return $this->renderer->statusBadge($status, $label);
267    }
268
269    /**
270     * Formats decimal/integer numbers into standard currency presentation.
271     *
272     * @param float|int|string|null $amount   Raw currency number.
273     * @param string                $currency Currency code (e.g. PLN, EUR, USD).
274     * @return string Formatted currency string.
275     */
276    public function formatCurrency(float|int|string|null $amount, string $currency = 'PLN'): string
277    {
278        $val = is_numeric($amount) ? (float)$amount : 0.0;
279        return number_format($val, 2, ',', ' ') . ' ' . $currency;
280    }
281
282    /**
283     * Parses and formats datetime string.
284     *
285     * @param string|null $date   Date or datetime string.
286     * @param string      $format Desired output date format.
287     * @return string Formatted date string or empty string.
288     */
289    public function formatDate(?string $date, string $format = 'Y-m-d'): string
290    {
291        if ($date === null || trim($date) === '') {
292            return '';
293        }
294
295        try {
296            $dt = new DateTimeImmutable($date);
297            return $dt->format($format);
298        } catch (Throwable) {
299            return $date;
300        }
301    }
302
303    /**
304     * Parses and formats timestamp into date and time string.
305     *
306     * @param string|null $date   Date or datetime string.
307     * @param string      $format Desired output datetime format.
308     * @return string Formatted datetime string or empty string.
309     */
310    public function formatDateTime(?string $date, string $format = 'Y-m-d H:i'): string
311    {
312        return $this->formatDate($date, $format);
313    }
314
315    /**
316     * Formats and resolves a field label element with translation map and fallback support.
317     *
318     * @param string      $fieldKey Technical field key or custom label.
319     * @param string|null $fallback Optional fallback label if primary translation is absent.
320     * @param string      $lang     Language code (default: 'pl').
321     * @return string Escaped field label string.
322     */
323    public function fieldLabel(string $fieldKey, ?string $fallback = null, string $lang = 'pl'): string
324    {
325        $resolved = self::TRANSLATION_MAP[$fieldKey][$lang]
326            ?? (trim((string)($fallback ?? '')) !== '' ? (string)$fallback : $fieldKey);
327
328        return htmlspecialchars($resolved, ENT_QUOTES, 'UTF-8');
329    }
330}