AccountingNext/Schaad.Accounting.Services/ViewService.cs
Claudio Schaad d819a836da Show signed amounts on the transactions page and in its charts
Adds TransactionDataset.GetValue(bool, string accountId), which
negates the value when the given account is the transaction's origin
(paying out) so the account's own transaction list and charts show
a minus sign for outgoing money, regardless of account class. Removes
the old Activa-only sign flip baked into
ViewService.GetTransactionViewListAsync(accountId), which would have
double-negated Activa bookings against the new accountId-aware
GetValue. ChartService's booking-text totals now sum through the same
accountId-aware GetValue instead of filtering to inbound transactions
only, so a booking text nets its in/out transactions correctly.
2026-07-22 14:33:54 +02:00

336 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
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;
}
public async Task<AccountDataset> GetAccountViewAsync(string id)
{
var accounts = await GetAccountViewListAsync();
return accounts.Single(t => t.Id == id);
}
public async Task<List<AccountDataset>> GetAccountViewListAsync()
{
var accounts = await accountRepository.GetAccountListAsync();
var transactionList = await GetTransactionViewListAsync();
var subClassNameByNumber = (await subclassRepository.GetSubClassListAsync()).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 async Task<BalanceDataset> GetBalanceViewAsync()
{
var accountList = await GetAccountViewListAsync();
return 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)));
}
public async Task<BalanceSheetDataset> GetBalanceSheetViewAsync(int year)
{
var accountList = await GetAccountViewListAsync();
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));
return 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);
}
/// <summary>
/// Get transaction list with the origin and target account for each transaction
/// </summary>
public async Task<List<TransactionDataset>> GetTransactionViewListAsync()
{
var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
var transactionList = await transactionRepository.GetTransactionListAsync();
return transactionList.Select(t =>
new TransactionDataset(
t,
accountsById[t.OriginAccountId],
accountsById[t.TargetAccountId]))
.ToList();
}
/// <summary>
/// Get transaction list filtered to a single account. Sign is not baked in here -
/// callers use <see cref="TransactionDataset.GetValue(bool, string)"/> with this
/// account's id to get a value that's negative when the account is paying out.
/// </summary>
public async Task<List<TransactionDataset>> GetTransactionViewListAsync(string accountId)
{
var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
var transactionList = (await transactionRepository.GetTransactionListAsync())
.Where(t => t.OriginAccountId == accountId || t.TargetAccountId == accountId);
return transactionList.Select(t =>
new TransactionDataset(
t,
accountsById[t.OriginAccountId],
accountsById[t.TargetAccountId]))
.ToList();
}
/// <summary>
/// Get booking rules with their account
/// </summary>
public async Task<List<BookingRuleDataset>> GetBookingRuleViewListAsync()
{
var accountsById = (await accountRepository.GetAccountListAsync()).ToDictionary(a => a.Id);
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
return bookingRules.Select(t =>
new BookingRuleDataset(t, accountsById[t.AccountId].Name))
.ToList();
}
/// <summary>
/// Load open bank transactions
/// </summary>
public async Task<List<BankTransaction>> GetOpenBankTransactionListAsync()
{
var transactions = (await transactionRepository.GetTransactionListAsync())
.Where(t => string.IsNullOrEmpty(t.BankTransactionId) == false)
.Select(t => t.BankTransactionId);
var bankTransactions = (await bankTransactionRepository.GetBankTransactionListAsync())
.Where(b => b.Ignore == false && transactions.Contains(b.Id) == false)
.ToList();
return bankTransactions;
}
public async Task<List<Transaction>> MatchOpenBankTransactionsAsync()
{
var newTransactionList = new List<Transaction>();
var transactions = await transactionRepository.GetTransactionListAsync();
var accounts = await accountRepository.GetAccountListAsync();
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
var bankTransactions = (await GetOpenBankTransactionListAsync()).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 async Task<List<Transaction>> MatchCreditCardTransactionsAsync(string bankTransactionId, IReadOnlyList<CreditCardTransaction> creditCardTransactions)
{
var accounts = await accountRepository.GetAccountListAsync();
var bankTrx = await bankTransactionRepository.GetBankTransactionAsync(bankTransactionId);
var bookingRules = await bookingRuleRepository.GetBookingRuleListAsync();
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;
}
}
}
}
}
}