1
0
mirror of https://github.com/aclindsa/moneygo.git synced 2024-12-27 07:52:28 -05:00

Add direct OFX imports

This commit is contained in:
Aaron Lindsay 2017-06-04 16:01:42 -04:00
parent bf284dc591
commit fb59f9b3c5
13 changed files with 582 additions and 149 deletions

View File

@ -225,11 +225,19 @@ func GetTradingAccount(transaction *gorp.Transaction, userid int64, securityid i
func GetImbalanceAccount(transaction *gorp.Transaction, userid int64, securityid int64) (*Account, error) { func GetImbalanceAccount(transaction *gorp.Transaction, userid int64, securityid int64) (*Account, error) {
var imbalanceAccount Account var imbalanceAccount Account
var account Account var account Account
xxxtemplate := FindSecurityTemplate("XXX", Currency)
if xxxtemplate == nil {
return nil, errors.New("Couldn't find XXX security template")
}
xxxsecurity, err := ImportGetCreateSecurity(transaction, userid, xxxtemplate)
if err != nil {
return nil, errors.New("Couldn't create XXX security")
}
imbalanceAccount.UserId = userid imbalanceAccount.UserId = userid
imbalanceAccount.Name = "Imbalances" imbalanceAccount.Name = "Imbalances"
imbalanceAccount.ParentAccountId = -1 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.SecurityId = xxxsecurity.SecurityId
imbalanceAccount.Type = Bank imbalanceAccount.Type = Bank
// Find/create the top-level trading account // Find/create the top-level trading account
@ -238,7 +246,7 @@ func GetImbalanceAccount(transaction *gorp.Transaction, userid int64, securityid
return nil, err return nil, err
} }
security, err := GetSecurity(securityid, userid) security, err := GetSecurityTx(transaction, securityid, userid)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -302,7 +302,7 @@ func GnucashImportHandler(w http.ResponseWriter, r *http.Request) {
securityMap := make(map[int64]int64) securityMap := make(map[int64]int64)
for _, security := range gnucashImport.Securities { for _, security := range gnucashImport.Securities {
securityId := security.SecurityId // save off because it could be updated securityId := security.SecurityId // save off because it could be updated
s, err := ImportGetCreateSecurity(sqltransaction, user, &security) s, err := ImportGetCreateSecurity(sqltransaction, user.UserId, &security)
if err != nil { if err != nil {
sqltransaction.Rollback() sqltransaction.Rollback()
WriteError(w, 6 /*Import Error*/) WriteError(w, 6 /*Import Error*/)

View File

@ -1,37 +1,29 @@
package main package main
import ( import (
"encoding/json"
"github.com/aclindsa/ofxgo"
"io" "io"
"log" "log"
"math/big" "math/big"
"net/http" "net/http"
"strings"
"time"
) )
/* type OFXDownload struct {
* Assumes the User is a valid, signed-in user, but accountid has not yet been validated OFXPassword string
*/ StartDate time.Time
func AccountImportHandler(w http.ResponseWriter, r *http.Request, user *User, accountid int64, importtype string) { EndDate time.Time
//TODO branch off for different importtype's
multipartReader, err := r.MultipartReader()
if err != nil {
WriteError(w, 3 /*Invalid Request*/)
return
} }
// assume there is only one 'part' func (od *OFXDownload) Read(json_str string) error {
part, err := multipartReader.NextPart() dec := json.NewDecoder(strings.NewReader(json_str))
if err != nil { return dec.Decode(od)
if err == io.EOF {
WriteError(w, 3 /*Invalid Request*/)
} else {
WriteError(w, 999 /*Internal Error*/)
log.Print(err)
}
return
} }
itl, err := ImportOFX(part) func ofxImportHelper(r io.Reader, w http.ResponseWriter, user *User, accountid int64) {
itl, err := ImportOFX(r)
if err != nil { if err != nil {
//TODO is this necessarily an invalid request (what if it was an error on our end)? //TODO is this necessarily an invalid request (what if it was an error on our end)?
@ -86,14 +78,17 @@ func AccountImportHandler(w http.ResponseWriter, r *http.Request, user *User, ac
// SecurityIds to the actual SecurityIDs // SecurityIds to the actual SecurityIDs
var securitymap = make(map[int64]*Security) var securitymap = make(map[int64]*Security)
for _, ofxsecurity := range itl.Securities { for _, ofxsecurity := range itl.Securities {
security, err := ImportGetCreateSecurity(sqltransaction, user, &ofxsecurity) // save off since ImportGetCreateSecurity overwrites SecurityId on
// ofxsecurity
oldsecurityid := ofxsecurity.SecurityId
security, err := ImportGetCreateSecurity(sqltransaction, user.UserId, &ofxsecurity)
if err != nil { if err != nil {
sqltransaction.Rollback() sqltransaction.Rollback()
WriteError(w, 999 /*Internal Error*/) WriteError(w, 999 /*Internal Error*/)
log.Print(err) log.Print(err)
return return
} }
securitymap[ofxsecurity.SecurityId] = security securitymap[oldsecurityid] = security
} }
if account.SecurityId != securitymap[importedAccount.SecurityId].SecurityId { if account.SecurityId != securitymap[importedAccount.SecurityId].SecurityId {
@ -236,3 +231,151 @@ func AccountImportHandler(w http.ResponseWriter, r *http.Request, user *User, ac
WriteSuccess(w) WriteSuccess(w)
} }
func OFXImportHandler(w http.ResponseWriter, r *http.Request, user *User, accountid int64) {
download_json := r.PostFormValue("ofxdownload")
if download_json == "" {
log.Print("download_json")
WriteError(w, 3 /*Invalid Request*/)
return
}
var ofxdownload OFXDownload
err := ofxdownload.Read(download_json)
if err != nil {
log.Print("ofxdownload.Read")
WriteError(w, 3 /*Invalid Request*/)
return
}
account, err := GetAccount(accountid, user.UserId)
if err != nil {
log.Print("GetAccount")
WriteError(w, 3 /*Invalid Request*/)
return
}
ofxver := ofxgo.OfxVersion203
if len(account.OFXVersion) != 0 {
ofxver, err = ofxgo.NewOfxVersion(account.OFXVersion)
if err != nil {
log.Print("NewOfxVersion")
WriteError(w, 3 /*Invalid Request*/)
return
}
}
var client = ofxgo.Client{
AppID: account.OFXAppID,
AppVer: account.OFXAppVer,
SpecVersion: ofxver,
NoIndent: account.OFXNoIndent,
}
var query ofxgo.Request
query.URL = account.OFXURL
query.Signon.ClientUID = ofxgo.UID(account.OFXClientUID)
query.Signon.UserID = ofxgo.String(account.OFXUser)
query.Signon.UserPass = ofxgo.String(ofxdownload.OFXPassword)
query.Signon.Org = ofxgo.String(account.OFXORG)
query.Signon.Fid = ofxgo.String(account.OFXFID)
transactionuid, err := ofxgo.RandomUID()
if err != nil {
WriteError(w, 999 /*Internal Error*/)
log.Println("Error creating uid for transaction:", err)
return
}
if account.Type == Investment {
// Investment account
statementRequest := ofxgo.InvStatementRequest{
TrnUID: *transactionuid,
InvAcctFrom: ofxgo.InvAcct{
BrokerID: ofxgo.String(account.OFXBankID),
AcctID: ofxgo.String(account.OFXAcctID),
},
Include: true,
IncludeOO: true,
IncludePos: true,
IncludeBalance: true,
Include401K: true,
Include401KBal: true,
}
query.InvStmt = append(query.InvStmt, &statementRequest)
} else if account.OFXAcctType == "CC" {
// Import credit card transactions
statementRequest := ofxgo.CCStatementRequest{
TrnUID: *transactionuid,
CCAcctFrom: ofxgo.CCAcct{
AcctID: ofxgo.String(account.OFXAcctID),
},
Include: true,
}
query.CreditCard = append(query.CreditCard, &statementRequest)
} else {
// Import generic bank transactions
acctTypeEnum, err := ofxgo.NewAcctType(account.OFXAcctType)
if err != nil {
WriteError(w, 3 /*Invalid Request*/)
return
}
statementRequest := ofxgo.StatementRequest{
TrnUID: *transactionuid,
BankAcctFrom: ofxgo.BankAcct{
BankID: ofxgo.String(account.OFXBankID),
AcctID: ofxgo.String(account.OFXAcctID),
AcctType: acctTypeEnum,
},
Include: true,
}
query.Bank = append(query.Bank, &statementRequest)
}
response, err := client.RequestNoParse(&query)
if err != nil {
// TODO this could be an error talking with the OFX server...
WriteError(w, 3 /*Invalid Request*/)
return
}
defer response.Body.Close()
ofxImportHelper(response.Body, w, user, accountid)
}
func OFXFileImportHandler(w http.ResponseWriter, r *http.Request, user *User, accountid int64) {
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
}
ofxImportHelper(part, w, user, accountid)
}
/*
* 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, importtype string) {
switch importtype {
case "ofx":
OFXImportHandler(w, r, user, accountid)
case "ofxfile":
OFXFileImportHandler(w, r, user, accountid)
default:
WriteError(w, 3 /*Invalid Request*/)
}
}

163
js/actions/ImportActions.js Normal file
View File

@ -0,0 +1,163 @@
var ImportConstants = require('../constants/ImportConstants');
var models = require('../models.js');
var OFXDownload = models.OFXDownload;
var Error = models.Error;
function beginImport() {
return {
type: ImportConstants.BEGIN_IMPORT
}
}
function updateProgress(progress) {
return {
type: ImportConstants.UPDATE_IMPORT_PROGRESS,
progress: progress
}
}
function importFinished() {
return {
type: ImportConstants.IMPORT_FINISHED
}
}
function importFailed(error) {
return {
type: ImportConstants.IMPORT_FAILED,
error: error
}
}
function openModal() {
return function(dispatch) {
dispatch({
type: ImportConstants.OPEN_IMPORT_MODAL
});
};
}
function closeModal() {
return function(dispatch) {
dispatch({
type: ImportConstants.CLOSE_IMPORT_MODAL
});
};
}
function importOFX(account, password, startDate, endDate) {
return function(dispatch) {
dispatch(beginImport());
dispatch(updateProgress(50));
var ofxdownload = new OFXDownload();
ofxdownload.OFXPassword = password;
ofxdownload.StartDate = startDate;
ofxdownload.EndDate = endDate;
$.ajax({
type: "POST",
dataType: "json",
url: "account/"+account.AccountId+"/import/ofx",
data: {ofxdownload: ofxdownload.toJSON()},
success: function(data, status, jqXHR) {
var e = new Error();
e.fromJSON(data);
if (e.isError()) {
var errString = e.ErrorString;
if (e.ErrorId == 3 /* Invalid Request */) {
errString = "Please check that your password and all other OFX login credentials are correct.";
}
dispatch(importFailed(errString));
} else {
dispatch(importFinished());
}
},
error: function(jqXHR, status, error) {
dispatch(importFailed(error));
}
});
};
}
function importFile(url, inputElement) {
return function(dispatch) {
dispatch(beginImport());
if (inputElement.files.length == 0) {
dispatch(importFailed("No files specified to be imported"))
return;
}
if (inputElement.files.length > 1) {
dispatch(importFailed("More than one file specified for import, only one allowed at a time"))
return;
}
var file = inputElement.files[0];
var formData = new FormData();
formData.append('importfile', file, file.name);
var handleSetProgress = function(e) {
if (e.lengthComputable) {
var pct = Math.round(e.loaded/e.total*100);
dispatch(updateProgress(pct));
} else {
dispatch(updateProgress(50));
}
}
$.ajax({
type: "POST",
url: url,
data: formData,
xhr: function() {
var xhrObject = $.ajaxSettings.xhr();
if (xhrObject.upload) {
xhrObject.upload.addEventListener('progress', handleSetProgress, false);
} else {
dispatch(importFailed("File upload failed because xhr.upload isn't supported by your browser."));
}
return xhrObject;
},
success: function(data, status, jqXHR) {
var e = new Error();
e.fromJSON(data);
if (e.isError()) {
var errString = e.ErrorString;
if (e.ErrorId == 3 /* Invalid Request */) {
errString = "Please check that the file you uploaded is valid and try again.";
}
dispatch(importFailed(errString));
} else {
dispatch(importFinished());
}
},
error: function(jqXHR, status, error) {
dispatch(importFailed(error));
},
// So jQuery doesn't try to process the data or content-type
cache: false,
contentType: false,
processData: false
});
};
}
function importOFXFile(inputElement, account) {
url = "account/"+account.AccountId+"/import/ofxfile";
return importFile(url, inputElement);
}
function importGnucash(inputElement) {
url = "import/gnucash";
return importFile(url, inputElement);
}
module.exports = {
openModal: openModal,
closeModal: closeModal,
importOFX: importOFX,
importOFXFile: importOFXFile,
importGnucash: importGnucash
};

View File

@ -473,25 +473,29 @@ const AddEditTransactionModal = React.createClass({
const ImportType = { const ImportType = {
OFX: 1, OFX: 1,
Gnucash: 2 OFXFile: 2,
Gnucash: 3
}; };
var ImportTypeList = []; var ImportTypeList = [];
for (var type in ImportType) { for (var type in ImportType) {
if (ImportType.hasOwnProperty(type)) { if (ImportType.hasOwnProperty(type)) {
var name = ImportType[type] == ImportType.OFX ? "OFX/QFX" : type; //QFX is a special snowflake var name = ImportType[type] == ImportType.OFX ? "Direct OFX" : type;
var name = ImportType[type] == ImportType.OFXFile ? "OFX/QFX File" : type; //QFX is a special snowflake
ImportTypeList.push({'TypeId': ImportType[type], 'Name': name}); ImportTypeList.push({'TypeId': ImportType[type], 'Name': name});
} }
} }
const ImportTransactionsModal = React.createClass({ const ImportTransactionsModal = React.createClass({
getInitialState: function() { getInitialState: function() {
var startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1);
return { return {
importing: false,
imported: false,
importFile: "", importFile: "",
importType: ImportType.Gnucash, importType: ImportType.Gnucash,
uploadProgress: -1, startDate: startDate,
error: null}; endDate: new Date(),
password: "",
};
}, },
handleCancel: function() { handleCancel: function() {
this.setState(this.getInitialState()); this.setState(this.getInitialState());
@ -504,73 +508,36 @@ const ImportTransactionsModal = React.createClass({
handleTypeChange: function(type) { handleTypeChange: function(type) {
this.setState({importType: type.TypeId}); this.setState({importType: type.TypeId});
}, },
handlePasswordChange: function() {
this.setState({password: ReactDOM.findDOMNode(this.refs.password).value});
},
handleStartDateChange: function(date, string) {
if (date == null)
return;
this.setState({
startDate: date
});
},
handleEndDateChange: function(date, string) {
if (date == null)
return;
this.setState({
endDate: date
});
},
handleSubmit: function() { handleSubmit: function() {
this.setState(this.getInitialState());
if (this.props.onSubmit != null) if (this.props.onSubmit != null)
this.props.onSubmit(this.props.account); 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() { handleImportTransactions: function() {
var file = ReactDOM.findDOMNode(this.refs.importfile).files[0]; if (this.state.importType == ImportType.OFX) {
var formData = new FormData(); this.props.onImportOFX(this.props.account, this.state.password, this.state.startDate, this.state.endDate);
formData.append('importfile', file, this.state.importFile); } else if (this.state.importType == ImportType.OFXFile) {
var url = "" this.props.onImportOFXFile(ReactDOM.findDOMNode(this.refs.importfile), this.props.account);
if (this.state.importType == ImportType.OFX) } else if (this.state.importType == ImportType.Gnucash) {
url = "account/"+this.props.account.AccountId+"/import/ofx"; this.props.onImportGnucash(ReactDOM.findDOMNode(this.refs.importfile));
else if (this.state.importType == ImportType.Gnucash)
url = "import/gnucash";
this.setState({importing: true});
$.ajax({
type: "POST",
url: url,
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),
success: function(data, status, jqXHR) {
var e = new Error();
e.fromJSON(data);
if (e.isError()) {
var errString = e.ErrorString;
if (e.ErrorId == 3 /* Invalid Request */) {
errString = "Please check that the file you uploaded is valid and try again.";
}
this.setState({
importing: false,
error: errString
});
return;
}
this.setState({
uploadProgress: 100,
importing: false,
imported: true
});
}.bind(this),
error: function(e) {
this.setState({importing: false});
console.log("error handler", e);
}.bind(this),
// So jQuery doesn't try to process teh data or content-type
cache: false,
contentType: false,
processData: false
});
}, },
render: function() { render: function() {
var accountNameLabel = "Performing global import:" var accountNameLabel = "Performing global import:"
@ -579,45 +546,104 @@ const ImportTransactionsModal = React.createClass({
// Display the progress bar if an upload/import is in progress // 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.props.imports.importing && this.props.imports.uploadProgress == 100) {
progressBar = (<ProgressBar now={this.state.uploadProgress} active label="Importing transactions..." />); progressBar = (<ProgressBar now={this.props.imports.uploadProgress} active label="Importing transactions..." />);
} else if (this.state.importing && this.state.uploadProgress != -1) { } else if (this.props.imports.importing) {
progressBar = (<ProgressBar now={this.state.uploadProgress} active label="Uploading... %(percent)s%" />); progressBar = (<ProgressBar now={this.props.imports.uploadProgress} active label={`Uploading... ${this.props.imports.uploadProgress}%`} />);
} }
// Create panel, possibly displaying error or success messages // Create panel, possibly displaying error or success messages
var panel = []; var panel = [];
if (this.state.error != null) { if (this.props.imports.importFailed) {
panel = (<Panel header="Error Importing Transactions" bsStyle="danger">{this.state.error}</Panel>); panel = (<Panel header="Error Importing Transactions" bsStyle="danger">{this.props.imports.errorMessage}</Panel>);
} else if (this.state.imported) { } else if (this.props.imports.importFinished) {
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>);
} }
// Display proper buttons, possibly disabling them if an import is in progress // 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.props.imports.importFinished && !this.props.imports.importFailed) {
button1 = (<Button onClick={this.handleCancel} disabled={this.state.importing} bsStyle="warning">Cancel</Button>); var importingDisabled = this.props.imports.importing || (this.state.importType != ImportType.OFX && this.state.importFile == "") || (this.state.importType == ImportType.OFX && this.state.password == "");
button2 = (<Button onClick={this.handleImportTransactions} disabled={this.state.importing || this.state.importFile == ""} bsStyle="success">Import</Button>); button1 = (<Button onClick={this.handleCancel} disabled={this.props.imports.importing} bsStyle="warning">Cancel</Button>);
button2 = (<Button onClick={this.handleImportTransactions} disabled={importingDisabled} bsStyle="success">Import</Button>);
} else { } else {
button1 = (<Button onClick={this.handleCancel} disabled={this.state.importing} bsStyle="success">OK</Button>); button1 = (<Button onClick={this.handleSubmit} disabled={this.props.imports.importing} bsStyle="success">OK</Button>);
} }
var inputDisabled = (this.state.importing || this.state.error != null || this.state.imported) ? true : false; var inputDisabled = (this.props.imports.importing || this.props.imports.importFailed || this.props.imports.importFinished) ? true : false;
// Disable OFX/QFX imports if no account is selected // Disable OFX/QFX imports if no account is selected
var disabledTypes = false; var disabledTypes = false;
if (this.props.account == null) if (this.props.account == null)
disabledTypes = [ImportTypeList[ImportType.OFX - 1]]; disabledTypes = [ImportTypeList[ImportType.OFX - 1], ImportTypeList[ImportType.OFXFile - 1]];
var importForm = [];
if (this.state.importType == ImportType.OFX) {
importForm = (
<div>
<FormGroup>
<Col componentClass={ControlLabel} xs={2}>OFX Password</Col>
<Col xs={10}>
<FormControl type="password"
value={this.state.password}
placeholder="Password..."
ref="password"
onChange={this.handlePasswordChange} />
</Col>
</FormGroup>
<FormGroup>
<Col componentClass={ControlLabel} xs={2}>Start Date</Col>
<Col xs={10}>
<DateTimePicker
time={false}
defaultValue={this.state.startDate}
onChange={this.handleStartDateChange} />
</Col>
</FormGroup>
<FormGroup>
<Col componentClass={ControlLabel} xs={2}>End Date</Col>
<Col xs={10}>
<DateTimePicker
time={false}
defaultValue={this.state.endDate}
onChange={this.handleEndDateChange} />
</Col>
</FormGroup>
</div>
);
} else {
importForm = (
<FormGroup>
<Col componentClass={ControlLabel} xs={2}>File</Col>
<Col xs={10}>
<FormControl type="file"
ref="importfile"
disabled={inputDisabled}
value={this.state.importFile}
onChange={this.handleImportChange} />
<HelpBlock>Select a file to upload.</HelpBlock>
</Col>
</FormGroup>
);
}
return ( return (
<Modal show={this.props.show} onHide={this.handleCancel} bsSize="small"> <Modal show={this.props.show} onHide={this.handleCancel}>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title>Import Transactions</Modal.Title> <Modal.Title>Import Transactions</Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<form onSubmit={this.handleImportTransactions} <Form horizontal onSubmit={this.handleImportTransactions}
encType="multipart/form-data" encType="multipart/form-data"
ref="importform"> ref="importform">
<FormGroup>
<Col xs={12}>
<ControlLabel>{accountNameLabel}</ControlLabel>
</Col>
</FormGroup>
<FormGroup>
<Col componentClass={ControlLabel} xs={2}>Import Type</Col>
<Col xs={10}>
<DropdownList <DropdownList
data={ImportTypeList} data={ImportTypeList}
valueField='TypeId' valueField='TypeId'
@ -626,16 +652,10 @@ const ImportTransactionsModal = React.createClass({
defaultValue={this.state.importType} defaultValue={this.state.importType}
disabled={disabledTypes} disabled={disabledTypes}
ref="importtype" /> ref="importtype" />
<FormGroup> </Col>
<ControlLabel>{accountNameLabel}</ControlLabel>
<FormControl type="file"
ref="importfile"
disabled={inputDisabled}
value={this.state.importFile}
onChange={this.handleImportChange} />
<HelpBlock>Select a file to upload.</HelpBlock>
</FormGroup> </FormGroup>
</form> {importForm}
</Form>
{progressBar} {progressBar}
{panel} {panel}
</Modal.Body> </Modal.Body>
@ -654,7 +674,6 @@ module.exports = React.createClass({
displayName: "AccountRegister", displayName: "AccountRegister",
getInitialState: function() { getInitialState: function() {
return { return {
importingTransactions: false,
newTransaction: null, newTransaction: null,
height: 0 height: 0
}; };
@ -695,16 +714,6 @@ module.exports = React.createClass({
newTransaction: newTransaction newTransaction: 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;
@ -725,7 +734,8 @@ module.exports = React.createClass({
} }
}, },
handleImportComplete: function() { handleImportComplete: function() {
this.setState({importingTransactions: false}); this.props.onCloseImportModal();
this.props.onFetchAllAccounts();
this.props.onFetchTransactionPage(this.props.accounts[this.props.selectedAccount], this.props.pageSize, this.props.transactionPage.page); this.props.onFetchTransactionPage(this.props.accounts[this.props.selectedAccount], this.props.pageSize, this.props.transactionPage.page);
}, },
handleDeleteTransaction: function(transaction) { handleDeleteTransaction: function(transaction) {
@ -810,11 +820,16 @@ module.exports = React.createClass({
onDelete={this.handleDeleteTransaction} onDelete={this.handleDeleteTransaction}
securities={this.props.securities} /> securities={this.props.securities} />
<ImportTransactionsModal <ImportTransactionsModal
show={this.state.importingTransactions} imports={this.props.imports}
show={this.props.imports.showModal}
account={this.props.accounts[this.props.selectedAccount]} account={this.props.accounts[this.props.selectedAccount]}
accounts={this.props.accounts} accounts={this.props.accounts}
onCancel={this.handleImportingCancel} onCancel={this.props.onCloseImportModal}
onSubmit={this.handleImportComplete}/> onHide={this.props.onCloseImportModal}
onSubmit={this.handleImportComplete}
onImportOFX={this.props.onImportOFX}
onImportOFXFile={this.props.onImportOFXFile}
onImportGnucash={this.props.onImportGnucash} />
<div className="transactions-register-toolbar"> <div className="transactions-register-toolbar">
Transactions for '{name}' Transactions for '{name}'
<ButtonToolbar className="pull-right"> <ButtonToolbar className="pull-right">
@ -835,7 +850,7 @@ module.exports = React.createClass({
<Glyphicon glyph='plus-sign' /> New Transaction <Glyphicon glyph='plus-sign' /> New Transaction
</Button> </Button>
<Button <Button
onClick={this.handleImportClicked} onClick={this.props.onOpenImportModal}
bsStyle="primary"> bsStyle="primary">
<Glyphicon glyph='import' /> Import <Glyphicon glyph='import' /> Import
</Button> </Button>

View File

@ -139,7 +139,7 @@ const AddEditAccountModal = React.createClass({
a.OFXAppID = this.state.ofxappid; a.OFXAppID = this.state.ofxappid;
a.OFXAppVer = this.state.ofxappver; a.OFXAppVer = this.state.ofxappver;
a.OFXVersion = this.state.ofxversion; a.OFXVersion = this.state.ofxversion;
a.OFXNoIndent = this.state.ofxNoIndent; a.OFXNoIndent = this.state.ofxnoindent;
if (this.props.onSubmit != null) if (this.props.onSubmit != null)
this.props.onSubmit(a); this.props.onSubmit(a);
@ -169,6 +169,7 @@ const AddEditAccountModal = React.createClass({
ref="ofxaccttype"> ref="ofxaccttype">
<option value="CHECKING">Checking</option> <option value="CHECKING">Checking</option>
<option value="SAVINGS">Savings</option> <option value="SAVINGS">Savings</option>
<option value="CC">Credit Card</option>
<option value="MONEYMRKT">Money Market</option> <option value="MONEYMRKT">Money Market</option>
<option value="CREDITLINE">Credit Line</option> <option value="CREDITLINE">Credit Line</option>
<option value="CD">CD</option> <option value="CD">CD</option>
@ -674,12 +675,19 @@ module.exports = React.createClass({
securities={this.props.securities} securities={this.props.securities}
transactions={this.props.transactions} transactions={this.props.transactions}
transactionPage={this.props.transactionPage} transactionPage={this.props.transactionPage}
imports={this.props.imports}
onFetchAllAccounts={this.props.onFetchAllAccounts}
onCreateTransaction={this.props.onCreateTransaction} onCreateTransaction={this.props.onCreateTransaction}
onUpdateTransaction={this.props.onUpdateTransaction} onUpdateTransaction={this.props.onUpdateTransaction}
onDeleteTransaction={this.props.onDeleteTransaction} onDeleteTransaction={this.props.onDeleteTransaction}
onSelectTransaction={this.props.onSelectTransaction} onSelectTransaction={this.props.onSelectTransaction}
onUnselectTransaction={this.props.onUnselectTransaction} onUnselectTransaction={this.props.onUnselectTransaction}
onFetchTransactionPage={this.props.onFetchTransactionPage}/> onFetchTransactionPage={this.props.onFetchTransactionPage}
onOpenImportModal={this.props.onOpenImportModal}
onCloseImportModal={this.props.onCloseImportModal}
onImportOFX={this.props.onImportOFX}
onImportOFXFile={this.props.onImportOFXFile}
onImportGnucash={this.props.onImportGnucash} />
</Col> </Col>
</Row></Grid> </Row></Grid>
); );

View File

@ -0,0 +1,10 @@
var keyMirror = require('keymirror');
module.exports = keyMirror({
OPEN_IMPORT_MODAL: null,
CLOSE_IMPORT_MODAL: null,
BEGIN_IMPORT: null,
UPDATE_IMPORT_PROGRESS: null,
IMPORT_FINISHED: null,
IMPORT_FAILED: null
});

View File

@ -2,6 +2,7 @@ var connect = require('react-redux').connect;
var AccountActions = require('../actions/AccountActions'); var AccountActions = require('../actions/AccountActions');
var TransactionActions = require('../actions/TransactionActions'); var TransactionActions = require('../actions/TransactionActions');
var ImportActions = require('../actions/ImportActions');
var AccountsTab = require('../components/AccountsTab'); var AccountsTab = require('../components/AccountsTab');
@ -18,12 +19,14 @@ function mapStateToProps(state) {
security_list: security_list, security_list: security_list,
selectedAccount: state.selectedAccount, selectedAccount: state.selectedAccount,
transactions: state.transactions, transactions: state.transactions,
transactionPage: state.transactionPage transactionPage: state.transactionPage,
imports: state.imports
} }
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
onFetchAllAccounts: function() {dispatch(AccountActions.fetchAll())},
onCreateAccount: function(account) {dispatch(AccountActions.create(account))}, onCreateAccount: function(account) {dispatch(AccountActions.create(account))},
onUpdateAccount: function(account) {dispatch(AccountActions.update(account))}, onUpdateAccount: function(account) {dispatch(AccountActions.update(account))},
onDeleteAccount: function(account) {dispatch(AccountActions.remove(account))}, onDeleteAccount: function(account) {dispatch(AccountActions.remove(account))},
@ -34,6 +37,11 @@ function mapDispatchToProps(dispatch) {
onSelectTransaction: function(transactionId) {dispatch(TransactionActions.select(transactionId))}, onSelectTransaction: function(transactionId) {dispatch(TransactionActions.select(transactionId))},
onUnselectTransaction: function() {dispatch(TransactionActions.unselect())}, onUnselectTransaction: function() {dispatch(TransactionActions.unselect())},
onFetchTransactionPage: function(account, pageSize, page) {dispatch(TransactionActions.fetchPage(account, pageSize, page))}, onFetchTransactionPage: function(account, pageSize, page) {dispatch(TransactionActions.fetchPage(account, pageSize, page))},
onOpenImportModal: function() {dispatch(ImportActions.openModal())},
onCloseImportModal: function() {dispatch(ImportActions.closeModal())},
onImportOFX: function(account, password, startDate, endDate) {dispatch(ImportActions.importOFX(account, password, startDate, endDate))},
onImportOFXFile: function(inputElement, account) {dispatch(ImportActions.importOFXFile(inputElement, account))},
onImportGnucash: function(inputElement) {dispatch(ImportActions.importGnucash(inputElement))},
} }
} }

View File

@ -573,6 +573,20 @@ Report.prototype.mapReduceSeries = function(mapFn, reduceFn) {
return this.mapReduceChildren(mapFn, reduceFn); return this.mapReduceChildren(mapFn, reduceFn);
} }
function OFXDownload() {
this.OFXPassword = "";
this.StartDate = new Date();
this.EndDate = new Date();
}
OFXDownload.prototype.toJSON = function() {
var json_obj = {};
json_obj.OFXPassword = this.OFXPassword;
json_obj.StartDate = this.StartDate.toJSON();
json_obj.EndDate = this.EndDate.toJSON();
return JSON.stringify(json_obj);
}
module.exports = models = { module.exports = models = {
// Classes // Classes
@ -583,6 +597,7 @@ module.exports = models = {
Split: Split, Split: Split,
Transaction: Transaction, Transaction: Transaction,
Report: Report, Report: Report,
OFXDownload: OFXDownload,
Error: Error, Error: Error,
// Enums, Lists // Enums, Lists

View File

@ -0,0 +1,47 @@
var assign = require('object-assign');
var ImportConstants = require('../constants/ImportConstants');
var UserConstants = require('../constants/UserConstants');
const initialState = {
showModal: false,
importing: false,
uploadProgress: 0,
importFinished: false,
importFailed: false,
errorMessage: null
};
module.exports = function(state = initialState, action) {
switch (action.type) {
case ImportConstants.OPEN_IMPORT_MODAL:
return assign({}, initialState, {
showModal: true
});
case ImportConstants.CLOSE_IMPORT_MODAL:
case UserConstants.USER_LOGGEDOUT:
return initialState;
case ImportConstants.BEGIN_IMPORT:
return assign({}, state, {
importing: true
});
case ImportConstants.UPDATE_IMPORT_PROGRESS:
return assign({}, state, {
uploadProgress: action.progress
});
case ImportConstants.IMPORT_FINISHED:
return assign({}, state, {
importing: false,
uploadProgress: 100,
importFinished: true
});
case ImportConstants.IMPORT_FAILED:
return assign({}, state, {
importing: false,
importFailed: true,
errorMessage: action.error
});
default:
return state;
}
};

View File

@ -11,6 +11,7 @@ var ReportReducer = require('./ReportReducer');
var SelectedReportReducer = require('./SelectedReportReducer'); var SelectedReportReducer = require('./SelectedReportReducer');
var TransactionReducer = require('./TransactionReducer'); var TransactionReducer = require('./TransactionReducer');
var TransactionPageReducer = require('./TransactionPageReducer'); var TransactionPageReducer = require('./TransactionPageReducer');
var ImportReducer = require('./ImportReducer');
var ErrorReducer = require('./ErrorReducer'); var ErrorReducer = require('./ErrorReducer');
module.exports = Redux.combineReducers({ module.exports = Redux.combineReducers({
@ -25,5 +26,6 @@ module.exports = Redux.combineReducers({
selectedReport: SelectedReportReducer, selectedReport: SelectedReportReducer,
transactions: TransactionReducer, transactions: TransactionReducer,
transactionPage: TransactionPageReducer, transactionPage: TransactionPageReducer,
imports: ImportReducer,
error: ErrorReducer error: ErrorReducer
}); });

4
ofx.go
View File

@ -113,11 +113,13 @@ func (i *OFXImport) importOFXBank(stmt *ofxgo.StatementResponse) error {
Type: Bank, Type: Bank,
} }
if stmt.BankTranList != nil {
for _, tran := range stmt.BankTranList.Transactions { for _, tran := range stmt.BankTranList.Transactions {
if err := i.AddTransaction(&tran, &account); err != nil { if err := i.AddTransaction(&tran, &account); err != nil {
return err return err
} }
} }
}
i.Accounts = append(i.Accounts, account) i.Accounts = append(i.Accounts, account)
@ -139,11 +141,13 @@ func (i *OFXImport) importOFXCC(stmt *ofxgo.CCStatementResponse) error {
} }
i.Accounts = append(i.Accounts, account) i.Accounts = append(i.Accounts, account)
if stmt.BankTranList != nil {
for _, tran := range stmt.BankTranList.Transactions { for _, tran := range stmt.BankTranList.Transactions {
if err := i.AddTransaction(&tran, &account); err != nil { if err := i.AddTransaction(&tran, &account); err != nil {
return err return err
} }
} }
}
return nil return nil
} }

View File

@ -96,6 +96,16 @@ func GetSecurity(securityid int64, userid int64) (*Security, error) {
return &s, nil return &s, nil
} }
func GetSecurityTx(transaction *gorp.Transaction, securityid int64, userid int64) (*Security, error) {
var s Security
err := transaction.SelectOne(&s, "SELECT * from securities where UserId=? AND SecurityId=?", userid, securityid)
if err != nil {
return nil, err
}
return &s, nil
}
func GetSecurities(userid int64) (*[]*Security, error) { func GetSecurities(userid int64) (*[]*Security, error) {
var securities []*Security var securities []*Security
@ -180,8 +190,8 @@ func DeleteSecurity(s *Security) error {
return nil return nil
} }
func ImportGetCreateSecurity(transaction *gorp.Transaction, user *User, security *Security) (*Security, error) { func ImportGetCreateSecurity(transaction *gorp.Transaction, userid int64, security *Security) (*Security, error) {
security.UserId = user.UserId security.UserId = userid
if len(security.AlternateId) == 0 { if len(security.AlternateId) == 0 {
// Always create a new local security if we can't match on the AlternateId // Always create a new local security if we can't match on the AlternateId
err := InsertSecurityTx(transaction, security) err := InsertSecurityTx(transaction, security)
@ -193,7 +203,7 @@ func ImportGetCreateSecurity(transaction *gorp.Transaction, user *User, security
var securities []*Security var securities []*Security
_, err := transaction.Select(&securities, "SELECT * from securities where UserId=? AND Type=? AND AlternateId=? AND Precision=?", user.UserId, security.Type, security.AlternateId, security.Precision) _, err := transaction.Select(&securities, "SELECT * from securities where UserId=? AND Type=? AND AlternateId=? AND Precision=?", userid, security.Type, security.AlternateId, security.Precision)
if err != nil { if err != nil {
return nil, err return nil, err
} }