feat: Initialize ComplianceNFs project structure with core, infrastructure, service, and monitor components

- Created ComplianceNFs.Core project with domain entities and ports
- Implemented BuyingRecord, EnergyInvoice, ParsedInvoice entities
- Defined domain interfaces for mail listening, XML and PDF parsing, and repository access
- Established ComplianceNFs.Infrastructure project with file archiving, mail listening, and data access implementations
- Developed PDF and XML parsers for invoice data extraction
- Set up AccessDbRepository and AttachmentRepository for data retrieval and storage
- Created ComplianceNFs.Service project for background processing and service orchestration
- Implemented Worker service for periodic tasks
- Established ComplianceNFs.Monitor project with WPF UI for monitoring invoice statuses
- Added ViewModel for UI data binding and command handling
- Configured project files for .NET 9.0 and necessary package references
- Created initial appsettings.json for configuration management
- Added TODOs and roadmap for future development
This commit is contained in:
Giuliano Paschoalino 2025-06-05 14:47:28 -03:00
commit 690ab131aa
34 changed files with 1205 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# .NET build artifacts
bin/
obj/
*.user
*.suo
*.userosscache
*.sln.docstates
# VS Code
.vscode/
# Test results
TestResults/
# Build logs
*.log
# OS generated files
Thumbs.db
.DS_Store
# Rider/JetBrains
.idea/
*.sln.iml
# Local settings
appsettings.Development.json
# Backup/history
.history/
# Global.json lock
project.lock.json
# Others
*.ncrunch*
*.localhistory
*.vs/
# Ignore all Debug/Release folders
**/bin/
**/obj/
**/Debug/
**/Release/
.fake

View File

@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ComplianceNFs.Core.Entities;
namespace ComplianceNFs.Core.Application
{
// Handles ingestion of invoices from mail attachments
public interface IInvoiceIngestionService
{
Task IngestAsync();
}
// Handles matching logic for invoices
public interface IMatchingService
{
Task MatchAsync(EnergyInvoice invoice);
}
// Handles compliance validation
public interface IComplianceService
{
Task ValidateAsync(EnergyInvoice invoice);
}
// Handles notifications for mismatches
public interface INotificationService
{
Task NotifyAsync(EnergyInvoice invoice, string message);
}
// Handles archiving of files
public interface IArchivingService
{
Task ArchiveAsync(EnergyInvoice invoice, byte[] rawFile);
}
// For streaming invoice status updates (for Monitor)
public interface IInvoiceStatusStream
{
event Action<EnergyInvoice> StatusUpdated;
IEnumerable<EnergyInvoice> GetRecent(int count = 100);
}
}

View File

