Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/Models/Records/IssuedBy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
namespace Komma\Verifactu\Models\Records;

/**
* Factura emitida por un tercero o por el destinatario
*
* @field EmitidaPorTerceroODestinatario
*/
enum IssuedBy: string {
/** Emitida por un tercero en nombre del obligado (requiere el bloque Tercero) */
case ThirdParty = 'T';

/** Emitida por el destinatario (autofacturación) */
case Recipient = 'D';
}
57 changes: 57 additions & 0 deletions src/Models/Records/RegistrationRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ class RegistrationRecord extends Record {
#[Assert\Count(max: 1000)]
public array $recipients = [];

/**
* Factura emitida por un tercero o por el destinatario (autofacturación)
*
* @field EmitidaPorTerceroODestinatario
*/
public ?IssuedBy $issuedBy = null;

/**
* Tercero que expide la factura en nombre del obligado
*
* @field Tercero
*/
#[Assert\Valid]
public ?FiscalIdentifier $thirdParty = null;

/**
* Tipo de factura rectificativa
*
Expand Down Expand Up @@ -252,6 +267,25 @@ private function hasRegimeWithoutTotalCheck(): bool {
return false;
}

#[Assert\Callback]
final public function validateIssuedBy(ExecutionContextInterface $context): void {
if ($this->issuedBy === IssuedBy::ThirdParty && $this->thirdParty === null) {
$context->buildViolation('An invoice issued by a third party requires the third party')
->atPath('thirdParty')
->addViolation();
}
if ($this->issuedBy !== IssuedBy::ThirdParty && $this->thirdParty !== null) {
$context->buildViolation('Only an invoice issued by a third party can carry the third party')
->atPath('thirdParty')
->addViolation();
}
if ($this->issuedBy === IssuedBy::Recipient && count($this->recipients) === 0) {
$context->buildViolation('A self-billed invoice requires its recipient, who issues it')
->atPath('recipients')
->addViolation();
}
}

#[Assert\Callback]
final public function validateRecipients(ExecutionContextInterface $context): void {
if (!isset($this->invoiceType)) {
Expand Down Expand Up @@ -418,6 +452,19 @@ protected function importCustomProperties(UXML $recordElement): void {
}
$this->description = $description;

// Issued by a third party or by the recipient
$rawIssuedBy = $recordElement->get('sum1:EmitidaPorTerceroODestinatario')?->asText();
if ($rawIssuedBy !== null) {
$this->issuedBy = IssuedBy::tryFrom($rawIssuedBy) ?? throw new ImportException('Invalid <sum1:EmitidaPorTerceroODestinatario /> value');
}
$terceroElement = $recordElement->get('sum1:Tercero');
if ($terceroElement !== null) {
$this->thirdParty = new FiscalIdentifier(
$terceroElement->get('sum1:NombreRazon')?->asText() ?? throw new ImportException('Missing <sum1:NombreRazon /> from <sum1:Tercero /> element'),
$terceroElement->get('sum1:NIF')?->asText() ?? throw new ImportException('Missing <sum1:NIF /> from <sum1:Tercero /> element'),
);
}

// Recipients
foreach ($recordElement->getAll('sum1:Destinatarios/sum1:IDDestinatario') as $destinatarioElement) {
$recipientName = $destinatarioElement->get('sum1:NombreRazon')?->asText();
Expand Down Expand Up @@ -526,6 +573,16 @@ protected function exportCustomProperties(UXML $recordElement): void {
// Description
$recordElement->add('sum1:DescripcionOperacion', $this->description);

// Issued by a third party or by the recipient
if ($this->issuedBy !== null) {
$recordElement->add('sum1:EmitidaPorTerceroODestinatario', $this->issuedBy->value);
}
if ($this->thirdParty !== null) {
$terceroElement = $recordElement->add('sum1:Tercero');
$terceroElement->add('sum1:NombreRazon', $this->thirdParty->name);
$terceroElement->add('sum1:NIF', $this->thirdParty->nif);
}

// Recipients
if (count($this->recipients) > 0) {
$destinatariosElement = $recordElement->add('sum1:Destinatarios');
Expand Down
49 changes: 49 additions & 0 deletions tests/Models/Records/RegistrationRecordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Komma\Verifactu\Models\Records\ForeignIdType;
use Komma\Verifactu\Models\Records\InvoiceIdentifier;
use Komma\Verifactu\Models\Records\InvoiceType;
use Komma\Verifactu\Models\Records\IssuedBy;
use Komma\Verifactu\Models\Records\OperationType;
use Komma\Verifactu\Models\Records\Record;
use Komma\Verifactu\Models\Records\RegimeType;
Expand Down Expand Up @@ -410,6 +411,54 @@ public function testValidatesReplacedInvoices(): void {
$record->validate();
}

public function testValidatesIssuedBy(): void {
$record = Record::fromXml(TestUtils::getXmlFile(__DIR__ . '/registration-record-f1.xml'));
$this->assertInstanceOf(RegistrationRecord::class, $record);

$record->issuedBy = IssuedBy::ThirdParty;
try {
$record->validate();
$this->fail('An invoice issued by a third party without the third party must not validate');
} catch (InvalidModelException $e) {
$this->assertStringContainsString('requires the third party', $e->getMessage());
}

$record->thirdParty = new FiscalIdentifier('Gestoría Rías SL', 'B70000021');
$record->validate();

$record->issuedBy = IssuedBy::Recipient;
try {
$record->validate();
$this->fail('A self-billed invoice must not carry a third party');
} catch (InvalidModelException $e) {
$this->assertStringContainsString('Only an invoice issued by a third party', $e->getMessage());
}

$record->thirdParty = null;
$record->validate();
$this->assertSame(IssuedBy::Recipient, $record->issuedBy);
}

public function testExportsAndImportsIssuedByRecipient(): void {
$modelXml = TestUtils::getXmlFile(__DIR__ . '/registration-record-f1.xml');
$record = Record::fromXml($modelXml);
$this->assertInstanceOf(RegistrationRecord::class, $record);
$record->issuedBy = IssuedBy::Recipient;

$computerSystemXml = $modelXml->get('sum1:SistemaInformatico');
$this->assertNotNull($computerSystemXml);
$exportedXml = UXML::newInstance('container', null, ['xmlns:sum1' => Record::NS]);
$record->export($exportedXml, ComputerSystem::fromXml($computerSystemXml));

$xml = $exportedXml->get('sum1:RegistroAlta')?->asXML() ?? '';
$this->assertStringContainsString('<sum1:EmitidaPorTerceroODestinatario>D</sum1:EmitidaPorTerceroODestinatario>', $xml);
$this->assertLessThan(strpos($xml, '<sum1:Destinatarios>'), strpos($xml, '<sum1:EmitidaPorTerceroODestinatario>'));

$reimported = Record::fromXml($exportedXml->get('sum1:RegistroAlta'));
$this->assertInstanceOf(RegistrationRecord::class, $reimported);
$this->assertSame(IssuedBy::Recipient, $reimported->issuedBy);
}

#[DataProvider('xmlPathsProvider')]
public function testImportsAndExportsXmlElement(string $xmlPath): void {
// Import model
Expand Down
Loading