Jak reprezentować kwoty pieniędzy w kodzie aplikacji?

Problem chyba tak stary jak stare są aplikacje liczące pieniądze – jak reprezentować kasę w kodzie aplikacji? W świecie .NET jedną z częściej pojawiających się odpowiedzi jest po prostu decimal jednak tak naprawdę jest to tylko połowa odpowiedzi. W każdym razie jeśli chcielibyśmy to zrobić w duchu OOP i OOD. No bo przecież co to znaczy na przykład decimal amountToPay = 1000M;? Albo decimal convertedAmount = amountToPay * 1.3;?

Kwotę pieniędzy można porównać do innych wartości określających ilość, np. 1kg, 5s, 2cm. Wartości takie składają się z dwóch części – liczby i jednostki. W przypadku pieniędzy jednostką jest waluta. Ogólnym rozwiązaniem tego problemu jest wzorzec Quantity, a w przypadku pieniędzy jego szczególna wersja – Money, która została bardzo dobrze opisana przez Martina Fowlera w tym artykule.

Quantity

Idea wzorca Quantity jest prosta – jest to klasa, która łączy ze sobą wartość i jednostkę i oferuje nam spójny interfejs pozwalający na wykonywanie operacji w sposób analogiczny jak dla innych typów liczbowych. Jednocześnie dużą zaletą takiego podejścia jest ukrycie wszystkich szczegółów implementacji (enkapsulacja) – możemy wybrać typ danych używany do reprezentowania wartości (decimal niekoniecznie musi być jedynym słusznym wyborem) lub jednostki i ewentualnie łatwo go zmienić oraz możemy ograniczyć dostępne operacje tylko do takich jakie mają sens. Na przykład w przypadku typu Money nie ma sensu udostępniać operacji mnożenia przez inną wartość typu Money (PLN^2? komu to potrzebne?) ale już mnożenie przez wartość skalarną może być przydatne gdybyśmy chcieli konwertować kwotę do innej waluty z zadanym kursem wymiany.

W Internecie można znaleźć różne przykłady implementacji wzorca Money dla .NET, np. pakiet Money do pobrania z nugeta. W zasadzie nie powinienem wynajdywać koła na nowo i na potrzeby MoneyStorm skorzystać z gotowej biblioteki jednak te, które sprawdziłem oferują dużo więcej niż jest mi faktycznie potrzebne. Poza tym w końcu cel projektu jest przede wszystkim edukacyjny dlatego też pomyślałem, że napisanie takiej implementacji samemu będzie ciekawym ćwiczeniem. No i z drugiej strony nie jest to aż tak bardzo skomplikowane a zawsze to jakiś temat na blogowy post 🙂

Na początek potrzebna jest mi struktura pozwalająca na przechowywanie wartości liczbowej i waluty – struktura ta jest w zasadzie przykładem Value Object (innymi słowy, prosty obiekt, którego porównanie nie opiera się na identity tylko na wartości, którą przechowuje) i jako taka jest idealnym kandydatem do bycia immutable (niezmiennym? chyba pewnych określeń lepiej nie tłumaczyć na język polski… może trzeba będzie pomyśleć o pisaniu postów po angielsku?).

public struct Money
{
	public decimal Amount { get; }
	public Currency Currency { get; }

	public Money(decimal amount, Currency currency)
	{
		if (currency == null)
		{
			throw new ArgumentNullException(nameof(currency));
		}

		Amount = amount;
		Currency = currency;
	}
}

Słowo wyjaśnienia należy się typowi Currency – na chwilę obecną zdecydowałem, że będzie to prosty wrapper na String przechowujący trzyliterowy kod waluty + kilka factory methods zwracających obiekt konkretnej waluty. Na razie nie potrzebuję niczego więcej ale taka konstrukcja jest o tyle lepsza od używania bezpośrednio String, że daje mi fundament do wprowadzania dalszych modyfikacji, np. gdybym chciał dodać jakiś opis waluty, zmienić sposób konwertowania do stringa lub tworzyć obiekty przez podanie kodu numerycznego definiowanego przez standard ISO 4217. Poza tym tak jest bardziej OOP 🙂

public struct Currency
{
	public static Currency PLN => new Currency("PLN");
	public static Currency EUR => new Currency("EUR");

	private string Code { get; }

	public Currency(string code)
	{
		Code = code;
	}

	public override bool Equals(object obj)
	{
		if (!(obj is Currency))
		{
			return false;
		}
		return Code.Equals(((Currency)obj).Code);
	}

	public override int GetHashCode()
	{
		return Code.GetHashCode();
	}

	public override string ToString()
	{
		return Code;
	}

	public static bool operator ==(Currency c1, Currency c2)
	{
		return c1.Code == c2.Code;
	}

	public static bool operator !=(Currency c1, Currency c2)
	{
		return !(c1 == c2);
	}
}

Kolejną rzeczą jaka będzie mi potrzebna jest możliwość dodawania i odejmowania pieniędzy. Na tę chwilę zakładam, że operacje te nie będą możliwe do wykonania na kwotach wyrażonych w różnych walutach – dzięki takiemu ograniczeniu implementacja jest bardzo prosta.

