mirror of
https://github.com/aclindsa/ofxgo.git
synced 2024-11-24 20:10:06 -05:00
387 lines
10 KiB
Go
387 lines
10 KiB
Go
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
|
|
}
|