From 9f90bf6a47ab55a5820a1edb748d9e0fffcf531c Mon Sep 17 00:00:00 2001 From: rfranzoni Date: Wed, 27 May 2026 23:23:03 -0300 Subject: [PATCH 1/2] feat(formatter): aceitar CNPJ alfanumerico (Reforma Tributaria 2026-07-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partir de 2026-07-01 a SEFAZ aceitara CNPJ no formato alfanumerico (LC 214/2025 + IN RFB 2.229/2024). 12 primeiros caracteres podem conter letras maiusculas [A-Z], dois ultimos seguem como digitos verificadores numericos. ## Mudancas no Formatador - Regex CNPJ: `\d{2}` -> `[0-9A-Z]{2}` nos 4 primeiros grupos (12 chars); ultimo grupo (2 digitos verificadores) inalterado. - CPF regex inalterado (CPF segue sendo \d{11} por norma RFB). - `FormatarCpfCnpj` reescrita para decidir por **comprimento da string limpa (sem mascara)** antes da regex. Elimina ambiguidade quando um CNPJ alfanumerico hipoteticamente pudesse bater na regex CPF (improvavel mas deterministico de mitigar). ## Comportamento preservado - CNPJs/CPFs numericos legados continuam formatando exatamente como antes. - Comprimento invalido devolve input trimado (sem exception) — mesmo contrato historico. - Null / vazio devolvem string vazia — mesmo contrato historico. ## Cobertura Adicionado `DanfeSharp.Test/FormatadorTests.cs` com 30+ casos usando DataTestMethod/DataRow (MSTest 1.1): - CNPJ numerico legado (regressao) - CNPJ alfanumerico em varias posicoes de letras - CNPJ com mascara visual (idempotencia) - CNPJ comprimento invalido / lowercase / DV alfabetico (todos rejeitados) - CPF numerico - CPF com letras (rejeitado — nunca aceita alfanumerico) - FormatarCpfCnpj dispatching por comprimento (11=CPF, 14=CNPJ, outros=cru) - Edge cases: null, vazio, espacos > Nota: o test project (DanfeSharp.Test) ja apresentava erros > preexistentes de compilacao em FabricaFake.cs e DanfeTest.cs (na main > antes deste PR). Validacao da nova logica feita via dotnet-script > contra a DLL compilada — 13/13 checks OK. Restauracao dos testes > existentes fica como follow-up separado. Issue: nfe/DanfeSharp#35 (spike). Cobre tambem o tipo basico exigido pela issue nfe/DanfeSharp#33 (barcode CNPJ alfanumerico). Co-Authored-By: Claude Opus 4.7 (1M context) --- DanfeSharp.Test/DanfeSharp.Test.csproj | 1 + DanfeSharp.Test/FormatadorTests.cs | 116 +++++++++++++++++++++++++ DanfeSharp/Formatador.cs | 46 +++++----- 3 files changed, 142 insertions(+), 21 deletions(-) create mode 100644 DanfeSharp.Test/FormatadorTests.cs diff --git a/DanfeSharp.Test/DanfeSharp.Test.csproj b/DanfeSharp.Test/DanfeSharp.Test.csproj index 9bbd1406..5dbe5bad 100644 --- a/DanfeSharp.Test/DanfeSharp.Test.csproj +++ b/DanfeSharp.Test/DanfeSharp.Test.csproj @@ -61,6 +61,7 @@ + diff --git a/DanfeSharp.Test/FormatadorTests.cs b/DanfeSharp.Test/FormatadorTests.cs new file mode 100644 index 00000000..dacae49c --- /dev/null +++ b/DanfeSharp.Test/FormatadorTests.cs @@ -0,0 +1,116 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DanfeSharp.Test +{ + /// + /// Cobertura do para CNPJ alfanumérico (Reforma + /// Tributária — LC 214/2025, vigência 2026-07-01) e regressão dos formatos + /// numéricos legados. + /// + [TestClass] + public class FormatadorTests + { + // CNPJ alfanumérico: regex agora aceita [0-9A-Z]{12}\d{2}. Máscara + // visual `XX.XXX.XXX/XXXX-XX` aplicada com letras nas posições corretas. + + [DataTestMethod] + [DataRow("12345678000190", "12.345.678/0001-90", DisplayName = "CNPJ numérico legado")] + [DataRow("11222333000181", "11.222.333/0001-81", DisplayName = "CNPJ numérico válido")] + [DataRow("12ABC345000188", "12.ABC.345/0001-88", DisplayName = "CNPJ alfanumérico letras no meio")] + [DataRow("98XYZ123000158", "98.XYZ.123/0001-58", DisplayName = "CNPJ alfanumérico outro vetor")] + public void FormatarCnpj_FormatosValidos_AplicaMascara(string input, string expected) + { + Assert.AreEqual(expected, Formatador.FormatarCnpj(input)); + } + + [DataTestMethod] + [DataRow("12.345.678/0001-90", "12.345.678/0001-90", DisplayName = "CNPJ já formatado (idempotência)")] + [DataRow("12.ABC.345/0001-88", "12.ABC.345/0001-88", DisplayName = "CNPJ alfanumérico já formatado")] + public void FormatarCnpj_JaFormatado_PreservaMascara(string input, string expected) + { + Assert.AreEqual(expected, Formatador.FormatarCnpj(input)); + } + + [DataTestMethod] + [DataRow("12345678", DisplayName = "Muito curto")] + [DataRow("123456789012345", DisplayName = "Muito longo")] + [DataRow("12abc345000188", DisplayName = "Letras minúsculas — rejeitadas")] + [DataRow("12ABC345000ABCD", DisplayName = "DV alfanumérico — rejeitado")] + public void FormatarCnpj_ComprimentoOuFormatoInvalido_RetornaInputSemMascara(string input) + { + // Comportamento histórico: quando a regex não bate, o input é devolvido + // sem máscara, sem exception. + Assert.AreEqual(input, Formatador.FormatarCnpj(input)); + } + + [DataTestMethod] + [DataRow("12345678901", "123.456.789-01")] + [DataRow("11144477735", "111.444.777-35")] + [DataRow("123.456.789-01", "123.456.789-01")] + public void FormatarCpf_FormatosValidos_AplicaMascara(string input, string expected) + { + Assert.AreEqual(expected, Formatador.FormatarCpf(input)); + } + + [DataTestMethod] + [DataRow("1234567890A", DisplayName = "CPF com letra — rejeitado (CPF nunca aceita alfanumérico)")] + [DataRow("123", DisplayName = "Muito curto")] + [DataRow("123456789012", DisplayName = "Muito longo")] + public void FormatarCpf_FormatoInvalido_RetornaInputSemMascara(string input) + { + Assert.AreEqual(input, Formatador.FormatarCpf(input)); + } + + // FormatarCpfCnpj decide por comprimento da string limpa (sem máscara) + // antes da regex — eliminando ambiguidade quando um CNPJ alfanumérico + // pudesse acidentalmente bater na regex CPF (improvável mas defensivo). + + [DataTestMethod] + [DataRow("12345678901", "123.456.789-01", DisplayName = "CPF numérico")] + [DataRow("11144477735", "111.444.777-35", DisplayName = "CPF numérico vetor 2")] + [DataRow("12345678000190", "12.345.678/0001-90", DisplayName = "CNPJ numérico legado")] + [DataRow("12ABC345000188", "12.ABC.345/0001-88", DisplayName = "CNPJ alfanumérico")] + [DataRow("98XYZ123000158", "98.XYZ.123/0001-58", DisplayName = "CNPJ alfanumérico vetor 2")] + public void FormatarCpfCnpj_FormatosValidos_AplicaMascaraCorrespondente(string input, string expected) + { + Assert.AreEqual(expected, Formatador.FormatarCpfCnpj(input)); + } + + [DataTestMethod] + [DataRow("123.456.789-01", "123.456.789-01", DisplayName = "CPF com máscara — limpa e refaz")] + [DataRow("12.345.678/0001-90", "12.345.678/0001-90", DisplayName = "CNPJ com máscara — limpa e refaz")] + [DataRow("12.ABC.345/0001-88", "12.ABC.345/0001-88", DisplayName = "CNPJ alfanumérico com máscara — limpa e refaz")] + public void FormatarCpfCnpj_ComMascara_NormalizaEReaplica(string input, string expected) + { + Assert.AreEqual(expected, Formatador.FormatarCpfCnpj(input)); + } + + [DataTestMethod] + [DataRow("123", DisplayName = "3 chars — não é CPF nem CNPJ")] + [DataRow("123456789012", DisplayName = "12 chars — comprimento inválido")] + [DataRow("1234567890123", DisplayName = "13 chars — comprimento inválido")] + public void FormatarCpfCnpj_ComprimentoInvalido_RetornaInputTrimadoSemMascara(string input) + { + // Não joga exception (comportamento histórico). Apenas devolve trimado. + Assert.AreEqual(input, Formatador.FormatarCpfCnpj(input)); + } + + [TestMethod] + public void FormatarCpfCnpj_StringVazia_RetornaStringVazia() + { + Assert.AreEqual(string.Empty, Formatador.FormatarCpfCnpj(string.Empty)); + } + + [TestMethod] + public void FormatarCpfCnpj_Null_RetornaStringVazia() + { + Assert.AreEqual(string.Empty, Formatador.FormatarCpfCnpj(null)); + } + + [TestMethod] + public void FormatarCpfCnpj_StringComEspacos_TrimaAntesDeFormatar() + { + Assert.AreEqual("12.ABC.345/0001-88", Formatador.FormatarCpfCnpj(" 12ABC345000188 ")); + } + } +} diff --git a/DanfeSharp/Formatador.cs b/DanfeSharp/Formatador.cs index ad7868ff..6fac30d7 100644 --- a/DanfeSharp/Formatador.cs +++ b/DanfeSharp/Formatador.cs @@ -29,7 +29,10 @@ static Formatador() public const String FormatoNumeroNF = @"000\.000\.000"; public const String CEP = @"^(\d{5})\-?(\d{3})$"; - public const String CNPJ = @"^(\d{2})\.?(\d{3})\.?(\d{3})\/?(\d{4})\-?(\d{2})$"; + // CNPJ aceita letras maiúsculas nos 12 primeiros caracteres a partir de 2026-07-01 + // (Reforma Tributária, LC 214/2025 e IN RFB 2.229/2024). Os 2 últimos seguem como + // dígitos verificadores numéricos. CPF permanece estritamente numérico. + public const String CNPJ = @"^([0-9A-Z]{2})\.?([0-9A-Z]{3})\.?([0-9A-Z]{3})\/?([0-9A-Z]{4})\-?(\d{2})$"; public const String CPF = @"^(\d{3})\.?(\d{3})\.?(\d{3})\-?(\d{2})$"; public const String Telefone = @"^\(?(\d{2})\)?\s*(\d{4,5})\s*\-?\s*(\d{4})$"; public const String Placa = @"^([A-Z]{3})\s*\-?\s*(\d{4})$"; @@ -122,33 +125,34 @@ public static String FormatarCpf(String cpf) } /// - /// Formata um número de documento + /// Formata um número de documento (CPF ou CNPJ). + /// Decide pelo comprimento da string limpa (sem máscara) antes de aplicar regex, + /// eliminando ambiguidade entre os formatos — relevante a partir de 2026-07-01, + /// quando o CNPJ passa a aceitar letras nos 12 primeiros caracteres. /// - /// - /// + /// Documento com ou sem máscara visual. + /// Documento formatado, ou o input trimado se comprimento for inválido. public static String FormatarCpfCnpj(String cpfCnpj) { - String result; - - if (!String.IsNullOrWhiteSpace(cpfCnpj)) + if (String.IsNullOrWhiteSpace(cpfCnpj)) { - result = cpfCnpj.Trim(); - - if (Regex.IsMatch(result, CPF)) - { - result = FormatarCpf(result); - } - else if (Regex.IsMatch(result, CNPJ)) - { - result = FormatarCnpj(result); - } + return String.Empty; } - else + + String trimmed = cpfCnpj.Trim(); + String clean = trimmed.Replace(".", String.Empty).Replace("-", String.Empty).Replace("/", String.Empty); + + switch (clean.Length) { - result = String.Empty; + case 11: + return FormatarCpf(clean); + case 14: + return FormatarCnpj(clean); + default: + // Comprimento inválido — devolve sem aplicar máscara (comportamento + // alinhado com o histórico: nunca jogou exception). + return trimmed; } - - return result; } /// From 7a41909f2873e5bf518b56f1465e3b4cb605f5bd Mon Sep 17 00:00:00 2001 From: rfranzoni Date: Thu, 28 May 2026 08:05:36 -0300 Subject: [PATCH 2/2] fix(formatter): preservar pontuacao do input em FormatarCpfCnpj quando invalido MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endereca o review do Copilot no PR #36 (comentario inline em Formatador.cs L150). ## Bug Quando o input tinha comprimento "limpo" 11 ou 14 mas conteudo invalido (ex.: CNPJ alfanumerico com letras minusculas: "12.abc.345/0001-88"), o dispatch por comprimento mandava para FormatarCnpj("12abc345000188"), a regex rejeitava (so aceita [0-9A-Z] maiusculo), e o retorno era o clean cru — **perdendo a pontuacao visivel do input original**. Comportamento histórico (antes do PR #36): regex miss devolvia o input trimado inalterado, mantendo pontuacao. ## Fix Valida `clean` contra a regex correspondente ANTES do dispatch: ```csharp if (clean.Length == 11 && Regex.IsMatch(clean, CPF)) return FormatarCpf(clean); if (clean.Length == 14 && Regex.IsMatch(clean, CNPJ)) return FormatarCnpj(clean); return trimmed; // Conteudo nao bate na regex — devolve com pontuacao ``` Comportamento agora alinhado com o contrato historico: input invalido (comprimento, formato, lowercase em CNPJ alfanumerico, DV alfabetico) devolve `trimmed` — preservando o input visivel para que o caller identifique o problema sem corrupcao silenciosa. ## Testes adicionados `DanfeSharp.Test/FormatadorTests.cs`: - `FormatarCpfCnpj_InvalidoComMascara_PreservaPontuacaoDoInput` (4 cenarios): - CNPJ alfanumerico minusculo com mascara: `"12.abc.345/0001-88"` -> identico - CNPJ com DV alfabetico + mascara - CPF com letra + mascara - CNPJ todo letras + mascara (DV alfabetico rejeita) - `FormatarCpfCnpj_InvalidoSemMascara_DevolveInputInalterado` (2 cenarios): - CNPJ lowercase sem mascara - CPF com letra sem mascara Validacao via dotnet-script contra a DLL recem-buildada: 13/13 OK. Co-Authored-By: Claude Opus 4.7 (1M context) --- DanfeSharp.Test/FormatadorTests.cs | 24 ++++++++++++++++++++++ DanfeSharp/Formatador.cs | 33 +++++++++++++++++++++--------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/DanfeSharp.Test/FormatadorTests.cs b/DanfeSharp.Test/FormatadorTests.cs index dacae49c..4c7c1547 100644 --- a/DanfeSharp.Test/FormatadorTests.cs +++ b/DanfeSharp.Test/FormatadorTests.cs @@ -95,6 +95,30 @@ public void FormatarCpfCnpj_ComprimentoInvalido_RetornaInputTrimadoSemMascara(st Assert.AreEqual(input, Formatador.FormatarCpfCnpj(input)); } + // Regressão (PR review #36): conteúdo inválido com comprimento "limpo" 11/14 + // não deve perder a pontuação do input original — devolve trimmed, não clean. + [DataTestMethod] + [DataRow("12.abc.345/0001-88", DisplayName = "CNPJ alfanumérico minúsculo com máscara — rejeitado, preserva pontuação")] + [DataRow("12.ABC.345/0001-AB", DisplayName = "CNPJ com DV alfabético + máscara — rejeitado, preserva pontuação")] + [DataRow("123.456.789-0X", DisplayName = "CPF com letra + máscara — rejeitado, preserva pontuação")] + [DataRow("AB.CDE.FGH/IJKL-MN", DisplayName = "CNPJ todo letras + máscara — rejeitado pela regex (DV não-numérico)")] + public void FormatarCpfCnpj_InvalidoComMascara_PreservaPontuacaoDoInput(string input) + { + // Antes do fix: input "limpo" caía no dispatch por comprimento e perdia a pontuação. + // Após o fix: a regex valida o conteúdo limpo antes do dispatch; conteúdo inválido + // devolve `trimmed` (que mantém a pontuação original). + Assert.AreEqual(input, Formatador.FormatarCpfCnpj(input)); + } + + [DataTestMethod] + [DataRow("12abc345000188", DisplayName = "CNPJ alfanumérico lowercase sem máscara — rejeitado, devolve cru")] + [DataRow("1234567890A", DisplayName = "CPF com letra sem máscara — rejeitado, devolve cru")] + public void FormatarCpfCnpj_InvalidoSemMascara_DevolveInputInalterado(string input) + { + // Conteúdo sem pontuação que falha a regex — `trimmed == clean`, devolve igual. + Assert.AreEqual(input, Formatador.FormatarCpfCnpj(input)); + } + [TestMethod] public void FormatarCpfCnpj_StringVazia_RetornaStringVazia() { diff --git a/DanfeSharp/Formatador.cs b/DanfeSharp/Formatador.cs index 6fac30d7..7df0c950 100644 --- a/DanfeSharp/Formatador.cs +++ b/DanfeSharp/Formatador.cs @@ -131,7 +131,12 @@ public static String FormatarCpf(String cpf) /// quando o CNPJ passa a aceitar letras nos 12 primeiros caracteres. /// /// Documento com ou sem máscara visual. - /// Documento formatado, ou o input trimado se comprimento for inválido. + /// + /// Documento formatado quando o conteúdo é válido (CPF de 11 dígitos OU + /// CNPJ de 14 caracteres numéricos/alfanuméricos maiúsculos); + /// o input trimado original caso contrário — preservando pontuação + /// visível para entradas inválidas (comportamento histórico). + /// public static String FormatarCpfCnpj(String cpfCnpj) { if (String.IsNullOrWhiteSpace(cpfCnpj)) @@ -142,17 +147,25 @@ public static String FormatarCpfCnpj(String cpfCnpj) String trimmed = cpfCnpj.Trim(); String clean = trimmed.Replace(".", String.Empty).Replace("-", String.Empty).Replace("/", String.Empty); - switch (clean.Length) + // Valida o conteúdo limpo contra a regex correspondente ANTES do dispatch. + // Sem essa validação, entradas como "12.abc.345/0001-88" (lowercase rejeitado + // pelo formato CNPJ alfanumérico) cairiam no dispatch por comprimento, o + // FormatarCnpj retornaria a string crua sem máscara, e a pontuação visível + // do input original seria perdida. Devolver `trimmed` para input inválido + // alinha com o contrato histórico: regex miss => input inalterado. + if (clean.Length == 11 && Regex.IsMatch(clean, CPF)) { - case 11: - return FormatarCpf(clean); - case 14: - return FormatarCnpj(clean); - default: - // Comprimento inválido — devolve sem aplicar máscara (comportamento - // alinhado com o histórico: nunca jogou exception). - return trimmed; + return FormatarCpf(clean); } + + if (clean.Length == 14 && Regex.IsMatch(clean, CNPJ)) + { + return FormatarCnpj(clean); + } + + // Comprimento inválido OU conteúdo não bate na regex (ex.: lowercase em CNPJ + // alfanumérico, letras em CPF, DV alfabético) — devolve input trimado sem mexer. + return trimmed; } ///