--- a/admin/api_bank_reconciliation.php
+++ b/admin/api_bank_reconciliation.php
@@ -29,13 +29,14 @@
         @set_time_limit(60);$res=$service->import((string)file_get_contents($f['tmp_name']),(string)$f['name'],$uid,(string)($_POST['confirm_account']??'')==='1');wa2_bank_json(['ok'=>true,'data'=>$res]);
     }
     if($action==='receipt_ids'){
-        $out=[];foreach($service->receiptsCurrent() as $r){
+        $from=(string)($_GET['date_from']??'');$to=(string)($_GET['date_to']??'');
+        $out=[];foreach($service->receiptsCurrent($from,$to) as $r){
             if(in_array($r['status'],['rejected','not_receipt'],true))continue;
             $out[]=['id'=>(int)$r['id'],'analysis_status'=>$r['bank_analysis_status'],'received_at'=>$r['created_at']];
         }wa2_bank_json(['ok'=>true,'items'=>$out]);
     }
-    if($action==='analyze'){@set_time_limit(110);$a=$service->analyze((int)($_POST['receipt_id']??0),$uid);wa2_bank_json(['ok'=>true,'data'=>$a]);}
-    if($action==='preview'){@set_time_limit(110);wa2_bank_json(['ok'=>true,'data'=>$service->preview((int)($_POST['import_id']??0),$uid)]);}
+    if($action==='analyze'){@set_time_limit(110);$a=$service->analyze((int)($_POST['receipt_id']??0),$uid,(string)($_POST['date_from']??''),(string)($_POST['date_to']??''));wa2_bank_json(['ok'=>true,'data'=>$a]);}
+    if($action==='preview'){@set_time_limit(110);wa2_bank_json(['ok'=>true,'data'=>$service->preview((int)($_POST['import_id']??0),$uid,(string)($_POST['date_from']??''),(string)($_POST['date_to']??''))]);}
     if($action==='results')wa2_bank_json(['ok'=>true,'items'=>$service->results((int)($_GET['run_id']??0))]);
     if($action==='confirm'){@set_time_limit(110);wa2_bank_json(['ok'=>true,'data'=>$service->confirm((int)($_POST['result_id']??0),$uid)]);}
     if($action==='receipt_file'){
--- a/admin/bank_reconciliation.php
+++ b/admin/bank_reconciliation.php
@@ -39,7 +39,13 @@
 <div class="card"><h2>2. Cargar extracto y analizar comprobantes</h2><form id="importForm" class="row"><div style="flex:1;min-width:230px"><input type="file" name="bank_file" accept=".xls" required><span class="small">Admite report.xls y Ultimos_Movimientos.xls · XLS original, una hoja · máximo 8 MB / 10.000 movimientos.</span></div><button class="primary">Importar archivo</button>
 <input type="hidden" name="account_revision" id="accountRevision" value="">
 <div style="flex-basis:100%"><label><input type="checkbox" name="confirm_account" id="confirmAccount" value="1"> Confirmo que el XLS sin número de cuenta en el encabezado corresponde a la cuenta receptora configurada.</label>
-<div class="small" id="importAccount"></div><div class="small">Ultimos_Movimientos: valida también el CUIT de Empresa en el pie. La columna Causal y el formato no crean otra identidad para un movimiento ya importado.</div></div></form><hr style="border:0;border-top:1px solid var(--line);margin:20px 0"><div class="row"><div class="field" style="flex:1;min-width:260px"><label>Extracto para comparar</label><select id="imports"></select></div><button id="analyze">Analizar comprobantes del mes con IA</button><a id="downloadOriginal" class="btn" href="#">Ver XLS guardado</a><button id="preview" class="primary">Simular conciliación</button><button id="stop" hidden>Detener después del actual</button></div><p class="small" id="month"></p><p class="small">Los análisis correctos se reutilizan si el archivo y la versión de extracción coinciden. Los análisis anteriores a v2 deben repetirse una vez; las simulaciones antiguas no autorizan nuevos pagos. PDF (incluidos escaneados), JPG, PNG y WebP. Los incompletos quedan para revisión; no se inventan documentos ni números de operación.</p><div id="progress" class="small"></div></div>
+<div class="small" id="importAccount"></div><div class="small">Ultimos_Movimientos: valida también el CUIT de Empresa en el pie. La columna Causal y el formato no crean otra identidad para un movimiento ya importado.</div></div></form><hr style="border:0;border-top:1px solid var(--line);margin:20px 0">
+<div class="row">
+<div class="field" style="flex:1;min-width:260px"><label>Extracto para comparar</label><select id="imports"></select></div>
+<div class="field" style="min-width:170px"><label>Desde · recepción</label><input type="date" id="rangeFrom"></div>
+<div class="field" style="min-width:170px"><label>Hasta · recepción</label><input type="date" id="rangeTo"></div>
+<button id="analyze">Analizar comprobantes del rango con IA</button><a class="btn" href="openai_probe.php" target="_blank" rel="noopener">Probar clave IA</a><a id="downloadOriginal" class="btn" href="#">Ver XLS guardado</a><button id="preview" class="primary">Simular conciliación</button><button id="stop" hidden>Detener después del actual</button></div>
+<p class="small" id="month"></p><p class="small">El rango se aplica a la <b>fecha de recepción del comprobante</b> y también limita las filas del extracto por la fecha del movimiento. Debe estar dentro del mes actual y no puede llegar a fechas futuras. La búsqueda de duplicados continúa usando todo el historial. Los análisis correctos se reutilizan si el archivo y la versión de extracción coinciden. PDF (incluidos escaneados), JPG, PNG y WebP.</p><div id="progress" class="small"></div></div>
 <div class="card"><div class="row"><h2 style="margin:0">3. Revisar y confirmar</h2><span class="spacer"></span><select id="runs" style="max-width:420px"></select><button id="reloadResults">Actualizar</button></div><div class="kpis" id="kpis"></div><div class="tools"><select id="filter" style="max-width:280px"><option value="all">Todos los resultados</option><option value="ready">Listos para confirmar</option><option value="pending">Pendientes / conflictos</option><option value="reconciled">Conciliados</option></select><button id="selectReady">Seleccionar listos visibles</button><button id="confirm" class="primary">Confirmar seleccionados</button><span class="small">Máximo 20 por lote.</span></div><div class="tablewrap" style="margin-top:16px"><table><thead><tr><th></th><th>Comprobante / cliente</th><th>Resultado</th><th>Motivo y evidencia</th><th>Banco</th></tr></thead><tbody id="results"><tr><td colspan="5">Importá un extracto y generá una simulación.</td></tr></tbody></table></div></div>
 <?php endif;?>
 <div id="message" class="notice" hidden></div></main>
@@ -51,13 +57,14 @@
 async function request(action,data={},get=false){let options={credentials:'same-origin'},url=api;if(get){url+='?'+new URLSearchParams({action,...data});}else{const f=data instanceof FormData?data:new FormData();if(!(data instanceof FormData))Object.entries(data).forEach(([k,v])=>f.append(k,v));f.set('action',action);f.set('csrf',csrf);options={...options,method:'POST',body:f};}const r=await fetch(url,options);let j;try{j=await r.json();}catch(e){throw new Error('Respuesta no JSON. Revisá el log de PHP; no repitas un pago manualmente.');}if(!j.ok)throw new Error(j.error||'Error de operación');return j;}
 function money(value){const n=Number(value);return Number.isFinite(n)?n.toLocaleString('es-AR',{style:'currency',currency:'ARS'}):'No disponible';}
 function option(sel,value,text){const o=document.createElement('option');o.value=value;o.textContent=text;sel.append(o);}
+function selectedRange(){const from=el('rangeFrom').value,to=el('rangeTo').value;if(!from||!to)throw new Error('Seleccioná Desde y Hasta para el rango de recepción.');if(from>to)throw new Error('Desde no puede ser posterior a Hasta.');return {date_from:from,date_to:to};}
 function setBusy(b){busy=b;document.querySelectorAll('button').forEach(n=>{if(n.id!=='stop')n.disabled=b||(n.id==='confirm'&&!allowApply);});el('stop')&&(el('stop').hidden=!b);}
 function bind(id,fn){const n=el(id);if(n)n.addEventListener('click',async e=>{e.preventDefault();if(busy)return;try{await fn();}catch(err){message(err.message,true);}finally{setBusy(false);}});}
 async function state(preferredImport=null,preferredRun=null){const {data}=await request('state',{},true);rulesVersion=data.rules_version||'';allowApply=Number(data.settings.allow_apply)===1;el('confirm').disabled=!allowApply;el('confirm').title=allowApply?'Confirmar solo coincidencias verificadas':'Modo simulación: no permite registrar pagos';el('accountRevision').value=data.settings.revision||'';el('importAccount').textContent=data.settings.destination_cbu?'Cuenta: '+data.settings.account_number+' · CBU/CVU: '+data.settings.destination_cbu+' · CUIT/CUIL titular: '+(data.settings.destination_cuit||'Sin configurar'):'Primero configurá la cuenta receptora.';const f=el('settingsForm');const methods=el('methods');methods.textContent='';option(methods,'','Seleccionar método');data.methods.forEach(x=>option(methods,x.id,x.name));Object.entries(data.settings).forEach(([k,v])=>{const n=f.elements.namedItem(k);if(n){if(n.type==='checkbox')n.checked=Number(v)===1;else n.value=v;}});if(!data.settings.id)el('settingsPanel').open=true;
-el('diagnostics').textContent='PHP: '+data.diagnostics.php_timezone+' · NOW() BD: '+data.diagnostics.db_now+' · UTC BD: '+data.diagnostics.db_utc+' (usar también un mensaje conocido para verificar la zona almacenada).';
+el('diagnostics').textContent='PHP: '+data.diagnostics.php_timezone+' · NOW() BD: '+data.diagnostics.db_now+' · UTC BD: '+data.diagnostics.db_utc+' (usar también un mensaje conocido para verificar la zona almacenada).';if(data.default_range){if(!el('rangeFrom').value)el('rangeFrom').value=data.default_range.date_from||'';if(!el('rangeTo').value)el('rangeTo').value=data.default_range.date_to||'';}
 const imp=el('imports'),prev=preferredImport||imp.value;imp.textContent='';data.imports.forEach(x=>option(imp,x.id,'#'+x.id+' · '+x.file_name+' · '+x.date_min+' a '+x.date_max+' · '+x.row_count+' filas'));if(prev&&[...imp.options].some(o=>o.value==prev))imp.value=prev;el('downloadOriginal').href=api+'?action=original_file&import_id='+encodeURIComponent(imp.value);
 const runs=el('runs'),pr=preferredRun||runs.value;runs.textContent='';data.runs.forEach(x=>option(runs,x.id,'Simulación #'+x.id+' · '+x.month_label+' · archivo #'+x.import_id));if(pr&&[...runs.options].some(o=>o.value==pr))runs.value=pr;
-el('month').textContent=data.month?'Mes a procesar: '+data.month.label+'. Duplicados: búsqueda en todo el historial.':'Configurá primero la zona horaria y la cuenta.';if(runs.value)await results();}
+el('month').textContent=data.default_range?'Rango inicial sugerido: '+data.default_range.date_from+' a '+data.default_range.date_to+'. Podés acortarlo. Duplicados: búsqueda en todo el historial.':'Configurá primero la zona horaria y la cuenta.';if(runs.value)await results();}
 function evidenceOf(i){try{return JSON.parse(i.evidence_json)||{};}catch(e){return {};}}
 function isReady(i){return i.status.startsWith('ready_')&&evidenceOf(i).rules_version===rulesVersion;}
 async function results(){if(!el('runs').value)return;const j=await request('results',{run_id:el('runs').value},true);items=j.items;render();}
@@ -70,9 +77,9 @@
 el('importForm').addEventListener('submit',async e=>{e.preventDefault();if(busy)return;setBusy(true);try{const j=await request('import',new FormData(e.target));message(j.data.repeated?'Este archivo ya estaba importado. No se duplicaron movimientos.':'Extracto importado ('+(j.data.format==='macro_ultimos_6'?'Referencia / Causal / Concepto':'Reporte clásico')+'). Todavía no se registraron pagos.');el('confirmAccount').checked=false;await state(j.data.import_id);}catch(err){message(err.message,true);}finally{setBusy(false);}});
 el('imports').addEventListener('change',()=>{el('downloadOriginal').href=api+'?action=original_file&import_id='+encodeURIComponent(el('imports').value);});
 bind('logout',async()=>{await request('logout');location.reload();});bind('reloadResults',results);el('runs').addEventListener('change',results);el('filter').addEventListener('change',render);bind('selectReady',()=>{document.querySelectorAll('.selected:not(:disabled)').forEach((n,i)=>n.checked=i<20);});
-bind('analyze',async()=>{if(!confirm('Se analizarán los comprobantes recibidos este mes. Se enviarán sus archivos a OpenAI y puede generar consumo de API. No se registrarán pagos. ¿Continuar?'))return;setBusy(true);stopRequested=false;el('progress').textContent='';const j=await request('receipt_ids',{},true);let n=0;for(const r of j.items){if(stopRequested)break;try{await request('analyze',{receipt_id:r.id});el('progress').textContent+='✓ #'+r.id+' analizado/reutilizado o ya conciliado\n';}catch(err){el('progress').textContent+='! #'+r.id+' '+err.message+'\n';}n++;el('progress').scrollTop=el('progress').scrollHeight;}message('Análisis finalizado: '+n+' comprobantes procesados. Generá la simulación para ver coincidencias y motivos.');});
+bind('analyze',async()=>{const range=selectedRange();if(!confirm('Se analizarán solo los comprobantes recibidos entre '+range.date_from+' y '+range.date_to+'. Sus archivos pueden enviarse a OpenAI y generar consumo de API. No se registrarán pagos. ¿Continuar?'))return;setBusy(true);stopRequested=false;el('progress').textContent='';const j=await request('receipt_ids',range,true);let n=0;for(const r of j.items){if(stopRequested)break;try{await request('analyze',{receipt_id:r.id,...range});el('progress').textContent+='✓ #'+r.id+' analizado/reutilizado o ya conciliado\n';}catch(err){el('progress').textContent+='! #'+r.id+' '+err.message+'\n';}n++;el('progress').scrollTop=el('progress').scrollHeight;}message('Análisis finalizado: '+n+' comprobantes procesados en el rango. Generá la simulación para ver coincidencias y motivos.');});
 el('stop').addEventListener('click',()=>{stopRequested=true;});
-bind('preview',async()=>{if(!el('imports').value)throw new Error('Seleccioná un extracto.');setBusy(true);const j=await request('preview',{import_id:el('imports').value});await state(null,j.data.run_id);message('Simulación guardada. Ningún pago fue creado.');});
+bind('preview',async()=>{if(!el('imports').value)throw new Error('Seleccioná un extracto.');const range=selectedRange();setBusy(true);const j=await request('preview',{import_id:el('imports').value,...range});await state(null,j.data.run_id);message('Simulación guardada para '+range.date_from+' a '+range.date_to+'. Ningún pago fue creado.');});
 bind('confirm',async()=>{const ids=[...document.querySelectorAll('.selected:checked')].map(n=>n.value);if(!ids.length||ids.length>20)throw new Error('Seleccioná de 1 a 20 coincidencias listas.');if(!confirm('¿Confirmar '+ids.length+' coincidencias? Los comprobantes nuevos se imputarán a las facturas pendientes del cliente. Los ya imputados solo se vincularán.'))return;setBusy(true);stopRequested=false;let ok=0,blocked=0;el('progress').textContent='';for(const id of ids){if(stopRequested)break;try{const j=await request('confirm',{result_id:id});ok++;el('progress').textContent+='✓ '+j.data.message+(j.data.whatsapp?' WhatsApp: '+j.data.whatsapp.status:'')+'\n';}catch(err){blocked++;el('progress').textContent+='! '+err.message+'\n';}}await results();message('Confirmaciones: '+ok+'. Bloqueadas: '+blocked+'. Revisá el detalle; no vuelvas a cargar esos pagos manualmente.');});
 state().catch(err=>message(err.message,true));}
 </script></body></html>
