Skip to content

Commit 3a02562

Browse files
committed
fix: envelope balance does not roll over
1 parent e42b5d7 commit 3a02562

File tree

5 files changed

+205
-11
lines changed

5 files changed

+205
-11
lines changed

docs/usage.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ The key of the budget. Each envelope tracks budget for one specific purpose, e.g
2121

2222
If you do not spend all of the allocated budget, it rolls over to the next month. This allows to allocate a budget for expenses that do not occur that often. Say you have an insurance that is billed yearly for 120€. In this case, you can budget 10€ per month for this insurance. Once the bill arrives, all the needed budget is already allocated.
2323

24+
Contrary to other popular tools, if you overspend on an Envelope, the negative balance also rolls over to the next month and has to be balanced by allocating this amount. It is planned to make this behaviour configurable by budget. If you want to help out in the development of this, see [#327](https://github.com/envelope-zero/backend/issues/327).
25+
2426
### Transaction
2527

2628
A transaction represents an actual transaction. Paying for groceries, getting paid your salary, gifting somebody money for their wedding, all of those are transactions. Every transaction must be tied to a source account and destination account.

pkg/controllers/budget_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ func (suite *TestSuiteEnv) TestBudgetMonth() {
307307
Name: "Utilities",
308308
Month: time.Date(2022, 2, 1, 0, 0, 0, 0, time.UTC),
309309
Spent: decimal.NewFromFloat(-5),
310-
Balance: decimal.NewFromFloat(42.12),
310+
Balance: decimal.NewFromFloat(53.11),
311311
Allocation: decimal.NewFromFloat(47.12),
312312
},
313313
},
@@ -325,7 +325,7 @@ func (suite *TestSuiteEnv) TestBudgetMonth() {
325325
Name: "Utilities",
326326
Month: time.Date(2022, 3, 1, 0, 0, 0, 0, time.UTC),
327327
Spent: decimal.NewFromFloat(-15),
328-
Balance: decimal.NewFromFloat(16.17),
328+
Balance: decimal.NewFromFloat(69.28),
329329
Allocation: decimal.NewFromFloat(31.17),
330330
},
331331
},

pkg/controllers/envelope_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ func (suite *TestSuiteEnv) TestEnvelopeMonth() {
285285
Name: "Utilities",
286286
Month: time.Date(2022, 2, 1, 0, 0, 0, 0, time.UTC),
287287
Spent: decimal.NewFromFloat(-5),
288-
Balance: decimal.NewFromFloat(42.12),
288+
Balance: decimal.NewFromFloat(53.11),
289289
Allocation: decimal.NewFromFloat(47.12),
290290
},
291291
},
@@ -295,7 +295,7 @@ func (suite *TestSuiteEnv) TestEnvelopeMonth() {
295295
Name: "Utilities",
296296
Month: time.Date(2022, 3, 1, 0, 0, 0, 0, time.UTC),
297297
Spent: decimal.NewFromFloat(-15),
298-
Balance: decimal.NewFromFloat(16.17),
298+
Balance: decimal.NewFromFloat(69.28),
299299
Allocation: decimal.NewFromFloat(31.17),
300300
},
301301
},
@@ -312,6 +312,8 @@ func (suite *TestSuiteEnv) TestEnvelopeMonth() {
312312
},
313313
}
314314

