diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c1661c3..aa99ad8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ Los cambios notables de cada lanzamiento serán documentados en este archivo.
- #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).
+- #258 Retención/Percepción: agregar `Payment::numero` (número de cuota) usado en `cac:Payment/cbc:ID`, validar un solo pago/cobro por detalle de retención y percepción (XSD SUNAT) y leer número/fecha de pago por nodo en los parsers.
## 5.0.0 - 2023-05-01
- #206 Agregar xml para nueva Guia de Remisión.
diff --git a/packages/core/src/Core/Model/Retention/Payment.php b/packages/core/src/Core/Model/Retention/Payment.php
index 94b31466..88a3631e 100644
--- a/packages/core/src/Core/Model/Retention/Payment.php
+++ b/packages/core/src/Core/Model/Retention/Payment.php
@@ -17,6 +17,15 @@
*/
class Payment
{
+ /**
+ * Número de pago (cuota) asignado a la factura.
+ *
+ * Si no se especifica, se utiliza la posición del pago dentro del detalle.
+ *
+ * @var int|null
+ */
+ private $numero;
+
/**
* Moneda de pago (igual a la moneda del documento de referencia).
*
@@ -38,6 +47,26 @@ class Payment
*/
private $fecha;
+ /**
+ * @return int|null
+ */
+ public function getNumero(): ?int
+ {
+ return $this->numero;
+ }
+
+ /**
+ * @param int|null $numero
+ *
+ * @return Payment
+ */
+ public function setNumero(?int $numero): Payment
+ {
+ $this->numero = $numero;
+
+ return $this;
+ }
+
/**
* @return string
*/
diff --git a/packages/validator/src/Validator/Loader/PaymentLoader.php b/packages/validator/src/Validator/Loader/PaymentLoader.php
index be3ac2e5..3c75bc1a 100644
--- a/packages/validator/src/Validator/Loader/PaymentLoader.php
+++ b/packages/validator/src/Validator/Loader/PaymentLoader.php
@@ -18,6 +18,7 @@ class PaymentLoader implements LoaderMetadataInterface
{
public function load(ClassMetadata $metadata)
{
+ $metadata->addPropertyConstraint('numero', new Assert\GreaterThan(0));
$metadata->addPropertyConstraints('moneda', [
new Assert\NotBlank(),
new Assert\Length(['min' => 3, 'max' => 3]),
diff --git a/packages/validator/src/Validator/Loader/PerceptionDetailLoader.php b/packages/validator/src/Validator/Loader/PerceptionDetailLoader.php
index d837f70b..5d53af42 100644
--- a/packages/validator/src/Validator/Loader/PerceptionDetailLoader.php
+++ b/packages/validator/src/Validator/Loader/PerceptionDetailLoader.php
@@ -33,6 +33,9 @@ public function load(ClassMetadata $metadata)
]);
$metadata->addPropertyConstraints('cobros', [
new Assert\NotBlank(),
+ // XSD SUNAT: cac:Payment maxOccurs=1 por SUNATPerceptionDocumentReference,
+ // para varios cobros de una factura agregar un detalle por cada cobro.
+ new Assert\Count(['max' => 1]),
new Assert\Valid(),
]);
$metadata->addPropertyConstraint('impCobrar', new Assert\NotNull());
diff --git a/packages/validator/src/Validator/Loader/RetentionDetailLoader.php b/packages/validator/src/Validator/Loader/RetentionDetailLoader.php
index 0f623d45..dd584321 100644
--- a/packages/validator/src/Validator/Loader/RetentionDetailLoader.php
+++ b/packages/validator/src/Validator/Loader/RetentionDetailLoader.php
@@ -35,6 +35,9 @@ public function load(ClassMetadata $metadata)
]);
$metadata->addPropertyConstraints('pagos', [
new Assert\NotBlank(),
+ // XSD SUNAT: cac:Payment maxOccurs=1 por SUNATRetentionDocumentReference,
+ // para varios pagos de una factura agregar un detalle por cada pago.
+ new Assert\Count(['max' => 1]),
new Assert\Valid(),
]);
$metadata->addPropertyConstraint('impPagar', new Assert\NotNull());
diff --git a/packages/validator/tests/Validator/CePerceptionValidatorTest.php b/packages/validator/tests/Validator/CePerceptionValidatorTest.php
index 2e25d8d1..d9115898 100644
--- a/packages/validator/tests/Validator/CePerceptionValidatorTest.php
+++ b/packages/validator/tests/Validator/CePerceptionValidatorTest.php
@@ -52,6 +52,32 @@ public function testValidatePerceptionInvalidIndExcepcional()
$this->assertEquals('indExcepcional', $errors->get(0)->getPropertyPath());
}
+ public function testValidatePerceptionMultiplesCobrosPorDetalle()
+ {
+ $perception = $this->getPerception();
+ $detail = $perception->getDetails()[0];
+ $cobro = (new Payment())
+ ->setNumero(2)
+ ->setMoneda('PEN')
+ ->setFecha(new DateTime())
+ ->setImporte(100);
+ $detail->setCobros(array_merge($detail->getCobros(), [$cobro]));
+
+ $errors = $this->getValidator()->validate($perception);
+
+ $this->assertEquals(1, $errors->count());
+ }
+
+ public function testValidatePerceptionNumeroCobroInvalido()
+ {
+ $perception = $this->getPerception();
+ $perception->getDetails()[0]->getCobros()[0]->setNumero(0);
+
+ $errors = $this->getValidator()->validate($perception);
+
+ $this->assertEquals(1, $errors->count());
+ }
+
/**
* @return Perception
*/
@@ -97,7 +123,8 @@ private function getPerception()
private function getExtras()
{
$pay = new Payment();
- $pay->setMoneda('PEN')
+ $pay->setNumero(1)
+ ->setMoneda('PEN')
->setFecha(new DateTime())
->setImporte(100);
diff --git a/packages/validator/tests/Validator/CeRetentionValidatorTest.php b/packages/validator/tests/Validator/CeRetentionValidatorTest.php
index e7fdb6ba..b8259053 100644
--- a/packages/validator/tests/Validator/CeRetentionValidatorTest.php
+++ b/packages/validator/tests/Validator/CeRetentionValidatorTest.php
@@ -30,6 +30,32 @@ public function testValidateRetention()
$this->assertEquals(0,$errors->count());
}
+ public function testValidateRetentionMultiplePagosPorDetalle()
+ {
+ $retention = $this->getRetention();
+ $detail = $retention->getDetails()[0];
+ $pay = (new Payment())
+ ->setNumero(2)
+ ->setMoneda('PEN')
+ ->setFecha(new \DateTime())
+ ->setImporte(100);
+ $detail->setPagos(array_merge($detail->getPagos(), [$pay]));
+
+ $errors = $this->getValidator()->validate($retention);
+
+ $this->assertEquals(1, $errors->count());
+ }
+
+ public function testValidateRetentionNumeroPagoInvalido()
+ {
+ $retention = $this->getRetention();
+ $retention->getDetails()[0]->getPagos()[0]->setNumero(0);
+
+ $errors = $this->getValidator()->validate($retention);
+
+ $this->assertEquals(1, $errors->count());
+ }
+
/**
* @return Retention
*/
@@ -77,7 +103,8 @@ private function getRetention()
private function getExtras()
{
$pay = new Payment();
- $pay->setMoneda('PEN')
+ $pay->setNumero(1)
+ ->setMoneda('PEN')
->setFecha(new \DateTime())
->setImporte(100);
diff --git a/packages/xml-parser/src/Xml/Parser/PerceptionParser.php b/packages/xml-parser/src/Xml/Parser/PerceptionParser.php
index 85c40a16..35faa471 100644
--- a/packages/xml-parser/src/Xml/Parser/PerceptionParser.php
+++ b/packages/xml-parser/src/Xml/Parser/PerceptionParser.php
@@ -172,10 +172,12 @@ private function getPayments(DOMNode $node)
$pays = $xml->getNodes('cac:Payment', $node);
foreach ($pays as $pay) {
$temp = $xml->getNode('cbc:PaidAmount', $pay);
+ $numero = $xml->getValue('cbc:ID', $pay);
$payment = new Payment();
- $payment->setMoneda($temp->getAttribute('currencyID'))
+ $payment->setNumero($numero === null || $numero === '' ? null : (int)$numero)
+ ->setMoneda($temp->getAttribute('currencyID'))
->setImporte((float)$temp->nodeValue)
- ->setFecha(new DateTime($xml->getValue('cbc:PaidDate')));
+ ->setFecha(new DateTime($xml->getValue('cbc:PaidDate', $pay)));
yield $payment;
}
diff --git a/packages/xml-parser/src/Xml/Parser/RetentionParser.php b/packages/xml-parser/src/Xml/Parser/RetentionParser.php
index a853830b..42dee4c6 100644
--- a/packages/xml-parser/src/Xml/Parser/RetentionParser.php
+++ b/packages/xml-parser/src/Xml/Parser/RetentionParser.php
@@ -167,10 +167,12 @@ private function getPayments(DOMNode $node)
$pays = $xml->getNodes('cac:Payment', $node);
foreach ($pays as $pay) {
$temp = $xml->getNode('cbc:PaidAmount', $pay);
+ $numero = $xml->getValue('cbc:ID', $pay);
$payment = new Payment();
- $payment->setMoneda($temp->getAttribute('currencyID'))
+ $payment->setNumero($numero === null || $numero === '' ? null : (int)$numero)
+ ->setMoneda($temp->getAttribute('currencyID'))
->setImporte((float)$temp->nodeValue)
- ->setFecha(new DateTime($xml->getValue('cbc:PaidDate')));
+ ->setFecha(new DateTime($xml->getValue('cbc:PaidDate', $pay)));
yield $payment;
}
diff --git a/packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php b/packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php
index e9488df9..6ae0c53a 100644
--- a/packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php
+++ b/packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php
@@ -37,6 +37,10 @@ public function testParseDoc($filename)
$this->assertNotEmpty($detail->getNumDoc());
$this->assertNotNull($detail->getFechaEmision());
$this->assertGreaterThan(0, count($detail->getCobros()));
+ foreach ($detail->getCobros() as $cobro) {
+ $this->assertGreaterThan(0, $cobro->getNumero());
+ $this->assertNotNull($cobro->getFecha());
+ }
$this->assertTrue(is_float($detail->getImpTotal()));
$this->assertTrue(is_float($detail->getImpPercibido()));
$this->assertTrue(is_float($detail->getImpCobrar()));
diff --git a/packages/xml-parser/tests/Xml/Parser/RetentionParserTest.php b/packages/xml-parser/tests/Xml/Parser/RetentionParserTest.php
index 0c5d9b39..7aacf8f0 100644
--- a/packages/xml-parser/tests/Xml/Parser/RetentionParserTest.php
+++ b/packages/xml-parser/tests/Xml/Parser/RetentionParserTest.php
@@ -37,6 +37,10 @@ public function testParseDoc($filename)
$this->assertNotEmpty($detail->getNumDoc());
$this->assertNotNull($detail->getFechaEmision());
$this->assertTrue(is_array($detail->getPagos()));
+ foreach ($detail->getPagos() as $pago) {
+ $this->assertGreaterThan(0, $pago->getNumero());
+ $this->assertNotNull($pago->getFecha());
+ }
$this->assertTrue(is_float($detail->getImpTotal()));
$this->assertTrue(is_float($detail->getImpRetenido()));
$this->assertTrue(is_float($detail->getImpPagar()));
diff --git a/packages/xml/src/Xml/Templates/perception.xml.twig b/packages/xml/src/Xml/Templates/perception.xml.twig
index eab37778..5152e639 100644
--- a/packages/xml/src/Xml/Templates/perception.xml.twig
+++ b/packages/xml/src/Xml/Templates/perception.xml.twig
@@ -76,7 +76,7 @@
{% if det.cobros %}
{% for cob in det.cobros %}
- {{ loop.index }}
+ {{ cob.numero|default(loop.index) }}
{{ cob.importe|n_format }}
{{ cob.fecha|date('Y-m-d') }}
diff --git a/packages/xml/src/Xml/Templates/retention.xml.twig b/packages/xml/src/Xml/Templates/retention.xml.twig
index e3a78b34..e6ad7695 100644
--- a/packages/xml/src/Xml/Templates/retention.xml.twig
+++ b/packages/xml/src/Xml/Templates/retention.xml.twig
@@ -73,7 +73,7 @@
{% if det.pagos %}
{% for pay in det.pagos %}
- {{ loop.index }}
+ {{ pay.numero|default(loop.index) }}
{{ pay.importe|n_format }}
{{ pay.fecha|date('Y-m-d') }}