Skip to content
Open
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
53 changes: 53 additions & 0 deletions Controller/EditRegularizacionImpuesto.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions Lib/Accounting/VatRegularizationToAccounting.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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();
Expand Down
161 changes: 161 additions & 0 deletions Test/main/VatRegularizationLinkExistingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php
/**
* This file is part of Modelo303 plugin for FacturaScripts
* Copyright (C) 2026 Carlos Garcia Gomez <carlos@facturascripts.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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);
}
}
2 changes: 2 additions & 0 deletions Translation/es_ES.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand Down
4 changes: 2 additions & 2 deletions XMLView/EditRegularizacionImpuesto.xml
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@
</widget>
</column>
<column name="accounting-entry" order="120">
<widget type="autocomplete" fieldname="idasiento" onclick="EditAsiento" readonly="true">
<values source="asientos" fieldcode="idasiento" fieldtitle="numero"/>
<widget type="autocomplete" fieldname="idasiento" onclick="EditAsiento" readonly="dinamic">
<values source="asientos" fieldcode="idasiento" fieldtitle="numero" fieldfilter="idempresa"/>
</widget>
</column>
<column name="accounting-date" order="130">
Expand Down