public static Money operator +(Money m1, Money m2)
{
	return m1.Add(m2);
}

public static Money operator -(Money m1, Money m2)
{
	return m1.Subtract(m2);
}

private Money Add(Money arg)
{
	EnsureTheSameCurrency(this, arg);
	return new Money(Amount + arg.Amount, Currency);
}

private Money Subtract(Money arg)
{
	EnsureTheSameCurrency(this, arg);
	return new Money(Amount - arg.Amount, Currency);
}

private static void EnsureTheSameCurrency(Money arg1, Money arg2)
{
	if (arg1.Currency != arg2.Currency)
	{
		throw new InvalidOperationException("Can not operate on amounts with different currencies.");
	}
}

Dobrze byłoby również móc porównywać ze sobą różne kwoty – w tym celu typ Money będzie implementował interfejsy IEquatable<Money> i IComparable<Money> oraz zostanie nadpisana metoda bool Equals(object obj) (i co za tym idzie int GetHashCode()). Dodatkowo, żeby ułatwić korzystanie z tego typu, dodam implementację odpowiednich operatorów porównania.

public override bool Equals(object obj)
{
	if (!(obj is Money))
	{
		return false;
	}

	return this == (Money) obj;
}

public override int GetHashCode()
{
	return Amount.GetHashCode();
}

public bool Equals(Money other)
{
	return this == other;
}

public int CompareTo(Money other)
{
	EnsureTheSameCurrency(this, other);
	return Amount.CompareTo(other.Amount);
}

public static bool operator ==(Money m1, Money m2)
{
	return m1.Currency == m2.Currency && m1.Amount == m2.Amount;
}

public static bool operator !=(Money m1, Money m2)
{
	return !(m1 == m2);
}

public static bool operator <(Money m1, Money m2)
{
	EnsureTheSameCurrency(m1, m2);
	return m1.Amount < m2.Amount;
}

public static bool operator >(Money m1, Money m2)
{
	EnsureTheSameCurrency(m1, m2);
	return m1.Amount > m2.Amount;
}

public static bool operator <=(Money m1, Money m2)
{
	EnsureTheSameCurrency(m1, m2);
	return m1.Amount <= m2.Amount;
}

public static bool operator >=(Money m1, Money m2)
{
	EnsureTheSameCurrency(m1, m2);
	return m1.Amount >= m2.Amount;
}

Ostatnią rzeczą jaka została do zrobienia jest nadpisanie metody string ToString() tak aby zwracała ładnie sformatowaną wartość razem z walutą. W tym momencie implementacja będzie bardzo prosta i naiwna jednak w przyszłości będę musiał ją dostosować tak, aby wartość była formatowana zgodnie z ustawieniami regionalnymi właściwymi dla danej waluty.

public override string ToString()
{
	return $"{Amount} {Currency}";
}

W tym momencie mam wszystko co jest mi na razie potrzebne jeśli chodzi o reprezentację forsy w mojej aplikacji – typ Money będzie z pewnością kluczowym typem, z którego będę korzystał. Pełen kod źródłowy można znaleźć na GitHubie.

  • Pingback: dotnetomaniak.pl()

  • ŁB

    Co prawda są to niewielkie elementy i pewnie to, co podrzucę to przykład
    over-engineeringu (tak, pewnych określeń lepiej nie tłumaczyć na polski
    😉 ), ale Eric Lippert swego czasu wspominał, w jaki sposób
    implementować CompareTo i operatory porównania tak, żeby zdefiniować
    logikę w jednym miejscu. Może uznasz to za przydatne 🙂

    http://ericlippert.com/2013/10/07/math-from-scratch-part-six-comparisons/

    • Dzięki za linka – ciekawe informacje. Może faktycznie będę musiał rozważyć mały refaktoring klasy Money 🙂

  • Dorzuciłbym jeszcze jeden temat, a mianowicie sposób zaokrąglania 🙂 Otóż .NET domyślnie zaokrągla kwoty w sposób bankowy. Co może niektórych zaskoczyć, gdy np. zaokrąglenia dla klienta po stronie JS są zawsze robione w sposób matematyczny, a zaokrąglenia po stronie serwera wykorzystują algorytm finansowy/bankowy 🙂

  • Jarosław Wasilewski

    Brakuje jeszcze obsługi formatowania na poziomie konkretnej waluty. Inaczej wyświetla się kwoty w dolarach, czy euro ($2.75), a inaczej w złotówkach (2,75 zł). Wydaje mi się, ze Currency należy rozszerzyć to listę formatów dla poszczególnych walut.

    • Zgadza się, słuszna uwaga – mam to na liście todo, może nawet w jakimś kolejnym poście opiszę jak to zrobić z wykorzystaniem klasy RegionInfo 🙂 Formatowanie samej wartości też trzeba poprawić bo na razie nie działa tak jak powinno (za dużo miejsc po przecinku).

  • A tak w ogóle, to czemu Money i Currency to struktury, a nie po prostu klasy?