1
0
mirror of https://github.com/aclindsa/moneygo.git synced 2024-12-26 23:42:29 -05:00

Add Initial Gnucash importing

There are still a number of bugs, but the basic functionality is there
This commit is contained in:
Aaron Lindsay 2016-02-15 11:28:44 -05:00
parent fcf6b2f1a4
commit 9e26b30bdc
9 changed files with 571 additions and 162 deletions

View File

@ -48,7 +48,7 @@ var accountImportRE *regexp.Regexp
func init() { func init() {
accountTransactionsRE = regexp.MustCompile(`^/account/[0-9]+/transactions/?$`) accountTransactionsRE = regexp.MustCompile(`^/account/[0-9]+/transactions/?$`)
accountImportRE = regexp.MustCompile(`^/account/[0-9]+/import/?$`) accountImportRE = regexp.MustCompile(`^/account/[0-9]+/import/[a-z]+/?$`)
} }
func (a *Account) Write(w http.ResponseWriter) error { func (a *Account) Write(w http.ResponseWriter) error {
@ -97,138 +97,98 @@ func GetAccounts(userid int64) (*[]Account, error) {
return &accounts, nil return &accounts, nil
} }
// Get (and attempt to create if it doesn't exist) the security/currency // Get (and attempt to create if it doesn't exist). Matches on UserId,
// trading account for the supplied security/currency // SecurityId, Type, Name, and ParentAccountId
func GetTradingAccount(userid int64, securityid int64) (*Account, error) { func GetCreateAccountTx(transaction *gorp.Transaction, a Account) (*Account, error) {
var tradingAccounts []Account //top-level 'Trading' account(s) var accounts []Account
var tradingAccount Account
var accounts []Account //second-level security-specific trading account(s)
var account Account var account Account
transaction, err := DB.Begin()
if err != nil {
return nil, err
}
// Try to find the top-level trading account // Try to find the top-level trading account
_, err = transaction.Select(&tradingAccounts, "SELECT * from accounts where UserId=? AND Name='Trading' AND ParentAccountId=-1 AND Type=? ORDER BY AccountId ASC LIMIT 1", userid, Trading) _, err := transaction.Select(&accounts, "SELECT * from accounts where UserId=? AND SecurityId=? AND Type=? AND Name=? AND ParentAccountId=? ORDER BY AccountId ASC LIMIT 1", a.UserId, a.SecurityId, a.Type, a.Name, a.ParentAccountId)
if err != nil { if err != nil {
transaction.Rollback()
return nil, err
}
if len(tradingAccounts) == 1 {
tradingAccount = tradingAccounts[0]
} else {
tradingAccount.UserId = userid
tradingAccount.Name = "Trading"
tradingAccount.ParentAccountId = -1
tradingAccount.SecurityId = 840 /*USD*/ //FIXME SecurityId shouldn't matter for top-level trading account, but maybe we should grab the user's default
tradingAccount.Type = Trading
err = transaction.Insert(&tradingAccount)
if err != nil {
transaction.Rollback()
return nil, err
}
}
// Now, try to find the security-specific trading account
_, err = transaction.Select(&accounts, "SELECT * from accounts where UserId=? AND SecurityId=? AND ParentAccountId=? ORDER BY AccountId ASC LIMIT 1", userid, securityid, tradingAccount.AccountId)
if err != nil {
transaction.Rollback()
return nil, err return nil, err
} }
if len(accounts) == 1 { if len(accounts) == 1 {
account = accounts[0] account = accounts[0]
} else { } else {
security := GetSecurity(securityid) account.UserId = a.UserId
account.UserId = userid account.SecurityId = a.SecurityId
account.Name = security.Name account.Type = a.Type
account.ParentAccountId = tradingAccount.AccountId account.Name = a.Name
account.SecurityId = securityid account.ParentAccountId = a.ParentAccountId
account.Type = Trading
err = transaction.Insert(&account) err = transaction.Insert(&account)
if err != nil { if err != nil {
transaction.Rollback()
return nil, err return nil, err
} }
} }
err = transaction.Commit()
if err != nil {
transaction.Rollback()
return nil, err
}
return &account, nil return &account, nil
} }
// Get (and attempt to create if it doesn't exist) the security/currency // Get (and attempt to create if it doesn't exist) the security/currency
// imbalance account for the supplied security/currency // trading account for the supplied security/currency
func GetImbalanceAccount(userid int64, securityid int64) (*Account, error) { func GetTradingAccount(transaction *gorp.Transaction, userid int64, securityid int64) (*Account, error) {
var imbalanceAccounts []Account //top-level imbalance account(s) var tradingAccount Account
var imbalanceAccount Account
var accounts []Account //second-level security-specific imbalance account(s)
var account Account var account Account
transaction, err := DB.Begin() tradingAccount.UserId = userid
tradingAccount.Type = Trading
tradingAccount.Name = "Trading"
tradingAccount.SecurityId = 840 /*USD*/ //FIXME SecurityId shouldn't matter for top-level trading account, but maybe we should grab the user's default
tradingAccount.ParentAccountId = -1
// Find/create the top-level trading account
ta, err := GetCreateAccountTx(transaction, tradingAccount)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Try to find the top-level imbalance account security := GetSecurity(securityid)
_, err = transaction.Select(&imbalanceAccounts, "SELECT * from accounts where UserId=? AND Name='Imbalances' AND ParentAccountId=-1 AND Type=? ORDER BY AccountId ASC LIMIT 1", userid, Bank) account.UserId = userid
account.Name = security.Name
account.ParentAccountId = ta.AccountId
account.SecurityId = securityid
account.Type = Trading
a, err := GetCreateAccountTx(transaction, account)
if err != nil { if err != nil {
transaction.Rollback()
return nil, err
}
if len(imbalanceAccounts) == 1 {
imbalanceAccount = imbalanceAccounts[0]
} else {
imbalanceAccount.UserId = userid
imbalanceAccount.Name = "Imbalances"
imbalanceAccount.ParentAccountId = -1
imbalanceAccount.SecurityId = 840 /*USD*/ //FIXME SecurityId shouldn't matter for top-level imbalance account, but maybe we should grab the user's default
imbalanceAccount.Type = Bank
err = transaction.Insert(&imbalanceAccount)
if err != nil {
transaction.Rollback()
return nil, err
}
}
// Now, try to find the security-specific imbalances account
_, err = transaction.Select(&accounts, "SELECT * from accounts where UserId=? AND SecurityId=? AND ParentAccountId=? ORDER BY AccountId ASC LIMIT 1", userid, securityid, imbalanceAccount.AccountId)
if err != nil {
transaction.Rollback()
return nil, err
}
if len(accounts) == 1 {
account = accounts[0]
} else {
security := GetSecurity(securityid)
account.UserId = userid
account.Name = security.Name
account.ParentAccountId = imbalanceAccount.AccountId
account.SecurityId = securityid
account.Type = Bank
err = transaction.Insert(&account)
if err != nil {
transaction.Rollback()
return nil, err
}
}
err = transaction.Commit()
if err != nil {
transaction.Rollback()
return nil, err return nil, err
} }
return &account, nil return a, nil
}
// Get (and attempt to create if it doesn't exist) the security/currency
// imbalance account for the supplied security/currency
func GetImbalanceAccount(transaction *gorp.Transaction, userid int64, securityid int64) (*Account, error) {
var imbalanceAccount Account
var account Account
imbalanceAccount.UserId = userid
imbalanceAccount.Name = "Imbalances"
imbalanceAccount.ParentAccountId = -1
imbalanceAccount.SecurityId = 840 /*USD*/ //FIXME SecurityId shouldn't matter for top-level imbalance account, but maybe we should grab the user's default
imbalanceAccount.Type = Bank
// Find/create the top-level trading account
ia, err := GetCreateAccountTx(transaction, imbalanceAccount)
if err != nil {
return nil, err
}
security := GetSecurity(securityid)
account.UserId = userid
account.Name = security.Name
account.ParentAccountId = ia.AccountId
account.SecurityId = securityid
account.Type = Bank
a, err := GetCreateAccountTx(transaction, account)
if err != nil {
return nil, err
}
return a, nil
} }
type ParentAccountMissingError struct{} type ParentAccountMissingError struct{}
@ -358,14 +318,15 @@ func AccountHandler(w http.ResponseWriter, r *http.Request) {
// import handler // import handler
if accountImportRE.MatchString(r.URL.Path) { if accountImportRE.MatchString(r.URL.Path) {
var accountid int64 var accountid int64
n, err := GetURLPieces(r.URL.Path, "/account/%d", &accountid) var importtype string
n, err := GetURLPieces(r.URL.Path, "/account/%d/import/%s", &accountid, &importtype)
if err != nil || n != 1 { if err != nil || n != 2 {
WriteError(w, 999 /*Internal Error*/) WriteError(w, 999 /*Internal Error*/)
log.Print(err) log.Print(err)
return return
} }
AccountImportHandler(w, r, user, accountid) AccountImportHandler(w, r, user, accountid, importtype)
return return
} }

372
gnucash.go Normal file
View File

@ -0,0 +1,372 @@
package main
import (
"encoding/xml"
"fmt"
"io"
"log"
"math"
"math/big"
"net/http"
"time"
)
type GnucashXMLCommodity struct {
Name string `xml:"http://www.gnucash.org/XML/cmdty id"`
Description string `xml:"http://www.gnucash.org/XML/cmdty name"`
Type string `xml:"http://www.gnucash.org/XML/cmdty space"`
Fraction int `xml:"http://www.gnucash.org/XML/cmdty fraction"`
XCode string `xml:"http://www.gnucash.org/XML/cmdty xcode"`
}
type GnucashCommodity struct{ Security }
func (gc *GnucashCommodity) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var gxc GnucashXMLCommodity
if err := d.DecodeElement(&gxc, &start); err != nil {
return err
}
gc.Security.Type = Stock // assumed default
if gxc.Type == "ISO4217" {
gc.Security.Type = Currency
}
gc.Name = gxc.Name
gc.Symbol = gxc.Name
gc.Description = gxc.Description
gc.AlternateId = gxc.XCode
if gxc.Fraction > 0 {
gc.Precision = int(math.Ceil(math.Log10(float64(gxc.Fraction))))
} else {
gc.Precision = 0
}
return nil
}
type GnucashTime struct{ time.Time }
func (g *GnucashTime) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return fmt.Errorf("date should be a string")
}
t, err := time.Parse("2006-01-02 15:04:05 -0700", s)
g.Time = t
return err
}
type GnucashDate struct {
Date GnucashTime `xml:"http://www.gnucash.org/XML/ts date"`
}
type GnucashAccount struct {
Version string `xml:"version,attr"`
accountid int64 // Used to map Gnucash guid's to integer ones
AccountId string `xml:"http://www.gnucash.org/XML/act id"`
ParentAccountId string `xml:"http://www.gnucash.org/XML/act parent"`
Name string `xml:"http://www.gnucash.org/XML/act name"`
Description string `xml:"http://www.gnucash.org/XML/act description"`
Type string `xml:"http://www.gnucash.org/XML/act type"`
Commodity GnucashXMLCommodity `xml:"http://www.gnucash.org/XML/act commodity"`
}
type GnucashTransaction struct {
TransactionId string `xml:"http://www.gnucash.org/XML/trn id"`
Description string `xml:"http://www.gnucash.org/XML/trn description"`
DatePosted GnucashDate `xml:"http://www.gnucash.org/XML/trn date-posted"`
DateEntered GnucashDate `xml:"http://www.gnucash.org/XML/trn date-entered"`
Commodity GnucashXMLCommodity `xml:"http://www.gnucash.org/XML/trn currency"`
Splits []GnucashSplit `xml:"http://www.gnucash.org/XML/trn splits>split"`
}
type GnucashSplit struct {
SplitId string `xml:"http://www.gnucash.org/XML/split id"`
AccountId string `xml:"http://www.gnucash.org/XML/split account"`
Memo string `xml:"http://www.gnucash.org/XML/split memo"`
Amount string `xml:"http://www.gnucash.org/XML/split quantity"`
Value string `xml:"http://www.gnucash.org/XML/split value"`
}
type GnucashXMLImport struct {
XMLName xml.Name `xml:"gnc-v2"`
Commodities []GnucashCommodity `xml:"http://www.gnucash.org/XML/gnc book>commodity"`
Accounts []GnucashAccount `xml:"http://www.gnucash.org/XML/gnc book>account"`
Transactions []GnucashTransaction `xml:"http://www.gnucash.org/XML/gnc book>transaction"`
}
type GnucashImport struct {
Securities []Security
Accounts []Account
Transactions []Transaction
}
func ImportGnucash(r io.Reader) (*GnucashImport, error) {
var gncxml GnucashXMLImport
var gncimport GnucashImport
// Perform initial parsing of xml into structs
decoder := xml.NewDecoder(r)
err := decoder.Decode(&gncxml)
if err != nil {
return nil, err
}
// Fixup securities, making a map of them as we go
securityMap := make(map[string]Security)
for i := range gncxml.Commodities {
s := gncxml.Commodities[i].Security
s.SecurityId = int64(i + 1)
securityMap[s.Name] = s
// Ignore gnucash's "template" commodity
if s.Name != "template" ||
s.Description != "template" ||
s.AlternateId != "template" {
gncimport.Securities = append(gncimport.Securities, s)
}
}
//find root account, while simultaneously creating map of GUID's to
//accounts
var rootAccount GnucashAccount
accountMap := make(map[string]GnucashAccount)
for i := range gncxml.Accounts {
gncxml.Accounts[i].accountid = int64(i + 1)
if gncxml.Accounts[i].Type == "ROOT" {
rootAccount = gncxml.Accounts[i]
} else {
accountMap[gncxml.Accounts[i].AccountId] = gncxml.Accounts[i]
}
}
//Translate to our account format, figuring out parent relationships
for guid := range accountMap {
ga := accountMap[guid]
var a Account
a.AccountId = ga.accountid
if ga.ParentAccountId == rootAccount.AccountId {
a.ParentAccountId = -1
} else {
parent, ok := accountMap[ga.ParentAccountId]
if ok {
a.ParentAccountId = parent.accountid
} else {
a.ParentAccountId = -1 // Ugly, but assign to top-level if we can't find its parent
}
}
a.Name = ga.Name
security, ok := securityMap[ga.Commodity.Name]
if ok {
} else {
return nil, fmt.Errorf("Unable to find security: %s", ga.Commodity.Name)
}
a.SecurityId = security.SecurityId
//TODO find account types
switch ga.Type {
default:
a.Type = Bank
case "ASSET":
a.Type = Asset
case "BANK":
a.Type = Bank
case "CASH":
a.Type = Cash
case "CREDIT", "LIABILITY":
a.Type = Liability
case "EQUITY":
a.Type = Equity
case "EXPENSE":
a.Type = Expense
case "INCOME":
a.Type = Income
case "PAYABLE":
a.Type = Payable
case "RECEIVABLE":
a.Type = Receivable
case "MUTUAL", "STOCK":
a.Type = Investment
case "TRADING":
a.Type = Trading
}
gncimport.Accounts = append(gncimport.Accounts, a)
}
//Translate transactions to our format
for i := range gncxml.Transactions {
gt := gncxml.Transactions[i]
t := new(Transaction)
t.Description = gt.Description
t.Date = gt.DatePosted.Date.Time
t.Status = Imported
for j := range gt.Splits {
gs := gt.Splits[j]
s := new(Split)
s.Memo = gs.Memo
account, ok := accountMap[gs.AccountId]
if !ok {
return nil, fmt.Errorf("Unable to find account: %s", gs.AccountId)
}
s.AccountId = account.accountid
security, ok := securityMap[account.Commodity.Name]
if !ok {
return nil, fmt.Errorf("Unable to find security: %s", account.Commodity.Name)
}
s.SecurityId = -1
var r big.Rat
_, ok = r.SetString(gs.Amount)
if ok {
s.Amount = r.FloatString(security.Precision)
} else {
return nil, fmt.Errorf("Can't set split Amount: %s", gs.Amount)
}
t.Splits = append(t.Splits, s)
}
gncimport.Transactions = append(gncimport.Transactions, *t)
}
return &gncimport, nil
}
func GnucashImportHandler(w http.ResponseWriter, r *http.Request) {
user, err := GetUserFromSession(r)
if err != nil {
WriteError(w, 1 /*Not Signed In*/)
return
}
if r.Method != "POST" {
WriteError(w, 3 /*Invalid Request*/)
return
}
multipartReader, err := r.MultipartReader()
if err != nil {
WriteError(w, 3 /*Invalid Request*/)
return
}
// assume there is only one 'part'
part, err := multipartReader.NextPart()
if err != nil {
if err == io.EOF {
WriteError(w, 3 /*Invalid Request*/)
} else {
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
}
return
}
gnucashImport, err := ImportGnucash(part)
if err != nil {
WriteError(w, 3 /*Invalid Request*/)
return
}
sqltransaction, err := DB.Begin()
if err != nil {
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
return
}
// Import securities, building map from Gnucash security IDs to our
// internal IDs
securityMap := make(map[int64]int64)
for _, security := range gnucashImport.Securities {
//TODO FIXME check on AlternateID also, and convert to the case
//where users have their own internal securities
s, err := GetSecurityByNameAndType(security.Name, security.Type)
if err != nil {
//TODO attempt to create security if it doesn't exist
sqltransaction.Rollback()
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
return
}
securityMap[security.SecurityId] = s.SecurityId
}
// Get/create accounts in the database, building a map from Gnucash account
// IDs to our internal IDs as we go
accountMap := make(map[int64]int64)
accountsRemaining := len(gnucashImport.Accounts)
accountsRemainingLast := accountsRemaining
for accountsRemaining > 0 {
for _, account := range gnucashImport.Accounts {
// If the account has already been added to the map, skip it
_, ok := accountMap[account.AccountId]
if ok {
continue
}
// If it hasn't been added, but its parent has, add it to the map
_, ok = accountMap[account.ParentAccountId]
if ok || account.ParentAccountId == -1 {
account.UserId = user.UserId
if account.ParentAccountId != -1 {
account.ParentAccountId = accountMap[account.ParentAccountId]
}
account.SecurityId = securityMap[account.SecurityId]
a, err := GetCreateAccountTx(sqltransaction, account)
if err != nil {
sqltransaction.Rollback()
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
return
}
accountMap[account.AccountId] = a.AccountId
accountsRemaining--
}
}
if accountsRemaining == accountsRemainingLast {
//We didn't make any progress in importing the next level of accounts, so there must be a circular parent-child relationship, so give up and tell the user they're wrong
sqltransaction.Rollback()
WriteError(w, 999 /*Internal Error*/)
log.Print(fmt.Errorf("Circular account parent-child relationship when importing %s", part.FileName()))
return
}
accountsRemainingLast = accountsRemaining
}
// Insert transactions, fixing up account IDs to match internal ones from
// above
for _, transaction := range gnucashImport.Transactions {
for _, split := range transaction.Splits {
acctId, ok := accountMap[split.AccountId]
if !ok {
sqltransaction.Rollback()
WriteError(w, 999 /*Internal Error*/)
log.Print(fmt.Errorf("Error: Split's AccountID Doesn't exist: %d\n", split.AccountId))
return
}
split.AccountId = acctId
fmt.Printf("Setting split AccountId to %d\n", acctId)
}
err := InsertTransactionTx(sqltransaction, &transaction, user)
if err != nil {
sqltransaction.Rollback()
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
return
}
}
err = sqltransaction.Commit()
if err != nil {
sqltransaction.Rollback()
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
return
}
WriteSuccess(w)
}

