diff --git a/internal/domain/base.go b/internal/domain/base.go new file mode 100644 index 0000000..caf7655 --- /dev/null +++ b/internal/domain/base.go @@ -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") diff --git a/internal/domain/currency/README.md b/internal/domain/currency/README.md new file mode 100644 index 0000000..13cb2da --- /dev/null +++ b/internal/domain/currency/README.md @@ -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. \ No newline at end of file diff --git a/internal/domain/currency/currencies.go b/internal/domain/currency/currencies.go new file mode 100644 index 0000000..8484ff0 --- /dev/null +++ b/internal/domain/currency/currencies.go @@ -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]" diff --git a/internal/domain/currency/currency.go b/internal/domain/currency/currency.go new file mode 100644 index 0000000..8b80d2b --- /dev/null +++ b/internal/domain/currency/currency.go @@ -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) +} diff --git a/internal/domain/currency/currency_test.go b/internal/domain/currency/currency_test.go new file mode 100644 index 0000000..6e2f939 --- /dev/null +++ b/internal/domain/currency/currency_test.go @@ -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)) + } + }) +} diff --git a/internal/domain/currency/error.go b/internal/domain/currency/error.go new file mode 100644 index 0000000..5116834 --- /dev/null +++ b/internal/domain/currency/error.go @@ -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, + } +} diff --git a/internal/domain/debt.go b/internal/domain/debt.go new file mode 100644 index 0000000..7d32c8e --- /dev/null +++ b/internal/domain/debt.go @@ -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") diff --git a/internal/domain/entry.go b/internal/domain/entry.go new file mode 100644 index 0000000..ad960fb --- /dev/null +++ b/internal/domain/entry.go @@ -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") diff --git a/internal/domain/expense.go b/internal/domain/expense.go new file mode 100644 index 0000000..4969e6e --- /dev/null +++ b/internal/domain/expense.go @@ -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 +}