diff --git a/Core/Lib/Accounting/TrialBalance.php b/Core/Lib/Accounting/TrialBalance.php
new file mode 100644
index 0000000000..81c157df90
--- /dev/null
+++ b/Core/Lib/Accounting/TrialBalance.php
@@ -0,0 +1,123 @@
+
+ *
+ * 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\Core\Lib\Accounting;
+
+use FacturaScripts\Core\Base\DataBase;
+use FacturaScripts\Core\Model\Asiento;
+use FacturaScripts\Core\Tools;
+use FacturaScripts\Dinamic\Model\Partida;
+
+/**
+ * Balance de sumas y saldos calculado directamente desde las partidas (nunca
+ * desde los saldos en caché de subcuentas/cuentas, que pueden estar
+ * desactualizados tras importaciones masivas). Agrupa por grupo (1 dígito),
+ * cuenta (3 dígitos) o subcuenta, con filtro opcional de fechas, y excluye
+ * por defecto los asientos de regularización y cierre.
+ *
+ * @author Santiago Lopez
+ */
+class TrialBalance
+{
+ const LEVEL_GROUP = 'grupo';
+ const LEVEL_ACCOUNT = 'cuenta';
+ const LEVEL_SUBACCOUNT = 'subcuenta';
+
+ /**
+ * Devuelve el balance de sumas y saldos del ejercicio como lista de filas
+ * ['codigo' => string, 'debe' => float, 'haber' => float, 'saldo' => float],
+ * ordenadas por código.
+ *
+ * Parámetros opcionales:
+ * - fecha_desde / fecha_hasta: acotar por fecha de asiento
+ * - include_closing: incluir los asientos de regularización (R) y cierre (C),
+ * excluidos por defecto para que el balance no salga a cero en ejercicios cerrados
+ *
+ * @param string $codejercicio
+ * @param string $level una de las constantes LEVEL_*
+ * @param array $params
+ *
+ * @return array
+ */
+ public static function generate(string $codejercicio, string $level = self::LEVEL_GROUP, array $params = []): array
+ {
+ $lengths = [
+ self::LEVEL_GROUP => 1,
+ self::LEVEL_ACCOUNT => 3,
+ self::LEVEL_SUBACCOUNT => 10
+ ];
+ $length = $lengths[$level] ?? 1;
+
+ $db = new DataBase();
+ $sql = 'SELECT SUBSTR(p.codsubcuenta, 1, ' . $length . ') AS codigo,'
+ . ' COALESCE(SUM(p.debe), 0) AS debe, COALESCE(SUM(p.haber), 0) AS haber'
+ . ' FROM ' . Partida::tableName() . ' p'
+ . ' JOIN ' . Asiento::tableName() . ' a ON a.idasiento = p.idasiento'
+ . ' WHERE a.codejercicio = ' . $db->var2str($codejercicio);
+
+ if (empty($params['include_closing'])) {
+ $sql .= " AND (a.operacion IS NULL OR a.operacion NOT IN ("
+ . $db->var2str(Asiento::OPERATION_REGULARIZATION) . ', '
+ . $db->var2str(Asiento::OPERATION_CLOSING) . '))';
+ }
+ if (!empty($params['fecha_desde'])) {
+ $sql .= ' AND a.fecha >= ' . $db->var2str($params['fecha_desde']);
+ }
+ if (!empty($params['fecha_hasta'])) {
+ $sql .= ' AND a.fecha <= ' . $db->var2str($params['fecha_hasta']);
+ }
+
+ $sql .= ' GROUP BY SUBSTR(p.codsubcuenta, 1, ' . $length . ') ORDER BY codigo';
+
+ $rows = [];
+ foreach ($db->select($sql) as $row) {
+ $debe = Tools::round((float)$row['debe']);
+ $haber = Tools::round((float)$row['haber']);
+ $rows[] = [
+ 'codigo' => $row['codigo'],
+ 'debe' => $debe,
+ 'haber' => $haber,
+ 'saldo' => Tools::round($debe - $haber)
+ ];
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Resultado del ejercicio calculado desde las partidas: la suma de
+ * (haber - debe) de los grupos 6 y 7. Positivo = beneficio.
+ *
+ * @param string $codejercicio
+ * @param array $params los mismos que generate()
+ *
+ * @return float
+ */
+ public static function result(string $codejercicio, array $params = []): float
+ {
+ $result = 0.0;
+ foreach (self::generate($codejercicio, self::LEVEL_GROUP, $params) as $row) {
+ if (in_array($row['codigo'], ['6', '7'])) {
+ $result += $row['haber'] - $row['debe'];
+ }
+ }
+
+ return Tools::round($result);
+ }
+}
diff --git a/Test/Core/Lib/TrialBalanceTest.php b/Test/Core/Lib/TrialBalanceTest.php
new file mode 100644
index 0000000000..48454d2e69
--- /dev/null
+++ b/Test/Core/Lib/TrialBalanceTest.php
@@ -0,0 +1,218 @@
+
+ *
+ * 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\Core\Lib;
+
+use FacturaScripts\Core\Lib\Accounting\TrialBalance;
+use FacturaScripts\Core\Model\Asiento;
+use FacturaScripts\Core\Model\Ejercicio;
+use FacturaScripts\Core\Tools;
+use FacturaScripts\Core\Where;
+use FacturaScripts\Dinamic\Model\Subcuenta;
+use FacturaScripts\Test\Traits\DefaultSettingsTrait;
+use FacturaScripts\Test\Traits\LogErrorsTrait;
+use PHPUnit\Framework\TestCase;
+
+final class TrialBalanceTest extends TestCase
+{
+ use DefaultSettingsTrait;
+ use LogErrorsTrait;
+
+ public static function setUpBeforeClass(): void
+ {
+ self::setDefaultSettings();
+
+ // nos aseguramos de que existe el ejercicio actual antes de instalar el plan contable
+ $exercise = new Ejercicio();
+ $exercise->idempresa = Tools::settings('default', 'idempresa', 1);
+ $exercise->loadFromDate(Tools::date());
+
+ self::installAccountingPlan();
+ self::removeTaxRegularization();
+ }
+
+ public function testTrialBalanceByGroup(): void
+ {
+ // creamos el asiento para conocer el ejercicio por defecto
+ $asiento = new Asiento();
+ $asiento->concepto = 'Test balance por grupo';
+ $this->assertTrue($asiento->save(), 'asiento-cant-save');
+ $codejercicio = $asiento->getExercise()->codejercicio;
+
+ $sub5 = $this->getSubaccountByGroup($codejercicio, '5');
+ $sub6 = $this->getSubaccountByGroup($codejercicio, '6');
+ $sub7 = $this->getSubaccountByGroup($codejercicio, '7');
+ $this->assertNotNull($sub5, 'no-group-5-subaccount');
+ $this->assertNotNull($sub6, 'no-group-6-subaccount');
+ $this->assertNotNull($sub7, 'no-group-7-subaccount');
+
+ // el balance puede contener datos de otros tests: comparamos diferencias
+ $before = $this->indexByCode(TrialBalance::generate($codejercicio));
+ $resultBefore = TrialBalance::result($codejercicio);
+
+ // DR 6xx 100, DR 5xx 50 / CR 7xx 150
+ $this->addLine($asiento, $sub6, 100, 0);
+ $this->addLine($asiento, $sub5, 50, 0);
+ $this->addLine($asiento, $sub7, 0, 150);
+
+ $after = $this->indexByCode(TrialBalance::generate($codejercicio));
+ $this->assertEqualsWithDelta(100.0, ($after['6']['debe'] ?? 0) - ($before['6']['debe'] ?? 0), 0.001, 'group-6-debe-wrong');
+ $this->assertEqualsWithDelta(50.0, ($after['5']['debe'] ?? 0) - ($before['5']['debe'] ?? 0), 0.001, 'group-5-debe-wrong');
+ $this->assertEqualsWithDelta(150.0, ($after['7']['haber'] ?? 0) - ($before['7']['haber'] ?? 0), 0.001, 'group-7-haber-wrong');
+
+ // resultado = (haber - debe) de los grupos 6 y 7: -100 + 150 = +50
+ $this->assertEqualsWithDelta(50.0, TrialBalance::result($codejercicio) - $resultBefore, 0.001, 'result-wrong');
+
+ // eliminamos y comprobamos que el balance vuelve al estado anterior
+ $this->assertTrue($asiento->delete(), 'asiento-cant-delete');
+ $this->assertEqualsWithDelta($resultBefore, TrialBalance::result($codejercicio), 0.001, 'result-not-restored');
+ }
+
+ public function testTrialBalanceByAccountAndSubaccount(): void
+ {
+ $asiento = new Asiento();
+ $asiento->concepto = 'Test balance por cuenta';
+ $this->assertTrue($asiento->save(), 'asiento-cant-save');
+ $codejercicio = $asiento->getExercise()->codejercicio;
+
+ $sub6 = $this->getSubaccountByGroup($codejercicio, '6');
+ $sub7 = $this->getSubaccountByGroup($codejercicio, '7');
+
+ $account6 = substr($sub6->codsubcuenta, 0, 3);
+ $beforeAccount = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_ACCOUNT));
+ $beforeSub = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_SUBACCOUNT));
+
+ // DR 6xx 30 / CR 7xx 30
+ $this->addLine($asiento, $sub6, 30, 0);
+ $this->addLine($asiento, $sub7, 0, 30);
+
+ // nivel cuenta: la cuenta de la subcuenta 6xx suma 30 al debe
+ $afterAccount = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_ACCOUNT));
+ $this->assertEqualsWithDelta(30.0,
+ ($afterAccount[$account6]['debe'] ?? 0) - ($beforeAccount[$account6]['debe'] ?? 0), 0.001,
+ 'account-debe-wrong');
+
+ // nivel subcuenta: la subcuenta exacta suma 30 al debe
+ $afterSub = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_SUBACCOUNT));
+ $this->assertEqualsWithDelta(30.0,
+ ($afterSub[$sub6->codsubcuenta]['debe'] ?? 0) - ($beforeSub[$sub6->codsubcuenta]['debe'] ?? 0), 0.001,
+ 'subaccount-debe-wrong');
+
+ $this->assertTrue($asiento->delete(), 'asiento-cant-delete');
+ }
+
+ public function testExcludesClosingEntries(): void
+ {
+ // asiento de regularización (operacion R)
+ $asiento = new Asiento();
+ $asiento->concepto = 'Test regularización';
+ $asiento->operacion = Asiento::OPERATION_REGULARIZATION;
+ $this->assertTrue($asiento->save(), 'asiento-cant-save');
+ $codejercicio = $asiento->getExercise()->codejercicio;
+
+ $sub6 = $this->getSubaccountByGroup($codejercicio, '6');
+ $sub7 = $this->getSubaccountByGroup($codejercicio, '7');
+
+ $before = $this->indexByCode(TrialBalance::generate($codejercicio));
+ $beforeIncluding = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['include_closing' => true]));
+
+ $this->addLine($asiento, $sub6, 77, 0);
+ $this->addLine($asiento, $sub7, 0, 77);
+
+ // por defecto los asientos R quedan fuera del balance
+ $after = $this->indexByCode(TrialBalance::generate($codejercicio));
+ $this->assertEqualsWithDelta(0.0, ($after['6']['debe'] ?? 0) - ($before['6']['debe'] ?? 0), 0.001, 'closing-not-excluded');
+
+ // con include_closing sí aparecen
+ $afterIncluding = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['include_closing' => true]));
+ $this->assertEqualsWithDelta(77.0,
+ ($afterIncluding['6']['debe'] ?? 0) - ($beforeIncluding['6']['debe'] ?? 0), 0.001,
+ 'closing-not-included');
+
+ $this->assertTrue($asiento->delete(), 'asiento-cant-delete');
+ }
+
+ public function testDateFilter(): void
+ {
+ $asiento = new Asiento();
+ $asiento->concepto = 'Test filtro fechas';
+ $this->assertTrue($asiento->save(), 'asiento-cant-save');
+ $codejercicio = $asiento->getExercise()->codejercicio;
+
+ $sub6 = $this->getSubaccountByGroup($codejercicio, '6');
+ $sub7 = $this->getSubaccountByGroup($codejercicio, '7');
+
+ // rango que excluye la fecha del asiento (posterior a hoy)
+ $tomorrow = date('Y-m-d', strtotime($asiento->fecha . ' +1 day'));
+ $beforeOut = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['fecha_desde' => $tomorrow]));
+ $beforeIn = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['fecha_hasta' => $asiento->fecha]));
+
+ $this->addLine($asiento, $sub6, 11, 0);
+ $this->addLine($asiento, $sub7, 0, 11);
+
+ // fuera del rango: sin cambios
+ $afterOut = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['fecha_desde' => $tomorrow]));
+ $this->assertEqualsWithDelta(0.0, ($afterOut['6']['debe'] ?? 0) - ($beforeOut['6']['debe'] ?? 0), 0.001, 'date-filter-not-applied');
+
+ // dentro del rango: aparece
+ $afterIn = $this->indexByCode(TrialBalance::generate($codejercicio, TrialBalance::LEVEL_GROUP, ['fecha_hasta' => $asiento->fecha]));
+ $this->assertEqualsWithDelta(11.0, ($afterIn['6']['debe'] ?? 0) - ($beforeIn['6']['debe'] ?? 0), 0.001, 'date-filter-wrong');
+
+ $this->assertTrue($asiento->delete(), 'asiento-cant-delete');
+ }
+
+ private function addLine(Asiento $asiento, Subcuenta $subcuenta, float $debe, float $haber): void
+ {
+ $line = $asiento->getNewLine();
+ $line->setAccount($subcuenta);
+ $line->concepto = 'Test partida';
+ $line->debe = $debe;
+ $line->haber = $haber;
+ $this->assertTrue($line->save(), 'linea-cant-save');
+ }
+
+ private function getSubaccountByGroup(string $codejercicio, string $group): ?Subcuenta
+ {
+ $where = [
+ Where::eq('codejercicio', $codejercicio),
+ Where::gte('codsubcuenta', $group),
+ Where::lt('codsubcuenta', (string)((int)$group + 1))
+ ];
+ foreach (Subcuenta::all($where, ['codsubcuenta' => 'ASC'], 0, 1) as $item) {
+ return $item;
+ }
+
+ return null;
+ }
+
+ private function indexByCode(array $rows): array
+ {
+ $indexed = [];
+ foreach ($rows as $row) {
+ $indexed[$row['codigo']] = $row;
+ }
+
+ return $indexed;
+ }
+
+ protected function tearDown(): void
+ {
+ $this->logErrors();
+ }
+}