Skip to content

Commit 74b9021

Browse files
authored
feat: add income to budget month calculations (#305)
1 parent 23690c5 commit 74b9021

File tree

7 files changed

+83
-10
lines changed

7 files changed

+83
-10
lines changed

api/docs.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2673,6 +2673,10 @@ const docTemplate = `{
26732673
"type": "string",
26742674
"example": "1e777d24-3f5b-4c43-8000-04f65f895578"
26752675
},
2676+
"income": {
2677+
"type": "number",
2678+
"example": 2317.34
2679+
},
26762680
"month": {
26772681
"description": "This is always set to 00:00 UTC on the first of the month.",
26782682
"type": "string",

api/swagger.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2661,6 +2661,10 @@
26612661
"type": "string",
26622662
"example": "1e777d24-3f5b-4c43-8000-04f65f895578"
26632663
},
2664+
"income": {
2665+
"type": "number",
2666+
"example": 2317.34
2667+
},
26642668
"month": {
26652669
"description": "This is always set to 00:00 UTC on the first of the month.",
26662670
"type": "string",

api/swagger.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,9 @@ definitions:
441441
description: The ID of the Envelope
442442
example: 1e777d24-3f5b-4c43-8000-04f65f895578
443443
type: string
444+
income:
445+
example: 2317.34
446+
type: number
444447
month:
445448
description: This is always set to 00:00 UTC on the first of the month.
446449
example: "2006-05-01T00:00:00.000000Z"

pkg/controllers/budget.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,10 +351,18 @@ func GetBudgetMonth(c *gin.Context) {
351351
budgeted = budgeted.Add(allocation.Amount)
352352
}
353353

354+
// Calculate the income
355+
income, err := budget.Income(month.Month)
356+
if err != nil {
357+
httperrors.Handler(c, err)
358+
return
359+
}
360+
354361
c.JSON(http.StatusOK, BudgetMonthResponse{Data: models.BudgetMonth{
355362
ID: budget.ID,
356363
Name: budget.Name,
357-
Month: time.Date(month.Month.UTC().Year(), month.Month.UTC().Month(), 1, 0, 0, 0, 0, time.UTC),
364+
Month: month.Month,
365+
Income: income,
358366
Budgeted: budgeted,
359367
Envelopes: envelopeMonths,
360368
}})
@@ -598,7 +606,7 @@ func getBudgetResource(c *gin.Context, id uuid.UUID) (models.Budget, error) {
598606
return models.Budget{}, err
599607
}
600608

601-
return budget, nil
609+
return budget.WithCalculations(), nil
602610
}
603611

604612
func getBudgetObject(c *gin.Context, id uuid.UUID) (Budget, error) {

pkg/controllers/budget_test.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,16 @@ func (suite *TestSuiteEnv) TestBudgetMonth() {
264264
Reconciled: true,
265265
})
266266

267+
_ = createTestTransaction(suite.T(), models.TransactionCreate{
268+
Date: time.Date(2022, 3, 1, 7, 38, 17, 0, time.UTC),
269+
Amount: decimal.NewFromFloat(1500),
270+
Note: "Income for march",
271+
BudgetID: budget.Data.ID,
272+
SourceAccountID: externalAccount.Data.ID,
273+
DestinationAccountID: account.Data.ID,
274+
EnvelopeID: nil,
275+
})
276+
267277
tests := []struct {
268278
path string
269279
response controllers.BudgetMonthResponse
@@ -272,7 +282,8 @@ func (suite *TestSuiteEnv) TestBudgetMonth() {
272282
fmt.Sprintf("%s/2022-01", budget.Data.Links.Self),
273283
controllers.BudgetMonthResponse{
274284
Data: models.BudgetMonth{
275-
Month: time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC),
285+
Month: time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC),
286+
Income: decimal.NewFromFloat(0),
276287
Envelopes: []models.EnvelopeMonth{
277288
{
278289
Name: "Utilities",
@@ -289,7 +300,8 @@ func (suite *TestSuiteEnv) TestBudgetMonth() {
289300
fmt.Sprintf("%s/2022-02", budget.Data.Links.Self),
290301
controllers.BudgetMonthResponse{
291302
Data: models.BudgetMonth{
292-
Month: time.Date(2022, 2, 1, 0, 0, 0, 0, time.UTC),
303+
Month: time.Date(2022, 2, 1, 0, 0, 0, 0, time.UTC),
304+
Income: decimal.NewFromFloat(0),
293305
Envelopes: []models.EnvelopeMonth{
294306
{
295307
Name: "Utilities",
@@ -306,7 +318,8 @@ func (suite *TestSuiteEnv) TestBudgetMonth() {
306318
fmt.Sprintf("%s/2022-03", budget.Data.Links.Self),
307319
controllers.BudgetMonthResponse{
308320
Data: models.BudgetMonth{
309-
Month: time.Date(2022, 3, 1, 0, 0, 0, 0, time.UTC),
321+
Month: time.Date(2022, 3, 1, 0, 0, 0, 0, time.UTC),
322+
Income: decimal.NewFromFloat(1500),
310323
Envelopes: []models.EnvelopeMonth{
311324
{
312325
Name: "Utilities",
@@ -327,16 +340,18 @@ func (suite *TestSuiteEnv) TestBudgetMonth() {
327340
test.AssertHTTPStatus(suite.T(), http.StatusOK, &r)
328341
test.DecodeResponse(suite.T(), &r, &budgetMonth)
329342

330-
// assert.FailNow(suite.T(), "BudgetMonth", budgetMonth)
343+
// Verify income calculation
344+
assert.True(suite.T(), budgetMonth.Data.Income.Equal(tt.response.Data.Income))
331345

332346
if !assert.Len(suite.T(), budgetMonth.Data.Envelopes, 1) {
333347
assert.FailNow(suite.T(), "Response length does not match!", "Response does not have exactly 1 item")
334348
}
335349

350+
expected := tt.response.Data.Envelopes[0]
336351
envelope := budgetMonth.Data.Envelopes[0]
337-
assert.True(suite.T(), envelope.Spent.Equal(tt.response.Data.Envelopes[0].Spent), "Monthly spent calculation for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, tt.response.Data.Envelopes[0].Spent, envelope.Spent, budgetMonth.Data)
338-
assert.True(suite.T(), envelope.Balance.Equal(tt.response.Data.Envelopes[0].Balance), "Monthly balance calculation for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, tt.response.Data.Envelopes[0].Balance, envelope.Balance, budgetMonth.Data)
339-
assert.True(suite.T(), envelope.Allocation.Equal(tt.response.Data.Envelopes[0].Allocation), "Monthly allocation fetch for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, tt.response.Data.Envelopes[0].Allocation, envelope.Allocation, budgetMonth.Data)
352+
assert.True(suite.T(), envelope.Spent.Equal(expected.Spent), "Monthly spent calculation for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, expected.Spent, envelope.Spent, budgetMonth.Data)
353+
assert.True(suite.T(), envelope.Balance.Equal(expected.Balance), "Monthly balance calculation for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, expected.Balance, envelope.Balance, budgetMonth.Data)
354+
assert.True(suite.T(), envelope.Allocation.Equal(expected.Allocation), "Monthly allocation fetch for %v is wrong: should be %v, but is %v: %#v", budgetMonth.Data.Month, expected.Allocation, envelope.Allocation, budgetMonth.Data)
340355
}
341356
}
342357

pkg/models/budget.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type BudgetMonth struct {
3030
Name string `json:"name" example:"Groceries"` // The name of the Envelope
3131
Month time.Time `json:"month" example:"2006-05-01T00:00:00.000000Z"` // This is always set to 00:00 UTC on the first of the month.
3232
Budgeted decimal.Decimal `json:"budgeted" example:"2100"`
33+
Income decimal.Decimal `json:"income" example:"2317.34"`
3334
Envelopes []EnvelopeMonth `json:"envelopes"`
3435
}
3536

@@ -52,3 +53,31 @@ func (b Budget) WithCalculations() Budget {
5253

5354
return b
5455
}
56+
57+
// Income returns the income for a budget in a given month.
58+
func (b Budget) Income(t time.Time) (decimal.Decimal, error) {
59+
var income decimal.NullDecimal
60+
61+
err := database.DB.
62+
Select("SUM(amount)").
63+
Joins("JOIN accounts source_account ON transactions.source_account_id = source_account.id AND source_account.deleted_at IS NULL").
64+
Joins("JOIN accounts destination_account ON transactions.destination_account_id = destination_account.id AND destination_account.deleted_at IS NULL").
65+
Where("source_account.external = 1").
66+
Where("destination_account.external = 0").
67+
Where("transactions.envelope_id IS NULL").
68+
Where("strftime('%m', transactions.date) = ?", fmt.Sprintf("%02d", t.Month())).
69+
Where("strftime('%Y', transactions.date) = ?", fmt.Sprintf("%d", t.Year())).
70+
Table("transactions").
71+
Find(&income).
72+
Error
73+
if err != nil {
74+
return decimal.Zero, err
75+
}
76+
77+
// If no transactions are found, the value is nil
78+
if !income.Valid {
79+
return decimal.NewFromFloat(0), nil
80+
}
81+
82+
return income.Decimal, nil
83+
}

pkg/models/budget_test.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
package models_test
22

33
import (
4+
"time"
5+
46
"github.com/envelope-zero/backend/internal/database"
57
"github.com/envelope-zero/backend/pkg/models"
68
"github.com/shopspring/decimal"
79
"github.com/stretchr/testify/assert"
810
)
911

1012
func (suite *TestSuiteEnv) TestBudgetCalculations() {
13+
marchFifteenthTwentyTwentyTwo := time.Date(2022, 3, 15, 0, 0, 0, 0, time.UTC)
14+
1115
budget := models.Budget{}
1216
err := database.DB.Save(&budget).Error
1317
if err != nil {
@@ -82,8 +86,9 @@ func (suite *TestSuiteEnv) TestBudgetCalculations() {
8286

8387
salaryTransaction := models.Transaction{
8488
TransactionCreate: models.TransactionCreate{
89+
Date: marchFifteenthTwentyTwentyTwo,
8590
BudgetID: budget.ID,
86-
EnvelopeID: &envelope.ID,
91+
EnvelopeID: nil,
8792
SourceAccountID: employerAccount.ID,
8893
DestinationAccountID: bankAccount.ID,
8994
Reconciled: true,
@@ -127,4 +132,9 @@ func (suite *TestSuiteEnv) TestBudgetCalculations() {
127132

128133
shouldBalance := decimal.NewFromFloat(2746.89)
129134
assert.True(suite.T(), budget.Balance.Equal(shouldBalance), "Balance for budget is not correct. Should be %s, is %s", shouldBalance, budget.Balance)
135+
136+
shouldIncome := decimal.NewFromFloat(2857.51)
137+
income, err := budget.Income(marchFifteenthTwentyTwentyTwo)
138+
assert.Nil(suite.T(), err)
139+
assert.True(suite.T(), income.Equal(shouldIncome), "Income is %s, should be %s", income, shouldIncome)
130140
}

0 commit comments

Comments
 (0)