mirror of
https://github.com/aclindsa/ofxgo.git
synced 2024-11-22 11:30:05 -05:00
Initial commit
This commit is contained in:
commit
99cd8f7273
23
acctinfo.go
Normal file
23
acctinfo.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package ofxgo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/go/src/encoding/xml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OfxAcctInfoRequest struct {
|
||||||
|
XMLName xml.Name `xml:"ACCTINFOTRNRQ"`
|
||||||
|
TrnUID OfxUID `xml:"TRNUID"`
|
||||||
|
CltCookie OfxInt `xml:"CLTCOOKIE"`
|
||||||
|
DtAcctup OfxDate `xml:"ACCTINFORQ>DTACCTUP"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OfxAcctInfoRequest) Name() string {
|
||||||
|
return "ACCTINFOTRNRQ"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OfxAcctInfoRequest) Valid() (bool, error) {
|
||||||
|
if ok, err := r.TrnUID.Valid(); !ok {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
236
leaf_elements.go
Normal file
236
leaf_elements.go
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
package ofxgo
|
||||||
|
|
||||||
|
// A list of all the leaf elements in OFX 1.0.3 (the last SGML version of the
|
||||||
|
// spec). These are all the elements that are possibly left unclosed, and which
|
||||||
|
// can have no children of their own. Fortunately these two sets of elements
|
||||||
|
// are the same. We use this list when parsing to remove ambiguities about
|
||||||
|
// element nesting.
|
||||||
|
//
|
||||||
|
// Generated using the following command with the 1.0.3 SPEC .dtd file:
|
||||||
|
// # sed -rn 's/^<!ELEMENT\s+([A-Z]+)\s+-\s+o\s+%.*TYPE>.*$/\t"\1",/p' *.dtd | sort
|
||||||
|
var ofxLeafElements = []string{
|
||||||
|
"ACCESSKEY",
|
||||||
|
"ACCTID",
|
||||||
|
"ACCTKEY",
|
||||||
|
"ACCTREQUIRED",
|
||||||
|
"ACCTTYPE",
|
||||||
|
"ADJAMT",
|
||||||
|
"ADJDATE",
|
||||||
|
"ADJDESC",
|
||||||
|
"ADJNO",
|
||||||
|
"APPID",
|
||||||
|
"APPVER",
|
||||||
|
"AUTHTOKEN",
|
||||||
|
"AUTHTOKENFIRST",
|
||||||
|
"AUTHTOKENINFOURL",
|
||||||
|
"AUTHTOKENLABEL",
|
||||||
|
"AVAILACCTS",
|
||||||
|
"BALAMT",
|
||||||
|
"BALCLOSE",
|
||||||
|
"BALMIN",
|
||||||
|
"BALOPEN",
|
||||||
|
"BALTYPE",
|
||||||
|
"BANKID",
|
||||||
|
"BILLREFINFO",
|
||||||
|
"BRANCHID",
|
||||||
|
"CANADDPAYEE",
|
||||||
|
"CANBILLPAY",
|
||||||
|
"CANCELWND",
|
||||||
|
"CANEMAIL",
|
||||||
|
"CANMODMDLS",
|
||||||
|
"CANMODPMTS",
|
||||||
|
"CANMODXFERS",
|
||||||
|
"CANNOTIFY",
|
||||||
|
"CANPENDING",
|
||||||
|
"CANRECUR",
|
||||||
|
"CANSCHED",
|
||||||
|
"CANUSEDESC",
|
||||||
|
"CANUSERANGE",
|
||||||
|
"CASESEN",
|
||||||
|
"CHARTYPE",
|
||||||
|
"CHECKNUM",
|
||||||
|
"CHGPINFIRST",
|
||||||
|
"CHGUSERINFO",
|
||||||
|
"CHKANDDEB",
|
||||||
|
"CHKERROR",
|
||||||
|
"CHKNUMEND",
|
||||||
|
"CHKNUMSTART",
|
||||||
|
"CHKSTATUS",
|
||||||
|
"CITY",
|
||||||
|
"CLIENTACTREQ",
|
||||||
|
"CLIENTROUTING",
|
||||||
|
"CLIENTUID",
|
||||||
|
"CLIENTUIDREQ",
|
||||||
|
"CLOSINGAVAIL",
|
||||||
|
"CLTCOOKIE",
|
||||||
|
"CODE",
|
||||||
|
"CONFMSG",
|
||||||
|
"CORRECTACTION",
|
||||||
|
"CORRECTFITID",
|
||||||
|
"COUNTRY",
|
||||||
|
"CREDITLIMIT",
|
||||||
|
"CSPHONE",
|
||||||
|
"CURDEF",
|
||||||
|
"CURRATE",
|
||||||
|
"CURSYM",
|
||||||
|
"DATEBIRTH",
|
||||||
|
"DAYPHONE",
|
||||||
|
"DAYSTOPAY",
|
||||||
|
"DAYSWITH",
|
||||||
|
"DEBADJ",
|
||||||
|
"DEPANDCREDIT",
|
||||||
|
"DESC",
|
||||||
|
"DFLTDAYSTOPAY",
|
||||||
|
"DIFFFIRSTPMT",
|
||||||
|
"DIFFLASTPMT",
|
||||||
|
"DOMXFERFEE",
|
||||||
|
"DSCAMT",
|
||||||
|
"DSCDATE",
|
||||||
|
"DSCDESC",
|
||||||
|
"DSCRATE",
|
||||||
|
"DTACCTUP",
|
||||||
|
"DTASOF",
|
||||||
|
"DTAVAIL",
|
||||||
|
"DTCHANGED",
|
||||||
|
"DTCLIENT",
|
||||||
|
"DTCLOSE",
|
||||||
|
"DTCREATED",
|
||||||
|
"DTDUE",
|
||||||
|
"DTEND",
|
||||||
|
"DTEXPIRE",
|
||||||
|
"DTINFOCHG",
|
||||||
|
"DTNEXT",
|
||||||
|
"DTOPEN",
|
||||||
|
"DTPMTDUE",
|
||||||
|
"DTPMTPRC",
|
||||||
|
"DTPOSTED",
|
||||||
|
"DTPOSTEND",
|
||||||
|
"DTPOSTSTART",
|
||||||
|
"DTPROFUP",
|
||||||
|
"DTPURCHASE",
|
||||||
|
"DTSERVER",
|
||||||
|
"DTSTART",
|
||||||
|
"DTUSER",
|
||||||
|
"DTXFERPRC",
|
||||||
|
"DTXFERPRJ",
|
||||||
|
"EMAIL",
|
||||||
|
"EVEPHONE",
|
||||||
|
"EXTDPMTCHK",
|
||||||
|
"EXTDPMTFOR",
|
||||||
|
"FAXPHONE",
|
||||||
|
"FEE",
|
||||||
|
"FEEMSG",
|
||||||
|
"FICERTID",
|
||||||
|
"FID",
|
||||||
|
"FINALAMT",
|
||||||
|
"FINAME",
|
||||||
|
"FINCHG",
|
||||||
|
"FIRSTNAME",
|
||||||
|
"FITID",
|
||||||
|
"FREQ",
|
||||||
|
"FROM",
|
||||||
|
"GENUSERKEY",
|
||||||
|
"GETMIMESUP",
|
||||||
|
"HASEXTDPMT",
|
||||||
|
"IDSCOPE",
|
||||||
|
"INCIMAGES",
|
||||||
|
"INITIALAMT",
|
||||||
|
"INTLXFERFEE",
|
||||||
|
"INVALIDACCTTYPE",
|
||||||
|
"INVDATE",
|
||||||
|
"INVDESC",
|
||||||
|
"INVNO",
|
||||||
|
"INVPAIDAMT",
|
||||||
|
"INVTOTALAMT",
|
||||||
|
"LANGUAGE",
|
||||||
|
"LASTNAME",
|
||||||
|
"LITMAMT",
|
||||||
|
"LITMDESC",
|
||||||
|
"LOSTSYNC",
|
||||||
|
"MAILSUP",
|
||||||
|
"MAX",
|
||||||
|
"MEMO",
|
||||||
|
"MESSAGE",
|
||||||
|
"MFACHALLENGEFIRST",
|
||||||
|
"MFACHALLENGESUPT",
|
||||||
|
"MFAPHRASEA",
|
||||||
|
"MFAPHRASEID",
|
||||||
|
"MFAPHRASELABEL",
|
||||||
|
"MIDDLENAME",
|
||||||
|
"MIN",
|
||||||
|
"MINPMTDUE",
|
||||||
|
"MKTGINFO",
|
||||||
|
"MODELWND",
|
||||||
|
"MODPENDING",
|
||||||
|
"NAME",
|
||||||
|
"NEWUSERPASS",
|
||||||
|
"NINSTS",
|
||||||
|
"NONCE",
|
||||||
|
"OFXSEC",
|
||||||
|
"ORG",
|
||||||
|
"PAYACCT",
|
||||||
|
"PAYANDCREDIT",
|
||||||
|
"PAYEEID",
|
||||||
|
"PAYEELSTID",
|
||||||
|
"PAYINSTRUCT",
|
||||||
|
"PHONE",
|
||||||
|
"PINCH",
|
||||||
|
"PMTBYADDR",
|
||||||
|
"PMTBYPAYEEID",
|
||||||
|
"PMTBYXFER",
|
||||||
|
"PMTPRCCODE",
|
||||||
|
"POSTALCODE",
|
||||||
|
"POSTPROCWND",
|
||||||
|
"PROCDAYSOFF",
|
||||||
|
"PROCENDTM",
|
||||||
|
"PURANDADV",
|
||||||
|
"RECSRVRTID",
|
||||||
|
"REFNUM",
|
||||||
|
"REFRESH",
|
||||||
|
"REFRESHSUPT",
|
||||||
|
"REJECTIFMISSING",
|
||||||
|
"RESPFILEER",
|
||||||
|
"SECURITYNAME",
|
||||||
|
"SESSCOOKIE",
|
||||||
|
"SEVERITY",
|
||||||
|
"SIC",
|
||||||
|
"SIGNONREALM",
|
||||||
|
"SPACES",
|
||||||
|
"SPECIAL",
|
||||||
|
"SPNAME",
|
||||||
|
"SRVRTID",
|
||||||
|
"STATE",
|
||||||
|
"STPCHKFEE",
|
||||||
|
"STSVIAMODS",
|
||||||
|
"SUBJECT",
|
||||||
|
"SUPTXDL",
|
||||||
|
"SVC",
|
||||||
|
"SVCSTATUS",
|
||||||
|
"SYNCMODE",
|
||||||
|
"TAN",
|
||||||
|
"TAXID",
|
||||||
|
"TEMPPASS",
|
||||||
|
"TO",
|
||||||
|
"TOKEN",
|
||||||
|
"TOKENONLY",
|
||||||
|
"TOTALFEES",
|
||||||
|
"TOTALINT",
|
||||||
|
"TRANSPSEC",
|
||||||
|
"TRNAMT",
|
||||||
|
"TRNTYPE",
|
||||||
|
"TRNUID",
|
||||||
|
"TSKEYEXPIRE",
|
||||||
|
"TSPHONE",
|
||||||
|
"URL",
|
||||||
|
"USEHTML",
|
||||||
|
"USERID",
|
||||||
|
"USERKEY",
|
||||||
|
"USERPASS",
|
||||||
|
"VALUE",
|
||||||
|
"VER",
|
||||||
|
"XFERDAYSWITH",
|
||||||
|
"XFERDEST",
|
||||||
|
"XFERDFLTDAYSTOPAY",
|
||||||
|
"XFERPRCCODE",
|
||||||
|
"XFERSRC",
|
||||||
|
}
|
378
ofx.go
Normal file
378
ofx.go
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
package ofxgo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/golang/go/src/encoding/xml"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OfxMessage interface {
|
||||||
|
Name() string
|
||||||
|
Valid() (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfxRequest struct {
|
||||||
|
URL string
|
||||||
|
Version string // String for OFX header, defaults to 203
|
||||||
|
Signon OfxSignonRequest //<SIGNONMSGSETV1>
|
||||||
|
Signup []OfxMessage //<SIGNUPMSGSETV1>
|
||||||
|
//<BANKMSGSETV1>
|
||||||
|
//<CREDITCARDMSGSETV1>
|
||||||
|
//<LOANMSGSETV1>
|
||||||
|
//<INVSTMTMSGSETV1>
|
||||||
|
//<INTERXFERMSGSETV1>
|
||||||
|
//<WIREXFERMSGSETV1>
|
||||||
|
//<BILLPAYMSGSETV1>
|
||||||
|
//<EMAILMSGSETV1>
|
||||||
|
//<SECLISTMSGSETV1>
|
||||||
|
//<PRESDIRMSGSETV1>
|
||||||
|
//<PRESDLVMSGSETV1>
|
||||||
|
Profile []OfxMessage //<PROFMSGSETV1>
|
||||||
|
//<IMAGEMSGSETV1>
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oq *OfxRequest) marshalMessageSet(e *xml.Encoder, requests []OfxMessage, setname string) error {
|
||||||
|
if len(requests) > 0 {
|
||||||
|
messageSetElement := xml.StartElement{Name: xml.Name{Local: setname}}
|
||||||
|
if err := e.EncodeToken(messageSetElement); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, request := range requests {
|
||||||
|
if ok, err := request.Valid(); !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := e.Encode(request); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.EncodeToken(messageSetElement.End()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oq *OfxRequest) Marshal() (*bytes.Buffer, error) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
|
||||||
|
if len(oq.Version) == 0 {
|
||||||
|
oq.Version = "203"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch oq.Version {
|
||||||
|
case "102", "103", "151", "160":
|
||||||
|
b.WriteString(`OFXHEADER:100
|
||||||
|
DATA:OFXSGML
|
||||||
|
VERSION:` + oq.Version + `
|
||||||
|
SECURITY:NONE
|
||||||
|
ENCODING:USASCII
|
||||||
|
CHARSET:1252
|
||||||
|
COMPRESSION:NONE
|
||||||
|
OLDFILEUID:NONE
|
||||||
|
NEWFILEUID:NONE
|
||||||
|
|
||||||
|
`)
|
||||||
|
case "200", "201", "202", "203", "210", "211", "220":
|
||||||
|
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="no"?>` + "\n")
|
||||||
|
b.WriteString(`<?OFX OFXHEADER="200" VERSION="` + oq.Version + `" SECURITY="NONE" OLDFILEUID="NONE" NEWFILEUID="NONE"?>` + "\n")
|
||||||
|
default:
|
||||||
|
return nil, errors.New(oq.Version + " is not a valid OFX version string")
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := xml.NewEncoder(&b)
|
||||||
|
encoder.Indent("", " ")
|
||||||
|
|
||||||
|
ofxElement := xml.StartElement{Name: xml.Name{Local: "OFX"}}
|
||||||
|
|
||||||
|
if err := encoder.EncodeToken(ofxElement); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok, err := oq.Signon.Valid(); !ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
signonMsgSet := xml.StartElement{Name: xml.Name{Local: "SIGNONMSGSRQV1"}}
|
||||||
|
if err := encoder.EncodeToken(signonMsgSet); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := encoder.Encode(&oq.Signon); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := encoder.EncodeToken(signonMsgSet.End()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := oq.marshalMessageSet(encoder, oq.Signup, "SIGNUPMSGSRQV1"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := oq.marshalMessageSet(encoder, oq.Profile, "PROFMSGSRQV1"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := encoder.EncodeToken(ofxElement.End()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := encoder.Flush(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oq *OfxRequest) Request() (*OfxResponse, error) {
|
||||||
|
oq.Signon.Dtclient = OfxDate(time.Now())
|
||||||
|
|
||||||
|
b, err := oq.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
response, err := http.Post(oq.URL, "application/x-ofx", b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != 200 {
|
||||||
|
return nil, errors.New("OFXQuery request status: " + response.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help the parser out by giving it a clue about what header format to
|
||||||
|
// expect
|
||||||
|
var xmlVersion bool = true
|
||||||
|
switch oq.Version {
|
||||||
|
case "102", "103", "151", "160":
|
||||||
|
xmlVersion = false
|
||||||
|
}
|
||||||
|
|
||||||
|
var ofxresp OfxResponse
|
||||||
|
if err := ofxresp.Unmarshal(response.Body, xmlVersion); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ofxresp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfxResponse struct {
|
||||||
|
Version string // String for OFX header, defaults to 203
|
||||||
|
Signon OfxSignonResponse //<SIGNONMSGSETV1>
|
||||||
|
Signup []OfxMessage //<SIGNUPMSGSETV1>
|
||||||
|
//<BANKMSGSETV1>
|
||||||
|
//<CREDITCARDMSGSETV1>
|
||||||
|
//<LOANMSGSETV1>
|
||||||
|
//<INVSTMTMSGSETV1>
|
||||||
|
//<INTERXFERMSGSETV1>
|
||||||
|
//<WIREXFERMSGSETV1>
|
||||||
|
//<BILLPAYMSGSETV1>
|
||||||
|
//<EMAILMSGSETV1>
|
||||||
|
//<SECLISTMSGSETV1>
|
||||||
|
//<PRESDIRMSGSETV1>
|
||||||
|
//<PRESDLVMSGSETV1>
|
||||||
|
Profile []OfxMessage //<PROFMSGSETV1>
|
||||||
|
//<IMAGEMSGSETV1>
|
||||||
|
}
|
||||||
|
|
||||||
|
func (or *OfxResponse) readSGMLHeaders(r *bufio.Reader) error {
|
||||||
|
var seenHeader, seenVersion bool = false, false
|
||||||
|
for {
|
||||||
|
line, err := r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// r.ReadString leaves the '\n' on the end...
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if len(line) == 0 {
|
||||||
|
if seenHeader {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header := strings.SplitN(line, ":", 2)
|
||||||
|
if header == nil || len(header) != 2 {
|
||||||
|
return errors.New("OFX headers malformed")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch header[0] {
|
||||||
|
case "OFXHEADER":
|
||||||
|
if header[1] != "100" {
|
||||||
|
return errors.New("OFXHEADER is not 100")
|
||||||
|
}
|
||||||
|
seenHeader = true
|
||||||
|
case "DATA":
|
||||||
|
if header[1] != "OFXSGML" {
|
||||||
|
return errors.New("OFX DATA header does not contain OFXSGML")
|
||||||
|
}
|
||||||
|
case "VERSION":
|
||||||
|
switch header[1] {
|
||||||
|
case "102", "103", "151", "160":
|
||||||
|
seenVersion = true
|
||||||
|
or.Version = header[1]
|
||||||
|
default:
|
||||||
|
return errors.New("Invalid OFX VERSION in header")
|
||||||
|
}
|
||||||
|
case "SECURITY":
|
||||||
|
if header[1] != "NONE" {
|
||||||
|
return errors.New("OFX SECURITY header not NONE")
|
||||||
|
}
|
||||||
|
case "COMPRESSION":
|
||||||
|
if header[1] != "NONE" {
|
||||||
|
return errors.New("OFX COMPRESSION header not NONE")
|
||||||
|
}
|
||||||
|
case "ENCODING", "CHARSET", "OLDFILEUID", "NEWFILEUID":
|
||||||
|
// TODO check/handle these headers?
|
||||||
|
default:
|
||||||
|
return errors.New("Invalid OFX header: " + header[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !seenVersion {
|
||||||
|
return errors.New("OFX VERSION header missing")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (or *OfxResponse) readXMLHeaders(decoder *xml.Decoder) error {
|
||||||
|
tok, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if xmlElem, ok := tok.(xml.ProcInst); !ok || xmlElem.Target != "xml" {
|
||||||
|
return errors.New("Missing xml processing instruction")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse the OFX header
|
||||||
|
tok, err = decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if ofxElem, ok := tok.(xml.ProcInst); ok && ofxElem.Target == "OFX" {
|
||||||
|
var seenHeader, seenVersion bool = false, false
|
||||||
|
|
||||||
|
headers := bytes.TrimSpace(ofxElem.Inst)
|
||||||
|
for len(headers) > 0 {
|
||||||
|
tmp := bytes.SplitN(headers, []byte("=\""), 2)
|
||||||
|
if len(tmp) != 2 {
|
||||||
|
return errors.New("Malformed OFX header")
|
||||||
|
}
|
||||||
|
header := string(tmp[0])
|
||||||
|
headers = tmp[1]
|
||||||
|
tmp = bytes.SplitN(headers, []byte("\""), 2)
|
||||||
|
if len(tmp) != 2 {
|
||||||
|
return errors.New("Malformed OFX header")
|
||||||
|
}
|
||||||
|
value := string(tmp[0])
|
||||||
|
headers = bytes.TrimSpace(tmp[1])
|
||||||
|
|
||||||
|
switch header {
|
||||||
|
case "OFXHEADER":
|
||||||
|
if value != "200" {
|
||||||
|
return errors.New("OFXHEADER is not 200")
|
||||||
|
}
|
||||||
|
seenHeader = true
|
||||||
|
case "VERSION":
|
||||||
|
switch value {
|
||||||
|
case "200", "201", "202", "203", "210", "211", "220":
|
||||||
|
seenVersion = true
|
||||||
|
or.Version = value
|
||||||
|
default:
|
||||||
|
return errors.New("Invalid OFX VERSION in header")
|
||||||
|
}
|
||||||
|
case "SECURITY":
|
||||||
|
if value != "NONE" {
|
||||||
|
return errors.New("OFX SECURITY header not NONE")
|
||||||
|
}
|
||||||
|
case "OLDFILEUID", "NEWFILEUID":
|
||||||
|
// TODO check/handle these headers?
|
||||||
|
default:
|
||||||
|
return errors.New("Invalid OFX header: " + header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !seenHeader {
|
||||||
|
return errors.New("OFXHEADER version missing")
|
||||||
|
}
|
||||||
|
if !seenVersion {
|
||||||
|
return errors.New("OFX VERSION header missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return errors.New("Missing xml 'OFX' processing instruction")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (or *OfxResponse) Unmarshal(reader io.Reader, xmlVersion bool) error {
|
||||||
|
r := bufio.NewReader(reader)
|
||||||
|
|
||||||
|
// parse SGML headers before creating XML decoder
|
||||||
|
if !xmlVersion {
|
||||||
|
if err := or.readSGMLHeaders(r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := xml.NewDecoder(r)
|
||||||
|
if !xmlVersion {
|
||||||
|
decoder.Strict = false
|
||||||
|
decoder.AutoCloseAfterCharData = ofxLeafElements
|
||||||
|
}
|
||||||
|
|
||||||
|
if xmlVersion {
|
||||||
|
// parse the xml header
|
||||||
|
if err := or.readXMLHeaders(decoder); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if ofxStart, ok := tok.(xml.StartElement); !ok || ofxStart.Name.Local != "OFX" {
|
||||||
|
return errors.New("Missing opening OFX xml element")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal the signon message
|
||||||
|
tok, err = decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if signonStart, ok := tok.(xml.StartElement); ok && signonStart.Name.Local == "SIGNONMSGSRSV1" {
|
||||||
|
if err := decoder.Decode(&or.Signon); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return errors.New("Missing opening SIGNONMSGSRSV1 xml element")
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err = decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if signonEnd, ok := tok.(xml.EndElement); !ok || signonEnd.Name.Local != "SIGNONMSGSRSV1" {
|
||||||
|
return errors.New("Missing closing SIGNONMSGSRSV1 xml element")
|
||||||
|
}
|
||||||
|
if ok, err := or.Signon.Valid(); !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
tok, err = decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if ofxEnd, ok := tok.(xml.EndElement); ok && ofxEnd.Name.Local == "OFX" {
|
||||||
|
return nil // found closing XML element, so we're done
|
||||||
|
} else if start, ok := tok.(xml.StartElement); ok {
|
||||||
|
// TODO decode other types
|
||||||
|
fmt.Println("Found starting element for: " + start.Name.Local)
|
||||||
|
} else {
|
||||||
|
return errors.New("Found unexpected token")
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder.Skip()
|
||||||
|
}
|
||||||
|
}
|
24
profile.go
Normal file
24
profile.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package ofxgo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/go/src/encoding/xml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OfxProfileRequest struct {
|
||||||
|
XMLName xml.Name `xml:"PROFTRNRQ"`
|
||||||
|
TrnUID OfxUID `xml:"TRNUID"`
|
||||||
|
ClientRouting OfxString `xml:"PROFRQ>CLIENTROUTING"` // Forced to NONE
|
||||||
|
DtProfup OfxDate `xml:"PROFRQ>DTPROFUP"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OfxProfileRequest) Name() string {
|
||||||
|
return "PROFTRNRQ"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OfxProfileRequest) Valid() (bool, error) {
|
||||||
|
if ok, err := r.TrnUID.Valid(); !ok {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
r.ClientRouting = "NONE"
|
||||||
|
return true, nil
|
||||||
|
}
|
102
signon.go
Normal file
102
signon.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package ofxgo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/golang/go/src/encoding/xml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OfxSignonRequest struct {
|
||||||
|
XMLName xml.Name `xml:"SONRQ"`
|
||||||
|
Dtclient OfxDate `xml:"DTCLIENT"` // Overridden in OfxRequest.Request()
|
||||||
|
UserId OfxString `xml:"USERID"`
|
||||||
|
UserPass OfxString `xml:"USERPASS,omitempty"`
|
||||||
|
UserKey OfxString `xml:"USERKEY,omitempty"`
|
||||||
|
Language OfxString `xml:"LANGUAGE"` // Defaults to ENG
|
||||||
|
Org OfxString `xml:"FI>ORG"`
|
||||||
|
Fid OfxString `xml:"FI>FID"`
|
||||||
|
AppId OfxString `xml:"APPID"` // Defaults to OFXGO
|
||||||
|
AppVer OfxString `xml:"APPVER"` // Defaults to 0001
|
||||||
|
ClientUID OfxUID `xml:"CLIENTUID,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OfxSignonRequest) Name() string {
|
||||||
|
return "SONRQ"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OfxSignonRequest) Valid() (bool, error) {
|
||||||
|
if len(r.UserId) < 1 || len(r.UserId) > 32 {
|
||||||
|
return false, errors.New("SONRQ>USERID invalid length")
|
||||||
|
}
|
||||||
|
if (len(r.UserPass) == 0) == (len(r.UserKey) == 0) {
|
||||||
|
return false, errors.New("One and only one of SONRQ>USERPASS and USERKEY must be supplied")
|
||||||
|
}
|
||||||
|
if len(r.UserPass) > 32 {
|
||||||
|
return false, errors.New("SONRQ>USERPASS invalid length")
|
||||||
|
}
|
||||||
|
if len(r.UserKey) > 64 {
|
||||||
|
return false, errors.New("SONRQ>USERKEY invalid length")
|
||||||
|
}
|
||||||
|
if len(r.Language) == 0 {
|
||||||
|
r.Language = "ENG"
|
||||||
|
} else if len(r.Language) != 3 {
|
||||||
|
return false, errors.New("SONRQ>LANGUAGE invalid length")
|
||||||
|
}
|
||||||
|
if len(r.AppId) == 0 {
|
||||||
|
r.AppId = "OFXGO"
|
||||||
|
} else if len(r.AppId) > 5 {
|
||||||
|
return false, errors.New("SONRQ>APPID invalid length")
|
||||||
|
}
|
||||||
|
if len(r.AppVer) == 0 {
|
||||||
|
r.AppVer = "0001"
|
||||||
|
} else if len(r.AppVer) > 4 {
|
||||||
|
return false, errors.New("SONRQ>APPVER invalid length")
|
||||||
|
}
|
||||||
|
if ok, err := r.ClientUID.Valid(); !ok {
|
||||||
|
if len(r.ClientUID) > 0 { // ClientUID isn't required
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfxStatus struct {
|
||||||
|
XMLName xml.Name `xml:"STATUS"`
|
||||||
|
Code OfxInt `xml:"CODE"`
|
||||||
|
Severity OfxString `xml:"SEVERITY"`
|
||||||
|
Message OfxString `xml:"MESSAGE,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OfxStatus) Valid() (bool, error) {
|
||||||
|
switch s.Severity {
|
||||||
|
case "INFO", "WARN", "ERROR":
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
return false, errors.New("Invalid STATUS>SEVERITY")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfxSignonResponse struct {
|
||||||
|
XMLName xml.Name `xml:"SONRS"`
|
||||||
|
Status OfxStatus `xml:"STATUS"`
|
||||||
|
Dtserver OfxDate `xml:"DTSERVER"`
|
||||||
|
UserKey OfxString `xml:"USERKEY,omitempty"`
|
||||||
|
TsKeyExpire OfxDate `xml:"TSKEYEXPIRE,omitempty"`
|
||||||
|
Language OfxString `xml:"LANGUAGE"`
|
||||||
|
Dtprofup OfxDate `xml:"DTPROFUP,omitempty"`
|
||||||
|
Dtacctup OfxDate `xml:"DTACCTUP,omitempty"`
|
||||||
|
Org OfxString `xml:"FI>ORG"`
|
||||||
|
Fid OfxString `xml:"FI>FID"`
|
||||||
|
SessCookie OfxString `xml:"SESSCOOKIE,omitempty"`
|
||||||
|
AccessKey OfxString `xml:"ACCESSKEY,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OfxSignonResponse) Name() string {
|
||||||
|
return "SONRS"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OfxSignonResponse) Valid() (bool, error) {
|
||||||
|
if len(r.Language) != 3 {
|
||||||
|
return false, errors.New("SONRS>LANGUAGE invalid length: " + string(r.Language))
|
||||||
|
}
|
||||||
|
return r.Status.Valid()
|
||||||
|
}
|
165
types.go
Normal file
165
types.go
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
package ofxgo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/golang/go/src/encoding/xml"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OfxInt int64
|
||||||
|
|
||||||
|
func (oi *OfxInt) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||||
|
var value string
|
||||||
|
err := d.DecodeElement(&value, &start)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*oi = (OfxInt)(i)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ofxDateFormats = []string{
|
||||||
|
"20060102150405.000",
|
||||||
|
"20060102150405",
|
||||||
|
"200601021504",
|
||||||
|
"2006010215",
|
||||||
|
"20060102",
|
||||||
|
}
|
||||||
|
var ofxDateZoneFormat = "20060102150405.000 -0700"
|
||||||
|
var ofxDateZoneRegex = regexp.MustCompile(`^\[([+-]?[0-9]+)(\.([0-9]{2}))?(:([A-Z]+))?\]$`)
|
||||||
|
|
||||||
|
type OfxDate time.Time
|
||||||
|
|
||||||
|
func (od *OfxDate) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||||
|
var value string
|
||||||
|
err := d.DecodeElement(&value, &start)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
|
if len(value) > len(ofxDateFormats[0]) {
|
||||||
|
matches := ofxDateZoneRegex.FindStringSubmatch(value[len(ofxDateFormats[0]):])
|
||||||
|
if matches == nil {
|
||||||
|
return errors.New("Invalid OFX Date Format")
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
var zonehours, zoneminutes int
|
||||||
|
zonehours, err = strconv.Atoi(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(matches[3]) > 0 {
|
||||||
|
zoneminutes, err = strconv.Atoi(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
zoneminutes = zoneminutes * 60 / 100
|
||||||
|
}
|
||||||
|
value = value[:len(ofxDateFormats[0])] + " " + fmt.Sprintf("%+d%02d", zonehours, zoneminutes)
|
||||||
|
t, err := time.Parse(ofxDateZoneFormat, value)
|
||||||
|
if err == nil {
|
||||||
|
tmpod := OfxDate(t)
|
||||||
|
*od = tmpod
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, format := range ofxDateFormats {
|
||||||
|
t, err := time.Parse(format, value)
|
||||||
|
if err == nil {
|
||||||
|
tmpod := OfxDate(t)
|
||||||
|
*od = tmpod
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("OFX: Couldn't parse date:" + value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (od *OfxDate) String() string {
|
||||||
|
t := time.Time(*od)
|
||||||
|
format := t.Format(ofxDateFormats[0])
|
||||||
|
zonename, zoneoffset := t.Zone()
|
||||||
|
format += "[" + fmt.Sprintf("%+d", zoneoffset/3600)
|
||||||
|
fractionaloffset := (zoneoffset % 3600) / 360
|
||||||
|
if fractionaloffset > 0 {
|
||||||
|
format += "." + fmt.Sprintf("%02d", fractionaloffset)
|
||||||
|
} else if fractionaloffset < 0 {
|
||||||
|
format += "." + fmt.Sprintf("%02d", -fractionaloffset)
|
||||||
|
}
|
||||||
|
return format + ":" + zonename + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (od *OfxDate) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||||
|
return e.EncodeElement(od.String(), start)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfxString string
|
||||||
|
|
||||||
|
func (os *OfxString) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||||
|
var value string
|
||||||
|
err := d.DecodeElement(&value, &start)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*os = OfxString(strings.TrimSpace(value))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfxBoolean bool
|
||||||
|
|
||||||
|
func (ob *OfxBoolean) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||||
|
var value string
|
||||||
|
err := d.DecodeElement(&value, &start)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpob := strings.TrimSpace(value)
|
||||||
|
switch tmpob {
|
||||||
|
case "Y":
|
||||||
|
*ob = OfxBoolean(true)
|
||||||
|
case "N":
|
||||||
|
*ob = OfxBoolean(false)
|
||||||
|
default:
|
||||||
|
return errors.New("Invalid OFX Boolean")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ob *OfxBoolean) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||||
|
if *ob {
|
||||||
|
return e.EncodeElement("Y", start)
|
||||||
|
}
|
||||||
|
return e.EncodeElement("N", start)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfxUID string
|
||||||
|
|
||||||
|
func (ou *OfxUID) Valid() (bool, error) {
|
||||||
|
if len(*ou) != 36 {
|
||||||
|
return false, errors.New("UID not 36 characters long")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RandomUID() (*OfxUID, error) {
|
||||||
|
uidbytes := make([]byte, 16)
|
||||||
|
n, err := rand.Read(uidbytes[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n != 16 {
|
||||||
|
return nil, errors.New("RandomUID failed to read 16 random bytes")
|
||||||
|
}
|
||||||
|
uid := OfxUID(fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", uidbytes[:4], uidbytes[4:6], uidbytes[6:8], uidbytes[8:10], uidbytes[10:]))
|
||||||
|
return &uid, nil
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user