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 @@
-
-
+
+