Replace the hand-rolled model.Copy(target) pattern in every SaveXxx with FindIndex -> in-place replace (or Add on new). Move the two hidden defaults out of the deleted Copy bodies: Currency = "CHF" default now lives in AccountRepository.SaveAccount; BookingDate = ValueDate default now lives in TransactionRepository.SaveTransaction. Delete Copy from Account, BookingRule, BookingText, SubClass, and SplitPredefinition. Keep Transaction.Clone() (renamed from Copy, and now includes RelatedParty) for the defensive copy in GetTransaction and ViewService.WithDisplaySign. Fixes a pre-existing bug where Transaction.Copy silently dropped RelatedParty on every update save. 42 tests total, all passing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
351 lines
No EOL
15 KiB
C#
351 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.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;
|
|
|
|
public ViewService(
|
|
IAccountRepository accountRepository,
|
|
IBankTransactionRepository bankTransactionRepository,
|
|
ITransactionRepository transactionRepository,
|
|
ISubclassRepository subclassRepository,
|
|
IBookingRuleRepository bookingRuleRepository,
|
|
IFxConverter fxConverter)
|
|
{
|
|
this.accountRepository = accountRepository;
|
|
this.bankTransactionRepository = bankTransactionRepository;
|
|
this.transactionRepository = transactionRepository;
|
|
this.subclassRepository = subclassRepository;
|
|
this.bookingRuleRepository = bookingRuleRepository;
|
|
this.fxConverter = fxConverter;
|
|
}
|
|
|
|
/// <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);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |