package ofxgo import ( "errors" "github.com/aclindsa/xml" ) // StatementRequest represents a request for a bank statement. It is used to // request balances and/or transactions for checking, savings, money market, // and line of credit accounts. See CCStatementRequest for the analog for // credit card accounts. type StatementRequest struct { XMLName xml.Name `xml:"STMTTRNRQ"` TrnUID UID `xml:"TRNUID"` CltCookie String `xml:"CLTCOOKIE,omitempty"` TAN String `xml:"TAN,omitempty"` // Transaction authorization number // TODO `xml:"OFXEXTENSION,omitempty"` BankAcctFrom BankAcct `xml:"STMTRQ>BANKACCTFROM"` DtStart *Date `xml:"STMTRQ>INCTRAN>DTSTART,omitempty"` DtEnd *Date `xml:"STMTRQ>INCTRAN>DTEND,omitempty"` Include Boolean `xml:"STMTRQ>INCTRAN>INCLUDE"` // Include transactions (instead of just balance) IncludePending Boolean `xml:"STMTRQ>INCLUDEPENDING,omitempty"` // Include pending transactions IncTranImg Boolean `xml:"STMTRQ>INCTRANIMG,omitempty"` // Include transaction images } // Name returns the name of the top-level transaction XML/SGML element func (r *StatementRequest) Name() string { return "STMTTRNRQ" } // Valid returns (true, nil) if this struct would be valid OFX if marshalled // into XML/SGML func (r *StatementRequest) Valid(version ofxVersion) (bool, error) { 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 // element of type []Message it should appended to) func (r *StatementRequest) Type() messageType { return BankRq } // Payee specifies a complete billing address for a payee type Payee struct { XMLName xml.Name `xml:"PAYEE"` Name String `xml:"NAME"` Addr1 String `xml:"ADDR1"` Addr2 String `xml:"ADDR2,omitempty"` Addr3 String `xml:"ADDR3,omitempty"` City String `xml:"CITY"` State String `xml:"STATE"` PostalCode String `xml:"POSTALCODE"` Country String `xml:"COUNTRY,omitempty"` 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 { XMLName xml.Name `xml:"IMAGEDATA"` ImageType imageType `xml:"IMAGETYPE"` // One of STATEMENT, TRANSACTION, TAX ImageRef String `xml:"IMAGEREF"` // URL or identifier, depending on IMAGEREFTYPE ImageRefType imageRefType `xml:"IMAGEREFTYPE"` // One of OPAQUE, URL, FORMURL (see spec for more details on how to access images of each of these types) // Only one of the next two should be valid at any given time ImageDelay Int `xml:"IMAGEDELAY,omitempty"` // Number of calendar days from DTSERVER (for statement images) or DTPOSTED (for transaction image) the image will become available DtImageAvail *Date `xml:"DTIMAGEAVAIL,omitempty"` // Date image will become available ImageTTL Int `xml:"IMAGETTL,omitempty"` // Number of days after image becomes available that it will remain available CheckSup checkSup `xml:"CHECKSUP,omitempty"` // What is contained in check images. One of FRONTONLY, BACKONLY, FRONTANDBACK } // Transaction represents a single banking transaction. At a minimum, it // identifies the type of transaction (TrnType) and the date it was posted // (DtPosted). Ideally it also provides metadata to help the user recognize // this transaction (i.e. CheckNum, Name or Payee, Memo, etc.) type Transaction struct { XMLName xml.Name `xml:"STMTTRN"` TrnType trnType `xml:"TRNTYPE"` // One of CREDIT, DEBIT, INT (interest earned or paid. Note: Depends on signage of amount), DIV, FEE, SRVCHG (service charge), DEP (deposit), ATM (Note: Depends on signage of amount), POS (Note: Depends on signage of amount), XFER, CHECK, PAYMENT, CASH, DIRECTDEP, DIRECTDEBIT, REPEATPMT, OTHER DtPosted Date `xml:"DTPOSTED"` DtUser *Date `xml:"DTUSER,omitempty"` DtAvail *Date `xml:"DTAVAIL,omitempty"` TrnAmt Amount `xml:"TRNAMT"` FiTID String `xml:"FITID"` // Client uses FITID to detect whether it has previously downloaded the transaction CorrectFiTID String `xml:"CORRECTFITID,omitempty"` // Transaction ID that this transaction corrects, if present CorrectAction correctAction `xml:"CORRECTACTION,omitempty"` // One of DELETE, REPLACE SrvrTID String `xml:"SRVRTID,omitempty"` CheckNum String `xml:"CHECKNUM,omitempty"` RefNum String `xml:"REFNUM,omitempty"` SIC Int `xml:"SIC,omitempty"` // Standard Industrial Code PayeeID String `xml:"PAYEEID,omitempty"` // Note: Servers should provide NAME or PAYEE, but not both Name String `xml:"NAME,omitempty"` Payee *Payee `xml:"PAYEE,omitempty"` ExtdName String `xml:"EXTDNAME,omitempty"` // Extended name of payee or transaction description BankAcctTo *BankAcct `xml:"BANKACCTTO,omitempty"` // If the transfer was to a bank account we have the account information for CCAcctTo *CCAcct `xml:"CCACCTTO,omitempty"` // If the transfer was to a credit card account we have the account information for Memo String `xml:"MEMO,omitempty"` // Extra information (not in NAME) ImageData []ImageData `xml:"IMAGEDATA,omitempty"` // Only one of Currency and OrigCurrency can ever be Valid() for the same transaction Currency *Currency `xml:"CURRENCY,omitempty"` // Represents the currency of TrnAmt (instead of CURDEF in STMTRS) if Valid OrigCurrency *Currency `xml:"ORIGCURRENCY,omitempty"` // Represents the currency TrnAmt was converted to STMTRS' CURDEF from if Valid 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") } var ok1, ok2 bool if t.Currency != nil { ok1, _ = t.Currency.Valid() } if t.OrigCurrency != nil { 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 { XMLName xml.Name `xml:"BANKTRANLIST"` DtStart Date `xml:"DTSTART"` // Start date for transaction data DtEnd Date `xml:"DTEND"` // Value that client should send in next request to ensure that it does not miss any transactions 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. type PendingTransaction struct { XMLName xml.Name `xml:"STMTTRNP"` TrnType trnType `xml:"TRNTYPE"` // One of CREDIT, DEBIT, INT (interest earned or paid. Note: Depends on signage of amount), DIV, FEE, SRVCHG (service charge), DEP (deposit), ATM (Note: Depends on signage of amount), POS (Note: Depends on signage of amount), XFER, CHECK, PAYMENT, CASH, DIRECTDEP, DIRECTDEBIT, REPEATPMT, HOLD, OTHER DtTran Date `xml:"DTTRAN"` DtExpire *Date `xml:"DTEXPIRE,omitempty"` // only valid for TrnType==HOLD, the date the hold will expire TrnAmt Amount `xml:"TRNAMT"` RefNum String `xml:"REFNUM,omitempty"` Name String `xml:"NAME,omitempty"` ExtdName String `xml:"EXTDNAME,omitempty"` // Extended name of payee or transaction description Memo String `xml:"MEMO,omitempty"` // Extra information (not in NAME) ImageData []ImageData `xml:"IMAGEDATA,omitempty"` // Only one of Currency and OrigCurrency can ever be Valid() for the same transaction Currency Currency `xml:"CURRENCY,omitempty"` // Represents the currency of TrnAmt (instead of CURDEF in STMTRS) if Valid OrigCurrency Currency `xml:"ORIGCURRENCY,omitempty"` // Represents the currency TrnAmt was converted to STMTRS' CURDEF from if Valid } // 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 { XMLName xml.Name `xml:"BANKTRANLISTP"` DtAsOf Date `xml:"DTASOF"` // Date and time this set of pending transactions was generated 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"` Name String `xml:"NAME"` Desc String `xml:"DESC"` // Balance type: // DOLLAR = dollar (value formatted DDDD.cc) // PERCENT = percentage (value formatted XXXX.YYYY) // NUMBER = number (value formatted as is) BalType balType `xml:"BALTYPE"` Value Amount `xml:"VALUE"` DtAsOf *Date `xml:"DTASOF,omitempty"` 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. type StatementResponse struct { XMLName xml.Name `xml:"STMTTRNRS"` TrnUID UID `xml:"TRNUID"` Status Status `xml:"STATUS"` CltCookie String `xml:"CLTCOOKIE,omitempty"` // TODO `xml:"OFXEXTENSION,omitempty"` CurDef CurrSymbol `xml:"STMTRS>CURDEF"` BankAcctFrom BankAcct `xml:"STMTRS>BANKACCTFROM"` BankTranList *TransactionList `xml:"STMTRS>BANKTRANLIST,omitempty"` BankTranListP *PendingTransactionList `xml:"STMTRS>BANKTRANLISTP,omitempty"` BalAmt Amount `xml:"STMTRS>LEDGERBAL>BALAMT"` DtAsOf Date `xml:"STMTRS>LEDGERBAL>DTASOF"` AvailBalAmt *Amount `xml:"STMTRS>AVAILBAL>BALAMT,omitempty"` AvailDtAsOf *Date `xml:"STMTRS>AVAILBAL>DTASOF,omitempty"` CashAdvBalAmt *Amount `xml:"STMTRS>CASHADVBALAMT,omitempty"` // Only for CREDITLINE accounts, available balance for cash advances IntRate *Amount `xml:"STMTRS>INTRATE,omitempty"` // Current interest rate BalList []Balance `xml:"STMTRS>BALLIST>BAL,omitempty"` MktgInfo String `xml:"STMTRS>MKTGINFO,omitempty"` // Marketing information } // Name returns the name of the top-level transaction XML/SGML element func (sr *StatementResponse) Name() string { return "STMTTRNRS" } // Valid returns (true, nil) if this struct was valid OFX when unmarshalled func (sr *StatementResponse) Valid(version ofxVersion) (bool, error) { 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 } // Type returns which message set this message belongs to (which Response // element of type []Message it belongs to) func (sr *StatementResponse) Type() messageType { return BankRs }