Skip to content

Commit d68239f

Browse files
committed
feat: add month endpoint for budgets
Resolves #43.
1 parent d29a212 commit d68239f

File tree

4 files changed

+175
-0
lines changed

4 files changed

+175
-0
lines changed

internal/controllers/budget.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package controllers
22

33
import (
44
"net/http"
5+
"time"
56

67
"github.com/envelope-zero/backend/internal/models"
78
"github.com/gin-gonic/gin"
@@ -63,10 +64,38 @@ func GetBudget(c *gin.Context) {
6364
return
6465
}
6566

67+
// Parse month from the request
68+
var month Month
69+
if err := c.ShouldBind(&month); err != nil {
70+
FetchErrorHandler(c, err)
71+
return
72+
}
73+
74+
if !month.Month.IsZero() {
75+
envelopes, err := getEnvelopes(c)
76+
if err != nil {
77+
return
78+
}
79+
80+
var envelopeMonths []models.EnvelopeMonth
81+
for _, envelope := range envelopes {
82+
envelopeMonths = append(envelopeMonths, envelope.Month(month.Month))
83+
}
84+
85+
c.JSON(http.StatusOK, gin.H{"data": models.BudgetMonth{
86+
ID: budget.ID,
87+
Name: budget.Name,
88+
Month: time.Date(month.Month.UTC().Year(), month.Month.UTC().Month(), 1, 0, 0, 0, 0, time.UTC),
89+
Envelopes: envelopeMonths,
90+
}})
91+
return
92+
}
93+
6694
c.JSON(http.StatusOK, gin.H{"data": budget, "links": map[string]string{
6795
"accounts": requestURL(c) + "/accounts",
6896
"categories": requestURL(c) + "/categories",
6997
"transactions": requestURL(c) + "/transactions",
98+
"month": requestURL(c) + "?month=YYYY-MM",
7099
}})
71100
}
72101

internal/controllers/budget_test.go

Lines changed: 98 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

@@ -20,6 +21,10 @@ type BudgetDetailResponse struct {
2021
test.APIResponse
2122
Data models.Budget
2223
}
24+
type BudgetMonthResponse struct {
25+
test.APIResponse
26+
Data models.BudgetMonth
27+
}
2328

