Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
57.61% |
140 / 243 |
|
16.67% |
1 / 6 |
CRAP | n/a |
0 / 0 |
|
| swinvoice_load_signed_xml | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
5.09 | |||
| swinvoice_issuer_place_row | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| swinvoice_send_to_agency | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
4.01 | |||
| swinvoice_send_ticketbai | |
72.58% |
90 / 124 |
|
0.00% |
0 / 1 |
53.11 | |||
| swinvoice_send_verifactu | |
0.00% |
0 / 58 |
|
0.00% |
0 / 1 |
110 | |||
| swinvoice_dispatch_async_request | |
73.33% |
22 / 30 |
|
0.00% |
0 / 1 |
5.47 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(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 | |
| 15 | require_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 | */ |
| 23 | function 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 | */ |
| 51 | function 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 | */ |
| 67 | function 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 | */ |
| 91 | function 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 | */ |
| 265 | function 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 | */ |
| 347 | function 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 | } |