--- /mnt/data/wa2_v22_inspect/whatsapp_v2/app/Bank/BankRules.php	2026-09-19 16:11:37.000000000 +0000
+++ /mnt/data/wa2_v23_work/whatsapp_v2/app/Bank/BankRules.php	2026-09-19 17:21:53.348990150 +0000
@@ -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.2-range-ai-20260919';
+    public const VERSION = 'macro-v2.3-dni-cuit-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)??'';}
@@ -76,6 +76,63 @@
     public static function document(string $s): string {
         $d=self::digits($s);return in_array(strlen($d),[7,8,11],true)?$d:'';
     }
+    /**
+     * Returns a structural identity for Argentine DNI/CUIT/CUIL.
+     * For a CUIT/CUIL XX-DNI-X, dni8 is exactly the 8 digits between the hyphens.
+     * A 7-digit DNI is left-padded with one zero ONLY for this structural comparison.
+     */
+    public static function documentIdentity(string $s): array {
+        $d=self::document($s);
+        if($d==='')return ['ok'=>false,'kind'=>null,'value'=>'','dni8'=>''];
+        if(strlen($d)===11){
+            if(!self::validCuit($d))return ['ok'=>false,'kind'=>'cuit','value'=>$d,'dni8'=>''];
+            return ['ok'=>true,'kind'=>'cuit','value'=>$d,'dni8'=>substr($d,2,8)];
+        }
+        $dni8=strlen($d)===7?str_pad($d,8,'0',STR_PAD_LEFT):$d;
+        return ['ok'=>true,'kind'=>'dni','value'=>$d,'dni8'=>$dni8];
+    }
+    /** Exact document match or DNI embedded in a valid CUIT/CUIL. Never arbitrary substring matching. */
+    public static function documentMatch(string $a,string $b): array {
+        $ia=self::documentIdentity($a);$ib=self::documentIdentity($b);
+        $out=['ok'=>false,'type'=>null,'dni8'=>null];
+        if(!$ia['ok']||!$ib['ok'])return $out;
+        if($ia['value']===$ib['value'])return ['ok'=>true,'type'=>'exact','dni8'=>$ia['dni8']?:null];
+        if($ia['dni8']!==''&&$ib['dni8']!==''&&hash_equals($ia['dni8'],$ib['dni8'])){
+            return ['ok'=>true,'type'=>'dni_inside_cuit','dni8'=>$ia['dni8']];
+        }
+        return $out;
+    }
+    private static function nameTokens(string $s): array {
+        $parts=preg_split('/[^A-Z0-9]+/u',self::text($s),-1,PREG_SPLIT_NO_EMPTY);
+        return is_array($parts)?array_values($parts):[];
+    }
+    private static function nameTokenCompatible(string $a,string $b): bool {
+        if($a===$b)return true;
+        $la=strlen($a);$lb=strlen($b);$min=min($la,$lb);
+        if($min>=5 && (strncmp($a,$b,$min)===0))return true; // apellido/nombre truncado al final
+        if($min>=6 && abs($la-$lb)<=1 && levenshtein($a,$b)<=1)return true; // un carácter omitido/extra
+        return false;
+    }
+    /** Conservative name check that accepts bank truncation and a one-character transcription difference. */
+    public static function nameMatch(string $a,string $b): array {
+        $x=self::nameTokens($a);$y=self::nameTokens($b);
+        if(!$x||!$y)return ['ok'=>false,'type'=>'missing'];
+        $xs=$x;$ys=$y;sort($xs);sort($ys);
+        if($xs===$ys)return ['ok'=>true,'type'=>'exact'];
+        $small=count($x)<=count($y)?$x:$y;$large=count($x)<=count($y)?$y:$x;
+        // A one-token partial name is too weak unless it was exact (handled above).
+        if(count($small)<2)return ['ok'=>false,'type'=>'different'];
+        $used=[];
+        foreach($small as $token){
+            $found=false;
+            foreach($large as $i=>$candidate){
+                if(isset($used[$i]))continue;
+                if(self::nameTokenCompatible($token,$candidate)){$used[$i]=true;$found=true;break;}
+            }
+            if(!$found)return ['ok'=>false,'type'=>'different'];
+        }
+        return ['ok'=>true,'type'=>count($x)===count($y)?'minor_difference':'truncated_or_subset'];
+    }
     public static function validCuit(string $s): bool {
         if(!preg_match('/^\d{11}$/D',$s))return false;$w=[5,4,3,2,7,6,5,4,3,2];$n=0;for($i=0;$i<10;$i++)$n+=(int)$s[$i]*$w[$i];$d=11-($n%11);$d=$d===11?0:($d===10?9:$d);return $d===(int)$s[10];
     }