315+
// Sum alloc: 99.28
316+
315317
var envelopeMonth controllers.EnvelopeMonthResponse
316318
for _, tt := range tests {
317319
r := test.Request(suite.T(), http.MethodGet, tt.path, "")

pkg/models/envelope.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,72 @@ func (e Envelope) Spent(t time.Time) decimal.Decimal {
6565
return incomingSum.Sub(outgoingSum)
6666
}
6767

68+
// Balance calculates the balance of an Envelope in a specific month
69+
// This code performs negative and positive rollover. See also
70+
// https://github.com/envelope-zero/backend/issues/327
71+
func (e Envelope) Balance(month time.Time) (decimal.Decimal, error) {
72+
// We add one month as the balance should include all transactions and the allocation for the present month
73+
// With that, we can query for all resources where the date/month is < the month
74+
month = time.Date(month.Year(), month.AddDate(0, 1, 0).Month(), 1, 0, 0, 0, 0, time.UTC)
75+
76+
// Sum of incoming transactions
77+
var incoming decimal.NullDecimal
78+
err := database.DB.
79+
Table("transactions").
80+
Select("SUM(amount)").
81+
Joins("JOIN accounts source_account ON transactions.source_account_id = source_account.id AND source_account.deleted_at IS NULL").
82+
Joins("JOIN accounts destination_account ON transactions.destination_account_id = destination_account.id AND destination_account.deleted_at IS NULL").
83+
Where("source_account.external = 1 AND destination_account.external = 0 AND transactions.envelope_id = ?", e.ID).
84+
Where("transactions.date < date(?) ", month).
85+
Find(&incoming).Error
86+
if err != nil {
87+
return decimal.Zero, err
88+
}
89+
90+
// If no transactions are found, the value is nil
91+
if !incoming.Valid {
92+
incoming.Decimal = decimal.Zero
93+
}
94+
95+
// Sum of outgoing transactions
96+
var outgoing decimal.NullDecimal
97+
err = database.DB.
98+
Table("transactions").
99+
Select("SUM(amount)").
100+
Joins("JOIN accounts source_account ON transactions.source_account_id = source_account.id AND source_account.deleted_at IS NULL").
101+
Joins("JOIN accounts destination_account ON transactions.destination_account_id = destination_account.id AND destination_account.deleted_at IS NULL").
102+
Where("source_account.external = 0 AND destination_account.external = 1 AND transactions.envelope_id = ?", e.ID).
103+
Where("transactions.date < date(?) ", month).
104+
Find(&outgoing).Error
105+
if err != nil {
106+
return decimal.Zero, err
107+
}
108+
109+
// If no transactions are found, the value is nil
110+
if !outgoing.Valid {
111+
outgoing.Decimal = decimal.Zero
112+
}
113+
114+
var budgeted decimal.NullDecimal
115+
err = database.DB.
116+
Select("SUM(amount)").
117+
Where("allocations.envelope_id = ?", e.ID).
118+
Where("allocations.month < date(?) ", month).
119+
Table("allocations").
120+
Find(&budgeted).
121+
Error
122+
if err != nil {
123+
return decimal.Zero, err
124+
}
125+
126+
// If no transactions are found, the value is nil
127+
if !budgeted.Valid {
128+
budgeted.Decimal = decimal.Zero
129+
}
130+
131+
return budgeted.Decimal.Add(incoming.Decimal).Sub(outgoing.Decimal), nil
132+
}
133+
68134
// Month calculates the month specific values for an envelope and returns an EnvelopeMonth for them.
69135
func (e Envelope) Month(t time.Time) (EnvelopeMonth, error) {
70136
spent := e.Spent(t)
@@ -92,7 +158,11 @@ func (e Envelope) Month(t time.Time) (EnvelopeMonth, error) {
92158
return EnvelopeMonth{}, err
93159
}
94160

95-
envelopeMonth.Balance = allocation.Amount.Add(spent)
161+
envelopeMonth.Balance, err = e.Balance(month)
162+
if err != nil {
163+
return EnvelopeMonth{}, err
164+
}
165+
96166
envelopeMonth.Allocation = allocation.Amount
97167
return envelopeMonth, nil
98168
}

pkg/models/envelope_test.go

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ func (suite *TestSuiteEnv) TestEnvelopeMonthSum() {
6060
suite.Assert().Fail("Resource could not be saved", err)
6161
}
6262

63+
january := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
64+
6365
spent := decimal.NewFromFloat(17.32)
6466
transaction := &models.Transaction{
6567
TransactionCreate: models.TransactionCreate{
@@ -68,7 +70,7 @@ func (suite *TestSuiteEnv) TestEnvelopeMonthSum() {
6870
Amount: spent,
6971
SourceAccountID: internalAccount.ID,
7072
DestinationAccountID: externalAccount.ID,
71-
Date: time.Date(2022, 1, 15, 0, 0, 0, 0, time.UTC),
73+
Date: january,
7274
},
7375
}
7476
err = database.DB.Create(&transaction).Error
@@ -83,19 +85,19 @@ func (suite *TestSuiteEnv) TestEnvelopeMonthSum() {
8385
Amount: spent.Neg(),
8486
SourceAccountID: externalAccount.ID,
8587
DestinationAccountID: internalAccount.ID,
86-
Date: time.Date(2022, 2, 15, 0, 0, 0, 0, time.UTC),
88+
Date: january.AddDate(0, 1, 0),
8789
},
8890
}
8991
err = database.DB.Create(&transactionIn).Error
9092
if err != nil {
9193
suite.Assert().Fail("Resource could not be saved", err)
9294
}
9395

94-
envelopeMonth, err := envelope.Month(time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC))
96+
envelopeMonth, err := envelope.Month(january)
9597
assert.Nil(suite.T(), err)
9698
assert.True(suite.T(), envelopeMonth.Spent.Equal(spent.Neg()), "Month calculation for 2022-01 is wrong: should be %v, but is %v", spent.Neg(), envelopeMonth.Spent)
9799

