Skip to content

Commit d0f4cd8

Browse files
authored
fix: wrong calculation of envelope balance (#492)
This fixes various bugs in the calculation of envelope balances. Envelope balances now also take overspend handling into account.
1 parent 99053d3 commit d0f4cd8

File tree

4 files changed

+177
-44
lines changed

4 files changed

+177
-44
lines changed

.golangci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ issues:
2121
- gocyclo
2222
text: "func `Create`"
2323

24+
- path: pkg/models/envelope.go
25+
linters:
26+
- gocyclo
27+
text: "func `\\(Envelope\\).Balance`"
28+
2429
linters:
2530
enable:
2631
- gocyclo

pkg/models/budget_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ func (suite *TestSuiteStandard) TestBudgetCalculations() {
1818
// Sum of allocations for Grocery Envelope until 2022-03: 67
1919
// Allocations for Grocery Envelope - Outgoing transactions = -43.62
2020
marchFifteenthTwentyTwentyTwo := time.Date(2022, 3, 15, 0, 0, 0, 0, time.UTC)
21+
marchFirstTwentyTwentyTwo := time.Date(2022, 3, 1, 0, 0, 0, 0, time.UTC)
2122

2223
budget := models.Budget{}
2324
err := suite.db.Save(&budget).Error
@@ -101,7 +102,7 @@ func (suite *TestSuiteStandard) TestBudgetCalculations() {
101102
AllocationCreate: models.AllocationCreate{
102103
EnvelopeID: envelope.ID,
103104
Amount: decimal.NewFromFloat(17.42),
104-
Month: marchFifteenthTwentyTwentyTwo.AddDate(0, -2, 0),
105+
Month: marchFirstTwentyTwentyTwo.AddDate(0, -2, 0),
105106
},
106107
}
107108
err = suite.db.Save(&allocation1).Error
@@ -113,7 +114,7 @@ func (suite *TestSuiteStandard) TestBudgetCalculations() {
113114
AllocationCreate: models.AllocationCreate{
114115
EnvelopeID: envelope.ID,
115116
Amount: decimal.NewFromFloat(24.58),
116-
Month: marchFifteenthTwentyTwentyTwo.AddDate(0, -1, 0),
117+
Month: marchFirstTwentyTwentyTwo.AddDate(0, -1, 0),
117118
},
118119
}
119120
err = suite.db.Save(&allocation2).Error
@@ -125,7 +126,7 @@ func (suite *TestSuiteStandard) TestBudgetCalculations() {
125126
AllocationCreate: models.AllocationCreate{
126127
EnvelopeID: envelope.ID,
127128
Amount: decimal.NewFromFloat(25),
128-
Month: marchFifteenthTwentyTwentyTwo,
129+
Month: marchFirstTwentyTwentyTwo,
129130
},
130131
}
131132
err = suite.db.Save(&allocationCurrentMonth).Error

pkg/models/envelope.go

Lines changed: 167 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package models
22

33
import (
4+
"sort"
45
"time"
56

67
"github.com/google/uuid"
@@ -70,70 +71,196 @@ func (e Envelope) Spent(db *gorm.DB, t time.Time) decimal.Decimal {
7071
return outgoingSum.Sub(incomingSum)
7172
}
7273

73-
// Balance calculates the balance of an Envelope in a specific month
74-
// This code performs negative and positive rollover. See also
75-
// https://github.com/envelope-zero/backend/issues/327
74+
type AggregatedTransaction struct {
75+
Amount decimal.Decimal
76+
Date time.Time
77+
SourceAccountExternal bool
78+
DestinationAccountExternal bool
79+
}
80+
81+
type EnvelopeMonthAllocation struct {
82+
Month time.Time
83+
Allocation decimal.Decimal
84+
}
85+
86+
type EnvelopeMonthConfig struct {
87+
Month time.Time
88+
OverspendMode OverspendMode
89+
}
90+
91+
// Balance calculates the balance of an Envelope in a specific month.
7692
func (e Envelope) Balance(db *gorm.DB, month time.Time) (decimal.Decimal, error) {
77-
// We add one month as the balance should include all transactions and the allocation for the present month
78-
// With that, we can query for all resources where the date/month is < the month
79-
month = time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0)
93+
month = time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.UTC)
8094

81-
// Sum of incoming transactions
82-
var incoming decimal.NullDecimal
95+
// Get all relevant data for rawTransactions
96+
var rawTransactions []AggregatedTransaction
8397
err := db.
8498
Table("transactions").
85-
Select("SUM(amount)").
8699
Joins("JOIN accounts source_account ON transactions.source_account_id = source_account.id AND source_account.deleted_at IS NULL").
87100
Joins("JOIN accounts destination_account ON transactions.destination_account_id = destination_account.id AND destination_account.deleted_at IS NULL").
88-
Where("source_account.external = 1 AND destination_account.external = 0 AND transactions.envelope_id = ?", e.ID).
89-
Where("transactions.date < date(?) ", month).
90-
Find(&incoming).Error
101+
Where("transactions.date < date(?)", month.AddDate(0, 1, 0)).
102+
Where("transactions.envelope_id = ?", e.ID).
103+
Select("transactions.amount AS Amount, transactions.date AS Date, source_account.external AS SourceAccountExternal, destination_account.external AS DestinationAccountExternal").
104+
Find(&rawTransactions).Error
91105
if err != nil {
92106
return decimal.Zero, err
93107
}
94108

95-
// If no transactions are found, the value is nil
96-
if !incoming.Valid {
97-
incoming.Decimal = decimal.Zero
109+
// Sort monthTransactions by month
110+
monthTransactions := make(map[time.Time][]AggregatedTransaction)
111+
for _, transaction := range rawTransactions {
112+
tDate := time.Date(transaction.Date.Year(), transaction.Date.Month(), 1, 0, 0, 0, 0, time.UTC)
113+
monthTransactions[tDate] = append(monthTransactions[tDate], transaction)
98114
}
99115

100-
// Sum of outgoing transactions
101-
var outgoing decimal.NullDecimal
116+
// Get allocations
117+
var rawAllocations []Allocation
102118
err = db.
103-
Table("transactions").
104-
Select("SUM(amount)").
105-
Joins("JOIN accounts source_account ON transactions.source_account_id = source_account.id AND source_account.deleted_at IS NULL").
106-
Joins("JOIN accounts destination_account ON transactions.destination_account_id = destination_account.id AND destination_account.deleted_at IS NULL").
107-
Where("source_account.external = 0 AND destination_account.external = 1 AND transactions.envelope_id = ?", e.ID).
108-
Where("transactions.date < date(?) ", month).
109-
Find(&outgoing).Error
119+
Table("allocations").
120+
Where("allocations.month < date(?)", month.AddDate(0, 1, 0)).
121+
Where("allocations.envelope_id = ?", e.ID).
122+
Find(&rawAllocations).Error
110123
if err != nil {
111-
return decimal.Zero, err
124+
return decimal.Zero, nil
112125
}
113126

114-
// If no transactions are found, the value is nil
115-
if !outgoing.Valid {
116-
outgoing.Decimal = decimal.Zero
127+
// Sort allocations by month
128+
allocationMonths := make(map[time.Time]Allocation)
129+
for _, allocation := range rawAllocations {
130+
allocationMonths[allocation.Month] = allocation
117131
}
118132

119-
var budgeted decimal.NullDecimal
133+
// Get MonthConfigs
134+
var rawConfigs []MonthConfig
120135
err = db.
121-
Select("SUM(amount)").
122-
Where("allocations.envelope_id = ?", e.ID).
123-
Where("allocations.month < date(?) ", month).
124-
Table("allocations").
125-
Find(&budgeted).
126-
Error
136+
Table("month_configs").
137+
Where("month_configs.month < date(?)", month.AddDate(0, 1, 0)).
138+
Where("month_configs.envelope_id = ?", e.ID).
139+
Find(&rawConfigs).Error
127140
if err != nil {
128-
return decimal.Zero, err
141+
return decimal.Zero, nil
142+
}
143+
144+
// Sort MonthConfigs by month
145+
configMonths := make(map[time.Time]MonthConfig)
146+
for _, monthConfig := range rawConfigs {
147+
configMonths[monthConfig.Month] = monthConfig
148+
}
149+
150+
// This is a helper map to only add unique months to the
151+
// monthKeys slice
152+
monthsWithData := make(map[time.Time]bool)
153+
154+
// Create a slice of the months that have Allocation
155+
// data to have a sorted list we can iterate over
156+
monthKeys := make([]time.Time, 0)
157+
for k := range allocationMonths {
158+
monthKeys = append(monthKeys, k)
159+
monthsWithData[k] = true
160+
}
161+
162+
// Add the months that have MonthConfigs
163+
for k := range configMonths {
164+
if _, ok := monthsWithData[k]; !ok {
165+
monthKeys = append(monthKeys, k)
166+
monthsWithData[k] = true
167+
}
168+
}
169+
170+
// Add the months that have transaction data
171+
for k := range monthTransactions {
172+
if _, ok := monthsWithData[k]; !ok {
173+
monthKeys = append(monthKeys, k)
174+
}
129175
}
130176

131-
// If no transactions are found, the value is nil
132-
if !budgeted.Valid {
133-
budgeted.Decimal = decimal.Zero
177+
// Sort by time so that earlier months are first
178+
sort.Slice(monthKeys, func(i, j int) bool {
179+
return monthKeys[i].Before(monthKeys[j])
180+
})
181+
182+
if len(monthKeys) == 0 {
183+
return decimal.Zero, nil
184+
}
185+
186+
sum := decimal.Zero
187+
loopMonth := monthKeys[0]
188+
for i := 0; i < len(monthKeys); i++ {
189+
currentMonthTransactions, transactionsOk := monthTransactions[loopMonth]
190+
currentMonthAllocation, allocationOk := allocationMonths[loopMonth]
191+
currentMonthConfig, configOk := configMonths[loopMonth]
192+
193+
// We always go forward one month until we
194+
// reach the last one with data
195+
loopMonth = loopMonth.AddDate(0, 1, 0)
196+
197+
// If there is no data for the current month,
198+
// we loop once more and go on to the next month
199+
//
200+
// We also reset the balance to 0 if it is negative
201+
// since with no MonthConfig, the balance starts from 0 again
202+
if !transactionsOk && !allocationOk && !configOk {
203+
i--
204+
if sum.IsNegative() {
205+
sum = decimal.Zero
206+
}
207+
continue
208+
}
209+
210+
// Initialize the sum for this month
211+
monthSum := sum
212+
213+
for _, transaction := range currentMonthTransactions {
214+
if transaction.SourceAccountExternal {
215+
// Incoming money gets added to the balance
216+
monthSum = monthSum.Add(transaction.Amount)
217+
} else {
218+
// Outgoing gets subtracted
219+
monthSum = monthSum.Sub(transaction.Amount)
220+
}
221+
}
222+
223+
// The zero value for a decimal is Zero, so we don't need to check
224+
// if there is an allocation
225+
monthSum = monthSum.Add(currentMonthAllocation.Amount)
226+
227+
// If the value is not negative, we're done here.
228+
if !monthSum.IsNegative() {
229+
sum = monthSum
230+
continue
231+
}
232+
233+
// If there is overspend and the overspend should affect the envelope,
234+
// the sum for the month is subtracted (using decimal.Add since the
235+
// number is negative)
236+
if monthSum.IsNegative() && configOk && currentMonthConfig.OverspendMode == AffectEnvelope {
237+
sum = monthSum
238+
// If this is the last month, the sum is the monthSum
239+
} else if monthSum.IsNegative() && loopMonth.After(month) {
240+
sum = monthSum
241+
// In all other cases, the overspend affects Available to Budget,
242+
// not the envelope balance
243+
} else if monthSum.IsNegative() {
244+
sum = decimal.Zero
245+
}
246+
247+
// In cases where the sum is negative and we do not have
248+
// configuration for the month before the month we are
249+
// calculating the balance for, we set the balance to 0
250+
// in the last loop iteration.
251+
//
252+
// This stops the rollover of overflow without configuration
253+
// infinitely far into the future.
254+
//
255+
// We check the month before the month we are calculating for
256+
// because if we do not have configuration for the current month,
257+
// negative balance from the month before could still roll over.
258+
if monthSum.IsNegative() && i+1 == len(monthKeys) && loopMonth.Before(month) {
259+
sum = decimal.Zero
260+
}
134261
}
135262

136-
return budgeted.Decimal.Add(incoming.Decimal).Sub(outgoing.Decimal), nil
263+
return sum, nil
137264
}
138265

139266
// Month calculates the month specific values for an envelope and returns an EnvelopeMonth and allocation ID for them.

pkg/models/month_config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import (
1010
type OverspendMode string
1111

1212
const (
13-
AffectEnvelope OverspendMode = "AFFECT_ENVELOPE"
1413
AffectAvailable OverspendMode = "AFFECT_AVAILABLE"
14+
AffectEnvelope OverspendMode = "AFFECT_ENVELOPE"
1515
)
1616

1717
type MonthConfig struct {

0 commit comments

Comments
 (0)