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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Los cambios notables de cada lanzamiento serán documentados en este archivo.

## Unreleased
- CI: tests unitarios separados de los de integración (SUNAT beta ya no bloquea PRs); matrix PHP 7.4–8.4; actions actualizadas.
- #274 Soportar `sac:ExceptionalIndicator` (`indExcepcional`) en comprobante de Percepción.
- #276 CI: tests unitarios separados de los de integración (SUNAT beta ya no bloquea PRs).
- Corregir parámetros implícitamente nullable (deprecación PHP 8.4).
- Registrar filtros y funciones Twig del reporte con instancias (compatibilidad con Psalm 5.26).

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"twig/twig": "~3.0"
},
"require-dev": {
"greenter/ubl-validator": "^2.0",
"greenter/ubl-validator": "^2.3",
"mockery/mockery": "^1.2",
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^9"
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/Core/Model/Perception/Perception.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ class Perception implements DocumentInterface
*/
private $observacion;

/**
* Indicador excepcional.
*
* Campo 7 de la hoja «Percepciones1_0» de las Reglas de validación de SUNAT
* (actualizado al 26.08.2026, https://cpe.sunat.gob.pe/guias-y-manuales).
* Código de excepción; actualmente el único valor admitido es '01' - emisión
* excepcional (regla 3322). Cuando se consigna, el comprobante solo puede
* llevar un documento relacionado (3323) y ese documento debe ser una
* factura (3324).
*
* @var string|null
*/
private $indExcepcional;

/**
* Dato del Comprobante relacionado.
*
Expand Down Expand Up @@ -283,6 +297,26 @@ public function setObservacion(?string $observacion): Perception
return $this;
}

/**
* @return string|null
*/
public function getIndExcepcional(): ?string
{
return $this->indExcepcional;
}

/**
* @param string|null $indExcepcional
*
* @return Perception
*/
public function setIndExcepcional(?string $indExcepcional): Perception
{
$this->indExcepcional = $indExcepcional;

return $this;
}

/**
* @return PerceptionDetail[]
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public function load(ClassMetadata $metadata)
$metadata->addPropertyConstraint('impPercibido', new Assert\NotNull());
$metadata->addPropertyConstraint('impCobrado', new Assert\NotNull());
$metadata->addPropertyConstraint('observacion', new Assert\Length(['max' => 250]));
$metadata->addPropertyConstraint('indExcepcional', new Assert\Length(['min' => 2, 'max' => 2]));
$metadata->addPropertyConstraints('details', [
new Assert\NotBlank(),
new Assert\Valid(),
Expand Down
21 changes: 21 additions & 0 deletions packages/validator/tests/Validator/CePerceptionValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ public function testValidatePerception()
$this->assertEquals(0, $errors->count());
}

public function testValidatePerceptionWithIndExcepcional()
{
$perception = $this->getPerception();
$perception->setIndExcepcional('01');
$validator = $this->getValidator();
$errors = $validator->validate($perception);

$this->assertEquals(0, $errors->count());
}

public function testValidatePerceptionInvalidIndExcepcional()
{
$perception = $this->getPerception();
$perception->setIndExcepcional('1');
$validator = $this->getValidator();
$errors = $validator->validate($perception);

$this->assertEquals(1, $errors->count());
$this->assertEquals('indExcepcional', $errors->get(0)->getPropertyPath());
}

/**
* @return Perception
*/
Expand Down
1 change: 1 addition & 0 deletions packages/xml-parser/src/Xml/Parser/PerceptionParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public function parse($value): ?DocumentInterface
$perception->setSerie($idNum[0])
->setCorrelativo($idNum[1])
->setFechaEmision(new DateTime($xml->getValue('cbc:IssueDate')))
->setIndExcepcional($xml->getValue('sac:ExceptionalIndicator', $root, null))
->setCompany($this->getCompany())
->setProveedor($this->getClient())
->setRegimen($xml->getValue('sac:SUNATPerceptionSystemCode'))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<Perception xmlns="urn:sunat:names:specification:ubl:peru:schema:xsd:Perception-1" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ccts="urn:un:unece:uncefact:documentation:2" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2" xmlns:sac="urn:sunat:names:specification:ubl:peru:schema:xsd:SunatAggregateComponents-1" xmlns:udt="urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent>
<ds:Signature Id="NubeFacTSign"/>
</ext:ExtensionContent>
</ext:UBLExtension>
</ext:UBLExtensions>
<cbc:UBLVersionID>2.0</cbc:UBLVersionID>
<cbc:CustomizationID>1.0</cbc:CustomizationID>
<cac:Signature>
<cbc:ID>20000000001</cbc:ID>
<cac:SignatoryParty>
<cac:PartyIdentification>
<cbc:ID>20000000001</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>TECNOMAQ V &amp; L SRL</cbc:Name>
</cac:PartyName>
</cac:SignatoryParty>
<cac:DigitalSignatureAttachment>
<cac:ExternalReference>
<cbc:URI>20000000001</cbc:URI>
</cac:ExternalReference>
</cac:DigitalSignatureAttachment>
</cac:Signature>
<cbc:ID>P001-2</cbc:ID>
<cbc:IssueDate>2018-01-26</cbc:IssueDate>
<sac:ExceptionalIndicator>01</sac:ExceptionalIndicator>
<cac:AgentParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6">20000000001</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>TECNOMAQ V &amp; L SRL</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:ID>150122</cbc:ID>
<cbc:StreetName>Miraflore</cbc:StreetName>
<cbc:CitySubdivisionName>SANTIAGO DE CHUCO</cbc:CitySubdivisionName>
<cbc:CityName>SANTIAGO DE CHUCO</cbc:CityName>
<cbc:CountrySubentity>LIMA</cbc:CountrySubentity>
<cbc:District>MIRAFLORES</cbc:District>
<cac:Country>
<cbc:IdentificationCode>PE</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>TECNOMAQ V &amp; L SRL</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:AgentParty>
<cac:ReceiverParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6">1724754054</cbc:ID>
</cac:PartyIdentification>
<cac:PartyLegalEntity>
<cbc:RegistrationName>TECNOMAQ V &amp; L SRL</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:ReceiverParty>
<sac:SUNATPerceptionSystemCode>01</sac:SUNATPerceptionSystemCode>
<sac:SUNATPerceptionPercent>2.0</sac:SUNATPerceptionPercent>
<cbc:Note>NOTA</cbc:Note>
<cbc:TotalInvoiceAmount currencyID="PEN">0.02</cbc:TotalInvoiceAmount>
<sac:SUNATTotalCashed currencyID="PEN">1.02</sac:SUNATTotalCashed>
<sac:SUNATPerceptionDocumentReference>
<cbc:ID schemeID="01">F001-12</cbc:ID>
<cbc:IssueDate>2018-01-26</cbc:IssueDate>
<cbc:TotalInvoiceAmount currencyID="PEN">12.00</cbc:TotalInvoiceAmount>
<cac:Payment>
<cbc:ID>212</cbc:ID>
<cbc:PaidAmount currencyID="PEN">1.00</cbc:PaidAmount>
<cbc:PaidDate>2018-01-26</cbc:PaidDate>
</cac:Payment>
<sac:SUNATPerceptionInformation>
<sac:SUNATPerceptionAmount currencyID="PEN">0.02</sac:SUNATPerceptionAmount>
<sac:SUNATPerceptionDate>2018-01-26</sac:SUNATPerceptionDate>
<sac:SUNATNetTotalCashed currencyID="PEN">1.02</sac:SUNATNetTotalCashed>
<cac:ExchangeRate>
<cbc:SourceCurrencyCode>PEN</cbc:SourceCurrencyCode>
<cbc:TargetCurrencyCode>PEN</cbc:TargetCurrencyCode>
<cbc:CalculationRate>1.0</cbc:CalculationRate>
<cbc:Date>2018-01-26</cbc:Date>
</cac:ExchangeRate>
</sac:SUNATPerceptionInformation>
</sac:SUNATPerceptionDocumentReference>
</Perception>
23 changes: 23 additions & 0 deletions packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ public function testParseDoc($filename)
}
}

