Update mapper class, line by line!

This commit is contained in:
David Bomba 2025-05-05 10:09:39 +10:00
parent e641825b3a
commit f1b374ee38
8 changed files with 278 additions and 155 deletions

View File

@ -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 */

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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 */

View File

@ -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;
}
}

View File

@ -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 */

View File

@ -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')]