invoiceninja/app/Services/EDocument/Gateway/Storecove/StorecoveAdapter.php

456 lines
16 KiB
PHP

<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Gateway\Storecove;
use App\DataMapper\Tax\BaseRule;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use InvoiceNinja\EInvoice\Models\Peppol\PaymentMeans;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use App\Services\EDocument\Gateway\Storecove\Storecove;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use App\Services\EDocument\Gateway\Storecove\Models\Invoice;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
use InvoiceNinja\EInvoice\Models\Peppol\Invoice as PeppolInvoice;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
use App\Services\EDocument\Gateway\Storecove\PeppolToStorecoveNormalizer;
use App\Services\EDocument\Standards\Peppol;
use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter;
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
class StorecoveAdapter
{
public function __construct(public Storecove $storecove){}
private Invoice $storecove_invoice;
private array $errors = [];
private bool $valid_document = true;
private $ninja_invoice;
private string $nexus;
public function validate(): self
{
return $this;
}
public function getInvoice(): Invoice
{
return $this->storecove_invoice;
}
public function getErrors(): array
{
return $this->errors;
}
/**
* addError
*
* Adds an error to the errors array.
*
* @param string $error
* @return self
*/
private function addError(string $error): self
{
$this->errors[] = $error;
return $this;
}
/**
* transform
*
* @param \App\Models\Invoice $invoice
* @return self
*/
public function transform($invoice): self
{
$this->ninja_invoice = $invoice;
$serializer = $this->getSerializer();
/** Currently - due to class structures, the serialization process goes like this:
*
* e-invoice => Peppol -> XML -> Peppol Decoded -> encode to Peppol -> deserialize to Storecove
*/
$p = (new Peppol($invoice))->run()->toXml();
$context = [
DateTimeNormalizer::FORMAT_KEY => 'Y-m-d',
AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
];
nlog($p);
$e = new \InvoiceNinja\EInvoice\EInvoice();
$peppolInvoice = $e->decode('Peppol', $p, 'xml');
$parent = \App\Services\EDocument\Gateway\Storecove\Models\Invoice::class;
$peppolInvoice = $e->encode($peppolInvoice, 'json');
$this->storecove_invoice = $serializer->deserialize($peppolInvoice, $parent, 'json', $context);
$this->buildNexus();
return $this;
}
public function getNexus(): string
{
return $this->nexus;
}
public function decorate(): self
{
//set all taxmap countries - resolve the taxing country
$lines = $this->storecove_invoice->getInvoiceLines();
foreach($lines as &$line)
{
if(isset($line->taxes_duties_fees))
{
foreach($line->taxes_duties_fees as &$tax)
{
$tax->country = $this->nexus;
if(property_exists($tax,'category'))
$tax->category = $this->tranformTaxCode($tax->category);
}
unset($tax);
}
if(isset($line->allowance_charges))
{
foreach($line->allowance_charges as &$allowance)
{
if($allowance->reason == ctrans('texts.discount'))
$allowance->amount_excluding_tax = $allowance->amount_excluding_tax * -1;
foreach($allowance->getTaxesDutiesFees() ?? [] as &$tax)
{
if (property_exists($tax, 'category')) {
$tax->category = $this->tranformTaxCode($tax->category);
}
}
unset($tax);
}
unset($allowance);
}
}
$this->storecove_invoice->setInvoiceLines($lines);
$tax_subtotals = $this->storecove_invoice->getTaxSubtotals();
foreach($tax_subtotals as &$tax)
{
$tax->country = $this->nexus;
if (property_exists($tax, 'category'))
$tax->category = $this->tranformTaxCode($tax->category);
}
unset($tax);
$this->storecove_invoice->setTaxSubtotals($tax_subtotals);
//configure identifiers
//update payment means codes to storecove equivalents
$payment_means = $this->storecove_invoice->getPaymentMeansArray();
foreach($payment_means as &$pm)
{
$pm->code = $this->transformPaymentMeansCode($pm->code);
}
$this->storecove_invoice->setPaymentMeansArray($payment_means);
$allowances = $this->storecove_invoice->getAllowanceCharges() ?? [];
foreach($allowances as &$allowance)
{
$taxes = $allowance->getTaxesDutiesFees() ?? [];
foreach($taxes as &$tax)
{
$tax->country = $this->nexus;
if (property_exists($tax, 'category')) {
$tax->category = $this->tranformTaxCode($tax->category);
}
}
unset($tax);
if ($allowance->reason == ctrans('texts.discount')) {
nlog($allowance->amount_excluding_tax);
$allowance->amount_excluding_tax = $allowance->amount_excluding_tax * -1;
}
$allowance->setTaxesDutiesFees($taxes);
}
unset($allowance);
$this->storecove_invoice->setAllowanceCharges($allowances);
$this->storecove_invoice->setTaxSystem('tax_line_percentages');
//resolve and set the public identifier for the customer
$accounting_customer_party = $this->storecove_invoice->getAccountingCustomerParty();
if(strlen($this->ninja_invoice->client->vat_number) > 2)
{
$id = $this->ninja_invoice->client->vat_number;
$scheme = $this->storecove->router->resolveTaxScheme($this->ninja_invoice->client->country->iso_3166_2, $this->ninja_invoice->client->classification ?? 'individual');
$pi = new \App\Services\EDocument\Gateway\Storecove\Models\PublicIdentifiers($scheme, $id);
$accounting_customer_party->addPublicIdentifiers($pi);
$this->storecove_invoice->setAccountingCustomerParty($accounting_customer_party);
}
return $this;
}
private function getSerializer()
{
$phpDocExtractor = new PhpDocExtractor();
$reflectionExtractor = new ReflectionExtractor();
$typeExtractors = [$reflectionExtractor,$phpDocExtractor];
$descriptionExtractors = [$phpDocExtractor];
$propertyInitializableExtractors = [$reflectionExtractor];
$propertyInfo = new PropertyInfoExtractor(
$propertyInitializableExtractors,
$descriptionExtractors,
$typeExtractors,
);
$xml_encoder = new XmlEncoder(['xml_format_output' => true, 'remove_empty_tags' => true,]);
$json_encoder = new JsonEncoder();
$classMetadataFactory = new ClassMetadataFactory(new AttributeLoader());
$metadataAwareNameConverter = new MetadataAwareNameConverter($classMetadataFactory, new CamelCaseToSnakeCaseNameConverter());
$normalizer = new ObjectNormalizer($classMetadataFactory, $metadataAwareNameConverter, null, $propertyInfo);
$normalizers = [new DateTimeNormalizer(), $normalizer, new ArrayDenormalizer()];
$encoders = [$xml_encoder, $json_encoder];
$serializer = new Serializer($normalizers, $encoders);
return $serializer;
}
/**
* Builds the document and appends an errors prop
*
* @return array
*/
public function getDocument(): mixed
{
$serializer = $this->getSerializer();
$context = [
DateTimeNormalizer::FORMAT_KEY => 'Y-m-d',
AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
];
$s_invoice = $serializer->encode($this->storecove_invoice, 'json', $context);
$s_invoice = json_decode($s_invoice, true);
$s_invoice = $this->removeEmptyValues($s_invoice);
$data = [
'errors' => $this->getErrors(),
'document' => $s_invoice,
];
return $data;
}
/**
* RemoveEmptyValues
*
* @param array $array
* @return array
*/
private function removeEmptyValues(array $array): array
{
foreach ($array as $key => $value) {
if (is_array($value)) {
$array[$key] = $this->removeEmptyValues($value);
if (empty($array[$key])) {
unset($array[$key]);
}
} elseif ($value === null || $value === '') {
unset($array[$key]);
}
}
// nlog($array);
return $array;
}
private function buildNexus(): self
{
nlog("building nexus");
//Calculate nexus
$company_country_code = $this->ninja_invoice->company->country()->iso_3166_2;
$client_country_code = $this->ninja_invoice->client->country->iso_3166_2;
$br = new BaseRule();
$eu_countries = $br->eu_country_codes;
if ($client_country_code == $company_country_code) {
//Domestic Sales
nlog("domestic sales");
$this->nexus = $company_country_code;
} elseif (in_array($company_country_code, $eu_countries) && !in_array($client_country_code, $eu_countries)) {
//NON-EU Sale
nlog("non eu");
$this->nexus = $company_country_code;
} elseif (in_array($client_country_code, $eu_countries)) {
//EU Sale where Company country != Client Country
// First, determine if we're over threshold
$is_over_threshold = isset($this->ninja_invoice->company->tax_data->regions->EU->has_sales_above_threshold) &&
$this->ninja_invoice->company->tax_data->regions->EU->has_sales_above_threshold;
// Is this B2B or B2C?
$is_b2c = strlen($this->ninja_invoice->client->vat_number) < 2 ||
!($this->ninja_invoice->client->has_valid_vat_number ?? false) ||
$this->ninja_invoice->client->classification == 'individual';
if (strlen($this->ninja_invoice->company->settings->vat_number) < 2) {
// No VAT registration at all - must charge origin country VAT
nlog("no company vat");
$this->nexus = $company_country_code;
} elseif ($is_b2c) {
if ($is_over_threshold) {
// B2C over threshold - need destination VAT number
if (!isset($this->ninja_invoice->company->tax_data->regions->EU->subregions->{$client_country_code}->vat_number)) {
$this->nexus = $client_country_code;
$this->addError("Tax Nexus is client country ({$client_country_code}) - however VAT number not present for this region. Document not sent!");
return $this;
}
nlog("B2C");
$this->nexus = $client_country_code;
$this->setupDestinationVAT($client_country_code);
} else {
nlog("under threshold origina country");
// B2C under threshold - origin country VAT
$this->nexus = $company_country_code;
}
} else {
nlog("B2B with valid vat");
// B2B with valid VAT - origin country
$this->nexus = $company_country_code;
}
nlog("nexus = {$this->nexus}");
nlog($this->ninja_invoice->company->tax_data->regions->EU->has_sales_above_threshold);
nlog("is b2c {$is_b2c}");
nlog("is over threshold {$is_over_threshold}");
}
return $this;
}
private function setupDestinationVAT($client_country_code):self
{
nlog("configuring destination tax");
$this->storecove_invoice->setConsumerTaxMode(true);
$id = $this->ninja_invoice->company->tax_data->regions->EU->subregions->{$client_country_code}->vat_number;
$scheme = $this->storecove->router->resolveTaxScheme($client_country_code, $this->ninja_invoice->client->classification ?? 'individual');
$pi = new \App\Services\EDocument\Gateway\Storecove\Models\PublicIdentifiers($scheme, $id);
$asp = $this->storecove_invoice->getAccountingSupplierParty();
$asp->addPublicIdentifiers($pi);
$this->storecove_invoice->setAccountingSupplierParty($asp);
return $this;
}
private function tranformTaxCode(string $code): ?string
{
return match($code){
'S' => 'standard',
'Z' => 'zero_rated',
'E' => 'exempt',
'AE' => 'reverse_charge',
'K' => 'intra_community',
'G' => 'export',
'O' => 'outside_scope',
'L' => 'cgst',
'I' => 'igst',
'SS' => 'sgst',
'B' => 'deemed_supply',
'SR' => 'srca_s',
'SC' => 'srca_c',
'NR' => 'not_registered',
default => null
};
}
private function transformPaymentMeansCode(?string $code): string
{
return match($code){
'30' => 'credit_transfer',
'58' => 'sepa_credit_transfer',
'31' => 'debit_transfer',
'49' => 'direct_debit',
'59' => 'sepa_direct_debit',
'48' => 'card', // Generic card payment
'54' => 'bank_card',
'55' => 'credit_card',
'57' => 'standing_agreement',
'10' => 'cash',
'20' => 'bank_cheque',
'21' => 'cashiers_cheque',
'97' => 'aunz_npp',
'98' => 'aunz_npp_payid',
'99' => 'aunz_npp_payto',
'71' => 'aunz_bpay',
'72' => 'aunz_postbillpay',
'73' => 'aunz_uri',
'50' => 'se_bankgiro',
'51' => 'se_plusgiro',
'74' => 'sg_giro',
'75' => 'sg_card',
'76' => 'sg_paynow',
'77' => 'it_mav',
'78' => 'it_pagopa',
'42' => 'nl_ga_beneficiary',
'43' => 'nl_ga_gaccount',
'1' => 'undefined', // Instrument not defined
default => 'undefined',
};
}
}