Skip to content

Commit 33312d0

Browse files
authored
feat: add Match Rule import to YNAB4 importer (#830)
1 parent f934bde commit 33312d0

File tree

4 files changed

+105
-0
lines changed

4 files changed

+105
-0
lines changed

pkg/importer/creator.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package importer
22

33
import (
44
"errors"
5+
"fmt"
56

67
"github.com/envelope-zero/backend/v3/pkg/models"
78
"github.com/google/uuid"
@@ -34,6 +35,23 @@ func Create(db *gorm.DB, resources ParsedResources) (models.Budget, error) {
3435
resources.Accounts[idx] = account
3536
}
3637

38+
// Create Match Rules
39+
for _, matchRule := range resources.MatchRules {
40+
aIdx := slices.IndexFunc(resources.Accounts, func(a models.Account) bool { return a.Name == matchRule.Account })
41+
if aIdx == -1 {
42+
tx.Rollback()
43+
return models.Budget{}, fmt.Errorf("the account '%s' specified in the Match Rule matching '%s' could not be found in the list of Accounts", matchRule.Account, matchRule.Match)
44+
}
45+
46+
matchRule.MatchRule.AccountID = resources.Accounts[aIdx].ID
47+
48+
err := tx.Create(&matchRule.MatchRule).Error
49+
if err != nil {
50+
tx.Rollback()
51+
return models.Budget{}, err
52+
}
53+
}
54+
3755
for cName, category := range resources.Categories {
3856
category.Model.BudgetID = budget.ID
3957

pkg/importer/parser/ynab4/parse.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,38 @@ func parsePayees(resources *importer.ParsedResources, payees []Payee) IDToName {
134134
ImportHash: helpers.Sha256String(payee.EntityID),
135135
},
136136
})
137+
138+
// Parse the Match Rules from the payee's rename conditions
139+
for _, r := range payee.RenameConditions {
140+
// Skip deleted rename conditions
141+
if r.Deleted {
142+
continue
143+
}
144+
145+
// Determine the match string. Since EZ uses globs and YNAB4 has different
146+
// operators, we translate between the two
147+
var match string
148+
switch r.Operator {
149+
case "Is":
150+
match = r.Operand
151+
case "Contains":
152+
match = fmt.Sprintf("*%s*", r.Operand)
153+
case "StartsWith":
154+
match = fmt.Sprintf("%s*", r.Operand)
155+
case "EndsWith":
156+
match = fmt.Sprintf("*%s", r.Operand)
157+
}
158+
159+
resources.MatchRules = append(resources.MatchRules, importer.MatchRule{
160+
Account: payee.Name,
161+
MatchRule: models.MatchRule{
162+
MatchRuleCreate: models.MatchRuleCreate{
163+
Priority: 0,
164+
Match: match,
165+
},
166+
},
167+
})
168+
}
137169
}
138170

139171
return idToNames

pkg/importer/parser/ynab4/parse_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ func TestParse(t *testing.T) {
110110
testAccounts(t, accounts)
111111
})
112112

113+
// Check MatchRules
114+
var matchRules []models.MatchRule
115+
db.Find(&matchRules)
116+
t.Run("MatchRules", func(t *testing.T) {
117+
testMatchRules(t, matchRules, accounts)
118+
})
119+
113120
// Check categories
114121
var categories []models.Category
115122
db.Find(&categories)
@@ -214,6 +221,47 @@ func testAccounts(t *testing.T, accounts []models.Account) {
214221
}
215222
}
216223

224+
// testMatchRules tests all MatchRule resources.
225+
func testMatchRules(t *testing.T, matchRules []models.MatchRule, accounts []models.Account) {
226+
assert.Len(t, matchRules, 5, "Number of MatchRules is wrong")
227+
228+
// Check MatchRule details
229+
//
230+
// Not checking priority because YNAB4 does not have priorities here
231+
// Therefore we always set it to 0 on import
232+
tests := []struct {
233+
match string
234+
account string
235+
}{
236+
{"Mum*", "Parents"},
237+
{"*& Dad", "Parents"},
238+
{"My Parents", "Parents"},
239+
{"Co", "Favorite Coffee Shop"},
240+
{"*Coffee Shop*", "Favorite Coffee Shop"},
241+
}
242+
243+
for _, tt := range tests {
244+
t.Run(tt.match, func(t *testing.T) {
245+
// Find Account
246+
aIdx := slices.IndexFunc(accounts, func(a models.Account) bool { return a.Name == tt.account })
247+
if !assert.NotEqual(t, -1, aIdx, "No Account with the name the Match Rule is targeting") {
248+
return
249+
}
250+
251+
// Find Match Rule
252+
mIdx := slices.IndexFunc(matchRules, func(m models.MatchRule) bool { return m.Match == tt.match })
253+
if !assert.NotEqual(t, -1, mIdx, "No Match Rule with the match we are looking for") {
254+
return
255+
}
256+
257+
a := accounts[aIdx]
258+
m := matchRules[mIdx]
259+
260+
assert.Equal(t, a.ID, m.AccountID, "Match Rule Account ID and actual Account ID do not match")
261+
})
262+
}
263+
}
264+
217265
// testCategories tests all the categories for correct import.
218266
func testCategories(t *testing.T, categories []models.Category) {
219267
// 3 categories, 1 (Rainy Day Funds) only has hidden envelopes

pkg/importer/types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type ParsedResources struct {
1515
Allocations []Allocation
1616
Transactions []Transaction
1717
MonthConfigs []MonthConfig
18+
MatchRules []MatchRule
1819
}
1920

2021
type Category struct {
@@ -26,6 +27,12 @@ type Envelope struct {
2627
Model models.Envelope
2728
}
2829

30+
// MatchRule represents a MatchRule to be imported.
31+
type MatchRule struct {
32+
models.MatchRule
33+
Account string
34+
}
35+
2936
type Allocation struct {
3037
Model models.Allocation
3138
Category string // There is a category here since an envelope with the same name can exist for multiple categories

0 commit comments

Comments
 (0)