diff --git a/app/Jobs/Util/UpdateExchangeRates.php b/app/Jobs/Util/UpdateExchangeRates.php index 332b932e9a..157b3dd1a1 100644 --- a/app/Jobs/Util/UpdateExchangeRates.php +++ b/app/Jobs/Util/UpdateExchangeRates.php @@ -59,8 +59,12 @@ class UpdateExchangeRates implements ShouldQueue /* Update all currencies */ Currency::all()->each(function ($currency) use ($currency_api) { - $currency->exchange_rate = $currency_api->rates->{$currency->code}; - $currency->save(); + + if(isset($currency_api->rates->{$currency->code})) { + $currency->exchange_rate = $currency_api->rates->{$currency->code}; + $currency->save(); + } + }); /* Rebuild the cache */ @@ -76,8 +80,12 @@ class UpdateExchangeRates implements ShouldQueue /* Update all currencies */ Currency::all()->each(function ($currency) use ($currency_api) { - $currency->exchange_rate = $currency_api->rates->{$currency->code}; - $currency->save(); + + if (isset($currency_api->rates->{$currency->code})) { + $currency->exchange_rate = $currency_api->rates->{$currency->code}; + $currency->save(); + } + }); /* Rebuild the cache */ diff --git a/app/Services/EDocument/Standards/Verifactu/InvoiceninjaToVerifactuMapper.php b/app/Services/EDocument/Standards/Verifactu/InvoiceninjaToVerifactuMapper.php index 46db953029..7dc49b7584 100644 --- a/app/Services/EDocument/Standards/Verifactu/InvoiceninjaToVerifactuMapper.php +++ b/app/Services/EDocument/Standards/Verifactu/InvoiceninjaToVerifactuMapper.php @@ -6,10 +6,12 @@ namespace App\Services\EDocument\Standards\Verifactu; use Carbon\Carbon; use App\Models\Invoice; +use App\DataMapper\Tax\BaseRule; use App\Services\EDocument\Standards\Verifactu\Types\IDOtro; use App\Services\EDocument\Standards\Verifactu\Types\Detalle; use App\Services\EDocument\Standards\Verifactu\Types\Desglose; use App\Services\EDocument\Standards\Verifactu\Types\Destinatarios; +use App\Services\EDocument\Standards\Verifactu\Types\IDDestinatario; use App\Services\EDocument\Standards\Verifactu\Types\IDFacturaExpedida; use App\Services\EDocument\Standards\Verifactu\Types\PersonaFisicaJuridica; use App\Services\EDocument\Standards\Verifactu\Types\RegistroFacturacionAlta; @@ -27,6 +29,22 @@ class InvoiceninjaToVerifactuMapper 'N', // No correction ]; + /** + * F series invoices are for the ORIGINAL / INITIAL version of the invoice. + * R series invoices are for the CORRECTED version of the invoice. + * + * F1 is a standard invoice. Where the full customer details are provided. + * F2 is a simplified invoice. Where the customer details are not provided. + * F3 is a substitute invoice. Used to replace F2 invoices - we will not implement this! + * + * R1 Corrective invoice for errors in the original invoice. + * R2 Used when customer enters bankruptcy during the invoice lifetime. + * R3 Bad debt invoice for VAT refund. + * R4 General purpose corrective invoice + * R5 Corrective invoice for F2 type invoices. + * + * @var array + */ public array $invoice_types = [ 'F1', // Invoice 'F2', // Simplified Invoice @@ -38,6 +56,13 @@ class InvoiceninjaToVerifactuMapper 'R5', // Rectification Invoice ]; + /** + * When generateing R type invoices, we will always use values + * that substitute the original invoice, this requires settings + * + * $registroFacturacionAlta->setTipoRectificativa('S'); // for Substitutive + */ + public function mapRegistroFacturacionAlta(Invoice $invoice): RegistroFacturacionAlta // Registration Entry { $registroFacturacionAlta = new RegistroFacturacionAlta(); // Registration Entry @@ -67,50 +92,216 @@ class InvoiceninjaToVerifactuMapper // $registroFacturacionAlta->setRechazoPrevio('RechazoPrevio::VALUE_N'); // Previous Rejection // Set invoice type (TipoFactura) - $registroFacturacionAlta->setTipoFactura(ClaveTipoFactura::VALUE_F_1); + $registroFacturacionAlta->setTipoFactura($this->getInvoiceType()); - // Set operation date and description (FechaOperacion y DescripcionOperacion) + // Delivery Date of the goods or services (we force invoice->date for this.) $registroFacturacionAlta->setFechaOperacion(\Carbon\Carbon::parse($invoice->date)->format('d-m-Y')); - $registroFacturacionAlta->setDescripcionOperacion($invoice->public_notes ?? ''); + + // Description of the operation (we use invoice->public_notes) BUT only if it's not empty + if(strlen($invoice->public_notes ?? '') > 0) { + $registroFacturacionAlta->setDescripcionOperacion($invoice->public_notes); + } // Set recipients (Destinatarios) $destinatarios = new Destinatarios(); // Recipients - $destinatario = new PersonaFisicaJuridica(); // Natural/Legal Person + $destinatario = new IDDestinatario(); // Natural/Legal Person $destinatario->setNombreRazon($invoice->client->present()->name()); // Business Name - if ($invoice->client->vat_number) { + // For Spanish clients with a VAT, we just need to set the NIF + if (strlen($invoice->client->vat_number ?? '') > 2 && $invoice->client->country->iso_3166_2 === 'ES') { $destinatario->setNIF($invoice->client->vat_number); // Tax ID Number } else { - $idOtro = new IDOtro(); // Other ID - $idOtro->setID('07'); // Not registered in census (No censado) - $idOtro->setID($invoice->client->id_number); + // For all other clients, we need to set the IDOtro + // this requires some logic to build + $idOtro = $this->buildIdOtro($invoice); $destinatario->setIDOtro($idOtro); } - $destinatarios->addToIDDestinatario($destinatario); + $destinatarios->addIDDestinatario($destinatario); $registroFacturacionAlta->setDestinatarios($destinatarios); - // Set breakdown (Desglose) + // Set breakdown (Desglose) MAXIMUM 12 Line items!!!!!!!! $desglose = new Desglose(); // Breakdown - $detalle = new Detalle(); // Detail - $detalle->setImpuesto(''); // Tax (IVA) - $detalle->setTipoImpositivo($invoice->tax_rate); //@todo this is not correct - $detalle->setBaseImponibleOimporteNoSujeto($invoice->amount); // Taxable Base or Non-Taxable Amount - $detalle->setCuotaRepercutida($invoice->tax_amount); // Charged Tax Amount - $desglose->addToDetalleDesglose($detalle); + + foreach($invoice->line_items as $item) { + $detalle = new Detalle(); // Detail + $detalle->setImpuesto('01'); // Tax (IVA) //@todo, need to implement logic for the other tax codes + $detalle->setTipoImpositivo($item->tax_rate1); + $detalle->setBaseImponibleOimporteNoSujeto($item->line_total); // Taxable Base or Non-Taxable Amount + $detalle->setCuotaRepercutida($item->tax_amount); // Charged Tax Amount + $desglose->addToDetalleDesglose($detalle); + } + $registroFacturacionAlta->setDesglose($desglose); // Set total amounts (CuotaTotal e ImporteTotal) - $registroFacturacionAlta->setCuotaTotal((string)$invoice->tax_amount); //@todo this is not correct - $registroFacturacionAlta->setImporteTotal((string)$invoice->total); //@todo this is not correct + $registroFacturacionAlta->setCuotaTotal($invoice->total_taxes); //@todo this is not correct + $registroFacturacionAlta->setImporteTotal($invoice->amount); //@todo this is not correct // Set fingerprint type and value (TipoHuella y Huella) - $registroFacturacionAlta->setTipoHuella(''); - $registroFacturacionAlta->setHuella(hash('sha256', $invoice->number)); // Digital Fingerprint - + $registroFacturacionAlta->setTipoHuella('01'); + // Set generation date (FechaHoraHusoGenRegistro) - $registroFacturacionAlta->setFechaHoraHusoGenRegistro(Carbon::now()->format('Y-m-d\TH:i:s')); //@todo set the timezone to the company locale + $registroFacturacionAlta->setFechaHoraHusoGenRegistro(new \DateTime()); //@todo set the timezone to the company locale + + $registroFacturacionAlta->setHuella($this->getHash($invoice, $registroFacturacionAlta)); // Digital Fingerprint return $registroFacturacionAlta; } + + /** + * getHash + * + * 1. High Billing Record + * The fields to include in the string to calculate the footprint are: + * + * IDEmisorFactura : Identification of the invoice issuer. + * NumSerieFactura : Serial number of the invoice. + * InvoiceIssueDate : Date the invoice was issued. + * TipoFactura : Invoice type code. + * TotalQuota : Total amount of tax quotas. + * TotalAmount : Total amount of the invoice. + * Fingerprint (previous record) : Hash of the immediately preceding billing record (if any). + * DateTimeZoneGenRecord : Date and time of record generation. + * + * 2. Cancellation Billing Record + * In this case, the fields used to generate the hash are: + * + * IDEmisorFacturaAnulada : Identification of the issuer of the cancelled invoice. + * NumSerieFacturaAnulada : Serial number of the cancelled invoice. + * CancelledInvoiceIssueDate : Date of issue of the canceled invoice. + * Fingerprint (previous record) : Hash of the cancelled invoice. + * DateTimeZoneGenRecord : Date and time of record generation. + * + * 3. Event Registration + * For event logs, the data string to be processed includes: + * NIF of the issuer and the person obliged to issue . + * Event ID . + * Identification of the computer system . + * Billing software version . + * Installation number . + * Event type . + * Trace of the previous event (if applicable). + * Date and time of event generation . + * + * Based on the type of record, the hash will need to be calculated differently. + * + * @param Invoice $invoice + * @param RegistroFacturacionAlta $registroFacturacionAlta + * @return string + */ + private function getHash(Invoice $invoice, RegistroFacturacionAlta $registroFacturacionAlta): string + { + // $hash = ''; + // Tipo de factura Invoice type + // Número de factura Invoice number + // Fecha de emisión Date of issue + // NIF del emisor Issuer's Tax Identification Number (NIF) + // NIF del receptor Recipient's Tax Identification Number (NIF) + // Importe total Total amount + // Base imponible Taxable base + // IVA aplicado Applied VAT + // Tipo impositivo Tax rate + // Fecha operación Transaction date + // Descripción operación Description of the transaction + // Serie Invoice series + // Concepto Concept or description of the invoice + + $hash = "IDEmisorFactura=" . $registroFacturacionAlta->getIDFactura()->getIDEmisorFactura() . + "&NumSerieFactura=" . $registroFacturacionAlta->getIDFactura()->getNumSerieFactura() . + "&FechaExpedicionFactura=" . $registroFacturacionAlta->getIDFactura()->getFechaExpedicionFactura() . + "&TipoFactura=" . $registroFacturacionAlta->getTipoFactura() . + "&CuotaTotal=" . $registroFacturacionAlta->getCuotaTotal() . + "&ImporteTotal=" . $registroFacturacionAlta->getImporteTotal() . + "&Huella=" . $registroFacturacionAlta->getHuella() . // Fingerprint of the previous record + "&FechaHoraHusoGenRegistro=" . $registroFacturacionAlta->getFechaHoraHusoGenRegistro()->format('Y-m-d\TH:i:sP'); + + $hash = utf8_encode($hash); + + $hash = strtoupper(hash('sha256', $hash)); + + return $hash; + + } + + /** + * Generate hash for cancellation records + * + * The fields used to generate the hash are: + * - IDEmisorFacturaAnulada: Identification of the issuer of the cancelled invoice + * - NumSerieFacturaAnulada: Serial number of the cancelled invoice + * - FechaExpedicionFacturaAnulada: Date of issue of the canceled invoice + * - Huella: Hash of the cancelled invoice + * - FechaHoraHusoGenRegistro: Date and time of record generation + */ + private function getHashForCancellation(RegistroFacturacionAnulacion $registroAnulacion): string + { + $hash = "IDEmisorFacturaAnulada=" . $registroAnulacion->getIDFactura()->getIDEmisorFactura() . + "&NumSerieFacturaAnulada=" . $registroAnulacion->getIDFactura()->getNumSerieFactura() . + "&FechaExpedicionFacturaAnulada=" . $registroAnulacion->getIDFactura()->getFechaExpedicionFactura() . + "&Huella=" . $registroAnulacion->getHuella() . // Hash of the cancelled invoice //@todo, when we init the doc, we need to set this!! + "&FechaHoraHusoGenRegistro=" . $registroAnulacion->getFechaHoraHusoGenRegistro()->format('Y-m-d\TH:i:sP'); + + $hash = utf8_encode($hash); + + return strtoupper(hash('sha256', $hash)); + } + /** + * getInvoiceType + * + * We do not yet have any UI for this. We'll need to implement UI + * functionality that allows the user to initially select F1/F2 + * + * and then on editting, they'll be able to select R1/R2/R3/R4/R5 + * be able to select R1/R2/R3/R4/R5 + * @return string + */ + private function getInvoiceType(Invoice $invoice): string + { + //@todo we need to have logic surrounding these two fields if the are applicable to the current doc + return match($invoice->status_id) { + Invoice::STATUS_DRAFT => 'F1', + Invoice::STATUS_SENT => 'R4', + Invoice::STATUS_PAID => 'R4', + Invoice::STATUS_OVERDUE => 'R4', + Invoice::STATUS_CANCELLED => 'R4', + default => 'F1', + }; + } + + /** + * buildIdOtro + * + * Client Identifier mapping + * @param Invoice $invoice + * @return IDOtro + */ + private function buildIdOtro(Invoice $invoice): IDOtro + { + $idOtro = new IDOtro(); // Other ID + + $br = new BaseRule(); + $eu_countries = $br->eu_country_codes; + + $client_country_code = $invoice->client->country->iso_3166_2; + + if(in_array($client_country_code, $eu_countries)) { + + // Is this B2C or B2B? + if(strlen($invoice->client->vat_number ?? '') > 2) { + $idOtro->setIDType('02'); // VAT Number + $idOtro->setID($invoice->client->vat_number); + } else { + $idOtro->setIDType('04'); // Legal Entity ID + $idOtro->setID($invoice->client->id_number); + } + } + else { + //foreign country + $idOtro->setIDType('03'); + $idOtro->setID(strlen($invoice->client->vat_number ?? '') > 2 ? $invoice->client->vat_number : $invoice->client->id_number); + } + + return $idOtro; + } } diff --git a/app/Services/EDocument/Standards/Verifactu/Types/Detalle.php b/app/Services/EDocument/Standards/Verifactu/Types/Detalle.php index aa591d5c8e..913dc5cad9 100644 --- a/app/Services/EDocument/Standards/Verifactu/Types/Detalle.php +++ b/app/Services/EDocument/Standards/Verifactu/Types/Detalle.php @@ -6,6 +6,13 @@ use Symfony\Component\Serializer\Annotation\SerializedName; class Detalle { + public array $impuestos = [ + '01', //IVA + '02', //IPSI (Ceuta y Melilla) + '03', //IGIC (Canarias) + '05', //Otros + ]; + /** @var string|null */ #[SerializedName('sum1:Impuesto')] protected $Impuesto; diff --git a/app/Services/EDocument/Standards/Verifactu/Types/IDDestinatario.php b/app/Services/EDocument/Standards/Verifactu/Types/IDDestinatario.php index b366c2e749..8892579068 100644 --- a/app/Services/EDocument/Standards/Verifactu/Types/IDDestinatario.php +++ b/app/Services/EDocument/Standards/Verifactu/Types/IDDestinatario.php @@ -4,38 +4,7 @@ namespace App\Services\EDocument\Standards\Verifactu\Types; use Symfony\Component\Serializer\Annotation\SerializedName; -class IDDestinatario extends PersonaFisicaJuridicaES +class IDDestinatario extends PersonaFisicaJuridica { - /** @var string|null */ - #[SerializedName('sum1:CodigoPais')] - protected $CodigoPais; - - /** @var IDOtro|null */ - #[SerializedName('sum1:IDOtro')] - protected $IDOtro; - - public function getCodigoPais(): ?string - { - return $this->CodigoPais; - } - - public function setCodigoPais(?string $codigoPais): self - { - if ($codigoPais !== null && strlen($codigoPais) !== 2) { - throw new \InvalidArgumentException('CodigoPais must be a 2-character ISO country code'); - } - $this->CodigoPais = $codigoPais; - return $this; - } - - public function getIDOtro(): ?IDOtro - { - return $this->IDOtro; - } - - public function setIDOtro(?IDOtro $idOtro): self - { - $this->IDOtro = $idOtro; - return $this; - } + } \ No newline at end of file diff --git a/app/Services/EDocument/Standards/Verifactu/Types/IDOtro.php b/app/Services/EDocument/Standards/Verifactu/Types/IDOtro.php index deee01b41d..4b485b0b82 100644 --- a/app/Services/EDocument/Standards/Verifactu/Types/IDOtro.php +++ b/app/Services/EDocument/Standards/Verifactu/Types/IDOtro.php @@ -6,8 +6,24 @@ use Symfony\Component\Serializer\Annotation\SerializedName; class IDOtro { + + // 01 NIFContraparte Spanish Tax ID (NIF) of the counterparty NIF de la contraparte (solo válido con NIF, no en IDOtro) + // 02 VATNumber EU VAT Number Número de IVA de operadores intracomunitarios + // 03 Passport/Foreign ID National ID, passport, or similar from non-EU countries Documento oficial de identificación expedido por otro país + // 04 Legal Entity ID Tax ID for foreign legal entities Código de identificación fiscal de personas jurídicas extranjeras + // 05 Residence Cert. Certificate of residence issued by a tax authority Certificado de residencia fiscal + // 06 Other Other officially recognized identifier Otro documento reconocido oficialmente + public array $id_types = [ + '01', + '02', + '03', + '04', + '05', + '06', + ]; + /** @var string */ - #[SerializedName('sum1:CodigoPais')] + #[SerializedName('sum1:CodigoPais')] // iso 2 country code protected $CodigoPais; /** @var string */ diff --git a/app/Services/EDocument/Standards/Verifactu/Types/PersonaFisicaJuridica.php b/app/Services/EDocument/Standards/Verifactu/Types/PersonaFisicaJuridica.php index 3c1fa06c4d..f651097f41 100644 --- a/app/Services/EDocument/Standards/Verifactu/Types/PersonaFisicaJuridica.php +++ b/app/Services/EDocument/Standards/Verifactu/Types/PersonaFisicaJuridica.php @@ -6,129 +6,60 @@ use Symfony\Component\Serializer\Annotation\SerializedName; class PersonaFisicaJuridica { - /** @var string|null */ - #[SerializedName('sum1:TipoPersona')] - protected $TipoPersona; - /** @var string */ + #[SerializedName('sum1:NombreRazon')] + protected $NombreRazon; + + /** @var string|null */ #[SerializedName('sum1:NIF')] protected $NIF; - /** @var string|null */ + /** @var IDOtro|null */ #[SerializedName('sum1:IDOtro')] protected $IDOtro; - /** @var string|null */ - #[SerializedName('sum1:CodigoPais')] - protected $CodigoPais; - - /** @var string|null */ - #[SerializedName('sum1:IDType')] - protected $IDType; - - /** @var string|null */ - #[SerializedName('sum1:ID')] - protected $ID; - - /** @var string|null */ - #[SerializedName('sum1:Web')] - protected $Web; - - public function getTipoPersona(): ?string + public function getNombreRazon(): string { - return $this->TipoPersona; + return $this->NombreRazon; } - public function setTipoPersona(?string $tipoPersona): self + public function setNombreRazon(string $nombreRazon): self { - if ($tipoPersona !== null && !in_array($tipoPersona, ['F', 'J'])) { - throw new \InvalidArgumentException('TipoPersona must be either "F" (Física) or "J" (Jurídica)'); + if (strlen($nombreRazon) > 120) { + throw new \InvalidArgumentException('NombreRazon must not exceed 120 characters'); } - $this->TipoPersona = $tipoPersona; + $this->NombreRazon = $nombreRazon; return $this; } - public function getNIF(): string + public function getNIF(): ?string { return $this->NIF; } - public function setNIF(string $nif): self + public function setNIF(?string $nif): self { - if (!preg_match('/^[A-Z0-9]{9}$/', $nif)) { - throw new \InvalidArgumentException('NIF must be a valid NIF (9 alphanumeric characters)'); + if ($nif !== null) { + if (!preg_match('/^[A-Z0-9]{9}$/', $nif)) { + throw new \InvalidArgumentException('NIF must be a valid NIF (9 alphanumeric characters)'); + } + $this->NIF = $nif; + $this->IDOtro = null; // Clear IDOtro as it's a choice } - $this->NIF = $nif; return $this; } - public function getIDOtro(): ?string + public function getIDOtro(): ?IDOtro { return $this->IDOtro; } - public function setIDOtro(?string $idOtro): self + public function setIDOtro(?IDOtro $idOtro): self { - if ($idOtro !== null && strlen($idOtro) > 20) { - throw new \InvalidArgumentException('IDOtro must not exceed 20 characters'); + if ($idOtro !== null) { + $this->IDOtro = $idOtro; + $this->NIF = null; // Clear NIF as it's a choice } - $this->IDOtro = $idOtro; return $this; } - - public function getCodigoPais(): ?string - { - return $this->CodigoPais; - } - - public function setCodigoPais(?string $codigoPais): self - { - if ($codigoPais !== null && !preg_match('/^[A-Z]{2}$/', $codigoPais)) { - throw new \InvalidArgumentException('CodigoPais must be a 2-letter ISO country code'); - } - $this->CodigoPais = $codigoPais; - return $this; - } - - public function getIDType(): ?string - { - return $this->IDType; - } - - public function setIDType(?string $idType): self - { - if ($idType !== null && !in_array($idType, ['02', '03', '04', '05', '06', '07'])) { - throw new \InvalidArgumentException('IDType must be one of: 02 (NIF-IVA), 03 (Pasaporte), 04 (Doc oficial país residencia), 05 (Cert residencia), 06 (Otro doc probatorio), 07 (No censado))'); - } - $this->IDType = $idType; - return $this; - } - - public function getID(): ?string - { - return $this->ID; - } - - public function setID(?string $id): self - { - if ($id !== null && strlen($id) > 20) { - throw new \InvalidArgumentException('ID must not exceed 20 characters'); - } - $this->ID = $id; - return $this; - } - - public function getWeb(): ?string - { - return $this->Web; - } - - public function setWeb(?string $web): self - { - if ($web !== null && strlen($web) > 500) { - throw new \InvalidArgumentException('Web must not exceed 500 characters'); - } - $this->Web = $web; - return $this; - } -} \ No newline at end of file +} \ No newline at end of file diff --git a/app/Services/EDocument/Standards/Verifactu/Types/PersonaFisicaJuridicaES.php b/app/Services/EDocument/Standards/Verifactu/Types/PersonaFisicaJuridicaES.php index 70348d0fc6..637301d78e 100644 --- a/app/Services/EDocument/Standards/Verifactu/Types/PersonaFisicaJuridicaES.php +++ b/app/Services/EDocument/Standards/Verifactu/Types/PersonaFisicaJuridicaES.php @@ -4,6 +4,7 @@ namespace App\Services\EDocument\Standards\Verifactu\Types; use Symfony\Component\Serializer\Annotation\SerializedName; +// User type is a person submitting on behalf of the company. class PersonaFisicaJuridicaES { /** @var string NIF format */ diff --git a/app/Services/EDocument/Standards/Verifactu/Types/RegistroFacturacionAlta.php b/app/Services/EDocument/Standards/Verifactu/Types/RegistroFacturacionAlta.php index 01e8b147a3..7fb800a0f1 100644 --- a/app/Services/EDocument/Standards/Verifactu/Types/RegistroFacturacionAlta.php +++ b/app/Services/EDocument/Standards/Verifactu/Types/RegistroFacturacionAlta.php @@ -124,7 +124,7 @@ class RegistroFacturacionAlta /** @var string Max length 64 characters */ #[SerializedName('sum1:Huella')] - protected $Huella; + protected $Huella = ''; /** @var string|null */ #[SerializedName('sum1:Signature')]