98-
envelopeMonth, err = envelope.Month(time.Date(2022, 2, 1, 0, 0, 0, 0, time.UTC))
100+
envelopeMonth, err = envelope.Month(january.AddDate(0, 1, 0))
99101
assert.Nil(suite.T(), err)
100102
assert.True(suite.T(), envelopeMonth.Spent.Equal(spent.Neg()), "Month calculation for 2022-02 is wrong: should be %v, but is %v", spent, envelopeMonth.Spent)
101103

@@ -104,7 +106,7 @@ func (suite *TestSuiteEnv) TestEnvelopeMonthSum() {
104106
suite.Assert().Fail("Resource could not be deleted", err)
105107
}
106108

107-
envelopeMonth, err = envelope.Month(time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC))
109+
envelopeMonth, err = envelope.Month(january)
108110
assert.Nil(suite.T(), err)
109111
assert.True(suite.T(), envelopeMonth.Spent.Equal(decimal.NewFromFloat(0)), "Month calculation for 2022-01 is wrong: should be %v, but is %v", decimal.NewFromFloat(0), envelopeMonth.Spent)
110112
}
@@ -160,5 +162,123 @@ func (suite *TestSuiteEnv) TestCreateTransactionNoEnvelope() {
160162
}
161163
err = database.DB.Create(&transaction).Error
162164

