diff --git a/Core/Lib/Accounting/BankStatementMatcher.php b/Core/Lib/Accounting/BankStatementMatcher.php new file mode 100644 index 0000000000..43eb287025 --- /dev/null +++ b/Core/Lib/Accounting/BankStatementMatcher.php @@ -0,0 +1,133 @@ + + * + * 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\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 + */ +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'); + } +} diff --git a/Test/Core/Lib/BankStatementMatcherTest.php b/Test/Core/Lib/BankStatementMatcherTest.php new file mode 100644 index 0000000000..c86fb7ed89 --- /dev/null +++ b/Test/Core/Lib/BankStatementMatcherTest.php @@ -0,0 +1,190 @@ + + * + * 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\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(); + } +}