diff --git a/Readme.md b/Readme.md index 847e782..e261ce5 100644 --- a/Readme.md +++ b/Readme.md @@ -1,98 +1,12 @@ -# Web-App-Template +# SpendSparrow -A basic template with authentication to easily host on a VPC. +SpendSparrow is a web app to keep track of expenses and income. It is very opinionated by keeping an keen eye on disciplin of it's users. Every Expense needs to be mapped to a Piggy Bank. For emergencies, funds can be moved between Piggy Banks. -## Features +## Design priciples -This template includes everything essential to build an app. It includes the following features: - -- Authentication: Users can login, logout, register and reset their password. (for increased security TOTP is planned aswell.) -- Observability: The stack contains an Grafana+Prometheus instance for basic monitoring. You are able to add alerts and get notified on your phone. -- Mail: You are able to send mail with SMTP. You still need an external Mail Server, but a guide on how to set that up with a custom domain is included. -- SSL: This is included by using traefik as reverse proxy. It handles SSL certificates automatically. Furthermore all services are accessible through subdomains. -- Stack: Tailwindcss + HTMX + GO Backend with templ and sqlite - - -## Architecture Design Decisions - -### Authentication - -Authentication is a broad topic. Many people think you should not consider implementing authentication yourself. On the other hand, If only security experts are allowed to write software, what does that result in? I'm going to explain my criterions and afterwards take a decision. - -There are a few restrictions I would like to contain: -- I want this template do as much as as possible without relying on external services. This way the setup cost and dependencies can be minimized. -- It should still be possible to run on a small VPC (2vcpu, 2GB). -- It should be as secure as possible - -I determined 4 options: -1. Implement the authentication myself -2. Using OAuth2 with Keycloak -3. Using OAuth2 with Google and Apple -4. Firebase, Clerk, etc. - - -#### 1. Implement the authentication myself - -It's always possible to implement it myself. The topic of authentication is something special though. - -Pros: - - Great Cheat cheets from OWASP - - No adittional configuration or services needed - - Great learning experience on the topic "security" -Cons: - - Great attack vector - - Introcution of vlunerabillities is possible - - No DDOS protection - -#### 2. Using OAuth2 with Google and Apple - -Instead of implementing authentication from scratch, an external OAuth2 provider is embedded into the application. - -Pros: - - The Systems of BigTech are probably safer. They have security experts employed. - - The other external system is responsible to prevent credential stuffing attacks, etc. - - Users don't have to create new credentials -Cons: - - High dependency on those providers - - Single Point of failure (If your account is banned, your application access get's lost as well.) - - It's possible that these providers ban the whole application (All users lose access) - - There still needs to be implemented some logic - - Full application integration can be difficult - -#### 3. Using OAuth2 with Keycloak - -This option is almost identical with the previois one, but the provider is self hosted. - -Pros: - - Indipendent from 3rd party providers - - The credentials are stored safly -Cons: - - Self hosted (no DDOS protection, etc.) - - There still needs to be implemented some logic server side - - Full application integration can be difficult - - -#### 4. Firebase, Clerk, etc. - -Users can sign in with a seperate sdk on your website - -Pros: - - Safe and Sound authentication -Cons: - - Dependent on those providers / adittional setup needed - - Application can be banned - - Still some integration code needed - -#### Decision - -I've decided on implementing authentication myself, as this is a great learning opportunity. It may not be as secure as other solutions, but if I keep tighly to the OWASP recommendations, it should should good enough. - - -### Email - -For Email verification, etc. a mail server is needed, that can send a whole lot of mails. Aditionally, a mail account is needed for incoming emails. I thought about self hosting, but unfortunatly this is a hastle to maintain. Not only you have to setup a mail server, which is not as easy as it sounds, you also have to "register" your mail server for diffrent providers. Otherwise you are not able to send and receive emails. - -In order to not vendor lock in, I decided to use an SMTP relay in favor of a vendor specific API. I chose brevo.com. They have a generous free tier of 300 mails per day. You can either upgrade to a monthly plan 10$ for 20k mails or buy credits for 30$ for 5k mails. +The State of the application can always be calculated on the fly. Even though it is not an Event Streaming Application, it is still important to be able to recalculate historic data. +It may be applicable to do some sort of monthly snapshots to speed up calculations, but this will be only done if database queries become a bottleneck. +This applications uses as little dependencies as feasible, especially on the front end. diff --git a/service/account.go b/service/account.go new file mode 100644 index 0000000..2d9017e --- /dev/null +++ b/service/account.go @@ -0,0 +1,130 @@ +package service + +import ( + "spend-sparrow/db" + "spend-sparrow/types" + + "errors" + "strconv" + "time" + + "github.com/google/uuid" +) + +type Workout interface { + AddWorkout(user *types.User, workoutDto *WorkoutDto) (*WorkoutDto, error) + DeleteWorkout(user *types.User, rowId int) error + GetWorkouts(user *types.User) ([]*WorkoutDto, error) +} + +type WorkoutImpl struct { + db db.WorkoutDb + random Random + clock Clock + mail Mail + settings *types.Settings +} + +func NewWorkoutImpl(db db.WorkoutDb, random Random, clock Clock, mail Mail, settings *types.Settings) Workout { + return WorkoutImpl{ + db: db, + random: random, + clock: clock, + mail: mail, + settings: settings, + } +} + +type AccountDto struct { +} + +type TransactionDto struct { +} + +func NewWorkoutDtoFromDb(workout *db.Workout) *WorkoutDto { + return &WorkoutDto{ + RowId: strconv.Itoa(workout.RowId), + Date: renderDate(workout.Date), + Type: workout.Type, + Sets: strconv.Itoa(workout.Sets), + Reps: strconv.Itoa(workout.Reps), + } +} +func NewWorkoutDto(rowId string, date string, workoutType string, sets string, reps string) *WorkoutDto { + return &WorkoutDto{ + RowId: rowId, + Date: date, + Type: workoutType, + Sets: sets, + Reps: reps, + } +} + +var ( + ErrInputValues = errors.New("invalid input values") +) + +func (service WorkoutImpl) AddWorkout(user *types.User, workoutDto *WorkoutDto) (*WorkoutDto, error) { + + if workoutDto.Date == "" || workoutDto.Type == "" || workoutDto.Sets == "" || workoutDto.Reps == "" { + return nil, ErrInputValues + } + + date, err := time.Parse("2006-01-02", workoutDto.Date) + if err != nil { + return nil, ErrInputValues + } + + sets, err := strconv.Atoi(workoutDto.Sets) + if err != nil { + return nil, ErrInputValues + } + + reps, err := strconv.Atoi(workoutDto.Reps) + if err != nil { + return nil, ErrInputValues + } + + workoutInsert := db.NewWorkoutInsert(date, workoutDto.Type, sets, reps) + + workout, err := service.db.InsertWorkout(user.Id, workoutInsert) + if err != nil { + return nil, err + } + + return NewWorkoutDtoFromDb(workout), nil +} + +func (service WorkoutImpl) DeleteWorkout(user *types.User, rowId int) error { + if user == nil { + return types.ErrInternal + } + + return service.db.DeleteWorkout(user.Id, rowId) +} + +func (service WorkoutImpl) GetWorkouts(user *types.User) ([]*WorkoutDto, error) { + if user == nil { + return nil, types.ErrInternal + } + + workouts, err := service.db.GetWorkouts(user.Id) + if err != nil { + return nil, err + } + + // for _, workout := range workouts { + // workout.Date = renderDate(workout.Date) + // } + + workoutsDto := make([]*WorkoutDto, len(workouts)) + for i, workout := range workouts { + workoutsDto[i] = NewWorkoutDtoFromDb(&workout) + } + + return workoutsDto, nil +} + +func renderDate(date time.Time) string { + return date.Format("2006-01-02") +} diff --git a/service/workout.go b/service/budget.go similarity index 100% rename from service/workout.go rename to service/budget.go diff --git a/service/money.go b/service/money.go new file mode 100644 index 0000000..0f31e83 --- /dev/null +++ b/service/money.go @@ -0,0 +1,8 @@ +package service + +type MoneyImpl struct { +} + +func NewMoneyImpl() *MoneyImpl { + return &MoneyImpl{} +} diff --git a/service/money_test.go b/service/money_test.go new file mode 100644 index 0000000..30c5905 --- /dev/null +++ b/service/money_test.go @@ -0,0 +1,112 @@ +package service + +import ( + "testing" + "time" + + "spend-sparrow/types" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestMoneyCalculation(t *testing.T) { + t.Parallel() + t.Run("should calculate correct oink balance", func(t *testing.T) { + t.Parallel() + + underTest := NewMoneyImpl() + + // GIVEN + timestamp := time.Date(2020, 01, 01, 0, 0, 0, 0, time.UTC) + + groupId := uuid.New() + + account := types.Account{ + Id: uuid.New(), + GroupId: groupId, + + Type: "Bank", + Name: "Bank", + + CurrentBalance: 0, + LastTransaction: time.Time{}, + OinkBalance: 0, + } + + // The PiggyBank is a fictional account. The money it "holds" is actually in the Account + piggyBank := types.PiggyBank{ + Id: uuid.New(), + GroupId: groupId, + + AccountId: account.Id, + Name: "Car", + + CurrentBalance: 0, + } + + savingsPlan := types.SavingsPlan{ + Id: uuid.New(), + GroupId: groupId, + PiggyBankId: piggyBank.Id, + + MonthlySaving: 10, + + ValidFrom: timestamp, + } + + transaction1 := types.Transaction{ + Id: uuid.New(), + GroupId: groupId, + + AccountId: account.Id, + + Amount: 20, + Timestamp: timestamp, + } + + transaction2 := types.Transaction{ + Id: uuid.New(), + GroupId: groupId, + + AccountId: account.Id, + PiggyBankId: &piggyBank.Id, + + Amount: -1, + Timestamp: timestamp.Add(1 * time.Hour), + } + + expected := []types.BalanceInTime{ + { + Id: uuid.New(), + GroupId: groupId, + + TranactionId: transaction1.Id, + AccountId: account.Id, + + ValidFrom: timestamp, + Balance: 20, + OinkBalance: 10, + }, + { + Id: uuid.New(), + GroupId: groupId, + + TranactionId: transaction2.Id, + AccountId: account.Id, + PiggyBankId: piggyBank.Id, + + ValidFrom: timestamp.Add(1 * time.Hour), + Balance: 19, + OinkBalance: 9, + }, + } + + // WHEN + actual, err := underTest.CalculateAllBalancesInTime(account, piggyBank, savingsPlan, []types.Transaction{transaction1, transaction2}) + + // THEN + assert.Nil(t, err) + assert.ElementsMatch(t, expected, actual) + }) +} diff --git a/types/money.go b/types/money.go new file mode 100644 index 0000000..b6ec31e --- /dev/null +++ b/types/money.go @@ -0,0 +1,92 @@ +package types + +import ( + "time" + + "github.com/google/uuid" +) + +// At the center of the application is the transaction. +// +// Every piece of data should be calculated based on transactions. This means potential calculation errors can be fixed later in time. +// +// If it becomes necessary to precalculate snapshots for performance reasons, this can be done in the future. But the transaction should always be the source of truth. +type Transaction struct { + Id uuid.UUID + GroupId uuid.UUID + + AccountId uuid.UUID + // nil indicates that the transaction is not yet associated with a piggy bank + PiggyBankId *uuid.UUID + + // The internal transaction is amove between e.g. an account and a piggy bank to execute a savings plan + Internal bool + + // The value of the transacion. Negative for outgoing and positive for incoming + Value int64 + Timestamp time.Time + + Note string + + CreatedAt time.Time + // either "" or "system-" + CreatedBy uuid.UUID + UpdatedAt time.Time + UpdatedBy uuid.UUID +} + +// The Account holds money +type Account struct { + Id uuid.UUID + GroupId uuid.UUID + + // "Bank-Name" or "Cash" + Type string + Name string + + CurrentBalance int64 + LastTransaction time.Time + // The current precalculated value of: + // Account.Balance - [PiggyBank.Balance...] + OinkBalance int64 + + CreatedAt time.Time + CreatedBy uuid.UUID + UpdatedAt *time.Time + UpdatedBy *uuid.UUID +} + +// The PiggyBank is a fictional account. The money it "holds" is actually in the Account +type PiggyBank struct { + Id uuid.UUID + GroupId uuid.UUID + + AccountId uuid.UUID + Name string + + CurrentBalance int64 + + CreatedAt time.Time + CreatedBy uuid.UUID + UpdatedAt *time.Time + UpdatedBy *uuid.UUID +} + +// The SavingsPlan is applied every interval to the PiggyBank/Account as a transaction +type SavingsPlan struct { + Id uuid.UUID + GroupId uuid.UUID + + PiggyBankId uuid.UUID + + MonthlySaving int64 + + ValidFrom time.Time + /// nil means it is valid indefinitely + ValidTo *time.Time + + CreatedAt time.Time + CreatedBy uuid.UUID + UpdatedAt *time.Time + UpdatedBy *uuid.UUID +}