From 4d0b33efeeffbe102a43abcd9c1056e839d4ed24 Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Tue, 21 Nov 2017 05:30:18 -0500 Subject: [PATCH 1/5] securities: Don't use 'precision', a MySQL reserved word, in DB --- internal/handlers/securities.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/handlers/securities.go b/internal/handlers/securities.go index 0ca96d0..5aabaa1 100644 --- a/internal/handlers/securities.go +++ b/internal/handlers/securities.go @@ -38,7 +38,7 @@ type Security struct { Symbol string // Number of decimal digits (to the right of the decimal point) this // security is precise to - Precision int + Precision int `db:"Preciseness"` Type SecurityType // AlternateId is CUSIP for Type=Stock, ISO4217 for Type=Currency AlternateId string @@ -206,7 +206,7 @@ func ImportGetCreateSecurity(tx *Tx, userid int64, security *Security) (*Securit var securities []*Security - _, err := tx.Select(&securities, "SELECT * from securities where UserId=? AND Type=? AND AlternateId=? AND Precision=?", userid, security.Type, security.AlternateId, security.Precision) + _, err := tx.Select(&securities, "SELECT * from securities where UserId=? AND Type=? AND AlternateId=? AND Preciseness=?", userid, security.Type, security.AlternateId, security.Precision) if err != nil { return nil, err } From b06b409cd548939fb0abf59fffdadb7be3377012 Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Mon, 20 Nov 2017 21:14:34 -0500 Subject: [PATCH 2/5] Add initial gnucash importing test This is woefully incomplete, but tests to make sure at least one balance on one account is correct... --- internal/handlers/gnucash_test.go | 103 ++++++++++++++++++ .../handlers_testdata/example.gnucash | Bin 0 -> 4858 bytes 2 files changed, 103 insertions(+) create mode 100644 internal/handlers/gnucash_test.go create mode 100644 internal/handlers/handlers_testdata/example.gnucash diff --git a/internal/handlers/gnucash_test.go b/internal/handlers/gnucash_test.go new file mode 100644 index 0000000..86501ea --- /dev/null +++ b/internal/handlers/gnucash_test.go @@ -0,0 +1,103 @@ +package handlers_test + +import ( + "bytes" + "github.com/aclindsa/moneygo/internal/handlers" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "os" + "testing" +) + +func importGnucash(client *http.Client, filename string) error { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + + file, err := os.Open(filename) + if err != nil { + return err + } + defer file.Close() + + filewriter, err := mw.CreateFormFile("gnucash", filename) + if err != nil { + return err + } + if _, err := io.Copy(filewriter, file); err != nil { + return err + } + + mw.Close() + + response, err := client.Post(server.URL+"/v1/imports/gnucash", mw.FormDataContentType(), &buf) + if err != nil { + return err + } + + body, err := ioutil.ReadAll(response.Body) + response.Body.Close() + if err != nil { + return err + } + + var e handlers.Error + err = (&e).Read(string(body)) + if err != nil { + return err + } + if e.ErrorId != 0 || len(e.ErrorString) != 0 { + return &e + } + + return nil +} + +func TestImportGnucash(t *testing.T) { + RunWith(t, &data[0], func(t *testing.T, d *TestData) { + // Ensure there's only one USD currency + oldDefault, err := getSecurity(d.clients[0], d.users[0].DefaultCurrency) + if err != nil { + t.Fatalf("Error fetching default security: %s\n", err) + } + d.users[0].DefaultCurrency = d.securities[0].SecurityId + if _, err := updateUser(d.clients[0], &d.users[0]); err != nil { + t.Fatalf("Error updating user: %s\n", err) + } + if err := deleteSecurity(d.clients[0], oldDefault); err != nil { + t.Fatalf("Error removing default security: %s\n", err) + } + + // Import and ensure it didn't return a nasty error code + if err = importGnucash(d.clients[0], "handlers_testdata/example.gnucash"); err != nil { + t.Fatalf("Error importing from Gnucash: %s\n", err) + } + + // Next, find the Expenses/Groceries account + var groceries *handlers.Account + accounts, err := getAccounts(d.clients[0]) + if err != nil { + t.Fatalf("Error fetching accounts: %s\n", err) + } + for _, account := range *accounts.Accounts { + if account.Name == "Groceries" { + groceries = &account + break + } + } + if groceries == nil { + t.Fatalf("Couldn't find 'Expenses/Groceries' account") + } + + grocerytransactions, err := getAccountTransactions(d.clients[0], groceries.AccountId, 0, 0, "") + if err != nil { + t.Fatalf("Couldn't fetch account transactions for 'Expenses/Groceries': %s\n", err) + } + + // 87.19 from preexisting transactions and 200.37 from Gnucash + if grocerytransactions.EndingBalance != "287.56" { + t.Errorf("Expected ending balance for 'Expenses/Groceries' to be '287.56', but found %s\n", grocerytransactions.EndingBalance) + } + }) +} diff --git a/internal/handlers/handlers_testdata/example.gnucash b/internal/handlers/handlers_testdata/example.gnucash new file mode 100644 index 0000000000000000000000000000000000000000..f6097e1d35e598d8ece340d585561f8b97b776a1 GIT binary patch literal 4858 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 literal 0 HcmV?d00001 From d65019f55c0daa047808e99aa64a3bfebad2bbf6 Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Tue, 21 Nov 2017 05:58:43 -0500 Subject: [PATCH 3/5] gnucash tests: Check for the presence of more accounts and their trees --- internal/handlers/gnucash_test.go | 39 ++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/internal/handlers/gnucash_test.go b/internal/handlers/gnucash_test.go index 86501ea..386ef6b 100644 --- a/internal/handlers/gnucash_test.go +++ b/internal/handlers/gnucash_test.go @@ -74,18 +74,45 @@ func TestImportGnucash(t *testing.T) { t.Fatalf("Error importing from Gnucash: %s\n", err) } - // Next, find the Expenses/Groceries account - var groceries *handlers.Account + // Next, find the Expenses/Groceries account and verify it's balance + var income, liabilities, expenses, salary, creditcard, groceries *handlers.Account accounts, err := getAccounts(d.clients[0]) if err != nil { t.Fatalf("Error fetching accounts: %s\n", err) } - for _, account := range *accounts.Accounts { - if account.Name == "Groceries" { - groceries = &account - break + for i, account := range *accounts.Accounts { + if account.Name == "Income" && account.Type == handlers.Income && account.ParentAccountId == -1 { + income = &(*accounts.Accounts)[i] + } else if account.Name == "Liabilities" && account.Type == handlers.Liability && account.ParentAccountId == -1 { + liabilities = &(*accounts.Accounts)[i] + } else if account.Name == "Expenses" && account.Type == handlers.Expense && account.ParentAccountId == -1 { + expenses = &(*accounts.Accounts)[i] } } + if income == nil { + t.Fatalf("Couldn't find 'Income' account") + } + if liabilities == nil { + t.Fatalf("Couldn't find 'Liabilities' account") + } + if expenses == nil { + t.Fatalf("Couldn't find 'Expenses' account") + } + for i, account := range *accounts.Accounts { + if account.Name == "Salary" && account.Type == handlers.Income && account.ParentAccountId == income.AccountId { + salary = &(*accounts.Accounts)[i] + } else if account.Name == "Credit Card" && account.Type == handlers.Liability && account.ParentAccountId == liabilities.AccountId { + creditcard = &(*accounts.Accounts)[i] + } else if account.Name == "Groceries" && account.Type == handlers.Expense && account.ParentAccountId == expenses.AccountId { + groceries = &(*accounts.Accounts)[i] + } + } + if salary == nil { + t.Fatalf("Couldn't find 'Income/Salary' account") + } + if creditcard == nil { + t.Fatalf("Couldn't find 'Liabilities/Credit Card' account") + } if groceries == nil { t.Fatalf("Couldn't find 'Expenses/Groceries' account") } From 947db54433376ded1a10255cf8f7e6957daddfc9 Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Wed, 22 Nov 2017 20:59:11 -0500 Subject: [PATCH 4/5] testing: Check more post-gnucash import account balances --- internal/handlers/gnucash_test.go | 41 ++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/internal/handlers/gnucash_test.go b/internal/handlers/gnucash_test.go index 386ef6b..264aff7 100644 --- a/internal/handlers/gnucash_test.go +++ b/internal/handlers/gnucash_test.go @@ -54,6 +54,18 @@ func importGnucash(client *http.Client, filename string) error { return nil } +func gnucashAccountBalanceHelper(t *testing.T, client *http.Client, account *handlers.Account, balance string) { + t.Helper() + transactions, err := getAccountTransactions(client, account.AccountId, 0, 0, "") + if err != nil { + t.Fatalf("Couldn't fetch account transactions for '%s': %s\n", account.Name, err) + } + + if transactions.EndingBalance != balance { + t.Errorf("Expected ending balance for '%s' to be '%s', but found %s\n", account.Name, balance, transactions.EndingBalance) + } +} + func TestImportGnucash(t *testing.T) { RunWith(t, &data[0], func(t *testing.T, d *TestData) { // Ensure there's only one USD currency @@ -75,7 +87,7 @@ func TestImportGnucash(t *testing.T) { } // Next, find the Expenses/Groceries account and verify it's balance - var income, liabilities, expenses, salary, creditcard, groceries *handlers.Account + var income, equity, liabilities, expenses, salary, creditcard, groceries, cable, openingbalances *handlers.Account accounts, err := getAccounts(d.clients[0]) if err != nil { t.Fatalf("Error fetching accounts: %s\n", err) @@ -83,6 +95,8 @@ func TestImportGnucash(t *testing.T) { for i, account := range *accounts.Accounts { if account.Name == "Income" && account.Type == handlers.Income && account.ParentAccountId == -1 { income = &(*accounts.Accounts)[i] + } else if account.Name == "Equity" && account.Type == handlers.Equity && account.ParentAccountId == -1 { + equity = &(*accounts.Accounts)[i] } else if account.Name == "Liabilities" && account.Type == handlers.Liability && account.ParentAccountId == -1 { liabilities = &(*accounts.Accounts)[i] } else if account.Name == "Expenses" && account.Type == handlers.Expense && account.ParentAccountId == -1 { @@ -92,6 +106,9 @@ func TestImportGnucash(t *testing.T) { if income == nil { t.Fatalf("Couldn't find 'Income' account") } + if equity == nil { + t.Fatalf("Couldn't find 'Equity' account") + } if liabilities == nil { t.Fatalf("Couldn't find 'Liabilities' account") } @@ -101,30 +118,36 @@ func TestImportGnucash(t *testing.T) { for i, account := range *accounts.Accounts { if account.Name == "Salary" && account.Type == handlers.Income && account.ParentAccountId == income.AccountId { salary = &(*accounts.Accounts)[i] + } else if account.Name == "Opening Balances" && account.Type == handlers.Equity && account.ParentAccountId == equity.AccountId { + openingbalances = &(*accounts.Accounts)[i] } else if account.Name == "Credit Card" && account.Type == handlers.Liability && account.ParentAccountId == liabilities.AccountId { creditcard = &(*accounts.Accounts)[i] } else if account.Name == "Groceries" && account.Type == handlers.Expense && account.ParentAccountId == expenses.AccountId { groceries = &(*accounts.Accounts)[i] + } else if account.Name == "Cable" && account.Type == handlers.Expense && account.ParentAccountId == expenses.AccountId { + cable = &(*accounts.Accounts)[i] } } if salary == nil { t.Fatalf("Couldn't find 'Income/Salary' account") } + if openingbalances == nil { + t.Fatalf("Couldn't find 'Equity/Opening Balances") + } if creditcard == nil { t.Fatalf("Couldn't find 'Liabilities/Credit Card' account") } if groceries == nil { t.Fatalf("Couldn't find 'Expenses/Groceries' account") } - - grocerytransactions, err := getAccountTransactions(d.clients[0], groceries.AccountId, 0, 0, "") - if err != nil { - t.Fatalf("Couldn't fetch account transactions for 'Expenses/Groceries': %s\n", err) + if cable == nil { + t.Fatalf("Couldn't find 'Expenses/Cable' account") } - // 87.19 from preexisting transactions and 200.37 from Gnucash - if grocerytransactions.EndingBalance != "287.56" { - t.Errorf("Expected ending balance for 'Expenses/Groceries' to be '287.56', but found %s\n", grocerytransactions.EndingBalance) - } + gnucashAccountBalanceHelper(t, d.clients[0], salary, "-998.34") + gnucashAccountBalanceHelper(t, d.clients[0], creditcard, "-272.03") + gnucashAccountBalanceHelper(t, d.clients[0], openingbalances, "-21014.33") + gnucashAccountBalanceHelper(t, d.clients[0], groceries, "287.56") // 87.19 from preexisting transactions and 200.37 from Gnucash + gnucashAccountBalanceHelper(t, d.clients[0], cable, "89.98") }) } From 0aa8ac63abe7d06b6074ccc35c3cdf427d6339be Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Wed, 22 Nov 2017 21:37:45 -0500 Subject: [PATCH 5/5] testing: Test importing Gnucash security prices --- internal/handlers/gnucash_test.go | 32 ++++++++++++++++++ .../handlers_testdata/example.gnucash | Bin 4858 -> 5199 bytes 2 files changed, 32 insertions(+) diff --git a/internal/handlers/gnucash_test.go b/internal/handlers/gnucash_test.go index 264aff7..5efd41e 100644 --- a/internal/handlers/gnucash_test.go +++ b/internal/handlers/gnucash_test.go @@ -149,5 +149,37 @@ func TestImportGnucash(t *testing.T) { gnucashAccountBalanceHelper(t, d.clients[0], openingbalances, "-21014.33") gnucashAccountBalanceHelper(t, d.clients[0], groceries, "287.56") // 87.19 from preexisting transactions and 200.37 from Gnucash gnucashAccountBalanceHelper(t, d.clients[0], cable, "89.98") + + var ge *handlers.Security + securities, err := getSecurities(d.clients[0]) + if err != nil { + t.Fatalf("Error fetching securities: %s\n", err) + } + for i, security := range *securities.Securities { + if security.Symbol == "GE" { + ge = (*securities.Securities)[i] + } + } + if ge == nil { + t.Fatalf("Couldn't find GE security") + } + + prices, err := getPrices(d.clients[0], ge.SecurityId) + if err != nil { + t.Fatalf("Error fetching prices: %s\n", err) + } + var p1787, p2894, p3170 bool + for _, price := range *prices.Prices { + if price.CurrencyId == d.securities[0].SecurityId && price.Value == "17.87" { + p1787 = true + } else if price.CurrencyId == d.securities[0].SecurityId && price.Value == "28.94" { + p2894 = true + } else if price.CurrencyId == d.securities[0].SecurityId && price.Value == "31.70" { + p3170 = true + } + } + if !p1787 || !p2894 || !p3170 { + t.Errorf("Error finding expected prices\n") + } }) } diff --git a/internal/handlers/handlers_testdata/example.gnucash b/internal/handlers/handlers_testdata/example.gnucash index f6097e1d35e598d8ece340d585561f8b97b776a1..e3a6ef2110eb416f24388b0d8034adcc6c12de2d 100644 GIT binary patch literal 5199 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 J&V)6;004%vEz$r0 literal 4858 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