Skip to content
This repository has been archived by the owner on Oct 31, 2021. It is now read-only.

Commit

Permalink
Big progress on transactions endpoints.
Browse files Browse the repository at this point in the history
  • Loading branch information
elliotcourant committed Mar 17, 2021
1 parent 59ad038 commit 531fcac
Show file tree
Hide file tree
Showing 10 changed files with 416 additions and 37 deletions.
214 changes: 214 additions & 0 deletions pkg/controller/transactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package controller

import (
"github.com/harderthanitneedstobe/rest-api/v0/pkg/models"
"github.com/harderthanitneedstobe/rest-api/v0/pkg/repository"
"github.com/kataras/iris/v12"
"github.com/kataras/iris/v12/context"
"github.com/pkg/errors"
"math"
"net/http"
"strings"
)

func (c *Controller) handleTransactions(p iris.Party) {
Expand Down Expand Up @@ -61,6 +64,59 @@ func (c *Controller) postTransactions(ctx *context.Context) {
return
}

var transaction models.Transaction
if err = ctx.ReadJSON(&transaction); err != nil {
c.wrapAndReturnError(ctx, err, http.StatusBadRequest, "malformed JSON")
return
}

transaction.BankAccountId = bankAccountId
transaction.Name = strings.TrimSpace(transaction.Name)
transaction.MerchantName = strings.TrimSpace(transaction.MerchantName)
transaction.OriginalName = transaction.Name

if transaction.Name == "" {
c.badRequest(ctx, "transaction must have a name")
return
}

if transaction.Amount <= 0 {
c.badRequest(ctx, "transaction amount must be greater than 0")
return
}

if transaction.ExpenseId != nil && *transaction.ExpenseId > 0 {
account, err := repo.GetAccount()
if err != nil {
c.wrapPgError(ctx, err, "could not get account to create transaction")
return
}

expense, err := repo.GetExpense(bankAccountId, *transaction.ExpenseId)
if err != nil {
c.wrapPgError(ctx, err, "could not get expense provided for transaction")
return
}

if err = c.addExpenseToTransaction(account, &transaction, expense); err != nil {
c.wrapAndReturnError(ctx, err, http.StatusInternalServerError, "failed to add expense to transaction")
return
}

if err = repo.UpdateExpenses(bankAccountId, []models.Expense{
*expense,
}); err != nil {
c.wrapPgError(ctx, err, "failed to update expense for transaction")
return
}
}

if err = repo.CreateTransaction(bankAccountId, &transaction); err != nil {
c.wrapPgError(ctx, err, "could not create transaction")
return
}

ctx.JSON(transaction)
}

func (c *Controller) putTransactions(ctx *context.Context) {
Expand Down Expand Up @@ -122,6 +178,21 @@ func (c *Controller) putTransactions(ctx *context.Context) {
}
}

if err = c.processTransactionSpentFrom(repo, bankAccountId, &transaction, existingTransaction); err != nil {
c.wrapPgError(ctx, err, "failed to process expense changes")
return
}

// TODO Handle more complex transaction updates via the API.
// I think with the way I've built this so far there might be some issues where if a field is missing during a PUT,
// like the name field; we might update the name to be blank?

if err = repo.UpdateTransaction(bankAccountId, &transaction); err != nil {
c.wrapPgError(ctx, err, "could not update transaction")
return
}

ctx.JSON(transaction)
}

func (c *Controller) deleteTransactions(ctx *context.Context) {
Expand Down Expand Up @@ -150,3 +221,146 @@ func (c *Controller) deleteTransactions(ctx *context.Context) {
return
}
}