@ -0,0 +1,151 @@
using System;
using System.Threading.Tasks;
using System.Net.Mail;
using System.IO;
using ComplianceNFs.Core.Entities;
using ComplianceNFs.Core.Ports;
namespace ComplianceNFs.Core.Application.Services
{
// Handles ingestion of invoices from mail attachments
public class InvoiceIngestionService : IInvoiceIngestionService
{
private readonly IMailListener _mailListener;
private readonly IAttachmentRepository _attachmentRepository;
private readonly IXmlParser _xmlParser;
private readonly IPdfParser _pdfParser;
public InvoiceIngestionService(IMailListener mailListener, IAttachmentRepository attachmentRepository, IXmlParser xmlParser, IPdfParser pdfParser)
{
_mailListener = mailListener;
_attachmentRepository = attachmentRepository;
_xmlParser = xmlParser;
_pdfParser = pdfParser;
_mailListener.NewMailReceived += OnNewMailReceived;
}
private async void OnNewMailReceived(MailMessage mail)
{
// Download attachments, parse, map to EnergyInvoice, save via _attachmentRepository
foreach (var attachment in mail.Attachments)
{
if (attachment is System.Net.Mail.Attachment att && att.Name != null)
{
using var stream = new MemoryStream();
att.ContentStream.CopyTo(stream);
stream.Position = 0;
ParsedInvoice parsed = new ParsedInvoice();
if (att.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
parsed = _xmlParser.Parse(stream);
else if (att.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
parsed = _pdfParser.Parse(stream);
else
continue;
var invoice = new EnergyInvoice
{
Filename = att.Name,
SupplierEmail = mail.From != null ? mail.From.Address : string.Empty,
ReceivedDate = !string.IsNullOrEmpty(mail.Headers?["Date"]) && DateTime.TryParse(mail.Headers["Date"], out var dt) ? dt : DateTime.Now,
CnpjComp = parsed.CnpjComp,
CnpjVend = parsed.CnpjVend,
MontNF = parsed.MontNF,
PrecNF = parsed.PrecNF,
ValorSemImpostos = parsed.ValorSemImpostos,
ValorFinalComImpostos = parsed.ValorFinalComImpostos,
RsComp = parsed.RsComp,
RsVend = parsed.RsVend,
NumeroNF = parsed.NumeroNF,
IcmsNF = parsed.IcmsNF,
UfComp = parsed.UfComp,
UfVend = parsed.UfVend,
Status = InvoiceStatus.Pending
};
await _attachmentRepository.SaveRawAsync(invoice);
}
}
}
public Task IngestAsync()
{
_mailListener.StartListening();
return Task.CompletedTask;
}
}
// Handles matching logic for invoices
public class MatchingService : IMatchingService
{
private readonly IAccessDbRepository _accessDbRepository;
public MatchingService(IAccessDbRepository accessDbRepository)
{
_accessDbRepository = accessDbRepository;
}
public Task MatchAsync(EnergyInvoice invoice)
{
// Example: Primary match logic (simplified)
var records = _accessDbRepository.GetByUnidade(invoice.CnpjComp);
foreach (var record in records)
{
if (record.CnpjComp == invoice.CnpjComp && record.CnpjVend == invoice.CnpjVend)
{
var volMatch = Math.Abs(record.MontLO - invoice.MontNF) / record.MontLO <= 0.01m;
var priceMatch = Math.Abs(record.PrecLO - invoice.PrecNF) / record.PrecLO <= 0.005m;
if (volMatch && priceMatch)
{
invoice.MatchedCodTE = record.CodTE;
invoice.Status = InvoiceStatus.Matched;
break;
}
}
}
// TODO: Add fallback and multi-invoice sum logic
return Task.CompletedTask;
}
}
// Handles compliance validation
public class ComplianceService : IComplianceService
{
public Task ValidateAsync(EnergyInvoice invoice)
{
// Example: Tax compliance check
if (invoice.Status == InvoiceStatus.Matched || invoice.Status == InvoiceStatus.FallbackMatched)
{
var impliedTax = invoice.ValorFinalComImpostos / (invoice.ValorSemImpostos == 0 ? 1 : invoice.ValorSemImpostos) - 1;
if (Math.Abs(impliedTax - invoice.IcmsNF) > 0.01m)
{
invoice.Status = InvoiceStatus.TaxMismatch;
invoice.DiscrepancyNotes = $"Tax mismatch: implied={impliedTax:P2}, expected={invoice.IcmsNF:P2}";
}
else
{
invoice.Status = InvoiceStatus.Validated;
}
}
return Task.CompletedTask;
}
}
// Handles notifications for mismatches
public class NotificationService : INotificationService
{
public Task NotifyAsync(EnergyInvoice invoice, string message)
{
// Example: Send notification (placeholder)
// In production, use SMTP or other email service
Console.WriteLine($"Notify {invoice.SupplierEmail}: {message}");
return Task.CompletedTask;
}
}
// Handles archiving of files
public class ArchivingService : IArchivingService
{
private readonly IFileArchiver _fileArchiver;
public ArchivingService(IFileArchiver fileArchiver)
{
_fileArchiver = fileArchiver;
}
public Task ArchiveAsync(EnergyInvoice invoice, byte[] rawFile)
{
return _fileArchiver.ArchiveAsync(invoice, rawFile);
}
}
}

View File

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.4.25258.110" />
</ItemGroup>
</Project>

View File

@ -0,0 +1 @@
// Contains domain entities for ComplianceNFs

View File

@ -0,0 +1,17 @@
using System;
namespace ComplianceNFs.Core.Entities
{
public class BuyingRecord
{
public int CodTE { get; set; }
public string CodSmartUnidade { get; set; }
public int Mes { get; set; } // month as integer
public int Ano { get; set; } // year as integer
public string CnpjComp { get; set; }
public string CnpjVend { get; set; }
public decimal MontLO { get; set; } // expected volume
public decimal PrecLO { get; set; } // expected unit price
// … other client fields omitted
}
}

View File

@ -0,0 +1,42 @@
using System;
namespace ComplianceNFs.Core.Entities
{
public class EnergyInvoice
{
public int InvoiceId { get; set; } // PK
public string Filename { get; set; }
public string SupplierEmail { get; set; }
public string ConversationId { get; set; }
public DateTime ReceivedDate { get; set; }
public string Md5 { get; set; }
public string CnpjComp { get; set; }
public string CnpjVend { get; set; }
public decimal MontNF { get; set; }
public decimal PrecNF { get; set; }
public decimal ValorSemImpostos { get; set; }
public decimal ValorFinalComImpostos { get; set; }
public string RsComp { get; set; }
public string RsVend { get; set; }
public string NumeroNF { get; set; }
public decimal IcmsNF { get; set; }
public string UfComp { get; set; }
public string UfVend { get; set; }
public int? MatchedCodTE { get; set; } // FK to BuyingRecord
public InvoiceStatus Status { get; set; }
public string DiscrepancyNotes { get; set; }
}
public enum InvoiceStatus
{
Pending,
Matched,
FallbackMatched,
VolumeMismatch,
PriceMismatch,
TaxMismatch,
NotFound,
Error,
Validated
}
}

View File

@ -0,0 +1,18 @@
namespace ComplianceNFs.Core.Entities
{
public class ParsedInvoice
{
public string CnpjComp { get; set; }
public string CnpjVend { get; set; }
public decimal MontNF { get; set; }
public decimal PrecNF { get; set; }
public decimal ValorSemImpostos { get; set; }
public decimal ValorFinalComImpostos { get; set; }
public string RsComp { get; set; }
public string RsVend { get; set; }
public string NumeroNF { get; set; }
public decimal IcmsNF { get; set; }
public string UfComp { get; set; }
public string UfVend { get; set; }
}
}

View File

@ -0,0 +1 @@
// Contains domain interfaces (ports) for ComplianceNFs

View File

@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Mail;
using System.Threading.Tasks;
namespace ComplianceNFs.Core.Ports
{
public interface IMailListener
{
void StartListening();
event Action<MailMessage> NewMailReceived;
}
public interface IXmlParser
{
Entities.ParsedInvoice Parse(Stream xmlStream);
}
public interface IPdfParser
{
Entities.ParsedInvoice Parse(Stream pdfStream);
}
public interface IAccessDbRepository
{
IEnumerable<Entities.BuyingRecord> GetByUnidade(string codSmartUnidade);
IEnumerable<Entities.BuyingRecord> GetByUnidadeAndMonth(string codSmartUnidade, int month, int year);
}
public interface IAttachmentRepository
{
Task SaveRawAsync(Entities.EnergyInvoice invoice);
Task UpdateMatchAsync(int invoiceId, int matchedCodTE, Entities.InvoiceStatus status, string notes);
}
public interface IFileArchiver
{
Task ArchiveAsync(Entities.EnergyInvoice invoice, byte[] rawFile);
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="3.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.1" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ComplianceNFs.Infrastructure\ComplianceNFs.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,76 @@
using System;
using System.IO;
using System.Threading.Tasks;
using ComplianceNFs.Core.Entities;
using ComplianceNFs.Infrastructure.Archiving;
using Xunit;
namespace ComplianceNFs.Infrastructure.Tests
{
public class FileArchiverTests : IDisposable
{
private readonly string _testBasePath;
public FileArchiverTests()
{
_testBasePath = Path.Combine(Path.GetTempPath(), "ComplianceNFsTestArchive");
if (Directory.Exists(_testBasePath))
Directory.Delete(_testBasePath, true);
}
[Fact]
public async Task ArchiveAsync_CreatesFolderAndWritesFile()
{
var archiver = new FileArchiver(_testBasePath);
var invoice = new EnergyInvoice
{
Filename = "testfile.txt",
Status = InvoiceStatus.Validated
};
var data = new byte[] { 1, 2, 3, 4 };
await archiver.ArchiveAsync(invoice, data);
var expectedFolder = Path.Combine(_testBasePath, "Validated");
var expectedFile = Path.Combine(expectedFolder, "testfile.txt");
Assert.True(Directory.Exists(expectedFolder));
Assert.True(File.Exists(expectedFile));
var fileData = await File.ReadAllBytesAsync(expectedFile);
Assert.Equal(data, fileData);
}
[Fact]
public async Task ArchiveAsync_OverwritesExistingFile()
{
var archiver = new FileArchiver(_testBasePath);
var invoice = new EnergyInvoice
{
Filename = "testfile.txt",
Status = InvoiceStatus.Validated
};
var data1 = new byte[] { 1, 2, 3 };
var data2 = new byte[] { 9, 8, 7 };
await archiver.ArchiveAsync(invoice, data1);
await archiver.ArchiveAsync(invoice, data2);
var expectedFile = Path.Combine(_testBasePath, "Validated", "testfile.txt");
var fileData = await File.ReadAllBytesAsync(expectedFile);
Assert.Equal(data2, fileData);
}
public void Dispose()
{
if (Directory.Exists(_testBasePath))
Directory.Delete(_testBasePath, true);
}
}
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
}

View File

@ -0,0 +1,33 @@
using System.IO;
using System.Threading.Tasks;
using ComplianceNFs.Core.Entities;
using ComplianceNFs.Core.Ports;
using System;
using System.IO;
namespace ComplianceNFs.Infrastructure.Archiving
{
// Moves files to archive folders by status
public class FileArchiver : IFileArchiver
{
private readonly string _basePath;
public FileArchiver(string basePath)
{
_basePath = basePath;
}
public async Task ArchiveAsync(EnergyInvoice invoice, byte[] rawFile)
{
// Create subfolder for invoice.Status
var statusFolder = Path.Combine(_basePath, invoice.Status.ToString());
if (!Directory.Exists(statusFolder))
{
Directory.CreateDirectory(statusFolder);
}
// Build file path
var filePath = Path.Combine(statusFolder, invoice.Filename);
// Write file (overwrite if exists)
await File.WriteAllBytesAsync(filePath, rawFile);
}
}
}

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ComplianceNFs.Core\ComplianceNFs.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MailKit" Version="4.12.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql" Version="9.0.3" />
<PackageReference Include="System.Data.OleDb" Version="10.0.0-preview.4.25258.110" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,57 @@
using System;
using System.Net.Mail;
using System.Collections.Generic;
using System.Threading.Tasks;
using ComplianceNFs.Core.Ports;
using Microsoft.Extensions.Configuration;
using MailKit.Net.Imap;
using MailKit.Security;
using MimeKit;
namespace ComplianceNFs.Infrastructure.Mail
{
public class MailListener : IMailListener
{
public event Action<MailMessage> NewMailReceived;
private readonly IConfiguration _config;
private ImapClient _client;
private readonly List<string> _allowList;
private bool _listening;
public MailListener(IConfiguration config)
{
_config = config;
_allowList = _config.GetSection("Mail:SupplierAllowList").Get<List<string>>() ?? new List<string>();
}
public void StartListening()
{
if (_listening) return;
_listening = true;
Task.Run(async () =>
{
_client = new ImapClient();
await _client.ConnectAsync(_config["Mail:Host"], int.Parse(_config["Mail:Port"] ?? "0"), SecureSocketOptions.SslOnConnect);
await _client.AuthenticateAsync(_config["Mail:User"], _config["Mail:Password"]);
await _client.Inbox.OpenAsync(MailKit.FolderAccess.ReadOnly);
foreach (var uid in await _client.Inbox.SearchAsync(MailKit.Search.SearchQuery.NotSeen))
{
var message = await _client.Inbox.GetMessageAsync(uid);
var senderAddress = message.From.Mailboxes.FirstOrDefault()?.Address;
if (!string.IsNullOrEmpty(senderAddress) && _allowList.Contains(senderAddress))
{
var mailMsg = new MailMessage
{
From = new MailAddress(senderAddress), // Fix for CS1922 and CS8670
Subject = message.Subject,
Body = message.TextBody
};
NewMailReceived?.Invoke(mailMsg);
}
}
// For demo: not a real polling loop. Add timer/polling for production.
});
}
}
}

View File

@ -0,0 +1,31 @@
using System.IO;
using System.Text.RegularExpressions;
using ComplianceNFs.Core.Entities;
using ComplianceNFs.Core.Ports;
namespace ComplianceNFs.Infrastructure.Parsers
{
public class PdfParser : IPdfParser
{
public ParsedInvoice Parse(Stream pdfStream)
{
// Minimal demo: just read bytes as text (replace with real PDF parsing in production)
using var ms = new MemoryStream();
pdfStream.CopyTo(ms);
var text = System.Text.Encoding.UTF8.GetString(ms.ToArray());
// Example: extract CNPJ and values using regex (replace with real patterns)
var cnpjComp = Regex.Match(text, @"CNPJComp: (\d{14})").Groups[1].Value;
var cnpjVend = Regex.Match(text, @"CNPJVend: (\d{14})").Groups[1].Value;
var montNF = decimal.TryParse(Regex.Match(text, @"MontNF: ([\d,.]+)").Groups[1].Value, out var m) ? m : 0;
var precNF = decimal.TryParse(Regex.Match(text, @"PrecNF: ([\d,.]+)").Groups[1].Value, out var p) ? p : 0;
return new ParsedInvoice
{
CnpjComp = cnpjComp,
CnpjVend = cnpjVend,
MontNF = montNF,
PrecNF = precNF
// ...fill other fields as needed
};
}
}
}

View File

@ -0,0 +1,33 @@
using System.IO;
using ComplianceNFs.Core.Entities;
using ComplianceNFs.Core.Ports;
namespace ComplianceNFs.Infrastructure.Parsers
{
// Placeholder: fill in actual XML parsing logic
public class XmlParser : IXmlParser
{
public ParsedInvoice Parse(Stream xmlStream)
{
// Use System.Xml to parse known elements
var doc = new System.Xml.XmlDocument();
doc.Load(xmlStream);
var invoice = new ParsedInvoice
{
CnpjComp = doc.SelectSingleNode("//CNPJComp")?.InnerText,
CnpjVend = doc.SelectSingleNode("//CNPJVend")?.InnerText,
MontNF = decimal.TryParse(doc.SelectSingleNode("//MontNF")?.InnerText, out var mont) ? mont : 0,
PrecNF = decimal.TryParse(doc.SelectSingleNode("//PrecNF")?.InnerText, out var prec) ? prec : 0,
ValorSemImpostos = decimal.TryParse(doc.SelectSingleNode("//ValorSemImpostos")?.InnerText, out var vsi) ? vsi : 0,
ValorFinalComImpostos = decimal.TryParse(doc.SelectSingleNode("//ValorFinalComImpostos")?.InnerText, out var vfi) ? vfi : 0,
RsComp = doc.SelectSingleNode("//RsComp")?.InnerText,
RsVend = doc.SelectSingleNode("//RsVend")?.InnerText,
NumeroNF = doc.SelectSingleNode("//NumeroNF")?.InnerText,
IcmsNF = decimal.TryParse(doc.SelectSingleNode("//IcmsNF")?.InnerText, out var icms) ? icms : 0,
UfComp = doc.SelectSingleNode("//UfComp")?.InnerText,
UfVend = doc.SelectSingleNode("//UfVend")?.InnerText
};
return invoice;
}
}
}

View File

@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.OleDb;
using ComplianceNFs.Core.Entities;
using ComplianceNFs.Core.Ports;
namespace ComplianceNFs.Infrastructure.Repositories
{
// Placeholder: fill in actual SQL and mapping logic
public class AccessDbRepository : IAccessDbRepository
{
private readonly string _connectionString;
public AccessDbRepository(string connectionString)
{
_connectionString = connectionString;
}
public IEnumerable<BuyingRecord> GetByUnidade(string codSmartUnidade)
{
var results = new List<BuyingRecord>();
using (var conn = new OleDbConnection(_connectionString))
{
conn.Open();
var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT Cod_TE, Cod_Smart_unidade, Mes, Ano, CNPJ_comp, CNPJ_vend, Mont_LO, Prec_LO FROM Dados_TE WHERE Cod_Smart_unidade = ?";
cmd.Parameters.AddWithValue("@CodSmartUnidade", codSmartUnidade);
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
results.Add(new BuyingRecord
{
CodTE = reader.GetInt32(0),
CodSmartUnidade = reader.GetString(1),
Mes = reader.GetInt32(2),
Ano = reader.GetInt32(3),
CnpjComp = reader.GetString(4),
CnpjVend = reader.GetString(5),
MontLO = reader.GetDecimal(6),
PrecLO = reader.GetDecimal(7)
});
}
}
}
return results;
}
public IEnumerable<BuyingRecord> GetByUnidadeAndMonth(string codSmartUnidade, int month, int year)
{
var results = new List<BuyingRecord>();
using (var conn = new OleDbConnection(_connectionString))
{
conn.Open();
var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT Cod_TE, Cod_Smart_unidade, Mes, Ano, CNPJ_comp, CNPJ_vend, Mont_LO, Prec_LO FROM Dados_TE WHERE Cod_Smart_unidade = ? AND Mes = ? AND Ano = ?";
cmd.Parameters.AddWithValue("@CodSmartUnidade", codSmartUnidade);
cmd.Parameters.AddWithValue("@Mes", month);
cmd.Parameters.AddWithValue("@Ano", year);
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
results.Add(new BuyingRecord
{
CodTE = reader.GetInt32(0),
CodSmartUnidade = reader.GetString(1),
Mes = reader.GetInt32(2),
Ano = reader.GetInt32(3),
CnpjComp = reader.GetString(4),
CnpjVend = reader.GetString(5),
MontLO = reader.GetDecimal(6),
PrecLO = reader.GetDecimal(7)
});
}
}
}
return results;
}
}
}