--- a/app/Bank/BankRules.php
+++ b/app/Bank/BankRules.php
@@ -3,7 +3,7 @@
 /** Pure rules. Never calls AI, bank, Meta or writes payments. Money is integer cents. */
 final class Wa2BankRules
 {
-    public const VERSION = 'macro-v2-origins-20260918';
+    public const VERSION = 'macro-v2.2-range-ai-20260919';
     public const EXTRACTION_VERSION = 'bank-fields-v2-20260918';
     public static function json($v): string {return json_encode($v,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR);}
     public static function digits($s): string {return preg_replace('/\D+/','',(string)$s)??'';}
@@ -47,7 +47,31 @@
         $business=new DateTimeZone('America/Argentina/Buenos_Aires');$dbtz=new DateTimeZone($dbTimezone);
         $today=($now??new DateTimeImmutable('now'))->setTimezone($business);
         $start=$today->modify('first day of this month')->setTime(0,0);$end=$start->modify('first day of next month');
-        return ['label'=>$start->format('Y-m'),'start'=>$start->setTimezone($dbtz)->format('Y-m-d H:i:s'),'end'=>$end->setTimezone($dbtz)->format('Y-m-d H:i:s')];
+        return ['label'=>$start->format('Y-m'),'date_from'=>$start->format('Y-m-d'),'date_to'=>$end->modify('-1 day')->format('Y-m-d'),'start'=>$start->setTimezone($dbtz)->format('Y-m-d H:i:s'),'end'=>$end->setTimezone($dbtz)->format('Y-m-d H:i:s')];
+    }
+    public static function defaultDateRange(string $dbTimezone,?DateTimeImmutable $now=null): array {
+        $business=new DateTimeZone('America/Argentina/Buenos_Aires');
+        $today=($now??new DateTimeImmutable('now'))->setTimezone($business);
+        return self::dateRange($dbTimezone,$today->modify('first day of this month')->format('Y-m-d'),$today->format('Y-m-d'),$now);
+    }
+    public static function dateRange(string $dbTimezone,string $from,string $to,?DateTimeImmutable $now=null): array {
+        self::isoDate($from);self::isoDate($to);
+        $business=new DateTimeZone('America/Argentina/Buenos_Aires');$dbtz=new DateTimeZone($dbTimezone);
+        $today=($now??new DateTimeImmutable('now'))->setTimezone($business);
+        $monthStart=$today->modify('first day of this month')->setTime(0,0);
+        $start=new DateTimeImmutable($from.' 00:00:00',$business);
+        $last=new DateTimeImmutable($to.' 00:00:00',$business);
+        if($start>$last)throw new RuntimeException('El rango de fechas es inválido: Desde no puede ser posterior a Hasta.');
+        if($start<$monthStart||$last>$today->setTime(23,59,59))throw new RuntimeException('El rango debe estar dentro del mes actual y no puede incluir fechas futuras.');
+        $end=$last->modify('+1 day');
+        return ['label'=>$from.' a '.$to,'date_from'=>$from,'date_to'=>$to,'start'=>$start->setTimezone($dbtz)->format('Y-m-d H:i:s'),'end'=>$end->setTimezone($dbtz)->format('Y-m-d H:i:s')];
+    }
+    public static function storedDateRange(string $dbTimezone,string $startStored,string $endStored,?DateTimeImmutable $now=null): array {
+        $business=new DateTimeZone('America/Argentina/Buenos_Aires');$dbtz=new DateTimeZone($dbTimezone);
+        $start=(new DateTimeImmutable($startStored,$dbtz))->setTimezone($business);
+        $end=(new DateTimeImmutable($endStored,$dbtz))->setTimezone($business);
+        if($end<=$start)throw new RuntimeException('El rango guardado de la simulación es inválido.');
+        return self::dateRange($dbTimezone,$start->format('Y-m-d'),$end->modify('-1 second')->format('Y-m-d'),$now);
     }
     public static function document(string $s): string {
         $d=self::digits($s);return in_array(strlen($d),[7,8,11],true)?$d:'';
--- a/app/Bank/BankReconciliation.php
+++ b/app/Bank/BankReconciliation.php
@@ -58,13 +58,18 @@
             return ['import_id'=>$import,'repeated'=>false,'rows'=>count($movs),'positive'=>$positive,'format'=>$parsed['format']];
         }catch(Throwable $e){if($this->db->inTransaction())$this->db->rollBack();throw $e;}finally{self::unlock($this->db);}
     }