func (c *Controller) processTransactionSpentFrom(
repo repository.Repository,
bankAccountId uint64,
input, existing *models.Transaction,
) error {
account, err := repo.GetAccount()
if err != nil {
return err
}

const (
AddExpense = iota
ChangeExpense
RemoveExpense
)

var existingExpenseId uint64
if existing.ExpenseId != nil {
existingExpenseId = *existing.ExpenseId
}

var newExpenseId uint64
if input.ExpenseId != nil {
newExpenseId = *input.ExpenseId
}

var expensePlan int

switch {
case existingExpenseId == 0 && newExpenseId > 0:
// Expense is being added to the transaction.
expensePlan = AddExpense
case existingExpenseId != 0 && newExpenseId != existingExpenseId && newExpenseId > 0:
// Expense is being changed from one expense to another.
expensePlan = ChangeExpense
case existingExpenseId != 0 && newExpenseId == 0:
// Expense is being removed from the transaction.
expensePlan = RemoveExpense
default:
// TODO Handle transaction amount changes with expenses.
return nil
}

// Retrieve the expenses that we need to work with and potentially update.
var currentExpense, newExpense *models.Expense
var currentErr, newErr error
switch expensePlan {
case AddExpense:
newExpense, newErr = repo.GetExpense(bankAccountId, newExpenseId)
case ChangeExpense:
currentExpense, currentErr = repo.GetExpense(bankAccountId, existingExpenseId)
newExpense, newErr = repo.GetExpense(bankAccountId, newExpenseId)
case RemoveExpense:
currentExpense, currentErr = repo.GetExpense(bankAccountId, existingExpenseId)
}

// If we failed to retrieve either of the expenses then something is wrong and we need to stop.
switch {
case currentErr != nil:
return errors.Wrap(currentErr, "failed to retrieve the current expense for the transaction")
case newErr != nil:
return errors.Wrap(newErr, "failed to retrieve the new expense for the transaction")
}

expenseUpdates := make([]models.Expense, 0)

switch expensePlan {
case ChangeExpense, RemoveExpense:
// If the transaction already has an expense then it should have an expense amount. If this is missing then
// something is wrong.
if existing.ExpenseAmount == nil {
// TODO Handle missing expense amount when changing or removing a transaction's expense.
panic("somethings wrong, expense amount missing")
}

// Add the amount we took from the expense back to it.
currentExpense.CurrentAmount += *existing.ExpenseAmount

// Now that we have added that money back to the expense we need to calculate the expense's next contribution.
if err = currentExpense.CalculateNextContribution(
account.Timezone,
currentExpense.FundingSchedule.NextOccurrence,
currentExpense.FundingSchedule.Rule,
); err != nil {
return errors.Wrap(err, "failed to calculate next contribution for current transaction expense")
}

// Then take all the fields that have changed and throw them in our list of things to update.
expenseUpdates = append(expenseUpdates, *currentExpense)

// If we are only removing the expense then we are done with this part.
if expensePlan == RemoveExpense {
break
}

// If we are changing the expense though then we want to fallthrough to handle the processing of the new
// expense.
fallthrough
case AddExpense:
if err = c.addExpenseToTransaction(account, input, newExpense); err != nil {
return err
}

// Then take all the fields that have changed and throw them in our list of things to update.
expenseUpdates = append(expenseUpdates, *newExpense)
}

return repo.UpdateExpenses(bankAccountId, expenseUpdates)
}

func (c *Controller) addExpenseToTransaction(
account *models.Account,
transaction *models.Transaction,
expense *models.Expense,
) error {
var allocationAmount int64
// If the amount allocated to the expense we are adding to the transaction is less than the amount of the
// transaction then we can only do a partial allocation.
if expense.CurrentAmount < transaction.Amount {
allocationAmount = expense.CurrentAmount
} else {
// Otherwise we will allocate the entire transaction amount from the expense.
allocationAmount = transaction.Amount
}

// Subtract the amount we are taking from the expense from it's current amount.
expense.CurrentAmount -= allocationAmount

// Keep track of how much we took from the expense in case things change later.
transaction.ExpenseAmount = &allocationAmount

// Now that we have deducted the amount we need from the expense we need to recalculate it's next contribution.
if err := expense.CalculateNextContribution(
account.Timezone,
expense.FundingSchedule.NextOccurrence,
expense.FundingSchedule.Rule,
); err != nil {
return errors.Wrap(err, "failed to calculate next contribution for new transaction expense")
}

return nil
}
31 changes: 31 additions & 0 deletions pkg/controller/transactions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package controller_test

import (
"github.com/harderthanitneedstobe/rest-api/v0/pkg/models"
"net/http"
"testing"
)