View File

@ -0,0 +1,70 @@
using System;
using System.Threading.Tasks;
using ComplianceNFs.Core.Entities;
using ComplianceNFs.Core.Ports;
using Npgsql;
using Newtonsoft.Json;
namespace ComplianceNFs.Infrastructure.Repositories
{
// Placeholder: fill in actual SQL and mapping logic
public class AttachmentRepository : IAttachmentRepository
{
private readonly string _connectionString;
public AttachmentRepository(string connectionString)
{
_connectionString = connectionString;
}
public async Task SaveRawAsync(EnergyInvoice invoice)
{
using (var conn = new NpgsqlConnection(_connectionString))
{
await conn.OpenAsync();
var cmd = conn.CreateCommand();
cmd.CommandText = @"INSERT INTO attachments (
filename, supplier_email, conversation_id, received_date, md5, cnpj_comp, cnpj_vend, mont_nf, prec_nf, valor_sem_imp, valor_com_imp, rs_comp, rs_vend, numero_nf, icms_nf, uf_comp, uf_vend, matched_cod_te, status, discrepancy, metadata
) VALUES (
@filename, @supplier_email, @conversation_id, @received_date, @md5, @cnpj_comp, @cnpj_vend, @mont_nf, @prec_nf, @valor_sem_imp, @valor_com_imp, @rs_comp, @rs_vend, @numero_nf, @icms_nf, @uf_comp, @uf_vend, @matched_cod_te, @status, @discrepancy, @metadata
)";
cmd.Parameters.AddWithValue("@filename", invoice.Filename);
cmd.Parameters.AddWithValue("@supplier_email", invoice.SupplierEmail);
cmd.Parameters.AddWithValue("@conversation_id", (object?)invoice.ConversationId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@received_date", invoice.ReceivedDate);
cmd.Parameters.AddWithValue("@md5", invoice.Md5);
cmd.Parameters.AddWithValue("@cnpj_comp", (object?)invoice.CnpjComp ?? DBNull.Value);
cmd.Parameters.AddWithValue("@cnpj_vend", (object?)invoice.CnpjVend ?? DBNull.Value);
cmd.Parameters.AddWithValue("@mont_nf", invoice.MontNF);
cmd.Parameters.AddWithValue("@prec_nf", invoice.PrecNF);
cmd.Parameters.AddWithValue("@valor_sem_imp", invoice.ValorSemImpostos);
cmd.Parameters.AddWithValue("@valor_com_imp", invoice.ValorFinalComImpostos);
cmd.Parameters.AddWithValue("@rs_comp", (object?)invoice.RsComp ?? DBNull.Value);
cmd.Parameters.AddWithValue("@rs_vend", (object?)invoice.RsVend ?? DBNull.Value);
cmd.Parameters.AddWithValue("@numero_nf", (object?)invoice.NumeroNF ?? DBNull.Value);
cmd.Parameters.AddWithValue("@icms_nf", invoice.IcmsNF);
cmd.Parameters.AddWithValue("@uf_comp", (object?)invoice.UfComp ?? DBNull.Value);
cmd.Parameters.AddWithValue("@uf_vend", (object?)invoice.UfVend ?? DBNull.Value);
cmd.Parameters.AddWithValue("@matched_cod_te", (object?)invoice.MatchedCodTE ?? DBNull.Value);
cmd.Parameters.AddWithValue("@status", invoice.Status.ToString());
cmd.Parameters.AddWithValue("@discrepancy", (object?)invoice.DiscrepancyNotes ?? DBNull.Value);
cmd.Parameters.AddWithValue("@metadata", Newtonsoft.Json.JsonConvert.SerializeObject(invoice));
await cmd.ExecuteNonQueryAsync();
}
}
public async Task UpdateMatchAsync(int invoiceId, int matchedCodTE, InvoiceStatus status, string notes)
{
using (var conn = new NpgsqlConnection(_connectionString))
{
await conn.OpenAsync();
var cmd = conn.CreateCommand();
cmd.CommandText = @"UPDATE attachments SET matched_cod_te = @matched_cod_te, status = @status, discrepancy = @discrepancy WHERE invoice_id = @invoice_id";
cmd.Parameters.AddWithValue("@matched_cod_te", matchedCodTE);
cmd.Parameters.AddWithValue("@status", status.ToString());
cmd.Parameters.AddWithValue("@discrepancy", (object?)notes ?? DBNull.Value);
cmd.Parameters.AddWithValue("@invoice_id", invoiceId);
await cmd.ExecuteNonQueryAsync();
}
}
}
}

