Add ILogger<ViewService> and log the match-count summary at the end
of MatchOpenBankTransactions: "Matched {Matched} of {Total} open bank
transactions". Completes item 18.
Also drop the ISettingsService [Inject] from Home.razor.cs that was
declared but never referenced.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
358 lines
No EOL
15 KiB
C#
358 lines
No EOL
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Microsoft.Extensions.Logging;
|
|
using Schaad.Accounting.Datasets;
|
|
using Schaad.Accounting.Datasets.Reports;
|
|
using Schaad.Accounting.Interfaces;
|
|
using Schaad.Accounting.Models;
|
|
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 IFxConverter fxConverter;
|
|
private readonly ILogger<ViewService> logger;
|
|
|
|
public ViewService(
|
|
IAccountRepository accountRepository,
|
|
IBankTransactionRepository bankTransactionRepository,
|
|
ITransactionRepository transactionRepository,
|
|
ISubclassRepository subclassRepository,
|
|
IBookingRuleRepository bookingRuleRepository,
|
|
IFxConverter fxConverter,
|
|
ILogger<ViewService> logger)
|
|
{
|
|
this.accountRepository = accountRepository;
|
|
this.bankTransactionRepository = bankTransactionRepository;
|
|
this.transactionRepository = transactionRepository;
|
|
this.subclassRepository = subclassRepository;
|
|
this.bookingRuleRepository = bookingRuleRepository;
|
|
this.fxConverter = fxConverter;
|
|
this.logger = logger;
|
|
}
|
|
|
|
/// <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);
|
|
|
|
// 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: fxConverter.ConvertToChf(balance, a.Currency),
|
|
startBalanceCHF: fxConverter.ConvertToChf(a.StartBalance, a.Currency),
|
|
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 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 => fxConverter.ConvertToChf(m.StartBalance, m.Currency)));
|
|
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 = t.Clone();
|
|
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).ToList();
|
|
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);
|
|
}
|
|
|
|
var matched = newTransactionList.Count(t => string.IsNullOrEmpty(t.Text) == false);
|
|
logger.LogInformation("Matched {Matched} of {Total} open bank transactions", matched, bankTransactions.Count);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |