Skip to content

Commit 277e5cb

Browse files
authored
feat: add spent sum per envelope per month (#102)
Resolves #39.
1 parent 240b29d commit 277e5cb

File tree

9 files changed

+241
-60
lines changed

9 files changed

+241
-60
lines changed

internal/controllers/all.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package controllers
2+
3+
import "time"
4+
5+
// This file holds types that are used over multiple files.
6+
7+
// Month is used to parse requests for data about a specific month.
8+
type Month struct {
9+
Month time.Time `form:"month" time_format:"2006-01" time_utc:"1"`
10+
}

internal/controllers/envelope.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func RegisterEnvelopeRoutes(r *gin.RouterGroup) {
2020
r.POST("", CreateEnvelope)
2121
}
2222

23-
// Transaction with ID
23+
// Envelope with ID
2424
{
2525
r.OPTIONS("/:envelopeId", func(c *gin.Context) {
2626
c.Header("allow", "GET, PATCH, DELETE")
@@ -72,9 +72,37 @@ func GetEnvelope(c *gin.Context) {
7272
return
7373
}
7474

75-
c.JSON(http.StatusOK, gin.H{"data": envelope, "links": map[string]string{
76-
"allocations": requestURL(c) + "/allocations",
77-
}})
75+
// Parse the month from the request
76+
var month Month
77+
if err := c.ShouldBind(&month); err != nil {
78+
FetchErrorHandler(c, err)
79+
return
80+
}
81+
82+
// If a month is requested, return only month specfic data
83+
if !month.Month.IsZero() {
84+
spent, err := envelope.Spent(month.Month)
85+
if err != nil {
86+
FetchErrorHandler(c, err)
87+
return
88+
}
89+
90+
c.JSON(http.StatusOK, gin.H{
91+
"data": map[string]interface{}{
92+
"spent": spent,
93+
"month": month.Month,
94+
},
95+
})
96+
return
97+
}
98+
99+
c.JSON(http.StatusOK, gin.H{
100+
"data": envelope,
101+
"links": map[string]string{
102+
"allocations": requestURL(c) + "/allocations",
103+
"month": requestURL(c) + "?month=YYYY-MM",
104+
},
105+
})
78106
}
79107

80108
// UpdateEnvelope updates a envelope, selected by the ID parameter.

internal/controllers/envelope_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/envelope-zero/backend/internal/models"
1010
"github.com/envelope-zero/backend/internal/test"
11+
"github.com/shopspring/decimal"
1112
"github.com/stretchr/testify/assert"
1213
)
1314

@@ -21,6 +22,14 @@ type EnvelopeDetailResponse struct {
2122
Data models.Envelope
2223
}
2324

25+
type EnvelopeMonthResponse struct {
26+
test.APIResponse
27+
Data struct {
28+
Month time.Time `json:"month"`
29+
Spent decimal.Decimal `json:"spent"`
30+
}
31+
}
32+
2433
func TestGetEnvelopes(t *testing.T) {
2534
recorder := test.Request(t, "GET", "/v1/budgets/1/categories/1/envelopes", "")
2635

@@ -132,6 +141,32 @@ func TestGetEnvelope(t *testing.T) {
132141
assert.Equal(t, dbEnvelope, envelope.Data)
133142
}
134143

144+
func TestEnvelopeMonth(t *testing.T) {
145+
var envelopeMonth EnvelopeMonthResponse
146+
147+
r := test.Request(t, "GET", "/v1/budgets/1/categories/1/envelopes/1?month=2022-01", "")
148+
test.AssertHTTPStatus(t, http.StatusOK, &r)
149+
spent := decimal.NewFromFloat(-10)
150+
test.DecodeResponse(t, &r, &envelopeMonth)
151+
assert.True(t, envelopeMonth.Data.Spent.Equal(spent), "Month calculation for 2022-01 is wrong: should be %v, but is %v", spent, envelopeMonth.Data.Spent)
152+
153+
r = test.Request(t, "GET", "/v1/budgets/1/categories/1/envelopes/1?month=2022-02", "")
154+
test.AssertHTTPStatus(t, http.StatusOK, &r)
155+
spent = decimal.NewFromFloat(-5)
156+
test.DecodeResponse(t, &r, &envelopeMonth)
157+
assert.True(t, envelopeMonth.Data.Spent.Equal(spent), "Month calculation for 2022-02 is wrong: should be %v, but is %v", spent, envelopeMonth.Data.Spent)
158+
159+
r = test.Request(t, "GET", "/v1/budgets/1/categories/1/envelopes/1?month=2022-03", "")
160+
test.AssertHTTPStatus(t, http.StatusOK, &r)
161+
spent = decimal.NewFromFloat(-15)
162+
test.DecodeResponse(t, &r, &envelopeMonth)
163+
assert.True(t, envelopeMonth.Data.Spent.Equal(spent), "Month calculation for 2022-03 is wrong: should be %v, but is %v", spent, envelopeMonth.Data.Spent)
164+
165+
// Test that non-parseable requests produce an error
166+
r = test.Request(t, "GET", "/v1/budgets/1/categories/1/envelopes/1?month=Stonks!", "")
167+
test.AssertHTTPStatus(t, http.StatusBadRequest, &r)
168+
}
169+
135170
func TestUpdateEnvelope(t *testing.T) {
136171
recorder := test.Request(t, "POST", "/v1/budgets/1/categories/1/envelopes", `{ "name": "New Envelope", "note": "More tests something something" }`)
137172
test.AssertHTTPStatus(t, http.StatusCreated, &recorder)

internal/controllers/helper.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"reflect"
99
"strconv"
10+
"time"
1011

1112
"github.com/envelope-zero/backend/internal/models"
1213
"github.com/gin-contrib/requestid"
@@ -231,6 +232,8 @@ func FetchErrorHandler(c *gin.Context, err error) {
231232
c.JSON(http.StatusBadRequest, gin.H{
232233
"error": "An ID specified in the query string was not a valid uint64",
233234
})
235+
} else if reflect.TypeOf(err) == reflect.TypeOf(&time.ParseError{}) {
236+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
234237
} else {
235238
log.Error().Str("request-id", requestid.Get(c)).Msgf("%T: %v", err, err.Error())
236239
c.JSON(http.StatusInternalServerError, gin.H{

internal/models/account.go

Lines changed: 13 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package models
22

33
import (
4-
"fmt"
5-
64
"github.com/shopspring/decimal"
75
"gorm.io/gorm"
86
)
@@ -48,69 +46,28 @@ func (a Account) Transactions() []Transaction {
4846
var transactions []Transaction
4947

5048
// Get all transactions where the account is either the source or the destination
51-
DB.Where(
52-
"source_account_id = ?", a.ID,
53-
).Or("destination_account_id = ?", a.ID).Find(&transactions)
54-
49+
DB.Where(Transaction{SourceAccountID: a.ID}).Or(Transaction{DestinationAccountID: a.ID}).Find(&transactions)
5550
return transactions
5651
}
5752

5853
// Transactions returns all transactions for this account.
5954
func (a Account) SumReconciledTransactions() (decimal.Decimal, error) {
60-
var sourceSum, destinationSum decimal.NullDecimal
61-
62-
err := DB.Table("transactions").
63-
Where(&Transaction{
64-
Reconciled: true,
65-
SourceAccountID: a.ID,
66-
}).
67-
Select("SUM(amount)").
68-
Row().
69-
Scan(&sourceSum)
70-
if err != nil {
71-
return decimal.NewFromFloat(0.0), fmt.Errorf("getting transactions for account with id %d (source) failed: %w", a.ID, err)
72-
}
73-
74-
err = DB.Table("transactions").
75-
Where(&Transaction{
55+
return TransactionsSum(
56+
Transaction{
7657
Reconciled: true,
7758
DestinationAccountID: a.ID,
78-
}).
79-
Select("SUM(amount)").
80-
Row().
81-
Scan(&destinationSum)
82-
83-
if err != nil {
84-
return decimal.NewFromFloat(0.0), fmt.Errorf("getting transactions for account with id %d (destination) failed: %w", a.ID, err)
85-
}
86-
87-
return destinationSum.Decimal.Sub(sourceSum.Decimal), nil
59+
},
60+
Transaction{
61+
Reconciled: true,
62+
SourceAccountID: a.ID,
63+
},
64+
)
8865
}
8966

9067
// GetBalance returns the balance of the account, including all transactions.
91-
//
92-
// Note that this will produce wrong results with sqlite as of now, see
93-
// https://github.com/go-gorm/gorm/issues/5153 for details.
9468
func (a Account) getBalance() (decimal.Decimal, error) {
95-
var sourceSum, destinationSum decimal.NullDecimal
96-
97-
err := DB.Table("transactions").
98-
Where(&Transaction{SourceAccountID: a.ID}).
99-
Select("SUM(amount)").
100-
Row().
101-
Scan(&sourceSum)
102-
if err != nil {
103-
return decimal.NewFromFloat(0.0), fmt.Errorf("getting transactions for account with id %d (source) failed: %w", a.ID, err)
104-
}
105-
106-
err = DB.Table("transactions").
107-
Where(&Transaction{DestinationAccountID: a.ID}).
108-
Select("SUM(amount)").
109-
Row().
110-
Scan(&destinationSum)
111-
if err != nil {
112-
return decimal.NewFromFloat(0.0), fmt.Errorf("getting transactions for account with id %d (destination) failed: %w", a.ID, err)
113-
}
114-
115-
return destinationSum.Decimal.Sub(sourceSum.Decimal), nil
69+
return TransactionsSum(
70+
Transaction{DestinationAccountID: a.ID},
71+
Transaction{SourceAccountID: a.ID},
72+
)
11673
}

internal/models/all.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package models
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/shopspring/decimal"
7+
)
8+
9+
// TransactionSums returns the sum of all transactions matching two Transaction structs
10+
//
11+
// The incoming Transactions fields is used to add the amount of all matching transactions to the overall sum
12+
// The outgoing Transactions fields is used to subtract the amount of all matching transactions from the overall sum.
13+
func TransactionsSum(incoming, outgoing Transaction) (decimal.Decimal, error) {
14+
var outgoingSum, incomingSum decimal.NullDecimal
15+
16+
_ = DB.Table("transactions").
17+
Where(&outgoing).
18+
Select("SUM(amount)").
19+
Row().
20+
Scan(&outgoingSum)
21+
22+
_ = DB.Table("transactions").
23+
Where(&incoming).
24+
Select("SUM(amount)").
25+
Row().
26+
Scan(&incomingSum)
27+
28+
return incomingSum.Decimal.Sub(outgoingSum.Decimal), nil
29+
}
30+
31+
// RawTransactions returns a list of transactions for a raw SQL query.
32+
func RawTransactions(query string) ([]Transaction, error) {
33+
var transactions []Transaction
34+
35+
err := DB.Raw(query).Scan(&transactions).Error
36+
if err != nil {
37+
return []Transaction{}, fmt.Errorf("getting transactions with query '%v' failed: %w", query, err)
38+
}
39+
40+
return transactions, nil
41+
}

internal/models/all_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package models_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/envelope-zero/backend/internal/models"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestRawTransactions(t *testing.T) {
11+
_, err := models.RawTransactions("INVALID query string")
12+
13+
assert.NotNil(t, err)
14+
}

internal/models/envelope.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
package models
22

3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/shopspring/decimal"
8+
)
9+
310
// Envelope represents an envelope in your budget.
411
type Envelope struct {
512
Model
@@ -8,3 +15,34 @@ type Envelope struct {
815
Category Category `json:"-"`
916
Note string `json:"note,omitempty"`
1017
}
18+
19+
// Spent returns the amount spent for the month the time.Time instance is in.
20+
func (e Envelope) Spent(t time.Time) (decimal.Decimal, error) {
21+
// All transactions where the Envelope ID matches and that have an external account as source and an internal account as destination
22+
incoming, _ := RawTransactions(
23+
fmt.Sprintf("SELECT transactions.* FROM transactions, accounts AS source_accounts, accounts AS destination_accounts WHERE transactions.source_account_id = source_accounts.id AND source_accounts.external AND transactions.destination_account_id = destination_accounts.id AND NOT destination_accounts.external AND transactions.envelope_id = %v", e.ID),
24+
)
25+
26+
// Add all incoming transactions that are in the correct month
27+
incomingSum := decimal.Zero
28+
for _, transaction := range incoming {
29+
if transaction.Date.UTC().Year() == t.UTC().Year() && transaction.Date.UTC().Month() == t.UTC().Month() {
30+
incomingSum = incomingSum.Add(transaction.Amount)
31+
}
32+
}
33+
34+
outgoing, _ := RawTransactions(
35+
// All transactions where the envelope ID matches that have an internal account as source and an external account as destination
36+
fmt.Sprintf("SELECT transactions.* FROM transactions, accounts AS source_accounts, accounts AS destination_accounts WHERE transactions.source_account_id = source_accounts.id AND NOT source_accounts.external AND transactions.destination_account_id = destination_accounts.id AND destination_accounts.external AND transactions.envelope_id = %v", e.ID),
37+
)
38+
39+
// Add all outgoing transactions that are in the correct month
40+
outgoingSum := decimal.Zero
41+
for _, transaction := range outgoing {
42+
if transaction.Date.UTC().Year() == t.UTC().Year() && transaction.Date.UTC().Month() == t.UTC().Month() {
43+
outgoingSum = outgoingSum.Add(transaction.Amount)
44+
}
45+
}
46+
47+
return incomingSum.Sub(outgoingSum), nil
48+
}

internal/models/envelope_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package models_test
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/envelope-zero/backend/internal/models"
8+
"github.com/shopspring/decimal"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestEnvelopeMonthSum(t *testing.T) {
13+
internalAccount := &models.Account{
14+
Name: "Internal Source Account",
15+
}
16+
models.DB.Create(internalAccount)
17+
18+
externalAccount := &models.Account{
19+
Name: "External Destination Account",
20+
External: true,
21+
}
22+
models.DB.Create(&externalAccount)
23+
24+
envelope := &models.Envelope{
25+
Name: "Testing envelope",
26+
}
27+
models.DB.Create(&envelope)
28+
29+
spent := decimal.NewFromFloat(17.32)
30+
transaction := &models.Transaction{
31+
EnvelopeID: envelope.ID,
32+
Amount: spent,
33+
SourceAccountID: internalAccount.ID,
34+
DestinationAccountID: externalAccount.ID,
35+
Date: time.Date(2022, 1, 15, 0, 0, 0, 0, time.UTC),
36+
}
37+
models.DB.Create(&transaction)
38+
39+
transactionIn := &models.Transaction{
40+
EnvelopeID: envelope.ID,
41+
Amount: spent.Neg(),
42+
SourceAccountID: externalAccount.ID,
43+
DestinationAccountID: internalAccount.ID,
44+
Date: time.Date(2022, 2, 15, 0, 0, 0, 0, time.UTC),
45+
}
46+
models.DB.Create(&transactionIn)
47+
48+
envelopeSpent, err := envelope.Spent(time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC))
49+
assert.Nil(t, err)
50+
assert.True(t, envelopeSpent.Equal(spent.Neg()), "Month calculation for 2022-01 is wrong: should be %v, but is %v", spent.Neg(), envelopeSpent)
51+
52+
envelopeSpent, err = envelope.Spent(time.Date(2022, 2, 1, 0, 0, 0, 0, time.UTC))
53+
assert.Nil(t, err)
54+
assert.True(t, envelopeSpent.Equal(spent.Neg()), "Month calculation for 2022-02 is wrong: should be %v, but is %v", spent, envelopeSpent)
55+
}

0 commit comments

Comments
 (0)