AccountingNext/Schaad.Accounting.Services/ViewService.cs
Claudio Schaad 5d75bbb64d PR F: precompute dictionaries in ViewService
Eliminate O(N*M) linear scans in view assembly.
GetTransactionViewList / GetBookingRuleViewList: precompute
accountsById and use dictionary lookups instead of accountList.Single
per row. GetAccountViewList: group transactions by
TargetAccountId/OriginAccountId once; per-account balance is now O(k)
instead of a full scan. Fold GetBalanceInAccountCurrency and
GetCurrentBalanceInCHF into a single ComputeBalance helper so the
account-currency balance is computed once per account instead of
twice.

Defer item 19 (async I/O): after PR D each XML file is loaded at most
once per circuit, and this app is single-user local Blazor Server, so
async would be ~40 files of churn for negligible benefit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:00:33 +02:00

358 lines
No EOL
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Datasets.Reports;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Finance.Api;
using Schaad.Finance.Api.Datasets;
namespace Schaad.Accounting.Services
{
public class ViewService : IViewService
{
private readonly IAccountRepository accountRepository;
private readonly IBankTransactionRepository bankTransactionRepository;
private readonly IBookingRuleRepository bookingRuleRepository;
private readonly ISubclassRepository subclassRepository;
private readonly ITransactionRepository transactionRepository;
private readonly IFxService fxService;
private readonly ISettingsService settingsService;
public ViewService(
IAccountRepository accountRepository,
IBankTransactionRepository bankTransactionRepository,
ITransactionRepository transactionRepository,
ISubclassRepository subclassRepository,
IBookingRuleRepository bookingRuleRepository,
IFxService fxService,
ISettingsService settingsService)
{
this.accountRepository = accountRepository;
this.bankTransactionRepository = bankTransactionRepository;
this.transactionRepository = transactionRepository;
this.subclassRepository = subclassRepository;
this.bookingRuleRepository = bookingRuleRepository;
this.fxService = fxService;
this.settingsService = settingsService;
}
/// <summary>
/// Load accounts
/// </summary>
public AccountDataset GetAccountView(string id)
{
var accounts = GetAccountViewList();
return accounts.Single(t => t.Id == id);
}
/// <summary>
/// Load accounts and calculates their balances
/// </summary>
public List<AccountDataset> GetAccountViewList()
{
var accounts = accountRepository.GetAccountList();
var transactionList = GetTransactionViewList();
var subClassNameByNumber = subclassRepository.GetSubClassList().ToDictionary(s => s.Number, s => s.Name);
var settings = settingsService.GetSettings();
// Group transactions by account so per-account balance is O(k) instead of O(M).
var creditsByAccount = transactionList.GroupBy(t => t.TargetAccountId).ToDictionary(g => g.Key, g => g.ToList());
var debitsByAccount = transactionList.GroupBy(t => t.OriginAccountId).ToDictionary(g => g.Key, g => g.ToList());
var accountList = accounts.Select(a =>
{
var balance = ComputeBalance(a, creditsByAccount, debitsByAccount);
return new AccountDataset(
account: a,
balance: balance,
balanceCHF: fxService.ConvertCurrency(balance, a.Currency, "CHF", settings.FixerIoApiKey),
startBalanceCHF: fxService.ConvertCurrency(a.StartBalance, a.Currency, "CHF", settings.FixerIoApiKey),
className: subclassRepository.GetClass(a.Class),
subClassName: subClassNameByNumber[a.SubClass]);
})
.ToList();
return accountList.OrderBy(a => a.Number).ToList();
}
private static decimal ComputeBalance(
Account account,
Dictionary<string, List<TransactionDataset>> creditsByAccount,
Dictionary<string, List<TransactionDataset>> debitsByAccount)
{
var balance = account.StartBalance;
if (creditsByAccount.TryGetValue(account.Id, out var credits))
{
balance += credits.Sum(t => t.GetValue(account.IsFxAccount));
}
if (debitsByAccount.TryGetValue(account.Id, out var debits))
{
balance -= debits.Sum(t => t.GetValue(account.IsFxAccount));
}
return balance;
}
public BalanceDataset GetBalanceView()
{
var accountList = GetAccountViewList();
var balanceView = new BalanceDataset(
activaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
passivaAccountList: accountList.Where(m => m.Class == ClassIds.Passiva).ToList(),
totalActivaCHF: Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => m.BalanceCHF)),
totalPassivaCHF: Math.Abs(accountList.Where(m => m.Class == ClassIds.Passiva).Sum(m => m.BalanceCHF))
);
return balanceView;
}
public BalanceSheetDataset GetBalanceSheetView(int year)
{
var accountList = GetAccountViewList();
var settings = settingsService.GetSettings();
var profit = Math.Abs(accountList.Where(m => m.Class == ClassIds.Income).Sum(m =>m.BalanceCHF));
profit += Math.Abs(accountList.Where(m => m.Class == ClassIds.Activa).Sum(m => fxService.ConvertCurrency(m.StartBalance, m.Currency, "CHF", settings.FixerIoApiKey)));
var loss = Math.Abs(accountList.Where(m => m.Class == ClassIds.Expenses).Sum(m => m.BalanceCHF));
var balanceView = new BalanceSheetDataset(
activaAccountList: accountList.Where(m => m.Class == ClassIds.Activa).ToList(),
incomeAccountList: accountList.Where(m => m.Class == ClassIds.Income).ToList(),
expensesAccountList: accountList.Where(m => m.Class == ClassIds.Expenses).ToList(),
profitCHF: profit,
lossCHF: loss,
year: year
);
return balanceView;
}
/// <summary>
/// Get transaction list with the origin and target account for each transaction
/// </summary>
public List<TransactionDataset> GetTransactionViewList()
{
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
var transactionList = transactionRepository.GetTransactionList();
return transactionList.Select(t =>
new TransactionDataset(
t,
accountsById[t.OriginAccountId],
accountsById[t.TargetAccountId]))
.ToList();
}
/// <summary>
/// Get transaction list with the origin and target account for each transaction
/// </summary>
public List<TransactionDataset> GetTransactionViewList(string accountId)
{
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
var transactionList = transactionRepository.GetTransactionList()
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
return transactionList.Select(t =>
new TransactionDataset(
WithDisplaySign(t),
accountsById[t.OriginAccountId],
accountsById[t.TargetAccountId]))
.ToList();
Transaction WithDisplaySign(Transaction t)
{
var originAccount = accountsById[t.OriginAccountId];
if (originAccount.Class == ClassIds.Activa && accountId == t.OriginAccountId)
{
var copy = new Transaction();
t.Copy(copy);
copy.Value *= -1;
return copy;
}
return t;
}
}
/// <summary>
/// Get booking rules with their account
/// </summary>
public List<BookingRuleDataset> GetBookingRuleViewList()
{
var accountsById = accountRepository.GetAccountList().ToDictionary(a => a.Id);
var bookingRules = bookingRuleRepository.GetBookingRuleList();
return bookingRules.Select(t =>
new BookingRuleDataset(t, accountsById[t.AccountId].Name))
.ToList();
}
/// <summary>
/// Load open bank transactions
/// </summary>
public List<BankTransaction> GetOpenBankTransactionList()
{
var transactions = transactionRepository
.GetTransactionList()
.Where(t => string.IsNullOrEmpty(t.BankTransactionId) == false)
.Select(t => t.BankTransactionId);
var bankTransactions = bankTransactionRepository
.GetBankTransactionList()
.Where(b => b.Ignore == false && transactions.Contains(b.Id) == false)
.ToList();
return bankTransactions;
}
public List<Transaction> MatchOpenBankTransactions()
{
var newTransactionList = new List<Transaction>();
var transactions = transactionRepository.GetTransactionList();
var accounts = accountRepository.GetAccountList();
var bookingRules = bookingRuleRepository.GetBookingRuleList();
var bankTransactions = GetOpenBankTransactionList().OrderBy(t => t.ValueDate);
foreach (var bankTransaction in bankTransactions)
{
var trx = new Transaction(bankTransaction, accounts);
MatchBankTransactionByBookingRule(bankTransaction.Text, trx, bookingRules);
MatchBankTransactionBySameAccountsLastMonth(bankTransaction, trx, transactions);
MatchBankTransactionBySameValueLastMonth(bankTransaction, trx, transactions);
newTransactionList.Add(trx);
}
return newTransactionList;
}
public List<Transaction> MatchCreditCardTransactions(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions)
{
var accounts = accountRepository.GetAccountList();
var bankTrx = bankTransactionRepository.GetBankTransaction(bankTransactionId);
var bookingRules = bookingRuleRepository.GetBookingRuleList();
var trxList = new List<Transaction>();
foreach (var creditCardTransaction in creditCardTransactions)
{
var trx = new Transaction(bankTrx, accounts);
trx.Value = -1 * creditCardTransaction.Amount;
trx.BookingDate = bankTrx.BookingDate;
trx.ValueDate = creditCardTransaction.TransactionDate;
MatchBankTransactionByBookingRule(creditCardTransaction.Transaction, trx, bookingRules);
if (string.IsNullOrEmpty(trx.Text))
{
trx.Text = creditCardTransaction.Transaction;
}
trxList.Add(trx);
}
return trxList;
}
private void MatchBankTransactionByBookingRule(string transactionText, Transaction trx, List<BookingRule> bookingRules)
{
if (string.IsNullOrEmpty(trx.Text) == false)
return;
// all booking rules that match the text
var matchedRules = bookingRules.Where(r => transactionText.ToLowerInvariant().Contains(r.LookupText.ToLowerInvariant())).ToList();
// then match by value
var matchedRule = matchedRules.FirstOrDefault(r => r.LookupValue == Math.Abs(trx.Value));
// if no found, take match without a value
if (matchedRule == null)
{
matchedRule = matchedRules.FirstOrDefault(r => r.LookupValue == 0);
}
if (matchedRule != null)
{
if (string.IsNullOrEmpty(trx.OriginAccountId))
{
trx.OriginAccountId = matchedRule.AccountId;
}
else
{
trx.TargetAccountId = matchedRule.AccountId;
}
trx.Text = matchedRule.BookingText;
}
}
private void MatchBankTransactionBySameAccountsLastMonth(BankTransaction bankTransaction, Transaction trx, List<Transaction> transactions)
{
if (string.IsNullOrEmpty(trx.Text) == false)
return;
var lastMonth = trx.ValueDate.AddMonths(-1);
var lastMonthStart = new DateTime(lastMonth.Year, lastMonth.Month, 1);
var lastMonthEnd = lastMonthStart.AddMonths(1).AddMinutes(-1);
// Find all bookings for the same accounts of the last month
var lastMonthTransactions = transactions.Where(
t =>
t.OriginAccountId == trx.OriginAccountId &&
t.TargetAccountId == trx.TargetAccountId &&
Math.Abs(t.Value) == Math.Abs(trx.Value) &&
t.ValueDate >= lastMonthStart &&
t.ValueDate <= lastMonthEnd)
.ToList();
MatchSameOrOtherDay(trx, lastMonthTransactions);
}
private void MatchBankTransactionBySameValueLastMonth(BankTransaction bankTransaction, Transaction trx, List<Transaction> transactions)
{
if (string.IsNullOrEmpty(trx.Text) == false)
return;
var lastMonth = trx.ValueDate.AddMonths(-1);
var lastMonthStart = new DateTime(lastMonth.Year, lastMonth.Month, 1);
var lastMonthEnd = lastMonthStart.AddMonths(1).AddMinutes(-1);
// Find all bookings for the same amount of the last month
var lastMonthTransactions = transactions.Where(
t =>
Math.Abs(t.Value) == Math.Abs(trx.Value) &&
t.ValueDate >= lastMonthStart &&
t.ValueDate <= lastMonthEnd)
.ToList();
MatchSameOrOtherDay(trx, lastMonthTransactions);
}
private void MatchSameOrOtherDay(Transaction trx, List<Transaction> lastMonthTransactions)
{
if (lastMonthTransactions.Any())
{
var lastMonthTransaction = lastMonthTransactions.Where(
t =>
t.ValueDate.Day == trx.ValueDate.Day)
.FirstOrDefault();
if (lastMonthTransaction == null)
{
lastMonthTransaction = lastMonthTransactions.Where(
t =>
t.ValueDate.Day >= trx.ValueDate.AddDays(-1).Day &&
t.ValueDate.Day <= trx.ValueDate.AddDays(1).Day)
.FirstOrDefault();
}
if (lastMonthTransaction != null)
{
trx.Text = lastMonthTransaction.Text;
if (string.IsNullOrEmpty(trx.OriginAccountId))
{
trx.OriginAccountId = lastMonthTransaction.OriginAccountId;
}
else
{
trx.TargetAccountId = lastMonthTransaction.TargetAccountId;
}
}
}
}
}
}