View File

@ -0,0 +1,9 @@
<Application x:Class="ComplianceNFs.Monitor.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ComplianceNFs.Monitor"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>

View File

@ -0,0 +1,13 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace ComplianceNFs.Monitor;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}

View File

@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.4.25258.110" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ComplianceNFs.Core\ComplianceNFs.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,25 @@
<Window x:Class="ComplianceNFs.Monitor.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:ComplianceNFs.Monitor"
mc:Ignorable="d"
Title="ComplianceNFs Monitor" Height="450" Width="800">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ListView x:Name="LogList" Grid.Row="0" ItemsSource="{Binding RecentInvoices}">
<ListView.View>
<GridView>
<GridViewColumn Header="Timestamp" DisplayMemberBinding="{Binding ReceivedDate}" Width="150"/>
<GridViewColumn Header="Filename" DisplayMemberBinding="{Binding Filename}" Width="250"/>
<GridViewColumn Header="Status" DisplayMemberBinding="{Binding Status}" Width="120"/>
</GridView>
</ListView.View>
</ListView>
<Button Grid.Row="1" Content="Force Scan" Command="{Binding ForceScanCommand}" Height="32" Width="120" HorizontalAlignment="Right" Margin="0,10,0,0"/>
</Grid>
</Window>

View File

