books/backend/Books.Api/Controllers/DocumentProcessingController.cs
Nicolaj Hartmann 1f75c5d791 Add all backend domain, commands, repositories, and tests
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>
2026-01-30 22:19:42 +01:00

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