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
133 changes: 133 additions & 0 deletions Core/Lib/Accounting/BankStatementMatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php
/**
* This file is part of 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\Core\Lib\Accounting;

use FacturaScripts\Core\Base\DataBase;
use FacturaScripts\Core\Model\Asiento;
use FacturaScripts\Dinamic\Model\Partida;

/**
* Concilia las líneas de un extracto bancario (fecha + importe) con las
* partidas del libro diario: cada línea se empareja con la partida más
* cercana en fecha (dentro de una tolerancia de días) cuyo importe coincida.
* Los importes positivos casan con el debe (entradas) y los negativos con
* el haber (salidas). Cada partida solo se empareja una vez.
*
* @author Santiago Lopez <santilh@gmail.com>
*/
class BankStatementMatcher
{
/** Tolerancia de importes al comparar con las partidas */
const AMOUNT_TOLERANCE = 0.005;

/**
* Empareja las líneas del extracto con las partidas.
*
* Cada línea del extracto es un array con al menos:
* - fecha: 'Y-m-d'
* - importe: positivo = entrada (debe), negativo = salida (haber)
* El resto de claves de la línea se conservan en el resultado.
*
* Parámetros opcionales:
* - codsubcuenta: limitar a una subcuenta o prefijo (p. ej. '572')
* - codejercicio: limitar a un ejercicio
* - days: tolerancia de días entre extracto y asiento (3 por defecto)
*
* @param array $lines
* @param array $params
*
* @return array ['matched' => array, 'unmatched' => array]
*/
public static function match(array $lines, array $params = []): array
{
$days = (int)($params['days'] ?? 3);
$db = new DataBase();

$matched = [];
$unmatched = [];
$usedPartidas = [];
foreach ($lines as $line) {
$amount = (float)($line['importe'] ?? 0);
$date = $line['fecha'] ?? '';
if (empty($date) || abs($amount) < self::AMOUNT_TOLERANCE) {
$unmatched[] = $line;
continue;
}

$best = null;
$bestDistance = null;
foreach (self::candidates($db, $amount, $date, $days, $params) as $row) {
if (isset($usedPartidas[$row['idpartida']])) {
continue;
}

$distance = abs((strtotime($row['fecha']) - strtotime($date)) / 86400);
if ($bestDistance === null || $distance < $bestDistance) {
$best = $row;
$bestDistance = $distance;
}
}

if ($best === null) {
$unmatched[] = $line;
continue;
}

$usedPartidas[$best['idpartida']] = true;
$matched[] = [
'line' => $line,
'idpartida' => (int)$best['idpartida'],
'idasiento' => (int)$best['idasiento'],
'fecha' => $best['fecha'],
'codsubcuenta' => $best['codsubcuenta'],
'concepto' => $best['concepto'],
'dias' => (int)round($bestDistance)
];
}

return ['matched' => $matched, 'unmatched' => $unmatched];
}

protected static function candidates(DataBase $db, float $amount, string $date, int $days, array $params): array
{
// los importes positivos casan con el debe, los negativos con el haber
$column = $amount >= 0 ? 'p.debe' : 'p.haber';
$fromDate = date('Y-m-d', strtotime($date . ' -' . $days . ' days'));
$toDate = date('Y-m-d', strtotime($date . ' +' . $days . ' days'));

$sql = 'SELECT p.idpartida, p.idasiento, p.codsubcuenta, p.concepto, a.fecha'
. ' FROM ' . Partida::tableName() . ' p'
. ' JOIN ' . Asiento::tableName() . ' a ON a.idasiento = p.idasiento'
. ' WHERE ABS(' . $column . ' - ' . $db->var2str(abs($amount)) . ') <= ' . $db->var2str(self::AMOUNT_TOLERANCE)
. ' AND a.fecha >= ' . $db->var2str($fromDate)
. ' AND a.fecha <= ' . $db->var2str($toDate);

if (!empty($params['codsubcuenta'])) {
$code = $params['codsubcuenta'];
$pattern = strpos($code, '%') === false && strlen($code) < 10 ? $code . '%' : $code;
$sql .= ' AND p.codsubcuenta LIKE ' . $db->var2str($pattern);
}
if (!empty($params['codejercicio'])) {
$sql .= ' AND a.codejercicio = ' . $db->var2str($params['codejercicio']);
}

return $db->select($sql . ' ORDER BY p.idpartida');
}
}
190 changes: 190 additions & 0 deletions Test/Core/Lib/BankStatementMatcherTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php
/**
* This file is part of 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\Core\Lib;

use FacturaScripts\Core\Lib\Accounting\BankStatementMatcher;
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 BankStatementMatcherTest 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 testMatchesByAmountAndSign(): void
{
// asiento: DR subcuenta1 8642.97 / CR subcuenta2 8642.97
$asiento = new Asiento();
$asiento->concepto = 'Test conciliación';
$this->assertTrue($asiento->save(), 'asiento-cant-save');
$codejercicio = $asiento->getExercise()->codejercicio;

$sub1 = $this->getSampleSubaccount($codejercicio, 0);
$sub2 = $this->getSampleSubaccount($codejercicio, 1);
$debitLine = $this->addLine($asiento, $sub1, 8642.97, 0);
$creditLine = $this->addLine($asiento, $sub2, 0, 8642.97);

$result = BankStatementMatcher::match([
['fecha' => $asiento->fecha, 'importe' => 8642.97, 'concepto' => 'entrada banco'],
['fecha' => $asiento->fecha, 'importe' => -8642.97, 'concepto' => 'salida banco'],
['fecha' => $asiento->fecha, 'importe' => 999888.77, 'concepto' => 'sin correspondencia'],
], ['codejercicio' => $codejercicio]);

$this->assertCount(2, $result['matched'], 'wrong-matched-count');
$this->assertCount(1, $result['unmatched'], 'wrong-unmatched-count');

// el importe positivo casa con la partida del debe, el negativo con la del haber
$this->assertEquals($debitLine->idpartida, $result['matched'][0]['idpartida'], 'debit-not-matched');
$this->assertEquals($creditLine->idpartida, $result['matched'][1]['idpartida'], 'credit-not-matched');
$this->assertEquals('sin correspondencia', $result['unmatched'][0]['concepto'], 'wrong-unmatched-line');

$this->assertTrue($asiento->delete(), 'asiento-cant-delete');
}

public function testDateTolerance(): void
{
$asiento = new Asiento();
$asiento->concepto = 'Test tolerancia fechas';
$this->assertTrue($asiento->save(), 'asiento-cant-save');
$codejercicio = $asiento->getExercise()->codejercicio;

$sub1 = $this->getSampleSubaccount($codejercicio, 0);
$sub2 = $this->getSampleSubaccount($codejercicio, 1);
$this->addLine($asiento, $sub1, 7531.86, 0);
$this->addLine($asiento, $sub2, 0, 7531.86);

// la línea del extracto llega 2 días después del asiento
$extractDate = date('Y-m-d', strtotime($asiento->fecha . ' +2 days'));
$lines = [['fecha' => $extractDate, 'importe' => 7531.86]];

// con la tolerancia por defecto (3 días) casa
$result = BankStatementMatcher::match($lines, ['codejercicio' => $codejercicio]);
$this->assertCount(1, $result['matched'], 'not-matched-within-tolerance');
$this->assertEquals(2, $result['matched'][0]['dias'], 'wrong-date-distance');

// con tolerancia de 1 día no casa
$result = BankStatementMatcher::match($lines, ['codejercicio' => $codejercicio, 'days' => 1]);
$this->assertCount(0, $result['matched'], 'matched-outside-tolerance');
$this->assertCount(1, $result['unmatched'], 'wrong-unmatched-count');

$this->assertTrue($asiento->delete(), 'asiento-cant-delete');
}

public function testEachPartidaMatchesOnce(): void
{
$asiento = new Asiento();
$asiento->concepto = 'Test partida única';
$this->assertTrue($asiento->save(), 'asiento-cant-save');
$codejercicio = $asiento->getExercise()->codejercicio;

$sub1 = $this->getSampleSubaccount($codejercicio, 0);
$sub2 = $this->getSampleSubaccount($codejercicio, 1);
$this->addLine($asiento, $sub1, 6420.13, 0);
$this->addLine($asiento, $sub2, 0, 6420.13);

// dos líneas idénticas del extracto, pero solo hay una partida del debe
$result = BankStatementMatcher::match([
['fecha' => $asiento->fecha, 'importe' => 6420.13],
['fecha' => $asiento->fecha, 'importe' => 6420.13],
], ['codejercicio' => $codejercicio]);

$this->assertCount(1, $result['matched'], 'partida-matched-twice');
$this->assertCount(1, $result['unmatched'], 'wrong-unmatched-count');

$this->assertTrue($asiento->delete(), 'asiento-cant-delete');
}

public function testSubaccountFilter(): void
{
$asiento = new Asiento();
$asiento->concepto = 'Test filtro subcuenta';
$this->assertTrue($asiento->save(), 'asiento-cant-save');
$codejercicio = $asiento->getExercise()->codejercicio;

$sub1 = $this->getSampleSubaccount($codejercicio, 0);
$sub2 = $this->getSampleSubaccount($codejercicio, 1);
$this->addLine($asiento, $sub1, 5319.24, 0);
$this->addLine($asiento, $sub2, 0, 5319.24);

$lines = [['fecha' => $asiento->fecha, 'importe' => 5319.24]];

// filtrando por la subcuenta correcta casa
$result = BankStatementMatcher::match($lines, [
'codejercicio' => $codejercicio,
'codsubcuenta' => $sub1->codsubcuenta
]);
$this->assertCount(1, $result['matched'], 'not-matched-with-filter');

// filtrando por otra subcuenta no casa (el debe está en sub1)
$result = BankStatementMatcher::match($lines, [
'codejercicio' => $codejercicio,
'codsubcuenta' => $sub2->codsubcuenta
]);
$this->assertCount(0, $result['matched'], 'matched-with-wrong-filter');

$this->assertTrue($asiento->delete(), 'asiento-cant-delete');
}

private function addLine(Asiento $asiento, Subcuenta $subcuenta, float $debe, float $haber)
{
$line = $asiento->getNewLine();
$line->setAccount($subcuenta);
$line->concepto = 'Test partida';
$line->debe = $debe;
$line->haber = $haber;
$this->assertTrue($line->save(), 'linea-cant-save');

return $line;
}

private function getSampleSubaccount(string $codejercicio, int $offset): ?Subcuenta
{
$where = [Where::eq('codejercicio', $codejercicio)];
foreach (Subcuenta::all($where, ['codsubcuenta' => 'ASC'], $offset, 1) as $item) {
return $item;
}

return null;
}

protected function tearDown(): void
{
$this->logErrors();
}
}