package ofxgo import ( "crypto/rand" "errors" "fmt" "github.com/aclindsa/xml" "golang.org/x/text/currency" "math/big" "regexp" "strconv" "strings" "time" ) // Int provides helper methods to unmarshal int64 values from SGML/XML type Int int64 // UnmarshalXML handles unmarshalling an Int from an SGML/XML string. Leading // and trailing whitespace is ignored. func (i *Int) 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) i2, err := strconv.ParseInt(value, 10, 64) if err != nil { return err } *i = Int(i2) return nil } // Equal returns true if the two Ints are equal in value func (i Int) Equal(o Int) bool { return i == o } // Amount represents non-integer values (or at least values for fields that may // not necessarily be integers) type Amount struct { big.Rat } // UnmarshalXML handles unmarshalling an Amount from an SGML/XML string. // Leading and trailing whitespace is ignored. func (a *Amount) 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) // The OFX spec allows the start of the fractional amount to be delineated // by a comma, so fix that up before attempting to parse it into big.Rat value = strings.Replace(value, ",", ".", 1) if _, ok := a.SetString(value); !ok { return errors.New("Failed to parse OFX amount") } return nil } // String prints a string representation of an Amount func (a Amount) String() string { return strings.TrimRight(strings.TrimRight(a.FloatString(100), "0"), ".") } // MarshalXML marshals an Amount to SGML/XML func (a Amount) MarshalXML(e *xml.Encoder, start xml.StartElement) error { return e.EncodeElement(a.String(), start) } // Equal returns true if two Amounts are equal in value func (a Amount) Equal(o Amount) bool { return (&a).Cmp(&o.Rat) == 0 } // Date represents OFX date/time values type Date struct { time.Time } var ofxDateFormats = []string{ "20060102150405.000", "20060102150405", "200601021504", "2006010215", "20060102", } var ofxDateZoneRegex = regexp.MustCompile(`^([+-]?[0-9]+)(\.([0-9]{2}))?(:([A-Z]+))?$`) // UnmarshalXML handles unmarshalling a Date from an SGML/XML string. It // attempts to unmarshal the valid date formats in order of decreasing length // and defaults to GMT if a time zone is not provided, as per the OFX spec. // Leading and trailing whitespace is ignored. func (od *Date) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { var value, zone, zoneFormat string err := d.DecodeElement(&value, &start) if err != nil { return err } value = strings.SplitN(value, "]", 2)[0] value = strings.TrimSpace(value) // Split the time zone off, if any split := strings.SplitN(value, "[", 2) if len(split) == 2 { value = split[0] zoneFormat = " -0700" zone = strings.TrimRight(split[1], "]") matches := ofxDateZoneRegex.FindStringSubmatch(zone) if matches == nil { return errors.New("Invalid OFX Date timezone format: " + zone) } 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[3]) if err != nil { return err } zoneminutes = zoneminutes * 60 / 100 } zone = fmt.Sprintf(" %+03d%02d", zonehours, zoneminutes) // Get the time zone name if it's there, default to GMT if the offset // is 0 and a name isn't supplied if len(matches[5]) > 0 { zone = zone + " " + matches[5] zoneFormat = zoneFormat + " MST" } else if zonehours == 0 && zoneminutes == 0 { zone = zone + " GMT" zoneFormat = zoneFormat + " MST" } } else { // Default to GMT if no time zone was specified zone = " +0000 GMT" zoneFormat = " -0700 MST" } // Try all the date formats, from longest to shortest for _, format := range ofxDateFormats { t, err := time.Parse(format+zoneFormat, value+zone) if err == nil { od.Time = t return nil } } return errors.New("OFX: Couldn't parse date:" + value) } // String returns a string representation of the Date abiding by the OFX spec func (od Date) String() string { format := od.Format(ofxDateFormats[0]) zonename, zoneoffset := od.Zone() if zoneoffset < 0 { format += "[" + fmt.Sprintf("%+d", zoneoffset/3600) } else { format += "[" + fmt.Sprintf("%d", zoneoffset/3600) } fractionaloffset := (zoneoffset % 3600) / 36 if fractionaloffset > 0 { format += "." + fmt.Sprintf("%02d", fractionaloffset) } else if fractionaloffset < 0 { format += "." + fmt.Sprintf("%02d", -fractionaloffset) } if len(zonename) > 0 { return format + ":" + zonename + "]" } return format + "]" } // MarshalXML marshals a Date to XML func (od Date) MarshalXML(e *xml.Encoder, start xml.StartElement) error { return e.EncodeElement(od.String(), start) } // Equal returns true if the two Dates represent the same time (time zones are // accounted for when comparing, but are not required to match) func (od Date) Equal(o Date) bool { return od.Time.Equal(o.Time) } // NewDate returns a new Date object with the provided date, time, and timezone func NewDate(year int, month time.Month, day, hour, min, sec, nsec int, loc *time.Location) *Date { return &Date{Time: time.Date(year, month, day, hour, min, sec, nsec, loc)} } var gmt = time.FixedZone("GMT", 0) // NewDateGMT returns a new Date object with the provided date and time in the // GMT timezone func NewDateGMT(year int, month time.Month, day, hour, min, sec, nsec int) *Date { return &Date{Time: time.Date(year, month, day, hour, min, sec, nsec, gmt)} } // String provides helper methods to unmarshal OFX string values from SGML/XML type String string // UnmarshalXML handles unmarshalling a String from an SGML/XML string. Leading // and trailing whitespace is ignored. func (os *String) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { var value string err := d.DecodeElement(&value, &start) if err != nil { return err } *os = String(strings.TrimSpace(value)) return nil } // String returns the string func (os *String) String() string { return string(*os) } // Equal returns true if the two Strings are equal in value func (os String) Equal(o String) bool { return os == o } // Boolean provides helper methods to unmarshal bool values from OFX SGML/XML type Boolean bool // UnmarshalXML handles unmarshalling a Boolean from an SGML/XML string. // Leading and trailing whitespace is ignored. func (ob *Boolean) 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 = Boolean(true) case "N": *ob = Boolean(false) default: return errors.New("Invalid OFX Boolean") } return nil } // MarshalXML marshals a Boolean to XML func (ob Boolean) MarshalXML(e *xml.Encoder, start xml.StartElement) error { if ob { return e.EncodeElement("Y", start) } return e.EncodeElement("N", start) } // String returns a string representation of a Boolean value func (ob *Boolean) String() string { return fmt.Sprintf("%v", *ob) } // Equal returns true if the two Booleans are the same func (ob Boolean) Equal(o Boolean) bool { return ob == o } // UID represents an UID according to the OFX spec type UID string // UnmarshalXML handles unmarshalling an UID from an SGML/XML string. Leading // and trailing whitespace is ignored. func (ou *UID) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { var value string err := d.DecodeElement(&value, &start) if err != nil { return err } *ou = UID(strings.TrimSpace(value)) return nil } // RecommendedFormat returns true iff this UID meets the OFX specification's // recommendation that UIDs follow the standard UUID 36-character format func (ou UID) RecommendedFormat() (bool, error) { if len(ou) != 36 { return false, errors.New("UID not 36 characters long") } if ou[8] != '-' || ou[13] != '-' || ou[18] != '-' || ou[23] != '-' { return false, errors.New("UID missing hyphens at the appropriate places") } return true, nil } // Valid returns true, nil if the UID is valid. This is less strict than // RecommendedFormat, and will always return true, nil if it does. func (ou UID) Valid() (bool, error) { if len(ou) == 0 || len(ou) > 36 { return false, errors.New("UID invalid length") } return true, nil } // Equal returns true if the two UIDs are the same func (ou UID) Equal(o UID) bool { return ou == o } // RandomUID creates a new randomly-generated UID func RandomUID() (*UID, 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 := UID(fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", uidbytes[:4], uidbytes[4:6], uidbytes[6:8], uidbytes[8:10], uidbytes[10:])) return &uid, nil } // CurrSymbol represents an ISO-4217 currency type CurrSymbol struct { currency.Unit } // UnmarshalXML handles unmarshalling a CurrSymbol from an SGML/XML string. // Leading and trailing whitespace is ignored. func (c *CurrSymbol) 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) unit, err := currency.ParseISO(value) if err != nil { return errors.New("Error parsing CurrSymbol:" + err.Error()) } c.Unit = unit return nil } // MarshalXML marshals a CurrSymbol to SGML/XML func (c CurrSymbol) MarshalXML(e *xml.Encoder, start xml.StartElement) error { return e.EncodeElement(c.String(), start) } // Equal returns true if the two Currencies are the same func (c CurrSymbol) Equal(o CurrSymbol) bool { return c.String() == o.String() } // Valid returns true, nil if the CurrSymbol is valid. func (c CurrSymbol) Valid() (bool, error) { if c.String() == "XXX" { return false, fmt.Errorf("Invalid CurrSymbol: %s", c.Unit) } return true, nil } // NewCurrSymbol returns a new CurrSymbol given a three-letter ISO-4217 // currency symbol as a string func NewCurrSymbol(s string) (*CurrSymbol, error) { unit, err := currency.ParseISO(s) if err != nil { return nil, errors.New("Error parsing string to create new CurrSymbol:" + err.Error()) } return &CurrSymbol{unit}, nil }