feat: initial project setup
- Add .NET 8 backend with Clean Architecture - Add React + Vite + TypeScript frontend - Implement authentication with JWT - Implement Azure Blob Storage client - Implement OCR integration - Implement supplier matching service - Implement voucher generation - Implement Fortnox provider - Add unit and integration tests - Add Docker Compose configuration
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
using InvoiceMaster.Application.DTOs;
|
||||
using InvoiceMaster.Core.Entities;
|
||||
using InvoiceMaster.Core.Interfaces;
|
||||
using InvoiceMaster.Integrations.Accounting;
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace InvoiceMaster.Application.Commands.Invoices;
|
||||
|
||||
public record ImportInvoiceCommand(
|
||||
Guid InvoiceId,
|
||||
Guid UserId,
|
||||
bool CreateSupplier = false,
|
||||
AccountingSupplier? SupplierData = null) : IRequest<ImportInvoiceResult>;
|
||||
|
||||
public record ImportInvoiceResult(
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
ImportedInvoiceDto? Data);
|
||||
|
||||
public record ImportedInvoiceDto(
|
||||
Guid Id,
|
||||
string Status,
|
||||
string Provider,
|
||||
VoucherInfoDto? Voucher,
|
||||
SupplierInfoDto? Supplier,
|
||||
AttachmentInfoDto? Attachment,
|
||||
DateTime ImportedAt);
|
||||
|
||||
public record SupplierInfoDto(
|
||||
string Number,
|
||||
string Name);
|
||||
|
||||
public record AttachmentInfoDto(
|
||||
string Id,
|
||||
bool Uploaded);
|
||||
|
||||
public class ImportInvoiceCommandHandler : IRequestHandler<ImportInvoiceCommand, ImportInvoiceResult>
|
||||
{
|
||||
private readonly IRepository<Invoice> _invoiceRepository;
|
||||
private readonly IRepository<AccountingConnection> _connectionRepository;
|
||||
private readonly IAccountingSystemFactory _accountingFactory;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly ISupplierMatchingService _supplierMatching;
|
||||
private readonly IVoucherGenerationService _voucherGeneration;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public ImportInvoiceCommandHandler(
|
||||
IRepository<Invoice> invoiceRepository,
|
||||
IRepository<AccountingConnection> connectionRepository,
|
||||
IAccountingSystemFactory accountingFactory,
|
||||
IBlobStorageService blobStorage,
|
||||
ISupplierMatchingService supplierMatching,
|
||||
IVoucherGenerationService voucherGeneration,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
_invoiceRepository = invoiceRepository;
|
||||
_connectionRepository = connectionRepository;
|
||||
_accountingFactory = accountingFactory;
|
||||
_blobStorage = blobStorage;
|
||||
_supplierMatching = supplierMatching;
|
||||
_voucherGeneration = voucherGeneration;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<ImportInvoiceResult> Handle(ImportInvoiceCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await _invoiceRepository.GetByIdAsync(request.InvoiceId, cancellationToken);
|
||||
if (invoice == null)
|
||||
{
|
||||
return new ImportInvoiceResult(false, "Invoice not found", null);
|
||||
}
|
||||
|
||||
if (invoice.Status == InvoiceStatus.Imported)
|
||||
{
|
||||
return new ImportInvoiceResult(false, "Invoice already imported", null);
|
||||
}
|
||||
|
||||
var connection = await _connectionRepository.GetByIdAsync(invoice.ConnectionId, cancellationToken);
|
||||
if (connection == null || !connection.IsActive)
|
||||
{
|
||||
return new ImportInvoiceResult(false, "Connection not found or inactive", null);
|
||||
}
|
||||
|
||||
invoice.SetStatus(InvoiceStatus.Importing);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var accounting = _accountingFactory.Create(connection.Provider);
|
||||
|
||||
string? supplierNumber = invoice.SupplierNumber;
|
||||
if (string.IsNullOrEmpty(supplierNumber))
|
||||
{
|
||||
if (request.CreateSupplier && request.SupplierData != null)
|
||||
{
|
||||
var createdSupplier = await accounting.CreateSupplierAsync(
|
||||
connection.AccessTokenEncrypted,
|
||||
request.SupplierData,
|
||||
cancellationToken);
|
||||
supplierNumber = createdSupplier.SupplierNumber;
|
||||
}
|
||||
else
|
||||
{
|
||||
var matchResult = await _supplierMatching.MatchSupplierAsync(
|
||||
connection.Id,
|
||||
invoice.ExtractedSupplierName,
|
||||
invoice.ExtractedSupplierOrgNumber,
|
||||
cancellationToken);
|
||||
|
||||
if (matchResult.Action == SupplierMatchAction.UseExisting)
|
||||
{
|
||||
supplierNumber = matchResult.SupplierNumber;
|
||||
}
|
||||
else if (matchResult.Action == SupplierMatchAction.CreateNew && connection.AutoCreateSupplier)
|
||||
{
|
||||
var newSupplier = new AccountingSupplier(
|
||||
"",
|
||||
invoice.ExtractedSupplierName ?? "Unknown",
|
||||
invoice.ExtractedSupplierOrgNumber);
|
||||
|
||||
var created = await accounting.CreateSupplierAsync(
|
||||
connection.AccessTokenEncrypted,
|
||||
newSupplier,
|
||||
cancellationToken);
|
||||
supplierNumber = created.SupplierNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invoice.SetSupplierMatch(
|
||||
supplierNumber ?? "",
|
||||
1.0m,
|
||||
SupplierMatchAction.UseExisting);
|
||||
|
||||
var voucher = await _voucherGeneration.GenerateVoucherAsync(
|
||||
invoice,
|
||||
connection,
|
||||
supplierNumber,
|
||||
cancellationToken);
|
||||
|
||||
var createdVoucher = await accounting.CreateVoucherAsync(
|
||||
connection.AccessTokenEncrypted,
|
||||
voucher,
|
||||
cancellationToken);
|
||||
|
||||
invoice.SetVoucher(
|
||||
createdVoucher.Series ?? connection.DefaultVoucherSeries,
|
||||
"",
|
||||
"",
|
||||
JsonSerializer.Serialize(createdVoucher.Rows));
|
||||
|
||||
string? attachmentId = null;
|
||||
if (connection.AutoAttachPdf)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var fileStream = await _blobStorage.DownloadAsync(invoice.StoragePath, cancellationToken);
|
||||
attachmentId = await accounting.UploadAttachmentAsync(
|
||||
connection.AccessTokenEncrypted,
|
||||
invoice.OriginalFilename,
|
||||
fileStream,
|
||||
cancellationToken);
|
||||
|
||||
invoice.SetAttachment(attachmentId, "");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to upload attachment: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
invoice.SetStatus(InvoiceStatus.Imported);
|
||||
invoice.SetReviewed(request.UserId);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var result = new ImportedInvoiceDto(
|
||||
invoice.Id,
|
||||
invoice.Status.ToString().ToLower(),
|
||||
invoice.Provider,
|
||||
new VoucherInfoDto(
|
||||
invoice.VoucherSeries,
|
||||
invoice.VoucherNumber,
|
||||
invoice.VoucherUrl),
|
||||
supplierNumber != null
|
||||
? new SupplierInfoDto(supplierNumber, invoice.ExtractedSupplierName ?? "Unknown")
|
||||
: null,
|
||||
attachmentId != null
|
||||
? new AttachmentInfoDto(attachmentId, true)
|
||||
: null,
|
||||
DateTime.UtcNow);
|
||||
|
||||
return new ImportInvoiceResult(true, null, result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var invoice = await _invoiceRepository.GetByIdAsync(request.InvoiceId, cancellationToken);
|
||||
if (invoice != null)
|
||||
{
|
||||
invoice.SetError("IMPORT_FAILED", ex.Message);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return new ImportInvoiceResult(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using InvoiceMaster.Application.DTOs;
|
||||
using InvoiceMaster.Core.Entities;
|
||||
using InvoiceMaster.Core.Interfaces;
|
||||
using MediatR;
|
||||
|
||||
namespace InvoiceMaster.Application.Commands.Invoices;
|
||||
|
||||
public record UploadInvoiceCommand(
|
||||
Guid UserId,
|
||||
string Provider,
|
||||
string FileName,
|
||||
Stream FileStream,
|
||||
long FileSize,
|
||||
string ContentType) : IRequest<UploadInvoiceResult>;
|
||||
|
||||
public record UploadInvoiceResult(
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
InvoiceDetailDto? Invoice);
|
||||
|
||||
public class UploadInvoiceCommandHandler : IRequestHandler<UploadInvoiceCommand, UploadInvoiceResult>
|
||||
{
|
||||
private readonly IRepository<Invoice> _invoiceRepository;
|
||||
private readonly IRepository<AccountingConnection> _connectionRepository;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly IOcrService _ocrService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public UploadInvoiceCommandHandler(
|
||||
IRepository<Invoice> invoiceRepository,
|
||||
IRepository<AccountingConnection> connectionRepository,
|
||||
IBlobStorageService blobStorage,
|
||||
IOcrService ocrService,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
_invoiceRepository = invoiceRepository;
|
||||
_connectionRepository = connectionRepository;
|
||||
_blobStorage = blobStorage;
|
||||
_ocrService = ocrService;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task<UploadInvoiceResult> Handle(UploadInvoiceCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var connections = await _connectionRepository.GetAllAsync(cancellationToken);
|
||||
var connection = connections.FirstOrDefault(c =>
|
||||
c.UserId == request.UserId &&
|
||||
c.Provider.Equals(request.Provider, StringComparison.OrdinalIgnoreCase) &&
|
||||
c.IsActive);
|
||||
|
||||
if (connection == null)
|
||||
{
|
||||
return new UploadInvoiceResult(false, $"No active connection found for provider '{request.Provider}'", null);
|
||||
}
|
||||
|
||||
var storagePath = await _blobStorage.UploadAsync(request.FileName, request.FileStream, request.ContentType, cancellationToken);
|
||||
|
||||
var invoice = Invoice.Create(
|
||||
connection.Id,
|
||||
request.Provider,
|
||||
request.FileName,
|
||||
storagePath,
|
||||
request.FileSize);
|
||||
|
||||
await _invoiceRepository.AddAsync(invoice, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
request.FileStream.Position = 0;
|
||||
var ocrResult = await _ocrService.ExtractAsync(request.FileStream, request.FileName, cancellationToken);
|
||||
|
||||
if (ocrResult.Success && ocrResult.Data != null)
|
||||
{
|
||||
var data = ocrResult.Data;
|
||||
invoice.SetExtractionData(
|
||||
System.Text.Json.JsonSerializer.Serialize(data),
|
||||
ocrResult.Confidence,
|
||||
data.SupplierName,
|
||||
data.SupplierOrgNumber,
|
||||
data.InvoiceNumber,
|
||||
data.InvoiceDate,
|
||||
data.DueDate,
|
||||
data.AmountTotal,
|
||||
data.AmountVat,
|
||||
data.VatRate,
|
||||
data.OcrNumber,
|
||||
data.Bankgiro,
|
||||
data.Plusgiro,
|
||||
data.Currency);
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var dto = MapToDto(invoice);
|
||||
return new UploadInvoiceResult(true, null, dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UploadInvoiceResult(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static InvoiceDetailDto MapToDto(Invoice invoice)
|
||||
{
|
||||
return new InvoiceDetailDto(
|
||||
invoice.Id,
|
||||
invoice.Status.ToString().ToLower(),
|
||||
invoice.Provider,
|
||||
new FileInfoDto(
|
||||
invoice.OriginalFilename,
|
||||
invoice.FileSize,
|
||||
$"/api/v1/invoices/{invoice.Id}/file"),
|
||||
new ExtractionDataDto(
|
||||
invoice.ExtractedSupplierName,
|
||||
invoice.ExtractedSupplierOrgNumber,
|
||||
invoice.ExtractedInvoiceNumber,
|
||||
invoice.ExtractedInvoiceDate,
|
||||
invoice.ExtractedDueDate,
|
||||
invoice.ExtractedAmountTotal,
|
||||
invoice.ExtractedAmountVat,
|
||||
invoice.ExtractedVatRate,
|
||||
invoice.ExtractedOcrNumber,
|
||||
invoice.ExtractedBankgiro,
|
||||
invoice.ExtractedPlusgiro,
|
||||
invoice.ExtractedCurrency,
|
||||
invoice.ExtractionConfidence),
|
||||
invoice.SupplierNumber != null
|
||||
? new SupplierMatchDto(
|
||||
invoice.SupplierMatchAction?.ToString() ?? "USE_EXISTING",
|
||||
invoice.SupplierNumber,
|
||||
invoice.ExtractedSupplierName,
|
||||
invoice.SupplierMatchConfidence)
|
||||
: null,
|
||||
null,
|
||||
invoice.CreatedAt);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user