diff --git a/Controller/EditRegularizacionImpuesto.php b/Controller/EditRegularizacionImpuesto.php index cdc4625..8c3aa6f 100644 --- a/Controller/EditRegularizacionImpuesto.php +++ b/Controller/EditRegularizacionImpuesto.php @@ -352,6 +352,59 @@ protected function downloadTxtAction(): bool return false; } + /** + * Runs the standard edit save and, when the record didn't have an accounting entry yet, + * checks whether the user has just linked an existing one by hand (picking it in the + * "accounting-entry" field instead of using the "create-accounting-entry" button). In + * that case, validates it and completes the derived fields (accounting date and lock) + * so it gets correctly excluded from later tax settlements, exactly as it already + * happens for entries generated automatically. + * + * @return bool + */ + protected function editAction(): bool + { + $before = new RegularizacionImpuesto(); + $hadEntry = $before->load($this->request->input('code', '')) && !empty($before->idasiento); + + if (false === parent::editAction()) { + return false; + } + + if (false === $hadEntry) { + $this->linkManualAccountingEntry(); + } + + return true; + } + + /** + * Validates the accounting entry the user has just picked in the "accounting-entry" + * field and completes its derived fields (accounting date and lock). If it's not a + * valid entry (different company or already linked to another settlement), the field + * is cleared so no invalid link remains stored. + * + * @return void + */ + private function linkManualAccountingEntry(): void + { + $reg = $this->getModel(); + if (empty($reg->idasiento)) { + return; + } + + $idasiento = (int)$reg->idasiento; + $reg->idasiento = null; + + $linker = new VatRegularizationToAccounting(); + if (false === $linker->linkExisting($reg, $idasiento)) { + $reg->save(); + return; + } + + Tools::log()->notice('record-updated-correctly'); + } + /** * Looks up the immediately previous tax settlement of the same company and copies its * pending-for-later-periods result (box 87) into box 110 (cuotas a compensar pendientes diff --git a/Lib/Accounting/VatRegularizationToAccounting.php b/Lib/Accounting/VatRegularizationToAccounting.php index 91a641d..b2e7b83 100644 --- a/Lib/Accounting/VatRegularizationToAccounting.php +++ b/Lib/Accounting/VatRegularizationToAccounting.php @@ -27,6 +27,7 @@ use FacturaScripts\Dinamic\Lib\SubAccountTools; use FacturaScripts\Dinamic\Model\Asiento; use FacturaScripts\Dinamic\Model\Join\PartidaImpuestoResumen; +use FacturaScripts\Dinamic\Model\RegularizacionImpuesto as DinRegularizacionImpuesto; use FacturaScripts\Dinamic\Model\Subcuenta; /** @@ -77,6 +78,46 @@ public function generate(RegularizacionImpuesto &$reg): bool return false; } + /** + * Links an already-existing accounting entry (typically created by hand, without using + * this assistant) to a tax settlement, instead of generating a new one. This lets the + * exclusion logic used to compute later settlements (see commonTaxWhere() and + * getSubtotals(), both keyed on RegularizacionImpuesto::idasiento) recognize the manual + * entry and exclude it, exactly as it already does for automatically generated entries. + * + * @param RegularizacionImpuesto $reg + * @param int $idasiento + * @return bool + */ + public function linkExisting(RegularizacionImpuesto &$reg, int $idasiento): bool + { + if ($reg->idasiento) { + Tools::log()->warning('accounting-entry-already-created'); + return false; + } + + $asiento = new Asiento(); + if (false === $asiento->load($idasiento) || $asiento->idempresa != $reg->idempresa) { + Tools::log()->warning('accounting-entry-invalid'); + return false; + } + + // el asiento no puede estar ya vinculado a otra regularización + $where = [ + Where::eq('idasiento', $asiento->idasiento), + Where::notEq('idregiva', $reg->idregiva), + ]; + if (false === empty(DinRegularizacionImpuesto::all($where, [], 0, 1))) { + Tools::log()->warning('accounting-entry-already-linked'); + return false; + } + + $reg->idasiento = $asiento->idasiento; + $reg->fechaasiento = $asiento->fecha; + $reg->bloquear = true; + return $reg->save(); + } + protected function addAccountingResultLine(Asiento $accEntry, RegularizacionImpuesto $reg): bool { $subaccount = new Subcuenta(); diff --git a/Test/main/VatRegularizationLinkExistingTest.php b/Test/main/VatRegularizationLinkExistingTest.php new file mode 100644 index 0000000..581e016 --- /dev/null +++ b/Test/main/VatRegularizationLinkExistingTest.php @@ -0,0 +1,161 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Test\Plugins; + +use FacturaScripts\Core\Tools; +use FacturaScripts\Dinamic\Model\Asiento; +use FacturaScripts\Dinamic\Model\RegularizacionImpuesto; +use FacturaScripts\Plugins\Modelo303\Lib\Accounting\VatRegularizationToAccounting; +use FacturaScripts\Test\Traits\DefaultSettingsTrait; +use FacturaScripts\Test\Traits\LogErrorsTrait; +use FacturaScripts\Test\Traits\RandomDataTrait; + +/** + * These tests cover the reported bug: when a user creates the VAT regularization + * accounting entry by hand (instead of using the "Crear asiento contable" assistant), + * the entry was never recognized as a regularization and kept being counted in later + * tax settlements. VatRegularizationToAccounting::linkExisting() lets a manual entry be + * linked to a settlement, so it's registered exactly like an automatically generated + * one and excluded afterwards. + */ +final class VatRegularizationLinkExistingTest extends Modelo303TestCase +{ + use DefaultSettingsTrait; + use LogErrorsTrait; + use RandomDataTrait; + + public static function setUpBeforeClass(): void + { + self::setDefaultSettings(); + self::installAccountingPlan(); + self::removeTaxRegularization(); + } + + public function testLinkExistingManualEntry() + { + $exercise = $this->getRandomExercise(); + + // creamos a mano un asiento de regularización, sin pasar por el asistente del plugin + $manualEntry = new Asiento(); + $manualEntry->codejercicio = $exercise->codejercicio; + $manualEntry->idempresa = (int)Tools::settings('default', 'idempresa'); + $manualEntry->concepto = 'Regularización manual de IVA'; + $manualEntry->fecha = date('31-03-Y', strtotime($exercise->fechainicio)); + $this->assertTrue($manualEntry->save()); + $this->addCleanup(static function () use ($manualEntry) { + if ($manualEntry->exists()) { + $manualEntry->delete(); + } + }); + + // creamos la liquidación del trimestre + $reg = new RegularizacionImpuesto(); + $reg->codejercicio = $exercise->codejercicio; + $reg->periodo = 'T1'; + $this->assertTrue($reg->save()); + $this->addCleanup(static function () use ($reg) { + if ($reg->exists()) { + $reg->delete(); + } + }); + + // antes de vincularlo, el asiento manual no aparece entre las regularizaciones registradas + $linkedIds = array_map(static fn($r) => $r->idasiento, RegularizacionImpuesto::all()); + $this->assertNotContains($manualEntry->idasiento, $linkedIds); + + // lo vinculamos a la regularización, en lugar de generar un asiento nuevo + $linker = new VatRegularizationToAccounting(); + $this->assertTrue($linker->linkExisting($reg, $manualEntry->idasiento)); + + // se ha completado la fecha del asiento y se ha bloqueado la regularización + $this->assertEquals($manualEntry->idasiento, $reg->idasiento); + $this->assertEquals($manualEntry->fecha, $reg->fechaasiento); + $this->assertTrue($reg->bloquear); + + // ahora sí, el asiento manual queda registrado como asiento de una regularización, + // que es justo el criterio que usa el plugin para excluirlo de los cálculos + // posteriores (ver commonTaxWhere() y getSubtotals()) + $linkedIds = array_map(static fn($r) => $r->idasiento, RegularizacionImpuesto::all()); + $this->assertContains($manualEntry->idasiento, $linkedIds); + } + + public function testCannotLinkNonexistentEntry() + { + $exercise = $this->getRandomExercise(); + + $reg = new RegularizacionImpuesto(); + $reg->codejercicio = $exercise->codejercicio; + $reg->periodo = 'T1'; + $this->assertTrue($reg->save()); + $this->addCleanup(static function () use ($reg) { + if ($reg->exists()) { + $reg->delete(); + } + }); + + $linker = new VatRegularizationToAccounting(); + $this->assertFalse($linker->linkExisting($reg, 999999999)); + $this->assertEmpty($reg->idasiento); + } + + public function testCannotLinkEntryAlreadyLinkedToAnotherSettlement() + { + $exercise = $this->getRandomExercise(); + + $manualEntry = new Asiento(); + $manualEntry->codejercicio = $exercise->codejercicio; + $manualEntry->idempresa = (int)Tools::settings('default', 'idempresa'); + $manualEntry->concepto = 'Regularización manual de IVA'; + $manualEntry->fecha = date('31-03-Y', strtotime($exercise->fechainicio)); + $this->assertTrue($manualEntry->save()); + $this->addCleanup(static function () use ($manualEntry) { + if ($manualEntry->exists()) { + $manualEntry->delete(); + } + }); + + $reg1 = new RegularizacionImpuesto(); + $reg1->codejercicio = $exercise->codejercicio; + $reg1->periodo = 'T1'; + $this->assertTrue($reg1->save()); + $this->addCleanup(static function () use ($reg1) { + if ($reg1->exists()) { + $reg1->delete(); + } + }); + + $reg2 = new RegularizacionImpuesto(); + $reg2->codejercicio = $exercise->codejercicio; + $reg2->periodo = 'T2'; + $this->assertTrue($reg2->save()); + $this->addCleanup(static function () use ($reg2) { + if ($reg2->exists()) { + $reg2->delete(); + } + }); + + $linker = new VatRegularizationToAccounting(); + $this->assertTrue($linker->linkExisting($reg1, $manualEntry->idasiento)); + + // el mismo asiento no puede vincularse también a la segunda liquidación + $this->assertFalse($linker->linkExisting($reg2, $manualEntry->idasiento)); + $this->assertEmpty($reg2->idasiento); + } +} diff --git a/Translation/es_ES.json b/Translation/es_ES.json index c7c7ed2..3fc08e5 100644 --- a/Translation/es_ES.json +++ b/Translation/es_ES.json @@ -1,5 +1,7 @@ { "accounting-entry-already-created": "Asiento ya creado anteriormente.", + "accounting-entry-already-linked": "El asiento seleccionado ya está vinculado a otra regularización de impuestos.", + "accounting-entry-invalid": "El asiento seleccionado no existe o pertenece a otra empresa.", "accounting-entry-not-created": "Asiento no creado.", "accounting-entry-not-found": "No se han encontrado asientos para este periodo", "aeat-file-303": "Presentación AEAT (fichero .303)", diff --git a/XMLView/EditRegularizacionImpuesto.xml b/XMLView/EditRegularizacionImpuesto.xml index d8fe130..ffe323a 100644 --- a/XMLView/EditRegularizacionImpuesto.xml +++ b/XMLView/EditRegularizacionImpuesto.xml @@ -78,8 +78,8 @@ - - + +