├── 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 | 
24 | 
25 | 
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 |
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 |
96 |
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 |
--------------------------------------------------------------------------------