2017-03-11 07:15:15 -05:00
|
|
|
package ofxgo
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bufio"
|
|
|
|
"bytes"
|
|
|
|
"errors"
|
|
|
|
"github.com/golang/go/src/encoding/xml"
|
|
|
|
"io"
|
|
|
|
"net/http"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
)
|
|
|
|
|
2017-03-11 07:18:02 -05:00
|
|
|
type Message interface {
|
2017-03-11 07:15:15 -05:00
|
|
|
Name() string
|
|
|
|
Valid() (bool, error)
|
|
|
|
}
|
|
|
|
|
2017-03-11 07:18:02 -05:00
|
|
|
type Request struct {
|
2017-03-11 07:15:15 -05:00
|
|
|
URL string
|
2017-03-11 07:18:02 -05:00
|
|
|
Version string // String for OFX header, defaults to 203
|
|
|
|
Signon SignonRequest //<SIGNONMSGSETV1>
|
|
|
|
Signup []Message //<SIGNUPMSGSETV1>
|
2017-03-11 07:15:15 -05:00
|
|
|
//<BANKMSGSETV1>
|
|
|
|
//<CREDITCARDMSGSETV1>
|
|
|
|
//<LOANMSGSETV1>
|
|
|
|
//<INVSTMTMSGSETV1>
|
|
|
|
//<INTERXFERMSGSETV1>
|
|
|
|
//<WIREXFERMSGSETV1>
|
|
|
|
//<BILLPAYMSGSETV1>
|
|
|
|
//<EMAILMSGSETV1>
|
|
|
|
//<SECLISTMSGSETV1>
|
|
|
|
//<PRESDIRMSGSETV1>
|
|
|
|
//<PRESDLVMSGSETV1>
|
2017-03-11 07:18:02 -05:00
|
|
|
Profile []Message //<PROFMSGSETV1>
|
2017-03-11 07:15:15 -05:00
|
|
|
//<IMAGEMSGSETV1>
|
|
|
|
}
|
|
|
|
|
2017-03-11 07:18:02 -05:00
|
|
|
func (oq *Request) marshalMessageSet(e *xml.Encoder, requests []Message, setname string) error {
|
2017-03-11 07:15:15 -05:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2017-03-11 07:18:02 -05:00
|
|
|
func (oq *Request) Marshal() (*bytes.Buffer, error) {
|
2017-03-11 07:15:15 -05:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2017-03-11 07:18:02 -05:00
|
|
|
func (oq *Request) Request() (*Response, error) {
|
2017-03-11 21:13:06 -05:00
|
|
|
oq.Signon.DtClient = Date(time.Now())
|
2017-03-11 07:15:15 -05:00
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2017-03-11 07:18:02 -05:00
|
|
|
var ofxresp Response
|
2017-03-11 07:15:15 -05:00
|
|
|
if err := ofxresp.Unmarshal(response.Body, xmlVersion); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return &ofxresp, nil
|
|
|
|
}
|
|
|
|
|
2017-03-11 07:18:02 -05:00
|
|
|
type Response struct {
|
|
|
|
Version string // String for OFX header, defaults to 203
|
|
|
|
Signon SignonResponse //<SIGNONMSGSETV1>
|
|
|
|
Signup []Message //<SIGNUPMSGSETV1>
|
2017-03-11 07:15:15 -05:00
|
|
|
//<BANKMSGSETV1>
|
|
|
|
//<CREDITCARDMSGSETV1>
|
|
|
|
//<LOANMSGSETV1>
|
|
|
|
//<INVSTMTMSGSETV1>
|
|
|
|
//<INTERXFERMSGSETV1>
|
|
|
|
//<WIREXFERMSGSETV1>
|
|
|
|
//<BILLPAYMSGSETV1>
|
|
|
|
//<EMAILMSGSETV1>
|
|
|
|
//<SECLISTMSGSETV1>
|
|
|
|
//<PRESDIRMSGSETV1>
|
|
|
|
//<PRESDLVMSGSETV1>
|
2017-03-11 07:18:02 -05:00
|
|
|
Profile []Message //<PROFMSGSETV1>
|
2017-03-11 07:15:15 -05:00
|
|
|
//<IMAGEMSGSETV1>
|
|
|
|
}
|
|
|
|
|
2017-03-11 07:18:02 -05:00
|
|
|
func (or *Response) readSGMLHeaders(r *bufio.Reader) error {
|
2017-03-11 07:15:15 -05:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2017-03-13 21:10:19 -04:00
|
|
|
func nextNonWhitespaceToken(decoder *xml.Decoder) (xml.Token, error) {
|
|
|
|
for {
|
|
|
|
tok, err := decoder.Token()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
} else if chars, ok := tok.(xml.CharData); ok {
|
|
|
|
strippedBytes := bytes.TrimSpace(chars)
|
|
|
|
if len(strippedBytes) != 0 {
|
|
|
|
return tok, nil
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
return tok, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-03-11 07:18:02 -05:00
|
|
|
func (or *Response) readXMLHeaders(decoder *xml.Decoder) error {
|
2017-03-13 21:10:19 -04:00
|
|
|
var tok xml.Token
|
|
|
|
tok, err := nextNonWhitespaceToken(decoder)
|
2017-03-11 07:15:15 -05:00
|
|
|
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
|
2017-03-13 21:10:19 -04:00
|
|
|
tok, err = nextNonWhitespaceToken(decoder)
|
2017-03-11 07:15:15 -05:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2017-03-11 07:18:02 -05:00
|
|
|
func (or *Response) Unmarshal(reader io.Reader, xmlVersion bool) error {
|
2017-03-11 07:15:15 -05:00
|
|
|
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
|
|
|
|
}
|
2017-03-13 21:10:43 -04:00
|
|
|
decoder.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
|
|
|
|
return input, nil
|
|
|
|
}
|
2017-03-11 07:15:15 -05:00
|
|
|
|
|
|
|
if xmlVersion {
|
|
|
|
// parse the xml header
|
|
|
|
if err := or.readXMLHeaders(decoder); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-03-13 21:10:19 -04:00
|
|
|
tok, err := nextNonWhitespaceToken(decoder)
|
2017-03-11 07:15:15 -05:00
|
|
|
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
|
2017-03-13 21:10:19 -04:00
|
|
|
tok, err = nextNonWhitespaceToken(decoder)
|
2017-03-11 07:15:15 -05:00
|
|
|
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")
|
|
|
|
}
|
|
|
|
|
2017-03-13 21:10:19 -04:00
|
|
|
tok, err = nextNonWhitespaceToken(decoder)
|
2017-03-11 07:15:15 -05:00
|
|
|
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 {
|
2017-03-13 21:10:19 -04:00
|
|
|
tok, err = nextNonWhitespaceToken(decoder)
|
2017-03-11 07:15:15 -05:00
|
|
|
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
|
2017-03-11 12:59:47 -05:00
|
|
|
switch start.Name.Local {
|
2017-03-11 21:13:06 -05:00
|
|
|
case "SIGNUPMSGSRSV1":
|
|
|
|
msgs, err := DecodeSignupMessageSet(decoder, start)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
or.Signup = msgs
|
2017-03-11 12:59:47 -05:00
|
|
|
//case "BANKMSGSRSV1":
|
|
|
|
//case "CREDITCARDMSGSRSV1":
|
|
|
|
//case "LOANMSGSRSV1":
|
|
|
|
//case "INVSTMTMSGSRSV1":
|
|
|
|
//case "INTERXFERMSGSRSV1":
|
|
|
|
//case "WIREXFERMSGSRSV1":
|
|
|
|
//case "BILLPAYMSGSRSV1":
|
|
|
|
//case "EMAILMSGSRSV1":
|
|
|
|
//case "SECLISTMSGSRSV1":
|
|
|
|
//case "PRESDIRMSGSRSV1":
|
|
|
|
//case "PRESDLVMSGSRSV1":
|
|
|
|
case "PROFMSGSRSV1":
|
|
|
|
msgs, err := DecodeProfileMessageSet(decoder, start)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
or.Profile = msgs
|
2017-03-11 21:13:06 -05:00
|
|
|
//case "IMAGEMSGSRSV1":
|
2017-03-11 12:59:47 -05:00
|
|
|
default:
|
|
|
|
return errors.New("Unsupported message set: " + start.Name.Local)
|
|
|
|
}
|
2017-03-11 07:15:15 -05:00
|
|
|
} else {
|
|
|
|
return errors.New("Found unexpected token")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|