-    public function receiptsCurrent(): array {
-        $cfg=$this->config();$range=Wa2BankRules::monthRange($cfg['db_timezone']);
+    private function selectedRange(?string $from=null,?string $to=null): array {
+        $cfg=$this->config();
+        if($from===null||$to===null||trim($from)===''||trim($to)==='')return Wa2BankRules::defaultDateRange($cfg['db_timezone']);
+        return Wa2BankRules::dateRange($cfg['db_timezone'],trim($from),trim($to));
+    }
+    public function receiptsCurrent(?string $from=null,?string $to=null): array {
+        $range=$this->selectedRange($from,$to);
         return $this->all("SELECT r.*,e.status AS bank_analysis_status,e.file_sha256 AS bank_file_sha,e.data_json AS bank_data_json,e.error_text AS bank_error FROM wa2_payment_receipts r LEFT JOIN wa2_bank_extractions e ON e.receipt_id=r.id WHERE r.created_at>=? AND r.created_at<? ORDER BY r.id",[$range['start'],$range['end']]);
     }
-    public function analyze(int $id,int $user): array {
-        $cfg=$this->config();$range=Wa2BankRules::monthRange($cfg['db_timezone']);
-        $r=$this->one('SELECT * FROM wa2_payment_receipts WHERE id=? AND created_at>=? AND created_at<?',[$id,$range['start'],$range['end']]);if(!$r)throw new RuntimeException('Comprobante inexistente o recibido fuera del mes actual.');
+    public function analyze(int $id,int $user,?string $from=null,?string $to=null): array {
+        $range=$this->selectedRange($from,$to);
+        $r=$this->one('SELECT * FROM wa2_payment_receipts WHERE id=? AND created_at>=? AND created_at<?',[$id,$range['start'],$range['end']]);if(!$r)throw new RuntimeException('Comprobante inexistente o recibido fuera del rango seleccionado.');
         if($this->one('SELECT id FROM wa2_bank_matches WHERE receipt_id=?',[$id]))return ['already_reconciled'=>true,'message'=>'Conciliado previamente: se conserva su análisis y no se solicita otro a IA.'];
         if(in_array($r['status'],['rejected','not_receipt'],true))throw new RuntimeException('Comprobante rechazado. Revisalo manualmente antes de analizar.');
         $extractor=new Wa2BankReceiptExtractor($this->db);
@@ -188,28 +193,35 @@
         $e['evidence']['amount']=$a['amount'];$e['evidence']['payment_ids']=array_column($applied,'id');
         return $e;
     }
-    private function dataForEvaluation(int $import): array {
+    private function dataForEvaluation(int $import,?array $range=null): array {
         $imp=$this->one('SELECT id FROM wa2_bank_imports WHERE id=?',[$import]);if(!$imp)throw new RuntimeException('Importación inexistente.');
-        return ['movements'=>$this->all('SELECT DISTINCT m.* FROM wa2_bank_movements m JOIN wa2_bank_import_rows ir ON ir.movement_id=m.id WHERE ir.import_id=? ORDER BY m.id',[$import]),
+        if($range){
+            $movements=$this->all('SELECT DISTINCT m.* FROM wa2_bank_movements m JOIN wa2_bank_import_rows ir ON ir.movement_id=m.id WHERE ir.import_id=? AND m.movement_date>=? AND m.movement_date<=? ORDER BY m.id',[$import,$range['date_from'],$range['date_to']]);
+        } else {
+            $movements=$this->all('SELECT DISTINCT m.* FROM wa2_bank_movements m JOIN wa2_bank_import_rows ir ON ir.movement_id=m.id WHERE ir.import_id=? ORDER BY m.id',[$import]);
+        }
+        return ['movements'=>$movements,
          'history'=>$this->all('SELECT r.*,e.file_sha256 AS bank_file_sha,e.data_json AS bank_data_json FROM wa2_payment_receipts r LEFT JOIN wa2_bank_extractions e ON e.receipt_id=r.id ORDER BY r.id'),
          'payments'=>$this->all('SELECT * FROM payments ORDER BY id'),
          'customers'=>$this->all('SELECT id,full_name,phone FROM customers ORDER BY id')];
     }
-    public function preview(int $import,int $user): array {
-        $cfg=$this->config();$range=Wa2BankRules::monthRange($cfg['db_timezone']);$data=$this->dataForEvaluation($import);$results=[];
-        foreach($this->receiptsCurrent() as $r){
+    public function preview(int $import,int $user,?string $from=null,?string $to=null): array {
+        $cfg=$this->config();$range=$this->selectedRange($from,$to);$data=$this->dataForEvaluation($import,$range);$results=[];
+        foreach($this->receiptsCurrent($range['date_from'],$range['date_to']) as $r){
             try{$e=$this->evaluate($r,$cfg,$data['movements'],$data['history'],$data['payments'],$data['customers']);}
             catch(Throwable $ex){$e=['receipt_id'=>(int)$r['id'],'customer_id'=>null,'movement_id'=>null,'status'=>'review','reason'=>$ex->getMessage(),'evidence'=>[]];}
+            if(!isset($e['evidence'])||!is_array($e['evidence']))$e['evidence']=[];
+            $e['evidence']['selected_range']=['date_from'=>$range['date_from'],'date_to'=>$range['date_to']];
             $e['snapshot']=hash('sha256',Wa2BankRules::json([$r,$cfg['revision'],$e]));$results[]=$e;
         }
         // One movement may not be claimed by two receipts even if their IDs/files differ.
         $claims=[];foreach($results as $i=>$e)if(in_array($e['status'],['ready_link','ready_apply'],true))$claims[$e['movement_id']][]=$i;
-        foreach($claims as $indexes)if(count($indexes)>1)foreach($indexes as $i){$results[$i]['status']='review';$results[$i]['reason']='Otro comprobante del mes compite por el mismo ingreso bancario.';}
+        foreach($claims as $indexes)if(count($indexes)>1)foreach($indexes as $i){$results[$i]['status']='review';$results[$i]['reason']='Otro comprobante del rango seleccionado compite por el mismo ingreso bancario.';}
         $this->db->beginTransaction();
         try{
             $this->db->prepare('INSERT INTO wa2_bank_runs(import_id,month_label,receipt_start,receipt_end,settings_revision,created_by,created_at) VALUES(?,?,?,?,?,?,UTC_TIMESTAMP())')->execute([$import,$range['label'],$range['start'],$range['end'],$cfg['revision'],$user]);$run=(int)$this->db->lastInsertId();
             foreach($results as $e)$this->db->prepare('INSERT INTO wa2_bank_results(run_id,receipt_id,customer_id,movement_id,status,reason,evidence_json,snapshot_hash,created_at) VALUES(?,?,?,?,?,?,?,?,UTC_TIMESTAMP())')->execute([$run,$e['receipt_id'],$e['customer_id'],$e['movement_id'],$e['status'],$e['reason'],Wa2BankRules::json($e['evidence']),$e['snapshot']]);
-            $this->audit($user,'preview',['run_id'=>$run,'import_id'=>$import,'count'=>count($results)]);$this->db->commit();return ['run_id'=>$run,'count'=>count($results)];
+            $this->audit($user,'preview',['run_id'=>$run,'import_id'=>$import,'count'=>count($results),'date_from'=>$range['date_from'],'date_to'=>$range['date_to']]);$this->db->commit();return ['run_id'=>$run,'count'=>count($results)];
         }catch(Throwable $e){if($this->db->inTransaction())$this->db->rollBack();throw $e;}
     }
     public function state(): array {
@@ -218,6 +230,7 @@
           'imports'=>$this->all('SELECT id,file_name,sheet_name,date_min,date_max,row_count,positive_count,imported_at FROM wa2_bank_imports ORDER BY id DESC LIMIT 50'),
           'runs'=>$this->all('SELECT * FROM wa2_bank_runs ORDER BY id DESC LIMIT 50'),
           'month'=>$settings?Wa2BankRules::monthRange($settings['db_timezone']):null,
+          'default_range'=>$settings?Wa2BankRules::defaultDateRange($settings['db_timezone']):null,
           'diagnostics'=>['php_timezone'=>date_default_timezone_get(),'db_now'=>$this->db->query('SELECT NOW()')->fetchColumn(),'db_utc'=>$this->db->query('SELECT UTC_TIMESTAMP()')->fetchColumn()]];
     }
     public function results(int $run): array {
@@ -227,10 +240,11 @@
         return (new Wa2BankSettlement($this->db,$this))->confirm($resultId,$user);
     }
     // Internal interface used by the transactional settlement; no HTTP or AI here.
-    public function evaluateLocked(array $r,array $cfg,int $import): array {
-        $data=$this->dataForEvaluation($import);$e=$this->evaluate($r,$cfg,$data['movements'],$data['history'],$data['payments'],$data['customers']);
+    public function evaluateLocked(array $r,array $cfg,int $import,?array $range=null): array {
+        $data=$this->dataForEvaluation($import,$range);$e=$this->evaluate($r,$cfg,$data['movements'],$data['history'],$data['payments'],$data['customers']);
         if(in_array($e['status'],['ready_link','ready_apply'],true)){
-            foreach($this->receiptsCurrent() as $other){if((int)$other['id']===(int)$r['id'])continue;
+            $others=$range?$this->receiptsCurrent($range['date_from'],$range['date_to']):$this->receiptsCurrent();
+            foreach($others as $other){if((int)$other['id']===(int)$r['id'])continue;
                 // Only a receipt with the same date/amount could claim this exact movement.
                 $candidate=json_decode((string)($other['bank_data_json']??''),true);
                 $target=$e['evidence']['extraction'];
--- a/app/Bank/BankReceiptExtractor.php
+++ b/app/Bank/BankReceiptExtractor.php
@@ -29,10 +29,8 @@
             if(is_array($cached)&&($cached['extraction_version']??'')===Wa2BankRules::EXTRACTION_VERSION)return $cached;
         }
         $settings=$this->db->query('SELECT * FROM wa2_settings WHERE id=1')->fetch(PDO::FETCH_ASSOC)?:[];
-        $key=Wa2SecretStore::openAiKeyFromRow($settings)['key']??null;
-        // Older settings may contain plain text. Only validate, never rewrite the secret.
-        if(!$key)$key=Wa2SecretStore::normalizeOpenAiKey((string)($settings['api_key_enc']??''));
-        if(!$key)throw new RuntimeException('La clave OpenAI del módulo no está disponible. Revisá Ajustes IA / OPENAI_API_KEY.');
+        $secret=Wa2SecretStore::openAiKeyFromRow($settings);$key=$secret['key']??null;
+        if(!$key)throw new RuntimeException('La clave OpenAI efectiva no está disponible (fuente: '.(string)($secret['source']??'none').'). Revisá WhatsApp v2 → Ajustes IA y probá OpenAI desde esa pantalla.');
         if(!function_exists('curl_init'))throw new RuntimeException('Falta la extensión cURL de PHP.');
         $mime=(new finfo(FILEINFO_MIME_TYPE))->file($file);$data=base64_encode((string)file_get_contents($file));
         $media=null;
--- a/app/Bank/BankSettlement.php
+++ b/app/Bank/BankSettlement.php
@@ -14,23 +14,25 @@
             $this->db->beginTransaction();
             $cfg=$this->one('SELECT * FROM wa2_bank_settings WHERE id=1 FOR UPDATE');if(!$cfg)throw new RuntimeException('Falta configuración.');
             if((int)$cfg['allow_apply']!==1)throw new RuntimeException('La sección está en modo simulación. Habilitá la confirmación en Configuración después de revisar.');
-            $row=$this->one('SELECT br.*,run.import_id,run.month_label,run.settings_revision FROM wa2_bank_results br JOIN wa2_bank_runs run ON run.id=br.run_id WHERE br.id=? FOR UPDATE',[$resultId]);
+            $row=$this->one('SELECT br.*,run.import_id,run.month_label,run.receipt_start,run.receipt_end,run.settings_revision FROM wa2_bank_results br JOIN wa2_bank_runs run ON run.id=br.run_id WHERE br.id=? FOR UPDATE',[$resultId]);
             if(!$row)throw new RuntimeException('Resultado inexistente.');$rid=(int)$row['receipt_id'];$mid=(int)$row['movement_id'];
             $old=$this->one('SELECT id,kind,amount FROM wa2_bank_matches WHERE receipt_id=?',[$rid]);
             if($old){$this->db->rollBack();return ['already'=>true,'match_id'=>(int)$old['id'],'message'=>'Ya conciliado. No se creó ningún pago.'];}
             if(!in_array($row['status'],['ready_link','ready_apply'],true))throw new RuntimeException('Este resultado no tiene una coincidencia lista para confirmar.');
             $savedEvidence=json_decode((string)$row['evidence_json'],true);
             if(!is_array($savedEvidence)||($savedEvidence['rules_version']??'')!==Wa2BankRules::VERSION)throw new RuntimeException('Las reglas por entidad cambiaron. Volvé a analizar los comprobantes y generar una simulación nueva.');
-            $range=Wa2BankRules::monthRange($cfg['db_timezone']);
-            if($row['month_label']!==$range['label']||(int)$cfg['revision']!==(int)$row['settings_revision'])throw new RuntimeException('Cambió el mes o la configuración. Volvé a generar la simulación.');
+            if((int)$cfg['revision']!==(int)$row['settings_revision'])throw new RuntimeException('Cambió la configuración. Volvé a generar la simulación.');
+            $range=Wa2BankRules::storedDateRange($cfg['db_timezone'],(string)$row['receipt_start'],(string)$row['receipt_end']);
+            if((string)$row['month_label']!==(string)$range['label'])throw new RuntimeException('El rango guardado no coincide con la simulación. Volvé a generar la simulación.');
             $m=$this->one('SELECT * FROM wa2_bank_movements WHERE id=? FOR UPDATE',[$mid]);if(!$m)throw new RuntimeException('Movimiento inexistente.');
+            if($m['movement_date']<$range['date_from']||$m['movement_date']>$range['date_to'])throw new RuntimeException('El movimiento bancario quedó fuera del rango de la simulación.');
             $r=$this->one('SELECT r.*,e.status AS bank_analysis_status,e.file_sha256 AS bank_file_sha,e.data_json AS bank_data_json,e.error_text AS bank_error FROM wa2_payment_receipts r LEFT JOIN wa2_bank_extractions e ON e.receipt_id=r.id WHERE r.id=? FOR UPDATE',[$rid]);
-            if(!$r||$r['created_at']<$range['start']||$r['created_at']>=$range['end'])throw new RuntimeException('El comprobante está fuera del mes actual.');
+            if(!$r||$r['created_at']<$range['start']||$r['created_at']>=$range['end'])throw new RuntimeException('El comprobante está fuera del rango de la simulación.');
             $cid=(int)$row['customer_id'];
             $c=$this->one('SELECT id FROM customers WHERE id=? FOR UPDATE',[$cid]);if(!$c)throw new RuntimeException('Cliente inexistente.');
             $invoices=$this->all("SELECT id,total,status,currency,due_date,issue_date FROM invoices WHERE customer_id=? ORDER BY due_date,issue_date,id FOR UPDATE",[$cid]);
             $existing=$this->all('SELECT * FROM payments WHERE customer_id=? ORDER BY id FOR UPDATE',[$cid]);
-            $fresh=$this->service->evaluateLocked($r,$cfg,(int)$row['import_id']);
+            $fresh=$this->service->evaluateLocked($r,$cfg,(int)$row['import_id'],$range);
             if(!in_array($fresh['status'],['ready_apply','ready_link'],true))throw new RuntimeException($fresh['reason']);
             $snapshot=hash('sha256',Wa2BankRules::json([$r,$cfg['revision'],$fresh]));
             if(!hash_equals((string)$row['snapshot_hash'],$snapshot))throw new RuntimeException('Los datos cambiaron desde la simulación. No se imputó. Volvé a simular.');
--- a/app/SecretStore.php
+++ b/app/SecretStore.php
@@ -4,46 +4,42 @@
 /**
  * Lectura y almacenamiento compatible de secretos de OpenAI.
  *
- * Orden de preferencia:
- * 1) OPENAI_API_KEY del entorno (recomendado);
- * 2) formato AES-256-GCM v2:gcm:* usando WA2_APP_KEY;
- * 3) Base64 legacy, solo para poder migrar sin cortar producción.
+ * Orden efectivo para WhatsApp v2:
+ * 1) clave guardada en wa2_settings (AES-GCM o Base64 legacy);
+ * 2) OPENAI_API_KEY del entorno como respaldo.
+ * Esto coincide con Ajustes IA y evita que una variable de entorno vieja pise la clave vigente.
  */
 final class Wa2SecretStore
 {
     public static function openAiKeyFromRow(array $row): array
     {
-        $env = self::normalizeOpenAiKey((string)(getenv('OPENAI_API_KEY') ?: ''));
-        if ($env !== null) {
-            return ['key'=>$env, 'source'=>'OPENAI_API_KEY', 'legacy'=>false, 'secure'=>true];
-        }
-
+        // Misma prioridad que Ajustes IA de WhatsApp v2: primero la clave guardada
+        // en wa2_settings; OPENAI_API_KEY queda como respaldo. Así una variable de
+        // entorno vieja no pisa la clave vigente cargada desde el panel.
         $raw = trim((string)($row['api_key_enc'] ?? ''));
-        if ($raw === '') {
-            return ['key'=>null, 'source'=>'none', 'legacy'=>false, 'secure'=>false];
+        if ($raw !== '') {
+            if (strpos($raw, 'v2:gcm:') === 0) {
+                $plain = self::decryptGcm($raw);
+                $key = self::normalizeOpenAiKey($plain ?? '');
+                if ($key !== null) {
+                    return ['key'=>$key, 'source'=>'wa2_settings_encrypted', 'legacy'=>false, 'secure'=>true];
+                }
+            } else {
+                $decoded = base64_decode($raw, true);
+                $candidate = is_string($decoded) && $decoded !== '' ? trim($decoded) : $raw;
+                $key = self::normalizeOpenAiKey($candidate);
+                if ($key !== null) {
+                    return ['key'=>$key, 'source'=>'wa2_settings_legacy_base64', 'legacy'=>true, 'secure'=>false];
+                }
+            }
         }
 
-        if (strpos($raw, 'v2:gcm:') === 0) {
-            $plain = self::decryptGcm($raw);
-            $key = self::normalizeOpenAiKey($plain ?? '');
-            return [
-                'key'=>$key,
-                'source'=>$key ? 'wa2_settings_encrypted' : 'wa2_settings_encrypted_unavailable',
-                'legacy'=>false,
-                'secure'=>$key !== null,
-            ];
+        $env = self::normalizeOpenAiKey((string)(getenv('OPENAI_API_KEY') ?: ''));
+        if ($env !== null) {
+            return ['key'=>$env, 'source'=>'OPENAI_API_KEY', 'legacy'=>false, 'secure'=>true];
         }
 
-        // Compatibilidad temporal con el formato anterior (Base64 no cifrado).
-        $decoded = base64_decode($raw, true);
-        $candidate = is_string($decoded) && $decoded !== '' ? trim($decoded) : $raw;
-        $key = self::normalizeOpenAiKey($candidate);
-        return [
-            'key'=>$key,
-            'source'=>$key ? 'wa2_settings_legacy_base64' : 'wa2_settings_invalid',
-            'legacy'=>$key !== null,
-            'secure'=>false,
-        ];
+        return ['key'=>null, 'source'=>$raw!==''?'wa2_settings_invalid':'none', 'legacy'=>false, 'secure'=>false];
     }
 
     public static function encodeOpenAiKey(string $key): string
--- a/tests/bank_rules_by_origin.php
+++ b/tests/bank_rules_by_origin.php
@@ -4,6 +4,7 @@
 if(PHP_SAPI!=='cli'){http_response_code(403);exit;}
 require_once __DIR__.'/../app/Bank/BankRules.php';
 require_once __DIR__.'/../app/Bank/MacroStatementParser.php';
+require_once __DIR__.'/../app/SecretStore.php';
 $tests=0;
 function checkBank(bool $ok,string $name):void{global $tests;if(!$ok)throw new RuntimeException('FAIL: '.$name);$tests++;}
 function mustRejectBank(callable $fn,string $name):void{try{$fn();}catch(Throwable $e){checkBank(true,$name);return;}checkBank(false,$name);}
@@ -77,6 +78,20 @@
 checkBank(Wa2BankRules::phone('387400000')==='','Teléfono ambiguo no asociado');
 $month=Wa2BankRules::monthRange('UTC',new DateTimeImmutable('2026-10-01T01:30:00+00:00'));
 checkBank($month['label']==='2026-09' && $month['start']==='2026-09-01 03:00:00' && $month['end']==='2026-10-01 03:00:00','Mes de recepción Argentina con almacenamiento UTC');
+$range=Wa2BankRules::dateRange('UTC','2026-09-10','2026-09-18',new DateTimeImmutable('2026-09-19T15:00:00+00:00'));
+checkBank($range['date_from']==='2026-09-10'&&$range['date_to']==='2026-09-18'&&$range['start']==='2026-09-10 03:00:00'&&$range['end']==='2026-09-19 03:00:00','Rango manual filtra recepción en horario Argentina');
+$defaultRange=Wa2BankRules::defaultDateRange('UTC',new DateTimeImmutable('2026-09-19T15:00:00+00:00'));
+checkBank($defaultRange['date_from']==='2026-09-01'&&$defaultRange['date_to']==='2026-09-19','Rango inicial: primero del mes hasta hoy');
+$stored=Wa2BankRules::storedDateRange('UTC','2026-09-10 03:00:00','2026-09-19 03:00:00',new DateTimeImmutable('2026-09-19T15:00:00+00:00'));
+checkBank($stored['date_from']==='2026-09-10'&&$stored['date_to']==='2026-09-18','Rango guardado se reconstruye sin ampliar fechas');
+mustRejectBank(function(){Wa2BankRules::dateRange('UTC','2026-08-31','2026-09-18',new DateTimeImmutable('2026-09-19T15:00:00+00:00'));},'No permite comprobantes del mes anterior');
+mustRejectBank(function(){Wa2BankRules::dateRange('UTC','2026-09-10','2026-09-20',new DateTimeImmutable('2026-09-19T15:00:00+00:00'));},'No permite fechas futuras');
+$oldEnv=getenv('OPENAI_API_KEY');$dbDummy='sk-proj-'.str_repeat('A',32);$envDummy='sk-proj-'.str_repeat('B',32);putenv('OPENAI_API_KEY='.$envDummy);
+$keySource=Wa2SecretStore::openAiKeyFromRow(['api_key_enc'=>base64_encode($dbDummy)]);
+checkBank(($keySource['key']??null)===$dbDummy&&($keySource['source']??'')==='wa2_settings_legacy_base64','Clave IA: wa2_settings tiene prioridad sobre entorno');
+$keyFallback=Wa2SecretStore::openAiKeyFromRow(['api_key_enc'=>'']);
+checkBank(($keyFallback['key']??null)===$envDummy&&($keyFallback['source']??'')==='OPENAI_API_KEY','Clave IA: entorno funciona como respaldo');
+if($oldEnv===false)putenv('OPENAI_API_KEY');else putenv('OPENAI_API_KEY='.$oldEnv);
 $split=Wa2BankRules::allocate(3200000,[['id'=>1,'total'=>'10000.00','paid'=>'0.00'],['id'=>2,'total'=>'10000.00','paid'=>'0.00']]);
 checkBank($split===[['invoice_id'=>1,'cents'=>1000000],['invoice_id'=>2,'cents'=>1000000],['invoice_id'=>null,'cents'=>1200000]],'Reparto y saldo a favor sin cambios');
 checkBank(Wa2BankRules::allocate(100,[['id'=>1,'total'=>'1.00','paid'=>'0.50']])===[['invoice_id'=>1,'cents'=>50],['invoice_id'=>null,'cents'=>50]],'Precisión de centavos');
