books/backend/Books.Api/Controllers/BankingController.cs
Nicolaj Hartmann 8e05171b66 Full product audit: fix security, compliance, UX, and wire broken features
Security (Phase 1):
- Add authentication middleware on /graphql endpoint
- Filter company queries by user access (prevent IDOR)
- Add role-based authorization on mutations (owner/accountant)
- Reduce API key cache TTL from 24h to 5 minutes
- Hide exception details in production GraphQL errors
- Fix RBAC in frontend companyStore (was hardcoded)

Wiring broken features (Phase 2):
- Wire Kassekladde submit/void/copy to GraphQL mutations
- Wire Kontooversigt account creation to createAccount mutation
- Wire Settings save to updateCompany mutation
- Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations
- Replace Momsindberetning mock data with real useVatReport query
- Remove Dashboard hardcoded percentages and fake VAT deadline
- Fix Kreditnotaer invoice selector to use real data
- Fix mutation retry from 1 to 0 (prevent duplicate operations)

Accounting compliance (Phase 3):
- Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate
- Add fiscal year boundary enforcement (status, date range checks)
- Add PostedAt timestamp to posted events (Bogføringsloven §7)
- Add account number uniqueness check within company
- Add fiscal year overlap and gap checks
- Add sequential invoice auto-numbering
- Fix InvoiceLine VAT rate to use canonical VatCodes
- Fix SAF-T account type mapping (financial → Expense)
- Add DraftLine validation (cannot have both debit and credit > 0)

UX improvements (Phase 4):
- Fix Danish character encoding across 15+ files (ø, æ, å)
- Deploy DemoDataDisclaimer on pages with mock/incomplete data
- Adopt PageHeader component universally across all pages
- Standardize active/inactive filtering to Switch pattern
- Fix dead buttons in Header (Help, Notifications)
- Remove hardcoded mock data from Settings
- Fix Sidebar controlled state and Kontooversigt navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:35:26 +01:00

109 lines
4.2 KiB
C#

using Books.Api.Banking;
using Books.Api.Commands.BankConnections;
using Books.Api.Domain.BankConnections;
using EventFlow;
using EventFlow.Aggregates.ExecutionResults;
using Microsoft.AspNetCore.Mvc;
namespace Books.Api.Controllers;
[ApiController]
[Route("api/banking")]
public class BankingController : ControllerBase
{
private readonly ICommandBus _commandBus;
private readonly IEnableBankingClient _bankingClient;
private readonly ILogger<BankingController> _logger;
private readonly IConfiguration _configuration;
public BankingController(
ICommandBus commandBus,
IEnableBankingClient bankingClient,
ILogger<BankingController> logger,
IConfiguration configuration)
{
_commandBus = commandBus;
_bankingClient = bankingClient;
_logger = logger;
_configuration = configuration;
}
/// <summary>
/// OAuth callback from Enable Banking after user authorizes bank connection.
/// </summary>
[HttpGet("callback")]
public async Task<IActionResult> Callback(
[FromQuery] string? code,
[FromQuery] string? state,
[FromQuery] string? error,
[FromQuery] string? error_description,
CancellationToken ct)
{
var frontendBaseUrl = _configuration["Frontend:BaseUrl"] ?? "http://localhost:3000";
var redirectUrl = $"{frontendBaseUrl}/indstillinger?tab=bankAccounts";
// Handle error from bank
if (!string.IsNullOrEmpty(error))
{
_logger.LogWarning("Bank authorization failed: {Error} - {Description}", error, error_description);
return Redirect($"{redirectUrl}&error={Uri.EscapeDataString(error_description ?? error)}");
}
// Validate required parameters
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
{
_logger.LogWarning("Missing code or state in callback");
return Redirect($"{redirectUrl}&error=missing_parameters");
}
try
{
// TODO: Add proper CSRF/state validation. Currently the state parameter
// is used as the connection ID, but it should also include a CSRF token
// that is validated against the user session to prevent cross-site request
// forgery attacks on the OAuth callback.
// State contains the connection ID (set during StartBankConnection)
var connectionId = state;
// Get PSU headers from HttpContext (required by Enable Banking API)
var psuIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var psuUserAgent = Request.Headers.UserAgent.ToString();
// Exchange authorization code for session
var session = await _bankingClient.CreateSessionAsync(code, psuIpAddress, psuUserAgent, ct);
_logger.LogInformation(
"Bank session created: {SessionId}, Bank: {Bank}, Accounts: {AccountCount}",
session.SessionId,
session.AspspName,
session.Accounts.Count);
// Complete the bank connection
var command = new EstablishBankConnectionCommand(
BankConnectionId.With(connectionId),
session.SessionId,
session.ValidUntil,
session.Accounts.Select(a => new BankAccountInfo(
a.AccountId,
a.Iban,
a.Currency,
a.AccountName)).ToList());
var result = await _commandBus.PublishAsync(command, ct);
if (result is FailedExecutionResult failed)
{
_logger.LogError("Failed to complete bank connection: {Errors}", string.Join(", ", failed.Errors));
return Redirect($"{redirectUrl}&error=completion_failed");
}
_logger.LogInformation("Bank connection {ConnectionId} completed successfully", connectionId);
return Redirect($"{redirectUrl}&success=true");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error completing bank connection");
return Redirect($"{redirectUrl}&error=internal_error");
}
}
}