commit 99cd8f7273cbb4fa6c3c5f81d54fa9b118d78b56 Author: Aaron Lindsay Date: Sat Mar 11 07:15:15 2017 -0500 Initial commit diff --git a/acctinfo.go b/acctinfo.go new file mode 100644 index 0000000..d346e67 --- /dev/null +++ b/acctinfo.go @@ -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 +} diff --git a/leaf_elements.go b/leaf_elements.go new file mode 100644 index 0000000..d2b2fd4 --- /dev/null +++ b/leaf_elements.go @@ -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/^.*$/\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", +} diff --git a/ofx.go b/ofx.go new file mode 100644 index 0000000..cb4fe79 --- /dev/null +++ b/ofx.go @@ -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 // + Signup []OfxMessage // + // + // + // + // + // + // + // + // + // + // + // + Profile []OfxMessage // + // +} + +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(`` + "\n") + b.WriteString(`` + "\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 // + Signup []OfxMessage // + // + // + // + // + // + // + // + // + // + // + // + Profile []OfxMessage // + // +} + +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() + } +} diff --git a/profile.go b/profile.go new file mode 100644 index 0000000..f4e103b --- /dev/null +++ b/profile.go @@ -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 +} diff --git a/signon.go b/signon.go new file mode 100644 index 0000000..d7abd35 --- /dev/null +++ b/signon.go @@ -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() +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..22052fc --- /dev/null +++ b/types.go @@ -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 +}