Skip to content

Commit

Permalink
Add domain models
Browse files Browse the repository at this point in the history
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
Ozoniuss committed Jul 27, 2024
1 parent 4f96844 commit 22520f9
Show file tree
Hide file tree
Showing 9 changed files with 354 additions and 0 deletions.
24 changes: 24 additions & 0 deletions internal/domain/base.go
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")
53 changes: 53 additions & 0 deletions internal/domain/currency/README.md
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.
16 changes: 16 additions & 0 deletions internal/domain/currency/currencies.go
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]"
87 changes: 87 additions & 0 deletions internal/domain/currency/currency.go
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)
}
26 changes: 26 additions & 0 deletions internal/domain/currency/currency_test.go
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))
}
})
}
17 changes: 17 additions & 0 deletions internal/domain/currency/error.go
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,
}
}
35 changes: 35 additions & 0 deletions internal/domain/debt.go
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")
64 changes: 64 additions & 0 deletions internal/domain/entry.go
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")
32 changes: 32 additions & 0 deletions internal/domain/expense.go
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
}

0 comments on commit 22520f9

Please sign in to comment.