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();
+ }
+}