From 1ee7197340b15c79db735b1d7216f92313af007a Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Tue, 18 Apr 2017 19:46:23 -0400 Subject: [PATCH] Add validation of banking requests and responses --- bank.go | 170 +++++++++++++++++++++++++++++++++++++++++++++++++- common.go | 20 ++++++ types.go | 9 +++ types_test.go | 15 +++++ 4 files changed, 211 insertions(+), 3 deletions(-) diff --git a/bank.go b/bank.go index 1af5fea..b31913c 100644 --- a/bank.go +++ b/bank.go @@ -1,6 +1,7 @@ package ofxgo import ( + "errors" "github.com/aclindsa/go/src/encoding/xml" ) @@ -30,8 +31,16 @@ func (r *StatementRequest) Name() string { // Valid returns (true, nil) if this struct would be valid OFX if marshalled // into XML/SGML func (r *StatementRequest) Valid(version ofxVersion) (bool, error) { - // TODO implement - return true, nil + if ok, err := r.TrnUID.Valid(); !ok { + return false, err + } + if r.IncludePending && version < OfxVersion220 { + return false, errors.New("StatementRequest.IncludePending invalid for OFX < 2.2") + } + if r.IncTranImg && version < OfxVersion210 { + return false, errors.New("StatementRequest.IncTranImg invalid for OFX < 2.1") + } + return r.BankAcctFrom.Valid() } // Type returns which message set this message belongs to (which Request @@ -54,6 +63,26 @@ type Payee struct { Phone String `xml:"PHONE"` } +// Valid returns (true, nil) if this struct is valid OFX +func (p Payee) Valid() (bool, error) { + if len(p.Name) == 0 { + return false, errors.New("Payee.Name empty") + } else if len(p.Addr1) == 0 { + return false, errors.New("Payee.Addr1 empty") + } else if len(p.City) == 0 { + return false, errors.New("Payee.City empty") + } else if len(p.State) == 0 { + return false, errors.New("Payee.State empty") + } else if len(p.PostalCode) == 0 { + return false, errors.New("Payee.PostalCode empty") + } else if len(p.Country) != 0 && len(p.Country) != 3 { + return false, errors.New("Payee.Country invalid length") + } else if len(p.Phone) == 0 { + return false, errors.New("Payee.Phone empty") + } + return true, nil +} + // ImageData represents the metadata surrounding a check or other image file, // including how to retrieve the image type ImageData struct { @@ -100,6 +129,49 @@ type Transaction struct { Inv401kSource inv401kSource `xml:"INV401KSOURCE,omitempty"` // One of PRETAX, AFTERTAX, MATCH, PROFITSHARING, ROLLOVER, OTHERVEST, OTHERNONVEST (Default if not present is OTHERNONVEST. The following cash source types are subject to vesting: MATCH, PROFITSHARING, and OTHERVEST.) } +// Valid returns (true, nil) if this struct is valid OFX +func (t Transaction) Valid(version ofxVersion) (bool, error) { + var emptyDate Date + if !t.TrnType.Valid() || t.TrnType == TrnTypeHold { + return false, errors.New("Transaction.TrnType invalid") + } else if t.DtPosted.Equal(emptyDate) { + return false, errors.New("Transaction.DtPosted not filled") + } else if len(t.FiTID) == 0 { + return false, errors.New("Transaction.FiTID empty") + } else if len(t.CorrectFiTID) > 0 && t.CorrectAction.Valid() { + return false, errors.New("Transaction.CorrectFiTID nonempty but CorrectAction invalid") + } else if len(t.Name) > 0 && t.Payee != nil { + return false, errors.New("Only one of Transaction.Name and Payee may be specified") + } + if t.Payee != nil { + if ok, err := t.Payee.Valid(); !ok { + return false, err + } + } + if t.BankAcctTo != nil && t.CCAcctTo != nil { + return false, errors.New("Only one of Transaction.BankAcctTo and CCAcctTo may be specified") + } else if t.BankAcctTo != nil { + if ok, err := t.BankAcctTo.Valid(); !ok { + return false, err + } + } else if t.CCAcctTo != nil { + if ok, err := t.CCAcctTo.Valid(); !ok { + return false, err + } + } + if version < OfxVersion220 && len(t.ImageData) > 0 { + return false, errors.New("Transaction.ImageData only supportd for OFX > 220") + } else if len(t.ImageData) > 2 { + return false, errors.New("Only 2 of ImageData allowed in Transaction") + } + ok1, _ := t.Currency.Valid() + ok2, _ := t.OrigCurrency.Valid() + if ok1 && ok2 { + return false, errors.New("Currency and OrigCurrency both supplied for Pending Transaction, only one allowed") + } + return true, nil +} + // TransactionList represents a list of bank transactions, and also includes // the date range its transactions cover. type TransactionList struct { @@ -109,6 +181,23 @@ type TransactionList struct { Transactions []Transaction `xml:"STMTTRN,omitempty"` } +// Valid returns (true, nil) if this struct is valid OFX +func (l TransactionList) Valid(version ofxVersion) (bool, error) { + var emptyDate Date + if l.DtStart.Equal(emptyDate) { + return false, errors.New("TransactionList.DtStart not filled") + } else if l.DtEnd.Equal(emptyDate) { + return false, errors.New("TransactionList.DtEnd not filled") + } + for _, t := range l.Transactions { + if ok, err := t.Valid(version); !ok { + return false, err + } + } + + return true, nil +} + // PendingTransaction represents a single pending transaction. It is similar to // Transaction, but is not finalized (and may never be). For instance, it lacks // FiTID and DtPosted fields. @@ -127,6 +216,24 @@ type PendingTransaction struct { OrigCurrency CurrSymbol `xml:"ORIGCURRENCY,omitempty"` // If different from CURDEF in STMTTRS } +// Valid returns (true, nil) if this struct is valid OFX +func (t PendingTransaction) Valid() (bool, error) { + var emptyDate Date + if !t.TrnType.Valid() { + return false, errors.New("PendingTransaction.TrnType invalid") + } else if t.DtTran.Equal(emptyDate) { + return false, errors.New("PendingTransaction.DtTran not filled") + } else if len(t.Name) == 0 { + return false, errors.New("PendingTransaction.Name empty") + } + ok1, _ := t.Currency.Valid() + ok2, _ := t.OrigCurrency.Valid() + if ok1 && ok2 { + return false, errors.New("Currency and OrigCurrency both supplied for Pending Transaction, only one allowed") + } + return true, nil +} + // PendingTransactionList represents a list of pending transactions, along with // the date they were generated type PendingTransactionList struct { @@ -135,6 +242,21 @@ type PendingTransactionList struct { Transactions []PendingTransaction `xml:"STMTTRNP,omitempty"` } +// Valid returns (true, nil) if this struct is valid OFX +func (l PendingTransactionList) Valid() (bool, error) { + var emptyDate Date + if l.DtAsOf.Equal(emptyDate) { + return false, errors.New("PendingTransactionList.DtAsOf not filled") + } + for _, t := range l.Transactions { + if ok, err := t.Valid(); !ok { + return false, err + } + } + + return true, nil +} + // Balance represents a generic (free-form) balance defined by an FI. type Balance struct { XMLName xml.Name `xml:"BAL"` @@ -152,6 +274,17 @@ type Balance struct { Currency *Currency `xml:"CURRENCY,omitempty"` // if BALTYPE is DOLLAR } +// Valid returns (true, nil) if this struct is valid OFX +func (b Balance) Valid() (bool, error) { + if len(b.Name) == 0 || len(b.Desc) == 0 { + return false, errors.New("Balance Name and Desc not supplied") + } + if !b.BalType.Valid() { + return false, errors.New("Balance BALTYPE not specified") + } + return true, nil +} + // StatementResponse represents a bank account statement, including its // balances and possibly transactions. It is a response to StatementRequest, or // sometimes provided as part of an OFX file downloaded manually from an FI. @@ -182,7 +315,38 @@ func (sr *StatementResponse) Name() string { // Valid returns (true, nil) if this struct was valid OFX when unmarshalled func (sr *StatementResponse) Valid(version ofxVersion) (bool, error) { - //TODO implement + var emptyDate Date + if ok, err := sr.TrnUID.Valid(); !ok { + return false, err + } else if ok, err := sr.Status.Valid(); !ok { + return false, err + } else if ok, err := sr.CurDef.Valid(); !ok { + return false, err + } else if ok, err := sr.BankAcctFrom.Valid(); !ok { + return false, err + } else if sr.DtAsOf.Equal(emptyDate) { + return false, errors.New("StatementResponse.DtAsOf not filled") + } else if (sr.AvailBalAmt == nil) != (sr.AvailDtAsOf == nil) { + return false, errors.New("StatementResponse.Avail* must both either be present or absent") + } + if sr.BankTranList != nil { + if ok, err := sr.BankTranList.Valid(version); !ok { + return false, err + } + } + if sr.BankTranListP != nil { + if version < OfxVersion220 { + return false, errors.New("StatementResponse.BankTranListP invalid for OFX < 2.2") + } + if ok, err := sr.BankTranListP.Valid(); !ok { + return false, err + } + } + for _, bal := range sr.BalList { + if ok, err := bal.Valid(); !ok { + return false, err + } + } return true, nil } diff --git a/common.go b/common.go index b7c8dc8..d658a35 100644 --- a/common.go +++ b/common.go @@ -274,6 +274,19 @@ type BankAcct struct { AcctKey String `xml:"ACCTKEY,omitempty"` // Unused in USA } +func (b BankAcct) Valid() (bool, error) { + if len(b.BankID) == 0 { + return false, errors.New("BankAcct.BankID empty") + } + if len(b.AcctID) == 0 { + return false, errors.New("BankAcct.AcctID empty") + } + if !b.AcctType.Valid() { + return false, errors.New("Invalid or unspecified BankAcct.AcctType") + } + return true, nil +} + // CCAcct represents the identifying information for one checking account type CCAcct struct { XMLName xml.Name // CCACCTTO or CCACCTFROM @@ -281,6 +294,13 @@ type CCAcct struct { AcctKey String `xml:"ACCTKEY,omitempty"` // Unused in USA } +func (c CCAcct) Valid() (bool, error) { + if len(c.AcctID) == 0 { + return false, errors.New("CCAcct.AcctID empty") + } + return true, nil +} + // InvAcct represents the identifying information for one investment account type InvAcct struct { XMLName xml.Name // INVACCTTO or INVACCTFROM diff --git a/types.go b/types.go index ff5fda2..31ec7ea 100644 --- a/types.go +++ b/types.go @@ -304,6 +304,15 @@ func (ou UID) RecommendedFormat() (bool, error) { 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 diff --git a/types_test.go b/types_test.go index 0497223..b7d0a50 100644 --- a/types_test.go +++ b/types_test.go @@ -364,6 +364,21 @@ func TestUIDRecommendedFormat(t *testing.T) { } } +func TestUIDValid(t *testing.T) { + var u ofxgo.UID = "" + if ok, err := u.Valid(); ok || err == nil { + t.Fatalf("Empty UID unexpectedly valid\n") + } + u = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if ok, err := u.Valid(); ok || err == nil { + t.Fatalf("Too-long UID unexpectedly valid\n") + } + u = "7be37076-623a-425f-ae6b-a5465b7e93b0" + if ok, err := u.Valid(); !ok || err != nil { + t.Fatalf("Good UID unexpectedly invalid: %s\n", err.Error()) + } +} + func TestRandomUID(t *testing.T) { uid, err := ofxgo.RandomUID() if err != nil {