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:
Invoice Master
2026-02-04 20:14:34 +01:00
commit 05ea67144f
250 changed files with 50402 additions and 0 deletions

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}