|
1 | 1 | package models |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "sort" |
4 | 5 | "time" |
5 | 6 |
|
6 | 7 | "github.com/google/uuid" |
@@ -70,70 +71,196 @@ func (e Envelope) Spent(db *gorm.DB, t time.Time) decimal.Decimal { |
70 | 71 | return outgoingSum.Sub(incomingSum) |
71 | 72 | } |
72 | 73 |
|
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. |
76 | 92 | 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) |
80 | 94 |
|
81 | | - // Sum of incoming transactions |
82 | | - var incoming decimal.NullDecimal |
| 95 | + // Get all relevant data for rawTransactions |
| 96 | + var rawTransactions []AggregatedTransaction |
83 | 97 | err := db. |
84 | 98 | Table("transactions"). |
85 | | - Select("SUM(amount)"). |
86 | 99 | Joins("JOIN accounts source_account ON transactions.source_account_id = source_account.id AND source_account.deleted_at IS NULL"). |
87 | 100 | 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 |
91 | 105 | if err != nil { |
92 | 106 | return decimal.Zero, err |
93 | 107 | } |
94 | 108 |
|
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) |
98 | 114 | } |
99 | 115 |
|
100 | | - // Sum of outgoing transactions |
101 | | - var outgoing decimal.NullDecimal |
| 116 | + // Get allocations |
| 117 | + var rawAllocations []Allocation |
102 | 118 | 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 |
110 | 123 | if err != nil { |
111 | | - return decimal.Zero, err |
| 124 | + return decimal.Zero, nil |
112 | 125 | } |
113 | 126 |
|
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 |
117 | 131 | } |
118 | 132 |
|
119 | | - var budgeted decimal.NullDecimal |
| 133 | + // Get MonthConfigs |
| 134 | + var rawConfigs []MonthConfig |
120 | 135 | 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 |
127 | 140 | 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 | + } |
129 | 175 | } |
130 | 176 |
|
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 | + } |
134 | 261 | } |
135 | 262 |
|
136 | | - return budgeted.Decimal.Add(incoming.Decimal).Sub(outgoing.Decimal), nil |
| 263 | + return sum, nil |
137 | 264 | } |
138 | 265 |
|
139 | 266 | // Month calculates the month specific values for an envelope and returns an EnvelopeMonth and allocation ID for them. |
|
0 commit comments