/**
* Field 7 of SUNAT's "Percepciones1_0" sheet, and conditional: absent
* from a document that is not exceptionally issued.
*/
public function testParseIndExcepcional()
{
$withIndicator = $this->parseResource('20000000001-40-P001-2.xml');
$this->assertSame('01', $withIndicator->getIndExcepcional());

$without = $this->parseResource('20000000001-40-P001-1.xml');
$this->assertNull($without->getIndExcepcional());
}

private function parseResource(string $filename): Perception
{
$path = __DIR__.'/../../Resources/perception/'.$filename;

/**@var $obj Perception */
$obj = $this->getParser()->parse(file_get_contents($path));

return $obj;
}

public function providerDocs()
{
$files = glob(__DIR__.'/../../Resources/perception/*.xml');
Expand Down
2 changes: 1 addition & 1 deletion packages/xml/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"require-dev": {
"greenter/data": "^5.0",
"greenter/ubl-validator": "^2.0",
"greenter/ubl-validator": "^2.3",
"phpunit/phpunit": "^9"
},
"autoload": {
Expand Down
3 changes: 3 additions & 0 deletions packages/xml/src/Xml/Templates/perception.xml.twig
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
<cbc:ID>{{ doc.serie }}-{{ doc.correlativo }}</cbc:ID>
<cbc:IssueDate>{{ doc.fechaEmision|date('Y-m-d') }}</cbc:IssueDate>
<cbc:IssueTime>{{ doc.fechaEmision|date('H:i:s') }}</cbc:IssueTime>
{% if doc.indExcepcional %}
<sac:ExceptionalIndicator>{{ doc.indExcepcional }}</sac:ExceptionalIndicator>
{% endif %}
<cac:AgentParty>
<cac:PartyIdentification>
<cbc:ID schemeID="6">{{ emp.ruc }}</cbc:ID>
Expand Down
53 changes: 53 additions & 0 deletions packages/xml/tests/Xml/Builder/CePerceptionBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

namespace Tests\Greenter\Xml\Builder;

use DOMDocument;
use DOMElement;
use Greenter\Data\Generator\PerceptionStore;
use Greenter\Model\Perception\Perception;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -42,6 +44,39 @@ public function testCreateXmlPerceptionWithoutInformation()
$this->assertSchema($xml);
}

public function testCreateXmlPerceptionWithIndExcepcional()
{
/**@var $perception Perception*/
$perception = $this->createDocument(PerceptionStore::class);
$detail = $perception->getDetails()[0];
$detail->setTipoDoc('01');
$perception
->setDetails([$detail])
->setIndExcepcional('01');

$xml = $this->build($perception);

$this->assertStringContainsString('<sac:ExceptionalIndicator>01</sac:ExceptionalIndicator>', $xml);

$names = $this->getRootChildNames($xml);
$indicator = array_search('sac:ExceptionalIndicator', $names, true);
$this->assertNotFalse($indicator);
$this->assertGreaterThan(array_search('cbc:IssueTime', $names, true), $indicator);
$this->assertLessThan(array_search('cac:AgentParty', $names, true), $indicator);

$this->assertSchema($xml);
}

public function testCreateXmlPerceptionWithoutIndExcepcional()
{
$perception = $this->createDocument(PerceptionStore::class);

$xml = $this->build($perception);

$this->assertStringNotContainsString('ExceptionalIndicator', $xml);
$this->assertSchema($xml);
}

public function testPerceptionFilename()
{
/**@var $perception Perception*/
Expand All @@ -51,6 +86,24 @@ public function testPerceptionFilename()
$this->assertEquals($this->getFilename($perception), $filename);
}

/**
* @return string[]
*/
private function getRootChildNames(string $xml): array
{
$doc = new DOMDocument();
$doc->loadXML($xml);

$names = [];
foreach ($doc->documentElement->childNodes as $node) {
if ($node instanceof DOMElement) {
$names[] = $node->nodeName;
}
}

return $names;
}

private function getFileName(Perception $perception)
{
$parts = [
Expand Down
Loading