163-
assert.Nil(suite.T(), err, "Transactions must be able to be created without an envelope (to enable internal transfers without an Envelope)")
165+
assert.Nil(suite.T(), err, "Transactions must be able to be created without an envelope (to enable internal transfers without an Envelope and income transactions)")
166+
}
167+
168+
func (suite *TestSuiteEnv) TestEnvelopeMonthBalance() {
169+
budget := models.Budget{}
170+
err := database.DB.Save(&budget).Error
171+
if err != nil {
172+
suite.Assert().Fail("Resource could not be saved", err)
173+
}
174+
175+
internalAccount := &models.Account{
176+
AccountCreate: models.AccountCreate{
177+
Name: "Internal Source Account",
178+
BudgetID: budget.ID,
179+
},
180+
}
181+
err = database.DB.Create(internalAccount).Error
182+
if err != nil {
183+
suite.Assert().Fail("Resource could not be saved", err)
184+
}
185+
186+
externalAccount := &models.Account{
187+
AccountCreate: models.AccountCreate{
188+
Name: "External Destination Account",
189+
BudgetID: budget.ID,
190+
External: true,
191+
},
192+
}
193+
err = database.DB.Create(&externalAccount).Error
194+
if err != nil {
195+
suite.Assert().Fail("Resource could not be saved", err)
196+
}
197+
198+
category := models.Category{
199+
CategoryCreate: models.CategoryCreate{
200+
BudgetID: budget.ID,
201+
},
202+
}
203+
err = database.DB.Save(&category).Error
204+
if err != nil {
205+
suite.Assert().Fail("Resource could not be saved", err)
206+
}
207+
208+
envelope := &models.Envelope{
209+
EnvelopeCreate: models.EnvelopeCreate{
210+
Name: "Testing envelope",
211+
CategoryID: category.ID,
212+
},
213+
}
214+
err = database.DB.Create(&envelope).Error
215+
if err != nil {
216+
suite.Assert().Fail("Resource could not be saved", err)
217+
}
218+
219+
january := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
220+
221+
allocationJan := &models.Allocation{
222+
AllocationCreate: models.AllocationCreate{
223+
EnvelopeID: envelope.ID,
224+
Month: january,
225+
Amount: decimal.NewFromFloat(50),
226+
},
227+
}
228+
err = database.DB.Create(&allocationJan).Error
229+
if err != nil {
230+
suite.Assert().Fail("Resource could not be saved", err)
231+
}
232+
233+
allocationFeb := &models.Allocation{
234+
AllocationCreate: models.AllocationCreate{
235+
EnvelopeID: envelope.ID,
236+
Month: january.AddDate(0, 1, 0),
237+
Amount: decimal.NewFromFloat(40),
238+
},
239+
}
240+
err = database.DB.Create(&allocationFeb).Error
241+
if err != nil {
242+
suite.Assert().Fail("Resource could not be saved", err)
243+
}
244+
245+
transaction := &models.Transaction{
246+
TransactionCreate: models.TransactionCreate{
247+
BudgetID: budget.ID,
248+
EnvelopeID: &envelope.ID,
249+
Amount: decimal.NewFromFloat(15),
250+
SourceAccountID: internalAccount.ID,
251+
DestinationAccountID: externalAccount.ID,
252+
Date: january,
253+
},
254+
}
255+
err = database.DB.Create(&transaction).Error
256+
if err != nil {
257+
suite.Assert().Fail("Resource could not be saved", err)
258+
}
259+
260+
transaction2 := &models.Transaction{
261+
TransactionCreate: models.TransactionCreate{
262+
BudgetID: budget.ID,
263+
EnvelopeID: &envelope.ID,
264+
Amount: decimal.NewFromFloat(30),
265+
SourceAccountID: internalAccount.ID,
266+
DestinationAccountID: externalAccount.ID,
267+
Date: january.AddDate(0, 1, 0),
268+
},
269+
}
270+
err = database.DB.Create(&transaction2).Error
271+
if err != nil {
272+
suite.Assert().Fail("Resource could not be saved", err)
273+
}
274+
275+
shouldBalance := decimal.NewFromFloat(35)
276+
envelopeMonth, err := envelope.Month(january)
277+
assert.Nil(suite.T(), err)
278+
assert.True(suite.T(), envelopeMonth.Balance.Equal(shouldBalance), "Balance calculation for 2022-01 is wrong: should be %v, but is %v", shouldBalance, envelopeMonth.Balance)
279+
280+
shouldBalance = decimal.NewFromFloat(45)
281+
envelopeMonth, err = envelope.Month(january.AddDate(0, 1, 0))
282+
assert.Nil(suite.T(), err)
283+
assert.True(suite.T(), envelopeMonth.Balance.Equal(shouldBalance), "Balance calculation for 2022-02 is wrong: should be %v, but is %v", shouldBalance, envelopeMonth.Balance)
164284
}

0 commit comments

Comments
 (0)