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

Initial pass at OFX imports

Still needs some fixups:
 * UI is incomplete
 * Investment transactions are unbalanced initially
 * OFX imports don't detect if one of the description fields for a
   transaction is empty (to fall back on another)
 * I'm sure plenty of other issues I haven't discovered yet
This commit is contained in:
Aaron Lindsay 2016-02-02 21:46:27 -05:00
parent 2e9828cc23
commit 58c7c17727
9 changed files with 638 additions and 48 deletions

View File

@ -21,12 +21,13 @@ const (
) )
type Account struct { type Account struct {
AccountId int64 AccountId int64
UserId int64 ExternalAccountId string
SecurityId int64 UserId int64
ParentAccountId int64 // -1 if this account is at the root SecurityId int64
Type int64 ParentAccountId int64 // -1 if this account is at the root
Name string Type int64
Name string
// monotonically-increasing account transaction version number. Used for // monotonically-increasing account transaction version number. Used for
// allowing a client to ensure they have a consistent version when paging // allowing a client to ensure they have a consistent version when paging
@ -39,9 +40,11 @@ type AccountList struct {
} }
var accountTransactionsRE *regexp.Regexp var accountTransactionsRE *regexp.Regexp
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/?$`)
} }
func (a *Account) Write(w http.ResponseWriter) error { func (a *Account) Write(w http.ResponseWriter) error {
@ -213,6 +216,21 @@ func AccountHandler(w http.ResponseWriter, r *http.Request) {
} }
if r.Method == "POST" { if r.Method == "POST" {
// if URL looks like /account/[0-9]+/import, use the account
// import handler
if accountImportRE.MatchString(r.URL.Path) {
var accountid int64
n, err := GetURLPieces(r.URL.Path, "/account/%d", &accountid)
if err != nil || n != 1 {
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
return
}
AccountImportHandler(w, r, user, accountid)
return
}
account_json := r.PostFormValue("account") account_json := r.PostFormValue("account")
if account_json == "" { if account_json == "" {
WriteError(w, 3 /*Invalid Request*/) WriteError(w, 3 /*Invalid Request*/)

91
imports.go Normal file
View File

@ -0,0 +1,91 @@
package main
import (
"io"
"io/ioutil"
"log"
"net/http"
"os"
)
/*
* 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) {
// Return Account with this Id
account, err := GetAccount(accountid, user.UserId)
if err != nil {
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
}
f, err := ioutil.TempFile(tmpDir, user.Username+"_"+account.Name)
if err != nil {
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
return
}
tmpFilename := f.Name()
defer os.Remove(tmpFilename)
_, err = io.Copy(f, part)
f.Close()
if err != nil {
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
return
}
itl, err := ImportOFX(tmpFilename, account)
if err != nil {
//TODO is this necessarily an invalid request?
WriteError(w, 3 /*Invalid Request*/)
return
}
for _, transaction := range *itl.Transactions {
if !transaction.Valid() {
WriteError(w, 3 /*Invalid Request*/)
return
}
// TODO check if transactions are balanced too
// balanced, err := transaction.Balanced()
// if !balanced || err != nil {
// WriteError(w, 3 /*Invalid Request*/)
// return
// }
}
/////////////////////// TODO ////////////////////////
for _, transaction := range *itl.Transactions {
transaction.UserId = user.UserId
transaction.Status = Imported
err := InsertTransaction(&transaction, user)
if err != nil {
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
}
}
WriteSuccess(w)
}

14
libofx.c Normal file
View File

@ -0,0 +1,14 @@
#include <libofx/libofx.h>
#include "_cgo_export.h"
int ofx_statement_callback(const struct OfxStatementData statement_data, void *data) {
return OFXStatementCallback(statement_data, data);
}
int ofx_account_callback(const struct OfxAccountData account_data, void *data) {
return OFXAccountCallback(account_data, data);
}
int ofx_transaction_callback(const struct OfxTransactionData transaction_data, void *data) {
return OFXTransactionCallback(transaction_data, data);
}

283
libofx.go Normal file
View File

@ -0,0 +1,283 @@
package main
//#cgo LDFLAGS: -lofx
//
//#include <stdlib.h>
//
// //The next line disables the definition of static variables to allow for it to
// //be included here (see libofx commit bd24df15531e52a2858f70487443af8b9fa407f4)
//#define OFX_AQUAMANIAC_UGLY_HACK1
//#include <libofx/libofx.h>
//
// typedef int (*ofx_statement_cb_fn) (const struct OfxStatementData, void *);
// extern int ofx_statement_callback(const struct OfxStatementData, void *);
// typedef int (*ofx_account_cb_fn) (const struct OfxAccountData, void *);
// extern int ofx_account_callback(const struct OfxAccountData, void *);
// typedef int (*ofx_transaction_cb_fn) (const struct OfxTransactionData, void *);
// extern int ofx_transaction_callback(const struct OfxTransactionData, void *);
import "C"
import (
"errors"
"math/big"
"time"
"unsafe"
)
type ImportObject struct {
TransactionList ImportTransactionsList
Error error
}
type ImportTransactionsList struct {
Account *Account
Transactions *[]Transaction
TotalTransactions int64
BeginningBalance string
EndingBalance string
}
func init() {
// Turn off all libofx info/debug messages
C.ofx_PARSER_msg = 0
C.ofx_DEBUG_msg = 0
C.ofx_DEBUG1_msg = 0
C.ofx_DEBUG2_msg = 0
C.ofx_DEBUG3_msg = 0
C.ofx_DEBUG4_msg = 0
C.ofx_DEBUG5_msg = 0
C.ofx_STATUS_msg = 0
C.ofx_INFO_msg = 0
C.ofx_WARNING_msg = 0
C.ofx_ERROR_msg = 0
}
//export OFXStatementCallback
func OFXStatementCallback(statement_data C.struct_OfxStatementData, data unsafe.Pointer) C.int {
// import := (*ImportObject)(data)
return 0
}
//export OFXAccountCallback
func OFXAccountCallback(account_data C.struct_OfxAccountData, data unsafe.Pointer) C.int {
iobj := (*ImportObject)(data)
itl := iobj.TransactionList
if account_data.account_id_valid != 0 {
account_name := C.GoString(&account_data.account_name[0])
account_id := C.GoString(&account_data.account_id[0])
itl.Account.Name = account_name
itl.Account.ExternalAccountId = account_id
} else {
if iobj.Error == nil {
iobj.Error = errors.New("OFX account ID invalid")
}
return 1
}
if account_data.account_type_valid != 0 {
switch account_data.account_type {
case C.OFX_CHECKING, C.OFX_SAVINGS, C.OFX_MONEYMRKT, C.OFX_CMA:
itl.Account.Type = Bank
case C.OFX_CREDITLINE, C.OFX_CREDITCARD:
itl.Account.Type = Liability
case C.OFX_INVESTMENT:
itl.Account.Type = Investment
}
} else {
if iobj.Error == nil {
iobj.Error = errors.New("OFX account type invalid")
}
return 1
}
if account_data.currency_valid != 0 {
currency_name := C.GoString(&account_data.currency[0])
currency, err := GetSecurityByName(currency_name)
if err != nil {
if iobj.Error == nil {
iobj.Error = err
}
return 1
}
itl.Account.SecurityId = currency.SecurityId
} else {
if iobj.Error == nil {
iobj.Error = errors.New("OFX account currency invalid")
}
return 1
}
return 0
}
//export OFXTransactionCallback
func OFXTransactionCallback(transaction_data C.struct_OfxTransactionData, data unsafe.Pointer) C.int {
iobj := (*ImportObject)(data)
itl := iobj.TransactionList
transaction := new(Transaction)
if transaction_data.name_valid != 0 {
transaction.Description = C.GoString(&transaction_data.name[0])
}
// if transaction_data.reference_number_valid != 0 {
// fmt.Println("reference_number: ", C.GoString(&transaction_data.reference_number[0]))
// }
if transaction_data.date_posted_valid != 0 {
transaction.Date = time.Unix(int64(transaction_data.date_posted), 0)
} else if transaction_data.date_initiated_valid != 0 {
transaction.Date = time.Unix(int64(transaction_data.date_initiated), 0)
}
if transaction_data.fi_id_valid != 0 {
transaction.RemoteId = C.GoString(&transaction_data.fi_id[0])
}
if transaction_data.amount_valid != 0 {
split := new(Split)
r := new(big.Rat)
r.SetFloat64(float64(transaction_data.amount))
security := GetSecurity(itl.Account.SecurityId)
split.Amount = r.FloatString(security.Precision)
if transaction_data.memo_valid != 0 {
split.Memo = C.GoString(&transaction_data.memo[0])
}
if transaction_data.check_number_valid != 0 {
split.Number = C.GoString(&transaction_data.check_number[0])
}
split.SecurityId = -1
split.AccountId = itl.Account.AccountId
transaction.Splits = append(transaction.Splits, split)
} else {
if iobj.Error == nil {
iobj.Error = errors.New("OFX transaction amount invalid")
}
return 1
}
var security *Security
split := new(Split)
units := new(big.Rat)
if transaction_data.units_valid != 0 {
units.SetFloat64(float64(transaction_data.units))
if transaction_data.security_data_valid != 0 {
security_data := transaction_data.security_data_ptr
if security_data.ticker_valid != 0 {
s, err := GetSecurityByName(C.GoString(&security_data.ticker[0]))
if err != nil {
if iobj.Error == nil {
iobj.Error = errors.New("Failed to find OFX transaction security: " + C.GoString(&security_data.ticker[0]))
}
return 1
}
security = s
} else {
if iobj.Error == nil {
iobj.Error = errors.New("OFX security ticker invalid")
}
return 1
}
if security.Type == Stock && security_data.unique_id_valid != 0 && security_data.unique_id_type_valid != 0 && C.GoString(&security_data.unique_id_type[0]) == "CUSIP" {
// Validate the security CUSIP, if possible
if security.AlternateId != C.GoString(&security_data.unique_id[0]) {
if iobj.Error == nil {
iobj.Error = errors.New("OFX transaction security CUSIP failed to validate")
}
return 1
}
}
} else {
security = GetSecurity(itl.Account.SecurityId)
}
} else {
// Calculate units from other available fields if its not present
// units = - (amount + various fees) / unitprice
units.SetFloat64(float64(transaction_data.amount))
fees := new(big.Rat)
if transaction_data.fees_valid != 0 {
fees.SetFloat64(float64(-transaction_data.fees))
}
if transaction_data.commission_valid != 0 {
commission := new(big.Rat)
commission.SetFloat64(float64(-transaction_data.commission))
fees.Add(fees, commission)
}
units.Add(units, fees)
units.Neg(units)
if transaction_data.unitprice_valid != 0 && transaction_data.unitprice != 0 {
unitprice := new(big.Rat)
unitprice.SetFloat64(float64(transaction_data.unitprice))
units.Quo(units, unitprice)
}
// If 'units' wasn't present, assume we're using the account's security
security = GetSecurity(itl.Account.SecurityId)
}
split.Amount = units.FloatString(security.Precision)
split.SecurityId = security.SecurityId
split.AccountId = -1
transaction.Splits = append(transaction.Splits, split)
if transaction_data.fees_valid != 0 {
split := new(Split)
r := new(big.Rat)
r.SetFloat64(float64(-transaction_data.fees))
security := GetSecurity(itl.Account.SecurityId)
split.Amount = r.FloatString(security.Precision)
split.Memo = "fees"
split.SecurityId = itl.Account.SecurityId
split.AccountId = -1
transaction.Splits = append(transaction.Splits, split)
}
if transaction_data.commission_valid != 0 {
split := new(Split)
r := new(big.Rat)
r.SetFloat64(float64(-transaction_data.commission))
security := GetSecurity(itl.Account.SecurityId)
split.Amount = r.FloatString(security.Precision)
split.Memo = "commission"
split.SecurityId = itl.Account.SecurityId
split.AccountId = -1
transaction.Splits = append(transaction.Splits, split)
}
// if transaction_data.payee_id_valid != 0 {
// fmt.Println("payee_id: ", C.GoString(&transaction_data.payee_id[0]))
// }
transaction_list := append(*itl.Transactions, *transaction)
iobj.TransactionList.Transactions = &transaction_list
return 0
}
func ImportOFX(filename string, account *Account) (*ImportTransactionsList, error) {
var a Account
var t []Transaction
var iobj ImportObject
iobj.TransactionList.Account = &a
iobj.TransactionList.Transactions = &t
a.AccountId = account.AccountId
context := C.libofx_get_new_context()
defer C.libofx_free_context(context)
C.ofx_set_statement_cb(context, C.ofx_statement_cb_fn(C.ofx_statement_callback), unsafe.Pointer(&iobj))
C.ofx_set_account_cb(context, C.ofx_account_cb_fn(C.ofx_account_callback), unsafe.Pointer(&iobj))
C.ofx_set_transaction_cb(context, C.ofx_transaction_cb_fn(C.ofx_transaction_callback), unsafe.Pointer(&iobj))
filename_cstring := C.CString(filename)
defer C.free(unsafe.Pointer(filename_cstring))
C.libofx_proc_file(context, filename_cstring, C.OFX) // unconditionally returns 0.
iobj.TransactionList.TotalTransactions = int64(len(*iobj.TransactionList.Transactions))
if iobj.TransactionList.TotalTransactions == 0 {
return nil, errors.New("No OFX transactions found")
}
if iobj.Error != nil {
return nil, iobj.Error
} else {
return &iobj.TransactionList, nil
}
}

View File

@ -14,6 +14,7 @@ import (
var serveFcgi bool var serveFcgi bool
var baseDir string var baseDir string
var tmpDir string
var port int var port int
var smtpServer string var smtpServer string
var smtpPort int var smtpPort int
@ -23,6 +24,7 @@ var reminderEmail string
func init() { func init() {
flag.StringVar(&baseDir, "base", "./", "Base directory for server") flag.StringVar(&baseDir, "base", "./", "Base directory for server")
flag.StringVar(&tmpDir, "tmp", "/tmp", "Directory to create temporary files in")
flag.IntVar(&port, "port", 80, "Port to serve API/files on") flag.IntVar(&port, "port", 80, "Port to serve API/files on")
flag.StringVar(&smtpServer, "smtp.server", "smtp.example.com", "SMTP server to send reminder emails from.") flag.StringVar(&smtpServer, "smtp.server", "smtp.example.com", "SMTP server to send reminder emails from.")
flag.IntVar(&smtpPort, "smtp.port", 587, "SMTP server port to connect to") flag.IntVar(&smtpPort, "smtp.port", 587, "SMTP server port to connect to")

View File

@ -2,15 +2,14 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"log" "log"
"net/http" "net/http"
) )
const ( const (
Banknote int64 = 1 Currency int64 = 1
Bond = 2 Stock = 2
Stock = 3
MutualFund = 4
) )
type Security struct { type Security struct {
@ -22,6 +21,8 @@ type Security struct {
// security is precise to // security is precise to
Precision int Precision int
Type int64 Type int64
// AlternateId is CUSIP for Type=Stock
AlternateId string
} }
type SecurityList struct { type SecurityList struct {
@ -1303,6 +1304,15 @@ func GetSecurity(securityid int64) *Security {
return nil return nil
} }
func GetSecurityByName(name string) (*Security, error) {
for _, value := range security_map {
if value.Name == name {
return value, nil
}
}
return nil, errors.New("Invalid Security Name")
}
func GetSecurities() []*Security { func GetSecurities() []*Security {
return security_list return security_list
} }

View File

@ -13,6 +13,8 @@ var Col = ReactBootstrap.Col;
var Button = ReactBootstrap.Button; var Button = ReactBootstrap.Button;
var ButtonToolbar = ReactBootstrap.ButtonToolbar; var ButtonToolbar = ReactBootstrap.ButtonToolbar;
var ProgressBar = ReactBootstrap.ProgressBar;
var DateTimePicker = ReactWidgets.DateTimePicker; var DateTimePicker = ReactWidgets.DateTimePicker;
const TransactionRow = React.createClass({ const TransactionRow = React.createClass({
@ -45,7 +47,11 @@ const TransactionRow = React.createClass({
var otherSplit = this.props.transaction.Splits[0]; var otherSplit = this.props.transaction.Splits[0];
if (otherSplit.AccountId == this.props.account.AccountId) if (otherSplit.AccountId == this.props.account.AccountId)
var otherSplit = this.props.transaction.Splits[1]; var otherSplit = this.props.transaction.Splits[1];
var accountName = getAccountDisplayName(this.props.account_map[otherSplit.AccountId], this.props.account_map);
if (otherSplit.AccountId == -1)
var accountName = "Unbalanced " + this.props.security_map[otherSplit.SecurityId].Symbol + " transaction";
else
var accountName = getAccountDisplayName(this.props.account_map[otherSplit.AccountId], this.props.account_map);
} else { } else {
accountName = "--Split Transaction--"; accountName = "--Split Transaction--";
} }
@ -224,6 +230,7 @@ const AddEditTransactionModal = React.createClass({
handleUpdateAccount: function(account, split) { handleUpdateAccount: function(account, split) {
var transaction = this.state.transaction; var transaction = this.state.transaction;
transaction.Splits[split] = React.addons.update(transaction.Splits[split], { transaction.Splits[split] = React.addons.update(transaction.Splits[split], {
SecurityId: {$set: -1},
AccountId: {$set: account.AccountId} AccountId: {$set: account.AccountId}
}); });
this.setState({ this.setState({
@ -290,11 +297,14 @@ const AddEditTransactionModal = React.createClass({
var accountValidation = ""; var accountValidation = "";
if (s.AccountId in this.props.account_map) { if (s.AccountId in this.props.account_map) {
security = this.props.security_map[this.props.account_map[s.AccountId].SecurityId]; security = this.props.security_map[this.props.account_map[s.AccountId].SecurityId];
if (security.SecurityId in imbalancedSecurityMap)
amountValidation = "error";
} else { } else {
if (s.SecurityId in this.props.security_map) {
security = this.props.security_map[s.SecurityId];
}
accountValidation = "has-error"; accountValidation = "has-error";
} }
if (security != null && security.SecurityId in imbalancedSecurityMap)
amountValidation = "error";
// Define all closures for calling split-updating functions // Define all closures for calling split-updating functions
var deleteSplitFn = (function() { var deleteSplitFn = (function() {
@ -423,9 +433,108 @@ const AddEditTransactionModal = React.createClass({
} }
}); });
const ImportTransactionsModal = React.createClass({
getInitialState: function() {
return {
importFile: "",
uploadProgress: -1};
},
handleCancel: function() {
this.setState({
importFile: "",
uploadProgress: -1
});
if (this.props.onCancel != null)
this.props.onCancel();
},
onImportChanged: function() {
this.setState({importFile: this.refs.importfile.getValue()});
},
handleSubmit: function() {
if (this.props.onSubmit != null)
this.props.onSubmit(this.props.account);
},
handleSetProgress: function(e) {
if (e.lengthComputable) {
var pct = Math.round(e.loaded/e.total*100);
this.setState({uploadProgress: pct});
} else {
this.setState({uploadProgress: 50});
}
},
handleImportTransactions: function() {
var file = this.refs.importfile.getInputDOMNode().files[0];
var formData = new FormData();
formData.append('importfile', file, this.state.importFile);
$.ajax({
type: "POST",
url: "account/"+this.props.account.AccountId+"/import",
data: formData,
xhr: function() {
var xhrObject = $.ajaxSettings.xhr();
if (xhrObject.upload) {
xhrObject.upload.addEventListener('progress', this.handleSetProgress, false);
} else {
console.log("File upload failed because !xhr.upload")
}
return xhrObject;
}.bind(this),
beforeSend: function() {
console.log("before send");
},
success: function() {
this.setState({uploadProgress: 100});
console.log("success");
}.bind(this),
error: function(e) {
console.log("error handler", e);
},
// So jQuery doesn't try to process teh data or content-type
cache: false,
contentType: false,
processData: false
});
},
render: function() {
var accountNameLabel = ""
if (this.props.account != null )
accountNameLabel = "Import File to '" + getAccountDisplayName(this.props.account, this.props.account_map) + "':";
var progressBar = [];
if (this.state.uploadProgress != -1)
progressBar = (<ProgressBar now={this.state.uploadProgress} label="%(percent)s%" />);
return (
<Modal show={this.props.show} onHide={this.handleCancel} bsSize="medium">
<Modal.Header closeButton>
<Modal.Title>Import Transactions</Modal.Title>
</Modal.Header>
<Modal.Body>
<form onSubmit={this.handleImportTransactions}
encType="multipart/form-data"
ref="importform">
<Input type="file"
ref="importfile"
value={this.state.importFile}
label={accountNameLabel}
help="Select an OFX/QFX file to upload."
onChange={this.onImportChanged} />
</form>
{progressBar}
</Modal.Body>
<Modal.Footer>
<ButtonGroup>
<Button onClick={this.handleCancel} bsStyle="warning">Cancel</Button>
<Button onClick={this.handleImportTransactions} bsStyle="success">Import</Button>
</ButtonGroup>
</Modal.Footer>
</Modal>
);
}
});
const AccountRegister = React.createClass({ const AccountRegister = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
importingTransactions: false,
editingTransaction: false, editingTransaction: false,
selectedTransaction: new Transaction(), selectedTransaction: new Transaction(),
transactions: [], transactions: [],
@ -468,6 +577,16 @@ const AccountRegister = React.createClass({
selectedTransaction: newTransaction selectedTransaction: newTransaction
}); });
}, },
handleImportClicked: function() {
this.setState({
importingTransactions: true
});
},
handleImportingCancel: function() {
this.setState({
importingTransactions: false
});
},
ajaxError: function(jqXHR, status, error) { ajaxError: function(jqXHR, status, error) {
var e = new Error(); var e = new Error();
e.ErrorId = 5; e.ErrorId = 5;
@ -593,6 +712,9 @@ const AccountRegister = React.createClass({
error: this.ajaxError error: this.ajaxError
}); });
}, },
handleImportComplete: function() {
this.setState({importingTransactions: false});
},
handleDeleteTransaction: function(transaction) { handleDeleteTransaction: function(transaction) {
this.setState({ this.setState({
editingTransaction: false editingTransaction: false
@ -676,6 +798,13 @@ const AccountRegister = React.createClass({
onDelete={this.handleDeleteTransaction} onDelete={this.handleDeleteTransaction}
securities={this.props.securities} securities={this.props.securities}
security_map={this.props.security_map}/> security_map={this.props.security_map}/>
<ImportTransactionsModal
show={this.state.importingTransactions}
account={this.props.selectedAccount}
accounts={this.props.accounts}
account_map={this.props.account_map}
onCancel={this.handleImportingCancel}
onSubmit={this.handleImportComplete}/>
<div className="transactions-register-toolbar"> <div className="transactions-register-toolbar">
Transactions for '{name}' Transactions for '{name}'
<ButtonToolbar className="pull-right"> <ButtonToolbar className="pull-right">
@ -695,6 +824,12 @@ const AccountRegister = React.createClass({
disabled={disabled}> disabled={disabled}>
<Glyphicon glyph='plus-sign' /> New Transaction <Glyphicon glyph='plus-sign' /> New Transaction
</Button> </Button>
<Button
onClick={this.handleImportClicked}
bsStyle="primary"
disabled={disabled}>
<Glyphicon glyph='import' /> Import
</Button>
</ButtonGroup> </ButtonGroup>
</ButtonToolbar> </ButtonToolbar>
</div> </div>

View File

@ -77,10 +77,8 @@ Session.prototype.isSession = function() {
} }
const SecurityType = { const SecurityType = {
Banknote: 1, Currency: 1,
Bond: 2, Stock: 2
Stock: 3,
MutualFund: 4
} }
var SecurityTypeList = []; var SecurityTypeList = [];
for (var type in SecurityType) { for (var type in SecurityType) {
@ -197,6 +195,7 @@ function Split() {
this.SplitId = -1; this.SplitId = -1;
this.TransactionId = -1; this.TransactionId = -1;
this.AccountId = -1; this.AccountId = -1;
this.SecurityId = -1;
this.Number = ""; this.Number = "";
this.Memo = ""; this.Memo = "";
this.Amount = new Big(0.0); this.Amount = new Big(0.0);
@ -208,6 +207,7 @@ Split.prototype.toJSONobj = function() {
json_obj.SplitId = this.SplitId; json_obj.SplitId = this.SplitId;
json_obj.TransactionId = this.TransactionId; json_obj.TransactionId = this.TransactionId;
json_obj.AccountId = this.AccountId; json_obj.AccountId = this.AccountId;
json_obj.SecurityId = this.SecurityId;
json_obj.Number = this.Number; json_obj.Number = this.Number;
json_obj.Memo = this.Memo; json_obj.Memo = this.Memo;
json_obj.Amount = this.Amount.toFixed(); json_obj.Amount = this.Amount.toFixed();
@ -222,6 +222,8 @@ Split.prototype.fromJSONobj = function(json_obj) {
this.TransactionId = json_obj.TransactionId; this.TransactionId = json_obj.TransactionId;
if (json_obj.hasOwnProperty("AccountId")) if (json_obj.hasOwnProperty("AccountId"))
this.AccountId = json_obj.AccountId; this.AccountId = json_obj.AccountId;
if (json_obj.hasOwnProperty("SecurityId"))
this.SecurityId = json_obj.SecurityId;
if (json_obj.hasOwnProperty("Number")) if (json_obj.hasOwnProperty("Number"))
this.Number = json_obj.Number; this.Number = json_obj.Number;
if (json_obj.hasOwnProperty("Memo")) if (json_obj.hasOwnProperty("Memo"))
@ -236,14 +238,16 @@ Split.prototype.isSplit = function() {
var empty_split = new Split(); var empty_split = new Split();
return this.SplitId != empty_split.SplitId || return this.SplitId != empty_split.SplitId ||
this.TransactionId != empty_split.TransactionId || this.TransactionId != empty_split.TransactionId ||
this.AccountId != empty_split.AccountId; this.AccountId != empty_split.AccountId ||
this.SecurityId != empty_split.SecurityId;
} }
const TransactionStatus = { const TransactionStatus = {
Entered: 1, Imported: 1,
Cleared: 2, Entered: 2,
Reconciled: 3, Cleared: 3,
Voided: 4 Reconciled: 4,
Voided: 5
} }
var TransactionStatusList = []; var TransactionStatusList = [];
for (var type in TransactionStatus) { for (var type in TransactionStatus) {
@ -331,10 +335,14 @@ Transaction.prototype.imbalancedSplitSecurities = function(account_map) {
const emptySplit = new Split(); const emptySplit = new Split();
for (var i = 0; i < this.Splits.length; i++) { for (var i = 0; i < this.Splits.length; i++) {
split = this.Splits[i]; split = this.Splits[i];
if (split.AccountId == emptySplit.AccountId) { var securityId = -1;
if (split.AccountId != emptySplit.AccountId) {
securityId = account_map[split.AccountId].SecurityId;
} else if (split.SecurityId != emptySplit.SecurityId) {
securityId = split.SecurityId;
} else {
continue; continue;
} }
var securityId = account_map[split.AccountId].SecurityId;
if (securityId in splitBalances) { if (securityId in splitBalances) {
splitBalances[securityId] = split.Amount.plus(splitBalances[securityId]); splitBalances[securityId] = split.Amount.plus(splitBalances[securityId]);
} else { } else {

View File

@ -17,10 +17,17 @@ import (
type Split struct { type Split struct {
SplitId int64 SplitId int64
TransactionId int64 TransactionId int64
AccountId int64
Number string // Check or reference number // One of AccountId and SecurityId must be -1
Memo string // In normal splits, AccountId will be valid and SecurityId will be -1. The
Amount string // String representation of decimal, suitable for passing to big.Rat.SetString() // only case where this is reversed is for transactions that have been
// imported and not yet associated with an account.
AccountId int64
SecurityId int64
Number string // Check or reference number
Memo string
Amount string // String representation of decimal, suitable for passing to big.Rat.SetString()
} }
func GetBigAmount(amt string) (*big.Rat, error) { func GetBigAmount(amt string) (*big.Rat, error) {
@ -37,20 +44,26 @@ func (s *Split) GetAmount() (*big.Rat, error) {
} }
func (s *Split) Valid() bool { func (s *Split) Valid() bool {
if (s.AccountId == -1 && s.SecurityId == -1) ||
(s.AccountId != -1 && s.SecurityId != -1) {
return false
}
_, err := s.GetAmount() _, err := s.GetAmount()
return err == nil return err == nil
} }
const ( const (
Entered int64 = 1 Imported int64 = 1
Cleared = 2 Entered = 2
Reconciled = 3 Cleared = 3
Voided = 4 Reconciled = 4
Voided = 5
) )
type Transaction struct { type Transaction struct {
TransactionId int64 TransactionId int64
UserId int64 UserId int64
RemoteId string // unique ID from server, for detecting duplicates
Description string Description string
Status int64 Status int64
Date time.Time Date time.Time
@ -106,14 +119,18 @@ func (t *Transaction) Balanced() (bool, error) {
return false, errors.New("Transaction invalid") return false, errors.New("Transaction invalid")
} }
for i := range t.Splits { for i := range t.Splits {
account, err := GetAccount(t.Splits[i].AccountId, t.UserId) securityid := t.Splits[i].SecurityId
if err != nil { if t.Splits[i].AccountId != -1 {
return false, err account, err := GetAccount(t.Splits[i].AccountId, t.UserId)
if err != nil {
return false, err
}
securityid = account.SecurityId
} }
amount, _ := t.Splits[i].GetAmount() amount, _ := t.Splits[i].GetAmount()
sum := sums[account.SecurityId] sum := sums[securityid]
(&sum).Add(&sum, amount) (&sum).Add(&sum, amount)
sums[account.SecurityId] = sum sums[securityid] = sum
} }
for _, security_sum := range sums { for _, security_sum := range sums {
if security_sum.Cmp(&zero) != 0 { if security_sum.Cmp(&zero) != 0 {
@ -212,16 +229,20 @@ func InsertTransaction(t *Transaction, user *User) error {
// 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 {
existing, err := transaction.SelectInt("SELECT count(*) from accounts where AccountId=?", t.Splits[i].AccountId) if t.Splits[i].AccountId != -1 {
if err != nil { existing, err := transaction.SelectInt("SELECT count(*) from accounts where AccountId=?", t.Splits[i].AccountId)
transaction.Rollback() if err != nil {
return err transaction.Rollback()
} return err
if existing != 1 { }
transaction.Rollback() if existing != 1 {
transaction.Rollback()
return AccountMissingError{}
}
a_map[t.Splits[i].AccountId] = true
} else if t.Splits[i].SecurityId == -1 {
return AccountMissingError{} return AccountMissingError{}
} }
a_map[t.Splits[i].AccountId] = true
} }
//increment versions for all accounts //increment versions for all accounts
@ -229,6 +250,10 @@ func InsertTransaction(t *Transaction, user *User) error {
for id := range a_map { for id := range a_map {
a_ids = append(a_ids, id) a_ids = append(a_ids, id)
} }
// ensure at least one of the splits is associated with an actual account
if len(a_ids) < 1 {
return AccountMissingError{}
}
err = incrementAccountVersions(transaction, user, a_ids) err = incrementAccountVersions(transaction, user, a_ids)
if err != nil { if err != nil {
transaction.Rollback() transaction.Rollback()
@ -305,13 +330,17 @@ func UpdateTransaction(t *Transaction, user *User) error {
return err return err
} }
} }
a_map[t.Splits[i].AccountId] = true if t.Splits[i].AccountId != -1 {
a_map[t.Splits[i].AccountId] = true
}
} }
// Delete any remaining pre-existing splits // Delete any remaining pre-existing splits
for i := range existing_splits { for i := range existing_splits {
_, ok := s_map[existing_splits[i].SplitId] _, ok := s_map[existing_splits[i].SplitId]
a_map[existing_splits[i].AccountId] = true if existing_splits[i].AccountId != -1 {
a_map[existing_splits[i].AccountId] = true
}
if ok { if ok {
_, err := transaction.Delete(existing_splits[i]) _, err := transaction.Delete(existing_splits[i])
if err != nil { if err != nil {
@ -358,7 +387,7 @@ func DeleteTransaction(t *Transaction, user *User) error {
} }
var accountids []int64 var accountids []int64
_, err = transaction.Select(&accountids, "SELECT DISTINCT AccountId FROM splits WHERE TransactionId=?", t.TransactionId) _, err = transaction.Select(&accountids, "SELECT DISTINCT AccountId FROM splits WHERE TransactionId=? AND AccountId != -1", t.TransactionId)
if err != nil { if err != nil {
transaction.Rollback() transaction.Rollback()
return err return err