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; /// /// REST API for AI-powered document processing. /// Handles document upload, AI analysis, draft creation, and bank transaction matching. /// [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 logger) : ControllerBase { private const long MaxFileSize = 10 * 1024 * 1024; // 10MB private static readonly HashSet AllowedContentTypes = [ "application/pdf", "image/png", "image/jpeg", "image/jpg", "image/gif" ]; /// /// Process a document using AI and optionally match to a bank transaction. /// /// Company ID /// Document file (PDF, PNG, JPG) /// Cancellation token [HttpPost("process")] [RequestSizeLimit(MaxFileSize)] public async Task 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? 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 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 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 CreateDraftLines(List? mappedLines) { if (mappedLines == null || mappedLines.Count == 0) { return []; } var lineNumber = 1; var result = new List(); 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? 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? 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; } }