View File

@ -12,7 +12,9 @@ import (
/* /*
* Assumes the User is a valid, signed-in user, but accountid has not yet been validated * Assumes the User is a valid, signed-in user, but accountid has not yet been validated
*/ */
func AccountImportHandler(w http.ResponseWriter, r *http.Request, user *User, accountid int64) { func AccountImportHandler(w http.ResponseWriter, r *http.Request, user *User, accountid int64, importtype string) {
//TODO branch off for different importtype's
// Return Account with this Id // Return Account with this Id
account, err := GetAccount(accountid, user.UserId) account, err := GetAccount(accountid, user.UserId)
if err != nil { if err != nil {
@ -58,23 +60,32 @@ func AccountImportHandler(w http.ResponseWriter, r *http.Request, user *User, ac
itl, err := ImportOFX(tmpFilename, account) itl, err := ImportOFX(tmpFilename, account)
if err != nil { if err != nil {
//TODO is this necessarily an invalid request? //TODO is this necessarily an invalid request (what if it was an error on our end)?
WriteError(w, 3 /*Invalid Request*/) WriteError(w, 3 /*Invalid Request*/)
return return
} }
sqltransaction, err := DB.Begin()
if err != nil {
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
return
}
var transactions []Transaction var transactions []Transaction
for _, transaction := range *itl.Transactions { for _, transaction := range *itl.Transactions {
transaction.UserId = user.UserId transaction.UserId = user.UserId
transaction.Status = Imported transaction.Status = Imported
if !transaction.Valid() { if !transaction.Valid() {
sqltransaction.Rollback()
WriteError(w, 3 /*Invalid Request*/) WriteError(w, 3 /*Invalid Request*/)
return return
} }
imbalances, err := transaction.GetImbalances() imbalances, err := transaction.GetImbalancesTx(sqltransaction)
if err != nil { if err != nil {
sqltransaction.Rollback()
WriteError(w, 999 /*Internal Error*/) WriteError(w, 999 /*Internal Error*/)
log.Print(err) log.Print(err)
return return
@ -95,11 +106,12 @@ func AccountImportHandler(w http.ResponseWriter, r *http.Request, user *User, ac
// If we're dealing with exactly two securities, assume any imbalances // If we're dealing with exactly two securities, assume any imbalances
// from imports are from trading currencies/securities // from imports are from trading currencies/securities
if num_imbalances == 2 { if num_imbalances == 2 {
imbalanced_account, err = GetTradingAccount(user.UserId, imbalanced_security) imbalanced_account, err = GetTradingAccount(sqltransaction, user.UserId, imbalanced_security)
} else { } else {
imbalanced_account, err = GetImbalanceAccount(user.UserId, imbalanced_security) imbalanced_account, err = GetImbalanceAccount(sqltransaction, user.UserId, imbalanced_security)
} }
if err != nil { if err != nil {
sqltransaction.Rollback()
WriteError(w, 999 /*Internal Error*/) WriteError(w, 999 /*Internal Error*/)
log.Print(err) log.Print(err)
return return
@ -121,8 +133,9 @@ func AccountImportHandler(w http.ResponseWriter, r *http.Request, user *User, ac
// accounts // accounts
for _, split := range transaction.Splits { for _, split := range transaction.Splits {
if split.SecurityId != -1 || split.AccountId == -1 { if split.SecurityId != -1 || split.AccountId == -1 {
imbalanced_account, err := GetImbalanceAccount(user.UserId, split.SecurityId) imbalanced_account, err := GetImbalanceAccount(sqltransaction, user.UserId, split.SecurityId)
if err != nil { if err != nil {
sqltransaction.Rollback()
WriteError(w, 999 /*Internal Error*/) WriteError(w, 999 /*Internal Error*/)
log.Print(err) log.Print(err)
return return
@ -133,23 +146,24 @@ func AccountImportHandler(w http.ResponseWriter, r *http.Request, user *User, ac
} }
} }
balanced, err := transaction.Balanced()
if !balanced || err != nil {
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
return
}
transactions = append(transactions, transaction) transactions = append(transactions, transaction)
} }
for _, transaction := range transactions { for _, transaction := range transactions {
err := InsertTransaction(&transaction, user) err := InsertTransactionTx(sqltransaction, &transaction, user)
if err != nil { if err != nil {
WriteError(w, 999 /*Internal Error*/) WriteError(w, 999 /*Internal Error*/)
log.Print(err) log.Print(err)
} }
} }
err = sqltransaction.Commit()
if err != nil {
sqltransaction.Rollback()
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
return
}
WriteSuccess(w) WriteSuccess(w)
} }

View File

@ -9,6 +9,7 @@ module.exports = React.createClass({
getDefaultProps: function() { getDefaultProps: function() {
return { return {
includeRoot: true, includeRoot: true,
disabled: false,
rootName: "New Top-level Account" rootName: "New Top-level Account"
}; };
}, },
@ -33,6 +34,7 @@ module.exports = React.createClass({
defaultValue={this.props.value} defaultValue={this.props.value}
onChange={this.handleAccountChange} onChange={this.handleAccountChange}
ref="account" ref="account"
disabled={this.props.disabled}
className={className} /> className={className} />
); );
} }

View File

@ -20,8 +20,10 @@ var ButtonToolbar = ReactBootstrap.ButtonToolbar;
var ProgressBar = ReactBootstrap.ProgressBar; var ProgressBar = ReactBootstrap.ProgressBar;
var Glyphicon = ReactBootstrap.Glyphicon; var Glyphicon = ReactBootstrap.Glyphicon;
var DateTimePicker = require('react-widgets').DateTimePicker; var ReactWidgets = require('react-widgets')
var Combobox = require('react-widgets').Combobox; var DateTimePicker = ReactWidgets.DateTimePicker;
var Combobox = ReactWidgets.Combobox;
var DropdownList = ReactWidgets.DropdownList;
var Big = require('big.js'); var Big = require('big.js');
@ -455,29 +457,39 @@ const AddEditTransactionModal = React.createClass({
} }
}); });
const ImportType = {
OFX: 1,
Gnucash: 2
};
var ImportTypeList = [];
for (var type in ImportType) {
if (ImportType.hasOwnProperty(type)) {
var name = ImportType[type] == ImportType.OFX ? "OFX/QFX" : type; //QFX is a special snowflake
ImportTypeList.push({'TypeId': ImportType[type], 'Name': name});
}
}
const ImportTransactionsModal = React.createClass({ const ImportTransactionsModal = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
importing: false, importing: false,
imported: false, imported: false,
importFile: "", importFile: "",
importType: ImportType.Gnucash,
uploadProgress: -1, uploadProgress: -1,
error: null}; error: null};
}, },
handleCancel: function() { handleCancel: function() {
this.setState({ this.setState(this.getInitialState());
importing: false,
imported: false,
importFile: "",
uploadProgress: -1,
error: null
});
if (this.props.onCancel != null) if (this.props.onCancel != null)
this.props.onCancel(); this.props.onCancel();
}, },
onImportChanged: function() { handleImportChange: function() {
this.setState({importFile: this.refs.importfile.getValue()}); this.setState({importFile: this.refs.importfile.getValue()});
}, },
handleTypeChange: function(type) {
this.setState({importType: type.TypeId});
},
handleSubmit: function() { handleSubmit: function() {
if (this.props.onSubmit != null) if (this.props.onSubmit != null)
this.props.onSubmit(this.props.account); this.props.onSubmit(this.props.account);
@ -493,11 +505,18 @@ const ImportTransactionsModal = React.createClass({
handleImportTransactions: function() { handleImportTransactions: function() {
var file = this.refs.importfile.getInputDOMNode().files[0]; var file = this.refs.importfile.getInputDOMNode().files[0];
var formData = new FormData(); var formData = new FormData();
this.setState({importing: true});
formData.append('importfile', file, this.state.importFile); formData.append('importfile', file, this.state.importFile);
var url = ""
if (this.state.importType == ImportType.OFX)
url = "account/"+this.props.account.AccountId+"/import/ofx";
else if (this.state.importType == ImportType.Gnucash)
url = "import/gnucash";
this.setState({importing: true});
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: "account/"+this.props.account.AccountId+"/import", url: url,
data: formData, data: formData,
xhr: function() { xhr: function() {
var xhrObject = $.ajaxSettings.xhr(); var xhrObject = $.ajaxSettings.xhr();
@ -514,7 +533,7 @@ const ImportTransactionsModal = React.createClass({
if (e.isError()) { if (e.isError()) {
var errString = e.ErrorString; var errString = e.ErrorString;
if (e.ErrorId == 3 /* Invalid Request */) { if (e.ErrorId == 3 /* Invalid Request */) {
errString = "Please check that the file you uploaded is a valid OFX file for this account and try again."; errString = "Please check that the file you uploaded is valid and try again.";
} }
this.setState({ this.setState({
importing: false, importing: false,
@ -540,9 +559,11 @@ const ImportTransactionsModal = React.createClass({
}); });
}, },
render: function() { render: function() {
var accountNameLabel = "" var accountNameLabel = "Performing global import:"
if (this.props.account != null ) if (this.props.account != null && this.state.importType != ImportType.Gnucash)
accountNameLabel = "Importing to '" + getAccountDisplayName(this.props.account, this.props.account_map) + "' account:"; accountNameLabel = "Importing to '" + getAccountDisplayName(this.props.account, this.props.account_map) + "' account:";
// Display the progress bar if an upload/import is in progress
var progressBar = []; var progressBar = [];
if (this.state.importing && this.state.uploadProgress == 100) { if (this.state.importing && this.state.uploadProgress == 100) {
progressBar = (<ProgressBar now={this.state.uploadProgress} active label="Importing transactions..." />); progressBar = (<ProgressBar now={this.state.uploadProgress} active label="Importing transactions..." />);
@ -550,6 +571,7 @@ const ImportTransactionsModal = React.createClass({
progressBar = (<ProgressBar now={this.state.uploadProgress} active label="Uploading... %(percent)s%" />); progressBar = (<ProgressBar now={this.state.uploadProgress} active label="Uploading... %(percent)s%" />);
} }
// Create panel, possibly displaying error or success messages
var panel = []; var panel = [];
if (this.state.error != null) { if (this.state.error != null) {
panel = (<Panel header="Error Importing Transactions" bsStyle="danger">{this.state.error}</Panel>); panel = (<Panel header="Error Importing Transactions" bsStyle="danger">{this.state.error}</Panel>);
@ -557,16 +579,22 @@ const ImportTransactionsModal = React.createClass({
panel = (<Panel header="Successfully Imported Transactions" bsStyle="success">Your import is now complete.</Panel>); panel = (<Panel header="Successfully Imported Transactions" bsStyle="success">Your import is now complete.</Panel>);
} }
var buttonsDisabled = (this.state.importing) ? true : false; // Display proper buttons, possibly disabling them if an import is in progress
var button1 = []; var button1 = [];
var button2 = []; var button2 = [];
if (!this.state.imported && this.state.error == null) { if (!this.state.imported && this.state.error == null) {
button1 = (<Button onClick={this.handleCancel} disabled={buttonsDisabled} bsStyle="warning">Cancel</Button>); button1 = (<Button onClick={this.handleCancel} disabled={this.state.importing} bsStyle="warning">Cancel</Button>);
button2 = (<Button onClick={this.handleImportTransactions} disabled={buttonsDisabled} bsStyle="success">Import</Button>); button2 = (<Button onClick={this.handleImportTransactions} disabled={this.state.importing || this.state.importFile == ""} bsStyle="success">Import</Button>);
} else { } else {
button1 = (<Button onClick={this.handleCancel} disabled={buttonsDisabled} bsStyle="success">OK</Button>); button1 = (<Button onClick={this.handleCancel} disabled={this.state.importing} bsStyle="success">OK</Button>);
} }
var inputDisabled = (this.state.importing || this.state.error != null || this.state.imported) ? true : false; var inputDisabled = (this.state.importing || this.state.error != null || this.state.imported) ? true : false;
// Disable OFX/QFX imports if no account is selected
var disabledTypes = false;
if (this.props.account == null)
disabledTypes = [ImportTypeList[ImportType.OFX - 1]];
return ( return (
<Modal show={this.props.show} onHide={this.handleCancel} bsSize="small"> <Modal show={this.props.show} onHide={this.handleCancel} bsSize="small">
<Modal.Header closeButton> <Modal.Header closeButton>
@ -576,13 +604,21 @@ const ImportTransactionsModal = React.createClass({
<form onSubmit={this.handleImportTransactions} <form onSubmit={this.handleImportTransactions}
encType="multipart/form-data" encType="multipart/form-data"
ref="importform"> ref="importform">
<DropdownList
data={ImportTypeList}
valueField='TypeId'
textField='Name'
onSelect={this.handleTypeChange}
defaultValue={this.state.importType}
disabled={disabledTypes}
ref="importtype" />
<Input type="file" <Input type="file"
ref="importfile" ref="importfile"
disabled={inputDisabled} disabled={inputDisabled}
value={this.state.importFile} value={this.state.importFile}
label={accountNameLabel} label={accountNameLabel}
help="Select an OFX/QFX file to upload." help="Select an OFX/QFX file to upload."
onChange={this.onImportChanged} /> onChange={this.handleImportChange} />
</form> </form>
{progressBar} {progressBar}
{panel} {panel}
@ -897,8 +933,7 @@ module.exports = React.createClass({
</Button> </Button>
<Button <Button
onClick={this.handleImportClicked} onClick={this.handleImportClicked}
bsStyle="primary" bsStyle="primary">
disabled={disabled}>
<Glyphicon glyph='import' /> Import <Glyphicon glyph='import' /> Import
</Button> </Button>
</ButtonGroup> </ButtonGroup>

View File

@ -25,11 +25,11 @@ import (
) )
type ImportObject struct { type ImportObject struct {
TransactionList ImportTransactionsList TransactionList OFXImport
Error error Error error
} }
type ImportTransactionsList struct { type OFXImport struct {
Account *Account Account *Account
Transactions *[]Transaction Transactions *[]Transaction
TotalTransactions int64 TotalTransactions int64
@ -249,7 +249,7 @@ func OFXTransactionCallback(transaction_data C.struct_OfxTransactionData, data u
return 0 return 0
} }
func ImportOFX(filename string, account *Account) (*ImportTransactionsList, error) { func ImportOFX(filename string, account *Account) (*OFXImport, error) {
var a Account var a Account
var t []Transaction var t []Transaction
var iobj ImportObject var iobj ImportObject

View File

@ -69,6 +69,7 @@ func main() {
servemux.HandleFunc("/security/", SecurityHandler) servemux.HandleFunc("/security/", SecurityHandler)
servemux.HandleFunc("/account/", AccountHandler) servemux.HandleFunc("/account/", AccountHandler)
servemux.HandleFunc("/transaction/", TransactionHandler) servemux.HandleFunc("/transaction/", TransactionHandler)
servemux.HandleFunc("/import/gnucash", GnucashImportHandler)
listener, err := net.Listen("tcp", ":"+strconv.Itoa(port)) listener, err := net.Listen("tcp", ":"+strconv.Itoa(port))
if err != nil { if err != nil {

View File

@ -2,7 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors" "fmt"
"log" "log"
"net/http" "net/http"
) )
@ -55703,7 +55703,16 @@ func GetSecurityByName(name string) (*Security, error) {
return value, nil return value, nil
} }
} }
return nil, errors.New("Invalid Security Name") return nil, fmt.Errorf("Invalid Security Name: \"%s\"", name)
}
func GetSecurityByNameAndType(name string, _type int64) (*Security, error) {
for _, value := range security_map {
if value.Name == name && value.Type == _type {
return value, nil
}
}
return nil, fmt.Errorf("Invalid Security Name (%s) or Type (%d)", name, _type)
} }
func GetSecurities() []*Security { func GetSecurities() []*Security {

View File

@ -113,7 +113,7 @@ func (t *Transaction) Valid() bool {
// Return a map of security ID's to big.Rat's containing the amount that // Return a map of security ID's to big.Rat's containing the amount that
// security is imbalanced by // security is imbalanced by
func (t *Transaction) GetImbalances() (map[int64]big.Rat, error) { func (t *Transaction) GetImbalancesTx(transaction *gorp.Transaction) (map[int64]big.Rat, error) {
sums := make(map[int64]big.Rat) sums := make(map[int64]big.Rat)
if !t.Valid() { if !t.Valid() {
@ -123,7 +123,13 @@ func (t *Transaction) GetImbalances() (map[int64]big.Rat, error) {
for i := range t.Splits { for i := range t.Splits {
securityid := t.Splits[i].SecurityId securityid := t.Splits[i].SecurityId
if t.Splits[i].AccountId != -1 { if t.Splits[i].AccountId != -1 {
account, err := GetAccount(t.Splits[i].AccountId, t.UserId) var err error
var account *Account
if transaction != nil {
account, err = GetAccountTx(transaction, t.Splits[i].AccountId, t.UserId)
} else {
account, err = GetAccount(t.Splits[i].AccountId, t.UserId)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -137,6 +143,10 @@ func (t *Transaction) GetImbalances() (map[int64]big.Rat, error) {
return sums, nil return sums, nil
} }
func (t *Transaction) GetImbalances() (map[int64]big.Rat, error) {
return t.GetImbalancesTx(nil)
}
// Returns true if all securities contained in this transaction are balanced, // Returns true if all securities contained in this transaction are balanced,
// false otherwise // false otherwise
func (t *Transaction) Balanced() (bool, error) { func (t *Transaction) Balanced() (bool, error) {
@ -235,23 +245,16 @@ func (ame AccountMissingError) Error() string {
return "Account missing" return "Account missing"
} }
func InsertTransaction(t *Transaction, user *User) error { func InsertTransactionTx(transaction *gorp.Transaction, t *Transaction, user *User) error {
transaction, err := DB.Begin()
if err != nil {
return err
}
// Map of any accounts with transaction splits being added // Map of any accounts with transaction splits being added
a_map := make(map[int64]bool) a_map := make(map[int64]bool)
for i := range t.Splits { for i := range t.Splits {
if t.Splits[i].AccountId != -1 { if t.Splits[i].AccountId != -1 {
existing, err := transaction.SelectInt("SELECT count(*) from accounts where AccountId=?", t.Splits[i].AccountId) existing, err := transaction.SelectInt("SELECT count(*) from accounts where AccountId=?", t.Splits[i].AccountId)
if err != nil { if err != nil {
transaction.Rollback()
return err return err
} }
if existing != 1 { if existing != 1 {
transaction.Rollback()
return AccountMissingError{} return AccountMissingError{}
} }
a_map[t.Splits[i].AccountId] = true a_map[t.Splits[i].AccountId] = true
@ -269,15 +272,14 @@ func InsertTransaction(t *Transaction, user *User) error {
if len(a_ids) < 1 { if len(a_ids) < 1 {
return AccountMissingError{} return AccountMissingError{}
} }
err = incrementAccountVersions(transaction, user, a_ids) err := incrementAccountVersions(transaction, user, a_ids)
if err != nil { if err != nil {
transaction.Rollback()
return err return err
} }
t.UserId = user.UserId
err = transaction.Insert(t) err = transaction.Insert(t)
if err != nil { if err != nil {
transaction.Rollback()
return err return err
} }
@ -286,11 +288,24 @@ func InsertTransaction(t *Transaction, user *User) error {
t.Splits[i].SplitId = -1 t.Splits[i].SplitId = -1
err = transaction.Insert(t.Splits[i]) err = transaction.Insert(t.Splits[i])
if err != nil { if err != nil {
transaction.Rollback()
return err return err
} }
} }
return nil
}
func InsertTransaction(t *Transaction, user *User) error {
transaction, err := DB.Begin()
if err != nil {
return err
}
err = InsertTransactionTx(transaction, t, user)
if err != nil {
transaction.Rollback()
return err
}
err = transaction.Commit() err = transaction.Commit()
if err != nil { if err != nil {
transaction.Rollback() transaction.Rollback()