From e211463a2048cf8f1a61a0a02c8304a43721f7f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Gr=C3=BCtzmacher?= Date: Sat, 10 Oct 2026 14:10:12 +0200 Subject: [PATCH 1/4] WIP UI --- docs/AccountingData.xsd | 10 +- .../Extensions/NumberExtensions.cs | 11 + .../Model/AccountingData.Extensions.cs | 19 +- .../Model/AccountingData.designer.cs | 195 +++++++++++++++++- src/SimpleAccounting/Model/AccountingData.xsd | 10 +- .../Presentation/AccountJournalViewModel.cs | 7 + .../Presentation/CurrencyValueConverter.cs | 47 +++++ .../Presentation/EditBookingViewModel.cs | 2 +- .../Presentation/FullJournalViewModel.cs | 7 + .../Presentation/IAccountJournalViewModel.cs | 4 + .../Presentation/IFullJournalViewModel.cs | 6 +- .../Presentation/ProjectOptionsView.xaml | 13 +- .../Presentation/ProjectOptionsViewModel.cs | 15 +- .../Presentation/ShellView.xaml | 39 +++- .../Presentation/ShellViewModel.cs | 6 + .../Properties/Resources.Designer.cs | 11 +- .../Properties/Resources.de.resx | 3 + .../Properties/Resources.fr.resx | 3 + .../Properties/Resources.resx | 3 + .../Model/AccountingDataTests.cs | 41 ++++ .../AccountJournalViewModelTests.cs | 23 +++ .../CurrencyValueConverterTests.cs | 66 ++++++ .../Presentation/FullJournalViewModelTests.cs | 23 +++ .../ProjectOptionsViewModelTests.cs | 17 +- .../Presentation/ShellViewModelTests.cs | 2 +- 25 files changed, 557 insertions(+), 26 deletions(-) create mode 100644 src/SimpleAccounting/Presentation/CurrencyValueConverter.cs create mode 100644 tests/SimpleAccounting.UnitTests/Presentation/CurrencyValueConverterTests.cs diff --git a/docs/AccountingData.xsd b/docs/AccountingData.xsd index 01deab64..ad4ff093 100644 --- a/docs/AccountingData.xsd +++ b/docs/AccountingData.xsd @@ -10,7 +10,15 @@ - + + + + + + + + + diff --git a/src/SimpleAccounting/Extensions/NumberExtensions.cs b/src/SimpleAccounting/Extensions/NumberExtensions.cs index 2e9d2293..8e6e68ea 100644 --- a/src/SimpleAccounting/Extensions/NumberExtensions.cs +++ b/src/SimpleAccounting/Extensions/NumberExtensions.cs @@ -22,6 +22,17 @@ public static string FormatCurrency(this long value) return (value / ConversionFactor).FormatCurrency(); } + /// + /// Formats the specified value as currency including the specified currency symbol. + /// The position of the symbol is defined by the specified culture. + /// + public static string FormatCurrency(this double value, string currencySymbol, CultureInfo culture) + { + var format = (NumberFormatInfo)culture.NumberFormat.Clone(); + format.CurrencySymbol = currencySymbol; + return value.ToString("C2", format); + } + public static long ToModelValue(this double value) { return (long)Math.Round(value * ConversionFactor); diff --git a/src/SimpleAccounting/Model/AccountingData.Extensions.cs b/src/SimpleAccounting/Model/AccountingData.Extensions.cs index 15f1ae2e..b1bfdf07 100644 --- a/src/SimpleAccounting/Model/AccountingData.Extensions.cs +++ b/src/SimpleAccounting/Model/AccountingData.Extensions.cs @@ -78,7 +78,7 @@ internal static AccountingData GetTemplateProject(int currentYear) }; return new AccountingData { - Setup = new AccountingDataSetup { Currency = "€" }, + Setup = new AccountingDataSetup(), Accounts = [new AccountingDataAccountGroup { Name = "Default", Account = defaultAccounts }], Journal = [accountJournal] }; @@ -216,9 +216,10 @@ private bool MergeYearsIntoJournal() private bool IntroduceCurrency() { - if (string.IsNullOrWhiteSpace(this.Setup.Currency)) + if (string.IsNullOrWhiteSpace(this.Setup.Currency?.Value)) { - this.Setup.Currency = "€"; + this.Setup.Currency ??= new AccountingDataSetupCurrency(); + this.Setup.Currency.Value = AccountingDataSetupCurrency.DefaultValue; return true; } @@ -263,10 +264,20 @@ public AccountingDataSetup() { this.behaviorField = new AccountingDataSetupBehavior(); this.reportsField = new AccountingDataSetupReports(); - this.Currency = "€"; + this.currencyField = new AccountingDataSetupCurrency { Value = AccountingDataSetupCurrency.DefaultValue }; } } +public partial class AccountingDataSetupCurrency +{ + internal const string DefaultValue = "€"; + + /// + /// Gets the currency symbol to be shown at the values, or null if the currency should not be shown. + /// + internal string? DisplaySymbol => this.Show ? this.Value : null; +} + public partial class AccountingDataSetupBehavior { /// diff --git a/src/SimpleAccounting/Model/AccountingData.designer.cs b/src/SimpleAccounting/Model/AccountingData.designer.cs index cf7489e6..ec184193 100644 --- a/src/SimpleAccounting/Model/AccountingData.designer.cs +++ b/src/SimpleAccounting/Model/AccountingData.designer.cs @@ -260,7 +260,7 @@ public partial class AccountingDataSetup { private string locationField; - private string currencyField; + private AccountingDataSetupCurrency currencyField; private AccountingDataSetupBookingTemplates bookingTemplatesField; @@ -288,7 +288,7 @@ public string Location { } } - public string Currency { + public AccountingDataSetupCurrency Currency { get { return this.currencyField; } @@ -478,6 +478,197 @@ public static AccountingDataSetup LoadFromFile(string fileName) { #endregion } + [System.CodeDom.Compiler.GeneratedCodeAttribute("Xsd2Code", "3.4.1+6249aec1f88eb051eadc7d928205edd1c2d96aad")] + [System.SerializableAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(AnonymousType=true)] + public partial class AccountingDataSetupCurrency { + + private bool showField; + + private string valueField; + + private static System.Xml.Serialization.XmlSerializer serializer; + + public AccountingDataSetupCurrency() { + this.showField = false; + } + + [System.Xml.Serialization.XmlAttributeAttribute()] + [System.ComponentModel.DefaultValueAttribute(false)] + public bool Show { + get { + return this.showField; + } + set { + this.showField = value; + } + } + + [System.Xml.Serialization.XmlTextAttribute()] + public string Value { + get { + return this.valueField; + } + set { + this.valueField = value; + } + } + + private static System.Xml.Serialization.XmlSerializer Serializer { + get { + if ((serializer == null)) { + serializer = new System.Xml.Serialization.XmlSerializer(typeof(AccountingDataSetupCurrency)); + } + return serializer; + } + } + + #region Serialize/Deserialize + /// + /// Serializes current AccountingDataSetupCurrency object into an XML document + /// + /// string XML value + public virtual string Serialize() { + System.IO.StreamReader streamReader = null; + System.IO.MemoryStream memoryStream = null; + try { + memoryStream = new System.IO.MemoryStream(); + Serializer.Serialize(memoryStream, this); + memoryStream.Seek(0, System.IO.SeekOrigin.Begin); + streamReader = new System.IO.StreamReader(memoryStream); + return streamReader.ReadToEnd(); + } + finally { + if ((streamReader != null)) { + streamReader.Dispose(); + } + if ((memoryStream != null)) { + memoryStream.Dispose(); + } + } + } + + /// + /// Deserializes workflow markup into an AccountingDataSetupCurrency object + /// + /// string workflow markup to deserialize + /// Output AccountingDataSetupCurrency object + /// output Exception value if deserialize failed + /// true if this XmlSerializer can deserialize the object; otherwise, false + public static bool Deserialize(string xml, out AccountingDataSetupCurrency obj, out System.Exception exception) { + exception = null; + obj = default(AccountingDataSetupCurrency); + try { + obj = Deserialize(xml); + return true; + } + catch (System.Exception ex) { + exception = ex; + return false; + } + } + + public static bool Deserialize(string xml, out AccountingDataSetupCurrency obj) { + System.Exception exception = null; + return Deserialize(xml, out obj, out exception); + } + + public static AccountingDataSetupCurrency Deserialize(string xml) { + System.IO.StringReader stringReader = null; + try { + stringReader = new System.IO.StringReader(xml); + return ((AccountingDataSetupCurrency)(Serializer.Deserialize(System.Xml.XmlReader.Create(stringReader)))); + } + finally { + if ((stringReader != null)) { + stringReader.Dispose(); + } + } + } + + /// + /// Serializes current AccountingDataSetupCurrency object into file + /// + /// full path of output XML file + /// output Exception value if failed + /// true if can serialize and save into file; otherwise, false + public virtual bool SaveToFile(string fileName, out System.Exception exception) { + exception = null; + try { + SaveToFile(fileName); + return true; + } + catch (System.Exception e) { + exception = e; + return false; + } + } + + public virtual void SaveToFile(string fileName) { + System.IO.StreamWriter streamWriter = null; + try { + string xmlString = Serialize(); + System.IO.FileInfo xmlFile = new System.IO.FileInfo(fileName); + streamWriter = xmlFile.CreateText(); + streamWriter.WriteLine(xmlString); + streamWriter.Close(); + } + finally { + if ((streamWriter != null)) { + streamWriter.Dispose(); + } + } + } + + /// + /// Deserializes XML markup from file into an AccountingDataSetupCurrency object + /// + /// string xml file to load and deserialize + /// Output AccountingDataSetupCurrency object + /// output Exception value if deserialize failed + /// true if this XmlSerializer can deserialize the object; otherwise, false + public static bool LoadFromFile(string fileName, out AccountingDataSetupCurrency obj, out System.Exception exception) { + exception = null; + obj = default(AccountingDataSetupCurrency); + try { + obj = LoadFromFile(fileName); + return true; + } + catch (System.Exception ex) { + exception = ex; + return false; + } + } + + public static bool LoadFromFile(string fileName, out AccountingDataSetupCurrency obj) { + System.Exception exception = null; + return LoadFromFile(fileName, out obj, out exception); + } + + public static AccountingDataSetupCurrency LoadFromFile(string fileName) { + System.IO.FileStream file = null; + System.IO.StreamReader sr = null; + try { + file = new System.IO.FileStream(fileName, FileMode.Open, FileAccess.Read); + sr = new System.IO.StreamReader(file); + string xmlString = sr.ReadToEnd(); + sr.Close(); + file.Close(); + return Deserialize(xmlString); + } + finally { + if ((file != null)) { + file.Dispose(); + } + if ((sr != null)) { + sr.Dispose(); + } + } + } + #endregion + } + [System.CodeDom.Compiler.GeneratedCodeAttribute("Xsd2Code", "3.4.1+6249aec1f88eb051eadc7d928205edd1c2d96aad")] [System.SerializableAttribute()] [System.ComponentModel.DesignerCategoryAttribute("code")] diff --git a/src/SimpleAccounting/Model/AccountingData.xsd b/src/SimpleAccounting/Model/AccountingData.xsd index 01deab64..ad4ff093 100644 --- a/src/SimpleAccounting/Model/AccountingData.xsd +++ b/src/SimpleAccounting/Model/AccountingData.xsd @@ -10,7 +10,15 @@ - + + + + + + + + + diff --git a/src/SimpleAccounting/Presentation/AccountJournalViewModel.cs b/src/SimpleAccounting/Presentation/AccountJournalViewModel.cs index 0281f526..7dc80408 100644 --- a/src/SimpleAccounting/Presentation/AccountJournalViewModel.cs +++ b/src/SimpleAccounting/Presentation/AccountJournalViewModel.cs @@ -36,6 +36,13 @@ public AccountJournalItemViewModel? SelectedItem } } + public string? DisplayCurrency => this.projectData.Storage.Setup.Currency.DisplaySymbol; + + public void RefreshCurrency() + { + this.NotifyOfPropertyChange(nameof(this.DisplayCurrency)); + } + public void Rebuild(ulong accountNumber) { this.Items.Clear(); diff --git a/src/SimpleAccounting/Presentation/CurrencyValueConverter.cs b/src/SimpleAccounting/Presentation/CurrencyValueConverter.cs new file mode 100644 index 00000000..0740e265 --- /dev/null +++ b/src/SimpleAccounting/Presentation/CurrencyValueConverter.cs @@ -0,0 +1,47 @@ +// +// Copyright (c) Lukas Grützmacher. All rights reserved. +// + +namespace lg2de.SimpleAccounting.Presentation; + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Windows.Data; +using lg2de.SimpleAccounting.Extensions; + +/// +/// Formats a value with the optional currency symbol according to the culture. +/// +/// +/// The first value is the value to be formatted. +/// The second value is the currency symbol, or null to format the value without currency. +/// With the parameter , zero values are formatted as empty text. +/// +public class CurrencyValueConverter : IMultiValueConverter +{ + public const string HideZeroParameter = "HideZero"; + + public object Convert(object?[] values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Length == 0 || values[0] is not double value) + { + return string.Empty; + } + + if (value == 0 && HideZeroParameter.Equals(parameter)) + { + return string.Empty; + } + + return values.Length > 1 && values[1] is string { Length: > 0 } currencySymbol + ? value.FormatCurrency(currencySymbol, culture) + : value.ToString("0.00", culture); + } + + [ExcludeFromCodeCoverage(Justification = "This method is not used.")] + public object[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/src/SimpleAccounting/Presentation/EditBookingViewModel.cs b/src/SimpleAccounting/Presentation/EditBookingViewModel.cs index 77fc23b6..c73f417e 100644 --- a/src/SimpleAccounting/Presentation/EditBookingViewModel.cs +++ b/src/SimpleAccounting/Presentation/EditBookingViewModel.cs @@ -37,7 +37,7 @@ public EditBookingViewModel(IProjectData projectData, DateTime date, bool editMo this.BookingIdentifier = this.projectData.MaxBookIdent + 1; this.ValueLabel = string.Format( CultureInfo.CurrentUICulture, - Resources.Label_ValueWithCurrencyX, this.projectData.Storage.Setup.Currency); + Resources.Label_ValueWithCurrencyX, this.projectData.Storage.Setup.Currency.Value); if (this.Date > this.DateEnd) { diff --git a/src/SimpleAccounting/Presentation/FullJournalViewModel.cs b/src/SimpleAccounting/Presentation/FullJournalViewModel.cs index f377a5b4..8a0f5d79 100644 --- a/src/SimpleAccounting/Presentation/FullJournalViewModel.cs +++ b/src/SimpleAccounting/Presentation/FullJournalViewModel.cs @@ -35,6 +35,13 @@ public FullJournalItemViewModel? SelectedItem } } + public string? DisplayCurrency => this.projectData.Storage.Setup.Currency.DisplaySymbol; + + public void RefreshCurrency() + { + this.NotifyOfPropertyChange(nameof(this.DisplayCurrency)); + } + public void Rebuild() { this.Items.Clear(); diff --git a/src/SimpleAccounting/Presentation/IAccountJournalViewModel.cs b/src/SimpleAccounting/Presentation/IAccountJournalViewModel.cs index 59b80bec..02f7119e 100644 --- a/src/SimpleAccounting/Presentation/IAccountJournalViewModel.cs +++ b/src/SimpleAccounting/Presentation/IAccountJournalViewModel.cs @@ -16,6 +16,10 @@ internal interface IAccountJournalViewModel : INotifyPropertyChanged AccountJournalItemViewModel? SelectedItem { get; set; } + string? DisplayCurrency { get; } + + void RefreshCurrency(); + void Rebuild(ulong accountNumber); void Select(ulong bookingId); diff --git a/src/SimpleAccounting/Presentation/IFullJournalViewModel.cs b/src/SimpleAccounting/Presentation/IFullJournalViewModel.cs index 5108e7dc..93dae812 100644 --- a/src/SimpleAccounting/Presentation/IFullJournalViewModel.cs +++ b/src/SimpleAccounting/Presentation/IFullJournalViewModel.cs @@ -15,7 +15,11 @@ internal interface IFullJournalViewModel : INotifyPropertyChanged ObservableCollection Items { get; } FullJournalItemViewModel? SelectedItem { get; set; } - + + string? DisplayCurrency { get; } + + void RefreshCurrency(); + void Rebuild(); void Select(ulong bookingId); diff --git a/src/SimpleAccounting/Presentation/ProjectOptionsView.xaml b/src/SimpleAccounting/Presentation/ProjectOptionsView.xaml index f0ca0f9e..5560385c 100644 --- a/src/SimpleAccounting/Presentation/ProjectOptionsView.xaml +++ b/src/SimpleAccounting/Presentation/ProjectOptionsView.xaml @@ -69,10 +69,17 @@ Grid.Column="0" Grid.Row="2" VerticalAlignment="Center" Text="{x:Static properties:Resources.Word_Currency}" /> - + Orientation="Horizontal"> + + + ( @@ -71,6 +72,8 @@ public ProjectOptionsViewModel(AccountingData data, ProjectOptionsPage page) public string Currency { get; set; } + public bool ShowCurrency { get; set; } + public bool PageBreakBetweenAccounts { get; set; } public ObservableCollection Signatures { get; } @@ -160,9 +163,15 @@ internal bool OnSave() } string currency = this.Currency.Trim(); - if (setup.Currency != currency) + if (setup.Currency.Value != currency) + { + setup.Currency.Value = currency; + changed = true; + } + + if (setup.Currency.Show != this.ShowCurrency) { - setup.Currency = currency; + setup.Currency.Show = this.ShowCurrency; changed = true; } diff --git a/src/SimpleAccounting/Presentation/ShellView.xaml b/src/SimpleAccounting/Presentation/ShellView.xaml index 6e6f8724..e009616b 100644 --- a/src/SimpleAccounting/Presentation/ShellView.xaml +++ b/src/SimpleAccounting/Presentation/ShellView.xaml @@ -17,6 +17,7 @@ + @@ -165,8 +166,16 @@ + SortMemberPath="Value" + ElementStyle="{StaticResource ValueGridStyle}"> + + + + + + + + SortMemberPath="DebitValue" + ElementStyle="{StaticResource ValueGridStyle}"> + + + + + + + + SortMemberPath="CreditValue" + ElementStyle="{StaticResource ValueGridStyle}"> + + + + + + + diff --git a/src/SimpleAccounting/Presentation/ShellViewModel.cs b/src/SimpleAccounting/Presentation/ShellViewModel.cs index ef10f311..cf8f858e 100644 --- a/src/SimpleAccounting/Presentation/ShellViewModel.cs +++ b/src/SimpleAccounting/Presentation/ShellViewModel.cs @@ -72,6 +72,8 @@ public ShellViewModel( { this.Accounts.OnDataLoaded(); this.Menu.OnDataLoaded(); + this.FullJournal.RefreshCurrency(); + this.AccountJournal.RefreshCurrency(); }; this.ProjectData.YearChanged += (_, _) => { @@ -245,6 +247,10 @@ private async Task EditProjectOptionsAsync(ProjectOptionsPage page) // e.g. names of account groups may have been changed this.Accounts.RefreshAccounts(); + + // e.g. the currency shown at the values may have been changed + this.FullJournal.RefreshCurrency(); + this.AccountJournal.RefreshCurrency(); } private async Task CheckForUpdateAfterStartAsync() diff --git a/src/SimpleAccounting/Properties/Resources.Designer.cs b/src/SimpleAccounting/Properties/Resources.Designer.cs index 02190d66..0ca9880e 100644 --- a/src/SimpleAccounting/Properties/Resources.Designer.cs +++ b/src/SimpleAccounting/Properties/Resources.Designer.cs @@ -1011,7 +1011,16 @@ public static string ProjectOptions_RemoveAccountGroup { return ResourceManager.GetString("ProjectOptions_RemoveAccountGroup", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Show currency with values. + /// + public static string ProjectOptions_ShowCurrency { + get { + return ResourceManager.GetString("ProjectOptions_ShowCurrency", resourceCulture); + } + } + /// /// Looks up a localized string similar to Signatures. /// diff --git a/src/SimpleAccounting/Properties/Resources.de.resx b/src/SimpleAccounting/Properties/Resources.de.resx index 8291afd4..ca326841 100644 --- a/src/SimpleAccounting/Properties/Resources.de.resx +++ b/src/SimpleAccounting/Properties/Resources.de.resx @@ -661,6 +661,9 @@ Fehlertext: Seitenumbruch zwischen den Konten + + Währung bei Beträgen anzeigen + Unterschriften diff --git a/src/SimpleAccounting/Properties/Resources.fr.resx b/src/SimpleAccounting/Properties/Resources.fr.resx index 8fb173c5..03b35a9c 100644 --- a/src/SimpleAccounting/Properties/Resources.fr.resx +++ b/src/SimpleAccounting/Properties/Resources.fr.resx @@ -673,6 +673,9 @@ Texte d'erreur: Saut de page entre les comptes + + Afficher la devise avec les montants + Signatures diff --git a/src/SimpleAccounting/Properties/Resources.resx b/src/SimpleAccounting/Properties/Resources.resx index 07235c09..1ea7eb48 100644 --- a/src/SimpleAccounting/Properties/Resources.resx +++ b/src/SimpleAccounting/Properties/Resources.resx @@ -676,6 +676,9 @@ Error message: Page break between accounts + + Show currency with values + Signatures diff --git a/tests/SimpleAccounting.UnitTests/Model/AccountingDataTests.cs b/tests/SimpleAccounting.UnitTests/Model/AccountingDataTests.cs index c8541661..7b1dc6cb 100644 --- a/tests/SimpleAccounting.UnitTests/Model/AccountingDataTests.cs +++ b/tests/SimpleAccounting.UnitTests/Model/AccountingDataTests.cs @@ -100,6 +100,47 @@ public void Deserialize_EmptyAccountGroup_AccountListEmpty() sut.Accounts.Should().BeEquivalentTo([new { Name = "Empty", Account = Array.Empty() }]); } + [Fact] + public void Deserialize_CurrencyWithoutShow_ShowDisabled() + { + var original = new AccountingData(); + string xml = original.Serialize(); + + var sut = AccountingData.Deserialize(xml); + + using var _ = new AssertionScope(); + xml.Should().Contain("€", "the default value is not serialized"); + sut.Setup.Currency.Value.Should().Be("€"); + sut.Setup.Currency.Show.Should().BeFalse(); + } + + [Fact] + public void Deserialize_CurrencyWithShow_ShowEnabled() + { + var original = new AccountingData + { + Setup = { Currency = new AccountingDataSetupCurrency { Value = "$", Show = true } } + }; + string xml = original.Serialize(); + + var sut = AccountingData.Deserialize(xml); + + using var _ = new AssertionScope(); + xml.Should().Contain("$"); + sut.Setup.Currency.Value.Should().Be("$"); + sut.Setup.Currency.Show.Should().BeTrue(); + } + + [Fact] + public void Migrate_EmptyCurrency_DefaultCurrencySet() + { + var sut = new AccountingData { Setup = { Currency = new AccountingDataSetupCurrency { Show = true } } }; + + sut.Migrate().Should().BeTrue(); + + sut.Setup.Currency.Should().BeEquivalentTo(new { Value = "€", Show = true }); + } + [Fact] public void Migrate_EmptyYears_YearsNodeRemoved() { diff --git a/tests/SimpleAccounting.UnitTests/Presentation/AccountJournalViewModelTests.cs b/tests/SimpleAccounting.UnitTests/Presentation/AccountJournalViewModelTests.cs index 41d627fa..3113fe0e 100644 --- a/tests/SimpleAccounting.UnitTests/Presentation/AccountJournalViewModelTests.cs +++ b/tests/SimpleAccounting.UnitTests/Presentation/AccountJournalViewModelTests.cs @@ -65,6 +65,29 @@ public void Rebuild_MultipleBookingsPerDay_OrderedByDateAndIdentifier() }, config: o => o.WithStrictOrdering()); } + [Theory] + [InlineData(false, null)] + [InlineData(true, "$")] + public void DisplayCurrency_ShowCurrencyVariations_ProvidedCorrectly(bool show, string expected) + { + var projectData = Samples.SampleProjectData; + projectData.Storage.Setup.Currency = new AccountingDataSetupCurrency { Value = "$", Show = show }; + var sut = new AccountJournalViewModel(projectData); + + sut.DisplayCurrency.Should().Be(expected); + } + + [Fact] + public void RefreshCurrency_PropertyChangedRaised() + { + var sut = new AccountJournalViewModel(Samples.SampleProjectData); + using var monitor = sut.Monitor(); + + sut.RefreshCurrency(); + + monitor.Should().RaisePropertyChangeFor(x => x.DisplayCurrency); + } + private static AccountingDataJournalBooking CreateBooking( uint date, ulong identifier, int creditCount, int debitCount, bool followup) { diff --git a/tests/SimpleAccounting.UnitTests/Presentation/CurrencyValueConverterTests.cs b/tests/SimpleAccounting.UnitTests/Presentation/CurrencyValueConverterTests.cs new file mode 100644 index 00000000..687f96c3 --- /dev/null +++ b/tests/SimpleAccounting.UnitTests/Presentation/CurrencyValueConverterTests.cs @@ -0,0 +1,66 @@ +// +// Copyright (c) Lukas Grützmacher. All rights reserved. +// + +namespace lg2de.SimpleAccounting.UnitTests.Presentation; + +using System.Globalization; +using lg2de.SimpleAccounting.Presentation; +using Xunit; + +public class CurrencyValueConverterTests +{ + [Theory] + [InlineData("de", 12.5, null, "12,50")] + [InlineData("en", 12.5, null, "12.50")] + [InlineData("de", 1234.5, "€", "1.234,50 €")] + [InlineData("de", -12.5, "€", "-12,50 €")] + [InlineData("en", 12.5, "€", "€12.50")] + [InlineData("en", -12.5, "€", "-€12.50")] + [InlineData("fr", 12.5, "€", "12,50 €")] + [InlineData("de", 12.5, "", "12,50")] + public void Convert_ValueAndCurrency_FormattedByCulture( + string cultureName, double value, string currency, string expected) + { + var sut = new CurrencyValueConverter(); + + var result = sut.Convert( + [value, currency], typeof(string), null, CultureInfo.GetCultureInfo(cultureName)); + + result.Should().Be(expected); + } + + [Theory] + [InlineData(null)] + [InlineData("€")] + public void Convert_ZeroWithHideZero_Empty(string currency) + { + var sut = new CurrencyValueConverter(); + + var result = sut.Convert( + [0.0, currency], typeof(string), CurrencyValueConverter.HideZeroParameter, CultureInfo.InvariantCulture); + + result.Should().Be(string.Empty); + } + + [Fact] + public void Convert_ZeroWithoutHideZero_Formatted() + { + var sut = new CurrencyValueConverter(); + + var result = sut.Convert([0.0, null], typeof(string), null, CultureInfo.InvariantCulture); + + result.Should().Be("0.00"); + } + + [Fact] + public void Convert_ValueNotAvailable_Empty() + { + var sut = new CurrencyValueConverter(); + + var result = sut.Convert( + [System.Windows.DependencyProperty.UnsetValue, "€"], typeof(string), null, CultureInfo.InvariantCulture); + + result.Should().Be(string.Empty); + } +} diff --git a/tests/SimpleAccounting.UnitTests/Presentation/FullJournalViewModelTests.cs b/tests/SimpleAccounting.UnitTests/Presentation/FullJournalViewModelTests.cs index 773cdba0..0128c08c 100644 --- a/tests/SimpleAccounting.UnitTests/Presentation/FullJournalViewModelTests.cs +++ b/tests/SimpleAccounting.UnitTests/Presentation/FullJournalViewModelTests.cs @@ -34,6 +34,29 @@ public void Rebuild_MultipleBookingsPerDay_OrderedByDateAndIdentifier() }, o => o.WithStrictOrdering()); } + [Theory] + [InlineData(false, null)] + [InlineData(true, "$")] + public void DisplayCurrency_ShowCurrencyVariations_ProvidedCorrectly(bool show, string expected) + { + var projectData = Samples.SampleProjectData; + projectData.Storage.Setup.Currency = new AccountingDataSetupCurrency { Value = "$", Show = show }; + var sut = new FullJournalViewModel(projectData); + + sut.DisplayCurrency.Should().Be(expected); + } + + [Fact] + public void RefreshCurrency_PropertyChangedRaised() + { + var sut = new FullJournalViewModel(Samples.SampleProjectData); + using var monitor = sut.Monitor(); + + sut.RefreshCurrency(); + + monitor.Should().RaisePropertyChangeFor(x => x.DisplayCurrency); + } + private static AccountingDataJournalBooking CreateBooking(uint date, ulong identifier) { return new AccountingDataJournalBooking diff --git a/tests/SimpleAccounting.UnitTests/Presentation/ProjectOptionsViewModelTests.cs b/tests/SimpleAccounting.UnitTests/Presentation/ProjectOptionsViewModelTests.cs index 3f613d4e..37a8fabe 100644 --- a/tests/SimpleAccounting.UnitTests/Presentation/ProjectOptionsViewModelTests.cs +++ b/tests/SimpleAccounting.UnitTests/Presentation/ProjectOptionsViewModelTests.cs @@ -24,6 +24,7 @@ private static AccountingData CreateData() { Name = "My Club", Location = "Hometown", + Currency = new AccountingDataSetupCurrency { Value = "€", Show = true }, Reports = { AccountJournalReport = @@ -147,7 +148,8 @@ public void OnSave_Unchanged_ReturnsFalse() var sut = new ProjectOptionsViewModel(data); sut.OnSave().Should().BeFalse(); - data.Setup.Currency.Should().Be("€"); + data.Setup.Currency.Value.Should().Be("€"); + data.Setup.Currency.Show.Should().BeFalse(); } [Fact] @@ -157,7 +159,17 @@ public void OnSave_Changed_ReturnsTrue() var sut = new ProjectOptionsViewModel(data) { Currency = "$" }; sut.OnSave().Should().BeTrue(); - data.Setup.Currency.Should().Be("$"); + data.Setup.Currency.Value.Should().Be("$"); + } + + [Fact] + public void OnSave_ShowCurrencyChanged_ReturnsTrue() + { + var data = new AccountingData(); + var sut = new ProjectOptionsViewModel(data) { ShowCurrency = true }; + + sut.OnSave().Should().BeTrue(); + data.Setup.Currency.Show.Should().BeTrue(); } [Fact] @@ -171,6 +183,7 @@ public void Ctor_ExistingSetup_AllValuesLoaded() sut.Organization.Should().Be("My Club"); sut.Location.Should().Be("Hometown"); sut.Currency.Should().Be("€"); + sut.ShowCurrency.Should().BeTrue(); sut.PageBreakBetweenAccounts.Should().BeTrue(); sut.Signatures.Select(x => x.Text).Should().Equal("Treasurer", "Auditor"); } diff --git a/tests/SimpleAccounting.UnitTests/Presentation/ShellViewModelTests.cs b/tests/SimpleAccounting.UnitTests/Presentation/ShellViewModelTests.cs index 536c2ad2..e3d8b924 100644 --- a/tests/SimpleAccounting.UnitTests/Presentation/ShellViewModelTests.cs +++ b/tests/SimpleAccounting.UnitTests/Presentation/ShellViewModelTests.cs @@ -199,7 +199,7 @@ public async Task OnActivate_SampleProject_JournalsUpdates() project.Journal[^1].Booking.AddRange(Samples.SampleBookings); sut.ProjectData.LoadData(project); - await ((IActivate)sut).ActivateAsync(); + await ((IActivate)sut).ActivateAsync(TestContext.Current.CancellationToken); using var _ = new AssertionScope(); sut.Accounts.AccountList.Select(x => x.Name).Should().Equal( From 78ae6ffdca853cc1c408ae2beffc5728efceeb40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Gr=C3=BCtzmacher?= Date: Sat, 10 Oct 2026 14:23:40 +0200 Subject: [PATCH 2/4] Reports --- .../Reports/AccountJournalReport.cs | 12 +++--- .../Reports/AnnualBalanceReport.cs | 14 +++---- src/SimpleAccounting/Reports/ReportBase.cs | 17 ++++++++ .../Reports/TotalJournalReport.cs | 8 ++-- .../Reports/TotalsAndBalancesReport.cs | 40 +++++++++---------- .../Presentation/AccountsViewModelTests.cs | 5 +-- .../Reports/AccountJournalReportTests.cs | 27 +++++++++++++ .../Reports/AnnualBalanceReportTests.cs | 36 +++++++++++++++-- .../Reports/ReportBaseTests.cs | 35 ++++++++++++++++ .../Reports/TotalJournalReportTests.cs | 18 +++++++++ .../Reports/TotalsAndBalancesReportTests.cs | 32 +++++++++++++-- 11 files changed, 197 insertions(+), 47 deletions(-) diff --git a/src/SimpleAccounting/Reports/AccountJournalReport.cs b/src/SimpleAccounting/Reports/AccountJournalReport.cs index 333e7250..942a868f 100644 --- a/src/SimpleAccounting/Reports/AccountJournalReport.cs +++ b/src/SimpleAccounting/Reports/AccountJournalReport.cs @@ -94,8 +94,8 @@ public void CreateReport() totalLineNode.AddTableNode(Resources.Word_Total).SetAttribute("align", "right"); totalLineNode.AddTableNode(string.Empty); // id totalLineNode.AddTableNode(string.Empty); // text - totalLineNode.AddTableNode(this.debitTotal.FormatCurrency()); - totalLineNode.AddTableNode(this.creditTotal.FormatCurrency()); + totalLineNode.AddTableNode(this.FormatCurrency(this.debitTotal)); + totalLineNode.AddTableNode(this.FormatCurrency(this.creditTotal)); totalLineNode.AddTableNode(string.Empty); // remote } @@ -108,13 +108,13 @@ public void CreateReport() balanceLineNode.AddTableNode(string.Empty); // text if (this.debitTotal > this.creditTotal) { - balanceLineNode.AddTableNode((this.debitTotal - this.creditTotal).FormatCurrency()); + balanceLineNode.AddTableNode(this.FormatCurrency(this.debitTotal - this.creditTotal)); balanceLineNode.AddTableNode(string.Empty); } else { balanceLineNode.AddTableNode(string.Empty); - balanceLineNode.AddTableNode((this.creditTotal - this.debitTotal).FormatCurrency()); + balanceLineNode.AddTableNode(this.FormatCurrency(this.creditTotal - this.debitTotal)); } // empty remote account column @@ -160,7 +160,7 @@ private void AddBookingEntries(AccountingDataJournalBooking entry, ulong account var lineNode = lineTemplate.Clone(); lineNode.AddTableNode(debitEntry.Text); double debitValue = Convert.ToDouble(debitEntry.Value) / 100; - lineNode.AddTableNode(debitValue.FormatCurrency()); + lineNode.AddTableNode(this.FormatCurrency(debitValue)); this.debitTotal += debitValue; lineNode.AddTableNode(string.Empty); lineNode.AddTableNode( @@ -177,7 +177,7 @@ private void AddBookingEntries(AccountingDataJournalBooking entry, ulong account lineNode.AddTableNode(creditEntry.Text); lineNode.AddTableNode(string.Empty); double creditValue = Convert.ToDouble(creditEntry.Value) / 100; - lineNode.AddTableNode(creditValue.FormatCurrency()); + lineNode.AddTableNode(this.FormatCurrency(creditValue)); this.creditTotal += creditValue; lineNode.AddTableNode( entry.Debit.Count == 1 diff --git a/src/SimpleAccounting/Reports/AnnualBalanceReport.cs b/src/SimpleAccounting/Reports/AnnualBalanceReport.cs index 71301c27..4f192457 100644 --- a/src/SimpleAccounting/Reports/AnnualBalanceReport.cs +++ b/src/SimpleAccounting/Reports/AnnualBalanceReport.cs @@ -39,7 +39,7 @@ public void CreateReport() this.ProcessExpenses(out var totalExpense); var balanceNode = this.PrintDocument.SelectSingleNode("//text[@ID=\"balance\"]")!; - balanceNode.InnerText = (totalIncome + totalExpense).FormatCurrency(); + balanceNode.InnerText = this.FormatCurrency(totalIncome + totalExpense); // receivables / Forderungen // liabilities / Verbindlichkeiten @@ -69,7 +69,7 @@ private void ProcessIncome(out long totalIncome) } var balanceElement = dataNode.SelectSingleNode(FourthColumnExpression)!; - balanceElement.InnerText = totalIncome.FormatCurrency(); + balanceElement.InnerText = this.FormatCurrency(totalIncome); } private void ProcessExpenses(out long totalExpense) @@ -92,7 +92,7 @@ private void ProcessExpenses(out long totalExpense) } var balanceElement = dataNode.SelectSingleNode(FourthColumnExpression)!; - balanceElement.InnerText = totalExpense.FormatCurrency(); + balanceElement.InnerText = this.FormatCurrency(totalExpense); } private void ProcessReceivablesAndLiabilities(out long totalReceivable, out long totalLiability) @@ -126,9 +126,9 @@ private void ProcessReceivablesAndLiabilities(out long totalReceivable, out long } var balanceElement = receivableNode.SelectSingleNode(FourthColumnExpression)!; - balanceElement.InnerText = totalReceivable.FormatCurrency(); + balanceElement.InnerText = this.FormatCurrency(totalReceivable); balanceElement = liabilityNode.SelectSingleNode(FourthColumnExpression)!; - balanceElement.InnerText = totalLiability.FormatCurrency(); + balanceElement.InnerText = this.FormatCurrency(totalLiability); } private void ProcessAssets(long totalReceivable, long totalLiability) @@ -162,7 +162,7 @@ private void ProcessAssets(long totalReceivable, long totalLiability) } var balanceElement = dataNode.SelectSingleNode(FourthColumnExpression)!; - balanceElement.InnerText = totalAccount.FormatCurrency(); + balanceElement.InnerText = this.FormatCurrency(totalAccount); } private long GetAccountBalance(AccountDefinition account, bool creditFromDebit) @@ -201,7 +201,7 @@ private XmlNode CreateBalanceNode(string balanceText, long balance) dataLineNode.AppendChild(dataItemNode); dataItemNode = dataItemNode.Clone(); - dataItemNode.InnerText = balance.FormatCurrency(); + dataItemNode.InnerText = this.FormatCurrency(balance); dataLineNode.AppendChild(dataItemNode); return dataLineNode; diff --git a/src/SimpleAccounting/Reports/ReportBase.cs b/src/SimpleAccounting/Reports/ReportBase.cs index 75fbfcd0..ebba42d1 100644 --- a/src/SimpleAccounting/Reports/ReportBase.cs +++ b/src/SimpleAccounting/Reports/ReportBase.cs @@ -58,6 +58,23 @@ protected void PreparePrintDocument() "CurrentDate", this.setup.Location + ", " + this.printingDate.ToString("D", CultureInfo.CurrentCulture)); } + /// + /// Formats the specified value with the currency symbol, if configured to be shown. + /// + protected string FormatCurrency(double value) + { + string? currencySymbol = this.setup.Currency.DisplaySymbol; + return currencySymbol == null + ? value.FormatCurrency() + : value.FormatCurrency(currencySymbol, CultureInfo.CurrentCulture); + } + + /// + protected string FormatCurrency(long value) + { + return this.FormatCurrency(value.ToViewModel()); + } + protected void UpdatePlaceholder(string name, string value) { string placeholder = $"#{name}#"; diff --git a/src/SimpleAccounting/Reports/TotalJournalReport.cs b/src/SimpleAccounting/Reports/TotalJournalReport.cs index accc280e..12369dc4 100644 --- a/src/SimpleAccounting/Reports/TotalJournalReport.cs +++ b/src/SimpleAccounting/Reports/TotalJournalReport.cs @@ -48,10 +48,10 @@ public void CreateReport() dataLineNode.AddTableNode(debit.Text); string strAccountNumber = debit.Account.ToString(CultureInfo.InvariantCulture); dataLineNode.AddTableNode(strAccountNumber); - dataLineNode.AddTableNode(debit.Value.FormatCurrency()); + dataLineNode.AddTableNode(this.FormatCurrency(debit.Value)); strAccountNumber = credit.Account.ToString(CultureInfo.InvariantCulture); dataLineNode.AddTableNode(strAccountNumber); - dataLineNode.AddTableNode(credit.Value.FormatCurrency()); + dataLineNode.AddTableNode(this.FormatCurrency(credit.Value)); dataNode.AppendChild(dataLineNode); continue; } @@ -61,7 +61,7 @@ public void CreateReport() dataLineNode.AddTableNode(debitEntry.Text); string strAccountNumber = debitEntry.Account.ToString(CultureInfo.InvariantCulture); dataLineNode.AddTableNode(strAccountNumber); - dataLineNode.AddTableNode(debitEntry.Value.FormatCurrency()); + dataLineNode.AddTableNode(this.FormatCurrency(debitEntry.Value)); dataLineNode.AddTableNode(string.Empty); dataLineNode.AddTableNode(string.Empty); dataNode.AppendChild(dataLineNode); @@ -78,7 +78,7 @@ public void CreateReport() dataLineNode.AddTableNode(string.Empty); string strAccountNumber = creditEntry.Account.ToString(CultureInfo.InvariantCulture); dataLineNode.AddTableNode(strAccountNumber); - dataLineNode.AddTableNode(creditEntry.Value.FormatCurrency()); + dataLineNode.AddTableNode(this.FormatCurrency(creditEntry.Value)); dataNode.AppendChild(dataLineNode); dataLineNode = this.PrintDocument.CreateElement("tr"); diff --git a/src/SimpleAccounting/Reports/TotalsAndBalancesReport.cs b/src/SimpleAccounting/Reports/TotalsAndBalancesReport.cs index de78d4fc..006a4ba7 100644 --- a/src/SimpleAccounting/Reports/TotalsAndBalancesReport.cs +++ b/src/SimpleAccounting/Reports/TotalsAndBalancesReport.cs @@ -96,14 +96,14 @@ public void CreateReport(string reportName) groupLineNode.AddTableNode(string.Empty); - groupLineNode.AddTableNode(FormatValue(this.groupOpeningDebit)); - groupLineNode.AddTableNode(FormatValue(this.groupOpeningCredit)); + groupLineNode.AddTableNode(this.FormatValue(this.groupOpeningDebit)); + groupLineNode.AddTableNode(this.FormatValue(this.groupOpeningCredit)); - groupLineNode.AddTableNode(FormatValue(this.groupTotalDebit)); - groupLineNode.AddTableNode(FormatValue(this.groupTotalCredit)); + groupLineNode.AddTableNode(this.FormatValue(this.groupTotalDebit)); + groupLineNode.AddTableNode(this.FormatValue(this.groupTotalCredit)); - groupLineNode.AddTableNode(FormatValue(this.groupBalanceDebit)); - groupLineNode.AddTableNode(FormatValue(this.groupBalanceCredit)); + groupLineNode.AddTableNode(this.FormatValue(this.groupBalanceDebit)); + groupLineNode.AddTableNode(this.FormatValue(this.groupBalanceCredit)); dataNode.AppendChild(groupLineNode); } @@ -117,14 +117,14 @@ public void CreateReport(string reportName) totalLineNode.AddTableNode(string.Empty); - totalLineNode.AddTableNode(FormatValue(this.overallOpeningDebit)); - totalLineNode.AddTableNode(FormatValue(this.overallOpeningCredit)); + totalLineNode.AddTableNode(this.FormatValue(this.overallOpeningDebit)); + totalLineNode.AddTableNode(this.FormatValue(this.overallOpeningCredit)); - totalLineNode.AddTableNode(FormatValue(this.overallTotalDebit)); - totalLineNode.AddTableNode(FormatValue(this.overallTotalCredit)); + totalLineNode.AddTableNode(this.FormatValue(this.overallTotalDebit)); + totalLineNode.AddTableNode(this.FormatValue(this.overallTotalCredit)); - totalLineNode.AddTableNode(FormatValue(this.overallBalanceDebit)); - totalLineNode.AddTableNode(FormatValue(this.overallBalanceCredit)); + totalLineNode.AddTableNode(this.FormatValue(this.overallBalanceDebit)); + totalLineNode.AddTableNode(this.FormatValue(this.overallBalanceCredit)); dataNode.AppendChild(totalLineNode); @@ -148,14 +148,14 @@ public void CreateReport(string reportName) signatures.ParentNode!.RemoveChild(signatures); } - private static string FormatValue(long value) + private string FormatValue(long value) { if (value <= 0) { return string.Empty; } - return value.FormatCurrency(); + return this.FormatCurrency(value); } private void ProcessAccount(XmlNode dataNode, AccountDefinition account) @@ -226,14 +226,14 @@ private void ProcessAccount(XmlNode dataNode, AccountDefinition account) dataLineNode.AddTableNode(lastBookingDate.ToDateTime().ToString("d", CultureInfo.CurrentCulture)); - dataLineNode.AddTableNode(FormatValue(openingDebit)); - dataLineNode.AddTableNode(FormatValue(openingCredit)); + dataLineNode.AddTableNode(this.FormatValue(openingDebit)); + dataLineNode.AddTableNode(this.FormatValue(openingCredit)); - dataLineNode.AddTableNode(FormatValue(totalDebit)); - dataLineNode.AddTableNode(FormatValue(totalCredit)); + dataLineNode.AddTableNode(this.FormatValue(totalDebit)); + dataLineNode.AddTableNode(this.FormatValue(totalCredit)); - dataLineNode.AddTableNode(FormatValue(balanceDebit)); - dataLineNode.AddTableNode(FormatValue(balanceCredit)); + dataLineNode.AddTableNode(this.FormatValue(balanceDebit)); + dataLineNode.AddTableNode(this.FormatValue(balanceCredit)); dataNode.AppendChild(dataLineNode); diff --git a/tests/SimpleAccounting.UnitTests/Presentation/AccountsViewModelTests.cs b/tests/SimpleAccounting.UnitTests/Presentation/AccountsViewModelTests.cs index abd0c17e..62bce232 100644 --- a/tests/SimpleAccounting.UnitTests/Presentation/AccountsViewModelTests.cs +++ b/tests/SimpleAccounting.UnitTests/Presentation/AccountsViewModelTests.cs @@ -142,9 +142,8 @@ public async Task OnEditAccount_ImportPatternsConfigured_ImportPatternsBuilt() accountViewModel.ImportRemoteAccounts.Should().BeEmpty(); await sut.OnEditAccountAsync(accountViewModel); -#pragma warning disable FAA0001 // false-positive - updatedViewModel?.ImportRemoteAccounts.Should().BeEquivalentTo([new { ID = 2 }]); -#pragma warning restore FAA0001 + updatedViewModel.Should().NotBeNull(); + updatedViewModel.ImportRemoteAccounts.Should().BeEquivalentTo([new { ID = 2 }]); } [Fact] diff --git a/tests/SimpleAccounting.UnitTests/Reports/AccountJournalReportTests.cs b/tests/SimpleAccounting.UnitTests/Reports/AccountJournalReportTests.cs index adad4319..e8c045df 100644 --- a/tests/SimpleAccounting.UnitTests/Reports/AccountJournalReportTests.cs +++ b/tests/SimpleAccounting.UnitTests/Reports/AccountJournalReportTests.cs @@ -10,6 +10,7 @@ namespace lg2de.SimpleAccounting.UnitTests.Reports; using System.Xml.Linq; using System.Xml.XPath; using lg2de.SimpleAccounting.Abstractions; +using lg2de.SimpleAccounting.Model; using lg2de.SimpleAccounting.Reports; using lg2de.SimpleAccounting.UnitTests.Presentation; using NSubstitute; @@ -312,4 +313,30 @@ public void CreateReport_SampleData_Converted(bool pageBreakBetweenAccounts) "friends debit table should match"); } } + + [CulturedFact(["de"])] + public void CreateReport_CurrencyShown_ValuesWithCurrency() + { + var projectData = Samples.SampleProjectData; + projectData.Storage.Setup.Currency = new AccountingDataSetupCurrency { Value = "€", Show = true }; + projectData.CurrentYear.Booking.AddRange(Samples.SampleBookings); + var clock = Substitute.For(); + var sut = new AccountJournalReport(new XmlPrinter(), projectData, clock); + + sut.CreateReport(); + + // bank account: debit and credit values of bookings, total and balance + var bankAccountValues = sut.DocumentForTests.XPathSelectElements("//table/data").First() + .Elements("tr") + .Select(x => x.Elements("td").ToArray()) + .Select(x => (Debit: x[3].Value, Credit: x[4].Value)); + bankAccountValues.Should().Equal( + ("1.000,00 €", ""), + ("200,00 €", ""), + ("", "400,00 €"), + ("", "50,00 €"), + ("", "99,00 €"), + ("1.200,00 €", "549,00 €"), + ("651,00 €", "")); + } } diff --git a/tests/SimpleAccounting.UnitTests/Reports/AnnualBalanceReportTests.cs b/tests/SimpleAccounting.UnitTests/Reports/AnnualBalanceReportTests.cs index 12715193..0746940a 100644 --- a/tests/SimpleAccounting.UnitTests/Reports/AnnualBalanceReportTests.cs +++ b/tests/SimpleAccounting.UnitTests/Reports/AnnualBalanceReportTests.cs @@ -4,9 +4,11 @@ namespace lg2de.SimpleAccounting.UnitTests.Reports; +using System.Linq; using System.Xml.Linq; using System.Xml.XPath; using lg2de.SimpleAccounting.Abstractions; +using lg2de.SimpleAccounting.Model; using lg2de.SimpleAccounting.Reports; using lg2de.SimpleAccounting.UnitTests.Presentation; using NSubstitute; @@ -26,9 +28,7 @@ public void CreateReport_SampleData_Converted() using var _ = new AssertionScope(); -#pragma warning disable FAA0001 // false-positive - sut.DocumentForTests.XPathSelectElement("//text[@ID='saldo']")?.Value.Should().Be("150.00"); -#pragma warning restore FAA0001 + sut.DocumentForTests.XPathSelectElement("//text[@ID='balance']")!.Value.Should().Be("150.00"); var expected = @" @@ -95,4 +95,34 @@ public void CreateReport_SampleData_Converted() sut.DocumentForTests.XPathSelectElement("//table/data[@target='asset']") .Should().BeEquivalentTo(XDocument.Parse(expected).Root); } + + [CulturedFact(["de"])] + public void CreateReport_CurrencyShown_ValuesWithCurrency() + { + var projectData = Samples.SampleProjectData; + projectData.Storage.Setup.Currency = new AccountingDataSetupCurrency { Value = "€", Show = true }; + projectData.CurrentYear.Booking.AddRange(Samples.SampleBookings); + var clock = Substitute.For(); + var sut = new AnnualBalanceReport(new XmlPrinter(), projectData, clock); + + sut.CreateReport(); + + using var _ = new AssertionScope(); + + sut.DocumentForTests.XPathSelectElement("//text[@ID='balance']")!.Value.Should().Be("150,00 €"); + + // The value is the last cell of the data rows. + string[] GetValues(string target) => + sut.DocumentForTests.XPathSelectElements($"//table/data[@target='{target}']/tr/td[last()]") + .Select(x => x.Value).ToArray(); + GetValues("income").Should().Equal("200,00 €"); + GetValues("expense").Should().Equal("-50,00 €"); + GetValues("receivable").Should().Equal("99,00 €"); + GetValues("liability").Should().Equal("-2.600,00 €"); + GetValues("asset").Should().Equal("651,00 €", "99,00 €", "-2.600,00 €"); + + // The totals are written into the last column header of each table. + sut.DocumentForTests.XPathSelectElements("//table/columns/column[position()=4]") + .Select(x => x.Value).Should().Equal("200,00 €", "-50,00 €", "99,00 €", "-2.600,00 €", "-1.850,00 €"); + } } diff --git a/tests/SimpleAccounting.UnitTests/Reports/ReportBaseTests.cs b/tests/SimpleAccounting.UnitTests/Reports/ReportBaseTests.cs index e6d9017a..c90282bb 100644 --- a/tests/SimpleAccounting.UnitTests/Reports/ReportBaseTests.cs +++ b/tests/SimpleAccounting.UnitTests/Reports/ReportBaseTests.cs @@ -29,6 +29,41 @@ public TestReport(IXmlPrinter printer, IProjectData projectData, IClock clock) { base.PreparePrintDocument(); } + + public new string FormatCurrency(long value) + { + return base.FormatCurrency(value); + } + } + + [CulturedFact(["en"])] + public void FormatCurrency_CurrencyHidden_ValueOnly() + { + var projectData = Samples.SampleProjectData; + projectData.Storage.Setup.Currency = new AccountingDataSetupCurrency { Value = "€", Show = false }; + var sut = new TestReport(Substitute.For(), projectData, Substitute.For()); + + sut.FormatCurrency(123456).Should().Be("1234.56"); + } + + [CulturedFact(["en"])] + public void FormatCurrency_CurrencyShownEnglish_SymbolBeforeValue() + { + var projectData = Samples.SampleProjectData; + projectData.Storage.Setup.Currency = new AccountingDataSetupCurrency { Value = "€", Show = true }; + var sut = new TestReport(Substitute.For(), projectData, Substitute.For()); + + sut.FormatCurrency(123456).Should().Be("€1,234.56"); + } + + [CulturedFact(["de"])] + public void FormatCurrency_CurrencyShownGerman_SymbolAfterValue() + { + var projectData = Samples.SampleProjectData; + projectData.Storage.Setup.Currency = new AccountingDataSetupCurrency { Value = "€", Show = true }; + var sut = new TestReport(Substitute.For(), projectData, Substitute.For()); + + sut.FormatCurrency(-123456).Should().Be("-1.234,56 €"); } [CulturedFact(["en"])] diff --git a/tests/SimpleAccounting.UnitTests/Reports/TotalJournalReportTests.cs b/tests/SimpleAccounting.UnitTests/Reports/TotalJournalReportTests.cs index 9ffd6ad1..2c2e8af0 100644 --- a/tests/SimpleAccounting.UnitTests/Reports/TotalJournalReportTests.cs +++ b/tests/SimpleAccounting.UnitTests/Reports/TotalJournalReportTests.cs @@ -4,9 +4,11 @@ namespace lg2de.SimpleAccounting.UnitTests.Reports; +using System.Linq; using System.Xml.Linq; using System.Xml.XPath; using lg2de.SimpleAccounting.Abstractions; +using lg2de.SimpleAccounting.Model; using lg2de.SimpleAccounting.Reports; using lg2de.SimpleAccounting.UnitTests.Presentation; using NSubstitute; @@ -133,4 +135,20 @@ public void CreateReport_SampleData_Converted() sut.DocumentForTests.XPathSelectElement("//table/data") .Should().BeEquivalentTo(XDocument.Parse(expected).Root); } + + [CulturedFact(["de"])] + public void CreateReport_CurrencyShown_ValuesWithCurrency() + { + var projectData = Samples.SampleProjectData; + projectData.Storage.Setup.Currency = new AccountingDataSetupCurrency { Value = "€", Show = true }; + projectData.CurrentYear.Booking.AddRange(Samples.SampleBookings); + var clock = Substitute.For(); + var sut = new TotalJournalReport(new XmlPrinter(), projectData, clock); + + sut.CreateReport(); + + var firstLine = sut.DocumentForTests.XPathSelectElement("//table/data/tr")!; + firstLine.Elements("td").Select(x => x.Value).Should().HaveElementAt(4, "1.000,00 €") + .And.HaveElementAt(6, "1.000,00 €"); + } } diff --git a/tests/SimpleAccounting.UnitTests/Reports/TotalsAndBalancesReportTests.cs b/tests/SimpleAccounting.UnitTests/Reports/TotalsAndBalancesReportTests.cs index 26e026ff..b1366e3f 100644 --- a/tests/SimpleAccounting.UnitTests/Reports/TotalsAndBalancesReportTests.cs +++ b/tests/SimpleAccounting.UnitTests/Reports/TotalsAndBalancesReportTests.cs @@ -9,6 +9,7 @@ namespace lg2de.SimpleAccounting.UnitTests.Reports; using System.Xml.Linq; using System.Xml.XPath; using lg2de.SimpleAccounting.Abstractions; +using lg2de.SimpleAccounting.Model; using lg2de.SimpleAccounting.Reports; using lg2de.SimpleAccounting.UnitTests.Presentation; using NSubstitute; @@ -146,6 +147,31 @@ public void CreateReport_SampleData_Converted() .Should().BeEquivalentTo(XDocument.Parse(expected).Root); } + [CulturedFact(["de"])] + public void CreateReport_CurrencyShown_ValuesWithCurrency() + { + var projectData = Samples.SampleProjectData; + projectData.Storage.Setup.Currency = new AccountingDataSetupCurrency { Value = "€", Show = true }; + projectData.CurrentYear.Booking.AddRange(Samples.SampleBookings); + var clock = Substitute.For(); + var sut = new TotalsAndBalancesReport( + new XmlPrinter(), projectData, clock, projectData.Storage.Accounts); + + sut.CreateReport("-TotalsAndBalances-"); + + // The value columns start at the fourth cell. Zero values stay empty. + var rows = sut.DocumentForTests.XPathSelectElements("//table/data/tr") + .Select(x => x.Elements("td").Skip(3).Select(y => y.Value).ToArray()) + .ToArray(); + using var _ = new AssertionScope(); + rows[0].Should().Equal( + ["1.000,00 €", "", "200,00 €", "549,00 €", "651,00 €", ""], "bank account"); + rows[4].Should().Equal( + ["3.000,00 €", "", "250,00 €", "749,00 €", "2.701,00 €", "200,00 €"], "group total"); + rows[^1].Should().Equal( + ["3.000,00 €", "3.000,00 €", "749,00 €", "749,00 €", "2.800,00 €", "2.800,00 €"], "overall total"); + } + [Fact] public void CreateReport_SampleWithSignature_SignatureLinesCreated() { @@ -173,11 +199,9 @@ public void CreateReport_ReportNames_NameAppliedToHeaderAndTitle(string reportNa sut.CreateReport(reportName); -#pragma warning disable FAA0001 // false-positive var expectedText = $"- {reportName} {DateTime.Today.Year} -"; - sut.DocumentForTests.XPathSelectElement("//text[@ID='page-header']")?.Value.Should().Be(expectedText); + sut.DocumentForTests.XPathSelectElement("//text[@ID='page-header']")!.Value.Should().Be(expectedText); expectedText = $"{reportName} {DateTime.Today.Year}"; - sut.DocumentForTests.XPathSelectElement("//text[@ID='report-header']")?.Value.Should().Be(expectedText); -#pragma warning restore FAA0001 + sut.DocumentForTests.XPathSelectElement("//text[@ID='report-header']")!.Value.Should().Be(expectedText); } } From a2fa37cf35120a284e071e50613349b63a82cf43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Gr=C3=BCtzmacher?= Date: Sat, 10 Oct 2026 14:26:56 +0200 Subject: [PATCH 3/4] Update sample --- samples/sample.acml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/samples/sample.acml b/samples/sample.acml index 01012c0d..1339c2f7 100644 --- a/samples/sample.acml +++ b/samples/sample.acml @@ -3,7 +3,7 @@ Demo World - € + € @@ -26,17 +26,17 @@ - - + + - + - + From 3ebdc89b5c740cf32c9390b3503522572aa674b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Gr=C3=BCtzmacher?= Date: Sat, 10 Oct 2026 14:33:17 +0200 Subject: [PATCH 4/4] fix SQ --- .../Presentation/CurrencyValueConverter.cs | 8 +++++++- src/SimpleAccounting/Reports/AnnualBalanceReport.cs | 1 - 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/SimpleAccounting/Presentation/CurrencyValueConverter.cs b/src/SimpleAccounting/Presentation/CurrencyValueConverter.cs index 0740e265..b33a47ba 100644 --- a/src/SimpleAccounting/Presentation/CurrencyValueConverter.cs +++ b/src/SimpleAccounting/Presentation/CurrencyValueConverter.cs @@ -20,17 +20,23 @@ namespace lg2de.SimpleAccounting.Presentation; /// public class CurrencyValueConverter : IMultiValueConverter { - public const string HideZeroParameter = "HideZero"; + public static string HideZeroParameter => "HideZero"; public object Convert(object?[] values, Type targetType, object? parameter, CultureInfo culture) { + ArgumentNullException.ThrowIfNull(values); + if (values.Length == 0 || values[0] is not double value) { + // The value is not available yet, e.g. DependencyProperty.UnsetValue while the binding is initialized. + // An empty cell is shown instead of failing the binding. return string.Empty; } if (value == 0 && HideZeroParameter.Equals(parameter)) { + // Each row of the account journal has either a debit or a credit value. + // Zero means "no value on this side", so the cell is left empty (like the former format "0.00;;#"). return string.Empty; } diff --git a/src/SimpleAccounting/Reports/AnnualBalanceReport.cs b/src/SimpleAccounting/Reports/AnnualBalanceReport.cs index 4f192457..d1a1d7c0 100644 --- a/src/SimpleAccounting/Reports/AnnualBalanceReport.cs +++ b/src/SimpleAccounting/Reports/AnnualBalanceReport.cs @@ -10,7 +10,6 @@ namespace lg2de.SimpleAccounting.Reports; using System.Linq; using System.Xml; using lg2de.SimpleAccounting.Abstractions; -using lg2de.SimpleAccounting.Extensions; using lg2de.SimpleAccounting.Model; using lg2de.SimpleAccounting.Properties;