@ -0,0 +1,24 @@
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace ComplianceNFs.Monitor;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MonitorViewModel();
}
}

View File

@ -0,0 +1,47 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using ComplianceNFs.Core.Entities;
namespace ComplianceNFs.Monitor
{
public class MonitorViewModel : INotifyPropertyChanged
{
public ObservableCollection<EnergyInvoice> RecentInvoices { get; } = new();
public ICommand ForceScanCommand { get; }
public event PropertyChangedEventHandler PropertyChanged;
public MonitorViewModel()
{
// TODO: Inject IInvoiceStatusStream and subscribe to updates
ForceScanCommand = new RelayCommand(_ => ForceScan());
}
private void ForceScan()
{
// TODO: Call service to force ingestion cycle
}
protected void OnPropertyChanged([CallerMemberName] string name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
public class RelayCommand : ICommand
{
private readonly Action<object> _execute;
private readonly Func<object, bool> _canExecute;
public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public bool CanExecute(object parameter) => _canExecute == null || _canExecute(parameter);
public void Execute(object parameter) => _execute(parameter);
public event EventHandler CanExecuteChanged { add { } remove { } }
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-ComplianceNFs.Service-386f44bf-03c8-490c-96b9-3852d8c3d01a</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-preview.4.25258.110" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ComplianceNFs.Core\ComplianceNFs.Core.csproj" />
<ProjectReference Include="..\ComplianceNFs.Infrastructure\ComplianceNFs.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,36 @@
using ComplianceNFs.Service;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ComplianceNFs.Core.Ports;
using ComplianceNFs.Infrastructure.Repositories;
using ComplianceNFs.Infrastructure.Mail;
using ComplianceNFs.Infrastructure.Parsers;
using ComplianceNFs.Infrastructure.Archiving;
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((hostingContext, config) =>
{
config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
})
.ConfigureServices((context, services) =>
{
var config = context.Configuration;
// Register infrastructure
services.AddSingleton<IAccessDbRepository>(sp => new AccessDbRepository(config["AccessConnectionString"]));
services.AddSingleton<IAttachmentRepository>(sp => new AttachmentRepository(config["PostgresConnectionString"]));
services.AddSingleton<IMailListener, MailListener>();
services.AddSingleton<IXmlParser, XmlParser>();
services.AddSingleton<IPdfParser, PdfParser>();
services.AddSingleton<IFileArchiver>(sp => new FileArchiver(config["ArchiveBasePath"]));
// Register application services
services.AddSingleton<ComplianceNFs.Core.Application.Services.InvoiceIngestionService>();
services.AddSingleton<ComplianceNFs.Core.Application.Services.MatchingService>();
services.AddSingleton<ComplianceNFs.Core.Application.Services.ComplianceService>();
services.AddSingleton<ComplianceNFs.Core.Application.Services.NotificationService>();
services.AddSingleton<ComplianceNFs.Core.Application.Services.ArchivingService>();
services.AddHostedService<Worker>();
})
.Build();
host.Run();

View File

@ -0,0 +1,11 @@
{
"profiles": {
"ComplianceNFs.Service": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,20 @@
namespace ComplianceNFs.Service;
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
public Worker(ILogger<Worker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
await Task.Delay(1000, stoppingToken);
}
}
}

View File

@ -0,0 +1,24 @@
{
"AccessConnectionString": "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=X:\\Back\\Controle NFs\\Dados.accdb;",
"PostgresConnectionString": "Host=…;Port=5432;Database=…;Username=…;Password=…;",
"Mail": {
"Host": "outlook.office365.com",
"Port": 993,
"User": "service@yourcompany.com",
"Password": "…",
"SupplierAllowList": [ "faturamento@…", "nfe@…" ]
},
"PollingIntervalMinutes": 2,
"Tolerances": {
"VolumePercent": 1.0,
"PricePercent": 0.5,
"TaxPercent": 1.0
},
"ArchiveBasePath": "X:\\Back\\Controle NFs\\Fs\\",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

40
ComplianceNFs.sln Normal file
View File

@ -0,0 +1,40 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComplianceNFs.Core", "ComplianceNFs.Core\ComplianceNFs.Core.csproj", "{43625B9E-D806-490C-A65C-DEC88263B563}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComplianceNFs.Infrastructure", "ComplianceNFs.Infrastructure\ComplianceNFs.Infrastructure.csproj", "{86962CF6-BC05-4167-972B-1F24AC9348D4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComplianceNFs.Monitor", "ComplianceNFs.Monitor\ComplianceNFs.Monitor.csproj", "{03F7AF44-2394-410A-98ED-4973B7F66FF4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComplianceNFs.Service", "ComplianceNFs.Service\ComplianceNFs.Service.csproj", "{72BFBD25-0814-420D-8524-21448479A771}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{43625B9E-D806-490C-A65C-DEC88263B563}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{43625B9E-D806-490C-A65C-DEC88263B563}.Debug|Any CPU.Build.0 = Debug|Any CPU
{43625B9E-D806-490C-A65C-DEC88263B563}.Release|Any CPU.ActiveCfg = Release|Any CPU
{43625B9E-D806-490C-A65C-DEC88263B563}.Release|Any CPU.Build.0 = Release|Any CPU
{86962CF6-BC05-4167-972B-1F24AC9348D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{86962CF6-BC05-4167-972B-1F24AC9348D4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{86962CF6-BC05-4167-972B-1F24AC9348D4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{86962CF6-BC05-4167-972B-1F24AC9348D4}.Release|Any CPU.Build.0 = Release|Any CPU
{03F7AF44-2394-410A-98ED-4973B7F66FF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{03F7AF44-2394-410A-98ED-4973B7F66FF4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{03F7AF44-2394-410A-98ED-4973B7F66FF4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{03F7AF44-2394-410A-98ED-4973B7F66FF4}.Release|Any CPU.Build.0 = Release|Any CPU
{72BFBD25-0814-420D-8524-21448479A771}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{72BFBD25-0814-420D-8524-21448479A771}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72BFBD25-0814-420D-8524-21448479A771}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72BFBD25-0814-420D-8524-21448479A771}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

101
TODOs-and-Roadmap.md Normal file
View File

@ -0,0 +1,101 @@
# ComplianceNFs TODOs and Roadmap
## TODOs in Codebase
### ComplianceNFs.Infrastructure/Archiving/FileArchiver.cs
- [x] FileArchiver.ArchiveAsync: Create subfolder for invoice.Status, move file
### ComplianceNFs.Infrastructure/Mail/MailListener.cs
- [x] MailListener.StartListening: Connect to mailbox, filter by allowlist, raise NewMailReceived
### ComplianceNFs.Infrastructure/Parsers/PdfParser.cs
- [x] PdfParser.Parse: Use iTextSharp or PdfSharp to extract text/numbers via regex
### ComplianceNFs.Infrastructure/Parsers/XmlParser.cs
- [x] XmlParser.Parse: Use System.Xml to parse known elements
### ComplianceNFs.Infrastructure/Repositories/AccessDbRepository.cs
- [x] AccessDbRepository.GetByUnidade: Implement actual query to Access DB
- [x] AccessDbRepository.GetByUnidadeAndMonth: Implement actual query to Access DB
### ComplianceNFs.Infrastructure/Repositories/AttachmentRepository.cs
- [x] AttachmentRepository.SaveRawAsync: Implement actual insert into Postgres attachments table
- [x] AttachmentRepository.UpdateMatchAsync: Implement actual update in Postgres
### ComplianceNFs.Core/Application/Services
- [x] InvoiceIngestionService: Implement ingestion logic and subscribe to NewMailReceived
- [x] MatchingService: Implement invoice matching logic
- [x] ComplianceService: Implement compliance validation logic
- [x] NotificationService: Implement notification logic for mismatches
- [x] ArchivingService: Implement archiving logic for final status
### ComplianceNFs.Monitor/MonitorViewModel.cs
- [ ] MonitorViewModel: Inject IInvoiceStatusStream and subscribe to updates
- [ ] MonitorViewModel.ForceScan: Call service to force ingestion cycle
### ComplianceNFs.Service/Program.cs
- [ ] Register application services (InvoiceIngestionService, MatchingService, etc.)
---
## Roadmap to Finish the App
### 1. Infrastructure Layer
- [x] **FileArchiver**: Implement logic to create subfolders by `InvoiceStatus` and move files accordingly.
- [x] **MailListener**: Use MailKit to connect to IMAP/Exchange, filter by allowlist, and raise `NewMailReceived` event.
- [x] **PdfParser**: Integrate iTextSharp or PdfSharp, extract invoice data using regex.
- [x] **XmlParser**: Use System.Xml to parse invoice XMLs and map to `ParsedInvoice`.
- [x] **AccessDbRepository**: Implement queries to Access DB for buying records.
- [x] **AttachmentRepository**: Implement Postgres insert/update for invoice attachments.
### 2. Application Layer
- [x] Implement and register:
- InvoiceIngestionService
- MatchingService
- ComplianceService
- NotificationService
- ArchivingService
- [ ] Wire up these services in DI in `Program.cs`.
### 3. Service Host
- [ ] Ensure all services are registered and started in the Worker.
- [ ] Implement polling and retry logic as per configuration.
### 4. WPF Monitor
- [ ] Inject and subscribe to `IInvoiceStatusStream` in `MonitorViewModel`.
- [ ] Implement `ForceScan` to trigger ingestion from UI.
- [ ] Bind UI to show recent invoice status updates.
### 5. Configuration & Testing
- [ ] Test all configuration values from `appsettings.json`.
- [ ] Add error handling, logging, and validation.
- [ ] Write integration tests for end-to-end flow.
---
**Tip:**
- Tackle infrastructure TODOs first, then application services, then UI and orchestration.
- Use comments in code for any business logic or mapping details that need clarification.

5
global.json Normal file
View File

@ -0,0 +1,5 @@
{
"sdk": {
"version": "9.0.101"
}
}