├── example_config.ini ├── screenshots ├── yearly_expenses.png ├── editing_transaction.png └── transaction_register.png ├── internal ├── integration │ ├── testdata │ │ ├── example.gnucash │ │ ├── creditcard.ofx │ │ ├── 401k_mutualfunds.ofx │ │ └── checking_20171129.ofx │ ├── reports_lua_test.go │ ├── prices_lua_test.go │ ├── date_lua_test.go │ ├── accounts_lua_test.go │ ├── securities_lua_test.go │ ├── users_test.go │ ├── security_templates_test.go │ ├── sessions_test.go │ └── balance_lua_test.go ├── handlers │ ├── Makefile │ ├── util.go │ ├── reports │ │ ├── monthly_cash_flow.lua │ │ ├── years_income.lua │ │ ├── asset_allocation.lua │ │ ├── monthly_expenses.lua │ │ ├── monthly_net_worth.lua │ │ ├── quarterly_net_worth.lua │ │ └── monthly_net_worth_change.lua │ ├── errors.go │ ├── scripts │ │ ├── gen_cusip_csv.sh │ │ └── gen_security_list.py │ ├── handlers.go │ ├── sessions.go │ ├── reports.go │ ├── prices.go │ └── users.go ├── models │ ├── users.go │ ├── prices.go │ ├── sessions.go │ ├── reports.go │ ├── securities.go │ ├── accounts.go │ ├── transactions.go │ ├── amounts.go │ └── amounts_test.go ├── store │ ├── db │ │ ├── sessions.go │ │ ├── reports.go │ │ ├── tx.go │ │ ├── users.go │ │ ├── securities.go │ │ ├── db.go │ │ ├── accounts.go │ │ └── prices.go │ └── store.go ├── config │ ├── testdata │ │ ├── sqlite_https_config.ini │ │ ├── generate_certs_config.ini │ │ └── postgres_fcgi_config.ini │ ├── config.go │ └── config_test.go └── reports │ ├── reports.go │ └── prices.go ├── docs └── index.md ├── .gitignore ├── js ├── constants │ ├── ErrorConstants.js │ ├── SecurityTemplateConstants.js │ ├── ImportConstants.js │ ├── UserConstants.js │ ├── AccountConstants.js │ ├── SecurityConstants.js │ ├── ReportConstants.js │ └── TransactionConstants.js ├── reducers │ ├── SessionReducer.js │ ├── UserReducer.js │ ├── ErrorReducer.js │ ├── SelectedAccountReducer.js │ ├── SelectedSecurityReducer.js │ ├── TransactionReducer.js │ ├── MoneyGoReducer.js │ ├── SecurityTemplateReducer.js │ ├── ImportReducer.js │ ├── AccountReducer.js │ ├── TransactionPageReducer.js │ └── SecurityReducer.js ├── containers │ ├── NewUserModalContainer.js │ ├── AccountSettingsModalContainer.js │ ├── TopBarContainer.js │ ├── ReportsTabContainer.js │ ├── MoneyGoAppContainer.js │ ├── SecuritiesTabContainer.js │ └── AccountsTabContainer.js ├── actions │ ├── ErrorActions.js │ ├── SecurityTemplateActions.js │ ├── AccountActions.js │ ├── SecurityActions.js │ └── ImportActions.js ├── main.js ├── utils.js └── components │ ├── AccountCombobox.js │ ├── AccountTree.js │ └── TopBar.js ├── Makefile ├── static ├── index.html └── css │ ├── reports.css │ └── stylesheet.css ├── LICENSE ├── package.json ├── Gopkg.toml ├── main.go ├── .travis.yml ├── README.md └── Gopkg.lock /example_config.ini: -------------------------------------------------------------------------------- 1 | internal/config/testdata/sqlite_https_config.ini -------------------------------------------------------------------------------- /screenshots/yearly_expenses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aclindsa/moneygo/HEAD/screenshots/yearly_expenses.png -------------------------------------------------------------------------------- /screenshots/editing_transaction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aclindsa/moneygo/HEAD/screenshots/editing_transaction.png -------------------------------------------------------------------------------- /screenshots/transaction_register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aclindsa/moneygo/HEAD/screenshots/transaction_register.png -------------------------------------------------------------------------------- /internal/integration/testdata/example.gnucash: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aclindsa/moneygo/HEAD/internal/integration/testdata/example.gnucash -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # MoneyGo Documentation 2 | 3 | * [Creating Reports in Lua](lua_reports.md) 4 | * [Importing Transactions Using OFX](ofx_imports.md) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | static/bundle.js 3 | static/codemirror 4 | static/react-widgets 5 | node_modules 6 | internal/handlers/cusip_list.csv 7 | internal/handlers/security_templates.go 8 | -------------------------------------------------------------------------------- /js/constants/ErrorConstants.js: -------------------------------------------------------------------------------- 1 | var keyMirror = require('keymirror'); 2 | 3 | module.exports = keyMirror({ 4 | ERROR_AJAX: null, 5 | ERROR_SERVER: null, 6 | ERROR_CLIENT: null, 7 | ERROR_USER: null, 8 | CLEAR_ERROR: null 9 | }); 10 | -------------------------------------------------------------------------------- /js/constants/SecurityTemplateConstants.js: -------------------------------------------------------------------------------- 1 | var keyMirror = require('keymirror'); 2 | 3 | module.exports = keyMirror({ 4 | FETCH_CURRENCIES: null, 5 | CURRENCIES_FETCHED: null, 6 | SEARCH_SECURITY_TEMPLATES: null, 7 | SECURITY_TEMPLATES_SEARCHED: null 8 | }); 9 | -------------------------------------------------------------------------------- /internal/handlers/Makefile: -------------------------------------------------------------------------------- 1 | all: security_templates.go 2 | 3 | security_templates.go: cusip_list.csv scripts/gen_security_list.py 4 | ./scripts/gen_security_list.py > security_templates.go 5 | 6 | cusip_list.csv: 7 | ./scripts/gen_cusip_csv.sh > cusip_list.csv 8 | 9 | .PHONY = all 10 | -------------------------------------------------------------------------------- /js/constants/ImportConstants.js: -------------------------------------------------------------------------------- 1 | var keyMirror = require('keymirror'); 2 | 3 | module.exports = keyMirror({ 4 | OPEN_IMPORT_MODAL: null, 5 | CLOSE_IMPORT_MODAL: null, 6 | BEGIN_IMPORT: null, 7 | UPDATE_IMPORT_PROGRESS: null, 8 | IMPORT_FINISHED: null, 9 | IMPORT_FAILED: null 10 | }); 11 | -------------------------------------------------------------------------------- /js/constants/UserConstants.js: -------------------------------------------------------------------------------- 1 | var keyMirror = require('keymirror'); 2 | 3 | module.exports = keyMirror({ 4 | CREATE_USER: null, 5 | USER_CREATED: null, 6 | LOGIN_USER: null, 7 | USER_LOGGEDIN: null, 8 | LOGOUT_USER: null, 9 | USER_LOGGEDOUT: null, 10 | FETCH_USER: null, 11 | USER_FETCHED: null, 12 | UPDATE_USER: null, 13 | USER_UPDATED: null 14 | }); 15 | -------------------------------------------------------------------------------- /js/constants/AccountConstants.js: -------------------------------------------------------------------------------- 1 | var keyMirror = require('keymirror'); 2 | 3 | module.exports = keyMirror({ 4 | FETCH_ACCOUNTS: null, 5 | ACCOUNTS_FETCHED: null, 6 | CREATE_ACCOUNT: null, 7 | ACCOUNT_CREATED: null, 8 | UPDATE_ACCOUNT: null, 9 | ACCOUNT_UPDATED: null, 10 | REMOVE_ACCOUNT: null, 11 | ACCOUNT_REMOVED: null, 12 | ACCOUNT_SELECTED: null 13 | }); 14 | -------------------------------------------------------------------------------- /js/constants/SecurityConstants.js: -------------------------------------------------------------------------------- 1 | var keyMirror = require('keymirror'); 2 | 3 | module.exports = keyMirror({ 4 | FETCH_SECURITIES: null, 5 | SECURITIES_FETCHED: null, 6 | CREATE_SECURITY: null, 7 | SECURITY_CREATED: null, 8 | UPDATE_SECURITY: null, 9 | SECURITY_UPDATED: null, 10 | REMOVE_SECURITY: null, 11 | SECURITY_REMOVED: null, 12 | SECURITY_SELECTED: null 13 | }); 14 | -------------------------------------------------------------------------------- /js/reducers/SessionReducer.js: -------------------------------------------------------------------------------- 1 | var UserConstants = require('../constants/UserConstants'); 2 | 3 | var Session = require('../models').Session; 4 | 5 | module.exports = function(state = new Session(), action) { 6 | switch (action.type) { 7 | case UserConstants.USER_LOGGEDIN: 8 | return action.session; 9 | case UserConstants.USER_LOGGEDOUT: 10 | return new Session(); 11 | default: 12 | return state; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /js/reducers/UserReducer.js: -------------------------------------------------------------------------------- 1 | var UserConstants = require('../constants/UserConstants'); 2 | 3 | var User = require('../models').User; 4 | 5 | module.exports = function(state = new User(), action) { 6 | switch (action.type) { 7 | case UserConstants.USER_FETCHED: 8 | case UserConstants.USER_UPDATED: 9 | return action.user; 10 | case UserConstants.USER_LOGGEDOUT: 11 | return new User(); 12 | default: 13 | return state; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /js/constants/ReportConstants.js: -------------------------------------------------------------------------------- 1 | var keyMirror = require('keymirror'); 2 | 3 | module.exports = keyMirror({ 4 | FETCH_REPORTS: null, 5 | REPORTS_FETCHED: null, 6 | CREATE_REPORT: null, 7 | REPORT_CREATED: null, 8 | UPDATE_REPORT: null, 9 | REPORT_UPDATED: null, 10 | REMOVE_REPORT: null, 11 | REPORT_REMOVED: null, 12 | TABULATE_REPORT: null, 13 | REPORT_TABULATED: null, 14 | REPORT_SELECTED: null, 15 | SELECTION_CLEARED: null, 16 | SERIES_SELECTED: null 17 | }); 18 | -------------------------------------------------------------------------------- /js/constants/TransactionConstants.js: -------------------------------------------------------------------------------- 1 | var keyMirror = require('keymirror'); 2 | 3 | module.exports = keyMirror({ 4 | FETCH_TRANSACTION_PAGE: null, 5 | TRANSACTION_PAGE_FETCHED: null, 6 | CREATE_TRANSACTION: null, 7 | TRANSACTION_CREATED: null, 8 | UPDATE_TRANSACTION: null, 9 | TRANSACTION_UPDATED: null, 10 | REMOVE_TRANSACTION: null, 11 | TRANSACTION_REMOVED: null, 12 | SELECT_TRANSACTION: null, 13 | TRANSACTION_SELECTED: null, 14 | SELECTION_CLEARED: null 15 | }); 16 | -------------------------------------------------------------------------------- /js/reducers/ErrorReducer.js: -------------------------------------------------------------------------------- 1 | var ErrorConstants = require('../constants/ErrorConstants'); 2 | 3 | var Error = require('../models').Error; 4 | 5 | module.exports = function(state = new Error(), action) { 6 | switch (action.type) { 7 | case ErrorConstants.ERROR_AJAX: 8 | case ErrorConstants.ERROR_SERVER: 9 | case ErrorConstants.ERROR_CLIENT: 10 | case ErrorConstants.ERROR_USER: 11 | return action.error; 12 | case ErrorConstants.CLEAR_ERROR: 13 | return new Error(); 14 | default: 15 | return state; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /js/containers/NewUserModalContainer.js: -------------------------------------------------------------------------------- 1 | var connect = require('react-redux').connect; 2 | 3 | var UserActions = require('../actions/UserActions'); 4 | 5 | var NewUserModal = require('../components/NewUserModal'); 6 | 7 | function mapStateToProps(state) { 8 | return { 9 | currencies: state.securityTemplates.currencies 10 | } 11 | } 12 | 13 | function mapDispatchToProps(dispatch) { 14 | return { 15 | createNewUser: function(user) {dispatch(UserActions.create(user))} 16 | } 17 | } 18 | 19 | module.exports = connect( 20 | mapStateToProps, 21 | mapDispatchToProps 22 | )(NewUserModal) 23 | -------------------------------------------------------------------------------- /js/containers/AccountSettingsModalContainer.js: -------------------------------------------------------------------------------- 1 | var connect = require('react-redux').connect; 2 | 3 | var UserActions = require('../actions/UserActions'); 4 | 5 | var AccountSettingsModal = require('../components/AccountSettingsModal'); 6 | 7 | function mapStateToProps(state) { 8 | return { 9 | user: state.user, 10 | currencies: state.securities.currency_list 11 | } 12 | } 13 | 14 | function mapDispatchToProps(dispatch) { 15 | return { 16 | onUpdateUser: function(user) {dispatch(UserActions.update(user))} 17 | } 18 | } 19 | 20 | module.exports = connect( 21 | mapStateToProps, 22 | mapDispatchToProps 23 | )(AccountSettingsModal) 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | JS_SOURCES = $(wildcard js/*.js) $(wildcard js/*/*.js) 2 | 3 | all: static/bundle.js static/react-widgets static/codemirror/codemirror.css 4 | 5 | node_modules: 6 | npm install 7 | 8 | static/bundle.js: $(JS_SOURCES) node_modules 9 | browserify -t [ babelify --presets [ react es2015 ] ] js/main.js -o static/bundle.js 10 | 11 | static/react-widgets: node_modules/react-widgets/dist node_modules 12 | rsync -a node_modules/react-widgets/dist/ static/react-widgets/ 13 | 14 | static/codemirror/codemirror.css: node_modules/codemirror/lib/codemirror.js node_modules 15 | mkdir -p static/codemirror 16 | cp node_modules/codemirror/lib/codemirror.css static/codemirror/codemirror.css 17 | 18 | .PHONY = all 19 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | MoneyGo 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /js/reducers/SelectedAccountReducer.js: -------------------------------------------------------------------------------- 1 | var AccountConstants = require('../constants/AccountConstants'); 2 | var UserConstants = require('../constants/UserConstants'); 3 | 4 | module.exports = function(state = -1, action) { 5 | switch (action.type) { 6 | case AccountConstants.ACCOUNTS_FETCHED: 7 | for (var i = 0; i < action.accounts.length; i++) { 8 | if (action.accounts[i].AccountId == state) 9 | return state; 10 | } 11 | return -1; 12 | case AccountConstants.ACCOUNT_REMOVED: 13 | if (action.accountId == state) 14 | return -1; 15 | return state; 16 | case AccountConstants.ACCOUNT_SELECTED: 17 | return action.accountId; 18 | case UserConstants.USER_LOGGEDOUT: 19 | return -1; 20 | default: 21 | return state; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /internal/handlers/util.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | ) 10 | 11 | func ReadJSON(r *http.Request, v interface{}) error { 12 | jsonstring, err := ioutil.ReadAll(io.LimitReader(r.Body, 10*1024*1024 /*10Mb*/)) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | return json.Unmarshal(jsonstring, v) 18 | } 19 | 20 | type ResponseWrapper struct { 21 | Code int 22 | Writer ResponseWriterWriter 23 | } 24 | 25 | func (r ResponseWrapper) Write(w http.ResponseWriter) error { 26 | w.WriteHeader(r.Code) 27 | return r.Writer.Write(w) 28 | } 29 | 30 | type SuccessWriter struct{} 31 | 32 | func (s SuccessWriter) Write(w http.ResponseWriter) error { 33 | fmt.Fprint(w, "{}") 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /js/reducers/SelectedSecurityReducer.js: -------------------------------------------------------------------------------- 1 | var SecurityConstants = require('../constants/SecurityConstants'); 2 | var UserConstants = require('../constants/UserConstants'); 3 | 4 | module.exports = function(state = -1, action) { 5 | switch (action.type) { 6 | case SecurityConstants.SECURITIES_FETCHED: 7 | for (var i = 0; i < action.securities.length; i++) { 8 | if (action.securities[i].SecurityId == state) 9 | return state; 10 | } 11 | return -1; 12 | case SecurityConstants.SECURITY_REMOVED: 13 | if (action.securityId == state) 14 | return -1; 15 | return state; 16 | case SecurityConstants.SECURITY_SELECTED: 17 | return action.securityId; 18 | case UserConstants.USER_LOGGEDOUT: 19 | return -1; 20 | default: 21 | return state; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /js/containers/TopBarContainer.js: -------------------------------------------------------------------------------- 1 | var connect = require('react-redux').connect; 2 | 3 | var UserActions = require('../actions/UserActions'); 4 | var ErrorActions = require('../actions/ErrorActions'); 5 | 6 | var TopBar = require('../components/TopBar'); 7 | 8 | function mapStateToProps(state) { 9 | return { 10 | user: state.user, 11 | error: state.error 12 | } 13 | } 14 | 15 | function mapDispatchToProps(dispatch) { 16 | return { 17 | onLogin: function(user) {dispatch(UserActions.login(user))}, 18 | onLogout: function() {dispatch(UserActions.logout())}, 19 | onUpdateUser: function(user) {dispatch(UserActions.update(user))}, 20 | onClearError: function() {dispatch(ErrorActions.clearError())} 21 | } 22 | } 23 | 24 | module.exports = connect( 25 | mapStateToProps, 26 | mapDispatchToProps 27 | )(TopBar) 28 | -------------------------------------------------------------------------------- /internal/handlers/reports/monthly_cash_flow.lua: -------------------------------------------------------------------------------- 1 | function generate() 2 | year = date.now().year 3 | 4 | accounts = get_accounts() 5 | t = tabulation.new(12) 6 | t:title(year .. " Monthly Cash Flow") 7 | series = t:series("Income minus expenses") 8 | 9 | for month=1,12 do 10 | begin_date = date.new(year, month, 1) 11 | end_date = date.new(year, month+1, 1) 12 | 13 | t:label(month, tostring(begin_date)) 14 | cash_flow = 0 15 | 16 | for id, acct in pairs(accounts) do 17 | if acct.type == account.Expense or acct.type == account.Income then 18 | balance = acct:balance(begin_date, end_date) 19 | cash_flow = cash_flow - balance.amount 20 | end 21 | end 22 | series:value(month, cash_flow) 23 | end 24 | 25 | return t 26 | end 27 | -------------------------------------------------------------------------------- /internal/models/users.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | type User struct { 13 | UserId int64 14 | DefaultCurrency int64 // SecurityId of default currency, or ISO4217 code for it if creating new user 15 | Name string 16 | Username string 17 | Password string `db:"-"` 18 | PasswordHash string `json:"-"` 19 | Email string 20 | } 21 | 22 | const BogusPassword = "password" 23 | 24 | func (u *User) Write(w http.ResponseWriter) error { 25 | enc := json.NewEncoder(w) 26 | return enc.Encode(u) 27 | } 28 | 29 | func (u *User) Read(json_str string) error { 30 | dec := json.NewDecoder(strings.NewReader(json_str)) 31 | return dec.Decode(u) 32 | } 33 | 34 | func (u *User) HashPassword() { 35 | password_hasher := sha256.New() 36 | io.WriteString(password_hasher, u.Password) 37 | u.PasswordHash = fmt.Sprintf("%x", password_hasher.Sum(nil)) 38 | u.Password = "" 39 | } 40 | -------------------------------------------------------------------------------- /internal/models/prices.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | type Price struct { 11 | PriceId int64 12 | SecurityId int64 13 | CurrencyId int64 14 | Date time.Time 15 | Value Amount // price of Security in Currency units 16 | RemoteId string // unique ID from source, for detecting duplicates 17 | } 18 | 19 | type PriceList struct { 20 | Prices *[]*Price `json:"prices"` 21 | } 22 | 23 | func (p *Price) Read(json_str string) error { 24 | dec := json.NewDecoder(strings.NewReader(json_str)) 25 | return dec.Decode(p) 26 | } 27 | 28 | func (p *Price) Write(w http.ResponseWriter) error { 29 | enc := json.NewEncoder(w) 30 | return enc.Encode(p) 31 | } 32 | 33 | func (pl *PriceList) Read(json_str string) error { 34 | dec := json.NewDecoder(strings.NewReader(json_str)) 35 | return dec.Decode(pl) 36 | } 37 | 38 | func (pl *PriceList) Write(w http.ResponseWriter) error { 39 | enc := json.NewEncoder(w) 40 | return enc.Encode(pl) 41 | } 42 | -------------------------------------------------------------------------------- /js/containers/ReportsTabContainer.js: -------------------------------------------------------------------------------- 1 | var connect = require('react-redux').connect; 2 | 3 | var ReportActions = require('../actions/ReportActions'); 4 | var ReportsTab = require('../components/ReportsTab'); 5 | 6 | function mapStateToProps(state) { 7 | return { 8 | reports: state.reports 9 | } 10 | } 11 | 12 | function mapDispatchToProps(dispatch) { 13 | return { 14 | onFetchAllReports: function() {dispatch(ReportActions.fetchAll())}, 15 | onCreateReport: function(report) {dispatch(ReportActions.create(report))}, 16 | onUpdateReport: function(report) {dispatch(ReportActions.update(report))}, 17 | onDeleteReport: function(report) {dispatch(ReportActions.remove(report))}, 18 | onSelectReport: function(report) {dispatch(ReportActions.select(report))}, 19 | onTabulateReport: function(report) {dispatch(ReportActions.tabulate(report))}, 20 | onSelectSeries: function(seriesTraversal) {dispatch(ReportActions.selectSeries(seriesTraversal))} 21 | } 22 | } 23 | 24 | module.exports = connect( 25 | mapStateToProps, 26 | mapDispatchToProps 27 | )(ReportsTab) 28 | -------------------------------------------------------------------------------- /internal/integration/reports_lua_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aclindsa/moneygo/internal/models" 6 | "net/http" 7 | "testing" 8 | ) 9 | 10 | type LuaTest struct { 11 | Name string 12 | Lua string 13 | Expected string 14 | } 15 | 16 | func simpleLuaTest(t *testing.T, client *http.Client, tests []LuaTest) { 17 | t.Helper() 18 | for _, lt := range tests { 19 | lua := fmt.Sprintf(`function test() 20 | %s 21 | end 22 | 23 | function generate() 24 | t = tabulation.new(0) 25 | t:title(tostring(test())) 26 | return t 27 | end`, lt.Lua) 28 | r := models.Report{ 29 | Name: lt.Name, 30 | Lua: lua, 31 | } 32 | report, err := createReport(client, &r) 33 | if err != nil { 34 | t.Fatalf("Error creating report: %s", err) 35 | } 36 | 37 | tab, err := tabulateReport(client, report.ReportId) 38 | if err != nil { 39 | t.Fatalf("Error tabulating report: %s", err) 40 | } 41 | 42 | if tab.Title != lt.Expected { 43 | t.Errorf("%s: Returned '%s', expected '%s'", lt.Name, tab.Title, lt.Expected) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/store/db/sessions.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aclindsa/moneygo/internal/models" 6 | "time" 7 | ) 8 | 9 | func (tx *Tx) InsertSession(session *models.Session) error { 10 | return tx.Insert(session) 11 | } 12 | 13 | func (tx *Tx) GetSession(secret string) (*models.Session, error) { 14 | var s models.Session 15 | 16 | err := tx.SelectOne(&s, "SELECT * from sessions where SessionSecret=?", secret) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | if s.Expires.Before(time.Now()) { 22 | tx.Delete(&s) 23 | return nil, fmt.Errorf("Session has expired") 24 | } 25 | return &s, nil 26 | } 27 | 28 | func (tx *Tx) SessionExists(secret string) (bool, error) { 29 | existing, err := tx.SelectInt("SELECT count(*) from sessions where SessionSecret=?", secret) 30 | return existing != 0, err 31 | } 32 | 33 | func (tx *Tx) DeleteSession(session *models.Session) error { 34 | count, err := tx.Delete(session) 35 | if err != nil { 36 | return err 37 | } 38 | if count != 1 { 39 | return fmt.Errorf("Expected to delete 1 user, was going to delete %d", count) 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Aaron Lindsay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /js/reducers/TransactionReducer.js: -------------------------------------------------------------------------------- 1 | var assign = require('object-assign'); 2 | 3 | var TransactionConstants = require('../constants/TransactionConstants'); 4 | var UserConstants = require('../constants/UserConstants'); 5 | 6 | module.exports = function(state = {}, action) { 7 | switch (action.type) { 8 | case TransactionConstants.TRANSACTION_PAGE_FETCHED: 9 | var transactions = assign({}, state); 10 | for (var tidx in action.transactions) { 11 | var t = action.transactions[tidx]; 12 | transactions = assign({}, transactions, { 13 | [t.TransactionId]: t 14 | }); 15 | } 16 | return transactions; 17 | case TransactionConstants.TRANSACTION_CREATED: 18 | case TransactionConstants.TRANSACTION_UPDATED: 19 | var transaction = action.transaction; 20 | return assign({}, state, { 21 | [transaction.TransactionId]: transaction 22 | }); 23 | case TransactionConstants.TRANSACTION_REMOVED: 24 | var transactions = assign({}, state); 25 | delete transactions[action.transactionId]; 26 | return transactions; 27 | case UserConstants.USER_LOGGEDOUT: 28 | return {}; 29 | default: 30 | return state; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /internal/handlers/errors.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | type Error struct { 12 | ErrorId int 13 | ErrorString string 14 | } 15 | 16 | func (e *Error) Error() string { 17 | return fmt.Sprintf("Error %d: %s", e.ErrorId, e.ErrorString) 18 | } 19 | 20 | func (e *Error) Read(json_str string) error { 21 | dec := json.NewDecoder(strings.NewReader(json_str)) 22 | return dec.Decode(e) 23 | } 24 | 25 | func (e *Error) Write(w http.ResponseWriter) error { 26 | enc := json.NewEncoder(w) 27 | return enc.Encode(e) 28 | } 29 | 30 | var error_codes = map[int]string{ 31 | 1: "Not Signed In", 32 | 2: "Unauthorized Access", 33 | 3: "Invalid Request", 34 | 4: "User Exists", 35 | // 5: "Connection Failed", //reserved for client-side error 36 | 6: "Import Error", 37 | 7: "In Use Error", 38 | 999: "Internal Error", 39 | } 40 | 41 | func NewError(error_code int) *Error { 42 | msg, ok := error_codes[error_code] 43 | if !ok { 44 | log.Printf("Error: NewError received unknown error code of %d", error_code) 45 | msg = error_codes[999] 46 | } 47 | return &Error{error_code, msg} 48 | } 49 | -------------------------------------------------------------------------------- /static/css/reports.css: -------------------------------------------------------------------------------- 1 | .chart-color1 { 2 | fill: #a6cee3; 3 | stroke: #86aec3; 4 | } 5 | .chart-color2 { 6 | fill: #1f78b4; 7 | stroke: #005894; 8 | } 9 | .chart-color3 { 10 | fill: #b2df8a; 11 | stroke: #92bf6a; 12 | } 13 | .chart-color4 { 14 | fill: #33a02c; 15 | stroke: #13800c; 16 | } 17 | .chart-color5 { 18 | fill: #fb9a99; 19 | stroke: #db7a79; 20 | } 21 | .chart-color6 { 22 | fill: #e31a1c; 23 | stroke: #c30000; 24 | } 25 | .chart-color7 { 26 | fill: #fdbf6f; 27 | stroke: #dd9f4f; 28 | } 29 | .chart-color8 { 30 | fill: #ff7f00; 31 | stroke: #df5f00; 32 | } 33 | .chart-color9 { 34 | fill: #cab2d6; 35 | stroke: #aa92b6; 36 | } 37 | .chart-color10 { 38 | fill: #6a3d9a; 39 | stroke: #4a1d7a; 40 | } 41 | .chart-color11 { 42 | fill: #ffff99; 43 | stroke: #dfdf79; 44 | } 45 | .chart-color12 { 46 | fill: #b15928; 47 | stroke: #913908; 48 | } 49 | 50 | .chart-element { 51 | stroke-width: 0; 52 | cursor: pointer; 53 | } 54 | g.chart-series:hover .chart-element { 55 | stroke-width: 2; 56 | } 57 | .chart-legend rect { 58 | stroke-width: 2; 59 | } 60 | 61 | .axis { 62 | stroke: #000; 63 | stroke-width: 2; 64 | } 65 | .axis-tick { 66 | stroke: #000; 67 | stroke-width: 1; 68 | } 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moneygo", 3 | "version": "0.0.1", 4 | "description": "A personal finance web application", 5 | "main": "js/main.js", 6 | "dependencies": { 7 | "babel-preset-es2015": "^6.24.1", 8 | "babel-preset-react": "^6.16.0", 9 | "babelify": "^7.3.0", 10 | "big.js": "^3.2.0", 11 | "browserify": "^14.5.0", 12 | "cldr-data": "^31.0.2", 13 | "d3": "^4.12.0", 14 | "globalize": "^1.3.0", 15 | "keymirror": "^0.1.1", 16 | "react": "^15.6.2", 17 | "react-addons-update": "^15.6.2", 18 | "react-bootstrap": "^0.31.5", 19 | "react-codemirror": "^1.0.0", 20 | "react-dom": "^15.6.2", 21 | "react-redux": "^5.0.6", 22 | "react-widgets": "^3.5.0", 23 | "redux": "^3.7.2", 24 | "redux-thunk": "^2.1.0" 25 | }, 26 | "devDependencies": {}, 27 | "scripts": { 28 | "test": "echo \"Error: no test specified\" && exit 1" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/aclindsa/moneygo.git" 33 | }, 34 | "author": "Aaron Lindsay", 35 | "bugs": { 36 | "url": "https://github.com/aclindsa/moneygo/issues" 37 | }, 38 | "homepage": "https://github.com/aclindsa/moneygo#moneygo" 39 | } 40 | -------------------------------------------------------------------------------- /js/actions/ErrorActions.js: -------------------------------------------------------------------------------- 1 | var ErrorConstants = require('../constants/ErrorConstants'); 2 | 3 | var models = require('../models.js'); 4 | var Error = models.Error; 5 | 6 | function serverError(error) { 7 | return { 8 | type: ErrorConstants.ERROR_SERVER, 9 | error: error 10 | }; 11 | } 12 | 13 | function ajaxError(error) { 14 | var e = new Error(); 15 | e.ErrorId = 5; 16 | e.ErrorString = "Request Failed: " + error; 17 | 18 | return { 19 | type: ErrorConstants.ERROR_AJAX, 20 | error: e 21 | }; 22 | } 23 | 24 | function clientError(error) { 25 | var e = new Error(); 26 | e.ErrorId = 999; 27 | e.ErrorString = "Client Error: " + error; 28 | 29 | return { 30 | type: ErrorConstants.ERROR_CLIENT, 31 | error: e 32 | }; 33 | } 34 | 35 | function userError(error) { 36 | var e = new Error(); 37 | e.ErrorId = 999; 38 | e.ErrorString = error; 39 | 40 | return { 41 | type: ErrorConstants.ERROR_USER, 42 | error: e 43 | }; 44 | } 45 | 46 | function clearError() { 47 | return { 48 | type: ErrorConstants.CLEAR_ERROR, 49 | }; 50 | } 51 | 52 | module.exports = { 53 | serverError: serverError, 54 | ajaxError: ajaxError, 55 | clientError: clientError, 56 | userError: userError, 57 | clearError: clearError 58 | }; 59 | -------------------------------------------------------------------------------- /js/reducers/MoneyGoReducer.js: -------------------------------------------------------------------------------- 1 | var Redux = require('redux'); 2 | 3 | var UserReducer = require('./UserReducer'); 4 | var SessionReducer = require('./SessionReducer'); 5 | var AccountReducer = require('./AccountReducer'); 6 | var SecurityReducer = require('./SecurityReducer'); 7 | var SecurityTemplateReducer = require('./SecurityTemplateReducer'); 8 | var SelectedAccountReducer = require('./SelectedAccountReducer'); 9 | var SelectedSecurityReducer = require('./SelectedSecurityReducer'); 10 | var ReportReducer = require('./ReportReducer'); 11 | var TransactionReducer = require('./TransactionReducer'); 12 | var TransactionPageReducer = require('./TransactionPageReducer'); 13 | var ImportReducer = require('./ImportReducer'); 14 | var ErrorReducer = require('./ErrorReducer'); 15 | 16 | module.exports = Redux.combineReducers({ 17 | user: UserReducer, 18 | session: SessionReducer, 19 | accounts: AccountReducer, 20 | securities: SecurityReducer, 21 | securityTemplates: SecurityTemplateReducer, 22 | selectedAccount: SelectedAccountReducer, 23 | selectedSecurity: SelectedSecurityReducer, 24 | reports: ReportReducer, 25 | transactions: TransactionReducer, 26 | transactionPage: TransactionPageReducer, 27 | imports: ImportReducer, 28 | error: ErrorReducer 29 | }); 30 | -------------------------------------------------------------------------------- /js/main.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var ReactDOM = require('react-dom'); 3 | 4 | var Provider = require('react-redux').Provider; 5 | var Redux = require('redux'); 6 | var ReduxThunk = require('redux-thunk').default; 7 | 8 | var Globalize = require('globalize'); 9 | var globalizeLocalizer = require('react-widgets/lib/localizers/globalize'); 10 | 11 | var MoneyGoAppContainer = require('./containers/MoneyGoAppContainer'); 12 | var MoneyGoReducer = require('./reducers/MoneyGoReducer'); 13 | 14 | // Setup globalization for react-widgets 15 | //Globalize.load(require("cldr-data").entireSupplemental()); 16 | Globalize.load( 17 | require("cldr-data/main/en/ca-gregorian"), 18 | require("cldr-data/main/en/numbers"), 19 | require("cldr-data/supplemental/likelySubtags"), 20 | require("cldr-data/supplemental/timeData"), 21 | require("cldr-data/supplemental/weekData") 22 | ); 23 | Globalize.locale('en'); 24 | globalizeLocalizer(Globalize); 25 | 26 | $(document).ready(function() { 27 | var store = Redux.createStore( 28 | MoneyGoReducer, 29 | Redux.applyMiddleware( 30 | ReduxThunk 31 | ) 32 | ); 33 | 34 | ReactDOM.render( 35 | 36 | 37 | , 38 | document.getElementById("content") 39 | ); 40 | }); 41 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | # Gopkg.toml example 3 | # 4 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 5 | # for detailed Gopkg.toml documentation. 6 | # 7 | # required = ["github.com/user/thing/cmd/thing"] 8 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 9 | # 10 | # [[constraint]] 11 | # name = "github.com/user/project" 12 | # version = "1.0.0" 13 | # 14 | # [[constraint]] 15 | # name = "github.com/user/project2" 16 | # branch = "dev" 17 | # source = "github.com/myfork/project2" 18 | # 19 | # [[override]] 20 | # name = "github.com/x/y" 21 | # version = "2.4.0" 22 | 23 | 24 | [[constraint]] 25 | name = "github.com/aclindsa/gorp" 26 | branch = "master" 27 | 28 | [[constraint]] 29 | name = "github.com/aclindsa/ofxgo" 30 | branch = "master" 31 | 32 | [[constraint]] 33 | name = "github.com/go-sql-driver/mysql" 34 | version = "1.3.0" 35 | 36 | [[constraint]] 37 | branch = "master" 38 | name = "github.com/lib/pq" 39 | 40 | [[constraint]] 41 | name = "github.com/mattn/go-sqlite3" 42 | revision = "1fc3fd346d3cc4c610f432d8bc938bb952733873" 43 | 44 | [[constraint]] 45 | branch = "master" 46 | name = "github.com/yuin/gopher-lua" 47 | 48 | [[constraint]] 49 | name = "gopkg.in/gcfg.v1" 50 | version = "1.2.1" 51 | -------------------------------------------------------------------------------- /js/reducers/SecurityTemplateReducer.js: -------------------------------------------------------------------------------- 1 | var assign = require('object-assign'); 2 | 3 | var SecurityTemplateConstants = require('../constants/SecurityTemplateConstants'); 4 | var UserConstants = require('../constants/UserConstants'); 5 | 6 | const initialState = { 7 | search: "", 8 | type: 0, 9 | templates: [], 10 | currencies: [] 11 | }; 12 | 13 | module.exports = function(state = initialState, action) { 14 | switch (action.type) { 15 | case SecurityTemplateConstants.SEARCH_SECURITY_TEMPLATES: 16 | return assign({}, state, { 17 | search: action.searchString, 18 | type: action.searchType, 19 | templates: [] 20 | }); 21 | case SecurityTemplateConstants.SECURITY_TEMPLATES_SEARCHED: 22 | if ((action.searchString != state.search) || (action.searchType != state.type)) 23 | return state; 24 | return assign({}, state, { 25 | search: action.searchString, 26 | type: action.searchType, 27 | templates: action.securities 28 | }); 29 | case SecurityTemplateConstants.CURRENCIES_FETCHED: 30 | return assign({}, state, { 31 | currencies: action.currencies 32 | }); 33 | case UserConstants.USER_LOGGEDOUT: 34 | return assign({}, initialState, { 35 | currencies: state.currencies 36 | }); 37 | default: 38 | return state; 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /js/containers/MoneyGoAppContainer.js: -------------------------------------------------------------------------------- 1 | var connect = require('react-redux').connect; 2 | 3 | var UserActions = require('../actions/UserActions'); 4 | var AccountActions = require('../actions/AccountActions'); 5 | var TransactionActions = require('../actions/TransactionActions'); 6 | var SecurityTemplateActions = require('../actions/SecurityTemplateActions'); 7 | 8 | var MoneyGoApp = require('../components/MoneyGoApp'); 9 | 10 | function mapStateToProps(state) { 11 | return { 12 | user: state.user, 13 | accounts: state.accounts.map, 14 | accountChildren: state.accounts.children, 15 | selectedAccount: state.selectedAccount, 16 | security_list: state.securities.list, 17 | } 18 | } 19 | 20 | function mapDispatchToProps(dispatch) { 21 | return { 22 | tryResumingSession: function() {dispatch(UserActions.tryResumingSession())}, 23 | fetchCurrencies: function() {dispatch(SecurityTemplateActions.fetchCurrencies())}, 24 | onCreateAccount: function(account) {dispatch(AccountActions.create(account))}, 25 | onSelectAccount: function(accountId) {dispatch(AccountActions.select(accountId))}, 26 | onFetchTransactionPage: function(account, pageSize, page) {dispatch(TransactionActions.fetchPage(account, pageSize, page))}, 27 | } 28 | } 29 | 30 | module.exports = connect( 31 | mapStateToProps, 32 | mapDispatchToProps 33 | )(MoneyGoApp) 34 | -------------------------------------------------------------------------------- /js/utils.js: -------------------------------------------------------------------------------- 1 | const recursiveAccountDisplayInfo = function(account, account_map, accountChildren, prefix) { 2 | var name = prefix + account.Name; 3 | var accounts = [{AccountId: account.AccountId, Name: name}]; 4 | for (var i = 0; i < accountChildren[account.AccountId].length; i++) 5 | accounts = accounts.concat(recursiveAccountDisplayInfo(account_map[accountChildren[account.AccountId][i]], account_map, accountChildren, name + "/")); 6 | return accounts 7 | }; 8 | 9 | const getAccountDisplayList = function(account_map, accountChildren, includeRoot, rootName) { 10 | var accounts = [] 11 | if (includeRoot) 12 | accounts.push({AccountId: -1, Name: rootName}); 13 | for (var accountId in account_map) { 14 | if (account_map.hasOwnProperty(accountId) && 15 | account_map[accountId].isRootAccount()) 16 | accounts = accounts.concat(recursiveAccountDisplayInfo(account_map[accountId], account_map, accountChildren, "")); 17 | } 18 | return accounts; 19 | }; 20 | 21 | const getAccountDisplayName = function(account, account_map) { 22 | var name = account.Name; 23 | while (account.ParentAccountId >= 0) { 24 | account = account_map[account.ParentAccountId]; 25 | name = account.Name + "/" + name; 26 | } 27 | return name; 28 | }; 29 | 30 | module.exports = { 31 | getAccountDisplayList: getAccountDisplayList, 32 | getAccountDisplayName: getAccountDisplayName 33 | }; 34 | -------------------------------------------------------------------------------- /js/reducers/ImportReducer.js: -------------------------------------------------------------------------------- 1 | var assign = require('object-assign'); 2 | 3 | var ImportConstants = require('../constants/ImportConstants'); 4 | var UserConstants = require('../constants/UserConstants'); 5 | 6 | const initialState = { 7 | showModal: false, 8 | importing: false, 9 | uploadProgress: 0, 10 | importFinished: false, 11 | importFailed: false, 12 | errorMessage: null 13 | }; 14 | 15 | module.exports = function(state = initialState, action) { 16 | switch (action.type) { 17 | case ImportConstants.OPEN_IMPORT_MODAL: 18 | return assign({}, initialState, { 19 | showModal: true 20 | }); 21 | case ImportConstants.CLOSE_IMPORT_MODAL: 22 | case UserConstants.USER_LOGGEDOUT: 23 | return initialState; 24 | case ImportConstants.BEGIN_IMPORT: 25 | return assign({}, state, { 26 | importing: true 27 | }); 28 | case ImportConstants.UPDATE_IMPORT_PROGRESS: 29 | return assign({}, state, { 30 | uploadProgress: action.progress 31 | }); 32 | case ImportConstants.IMPORT_FINISHED: 33 | return assign({}, state, { 34 | importing: false, 35 | uploadProgress: 100, 36 | importFinished: true 37 | }); 38 | case ImportConstants.IMPORT_FAILED: 39 | return assign({}, state, { 40 | importing: false, 41 | importFailed: true, 42 | errorMessage: action.error 43 | }); 44 | default: 45 | return state; 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /internal/store/db/reports.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aclindsa/moneygo/internal/models" 6 | ) 7 | 8 | func (tx *Tx) GetReport(reportid int64, userid int64) (*models.Report, error) { 9 | var r models.Report 10 | 11 | err := tx.SelectOne(&r, "SELECT * from reports where UserId=? AND ReportId=?", userid, reportid) 12 | if err != nil { 13 | return nil, err 14 | } 15 | return &r, nil 16 | } 17 | 18 | func (tx *Tx) GetReports(userid int64) (*[]*models.Report, error) { 19 | var reports []*models.Report 20 | 21 | _, err := tx.Select(&reports, "SELECT * from reports where UserId=?", userid) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return &reports, nil 26 | } 27 | 28 | func (tx *Tx) InsertReport(report *models.Report) error { 29 | err := tx.Insert(report) 30 | if err != nil { 31 | return err 32 | } 33 | return nil 34 | } 35 | 36 | func (tx *Tx) UpdateReport(report *models.Report) error { 37 | count, err := tx.Update(report) 38 | if err != nil { 39 | return err 40 | } 41 | if count != 1 { 42 | return fmt.Errorf("Expected to update 1 report, was going to update %d", count) 43 | } 44 | return nil 45 | } 46 | 47 | func (tx *Tx) DeleteReport(report *models.Report) error { 48 | count, err := tx.Delete(report) 49 | if err != nil { 50 | return err 51 | } 52 | if count != 1 { 53 | return fmt.Errorf("Expected to delete 1 report, was going to delete %d", count) 54 | } 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /js/components/AccountCombobox.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var Combobox = require('react-widgets').Combobox; 4 | 5 | var getAccountDisplayList = require('../utils').getAccountDisplayList; 6 | 7 | class AccountCombobox extends React.Component { 8 | static get defaultProps() { 9 | return { 10 | includeRoot: true, 11 | disabled: false, 12 | rootName: "New Top-level Account" 13 | } 14 | } 15 | constructor() { 16 | super(); 17 | this.onAccountChange = this.handleAccountChange.bind(this); 18 | } 19 | handleAccountChange(account) { 20 | if (this.props.onChange != null && 21 | account.hasOwnProperty('AccountId') && 22 | (this.props.accounts.hasOwnProperty([account.AccountId]) || 23 | account.AccountId == -1)) { 24 | this.props.onChange(account) 25 | } 26 | } 27 | render() { 28 | var accounts = getAccountDisplayList(this.props.accounts, this.props.accountChildren, this.props.includeRoot, this.props.rootName); 29 | var className = ""; 30 | if (this.props.className) 31 | className = this.props.className; 32 | return ( 33 | 44 | ); 45 | } 46 | } 47 | 48 | module.exports = AccountCombobox; 49 | -------------------------------------------------------------------------------- /internal/handlers/reports/years_income.lua: -------------------------------------------------------------------------------- 1 | function account_series_map(accounts, tabulation) 2 | map = {} 3 | 4 | for i=1,100 do -- we're not messing with accounts more than 100 levels deep 5 | all_handled = true 6 | for id, acct in pairs(accounts) do 7 | if not map[id] then 8 | all_handled = false 9 | if not acct.parent then 10 | map[id] = tabulation:series(acct.name) 11 | elseif map[acct.parent.accountid] then 12 | map[id] = map[acct.parent.accountid]:series(acct.name) 13 | end 14 | end 15 | end 16 | if all_handled then 17 | return map 18 | end 19 | end 20 | 21 | error("Accounts nested (at least) 100 levels deep") 22 | end 23 | 24 | function generate() 25 | year = date.now().year 26 | account_type = account.Income 27 | 28 | accounts = get_accounts() 29 | t = tabulation.new(1) 30 | t:title(year .. " Income") 31 | series_map = account_series_map(accounts, t) 32 | 33 | begin_date = date.new(year, 1, 1) 34 | end_date = date.new(year+1, 1, 1) 35 | 36 | t:label(1, year .. " Income") 37 | 38 | for id, acct in pairs(accounts) do 39 | series = series_map[id] 40 | if acct.type == account_type then 41 | balance = acct:balance(begin_date, end_date) 42 | series:value(1, balance.amount) 43 | end 44 | end 45 | 46 | return t 47 | end 48 | -------------------------------------------------------------------------------- /internal/models/sessions.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type Session struct { 14 | SessionId int64 15 | SessionSecret string `json:"-"` 16 | UserId int64 17 | Created time.Time 18 | Expires time.Time 19 | } 20 | 21 | func (s *Session) Cookie(domain string) *http.Cookie { 22 | return &http.Cookie{ 23 | Name: "moneygo-session", 24 | Value: s.SessionSecret, 25 | Path: "/", 26 | Domain: domain, 27 | Expires: s.Expires, 28 | Secure: true, 29 | HttpOnly: true, 30 | } 31 | } 32 | 33 | func (s *Session) Write(w http.ResponseWriter) error { 34 | enc := json.NewEncoder(w) 35 | return enc.Encode(s) 36 | } 37 | 38 | func (s *Session) Read(json_str string) error { 39 | dec := json.NewDecoder(strings.NewReader(json_str)) 40 | return dec.Decode(s) 41 | } 42 | 43 | func newSessionSecret() (string, error) { 44 | bits := make([]byte, 128) 45 | if _, err := io.ReadFull(rand.Reader, bits); err != nil { 46 | return "", err 47 | } 48 | return base64.StdEncoding.EncodeToString(bits), nil 49 | } 50 | 51 | func NewSession(userid int64) (*Session, error) { 52 | session_secret, err := newSessionSecret() 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | now := time.Now() 58 | 59 | s := Session{ 60 | SessionSecret: session_secret, 61 | UserId: userid, 62 | Created: now, 63 | Expires: now.AddDate(0, 1, 0), // a month from now 64 | } 65 | 66 | return &s, nil 67 | } 68 | -------------------------------------------------------------------------------- /internal/handlers/reports/asset_allocation.lua: -------------------------------------------------------------------------------- 1 | function generate() 2 | accounts = get_accounts() 3 | securities = get_securities() 4 | default_currency = get_default_currency() 5 | series_map = {} 6 | totals_map = {} 7 | 8 | t = tabulation.new(1) 9 | t:title("Current Asset Allocation") 10 | 11 | t:label(1, "Assets") 12 | 13 | for id, security in pairs(securities) do 14 | totals_map[id] = 0 15 | series_map[id] = t:series(tostring(security)) 16 | end 17 | 18 | for id, acct in pairs(accounts) do 19 | if acct.type == account.Asset or acct.type == account.Investment or acct.type == account.Bank or acct.type == account.Cash then 20 | balance = acct:balance() 21 | multiplier = 1 22 | if acct.security ~= default_currency and balance.amount ~= 0 then 23 | price = acct.security:closestprice(default_currency, date.now()) 24 | if price == nil then 25 | --[[ 26 | -- This should contain code to warn the user that their report is missing some information 27 | --]] 28 | multiplier = 0 29 | else 30 | multiplier = price.value 31 | end 32 | end 33 | totals_map[acct.security.SecurityId] = balance.amount * multiplier + totals_map[acct.security.SecurityId] 34 | end 35 | end 36 | 37 | for id, series in pairs(series_map) do 38 | series:value(1, totals_map[id]) 39 | end 40 | 41 | return t 42 | end 43 | -------------------------------------------------------------------------------- /internal/handlers/reports/monthly_expenses.lua: -------------------------------------------------------------------------------- 1 | function account_series_map(accounts, tabulation) 2 | map = {} 3 | 4 | for i=1,100 do -- we're not messing with accounts more than 100 levels deep 5 | all_handled = true 6 | for id, acct in pairs(accounts) do 7 | if not map[id] then 8 | all_handled = false 9 | if not acct.parent then 10 | map[id] = tabulation:series(acct.name) 11 | elseif map[acct.parent.accountid] then 12 | map[id] = map[acct.parent.accountid]:series(acct.name) 13 | end 14 | end 15 | end 16 | if all_handled then 17 | return map 18 | end 19 | end 20 | 21 | error("Accounts nested (at least) 100 levels deep") 22 | end 23 | 24 | function generate() 25 | year = date.now().year 26 | account_type = account.Expense 27 | 28 | accounts = get_accounts() 29 | t = tabulation.new(12) 30 | t:title(year .. " Monthly Expenses") 31 | series_map = account_series_map(accounts, t) 32 | 33 | for month=1,12 do 34 | begin_date = date.new(year, month, 1) 35 | end_date = date.new(year, month+1, 1) 36 | 37 | t:label(month, tostring(begin_date)) 38 | 39 | for id, acct in pairs(accounts) do 40 | series = series_map[id] 41 | if acct.type == account_type then 42 | balance = acct:balance(begin_date, end_date) 43 | series:value(month, balance.amount) 44 | end 45 | end 46 | end 47 | 48 | return t 49 | end 50 | -------------------------------------------------------------------------------- /internal/models/reports.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | type Report struct { 10 | ReportId int64 11 | UserId int64 12 | Name string 13 | Lua string 14 | } 15 | 16 | // The maximum length (in bytes) the Lua code may be. This is used to set the 17 | // max size of the database columns (with an added fudge factor) 18 | const LuaMaxLength int = 65536 19 | 20 | func (r *Report) Write(w http.ResponseWriter) error { 21 | enc := json.NewEncoder(w) 22 | return enc.Encode(r) 23 | } 24 | 25 | func (r *Report) Read(json_str string) error { 26 | dec := json.NewDecoder(strings.NewReader(json_str)) 27 | return dec.Decode(r) 28 | } 29 | 30 | type ReportList struct { 31 | Reports *[]*Report `json:"reports"` 32 | } 33 | 34 | func (rl *ReportList) Write(w http.ResponseWriter) error { 35 | enc := json.NewEncoder(w) 36 | return enc.Encode(rl) 37 | } 38 | 39 | func (rl *ReportList) Read(json_str string) error { 40 | dec := json.NewDecoder(strings.NewReader(json_str)) 41 | return dec.Decode(rl) 42 | } 43 | 44 | type Series struct { 45 | Values []float64 46 | Series map[string]*Series 47 | } 48 | 49 | type Tabulation struct { 50 | ReportId int64 51 | Title string 52 | Subtitle string 53 | Units string 54 | Labels []string 55 | Series map[string]*Series 56 | } 57 | 58 | func (t *Tabulation) Write(w http.ResponseWriter) error { 59 | enc := json.NewEncoder(w) 60 | return enc.Encode(t) 61 | } 62 | 63 | func (t *Tabulation) Read(json_str string) error { 64 | dec := json.NewDecoder(strings.NewReader(json_str)) 65 | return dec.Decode(t) 66 | } 67 | -------------------------------------------------------------------------------- /internal/integration/testdata/creditcard.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 0INFOSUCCESS20171128054239.013[-5:EST]ENGC2292921cc61e4b-1f74-7d9a-b143-b8c80d5fda580INFOUSD123412341234123420170731054239.277[-4:EDT]20171128054239.277[-5:EST]DEBIT20171016120000[0:GMT]-99.982017101624445727288300440999736KROGER #111DEBIT20170910120000[0:GMT]-1502017091024493987251438675718282CHARITY DONATIONDEBIT20170814120000[0:GMT]-44.992017081424692167225100642481235CABLECREDIT20171101120000[0:GMT]185.712017110123053057200000291455612Payment Thank You ElectroDEBIT20171016120000[0:GMT]-4.492017101624510727289100677772726CRAFTSCREDIT20170815120000[0:GMT]109.262017081574692167226100322807539Example.com-4.4920171128070000.000[-5:EST]995.5120171128070000.000[-5:EST] 12 | -------------------------------------------------------------------------------- /internal/integration/prices_lua_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "testing" 7 | ) 8 | 9 | func TestLuaPrices(t *testing.T) { 10 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 11 | security := d.securities[1] 12 | currency := d.securities[0] 13 | 14 | simpleLuaTest(t, d.clients[0], []LuaTest{ 15 | {"Security:ClosestPrice", fmt.Sprintf("secs = get_securities(); return secs[%d]:ClosestPrice(secs[%d], date.new('2016-11-19'))", security.SecurityId, currency.SecurityId), fmt.Sprintf("225.24 %s (%s)", currency.Symbol, security.Symbol)}, 16 | {"Security:ClosestPrice(2)", fmt.Sprintf("secs = get_securities(); return secs[%d]:ClosestPrice(secs[%d], date.new('2017-01-04'))", security.SecurityId, currency.SecurityId), fmt.Sprintf("226.58 %s (%s)", currency.Symbol, security.Symbol)}, 17 | {"PriceId", fmt.Sprintf("secs = get_securities(); return secs[%d]:ClosestPrice(secs[%d], date.new('2016-11-19')).PriceId", security.SecurityId, currency.SecurityId), strconv.FormatInt(d.prices[0].PriceId, 10)}, 18 | {"Security", fmt.Sprintf("secs = get_securities(); return secs[%d]:ClosestPrice(secs[%d], date.new('2016-11-19')).Security == secs[%d]", security.SecurityId, currency.SecurityId, security.SecurityId), "true"}, 19 | {"Currency", fmt.Sprintf("secs = get_securities(); return secs[%d]:ClosestPrice(secs[%d], date.new('2016-11-19')).Currency == secs[%d]", security.SecurityId, currency.SecurityId, currency.SecurityId), "true"}, 20 | {"Value", fmt.Sprintf("secs = get_securities(); return secs[%d]:ClosestPrice(secs[%d], date.new('2098-11-09')).Value", security.SecurityId, currency.SecurityId), "227.21"}, 21 | }) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /internal/models/securities.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | type SecurityType int64 10 | 11 | const ( 12 | Currency SecurityType = 1 13 | Stock = 2 14 | ) 15 | 16 | func GetSecurityType(typestring string) SecurityType { 17 | if strings.EqualFold(typestring, "currency") { 18 | return Currency 19 | } else if strings.EqualFold(typestring, "stock") { 20 | return Stock 21 | } else { 22 | return 0 23 | } 24 | } 25 | 26 | // MaxPrexision denotes the maximum valid value for Security.Precision 27 | const MaxPrecision uint64 = 15 28 | 29 | type Security struct { 30 | SecurityId int64 31 | UserId int64 32 | Name string 33 | Description string 34 | Symbol string 35 | // Number of decimal digits (to the right of the decimal point) this 36 | // security is precise to 37 | Precision uint64 `db:"Preciseness"` 38 | Type SecurityType 39 | // AlternateId is CUSIP for Type=Stock, ISO4217 for Type=Currency 40 | AlternateId string 41 | } 42 | 43 | type SecurityList struct { 44 | Securities *[]*Security `json:"securities"` 45 | } 46 | 47 | func (s *Security) Read(json_str string) error { 48 | dec := json.NewDecoder(strings.NewReader(json_str)) 49 | return dec.Decode(s) 50 | } 51 | 52 | func (s *Security) Write(w http.ResponseWriter) error { 53 | enc := json.NewEncoder(w) 54 | return enc.Encode(s) 55 | } 56 | 57 | func (sl *SecurityList) Read(json_str string) error { 58 | dec := json.NewDecoder(strings.NewReader(json_str)) 59 | return dec.Decode(sl) 60 | } 61 | 62 | func (sl *SecurityList) Write(w http.ResponseWriter) error { 63 | enc := json.NewEncoder(w) 64 | return enc.Encode(sl) 65 | } 66 | -------------------------------------------------------------------------------- /js/containers/SecuritiesTabContainer.js: -------------------------------------------------------------------------------- 1 | var connect = require('react-redux').connect; 2 | 3 | var SecurityActions = require('../actions/SecurityActions'); 4 | var SecurityTemplateActions = require('../actions/SecurityTemplateActions'); 5 | var ErrorActions = require('../actions/ErrorActions'); 6 | 7 | var SecuritiesTab = require('../components/SecuritiesTab'); 8 | 9 | function mapStateToProps(state) { 10 | var selectedSecurityAccounts = []; 11 | for (var accountId in state.accounts.map) { 12 | if (state.accounts.map.hasOwnProperty(accountId) 13 | && state.accounts.map[accountId].SecurityId == state.selectedSecurity) 14 | selectedSecurityAccounts.push(state.accounts.map[accountId]); 15 | } 16 | return { 17 | user: state.user, 18 | securities: state.securities.map, 19 | security_list: state.securities.list, 20 | selectedSecurityAccounts: selectedSecurityAccounts, 21 | selectedSecurity: state.selectedSecurity, 22 | securityTemplates: state.securityTemplates 23 | } 24 | } 25 | 26 | function mapDispatchToProps(dispatch) { 27 | return { 28 | onCreateSecurity: function(security) {dispatch(SecurityActions.create(security))}, 29 | onUpdateSecurity: function(security) {dispatch(SecurityActions.update(security))}, 30 | onDeleteSecurity: function(securityId) {dispatch(SecurityActions.remove(securityId))}, 31 | onSelectSecurity: function(securityId) {dispatch(SecurityActions.select(securityId))}, 32 | onSearchTemplates: function(search, type, limit) {dispatch(SecurityTemplateActions.search(search, type, limit))}, 33 | onUserError: function(error) {dispatch(ErrorActions.userError(error))} 34 | } 35 | } 36 | 37 | module.exports = connect( 38 | mapStateToProps, 39 | mapDispatchToProps 40 | )(SecuritiesTab) 41 | -------------------------------------------------------------------------------- /js/reducers/AccountReducer.js: -------------------------------------------------------------------------------- 1 | var assign = require('object-assign'); 2 | 3 | var AccountConstants = require('../constants/AccountConstants'); 4 | var UserConstants = require('../constants/UserConstants'); 5 | 6 | function accountChildren(accounts) { 7 | var children = {}; 8 | for (var accountId in accounts) { 9 | if (accounts.hasOwnProperty(accountId)) { 10 | var parentAccountId = accounts[accountId].ParentAccountId; 11 | if (!children.hasOwnProperty(parentAccountId)) 12 | children[parentAccountId] = []; 13 | if (!children.hasOwnProperty(accountId)) 14 | children[accountId] = []; 15 | children[parentAccountId].push(accountId); 16 | } 17 | } 18 | return children; 19 | } 20 | 21 | const initialState = {map: {}, children: {}}; 22 | 23 | module.exports = function(state = initialState, action) { 24 | switch (action.type) { 25 | case AccountConstants.ACCOUNTS_FETCHED: 26 | var accounts = {}; 27 | for (var i = 0; i < action.accounts.length; i++) { 28 | var account = action.accounts[i]; 29 | accounts[account.AccountId] = account; 30 | } 31 | return { 32 | map: accounts, 33 | children: accountChildren(accounts) 34 | }; 35 | case AccountConstants.ACCOUNT_CREATED: 36 | case AccountConstants.ACCOUNT_UPDATED: 37 | var account = action.account; 38 | var accounts = assign({}, state.map, { 39 | [account.AccountId]: account 40 | }); 41 | return { 42 | map: accounts, 43 | children: accountChildren(accounts) 44 | }; 45 | case AccountConstants.ACCOUNT_REMOVED: 46 | var accounts = assign({}, state.map); 47 | delete accounts[action.accountId]; 48 | return { 49 | map: accounts, 50 | children: accountChildren(accounts) 51 | }; 52 | case UserConstants.USER_LOGGEDOUT: 53 | return initialState; 54 | default: 55 | return state; 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /internal/integration/date_lua_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestLuaDates(t *testing.T) { 8 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 9 | simpleLuaTest(t, d.clients[0], []LuaTest{ 10 | {"Year", "return date.new('0009-01-03').Year", "9"}, 11 | {"Month", "return date.new('3999-02-01').Month", "2"}, 12 | {"Day", "return date.new('1997-12-31').Day", "31"}, 13 | {"__tostring", "return date.new('0997-12-01')", "0997-12-01"}, 14 | {"__tostring 2", "return date.new(997, 12, 1)", "0997-12-01"}, 15 | {"__tostring 3", "return date.new({year=997, month=12, day=1})", "0997-12-01"}, 16 | {"__eq", "return date.new('2017-10-05') == date.new(2017, 10, 5)", "true"}, 17 | {"(not) __eq", "return date.new('0997-12-01') == date.new('1997-12-01')", "false"}, 18 | {"__lt", "return date.new('0997-12-01') < date.new('1997-12-01')", "true"}, 19 | {"(not) __lt", "return date.new('2001-12-01') < date.new('1997-12-01')", "false"}, 20 | {"not __lt self", "return date.new('2001-12-01') < date.new('2001-12-01')", "false"}, 21 | {"__le", "return date.new('0997-12-01') <= date.new('1997-12-01')", "true"}, 22 | {"(not) __le", "return date.new(2001, 12, 1) <= date.new('1997-12-01')", "false"}, 23 | {"__le self", "return date.new('2001-12-01') <= date.new(2001, 12, 1)", "true"}, 24 | {"__add", "return date.new('2001-12-30') + date.new({year=0, month=0, day=1})", "2001-12-31"}, 25 | {"__add", "return date.new('2001-12-30') + date.new({year=0, month=0, day=2})", "2002-01-01"}, 26 | {"__sub", "return date.new('2001-12-30') - date.new({year=1, month=1, day=1})", "2000-11-29"}, 27 | {"__sub", "return date.new('2058-03-01') - date.new({year=0, month=0, day=1})", "2058-02-28"}, 28 | {"__sub", "return date.new('2058-03-31') - date.new({year=0, month=1, day=0})", "2058-02-28"}, 29 | }) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /internal/config/testdata/sqlite_https_config.ini: -------------------------------------------------------------------------------- 1 | [moneygo] 2 | 3 | # Whether to serve as FastCGI (default is false, for HTTPS) 4 | fcgi = false 5 | 6 | # Port on which to serve HTTPS or FCGI 7 | port = 8443 8 | 9 | # Base directory for serving files out of. This should point to the root of the 10 | # moneygo source directory 11 | base-directory = src/github.com/aclindsa/moneygo/ 12 | 13 | # Type of database being used (sqlite3, mysql, postgres) 14 | db-type = sqlite3 15 | 16 | # 'Data Source Name' for the database being used. This is driver-specific. See 17 | # the following examples and external resources for more information about 18 | # configuring this for your particular database configuration: 19 | # 20 | # Sqlite example DSN: "file:moneygo.sqlite?cache=shared&mode=rwc" 21 | # MySQL documentation: https://github.com/go-sql-driver/mysql/#dsn-data-source-name 22 | # example DSN: "user:password@tcp(localhost)/dbname&parseTime=true" 23 | # (Note: MySQL DSN's *must* include the 24 | # "parseTime=true" parameter) 25 | # Postgres documentation: https://godoc.org/github.com/lib/pq 26 | # example DSN: "postgres://user:password@localhost/dbname" 27 | db-dsn = file:moneygo.sqlite?cache=shared&mode=rwc 28 | 29 | 30 | [https] 31 | # If 'fcgi = false', the following paths to a SSL certificate and the paired 32 | # private key are used when serving HTTPS 33 | cert-file = ./cert.pem 34 | key-file = ./key.pem 35 | 36 | # Attempt to generate self-signed certificates if the certificate files 37 | # specified above are missing or invalid. This should *never* be set to 'true' 38 | # for any environment where security is important (including but not limited to 39 | # production systems) 40 | generate-certs-if-absent = false 41 | # A CSV list of hostnames to generate the above certs for 42 | generate-certs-hosts = localhost,127.0.0.1 43 | -------------------------------------------------------------------------------- /internal/config/testdata/generate_certs_config.ini: -------------------------------------------------------------------------------- 1 | [moneygo] 2 | 3 | # Whether to serve as FastCGI (default is false, for HTTPS) 4 | fcgi = false 5 | 6 | # Port on which to serve HTTPS or FCGI 7 | port = 8443 8 | 9 | # Base directory for serving files out of. This should point to the root of the 10 | # moneygo source directory 11 | base-directory = src/github.com/aclindsa/moneygo/ 12 | 13 | # Type of database being used (sqlite3, mysql, postgres) 14 | db-type = sqlite3 15 | 16 | # 'Data Source Name' for the database being used. This is driver-specific. See 17 | # the following examples and external resources for more information about 18 | # configuring this for your particular database configuration: 19 | # 20 | # Sqlite example DSN: "file:moneygo.sqlite?cache=shared&mode=rwc" 21 | # MySQL documentation: https://github.com/go-sql-driver/mysql/#dsn-data-source-name 22 | # example DSN: "user:password@tcp(localhost)/dbname&parseTime=true" 23 | # (Note: MySQL DSN's *must* include the 24 | # "parseTime=true" parameter) 25 | # Postgres documentation: https://godoc.org/github.com/lib/pq 26 | # example DSN: "postgres://user:password@localhost/dbname" 27 | db-dsn = file:moneygo.sqlite?cache=shared&mode=rwc 28 | 29 | 30 | [https] 31 | # If 'fcgi = false', the following paths to a SSL certificate and the paired 32 | # private key are used when serving HTTPS 33 | cert-file = ./local_cert.pem 34 | key-file = ./local_key.pem 35 | 36 | # Attempt to generate self-signed certificates if the certificate files 37 | # specified above are missing or invalid. This should *never* be set to 'true' 38 | # for any environment where security is important (including but not limited to 39 | # production systems) 40 | generate-certs-if-absent = true 41 | # A CSV list of hostnames to generate the above certs for 42 | generate-certs-hosts = example.com 43 | -------------------------------------------------------------------------------- /internal/config/testdata/postgres_fcgi_config.ini: -------------------------------------------------------------------------------- 1 | [moneygo] 2 | 3 | # Whether to serve as FastCGI (default is false, for HTTPS) 4 | fcgi = true 5 | 6 | # Port on which to serve HTTPS or FCGI 7 | port = 9001 8 | 9 | # Base directory for serving files out of. This should point to the root of the 10 | # moneygo source directory 11 | base-directory = src/github.com/aclindsa/moneygo/ 12 | 13 | # Type of database being used (sqlite3, mysql, postgres) 14 | db-type = postgres 15 | 16 | # 'Data Source Name' for the database being used. This is driver-specific. See 17 | # the following examples and external resources for more information about 18 | # configuring this for your particular database configuration: 19 | # 20 | # Sqlite example DSN: "file:moneygo.sqlite?cache=shared&mode=rwc" 21 | # MySQL documentation: https://github.com/go-sql-driver/mysql/#dsn-data-source-name 22 | # example DSN: "user:password@tcp(localhost)/dbname&parseTime=true" 23 | # (Note: MySQL DSN's *must* include the 24 | # "parseTime=true" parameter) 25 | # Postgres documentation: https://godoc.org/github.com/lib/pq 26 | # example DSN: "postgres://user:password@localhost/dbname" 27 | db-dsn = postgres://moneygo_test@localhost/moneygo_test?sslmode=disable 28 | 29 | 30 | [https] 31 | # If 'fcgi = false', the following paths to a SSL certificate and the paired 32 | # private key are used when serving HTTPS 33 | cert-file = ./cert.pem 34 | key-file = ./key.pem 35 | 36 | # Attempt to generate self-signed certificates if the certificate files 37 | # specified above are missing or invalid. This should *never* be set to 'true' 38 | # for any environment where security is important (including but not limited to 39 | # production systems) 40 | generate-certs-if-absent = false 41 | # A CSV list of hostnames to generate the above certs for 42 | generate-certs-hosts = localhost,127.0.0.1 43 | -------------------------------------------------------------------------------- /internal/store/db/tx.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/aclindsa/gorp" 6 | "strings" 7 | ) 8 | 9 | type Tx struct { 10 | Dialect gorp.Dialect 11 | Tx *gorp.Transaction 12 | } 13 | 14 | func (tx *Tx) Rebind(query string) string { 15 | chunks := strings.Split(query, "?") 16 | str := chunks[0] 17 | for i := 1; i < len(chunks); i++ { 18 | str += tx.Dialect.BindVar(i-1) + chunks[i] 19 | } 20 | return str 21 | } 22 | 23 | func (tx *Tx) Select(i interface{}, query string, args ...interface{}) ([]interface{}, error) { 24 | return tx.Tx.Select(i, tx.Rebind(query), args...) 25 | } 26 | 27 | func (tx *Tx) Exec(query string, args ...interface{}) (sql.Result, error) { 28 | return tx.Tx.Exec(tx.Rebind(query), args...) 29 | } 30 | 31 | func (tx *Tx) SelectInt(query string, args ...interface{}) (int64, error) { 32 | return tx.Tx.SelectInt(tx.Rebind(query), args...) 33 | } 34 | 35 | func (tx *Tx) SelectOne(holder interface{}, query string, args ...interface{}) error { 36 | return tx.Tx.SelectOne(holder, tx.Rebind(query), args...) 37 | } 38 | 39 | func (tx *Tx) Insert(list ...interface{}) error { 40 | return tx.Tx.Insert(list...) 41 | } 42 | 43 | func (tx *Tx) Update(list ...interface{}) (int64, error) { 44 | count, err := tx.Tx.Update(list...) 45 | if count == 0 { 46 | switch tx.Dialect.(type) { 47 | case gorp.MySQLDialect: 48 | // Always return 1 for 0 if we're using MySQL because it returns 49 | // count=0 if the row data was unchanged, even if the row existed 50 | 51 | // TODO Find another way to fix this without risking ignoring 52 | // errors 53 | 54 | count = 1 55 | } 56 | } 57 | return count, err 58 | } 59 | 60 | func (tx *Tx) Delete(list ...interface{}) (int64, error) { 61 | return tx.Tx.Delete(list...) 62 | } 63 | 64 | func (tx *Tx) Commit() error { 65 | return tx.Tx.Commit() 66 | } 67 | 68 | func (tx *Tx) Rollback() error { 69 | return tx.Tx.Rollback() 70 | } 71 | -------------------------------------------------------------------------------- /internal/handlers/scripts/gen_cusip_csv.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | QUARTER=2017q1 3 | 4 | function get_ticker() { 5 | local cusip=$1 6 | 7 | local tmpfile=$tmpdir/curl_tmpfile 8 | curl -s -d "sopt=cusip&tickersymbol=${cusip}" http://quantumonline.com/search.cfm > $tmpfile 9 | local quantum_name=$(sed -rn 's@
(.+)
\s*$@\1@p' $tmpfile | head -n1) 10 | local quantum_ticker=$(sed -rn 's@^.*Ticker Symbol: ([A-Z\.0-9\-]+)     CUSIP.*$@\1@p' $tmpfile | head -n1) 11 | 12 | if [[ -z $quantum_ticker ]] || [[ -z $quantum_name ]]; then 13 | curl -s -d "reqforlookup=REQUESTFORLOOKUP&productid=mmnet&isLoggedIn=mmnet&rows=50&for=stock&by=cusip&criteria=${cusip}&submit=Search" http://quotes.fidelity.com/mmnet/SymLookup.phtml > $tmpfile 14 | fidelity_name=$(sed -rn 's@(.+)\s*@\1@p' $tmpfile | sed -r 's/\&/\&/') 15 | fidelity_ticker=$(sed -rn 's@\s+(.+)\s*@\1@p' $tmpfile | head -n1) 16 | if [[ -z $fidelity_ticker ]] || [[ -z $fidelity_name ]]; then 17 | echo $cusip >> $tmpdir/${QUARTER}_bad_cusips.csv 18 | else 19 | echo "$cusip,$fidelity_ticker,$fidelity_name" 20 | fi 21 | else 22 | echo "$cusip,$quantum_ticker,$quantum_name" 23 | fi 24 | } 25 | 26 | tmpdir=$(mktemp -d -p $PWD) 27 | 28 | # Get the list of CUSIPs from the SEC and generate a nicer format of it 29 | wget -q http://www.sec.gov/divisions/investment/13f/13flist${QUARTER}.pdf -O $tmpdir/13flist${QUARTER}.pdf 30 | pdftotext -layout $tmpdir/13flist${QUARTER}.pdf - > $tmpdir/13flist${QUARTER}.txt 31 | sed -rn 's/^([A-Z0-9]{6}) ([A-Z0-9]{2}) ([A-Z0-9]) .*$/\1\2\3/p' $tmpdir/13flist${QUARTER}.txt > $tmpdir/${QUARTER}_cusips 32 | 33 | # Find tickers and names for all the CUSIPs we can and print them out 34 | for cusip in $(cat $tmpdir/${QUARTER}_cusips); do 35 | get_ticker $cusip 36 | done 37 | 38 | rm -rf $tmpdir 39 | -------------------------------------------------------------------------------- /js/reducers/TransactionPageReducer.js: -------------------------------------------------------------------------------- 1 | var assign = require('object-assign'); 2 | 3 | var TransactionConstants = require('../constants/TransactionConstants'); 4 | var UserConstants = require('../constants/UserConstants'); 5 | var AccountConstants = require('../constants/AccountConstants'); 6 | 7 | var Account = require('../models').Account; 8 | 9 | module.exports = function(state = {account: new Account(), pageSize: 1, page: 0, numPages: 0, transactions: [], endingBalance: "0", selection: -1, upToDate: false }, action) { 10 | switch (action.type) { 11 | case AccountConstants.ACCOUNT_SELECTED: 12 | case TransactionConstants.FETCH_TRANSACTION_PAGE: 13 | return assign({}, state, { 14 | account: action.account, 15 | pageSize: action.pageSize, 16 | page: action.page, 17 | numPages: 0, 18 | transactions: [], 19 | endingBalance: "0", 20 | upToDate: true 21 | }); 22 | case TransactionConstants.TRANSACTION_PAGE_FETCHED: 23 | return assign({}, state, { 24 | account: action.account, 25 | pageSize: action.pageSize, 26 | page: action.page, 27 | numPages: action.numPages, 28 | transactions: action.transactions.map(function(t) {return t.TransactionId}), 29 | endingBalance: action.endingBalance, 30 | upToDate: true 31 | }); 32 | case UserConstants.USER_LOGGEDOUT: 33 | return { 34 | account: new Account(), 35 | pageSize: 1, 36 | page: 0, 37 | numPages: 0, 38 | transactions: [], 39 | endingBalance: "0", 40 | selection: -1, 41 | upToDate: false 42 | }; 43 | case TransactionConstants.TRANSACTION_CREATED: 44 | case TransactionConstants.TRANSACTION_UPDATED: 45 | return assign({}, state, { 46 | upToDate: false 47 | }); 48 | case TransactionConstants.TRANSACTION_REMOVED: 49 | return assign({}, state, { 50 | transactions: state.transactions.filter(function(t) {return t != action.transactionId}), 51 | upToDate: false 52 | }); 53 | case TransactionConstants.TRANSACTION_SELECTED: 54 | return assign({}, state, { 55 | selection: action.transactionId 56 | }); 57 | case TransactionConstants.SELECTION_CLEARED: 58 | return assign({}, state, { 59 | selection: -1 60 | }); 61 | default: 62 | return state; 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /internal/handlers/reports/monthly_net_worth.lua: -------------------------------------------------------------------------------- 1 | function account_series_map(accounts, tabulation) 2 | map = {} 3 | 4 | for i=1,100 do -- we're not messing with accounts more than 100 levels deep 5 | all_handled = true 6 | for id, acct in pairs(accounts) do 7 | if not map[id] then 8 | all_handled = false 9 | if not acct.parent then 10 | map[id] = tabulation:series(acct.name) 11 | elseif map[acct.parent.accountid] then 12 | map[id] = map[acct.parent.accountid]:series(acct.name) 13 | end 14 | end 15 | end 16 | if all_handled then 17 | return map 18 | end 19 | end 20 | 21 | error("Accounts nested (at least) 100 levels deep") 22 | end 23 | 24 | function generate() 25 | year = date.now().year 26 | 27 | accounts = get_accounts() 28 | t = tabulation.new(12) 29 | t:title(year .. " Monthly Net Worth") 30 | series_map = account_series_map(accounts, t) 31 | default_currency = get_default_currency() 32 | 33 | for month=1,12 do 34 | end_date = date.new(year, month+1, 1) 35 | 36 | t:label(month, tostring(end_date)) 37 | 38 | for id, acct in pairs(accounts) do 39 | series = series_map[id] 40 | if acct.type ~= account.Expense and acct.type ~= account.Income and acct.type ~= account.Trading then 41 | balance = acct:balance(end_date) 42 | multiplier = 1 43 | if acct.security ~= default_currency and balance.amount ~= 0 then 44 | price = acct.security:closestprice(default_currency, end_date) 45 | if price == nil then 46 | --[[ 47 | -- This should contain code to warn the user that their report is missing some information 48 | --]] 49 | multiplier = 0 50 | else 51 | multiplier = price.value 52 | end 53 | end 54 | series:value(month, balance.amount * multiplier) 55 | end 56 | end 57 | end 58 | 59 | return t 60 | end 61 | -------------------------------------------------------------------------------- /js/reducers/SecurityReducer.js: -------------------------------------------------------------------------------- 1 | var assign = require('object-assign'); 2 | 3 | var SecurityConstants = require('../constants/SecurityConstants'); 4 | var UserConstants = require('../constants/UserConstants'); 5 | 6 | var SecurityType = require('../models').SecurityType; 7 | 8 | const initialState = { 9 | map: {}, 10 | list: [], 11 | currency_list: [] 12 | } 13 | 14 | function mapToList(securities) { 15 | var security_list = []; 16 | for (var securityId in securities) { 17 | if (securities.hasOwnProperty(securityId)) 18 | security_list.push(securities[securityId]); 19 | } 20 | return security_list; 21 | } 22 | 23 | function mapToCurrencyList(securities) { 24 | var security_list = []; 25 | for (var securityId in securities) { 26 | if (securities.hasOwnProperty(securityId) && securities[securityId].Type == SecurityType.Currency) 27 | security_list.push(securities[securityId]); 28 | } 29 | return security_list; 30 | } 31 | 32 | module.exports = function(state = initialState, action) { 33 | switch (action.type) { 34 | case SecurityConstants.SECURITIES_FETCHED: 35 | var securities = {}; 36 | var list = []; 37 | var currency_list = []; 38 | for (var i = 0; i < action.securities.length; i++) { 39 | var security = action.securities[i]; 40 | securities[security.SecurityId] = security; 41 | list.push(security); 42 | if (security.Type == SecurityType.Currency) 43 | currency_list.push(security); 44 | } 45 | return { 46 | map: securities, 47 | list: list, 48 | currency_list: currency_list 49 | }; 50 | case SecurityConstants.SECURITY_CREATED: 51 | case SecurityConstants.SECURITY_UPDATED: 52 | var security = action.security; 53 | var map = assign({}, state.map, { 54 | [security.SecurityId]: security 55 | }); 56 | return { 57 | map: map, 58 | list: mapToList(map), 59 | currency_list: mapToCurrencyList(map) 60 | }; 61 | case SecurityConstants.SECURITY_REMOVED: 62 | var map = assign({}, state.map); 63 | delete map[action.securityId]; 64 | return { 65 | map: map, 66 | list: mapToList(map), 67 | currency_list: mapToCurrencyList(map) 68 | }; 69 | case UserConstants.USER_LOGGEDOUT: 70 | return initialState; 71 | default: 72 | return state; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /internal/handlers/reports/quarterly_net_worth.lua: -------------------------------------------------------------------------------- 1 | function account_series_map(accounts, tabulation) 2 | map = {} 3 | 4 | for i=1,100 do -- we're not messing with accounts more than 100 levels deep 5 | all_handled = true 6 | for id, acct in pairs(accounts) do 7 | if not map[id] then 8 | all_handled = false 9 | if not acct.parent then 10 | map[id] = tabulation:series(acct.name) 11 | elseif map[acct.parent.accountid] then 12 | map[id] = map[acct.parent.accountid]:series(acct.name) 13 | end 14 | end 15 | end 16 | if all_handled then 17 | return map 18 | end 19 | end 20 | 21 | error("Accounts nested (at least) 100 levels deep") 22 | end 23 | 24 | function generate() 25 | year = date.now().year-4 26 | 27 | accounts = get_accounts() 28 | t = tabulation.new(20) 29 | t:title(year .. "-" .. date.now().year .. " Quarterly Net Worth") 30 | series_map = account_series_map(accounts, t:series("Net Worth")) 31 | default_currency = get_default_currency() 32 | 33 | for month=1,20 do 34 | end_date = date.new(year, month*3-2, 1) 35 | 36 | t:label(month, tostring(end_date)) 37 | 38 | for id, acct in pairs(accounts) do 39 | series = series_map[id] 40 | if acct.type ~= account.Expense and acct.type ~= account.Income and acct.type ~= account.Trading then 41 | balance = acct:balance(end_date) 42 | multiplier = 1 43 | if acct.security ~= default_currency then 44 | price = acct.security:closestprice(default_currency, end_date) 45 | if price == nil then 46 | --[[ 47 | -- This should contain code to warn the user that their report is missing some information 48 | --]] 49 | multiplier = 0 50 | else 51 | multiplier = price.value 52 | end 53 | end 54 | series:value(month, balance.amount * multiplier) 55 | end 56 | end 57 | end 58 | 59 | return t 60 | end 61 | -------------------------------------------------------------------------------- /internal/handlers/reports/monthly_net_worth_change.lua: -------------------------------------------------------------------------------- 1 | function account_series_map(accounts, tabulation) 2 | map = {} 3 | 4 | for i=1,100 do -- we're not messing with accounts more than 100 levels deep 5 | all_handled = true 6 | for id, acct in pairs(accounts) do 7 | if not map[id] then 8 | all_handled = false 9 | if not acct.parent then 10 | map[id] = tabulation:series(acct.name) 11 | elseif map[acct.parent.accountid] then 12 | map[id] = map[acct.parent.accountid]:series(acct.name) 13 | end 14 | end 15 | end 16 | if all_handled then 17 | return map 18 | end 19 | end 20 | 21 | error("Accounts nested (at least) 100 levels deep") 22 | end 23 | 24 | function generate() 25 | year = date.now().year 26 | 27 | accounts = get_accounts() 28 | t = tabulation.new(12) 29 | t:title(year .. " Monthly Net Worth") 30 | series_map = account_series_map(accounts, t) 31 | default_currency = get_default_currency() 32 | 33 | for month=1,12 do 34 | begin_date = date.new(year, month, 1) 35 | end_date = date.new(year, month+1, 1) 36 | 37 | t:label(month, tostring(begin_date)) 38 | 39 | for id, acct in pairs(accounts) do 40 | series = series_map[id] 41 | if acct.type ~= account.Expense and acct.type ~= account.Income and acct.type ~= account.Trading then 42 | balance = acct:balance(begin_date, end_date) 43 | multiplier = 1 44 | if acct.security ~= default_currency then 45 | price = acct.security:closestprice(default_currency, end_date) 46 | if price == nil then 47 | --[[ 48 | -- This should contain code to warn the user that their report is missing some information 49 | --]] 50 | multiplier = 0 51 | else 52 | multiplier = price.value 53 | end 54 | end 55 | series:value(month, balance.amount * multiplier) 56 | end 57 | end 58 | end 59 | 60 | return t 61 | end 62 | -------------------------------------------------------------------------------- /js/containers/AccountsTabContainer.js: -------------------------------------------------------------------------------- 1 | var connect = require('react-redux').connect; 2 | 3 | var SecurityActions = require('../actions/SecurityActions'); 4 | var AccountActions = require('../actions/AccountActions'); 5 | var TransactionActions = require('../actions/TransactionActions'); 6 | var ImportActions = require('../actions/ImportActions'); 7 | 8 | var AccountsTab = require('../components/AccountsTab'); 9 | 10 | function mapStateToProps(state) { 11 | return { 12 | accounts: state.accounts.map, 13 | accountChildren: state.accounts.children, 14 | securities: state.securities.map, 15 | security_list: state.securities.list, 16 | selectedAccount: state.selectedAccount, 17 | transactions: state.transactions, 18 | transactionPage: state.transactionPage, 19 | imports: state.imports 20 | } 21 | } 22 | 23 | function mapDispatchToProps(dispatch) { 24 | return { 25 | onFetchAllAccounts: function() {dispatch(AccountActions.fetchAll())}, 26 | onUpdateAccount: function(account) {dispatch(AccountActions.update(account))}, 27 | onDeleteAccount: function(account) {dispatch(AccountActions.remove(account))}, 28 | onSelectAccount: function(accountId) {dispatch(AccountActions.select(accountId))}, 29 | onFetchAllSecurities: function() {dispatch(SecurityActions.fetchAll())}, 30 | onCreateTransaction: function(transaction) {dispatch(TransactionActions.create(transaction))}, 31 | onUpdateTransaction: function(transaction) {dispatch(TransactionActions.update(transaction))}, 32 | onDeleteTransaction: function(transaction) {dispatch(TransactionActions.remove(transaction))}, 33 | onSelectTransaction: function(transactionId) {dispatch(TransactionActions.select(transactionId))}, 34 | onUnselectTransaction: function() {dispatch(TransactionActions.unselect())}, 35 | onFetchTransactionPage: function(account, pageSize, page) {dispatch(TransactionActions.fetchPage(account, pageSize, page))}, 36 | onOpenImportModal: function() {dispatch(ImportActions.openModal())}, 37 | onCloseImportModal: function() {dispatch(ImportActions.closeModal())}, 38 | onImportOFX: function(account, password, startDate, endDate) {dispatch(ImportActions.importOFX(account, password, startDate, endDate))}, 39 | onImportOFXFile: function(inputElement, account) {dispatch(ImportActions.importOFXFile(inputElement, account))}, 40 | onImportGnucash: function(inputElement) {dispatch(ImportActions.importGnucash(inputElement))}, 41 | } 42 | } 43 | 44 | module.exports = connect( 45 | mapStateToProps, 46 | mapDispatchToProps 47 | )(AccountsTab) 48 | -------------------------------------------------------------------------------- /internal/store/db/users.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aclindsa/moneygo/internal/models" 6 | ) 7 | 8 | func (tx *Tx) UsernameExists(username string) (bool, error) { 9 | existing, err := tx.SelectInt("SELECT count(*) from users where Username=?", username) 10 | return existing != 0, err 11 | } 12 | 13 | func (tx *Tx) InsertUser(user *models.User) error { 14 | return tx.Insert(user) 15 | } 16 | 17 | func (tx *Tx) GetUser(userid int64) (*models.User, error) { 18 | var u models.User 19 | 20 | err := tx.SelectOne(&u, "SELECT * from users where UserId=?", userid) 21 | if err != nil { 22 | return nil, err 23 | } 24 | return &u, nil 25 | } 26 | 27 | func (tx *Tx) GetUserByUsername(username string) (*models.User, error) { 28 | var u models.User 29 | 30 | err := tx.SelectOne(&u, "SELECT * from users where Username=?", username) 31 | if err != nil { 32 | return nil, err 33 | } 34 | return &u, nil 35 | } 36 | 37 | func (tx *Tx) UpdateUser(user *models.User) error { 38 | count, err := tx.Update(user) 39 | if err != nil { 40 | return err 41 | } 42 | if count != 1 { 43 | return fmt.Errorf("Expected to update 1 user, was going to update %d", count) 44 | } 45 | return nil 46 | } 47 | 48 | func (tx *Tx) DeleteUser(user *models.User) error { 49 | count, err := tx.Delete(user) 50 | if err != nil { 51 | return err 52 | } 53 | if count != 1 { 54 | return fmt.Errorf("Expected to delete 1 user, was going to delete %d", count) 55 | } 56 | _, err = tx.Exec("DELETE FROM prices WHERE prices.SecurityId IN (SELECT securities.SecurityId FROM securities WHERE securities.UserId=?)", user.UserId) 57 | if err != nil { 58 | return err 59 | } 60 | _, err = tx.Exec("DELETE FROM splits WHERE splits.TransactionId IN (SELECT transactions.TransactionId FROM transactions WHERE transactions.UserId=?)", user.UserId) 61 | if err != nil { 62 | return err 63 | } 64 | _, err = tx.Exec("DELETE FROM transactions WHERE transactions.UserId=?", user.UserId) 65 | if err != nil { 66 | return err 67 | } 68 | _, err = tx.Exec("DELETE FROM securities WHERE securities.UserId=?", user.UserId) 69 | if err != nil { 70 | return err 71 | } 72 | _, err = tx.Exec("DELETE FROM accounts WHERE accounts.UserId=?", user.UserId) 73 | if err != nil { 74 | return err 75 | } 76 | _, err = tx.Exec("DELETE FROM reports WHERE reports.UserId=?", user.UserId) 77 | if err != nil { 78 | return err 79 | } 80 | _, err = tx.Exec("DELETE FROM sessions WHERE sessions.UserId=?", user.UserId) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "gopkg.in/gcfg.v1" 7 | "strings" 8 | ) 9 | 10 | type DbType uint 11 | 12 | const ( 13 | SQLite DbType = 1 + iota 14 | MySQL 15 | Postgres 16 | ) 17 | 18 | var dbTypes = [...]string{"sqlite3", "mysql", "postgres"} 19 | 20 | func (e DbType) Valid() bool { 21 | // This check is mostly out of paranoia, ensuring e != 0 should be 22 | // sufficient 23 | return e >= SQLite && e <= Postgres 24 | } 25 | 26 | func (e DbType) String() string { 27 | if e.Valid() { 28 | return dbTypes[e-1] 29 | } 30 | return fmt.Sprintf("invalid DbType (%d)", e) 31 | } 32 | 33 | func (e *DbType) FromString(in string) error { 34 | value := strings.TrimSpace(in) 35 | 36 | for i, s := range dbTypes { 37 | if s == value { 38 | *e = DbType(i + 1) 39 | return nil 40 | } 41 | } 42 | *e = 0 43 | return errors.New("Invalid DbType: \"" + in + "\"") 44 | } 45 | 46 | func (e *DbType) UnmarshalText(text []byte) error { 47 | return e.FromString(string(text)) 48 | } 49 | 50 | type MoneyGo struct { 51 | Fcgi bool // whether to serve FCGI (HTTP by default if false) 52 | Port int // port to serve API/files on 53 | Basedir string `gcfg:"base-directory"` // base directory for serving files out of 54 | DBType DbType `gcfg:"db-type"` // Whether this is a sqlite/mysql/postgresql database 55 | DSN string `gcfg:"db-dsn"` // 'Data Source Name' for database connection 56 | } 57 | 58 | type Https struct { 59 | CertFile string `gcfg:"cert-file"` 60 | KeyFile string `gcfg:"key-file"` 61 | GenerateCerts bool `gcfg:"generate-certs-if-absent"` // Generate certificates if missing 62 | GenerateCertsHosts string `gcfg:"generate-certs-hosts"` // Hostnames to generate certificates for if missing and GenerateCerts==true 63 | } 64 | 65 | type Config struct { 66 | MoneyGo MoneyGo 67 | Https Https 68 | } 69 | 70 | func ReadConfig(filename string) (*Config, error) { 71 | cfg := Config{ 72 | MoneyGo: MoneyGo{ 73 | Fcgi: false, 74 | Port: 80, 75 | Basedir: "src/github.com/aclindsa/moneygo/", 76 | DBType: SQLite, 77 | DSN: "file:moneygo.sqlite?cache=shared&mode=rwc", 78 | }, 79 | Https: Https{ 80 | CertFile: "./cert.pem", 81 | KeyFile: "./key.pem", 82 | GenerateCerts: false, 83 | GenerateCertsHosts: "localhost", 84 | }, 85 | } 86 | 87 | err := gcfg.ReadFileInto(&cfg, filename) 88 | if err != nil { 89 | return nil, fmt.Errorf("Failed to parse config file: %s", err) 90 | } 91 | return &cfg, nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/reports/reports.go: -------------------------------------------------------------------------------- 1 | package reports 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/aclindsa/moneygo/internal/models" 8 | "github.com/aclindsa/moneygo/internal/store" 9 | "github.com/yuin/gopher-lua" 10 | "time" 11 | ) 12 | 13 | //type and value to store user in lua's Context 14 | type key int 15 | 16 | const ( 17 | userContextKey key = iota 18 | accountsContextKey 19 | securitiesContextKey 20 | balanceContextKey 21 | dbContextKey 22 | ) 23 | 24 | const luaTimeoutSeconds time.Duration = 30 // maximum time a lua request can run for 25 | 26 | func RunReport(tx store.Tx, user *models.User, report *models.Report) (*models.Tabulation, error) { 27 | // Create a new LState without opening the default libs for security 28 | L := lua.NewState(lua.Options{SkipOpenLibs: true}) 29 | defer L.Close() 30 | 31 | // Create a new context holding the current user with a timeout 32 | ctx := context.WithValue(context.Background(), userContextKey, user) 33 | ctx = context.WithValue(ctx, dbContextKey, tx) 34 | ctx, cancel := context.WithTimeout(ctx, luaTimeoutSeconds*time.Second) 35 | defer cancel() 36 | L.SetContext(ctx) 37 | 38 | for _, pair := range []struct { 39 | n string 40 | f lua.LGFunction 41 | }{ 42 | {lua.LoadLibName, lua.OpenPackage}, // Must be first 43 | {lua.BaseLibName, lua.OpenBase}, 44 | {lua.TabLibName, lua.OpenTable}, 45 | {lua.StringLibName, lua.OpenString}, 46 | {lua.MathLibName, lua.OpenMath}, 47 | } { 48 | if err := L.CallByParam(lua.P{ 49 | Fn: L.NewFunction(pair.f), 50 | NRet: 0, 51 | Protect: true, 52 | }, lua.LString(pair.n)); err != nil { 53 | return nil, errors.New("Error initializing Lua packages") 54 | } 55 | } 56 | 57 | luaRegisterAccounts(L) 58 | luaRegisterSecurities(L) 59 | luaRegisterBalances(L) 60 | luaRegisterDates(L) 61 | luaRegisterTabulations(L) 62 | luaRegisterPrices(L) 63 | 64 | err := L.DoString(report.Lua) 65 | 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | if err := L.CallByParam(lua.P{ 71 | Fn: L.GetGlobal("generate"), 72 | NRet: 1, 73 | Protect: true, 74 | }); err != nil { 75 | return nil, err 76 | } 77 | 78 | value := L.Get(-1) 79 | if ud, ok := value.(*lua.LUserData); ok { 80 | if tabulation, ok := ud.Value.(*models.Tabulation); ok { 81 | return tabulation, nil 82 | } else { 83 | return nil, fmt.Errorf("generate() for %s (Id: %d) didn't return a tabulation", report.Name, report.ReportId) 84 | } 85 | } else { 86 | return nil, fmt.Errorf("generate() for %s (Id: %d) didn't even return LUserData", report.Name, report.ReportId) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /internal/reports/prices.go: -------------------------------------------------------------------------------- 1 | package reports 2 | 3 | import ( 4 | "github.com/aclindsa/moneygo/internal/models" 5 | "github.com/yuin/gopher-lua" 6 | ) 7 | 8 | const luaPriceTypeName = "price" 9 | 10 | func luaRegisterPrices(L *lua.LState) { 11 | mt := L.NewTypeMetatable(luaPriceTypeName) 12 | L.SetGlobal("price", mt) 13 | L.SetField(mt, "__index", L.NewFunction(luaPrice__index)) 14 | L.SetField(mt, "__tostring", L.NewFunction(luaPrice__tostring)) 15 | L.SetField(mt, "__metatable", lua.LString("protected")) 16 | } 17 | 18 | func PriceToLua(L *lua.LState, price *models.Price) *lua.LUserData { 19 | ud := L.NewUserData() 20 | ud.Value = price 21 | L.SetMetatable(ud, L.GetTypeMetatable(luaPriceTypeName)) 22 | return ud 23 | } 24 | 25 | // Checks whether the first lua argument is a *LUserData with *Price and returns this *Price. 26 | func luaCheckPrice(L *lua.LState, n int) *models.Price { 27 | ud := L.CheckUserData(n) 28 | if price, ok := ud.Value.(*models.Price); ok { 29 | return price 30 | } 31 | L.ArgError(n, "price expected") 32 | return nil 33 | } 34 | 35 | func luaPrice__index(L *lua.LState) int { 36 | p := luaCheckPrice(L, 1) 37 | field := L.CheckString(2) 38 | 39 | switch field { 40 | case "PriceId", "priceid": 41 | L.Push(lua.LNumber(float64(p.PriceId))) 42 | case "Security", "security": 43 | security_map, err := luaContextGetSecurities(L) 44 | if err != nil { 45 | panic("luaContextGetSecurities couldn't fetch securities") 46 | } 47 | s, ok := security_map[p.SecurityId] 48 | if !ok { 49 | panic("Price's security not found for user") 50 | } 51 | L.Push(SecurityToLua(L, s)) 52 | case "Currency", "currency": 53 | security_map, err := luaContextGetSecurities(L) 54 | if err != nil { 55 | panic("luaContextGetSecurities couldn't fetch securities") 56 | } 57 | c, ok := security_map[p.CurrencyId] 58 | if !ok { 59 | panic("Price's currency not found for user") 60 | } 61 | L.Push(SecurityToLua(L, c)) 62 | case "Value", "value": 63 | float, _ := p.Value.Float64() 64 | L.Push(lua.LNumber(float)) 65 | default: 66 | L.ArgError(2, "unexpected price attribute: "+field) 67 | } 68 | 69 | return 1 70 | } 71 | 72 | func luaPrice__tostring(L *lua.LState) int { 73 | p := luaCheckPrice(L, 1) 74 | 75 | security_map, err := luaContextGetSecurities(L) 76 | if err != nil { 77 | panic("luaContextGetSecurities couldn't fetch securities") 78 | } 79 | s, ok1 := security_map[p.SecurityId] 80 | c, ok2 := security_map[p.CurrencyId] 81 | if !ok1 || !ok2 { 82 | panic("Price's currency or security not found for user") 83 | } 84 | 85 | L.Push(lua.LString(p.Value.String() + " " + c.Symbol + " (" + s.Symbol + ")")) 86 | 87 | return 1 88 | } 89 | -------------------------------------------------------------------------------- /static/css/stylesheet.css: -------------------------------------------------------------------------------- 1 | @import url("reports.css"); 2 | 3 | div#content { 4 | display: block; 5 | width: 95%; 6 | min-width: 75em; 7 | max-width: 100em; 8 | margin: auto; 9 | } 10 | 11 | .ui { 12 | display: flex; 13 | flex-flow: column; 14 | } 15 | .ui > div:nth-child(1), .ui > div:nth-child(2) > nav { 16 | flex: none; 17 | } 18 | .ui > div:nth-child(2) { 19 | display: flex; 20 | flex: auto; 21 | flex-flow: column; 22 | } 23 | .ui > div:nth-child(2) > div { 24 | flex: auto; 25 | } 26 | 27 | /* Tabs */ 28 | 29 | .nav-tabs { 30 | margin-bottom: 15px; 31 | } 32 | 33 | .account-filter-menuitem > a { 34 | padding: 0px 5px !important; 35 | } 36 | 37 | .clear-account-filter { 38 | height: 34px; 39 | padding: 6px; 40 | } 41 | 42 | /* Style the account tree */ 43 | div.accounttree-root-nochildren { 44 | position: relative; 45 | left: 24px; 46 | } 47 | div.accounttree { 48 | position: relative; 49 | left: -24px; 50 | white-space: nowrap; 51 | } 52 | div.accounttree-nochildren { 53 | position: relative; 54 | left: 0px; 55 | } 56 | 57 | div.accounttree div { 58 | padding-left: 24px; 59 | } 60 | div.accounttree-root div { 61 | padding-left: 24px; 62 | } 63 | .accounttree-name { 64 | padding: 3px; 65 | } 66 | 67 | .accounttree-root { 68 | display: block; 69 | margin-left: 5px; 70 | } 71 | .accounttree-expandbutton { 72 | padding-bottom: 6px; 73 | } 74 | .transactions-container { 75 | display: block; 76 | width: 100%; 77 | } 78 | .transactions-register { 79 | display: block; 80 | width: 100%; 81 | overflow: auto; 82 | } 83 | .transactions-register-toolbar { 84 | width: 100%; 85 | } 86 | td.amount-cell { 87 | white-space: nowrap; 88 | } 89 | 90 | .register-row-editing { 91 | background-color: #FFFFE0 !important; 92 | } 93 | .register-row-editing:hover { 94 | background-color: #e8e8e8 !important; 95 | } 96 | .register-row-editing .form-group { 97 | margin: 0; 98 | } 99 | 100 | .row > div > .form-group, 101 | .row > div > .rw-combobox { 102 | margin-right: -7px; 103 | margin-left: -7px; 104 | } 105 | 106 | .split-header { 107 | font-weight: 700; 108 | text-align: center; 109 | } 110 | 111 | .skinny-pagination { 112 | margin: 0px; 113 | } 114 | 115 | /* Make Combobox support .has-error class */ 116 | .has-error.rw-widget { 117 | border-color: #843534; 118 | } 119 | .has-error.rw-widget.rw-state-focus { 120 | border-color: #843534; 121 | box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.075) inset, 0px 0px 6px #CE8483; 122 | } 123 | .has-error.rw-widget > .rw-select { 124 | border-left: 1px solid #843534; 125 | color: #A94442; 126 | background-color: #F2DEDE; 127 | } 128 | 129 | /* Fix Alert Spacing inside */ 130 | .alert.saving-transaction-alert { 131 | margin: 20px 0 0 0; 132 | } 133 | -------------------------------------------------------------------------------- /internal/integration/accounts_lua_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestLuaAccounts(t *testing.T) { 11 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 12 | accounts, err := getAccounts(d.clients[0]) 13 | if err != nil { 14 | t.Fatalf("Error getting accounts: %s", err) 15 | } 16 | accountids := make(Int64Slice, len(*accounts.Accounts)) 17 | for i, s := range *accounts.Accounts { 18 | accountids[i] = s.AccountId 19 | } 20 | accountids.Sort() 21 | 22 | equalityString := "" 23 | for i := range accountids { 24 | for j := range accountids { 25 | if i == j { 26 | equalityString += "true" 27 | } else { 28 | equalityString += "false" 29 | } 30 | } 31 | } 32 | 33 | id := d.accounts[3].AccountId 34 | simpleLuaTest(t, d.clients[0], []LuaTest{ 35 | {"SecurityId", fmt.Sprintf("return get_accounts()[%d].SecurityId", id), strconv.FormatInt(d.accounts[3].SecurityId, 10)}, 36 | {"Security", fmt.Sprintf("return get_accounts()[%d].Security.SecurityId", id), strconv.FormatInt(d.accounts[3].SecurityId, 10)}, 37 | {"Parent", fmt.Sprintf("return get_accounts()[%d].Parent.AccountId", id), strconv.FormatInt(d.accounts[3].ParentAccountId, 10)}, 38 | {"Name", fmt.Sprintf("return get_accounts()[%d].Name", id), d.accounts[3].Name}, 39 | {"Type", fmt.Sprintf("return get_accounts()[%d].Type", id), strconv.FormatInt(int64(d.accounts[3].Type), 10)}, 40 | {"TypeName", fmt.Sprintf("return get_accounts()[%d].TypeName", id), d.accounts[3].Type.String()}, 41 | {"typename", fmt.Sprintf("return get_accounts()[%d].typename", id), strings.ToLower(d.accounts[3].Type.String())}, 42 | {"Balance()", fmt.Sprintf("return get_accounts()[%d]:Balance().Amount", id), "87.19"}, 43 | {"Balance(1)", fmt.Sprintf("return get_accounts()[%d]:Balance(date.new('2017-10-30')).Amount", id), "5.6"}, 44 | {"Balance(2)", fmt.Sprintf("return get_accounts()[%d]:Balance(date.new(2017, 10, 30), date.new('2017-11-01')).Amount", id), "81.59"}, 45 | {"__tostring", fmt.Sprintf("return get_accounts()[%d]", id), "Expenses/Groceries"}, 46 | {"__eq", ` 47 | accounts = get_accounts() 48 | sorted = {} 49 | for id in pairs(accounts) do 50 | table.insert(sorted, id) 51 | end 52 | str = "" 53 | table.sort(sorted) 54 | for i,idi in ipairs(sorted) do 55 | for j,idj in ipairs(sorted) do 56 | if accounts[idi] == accounts[idj] then 57 | str = str .. "true" 58 | else 59 | str = str .. "false" 60 | end 61 | end 62 | end 63 | return str`, equalityString}, 64 | {"get_accounts()", ` 65 | sorted = {} 66 | for id in pairs(get_accounts()) do 67 | table.insert(sorted, id) 68 | end 69 | table.sort(sorted) 70 | str = "[" 71 | for i,id in ipairs(sorted) do 72 | str = str .. id .. " " 73 | end 74 | return string.sub(str, 1, -2) .. "]"`, fmt.Sprint(accountids)}, 75 | }) 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /internal/integration/securities_lua_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strconv" 7 | "testing" 8 | ) 9 | 10 | // Int64Slice attaches the methods of int64 to []int64, sorting in increasing order. 11 | type Int64Slice []int64 12 | 13 | func (p Int64Slice) Len() int { return len(p) } 14 | func (p Int64Slice) Less(i, j int) bool { return p[i] < p[j] } 15 | func (p Int64Slice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } 16 | 17 | // Sort is a convenience method. 18 | func (p Int64Slice) Sort() { sort.Sort(p) } 19 | 20 | func TestLuaSecurities(t *testing.T) { 21 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 22 | defaultSecurity, err := getSecurity(d.clients[0], d.users[0].DefaultCurrency) 23 | if err != nil { 24 | t.Fatalf("Error getting default security: %s", err) 25 | } 26 | securities, err := getSecurities(d.clients[0]) 27 | if err != nil { 28 | t.Fatalf("Error getting securities: %s", err) 29 | } 30 | securityids := make(Int64Slice, len(*securities.Securities)) 31 | for i, s := range *securities.Securities { 32 | securityids[i] = s.SecurityId 33 | } 34 | securityids.Sort() 35 | 36 | equalityString := "" 37 | for i := range securityids { 38 | for j := range securityids { 39 | if i == j { 40 | equalityString += "true" 41 | } else { 42 | equalityString += "false" 43 | } 44 | } 45 | } 46 | 47 | simpleLuaTest(t, d.clients[0], []LuaTest{ 48 | {"__tostring", `return get_default_currency()`, fmt.Sprintf("%s - %s (%s)", defaultSecurity.Name, defaultSecurity.Description, defaultSecurity.Symbol)}, 49 | {"SecurityId", `return get_default_currency().SecurityId`, strconv.FormatInt(defaultSecurity.SecurityId, 10)}, 50 | {"Name", `return get_default_currency().Name`, defaultSecurity.Name}, 51 | {"Description", `return get_default_currency().Description`, defaultSecurity.Description}, 52 | {"Symbol", `return get_default_currency().Symbol`, defaultSecurity.Symbol}, 53 | {"Precision", `return get_default_currency().Precision`, strconv.FormatInt(int64(defaultSecurity.Precision), 10)}, 54 | {"Type", `return get_default_currency().Type`, strconv.FormatInt(int64(defaultSecurity.Type), 10)}, 55 | {"AlternateId", `return get_default_currency().AlternateId`, defaultSecurity.AlternateId}, 56 | {"__eq", ` 57 | securities = get_securities() 58 | sorted = {} 59 | for id in pairs(securities) do 60 | table.insert(sorted, id) 61 | end 62 | str = "" 63 | table.sort(sorted) 64 | for i,idi in ipairs(sorted) do 65 | for j,idj in ipairs(sorted) do 66 | if securities[idi] == securities[idj] then 67 | str = str .. "true" 68 | else 69 | str = str .. "false" 70 | end 71 | end 72 | end 73 | return str`, equalityString}, 74 | {"get_securities()", ` 75 | sorted = {} 76 | for id in pairs(get_securities()) do 77 | table.insert(sorted, id) 78 | end 79 | table.sort(sorted) 80 | str = "[" 81 | for i,id in ipairs(sorted) do 82 | str = str .. id .. " " 83 | end 84 | return string.sub(str, 1, -2) .. "]"`, fmt.Sprint(securityids)}, 85 | }) 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /internal/handlers/handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/aclindsa/moneygo/internal/models" 5 | "github.com/aclindsa/moneygo/internal/store" 6 | "log" 7 | "net/http" 8 | "path" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // But who writes the ResponseWriterWriter? 14 | type ResponseWriterWriter interface { 15 | Write(http.ResponseWriter) error 16 | } 17 | 18 | type Context struct { 19 | Tx store.Tx 20 | User *models.User 21 | remainingURL string // portion of URL path not yet reached in the hierarchy 22 | } 23 | 24 | func (c *Context) SetURL(url string) { 25 | c.remainingURL = path.Clean("/" + url)[1:] 26 | } 27 | 28 | func (c *Context) NextLevel() string { 29 | split := strings.SplitN(c.remainingURL, "/", 2) 30 | if len(split) == 2 { 31 | c.remainingURL = split[1] 32 | } else { 33 | c.remainingURL = "" 34 | } 35 | return split[0] 36 | } 37 | 38 | func (c *Context) NextID() (int64, error) { 39 | return strconv.ParseInt(c.NextLevel(), 0, 64) 40 | } 41 | 42 | func (c *Context) LastLevel() bool { 43 | return len(c.remainingURL) == 0 44 | } 45 | 46 | type Handler func(*http.Request, *Context) ResponseWriterWriter 47 | 48 | type APIHandler struct { 49 | Store store.Store 50 | } 51 | 52 | func (ah *APIHandler) txWrapper(h Handler, r *http.Request, context *Context) (writer ResponseWriterWriter) { 53 | tx, err := ah.Store.Begin() 54 | if err != nil { 55 | log.Print(err) 56 | return NewError(999 /*Internal Error*/) 57 | } 58 | defer func() { 59 | if r := recover(); r != nil { 60 | tx.Rollback() 61 | panic(r) 62 | } 63 | if _, ok := writer.(*Error); ok { 64 | tx.Rollback() 65 | } else { 66 | err = tx.Commit() 67 | if err != nil { 68 | log.Print(err) 69 | writer = NewError(999 /*Internal Error*/) 70 | } 71 | } 72 | }() 73 | 74 | context.Tx = tx 75 | return h(r, context) 76 | } 77 | 78 | func (ah *APIHandler) route(r *http.Request) ResponseWriterWriter { 79 | context := &Context{} 80 | context.SetURL(r.URL.Path) 81 | if context.NextLevel() != "v1" { 82 | return NewError(3 /*Invalid Request*/) 83 | } 84 | 85 | route := context.NextLevel() 86 | 87 | switch route { 88 | case "sessions": 89 | return ah.txWrapper(SessionHandler, r, context) 90 | case "users": 91 | return ah.txWrapper(UserHandler, r, context) 92 | case "securities": 93 | return ah.txWrapper(SecurityHandler, r, context) 94 | case "securitytemplates": 95 | return SecurityTemplateHandler(r, context) 96 | case "accounts": 97 | return ah.txWrapper(AccountHandler, r, context) 98 | case "transactions": 99 | return ah.txWrapper(TransactionHandler, r, context) 100 | case "imports": 101 | return ah.txWrapper(ImportHandler, r, context) 102 | case "reports": 103 | return ah.txWrapper(ReportHandler, r, context) 104 | default: 105 | return NewError(3 /*Invalid Request*/) 106 | } 107 | } 108 | 109 | func (ah *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 110 | ah.route(r).Write(w) 111 | } 112 | -------------------------------------------------------------------------------- /internal/models/accounts.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | type AccountType int64 10 | 11 | const ( 12 | Bank AccountType = 1 // start at 1 so that the default (0) is invalid 13 | Cash = 2 14 | Asset = 3 15 | Liability = 4 16 | Investment = 5 17 | Income = 6 18 | Expense = 7 19 | Trading = 8 20 | Equity = 9 21 | Receivable = 10 22 | Payable = 11 23 | ) 24 | 25 | var AccountTypes = []AccountType{ 26 | Bank, 27 | Cash, 28 | Asset, 29 | Liability, 30 | Investment, 31 | Income, 32 | Expense, 33 | Trading, 34 | Equity, 35 | Receivable, 36 | Payable, 37 | } 38 | 39 | func (t AccountType) String() string { 40 | switch t { 41 | case Bank: 42 | return "Bank" 43 | case Cash: 44 | return "Cash" 45 | case Asset: 46 | return "Asset" 47 | case Liability: 48 | return "Liability" 49 | case Investment: 50 | return "Investment" 51 | case Income: 52 | return "Income" 53 | case Expense: 54 | return "Expense" 55 | case Trading: 56 | return "Trading" 57 | case Equity: 58 | return "Equity" 59 | case Receivable: 60 | return "Receivable" 61 | case Payable: 62 | return "Payable" 63 | } 64 | return "" 65 | } 66 | 67 | type Account struct { 68 | AccountId int64 69 | ExternalAccountId string 70 | UserId int64 71 | SecurityId int64 72 | ParentAccountId int64 // -1 if this account is at the root 73 | Type AccountType 74 | Name string 75 | 76 | // monotonically-increasing account transaction version number. Used for 77 | // allowing a client to ensure they have a consistent version when paging 78 | // through transactions. 79 | AccountVersion int64 `json:"Version"` 80 | 81 | // Optional fields specifying how to fetch transactions from a bank via OFX 82 | OFXURL string 83 | OFXORG string 84 | OFXFID string 85 | OFXUser string 86 | OFXBankID string // OFX BankID (BrokerID if AcctType == Investment) 87 | OFXAcctID string 88 | OFXAcctType string // ofxgo.acctType 89 | OFXClientUID string 90 | OFXAppID string 91 | OFXAppVer string 92 | OFXVersion string 93 | OFXNoIndent bool 94 | } 95 | 96 | type AccountList struct { 97 | Accounts *[]*Account `json:"accounts"` 98 | } 99 | 100 | func (a *Account) Write(w http.ResponseWriter) error { 101 | enc := json.NewEncoder(w) 102 | return enc.Encode(a) 103 | } 104 | 105 | func (a *Account) Read(json_str string) error { 106 | dec := json.NewDecoder(strings.NewReader(json_str)) 107 | return dec.Decode(a) 108 | } 109 | 110 | func (al *AccountList) Write(w http.ResponseWriter) error { 111 | enc := json.NewEncoder(w) 112 | return enc.Encode(al) 113 | } 114 | 115 | func (al *AccountList) Read(json_str string) error { 116 | dec := json.NewDecoder(strings.NewReader(json_str)) 117 | return dec.Decode(al) 118 | } 119 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "github.com/aclindsa/moneygo/internal/config" 5 | "testing" 6 | ) 7 | 8 | func TestSqliteHTTPSConfig(t *testing.T) { 9 | cfg, err := config.ReadConfig("./testdata/sqlite_https_config.ini") 10 | if err != nil { 11 | t.Fatalf("Unexpected error parsing config: %s\n", err) 12 | } 13 | 14 | if cfg.MoneyGo.Fcgi { 15 | t.Errorf("MoneyGo.Fcgi unexpectedly true") 16 | } 17 | if cfg.MoneyGo.Port != 8443 { 18 | t.Errorf("MoneyGo.Port %d instead of 8443", cfg.MoneyGo.Port) 19 | } 20 | if cfg.MoneyGo.Basedir != "src/github.com/aclindsa/moneygo/" { 21 | t.Errorf("MoneyGo.Basedir not correct") 22 | } 23 | if cfg.MoneyGo.DBType != config.SQLite { 24 | t.Errorf("MoneyGo.DBType not config.SQLite") 25 | } 26 | if cfg.MoneyGo.DSN != "file:moneygo.sqlite?cache=shared&mode=rwc" { 27 | t.Errorf("MoneyGo.DSN not correct") 28 | } 29 | 30 | if cfg.Https.CertFile != "./cert.pem" { 31 | t.Errorf("Https.CertFile '%s', not ./cert.pem", cfg.Https.CertFile) 32 | } 33 | if cfg.Https.KeyFile != "./key.pem" { 34 | t.Errorf("Https.KeyFile '%s', not ./key.pem", cfg.Https.KeyFile) 35 | } 36 | if cfg.Https.GenerateCerts { 37 | t.Errorf("Https.GenerateCerts not false") 38 | } 39 | if cfg.Https.GenerateCertsHosts != "localhost,127.0.0.1" { 40 | t.Errorf("Https.GenerateCertsHosts '%s', not localhost", cfg.Https.GenerateCertsHosts) 41 | } 42 | } 43 | 44 | func TestPostgresFcgiConfig(t *testing.T) { 45 | cfg, err := config.ReadConfig("./testdata/postgres_fcgi_config.ini") 46 | if err != nil { 47 | t.Fatalf("Unexpected error parsing config: %s\n", err) 48 | } 49 | 50 | if !cfg.MoneyGo.Fcgi { 51 | t.Errorf("MoneyGo.Fcgi unexpectedly false") 52 | } 53 | if cfg.MoneyGo.Port != 9001 { 54 | t.Errorf("MoneyGo.Port %d instead of 9001", cfg.MoneyGo.Port) 55 | } 56 | if cfg.MoneyGo.Basedir != "src/github.com/aclindsa/moneygo/" { 57 | t.Errorf("MoneyGo.Basedir not correct") 58 | } 59 | if cfg.MoneyGo.DBType != config.Postgres { 60 | t.Errorf("MoneyGo.DBType not config.Postgres") 61 | } 62 | if cfg.MoneyGo.DSN != "postgres://moneygo_test@localhost/moneygo_test?sslmode=disable" { 63 | t.Errorf("MoneyGo.DSN not correct") 64 | } 65 | } 66 | 67 | func TestGenerateCertsConfig(t *testing.T) { 68 | cfg, err := config.ReadConfig("./testdata/generate_certs_config.ini") 69 | if err != nil { 70 | t.Fatalf("Unexpected error parsing config: %s\n", err) 71 | } 72 | 73 | if cfg.Https.CertFile != "./local_cert.pem" { 74 | t.Errorf("Https.CertFile '%s', not ./local_cert.pem", cfg.Https.CertFile) 75 | } 76 | if cfg.Https.KeyFile != "./local_key.pem" { 77 | t.Errorf("Https.KeyFile '%s', not ./local_key.pem", cfg.Https.KeyFile) 78 | } 79 | if !cfg.Https.GenerateCerts { 80 | t.Errorf("Https.GenerateCerts not true") 81 | } 82 | if cfg.Https.GenerateCertsHosts != "example.com" { 83 | t.Errorf("Https.GenerateCertsHosts '%s', not example.com", cfg.Https.GenerateCertsHosts) 84 | } 85 | } 86 | 87 | func TestNonexistentConfig(t *testing.T) { 88 | cfg, err := config.ReadConfig("./testdata/nonexistent_config.ini") 89 | if err == nil || cfg != nil { 90 | t.Fatalf("Expected error parsing nonexistent config") 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /internal/models/transactions.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // Split.Status 11 | const ( 12 | Imported int64 = 1 13 | Entered = 2 14 | Cleared = 3 15 | Reconciled = 4 16 | Voided = 5 17 | ) 18 | 19 | // Split.ImportSplitType 20 | const ( 21 | Default int64 = 0 22 | ImportAccount = 1 // This split belongs to the main account being imported 23 | SubAccount = 2 // This split belongs to a sub-account of that being imported 24 | ExternalAccount = 3 25 | TradingAccount = 4 26 | Commission = 5 27 | Taxes = 6 28 | Fees = 7 29 | Load = 8 30 | IncomeAccount = 9 31 | ExpenseAccount = 10 32 | ) 33 | 34 | type Split struct { 35 | SplitId int64 36 | TransactionId int64 37 | Status int64 38 | ImportSplitType int64 39 | 40 | // One of AccountId and SecurityId must be -1 41 | // In normal splits, AccountId will be valid and SecurityId will be -1. The 42 | // only case where this is reversed is for transactions that have been 43 | // imported and not yet associated with an account. 44 | AccountId int64 45 | SecurityId int64 46 | 47 | RemoteId string // unique ID from server, for detecting duplicates 48 | Number string // Check or reference number 49 | Memo string 50 | Amount Amount 51 | } 52 | 53 | func (s *Split) Valid() bool { 54 | return (s.AccountId == -1) != (s.SecurityId == -1) 55 | } 56 | 57 | type Transaction struct { 58 | TransactionId int64 59 | UserId int64 60 | Description string 61 | Date time.Time 62 | Splits []*Split `db:"-"` 63 | } 64 | 65 | type TransactionList struct { 66 | Transactions *[]*Transaction `json:"transactions"` 67 | } 68 | 69 | type AccountTransactionsList struct { 70 | Account *Account 71 | Transactions *[]*Transaction 72 | TotalTransactions int64 73 | BeginningBalance Amount 74 | EndingBalance Amount 75 | } 76 | 77 | func (t *Transaction) Write(w http.ResponseWriter) error { 78 | enc := json.NewEncoder(w) 79 | return enc.Encode(t) 80 | } 81 | 82 | func (t *Transaction) Read(json_str string) error { 83 | dec := json.NewDecoder(strings.NewReader(json_str)) 84 | return dec.Decode(t) 85 | } 86 | 87 | func (tl *TransactionList) Write(w http.ResponseWriter) error { 88 | enc := json.NewEncoder(w) 89 | return enc.Encode(tl) 90 | } 91 | 92 | func (tl *TransactionList) Read(json_str string) error { 93 | dec := json.NewDecoder(strings.NewReader(json_str)) 94 | return dec.Decode(tl) 95 | } 96 | 97 | func (atl *AccountTransactionsList) Write(w http.ResponseWriter) error { 98 | enc := json.NewEncoder(w) 99 | return enc.Encode(atl) 100 | } 101 | 102 | func (atl *AccountTransactionsList) Read(json_str string) error { 103 | dec := json.NewDecoder(strings.NewReader(json_str)) 104 | return dec.Decode(atl) 105 | } 106 | 107 | func (t *Transaction) Valid() bool { 108 | for i := range t.Splits { 109 | if !t.Splits[i].Valid() { 110 | return false 111 | } 112 | } 113 | return true 114 | } 115 | -------------------------------------------------------------------------------- /internal/integration/users_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "github.com/aclindsa/moneygo/internal/handlers" 5 | "net/http" 6 | "strconv" 7 | "testing" 8 | ) 9 | 10 | func createUser(user *User) (*User, error) { 11 | var u User 12 | err := create(server.Client(), user, &u, "/v1/users/") 13 | return &u, err 14 | } 15 | 16 | func getUser(client *http.Client, userid int64) (*User, error) { 17 | var u User 18 | err := read(client, &u, "/v1/users/"+strconv.FormatInt(userid, 10)) 19 | if err != nil { 20 | return nil, err 21 | } 22 | return &u, nil 23 | } 24 | 25 | func updateUser(client *http.Client, user *User) (*User, error) { 26 | var u User 27 | err := update(client, user, &u, "/v1/users/"+strconv.FormatInt(user.UserId, 10)) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &u, nil 32 | } 33 | 34 | func deleteUser(client *http.Client, u *User) error { 35 | err := remove(client, "/v1/users/"+strconv.FormatInt(u.UserId, 10)) 36 | if err != nil { 37 | return err 38 | } 39 | return nil 40 | } 41 | 42 | func TestCreateUser(t *testing.T) { 43 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 44 | if d.users[0].UserId == 0 || len(d.users[0].Username) == 0 { 45 | t.Errorf("Unable to create user: %+v", data[0].users[0]) 46 | } 47 | 48 | if len(d.users[0].Password) != 0 || len(d.users[0].PasswordHash) != 0 { 49 | t.Error("Never send password, only send password hash when necessary") 50 | } 51 | }) 52 | } 53 | 54 | func TestDontRecreateUser(t *testing.T) { 55 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 56 | for _, user := range data[0].users { 57 | _, err := createUser(&user) 58 | if err == nil { 59 | t.Fatalf("Expected error re-creating user") 60 | } 61 | if herr, ok := err.(*handlers.Error); ok { 62 | if herr.ErrorId != 4 { // User exists 63 | t.Fatalf("Unexpected API error re-creating user: %s", herr) 64 | } 65 | } else { 66 | t.Fatalf("Expected error re-creating user") 67 | } 68 | } 69 | }) 70 | } 71 | 72 | func TestGetUser(t *testing.T) { 73 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 74 | u, err := getUser(d.clients[0], d.users[0].UserId) 75 | if err != nil { 76 | t.Fatalf("Error fetching user: %s\n", err) 77 | } 78 | if u.UserId != d.users[0].UserId { 79 | t.Errorf("UserId doesn't match") 80 | } 81 | if len(u.Username) == 0 { 82 | t.Fatalf("Empty username for: %d", d.users[0].UserId) 83 | } 84 | }) 85 | } 86 | 87 | func TestUpdateUser(t *testing.T) { 88 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 89 | user := &d.users[0] 90 | user.Name = "Bob" 91 | user.Email = "bob@example.com" 92 | 93 | u, err := updateUser(d.clients[0], user) 94 | if err != nil { 95 | t.Fatalf("Error updating user: %s\n", err) 96 | } 97 | if u.UserId != user.UserId { 98 | t.Errorf("UserId doesn't match") 99 | } 100 | if u.Username != u.Username { 101 | t.Errorf("Username doesn't match") 102 | } 103 | if u.Name != user.Name { 104 | t.Errorf("Name doesn't match") 105 | } 106 | if u.Email != user.Email { 107 | t.Errorf("Email doesn't match") 108 | } 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /js/actions/SecurityTemplateActions.js: -------------------------------------------------------------------------------- 1 | var SecurityTemplateConstants = require('../constants/SecurityTemplateConstants'); 2 | 3 | var ErrorActions = require('./ErrorActions'); 4 | 5 | var models = require('../models.js'); 6 | var Security = models.Security; 7 | var Error = models.Error; 8 | var SecurityType = models.SecurityType; 9 | 10 | function searchSecurityTemplates(searchString, searchType) { 11 | return { 12 | type: SecurityTemplateConstants.SEARCH_SECURITY_TEMPLATES, 13 | searchString: searchString, 14 | searchType: searchType 15 | } 16 | } 17 | 18 | function securityTemplatesSearched(searchString, searchType, securities) { 19 | return { 20 | type: SecurityTemplateConstants.SECURITY_TEMPLATES_SEARCHED, 21 | searchString: searchString, 22 | searchType: searchType, 23 | securities: securities 24 | } 25 | } 26 | 27 | function fetchCurrencyTemplates() { 28 | return { 29 | type: SecurityTemplateConstants.FETCH_CURRENCIES 30 | } 31 | } 32 | 33 | function currencyTemplatesFetched(currencies) { 34 | return { 35 | type: SecurityTemplateConstants.CURRENCIES_FETCHED, 36 | currencies: currencies 37 | } 38 | } 39 | 40 | function search(searchString, searchType, limit) { 41 | return function (dispatch) { 42 | dispatch(searchSecurityTemplates(searchString, searchType)); 43 | 44 | if (searchString == "") 45 | return; 46 | 47 | $.ajax({ 48 | type: "GET", 49 | dataType: "json", 50 | url: "v1/securitytemplates/?search="+searchString+"&type="+searchType+"&limit="+limit, 51 | success: function(data, status, jqXHR) { 52 | var e = new Error(); 53 | e.fromJSON(data); 54 | if (e.isError()) { 55 | dispatch(ErrorActions.serverError(e)); 56 | } else if (data.securities == null) { 57 | dispatch(securityTemplatesSearched(searchString, searchType, new Array())); 58 | } else { 59 | dispatch(securityTemplatesSearched(searchString, searchType, 60 | data.securities.map(function(json) { 61 | var s = new Security(); 62 | s.fromJSON(json); 63 | return s; 64 | }))); 65 | } 66 | }, 67 | error: function(jqXHR, status, error) { 68 | dispatch(ErrorActions.ajaxError(error)); 69 | } 70 | }); 71 | }; 72 | } 73 | 74 | function fetchCurrencies() { 75 | return function (dispatch) { 76 | dispatch(fetchCurrencyTemplates()); 77 | 78 | $.ajax({ 79 | type: "GET", 80 | dataType: "json", 81 | url: "v1/securitytemplates/?search=&type=currency", 82 | success: function(data, status, jqXHR) { 83 | var e = new Error(); 84 | e.fromJSON(data); 85 | if (e.isError()) { 86 | dispatch(ErrorActions.serverError(e)); 87 | } else if (data.securities == null) { 88 | dispatch(currencyTemplatesFetched(new Array())); 89 | } else { 90 | dispatch(currencyTemplatesFetched( 91 | data.securities.map(function(json) { 92 | var s = new Security(); 93 | s.fromJSON(json); 94 | return s; 95 | }))); 96 | } 97 | }, 98 | error: function(jqXHR, status, error) { 99 | dispatch(ErrorActions.ajaxError(error)); 100 | } 101 | }); 102 | }; 103 | } 104 | 105 | module.exports = { 106 | search: search, 107 | fetchCurrencies: fetchCurrencies 108 | }; 109 | -------------------------------------------------------------------------------- /internal/store/db/securities.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aclindsa/moneygo/internal/models" 6 | "github.com/aclindsa/moneygo/internal/store" 7 | ) 8 | 9 | // MaxPrexision denotes the maximum valid value for models.Security.Precision. 10 | // This constant is used when storing amounts in securities into the database, 11 | // so it must not be changed without appropriately migrating the database. 12 | const MaxPrecision uint64 = 15 13 | 14 | func init() { 15 | if MaxPrecision < models.MaxPrecision { 16 | panic("db.MaxPrecision must be >= models.MaxPrecision") 17 | } 18 | } 19 | 20 | func (tx *Tx) GetSecurity(securityid int64, userid int64) (*models.Security, error) { 21 | var s models.Security 22 | 23 | err := tx.SelectOne(&s, "SELECT * from securities where UserId=? AND SecurityId=?", userid, securityid) 24 | if err != nil { 25 | return nil, err 26 | } 27 | return &s, nil 28 | } 29 | 30 | func (tx *Tx) GetSecurities(userid int64) (*[]*models.Security, error) { 31 | var securities []*models.Security 32 | 33 | _, err := tx.Select(&securities, "SELECT * from securities where UserId=?", userid) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return &securities, nil 38 | } 39 | 40 | func (tx *Tx) FindMatchingSecurities(security *models.Security) (*[]*models.Security, error) { 41 | var securities []*models.Security 42 | 43 | _, err := tx.Select(&securities, "SELECT * from securities where UserId=? AND Type=? AND AlternateId=? AND Preciseness=?", security.UserId, security.Type, security.AlternateId, security.Precision) 44 | if err != nil { 45 | return nil, err 46 | } 47 | return &securities, nil 48 | } 49 | 50 | func (tx *Tx) InsertSecurity(s *models.Security) error { 51 | err := tx.Insert(s) 52 | if err != nil { 53 | return err 54 | } 55 | return nil 56 | } 57 | 58 | func (tx *Tx) UpdateSecurity(security *models.Security) error { 59 | count, err := tx.Update(security) 60 | if err != nil { 61 | return err 62 | } 63 | if count != 1 { 64 | return fmt.Errorf("Expected to update 1 security, was going to update %d", count) 65 | } 66 | return nil 67 | } 68 | 69 | func (tx *Tx) DeleteSecurity(s *models.Security) error { 70 | // First, ensure no accounts are using this security 71 | accounts, err := tx.SelectInt("SELECT count(*) from accounts where UserId=? and SecurityId=?", s.UserId, s.SecurityId) 72 | 73 | if accounts != 0 { 74 | return store.SecurityInUseError{"One or more accounts still use this security"} 75 | } 76 | 77 | user, err := tx.GetUser(s.UserId) 78 | if err != nil { 79 | return err 80 | } else if user.DefaultCurrency == s.SecurityId { 81 | return store.SecurityInUseError{"Cannot delete security which is user's default currency"} 82 | } 83 | 84 | // Remove all prices involving this security (either of this security, or 85 | // using it as a currency) 86 | _, err = tx.Exec("DELETE FROM prices WHERE SecurityId=? OR CurrencyId=?", s.SecurityId, s.SecurityId) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | count, err := tx.Delete(s) 92 | if err != nil { 93 | return err 94 | } 95 | if count != 1 { 96 | return fmt.Errorf("Expected to delete 1 security, was going to delete %d", count) 97 | } 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/integration/security_templates_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "github.com/aclindsa/moneygo/internal/handlers" 5 | "github.com/aclindsa/moneygo/internal/models" 6 | "io/ioutil" 7 | "testing" 8 | ) 9 | 10 | func TestSecurityTemplates(t *testing.T) { 11 | var sl models.SecurityList 12 | response, err := server.Client().Get(server.URL + "/v1/securitytemplates/?search=USD&type=currency") 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | if response.StatusCode != 200 { 17 | t.Fatalf("Unexpected HTTP status code: %d\n", response.StatusCode) 18 | } 19 | 20 | body, err := ioutil.ReadAll(response.Body) 21 | response.Body.Close() 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | err = (&sl).Read(string(body)) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | num_usd := 0 32 | if sl.Securities != nil { 33 | for _, s := range *sl.Securities { 34 | if s.Type != models.Currency { 35 | t.Fatalf("Requested Currency-only security templates, received a non-Currency template for %s", s.Name) 36 | } 37 | 38 | if s.Name == "USD" && s.AlternateId == "840" { 39 | num_usd++ 40 | } 41 | } 42 | } 43 | 44 | if num_usd != 1 { 45 | t.Fatalf("Expected one USD security template, found %d\n", num_usd) 46 | } 47 | } 48 | 49 | func TestSecurityTemplateLimit(t *testing.T) { 50 | var sl models.SecurityList 51 | response, err := server.Client().Get(server.URL + "/v1/securitytemplates/?search=e&limit=5") 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | if response.StatusCode != 200 { 56 | t.Fatalf("Unexpected HTTP status code: %d\n", response.StatusCode) 57 | } 58 | 59 | body, err := ioutil.ReadAll(response.Body) 60 | response.Body.Close() 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | err = (&sl).Read(string(body)) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | if sl.Securities == nil { 71 | t.Fatalf("Securities was unexpectedly nil\n") 72 | } 73 | 74 | if len(*sl.Securities) > 5 { 75 | t.Fatalf("Requested only 5 securities, received %d\n", len(*sl.Securities)) 76 | } 77 | } 78 | 79 | func TestSecurityTemplateInvalidType(t *testing.T) { 80 | var e handlers.Error 81 | response, err := server.Client().Get(server.URL + "/v1/securitytemplates/?search=e&type=blah") 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | body, err := ioutil.ReadAll(response.Body) 87 | response.Body.Close() 88 | if err != nil { 89 | t.Fatal(err) 90 | } 91 | 92 | err = (&e).Read(string(body)) 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | if e.ErrorId != 3 { 98 | t.Fatal("Expected ErrorId 3, Invalid Request") 99 | } 100 | } 101 | 102 | func TestSecurityTemplateInvalidLimit(t *testing.T) { 103 | var e handlers.Error 104 | response, err := server.Client().Get(server.URL + "/v1/securitytemplates/?search=e&type=Currency&limit=foo") 105 | if err != nil { 106 | t.Fatal(err) 107 | } 108 | 109 | body, err := ioutil.ReadAll(response.Body) 110 | response.Body.Close() 111 | if err != nil { 112 | t.Fatal(err) 113 | } 114 | 115 | err = (&e).Read(string(body)) 116 | if err != nil { 117 | t.Fatal(err) 118 | } 119 | 120 | if e.ErrorId != 3 { 121 | t.Fatal("Expected ErrorId 3, Invalid Request") 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /internal/store/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "github.com/aclindsa/gorp" 7 | "github.com/aclindsa/moneygo/internal/config" 8 | "github.com/aclindsa/moneygo/internal/models" 9 | "github.com/aclindsa/moneygo/internal/store" 10 | _ "github.com/go-sql-driver/mysql" 11 | _ "github.com/lib/pq" 12 | _ "github.com/mattn/go-sqlite3" 13 | "log" 14 | "strings" 15 | ) 16 | 17 | // luaMaxLengthBuffer is intended to be enough bytes such that a given string 18 | // no longer than models.LuaMaxLength is sure to fit within a database 19 | // implementation's string type specified by the same. 20 | const luaMaxLengthBuffer int = 4096 21 | 22 | func getDbMap(db *sql.DB, dbtype config.DbType) (*gorp.DbMap, error) { 23 | var dialect gorp.Dialect 24 | if dbtype == config.SQLite { 25 | dialect = gorp.SqliteDialect{} 26 | } else if dbtype == config.MySQL { 27 | dialect = gorp.MySQLDialect{ 28 | Engine: "InnoDB", 29 | Encoding: "UTF8", 30 | } 31 | } else if dbtype == config.Postgres { 32 | dialect = gorp.PostgresDialect{ 33 | LowercaseFields: true, 34 | } 35 | } else { 36 | return nil, fmt.Errorf("Don't know gorp dialect to go with '%s' DB type", dbtype.String()) 37 | } 38 | 39 | dbmap := &gorp.DbMap{Db: db, Dialect: dialect} 40 | dbmap.AddTableWithName(models.User{}, "users").SetKeys(true, "UserId") 41 | dbmap.AddTableWithName(models.Session{}, "sessions").SetKeys(true, "SessionId") 42 | dbmap.AddTableWithName(models.Security{}, "securities").SetKeys(true, "SecurityId") 43 | dbmap.AddTableWithName(Price{}, "prices").SetKeys(true, "PriceId") 44 | dbmap.AddTableWithName(models.Account{}, "accounts").SetKeys(true, "AccountId") 45 | dbmap.AddTableWithName(models.Transaction{}, "transactions").SetKeys(true, "TransactionId") 46 | dbmap.AddTableWithName(Split{}, "splits").SetKeys(true, "SplitId") 47 | rtable := dbmap.AddTableWithName(models.Report{}, "reports").SetKeys(true, "ReportId") 48 | rtable.ColMap("Lua").SetMaxSize(models.LuaMaxLength + luaMaxLengthBuffer) 49 | 50 | err := dbmap.CreateTablesIfNotExists() 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return dbmap, nil 56 | } 57 | 58 | func getDSN(dbtype config.DbType, dsn string) string { 59 | if dbtype == config.MySQL && !strings.Contains(dsn, "parseTime=true") { 60 | log.Fatalf("The DSN for MySQL MUST contain 'parseTime=True' but does not!") 61 | } 62 | return dsn 63 | } 64 | 65 | type DbStore struct { 66 | dbMap *gorp.DbMap 67 | } 68 | 69 | func (db *DbStore) Empty() error { 70 | return db.dbMap.TruncateTables() 71 | } 72 | 73 | func (db *DbStore) Begin() (store.Tx, error) { 74 | tx, err := db.dbMap.Begin() 75 | if err != nil { 76 | return nil, err 77 | } 78 | return &Tx{db.dbMap.Dialect, tx}, nil 79 | } 80 | 81 | func (db *DbStore) Close() error { 82 | err := db.dbMap.Db.Close() 83 | db.dbMap = nil 84 | return err 85 | } 86 | 87 | func GetStore(dbtype config.DbType, dsn string) (store store.Store, err error) { 88 | dsn = getDSN(dbtype, dsn) 89 | database, err := sql.Open(dbtype.String(), dsn) 90 | if err != nil { 91 | return nil, err 92 | } 93 | defer func() { 94 | if err != nil { 95 | database.Close() 96 | } 97 | }() 98 | 99 | dbmap, err := getDbMap(database, dbtype) 100 | if err != nil { 101 | return nil, err 102 | } 103 | return &DbStore{dbmap}, nil 104 | } 105 | -------------------------------------------------------------------------------- /internal/handlers/sessions.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aclindsa/moneygo/internal/models" 6 | "github.com/aclindsa/moneygo/internal/store" 7 | "log" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | func GetSession(tx store.Tx, r *http.Request) (*models.Session, error) { 13 | cookie, err := r.Cookie("moneygo-session") 14 | if err != nil { 15 | return nil, fmt.Errorf("moneygo-session cookie not set") 16 | } 17 | 18 | s, err := tx.GetSession(cookie.Value) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | if s.Expires.Before(time.Now()) { 24 | err := tx.DeleteSession(s) 25 | if err != nil { 26 | log.Printf("Unexpected error when attempting to delete expired session: %s", err) 27 | } 28 | return nil, fmt.Errorf("Session has expired") 29 | } 30 | return s, nil 31 | } 32 | 33 | func DeleteSessionIfExists(tx store.Tx, r *http.Request) error { 34 | session, err := GetSession(tx, r) 35 | if err == nil { 36 | err := tx.DeleteSession(session) 37 | if err != nil { 38 | return err 39 | } 40 | } 41 | return nil 42 | } 43 | 44 | type NewSessionWriter struct { 45 | session *models.Session 46 | cookie *http.Cookie 47 | } 48 | 49 | func (n *NewSessionWriter) Write(w http.ResponseWriter) error { 50 | http.SetCookie(w, n.cookie) 51 | return n.session.Write(w) 52 | } 53 | 54 | func NewSession(tx store.Tx, r *http.Request, userid int64) (*NewSessionWriter, error) { 55 | err := DeleteSessionIfExists(tx, r) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | s, err := models.NewSession(userid) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | exists, err := tx.SessionExists(s.SessionSecret) 66 | if err != nil { 67 | return nil, err 68 | } 69 | if exists { 70 | return nil, fmt.Errorf("Session already exists with the generated session_secret") 71 | } 72 | 73 | err = tx.InsertSession(s) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | return &NewSessionWriter{s, s.Cookie(r.URL.Host)}, nil 79 | } 80 | 81 | func SessionHandler(r *http.Request, context *Context) ResponseWriterWriter { 82 | if r.Method == "POST" || r.Method == "PUT" { 83 | var user models.User 84 | if err := ReadJSON(r, &user); err != nil { 85 | return NewError(3 /*Invalid Request*/) 86 | } 87 | 88 | // Hash password before checking username to help mitigate timing 89 | // attacks 90 | user.HashPassword() 91 | 92 | dbuser, err := context.Tx.GetUserByUsername(user.Username) 93 | if err != nil { 94 | return NewError(2 /*Unauthorized Access*/) 95 | } 96 | 97 | if user.PasswordHash != dbuser.PasswordHash { 98 | return NewError(2 /*Unauthorized Access*/) 99 | } 100 | 101 | sessionwriter, err := NewSession(context.Tx, r, dbuser.UserId) 102 | if err != nil { 103 | log.Print(err) 104 | return NewError(999 /*Internal Error*/) 105 | } 106 | return sessionwriter 107 | } else if r.Method == "GET" { 108 | s, err := GetSession(context.Tx, r) 109 | if err != nil { 110 | return NewError(1 /*Not Signed In*/) 111 | } 112 | 113 | return s 114 | } else if r.Method == "DELETE" { 115 | err := DeleteSessionIfExists(context.Tx, r) 116 | if err != nil { 117 | log.Print(err) 118 | return NewError(999 /*Internal Error*/) 119 | } 120 | return SuccessWriter{} 121 | } 122 | return NewError(3 /*Invalid Request*/) 123 | } 124 | -------------------------------------------------------------------------------- /internal/integration/sessions_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aclindsa/moneygo/internal/handlers" 6 | "github.com/aclindsa/moneygo/internal/models" 7 | "net/http" 8 | "net/http/cookiejar" 9 | "net/url" 10 | "testing" 11 | ) 12 | 13 | func newSession(user *User) (*http.Client, error) { 14 | var u User 15 | 16 | jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: nil}) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | var client http.Client 22 | client = *server.Client() 23 | client.Jar = jar 24 | 25 | create(&client, user, &u, "/v1/sessions/") 26 | 27 | return &client, nil 28 | } 29 | 30 | func getSession(client *http.Client) (*models.Session, error) { 31 | var s models.Session 32 | err := read(client, &s, "/v1/sessions/") 33 | return &s, err 34 | } 35 | 36 | func deleteSession(client *http.Client) error { 37 | return remove(client, "/v1/sessions/") 38 | } 39 | 40 | func sessionExistsOrError(c *http.Client) error { 41 | 42 | url, err := url.Parse(server.URL) 43 | if err != nil { 44 | return err 45 | } 46 | cookies := c.Jar.Cookies(url) 47 | 48 | var found_session bool = false 49 | for _, cookie := range cookies { 50 | if cookie.Name == "moneygo-session" { 51 | found_session = true 52 | } 53 | } 54 | if found_session { 55 | return nil 56 | } 57 | return fmt.Errorf("Didn't find 'moneygo-session' cookie in CookieJar") 58 | } 59 | 60 | func TestCreateSession(t *testing.T) { 61 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 62 | if err := sessionExistsOrError(d.clients[0]); err != nil { 63 | t.Fatal(err) 64 | } 65 | }) 66 | } 67 | 68 | func TestGetSession(t *testing.T) { 69 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 70 | session, err := getSession(d.clients[0]) 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | 75 | if len(session.SessionSecret) != 0 { 76 | t.Error("Session.SessionSecret should not be passed back in JSON") 77 | } 78 | 79 | if session.UserId != d.users[0].UserId { 80 | t.Errorf("session's UserId (%d) should equal user's UserID (%d)", session.UserId, d.users[0].UserId) 81 | } 82 | 83 | if session.SessionId == 0 { 84 | t.Error("session's SessionId should not be 0") 85 | } 86 | }) 87 | } 88 | 89 | func TestDeleteSession(t *testing.T) { 90 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 91 | err := deleteSession(d.clients[0]) 92 | if err != nil { 93 | t.Fatalf("Unexpected error removing session: %s\n", err) 94 | } 95 | err = deleteSession(d.clients[0]) 96 | if err != nil { 97 | t.Fatalf("Unexpected error attempting to delete nonexistent session: %s\n", err) 98 | } 99 | _, err = getSession(d.clients[0]) 100 | if err == nil { 101 | t.Fatalf("Expected error fetching deleted session") 102 | } 103 | if herr, ok := err.(*handlers.Error); ok { 104 | if herr.ErrorId != 1 { // Not Signed in 105 | t.Fatalf("Unexpected API error fetching deleted session: %s", herr) 106 | } 107 | } else { 108 | t.Fatalf("Unexpected error fetching deleted session") 109 | } 110 | 111 | // Login again so we don't screw up the TestData teardown code 112 | userWithPassword := d.users[0] 113 | userWithPassword.Password = data[0].users[0].Password 114 | 115 | client, err := newSession(&userWithPassword) 116 | if err != nil { 117 | t.Fatalf("Unexpected error re-creating session: %s\n", err) 118 | } 119 | d.clients[0] = client 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | //go:generate make 4 | 5 | import ( 6 | "flag" 7 | "github.com/aclindsa/moneygo/internal/config" 8 | "github.com/aclindsa/moneygo/internal/handlers" 9 | "github.com/aclindsa/moneygo/internal/store/db" 10 | "github.com/kabukky/httpscerts" 11 | "log" 12 | "net" 13 | "net/http" 14 | "net/http/fcgi" 15 | "os" 16 | "path" 17 | "strconv" 18 | ) 19 | 20 | var configFile string 21 | var cfg *config.Config 22 | 23 | func init() { 24 | var err error 25 | flag.StringVar(&configFile, "config", "/etc/moneygo/config.ini", "Path to config file") 26 | flag.Parse() 27 | 28 | cfg, err = config.ReadConfig(configFile) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | static_path := path.Join(cfg.MoneyGo.Basedir, "static") 34 | 35 | // Ensure base directory is valid 36 | dir_err_str := "The base directory doesn't look like it contains the " + 37 | "'static' directory. Check to make sure your config file contains the" + 38 | "right path for 'base-directory'." 39 | static_dir, err := os.Stat(static_path) 40 | if err != nil { 41 | log.Print(err) 42 | log.Fatal(dir_err_str) 43 | } 44 | if !static_dir.IsDir() { 45 | log.Fatal(dir_err_str) 46 | } 47 | 48 | // Setup the logging flags to be printed 49 | log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) 50 | } 51 | 52 | type FileHandler func(http.ResponseWriter, *http.Request, string) 53 | 54 | func FileHandlerFunc(h FileHandler, basedir string) http.HandlerFunc { 55 | return func(w http.ResponseWriter, r *http.Request) { 56 | h(w, r, basedir) 57 | } 58 | } 59 | 60 | func rootHandler(w http.ResponseWriter, r *http.Request, basedir string) { 61 | http.ServeFile(w, r, path.Join(basedir, "static/index.html")) 62 | } 63 | 64 | func staticHandler(w http.ResponseWriter, r *http.Request, basedir string) { 65 | http.ServeFile(w, r, path.Join(basedir, r.URL.Path)) 66 | } 67 | 68 | func main() { 69 | db, err := db.GetStore(cfg.MoneyGo.DBType, cfg.MoneyGo.DSN) 70 | if err != nil { 71 | log.Fatal(err) 72 | } 73 | defer db.Close() 74 | 75 | // Get ServeMux for API and add our own handlers for files 76 | servemux := http.NewServeMux() 77 | servemux.Handle("/v1/", &handlers.APIHandler{Store: db}) 78 | servemux.HandleFunc("/", FileHandlerFunc(rootHandler, cfg.MoneyGo.Basedir)) 79 | servemux.HandleFunc("/static/", FileHandlerFunc(staticHandler, cfg.MoneyGo.Basedir)) 80 | 81 | listener, err := net.Listen("tcp", ":"+strconv.Itoa(cfg.MoneyGo.Port)) 82 | if err != nil { 83 | log.Fatal(err) 84 | } 85 | 86 | if cfg.MoneyGo.Fcgi { 87 | log.Printf("Serving via FCGI on port %d out of directory: %s", cfg.MoneyGo.Port, cfg.MoneyGo.Basedir) 88 | fcgi.Serve(listener, servemux) 89 | } else { 90 | cert := cfg.Https.CertFile 91 | key := cfg.Https.KeyFile 92 | 93 | if err := httpscerts.Check(cert, key); err != nil { 94 | if !cfg.Https.GenerateCerts { 95 | log.Fatalf("HTTPS certficates not found at '%s' and '%s'. If you would like for them to be auto-generated for you, specify 'generate-certs-if-absent = true' in your config file at '%s'", cert, key, configFile) 96 | } 97 | 98 | err = httpscerts.Generate(cert, key, cfg.Https.GenerateCertsHosts) 99 | if err != nil { 100 | log.Fatalf("Error: Generating HTTPS cert/key at '%s' and '%s' failed: %s", cert, key, err) 101 | } 102 | } 103 | log.Printf("Serving via HTTPS on port %d out of directory: %s", cfg.MoneyGo.Port, cfg.MoneyGo.Basedir) 104 | http.ServeTLS(listener, servemux, cert, key) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /internal/handlers/reports.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/aclindsa/moneygo/internal/models" 5 | "github.com/aclindsa/moneygo/internal/reports" 6 | "github.com/aclindsa/moneygo/internal/store" 7 | "log" 8 | "net/http" 9 | ) 10 | 11 | func ReportTabulationHandler(tx store.Tx, r *http.Request, user *models.User, reportid int64) ResponseWriterWriter { 12 | report, err := tx.GetReport(reportid, user.UserId) 13 | if err != nil { 14 | return NewError(3 /*Invalid Request*/) 15 | } 16 | 17 | tabulation, err := reports.RunReport(tx, user, report) 18 | if err != nil { 19 | // TODO handle different failure cases differently 20 | log.Print("reports.RunReport returned:", err) 21 | return NewError(3 /*Invalid Request*/) 22 | } 23 | 24 | tabulation.ReportId = reportid 25 | 26 | return tabulation 27 | } 28 | 29 | func ReportHandler(r *http.Request, context *Context) ResponseWriterWriter { 30 | user, err := GetUserFromSession(context.Tx, r) 31 | if err != nil { 32 | return NewError(1 /*Not Signed In*/) 33 | } 34 | 35 | if r.Method == "POST" { 36 | var report models.Report 37 | if err := ReadJSON(r, &report); err != nil { 38 | return NewError(3 /*Invalid Request*/) 39 | } 40 | report.ReportId = -1 41 | report.UserId = user.UserId 42 | 43 | if len(report.Lua) >= models.LuaMaxLength { 44 | return NewError(3 /*Invalid Request*/) 45 | } 46 | 47 | err = context.Tx.InsertReport(&report) 48 | if err != nil { 49 | log.Print(err) 50 | return NewError(999 /*Internal Error*/) 51 | } 52 | 53 | return ResponseWrapper{201, &report} 54 | } else if r.Method == "GET" { 55 | if context.LastLevel() { 56 | //Return all Reports 57 | var rl models.ReportList 58 | reports, err := context.Tx.GetReports(user.UserId) 59 | if err != nil { 60 | log.Print(err) 61 | return NewError(999 /*Internal Error*/) 62 | } 63 | rl.Reports = reports 64 | return &rl 65 | } 66 | 67 | reportid, err := context.NextID() 68 | if err != nil { 69 | return NewError(3 /*Invalid Request*/) 70 | } 71 | 72 | if context.NextLevel() == "tabulations" { 73 | return ReportTabulationHandler(context.Tx, r, user, reportid) 74 | } else { 75 | // Return Report with this Id 76 | report, err := context.Tx.GetReport(reportid, user.UserId) 77 | if err != nil { 78 | return NewError(3 /*Invalid Request*/) 79 | } 80 | 81 | return report 82 | } 83 | } else { 84 | reportid, err := context.NextID() 85 | if err != nil { 86 | return NewError(3 /*Invalid Request*/) 87 | } 88 | 89 | if r.Method == "PUT" { 90 | var report models.Report 91 | if err := ReadJSON(r, &report); err != nil || report.ReportId != reportid { 92 | return NewError(3 /*Invalid Request*/) 93 | } 94 | report.UserId = user.UserId 95 | 96 | if len(report.Lua) >= models.LuaMaxLength { 97 | return NewError(3 /*Invalid Request*/) 98 | } 99 | 100 | err = context.Tx.UpdateReport(&report) 101 | if err != nil { 102 | log.Print(err) 103 | return NewError(999 /*Internal Error*/) 104 | } 105 | 106 | return &report 107 | } else if r.Method == "DELETE" { 108 | report, err := context.Tx.GetReport(reportid, user.UserId) 109 | if err != nil { 110 | return NewError(3 /*Invalid Request*/) 111 | } 112 | 113 | err = context.Tx.DeleteReport(report) 114 | if err != nil { 115 | log.Print(err) 116 | return NewError(999 /*Internal Error*/) 117 | } 118 | 119 | return SuccessWriter{} 120 | } 121 | } 122 | return NewError(3 /*Invalid Request*/) 123 | } 124 | -------------------------------------------------------------------------------- /js/components/AccountTree.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var ReactBootstrap = require('react-bootstrap'); 4 | var Button = ReactBootstrap.Button; 5 | var Collapse = ReactBootstrap.Collapse; 6 | var Glyphicon = ReactBootstrap.Glyphicon; 7 | 8 | class AccountTreeNode extends React.Component { 9 | constructor() { 10 | super(); 11 | this.state = {expanded: false}; 12 | this.onToggle = this.handleToggle.bind(this); 13 | this.onChildSelect = this.handleChildSelect.bind(this); 14 | this.onSelect = this.handleSelect.bind(this); 15 | } 16 | handleToggle(e) { 17 | e.preventDefault(); 18 | this.setState({expanded:!this.state.expanded}); 19 | } 20 | handleChildSelect(account) { 21 | if (this.props.onSelect != null) 22 | this.props.onSelect(account); 23 | } 24 | handleSelect() { 25 | if (this.props.onSelect != null) 26 | this.props.onSelect(this.props.account); 27 | } 28 | render() { 29 | var glyph = this.state.expanded ? 'minus' : 'plus'; 30 | var active = (this.props.selectedAccount != -1 && 31 | this.props.account.AccountId == this.props.selectedAccount); 32 | 33 | var self = this; 34 | var children = this.props.accountChildren[this.props.account.AccountId].map(function(childId) { 35 | var account = self.props.accounts[childId]; 36 | return ( 37 | 44 | ); 45 | }); 46 | var accounttreeClasses = "accounttree" 47 | var expandButton = []; 48 | if (children.length > 0) { 49 | expandButton.push(( 50 | 57 | )); 58 | } else { 59 | accounttreeClasses += "-nochildren"; 60 | } 61 | return ( 62 |
63 | {expandButton} 64 | 70 | 71 |
72 | {children} 73 |
74 |
75 |
76 | ); 77 | } 78 | } 79 | 80 | class AccountTree extends React.Component { 81 | constructor() { 82 | super(); 83 | this.onSelect = this.handleSelect.bind(this); 84 | } 85 | handleSelect(account) { 86 | if (this.props.onSelectAccount != null) { 87 | this.props.onSelectAccount(account); 88 | } 89 | if (this.props.onSelect != null && this.props.onSelectKey != null) { 90 | this.props.onSelect(this.props.onSelectKey); 91 | } 92 | } 93 | render() { 94 | var accounts = this.props.accounts; 95 | 96 | var children = []; 97 | for (var accountId in accounts) { 98 | if (accounts.hasOwnProperty(accountId) && 99 | accounts[accountId].isRootAccount()) { 100 | children.push(()); 107 | } 108 | } 109 | 110 | return ( 111 |
112 | {children} 113 |
114 | ); 115 | } 116 | } 117 | 118 | module.exports = AccountTree; 119 | -------------------------------------------------------------------------------- /internal/handlers/prices.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/aclindsa/moneygo/internal/models" 5 | "github.com/aclindsa/moneygo/internal/store" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | func CreatePriceIfNotExist(tx store.Tx, price *models.Price) error { 11 | if len(price.RemoteId) == 0 { 12 | // Always create a new price if we can't match on the RemoteId 13 | err := tx.InsertPrice(price) 14 | if err != nil { 15 | return err 16 | } 17 | return nil 18 | } 19 | 20 | exists, err := tx.PriceExists(price) 21 | if err != nil { 22 | return err 23 | } 24 | if exists { 25 | return nil // price already exists 26 | } 27 | 28 | err = tx.InsertPrice(price) 29 | if err != nil { 30 | return err 31 | } 32 | return nil 33 | } 34 | 35 | func PriceHandler(r *http.Request, context *Context, user *models.User, securityid int64) ResponseWriterWriter { 36 | security, err := context.Tx.GetSecurity(securityid, user.UserId) 37 | if err != nil { 38 | return NewError(3 /*Invalid Request*/) 39 | } 40 | 41 | if r.Method == "POST" { 42 | var price models.Price 43 | if err := ReadJSON(r, &price); err != nil { 44 | return NewError(3 /*Invalid Request*/) 45 | } 46 | price.PriceId = -1 47 | 48 | if price.SecurityId != security.SecurityId { 49 | return NewError(3 /*Invalid Request*/) 50 | } 51 | _, err = context.Tx.GetSecurity(price.CurrencyId, user.UserId) 52 | if err != nil { 53 | return NewError(3 /*Invalid Request*/) 54 | } 55 | 56 | err = context.Tx.InsertPrice(&price) 57 | if err != nil { 58 | log.Print(err) 59 | return NewError(999 /*Internal Error*/) 60 | } 61 | 62 | return ResponseWrapper{201, &price} 63 | } else if r.Method == "GET" { 64 | if context.LastLevel() { 65 | //Return all this security's prices 66 | var pl models.PriceList 67 | 68 | prices, err := context.Tx.GetPrices(security.SecurityId) 69 | if err != nil { 70 | log.Print(err) 71 | return NewError(999 /*Internal Error*/) 72 | } 73 | 74 | pl.Prices = prices 75 | return &pl 76 | } 77 | 78 | priceid, err := context.NextID() 79 | if err != nil { 80 | return NewError(3 /*Invalid Request*/) 81 | } 82 | 83 | price, err := context.Tx.GetPrice(priceid, security.SecurityId) 84 | if err != nil { 85 | return NewError(3 /*Invalid Request*/) 86 | } 87 | 88 | return price 89 | } else { 90 | priceid, err := context.NextID() 91 | if err != nil { 92 | return NewError(3 /*Invalid Request*/) 93 | } 94 | if r.Method == "PUT" { 95 | var price models.Price 96 | if err := ReadJSON(r, &price); err != nil || price.PriceId != priceid { 97 | return NewError(3 /*Invalid Request*/) 98 | } 99 | 100 | _, err = context.Tx.GetSecurity(price.SecurityId, user.UserId) 101 | if err != nil { 102 | return NewError(3 /*Invalid Request*/) 103 | } 104 | _, err = context.Tx.GetSecurity(price.CurrencyId, user.UserId) 105 | if err != nil { 106 | return NewError(3 /*Invalid Request*/) 107 | } 108 | 109 | err = context.Tx.UpdatePrice(&price) 110 | if err != nil { 111 | log.Print(err) 112 | return NewError(999 /*Internal Error*/) 113 | } 114 | 115 | return &price 116 | } else if r.Method == "DELETE" { 117 | price, err := context.Tx.GetPrice(priceid, security.SecurityId) 118 | if err != nil { 119 | return NewError(3 /*Invalid Request*/) 120 | } 121 | 122 | err = context.Tx.DeletePrice(price) 123 | if err != nil { 124 | log.Print(err) 125 | return NewError(999 /*Internal Error*/) 126 | } 127 | 128 | return SuccessWriter{} 129 | } 130 | } 131 | return NewError(3 /*Invalid Request*/) 132 | } 133 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | os: 4 | - linux 5 | - osx 6 | 7 | go: 8 | - 1.9.x 9 | - master 10 | 11 | services: 12 | - mysql 13 | - postgresql 14 | 15 | env: 16 | global: 17 | - DEP_VERSION="0.3.2" 18 | matrix: 19 | - MONEYGO_TEST_DB=sqlite 20 | - MONEYGO_TEST_DB=mysql 21 | - MONEYGO_TEST_DB=postgres 22 | 23 | # OSX builds take too long, so don't wait for them 24 | matrix: 25 | fast_finish: true 26 | allow_failures: 27 | - os: osx 28 | 29 | before_install: 30 | # Fetch/build coverage reporting tools 31 | - go get golang.org/x/tools/cmd/cover 32 | - go get github.com/mattn/goveralls 33 | - go install github.com/mattn/goveralls 34 | # Download `dep` and ensure it's executable 35 | - if [ $TRAVIS_OS_NAME = 'linux' ]; then curl -L -s https://github.com/golang/dep/releases/download/v${DEP_VERSION}/dep-linux-amd64 -o $GOPATH/bin/dep; fi 36 | - if [ $TRAVIS_OS_NAME = 'osx' ]; then curl -L -s https://github.com/golang/dep/releases/download/v${DEP_VERSION}/dep-darwin-amd64 -o $GOPATH/bin/dep; fi 37 | - chmod +x $GOPATH/bin/dep 38 | # Install and start MySQL or Postgres if on OSX 39 | - if [ $TRAVIS_OS_NAME = 'osx' ] && [ $MONEYGO_TEST_DB = 'mysql' ]; then brew update > /dev/null && brew install mariadb && mysql.server start; fi 40 | - if [ $TRAVIS_OS_NAME = 'osx' ] && [ $MONEYGO_TEST_DB = 'postgres' ]; then brew update > /dev/null; fi 41 | - if [ $TRAVIS_OS_NAME = 'osx' ] && [ $MONEYGO_TEST_DB = 'postgres' ]; then rm -rf /usr/local/var/postgres; fi 42 | - if [ $TRAVIS_OS_NAME = 'osx' ] && [ $MONEYGO_TEST_DB = 'postgres' ]; then initdb /usr/local/var/postgres; fi 43 | - if [ $TRAVIS_OS_NAME = 'osx' ] && [ $MONEYGO_TEST_DB = 'postgres' ]; then pg_ctl -D /usr/local/var/postgres start; fi 44 | - if [ $TRAVIS_OS_NAME = 'osx' ] && [ $MONEYGO_TEST_DB = 'postgres' ]; then createuser -s postgres; fi 45 | 46 | install: 47 | - dep ensure 48 | 49 | # Initialize databases, if testing MySQL or Postgres 50 | before_script: 51 | - if [ $MONEYGO_TEST_DB = 'mysql' ]; then export MONEYGO_TEST_DSN="root@tcp(127.0.0.1:3306)/moneygo_test?parseTime=true"; fi 52 | - if [ $MONEYGO_TEST_DB = 'postgres' ]; then export MONEYGO_TEST_DSN="postgres://postgres@localhost/moneygo_test?sslmode=disable"; fi 53 | - if [ $MONEYGO_TEST_DB = 'mysql' ]; then mysql -u root -e 'CREATE DATABASE IF NOT EXISTS moneygo_test;'; fi 54 | - if [ $MONEYGO_TEST_DB = 'postgres' ]; then psql -c 'DROP DATABASE IF EXISTS moneygo_test;' -U postgres; fi 55 | - if [ $MONEYGO_TEST_DB = 'postgres' ]; then psql -c 'CREATE DATABASE moneygo_test;' -U postgres; fi 56 | 57 | script: 58 | # Don't allow the test to query for a full list of all CUSIPs 59 | - touch $GOPATH/src/github.com/aclindsa/moneygo/internal/handlers/cusip_list.csv 60 | # Build and test MoneyGo 61 | - go generate -v github.com/aclindsa/moneygo/internal/handlers 62 | - export COVER_PACKAGES="github.com/aclindsa/moneygo/internal/config,github.com/aclindsa/moneygo/internal/handlers,github.com/aclindsa/moneygo/internal/models,github.com/aclindsa/moneygo/internal/store,github.com/aclindsa/moneygo/internal/store/db,github.com/aclindsa/moneygo/internal/reports" 63 | - go test -v -covermode=count -coverpkg $COVER_PACKAGES -coverprofile=integration_coverage.out github.com/aclindsa/moneygo/internal/integration 64 | - go test -v -covermode=count -coverpkg $COVER_PACKAGES -coverprofile=config_coverage.out github.com/aclindsa/moneygo/internal/config 65 | - go test -v -covermode=count -coverpkg $COVER_PACKAGES -coverprofile=models_coverage.out github.com/aclindsa/moneygo/internal/models 66 | 67 | # Report the test coverage 68 | after_script: 69 | - $GOPATH/bin/goveralls -coverprofile=integration_coverage.out,config_coverage.out,models_coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MoneyGo 2 | 3 | **MoneyGo** is a personal finance web application written in JavaScript and 4 | Golang. It adheres to [double-entry 5 | accounting](https://en.wikipedia.org/wiki/Double-entry_bookkeeping_system) 6 | principles and allows for importing directly from financial institutions using 7 | OFX (via [ofxgo](https://github.com/aclindsa/ofxgo)). 8 | 9 | This project is in active development and is not yet ready to be relied upon as 10 | your primary accounting software (but please feel free to try it out and offer 11 | feedback!). 12 | 13 | ## Features 14 | 15 | * [Import from OFX](./docs/ofx_imports.md) and 16 | [Gnucash](http://www.gnucash.org/) 17 | * Enter transactions manually using the register, double-entry accounting is 18 | enforced 19 | * Generate [custom charts in Lua](./docs/lua_reports.md) 20 | 21 | ## Screenshots 22 | 23 | ![Yearly Expense Report](./screenshots/yearly_expenses.png) 24 | ![Transaction Register](./screenshots/transaction_register.png) 25 | ![Transaction Editing](./screenshots/editing_transaction.png) 26 | 27 | ## Usage Documentation 28 | 29 | Though I believe much of the interface is 'discoverable', I'm working on 30 | documentation for those things that may not be so obvious to use: creating 31 | custom reports, importing transactions, etc. For the moment, the easiest way to 32 | view that documentation is to [browse it on github](./docs/index.md). 33 | 34 | ## Installation 35 | 36 | First, install npm, nodejs >= 6.11.3 (may work on older 6.x.x releases, but this 37 | is untested), python, curl, and go >= 1.9 in your distribution. Here is how in 38 | Arch Linux: 39 | 40 | sudo pacman -S npm curl go python 41 | 42 | Install browserify globally using npm: 43 | 44 | sudo npm install -g browserify 45 | 46 | You'll then want to build everything (the Golang and Javascript portions) using 47 | something like: 48 | 49 | export GOPATH=`pwd` 50 | go get -d github.com/aclindsa/moneygo 51 | go generate -v github.com/aclindsa/moneygo/internal/handlers 52 | go generate -v github.com/aclindsa/moneygo 53 | go install -v github.com/aclindsa/moneygo 54 | 55 | This may take quite a while the first time you build the project since it is 56 | auto-generating a list of currencies and securities by querying multiple 57 | websites and services. To avoid this step, you can `touch 58 | src/github.com/aclindsa/moneygo/internal/handlers/cusip_list.csv` before 59 | executing the `go generate ...` command above. Note that this will mean that no 60 | security templates are available to easily populate securities in your 61 | installation. If you would like to later generate these, simply remove the 62 | cusip_list.csv file and re-run the `go generate ...` command. 63 | 64 | ## Running 65 | 66 | MoneyGo requires HTTPS or FCGI (no HTTP). Before starting the server, you will 67 | want to edit the example configuration file 68 | (src/github.com/aclindsa/moneygo/internal/config/example_config.ini) to point to 69 | your own SSL certificate/key OR set 'generate-certs-if-absent = true' in the 70 | '[http]' section of the config file. 71 | 72 | Then, assuming you're in the same directory you ran the above installation 73 | commands from, running MoneyGo is as easy as: 74 | 75 | ./bin/moneygo -config src/github.com/aclindsa/moneygo/internal/config/example_config.ini 76 | 77 | You should then be able to explore MoneyGo by visiting https://localhost:8443 in 78 | your browser. Editing the configuration file supplied will allow you to modify 79 | several settings including the port used, SSL certificate locations, and whether 80 | to serve via FastCGI instead of HTTPS (the default). 81 | 82 | ## Missing Features 83 | 84 | * Importing a few of the more exotic investment transactions via OFX 85 | * Budgets 86 | * Scheduled transactions 87 | * Matching duplicate transactions 88 | * Tracking exchange rates, security prices 89 | * Import QIF 90 | -------------------------------------------------------------------------------- /internal/store/db/accounts.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "github.com/aclindsa/moneygo/internal/models" 6 | "github.com/aclindsa/moneygo/internal/store" 7 | ) 8 | 9 | func (tx *Tx) GetAccount(accountid int64, userid int64) (*models.Account, error) { 10 | var account models.Account 11 | 12 | err := tx.SelectOne(&account, "SELECT * from accounts where UserId=? AND AccountId=?", userid, accountid) 13 | if err != nil { 14 | return nil, err 15 | } 16 | return &account, nil 17 | } 18 | 19 | func (tx *Tx) GetAccounts(userid int64) (*[]*models.Account, error) { 20 | var accounts []*models.Account 21 | 22 | _, err := tx.Select(&accounts, "SELECT * from accounts where UserId=?", userid) 23 | if err != nil { 24 | return nil, err 25 | } 26 | return &accounts, nil 27 | } 28 | 29 | func (tx *Tx) FindMatchingAccounts(account *models.Account) (*[]*models.Account, error) { 30 | var accounts []*models.Account 31 | 32 | _, err := tx.Select(&accounts, "SELECT * from accounts where UserId=? AND SecurityId=? AND Type=? AND Name=? AND ParentAccountId=? ORDER BY AccountId ASC", account.UserId, account.SecurityId, account.Type, account.Name, account.ParentAccountId) 33 | if err != nil { 34 | return nil, err 35 | } 36 | return &accounts, nil 37 | } 38 | 39 | func (tx *Tx) insertUpdateAccount(account *models.Account, insert bool) error { 40 | found := make(map[int64]bool) 41 | if !insert { 42 | found[account.AccountId] = true 43 | } 44 | parentid := account.ParentAccountId 45 | depth := 0 46 | for parentid != -1 { 47 | depth += 1 48 | if depth > 100 { 49 | return store.TooMuchNestingError{} 50 | } 51 | 52 | var a models.Account 53 | err := tx.SelectOne(&a, "SELECT * from accounts where AccountId=?", parentid) 54 | if err != nil { 55 | return store.ParentAccountMissingError{} 56 | } 57 | 58 | // Insertion by itself can never result in circular dependencies 59 | if insert { 60 | break 61 | } 62 | 63 | found[parentid] = true 64 | parentid = a.ParentAccountId 65 | if _, ok := found[parentid]; ok { 66 | return store.CircularAccountsError{} 67 | } 68 | } 69 | 70 | if insert { 71 | err := tx.Insert(account) 72 | if err != nil { 73 | return err 74 | } 75 | } else { 76 | oldacct, err := tx.GetAccount(account.AccountId, account.UserId) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | account.AccountVersion = oldacct.AccountVersion + 1 82 | 83 | count, err := tx.Update(account) 84 | if err != nil { 85 | return err 86 | } 87 | if count != 1 { 88 | return errors.New("Updated more than one account") 89 | } 90 | } 91 | 92 | return nil 93 | } 94 | 95 | func (tx *Tx) InsertAccount(account *models.Account) error { 96 | return tx.insertUpdateAccount(account, true) 97 | } 98 | 99 | func (tx *Tx) UpdateAccount(account *models.Account) error { 100 | return tx.insertUpdateAccount(account, false) 101 | } 102 | 103 | func (tx *Tx) DeleteAccount(account *models.Account) error { 104 | if account.ParentAccountId != -1 { 105 | // Re-parent splits to this account's parent account if this account isn't a root account 106 | _, err := tx.Exec("UPDATE splits SET AccountId=? WHERE AccountId=?", account.ParentAccountId, account.AccountId) 107 | if err != nil { 108 | return err 109 | } 110 | } else { 111 | // Delete splits if this account is a root account 112 | _, err := tx.Exec("DELETE FROM splits WHERE AccountId=?", account.AccountId) 113 | if err != nil { 114 | return err 115 | } 116 | } 117 | 118 | // Re-parent child accounts to this account's parent account 119 | _, err := tx.Exec("UPDATE accounts SET ParentAccountId=? WHERE ParentAccountId=?", account.ParentAccountId, account.AccountId) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | count, err := tx.Delete(account) 125 | if err != nil { 126 | return err 127 | } 128 | if count != 1 { 129 | return errors.New("Was going to delete more than one account") 130 | } 131 | 132 | return nil 133 | } 134 | -------------------------------------------------------------------------------- /js/components/TopBar.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | var ReactBootstrap = require('react-bootstrap'); 4 | var Alert = ReactBootstrap.Alert; 5 | var FormGroup = ReactBootstrap.FormGroup; 6 | var FormControl = ReactBootstrap.FormControl; 7 | var Button = ReactBootstrap.Button; 8 | var DropdownButton = ReactBootstrap.DropdownButton; 9 | var MenuItem = ReactBootstrap.MenuItem; 10 | var Row = ReactBootstrap.Row; 11 | var Col = ReactBootstrap.Col; 12 | 13 | var ReactDOM = require('react-dom'); 14 | 15 | var User = require('../models').User; 16 | 17 | class LoginBar extends React.Component { 18 | constructor() { 19 | super(); 20 | this.state = {username: '', password: ''}; 21 | this.onSubmit = this.handleSubmit.bind(this); 22 | this.onNewUserSubmit = this.handleNewUserSubmit.bind(this); 23 | } 24 | onUsernameChange(e) { 25 | this.setState({username: e.target.value}); 26 | } 27 | onPasswordChange(e) { 28 | this.setState({password: e.target.value}); 29 | } 30 | handleSubmit(e) { 31 | var user = new User(); 32 | e.preventDefault(); 33 | user.Username = ReactDOM.findDOMNode(this.refs.username).value; 34 | user.Password = ReactDOM.findDOMNode(this.refs.password).value; 35 | this.props.onLogin(user); 36 | } 37 | handleNewUserSubmit(e) { 38 | e.preventDefault(); 39 | this.props.onCreateNewUser(); 40 | } 41 | render() { 42 | return ( 43 |
44 | 45 | 46 | 47 | 48 | 50 | 51 | 52 | 55 | 56 | 57 | 60 | 61 | 62 | 64 | 65 | 66 | 67 |
68 | ); 69 | } 70 | } 71 | 72 | class LogoutBar extends React.Component { 73 | constructor() { 74 | super(); 75 | this.onSelect = this.handleOnSelect.bind(this); 76 | } 77 | handleOnSelect(key) { 78 | if (key == 1) { 79 | if (this.props.onSettings != null) 80 | this.props.onSettings(); 81 | } else if (key == 2) { 82 | this.props.onLogout(); 83 | } 84 | } 85 | render() { 86 | var signedInString = "Signed in as "+this.props.user.Name; 87 | return ( 88 | 89 | 90 | 91 | 92 | 93 |
94 | 95 | Account Settings 96 | Logout 97 | 98 |
99 | 100 |
101 |
102 | ); 103 | } 104 | } 105 | 106 | class TopBar extends React.Component { 107 | render() { 108 | var barContents; 109 | var errorAlert; 110 | if (!this.props.user.isUser()) 111 | barContents = ; 112 | else 113 | barContents = ; 114 | if (this.props.error.isError()) 115 | errorAlert = 116 | 117 |

Error!

118 |

Error {this.props.error.ErrorId}: {this.props.error.ErrorString}

119 | 120 |
; 121 | 122 | return ( 123 |
124 | {barContents} 125 | {errorAlert} 126 |
127 | ); 128 | } 129 | } 130 | 131 | module.exports = TopBar; 132 | -------------------------------------------------------------------------------- /internal/integration/balance_lua_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestLuaBalances(t *testing.T) { 9 | RunWith(t, &data[0], func(t *testing.T, d *TestData) { 10 | accountid := d.accounts[3].AccountId 11 | symbol := d.securities[data[0].accounts[3].SecurityId].Symbol 12 | 13 | simpleLuaTest(t, d.clients[0], []LuaTest{ 14 | {"Account:Balance()", fmt.Sprintf("return get_accounts()[%d]:Balance()", accountid), symbol + " 87.19"}, 15 | {"Account:Balance(1)", fmt.Sprintf("return get_accounts()[%d]:Balance(date.new('2017-10-30')).Amount", accountid), "5.6"}, 16 | {"Account:Balance(2)", fmt.Sprintf("return get_accounts()[%d]:Balance(date.new(2017, 10, 30), date.new('2017-11-01')).Amount", accountid), "81.59"}, 17 | {"Security", fmt.Sprintf("return get_accounts()[%d]:Balance().Security.Symbol", accountid), symbol}, 18 | {"__eq", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new(2017, 10, 30)) == (act:Balance(date.new('2017-10-29')) + 0.0)", accountid), "true"}, 19 | {"not __eq", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new(2017, 10, 30)) == act:Balance(date.new('2017-11-01'))", accountid), "false"}, 20 | {"__lt", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new(2017, 10, 14)) < act:Balance(date.new('2017-10-16'))", accountid), "true"}, 21 | {"not __lt", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new(2017, 11, 01)) < act:Balance(date.new('2017-10-16'))", accountid), "false"}, 22 | {"__le", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new(2017, 10, 14)) <= act:Balance(date.new('2017-10-16'))", accountid), "true"}, 23 | {"__le (=)", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new(2017, 10, 16)) <= act:Balance(date.new('2017-10-17'))", accountid), "true"}, 24 | {"not __le", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new(2017, 11, 01)) <= act:Balance(date.new('2017-10-16'))", accountid), "false"}, 25 | {"__add", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new('2017-10-30')) + act:Balance(date.new(2017, 10, 30), date.new('2017-11-01'))", accountid), symbol + " 87.19"}, 26 | {"__add number", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new('2017-10-30')) + 9", accountid), symbol + " 14.60"}, 27 | {"__add to number", fmt.Sprintf("act = get_accounts()[%d]; return 5.489 + act:Balance(date.new(2017, 10, 30), date.new('2017-11-01'))", accountid), symbol + " 87.08"}, 28 | {"__sub", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new('2017-10-30')) - act:Balance(date.new(2017, 10, 30), date.new('2017-11-01'))", accountid), symbol + " -75.99"}, 29 | {"__sub number", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new('2017-10-30')) - 5", accountid), symbol + " 0.60"}, 30 | {"__sub from number", fmt.Sprintf("act = get_accounts()[%d]; return 100 - act:Balance(date.new(2017, 10, 30), date.new('2017-11-01'))", accountid), symbol + " 18.41"}, 31 | {"__mul", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new('2017-10-30')) * act:Balance(date.new(2017, 10, 30), date.new('2017-11-01'))", accountid), symbol + " 456.90"}, 32 | {"__mul number", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new('2017-10-30')) * 5", accountid), symbol + " 28.00"}, 33 | {"__mul with number", fmt.Sprintf("act = get_accounts()[%d]; return 11.1111 * act:Balance(date.new('2017-10-30')) * 5", accountid), symbol + " 311.11"}, 34 | {"__div", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new('2017-10-30')) / act:Balance(date.new(2017, 10, 30), date.new('2017-11-01'))", accountid), symbol + " 0.07"}, 35 | {"__div number", fmt.Sprintf("act = get_accounts()[%d]; return act:Balance(date.new('2017-10-30')) / 5", accountid), symbol + " 1.12"}, 36 | {"__div with number", fmt.Sprintf("act = get_accounts()[%d]; return 11.1111 / act:Balance(date.new('2017-10-30'))", accountid), symbol + " 1.98"}, 37 | {"__unm", fmt.Sprintf("act = get_accounts()[%d]; return -act:Balance(date.new('2017-10-30'))", accountid), symbol + " -5.60"}, 38 | }) 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /internal/handlers/users.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "errors" 5 | "github.com/aclindsa/moneygo/internal/models" 6 | "github.com/aclindsa/moneygo/internal/store" 7 | "log" 8 | "net/http" 9 | ) 10 | 11 | type UserExistsError struct{} 12 | 13 | func (ueu UserExistsError) Error() string { 14 | return "User exists" 15 | } 16 | 17 | func InsertUser(tx store.Tx, u *models.User) error { 18 | security_template := FindCurrencyTemplate(u.DefaultCurrency) 19 | if security_template == nil { 20 | return errors.New("Invalid ISO4217 Default Currency") 21 | } 22 | 23 | exists, err := tx.UsernameExists(u.Username) 24 | if err != nil { 25 | return err 26 | } 27 | if exists { 28 | return UserExistsError{} 29 | } 30 | 31 | err = tx.InsertUser(u) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | // Copy the security template and give it our new UserId 37 | var security models.Security 38 | security = *security_template 39 | security.UserId = u.UserId 40 | 41 | err = tx.InsertSecurity(&security) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | // Update the user's DefaultCurrency to our new SecurityId 47 | u.DefaultCurrency = security.SecurityId 48 | err = tx.UpdateUser(u) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func GetUserFromSession(tx store.Tx, r *http.Request) (*models.User, error) { 57 | s, err := GetSession(tx, r) 58 | if err != nil { 59 | return nil, err 60 | } 61 | return tx.GetUser(s.UserId) 62 | } 63 | 64 | func UpdateUser(tx store.Tx, u *models.User) error { 65 | security, err := tx.GetSecurity(u.DefaultCurrency, u.UserId) 66 | if err != nil { 67 | return err 68 | } else if security.UserId != u.UserId || security.SecurityId != u.DefaultCurrency { 69 | return errors.New("UserId and DefaultCurrency don't match the fetched security") 70 | } else if security.Type != models.Currency { 71 | return errors.New("New DefaultCurrency security is not a currency") 72 | } 73 | 74 | err = tx.UpdateUser(u) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | return nil 80 | } 81 | 82 | func UserHandler(r *http.Request, context *Context) ResponseWriterWriter { 83 | if r.Method == "POST" { 84 | var user models.User 85 | if err := ReadJSON(r, &user); err != nil { 86 | return NewError(3 /*Invalid Request*/) 87 | } 88 | user.UserId = -1 89 | user.HashPassword() 90 | 91 | err := InsertUser(context.Tx, &user) 92 | if err != nil { 93 | if _, ok := err.(UserExistsError); ok { 94 | return NewError(4 /*User Exists*/) 95 | } else { 96 | log.Print(err) 97 | return NewError(999 /*Internal Error*/) 98 | } 99 | } 100 | 101 | return ResponseWrapper{201, &user} 102 | } else { 103 | user, err := GetUserFromSession(context.Tx, r) 104 | if err != nil { 105 | return NewError(1 /*Not Signed In*/) 106 | } 107 | 108 | userid, err := context.NextID() 109 | if err != nil { 110 | return NewError(3 /*Invalid Request*/) 111 | } 112 | 113 | if userid != user.UserId { 114 | return NewError(2 /*Unauthorized Access*/) 115 | } 116 | 117 | if r.Method == "GET" { 118 | return user 119 | } else if r.Method == "PUT" { 120 | // Save old PWHash in case the new password is bogus 121 | old_pwhash := user.PasswordHash 122 | 123 | if err := ReadJSON(r, &user); err != nil || user.UserId != userid { 124 | return NewError(3 /*Invalid Request*/) 125 | } 126 | 127 | // If the user didn't create a new password, keep their old one 128 | if user.Password != models.BogusPassword { 129 | user.HashPassword() 130 | } else { 131 | user.Password = "" 132 | user.PasswordHash = old_pwhash 133 | } 134 | 135 | err = UpdateUser(context.Tx, user) 136 | if err != nil { 137 | log.Print(err) 138 | return NewError(999 /*Internal Error*/) 139 | } 140 | 141 | return user 142 | } else if r.Method == "DELETE" { 143 | err := context.Tx.DeleteUser(user) 144 | if err != nil { 145 | log.Print(err) 146 | return NewError(999 /*Internal Error*/) 147 | } 148 | return SuccessWriter{} 149 | } 150 | } 151 | return NewError(3 /*Invalid Request*/) 152 | } 153 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | digest = "1:476444199a2ed0626eab92a39d6379bb72c67b84758191f7be7d07dd44708fd7" 7 | name = "github.com/aclindsa/gorp" 8 | packages = ["."] 9 | pruneopts = "" 10 | revision = "4735379e1f46302b58b985d8172a53988aad93b4" 11 | 12 | [[projects]] 13 | branch = "master" 14 | digest = "1:a4aefb0596f8f241f0b6a633e3a73ef0a969017c3e714b7878609dd724ddf962" 15 | name = "github.com/aclindsa/ofxgo" 16 | packages = ["."] 17 | pruneopts = "" 18 | revision = "0f6ceccd861bb58a107aef5bb9a5cab54c479e5e" 19 | 20 | [[projects]] 21 | branch = "master" 22 | digest = "1:7c685fa85b4486ecf622995905408afed6330c493b0f8f81b54c9535343c23c1" 23 | name = "github.com/aclindsa/xml" 24 | packages = ["."] 25 | pruneopts = "" 26 | revision = "207ec7fb74201d07016cbd7064cb6331dde91eba" 27 | 28 | [[projects]] 29 | digest = "1:c07de423ca37dc2765396d6971599ab652a339538084b9b58c9f7fc533b28525" 30 | name = "github.com/go-sql-driver/mysql" 31 | packages = ["."] 32 | pruneopts = "" 33 | revision = "d523deb1b23d913de5bdada721a6071e71283618" 34 | version = "v1.4.0" 35 | 36 | [[projects]] 37 | branch = "master" 38 | digest = "1:048f85adb4eeed8feedaec030983d629e9903950b237345d579f920bdeaabf00" 39 | name = "github.com/kabukky/httpscerts" 40 | packages = ["."] 41 | pruneopts = "" 42 | revision = "617593d7dcb39c9ed617bb62c5e2056244d02184" 43 | 44 | [[projects]] 45 | branch = "master" 46 | digest = "1:bc36cd980d0069137800b505af4937c6109f4dc7cefe39d7c2efc7c8d51528d6" 47 | name = "github.com/lib/pq" 48 | packages = [ 49 | ".", 50 | "oid", 51 | ] 52 | pruneopts = "" 53 | revision = "9eb73efc1fcc404148b56765b0d3f61d9a5ef8ee" 54 | 55 | [[projects]] 56 | digest = "1:c8b59c9235e65231db9f6fe5b05306ccf1108c8fd00cb017f0529fe242e68dd4" 57 | name = "github.com/mattn/go-sqlite3" 58 | packages = ["."] 59 | pruneopts = "" 60 | revision = "1fc3fd346d3cc4c610f432d8bc938bb952733873" 61 | 62 | [[projects]] 63 | branch = "master" 64 | digest = "1:e5e7d7742a5607737fd6a564ca941aecbd4e70c56ff443b11e3ce107608b5299" 65 | name = "github.com/yuin/gopher-lua" 66 | packages = [ 67 | ".", 68 | "ast", 69 | "parse", 70 | "pm", 71 | ] 72 | pruneopts = "" 73 | revision = "12c4817b42c5e5b1f9e2690e42bfeaac8727924f" 74 | 75 | [[projects]] 76 | digest = "1:5acd3512b047305d49e8763eef7ba423901e85d5dd2fd1e71778a0ea8de10bd4" 77 | name = "golang.org/x/text" 78 | packages = [ 79 | "currency", 80 | "internal", 81 | "internal/format", 82 | "internal/gen", 83 | "internal/tag", 84 | "language", 85 | "unicode/cldr", 86 | ] 87 | pruneopts = "" 88 | revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" 89 | version = "v0.3.0" 90 | 91 | [[projects]] 92 | digest = "1:77d3cff3a451d50be4b52db9c7766c0d8570ba47593f0c9dc72173adb208e788" 93 | name = "google.golang.org/appengine" 94 | packages = ["cloudsql"] 95 | pruneopts = "" 96 | revision = "4a4468ece617fc8205e99368fa2200e9d1fad421" 97 | version = "v1.3.0" 98 | 99 | [[projects]] 100 | digest = "1:bb864e9881b2c241fc6348ba5ed57e6ccf8a675903f1f5c3d81c1b3d7cc4b8f8" 101 | name = "gopkg.in/gcfg.v1" 102 | packages = [ 103 | ".", 104 | "scanner", 105 | "token", 106 | "types", 107 | ] 108 | pruneopts = "" 109 | revision = "61b2c08bc8f6068f7c5ca684372f9a6cb1c45ebe" 110 | version = "v1.2.3" 111 | 112 | [[projects]] 113 | digest = "1:ceec7e96590fb8168f36df4795fefe17051d4b0c2acc7ec4e260d8138c4dafac" 114 | name = "gopkg.in/warnings.v0" 115 | packages = ["."] 116 | pruneopts = "" 117 | revision = "ec4a0fea49c7b46c2aeb0b51aac55779c607e52b" 118 | version = "v0.1.2" 119 | 120 | [solve-meta] 121 | analyzer-name = "dep" 122 | analyzer-version = 1 123 | input-imports = [ 124 | "github.com/aclindsa/gorp", 125 | "github.com/aclindsa/ofxgo", 126 | "github.com/go-sql-driver/mysql", 127 | "github.com/kabukky/httpscerts", 128 | "github.com/lib/pq", 129 | "github.com/mattn/go-sqlite3", 130 | "github.com/yuin/gopher-lua", 131 | "gopkg.in/gcfg.v1", 132 | ] 133 | solver-name = "gps-cdcl" 134 | solver-version = 1 135 | -------------------------------------------------------------------------------- /internal/handlers/scripts/gen_security_list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import csv 4 | from xml.dom import minidom 5 | import sys 6 | 7 | if sys.version_info[0] < 3: 8 | from urllib2 import urlopen 9 | 10 | # Allow writing utf-8 to stdout 11 | import codecs 12 | UTF8Writer = codecs.getwriter('utf8') 13 | sys.stdout = UTF8Writer(sys.stdout) 14 | else: 15 | from urllib.request import urlopen 16 | 17 | # This is absent, but also unneeded in python3, so just return the string 18 | def unicode(s, encoding): 19 | return s 20 | 21 | class Security(object): 22 | def __init__(self, name, description, number, _type, precision): 23 | self.name = name 24 | self.description = description 25 | self.number = number 26 | self.type = _type 27 | self.precision = precision 28 | def unicode(self): 29 | s = """\t{ 30 | \t\tName: \"%s\", 31 | \t\tDescription: \"%s\", 32 | \t\tSymbol: \"%s\", 33 | \t\tPrecision: %d, 34 | \t\tType: %s, 35 | \t\tAlternateId: \"%s\"},\n""" % (self.name, self.description, self.name, self.precision, self.type, str(self.number)) 36 | try: 37 | return unicode(s, 'utf_8') 38 | except TypeError: 39 | return s 40 | 41 | class SecurityList(object): 42 | def __init__(self, comment): 43 | self.comment = comment 44 | self.currencies = {} 45 | def add(self, currency): 46 | self.currencies[currency.number] = currency 47 | def unicode(self): 48 | string = "\t// "+self.comment+"\n" 49 | for key in sorted(self.currencies.keys()): 50 | string += self.currencies[key].unicode() 51 | return string 52 | 53 | def process_ccyntry(currency_list, node): 54 | name = "" 55 | nameSet = False 56 | number = 0 57 | numberSet = False 58 | description = "" 59 | precision = 0 60 | for n in node.childNodes: 61 | if n.nodeName == "Ccy": 62 | name = n.firstChild.nodeValue 63 | nameSet = True 64 | elif n.nodeName == "CcyNm": 65 | description = n.firstChild.nodeValue 66 | elif n.nodeName == "CcyNbr": 67 | number = int(n.firstChild.nodeValue) 68 | numberSet = True 69 | elif n.nodeName == "CcyMnrUnts": 70 | if n.firstChild.nodeValue == "N.A.": 71 | precision = 0 72 | else: 73 | precision = int(n.firstChild.nodeValue) 74 | if nameSet and numberSet: 75 | currency_list.add(Security(name, description, number, "models.Currency", precision)) 76 | 77 | def get_currency_list(): 78 | currency_list = SecurityList("ISO 4217, from http://www.currency-iso.org/en/home/tables/table-a1.html") 79 | 80 | f = urlopen('http://www.currency-iso.org/dam/downloads/lists/list_one.xml') 81 | xmldoc = minidom.parse(f) 82 | for isonode in xmldoc.childNodes: 83 | if isonode.nodeName == "ISO_4217": 84 | for ccytblnode in isonode.childNodes: 85 | if ccytblnode.nodeName == "CcyTbl": 86 | for ccyntrynode in ccytblnode.childNodes: 87 | if ccyntrynode.nodeName == "CcyNtry": 88 | process_ccyntry(currency_list, ccyntrynode) 89 | f.close() 90 | return currency_list 91 | 92 | def get_cusip_list(filename): 93 | cusip_list = SecurityList("") 94 | with open(filename) as csvfile: 95 | csvreader = csv.reader(csvfile, delimiter=',') 96 | for row in csvreader: 97 | cusip = row[0] 98 | name = row[1] 99 | description = ",".join(row[2:]) 100 | cusip_list.add(Security(name, description, cusip, "models.Stock", 5)) 101 | return cusip_list 102 | 103 | def main(): 104 | currency_list = get_currency_list() 105 | cusip_list = get_cusip_list('cusip_list.csv') 106 | 107 | print("package handlers\n") 108 | print("import (") 109 | print("\t\"github.com/aclindsa/moneygo/internal/models\"") 110 | print(")\n") 111 | print("var SecurityTemplates = []models.Security{") 112 | print(currency_list.unicode()) 113 | print(cusip_list.unicode()) 114 | print("}") 115 | 116 | if __name__ == "__main__": 117 | main() 118 | -------------------------------------------------------------------------------- /internal/integration/testdata/401k_mutualfunds.ofx: -------------------------------------------------------------------------------- 1 | 0INFOSUCCESS20171128203521.622[-5:EST]ENGofx.bank.com9199 d87db96a-c872-7f73-7637-7e9e2816c25a 0INFOSUCCESS20171128193521.926[-5:EST]USDofx.bank.com12321 20170829213521.814[-4:EDT]20171127203521.814[-5:EST]20170901OAEL01120170901070000.000[-4:EDT]CONTRIBUTION;VANGUARD TARGET 2045 OAEL;as of 09/01/2017OAELCUSIP1.75656.97100.05OTHEROTHERBUY 20170915OAEL01120170915070000.000[-4:EDT]CONTRIBUTION;VANGUARD TARGET 2045 OAEL;as of 09/15/2017OAELCUSIP1.73757.59100.05OTHEROTHERBUY 20170901OAEL13120170901070000.000[-4:EDT]FEES;VANGUARD TARGET 2045 OAEL;as of 09/01/2017OAELCUSIP0.0756.974.0OTHEROTHERSELL 20171002OAEL13120171002070000.000[-4:EDT]FEES;VANGUARD TARGET 2045 OAEL;as of 10/02/2017OAELCUSIP0.06958.14.0OTHEROTHERSELL OAELCUSIPOTHERLONG2792.37359.64200.03 20171127160000.000[-5:EST] Market close as of 11/27/2017;VANGUARD TARGET 2045 000MarketValueMarketValueDOLLAR200.0320171128193521.926[-5:EST]VestedValueVestedValueDOLLAR200.0320171128193521.926[-5:EST]TotalAssetsValueTotalAssetsValueDOLLAR200.0320171128193521.926[-5:EST]QC 401(K) PLAN200.03MarketValueMarketValueDOLLAR200.0320171128193521.926[-5:EST]VestedValueVestedValueDOLLAR200.0320171128193521.926[-5:EST]TotalAssetsValueTotalAssetsValueDOLLAR200.0320171128193521.926[-5:EST] OAELCUSIPVANGUARD TARGET 2045OAEL59.6420171127160000.000[-5:EST]Market close as of 11/27/2017;VANGUARD TARGET 2045OTHER 2 | -------------------------------------------------------------------------------- /internal/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "github.com/aclindsa/moneygo/internal/models" 5 | "time" 6 | ) 7 | 8 | type UserStore interface { 9 | UsernameExists(username string) (bool, error) 10 | InsertUser(user *models.User) error 11 | GetUser(userid int64) (*models.User, error) 12 | GetUserByUsername(username string) (*models.User, error) 13 | UpdateUser(user *models.User) error 14 | DeleteUser(user *models.User) error 15 | } 16 | 17 | type SessionStore interface { 18 | SessionExists(secret string) (bool, error) 19 | InsertSession(session *models.Session) error 20 | GetSession(secret string) (*models.Session, error) 21 | DeleteSession(session *models.Session) error 22 | } 23 | 24 | type SecurityInUseError struct { 25 | Message string 26 | } 27 | 28 | func (e SecurityInUseError) Error() string { 29 | return e.Message 30 | } 31 | 32 | type SecurityStore interface { 33 | InsertSecurity(security *models.Security) error 34 | GetSecurity(securityid int64, userid int64) (*models.Security, error) 35 | GetSecurities(userid int64) (*[]*models.Security, error) 36 | FindMatchingSecurities(security *models.Security) (*[]*models.Security, error) 37 | UpdateSecurity(security *models.Security) error 38 | DeleteSecurity(security *models.Security) error 39 | } 40 | 41 | type PriceStore interface { 42 | PriceExists(price *models.Price) (bool, error) 43 | InsertPrice(price *models.Price) error 44 | GetPrice(priceid, securityid int64) (*models.Price, error) 45 | GetPrices(securityid int64) (*[]*models.Price, error) 46 | GetLatestPrice(security, currency *models.Security, date *time.Time) (*models.Price, error) 47 | GetEarliestPrice(security, currency *models.Security, date *time.Time) (*models.Price, error) 48 | UpdatePrice(price *models.Price) error 49 | DeletePrice(price *models.Price) error 50 | } 51 | 52 | type ParentAccountMissingError struct{} 53 | 54 | func (pame ParentAccountMissingError) Error() string { 55 | return "Parent account missing" 56 | } 57 | 58 | type TooMuchNestingError struct{} 59 | 60 | func (tmne TooMuchNestingError) Error() string { 61 | return "Too much account nesting" 62 | } 63 | 64 | type CircularAccountsError struct{} 65 | 66 | func (cae CircularAccountsError) Error() string { 67 | return "Would result in circular account relationship" 68 | } 69 | 70 | type AccountStore interface { 71 | InsertAccount(account *models.Account) error 72 | GetAccount(accountid int64, userid int64) (*models.Account, error) 73 | GetAccounts(userid int64) (*[]*models.Account, error) 74 | FindMatchingAccounts(account *models.Account) (*[]*models.Account, error) 75 | UpdateAccount(account *models.Account) error 76 | DeleteAccount(account *models.Account) error 77 | } 78 | 79 | type AccountMissingError struct{} 80 | 81 | func (ame AccountMissingError) Error() string { 82 | return "Account missing" 83 | } 84 | 85 | type TransactionStore interface { 86 | SplitExists(s *models.Split) (bool, error) 87 | InsertTransaction(t *models.Transaction, user *models.User) error 88 | GetTransaction(transactionid int64, userid int64) (*models.Transaction, error) 89 | GetTransactions(userid int64) (*[]*models.Transaction, error) 90 | UpdateTransaction(t *models.Transaction, user *models.User) error 91 | DeleteTransaction(t *models.Transaction, user *models.User) error 92 | GetAccountBalance(user *models.User, accountid int64) (*models.Amount, error) 93 | GetAccountBalanceDate(user *models.User, accountid int64, date *time.Time) (*models.Amount, error) 94 | GetAccountBalanceDateRange(user *models.User, accountid int64, begin, end *time.Time) (*models.Amount, error) 95 | GetAccountTransactions(user *models.User, accountid int64, sort string, page uint64, limit uint64) (*models.AccountTransactionsList, error) 96 | } 97 | 98 | type ReportStore interface { 99 | InsertReport(report *models.Report) error 100 | GetReport(reportid int64, userid int64) (*models.Report, error) 101 | GetReports(userid int64) (*[]*models.Report, error) 102 | UpdateReport(report *models.Report) error 103 | DeleteReport(report *models.Report) error 104 | } 105 | 106 | type Tx interface { 107 | Commit() error 108 | Rollback() error 109 | 110 | UserStore 111 | SessionStore 112 | SecurityStore 113 | PriceStore 114 | AccountStore 115 | TransactionStore 116 | ReportStore 117 | } 118 | 119 | type Store interface { 120 | Empty() error 121 | Begin() (Tx, error) 122 | Close() error 123 | } 124 | -------------------------------------------------------------------------------- /js/actions/AccountActions.js: -------------------------------------------------------------------------------- 1 | var AccountConstants = require('../constants/AccountConstants'); 2 | 3 | var ErrorActions = require('./ErrorActions'); 4 | 5 | var models = require('../models.js'); 6 | var Account = models.Account; 7 | var Error = models.Error; 8 | 9 | function fetchAccounts() { 10 | return { 11 | type: AccountConstants.FETCH_ACCOUNTS 12 | } 13 | } 14 | 15 | function accountsFetched(accounts) { 16 | return { 17 | type: AccountConstants.ACCOUNTS_FETCHED, 18 | accounts: accounts 19 | } 20 | } 21 | 22 | function createAccount() { 23 | return { 24 | type: AccountConstants.CREATE_ACCOUNT 25 | } 26 | } 27 | 28 | function accountCreated(account) { 29 | return { 30 | type: AccountConstants.ACCOUNT_CREATED, 31 | account: account 32 | } 33 | } 34 | 35 | function updateAccount() { 36 | return { 37 | type: AccountConstants.UPDATE_ACCOUNT 38 | } 39 | } 40 | 41 | function accountUpdated(account) { 42 | return { 43 | type: AccountConstants.ACCOUNT_UPDATED, 44 | account: account 45 | } 46 | } 47 | 48 | function removeAccount() { 49 | return { 50 | type: AccountConstants.REMOVE_ACCOUNT 51 | } 52 | } 53 | 54 | function accountRemoved(accountId) { 55 | return { 56 | type: AccountConstants.ACCOUNT_REMOVED, 57 | accountId: accountId 58 | } 59 | } 60 | 61 | function accountSelected(accountId) { 62 | return { 63 | type: AccountConstants.ACCOUNT_SELECTED, 64 | accountId: accountId 65 | } 66 | } 67 | 68 | function fetchAll() { 69 | return function (dispatch) { 70 | dispatch(fetchAccounts()); 71 | 72 | $.ajax({ 73 | type: "GET", 74 | dataType: "json", 75 | url: "v1/accounts/", 76 | success: function(data, status, jqXHR) { 77 | var e = new Error(); 78 | e.fromJSON(data); 79 | if (e.isError()) { 80 | dispatch(ErrorActions.serverError(e)); 81 | } else { 82 | dispatch(accountsFetched(data.accounts.map(function(json) { 83 | var a = new Account(); 84 | a.fromJSON(json); 85 | return a; 86 | }))); 87 | } 88 | }, 89 | error: function(jqXHR, status, error) { 90 | dispatch(ErrorActions.ajaxError(error)); 91 | } 92 | }); 93 | }; 94 | } 95 | 96 | function create(account) { 97 | return function (dispatch) { 98 | dispatch(createAccount()); 99 | 100 | $.ajax({ 101 | type: "POST", 102 | contentType: "application/json", 103 | dataType: "json", 104 | url: "v1/accounts/", 105 | data: account.toJSON(), 106 | success: function(data, status, jqXHR) { 107 | var e = new Error(); 108 | e.fromJSON(data); 109 | if (e.isError()) { 110 | dispatch(ErrorActions.serverError(e)); 111 | } else { 112 | var a = new Account(); 113 | a.fromJSON(data); 114 | dispatch(accountCreated(a)); 115 | } 116 | }, 117 | error: function(jqXHR, status, error) { 118 | dispatch(ErrorActions.ajaxError(error)); 119 | } 120 | }); 121 | }; 122 | } 123 | 124 | function update(account) { 125 | return function (dispatch) { 126 | dispatch(updateAccount()); 127 | 128 | $.ajax({ 129 | type: "PUT", 130 | contentType: "application/json", 131 | dataType: "json", 132 | url: "v1/accounts/"+account.AccountId+"/", 133 | data: account.toJSON(), 134 | success: function(data, status, jqXHR) { 135 | var e = new Error(); 136 | e.fromJSON(data); 137 | if (e.isError()) { 138 | dispatch(ErrorActions.serverError(e)); 139 | } else { 140 | var a = new Account(); 141 | a.fromJSON(data); 142 | dispatch(accountUpdated(a)); 143 | } 144 | }, 145 | error: function(jqXHR, status, error) { 146 | dispatch(ErrorActions.ajaxError(error)); 147 | } 148 | }); 149 | }; 150 | } 151 | 152 | function remove(account) { 153 | return function(dispatch) { 154 | dispatch(removeAccount()); 155 | 156 | $.ajax({ 157 | type: "DELETE", 158 | dataType: "json", 159 | url: "v1/accounts/"+account.AccountId+"/", 160 | success: function(data, status, jqXHR) { 161 | var e = new Error(); 162 | e.fromJSON(data); 163 | if (e.isError()) { 164 | dispatch(ErrorActions.serverError(e)); 165 | } else { 166 | dispatch(accountRemoved(account.AccountId)); 167 | } 168 | }, 169 | error: function(jqXHR, status, error) { 170 | dispatch(ErrorActions.ajaxError(error)); 171 | } 172 | }); 173 | }; 174 | } 175 | 176 | module.exports = { 177 | fetchAll: fetchAll, 178 | create: create, 179 | update: update, 180 | remove: remove, 181 | select: accountSelected 182 | }; 183 | -------------------------------------------------------------------------------- /internal/integration/testdata/checking_20171129.ofx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 0 7 | INFO 8 | 9 | 20171129025346.132[0:GMT] 10 | ENG 11 | 12 | YCKVJ 13 | 0351 14 | 15 | 16 | 17 | 18 | 0549c828-f02c-43c7-81a3-de0b3f23c393 19 | 20 | 0 21 | INFO 22 | 23 | 24 | USD 25 | 26 | 115483849 27 | 14839128817 28 | CHECKING 29 | 30 | 31 | 20170831174401.637[0:GMT] 32 | 20171129184401.637[0:GMT] 33 | 34 | DEBIT 35 | 20171107120000.000[0:GMT] 36 | -282.68 37 | 29c74a94-f226-4980-b54c-da6fa2721d7e 38 | DAYCARE o SIGONFILE 39 | ACH Debit 11818191919191 40 | 41 | 42 | CREDIT 43 | 20171109120000.000[0:GMT] 44 | 1300.98 45 | 32e40e98-61c3-421c-acaa-55ae67a5f8fe 46 | DIRECT DEPOSIT 47 | ACH Deposit 8282828282828 48 | 49 | 50 | DEBIT 51 | 20171109120000.000[0:GMT] 52 | -98.20 53 | 4b73dbbf-aa27-4f62-b54a-ee0a9a3486d8 54 | DUKEENGYPROGRESS DUKEENGYPR 55 | ACH Debit 017313004099621 56 | 57 | 58 | CREDIT 59 | 20171115120000.000[0:GMT] 60 | 1.01 61 | 51c47252-4cf0-442c-b619-8a31b17ac489 62 | Dividend Earned 63 | 64 | 65 | DEBIT 66 | 20171116120000.000[0:GMT] 67 | -51.75 68 | 51cb12bb-cdd9-4333-8d8d-c423f9e8f833 69 | TARGET DEBIT CRD ACH TRAN 70 | ACH Debit 71 | 72 | 73 | DEBIT 74 | 20171120120000.000[0:GMT] 75 | -25.18 76 | 366a5b23-2f2e-4cf0-a714-6a306bd4e909 77 | TARGET DEBIT CRD ACH TRAN 78 | ACH Debit 79 | 80 | 81 | DEBIT 82 | 20171121120000.000[0:GMT] 83 | -10.71 84 | 9a463f21-c6e1-4fe0-b37b-f9a8cc942cf0 85 | NETFLIX COM NETFLIX COM 86 | Point of Sale Debit L999 DATE 11-20 87 | 88 | 89 | CREDIT 90 | 20171122120000.000[0:GMT] 91 | 1300.98 92 | 31f165e5-569f-4530-8438-a6ceb2301335 93 | DIRECT DEPOSIT 94 | ACH Deposit 838383838383838 95 | 96 | 97 | CREDIT 98 | 20171122120000.000[0:GMT] 99 | 12.50 100 | 215a10dd-f3a2-4336-ab8c-f22276cad552 101 | CIRCLE INTERNET CIRCLE 102 | ACH Deposit 017326000283477 103 | 104 | 105 | CREDIT 106 | 20171129120000.000[0:GMT] 107 | 2843.08 108 | 9a52df4b-3a8d-41bb-9141-96e1e3f294cf 109 | SALARY 110 | ACH Deposit 18181818199 111 | 112 | 113 | 114 | 5463.45 115 | 20171129025346.132[0:GMT] 116 | 117 | 118 | 6463.45 119 | 20171129025346.132[0:GMT] 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /js/actions/SecurityActions.js: -------------------------------------------------------------------------------- 1 | var SecurityConstants = require('../constants/SecurityConstants'); 2 | 3 | var ErrorActions = require('./ErrorActions'); 4 | 5 | var models = require('../models.js'); 6 | var Security = models.Security; 7 | var Error = models.Error; 8 | 9 | function fetchSecurities() { 10 | return { 11 | type: SecurityConstants.FETCH_SECURITIES 12 | } 13 | } 14 | 15 | function securitiesFetched(securities) { 16 | return { 17 | type: SecurityConstants.SECURITIES_FETCHED, 18 | securities: securities 19 | } 20 | } 21 | 22 | function createSecurity() { 23 | return { 24 | type: SecurityConstants.CREATE_SECURITY 25 | } 26 | } 27 | 28 | function securityCreated(security) { 29 | return { 30 | type: SecurityConstants.SECURITY_CREATED, 31 | security: security 32 | } 33 | } 34 | 35 | function updateSecurity() { 36 | return { 37 | type: SecurityConstants.UPDATE_SECURITY 38 | } 39 | } 40 | 41 | function securityUpdated(security) { 42 | return { 43 | type: SecurityConstants.SECURITY_UPDATED, 44 | security: security 45 | } 46 | } 47 | 48 | function removeSecurity() { 49 | return { 50 | type: SecurityConstants.REMOVE_SECURITY 51 | } 52 | } 53 | 54 | function securityRemoved(securityId) { 55 | return { 56 | type: SecurityConstants.SECURITY_REMOVED, 57 | securityId: securityId 58 | } 59 | } 60 | 61 | function securitySelected(securityId) { 62 | return { 63 | type: SecurityConstants.SECURITY_SELECTED, 64 | securityId: securityId 65 | } 66 | } 67 | 68 | function fetchAll() { 69 | return function (dispatch) { 70 | dispatch(fetchSecurities()); 71 | 72 | $.ajax({ 73 | type: "GET", 74 | dataType: "json", 75 | url: "v1/securities/", 76 | success: function(data, status, jqXHR) { 77 | var e = new Error(); 78 | e.fromJSON(data); 79 | if (e.isError()) { 80 | dispatch(ErrorActions.serverError(e)); 81 | } else { 82 | dispatch(securitiesFetched(data.securities.map(function(json) { 83 | var s = new Security(); 84 | s.fromJSON(json); 85 | return s; 86 | }))); 87 | } 88 | }, 89 | error: function(jqXHR, status, error) { 90 | dispatch(ErrorActions.ajaxError(error)); 91 | } 92 | }); 93 | }; 94 | } 95 | 96 | function create(security) { 97 | return function (dispatch) { 98 | dispatch(createSecurity()); 99 | 100 | $.ajax({ 101 | type: "POST", 102 | contentType: "application/json", 103 | dataType: "json", 104 | url: "v1/securities/", 105 | data: security.toJSON(), 106 | success: function(data, status, jqXHR) { 107 | var e = new Error(); 108 | e.fromJSON(data); 109 | if (e.isError()) { 110 | dispatch(ErrorActions.serverError(e)); 111 | } else { 112 | var s = new Security(); 113 | s.fromJSON(data); 114 | dispatch(securityCreated(s)); 115 | } 116 | }, 117 | error: function(jqXHR, status, error) { 118 | dispatch(ErrorActions.ajaxError(error)); 119 | } 120 | }); 121 | }; 122 | } 123 | 124 | function update(security) { 125 | return function (dispatch) { 126 | dispatch(updateSecurity()); 127 | 128 | $.ajax({ 129 | type: "PUT", 130 | contentType: "application/json", 131 | dataType: "json", 132 | url: "v1/securities/"+security.SecurityId+"/", 133 | data: security.toJSON(), 134 | success: function(data, status, jqXHR) { 135 | var e = new Error(); 136 | e.fromJSON(data); 137 | if (e.isError()) { 138 | dispatch(ErrorActions.serverError(e)); 139 | } else { 140 | var s = new Security(); 141 | s.fromJSON(data); 142 | dispatch(securityUpdated(s)); 143 | } 144 | }, 145 | error: function(jqXHR, status, error) { 146 | dispatch(ErrorActions.ajaxError(error)); 147 | } 148 | }); 149 | }; 150 | } 151 | 152 | function remove(security) { 153 | return function(dispatch) { 154 | dispatch(removeSecurity()); 155 | 156 | $.ajax({ 157 | type: "DELETE", 158 | dataType: "json", 159 | url: "v1/securities/"+security.SecurityId+"/", 160 | success: function(data, status, jqXHR) { 161 | var e = new Error(); 162 | e.fromJSON(data); 163 | if (e.isError()) { 164 | dispatch(ErrorActions.serverError(e)); 165 | } else { 166 | dispatch(securityRemoved(security.SecurityId)); 167 | } 168 | }, 169 | error: function(jqXHR, status, error) { 170 | dispatch(ErrorActions.ajaxError(error)); 171 | } 172 | }); 173 | }; 174 | } 175 | 176 | module.exports = { 177 | fetchAll: fetchAll, 178 | create: create, 179 | update: update, 180 | remove: remove, 181 | select: securitySelected 182 | }; 183 | -------------------------------------------------------------------------------- /internal/store/db/prices.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aclindsa/moneygo/internal/models" 6 | "time" 7 | ) 8 | 9 | // Price is a mirror of models.Price with the Value broken out into whole and 10 | // fractional components 11 | type Price struct { 12 | PriceId int64 13 | SecurityId int64 14 | CurrencyId int64 15 | Date time.Time 16 | WholeValue int64 17 | FractionalValue int64 18 | RemoteId string // unique ID from source, for detecting duplicates 19 | } 20 | 21 | func NewPrice(p *models.Price) (*Price, error) { 22 | whole, err := p.Value.Whole() 23 | if err != nil { 24 | return nil, err 25 | } 26 | fractional, err := p.Value.Fractional(MaxPrecision) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return &Price{ 31 | PriceId: p.PriceId, 32 | SecurityId: p.SecurityId, 33 | CurrencyId: p.CurrencyId, 34 | Date: p.Date, 35 | WholeValue: whole, 36 | FractionalValue: fractional, 37 | RemoteId: p.RemoteId, 38 | }, nil 39 | } 40 | 41 | func (p Price) Price() *models.Price { 42 | price := &models.Price{ 43 | PriceId: p.PriceId, 44 | SecurityId: p.SecurityId, 45 | CurrencyId: p.CurrencyId, 46 | Date: p.Date, 47 | RemoteId: p.RemoteId, 48 | } 49 | price.Value.FromParts(p.WholeValue, p.FractionalValue, MaxPrecision) 50 | 51 | return price 52 | } 53 | 54 | func (tx *Tx) PriceExists(price *models.Price) (bool, error) { 55 | p, err := NewPrice(price) 56 | if err != nil { 57 | return false, err 58 | } 59 | 60 | var prices []*Price 61 | _, err = tx.Select(&prices, "SELECT * from prices where SecurityId=? AND CurrencyId=? AND Date=? AND WholeValue=? AND FractionalValue=?", p.SecurityId, p.CurrencyId, p.Date, p.WholeValue, p.FractionalValue) 62 | return len(prices) > 0, err 63 | } 64 | 65 | func (tx *Tx) InsertPrice(price *models.Price) error { 66 | p, err := NewPrice(price) 67 | if err != nil { 68 | return err 69 | } 70 | err = tx.Insert(p) 71 | if err != nil { 72 | return err 73 | } 74 | *price = *p.Price() 75 | return nil 76 | } 77 | 78 | func (tx *Tx) GetPrice(priceid, securityid int64) (*models.Price, error) { 79 | var price Price 80 | err := tx.SelectOne(&price, "SELECT * from prices where PriceId=? AND SecurityId=?", priceid, securityid) 81 | if err != nil { 82 | return nil, err 83 | } 84 | return price.Price(), nil 85 | } 86 | 87 | func (tx *Tx) GetPrices(securityid int64) (*[]*models.Price, error) { 88 | var prices []*Price 89 | var modelprices []*models.Price 90 | 91 | _, err := tx.Select(&prices, "SELECT * from prices where SecurityId=?", securityid) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | for _, p := range prices { 97 | modelprices = append(modelprices, p.Price()) 98 | } 99 | 100 | return &modelprices, nil 101 | } 102 | 103 | // Return the latest price for security in currency units before date 104 | func (tx *Tx) GetLatestPrice(security, currency *models.Security, date *time.Time) (*models.Price, error) { 105 | var price Price 106 | err := tx.SelectOne(&price, "SELECT * from prices where SecurityId=? AND CurrencyId=? AND Date <= ? ORDER BY Date DESC LIMIT 1", security.SecurityId, currency.SecurityId, date) 107 | if err != nil { 108 | return nil, err 109 | } 110 | return price.Price(), nil 111 | } 112 | 113 | // Return the earliest price for security in currency units after date 114 | func (tx *Tx) GetEarliestPrice(security, currency *models.Security, date *time.Time) (*models.Price, error) { 115 | var price Price 116 | err := tx.SelectOne(&price, "SELECT * from prices where SecurityId=? AND CurrencyId=? AND Date >= ? ORDER BY Date ASC LIMIT 1", security.SecurityId, currency.SecurityId, date) 117 | if err != nil { 118 | return nil, err 119 | } 120 | return price.Price(), nil 121 | } 122 | 123 | func (tx *Tx) UpdatePrice(price *models.Price) error { 124 | p, err := NewPrice(price) 125 | if err != nil { 126 | return err 127 | } 128 | 129 | count, err := tx.Update(p) 130 | if err != nil { 131 | return err 132 | } 133 | if count != 1 { 134 | return fmt.Errorf("Expected to update 1 price, was going to update %d", count) 135 | } 136 | *price = *p.Price() 137 | return nil 138 | } 139 | 140 | func (tx *Tx) DeletePrice(price *models.Price) error { 141 | p, err := NewPrice(price) 142 | if err != nil { 143 | return err 144 | } 145 | 146 | count, err := tx.Delete(p) 147 | if err != nil { 148 | return err 149 | } 150 | if count != 1 { 151 | return fmt.Errorf("Expected to delete 1 price, was going to delete %d", count) 152 | } 153 | *price = *p.Price() 154 | return nil 155 | } 156 | -------------------------------------------------------------------------------- /internal/models/amounts.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "math" 7 | "math/big" 8 | "strings" 9 | ) 10 | 11 | type Amount struct { 12 | big.Rat 13 | } 14 | 15 | type PrecisionError struct { 16 | message string 17 | } 18 | 19 | func (p PrecisionError) Error() string { 20 | return p.message 21 | } 22 | 23 | // Whole returns the integral portion of the Amount 24 | func (amount Amount) Whole() (int64, error) { 25 | var whole big.Int 26 | whole.Quo(amount.Num(), amount.Denom()) 27 | if whole.IsInt64() { 28 | return whole.Int64(), nil 29 | } 30 | return 0, PrecisionError{"integral portion of Amount cannot be represented as an int64"} 31 | } 32 | 33 | // Fractional returns the fractional portion of the Amount, multiplied by 34 | // 10^precision 35 | func (amount Amount) Fractional(precision uint64) (int64, error) { 36 | if precision < amount.Precision() { 37 | return 0, PrecisionError{"Fractional portion of Amount cannot be represented with the given precision"} 38 | } 39 | 40 | // Reduce the fraction to its simplest form 41 | var r, gcd, d, n big.Int 42 | r.Rem(amount.Num(), amount.Denom()) 43 | gcd.GCD(nil, nil, &r, amount.Denom()) 44 | if gcd.Sign() != 0 { 45 | n.Quo(&r, &gcd) 46 | d.Quo(amount.Denom(), &gcd) 47 | } else { 48 | n.Set(&r) 49 | d.Set(amount.Denom()) 50 | } 51 | 52 | // Figure out what we need to multiply the numerator by to get the 53 | // denominator to be 10^precision 54 | var prec, multiplier big.Int 55 | prec.SetUint64(precision) 56 | multiplier.SetInt64(10) 57 | multiplier.Exp(&multiplier, &prec, nil) 58 | multiplier.Quo(&multiplier, &d) 59 | 60 | n.Mul(&n, &multiplier) 61 | if n.IsInt64() { 62 | return n.Int64(), nil 63 | } 64 | return 0, fmt.Errorf("Fractional portion of Amount does not fit in int64 with given precision") 65 | } 66 | 67 | // FromParts re-assembles an Amount from the results from previous calls to 68 | // Whole and Fractional 69 | func (amount *Amount) FromParts(whole, fractional int64, precision uint64) { 70 | var fracnum, fracdenom, power big.Int 71 | fracnum.SetInt64(fractional) 72 | fracdenom.SetInt64(10) 73 | power.SetUint64(precision) 74 | fracdenom.Exp(&fracdenom, &power, nil) 75 | 76 | var fracrat big.Rat 77 | fracrat.SetFrac(&fracnum, &fracdenom) 78 | amount.Rat.SetInt64(whole) 79 | amount.Rat.Add(&amount.Rat, &fracrat) 80 | } 81 | 82 | // Round rounds the given Amount to the given precision 83 | func (amount *Amount) Round(precision uint64) { 84 | // This probably isn't exactly the most efficient way to do this... 85 | amount.SetString(amount.FloatString(int(precision))) 86 | } 87 | 88 | func (amount Amount) String() string { 89 | return amount.FloatString(int(amount.Precision())) 90 | } 91 | 92 | func (amount *Amount) UnmarshalJSON(bytes []byte) error { 93 | var value string 94 | if err := json.Unmarshal(bytes, &value); err != nil { 95 | return err 96 | } 97 | value = strings.TrimSpace(value) 98 | 99 | if _, ok := amount.SetString(value); !ok { 100 | return fmt.Errorf("Failed to parse '%s' into Amount", value) 101 | } 102 | return nil 103 | } 104 | 105 | func (amount Amount) MarshalJSON() ([]byte, error) { 106 | return json.Marshal(amount.String()) 107 | } 108 | 109 | // Precision returns the minimum positive integer p such that if you multiplied 110 | // this Amount by 10^p, it would become an integer 111 | func (amount Amount) Precision() uint64 { 112 | if amount.IsInt() || amount.Sign() == 0 { 113 | return 0 114 | } 115 | 116 | // Find d, the denominator of the reduced fractional portion of 'amount' 117 | var r, gcd, d big.Int 118 | r.Rem(amount.Num(), amount.Denom()) 119 | gcd.GCD(nil, nil, &r, amount.Denom()) 120 | if gcd.Sign() != 0 { 121 | d.Quo(amount.Denom(), &gcd) 122 | } else { 123 | d.Set(amount.Denom()) 124 | } 125 | d.Abs(&d) 126 | 127 | var power, result big.Int 128 | one := big.NewInt(1) 129 | ten := big.NewInt(10) 130 | 131 | // Estimate an initial power 132 | if d.IsUint64() { 133 | power.SetInt64(int64(math.Log10(float64(d.Uint64())))) 134 | } else { 135 | 136 | // If the simplified denominator wasn't a uint64, its > 10^19 137 | power.SetInt64(19) 138 | } 139 | 140 | // If the initial estimate was too high, bring it down 141 | result.Exp(ten, &power, nil) 142 | for result.Cmp(&d) > 0 { 143 | power.Sub(&power, one) 144 | result.Exp(ten, &power, nil) 145 | } 146 | // If it was too low, bring it up 147 | for result.Cmp(&d) < 0 { 148 | power.Add(&power, one) 149 | result.Exp(ten, &power, nil) 150 | } 151 | 152 | if !power.IsUint64() { 153 | panic("Unable to represent Amount's precision as a uint64") 154 | } 155 | return power.Uint64() 156 | } 157 | -------------------------------------------------------------------------------- /js/actions/ImportActions.js: -------------------------------------------------------------------------------- 1 | var ImportConstants = require('../constants/ImportConstants'); 2 | 3 | var models = require('../models.js'); 4 | var OFXDownload = models.OFXDownload; 5 | var Error = models.Error; 6 | 7 | function beginImport() { 8 | return { 9 | type: ImportConstants.BEGIN_IMPORT 10 | } 11 | } 12 | 13 | function updateProgress(progress) { 14 | return { 15 | type: ImportConstants.UPDATE_IMPORT_PROGRESS, 16 | progress: progress 17 | } 18 | } 19 | 20 | function importFinished() { 21 | return { 22 | type: ImportConstants.IMPORT_FINISHED 23 | } 24 | } 25 | 26 | function importFailed(error) { 27 | return { 28 | type: ImportConstants.IMPORT_FAILED, 29 | error: error 30 | } 31 | } 32 | 33 | function openModal() { 34 | return function(dispatch) { 35 | dispatch({ 36 | type: ImportConstants.OPEN_IMPORT_MODAL 37 | }); 38 | }; 39 | } 40 | 41 | function closeModal() { 42 | return function(dispatch) { 43 | dispatch({ 44 | type: ImportConstants.CLOSE_IMPORT_MODAL 45 | }); 46 | }; 47 | } 48 | 49 | function importOFX(account, password, startDate, endDate) { 50 | return function(dispatch) { 51 | dispatch(beginImport()); 52 | dispatch(updateProgress(50)); 53 | 54 | var ofxdownload = new OFXDownload(); 55 | ofxdownload.OFXPassword = password; 56 | ofxdownload.StartDate = startDate; 57 | ofxdownload.EndDate = endDate; 58 | 59 | $.ajax({ 60 | type: "POST", 61 | contentType: "application/json", 62 | dataType: "json", 63 | url: "v1/accounts/"+account.AccountId+"/imports/ofx", 64 | data: ofxdownload.toJSON(), 65 | success: function(data, status, jqXHR) { 66 | var e = new Error(); 67 | e.fromJSON(data); 68 | if (e.isError()) { 69 | var errString = e.ErrorString; 70 | if (e.ErrorId == 3 /* Invalid Request */) { 71 | errString = "Please check that your password and all other OFX login credentials are correct."; 72 | } 73 | dispatch(importFailed(errString)); 74 | } else { 75 | dispatch(importFinished()); 76 | } 77 | }, 78 | error: function(jqXHR, status, error) { 79 | dispatch(importFailed(error)); 80 | } 81 | }); 82 | }; 83 | } 84 | 85 | function importFile(url, inputElement) { 86 | return function(dispatch) { 87 | dispatch(beginImport()); 88 | 89 | if (inputElement.files.length == 0) { 90 | dispatch(importFailed("No files specified to be imported")) 91 | return; 92 | } 93 | if (inputElement.files.length > 1) { 94 | dispatch(importFailed("More than one file specified for import, only one allowed at a time")) 95 | return; 96 | } 97 | 98 | var file = inputElement.files[0]; 99 | var formData = new FormData(); 100 | formData.append('importfile', file, file.name); 101 | 102 | var handleSetProgress = function(e) { 103 | if (e.lengthComputable) { 104 | var pct = Math.round(e.loaded/e.total*100); 105 | dispatch(updateProgress(pct)); 106 | } else { 107 | dispatch(updateProgress(50)); 108 | } 109 | } 110 | 111 | $.ajax({ 112 | type: "POST", 113 | url: url, 114 | data: formData, 115 | xhr: function() { 116 | var xhrObject = $.ajaxSettings.xhr(); 117 | if (xhrObject.upload) { 118 | xhrObject.upload.addEventListener('progress', handleSetProgress, false); 119 | } else { 120 | dispatch(importFailed("File upload failed because xhr.upload isn't supported by your browser.")); 121 | } 122 | return xhrObject; 123 | }, 124 | success: function(data, status, jqXHR) { 125 | var e = new Error(); 126 | e.fromJSON(data); 127 | if (e.isError()) { 128 | var errString = e.ErrorString; 129 | if (e.ErrorId == 3 /* Invalid Request */) { 130 | errString = "Please check that the file you uploaded is valid and try again."; 131 | } 132 | dispatch(importFailed(errString)); 133 | } else { 134 | dispatch(importFinished()); 135 | } 136 | }, 137 | error: function(jqXHR, status, error) { 138 | dispatch(importFailed(error)); 139 | }, 140 | // So jQuery doesn't try to process the data or content-type 141 | cache: false, 142 | contentType: false, 143 | processData: false 144 | }); 145 | }; 146 | } 147 | 148 | function importOFXFile(inputElement, account) { 149 | var url = "v1/accounts/"+account.AccountId+"/imports/ofxfile"; 150 | return importFile(url, inputElement); 151 | } 152 | 153 | function importGnucash(inputElement) { 154 | var url = "v1/imports/gnucash"; 155 | return importFile(url, inputElement); 156 | } 157 | 158 | module.exports = { 159 | openModal: openModal, 160 | closeModal: closeModal, 161 | importOFX: importOFX, 162 | importOFXFile: importOFXFile, 163 | importGnucash: importGnucash 164 | }; 165 | -------------------------------------------------------------------------------- /internal/models/amounts_test.go: -------------------------------------------------------------------------------- 1 | package models_test 2 | 3 | import ( 4 | "github.com/aclindsa/moneygo/internal/models" 5 | "testing" 6 | ) 7 | 8 | func expectedPrecision(t *testing.T, amount *models.Amount, precision uint64) { 9 | t.Helper() 10 | if amount.Precision() != precision { 11 | t.Errorf("Expected precision %d for %s, found %d", precision, amount.String(), amount.Precision()) 12 | } 13 | } 14 | 15 | func TestAmountPrecision(t *testing.T) { 16 | var a models.Amount 17 | a.SetString("1.1928712") 18 | expectedPrecision(t, &a, 7) 19 | a.SetString("0") 20 | expectedPrecision(t, &a, 0) 21 | a.SetString("-0.7") 22 | expectedPrecision(t, &a, 1) 23 | a.SetString("-1.1837281037509137509173049173052130957210361309572047598275398265926351231426357130289523647634895285603247284245928712") 24 | expectedPrecision(t, &a, 118) 25 | a.SetInt64(1050) 26 | expectedPrecision(t, &a, 0) 27 | } 28 | 29 | func TestAmountRound(t *testing.T) { 30 | var a models.Amount 31 | tests := []struct { 32 | String string 33 | RoundTo uint64 34 | Expected string 35 | }{ 36 | {"0", 5, "0"}, 37 | {"929.92928", 2, "929.93"}, 38 | {"-105.499999", 4, "-105.5"}, 39 | {"0.5111111", 1, "0.5"}, 40 | {"0.5111111", 0, "1"}, 41 | {"9.876456", 3, "9.876"}, 42 | } 43 | 44 | for _, test := range tests { 45 | a.SetString(test.String) 46 | a.Round(test.RoundTo) 47 | if a.String() != test.Expected { 48 | t.Errorf("Expected '%s' after Round(%d) to be %s intead of %s\n", test.String, test.RoundTo, test.Expected, a.String()) 49 | } 50 | } 51 | } 52 | 53 | func TestAmountString(t *testing.T) { 54 | var a models.Amount 55 | for _, s := range []string{ 56 | "1.1928712", 57 | "0", 58 | "-0.7", 59 | "-1.1837281037509137509173049173052130957210361309572047598275398265926351231426357130289523647634895285603247284245928712", 60 | "1050", 61 | } { 62 | a.SetString(s) 63 | if s != a.String() { 64 | t.Errorf("Expected '%s', found '%s'", s, a.String()) 65 | } 66 | } 67 | 68 | a.SetString("+182.27") 69 | if "182.27" != a.String() { 70 | t.Errorf("Expected '182.27', found '%s'", a.String()) 71 | } 72 | a.SetString("-0") 73 | if "0" != a.String() { 74 | t.Errorf("Expected '0', found '%s'", a.String()) 75 | } 76 | } 77 | 78 | func TestWhole(t *testing.T) { 79 | var a models.Amount 80 | tests := []struct { 81 | String string 82 | Whole int64 83 | }{ 84 | {"0", 0}, 85 | {"-0", 0}, 86 | {"181.1293871230", 181}, 87 | {"-0.1821", 0}, 88 | {"99992737.9", 99992737}, 89 | {"-7380.000009", -7380}, 90 | {"4108740192740912741", 4108740192740912741}, 91 | } 92 | 93 | for _, test := range tests { 94 | a.SetString(test.String) 95 | val, err := a.Whole() 96 | if err != nil { 97 | t.Errorf("Unexpected error: %s\n", err) 98 | } else if val != test.Whole { 99 | t.Errorf("Expected '%s'.Whole() to return %d intead of %d\n", test.String, test.Whole, val) 100 | } 101 | } 102 | 103 | a.SetString("81367662642302823790328492349823472634926342") 104 | _, err := a.Whole() 105 | if err == nil { 106 | t.Errorf("Expected error for overflowing int64") 107 | } 108 | } 109 | 110 | func TestFractional(t *testing.T) { 111 | var a models.Amount 112 | tests := []struct { 113 | String string 114 | Precision uint64 115 | Fractional int64 116 | }{ 117 | {"0", 5, 0}, 118 | {"181.1293871230", 9, 129387123}, 119 | {"181.1293871230", 10, 1293871230}, 120 | {"181.1293871230", 15, 129387123000000}, 121 | {"1828.37", 7, 3700000}, 122 | {"-0.748", 5, -74800}, 123 | {"-9", 5, 0}, 124 | {"-9.9", 1, -9}, 125 | } 126 | 127 | for _, test := range tests { 128 | a.SetString(test.String) 129 | val, err := a.Fractional(test.Precision) 130 | if err != nil { 131 | t.Errorf("Unexpected error: %s\n", err) 132 | } else if val != test.Fractional { 133 | t.Errorf("Expected '%s'.Fractional(%d) to return %d intead of %d\n", test.String, test.Precision, test.Fractional, val) 134 | } 135 | } 136 | } 137 | 138 | func TestFromParts(t *testing.T) { 139 | var a models.Amount 140 | tests := []struct { 141 | Whole int64 142 | Fractional int64 143 | Precision uint64 144 | Result string 145 | }{ 146 | {839, 9080, 4, "839.908"}, 147 | {-10, 0, 5, "-10"}, 148 | {0, 900, 10, "0.00000009"}, 149 | {9128713621, 87272727, 20, "9128713621.00000000000087272727"}, 150 | {89, 1, 0, "90"}, // Not sure if this should really be supported, but it is 151 | } 152 | 153 | for _, test := range tests { 154 | a.FromParts(test.Whole, test.Fractional, test.Precision) 155 | if a.String() != test.Result { 156 | t.Errorf("Expected Amount.FromParts(%d, %d, %d) to return %s intead of %s\n", test.Whole, test.Fractional, test.Precision, test.Result, a.String()) 157 | } 158 | } 159 | } 160 | --------------------------------------------------------------------------------