squareetlabs /
LaravelVerifactu
| 1 | <?php |
||
| 2 | |||
| 3 | declare(strict_types=1); |
||
| 4 | |||
| 5 | namespace Squareetlabs\VeriFactu\Services; |
||
| 6 | |||
| 7 | use GuzzleHttp\Client; |
||
| 8 | use GuzzleHttp\Exception\GuzzleException; |
||
| 9 | use Squareetlabs\VeriFactu\Contracts\VeriFactuInvoice; |
||
| 10 | use Squareetlabs\VeriFactu\Models\Invoice; |
||
| 11 | use Illuminate\Support\Facades\Log; |
||
| 12 | |||
| 13 | class AeatClient |
||
| 14 | { |
||
| 15 | private string $baseUri; |
||
| 16 | private string $certPath; |
||
| 17 | private ?string $certPassword; |
||
| 18 | private Client $client; |
||
| 19 | private bool $production; |
||
| 20 | private bool $verifactuMode; |
||
| 21 | |||
| 22 | public function __construct(string $certPath, ?string $certPassword = null, bool $production = false, ?bool $verifactuMode = null) |
||
| 23 | { |
||
| 24 | $this->certPath = $certPath; |
||
| 25 | $this->certPassword = $certPassword; |
||
| 26 | $this->production = $production; |
||
| 27 | $this->verifactuMode = $verifactuMode ?? config('verifactu.verifactu_mode', true); |
||
| 28 | $this->baseUri = $production |
||
| 29 | ? 'https://www1.aeat.es' |
||
| 30 | : 'https://prewww1.aeat.es'; |
||
| 31 | $this->client = new Client([ |
||
| 32 | 'cert' => ($certPassword === null) ? $certPath : [$certPath, $certPassword], |
||
| 33 | 'base_uri' => $this->baseUri, |
||
| 34 | 'headers' => [ |
||
| 35 | 'User-Agent' => 'LaravelVerifactu/1.0', |
||
| 36 | ], |
||
| 37 | ]); |
||
| 38 | } |
||
| 39 | |||
| 40 | |||
| 41 | |||
| 42 | /** |
||
| 43 | * Build fingerprint/hash for invoice chaining |
||
| 44 | * |
||
| 45 | * @param string $issuerVat |
||
| 46 | * @param string $numSerie |
||
| 47 | * @param string $fechaExp |
||
| 48 | * @param string $tipoFactura |
||
| 49 | * @param string $cuotaTotal |
||
| 50 | * @param string $importeTotal |
||
| 51 | * @param string $ts |
||
| 52 | * @param string $prevHash |
||
| 53 | * @return string |
||
| 54 | */ |
||
| 55 | private function buildFingerprint( |
||
| 56 | string $issuerVat, |
||
| 57 | string $numSerie, |
||
| 58 | string $fechaExp, |
||
| 59 | string $tipoFactura, |
||
| 60 | string $cuotaTotal, |
||
| 61 | string $importeTotal, |
||
| 62 | string $ts, |
||
| 63 | string $prevHash = '' |
||
| 64 | ): string { |
||
| 65 | $raw = 'IDEmisorFactura=' . $issuerVat |
||
| 66 | . '&NumSerieFactura=' . $numSerie |
||
| 67 | . '&FechaExpedicionFactura=' . $fechaExp |
||
| 68 | . '&TipoFactura=' . $tipoFactura |
||
| 69 | . '&CuotaTotal=' . $cuotaTotal |
||
| 70 | . '&ImporteTotal=' . $importeTotal |
||
| 71 | . '&Huella=' . $prevHash |
||
| 72 | . '&FechaHoraHusoGenRegistro=' . $ts; |
||
| 73 | return strtoupper(hash('sha256', $raw)); |
||
| 74 | } |
||
| 75 | |||
| 76 | /** |
||
| 77 | * Send invoice registration to AEAT with support for invoice chaining |
||
| 78 | * |
||
| 79 | * @param Invoice $invoice |
||
| 80 | * @param array|null $previous Previous invoice data for chaining (hash, number, date) |
||
| 81 | * @return array |
||
| 82 | */ |
||
| 83 | /** |
||
| 84 | * Send invoice registration to AEAT with support for invoice chaining |
||
| 85 | * |
||
| 86 | * @param VeriFactuInvoice $invoice |
||
| 87 | * @param array|null $previous Previous invoice data for chaining (hash, number, date) |
||
| 88 | * @return array |
||
| 89 | */ |
||
| 90 | /** |
||
| 91 | * Send invoice registration to AEAT with support for invoice chaining |
||
| 92 | * |
||
| 93 | * @param VeriFactuInvoice $invoice |
||
| 94 | * @param array|null $previous Previous invoice data for chaining (hash, number, date) |
||
| 95 | * @return array |
||
| 96 | */ |
||
| 97 | public function sendInvoice(VeriFactuInvoice $invoice, ?array $previous = null): array |
||
| 98 | { |
||
| 99 | // 1. Obtener datos del emisor |
||
| 100 | $issuer = config('verifactu.issuer'); |
||
| 101 | $issuerName = $issuer['name'] ?? ''; |
||
| 102 | $issuerVat = $issuer['vat'] ?? ''; |
||
| 103 | |||
| 104 | // 2. Preparar datos comunes |
||
| 105 | $ts = \Carbon\Carbon::now('UTC')->format('c'); |
||
| 106 | $numSerie = (string) $invoice->getInvoiceNumber(); |
||
| 107 | $fechaExp = $invoice->getIssueDate()->format('d-m-Y'); |
||
| 108 | $tipoFactura = $invoice->getInvoiceType(); |
||
| 109 | $cuotaTotal = sprintf('%.2f', (float) $invoice->getTaxAmount()); |
||
| 110 | $importeTotal = sprintf('%.2f', (float) $invoice->getTotalAmount()); |
||
| 111 | $prevHash = $previous['hash'] ?? $invoice->getPreviousHash() ?? ''; |
||
| 112 | |||
| 113 | // 3. Generar huella |
||
| 114 | $huella = $this->buildFingerprint( |
||
| 115 | $issuerVat, |
||
| 116 | $numSerie, |
||
| 117 | $fechaExp, |
||
| 118 | $tipoFactura, |
||
| 119 | $cuotaTotal, |
||
| 120 | $importeTotal, |
||
| 121 | $ts, |
||
| 122 | $prevHash |
||
| 123 | ); |
||
| 124 | |||
| 125 | // 4. Construir partes del mensaje |
||
| 126 | $cabecera = $this->buildHeader($issuerName, $issuerVat); |
||
| 127 | $detalle = $this->buildBreakdowns($invoice); |
||
| 128 | $encadenamiento = $this->buildChaining($previous, $issuerVat); |
||
| 129 | $destinatarios = $this->buildRecipients($invoice); |
||
| 130 | |||
| 131 | // 5. Construir RegistroAlta |
||
| 132 | $registroAlta = $this->buildRegistration( |
||
| 133 | $invoice, |
||
| 134 | $issuerName, |
||
| 135 | $issuerVat, |
||
| 136 | $numSerie, |
||
| 137 | $fechaExp, |
||
| 138 | $tipoFactura, |
||
| 139 | $cuotaTotal, |
||
| 140 | $importeTotal, |
||
| 141 | $ts, |
||
| 142 | $huella, |
||
| 143 | $detalle, |
||
| 144 | $encadenamiento, |
||
| 145 | $destinatarios |
||
| 146 | ); |
||
| 147 | |||
| 148 | $body = [ |
||
| 149 | 'Cabecera' => $cabecera, |
||
| 150 | 'RegistroFactura' => [ |
||
| 151 | ['RegistroAlta' => $registroAlta] |
||
| 152 | ], |
||
| 153 | ]; |
||
| 154 | |||
| 155 | // 6. Enviar |
||
| 156 | return $this->performSoapCall($body, $huella, $numSerie, $fechaExp, $ts, $previous); |
||
| 157 | } |
||
| 158 | |||
| 159 | private function buildHeader(string $issuerName, string $issuerVat): array |
||
| 160 | { |
||
| 161 | return [ |
||
| 162 | 'ObligadoEmision' => [ |
||
| 163 | 'NombreRazon' => $issuerName, |
||
| 164 | 'NIF' => $issuerVat, |
||
| 165 | ], |
||
| 166 | ]; |
||
| 167 | } |
||
| 168 | |||
| 169 | private function buildBreakdowns(VeriFactuInvoice $invoice): array |
||
| 170 | { |
||
| 171 | $breakdowns = $invoice->getBreakdowns(); |
||
| 172 | $detalle = []; |
||
| 173 | |||
| 174 | foreach ($breakdowns as $breakdown) { |
||
| 175 | $detalle[] = [ |
||
| 176 | 'ClaveRegimen' => $breakdown->getRegimeType(), |
||
| 177 | 'CalificacionOperacion' => $breakdown->getOperationType(), |
||
| 178 | 'TipoImpositivo' => (float) $breakdown->getTaxRate(), |
||
| 179 | 'BaseImponibleOimporteNoSujeto' => sprintf('%.2f', (float) $breakdown->getBaseAmount()), |
||
| 180 | 'CuotaRepercutida' => sprintf('%.2f', (float) $breakdown->getTaxAmount()), |
||
| 181 | ]; |
||
| 182 | } |
||
| 183 | |||
| 184 | if (count($detalle) === 0) { |
||
| 185 | $base = sprintf('%.2f', (float) $invoice->getTotalAmount() - $invoice->getTaxAmount()); |
||
| 186 | $detalle[] = [ |
||
| 187 | 'ClaveRegimen' => '01', |
||
| 188 | 'CalificacionOperacion' => 'S1', |
||
| 189 | 'TipoImpositivo' => 0.0, |
||
| 190 | 'BaseImponibleOimporteNoSujeto' => $base, |
||
| 191 | 'CuotaRepercutida' => sprintf('%.2f', 0.0), |
||
| 192 | ]; |
||
| 193 | } |
||
| 194 | |||
| 195 | return $detalle; |
||
| 196 | } |
||
| 197 | |||
| 198 | private function buildChaining(?array $previous, string $issuerVat): array |
||
| 199 | { |
||
| 200 | if ($previous) { |
||
|
0 ignored issues
–
show
|
|||
| 201 | return [ |
||
| 202 | 'RegistroAnterior' => [ |
||
| 203 | 'IDEmisorFactura' => $issuerVat, |
||
| 204 | 'NumSerieFactura' => $previous['number'], |
||
| 205 | 'FechaExpedicionFactura' => $previous['date'], |
||
| 206 | 'Huella' => $previous['hash'], |
||
| 207 | ], |
||
| 208 | ]; |
||
| 209 | } |
||
| 210 | return ['PrimerRegistro' => 'S']; |
||
| 211 | } |
||
| 212 | |||
| 213 | private function buildRecipients(VeriFactuInvoice $invoice): ?array |
||
| 214 | { |
||
| 215 | $recipients = $invoice->getRecipients(); |
||
| 216 | if ($recipients->count() > 0) { |
||
| 217 | $destinatarios = []; |
||
| 218 | foreach ($recipients as $recipient) { |
||
| 219 | $r = ['NombreRazon' => $recipient->getName()]; |
||
| 220 | $taxId = $recipient->getTaxId(); |
||
| 221 | if (!empty($taxId)) { |
||
| 222 | $r['NIF'] = $taxId; |
||
| 223 | } |
||
| 224 | $destinatarios[] = $r; |
||
| 225 | } |
||
| 226 | return ['IDDestinatario' => $destinatarios]; |
||
| 227 | } |
||
| 228 | return null; |
||
| 229 | } |
||
| 230 | |||
| 231 | private function buildRegistration( |
||
| 232 | VeriFactuInvoice $invoice, |
||
| 233 | string $issuerName, |
||
| 234 | string $issuerVat, |
||
| 235 | string $numSerie, |
||
| 236 | string $fechaExp, |
||
| 237 | string $tipoFactura, |
||
| 238 | string $cuotaTotal, |
||
| 239 | string $importeTotal, |
||
| 240 | string $ts, |
||
| 241 | string $huella, |
||
| 242 | array $detalle, |
||
| 243 | array $encadenamiento, |
||
| 244 | ?array $destinatarios |
||
| 245 | ): array { |
||
| 246 | $registroAlta = [ |
||
| 247 | 'IDVersion' => '1.0', |
||
| 248 | 'IDFactura' => [ |
||
| 249 | 'IDEmisorFactura' => $issuerVat, |
||
| 250 | 'NumSerieFactura' => $numSerie, |
||
| 251 | 'FechaExpedicionFactura' => $fechaExp, |
||
| 252 | ], |
||
| 253 | 'NombreRazonEmisor' => $issuerName, |
||
| 254 | 'TipoFactura' => $tipoFactura, |
||
| 255 | 'DescripcionOperacion' => $invoice->getOperationDescription(), |
||
| 256 | 'Desglose' => ['DetalleDesglose' => $detalle], |
||
| 257 | 'CuotaTotal' => $cuotaTotal, |
||
| 258 | 'ImporteTotal' => $importeTotal, |
||
| 259 | 'Encadenamiento' => $encadenamiento, |
||
| 260 | 'SistemaInformatico' => [ |
||
| 261 | 'NombreRazon' => $issuerName, |
||
| 262 | 'NIF' => $issuerVat, |
||
| 263 | 'NombreSistemaInformatico' => config('verifactu.sistema_informatico.name', 'LaravelVerifactu'), |
||
| 264 | 'IdSistemaInformatico' => config('verifactu.sistema_informatico.id', 'LV'), |
||
| 265 | 'Version' => config('verifactu.sistema_informatico.version', '1.0'), |
||
| 266 | 'NumeroInstalacion' => config('verifactu.sistema_informatico.installation_number', '001'), |
||
| 267 | 'TipoUsoPosibleSoloVerifactu' => config('verifactu.sistema_informatico.only_verifactu_capable', 'S'), |
||
| 268 | 'TipoUsoPosibleMultiOT' => config('verifactu.sistema_informatico.multi_obligated_entities_capable', 'N'), |
||
| 269 | 'IndicadorMultiplesOT' => config('verifactu.sistema_informatico.has_multiple_obligated_entities', 'N'), |
||
| 270 | ], |
||
| 271 | 'FechaHoraHusoGenRegistro' => $ts, |
||
| 272 | 'TipoHuella' => '01', |
||
| 273 | 'Huella' => $huella, |
||
| 274 | ]; |
||
| 275 | |||
| 276 | // Campos opcionales nuevos |
||
| 277 | if ($invoice->getOperationDate()) { |
||
| 278 | $registroAlta['FechaOperacion'] = $invoice->getOperationDate()->format('d-m-Y'); |
||
| 279 | } |
||
| 280 | |||
| 281 | if ($invoice->getTaxPeriod()) { |
||
| 282 | $registroAlta['PeriodoImpositivo'] = [ |
||
| 283 | 'Ejercicio' => $invoice->getIssueDate()->format('Y'), |
||
| 284 | 'Periodo' => $invoice->getTaxPeriod(), |
||
| 285 | ]; |
||
| 286 | } |
||
| 287 | |||
| 288 | if ($invoice->getCorrectionType()) { |
||
| 289 | $registroAlta['TipoRectificativa'] = $invoice->getCorrectionType(); |
||
| 290 | } |
||
| 291 | |||
| 292 | if ($invoice->getExternalReference()) { |
||
| 293 | $registroAlta['RefExterna'] = $invoice->getExternalReference(); |
||
| 294 | } |
||
| 295 | |||
| 296 | if ($destinatarios) { |
||
|
0 ignored issues
–
show
The expression
$destinatarios of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent. Consider making the comparison explicit by using Loading history...
|
|||
| 297 | $registroAlta['Destinatarios'] = $destinatarios; |
||
| 298 | } |
||
| 299 | |||
| 300 | return $registroAlta; |
||
| 301 | } |
||
| 302 | |||
| 303 | protected function getSoapClient(): \SoapClient |
||
| 304 | { |
||
| 305 | if ($this->production) { |
||
| 306 | $wsdl = $this->verifactuMode |
||
| 307 | ? 'https://www1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP?wsdl' |
||
| 308 | : 'https://www1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/RequerimientoSOAP?wsdl'; |
||
| 309 | } else { |
||
| 310 | $wsdl = 'https://prewww2.aeat.es/static_files/common/internet/dep/aplicaciones/es/aeat/tikeV1.0/cont/ws/SistemaFacturacion.wsdl'; |
||
| 311 | } |
||
| 312 | |||
| 313 | $options = [ |
||
| 314 | 'local_cert' => $this->certPath, |
||
| 315 | 'passphrase' => $this->certPassword, |
||
| 316 | 'trace' => true, |
||
| 317 | 'exceptions' => true, |
||
| 318 | 'cache_wsdl' => 0, |
||
| 319 | 'soap_version' => SOAP_1_1, |
||
| 320 | 'connection_timeout' => 30, |
||
| 321 | 'stream_context' => stream_context_create([ |
||
| 322 | 'ssl' => [ |
||
| 323 | 'verify_peer' => true, |
||
| 324 | 'verify_peer_name' => true, |
||
| 325 | 'allow_self_signed' => false, |
||
| 326 | 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, |
||
| 327 | ], |
||
| 328 | 'http' => [ |
||
| 329 | 'user_agent' => 'LaravelVerifactu/1.0', |
||
| 330 | ], |
||
| 331 | ]), |
||
| 332 | ]; |
||
| 333 | |||
| 334 | return new \SoapClient($wsdl, $options); |
||
| 335 | } |
||
| 336 | |||
| 337 | private function performSoapCall(array $body, string $huella, string $numSerie, string $fechaExp, string $ts, ?array $previous): array |
||
| 338 | { |
||
| 339 | if ($this->production) { |
||
| 340 | $location = $this->verifactuMode |
||
| 341 | ? 'https://www1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP' |
||
| 342 | : 'https://www1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/RequerimientoSOAP'; |
||
| 343 | } else { |
||
| 344 | $location = $this->verifactuMode |
||
| 345 | ? 'https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP' |
||
| 346 | : 'https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/RequerimientoSOAP'; |
||
| 347 | } |
||
| 348 | |||
| 349 | try { |
||
| 350 | $client = $this->getSoapClient(); |
||
| 351 | $client->__setLocation($location); |
||
| 352 | $response = $client->__soapCall('RegFactuSistemaFacturacion', [$body]); |
||
| 353 | return [ |
||
| 354 | 'status' => 'success', |
||
| 355 | 'request' => $client->__getLastRequest(), |
||
| 356 | 'response' => $client->__getLastResponse(), |
||
| 357 | 'aeat_response' => $response, |
||
| 358 | 'hash' => $huella, |
||
| 359 | 'number' => $numSerie, |
||
| 360 | 'date' => $fechaExp, |
||
| 361 | 'timestamp' => $ts, |
||
| 362 | 'first' => $previous ? false : true, |
||
| 363 | ]; |
||
| 364 | } catch (\SoapFault $e) { |
||
| 365 | return [ |
||
| 366 | 'status' => 'error', |
||
| 367 | 'message' => $e->getMessage(), |
||
| 368 | 'request' => isset($client) ? $client->__getLastRequest() : null, |
||
| 369 | 'response' => isset($client) ? $client->__getLastResponse() : null, |
||
| 370 | ]; |
||
| 371 | } |
||
| 372 | } |
||
| 373 | } |
||
| 374 | |||
| 375 |
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)or! empty(...)instead.