Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.61% covered (warning)
57.61%
140 / 243
16.67% covered (danger)
16.67%
1 / 6
CRAP
n/a
0 / 0
swinvoice_load_signed_xml
84.62% covered (success)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
5.09
swinvoice_issuer_place_row
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
swinvoice_send_to_agency
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
swinvoice_send_ticketbai
72.58% covered (warning)
72.58%
90 / 124
0.00% covered (danger)
0.00%
0 / 1
53.11
swinvoice_send_verifactu
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
110
swinvoice_dispatch_async_request
73.33% covered (warning)
73.33%
22 / 30
0.00% covered (danger)
0.00%
0 / 1
5.47
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Swinvoice institutional POST + LocalUrl async dispatch (TicketBAI / VeriFactu).
7 *
8 * Agency send reuses URL maps, cert loading and Bizkaia N3 helpers from place
9 * fiscal modules (lazy-required inside send_* only). Never writes tenant
10 * TICKETBAI / VERIFACTU rows. dispatchAsync only needs this file for
11 * swinvoice_dispatch_async_request — same shape as dispatch_ticketbai_async_request
12 * in ticketbai.php, without pulling syncchanges on the fire-and-forget path.
13 */
14
15require_once __DIR__.'/Fiscal.php';
16
17/**
18 * Loads signed XML body from SwlcnsPath using SWINVOICE.xml basename.
19 *
20 * @param array<string, mixed> $swinvoice
21 * @return array{errorcode: int, xml?: string}
22 */
23function swinvoice_load_signed_xml(array $swinvoice): array
24{
25    $name = (string) ($swinvoice['xml'] ?? '');
26    if ($name === '') {
27        addlog(__FILE__, LogLevel::Error, 'Swinvoice send: missing xml filename.');
28        return ['errorcode' => ApplicationError::Invalid];
29    }
30
31    $path = AppConstants::SwlcnsPath().'/'.$name;
32    if (!is_file($path)) {
33        addlog(__FILE__, LogLevel::Error, "Swinvoice XML not found at '".$path."'");
34        return ['errorcode' => ApplicationError::NotFound];
35    }
36
37    $xml = file_get_contents($path);
38    if ($xml === false || $xml === '') {
39        addlog(__FILE__, LogLevel::Error, "Swinvoice XML unreadable at '".$path."'");
40        return ['errorcode' => ApplicationError::Unknown];
41    }
42
43    return ['errorcode' => ApplicationError::Success, 'xml' => $xml];
44}
45
46/**
47 * Synthetic "place" row for Bizkaia N3 header — emisor is always Gekkotech.
48 *
49 * @return array<string, mixed>
50 */
51function swinvoice_issuer_place_row(): array
52{
53    return [
54        'objid' => 0,
55        'taxid' => AppConstants::SoftwareDeveloperTaxId(),
56        'business' => AppConstants::SoftwareDeveloperName(),
57        'company' => 1,
58    ];
59}
60
61/**
62 * POSTs signed swinvoice XML to the agency; updates sent/valid/error on $swinvoice.
63 *
64 * @param array<string, mixed> $swinvoice
65 * @return array{errorcode: int}
66 */
67function swinvoice_send_to_agency(array &$swinvoice): array
68{
69    $agency = (string) ($swinvoice['agency'] ?? '');
70    $loaded = swinvoice_load_signed_xml($swinvoice);
71    if (!success($loaded['errorcode'])) {
72        return ['errorcode' => $loaded['errorcode']];
73    }
74    $xml = (string) $loaded['xml'];
75
76    if ($agency === SWINVOICE_AGENCY_TICKETBAI) {
77        return swinvoice_send_ticketbai($swinvoice, $xml);
78    }
79    if ($agency === SWINVOICE_AGENCY_VERIFACTU) {
80        return swinvoice_send_verifactu($swinvoice, $xml);
81    }
82
83    addlog(__FILE__, LogLevel::Error, "Swinvoice send: unknown agency '".$agency."'");
84    return ['errorcode' => ApplicationError::Invalid];
85}
86
87/**
88 * @param array<string, mixed> $swinvoice
89 * @return array{errorcode: int}
90 */
91function swinvoice_send_ticketbai(array &$swinvoice, string $xml): array
92{
93    require_once dirname(__DIR__).'/legacy/model/spec/ticketbai.php';
94
95    $retval = ApplicationError::Success;
96    $swinvoice['error'] = null;
97    $regionRaw = (string) ($swinvoice['region'] ?? 'Bizkaia');
98    $region = strtolower($regionRaw);
99
100    $retval = validate_ticketbai_xml_before_send($xml);
101    if (!success($retval)) {
102        $swinvoice['error'] = 'XML_VALIDATE';
103        return ['errorcode' => $retval];
104    }
105
106    $passkey = ticketbai_env('BUILD_CERT_PASSWORD', '');
107    if ($passkey === '') {
108        addlog(__FILE__, LogLevel::Error, 'BUILD_CERT_PASSWORD required for swinvoice TicketBAI send.');
109        $swinvoice['error'] = 'CERT_PASSWORD';
110        return ['errorcode' => ApplicationError::Parameters];
111    }
112
113    $certificate = AppConstants::certPath();
114    $certval = null;
115    $certkey = null;
116    if (!file_exists($certificate)) {
117        $swinvoice['error'] = 'CERT_MISSING';
118        return ['errorcode' => ApplicationError::NotFound];
119    }
120    $retval = get_certificate_info($certificate, $passkey, $certval, $certkey);
121    if (!success($retval)) {
122        $swinvoice['error'] = 'CERT_READ';
123        return ['errorcode' => $retval];
124    }
125
126    $tbaiurl = '';
127    $tbaihdr = [];
128    $retval = _get_ticketbai_url($region, 'insert', $tbaiurl, $tbaihdr);
129    if (!success($retval)) {
130        $swinvoice['error'] = 'URL';
131        return ['errorcode' => $retval];
132    }
133
134    $place = swinvoice_issuer_place_row();
135    $invcenfo = [
136        'objid' => $swinvoice['objid'] ?? 0,
137        'series' => $swinvoice['series'] ?? \UppServices\SwinvoiceService::SERIES,
138        'invceno' => $swinvoice['number'] ?? 0,
139        'created' => $swinvoice['issued'] ?? gmdate('Y-m-d H:i:s'),
140        'place' => 0,
141    ];
142
143    $content = $xml;
144    $n3data = null;
145    if ($region === 'bizkaia') {
146        $n3data = _eus_bizkaia_n3_data($place, $invcenfo);
147        if ($n3data === false || $n3data === 'false' || $n3data === null) {
148            // _eus_bizkaia_n3_data returns json string on success; on invalid place it may still return json with empty nif — check taxid.
149            if (!$place['taxid'] || !$place['business']) {
150                $swinvoice['error'] = 'N3_DATA';
151                return ['errorcode' => ApplicationError::Invalid];
152            }
153        }
154        $n3cntt = _eus_bizkaia_n3_cntt('1.0', 'A00', $place, $n3data, $xml);
155        $retval = validate_ticketbai_xml_before_send($n3cntt);
156        if (!success($retval)) {
157            $swinvoice['error'] = 'N3_VALIDATE';
158            return ['errorcode' => $retval];
159        }
160        $content = gzencode($n3cntt);
161        if ($content === false) {
162            $swinvoice['error'] = 'GZIP';
163            return ['errorcode' => ApplicationError::Unknown];
164        }
165    }
166
167    $headers = [];
168    foreach ($tbaihdr as $key => $value) {
169        if ($key === 'Content-Length') {
170            $value = strlen($content);
171        }
172        if ($key === 'eus-bizkaia-n3-data') {
173            $value = $n3data;
174        }
175        $headers[] = $key.': '.$value;
176    }
177
178    $tmpCert = null;
179    $tmpKey = null;
180    if ($certval) {
181        $tmpCert = tempnam(sys_get_temp_dir(), 'si_c_');
182        if (!file_put_contents($tmpCert, $certval)) {
183            $swinvoice['error'] = 'CERT_TMP';
184            return ['errorcode' => ApplicationError::Generic];
185        }
186    }
187    if ($certkey) {
188        $tmpKey = tempnam(sys_get_temp_dir(), 'si_k_');
189        if (!file_put_contents($tmpKey, $certkey)) {
190            if ($tmpCert) {
191                @unlink($tmpCert);
192            }
193            $swinvoice['error'] = 'KEY_TMP';
194            return ['errorcode' => ApplicationError::Generic];
195        }
196    }
197
198    $curl = curl_init();
199    curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
200    curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
201    curl_setopt($curl, CURLOPT_URL, $tbaiurl);
202    if ($tmpCert) {
203        curl_setopt($curl, CURLOPT_SSLCERT, $tmpCert);
204    }
205    if ($tmpKey) {
206        curl_setopt($curl, CURLOPT_SSLKEY, $tmpKey);
207    }
208    curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_ANY);
209    curl_setopt($curl, CURLOPT_TIMEOUT_MS, 30000);
210    curl_setopt($curl, CURLOPT_POST, true);
211    curl_setopt($curl, CURLOPT_POSTFIELDS, $content);
212    curl_setopt($curl, CURLOPT_HEADER, true);
213    curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
214
215    addlog(__FILE__, LogLevel::Information, "Swinvoice TBAI dispatch [series: '".$invcenfo['series']."', number: '".$invcenfo['invceno']."', region: '".$region."']");
216    $response = curl_exec($curl);
217    $http = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
218    if ($response === false) {
219        $swinvoice['error'] = 'CURL ERRNO ('.curl_errno($curl).')';
220        $retval = ApplicationError::Unknown;
221        addlog(__FILE__, LogLevel::Error, 'Swinvoice TBAI curl error: '.curl_error($curl));
222    } else {
223        $headerSize = (int) curl_getinfo($curl, CURLINFO_HEADER_SIZE);
224        $hresponse = substr((string) $response, 0, $headerSize);
225        $bresponse = substr((string) $response, $headerSize);
226        $respHeaders = _bai_headers_to_array($hresponse);
227        $swinvoice['sent'] = gmdate('Y-m-d H:i:s');
228
229        if ($region === 'bizkaia' && array_key_exists('eus-bizkaia-n3-tipo-respuesta', $respHeaders)) {
230            $tiporesp = trim((string) $respHeaders['eus-bizkaia-n3-tipo-respuesta']);
231            if ($tiporesp === 'Correcto') {
232                $swinvoice['valid'] = $tiporesp;
233                $swinvoice['error'] = null;
234            } else {
235                $code = $respHeaders['eus-bizkaia-n3-codigo-respuesta'] ?? $tiporesp;
236                $swinvoice['error'] = trim((string) $code);
237                $swinvoice['valid'] = null;
238                $retval = ApplicationError::Generic;
239            }
240        } elseif ($http >= 200 && $http < 300) {
241            $swinvoice['valid'] = 'HTTP_'.$http;
242            $swinvoice['error'] = null;
243        } else {
244            $swinvoice['error'] = 'HTTP_'.$http;
245            $swinvoice['valid'] = null;
246            $retval = ApplicationError::Generic;
247            addlog(__FILE__, LogLevel::Error, 'Swinvoice TBAI HTTP '.$http.': '.$bresponse);
248        }
249    }
250    unset($curl);
251    if ($tmpCert) {
252        @unlink($tmpCert);
253    }
254    if ($tmpKey) {
255        @unlink($tmpKey);
256    }
257
258    return ['errorcode' => $retval];
259}
260
261/**
262 * @param array<string, mixed> $swinvoice
263 * @return array{errorcode: int}
264 */
265function swinvoice_send_verifactu(array &$swinvoice, string $xml): array
266{
267    require_once dirname(__DIR__).'/legacy/model/spec/verifactu.php';
268
269    $retval = ApplicationError::Success;
270    $swinvoice['error'] = null;
271
272    $retval = validate_verifactu_xml_before_send($xml);
273    if (!success($retval)) {
274        $swinvoice['error'] = 'XML_VALIDATE';
275        return ['errorcode' => $retval];
276    }
277
278    $endpoint = null;
279    $retval = _verifactu_extract_endpoint($endpoint);
280    if (!success($retval)) {
281        $swinvoice['error'] = 'ENDPOINT';
282        return ['errorcode' => $retval];
283    }
284
285    $certificatePem = null;
286    $privateKeyPem = null;
287    $retval = _verifactu_certificate($certificatePem, $privateKeyPem);
288    if (!success($retval)) {
289        $swinvoice['error'] = 'CERT';
290        return ['errorcode' => $retval];
291    }
292
293    $tmpCert = tempnam(sys_get_temp_dir(), 'si_vft_c_');
294    $tmpKey = tempnam(sys_get_temp_dir(), 'si_vft_k_');
295    if (!file_put_contents($tmpCert, $certificatePem) || !file_put_contents($tmpKey, $privateKeyPem)) {
296        $swinvoice['error'] = 'CERT_TMP';
297        @unlink($tmpCert);
298        @unlink($tmpKey);
299        return ['errorcode' => ApplicationError::Generic];
300    }
301
302    $curl = curl_init();
303    curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
304    curl_setopt($curl, CURLOPT_URL, $endpoint);
305    curl_setopt($curl, CURLOPT_SSLCERT, $tmpCert);
306    curl_setopt($curl, CURLOPT_SSLKEY, $tmpKey);
307    curl_setopt($curl, CURLOPT_HTTPHEADER, ['Content-Type: text/xml; charset=utf-8']);
308    curl_setopt($curl, CURLOPT_TIMEOUT_MS, 6000);
309    curl_setopt($curl, CURLOPT_POST, true);
310    curl_setopt($curl, CURLOPT_POSTFIELDS, $xml);
311    curl_setopt($curl, CURLOPT_HEADER, true);
312    curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
313
314    addlog(__FILE__, LogLevel::Information, "Swinvoice VFT dispatch [series: '".($swinvoice['series'] ?? '')."', number: '".($swinvoice['number'] ?? '')."']");
315    $response = curl_exec($curl);
316    $http = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
317    if ($response === false) {
318        $swinvoice['error'] = 'CURL ERRNO ('.curl_errno($curl).')';
319        $retval = ApplicationError::Unknown;
320    } else {
321        $headerSize = (int) curl_getinfo($curl, CURLINFO_HEADER_SIZE);
322        $body = substr((string) $response, $headerSize);
323        $swinvoice['sent'] = gmdate('Y-m-d H:i:s');
324        if (preg_match('/CSV[^A-Z0-9]*([A-Z0-9]+)/i', $body, $matches)) {
325            $swinvoice['valid'] = 'CSV: '.$matches[1];
326            $swinvoice['error'] = null;
327        } elseif ($http >= 200 && $http < 300) {
328            $swinvoice['valid'] = 'HTTP_'.$http;
329            $swinvoice['error'] = null;
330        } else {
331            $swinvoice['error'] = 'HTTP_'.$http;
332            $swinvoice['valid'] = null;
333            $retval = ApplicationError::Generic;
334        }
335    }
336    unset($curl);
337    @unlink($tmpCert);
338    @unlink($tmpKey);
339
340    return ['errorcode' => $retval];
341}
342
343/**
344 * Fire-and-forget LocalUrl POST to swinvoice/async (parity with
345 * dispatch_ticketbai_async_request / dispatch_verifactu_async_request).
346 */
347function swinvoice_dispatch_async_request(int $placeId, int $paymentId): int
348{
349    $post = [
350        'place' => $placeId,
351        'payment' => $paymentId,
352    ];
353
354    $curl = curl_init();
355    if ($curl === false) {
356        addlog(__FILE__, LogLevel::Error, 'Could not initialize cURL for swinvoice async dispatch');
357        return ApplicationError::Unknown;
358    }
359
360    $url = AppConstants::LocalUrl().'swinvoice/async';
361    curl_setopt($curl, CURLOPT_URL, $url);
362    curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
363    curl_setopt($curl, CURLOPT_TIMEOUT, 1);
364    curl_setopt($curl, CURLOPT_POST, true);
365    curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($post));
366    curl_setopt($curl, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
367
368    curl_exec($curl);
369    $curlerr = curl_errno($curl);
370    $retval = ApplicationError::Success;
371    if ($curlerr) {
372        // Same rationale as TicketBAI: 1s timeout means the worker is ongoing.
373        if ($curlerr === CURLE_OPERATION_TIMEDOUT) {
374            addlog(__FILE__, LogLevel::Information, "[SWINVOICE] Internal request to '".$url."' ongoing");
375        } else {
376            addlog(__FILE__, LogLevel::Error, "[SWINVOICE] Internal request to '".$url."' failed: ".curl_error($curl));
377            $retval = ApplicationError::Unknown;
378        }
379    } else {
380        $http = curl_getinfo($curl, CURLINFO_HTTP_CODE);
381        if ($http >= 400) {
382            addlog(__FILE__, LogLevel::Error, "[SWINVOICE] Internal request to '".$url."' failed with HTTP code: [".$http."]");
383            $retval = ApplicationError::Unknown;
384        } else {
385            addlog(__FILE__, LogLevel::Information, "[SWINVOICE] Internal request to '".$url."' ongoing");
386        }
387    }
388    curl_close($curl);
389    return $retval;
390}