-
Notifications
You must be signed in to change notification settings - Fork 0
/
problems.go
409 lines (362 loc) · 12.2 KB
/
problems.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
package doctor
import (
"fmt"
"github.com/pkg/errors"
"github.com/lieut-data/go-moneywell/api"
"github.com/lieut-data/go-moneywell/api/money"
)
const (
// ProblemNotFullySplit identifies a split transaction whose bucketed children do not sum
// to the transaction amount. This leads to an imbalance that doesn't show up as
// unassigned.
ProblemNotFullySplit = 1
// ProblemSplitParentAssignedBucket identifies a split transaction that is itself assigned
// a bucket. Only the children should be assigned to buckets. This problem has never been
// observed in an actual MoneyWell document.
ProblemSplitParentAssignedBucket = 2
// ProblemTransferInsideCashFlowAssignedBucket identifies a transfer that should not be
// assigned a bucket since both accounts are inside the cash flow.
ProblemTransferInsideCashFlowAssignedBucket = 3
// ProblemTransferOutsideCashFlowAssignedBucket identifies a transfer that should not be
// assigned a bucket since both accounts are outside the cash flow.
ProblemTransferOutsideCashFlowAssignedBucket = 4
// ProblemTransferOutOfCashFlowMissingBucket identifies a transfer that should be assigned
// a bucket since it moves money out of the cash flow.
ProblemTransferOutOfCashFlowMissingBucket = 5
// ProblemTransferFromCashFlowAssignedBucket identifies a transfer that should not be
// assigned a bucket since it receives money from inside the cash flow.
ProblemTransferFromCashFlowAssignedBucket = 6
// ProblemBucketOptionalInsideCashFlow identifies a transaction marked as bucket optional
// that should not be.
ProblemBucketOptionalInsideCashFlow = 7
// ProblemMissingBucketInsideCashFlow identifies a transaction missing an assigned bucket.
ProblemMissingBucketInsideCashFlow = 8
// ProblemBucketOutsideCashFlow identifies a non-transfer transaction incorrectly having
// a transaction assigned.
ProblemBucketOutsideCashFlow = 9
)
// ProblematicTranscations represents a transaction diagnosed with a potential problem.
type ProblematicTransaction struct {
Transaction int64
Problem int
Description string
}
// GetProblematicTransactions finds transactions with potential problems, typically leading to
// an imbalance between accounts and buckets within MoneyWell.
func GetProblematicTransactions(
settings api.Settings,
accounts []api.Account,
transactions []api.Transaction,
) ([]ProblematicTransaction, error) {
problematicTransactions := []ProblematicTransaction{}
for _, transaction := range transactions {
// Ignore transactions before the cash flow start date. They won't contribute
// to any current imbalance.
if transaction.Date.Before(settings.CashFlowStartDate) {
continue
}
// Ignore $0.00 transactions. These won't contribute to an imbalance, and might
// be used to demarcate initial balances.
if transaction.Amount.IsZero() {
continue
}
account, err := getAccount(accounts, transaction.Account)
if err != nil {
return nil, errors.WithStack(err)
}
problematicSplitTransactions, err := checkSplitTransaction(
account,
transactions,
transaction,
)
if err != nil {
return nil, errors.WithStack(err)
}
problematicTransactions = append(
problematicTransactions,
problematicSplitTransactions...,
)
problematicTransferTransactions, err := checkTransferTransaction(
accounts,
account,
transactions,
transaction,
)
if err != nil {
return nil, errors.WithStack(err)
}
problematicTransactions = append(
problematicTransactions,
problematicTransferTransactions...,
)
problematicBucketOptionalTransactions, err := checkBucketOptionalTransaction(
account,
transactions,
transaction,
)
if err != nil {
return nil, errors.WithStack(err)
}
problematicTransactions = append(
problematicTransactions,
problematicBucketOptionalTransactions...,
)
problematicMissingBucketTransactions, err := checkMissingBucketTransaction(
account,
transactions,
transaction,
)
if err != nil {
return nil, errors.WithStack(err)
}
problematicTransactions = append(
problematicTransactions,
problematicMissingBucketTransactions...,
)
problematicInvalidBucketTransactions, err := checkInvalidBucketTransaction(
account,
transactions,
transaction,
)
if err != nil {
return nil, errors.WithStack(err)
}
problematicTransactions = append(
problematicTransactions,
problematicInvalidBucketTransactions...,
)
}
return problematicTransactions, nil
}
func checkSplitTransaction(
account api.Account,
transactions []api.Transaction,
transaction api.Transaction,
) ([]ProblematicTransaction, error) {
if !transaction.IsSplit {
return nil, nil
}
problematicTransactions := []ProblematicTransaction{}
// The split parent in a transaction should not be assigned a bucket.
if transaction.Bucket != 0 {
problematicTransactions = append(problematicTransactions, ProblematicTransaction{
Transaction: transaction.PrimaryKey,
Problem: ProblemSplitParentAssignedBucket,
Description: fmt.Sprintf(
"%s should not be assigned to a bucket",
describeTransaction("split parent", account, transaction),
),
})
}
// The children of a split transaction should sum to the transaction amount. Otherwise,
// this creates an imbalance that doesn't even show up in the "Unassigned" Smart Bucket
// within MoneyWell.
// Find and sum the children
// TODO: Avoid O(n^2) only if this ever seems slow.
childBalance := money.Money{}
for _, child := range transactions {
if child.SplitParent == transaction.PrimaryKey {
childBalance = childBalance.Add(child.Amount)
}
}
if transaction.Amount != childBalance {
problematicTransactions = append(problematicTransactions, ProblematicTransaction{
Transaction: transaction.PrimaryKey,
Problem: ProblemNotFullySplit,
Description: fmt.Sprintf(
"%s is not fully split (off by %s)",
describeTransaction(
"transaction",
account,
transaction,
),
transaction.Amount.Add(
childBalance.Multiply(-1),
),
),
})
}
return problematicTransactions, nil
}
func checkTransferTransaction(
accounts []api.Account,
account api.Account,
transactions []api.Transaction,
transaction api.Transaction,
) ([]ProblematicTransaction, error) {
if !transaction.IsTransfer() {
return nil, nil
}
// Assume split transactions are checked elsewhere.
if transaction.IsSplit {
return nil, nil
}
problematicTransactions := []ProblematicTransaction{}
transferAccount, err := getAccount(accounts, transaction.TransferAccount)
if err != nil {
return nil, errors.WithStack(err)
}
// A transfer between accounts inside the cash flow should not have a bucket assigned.
if account.IncludeInCashFlow && transferAccount.IncludeInCashFlow && transaction.Bucket != 0 {
problematicTransactions = append(problematicTransactions, ProblematicTransaction{
Transaction: transaction.PrimaryKey,
Problem: ProblemTransferInsideCashFlowAssignedBucket,
Description: fmt.Sprintf(
"%s between accounts in the cash flow should not be assigned to a bucket",
describeTransaction("transfer", account, transaction),
),
})
}
// A transfer between accounts outside the cash flow should not have a bucket assigned.
if !account.IncludeInCashFlow && !transferAccount.IncludeInCashFlow && transaction.Bucket != 0 {
problematicTransactions = append(problematicTransactions, ProblematicTransaction{
Transaction: transaction.PrimaryKey,
Problem: ProblemTransferOutsideCashFlowAssignedBucket,
Description: fmt.Sprintf(
"%s between accounts outside the cash flow should not be assigned to a bucket",
describeTransaction("transfer", account, transaction),
),
})
}
// A transfer between accounts with one in the cash flow and one outside should have a
// bucket assigned only on the account inside the cash flow.
if account.IncludeInCashFlow && !transferAccount.IncludeInCashFlow && transaction.Bucket == 0 {
problematicTransactions = append(problematicTransactions, ProblematicTransaction{
Transaction: transaction.PrimaryKey,
Problem: ProblemTransferOutOfCashFlowMissingBucket,
Description: fmt.Sprintf(
"%s from account inside cash flow to account outside cash flow should be assigned to a bucket",
describeTransaction("transfer", account, transaction),
),
})
} else if !account.IncludeInCashFlow && transferAccount.IncludeInCashFlow && transaction.Bucket != 0 {
problematicTransactions = append(problematicTransactions, ProblematicTransaction{
Transaction: transaction.PrimaryKey,
Problem: ProblemTransferFromCashFlowAssignedBucket,
Description: fmt.Sprintf(
"%s from account outside cash flow to account inside cash flow should not be assigned to a bucket",
describeTransaction("transfer", account, transaction),
),
})
}
return problematicTransactions, nil
}
func checkBucketOptionalTransaction(
account api.Account,
transactions []api.Transaction,
transaction api.Transaction,
) ([]ProblematicTransaction, error) {
// If it's not marked as bucket optional, it's not a problem!
if !transaction.IsBucketOptional {
return nil, nil
}
// Assume split transactions are checked elsewhere.
if transaction.IsSplit {
return nil, nil
}
// A transaction against an account outside the cash flow won't impact the cash flow.
if !account.IncludeInCashFlow {
return nil, nil
}
// A transaction marked as bucket optional but that strangely has a bucket assigned seems
// to occur from time to time normally.
if transaction.Bucket != 0 {
return nil, nil
}
// Assume transfer transfers are checked elsewhere.
if transaction.IsTransfer() {
return nil, nil
}
// A transaction should generally not be marked as bucket optional in an account that is
// part of the of the cash flow.
return []ProblematicTransaction{
{
Transaction: transaction.PrimaryKey,
Problem: ProblemBucketOptionalInsideCashFlow,
Description: fmt.Sprintf(
"%s should not be marked as bucket optional in a cash flow account",
describeTransaction("transaction", account, transaction),
),
},
}, nil
}
func checkMissingBucketTransaction(
account api.Account,
transactions []api.Transaction,
transaction api.Transaction,
) ([]ProblematicTransaction, error) {
// If a bucket is assigned, it's not missing!
if transaction.Bucket != 0 {
return nil, nil
}
// Assume transfer and split transactions are checked elsewhere.
if transaction.IsTransfer() || transaction.IsSplit {
return nil, nil
}
// A transaction against an account outside the cash flow won't impact the cash flow.
if !account.IncludeInCashFlow {
return nil, nil
}
return []ProblematicTransaction{
{
Transaction: transaction.PrimaryKey,
Problem: ProblemMissingBucketInsideCashFlow,
Description: fmt.Sprintf(
"%s is not assigned to a bucket",
describeTransaction("transaction", account, transaction),
),
},
}, nil
}
func checkInvalidBucketTransaction(
account api.Account,
transactions []api.Transaction,
transaction api.Transaction,
) ([]ProblematicTransaction, error) {
// Assume transfer and split transactions are checked elsewhere.
if transaction.IsTransfer() || transaction.IsSplit {
return nil, nil
}
// If a bucket is not assigned, it's not invalid!
if transaction.Bucket == 0 {
return nil, nil
}
// If the account is inside the cash flow, having a bucket assigned is normal.
if account.IncludeInCashFlow {
return nil, nil
}
return []ProblematicTransaction{
{
Transaction: transaction.PrimaryKey,
Problem: ProblemBucketOutsideCashFlow,
Description: fmt.Sprintf(
"%s is incorrectly assigned to a bucket",
describeTransaction("transaction", account, transaction),
),
},
}, nil
}
func describeTransaction(description string, account api.Account, transaction api.Transaction) string {
memo := transaction.Memo
if len(memo) > 1 {
memo = fmt.Sprintf(" (%s)", memo)
}
return fmt.Sprintf(
"%s[%d] on %s against %s for %s%s",
description,
transaction.PrimaryKey,
transaction.Date.Format("2006-01-02"),
account.Name,
transaction.Amount,
memo,
)
}
func getAccount(accounts []api.Account, accountPrimaryKey int64) (api.Account, error) {
// TODO: Avoid O(n^2) only if this ever seems slow.
for _, account := range accounts {
if account.PrimaryKey == accountPrimaryKey {
return account, nil
}
}
return api.Account{}, errors.Errorf("failed to find account %v", accountPrimaryKey)
}