Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.78% covered (success)
87.78%
79 / 90
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
FiscalQr
87.78% covered (success)
87.78%
79 / 90
33.33% covered (danger)
33.33%
1 / 3
16.47
0.00% covered (danger)
0.00%
0 / 1
 crc8
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
3
 build
83.33% covered (success)
83.33%
30 / 36
0.00% covered (danger)
0.00%
0 / 1
9.37
 identifierDate
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
5.16
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices\ticketbai;
6
7/**
8 * TicketBAI fiscal identifier + regional QR URL (parity with client ticketbai.ts FiscalQr).
9 *
10 * Domain helper: does not touch TICKETDATA.registry. Callers (tbaicalc, TicketFiscal,
11 * swinvoice) persist id/qr after generation.
12 */
13class FiscalQr
14{
15    /** @var array<string, array{sbox: string, prod: string}> */
16    private const ENDPOINTS = [
17        'Bizkaia' => [
18            'sbox' => 'https://batuz.eus/QRTBAI/',
19            'prod' => 'https://batuz.eus/QRTBAI/',
20        ],
21        'Gipuzkoa' => [
22            'sbox' => 'https://tbai.prep.gipuzkoa.eus/qr/',
23            'prod' => 'https://tbai.egoitza.gipuzkoa.eus/qr/',
24        ],
25        'Araba' => [
26            'sbox' => 'https://pruebas-ticketbai.araba.eus/tbai/qrtbai/',
27            'prod' => 'https://ticketbai.araba.eus/tbai/qrtbai',
28        ],
29    ];
30
31    /**
32     * CRC8 (3-digit) matching GenericUtils.CRC8 / client TicketBAI QR helpers.
33     */
34    public static function crc8(?string $str): string
35    {
36        static $table = null;
37        if ($table === null) {
38            $table = [
39                0x00, 0x07, 0x0E, 0x09, 0x1C, 0x1B, 0x12, 0x15,
40                0x38, 0x3F, 0x36, 0x31, 0x24, 0x23, 0x2A, 0x2D,
41                0x70, 0x77, 0x7E, 0x79, 0x6C, 0x6B, 0x62, 0x65,
42                0x48, 0x4F, 0x46, 0x41, 0x54, 0x53, 0x5A, 0x5D,
43                0xE0, 0xE7, 0xEE, 0xE9, 0xFC, 0xFB, 0xF2, 0xF5,
44                0xD8, 0xDF, 0xD6, 0xD1, 0xC4, 0xC3, 0xCA, 0xCD,
45                0x90, 0x97, 0x9E, 0x99, 0x8C, 0x8B, 0x82, 0x85,
46                0xA8, 0xAF, 0xA6, 0xA1, 0xB4, 0xB3, 0xBA, 0xBD,
47                0xC7, 0xC0, 0xC9, 0xCE, 0xDB, 0xDC, 0xD5, 0xD2,
48                0xFF, 0xF8, 0xF1, 0xF6, 0xE3, 0xE4, 0xED, 0xEA,
49                0xB7, 0xB0, 0xB9, 0xBE, 0xAB, 0xAC, 0xA5, 0xA2,
50                0x8F, 0x88, 0x81, 0x86, 0x93, 0x94, 0x9D, 0x9A,
51                0x27, 0x20, 0x29, 0x2E, 0x3B, 0x3C, 0x35, 0x32,
52                0x1F, 0x18, 0x11, 0x16, 0x03, 0x04, 0x0D, 0x0A,
53                0x57, 0x50, 0x59, 0x5E, 0x4B, 0x4C, 0x45, 0x42,
54                0x6F, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7D, 0x7A,
55                0x89, 0x8E, 0x87, 0x80, 0x95, 0x92, 0x9B, 0x9C,
56                0xB1, 0xB6, 0xBF, 0xB8, 0xAD, 0xAA, 0xA3, 0xA4,
57                0xF9, 0xFE, 0xF7, 0xF0, 0xE5, 0xE2, 0xEB, 0xEC,
58                0xC1, 0xC6, 0xCF, 0xC8, 0xDD, 0xDA, 0xD3, 0xD4,
59                0x69, 0x6E, 0x67, 0x60, 0x75, 0x72, 0x7B, 0x7C,
60                0x51, 0x56, 0x5F, 0x58, 0x4D, 0x4A, 0x43, 0x44,
61                0x19, 0x1E, 0x17, 0x10, 0x05, 0x02, 0x0B, 0x0C,
62                0x21, 0x26, 0x2F, 0x28, 0x3D, 0x3A, 0x33, 0x34,
63                0x4E, 0x49, 0x40, 0x47, 0x52, 0x55, 0x5C, 0x5B,
64                0x76, 0x71, 0x78, 0x7F, 0x6A, 0x6D, 0x64, 0x63,
65                0x3E, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2C, 0x2B,
66                0x06, 0x01, 0x08, 0x0F, 0x1A, 0x1D, 0x14, 0x13,
67                0xAE, 0xA9, 0xA0, 0xA7, 0xB2, 0xB5, 0xBC, 0xBB,
68                0x96, 0x91, 0x98, 0x9F, 0x8A, 0x8D, 0x84, 0x83,
69                0xDE, 0xD9, 0xD0, 0xD7, 0xC2, 0xC5, 0xCC, 0xCB,
70                0xE6, 0xE1, 0xE8, 0xEF, 0xFA, 0xFD, 0xF4, 0xF3,
71            ];
72        }
73
74        $crc = 0;
75        $bytes = $str ?? '';
76        $len = strlen($bytes);
77        for ($i = 0; $i < $len; $i++) {
78            $crc = $table[($crc ^ ord($bytes[$i])) & 0xFF];
79        }
80        return str_pad((string) $crc, 3, '0', STR_PAD_LEFT);
81    }
82
83    /**
84     * Builds TBAI identifier + regional QR URL.
85     *
86     * @return array{errorcode: int, id: string|null, qr: string|null}
87     */
88    public static function build(
89        string $region,
90        string $taxId,
91        mixed $invoiceSeries,
92        mixed $invoiceNumber,
93        mixed $invoiceTotal,
94        mixed $issuedAt,
95        string $signatureValue,
96        ?bool $sandbox = null
97    ): array {
98        if (!array_key_exists($region, self::ENDPOINTS)) {
99            return [
100                'errorcode' => \ApplicationError::Unapplicable,
101                'id' => null,
102                'qr' => null,
103            ];
104        }
105
106        $taxId = trim($taxId);
107        $sign = substr(trim($signatureValue), 0, 13);
108        $date = self::identifierDate($issuedAt);
109        if (($taxId === '') || ($date === null) || ($sign === '')) {
110            return [
111                'errorcode' => \ApplicationError::Invalid,
112                'id' => null,
113                'qr' => null,
114            ];
115        }
116
117        $prefix = 'TBAI-'.$taxId.'-'.$date.'-'.$sign.'-';
118        $fiscalId = $prefix.self::crc8($prefix);
119
120        if ($sandbox === null) {
121            $sandbox = class_exists(\AppConstants::class, false) && method_exists(\AppConstants::class, 'BAI_Sandbox')
122                ? (bool) \AppConstants::BAI_Sandbox()
123                : true;
124        }
125        $base = $sandbox ? self::ENDPOINTS[$region]['sbox'] : self::ENDPOINTS[$region]['prod'];
126        $series = trim((string) ($invoiceSeries ?? ''));
127        $number = trim((string) ($invoiceNumber ?? ''));
128        $total = number_format(round((float) $invoiceTotal, 2), 2, '.', '');
129
130        // Match client QrUrl: base + '/' even when base already ends with '/'.
131        $qr = $base.'/';
132        $qr .= '?id='.rawurlencode($fiscalId);
133        $qr .= '&s='.$series;
134        $qr .= '&nf='.$number;
135        $qr .= '&i='.$total;
136        $qr .= '&cr='.self::crc8($qr);
137
138        return [
139            'errorcode' => \ApplicationError::Success,
140            'id' => $fiscalId,
141            'qr' => $qr,
142        ];
143    }
144
145    /** Formats issuedAt ISO/MySQL datetime as DDMMYY for the QR identifier. */
146    public static function identifierDate(mixed $issuedAt): ?string
147    {
148        $value = trim((string) $issuedAt);
149        if (strlen($value) < 10) {
150            return null;
151        }
152        // Prefer ISO YYYY-MM-DD… segment.
153        $dd = substr($value, 8, 2);
154        $mm = substr($value, 5, 2);
155        $yy = substr($value, 2, 2);
156        if (!ctype_digit($dd.$mm.$yy)) {
157            $ts = strtotime($value);
158            if ($ts === false) {
159                return null;
160            }
161            return date('dmy', $ts);
162        }
163        return $dd.$mm.$yy;
164    }
165}