Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.83% covered (success)
86.83%
178 / 205
55.56% covered (warning)
55.56%
5 / 9
CRAP
n/a
0 / 0
swinvoice_agency_from_address
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
swinvoice_ticketbai_region
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
swinvoice_is_ticketbai
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
swinvoice_is_verifactu
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
swinvoice_address_matches_any
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
swinvoice_crc8
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
swinvoice_build_payload
87.39% covered (success)
87.39%
104 / 119
0.00% covered (danger)
0.00%
0 / 1
16.51
swinvoice_ticketbai_qr
63.64% covered (warning)
63.64%
14 / 22
0.00% covered (danger)
0.00%
0 / 1
7.73
swinvoice_sign_payload
91.89% covered (success)
91.89%
34 / 37
0.00% covered (danger)
0.00%
0 / 1
6.02
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Swinvoice fiscal payload + signed XML + QR for Gekkotech license invoices.
7 *
8 * Reuses TicketBAI / VeriFactu registry builders; does not load TICKETDATA.
9 * Not executed as HTTP entry — SwinvoiceService requires this file.
10 */
11
12require_once dirname(__DIR__).'/legacy/model/spec/ticketbai/tbaicalc.php';
13require_once dirname(__DIR__).'/legacy/model/spec/verifactu/vfctcalc.php';
14
15/** Agency stored on SWINVOICE.agency when foral. */
16const SWINVOICE_AGENCY_TICKETBAI = 'TICKETBAI';
17/** Agency stored on SWINVOICE.agency when AEAT (non-foral Spain). */
18const SWINVOICE_AGENCY_VERIFACTU = 'VERIFACTU';
19
20/**
21 * Resolves TicketBAI vs VeriFactu from a Spanish fiscal address (Gekkotech ContactPost).
22 * Empty address → null.
23 */
24function swinvoice_agency_from_address(?string $address): ?string
25{
26    $text = trim((string) $address);
27    if ($text === '') {
28        return null;
29    }
30
31    if (swinvoice_ticketbai_region($text) !== null) {
32        return SWINVOICE_AGENCY_TICKETBAI;
33    }
34
35    return SWINVOICE_AGENCY_VERIFACTU;
36}
37
38/**
39 * Returns Bizkaia, Gipuzkoa, or Araba when foral; otherwise null.
40 */
41function swinvoice_ticketbai_region(?string $address): ?string
42{
43    $text = trim((string) $address);
44    if ($text === '') {
45        return null;
46    }
47
48    if (swinvoice_address_matches_any($text, ['Bizkaia', 'Vizcaya'])) {
49        return 'Bizkaia';
50    }
51    if (swinvoice_address_matches_any($text, ['Guipúzcoa', 'Guipuzcoa', 'Gipuzkoa'])) {
52        return 'Gipuzkoa';
53    }
54    if (swinvoice_address_matches_any($text, ['Álava', 'Alava', 'Araba'])) {
55        return 'Araba';
56    }
57
58    return null;
59}
60
61function swinvoice_is_ticketbai(?string $address): bool
62{
63    return swinvoice_agency_from_address($address) === SWINVOICE_AGENCY_TICKETBAI;
64}
65
66function swinvoice_is_verifactu(?string $address): bool
67{
68    return swinvoice_agency_from_address($address) === SWINVOICE_AGENCY_VERIFACTU;
69}
70
71/**
72 * @param list<string> $needles
73 */
74function swinvoice_address_matches_any(string $haystack, array $needles): bool
75{
76    foreach ($needles as $needle) {
77        if (stripos($haystack, $needle) !== false) {
78            return true;
79        }
80    }
81
82    return false;
83}
84
85/**
86 * CRC8 (3-digit) matching GenericUtils.CRC8 for TicketBAI QR identifiers.
87 */
88function swinvoice_crc8(?string $str): string
89{
90    if (!class_exists(\UppServices\ticketbai\FiscalQr::class, false)) {
91        require_once dirname(__DIR__).'/ticketbai/FiscalQr.php';
92    }
93    return \UppServices\ticketbai\FiscalQr::crc8($str);
94}
95
96/**
97 * @param array<string, mixed> $payment Tenant PAYMENT row (license).
98 * @param array<string, mixed> $placeCentral Central PLACE row (+ fiscal fields expected on place or nested).
99 * @param array{series: string, number: int, issued?: string|null, agency: string, region?: string|null} $invoice
100 * @return array{errorcode: int, payload?: array<string, mixed>}
101 */
102function swinvoice_build_payload(array $payment, array $placeCentral, array $invoice): array
103{
104    $retval = ApplicationError::Success;
105    $agency = (string) ($invoice['agency'] ?? '');
106    $series = (string) ($invoice['series'] ?? '');
107    $number = (string) ($invoice['number'] ?? '');
108    $issuedAt = (string) ($invoice['issued'] ?? gmdate('c'));
109    $region = $invoice['region'] ?? null;
110
111    $base = (float) ($payment['amount'] ?? 0);
112    $taxRate = (float) ($payment['taxrate'] ?? 0);
113    $taxAmount = round($base * $taxRate / 100.0, 2);
114    $total = round($base + $taxAmount, 2);
115
116    $conceptsRaw = $payment['concepts'] ?? null;
117    $concepts = [];
118    if (is_string($conceptsRaw) && $conceptsRaw !== '') {
119        $decoded = json_decode($conceptsRaw, true);
120        if (is_array($decoded)) {
121            $concepts = $decoded;
122        }
123    } elseif (is_array($conceptsRaw)) {
124        $concepts = $conceptsRaw;
125    }
126
127    $lines = [];
128    foreach ($concepts as $concept) {
129        if (!is_array($concept)) {
130            continue;
131        }
132        $qty = (float) ($concept['quantity'] ?? 1);
133        $unit = (float) ($concept['unitPrice'] ?? $concept['unit_price'] ?? 0);
134        $amount = (float) ($concept['amount'] ?? ($qty * $unit));
135        $label = (string) ($concept['labelKey'] ?? $concept['id'] ?? 'License');
136        $lines[] = [
137            'description' => $label,
138            'quantity' => $qty,
139            'unitAmount' => $unit,
140            'discountAmount' => null,
141            'totalAmount' => $amount,
142        ];
143    }
144    if (count($lines) === 0) {
145        $lines[] = [
146            'description' => 'License renewal',
147            'quantity' => 1,
148            'unitAmount' => $base,
149            'discountAmount' => null,
150            'totalAmount' => $base,
151        ];
152    }
153
154    $taxes = [[
155        'rate' => $taxRate,
156        'base' => $base,
157        'amount' => $taxAmount,
158    ]];
159
160    $recipientName = trim((string) ($placeCentral['business'] ?? $placeCentral['name'] ?? ''));
161    $recipientTaxId = trim((string) ($placeCentral['taxid'] ?? ''));
162    $recipient = null;
163    if ($recipientName !== '' && $recipientTaxId !== '') {
164        $recipient = [
165            'name' => $recipientName,
166            'taxId' => $recipientTaxId,
167            'country' => 'ES',
168        ];
169    }
170
171    $issuer = [
172        'taxId' => AppConstants::SoftwareDeveloperTaxId(),
173        'name' => AppConstants::SoftwareDeveloperName(),
174    ];
175
176    if ($agency === SWINVOICE_AGENCY_TICKETBAI) {
177        $software = _ticketbai_registry_software($region ?: 'Bizkaia');
178        if ($software === null) {
179            addlog(__FILE__, LogLevel::Error, "Swinvoice TBAI software settings missing for region '".$region."'");
180            $retval = ApplicationError::Invalid;
181        }
182        if (success($retval)) {
183            $payload = [
184                'changeReason' => 'F',
185                'issuer' => $issuer,
186                'recipient' => $recipient,
187                'software' => $software,
188                'previous' => null,
189                'invoice' => [
190                    'series' => $series,
191                    'number' => $number,
192                    'issuedAt' => $issuedAt,
193                    'description' => 'License '.$series.'/'.$number,
194                    'simplified' => ($recipient === null),
195                    'total' => $total,
196                    'lines' => $lines,
197                    'taxes' => $taxes,
198                    'rectification' => null,
199                    'rectifies' => [],
200                ],
201                'agency' => $agency,
202                'region' => $region,
203            ];
204            return ['errorcode' => $retval, 'payload' => $payload];
205        }
206    } elseif ($agency === SWINVOICE_AGENCY_VERIFACTU) {
207        $invoiceNumber = $series.'/'.$number;
208        $payload = [
209            'issuer' => $issuer,
210            'representative' => [
211                'taxId' => AppConstants::SoftwareDeveloperTaxId(),
212                'name' => AppConstants::SoftwareDeveloperName(),
213            ],
214            'invoice' => [
215                'number' => $invoiceNumber,
216                'date' => _verifactu_registry_date($issuedAt),
217                'type' => is_array($recipient) ? 'F1' : 'F2',
218                'description' => 'License '.$invoiceNumber,
219                'total' => number_format($total, 2, '.', ''),
220                'taxes' => number_format($taxAmount, 2, '.', ''),
221                'taxesDetail' => $taxes,
222                'recipient' => $recipient,
223                'rectification' => null,
224                'rectifies' => [],
225            ],
226            'generatedAt' => _verifactu_registry_datetime($issuedAt),
227            'previous' => [
228                'number' => '',
229                'date' => '',
230            ],
231            'agency' => $agency,
232            'region' => null,
233        ];
234        return ['errorcode' => $retval, 'payload' => $payload];
235    } else {
236        addlog(__FILE__, LogLevel::Error, "Swinvoice unknown agency '".$agency."'");
237        $retval = ApplicationError::Invalid;
238    }
239
240    return ['errorcode' => $retval];
241}
242
243/**
244 * Builds TicketBAI QR id + URL from signature (parity with client Ticketbai.FiscalQr).
245 *
246 * @param array<string, mixed> $payload
247 * @return array{id: string|null, qr: string|null}
248 */
249function swinvoice_ticketbai_qr(array $payload, string $signatureValue, string $region): array
250{
251    if (!class_exists(\UppServices\ticketbai\FiscalQr::class, false)) {
252        require_once dirname(__DIR__).'/ticketbai/FiscalQr.php';
253    }
254
255    $taxId = trim((string) ($payload['issuer']['taxId'] ?? ''));
256    $result = \UppServices\ticketbai\FiscalQr::build(
257        $region,
258        $taxId,
259        $payload['invoice']['series'] ?? '',
260        $payload['invoice']['number'] ?? '',
261        $payload['invoice']['total'] ?? 0,
262        $payload['invoice']['issuedAt'] ?? '',
263        $signatureValue
264    );
265
266    // Preserve previous swinvoice behaviour: unsupported region still returns id when inputs allow.
267    if ($result['errorcode'] === \ApplicationError::Unapplicable && $taxId !== '') {
268        $date = \UppServices\ticketbai\FiscalQr::identifierDate($payload['invoice']['issuedAt'] ?? '');
269        $sign = substr(trim($signatureValue), 0, 13);
270        if ($date !== null && $sign !== '') {
271            $prefix = 'TBAI-'.$taxId.'-'.$date.'-'.$sign.'-';
272            return [
273                'id' => $prefix.\UppServices\ticketbai\FiscalQr::crc8($prefix),
274                'qr' => null,
275            ];
276        }
277    }
278
279    return ['id' => $result['id'], 'qr' => $result['qr']];
280}
281
282/**
283 * Signs payload and returns xml/sign/id/qr fields for SWINVOICE.
284 *
285 * @param array<string, mixed> $payload
286 * @return array{errorcode: int, xml?: string, sign?: string, id?: string|null, qr?: string|null}
287 */
288function swinvoice_sign_payload(array $payload): array
289{
290    $retval = ApplicationError::Success;
291    $agency = (string) ($payload['agency'] ?? '');
292
293    if ($agency === SWINVOICE_AGENCY_TICKETBAI) {
294        $region = (string) ($payload['region'] ?? 'Bizkaia');
295        $xmlUnsigned = _ticketbai_registry_build_xml($payload);
296        $certificatePem = null;
297        $privateKeyPem = null;
298        $retval = _ticketbai_registry_signing_certificate($certificatePem, $privateKeyPem);
299        $xmlSigned = null;
300        if (success($retval)) {
301            $retval = _ticketbai_registry_sign_xades($xmlUnsigned, $region, $certificatePem, $privateKeyPem, $xmlSigned);
302        }
303        if (success($retval)) {
304            $signature = _ticketbai_registry_signature($xmlSigned, 100);
305            if ($signature === null) {
306                $retval = ApplicationError::Invalid;
307            } else {
308                $qr = swinvoice_ticketbai_qr($payload, $signature, $region);
309                return [
310                    'errorcode' => $retval,
311                    'xml' => $xmlSigned,
312                    'sign' => $signature,
313                    'id' => $qr['id'],
314                    'qr' => $qr['qr'],
315                ];
316            }
317        }
318        return ['errorcode' => $retval];
319    }
320
321    if ($agency === SWINVOICE_AGENCY_VERIFACTU) {
322        $previousSignature = '';
323        $signature = _verifactu_registry_hash($payload, $previousSignature);
324        $xml = _verifactu_registry_build_xml($payload, $signature, $previousSignature);
325        $qr = _verifactu_registry_qr_url($payload);
326        return [
327            'errorcode' => ApplicationError::Success,
328            'xml' => $xml,
329            'sign' => $signature,
330            'id' => null,
331            'qr' => $qr,
332        ];
333    }
334
335    return ['errorcode' => ApplicationError::Invalid];
336}