-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This is the first step towards the transition to DDD, and does the following: - port over the definition of the old gorm models (Entry, Expense, Debt) to storage agnostic aggregates - define helper value objects for currency and month - remove validators by providing public methods which always preserve the consistency of the models Given the application is written in Go and there is no rich support for classes, the consistency rules are not "enforced" in the structs, thus the properties are public. This should be good enough.
- Loading branch information
Showing
9 changed files
with
354 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
package domain | ||
|
||
import ( | ||
"errors" | ||
"time" | ||
) | ||
|
||
type BaseModel struct { | ||
Id int | ||
CreatedAt time.Time | ||
UpdatedAt time.Time | ||
DeletedAt time.Time | ||
} | ||
|
||
type Month int | ||
|
||
func NewMonth(month int) (Month, error) { | ||
if month < 1 || month > 12 { | ||
return 0, ErrInvalidMonthNumber | ||
} | ||
return Month(month), nil | ||
} | ||
|
||
var ErrInvalidMonthNumber = errors.New("month must be between 1 and 12") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
# Currency | ||
|
||
Defining what a "value" is in this application has made me overthink this more | ||
than I should have. Normally it should be simple: I expect to spend x amount in | ||
my local currency for this entry, and I create expenses for that in the local | ||
currency which I add up to compare with my original expectation. Simple enough. | ||
|
||
But even if we approach the problem like this alone, the primary instinct would | ||
be to just store the values as floats, which is actually not recommended. Many | ||
sources like [this one](https://culttt.com/2014/05/28/handle-money-currency-web-applications#use-integers-not-floats) | ||
and [this one](https://cardinalby.github.io/blog/post/best-practices/storing-currency-values-data-types/#bad-choice) | ||
advice against that. TLDR; the operations can be innacurate and you're not | ||
making use of all the available decimals. I started the app by storing the | ||
smallest unit of my country's RON currency (i.e. a value of 1 RON is stored as | ||
100 in the database). | ||
|
||
This was fine at start, but I quickly realised that I was also being paid in | ||
euros at that time, and some transactions were in euros. Again, the first | ||
instinct is to convert in my country's currency. But then I got all philosophical | ||
into thinking things like "what is a value, in fact" and "what if I have to use | ||
something whose minimum unit might change", and shit like that. I essentially | ||
wanted to generalize the meaning of value -- what if I wanted to make a payment | ||
in oil at some point? 😒... | ||
|
||
I didn't really want to study currencies and their minor units to know which | ||
numbers to put in my database. I also didn't really want to store huge numbers | ||
in my database (for example, the smallest Bitcoin unit is 0.00000001 of a BTC) | ||
so I came up with the following system: I would store | ||
|
||
- the currency (can be RON, USD, Bitcoin etc.) | ||
- the value | ||
- the exponent (such that value * exponent represents the value in the unit | ||
of that currency) | ||
|
||
For example, by storing a value of 150 with currency RON and exponent -2, I'm | ||
actually storing 1.5 RON. This gives me enough flexibility and essentially | ||
allows me to not care at all about the minor unit, or whether if it unexpectedly | ||
changes. | ||
|
||
The main purpose of this package is to provide a `Value` struct that encapsulates | ||
such an object defined previously, and provides a set of constructors in order | ||
to always instantiate only valid values of this object. The application will | ||
create values using these constructors, and the database will convert these | ||
values (which are always correct) to its internal representation of the value. | ||
|
||
There are some things that I had considered (e.g. the unit of the currency | ||
changing, such as the [revaluation of my country's currency](https://en.wikipedia.org/wiki/Romanian_leu)) | ||
or storing the exchange rates at the day of the exchange to be able to accurately, | ||
reproduce and understand the history of my expenses, but I decided to quit | ||
pursuing these thoughts for the sake of my mental health. The real use case of | ||
this application is in the present and short-term past and future, in which case | ||
things like the exact exchange rate at the moment of the transaction doesn't | ||
matter. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
package currency | ||
|
||
// ISO 4217 compliant currency codes | ||
const ( | ||
EUR = "EUR" | ||
RON = "RON" | ||
USD = "USD" | ||
BTC = "BTC" | ||
GBP = "GBP" | ||
) | ||
|
||
var validCurrencies = []string{EUR, RON, USD, BTC, GBP} | ||
|
||
// Honestly it's not really worth doing anything better, I won't be having | ||
// many currencies. This is just for error purposes anyway. | ||
var validCurrenciesString = "[EUR, RON, USD, BTC, GBP]" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
package currency | ||
|
||
import ( | ||
"fmt" | ||
) | ||
|
||
// Value represents a currency value in a certain currency. The actual value | ||
// shall be multiplied by "10eExp" to obtain the value in the base unit of the | ||
// currency. | ||
// | ||
// Value should only be created via the provided constructors, to prevent | ||
// invalid "values". Do not modify "Value" directly. | ||
// | ||
// E.g. a Value of 100 with Exp = -2 and USD currency is the equivalent of 1$. | ||
type Value struct { | ||
Currency string | ||
Amount int | ||
Exponent int | ||
} | ||
|
||
func NewValue(amount int, currency string, exp int) (Value, error) { | ||
|
||
value := Value{ | ||
Amount: amount, | ||
Currency: currency, | ||
Exponent: exp, | ||
} | ||
|
||
if err := validateCurrency(currency); err != nil { | ||
return Value{}, fmt.Errorf("creating value: %w", err) | ||
} | ||
return value, nil | ||
} | ||
|
||
func NewValueBasedOnMinorCurrency(amount int, currency string, exponent *int) (Value, error) { | ||
|
||
// May change in the future, but at the moment the only currencies that | ||
// are allowed have the least valuable unit two orders of magnitude smaller | ||
// than the actual unit. | ||
actualExponent := -2 | ||
if exponent != nil { | ||
actualExponent = *exponent | ||
} | ||
|
||
if err := validateCurrency(currency); err != nil { | ||
return Value{}, fmt.Errorf("creating value: %w", NewErrInvalidCurrency(currency)) | ||
} | ||
|
||
return Value{ | ||
Amount: amount, | ||
Currency: currency, | ||
Exponent: actualExponent, | ||
}, nil | ||
} | ||
|
||
func NewUSDValue(amount int) Value { | ||
return Value{ | ||
Currency: USD, | ||
Amount: amount, | ||
Exponent: -2, | ||
} | ||
} | ||
func NewEURValue(amount int) Value { | ||
return Value{ | ||
Currency: EUR, | ||
Amount: amount, | ||
Exponent: -2, | ||
} | ||
} | ||
func NewRONValue(amount int) Value { | ||
return Value{ | ||
Currency: RON, | ||
Amount: amount, | ||
Exponent: -2, | ||
} | ||
} | ||
|
||
// validateCurrency provides a way to test whether or not a string can represent | ||
// a valid currency. | ||
func validateCurrency(currency string) error { | ||
for _, c := range validCurrencies { | ||
if currency == c { | ||
return nil | ||
} | ||
} | ||
return NewErrInvalidCurrency(currency) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
package currency | ||
|
||
import ( | ||
"errors" | ||
"reflect" | ||
"testing" | ||
) | ||
|
||
func TestCurrency(t *testing.T) { | ||
t.Run("Creating a currency value with valid code should not fail", func(t *testing.T) { | ||
_, err := NewValue(100, "EUR", 0) | ||
if err != nil { | ||
t.Error("Currency should have been created") | ||
} | ||
}) | ||
t.Run("Creating a currency value with invalid code should fail with invalid currency", func(t *testing.T) { | ||
_, err := NewValue(100, "abcd", 0) | ||
if err == nil { | ||
t.Error("Currency should have not been created") | ||
} | ||
var expectedErr ErrInvalidCurrency | ||
if !errors.As(err, &expectedErr) { | ||
t.Errorf("Expected error of type %s, got %s", reflect.TypeOf(expectedErr), reflect.TypeOf(err)) | ||
} | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
package currency | ||
|
||
import "fmt" | ||
|
||
type ErrInvalidCurrency struct { | ||
attemptedCurrency string | ||
} | ||
|
||
func (e ErrInvalidCurrency) Error() string { | ||
return fmt.Sprintf("currency %s does not exist, must be one of %s", e.attemptedCurrency, validCurrenciesString) | ||
} | ||
|
||
func NewErrInvalidCurrency(attemptedCurrency string) ErrInvalidCurrency { | ||
return ErrInvalidCurrency{ | ||
attemptedCurrency: attemptedCurrency, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
package domain | ||
|
||
import ( | ||
"errors" | ||
"time" | ||
|
||
"github.com/Ozoniuss/casheer/internal/domain/currency" | ||
) | ||
|
||
// Debt models a debt owed to or held by someone. Fields are automatically | ||
// mapped by gorm to their database equivalents. | ||
type Debt struct { | ||
BaseModel | ||
currency.Value | ||
|
||
Person string | ||
Details string | ||
} | ||
|
||
func NewDebt(person string, details string) (Debt, error) { | ||
|
||
if person == "" { | ||
return Debt{}, ErrEmptyPerson | ||
} | ||
|
||
return Debt{ | ||
Person: person, | ||
Details: details, | ||
BaseModel: BaseModel{ | ||
CreatedAt: time.Now(), | ||
}, | ||
}, nil | ||
} | ||
|
||
var ErrEmptyPerson = errors.New("person must not be empty") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
package domain | ||
|
||
import ( | ||
"errors" | ||
"time" | ||
|
||
"github.com/Ozoniuss/casheer/internal/domain/currency" | ||
) | ||
|
||
// Entry models an entry that can have zero or more expenses. | ||
// | ||
// Do not modify an entry directly. Use the provided functions to ensure | ||
// entries are always valid. | ||
type Entry struct { | ||
BaseModel | ||
currency.Value | ||
|
||
Month Month | ||
Year int | ||
Category string | ||
Subcategory string | ||
Recurring bool | ||
|
||
Expenses []Expense | ||
} | ||
|
||
func NewEntry(month int, year int, category, subcategory string, recurring bool) (Entry, error) { | ||
var err error = nil | ||
|
||
mo, merr := NewMonth(month) | ||
if merr != nil { | ||
errors.Join(err, merr) | ||
} | ||
|
||
if category == "" { | ||
err = errors.Join(err, ErrEmptyCategory) | ||
} | ||
if subcategory == "" { | ||
err = errors.Join(err, ErrEmptySubcategory) | ||
} | ||
|
||
if err != nil { | ||
return Entry{}, err | ||
} | ||
|
||
return Entry{ | ||
Month: mo, | ||
Year: year, | ||
Category: category, | ||
Subcategory: subcategory, | ||
Recurring: recurring, | ||
Expenses: make([]Expense, 0), | ||
BaseModel: BaseModel{ | ||
CreatedAt: time.Now(), | ||
}, | ||
}, nil | ||
} | ||
|
||
func (e *Entry) AddExpense(exp Expense) { | ||
e.Expenses = append(e.Expenses, exp) | ||
} | ||
|
||
var ErrEmptyCategory = errors.New("category must not be empty") | ||
var ErrEmptySubcategory = errors.New("subcategory must not be empty") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
package domain | ||
|
||
import ( | ||
"time" | ||
|
||
"github.com/Ozoniuss/casheer/internal/domain/currency" | ||
) | ||
|
||
// Expense models an expense that can be associated with an entry. | ||
// | ||
// Do not modify an expense directly. Use the provided functions to ensure | ||
// expenses are always valid. | ||
type Expense struct { | ||
BaseModel | ||
currency.Value | ||
|
||
Name string | ||
Description string | ||
PaymentMethod string | ||
} | ||
|
||
func NewScratchExpense(name string, description string, paymentMethod string, value currency.Value) (Expense, error) { | ||
return Expense{ | ||
Name: name, | ||
Description: description, | ||
PaymentMethod: paymentMethod, | ||
Value: value, | ||
BaseModel: BaseModel{ | ||
CreatedAt: time.Now(), | ||
}, | ||
}, nil | ||
} |