@@ -104,13 +161,13 @@
     }
     public static function ruleLabel(string $key): string {
         $labels=[
-            'mercadopago'=>'Mercado Pago: 6 dígitos de identificación + CUIT/CUIL',
+            'mercadopago'=>'Mercado Pago: 6 dígitos de identificación + DNI/CUIT/CUIL',
             'macro'=>'Macro: referencia = operación completa',
-            'naranja_x'=>'Naranja X: CUIT/CUIL + fecha + importe',
-            'uala'=>'Ualá: CUIT/CUIL + fecha + importe; ID único',
-            'personal_pay'=>'Personal Pay: CoelsaID completo + CUIT/CUIL',
+            'naranja_x'=>'Naranja X: DNI/CUIT/CUIL + fecha + importe',
+            'uala'=>'Ualá: DNI/CUIT/CUIL + fecha + importe; ID único',
+            'personal_pay'=>'Personal Pay: CoelsaID completo + DNI/CUIT/CUIL',
             'bna'=>'BNA: referencia = transacción completa',
-            'document_date_amount'=>'Regla general: documento + fecha + importe',
+            'document_date_amount'=>'Regla general: DNI/CUIT/CUIL + fecha + importe',
             'ambiguous'=>'Entidad emisora ambigua'
         ];
         return $labels[$key]??'Entidad no reconocida';
@@ -165,18 +222,28 @@
         if(($m['movement_date']??'')!==($a['date']??null))return $fail('Diferencia de fecha; requiere revisión.');
         $out['proof']=['destination_cbu_match'=>true,'amount_match'=>true,'date_match'=>true];
         $ad=(string)($a['origin_document']??'');$md=(string)($m['origin_document']??'');
-        foreach([$ad,$md] as $doc)if($doc!=='' && (self::document($doc)!==$doc || (strlen($doc)===11&&!self::validCuit($doc))))return $fail('Documento del emisor inválido o ilegible.');
-        $needsCuit=in_array($key,['mercadopago','naranja_x','uala','personal_pay'],true);
-        if($needsCuit && (strlen($ad)!==11 || strlen($md)!==11))return $fail('Falta CUIT/CUIL inequívoco del emisor para la regla '.$key.'.');
+        foreach([$ad,$md] as $doc){
+            if($doc==='' )continue;
+            $identity=self::documentIdentity($doc);
+            if(!$identity['ok'])return $fail('Documento del emisor inválido o ilegible.');
+        }
+        $needsDocument=in_array($key,['mercadopago','naranja_x','uala','personal_pay'],true);
+        if($needsDocument && ($ad==='' || $md===''))return $fail('Falta DNI/CUIT/CUIL inequívoco del emisor para la regla '.$key.'.');
         if($key==='document_date_amount' && ($ad===''||$md===''))return $fail('Falta DNI/CUIT/CUIL inequívoco del emisor.');
-        if($ad!==''&&$md!==''&&$ad!==$md)return $fail('Documento del emisor diferente.');
-        $out['proof']['origin_document_match']=($ad!==''&&$md!=='')?true:null;
-        // Preserve the previous conservative name check when the bank exposes a name.
+        if($ad!==''&&$md!==''){
+            $docMatch=self::documentMatch($ad,$md);
+            if(!$docMatch['ok'])return $fail('Documento del emisor diferente.');
+            $out['proof']['origin_document_match']=true;
+            $out['proof']['origin_document_match_type']=$docMatch['type'];
+            if(!empty($docMatch['dni8']))$out['proof']['origin_dni']=$docMatch['dni8'];
+        } else $out['proof']['origin_document_match']=null;
+        // Name is corroborating evidence. Accept normal truncation/one-character transcription differences,
+        // but keep genuinely different names in manual review even if the document matches.
         if(!empty($m['origin_name'])){
-            $x=preg_split('/\W+/u',self::text($m['origin_name']),-1,PREG_SPLIT_NO_EMPTY);
-            $y=preg_split('/\W+/u',self::text($a['origin_name']??''),-1,PREG_SPLIT_NO_EMPTY);
-            sort($x);sort($y);if($x!==$y)return $fail('Nombre de origen diferente o truncado; revisar aunque coincida el documento.');
+            $nameMatch=self::nameMatch((string)$m['origin_name'],(string)($a['origin_name']??''));
+            if(!$nameMatch['ok'])return $fail('Nombre de origen diferente; revisar aunque coincida el documento.');
             $out['proof']['origin_name_match']=true;
+            $out['proof']['origin_name_match_type']=$nameMatch['type'];
         }
         $reference=trim((string)($m['transaction_number']??''));
         $out['proof']['bank_reference']=$reference;
--- /mnt/data/wa2_v22_inspect/whatsapp_v2/tests/bank_rules_by_origin.php	2026-09-19 16:14:57.000000000 +0000
+++ /mnt/data/wa2_v23_work/whatsapp_v2/tests/bank_rules_by_origin.php	2026-09-19 17:21:53.349142907 +0000
@@ -139,4 +139,48 @@
 mustRejectBank(function()use($parser,$preamble,$cfg){$bad=$preamble;$bad['rows'][5][2]='999999999999999';$parser->parseWorkbook($bad,$cfg);},'Formato con encabezado rechaza otra cuenta');
 mustRejectBank(function()use($parser,$preamble,$cfg){$bad=$preamble;$bad['rows'][6][2]='DOLARES';$parser->parseWorkbook($bad,$cfg);},'Formato con encabezado rechaza moneda distinta');
 mustRejectBank(function()use($parser,$cfg){$parser->parse('<html>archivo.xls</html>',$cfg);},'No acepta HTML renombrado XLS');
+
+// v2.3: DNI vs CUIT/CUIL uses only the structural DNI body XX-DNI-X, never an arbitrary substring.
+checkBank(Wa2BankRules::documentMatch('40069488','27-40069488-0')['ok'],'Documento: DNI 8 coincide con DNI interno del CUIT');
+checkBank(Wa2BankRules::documentMatch('27-40069488-0','40069488')['ok'],'Documento: CUIT coincide con DNI 8 en sentido inverso');
+$sevenCuit=cuitBank('2001234567');
+checkBank(Wa2BankRules::documentMatch('1234567',$sevenCuit)['ok'],'Documento: DNI 7 coincide con CUIT cuyo cuerpo tiene cero inicial');
+checkBank(!Wa2BankRules::documentMatch('40069487','27-40069488-0')['ok'],'Documento: DNI diferente no coincide por substring');
+checkBank(!Wa2BankRules::documentMatch('12345678','27123456789')['ok'],'Documento: CUIT inválido no habilita coincidencia');
+$mpDni=array_replace($receipt,['origin_document'=>substr($doc,2,8)]);
+checkBank($pass($mpDni,$movement),'MP: DNI del comprobante coincide con CUIT/CUIL del extracto');
+$mpBankDni=array_replace($movement,['origin_document'=>substr($doc,2,8)]);
+checkBank($pass($receipt,$mpBankDni),'MP: CUIT/CUIL del comprobante coincide con DNI del extracto');
+checkBank(Wa2BankRules::nameMatch('CLAUDIA MARISOL DONAIR','CLAUDIA MARISOL DONAIRE')['ok'],'Nombre: acepta truncamiento final del banco');
+checkBank(Wa2BankRules::nameMatch('ELIAS NELSON ALMAZAN','ELIAS NELSON ALMANZAN')['ok'],'Nombre: acepta diferencia de un carácter');
+checkBank(Wa2BankRules::nameMatch('JUDITH MARISOL SOLOZA','JUDITH MARISOL SOLOZA HERRERA')['ok'],'Nombre: acepta segundo apellido omitido');
+checkBank(!Wa2BankRules::nameMatch('PERSONA DISTINTA','CLAUDIA MARISOL DONAIRE')['ok'],'Nombre: no acepta identidad realmente diferente');
+$claudia=array_replace($receipt,[
+    'amount'=>'26500.00','date'=>'2026-09-19','origin_name'=>'CLAUDIA MARISOL DONAIRE','origin_document'=>'27400694880',
+    'identification_code'=>'978348803637E81484','operation_number'=>'178133794477'
+]);
+$claudiaM=array_replace($movement,[
+    'amount'=>'26500.00','movement_date'=>'2026-09-19','transaction_number'=>'803637','origin_document'=>'27400694880',
+    'origin_name'=>'CLAUDIA MARISOL DONAIR','description'=>'ING TRANSF:CLAUDIA MARISOL DONAIR-27400694880'
+]);
+checkBank($pass($claudia,$claudiaM),'Caso real: Claudia Donaire concilia con nombre truncado y CUIT exacto');
+$nelson=array_replace($receipt,[
+    'amount'=>'26500.00','date'=>'2026-09-19','origin_name'=>'ELIAS NELSON ALMANZAN','origin_document'=>'20378390738',
+    'identification_code'=>'199734894784468A02','operation_number'=>'179835582702'
+]);
+$nelsonM=array_replace($movement,[
+    'amount'=>'26500.00','movement_date'=>'2026-09-19','transaction_number'=>'894784','origin_document'=>'20378390738',
+    'origin_name'=>'ELIAS NELSON ALMAZAN','description'=>'ING TRANSF:ELIAS NELSON ALMAZAN-20378390738'
+]);
+checkBank($pass($nelson,$nelsonM),'Caso real: Nelson Almanzan concilia con una letra omitida');
+$soloza=array_replace($receipt,[
+    'amount'=>'48000.00','date'=>'2026-09-19','origin_name'=>'JUDITH MARISOL SOLOZA HERRERA','origin_document'=>'27415303195',
+    'identification_code'=>'746815230930195468','operation_number'=>'178855904453'
+]);
+$solozaM=array_replace($movement,[
+    'amount'=>'48000.00','movement_date'=>'2026-09-19','transaction_number'=>'230930','origin_document'=>'27415303195',
+    'origin_name'=>'JUDITH MARISOL SOLOZA','description'=>'ING TRANSF:JUDITH MARISOL SOLOZA-27415303195'
+]);
+checkBank($pass($soloza,$solozaM),'Caso real: Soloza Herrera concilia con segundo apellido truncado');
+
 echo 'OK: '.$tests.' comprobaciones de reglas e importación. Sin base de datos, IA, Meta ni escrituras.'.PHP_EOL;
