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>
399 lines
15 KiB
C#
399 lines
15 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
namespace Books.Api.Banking;
|
|
|
|
public class EnableBankingClient : IEnableBankingClient, IDisposable
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly EnableBankingOptions _options;
|
|
private readonly ILogger<EnableBankingClient> _logger;
|
|
private readonly JsonSerializerOptions _jsonOptions;
|
|
private readonly RSA _rsaKey;
|
|
private readonly SigningCredentials _signingCredentials;
|
|
|
|
public EnableBankingClient(
|
|
HttpClient httpClient,
|
|
EnableBankingOptions options,
|
|
ILogger<EnableBankingClient> logger)
|
|
{
|
|
_httpClient = httpClient;
|
|
_options = options;
|
|
_logger = logger;
|
|
_jsonOptions = new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
// Parse RSA key once at startup and cache it
|
|
_rsaKey = RSA.Create();
|
|
if (!string.IsNullOrEmpty(options.PrivateKey))
|
|
{
|
|
_rsaKey.ImportFromPem(options.PrivateKey);
|
|
_signingCredentials = new SigningCredentials(
|
|
new RsaSecurityKey(_rsaKey) { KeyId = options.KeyId },
|
|
SecurityAlgorithms.RsaSha256)
|
|
{
|
|
CryptoProviderFactory = new CryptoProviderFactory { CacheSignatureProviders = false }
|
|
};
|
|
}
|
|
else
|
|
{
|
|
_signingCredentials = null!;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_rsaKey?.Dispose();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Aspsp>> GetAspspsAsync(string country = "DK", CancellationToken ct = default)
|
|
{
|
|
var request = new HttpRequestMessage(HttpMethod.Get, $"/aspsps?country={country}");
|
|
AddAuthHeader(request);
|
|
|
|
var response = await _httpClient.SendAsync(request, ct);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<AspspsApiResponse>(_jsonOptions, ct);
|
|
return result?.Aspsps?.Select(MapAspsp).ToList() ?? [];
|
|
}
|
|
|
|
public async Task<AuthorizationResponse> StartAuthorizationAsync(
|
|
string aspspName,
|
|
string redirectUrl,
|
|
string state,
|
|
string psuType = "personal",
|
|
string? psuIpAddress = null,
|
|
string? psuUserAgent = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
var request = new HttpRequestMessage(HttpMethod.Post, "/auth");
|
|
AddAuthHeader(request);
|
|
AddPsuHeaders(request, psuIpAddress, psuUserAgent);
|
|
|
|
var body = new
|
|
{
|
|
access = new
|
|
{
|
|
valid_until = DateTimeOffset.UtcNow.AddDays(90).ToString("o")
|
|
},
|
|
aspsp = new { name = aspspName, country = "DK" },
|
|
state,
|
|
redirect_url = redirectUrl,
|
|
psu_type = psuType
|
|
};
|
|
|
|
request.Content = JsonContent.Create(body, options: _jsonOptions);
|
|
|
|
_logger.LogDebug(
|
|
"Starting authorization for {Bank} with PSU IP: {PsuIp}, PSU UA: {PsuUa}",
|
|
aspspName, psuIpAddress ?? "(not provided)", psuUserAgent ?? "(not provided)");
|
|
|
|
var response = await _httpClient.SendAsync(request, ct);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync(ct);
|
|
throw new HttpRequestException($"Enable Banking auth error {response.StatusCode}: {errorContent}");
|
|
}
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<AuthApiResponse>(_jsonOptions, ct);
|
|
return new AuthorizationResponse(result!.AuthorizationId, result.Url);
|
|
}
|
|
|
|
public async Task<SessionResponse> CreateSessionAsync(
|
|
string authorizationCode,
|
|
string? psuIpAddress = null,
|
|
string? psuUserAgent = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
var request = new HttpRequestMessage(HttpMethod.Post, "/sessions");
|
|
AddAuthHeader(request);
|
|
AddPsuHeaders(request, psuIpAddress, psuUserAgent);
|
|
|
|
_logger.LogDebug(
|
|
"Creating session with PSU IP: {PsuIp}, PSU UA: {PsuUa}",
|
|
psuIpAddress ?? "(not provided)", psuUserAgent ?? "(not provided)");
|
|
|
|
request.Content = JsonContent.Create(new { code = authorizationCode }, options: _jsonOptions);
|
|
|
|
var response = await _httpClient.SendAsync(request, ct);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
// Log raw response for debugging
|
|
var rawJson = await response.Content.ReadAsStringAsync(ct);
|
|
_logger.LogInformation("Raw session response: {RawJson}", rawJson);
|
|
|
|
var result = System.Text.Json.JsonSerializer.Deserialize<SessionApiResponse>(rawJson, _jsonOptions);
|
|
var sessionId = result!.SessionId;
|
|
var aspspName = result.Aspsp?.Name ?? "";
|
|
|
|
_logger.LogDebug(
|
|
"Session created: {SessionId}, Bank: {Bank}, Accounts in response: {AccountCount}",
|
|
sessionId, aspspName, result.Accounts?.Count ?? 0);
|
|
|
|
// Map accounts from session response
|
|
// Use Uid as AccountId - this is required for fetching transactions/balances
|
|
// Filter out accounts without uid as they cannot be used for API calls
|
|
var accounts = result.Accounts?
|
|
.Where(a => !string.IsNullOrEmpty(a.Uid))
|
|
.Select(a => new SessionAccount(
|
|
a.Uid!,
|
|
a.AccountId?.Iban ?? "",
|
|
a.Currency ?? "DKK",
|
|
a.Name)).ToList() ?? [];
|
|
|
|
_logger.LogDebug(
|
|
"Mapped {MappedCount} accounts with valid uid from {TotalCount} accounts in response",
|
|
accounts.Count, result.Accounts?.Count ?? 0);
|
|
|
|
// Note: There is NO separate /accounts endpoint in Enable Banking API
|
|
// The session response is the ONLY source for account information
|
|
// If accounts array is empty, it may be because:
|
|
// 1. Wrong psuType (personal vs business)
|
|
// 2. Bank doesn't provide account list for this connection type
|
|
if (accounts.Count == 0)
|
|
{
|
|
_logger.LogWarning(
|
|
"No accounts with valid uid in session response for {Bank}. " +
|
|
"This may indicate wrong psuType or bank limitation. Session: {SessionId}",
|
|
aspspName, sessionId);
|
|
}
|
|
|
|
// valid_until is nested inside the access object in the API response
|
|
var validUntil = result.Access?.ValidUntil ?? DateTimeOffset.UtcNow.AddDays(90);
|
|
|
|
return new SessionResponse(sessionId, aspspName, accounts, validUntil);
|
|
}
|
|
|
|
public async Task<AccountDetails> GetAccountDetailsAsync(string sessionId, string accountId, CancellationToken ct = default)
|
|
{
|
|
var request = new HttpRequestMessage(HttpMethod.Get, $"/accounts/{accountId}/details");
|
|
AddAuthHeader(request, sessionId);
|
|
|
|
var response = await _httpClient.SendAsync(request, ct);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<AccountDetailsApiResponse>(_jsonOptions, ct);
|
|
return new AccountDetails(
|
|
accountId,
|
|
result?.Iban ?? "",
|
|
result?.Currency ?? "DKK",
|
|
result?.Name,
|
|
result?.OwnerName,
|
|
result?.Product);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Balance>> GetBalancesAsync(string sessionId, string accountId, CancellationToken ct = default)
|
|
{
|
|
var request = new HttpRequestMessage(HttpMethod.Get, $"/accounts/{accountId}/balances");
|
|
AddAuthHeader(request, sessionId);
|
|
|
|
var response = await _httpClient.SendAsync(request, ct);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<BalancesApiResponse>(_jsonOptions, ct);
|
|
return result?.Balances?.Select(b => new Balance(
|
|
b.BalanceType ?? "",
|
|
b.BalanceAmount?.Amount ?? 0,
|
|
b.BalanceAmount?.Currency ?? "DKK",
|
|
b.ReferenceDate)).ToList() ?? [];
|
|
}
|
|
|
|
public async Task<TransactionsResponse> GetTransactionsAsync(
|
|
string sessionId,
|
|
string accountId,
|
|
DateOnly? dateFrom = null,
|
|
DateOnly? dateTo = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
var from = dateFrom ?? DateOnly.FromDateTime(DateTime.Today.AddMonths(-3));
|
|
var to = dateTo ?? DateOnly.FromDateTime(DateTime.Today);
|
|
|
|
var url = $"/accounts/{accountId}/transactions?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}";
|
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
AddAuthHeader(request, sessionId);
|
|
|
|
var response = await _httpClient.SendAsync(request, ct);
|
|
|
|
var rawJson = await response.Content.ReadAsStringAsync(ct);
|
|
_logger.LogInformation("Transactions API response ({Status}): {RawJson}", response.StatusCode, rawJson);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var result = JsonSerializer.Deserialize<TransactionsApiResponse>(rawJson, _jsonOptions);
|
|
return new TransactionsResponse(
|
|
result?.Transactions?.Select(t => new Transaction(
|
|
t.TransactionId ?? t.EntryReference ?? GenerateDeterministicId(t),
|
|
t.IsDebit ? -(t.TransactionAmount?.Amount ?? 0) : (t.TransactionAmount?.Amount ?? 0),
|
|
t.TransactionAmount?.Currency ?? "DKK",
|
|
t.BookingDate ?? DateOnly.FromDateTime(DateTime.Today),
|
|
t.ValueDate,
|
|
t.CreditorName,
|
|
t.DebtorName,
|
|
t.RemittanceInformationUnstructured,
|
|
t.EndToEndId,
|
|
t.IsDebit)).ToList() ?? [],
|
|
result?.ContinuationKey);
|
|
}
|
|
|
|
private static string GenerateDeterministicId(TransactionApiModel t)
|
|
{
|
|
// Create a deterministic ID based on transaction content
|
|
// Used when bank doesn't provide a unique ID
|
|
var content = string.Join("|",
|
|
t.TransactionAmount?.Amount.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
t.TransactionAmount?.Currency,
|
|
t.BookingDate?.ToString("yyyy-MM-dd"),
|
|
t.RemittanceInformationUnstructured?.Trim(),
|
|
t.CreditorName?.Trim(),
|
|
t.DebtorName?.Trim());
|
|
|
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
|
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
|
var hash = sha256.ComputeHash(bytes);
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
|
|
private void AddAuthHeader(HttpRequestMessage request, string? sessionId = null)
|
|
{
|
|
var jwt = GenerateJwt(sessionId);
|
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt);
|
|
}
|
|
|
|
private void AddPsuHeaders(HttpRequestMessage request, string? psuIpAddress, string? psuUserAgent)
|
|
{
|
|
if (!string.IsNullOrEmpty(psuIpAddress))
|
|
request.Headers.Add("psu-ip-address", psuIpAddress);
|
|
if (!string.IsNullOrEmpty(psuUserAgent))
|
|
request.Headers.Add("psu-user-agent", psuUserAgent);
|
|
}
|
|
|
|
private string GenerateJwt(string? sessionId = null)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
|
|
// Build claims list - only add access claim if session is provided
|
|
List<Claim>? claims = null;
|
|
if (!string.IsNullOrEmpty(sessionId))
|
|
{
|
|
claims = [new Claim("access", JsonSerializer.Serialize(new { session_id = sessionId }))];
|
|
}
|
|
|
|
// Create header with kid explicitly set
|
|
var header = new JwtHeader(_signingCredentials)
|
|
{
|
|
["kid"] = _options.ApplicationId
|
|
};
|
|
|
|
// Create payload matching Enable Banking's expected format
|
|
var payload = new JwtPayload(
|
|
issuer: "enablebanking.com",
|
|
audience: "api.enablebanking.com",
|
|
claims: claims,
|
|
notBefore: null,
|
|
expires: now.AddHours(1),
|
|
issuedAt: now);
|
|
|
|
var token = new JwtSecurityToken(header, payload);
|
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
|
}
|
|
|
|
private static Aspsp MapAspsp(AspspApiModel a) => new(
|
|
a.Name ?? "",
|
|
a.Country ?? "",
|
|
a.Logo ?? "",
|
|
a.PsuTypes ?? [],
|
|
a.PsuTypes?.Contains("business") ?? false,
|
|
a.PsuTypes?.Contains("personal") ?? false);
|
|
|
|
// API Response models
|
|
private record AspspsApiResponse(List<AspspApiModel>? Aspsps);
|
|
private record AspspApiModel(string? Name, string? Country, string? Logo, List<string>? PsuTypes);
|
|
private record AuthApiResponse(string AuthorizationId, string Url);
|
|
private record AccessApiModel(DateTimeOffset? ValidUntil);
|
|
private record SessionApiResponse(
|
|
string SessionId,
|
|
AspspApiModel? Aspsp,
|
|
List<AccountApiModel>? Accounts,
|
|
AccessApiModel? Access);
|
|
private record AccountApiModel(
|
|
AccountIdApiModel? AccountId,
|
|
string? Uid,
|
|
string? Currency,
|
|
string? Name);
|
|
private record AccountIdApiModel(string? Iban);
|
|
private record AccountDetailsApiResponse(
|
|
string? Iban,
|
|
string? Currency,
|
|
string? Name,
|
|
string? OwnerName,
|
|
string? Product);
|
|
private record BalancesApiResponse(List<BalanceApiModel>? Balances);
|
|
private record BalanceApiModel(
|
|
string? BalanceType,
|
|
AmountApiModel? BalanceAmount,
|
|
DateTimeOffset? ReferenceDate);
|
|
private record AmountApiModel(
|
|
[property: JsonConverter(typeof(StringToDecimalConverter))]
|
|
decimal Amount,
|
|
string? Currency);
|
|
|
|
private class StringToDecimalConverter : JsonConverter<decimal>
|
|
{
|
|
public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
{
|
|
if (reader.TokenType == JsonTokenType.String)
|
|
{
|
|
var str = reader.GetString();
|
|
return decimal.Parse(str!, System.Globalization.CultureInfo.InvariantCulture);
|
|
}
|
|
return reader.GetDecimal();
|
|
}
|
|
|
|
public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options)
|
|
{
|
|
writer.WriteNumberValue(value);
|
|
}
|
|
}
|
|
private record TransactionsApiResponse(
|
|
List<TransactionApiModel>? Transactions,
|
|
string? ContinuationKey);
|
|
private record TransactionApiModel(
|
|
string? TransactionId,
|
|
string? EntryReference,
|
|
AmountApiModel? TransactionAmount,
|
|
DateOnly? BookingDate,
|
|
DateOnly? ValueDate,
|
|
PartyApiModel? Creditor,
|
|
PartyApiModel? Debtor,
|
|
List<string>? RemittanceInformation,
|
|
string? EndToEndId,
|
|
string? CreditDebitIndicator)
|
|
{
|
|
public string? CreditorName => Creditor?.Name;
|
|
public string? DebtorName => Debtor?.Name;
|
|
public string? RemittanceInformationUnstructured =>
|
|
RemittanceInformation != null && RemittanceInformation.Count > 0
|
|
? string.Join(" ", RemittanceInformation)
|
|
: null;
|
|
public bool IsDebit => CreditDebitIndicator == "DBIT";
|
|
}
|
|
|
|
private record PartyApiModel(string? Name);
|
|
}
|
|
|
|
public class EnableBankingOptions
|
|
{
|
|
public string ApplicationId { get; set; } = "";
|
|
public string KeyId { get; set; } = "";
|
|
public string PrivateKey { get; set; } = "";
|
|
}
|