2429
func TestGetBudgets(t *testing.T) {
2530
recorder := test.Request(t, "GET", "/v1/budgets", "")
@@ -94,6 +99,99 @@ func TestGetBudget(t *testing.T) {
9499
assert.Equal(t, dbBudget, budget.Data)
95100
}
96101

102+
// TestBudgetMonth verifies that the monthly calculations are correct.
103+
func TestBudgetMonth(t *testing.T) {
104+
var budgetMonth BudgetMonthResponse
105+
106+
tests := []struct {
107+
path string
108+
response BudgetMonthResponse
109+
}{
110+
{
111+
"/v1/budgets/1?month=2022-01",
112+
BudgetMonthResponse{
113+
Data: models.BudgetMonth{
114+
Month: time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC),
115+
Envelopes: []models.EnvelopeMonth{
116+
{
117+
ID: 1,
118+
Name: "Utilities",
119+
Month: time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC),
120+
Spent: decimal.NewFromFloat(-10),
121+
Balance: decimal.NewFromFloat(10.99),
122+
Allocation: decimal.NewFromFloat(20.99),
123+
},
124+
},
125+
},
126+
},
127+
},
128+
{
129+
"/v1/budgets/1?month=2022-02",
130+
BudgetMonthResponse{
131+
Data: models.BudgetMonth{
132+
Month: time.Date(2022, 2, 1, 0, 0, 0, 0, time.UTC),
133+
Envelopes: []models.EnvelopeMonth{
134+
{
135+
ID: 1,
136+
Name: "Utilities",
137+
Month: time.Date(2022, 2, 1, 0, 0, 0, 0, time.UTC),
138+
Spent: decimal.NewFromFloat(-5),
139+
Balance: decimal.NewFromFloat(42.12),
140+
Allocation: decimal.NewFromFloat(47.12),
141+
},
142+
},
143+
},
144+
},
145+
},
146+
{
147+
"/v1/budgets/1?month=2022-03",
148+
BudgetMonthResponse{
149+
Data: models.BudgetMonth{
150+
Month: time.Date(2022, 3, 1, 0, 0, 0, 0, time.UTC),
151+
Envelopes: []models.EnvelopeMonth{
152+
{
153+
ID: 1,
154+
Name: "Utilities",
155+
Month: time.Date(2022, 3, 1, 0, 0, 0, 0, time.UTC),
156+
Spent: decimal.NewFromFloat(-15),
157+
Balance: decimal.NewFromFloat(16.17),
158+
Allocation: decimal.NewFromFloat(31.17),
159+
},
160+
},
161+
},
162+
},
163+
},
164+
}
165+
166+
for _, tt := range tests {
167+
r := test.Request(t, "GET", tt.path, "")
168+
test.AssertHTTPStatus(t, http.StatusOK, &r)
169+
test.DecodeResponse(t, &r, &budgetMonth)
170+
171+
if !assert.Len(t, budgetMonth.Data.Envelopes, len(tt.response.Data.Envelopes)) {
172+
assert.FailNow(t, "Reponse length does not match!", "Response does not have exactly %v item(s)", len(tt.response.Data.Envelopes))
173+
}
174+
175+
for i, envelope := range budgetMonth.Data.Envelopes {
176+
assert.True(t, envelope.Spent.Equal(tt.response.Data.Envelopes[i].Spent), "Monthly spent calculation for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, tt.response.Data.Envelopes[i].Spent, envelope.Spent, budgetMonth.Data)
177+
assert.True(t, envelope.Balance.Equal(tt.response.Data.Envelopes[i].Balance), "Monthly balance calculation for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, tt.response.Data.Envelopes[i].Balance, envelope.Balance, budgetMonth.Data)
178+
assert.True(t, envelope.Allocation.Equal(tt.response.Data.Envelopes[i].Allocation), "Monthly allocation fetch for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, tt.response.Data.Envelopes[i].Allocation, envelope.Allocation, budgetMonth.Data)
179+
}
180+
}
181+
}
182+
183+
// TestBudgetMonthInvalid verifies that non-parseable requests return a HTTP 400 Bad Request.
184+
func TestBudgetMonthInvalid(t *testing.T) {
185+
r := test.Request(t, "GET", "/v1/budgets/1?month=Stonks!", "")
186+
test.AssertHTTPStatus(t, http.StatusBadRequest, &r)
187+
}
188+
189+
// TestBudgetMonthNonExistent verifies that month requests for non-existing budgets return a HTTP 404 Not Found.
190+
func TestBudgetMonthNonExistent(t *testing.T) {
191+
r := test.Request(t, "GET", "/v1/budgets/831?month=2022-01", "")
192+
test.AssertHTTPStatus(t, http.StatusNotFound, &r)
193+
}
194+
97195
func TestUpdateBudget(t *testing.T) {
98196
recorder := test.Request(t, "POST", "/v1/budgets", `{ "name": "New Budget", "note": "More tests something something" }`)
99197
test.AssertHTTPStatus(t, http.StatusCreated, &recorder)

internal/controllers/helper.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ func getCategory(c *gin.Context) (models.Category, error) {
138138
return category, nil
139139
}
140140

141+
// getCategories returns all categories for the reuqested budget.
142+
func getCategories(c *gin.Context) ([]models.Category, error) {
143+
var categories []models.Category
144+
145+
budget, err := getBudget(c)
146+
if err != nil {
147+
return []models.Category{}, err
148+
}
149+
150+
models.DB.Where(&models.Category{
151+
BudgetID: budget.ID,
152+
}).Find(&categories)
153+
154+
return categories, nil
155+
}
156+
141157
// getEnvelope verifies that the envelope from the URL parameters exists and returns it
142158
//
143159
// It also verifies that the budget and the category that are referred to exist.
@@ -168,6 +184,29 @@ func getEnvelope(c *gin.Context) (models.Envelope, error) {
168184
return envelope, nil
169185
}
170186

187+
// getEnvelopes returns all categories for the reuqested budget4.
188+
func getEnvelopes(c *gin.Context) ([]models.Envelope, error) {
189+
var envelopes []models.Envelope
190+
191+
categories, err := getCategories(c)
192+
if err != nil {
193+
return []models.Envelope{}, err
194+
}
195+
196+
// Get envelopes for all categories
197+
for _, category := range categories {
198+
var e []models.Envelope
199+
200+
models.DB.Where(&models.Envelope{
201+
CategoryID: category.ID,
202+
}).Find(&e)
203+
204+
envelopes = append(envelopes, e...)
205+
}
206+
207+
return envelopes, nil
208+
}
209+
171210
// getAllocation verifies that the request URI is valid for the transaction and returns it.
172211
func getAllocation(c *gin.Context) (models.Allocation, error) {
173212
var allocation models.Allocation

internal/models/budget.go

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

3+
import "time"
4+
35
// Budget represents a budget
46
//
57
// A budget is the highest level of organization in Envelope Zero, all other
@@ -10,3 +12,10 @@ type Budget struct {
1012
Note string `json:"note,omitempty"`
1113
Currency string `json:"currency,omitempty"`
1214
}
15+
16+
type BudgetMonth struct {
17+
ID uint64 `json:"id"`
18+
Name string `json:"name"`
19+
Month time.Time `json:"month"`
20+
Envelopes []EnvelopeMonth `json:"envelopes,omitempty"`
21+
}

0 commit comments

Comments
 (0)