This commit includes all previously untracked backend files: Domain: - Accounts, Attachments, BankConnections, Customers - FiscalYears, Invoices, JournalEntryDrafts - Orders, Products, UserAccess Commands & Handlers: - Full CQRS command structure for all domains Repositories: - PostgreSQL repositories for all read models - Bank transaction and ledger repositories GraphQL: - Input types, scalars, and types for all entities - Mutations and queries Infrastructure: - Banking integration (Enable Banking client) - File storage, Invoicing, Reporting, SAF-T export - Database migrations (003-029) Tests: - Integration tests for GraphQL endpoints - Domain tests - Invoicing and reporting tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
455 lines
17 KiB
C#
455 lines
17 KiB
C#
using System.Linq;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using Books.Api.AiBookkeeper;
|
|
using Books.Api.Authorization;
|
|
using Books.Api.Commands.Attachments;
|
|
using Books.Api.Commands.JournalEntryDrafts;
|
|
using Books.Api.Domain.Attachments;
|
|
using Books.Api.Domain.JournalEntryDrafts;
|
|
using Books.Api.EventFlow.Repositories;
|
|
using Books.Api.Infrastructure.FileStorage;
|
|
using EventFlow;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace Books.Api.Controllers;
|
|
|
|
/// <summary>
|
|
/// REST API for AI-powered document processing.
|
|
/// Handles document upload, AI analysis, draft creation, and bank transaction matching.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/documents")]
|
|
[Authorize]
|
|
public class DocumentProcessingController(
|
|
ICommandBus commandBus,
|
|
IAiBookkeeperClient aiClient,
|
|
IChartOfAccountsProvider chartProvider,
|
|
IAccountMappingService accountMapping,
|
|
IBankTransactionMatcher transactionMatcher,
|
|
IDocumentHashRepository hashRepository,
|
|
IBankTransactionRepository bankTransactionRepository,
|
|
IVoucherNumberService voucherNumberService,
|
|
IFileStorageService fileStorage,
|
|
ICompanyAccessService companyAccess,
|
|
IWebHostEnvironment environment,
|
|
ILogger<DocumentProcessingController> logger) : ControllerBase
|
|
{
|
|
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
|
private static readonly HashSet<string> AllowedContentTypes =
|
|
[
|
|
"application/pdf",
|
|
"image/png",
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/gif"
|
|
];
|
|
|
|
/// <summary>
|
|
/// Process a document using AI and optionally match to a bank transaction.
|
|
/// </summary>
|
|
/// <param name="companyId">Company ID</param>
|
|
/// <param name="document">Document file (PDF, PNG, JPG)</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
[HttpPost("process")]
|
|
[RequestSizeLimit(MaxFileSize)]
|
|
public async Task<IActionResult> ProcessDocument(
|
|
[FromQuery] string companyId,
|
|
[FromForm] IFormFile document,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Validate authentication
|
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
return Unauthorized(new DocumentProcessingError("NOT_AUTHENTICATED", "Du skal være logget ind"));
|
|
}
|
|
|
|
// Validate company access
|
|
var canWrite = await companyAccess.CanWriteAsync(companyId, cancellationToken);
|
|
if (!canWrite)
|
|
{
|
|
return Forbid();
|
|
}
|
|
|
|
// Validate file
|
|
if (document.Length == 0)
|
|
{
|
|
return BadRequest(new DocumentProcessingError("NO_FILE", "Ingen fil uploadet"));
|
|
}
|
|
|
|
if (document.Length > MaxFileSize)
|
|
{
|
|
return BadRequest(new DocumentProcessingError("FILE_TOO_LARGE", "Filen er for stor (maks 10MB)"));
|
|
}
|
|
|
|
if (!AllowedContentTypes.Contains(document.ContentType.ToLowerInvariant()))
|
|
{
|
|
return BadRequest(new DocumentProcessingError(
|
|
"INVALID_FILE_TYPE",
|
|
"Kun PDF og billeder (PNG, JPG) er tilladt"));
|
|
}
|
|
|
|
try
|
|
{
|
|
// 1. Calculate content hash
|
|
string contentHash;
|
|
await using (var hashStream = document.OpenReadStream())
|
|
{
|
|
contentHash = await ComputeHashAsync(hashStream, cancellationToken);
|
|
}
|
|
|
|
// 2. Check for duplicate
|
|
var existingHash = await hashRepository.GetByHashAsync(companyId, contentHash, cancellationToken);
|
|
if (existingHash != null)
|
|
{
|
|
logger.LogInformation(
|
|
"Duplicate document detected for company {CompanyId}: {Hash}",
|
|
companyId, contentHash);
|
|
|
|
return Ok(new DocumentProcessingResult
|
|
{
|
|
IsDuplicate = true,
|
|
DraftId = existingHash.DraftId,
|
|
AttachmentId = existingHash.AttachmentId,
|
|
Message = "Dokumentet er allerede behandlet"
|
|
});
|
|
}
|
|
|
|
// 3. Get chart of accounts
|
|
var chartOfAccounts = await chartProvider.GetChartOfAccountsAsync(companyId, cancellationToken);
|
|
|
|
// 4. Call AI Bookkeeper
|
|
AiBookkeeperResponse aiResponse;
|
|
await using (var aiStream = document.OpenReadStream())
|
|
{
|
|
aiResponse = await aiClient.ProcessDocumentAsync(
|
|
aiStream,
|
|
document.FileName,
|
|
document.ContentType,
|
|
chartOfAccounts,
|
|
cancellationToken);
|
|
}
|
|
|
|
if (!aiResponse.Success)
|
|
{
|
|
logger.LogWarning(
|
|
"AI Bookkeeper failed for document {FileName}: {Error}",
|
|
document.FileName, aiResponse.ErrorMessage);
|
|
|
|
return StatusCode(503, new DocumentProcessingError(
|
|
"AI_UNAVAILABLE",
|
|
aiResponse.ErrorMessage ?? "AI-tjenesten er midlertidigt utilgængelig"));
|
|
}
|
|
|
|
// 5. Store attachment
|
|
string attachmentId;
|
|
await using (var storageStream = document.OpenReadStream())
|
|
{
|
|
var storageResult = await fileStorage.StoreAsync(
|
|
companyId,
|
|
document.FileName,
|
|
document.ContentType,
|
|
storageStream,
|
|
cancellationToken);
|
|
|
|
var attId = AttachmentId.New;
|
|
attachmentId = attId.Value;
|
|
|
|
var uploadCommand = new UploadAttachmentCommand(
|
|
attId,
|
|
companyId,
|
|
storageResult.StoredFileName,
|
|
document.FileName,
|
|
document.ContentType,
|
|
storageResult.FileSize,
|
|
storageResult.StoragePath,
|
|
userId,
|
|
null); // draftId will be set later
|
|
|
|
await commandBus.PublishAsync(uploadCommand, cancellationToken);
|
|
}
|
|
|
|
// 6. Map standard accounts to company accounts
|
|
List<MappedSuggestedLine>? mappedLines = null;
|
|
if (aiResponse.Suggestion?.Lines != null)
|
|
{
|
|
mappedLines = await accountMapping.MapSuggestedLinesAsync(
|
|
companyId,
|
|
aiResponse.Suggestion.Lines,
|
|
cancellationToken);
|
|
}
|
|
|
|
// 7. Create JournalEntryDraft
|
|
var draftId = JournalEntryDraftId.New();
|
|
var voucherNumber = await voucherNumberService.GetNextVoucherNumberAsync(companyId, null, cancellationToken);
|
|
|
|
// Serialize full extraction data to preserve all AI-extracted fields
|
|
string? extractionDataJson = null;
|
|
if (aiResponse.Extraction != null)
|
|
{
|
|
extractionDataJson = System.Text.Json.JsonSerializer.Serialize(aiResponse.Extraction,
|
|
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });
|
|
}
|
|
|
|
var draftName = aiResponse.Extraction?.Vendor ?? document.FileName;
|
|
var createDraftCommand = new CreateJournalEntryDraftCommand(
|
|
draftId,
|
|
companyId,
|
|
draftName,
|
|
userId,
|
|
voucherNumber,
|
|
extractionDataJson);
|
|
|
|
await commandBus.PublishAsync(createDraftCommand, cancellationToken);
|
|
|
|
// Update draft with AI-suggested lines
|
|
var draftLines = CreateDraftLines(mappedLines);
|
|
var updateDraftCommand = new UpdateJournalEntryDraftCommand(
|
|
draftId,
|
|
draftName,
|
|
aiResponse.Extraction?.Date,
|
|
aiResponse.Extraction?.InvoiceNumber ?? aiResponse.Suggestion?.Description,
|
|
null, // fiscalYearId - let the system determine
|
|
draftLines,
|
|
[attachmentId]);
|
|
|
|
await commandBus.PublishAsync(updateDraftCommand, cancellationToken);
|
|
|
|
// 8. Find matching bank transaction
|
|
var matchedTransaction = await FindMatchingTransaction(
|
|
companyId,
|
|
aiResponse.Extraction?.TotalAmount,
|
|
cancellationToken);
|
|
|
|
// 9. Link draft to transaction if matched
|
|
if (matchedTransaction != null)
|
|
{
|
|
await bankTransactionRepository.UpdateStatusAsync(
|
|
matchedTransaction.Id,
|
|
"booked",
|
|
draftId.Value,
|
|
cancellationToken);
|
|
|
|
logger.LogInformation(
|
|
"Linked draft {DraftId} to bank transaction {TransactionId}",
|
|
draftId.Value, matchedTransaction.Id);
|
|
}
|
|
|
|
// 10. Save content hash
|
|
await hashRepository.InsertAsync(
|
|
companyId,
|
|
contentHash,
|
|
document.FileName,
|
|
attachmentId,
|
|
draftId.Value,
|
|
cancellationToken);
|
|
|
|
// 11. Build response
|
|
var result = new DocumentProcessingResult
|
|
{
|
|
DraftId = draftId.Value,
|
|
AttachmentId = attachmentId,
|
|
IsDuplicate = false,
|
|
Extraction = aiResponse.Extraction != null ? new ExtractionResult
|
|
{
|
|
Vendor = aiResponse.Extraction.Vendor,
|
|
VendorCvr = aiResponse.Extraction.VendorCvr,
|
|
Amount = aiResponse.Extraction.TotalAmount,
|
|
AmountExVat = aiResponse.Extraction.AmountExVat,
|
|
VatAmount = aiResponse.Extraction.VatAmount,
|
|
Date = aiResponse.Extraction.Date?.ToString("yyyy-MM-dd"),
|
|
DueDate = aiResponse.Extraction.DueDate?.ToString("yyyy-MM-dd"),
|
|
InvoiceNumber = aiResponse.Extraction.InvoiceNumber,
|
|
DocumentType = aiResponse.Extraction.DocumentType,
|
|
Currency = aiResponse.Extraction.Currency,
|
|
PaymentReference = aiResponse.Extraction.PaymentReference,
|
|
LineItems = aiResponse.Extraction.LineItems?.Select(li => new ExtractedLineItemResult
|
|
{
|
|
Description = li.Description,
|
|
Quantity = li.Quantity,
|
|
UnitPrice = li.UnitPrice,
|
|
Amount = li.Amount,
|
|
VatRate = li.VatRate
|
|
}).ToList()
|
|
} : null,
|
|
AccountSuggestion = mappedLines != null && mappedLines.Any(l => l.IsMapped)
|
|
? new AccountSuggestionResult
|
|
{
|
|
MappedAccountId = mappedLines.FirstOrDefault(l => l.IsMapped)?.MappedAccount?.Id,
|
|
MappedAccountNumber = mappedLines.FirstOrDefault(l => l.IsMapped)?.MappedAccount?.AccountNumber,
|
|
MappedAccountName = mappedLines.FirstOrDefault(l => l.IsMapped)?.MappedAccount?.Name,
|
|
Confidence = aiResponse.Suggestion?.Confidence ?? 0
|
|
}
|
|
: null,
|
|
BankTransactionMatch = matchedTransaction != null
|
|
? new BankTransactionMatchResult
|
|
{
|
|
TransactionId = matchedTransaction.Id,
|
|
Amount = matchedTransaction.Amount,
|
|
Date = matchedTransaction.TransactionDate.ToString("yyyy-MM-dd"),
|
|
Description = matchedTransaction.Description,
|
|
Counterparty = matchedTransaction.DisplayCounterparty
|
|
}
|
|
: null,
|
|
SuggestedLines = mappedLines?.Select(ml => new SuggestedJournalLine
|
|
{
|
|
AccountId = ml.MappedAccount?.Id,
|
|
AccountNumber = ml.MappedAccount?.AccountNumber,
|
|
AccountName = ml.MappedAccount?.Name ?? ml.Original.AccountName,
|
|
DebitAmount = ml.Original.DebitAmount,
|
|
CreditAmount = ml.Original.CreditAmount,
|
|
VatCode = ml.Original.VatCode
|
|
}).ToList()
|
|
};
|
|
|
|
logger.LogInformation(
|
|
"Successfully processed document {FileName} for company {CompanyId}. Draft: {DraftId}, Match: {HasMatch}",
|
|
document.FileName, companyId, draftId.Value, matchedTransaction != null);
|
|
|
|
return Ok(result);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Unexpected error processing document {FileName}", document.FileName);
|
|
|
|
var errorMessage = environment.IsDevelopment()
|
|
? $"{ex.Message}\n\nStack trace:\n{ex.StackTrace}"
|
|
: "Der opstod en uventet fejl ved behandling af dokumentet";
|
|
|
|
return StatusCode(500, new DocumentProcessingError(
|
|
"PROCESSING_FAILED",
|
|
errorMessage));
|
|
}
|
|
}
|
|
|
|
private static async Task<string> ComputeHashAsync(Stream stream, CancellationToken cancellationToken)
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
var hashBytes = await sha256.ComputeHashAsync(stream, cancellationToken);
|
|
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
|
}
|
|
|
|
private async Task<EventFlow.ReadModels.BankTransactionDto?> FindMatchingTransaction(
|
|
string companyId,
|
|
decimal? amount,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!amount.HasValue || amount.Value == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// For expenses (invoices to pay), the document amount is positive
|
|
// The bank transaction will be negative (money leaving the account)
|
|
// So we search for -amount
|
|
return await transactionMatcher.FindMatchingTransactionAsync(
|
|
companyId,
|
|
-amount.Value, // Negate for expense matching
|
|
0.01m,
|
|
cancellationToken);
|
|
}
|
|
|
|
private static List<DraftLine> CreateDraftLines(List<MappedSuggestedLine>? mappedLines)
|
|
{
|
|
if (mappedLines == null || mappedLines.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var lineNumber = 1;
|
|
var result = new List<DraftLine>();
|
|
|
|
foreach (var mapped in mappedLines)
|
|
{
|
|
result.Add(new DraftLine(
|
|
lineNumber++,
|
|
mapped.MappedAccount?.Id,
|
|
mapped.Original.DebitAmount,
|
|
mapped.Original.CreditAmount,
|
|
mapped.Original.AccountName,
|
|
mapped.Original.VatCode));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// Response DTOs
|
|
|
|
public class DocumentProcessingResult
|
|
{
|
|
public string? DraftId { get; set; }
|
|
public string? AttachmentId { get; set; }
|
|
public bool IsDuplicate { get; set; }
|
|
public string? Message { get; set; }
|
|
public ExtractionResult? Extraction { get; set; }
|
|
public AccountSuggestionResult? AccountSuggestion { get; set; }
|
|
public BankTransactionMatchResult? BankTransactionMatch { get; set; }
|
|
public List<SuggestedJournalLine>? SuggestedLines { get; set; }
|
|
}
|
|
|
|
public class SuggestedJournalLine
|
|
{
|
|
public string? AccountId { get; set; }
|
|
public string? AccountNumber { get; set; }
|
|
public string? AccountName { get; set; }
|
|
public decimal DebitAmount { get; set; }
|
|
public decimal CreditAmount { get; set; }
|
|
public string? VatCode { get; set; }
|
|
}
|
|
|
|
public class ExtractionResult
|
|
{
|
|
public string? Vendor { get; set; }
|
|
public string? VendorCvr { get; set; }
|
|
public decimal? Amount { get; set; }
|
|
public decimal? AmountExVat { get; set; }
|
|
public decimal? VatAmount { get; set; }
|
|
public string? Date { get; set; }
|
|
public string? DueDate { get; set; }
|
|
public string? InvoiceNumber { get; set; }
|
|
public string? DocumentType { get; set; }
|
|
public string? Currency { get; set; }
|
|
public string? PaymentReference { get; set; }
|
|
public List<ExtractedLineItemResult>? LineItems { get; set; }
|
|
}
|
|
|
|
public class ExtractedLineItemResult
|
|
{
|
|
public string? Description { get; set; }
|
|
public decimal? Quantity { get; set; }
|
|
public decimal? UnitPrice { get; set; }
|
|
public decimal? Amount { get; set; }
|
|
public decimal? VatRate { get; set; }
|
|
}
|
|
|
|
public class AccountSuggestionResult
|
|
{
|
|
public string? MappedAccountId { get; set; }
|
|
public string? MappedAccountNumber { get; set; }
|
|
public string? MappedAccountName { get; set; }
|
|
public decimal Confidence { get; set; }
|
|
}
|
|
|
|
public class BankTransactionMatchResult
|
|
{
|
|
public string? TransactionId { get; set; }
|
|
public decimal Amount { get; set; }
|
|
public string? Date { get; set; }
|
|
public string? Description { get; set; }
|
|
public string? Counterparty { get; set; }
|
|
}
|
|
|
|
public class DocumentProcessingError
|
|
{
|
|
public string Code { get; set; }
|
|
public string Message { get; set; }
|
|
|
|
public DocumentProcessingError(string code, string message)
|
|
{
|
|
Code = code;
|
|
Message = message;
|
|
}
|
|
}
|