diff --git a/.gitattributes b/.gitattributes index 6224b4b..edeabed 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,6 +19,15 @@ Dockerfile text eol=lf *.dockerfile text eol=lf .dockerignore text eol=lf +# Fontes do frontend: LF sempre, inclusive na cópia de trabalho no Windows. O Prettier valida o fim de +# linha e usa LF por padrão; deixá-las no `text=auto` fazia o checkout no Windows gravar CRLF e +# `prettier --check` falhar na máquina do desenvolvedor enquanto passava no CI Linux. Rodar `--write` +# "resolvia" até o próximo checkout, e a falha voltava. Estes tipos só existem em `src/econtabil-web`. +*.ts text eol=lf +*.html text eol=lf +*.scss text eol=lf +*.css text eol=lf + # Nunca tratar como texto. *.pfx binary *.p12 binary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c94df9..070ffd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,11 +46,15 @@ dotnet build eContabil.slnx # 0 avisos dotnet format eContabil.slnx --verify-no-changes dotnet test eContabil.slnx -cd src/econtabil-web && npm run build && npm test +cd src/econtabil-web +npx prettier --check "src/**/*.{ts,html,scss}" # o CI recusa o que estiver fora do padrão +npm run build +npm test ``` -Os três primeiros e os dois últimos precisam estar verdes. O CI roda o mesmo conjunto e recusa o -contrário. +Todos precisam estar verdes. O CI roda exatamente este conjunto e recusa o contrário — inclusive a +conferência do Prettier, que é a que mais escapa por rodar fora do `ng build`. Para corrigir de uma +vez: `npx prettier --write "src/**/*.{ts,html,scss}"`. ## Testes diff --git a/README.md b/README.md index e15c13b..e1561d0 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,16 @@ requisições, cabeçalhos de segurança e exceção não tratada virando Proble **O frontend também**: sessão com renovação silenciosa, rotas protegidas por perfil e as sete telas — painel, empresas, certificados, documentos, detalhe do documento, perfil e login. +A captura já foi exercitada contra a SEFAZ de **produção**: NF-e reais entraram pela Distribuição DF-e, +com XML completo arquivado no object storage e o ponteiro de NSU avançando entre as rodadas. + +Uma ressalva que vale para qualquer instalação: o ponteiro de NSU pertence ao **CNPJ**, não à +aplicação. Se outro sistema fiscal já consome a fila daquele CNPJ, a carga inicial não traz histórico — +os NSUs anteriores já foram entregues a ele. Não é defeito, é como a SEFAZ define a fila. + As pendências conhecidas — todas registradas como decisão, não esquecimento — estão em -[`RELEASE.md`](RELEASE.md). A principal: o teste de ponta a ponta contra a SEFAZ de produção depende de -um CNPJ que ainda não seja consultado por outro sistema fiscal. +[`RELEASE.md`](RELEASE.md). A principal: a manifestação de ciência ainda não foi exercitada contra a +SEFAZ de produção. ### Fluxo mínimo para testar de ponta a ponta @@ -272,9 +279,13 @@ fix/ fix/nsu-retrocede-em-lote-parcial ### Commits ``` -(): (spec NNN) +(): ``` +O resumo se sustenta sozinho. Não há sufixo apontando para spec: elas são locais e não versionadas, e +quem lê o histórico num clone não teria como abrir a referência. O corpo do commit é o lugar de explicar +o porquê, com a informação inteira e não com um ponteiro. + Tipos: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `perf`, `build`. Escopos: `domain`, `application`, `infra`, `api`, `auth`, `sefaz`, `minio`, `hangfire`, `web`, `docker`, `db`. diff --git a/RELEASE.md b/RELEASE.md index 48e8fd0..495c0f2 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,6 +5,7 @@ - [ ] `dotnet build eContabil.slnx` sem avisos - [ ] `dotnet format eContabil.slnx --verify-no-changes` limpo - [ ] `dotnet test eContabil.slnx` verde +- [ ] `npx prettier --check "src/**/*.{ts,html,scss}"` limpo em `src/econtabil-web` - [ ] `npm run build` e `npm test` verdes em `src/econtabil-web` - [ ] `docker compose --profile frontend up -d --build` sobe os cinco serviços saudáveis - [ ] Nenhum `.pfx`, `.env` ou XML de cliente rastreado pelo git @@ -27,6 +28,9 @@ - [ ] `/health/ready` com PostgreSQL, MinIO e SEFAZ - [ ] `/hangfire` recusando anônimo e aceitando `Admin` - [ ] Grade de documentos, detalhe e download de XML +- [ ] Totalizadores da grade e relatório por empresa batendo com a contagem no banco +- [ ] Manifestação de ciência: documento sai de `Pendente` e o protocolo aparece no detalhe +- [ ] Empresa bloqueada pela SEFAZ não recebe consulta nem evento até o prazo expirar ## Operação @@ -37,15 +41,19 @@ ## Pendências conhecidas do 1.0 -Itens registrados como decisão, não esquecimento: +Itens adiados por decisão, não por esquecimento. A coluna diz o que o operador perde enquanto a +pendência existir — as especificações de origem são locais e não versionadas, então apontar para elas +daqui só criaria referência que ninguém consegue abrir. -| Pendência | Onde está registrado | +| Pendência | O que isso custa hoje | |---|---| -| Imagem da API com 375 MB contra os 250 MB do NFR | spec 013 | -| Comando de rotação da chave mestra ainda não implementado | spec 008 e `RUNBOOK.md` | -| Manifestação não exercitada contra a SEFAZ de produção | spec 010 | -| Consulta avulsa por chave (`consChNFe`) | spec 010, decisão adiada | -| E2E de navegador e teste de carga | spec 016, decisões adiadas | +| Imagem da API com 375 MB contra os 250 MB do NFR | Deploy mais lento e mais banda por release; sem impacto em execução | +| Comando de rotação da chave mestra não implementado | Rotacionar `ECONTABIL_CRYPTO_KEY` exige o procedimento manual do `RUNBOOK.md`, com janela de parada | +| Manifestação não exercitada contra a SEFAZ de produção | O caminho de ciência só foi provado por teste; a primeira execução real pode revelar rejeição não prevista | +| Consulta avulsa por chave (`consChNFe`) | Nota conhecida por chave só entra no acervo se aparecer na fila de NSU | +| E2E de navegador e teste de carga | Regressão de fluxo completo e limite de carga dependem de verificação manual | +| Sem tela de usuários no frontend | Criar, editar e desativar operador só por API — `curl` ou Swagger | +| Sem troca da própria senha | O usuário depende de um `Admin` para redefinir; o próprio `Admin` precisa chamar o endpoint contra o próprio id | ## Marcar diff --git a/RUNBOOK.md b/RUNBOOK.md index 860c5cb..c5b1428 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -75,8 +75,9 @@ Quando fazer: suspeita de vazamento, saída de alguém com acesso ao `.env`, ou O modelo já prevê a rotação: cada certificado guarda a `versao_chave` com que foi cifrado. A rotação decifra com a antiga e recifra com a nova, **um certificado por vez**, sem parar o sistema. -> **A implementação do comando de rotação ainda não existe** — está registrada como decisão adiada na -> spec 008. Enquanto não existir, a rotação exige o procedimento manual abaixo, com janela de parada. +> **A implementação do comando de rotação ainda não existe** — é decisão adiada, registrada nas +> pendências de [`RELEASE.md`](RELEASE.md). Enquanto não existir, a rotação exige o procedimento manual +> abaixo, com janela de parada. ### Procedimento manual, hoje diff --git a/src/eContabil.Application/Manifestacao/ManifestarCienciaCommandHandler.cs b/src/eContabil.Application/Manifestacao/ManifestarCienciaCommandHandler.cs index 6192758..500cba5 100644 --- a/src/eContabil.Application/Manifestacao/ManifestarCienciaCommandHandler.cs +++ b/src/eContabil.Application/Manifestacao/ManifestarCienciaCommandHandler.cs @@ -69,6 +69,15 @@ public async Task> Handle( return "Empresa inativa."; } + // O bloqueio por consumo indevido vale para o CNPJ, não para um webservice específico: a SEFAZ + // conta distribuição e eventos na mesma cota. Sem esta guarda o ciclo de manifestação continuaria + // batendo de meia em meia hora numa empresa que a sincronização já parou de consultar, renovando + // justamente o bloqueio que se está esperando expirar. + if (empresa.EstaBloqueada(agora)) + { + return $"Empresa bloqueada pela SEFAZ até {empresa.BloqueadaAte:dd/MM/yyyy HH:mm} UTC."; + } + if (certificado is null) { return "Empresa sem certificado ativo."; @@ -136,6 +145,17 @@ private async Task> PercorrerLotesAsync( await unitOfWork.SalvarAlteracoesAsync(ct); + // Lote inteiro sem desfecho: a SEFAZ processou a remessa mas não devolveu retorno para + // nenhuma das chaves. Elas continuam pendentes, então a próxima volta do laço buscaria + // exatamente o mesmo lote e o reenviaria — até o teto, multiplicando chamadas idênticas + // contra a cota do CNPJ. Quem retoma é o ciclo seguinte. + if (parcial.Registradas + parcial.JaRegistradas + parcial.Rejeitadas == 0) + { + return Result.Ok(new ResultadoManifestacao( + empresa.Id, registradas, jaRegistradas, rejeitadas, RestamPendentes: true, + Mensagem(registradas, jaRegistradas, rejeitadas))); + } + // Lote menor que o pedido significa que a fila de pendentes acabou. if (pendentes.Count < _opcoes.ChavesPorLote) { diff --git a/src/eContabil.Domain/Documentos/DocumentoFiscal.cs b/src/eContabil.Domain/Documentos/DocumentoFiscal.cs index b828ffa..4071b4d 100644 --- a/src/eContabil.Domain/Documentos/DocumentoFiscal.cs +++ b/src/eContabil.Domain/Documentos/DocumentoFiscal.cs @@ -175,6 +175,15 @@ public Result PromoverParaXmlCompleto(string bucket, string objectName, string h PossuiXml = true; OrigemConteudo = OrigemConteudo.XmlCompleto; + // A ciência existe para destravar o XML completo. Com ele em mãos não sobra o que destravar, e + // manter o documento como pendente faria o job de manifestação reenviar o evento 210210 de uma + // nota já resolvida — chamada à SEFAZ sem propósito, do tipo que puxa rejeição e bloqueio do CNPJ. + // Ciência já registrada ou recusada preserva o próprio desfecho: são histórico do que aconteceu. + if (ManifestacaoStatus is ManifestacaoStatus.Pendente) + { + ManifestacaoStatus = ManifestacaoStatus.NaoAplicavel; + } + return Result.Ok(); } diff --git a/src/eContabil.Infrastructure/Persistencia/Migrations/20260728202729_CorrigirManifestacaoDeDocumentosComXmlCompleto.Designer.cs b/src/eContabil.Infrastructure/Persistencia/Migrations/20260728202729_CorrigirManifestacaoDeDocumentosComXmlCompleto.Designer.cs new file mode 100644 index 0000000..c30ba16 --- /dev/null +++ b/src/eContabil.Infrastructure/Persistencia/Migrations/20260728202729_CorrigirManifestacaoDeDocumentosComXmlCompleto.Designer.cs @@ -0,0 +1,598 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using eContabil.Infrastructure.Persistencia; + +#nullable disable + +namespace eContabil.Infrastructure.Persistencia.Migrations +{ + [DbContext(typeof(EContabilDbContext))] + [Migration("20260728202729_CorrigirManifestacaoDeDocumentosComXmlCompleto")] + partial class CorrigirManifestacaoDeDocumentosComXmlCompleto + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("eContabil.Domain.Certificados.CertificadoDigital", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Ativo") + .HasColumnType("boolean") + .HasColumnName("ativo"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("bucket"); + + b.Property("CnpjTitular") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("cnpj_titular"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("DataEmissao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_emissao"); + + b.Property("DataExpiracao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_expiracao"); + + b.Property("EmpresaId") + .HasColumnType("uuid") + .HasColumnName("empresa_id"); + + b.Property("HashArquivo") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hash_arquivo"); + + b.Property("NomeArquivo") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("nome_arquivo"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("object_name"); + + b.Property("SenhaCriptografada") + .IsRequired() + .HasColumnType("text") + .HasColumnName("senha_criptografada"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("thumbprint"); + + b.Property("UfTitular") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("uf_titular"); + + b.Property("VersaoChave") + .HasColumnType("integer") + .HasColumnName("versao_chave"); + + b.HasKey("Id") + .HasName("pk_certificados_digitais"); + + b.HasIndex("DataExpiracao") + .HasDatabaseName("ix_certificados_expiracao") + .HasFilter("ativo"); + + b.HasIndex("EmpresaId") + .IsUnique() + .HasDatabaseName("ix_certificados_empresa_ativo") + .HasFilter("ativo"); + + b.ToTable("certificados_digitais", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.DocumentoFiscal", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bucket") + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("bucket"); + + b.Property("Chave") + .IsRequired() + .HasMaxLength(44) + .HasColumnType("character varying(44)") + .HasColumnName("chave"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("DataAutorizacao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_autorizacao"); + + b.Property("DataEmissao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_emissao"); + + b.Property("EmpresaId") + .HasColumnType("uuid") + .HasColumnName("empresa_id"); + + b.Property("HashXml") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hash_xml"); + + b.Property("ManifestacaoStatus") + .HasColumnType("integer") + .HasColumnName("manifestacao_status"); + + b.Property("ManifestadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("manifestado_em"); + + b.Property("Modelo") + .HasColumnType("integer") + .HasColumnName("modelo"); + + b.Property("MotivoFalhaManifestacao") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("motivo_falha_manifestacao"); + + b.Property("Nsu") + .HasColumnType("bigint") + .HasColumnName("nsu"); + + b.Property("Numero") + .HasColumnType("integer") + .HasColumnName("numero"); + + b.Property("ObjectName") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("object_name"); + + b.Property("OrigemConteudo") + .HasColumnType("integer") + .HasColumnName("origem_conteudo"); + + b.Property("Papel") + .HasColumnType("integer") + .HasColumnName("papel"); + + b.Property("PossuiXml") + .HasColumnType("boolean") + .HasColumnName("possui_xml"); + + b.Property("ProtocoloManifestacao") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("protocolo_manifestacao"); + + b.Property("Serie") + .HasColumnType("integer") + .HasColumnName("serie"); + + b.Property("Situacao") + .HasColumnType("integer") + .HasColumnName("situacao"); + + b.Property("TipoDocumento") + .HasColumnType("integer") + .HasColumnName("tipo_documento"); + + b.Property("ValorTotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("valor_total"); + + b.HasKey("Id") + .HasName("pk_documentos_fiscais"); + + b.HasIndex("Chave") + .HasDatabaseName("ix_documentos_chave"); + + b.HasIndex("EmpresaId", "Chave") + .IsUnique() + .HasDatabaseName("ix_documentos_empresa_chave"); + + b.HasIndex("EmpresaId", "DataEmissao") + .IsDescending(false, true) + .HasDatabaseName("ix_documentos_empresa_emissao"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("EmpresaId", "DataEmissao"), new[] { "Numero", "Serie", "ValorTotal", "Situacao", "PossuiXml" }); + + b.HasIndex("EmpresaId", "Nsu") + .HasDatabaseName("ix_documentos_empresa_nsu"); + + b.HasIndex(new[] { "EmpresaId", "DataEmissao" }, "ix_documentos_manifestacao_pendente") + .HasDatabaseName("ix_documentos_manifestacao_pendente") + .HasFilter("manifestacao_status = 1"); + + b.ToTable("documentos_fiscais", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Empresas.Empresa", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Ambiente") + .HasColumnType("integer") + .HasColumnName("ambiente"); + + b.Property("Ativa") + .HasColumnType("boolean") + .HasColumnName("ativa"); + + b.Property("BloqueadaAte") + .HasColumnType("timestamp with time zone") + .HasColumnName("bloqueada_ate"); + + b.Property("BloqueiosConsecutivos") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bloqueios_consecutivos"); + + b.Property("Cnpj") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("cnpj"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("InscricaoEstadual") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("inscricao_estadual"); + + b.Property("MaxNSU") + .HasColumnType("bigint") + .HasColumnName("max_nsu"); + + b.Property("NomeFantasia") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("nome_fantasia"); + + b.Property("ProximaConsultaEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("proxima_consulta_em"); + + b.Property("RazaoSocial") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("razao_social"); + + b.Property("Uf") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("uf"); + + b.Property("UltimaSincronizacaoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("ultima_sincronizacao_em"); + + b.Property("UltimoNSU") + .HasColumnType("bigint") + .HasColumnName("ultimo_nsu"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_empresas"); + + b.HasIndex("Cnpj") + .IsUnique() + .HasDatabaseName("ix_empresas_cnpj") + .HasFilter("ativa"); + + b.HasIndex("ProximaConsultaEm") + .HasDatabaseName("ix_empresas_proxima_consulta") + .HasFilter("ativa"); + + b.ToTable("empresas", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Sincronizacoes.ExecucaoSincronizacao", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("DocumentosNovos") + .HasColumnType("integer") + .HasColumnName("documentos_novos"); + + b.Property("DocumentosPromovidos") + .HasColumnType("integer") + .HasColumnName("documentos_promovidos"); + + b.Property("EmpresaId") + .HasColumnType("uuid") + .HasColumnName("empresa_id"); + + b.Property("FinalizadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("finalizado_em"); + + b.Property("IniciadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("iniciado_em"); + + b.Property("ItensDescartados") + .HasColumnType("integer") + .HasColumnName("itens_descartados"); + + b.Property("Mensagem") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("mensagem"); + + b.Property("Modo") + .HasColumnType("integer") + .HasColumnName("modo"); + + b.Property("NsuFinal") + .HasColumnType("bigint") + .HasColumnName("nsu_final"); + + b.Property("NsuInicial") + .HasColumnType("bigint") + .HasColumnName("nsu_inicial"); + + b.Property("Resultado") + .HasColumnType("integer") + .HasColumnName("resultado"); + + b.Property("XmlsArquivados") + .HasColumnType("integer") + .HasColumnName("xmls_arquivados"); + + b.HasKey("Id") + .HasName("pk_execucoes_sincronizacao"); + + b.HasIndex("FinalizadoEm") + .IsDescending() + .HasDatabaseName("ix_execucoes_fim"); + + b.HasIndex("EmpresaId", "FinalizadoEm") + .IsDescending(false, true) + .HasDatabaseName("ix_execucoes_empresa_fim"); + + b.ToTable("execucoes_sincronizacao", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("CriadoPorIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("criado_por_ip"); + + b.Property("ExpiraEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_em"); + + b.Property("RevogadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("revogado_em"); + + b.Property("SubstituidoPorId") + .HasColumnType("uuid") + .HasColumnName("substituido_por_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_hash"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_refresh_tokens_usuario"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.Usuario", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Ativo") + .HasColumnType("boolean") + .HasColumnName("ativo"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("nome"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("SenhaHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("senha_hash"); + + b.HasKey("Id") + .HasName("pk_usuarios"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_usuarios_email"); + + b.ToTable("usuarios", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.DocumentoFiscal", b => + { + b.OwnsOne("eContabil.Domain.ValueObjects.ParticipanteFiscal", "Destinatario", b1 => + { + b1.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Documento") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("destinatario_documento"); + + b1.Property("Nome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("destinatario_nome"); + + b1.HasKey("DocumentoFiscalId"); + + b1.ToTable("documentos_fiscais"); + + b1.WithOwner() + .HasForeignKey("DocumentoFiscalId") + .HasConstraintName("fk_documentos_fiscais_documentos_fiscais_id"); + }); + + b.OwnsOne("eContabil.Domain.ValueObjects.ParticipanteFiscal", "Emitente", b1 => + { + b1.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Documento") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("emitente_documento"); + + b1.Property("Nome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("emitente_nome"); + + b1.HasKey("DocumentoFiscalId"); + + b1.HasIndex("Nome") + .HasDatabaseName("ix_documentos_emitente_nome"); + + NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Nome"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b1.HasIndex("Nome"), new[] { "gin_trgm_ops" }); + + b1.ToTable("documentos_fiscais"); + + b1.WithOwner() + .HasForeignKey("DocumentoFiscalId") + .HasConstraintName("fk_documentos_fiscais_documentos_fiscais_id"); + }); + + b.Navigation("Destinatario") + .IsRequired(); + + b.Navigation("Emitente") + .IsRequired(); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.RefreshToken", b => + { + b.HasOne("eContabil.Domain.Usuarios.Usuario", null) + .WithMany("RefreshTokens") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_usuarios_usuario_id"); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.Usuario", b => + { + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/eContabil.Infrastructure/Persistencia/Migrations/20260728202729_CorrigirManifestacaoDeDocumentosComXmlCompleto.cs b/src/eContabil.Infrastructure/Persistencia/Migrations/20260728202729_CorrigirManifestacaoDeDocumentosComXmlCompleto.cs new file mode 100644 index 0000000..bc6f1a0 --- /dev/null +++ b/src/eContabil.Infrastructure/Persistencia/Migrations/20260728202729_CorrigirManifestacaoDeDocumentosComXmlCompleto.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace eContabil.Infrastructure.Persistencia.Migrations +{ + /// + /// Corrige documentos que ficaram marcados como pendentes de ciência mesmo já tendo o XML completo. + /// + /// + /// A promoção de resumo para XML completo não limpava o estado da manifestação. Como a ciência + /// existe só para destravar o XML, esses documentos não têm nada pendente — mas as consultas do job + /// os selecionavam mesmo assim, e cada rodada reenviaria o evento 210210 de notas já resolvidas. + /// Evento sem propósito é justamente o que a SEFAZ trata como consumo indevido, e o bloqueio é por + /// CNPJ. + /// + /// Só o estado pendente é convertido: ciência registrada e ciência recusada são histórico do que de + /// fato aconteceu com o evento e permanecem como estão. + /// + public partial class CorrigirManifestacaoDeDocumentosComXmlCompleto : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + // 1 = Pendente, 0 = NaoAplicavel. + migrationBuilder.Sql( + """ + UPDATE documentos_fiscais + SET manifestacao_status = 0 + WHERE manifestacao_status = 1 + AND possui_xml = TRUE; + """); + } + + /// + /// + /// Sem reversão: depois da correção, o documento promovido fica idêntico ao que já nasceu + /// completo, e devolver os dois ao estado pendente reintroduziria o envio de eventos inúteis à + /// SEFAZ em vez de restaurar o estado anterior. + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/src/eContabil.Infrastructure/Persistencia/Repositorios/CertificadoRepository.cs b/src/eContabil.Infrastructure/Persistencia/Repositorios/CertificadoRepository.cs index c08f987..a12b3b9 100644 --- a/src/eContabil.Infrastructure/Persistencia/Repositorios/CertificadoRepository.cs +++ b/src/eContabil.Infrastructure/Persistencia/Repositorios/CertificadoRepository.cs @@ -1,9 +1,10 @@ using eContabil.Domain.Certificados; +using eContabil.Shared; using Microsoft.EntityFrameworkCore; namespace eContabil.Infrastructure.Persistencia.Repositorios; -public sealed class CertificadoRepository(EContabilDbContext contexto) +public sealed class CertificadoRepository(EContabilDbContext contexto, TimeProvider relogio) : RepositorioBase(contexto), ICertificadoRepository { public async Task ObterAtivoDaEmpresaAsync(Guid empresaId, CancellationToken ct) => @@ -23,9 +24,10 @@ await Conjunto public async Task> ObterVencendoEmAteAsync(int dias, CancellationToken ct) { - // O limite é calculado fora da consulta: DateTime.UtcNow dentro da árvore de expressão seria - // traduzido para now() do banco e o teste perderia o controle do relógio. - var limite = DateTime.UtcNow.AddDays(dias); + // O limite é calculado fora da consulta e vem do relógio injetado. Dentro da árvore de expressão + // viraria now() do banco, e o alerta de vencimento passaria a depender do relógio do PostgreSQL — + // impossível de exercitar num teste, que é justamente onde as janelas de borda se provam. + var limite = relogio.AgoraUtc().AddDays(dias); return await Conjunto .Where(certificado => certificado.DataExpiracao <= limite) diff --git a/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs b/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs index 4510535..7370aa8 100644 --- a/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs +++ b/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs @@ -36,13 +36,18 @@ public async Task AdicionarEmLoteAsync(IEnumerable documentos, /// /// Rastreamento ligado: estes documentos são alterados na mesma unidade de trabalho, assim que a /// SEFAZ responde ao lote de eventos. + /// + /// O PossuiXml na cláusula não é redundante com o status. A ciência serve para destravar o + /// XML completo; quando ele já chegou por outro caminho, não sobra o que destravar, e manifestar + /// assim mesmo é chamada à SEFAZ sem propósito — do tipo que puxa rejeição e bloqueio do CNPJ. /// public async Task> ObterPendentesDeManifestacaoAsync( Guid empresaId, int limite, CancellationToken ct) => await Conjunto .AsTracking() .Where(documento => documento.EmpresaId == empresaId - && documento.ManifestacaoStatus == ManifestacaoStatus.Pendente) + && documento.ManifestacaoStatus == ManifestacaoStatus.Pendente + && !documento.PossuiXml) .OrderBy(documento => documento.DataEmissao) .Take(limite) .ToListAsync(ct); @@ -50,7 +55,8 @@ await Conjunto public async Task> ObterEmpresasComManifestacaoPendenteAsync( int limite, CancellationToken ct) => await Conjunto - .Where(documento => documento.ManifestacaoStatus == ManifestacaoStatus.Pendente) + .Where(documento => documento.ManifestacaoStatus == ManifestacaoStatus.Pendente + && !documento.PossuiXml) .Select(documento => documento.EmpresaId) .Distinct() .Take(limite) diff --git a/src/econtabil-web/.editorconfig b/src/econtabil-web/.editorconfig index f166060..d104a44 100644 --- a/src/econtabil-web/.editorconfig +++ b/src/econtabil-web/.editorconfig @@ -8,6 +8,10 @@ indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true +# O Prettier valida o fim de linha e usa LF. Sem declarar aqui, o editor no Windows gravava CRLF e a +# conferência do CI recusava o arquivo. O .gitattributes fixa o mesmo LF na cópia de trabalho. +end_of_line = lf + [*.ts] quote_type = single ij_typescript_use_double_quotes = false diff --git a/tests/eContabil.Application.Tests/Manifestacao/CenarioManifestacao.cs b/tests/eContabil.Application.Tests/Manifestacao/CenarioManifestacao.cs index 818f633..8110556 100644 --- a/tests/eContabil.Application.Tests/Manifestacao/CenarioManifestacao.cs +++ b/tests/eContabil.Application.Tests/Manifestacao/CenarioManifestacao.cs @@ -55,6 +55,14 @@ public CenarioManifestacao ComEmpresa(bool ativa = true) return this; } + /// Coloca a empresa em bloqueio da SEFAZ vigente no instante do cenário. + public CenarioManifestacao Bloqueada() + { + Empresa.Bloquear(AgoraUtc, TimeSpan.FromHours(1), TimeSpan.FromHours(24)); + + return this; + } + public CenarioManifestacao ComCertificado() { var certificado = CertificadoDigital.Criar( diff --git a/tests/eContabil.Application.Tests/Manifestacao/ManifestarCienciaCommandHandlerTests.cs b/tests/eContabil.Application.Tests/Manifestacao/ManifestarCienciaCommandHandlerTests.cs index ffd6569..7786f4f 100644 --- a/tests/eContabil.Application.Tests/Manifestacao/ManifestarCienciaCommandHandlerTests.cs +++ b/tests/eContabil.Application.Tests/Manifestacao/ManifestarCienciaCommandHandlerTests.cs @@ -130,6 +130,32 @@ public async Task Handle_QuandoASefazOmiteORetornoDeUmaChave_DeixaAPendente() Assert.Equal(ManifestacaoStatus.Pendente, omitido.ManifestacaoStatus); } + [Fact] + public async Task Handle_ComLoteInteiroSemDesfecho_ParaEmVezDeReenviarAsMesmasChaves() + { + // As chaves continuam pendentes, então a volta seguinte do laço buscaria exatamente o mesmo lote. + // Sem parar aqui, o handler reenviaria a remessa idêntica até o teto, multiplicando chamadas + // contra a cota do CNPJ sem que nenhuma delas mude alguma coisa. + var cenario = new CenarioManifestacao().ComEmpresa().ComCertificado(); + var omitido = cenario.Resumo(11); + + cenario + .ComPendentes([omitido], [omitido], [omitido]) + .ComRespostas( + CenarioManifestacao.LoteProcessado(), + CenarioManifestacao.LoteProcessado(), + CenarioManifestacao.LoteProcessado()); + + var resultado = await Executar(cenario, chavesPorLote: 1, tetoDeLotes: 3); + + Assert.True(resultado.Sucesso); + Assert.True(resultado.Valor.RestamPendentes); + Assert.Equal(ManifestacaoStatus.Pendente, omitido.ManifestacaoStatus); + + await cenario.Sefaz.Received(1).ManifestarCienciaAsync( + Arg.Any(), Arg.Any()); + } + [Fact] public async Task Handle_AoAtingirOTetoDeLotes_SinalizaQueRestamPendentes() { @@ -174,6 +200,25 @@ public async Task Handle_SemCertificadoAtivo_NaoEhErro() Assert.Contains("certificado", resultado.Valor.Mensagem, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Handle_ComEmpresaBloqueadaPelaSefaz_NaoChamaASefaz() + { + // A cota de consumo indevido é do CNPJ e a SEFAZ conta distribuição e eventos junto. Manifestar + // durante o bloqueio renova o próprio bloqueio que se está esperando expirar — e o ciclo de + // manifestação roda de meia em meia hora, mesmo com a sincronização já parada. + var cenario = new CenarioManifestacao().ComEmpresa().Bloqueada().ComCertificado(); + cenario.ComPendentes([cenario.Resumo(0)]); + + var resultado = await Executar(cenario); + + Assert.True(resultado.Sucesso); + Assert.Equal(0, resultado.Valor.Registradas); + Assert.Contains("bloqueada", resultado.Valor.Mensagem, StringComparison.OrdinalIgnoreCase); + + await cenario.Sefaz.DidNotReceive().ManifestarCienciaAsync( + Arg.Any(), Arg.Any()); + } + [Fact] public async Task Handle_ComEmpresaInativa_NaoChamaASefaz() { diff --git a/tests/eContabil.Domain.Tests/Documentos/DocumentoFiscalTests.cs b/tests/eContabil.Domain.Tests/Documentos/DocumentoFiscalTests.cs index 0120569..37aad71 100644 --- a/tests/eContabil.Domain.Tests/Documentos/DocumentoFiscalTests.cs +++ b/tests/eContabil.Domain.Tests/Documentos/DocumentoFiscalTests.cs @@ -80,6 +80,46 @@ public void PromoverParaXmlCompleto_ChamadoDuasVezes_EIdempotente() Assert.Equal("hash-original", documento.HashXml); } + [Fact] + public void PromoverParaXmlCompleto_ComCienciaPendente_EncerraAPendencia() + { + // A ciência serve para destravar o XML completo. Com ele em mãos não sobra o que destravar, e + // manter a pendência faria o job reenviar o evento 210210 de uma nota já resolvida — consumo + // indevido, que a SEFAZ pune com bloqueio do CNPJ inteiro. + var documento = DominioFake.CriarDocumento(Guid.CreateVersion7(), PapelEmpresa.Destinatario); + + Assert.Equal(ManifestacaoStatus.Pendente, documento.ManifestacaoStatus); + + documento.PromoverParaXmlCompleto("xmls", "2026/07/chave.xml", "hash-sha256"); + + Assert.Equal(ManifestacaoStatus.NaoAplicavel, documento.ManifestacaoStatus); + } + + [Fact] + public void PromoverParaXmlCompleto_ComCienciaJaRegistrada_PreservaODesfecho() + { + // Ciência aceita é histórico do que aconteceu com o evento, e o protocolo é a prova dela. + var documento = DominioFake.CriarDocumento(Guid.CreateVersion7(), PapelEmpresa.Destinatario); + documento.RegistrarCiencia("135250000012345", DateTime.UtcNow); + + documento.PromoverParaXmlCompleto("xmls", "2026/07/chave.xml", "hash-sha256"); + + Assert.Equal(ManifestacaoStatus.CienciaRegistrada, documento.ManifestacaoStatus); + Assert.Equal("135250000012345", documento.ProtocoloManifestacao); + } + + [Fact] + public void PromoverParaXmlCompleto_ComManifestacaoRecusada_PreservaOMotivo() + { + var documento = DominioFake.CriarDocumento(Guid.CreateVersion7(), PapelEmpresa.Destinatario); + documento.MarcarManifestacaoFalhou("596 — Evento apresentado após o prazo"); + + documento.PromoverParaXmlCompleto("xmls", "2026/07/chave.xml", "hash-sha256"); + + Assert.Equal(ManifestacaoStatus.Falhou, documento.ManifestacaoStatus); + Assert.Equal("596 — Evento apresentado após o prazo", documento.MotivoFalhaManifestacao); + } + [Fact] public void PromoverParaXmlCompleto_SemLocalizacao_RetornaFalha() { diff --git a/tests/eContabil.Infrastructure.Tests/Persistencia/CertificadoRepositoryTests.cs b/tests/eContabil.Infrastructure.Tests/Persistencia/CertificadoRepositoryTests.cs index 3d64259..66e9341 100644 --- a/tests/eContabil.Infrastructure.Tests/Persistencia/CertificadoRepositoryTests.cs +++ b/tests/eContabil.Infrastructure.Tests/Persistencia/CertificadoRepositoryTests.cs @@ -2,6 +2,7 @@ using eContabil.Domain.ValueObjects; using eContabil.Infrastructure.Persistencia.Repositorios; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Time.Testing; using Npgsql; using Xunit; @@ -12,6 +13,8 @@ public class CertificadoRepositoryTests(PostgresFixture postgres) { private static readonly DateTime AgoraUtc = new(2026, 7, 27, 12, 0, 0, DateTimeKind.Utc); + private static FakeTimeProvider Relogio() => new(new DateTimeOffset(AgoraUtc, TimeSpan.Zero)); + [Fact] public async Task Gravar_SegundoCertificadoAtivoParaAMesmaEmpresa_ERejeitadoPeloIndiceParcial() { @@ -61,7 +64,7 @@ public async Task Gravar_NovoCertificadoAposInativarOAnterior_EPermitido() await contexto.SaveChangesAsync(TestContext.Current.CancellationToken); - var repositorio = new CertificadoRepository(contexto); + var repositorio = new CertificadoRepository(contexto, Relogio()); var ativos = await repositorio.ObterDaEmpresaAsync(empresaId, TestContext.Current.CancellationToken); Assert.Equal(2, ativos.Count); @@ -91,11 +94,49 @@ public async Task ObterAtivoDaEmpresa_ComCertificadoInativado_NaoRetorna() await contexto.SaveChangesAsync(TestContext.Current.CancellationToken); await using var leitura = postgres.CriarContexto(); - var repositorio = new CertificadoRepository(leitura); + var repositorio = new CertificadoRepository(leitura, Relogio()); Assert.Null(await repositorio.ObterAtivoDaEmpresaAsync(empresaId, TestContext.Current.CancellationToken)); } + [Fact] + public async Task ObterVencendoEmAte_ContaOsDiasAPartirDoRelogioInjetado() + { + // O certificado vence daqui a um ano. Com o relógio real ele nunca entraria numa janela de 30 + // dias, e a única forma de exercitar a borda do alerta é adiantar o relógio do teste — o que + // exige que a consulta leia o instante de fora, e não do `now()` do PostgreSQL. + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await postgres.LimparAsync(); + var empresaId = await CriarEmpresaAsync(); + + await using (var escrita = postgres.CriarContexto()) + { + escrita.CertificadosDigitais.Add(Certificado(empresaId)); + await escrita.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var contexto = postgres.CriarContexto(); + var relogio = Relogio(); + var repositorio = new CertificadoRepository(contexto, relogio); + + var antes = await repositorio.ObterVencendoEmAteAsync( + dias: 30, TestContext.Current.CancellationToken); + + Assert.Empty(antes); + + relogio.Advance(TimeSpan.FromDays(350)); + + var depois = await repositorio.ObterVencendoEmAteAsync( + dias: 30, TestContext.Current.CancellationToken); + + Assert.Single(depois); + } + private async Task CriarEmpresaAsync() { await using var contexto = postgres.CriarContexto(); diff --git a/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoFiscalRepositoryTests.cs b/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoFiscalRepositoryTests.cs index 96547a5..50128cd 100644 --- a/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoFiscalRepositoryTests.cs +++ b/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoFiscalRepositoryTests.cs @@ -126,6 +126,75 @@ public async Task ValueObjects_AoIremEVoltaremDoBanco_PreservamOValor() Assert.Equal(DateTimeKind.Utc, documento.DataEmissao.Kind); } + [Fact] + public async Task ObterPendentesDeManifestacao_IgnoraODocumentoQueJaTemXmlCompleto() + { + // Rede de segurança para o job: manifestar uma nota cujo XML já chegou é chamada à SEFAZ sem + // propósito, e é assim que o CNPJ inteiro entra em bloqueio por consumo indevido. + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await postgres.LimparAsync(); + var empresaId = await CriarEmpresaAsync(); + + await using (var escrita = postgres.CriarContexto()) + { + var comXml = Documento(empresaId, nsu: 1000); + comXml.PromoverParaXmlCompleto("xmls", "2026/07/chave.xml", "hash"); + + escrita.DocumentosFiscais.Add(comXml); + await escrita.SaveChangesAsync(TestContext.Current.CancellationToken); + + // Volta ao estado que a base de produção carregava antes da correção: pendente mesmo com XML. + await escrita.Database.ExecuteSqlRawAsync( + "UPDATE documentos_fiscais SET manifestacao_status = 1", + TestContext.Current.CancellationToken); + } + + await using var contexto = postgres.CriarContexto(); + var repositorio = new DocumentoFiscalRepository(contexto); + + var pendentes = await repositorio.ObterPendentesDeManifestacaoAsync( + empresaId, limite: 50, TestContext.Current.CancellationToken); + + Assert.Empty(pendentes); + + var empresas = await repositorio.ObterEmpresasComManifestacaoPendenteAsync( + limite: 50, TestContext.Current.CancellationToken); + + Assert.Empty(empresas); + } + + [Fact] + public async Task ObterPendentesDeManifestacao_ComResumoSemXml_TrazODocumento() + { + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await postgres.LimparAsync(); + var empresaId = await CriarEmpresaAsync(); + + await using (var escrita = postgres.CriarContexto()) + { + escrita.DocumentosFiscais.Add(Documento(empresaId, nsu: 1000)); + await escrita.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var contexto = postgres.CriarContexto(); + var repositorio = new DocumentoFiscalRepository(contexto); + + var pendentes = await repositorio.ObterPendentesDeManifestacaoAsync( + empresaId, limite: 50, TestContext.Current.CancellationToken); + + Assert.Single(pendentes); + } + private async Task CriarEmpresaAsync(string cnpj = "11222333000181") { await using var contexto = postgres.CriarContexto(); diff --git a/tests/eContabil.Infrastructure.Tests/eContabil.Infrastructure.Tests.csproj b/tests/eContabil.Infrastructure.Tests/eContabil.Infrastructure.Tests.csproj index a11017b..6354ea9 100644 --- a/tests/eContabil.Infrastructure.Tests/eContabil.Infrastructure.Tests.csproj +++ b/tests/eContabil.Infrastructure.Tests/eContabil.Infrastructure.Tests.csproj @@ -11,6 +11,7 @@ +