func TestPostTransactions(t *testing.T) {
t.Run("bad request", func(t *testing.T) {
e := NewTestApplication(t)
token := GivenIHaveToken(t, e)

response := e.POST("/bank_accounts/1234/transactions").
WithHeader("H-Token", token).
WithJSON(models.Transaction{
BankAccountId: 1234,
ExpenseId: nil,
Categories: []string{
"Things",
},
Name: "I spent money",
MerchantName: "A place",
IsPending: false,
}).
Expect()

response.Status(http.StatusBadRequest)
response.JSON().Path("$.error").Equal("cannot create transactions for non-manual links")
})
}
21 changes: 21 additions & 0 deletions pkg/internal/fixtures/bank_account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package fixtures

import (
"github.com/brianvoe/gofakeit/v6"
"github.com/harderthanitneedstobe/rest-api/v0/pkg/models"
"testing"
)

func BankAccountFixture(t *testing.T) *models.BankAccount {
return &models.BankAccount{
PlaidAccountId: gofakeit.UUID(),
AvailableBalance: 100000,
CurrentBalance: 98500,
Mask: "0123",
Name: "Personal Checking",
PlaidName: "Checking",
PlaidOfficialName: "Checking",
Type: "depository",
SubType: "checking",
}
}
33 changes: 33 additions & 0 deletions pkg/internal/fixtures/link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package fixtures

import (
"fmt"
"github.com/brianvoe/gofakeit/v6"
"github.com/harderthanitneedstobe/rest-api/v0/pkg/models"
"testing"
)

func ManualLink(t *testing.T) *models.Link {
return &models.Link{
LinkType: models.ManualLinkType,
InstitutionName: fmt.Sprintf("%s Bank", gofakeit.Company()),
CustomInstitutionName: "Personal Bank",
}
}

func PlaidLink(t *testing.T) (*models.Link, *models.PlaidLink) {
return &models.Link{
LinkType: models.ManualLinkType,
InstitutionName: fmt.Sprintf("%s Bank", gofakeit.Company()),
CustomInstitutionName: "Personal Bank",
}, &models.PlaidLink{
ItemId: gofakeit.UUID(),
AccessToken: gofakeit.UUID(),
Products: []string{
"transactions",
},
WebhookUrl: "",
InstitutionId: "1234",
InstitutionName: gofakeit.Company(),
}
}
39 changes: 29 additions & 10 deletions pkg/repository/expenses.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package repository
import (
"github.com/harderthanitneedstobe/rest-api/v0/pkg/models"
"github.com/pkg/errors"
"time"
)

func (r *repositoryBase) GetExpenses(bankAccountId uint64) ([]models.Expense, error) {
Expand Down Expand Up @@ -40,15 +39,35 @@ func (r *repositoryBase) CreateExpense(expense *models.Expense) error {
return errors.Wrap(err, "failed to create expense")
}

type ExpenseUpdateItem struct {
ExpenseId uint64 `pg:"expense_id"`
CurrentAmount int64 `pg:"current_amount"`
NextContributionAmount int64 `pg:"next_contribution_amount"`
IsBehind bool `pg:"is_behind"`
LastRecurrence *time.Time `pg:"last_recurrence"`
NextRecurrence *time.Time `pg:"next_recurrence"`
}
// UpdateExpenses should only be called with complete expense models. Do not use partial models with missing data for
// this action.
func (r *repositoryBase) UpdateExpenses(bankAccountId uint64, updates []models.Expense) error {
for i := range updates {
updates[i].AccountId = r.AccountId()
}

_, err := r.txn.Model(&updates).
Where(`"expense"."account_id" = ?`, r.AccountId()).
Where(`"expense"."bank_account_id" = ?`, bankAccountId).
Update(&updates)
if err != nil {
return errors.Wrap(err, "failed to update expenses")
}

func (r *repositoryBase) UpdateExpenseBalances(bankAccountId uint64, updates []ExpenseUpdateItem) error {
return nil
}

func (r *repositoryBase) GetExpense(bankAccountId, expenseId uint64) (*models.Expense, error) {
var result models.Expense
err := r.txn.Model(&result).
Relation("FundingSchedule").
Where(`"expense"."account_id" = ?`, r.AccountId()).
Where(`"expense"."bank_account_id" = ?`, bankAccountId).
Where(`"expense"."expense_id" = ?`, expenseId).
Select(&result)
if err != nil {
return nil, errors.Wrap(err, "failed to retrieve expense")
}

return &result, nil
}
Loading

0 comments on commit 531fcac

Please sign in to comment.