From b78e0255928b28420b43948bae4cb8d144fdb266 Mon Sep 17 00:00:00 2001 From: Valters Melnalksnis Date: Fri, 24 May 2024 19:26:24 +0300 Subject: [PATCH] pit-stop --- .../DesignTime/DesignTimeGnomeshadeClient.cs | 118 ++++++++++++++++- .../MainWindowViewModel.cs | 1 + .../Reports/BalanceReportViewModel.cs | 84 +++++++++++- .../Reports/CategoryReportViewModel.cs | 124 ++++++++++++++++-- .../Controls/TransactionFilter.cs | 3 + .../Transactions/TransactionOverview.cs | 6 +- .../TransactionUpsertionViewModel.cs | 2 + .../Transactions/TransactionViewModel.cs | 86 +++++++++++- .../Transfers/PlannedTransferViewModel.cs | 20 +++ .../Transfers/TransferExtensions.cs | 88 +++++++++++++ .../Transfers/TransferUpsertionViewModel.cs | 11 +- .../Transfers/TransferViewModel.cs | 9 +- .../UpsertionViewModel.cs | 4 + .../Entities/PlannedLoanPaymentEntity.cs | 38 ++++++ .../Entities/PlannedPurchaseEntity.cs | 45 +++++++ .../Entities/PlannedTransactionEntity.cs | 28 ++++ .../Entities/PlannedTransferEntity.cs | 67 ++++++++++ source/Gnomeshade.Desktop/App.axaml.cs | 2 + .../Views/Reports/BalanceReportView.axaml | 4 + .../Views/Reports/CategoryReportView.axaml | 5 +- .../Controls/TransactionFilterView.axaml | 5 +- .../Transactions/Purchases/PurchaseView.axaml | 7 + .../TransactionUpsertionView.axaml | 4 +- .../GnomeshadeClient.cs | 31 +++++ .../ITransactionClient.cs | 15 +++ .../Loans/PlannedLoanPayment.cs | 51 +++++++ .../Transactions/PlannedPurchase.cs | 58 ++++++++ .../Transactions/PlannedTransaction.cs | 43 ++++++ .../Transactions/PlannedTransfer.cs | 80 +++++++++++ 29 files changed, 996 insertions(+), 43 deletions(-) create mode 100644 source/Gnomeshade.Avalonia.Core/Transactions/Transfers/PlannedTransferViewModel.cs create mode 100644 source/Gnomeshade.Data/Entities/PlannedLoanPaymentEntity.cs create mode 100644 source/Gnomeshade.Data/Entities/PlannedPurchaseEntity.cs create mode 100644 source/Gnomeshade.Data/Entities/PlannedTransactionEntity.cs create mode 100644 source/Gnomeshade.Data/Entities/PlannedTransferEntity.cs create mode 100644 source/Gnomeshade.WebApi.Models/Loans/PlannedLoanPayment.cs create mode 100644 source/Gnomeshade.WebApi.Models/Transactions/PlannedPurchase.cs create mode 100644 source/Gnomeshade.WebApi.Models/Transactions/PlannedTransaction.cs create mode 100644 source/Gnomeshade.WebApi.Models/Transactions/PlannedTransfer.cs diff --git a/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeGnomeshadeClient.cs b/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeGnomeshadeClient.cs index 7b3a993ea..2be300a01 100644 --- a/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeGnomeshadeClient.cs +++ b/source/Gnomeshade.Avalonia.Core/DesignTime/DesignTimeGnomeshadeClient.cs @@ -45,6 +45,11 @@ public sealed class DesignTimeGnomeshadeClient : IGnomeshadeClient private static readonly List _loans; private static readonly List _loanPayments; + private static readonly List _plannedTransactions; + private static readonly List _plannedTransfers; + private static readonly List _plannedPurchases; + private static readonly List _plannedLoanPayments; + static DesignTimeGnomeshadeClient() { var euro = new Currency { Id = Guid.NewGuid(), Name = "Euro", AlphabeticCode = "EUR" }; @@ -53,7 +58,8 @@ static DesignTimeGnomeshadeClient() var counterparty = new Counterparty { Id = Guid.Empty, Name = "John Doe" }; var otherCounterparty = new Counterparty { Id = Guid.NewGuid(), Name = "Jane Doe" }; - _counterparties = [counterparty, otherCounterparty]; + var bankCounterparty = new Counterparty { Id = Guid.NewGuid(), Name = "Bank" }; + _counterparties = [counterparty, otherCounterparty, bankCounterparty]; var cash = new Account { @@ -67,27 +73,39 @@ static DesignTimeGnomeshadeClient() new() { Id = Guid.NewGuid(), CurrencyId = usd.Id, CurrencyAlphabeticCode = usd.AlphabeticCode } ], }; + var spending = new Account { Id = Guid.NewGuid(), Name = "Spending", CounterpartyId = counterparty.Id, PreferredCurrencyId = euro.Id, - Currencies = - [new() { Id = Guid.NewGuid(), CurrencyId = euro.Id, CurrencyAlphabeticCode = euro.AlphabeticCode }], + Currencies = [new() { Id = Guid.NewGuid(), CurrencyId = euro.Id, CurrencyAlphabeticCode = euro.AlphabeticCode }], }; - _accounts = [cash, spending]; + + var bankAccount = new Account + { + Id = Guid.NewGuid(), + Name = "Bank", + CounterpartyId = bankCounterparty.Id, + PreferredCurrencyId = euro.Id, + Currencies = [new() { Id = Guid.NewGuid(), CurrencyId = euro.Id, CurrencyAlphabeticCode = euro.AlphabeticCode }], + }; + + _accounts = [cash, spending, bankAccount]; var kilogram = new Unit { Id = Guid.NewGuid(), Name = "Kilogram" }; var gram = new Unit { Id = Guid.NewGuid(), Name = "Gram", ParentUnitId = kilogram.Id, Multiplier = 1000m }; _units = [kilogram, gram]; var food = new Category { Id = Guid.Empty, Name = "Food" }; - _categories = [food]; + var liabilities = new Category { Id = Guid.NewGuid(), Name = "Liabilities" }; + _categories = [food, liabilities]; var bread = new Product { Id = Guid.NewGuid(), Name = "Bread", CategoryId = food.Id, UnitId = kilogram.Id }; var milk = new Product { Id = Guid.NewGuid(), Name = "Milk", CategoryId = food.Id }; - _products = [bread, milk]; + var loan = new Product { Id = Guid.NewGuid(), Name = "Loan", CategoryId = liabilities.Id }; + _products = [bread, milk, loan]; var transaction = new Transaction { @@ -206,10 +224,68 @@ static DesignTimeGnomeshadeClient() Interest = 150m, } ]; + + var plannedTransaction = new PlannedTransaction + { + Id = Guid.Empty, + StartTime = SystemClock.Instance.GetCurrentInstant() + Duration.FromDays(15), + Period = Period.FromMonths(1), + Count = 12, + }; + + var plannedPrincipalTransfer = new PlannedTransfer + { + Id = Guid.Empty, + PlannedTransactionId = plannedTransaction.Id, + SourceAmount = 500, + SourceAccountId = spending.Currencies.Single(account => account.CurrencyId == euro.Id).Id, + TargetAmount = 500, + TargetCounterpartyId = bankCounterparty.Id, + TargetCurrencyId = euro.Id, + BookedAt = new(09, 00), + Order = 1, + }; + + var plannedInterestTransfer = new PlannedTransfer + { + Id = Guid.Empty, + PlannedTransactionId = plannedTransaction.Id, + SourceAmount = 150, + SourceAccountId = spending.Currencies.Single(account => account.CurrencyId == euro.Id).Id, + TargetAmount = 150, + TargetCounterpartyId = bankCounterparty.Id, + TargetCurrencyId = euro.Id, + BookedAt = new(09, 00), + Order = 2, + }; + + var plannedPurchase = new PlannedPurchase + { + Id = Guid.Empty, + PlannedTransactionId = plannedTransaction.Id, + Price = plannedPrincipalTransfer.SourceAmount + plannedInterestTransfer.SourceAmount, + CurrencyId = euro.Id, + ProductId = loan.Id, + Amount = 1, + }; + + var plannedLoanPayment = new PlannedLoanPayment + { + Id = Guid.Empty, + LoanId = _loans.Single().Id, + PlannedTransactionId = plannedTransaction.Id, + Amount = plannedPrincipalTransfer.SourceAmount, + Interest = plannedInterestTransfer.SourceAmount, + }; + + _plannedTransactions = [plannedTransaction]; + _plannedTransfers = [plannedPrincipalTransfer, plannedInterestTransfer]; + _plannedPurchases = [plannedPurchase]; + _plannedLoanPayments = [plannedLoanPayment]; } /// - public Task LogInAsync(Login login) => throw new NotImplementedException(); + public Task LogInAsync(Login login) => Task.FromResult(new SuccessfulLogin()); /// public Task SocialRegister() => throw new NotImplementedException(); @@ -460,6 +536,34 @@ public Task AddRelatedTransactionAsync(Guid id, Guid relatedId) => public Task RemoveRelatedTransactionAsync(Guid id, Guid relatedId) => throw new NotImplementedException(); + /// + public Task> GetPlannedTransactions(CancellationToken cancellationToken = default) => + Task.FromResult(_plannedTransactions.ToList()); + + /// + public Task> GetPlannedTransfers(CancellationToken cancellationToken = default) => + Task.FromResult(_plannedTransfers.ToList()); + + /// + public Task> GetPlannedTransfers(Guid transactionId, CancellationToken cancellationToken = default) => + Task.FromResult(_plannedTransfers.Where(transfer => transfer.PlannedTransactionId == transactionId).ToList()); + + /// + public Task> GetPlannedPurchases(CancellationToken cancellationToken = default) => + Task.FromResult(_plannedPurchases.ToList()); + + /// + public Task> GetPlannedPurchases(Guid transactionId, CancellationToken cancellationToken = default) => + Task.FromResult(_plannedPurchases.Where(transfer => transfer.PlannedTransactionId == transactionId).ToList()); + + /// + public Task> GetPlannedLoanPayments(CancellationToken cancellationToken = default) => + Task.FromResult(_plannedLoanPayments.ToList()); + + /// + public Task> GetPlannedLoanPayments(Guid transactionId, CancellationToken cancellationToken = default) => + Task.FromResult(_plannedLoanPayments.Where(transfer => transfer.PlannedTransactionId == transactionId).ToList()); + /// [Obsolete] public Task> GetLegacyLoans(CancellationToken cancellationToken = default) => diff --git a/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs b/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs index 6570f8821..3bb7e128a 100644 --- a/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs +++ b/source/Gnomeshade.Avalonia.Core/MainWindowViewModel.cs @@ -232,6 +232,7 @@ private static Exception CurrentLifetimeIsNull() private async Task InitializeActiveViewAsync() { // The first notification does not show up, and subsequent calls work after some delay + // todo if the app starts too fast the first notification still does not show up ActivityService.ShowNotification(new(null, null, expiration: TimeSpan.FromMilliseconds(1))); if (ActiveView is not null) diff --git a/source/Gnomeshade.Avalonia.Core/Reports/BalanceReportViewModel.cs b/source/Gnomeshade.Avalonia.Core/Reports/BalanceReportViewModel.cs index 5ecece1a5..76f81d8fd 100644 --- a/source/Gnomeshade.Avalonia.Core/Reports/BalanceReportViewModel.cs +++ b/source/Gnomeshade.Avalonia.Core/Reports/BalanceReportViewModel.cs @@ -7,11 +7,13 @@ using System.Collections.Specialized; using System.ComponentModel; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Gnomeshade.Avalonia.Core.Reports.Splits; using Gnomeshade.WebApi.Client; using Gnomeshade.WebApi.Models.Accounts; +using Gnomeshade.WebApi.Models.Transactions; using LiveChartsCore.Defaults; using LiveChartsCore.Kernel.Sketches; @@ -54,6 +56,9 @@ public sealed partial class BalanceReportViewModel : ViewModelBase [Notify] private IReportSplit? _selectedSplit = SplitProvider.MonthlySplit; + [Notify] + private bool _includeProjections = true; + /// Gets the y axes for . [Notify(Setter.Private)] private List _yAxes; @@ -103,11 +108,17 @@ public void ResetZoom() /// protected override async Task Refresh() { - var transfersTask = _gnomeshadeClient.GetTransfersAsync(); + var cancellation = new CancellationTokenSource(); + var task = + (_gnomeshadeClient.GetTransfersAsync(cancellation.Token), + _gnomeshadeClient.GetPlannedTransactions(cancellation.Token), + _gnomeshadeClient.GetPlannedTransfers(cancellation.Token)) + .WhenAll(); + var (counterparty, allAccounts, currencies) = await - (_gnomeshadeClient.GetMyCounterpartyAsync(), - _gnomeshadeClient.GetAccountsAsync(), - _gnomeshadeClient.GetCurrenciesAsync()) + (_gnomeshadeClient.GetMyCounterpartyAsync(cancellation.Token), + _gnomeshadeClient.GetAccountsAsync(cancellation.Token), + _gnomeshadeClient.GetCurrenciesAsync(cancellation.Token)) .WhenAll(); var selected = SelectedAccounts.Select(account => account.Id).ToArray(); @@ -123,6 +134,7 @@ protected override async Task Refresh() if (SelectedSplit is not { } reportSplit) { + await cancellation.CancelAsync(); return; } @@ -132,7 +144,22 @@ protected override async Task Refresh() .SelectMany(account => account.Currencies.Where(aic => aic.CurrencyId == (SelectedCurrency?.Id ?? account.PreferredCurrencyId)).Select(aic => aic.Id)) .ToArray(); - var transfers = (await transfersTask) + var (actualTransfers, plannedTransactions, plannedTransfers) = await task; + var timeZone = _dateTimeZoneProvider.GetSystemDefault(); + + IEnumerable allTransfers = actualTransfers; + if (IncludeProjections) + { + var x = plannedTransfers + .SelectMany(plannedTransfer => PlannedTransferSelector(plannedTransfer, plannedTransactions, timeZone)) + .Where(tuple => + (tuple.Planned.SourceAccountId is { } sourceId && inCurrencyIds.Contains(sourceId)) || + (tuple.Planned.TargetAccountId is { } targetId && inCurrencyIds.Contains(targetId))) + .Select(tuple => tuple.Transfer); + allTransfers = allTransfers.Concat(x); + } + + var transfers = allTransfers .Where(transfer => inCurrencyIds.Contains(transfer.SourceAccountId) || inCurrencyIds.Contains(transfer.TargetAccountId)) @@ -141,7 +168,6 @@ protected override async Task Refresh() .ThenBy(transfer => transfer.ModifiedAt) .ToArray(); - var timeZone = _dateTimeZoneProvider.GetSystemDefault(); var currentTime = _clock.GetCurrentInstant(); var dates = transfers .Select(transfer => transfer.ValuedAt ?? transfer.BookedAt!.Value) @@ -185,6 +211,47 @@ protected override async Task Refresh() XAxes = [reportSplit.GetXAxis(startTime, endTime)]; } + private static IEnumerable<(PlannedTransfer Planned, Transfer Transfer)> PlannedTransferSelector(PlannedTransfer plannedTransfer, List plannedTransactions, DateTimeZone timeZone) + { + var plannedTransaction = plannedTransactions.Single(transaction => transaction.Id == plannedTransfer.PlannedTransactionId); + + for (var index = 0; index < plannedTransaction.Count; index++) + { + var startDate = plannedTransaction.StartTime.InZone(timeZone); + var startTime = plannedTransfer.BookedAt; + var startDateTime = new LocalDateTime(startDate.Year, startDate.Month, startDate.Day, startTime.Hour, startTime.Minute); + for (var i = 0; i <= index; i++) + { + startDateTime += plannedTransaction.Period; + } + + var instant = startDateTime.InZoneStrictly(timeZone).ToInstant(); + + var transfer = new Transfer + { + Id = plannedTransfer.Id, + CreatedAt = plannedTransfer.CreatedAt, + OwnerId = plannedTransfer.OwnerId, + CreatedByUserId = plannedTransfer.CreatedByUserId, + ModifiedAt = plannedTransfer.ModifiedAt, + ModifiedByUserId = plannedTransfer.ModifiedByUserId, + TransactionId = plannedTransfer.PlannedTransactionId, + SourceAmount = plannedTransfer.SourceAmount, + SourceAccountId = plannedTransfer.SourceAccountId ?? default, + TargetAmount = plannedTransfer.TargetAmount, + TargetAccountId = plannedTransfer.TargetAccountId ?? default, + BankReference = null, + ExternalReference = null, + InternalReference = null, + Order = plannedTransfer.Order, + BookedAt = instant, + ValuedAt = null, + }; + + yield return (plannedTransfer, transfer); + } + } + private void OnPropertyChanging(object? sender, PropertyChangingEventArgs e) { if (e.PropertyName is nameof(SelectedAccounts)) @@ -205,6 +272,11 @@ private async void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) await RefreshAsync(); } + if (e.PropertyName is nameof(IncludeProjections)) + { + await RefreshAsync(); + } + if (!IsBusy && e.PropertyName is nameof(SelectedCurrency)) { await RefreshAsync(); diff --git a/source/Gnomeshade.Avalonia.Core/Reports/CategoryReportViewModel.cs b/source/Gnomeshade.Avalonia.Core/Reports/CategoryReportViewModel.cs index 387a9cdd2..eebdc32af 100644 --- a/source/Gnomeshade.Avalonia.Core/Reports/CategoryReportViewModel.cs +++ b/source/Gnomeshade.Avalonia.Core/Reports/CategoryReportViewModel.cs @@ -44,6 +44,9 @@ public sealed partial class CategoryReportViewModel : ViewModelBase [Notify] private IReportSplit? _selectedSplit = SplitProvider.MonthlySplit; + [Notify] + private bool _includeProjections = true; + /// Gets the data series of amount spent per month per category. [Notify(Setter.Private)] private List> _series = []; @@ -86,9 +89,16 @@ protected override async Task Refresh() return; } - var accounts = await _gnomeshadeClient.GetAccountsAsync(); + var (accounts, allTransactions, plannedTransactions, plannedTransfers, plannedPurchases, products) = await + (_gnomeshadeClient.GetAccountsAsync(), + _gnomeshadeClient.GetDetailedTransactionsAsync(new(Instant.MinValue, Instant.MaxValue)), + _gnomeshadeClient.GetPlannedTransactions(), + _gnomeshadeClient.GetPlannedTransfers(), + _gnomeshadeClient.GetPlannedPurchases(), + _gnomeshadeClient.GetProductsAsync()) + .WhenAll(); + var accountsInCurrency = accounts.SelectMany(account => account.Currencies).ToArray(); - var allTransactions = await _gnomeshadeClient.GetDetailedTransactionsAsync(new(Instant.MinValue, Instant.MaxValue)); var displayableTransactions = allTransactions .Select(transaction => transaction with { TransferBalance = -transaction.TransferBalance }) .Where(transaction => transaction.TransferBalance > 0) @@ -96,6 +106,13 @@ protected override async Task Refresh() var timeZone = _dateTimeZoneProvider.GetSystemDefault(); + if (IncludeProjections) + { + displayableTransactions = displayableTransactions + .Concat(plannedTransactions.SelectMany(transaction => Selector(transaction, timeZone, plannedTransfers, plannedPurchases))) + .ToArray(); + } + var transactions = new TransactionData[displayableTransactions.Length]; for (var i = 0; i < displayableTransactions.Length; i++) { @@ -133,11 +150,9 @@ protected override async Task Refresh() var endTime = new ZonedDateTime(dates.Max(), timeZone); var splits = reportSplit.GetSplits(startTime, endTime).ToArray(); - var products = await _gnomeshadeClient.GetProductsAsync(); - var categories = await _gnomeshadeClient.GetCategoriesAsync(); - var nodes = categories + var nodes = Categories .Where(category => category.CategoryId == SelectedCategory?.Id || category.Id == SelectedCategory?.Id) - .Select(category => CategoryNode.FromCategory(category, categories)) + .Select(category => CategoryNode.FromCategory(category, Categories)) .ToList(); var uncategorizedTransfers = transactions @@ -185,19 +200,15 @@ protected override async Task Refresh() for (var purchaseIndex = 0; purchaseIndex < purchasesToSum.Length; purchaseIndex++) { var purchase = purchasesToSum[purchaseIndex]; - var sourceCurrencyIds = purchase.SourceCurrencyIds; - var targetCurrencyIds = purchase.TargetCurrencyIds; - if (sourceCurrencyIds.Length is not 1 || targetCurrencyIds.Length is not 1) + if (purchase.SourceCurrencyIds is not [var sourceCurrency] || + purchase.TargetCurrencyIds is not [var targetCurrency]) { // todo cannot handle multiple currencies (#686) sum += purchase.Purchase.Price; continue; } - var sourceCurrency = sourceCurrencyIds.Single(); - var targetCurrency = targetCurrencyIds.Single(); - if (sourceCurrency == targetCurrency) { sum += purchase.Purchase.Price; @@ -219,12 +230,101 @@ protected override async Task Refresh() XAxes = [reportSplit.GetXAxis(startTime, endTime)]; } + private static IEnumerable Selector( + PlannedTransaction transaction, + DateTimeZone timeZone, + List allTransfers, + List allPurchases) + { + var plannedTransfers = allTransfers.Where(transfer => transfer.PlannedTransactionId == transaction.Id).ToArray(); + var plannedPurchases = allPurchases.Where(purchase => purchase.PlannedTransactionId == transaction.Id).ToArray(); + + for (var index = 0; index < transaction.Count; index++) + { + var startDate = transaction.StartTime.InZone(timeZone); + var startTime = plannedTransfers.Select(transfer => transfer.BookedAt).Max(); + var startDateTime = new LocalDateTime(startDate.Year, startDate.Month, startDate.Day, startTime.Hour, startTime.Minute); + for (var i = 0; i <= index; i++) + { + startDateTime += transaction.Period; + } + + var instant = startDateTime.InZoneStrictly(timeZone).ToInstant(); + + var transfers = plannedTransfers.Select(plannedTransfer => new Transfer + { + Id = plannedTransfer.Id, + CreatedAt = plannedTransfer.CreatedAt, + OwnerId = plannedTransfer.OwnerId, + CreatedByUserId = plannedTransfer.CreatedByUserId, + ModifiedAt = plannedTransfer.ModifiedAt, + ModifiedByUserId = plannedTransfer.ModifiedByUserId, + TransactionId = plannedTransfer.PlannedTransactionId, + SourceAmount = plannedTransfer.SourceAmount, + SourceAccountId = plannedTransfer.SourceAccountId ?? default, + TargetAmount = plannedTransfer.TargetAmount, + TargetAccountId = plannedTransfer.TargetAccountId ?? default, + BankReference = null, + ExternalReference = null, + InternalReference = null, + Order = plannedTransfer.Order, + BookedAt = instant, + ValuedAt = null, + }).ToList(); + + var purchases = plannedPurchases.Select(plannedPurchase => new Purchase + { + Id = plannedPurchase.Id, + CreatedAt = plannedPurchase.CreatedAt, + OwnerId = plannedPurchase.OwnerId, + CreatedByUserId = plannedPurchase.CreatedByUserId, + ModifiedAt = plannedPurchase.ModifiedAt, + ModifiedByUserId = plannedPurchase.ModifiedByUserId, + TransactionId = plannedPurchase.PlannedTransactionId, + Price = plannedPurchase.Price, + CurrencyId = plannedPurchase.CurrencyId, + ProductId = plannedPurchase.ProductId, + Amount = plannedPurchase.Amount, + DeliveryDate = null, + Order = plannedPurchase.Order, + }).ToList(); + + yield return new() + { + Id = transaction.Id, + OwnerId = transaction.OwnerId, + CreatedAt = transaction.CreatedAt, + CreatedByUserId = transaction.CreatedByUserId, + ModifiedAt = transaction.ModifiedAt, + ModifiedByUserId = transaction.ModifiedByUserId, + Description = null, + ImportedAt = null, + ReconciledAt = null, + RefundedBy = null, + BookedAt = instant, + ValuedAt = null, + Transfers = transfers, + TransferBalance = transfers.Select(transfer => transfer.SourceAmount).Sum(), // todo + Purchases = purchases, + PurchaseTotal = purchases.Select(purchase => purchase.Price).Sum(), + LoanPayments = [], + LoanTotal = 0, + Links = [], + }; + } + } + private async void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is nameof(SelectedSplit) && SelectedSplit is not null) { await RefreshAsync(); } + + if (e.PropertyName is nameof(IncludeProjections)) + { + await RefreshAsync(); + } } private readonly struct PurchaseData(Purchase purchase, CategoryNode? node, TransactionData transaction) diff --git a/source/Gnomeshade.Avalonia.Core/Transactions/Controls/TransactionFilter.cs b/source/Gnomeshade.Avalonia.Core/Transactions/Controls/TransactionFilter.cs index 54bb93d1e..05bfeb05a 100644 --- a/source/Gnomeshade.Avalonia.Core/Transactions/Controls/TransactionFilter.cs +++ b/source/Gnomeshade.Avalonia.Core/Transactions/Controls/TransactionFilter.cs @@ -108,6 +108,9 @@ public sealed partial class TransactionFilter : FilterBase [Notify] private string? _transferReferenceFilter; + [Notify] + private bool _includeProjections = true; + /// Initializes a new instance of the class. /// Service for indicating the activity of the application to the user. /// Clock which can provide the current instant. diff --git a/source/Gnomeshade.Avalonia.Core/Transactions/TransactionOverview.cs b/source/Gnomeshade.Avalonia.Core/Transactions/TransactionOverview.cs index d27140093..9745565df 100644 --- a/source/Gnomeshade.Avalonia.Core/Transactions/TransactionOverview.cs +++ b/source/Gnomeshade.Avalonia.Core/Transactions/TransactionOverview.cs @@ -28,7 +28,8 @@ public TransactionOverview( DateTimeOffset? reconciledAt, List transfers, List purchases, - List loanPayments) + List loanPayments, + bool projection = false) { Id = id; BookedAt = bookedAt; @@ -37,6 +38,7 @@ public TransactionOverview( Transfers = transfers; Purchases = purchases; LoanPayments = loanPayments; + Projection = projection; } /// Gets the id of the transactions. @@ -60,6 +62,8 @@ public TransactionOverview( /// Gets all transfers of the transaction. public List Transfers { get; } + public bool Projection { get; } + internal List Purchases { get; } internal List LoanPayments { get; } diff --git a/source/Gnomeshade.Avalonia.Core/Transactions/TransactionUpsertionViewModel.cs b/source/Gnomeshade.Avalonia.Core/Transactions/TransactionUpsertionViewModel.cs index 8c18c0059..68fd6ed7f 100644 --- a/source/Gnomeshade.Avalonia.Core/Transactions/TransactionUpsertionViewModel.cs +++ b/source/Gnomeshade.Avalonia.Core/Transactions/TransactionUpsertionViewModel.cs @@ -123,10 +123,12 @@ protected override async Task Refresh() { if (Id is not { } transactionId) { + IsReadOnly = false; return; } var transaction = await GnomeshadeClient.GetTransactionAsync(transactionId); + IsReadOnly = transaction.Reconciled; var defaultZone = _dateTimeZoneProvider.GetSystemDefault(); diff --git a/source/Gnomeshade.Avalonia.Core/Transactions/TransactionViewModel.cs b/source/Gnomeshade.Avalonia.Core/Transactions/TransactionViewModel.cs index 9d041aaaa..2cce8d622 100644 --- a/source/Gnomeshade.Avalonia.Core/Transactions/TransactionViewModel.cs +++ b/source/Gnomeshade.Avalonia.Core/Transactions/TransactionViewModel.cs @@ -2,6 +2,7 @@ // Licensed under the GNU Affero General Public License v3.0 or later. // See LICENSE.txt file in the project root for full license information. +using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Threading.Tasks; @@ -11,6 +12,7 @@ using Gnomeshade.Avalonia.Core.Transactions.Controls; using Gnomeshade.Avalonia.Core.Transactions.Transfers; using Gnomeshade.WebApi.Client; +using Gnomeshade.WebApi.Models.Loans; using Gnomeshade.WebApi.Models.Transactions; using NodaTime; @@ -181,6 +183,83 @@ protected override async Task Refresh() transaction.LoanPayments); }).ToList(); + if (Filter.IncludeProjections) + { + var (plannedTransactions, plannedTransfers, plannedPurchases, plannedLoanPayments) = await ( + _gnomeshadeClient.GetPlannedTransactions(), + _gnomeshadeClient.GetPlannedTransfers(), + _gnomeshadeClient.GetPlannedPurchases(), + _gnomeshadeClient.GetPlannedLoanPayments()) + .WhenAll(); + + var timeZone = _dateTimeZoneProvider.GetSystemDefault(); + var plannedOverviews = plannedTransactions.SelectMany(transaction => Selector(transaction, timeZone)); + + overviews = overviews.Concat(plannedOverviews).ToList(); + + IEnumerable Selector(PlannedTransaction transaction, DateTimeZone timeZone) + { + for (var index = 0; index < transaction.Count; index++) + { + var startDate = transaction.StartTime.InZone(timeZone); + var startTime = plannedTransfers.Select(transfer => transfer.BookedAt).Max(); + var startDateTime = new LocalDateTime(startDate.Year, startDate.Month, startDate.Day, startTime.Hour, startTime.Minute); + for (var i = 0; i <= index; i++) + { + startDateTime += transaction.Period; + } + + var instant = startDateTime.InZoneStrictly(timeZone).ToInstant(); + + var transfers = plannedTransfers + .Select(plannedTransfer => plannedTransfer.ToSummary(instant, counterparties, counterparty, accountsInCurrency)) + .ToList(); + + var purchases = plannedPurchases.Select(plannedPurchase => new Purchase + { + Id = plannedPurchase.Id, + CreatedAt = plannedPurchase.CreatedAt, + OwnerId = plannedPurchase.OwnerId, + CreatedByUserId = plannedPurchase.CreatedByUserId, + ModifiedAt = plannedPurchase.ModifiedAt, + ModifiedByUserId = plannedPurchase.ModifiedByUserId, + TransactionId = plannedPurchase.PlannedTransactionId, + Price = plannedPurchase.Price, + CurrencyId = plannedPurchase.CurrencyId, + ProductId = plannedPurchase.ProductId, + Amount = plannedPurchase.Amount, + DeliveryDate = null, + Order = plannedPurchase.Order, + }).ToList(); + + var loanPayments = plannedLoanPayments.Select(payment => new LoanPayment + { + Id = payment.Id, + CreatedAt = default, + CreatedByUserId = default, + OwnerId = default, + ModifiedAt = default, + ModifiedByUserId = default, + LoanId = payment.LoanId, + TransactionId = payment.PlannedTransactionId, + Amount = payment.Amount, + Interest = payment.Interest, + }) + .ToList(); + + yield return new( + transaction.Id, + instant.InZone(timeZone).ToDateTimeOffset(), + null, + null, + transfers, + purchases, + loanPayments, + true); + } + } + } + var selected = Selected; var sort = DataGridView.SortDescriptions; Rows = new(overviews); @@ -249,13 +328,18 @@ private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) } } - private void FilterOnPropertyChanged(object? sender, PropertyChangedEventArgs e) + private async void FilterOnPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is nameof(TransactionFilter.IsValid)) { OnPropertyChanged(nameof(CanRefresh)); } + if (e.PropertyName is nameof(TransactionFilter.IncludeProjections)) + { + await RefreshAsync(); + } + if (e.PropertyName is nameof(TransactionFilter.SelectedAccount) or nameof(TransactionFilter.InvertAccount) or diff --git a/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/PlannedTransferViewModel.cs b/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/PlannedTransferViewModel.cs new file mode 100644 index 000000000..e398f4eec --- /dev/null +++ b/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/PlannedTransferViewModel.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading.Tasks; + +using Gnomeshade.Avalonia.Core.Commands; +using Gnomeshade.WebApi.Client; + +using NodaTime; + +namespace Gnomeshade.Avalonia.Core.Transactions.Transfers; + +public sealed partial class PlannedTransferViewModel : ViewModelBase +{ + private readonly IGnomeshadeClient _gnomeshadeClient; + + public PlannedTransferViewModel(IActivityService activityService, IGnomeshadeClient gnomeshadeClient) + : base(activityService) + { + _gnomeshadeClient = gnomeshadeClient; + } +} diff --git a/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferExtensions.cs b/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferExtensions.cs index 2fea59176..cc9a38d62 100644 --- a/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferExtensions.cs +++ b/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the GNU Affero General Public License v3.0 or later. // See LICENSE.txt file in the project root for full license information. +using System; using System.Collections.Generic; using System.Linq; @@ -69,4 +70,91 @@ internal static TransferSummary ToSummary( sourceCurrency.CurrencyAlphabeticCode, transfer.SourceAmount); } + + internal static TransferSummary ToSummary( + this PlannedTransfer plannedTransfer, + Instant bookedAt, + IEnumerable counterparties, + Counterparty userCounterparty, + (AccountInCurrency AccountInCurrency, Account Account)[] accounts) + { + var (sourceCounterpartyId, sourceCurrencyCode, sourcePreferredCurrencyId, sourceCurrencyId, sourceAccountName) = + (plannedTransfer.SourceCounterpartyId ?? accounts.Single(tuple => tuple.AccountInCurrency.Id == plannedTransfer.SourceAccountId).Account.CounterpartyId, + plannedTransfer.SourceAccountId is { } x + ? accounts.Single(tuple => tuple.AccountInCurrency.Id == x).AccountInCurrency.CurrencyAlphabeticCode + : accounts.First(tuple => tuple.AccountInCurrency.CurrencyId == plannedTransfer.SourceCurrencyId) + .AccountInCurrency.CurrencyAlphabeticCode, + plannedTransfer.SourceAccountId is { } y + ? accounts.Single(tuple => tuple.AccountInCurrency.Id == y).Account.PreferredCurrencyId + : Guid.Empty, + plannedTransfer.SourceAccountId is { } z + ? accounts.Single(tuple => tuple.AccountInCurrency.Id == z).AccountInCurrency.CurrencyId + : plannedTransfer.SourceCurrencyId!.Value, + plannedTransfer.SourceAccountId is { } w + ? accounts.Single(tuple => tuple.AccountInCurrency.Id == w).Account.Name + : string.Empty); + + var (targetCounterpartyId, targetCurrencyCode, targetPreferredCurrencyId, targetCurrencyId, targetAccountName) = + (plannedTransfer.TargetCounterpartyId ?? accounts.Single(tuple => tuple.AccountInCurrency.Id == plannedTransfer.TargetAccountId).Account.CounterpartyId, + plannedTransfer.TargetAccountId is { } a + ? accounts.Single(tuple => tuple.AccountInCurrency.Id == a).AccountInCurrency.CurrencyAlphabeticCode + : accounts.First(tuple => tuple.AccountInCurrency.CurrencyId == plannedTransfer.TargetCurrencyId) + .AccountInCurrency.CurrencyAlphabeticCode, + plannedTransfer.TargetAccountId is { } b + ? accounts.Single(tuple => tuple.AccountInCurrency.Id == b).Account.PreferredCurrencyId + : Guid.Empty, + plannedTransfer.TargetAccountId is { } c + ? accounts.Single(tuple => tuple.AccountInCurrency.Id == c).AccountInCurrency.CurrencyId + : plannedTransfer.TargetCurrencyId!.Value, + plannedTransfer.TargetAccountId is { } d + ? accounts.Single(tuple => tuple.AccountInCurrency.Id == d).Account.Name + : string.Empty); + + var transfer = new Transfer + { + Id = plannedTransfer.Id, + CreatedAt = plannedTransfer.CreatedAt, + OwnerId = plannedTransfer.OwnerId, + CreatedByUserId = plannedTransfer.CreatedByUserId, + ModifiedAt = plannedTransfer.ModifiedAt, + ModifiedByUserId = plannedTransfer.ModifiedByUserId, + TransactionId = plannedTransfer.PlannedTransactionId, + SourceAmount = plannedTransfer.SourceAmount, + SourceAccountId = plannedTransfer.SourceAccountId ?? default, + TargetAmount = plannedTransfer.TargetAmount, + TargetAccountId = plannedTransfer.TargetAccountId ?? default, + BankReference = null, + ExternalReference = null, + InternalReference = null, + Order = plannedTransfer.Order, + BookedAt = bookedAt, + ValuedAt = null, + }; + + return sourceCounterpartyId == userCounterparty.Id + ? new( + transfer, + sourceCurrencyCode, + sourcePreferredCurrencyId != sourceCurrencyId, + transfer.SourceAmount, + sourceAccountName, + "→", + targetCounterpartyId == userCounterparty.Id, + counterparties.Single(counterparty => targetCounterpartyId == counterparty.Id).Name, + targetAccountName, + targetCurrencyCode, + transfer.TargetAmount) + : new( + transfer, + targetCurrencyCode, + targetPreferredCurrencyId != targetCurrencyId, + transfer.TargetAmount, + targetAccountName, + "←", + false, + counterparties.Single(counterparty => sourceCounterpartyId == counterparty.Id).Name, + sourceAccountName, + sourceCurrencyCode, + transfer.SourceAmount); + } } diff --git a/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferUpsertionViewModel.cs b/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferUpsertionViewModel.cs index 85cd32951..94b8395e8 100644 --- a/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferUpsertionViewModel.cs +++ b/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferUpsertionViewModel.cs @@ -152,13 +152,10 @@ TargetCurrency is not null && /// protected override async Task Refresh() { - var accountsTask = GnomeshadeClient.GetAccountsAsync(); - var currenciesTask = GnomeshadeClient.GetCurrenciesAsync(); - - await Task.WhenAll(accountsTask, currenciesTask); - - Accounts = accountsTask.Result; - Currencies = currenciesTask.Result; + (Accounts, Currencies) = await + (GnomeshadeClient.GetAccountsAsync(), + GnomeshadeClient.GetCurrenciesAsync()) + .WhenAll(); if (Id is not { } transferId) { diff --git a/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferViewModel.cs b/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferViewModel.cs index a18d07849..ee52527c6 100644 --- a/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferViewModel.cs +++ b/source/Gnomeshade.Avalonia.Core/Transactions/Transfers/TransferViewModel.cs @@ -78,12 +78,11 @@ public override async Task UpdateSelection() /// protected override async Task Refresh() { - var transactionsTask = _gnomeshadeClient.GetDetailedTransactionAsync(_transactionId); - var accountsTask = _gnomeshadeClient.GetAccountsAsync(); - await Task.WhenAll(transactionsTask, accountsTask); + var (transaction, accounts) = await + (_gnomeshadeClient.GetDetailedTransactionAsync(_transactionId), + _gnomeshadeClient.GetAccountsAsync()) + .WhenAll(); - var accounts = accountsTask.Result; - var transaction = transactionsTask.Result; Total = transaction.TransferBalance; IsReadOnly = transaction.Reconciled; diff --git a/source/Gnomeshade.Avalonia.Core/UpsertionViewModel.cs b/source/Gnomeshade.Avalonia.Core/UpsertionViewModel.cs index 092b434b6..d8f9476d5 100644 --- a/source/Gnomeshade.Avalonia.Core/UpsertionViewModel.cs +++ b/source/Gnomeshade.Avalonia.Core/UpsertionViewModel.cs @@ -20,6 +20,10 @@ public abstract partial class UpsertionViewModel : ViewModelBase [Notify(Setter.Protected)] private Guid? _id; + /// Gets or sets a value indicating whether this model can be modified. + [Notify] + private bool _isReadOnly = true; + /// Initializes a new instance of the class. /// Service for indicating the activity of the application to the user. /// The strongly typed API client. diff --git a/source/Gnomeshade.Data/Entities/PlannedLoanPaymentEntity.cs b/source/Gnomeshade.Data/Entities/PlannedLoanPaymentEntity.cs new file mode 100644 index 000000000..064627dd7 --- /dev/null +++ b/source/Gnomeshade.Data/Entities/PlannedLoanPaymentEntity.cs @@ -0,0 +1,38 @@ +// Copyright 2021 Valters Melnalksnis +// Licensed under the GNU Affero General Public License v3.0 or later. +// See LICENSE.txt file in the project root for full license information. + +using System; + +using Gnomeshade.Data.Entities.Abstractions; + +using NodaTime; + +namespace Gnomeshade.Data.Entities; + +/// An amount that was loaned or payed back as a part of a transaction. +public sealed record PlannedLoanPaymentEntity : Entity, IOwnableEntity, IModifiableEntity +{ + /// + public Guid OwnerId { get; set; } + + /// + public Instant ModifiedAt { get; set; } + + /// + public Guid ModifiedByUserId { get; set; } + + /// Gets or sets the id of the the loan this loan payment is a part of. + /// + public Guid LoanId { get; set; } + + /// Gets or sets the id of the the transaction this loan payment is a part of. + /// + public Guid PlannedTransactionId { get; set; } + + /// Gets or sets the amount that was loaned or payed back. + public decimal Amount { get; set; } + + /// Gets or sets the interest amount of this loan payment. + public decimal Interest { get; set; } +} diff --git a/source/Gnomeshade.Data/Entities/PlannedPurchaseEntity.cs b/source/Gnomeshade.Data/Entities/PlannedPurchaseEntity.cs new file mode 100644 index 000000000..56133ee13 --- /dev/null +++ b/source/Gnomeshade.Data/Entities/PlannedPurchaseEntity.cs @@ -0,0 +1,45 @@ +// Copyright 2021 Valters Melnalksnis +// Licensed under the GNU Affero General Public License v3.0 or later. +// See LICENSE.txt file in the project root for full license information. + +using System; + +using Gnomeshade.Data.Entities.Abstractions; + +using NodaTime; + +namespace Gnomeshade.Data.Entities; + +/// Represents the purchasing of a product or a service. +public sealed record PlannedPurchaseEntity : Entity, IOwnableEntity, IModifiableEntity, ISortableEntity +{ + /// + public Guid OwnerId { get; set; } + + /// + public Instant ModifiedAt { get; set; } + + /// + public Guid ModifiedByUserId { get; set; } + + /// Gets or sets the id of transaction this transfer is a part of. + /// + public Guid PlannedTransactionId { get; set; } + + /// Gets or sets the amount paid to purchase an of . + public decimal Price { get; set; } + + /// Gets or sets the id of the currency of . + /// + public Guid CurrencyId { get; set; } + + /// Gets or sets the id of the purchased product. + /// + public Guid ProductId { get; set; } + + /// Gets or sets the amount of that was purchased. + public decimal Amount { get; set; } + + /// + public uint? Order { get; set; } +} diff --git a/source/Gnomeshade.Data/Entities/PlannedTransactionEntity.cs b/source/Gnomeshade.Data/Entities/PlannedTransactionEntity.cs new file mode 100644 index 000000000..033728f2a --- /dev/null +++ b/source/Gnomeshade.Data/Entities/PlannedTransactionEntity.cs @@ -0,0 +1,28 @@ +// Copyright 2021 Valters Melnalksnis +// Licensed under the GNU Affero General Public License v3.0 or later. +// See LICENSE.txt file in the project root for full license information. + +using System; + +using Gnomeshade.Data.Entities.Abstractions; + +using NodaTime; + +namespace Gnomeshade.Data.Entities; + +/// A single financial transaction. +public sealed record PlannedTransactionEntity : Entity, IOwnableEntity, IModifiableEntity +{ + /// + public Guid OwnerId { get; set; } + + /// + public Instant ModifiedAt { get; set; } + + /// + public Guid ModifiedByUserId { get; set; } + + public Instant StartTime { get; set; } + + public Period Period { get; set; } +} diff --git a/source/Gnomeshade.Data/Entities/PlannedTransferEntity.cs b/source/Gnomeshade.Data/Entities/PlannedTransferEntity.cs new file mode 100644 index 000000000..69702d6e7 --- /dev/null +++ b/source/Gnomeshade.Data/Entities/PlannedTransferEntity.cs @@ -0,0 +1,67 @@ +// Copyright 2021 Valters Melnalksnis +// Licensed under the GNU Affero General Public License v3.0 or later. +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Diagnostics.CodeAnalysis; + +using Gnomeshade.Data.Entities.Abstractions; + +using NodaTime; + +namespace Gnomeshade.Data.Entities; + +public sealed record PlannedTransferEntity : Entity, IOwnableEntity, IModifiableEntity, ISortableEntity +{ + /// + public Guid OwnerId { get; set; } + + /// + public Instant ModifiedAt { get; set; } + + /// + public Guid ModifiedByUserId { get; set; } + + /// Gets or sets the id of transaction this transfer is a part of. + /// + public Guid PlannedTransactionId { get; set; } + + /// Gets or sets the amount withdrawn from the source account. + public decimal SourceAmount { get; set; } + + /// Gets or sets the id of the account from which currency is withdrawn from. + /// + [MemberNotNullWhen(true, nameof(SourceUsesAccount))] + public Guid? SourceAccountId { get; set; } + + /// + [MemberNotNullWhen(false, nameof(SourceUsesAccount))] + public Guid? SourceCounterpartyId { get; set; } + + /// + [MemberNotNullWhen(false, nameof(SourceUsesAccount))] + public Guid? SourceCurrencyId { get; set; } + + /// Gets or sets the amount deposited in the target account. + public decimal TargetAmount { get; set; } + + /// Gets or sets the id of the to which currency is deposited to. + /// + [MemberNotNullWhen(true, nameof(TargetUsesAccount))] + public Guid? TargetAccountId { get; set; } + + /// + [MemberNotNullWhen(false, nameof(TargetUsesAccount))] + public Guid? TargetCounterpartyId { get; set; } + + /// + [MemberNotNullWhen(false, nameof(TargetUsesAccount))] + public Guid? TargetCurrencyId { get; set; } + + /// + public uint? Order { get; set; } + + public bool SourceUsesAccount => SourceAccountId is not null; + + public bool TargetUsesAccount => TargetAccountId is not null; +} diff --git a/source/Gnomeshade.Desktop/App.axaml.cs b/source/Gnomeshade.Desktop/App.axaml.cs index 92a9f8051..0febc1182 100644 --- a/source/Gnomeshade.Desktop/App.axaml.cs +++ b/source/Gnomeshade.Desktop/App.axaml.cs @@ -17,6 +17,7 @@ using Gnomeshade.Avalonia.Core; using Gnomeshade.Avalonia.Core.Authentication; using Gnomeshade.Avalonia.Core.Configuration; +using Gnomeshade.Avalonia.Core.DesignTime; using Gnomeshade.Desktop.Authentication; using Gnomeshade.Desktop.Views; using Gnomeshade.WebApi.Client; @@ -123,6 +124,7 @@ public App(string[] args) }); serviceCollection.AddGnomeshadeClient(configuration); + serviceCollection.AddSingleton(); // todo remove serviceCollection .AddViewModels() diff --git a/source/Gnomeshade.Desktop/Views/Reports/BalanceReportView.axaml b/source/Gnomeshade.Desktop/Views/Reports/BalanceReportView.axaml index 0d0e2e85d..7f35f73f8 100644 --- a/source/Gnomeshade.Desktop/Views/Reports/BalanceReportView.axaml +++ b/source/Gnomeshade.Desktop/Views/Reports/BalanceReportView.axaml @@ -28,6 +28,10 @@ SelectedItem="{Binding SelectedSplit, Mode=TwoWay}" IsEnabled="{Binding !IsBusy}" /> + + Include projections + + - + + Include projections + + - + + + + diff --git a/source/Gnomeshade.Desktop/Views/Transactions/Purchases/PurchaseView.axaml b/source/Gnomeshade.Desktop/Views/Transactions/Purchases/PurchaseView.axaml index dba2c6627..806cdb6c6 100644 --- a/source/Gnomeshade.Desktop/Views/Transactions/Purchases/PurchaseView.axaml +++ b/source/Gnomeshade.Desktop/Views/Transactions/Purchases/PurchaseView.axaml @@ -12,6 +12,13 @@ x:DataType="purchases:PurchaseViewModel"> + + + + + + + diff --git a/source/Gnomeshade.Desktop/Views/Transactions/TransactionUpsertionView.axaml b/source/Gnomeshade.Desktop/Views/Transactions/TransactionUpsertionView.axaml index 1a512f24f..a6ff3631e 100644 --- a/source/Gnomeshade.Desktop/Views/Transactions/TransactionUpsertionView.axaml +++ b/source/Gnomeshade.Desktop/Views/Transactions/TransactionUpsertionView.axaml @@ -24,7 +24,7 @@ diff --git a/source/Gnomeshade.WebApi.Client/GnomeshadeClient.cs b/source/Gnomeshade.WebApi.Client/GnomeshadeClient.cs index e2ec706ae..e21348b87 100644 --- a/source/Gnomeshade.WebApi.Client/GnomeshadeClient.cs +++ b/source/Gnomeshade.WebApi.Client/GnomeshadeClient.cs @@ -235,6 +235,37 @@ public Task AddRelatedTransactionAsync(Guid id, Guid relatedId) => public Task RemoveRelatedTransactionAsync(Guid id, Guid relatedId) => DeleteAsync(Transactions.RelatedUri(id, relatedId)); + /// + public Task> GetPlannedTransactions(CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + /// + public Task> GetPlannedTransfers(CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + /// + public Task> GetPlannedTransfers( + Guid transactionId, + CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + /// + public Task> GetPlannedPurchases(CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + /// + public Task> GetPlannedPurchases( + Guid transactionId, + CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + /// + public Task> GetPlannedLoanPayments(CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + /// + public Task> GetPlannedLoanPayments( + Guid transactionId, + CancellationToken cancellationToken = default) => throw new NotImplementedException(); + /// [Obsolete] public Task> GetLegacyLoans(CancellationToken cancellationToken = default) => diff --git a/source/Gnomeshade.WebApi.Client/ITransactionClient.cs b/source/Gnomeshade.WebApi.Client/ITransactionClient.cs index 12826007b..6dad00081 100644 --- a/source/Gnomeshade.WebApi.Client/ITransactionClient.cs +++ b/source/Gnomeshade.WebApi.Client/ITransactionClient.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Gnomeshade.WebApi.Models; +using Gnomeshade.WebApi.Models.Loans; using Gnomeshade.WebApi.Models.Transactions; using NodaTime; @@ -156,6 +157,20 @@ Task> GetDetailedTransactionsAsync( /// A representing the asynchronous operation. Task RemoveRelatedTransactionAsync(Guid id, Guid relatedId); + Task> GetPlannedTransactions(CancellationToken cancellationToken = default); + + Task> GetPlannedTransfers(CancellationToken cancellationToken = default); + + Task> GetPlannedTransfers(Guid transactionId, CancellationToken cancellationToken = default); + + Task> GetPlannedPurchases(CancellationToken cancellationToken = default); + + Task> GetPlannedPurchases(Guid transactionId, CancellationToken cancellationToken = default); + + Task> GetPlannedLoanPayments(CancellationToken cancellationToken = default); + + Task> GetPlannedLoanPayments(Guid transactionId, CancellationToken cancellationToken = default); + /// Gets all loans. /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// All loans. diff --git a/source/Gnomeshade.WebApi.Models/Loans/PlannedLoanPayment.cs b/source/Gnomeshade.WebApi.Models/Loans/PlannedLoanPayment.cs new file mode 100644 index 000000000..d6e1c43b9 --- /dev/null +++ b/source/Gnomeshade.WebApi.Models/Loans/PlannedLoanPayment.cs @@ -0,0 +1,51 @@ +// Copyright 2021 Valters Melnalksnis +// Licensed under the GNU Affero General Public License v3.0 or later. +// See LICENSE.txt file in the project root for full license information. + +using System; + +using Gnomeshade.WebApi.Models.Transactions; + +using JetBrains.Annotations; + +using NodaTime; + +namespace Gnomeshade.WebApi.Models.Loans; + +/// A payment that was to either issue or pay back a loan. +/// +[PublicAPI] +public sealed record PlannedLoanPayment +{ + /// The id of the loan payment. + public Guid Id { get; set; } + + /// The point in time when the loan was created. + public Instant CreatedAt { get; set; } + + /// The id of the user that created this loan payment. + public Guid CreatedByUserId { get; set; } + + /// The id of the owner of the loan payment. + public Guid OwnerId { get; set; } + + /// The point in time when the loan payment was last modified. + public Instant ModifiedAt { get; set; } + + /// The id of the user that last modified this loan payment. + public Guid ModifiedByUserId { get; set; } + + /// The id of the loan that this payment is a part of. + /// + public Guid LoanId { get; set; } + + /// The id of the transaction that this payment is a part of. + /// + public Guid PlannedTransactionId { get; set; } + + /// The amount that was loaned or paid back. + public decimal Amount { get; set; } + + /// The interest amount of this loan payment. + public decimal Interest { get; set; } +} diff --git a/source/Gnomeshade.WebApi.Models/Transactions/PlannedPurchase.cs b/source/Gnomeshade.WebApi.Models/Transactions/PlannedPurchase.cs new file mode 100644 index 000000000..1697716bb --- /dev/null +++ b/source/Gnomeshade.WebApi.Models/Transactions/PlannedPurchase.cs @@ -0,0 +1,58 @@ +// Copyright 2021 Valters Melnalksnis +// Licensed under the GNU Affero General Public License v3.0 or later. +// See LICENSE.txt file in the project root for full license information. + +using System; + +using Gnomeshade.WebApi.Models.Accounts; +using Gnomeshade.WebApi.Models.Products; + +using JetBrains.Annotations; + +using NodaTime; + +namespace Gnomeshade.WebApi.Models.Transactions; + +/// The act or an instance of buying of a as a part of a . +[PublicAPI] +public sealed record PlannedPurchase +{ + /// The id of the purchase. + public Guid Id { get; set; } + + /// The point in time when the purchase was created. + public Instant CreatedAt { get; set; } + + /// The id of the owner of the purchase. + public Guid OwnerId { get; set; } + + /// The id of the user that created this purchase. + public Guid CreatedByUserId { get; set; } + + /// The point in the when the purchase was last modified. + public Instant ModifiedAt { get; set; } + + /// The id of the user that last modified this purchase. + public Guid ModifiedByUserId { get; set; } + + /// The id of transaction this purchase is a part of. + /// + public Guid PlannedTransactionId { get; set; } + + /// The amount paid to purchase an of . + public decimal Price { get; set; } + + /// The id of the currency of . + /// + public Guid CurrencyId { get; set; } + + /// The id of the purchased product. + /// + public Guid ProductId { get; set; } + + /// The amount of that was purchased. + public decimal Amount { get; set; } + + /// The order of the purchase within a transaction. + public uint? Order { get; set; } +} diff --git a/source/Gnomeshade.WebApi.Models/Transactions/PlannedTransaction.cs b/source/Gnomeshade.WebApi.Models/Transactions/PlannedTransaction.cs new file mode 100644 index 000000000..122dbf14c --- /dev/null +++ b/source/Gnomeshade.WebApi.Models/Transactions/PlannedTransaction.cs @@ -0,0 +1,43 @@ +// Copyright 2021 Valters Melnalksnis +// Licensed under the GNU Affero General Public License v3.0 or later. +// See LICENSE.txt file in the project root for full license information. + +using System; + +using JetBrains.Annotations; + +using NodaTime; + +namespace Gnomeshade.WebApi.Models.Transactions; + +/// A financial transaction during which funds can be transferred between multiple accounts. +[PublicAPI] +public record PlannedTransaction +{ + /// The id of the transaction. + public Guid Id { get; set; } + + /// The id of the owner of the transaction. + public Guid OwnerId { get; set; } + + /// The point in time when the transaction was created. + public Instant CreatedAt { get; set; } + + /// The id of the user that created this transaction. + public Guid CreatedByUserId { get; set; } + + /// The point in time when the transaction was last modified. + public Instant ModifiedAt { get; set; } + + /// The id of the user that last modified this transaction. + public Guid ModifiedByUserId { get; set; } + + /// The point in time when the first planned transaction will what?. // todo + public Instant StartTime { get; set; } + + /// The number of planned transactions to repeat. + public int Count { get; set; } + + /// The period between each repeated planned transaction. + public Period Period { get; set; } = null!; +} diff --git a/source/Gnomeshade.WebApi.Models/Transactions/PlannedTransfer.cs b/source/Gnomeshade.WebApi.Models/Transactions/PlannedTransfer.cs new file mode 100644 index 000000000..4ef631e75 --- /dev/null +++ b/source/Gnomeshade.WebApi.Models/Transactions/PlannedTransfer.cs @@ -0,0 +1,80 @@ +// Copyright 2021 Valters Melnalksnis +// Licensed under the GNU Affero General Public License v3.0 or later. +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Diagnostics.CodeAnalysis; + +using Gnomeshade.WebApi.Models.Accounts; + +using JetBrains.Annotations; + +using NodaTime; + +namespace Gnomeshade.WebApi.Models.Transactions; + +/// A transfer between two accounts. +/// +[PublicAPI] +public sealed record PlannedTransfer +{ + /// The id of the transfer. + public Guid Id { get; set; } + + /// The point in time when the transfer was created. + public Instant CreatedAt { get; set; } + + /// The id of the owner of the transfer. + public Guid OwnerId { get; set; } + + /// The id of the user that created this transfer. + public Guid CreatedByUserId { get; set; } + + /// The point in the when the transfer was last modified. + public Instant ModifiedAt { get; set; } + + /// The id of the user that last modified this transfer. + public Guid ModifiedByUserId { get; set; } + + /// The id of transaction this transfer is a part of. + /// + public Guid PlannedTransactionId { get; set; } + + /// The amount withdrawn from the source account. + public decimal SourceAmount { get; set; } + + /// The id of the account from which currency is withdrawn from. + /// + public Guid? SourceAccountId { get; set; } + + [MemberNotNullWhen(true, nameof(SourceAccountId))] + [MemberNotNullWhen(false, nameof(SourceCounterpartyId))] + [MemberNotNullWhen(false, nameof(SourceCurrencyId))] + public bool IsSourceAccount => SourceAccountId.HasValue; + + public Guid? SourceCounterpartyId { get; set; } + + public Guid? SourceCurrencyId { get; set; } + + /// The amount deposited in the target account. + public decimal TargetAmount { get; set; } + + /// The id of the account to which currency is deposited to. + /// + public Guid? TargetAccountId { get; set; } + + [MemberNotNullWhen(true, nameof(SourceAccountId))] + [MemberNotNullWhen(false, nameof(SourceCounterpartyId))] + [MemberNotNullWhen(false, nameof(SourceCurrencyId))] + public bool IsTargetAccount => TargetAccountId.HasValue; + + public Guid? TargetCounterpartyId { get; set; } + + public Guid? TargetCurrencyId { get; set; } + + /// The order of the transfer within a transaction. + public uint? Order { get; set; } + + /// The point in time when this transfer was posted to an account on the account servicer accounting books. + public LocalTime BookedAt { get; set; } +}