|
| 1 | +// Copyright 2021 Valters Melnalksnis |
| 2 | +// Licensed under the GNU Affero General Public License v3.0 or later. |
| 3 | +// See LICENSE.txt file in the project root for full license information. |
| 4 | + |
| 5 | +using System; |
| 6 | +using System.Collections.Generic; |
| 7 | +using System.Collections.ObjectModel; |
| 8 | +using System.Linq; |
| 9 | +using System.Threading.Tasks; |
| 10 | + |
| 11 | +using Gnomeshade.Avalonia.Core.Products; |
| 12 | +using Gnomeshade.Avalonia.Core.Reports; |
| 13 | +using Gnomeshade.Avalonia.Core.Reports.Splits; |
| 14 | +using Gnomeshade.WebApi.Client; |
| 15 | +using Gnomeshade.WebApi.Models.Accounts; |
| 16 | +using Gnomeshade.WebApi.Models.Transactions; |
| 17 | + |
| 18 | +using LiveChartsCore.Defaults; |
| 19 | +using LiveChartsCore.Kernel.Sketches; |
| 20 | +using LiveChartsCore.SkiaSharpView; |
| 21 | +using LiveChartsCore.SkiaSharpView.Painting; |
| 22 | + |
| 23 | +using NodaTime; |
| 24 | + |
| 25 | +using PropertyChanged.SourceGenerator; |
| 26 | + |
| 27 | +using SkiaSharp; |
| 28 | + |
| 29 | +namespace Gnomeshade.Avalonia.Core; |
| 30 | + |
| 31 | +/// <summary>Quick overview of all accounts.</summary> |
| 32 | +public sealed partial class DashboardViewModel : ViewModelBase |
| 33 | +{ |
| 34 | + private readonly IGnomeshadeClient _gnomeshadeClient; |
| 35 | + private readonly IClock _clock; |
| 36 | + private readonly IDateTimeZoneProvider _dateTimeZoneProvider; |
| 37 | + |
| 38 | + /// <summary>Gets the data series of the balance of all user accounts with positive balance.</summary> |
| 39 | + [Notify(Setter.Private)] |
| 40 | + private PieSeries<decimal>[] _balanceSeries = []; |
| 41 | + |
| 42 | + /// <summary>Gets the data series of the balance of all user accounts with negative balance.</summary> |
| 43 | + [Notify(Setter.Private)] |
| 44 | + private PieSeries<decimal>[] _liabilitiesBalanceSeries = []; |
| 45 | + |
| 46 | + /// <summary>Gets the data series of spending by category.</summary> |
| 47 | + [Notify(Setter.Private)] |
| 48 | + private PieSeries<decimal>[] _categoriesSeries = []; |
| 49 | + |
| 50 | + /// <summary>Gets the data series of balance of the users account over time.</summary> |
| 51 | + [Notify(Setter.Private)] |
| 52 | + private CandlesticksSeries<FinancialPointI>[] _cashflowSeries = []; |
| 53 | + |
| 54 | + /// <summary>Gets a collection of all accounts of the current user.</summary> |
| 55 | + [Notify(Setter.Private)] |
| 56 | + private List<Account> _userAccounts = []; |
| 57 | + |
| 58 | + /// <summary>Gets or sets a collection of accounts selected from <see cref="UserAccounts"/>.</summary> |
| 59 | + [Notify] |
| 60 | + private ObservableCollection<Account> _selectedAccounts = []; |
| 61 | + |
| 62 | + /// <summary>Gets a collection of all currencies used in <see cref="UserAccounts"/>.</summary> |
| 63 | + [Notify(Setter.Private)] |
| 64 | + private List<Currency> _currencies = []; |
| 65 | + |
| 66 | + /// <summary>Gets or sets the selected currency from <see cref="Currencies"/>.</summary> |
| 67 | + [Notify] |
| 68 | + private Currency? _selectedCurrency; |
| 69 | + |
| 70 | + /// <summary>Gets the y axes for <see cref="CashflowSeries"/>.</summary> |
| 71 | + [Notify(Setter.Private)] |
| 72 | + private ICartesianAxis[] _yAxes = [new Axis()]; |
| 73 | + |
| 74 | + /// <summary>Gets the x axes for <see cref="CashflowSeries"/>.</summary> |
| 75 | + [Notify(Setter.Private)] |
| 76 | + private ICartesianAxis[] _xAxes = [new Axis()]; |
| 77 | + |
| 78 | + /// <summary>Gets the collection of account summary rows.</summary> |
| 79 | + [Notify(Setter.Private)] |
| 80 | + private AccountRow[] _accountRows = []; |
| 81 | + |
| 82 | + /// <summary>Gets the collection of rows of various totals of <see cref="AccountRows"/>.</summary> |
| 83 | + [Notify(Setter.Private)] |
| 84 | + private AccountRow[] _accountRowsTotals = []; |
| 85 | + |
| 86 | + /// <summary>Initializes a new instance of the <see cref="DashboardViewModel"/> class.</summary> |
| 87 | + /// <param name="activityService">Service for indicating the activity of the application to the user.</param> |
| 88 | + /// <param name="gnomeshadeClient">A strongly typed API client.</param> |
| 89 | + /// <param name="clock">Clock which can provide the current instant.</param> |
| 90 | + /// <param name="dateTimeZoneProvider">Time zone provider for localizing instants to local time.</param> |
| 91 | + public DashboardViewModel( |
| 92 | + IActivityService activityService, |
| 93 | + IGnomeshadeClient gnomeshadeClient, |
| 94 | + IClock clock, |
| 95 | + IDateTimeZoneProvider dateTimeZoneProvider) |
| 96 | + : base(activityService) |
| 97 | + { |
| 98 | + _gnomeshadeClient = gnomeshadeClient; |
| 99 | + _clock = clock; |
| 100 | + _dateTimeZoneProvider = dateTimeZoneProvider; |
| 101 | + } |
| 102 | + |
| 103 | + /// <inheritdoc /> |
| 104 | + protected override async Task Refresh() |
| 105 | + { |
| 106 | + var (counterparty, allAccounts, currencies) = await |
| 107 | + (_gnomeshadeClient.GetMyCounterpartyAsync(), |
| 108 | + _gnomeshadeClient.GetAccountsAsync(), |
| 109 | + _gnomeshadeClient.GetCurrenciesAsync()) |
| 110 | + .WhenAll(); |
| 111 | + |
| 112 | + var selected = SelectedAccounts.Select(account => account.Id).ToArray(); |
| 113 | + var selectedCurrency = SelectedCurrency?.Id; |
| 114 | + |
| 115 | + UserAccounts = allAccounts.Where(account => account.CounterpartyId == counterparty.Id).ToList(); |
| 116 | + Currencies = currencies |
| 117 | + .Where(currency => UserAccounts.SelectMany(account => account.Currencies).Any(aic => aic.CurrencyId == currency.Id)) |
| 118 | + .ToList(); |
| 119 | + |
| 120 | + SelectedAccounts = new(UserAccounts.Where(account => selected.Contains(account.Id))); |
| 121 | + SelectedCurrency = selectedCurrency is { } id ? Currencies.Single(currency => currency.Id == id) : null; |
| 122 | + |
| 123 | + IReadOnlyCollection<Account> accounts = SelectedAccounts.Count is not 0 ? SelectedAccounts : UserAccounts; |
| 124 | + |
| 125 | + var timeZone = _dateTimeZoneProvider.GetSystemDefault(); |
| 126 | + var currentInstant = _clock.GetCurrentInstant(); |
| 127 | + var localTime = currentInstant.InZone(timeZone).LocalDateTime; |
| 128 | + |
| 129 | + var startTime = localTime.With(DateAdjusters.StartOfMonth).InZoneStrictly(timeZone); |
| 130 | + var endTime = localTime.With(DateAdjusters.EndOfMonth).InZoneStrictly(timeZone); |
| 131 | + |
| 132 | + await Task.WhenAll( |
| 133 | + RefreshCashFlow(accounts, startTime, endTime, timeZone, counterparty), |
| 134 | + RefreshBalance(), |
| 135 | + RefreshCategories(accounts, timeZone, startTime)); |
| 136 | + } |
| 137 | + |
| 138 | + private async Task RefreshCashFlow( |
| 139 | + IReadOnlyCollection<Account> accounts, |
| 140 | + ZonedDateTime startTime, |
| 141 | + ZonedDateTime endTime, |
| 142 | + DateTimeZone timeZone, |
| 143 | + Counterparty counterparty) |
| 144 | + { |
| 145 | + var inCurrencyIds = accounts |
| 146 | + .SelectMany(account => account.Currencies.Where(aic => aic.CurrencyId == (SelectedCurrency?.Id ?? account.PreferredCurrencyId)).Select(aic => aic.Id)) |
| 147 | + .ToArray(); |
| 148 | + |
| 149 | + var transfers = (await _gnomeshadeClient.GetTransfersAsync()) |
| 150 | + .Where(transfer => |
| 151 | + inCurrencyIds.Contains(transfer.SourceAccountId) || |
| 152 | + inCurrencyIds.Contains(transfer.TargetAccountId)) |
| 153 | + .OrderBy(transfer => transfer.ValuedAt ?? transfer.BookedAt) |
| 154 | + .ThenBy(transfer => transfer.CreatedAt) |
| 155 | + .ThenBy(transfer => transfer.ModifiedAt) |
| 156 | + .ToArray(); |
| 157 | + var reportSplit = SplitProvider.DailySplit; |
| 158 | + |
| 159 | + var values = reportSplit |
| 160 | + .GetSplits(startTime, endTime) |
| 161 | + .Select(date => |
| 162 | + { |
| 163 | + var splitZonedDate = date.AtStartOfDayInZone(timeZone); |
| 164 | + var splitInstant = splitZonedDate.ToInstant(); |
| 165 | + |
| 166 | + var transfersBefore = transfers |
| 167 | + .Where(transfer => new ZonedDateTime(transfer.ValuedAt ?? transfer.BookedAt!.Value, timeZone).ToInstant() < splitInstant); |
| 168 | + |
| 169 | + var transfersIn = transfers |
| 170 | + .Where(transfer => reportSplit.Equals( |
| 171 | + splitZonedDate, |
| 172 | + new(transfer.ValuedAt ?? transfer.BookedAt!.Value, timeZone))) |
| 173 | + .ToArray(); |
| 174 | + |
| 175 | + var sumBefore = transfersBefore.SumForAccounts(inCurrencyIds); |
| 176 | + |
| 177 | + var sumAfter = sumBefore + transfersIn.SumForAccounts(inCurrencyIds); |
| 178 | + var sums = transfersIn |
| 179 | + .Select((_, index) => sumBefore + transfersIn.Where((_, i) => i <= index).SumForAccounts(inCurrencyIds)) |
| 180 | + .ToArray(); |
| 181 | + |
| 182 | + return new FinancialPointI( |
| 183 | + (double)sums.Concat([sumBefore, sumAfter]).Max(), |
| 184 | + (double)sumBefore, |
| 185 | + (double)sumAfter, |
| 186 | + (double)sums.Append(sumBefore).Min()); |
| 187 | + }); |
| 188 | + |
| 189 | + CashflowSeries = [new() { Values = values.ToArray(), Name = counterparty.Name }]; |
| 190 | + XAxes = [reportSplit.GetXAxis(startTime, endTime)]; |
| 191 | + } |
| 192 | + |
| 193 | + private async Task RefreshBalance() |
| 194 | + { |
| 195 | + var accountRowTasks = UserAccounts |
| 196 | + .Select(async account => |
| 197 | + { |
| 198 | + var balances = await _gnomeshadeClient.GetAccountBalanceAsync(account.Id); |
| 199 | + var preferredAccountInCurrency = account |
| 200 | + .Currencies |
| 201 | + .Single(currency => currency.CurrencyId == account.PreferredCurrencyId); |
| 202 | + |
| 203 | + var balance = |
| 204 | + balances.Single(balance => balance.AccountInCurrencyId == preferredAccountInCurrency.Id); |
| 205 | + |
| 206 | + return new AccountRow(account.Name, balance.TargetAmount - balance.SourceAmount); |
| 207 | + }); |
| 208 | + |
| 209 | + var rows = await Task.WhenAll(accountRowTasks); |
| 210 | + AccountRows = rows.OrderByDescending(row => row.Balance).ToArray(); |
| 211 | + AccountRowsTotals = |
| 212 | + [ |
| 213 | + new("Balance", AccountRows.Where(row => row.Balance > 0).Sum(row => row.Balance)), |
| 214 | + new("Liabilities", AccountRows.Where(row => row.Balance < 0).Sum(row => row.Balance)), |
| 215 | + new("Total", AccountRows.Sum(row => row.Balance)), |
| 216 | + ]; |
| 217 | + |
| 218 | + var balanceSeries = AccountRows |
| 219 | + .Select(row => new PieSeries<decimal> |
| 220 | + { |
| 221 | + Name = row.Name, |
| 222 | + Values = [row.Balance], |
| 223 | + DataLabelsPaint = new SolidColorPaint(SKColors.Black), |
| 224 | + DataLabelsFormatter = point => point.Model.ToString("N2"), |
| 225 | + }) |
| 226 | + .ToArray(); |
| 227 | + |
| 228 | + BalanceSeries = balanceSeries.Where(series => series.Values?.Sum() > 0).ToArray(); |
| 229 | + LiabilitiesBalanceSeries = balanceSeries |
| 230 | + .Where(series => series.Values?.Sum() < 0) |
| 231 | + .Select(series => |
| 232 | + { |
| 233 | + series.Values = series.Values?.Select(x => -x) ?? []; |
| 234 | + return series; |
| 235 | + }) |
| 236 | + .ToArray(); |
| 237 | + } |
| 238 | + |
| 239 | + private async Task RefreshCategories(IReadOnlyCollection<Account> accounts, DateTimeZone timeZone, ZonedDateTime startTime) |
| 240 | + { |
| 241 | + var accountsInCurrency = accounts.SelectMany(account => account.Currencies).ToArray(); |
| 242 | + var (allTransactions, categories, products) = await ( |
| 243 | + _gnomeshadeClient.GetDetailedTransactionsAsync(new(Instant.MinValue, Instant.MaxValue)), |
| 244 | + _gnomeshadeClient.GetCategoriesAsync(), |
| 245 | + _gnomeshadeClient.GetProductsAsync()) |
| 246 | + .WhenAll(); |
| 247 | + var displayableTransactions = allTransactions |
| 248 | + .Select(transaction => transaction with { TransferBalance = -transaction.TransferBalance }) |
| 249 | + .Where(transaction => transaction.TransferBalance > 0) |
| 250 | + .ToArray(); |
| 251 | + |
| 252 | + var transactions = new CategoryReportViewModel.TransactionData[displayableTransactions.Length]; |
| 253 | + for (var i = 0; i < displayableTransactions.Length; i++) |
| 254 | + { |
| 255 | + var transaction = displayableTransactions[i]; |
| 256 | + var date = new ZonedDateTime(transaction.ValuedAt ?? transaction.BookedAt!.Value, timeZone); |
| 257 | + |
| 258 | + var transactionTransfers = transaction.Transfers; |
| 259 | + var transferAccounts = transactionTransfers |
| 260 | + .Select(transfer => transfer.SourceAccountId) |
| 261 | + .Concat(transactionTransfers.Select(transfer => transfer.TargetAccountId)) |
| 262 | + .ToArray(); |
| 263 | + |
| 264 | + var sourceCurrencyIds = accountsInCurrency |
| 265 | + .IntersectBy(transferAccounts, currency => currency.Id) |
| 266 | + .Select(currency => currency.CurrencyId) |
| 267 | + .Distinct() |
| 268 | + .ToArray(); |
| 269 | + |
| 270 | + var targetCurrencyIds = accountsInCurrency |
| 271 | + .IntersectBy(transferAccounts, currency => currency.Id) |
| 272 | + .Select(account => account.CurrencyId) |
| 273 | + .Distinct() |
| 274 | + .ToArray(); |
| 275 | + |
| 276 | + transactions[i] = new(transaction, date, sourceCurrencyIds, targetCurrencyIds); |
| 277 | + } |
| 278 | + |
| 279 | + var nodes = categories |
| 280 | + .Where(category => category.CategoryId == null) |
| 281 | + .Select(category => CategoryNode.FromCategory(category, categories)) |
| 282 | + .ToList(); |
| 283 | + |
| 284 | + var uncategorizedTransfers = transactions |
| 285 | + .Where(data => data.Transaction.TransferBalance > data.Transaction.PurchaseTotal) |
| 286 | + .Select(data => |
| 287 | + { |
| 288 | + var purchase = new Purchase { Price = data.Transaction.TransferBalance - data.Transaction.PurchaseTotal }; |
| 289 | + return new CategoryReportViewModel.PurchaseData(purchase, null, data); |
| 290 | + }) |
| 291 | + .ToList(); |
| 292 | + |
| 293 | + var groupings = transactions |
| 294 | + .SelectMany(data => data.Transaction.Purchases.Select(purchase => |
| 295 | + { |
| 296 | + var product = products.SingleOrDefault(product => product.Id == purchase.ProductId); |
| 297 | + var node = product?.CategoryId is not { } categoryId |
| 298 | + ? null |
| 299 | + : nodes.SingleOrDefault(node => node.Contains(categoryId)); |
| 300 | + |
| 301 | + return new CategoryReportViewModel.PurchaseData(purchase, node, data); |
| 302 | + })) |
| 303 | + .Concat(uncategorizedTransfers) |
| 304 | + .GroupBy(data => data.Node) |
| 305 | + .ToArray(); |
| 306 | + |
| 307 | + CategoriesSeries = groupings |
| 308 | + .Select(grouping => |
| 309 | + { |
| 310 | + var zonedSplit = startTime; |
| 311 | + |
| 312 | + var purchasesToSum = grouping |
| 313 | + .Where(purchase => SplitProvider.MonthlySplit.Equals(zonedSplit, purchase.Date)) |
| 314 | + .ToArray(); |
| 315 | + |
| 316 | + var sum = 0m; |
| 317 | + for (var purchaseIndex = 0; purchaseIndex < purchasesToSum.Length; purchaseIndex++) |
| 318 | + { |
| 319 | + var purchase = purchasesToSum[purchaseIndex]; |
| 320 | + var sourceCurrencyIds = purchase.SourceCurrencyIds; |
| 321 | + var targetCurrencyIds = purchase.TargetCurrencyIds; |
| 322 | + |
| 323 | + if (sourceCurrencyIds.Length is not 1 || targetCurrencyIds.Length is not 1) |
| 324 | + { |
| 325 | + // todo cannot handle multiple currencies (#686) |
| 326 | + sum += purchase.Purchase.Price; |
| 327 | + continue; |
| 328 | + } |
| 329 | + |
| 330 | + var sourceCurrency = sourceCurrencyIds.Single(); |
| 331 | + var targetCurrency = targetCurrencyIds.Single(); |
| 332 | + |
| 333 | + if (sourceCurrency == targetCurrency) |
| 334 | + { |
| 335 | + sum += purchase.Purchase.Price; |
| 336 | + continue; |
| 337 | + } |
| 338 | + |
| 339 | + var transfer = purchase.Transfers.Single(); |
| 340 | + var ratio = transfer.SourceAmount / transfer.TargetAmount; |
| 341 | + sum += Math.Round(purchase.Purchase.Price * ratio, 2); |
| 342 | + } |
| 343 | + |
| 344 | + return new PieSeries<decimal> |
| 345 | + { |
| 346 | + Name = grouping.Key?.Name ?? "Uncategorized", |
| 347 | + Values = [sum], |
| 348 | + DataLabelsPaint = new SolidColorPaint(SKColors.Black), |
| 349 | + DataLabelsFormatter = point => point.Model.ToString("N2"), |
| 350 | + }; |
| 351 | + }) |
| 352 | + .Where(series => series.Values?.Sum() > 0) |
| 353 | + .OrderByDescending(series => series.Values?.Sum()) |
| 354 | + .ToArray(); |
| 355 | + } |
| 356 | + |
| 357 | + /// <summary>Minimal overview of a single <see cref="Account"/>.</summary> |
| 358 | + public sealed class AccountRow : PropertyChangedBase |
| 359 | + { |
| 360 | + /// <summary>Initializes a new instance of the <see cref="AccountRow"/> class.</summary> |
| 361 | + /// <param name="name">The name of the account.</param> |
| 362 | + /// <param name="balance">The balance of the account in the preferred currency.</param> |
| 363 | + public AccountRow(string name, decimal balance) |
| 364 | + { |
| 365 | + Name = name; |
| 366 | + Balance = balance; |
| 367 | + } |
| 368 | + |
| 369 | + /// <summary>Gets the name of the account.</summary> |
| 370 | + public string Name { get; } |
| 371 | + |
| 372 | + /// <summary>Gets the account balance in the preferred currency.</summary> |
| 373 | + public decimal Balance { get; } |
| 374 | + } |
| 375 | +} |
0 commit comments