1
0
mirror of https://github.com/aclindsa/ofxgo.git synced 2025-07-02 20:08:38 -04:00

76 Commits

Author SHA1 Message Date
d31ac10d08 Add InvTransaction() method to InvTransaction interface. 2024-04-10 21:16:42 -04:00
4f2c5582d1 Pass by value in MarshalXML. 2024-04-10 09:12:15 -04:00
e302ce2e6b CI: Update go versions tested 2023-03-14 22:18:43 -04:00
e3c4afc0a3 Add SecurityInfo() and InvPosition() tests. 2023-03-14 22:10:19 -04:00
c2490e1c6a Add InvPosition() InvPosition to Position interface. 2023-03-14 22:10:19 -04:00
12aca9ab06 Add SecurityInfo() SecInfo to Security interface. 2023-03-14 22:10:19 -04:00
1f657a5d18 Add missing handling for -dryrun flag in command-line client
This flag was only handled for the `download-profile` command. Add
the same handling for all other commands (except `detect-settings`).
2023-02-05 19:52:02 -05:00
afd882f7d2 README: Add inline link to documentation 2021-10-17 21:57:26 -04:00
5ed0050aad Add tests for String/Boolean String() methods 2021-10-17 21:55:45 -04:00
09f161e13e Add tests for Payee/Balance Valid() methods 2021-10-17 21:55:45 -04:00
e1a72fcd54 Replace github.com/howeyc/gopass with golang.org/x/term
gopass is no longer maintained, and the suggested replacement for
getting passwords from terminals is x/term.
2021-10-17 21:20:04 -04:00
a4a166aa74 Update dependencies 2021-10-17 21:04:05 -04:00
3ee400d1ec Test against latest golang: 1.17 2021-10-17 20:50:10 -04:00
67fa945cc8 cmd/ofx: check for nil Currency fields in transactions 2021-10-17 15:49:08 -04:00
cb48d30deb Added Accept http header as Citi now requires it
Without this header set Citi returns http 403 for every request.
2021-09-01 09:35:45 -04:00
12ea3b7e8b Update max CI Golang version to 1.16.x 2021-03-16 15:52:56 -04:00
e76c697cad cmd/ofx: Allow setting User-Agent header from command-line 2021-03-16 15:36:27 -04:00
2641443ebe BasicClient: Add ability to set User-Agent header
Some financial institutions require specific values in the User-Agent
header.
2021-03-16 15:36:27 -04:00
01b26887af GH-39 relax ofx response parsing to support TYPE1 responses
Section 4.2.2.2 Type 1 Protocol Overview in the
[Open Financial Exchange Specification 2.2, Nov 26, 2017](https://www.ofx.net/downloads/OFX%202.2.pdf)
states that:

    Type 1 applies only to the request part of a message; the server response is unaffected.

Thus it appears that we can safely parse SECURITY:TYPE1
using the same logic that we parse SECURITY:NONE

As I understand it, Security:TYPE1 indicates that the
financial institution uses SSL enryption to protect
customer credentials in transit.  This applies
explicitly to the incoming requests which may
contain authentication as part of the request
but does not appear to cause any material changes
to the response format.

As a result it appears that we can safely parse
SECURITY:TYPE1 responess in the same way that
we parse SECURITY:NONE responsese.
2021-03-12 10:46:24 -05:00
2b8a79e4b7 review changes 2021-01-04 07:24:40 -05:00
9136c9bab2 README: add example for parsing local file 2021-01-04 07:24:40 -05:00
0d93a42626 support SGML OFX responses with no line breaks
Some financial institutions (*cough* Wells Fargo *cough*) export OFX files as a single line, which is technically valid according to the v1 spec. In order to parse them correctly, `readSGMLHeaders` now uses a regular expression that allows for all whitespace/line breaks to be optionally excluded.

A new sample response (wellsfargo.qfx) has been added to document this behaviour.
2021-01-04 07:24:40 -05:00
56ca46714b cmd/ofx: Add -dryrun 2020-12-07 13:49:35 -05:00
4c7c48cab7 cmd/ofx: Use 'response.ofx' as the default download file everywhere
Conflicting defaults led to confusing default behavior between
sub-commands.
2020-12-07 13:49:35 -05:00
8c1e6eafab cmd/ofx: Add download-profile command 2020-11-25 14:42:25 -05:00
52f3e4120b README: Replace Travis badge with GitHub Actions 2020-11-25 07:18:43 -05:00
ef87cc536c Replace Travis CI with Github Actions 2020-11-25 07:09:08 -05:00
830a6064c7 .travis.yml: Set Go 1.12 as the minimum version
I'm getting errors when attempting to build with any older version:

    golang.org/x/term
    # golang.org/x/term
    ../../../golang.org/x/term/term_unix_linux.go:9:7: ioctlReadTermios redeclared in this block
	    previous declaration at ../../../golang.org/x/term/term_unix_aix.go:9:26
    ../../../golang.org/x/term/term_unix_linux.go:10:7: ioctlWriteTermios redeclared in this block
	    previous declaration at ../../../golang.org/x/term/term_unix_aix.go:10:27
2020-11-17 10:33:48 -05:00
6807c93e0e cmd/ofx: Add option to use carriage returns for requests 2020-11-17 10:33:48 -05:00
10edd94920 Vanguard client: Accept 500 errors on initial response
Though their server returns a 500 HTTP status code, it still sets the
required cookies on the response that we can use to make a second
request.
2020-11-17 10:33:48 -05:00
d88d45a664 signon: Add missing GENUSERKEY field 2020-11-17 10:33:48 -05:00
2caa23564a Fix spelling of 'Withholding' 2020-11-07 16:45:55 -05:00
5923a34de0 README: Fix bad documentation badge link 2020-10-06 23:15:00 -04:00
aa4d8074b2 Fix spelling error 2020-10-06 23:14:42 -04:00
65cc26a0db Make golint happy 2020-10-06 23:14:42 -04:00
8f3e7309f2 Add comments to a few exported methods 2020-10-06 23:14:42 -04:00
631508ccc9 README: Move from godoc.org to pkg.go.dev 2020-10-06 22:46:45 -04:00
60a5707de6 Update dependencies 2020-10-06 22:38:40 -04:00
3240ef383b README: Update build status badge 2020-10-06 22:19:44 -04:00
8ad638c7e2 Rename ofxgo_test package to ofxgo and remove self-imports/references
* Fix package in generate_constants.py
* Update generate_constants.py to use the new imports
2020-04-03 08:38:41 -04:00
f19189de45 Add DecodeResponse and *Response.Valid() for fine-grained parse control 2020-03-31 11:42:08 -04:00
677a09295a Continue parsing after hitting validation errors
Supports mixed-case severity for Ally Bank's Quicken downloads 🙄
2020-03-30 10:50:10 -04:00
f75592381a Add Go version to go.mod for compatibility guarantees 2020-03-30 10:50:10 -04:00
ebf7f5b757 Update xml module 2019-07-01 06:11:17 -04:00
212fdc731b Omit end tags for encoding OFX 100-series requests
Closes https://github.com/aclindsa/ofxgo/issues/18
2019-07-01 06:11:16 -04:00
66dd37781f Remove old cover tool installer 2019-07-01 06:01:57 -04:00
67e527c855 Support carriage returns on new lines 2019-06-26 06:23:52 -04:00
f41286cac7 .travis.yml: Add Go 1.12 2019-03-06 06:50:30 -05:00
423d460747 Bank transactions: Allow transaction currencies to be empty
The XML marahaller will attempt to marshal the top-level elements, not
knowing that the lower-level elements are empty. Making them pointers
solves this.
2019-03-06 06:00:05 -05:00
3e8a9c5a53 Merge pull request #16 from aclindsa/writable_responses
Allow for marshalling Response objects to strings containing XML/SGML
2019-03-02 15:33:38 -05:00
35c7116654 Add the ability to marshal a Response to SGML/XML and test it
This allows for ofxgo to be used to create well-formatted OFX from poor
OFX, or even be used to generate OFX from other formats for easier
importing into financial management software.

Test this functionality by adding "round trip" testing to all existing
tests - ensure that responses' content is the same after a round trip of
marshalling and unmarshalling them.
2019-03-02 07:03:11 -05:00
286e619071 Pull out writing the OFX header to its own function
This is in preparation for also allowing responses to be written via the
library (as opposed to only read).
2019-03-01 05:51:36 -05:00
9dd9c3bd3f Include 'cmd' module dependencies 2019-03-01 05:50:46 -05:00
0f6ceccd86 Work around missing blank line after last header
Detect when a newline doesn't follow the last header when parsing SGML and break out of the header-parsing loop early. Add an example .qfx file demonstrating the broken behavior we're working around and a test.
2019-01-04 11:18:55 -05:00
7691881132 Merge pull request #13 from aclindsa/dep_to_go_mod
Switch from `dep` to `go mod`
2018-10-11 21:08:30 -04:00
61262b87d8 Switch from dep to go mod 2018-10-11 21:00:03 -04:00
5e2e3a2bf7 Merge pull request #12 from aclindsa/discover_hack
Add Hack for Discover
2018-10-08 06:00:36 -04:00
22a6d65b98 Add Hack for Discover
Discover requires an exact set of headers in exact order, or it returns
HTTP 403.
2018-10-08 05:55:08 -04:00
77b154695f detect_settings: Add newer APPVER's for 'QWIN' APPID 2018-10-04 06:50:38 -04:00
ac09538ec3 Merge pull request #11 from aclindsa/make_client_interface
Make Client an interface instead of a struct
2018-10-03 10:23:40 -04:00
d8491bed1d Make Client an interface instead of a struct
This makes it easier to maintain per-institution hacks that start
interacting with each other if you try to do them all in the same client
code. This commit also breaks out the existing Vanguard hack into its
own Client implementation.
2018-10-03 10:18:54 -04:00
1b4f27b31f Merge pull request #10 from aclindsa/update_dependencies
Update dependencies
2018-10-02 21:07:02 -04:00
eb35a26986 Update dependencies 2018-10-02 21:01:03 -04:00
5c10ac5ea1 Merge pull request #9 from aclindsa/client_interface
Add BasicClient, update Client to be interface
2018-10-02 21:00:44 -04:00
94a77ac754 Add BasicClient, update Client to be interface
This paves the way for more easily implementing different clients for
different financial institutions
2018-10-02 20:55:25 -04:00
de58d3fc0d Merge pull request #8 from aclindsa/remove_unused_default_client
client.go: remove unused defaultClient var
2018-10-02 20:41:42 -04:00
88e5521348 client.go: remove unused defaultClient var 2018-10-02 20:33:33 -04:00
54666608a4 Merge pull request #7 from aclindsa/dont_export_rawrequestcookies
Don't export Client's rawRequestCookies method
2018-10-02 20:27:34 -04:00
1cc508c6d3 Don't export Client's rawRequestCookies method
It is used to fix bad behavior and should not be exposed
2018-10-02 20:20:07 -04:00
c6a806399a Merge pull request #5 from nicksnyder/patch-1
Make README example compile
2018-04-15 07:21:52 -04:00
2a92b29a62 currency := stmt.CurDef 2018-04-14 22:28:30 -07:00
2fbb276a22 Make example compile 2018-04-14 09:06:32 -04:00
06de7e2af6 Add example usage to README 2017-11-18 20:00:37 -05:00
977dacfbbd .travis.yml: Add dep ensure 2017-11-18 20:00:37 -05:00
ddc674b287 go fmt 2017-11-18 05:58:32 -05:00
29fc9c20fe Add dep vendoring 2017-11-18 05:56:33 -05:00
48 changed files with 3174 additions and 1073 deletions

34
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: ofxgo CI Test
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
go-version: [1.13.x, 1.18.x, 1.20.x]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
- name: Checkout code
uses: actions/checkout@v2
- name: Test
run: go test -v -covermode=count -coverprofile="profile.cov" ./...
- name: Send Coverage
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: "profile.cov"
flag-name: ${{ matrix.os }}-go-${{ matrix.go-version }}
parallel: true
# notifies that all test jobs are finished.
finish:
needs: test
runs-on: ubuntu-latest
steps:
- uses: shogo82148/actions-goveralls@v1
with:
parallel-finished: true

View File

@ -1,17 +0,0 @@
language: go
os:
- linux
go:
- 1.9.x
- master
script:
- go get golang.org/x/tools/cmd/cover
- go get github.com/mattn/goveralls
- go install github.com/mattn/goveralls
- go test -v -covermode=count -coverprofile=coverage.out
after_script:
- $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN

View File

@ -1,9 +1,9 @@
# OFXGo
[![Go Report Card](https://goreportcard.com/badge/github.com/aclindsa/ofxgo)](https://goreportcard.com/report/github.com/aclindsa/ofxgo)
[![Build Status](https://travis-ci.org/aclindsa/ofxgo.svg?branch=master)](https://travis-ci.org/aclindsa/ofxgo)
[![Build Status](https://github.com/aclindsa/ofxgo/workflows/ofxgo%20CI%20Test/badge.svg?branch=master)](https://github.com/aclindsa/ofxgo/actions?query=workflow%3A%22ofxgo+CI+Test%22+branch%3Amaster)
[![Coverage Status](https://coveralls.io/repos/github/aclindsa/ofxgo/badge.svg?branch=master)](https://coveralls.io/github/aclindsa/ofxgo?branch=master)
[![GoDoc](https://godoc.org/github.com/aclindsa/ofxgo?status.svg)](https://godoc.org/github.com/aclindsa/ofxgo)
[![PkgGoDev](https://pkg.go.dev/badge/github.com/aclindsa?ofxgo)](https://pkg.go.dev/github.com/aclindsa/ofxgo)
**OFXGo** is a library for querying OFX servers and/or parsing the responses. It
also provides an example command-line client to demonstrate the use of the
@ -13,7 +13,7 @@ library.
The main purpose of this project is to provide a library to make it easier to
query financial information with OFX from the comfort of Golang, without having
to marshal/unmarshal to SGML or XML. The library does *not* intend to abstract
to marshal/unmarshal to SGML or XML. The library does _not_ intend to abstract
away all of the details of the OFX specification, which would be difficult to do
well. Instead, it exposes the OFX SGML/XML hierarchy as structs which mostly
resemble it. Its primary goal is to enable the creation of other personal
@ -26,7 +26,8 @@ created a sample command-line client which uses the library to do simple tasks
(currently it does little more than list accounts and query for balances and
transactions). My hope is that by studying its code, new users will be able to
figure out how to use the library much faster than staring at the OFX
specification (or this library's API documentation). The command-line client
specification (or this library's [API
documentation](https://pkg.go.dev/github.com/aclindsa/ofxgo)). The command-line client
also serves as an easy way for me to test/debug the library with actual
financial institutions, which frequently have 'quirks' in their implementations.
The command-line client can be found in the [cmd/ofx
@ -36,11 +37,88 @@ repository.
## Library documentation
Documentation can be found with the `go doc` tool, or at
https://godoc.org/github.com/aclindsa/ofxgo
https://pkg.go.dev/github.com/aclindsa/ofxgo
## Example Usage
The following code snippet demonstrates how to use OFXGo to query and parse
OFX code from a checking account, printing the balance and returned transactions:
```go
client := ofxgo.BasicClient{} // Accept the default Client settings
// These values are specific to your bank
var query ofxgo.Request
query.URL = "https://secu.example.com/ofx"
query.Signon.Org = ofxgo.String("SECU")
query.Signon.Fid = ofxgo.String("1234")
// Set your username/password
query.Signon.UserID = ofxgo.String("username")
query.Signon.UserPass = ofxgo.String("hunter2")
uid, _ := ofxgo.RandomUID() // Handle error in real code
query.Bank = append(query.Bank, &ofxgo.StatementRequest{
TrnUID: *uid,
BankAcctFrom: ofxgo.BankAcct{
BankID: ofxgo.String("123456789"), // Possibly your routing number
AcctID: ofxgo.String("00011122233"), // Possibly your account number
AcctType: ofxgo.AcctTypeChecking,
},
Include: true, // Include transactions (instead of only balance information)
})
response, _ := client.Request(&query) // Handle error in real code
// Was there an OFX error while processing our request?
if response.Signon.Status.Code != 0 {
meaning, _ := response.Signon.Status.CodeMeaning()
fmt.Printf("Nonzero signon status (%d: %s) with message: %s\n", response.Signon.Status.Code, meaning, response.Signon.Status.Message)
os.Exit(1)
}
if len(response.Bank) < 1 {
fmt.Println("No banking messages received")
os.Exit(1)
}
if stmt, ok := response.Bank[0].(*ofxgo.StatementResponse); ok {
fmt.Printf("Balance: %s %s (as of %s)\n", stmt.BalAmt, stmt.CurDef, stmt.DtAsOf)
fmt.Println("Transactions:")
for _, tran := range stmt.BankTranList.Transactions {
currency := stmt.CurDef
if ok, _ := tran.Currency.Valid(); ok {
currency = tran.Currency.CurSym
}
fmt.Printf("%s %-15s %-11s %s%s%s\n", tran.DtPosted, tran.TrnAmt.String()+" "+currency.String(), tran.TrnType, tran.Name, tran.Payee.Name, tran.Memo)
}
}
```
Similarly, if you have an OFX file available locally, you can parse it directly:
```go
func main() {
f, err := os.Open("./transactions.qfx")
if err != nil {
fmt.Printf("can't open file: %v\n", err)
return
}
defer f.Close()
resp, err := ofxgo.ParseResponse(f)
if err != nil {
fmt.Printf("can't parse response: %v\n", err)
return
}
// do something with resp (*ofxgo.Response)
}
```
## Requirements
OFXGo requires go >= 1.9
OFXGo requires go >= 1.12
## Using the command-line client

13
bank.go
View File

@ -126,8 +126,8 @@ type Transaction struct {
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
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.)
}
@ -166,8 +166,13 @@ func (t Transaction) Valid(version ofxVersion) (bool, error) {
} 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()
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")
}

View File

@ -1,7 +1,6 @@
package ofxgo_test
package ofxgo
import (
"github.com/aclindsa/ofxgo"
"strings"
"testing"
"time"
@ -42,24 +41,24 @@ func TestMarshalBankStatementRequest(t *testing.T) {
</BANKMSGSRQV1>
</OFX>`
var client = ofxgo.Client{
var client = BasicClient{
AppID: "OFXGO",
AppVer: "0001",
SpecVersion: ofxgo.OfxVersion203,
SpecVersion: OfxVersion203,
}
var request ofxgo.Request
var request Request
request.Signon.UserID = "myusername"
request.Signon.UserPass = "Pa$$word"
request.Signon.Org = "BNK"
request.Signon.Fid = "1987"
statementRequest := ofxgo.StatementRequest{
statementRequest := StatementRequest{
TrnUID: "123",
BankAcctFrom: ofxgo.BankAcct{
BankAcctFrom: BankAcct{
BankID: "318398732",
AcctID: "78346129",
AcctType: ofxgo.AcctTypeChecking,
AcctType: AcctTypeChecking,
},
Include: true,
}
@ -68,7 +67,7 @@ func TestMarshalBankStatementRequest(t *testing.T) {
request.SetClientFields(&client)
// Overwrite the DtClient value set by SetClientFields to time.Now()
EST := time.FixedZone("EST", -5*60*60)
request.Signon.DtClient = *ofxgo.NewDate(2006, 1, 15, 11, 23, 0, 0, EST)
request.Signon.DtClient = *NewDate(2006, 1, 15, 11, 23, 0, 0, EST)
marshalCheckRequest(t, &request, expectedString)
}
@ -87,53 +86,53 @@ NEWFILEUID:NONE
<OFX>
<SIGNONMSGSRQV1>
<SONRQ>
<DTCLIENT>20060115112300.000[-5:EST]</DTCLIENT>
<USERID>myusername</USERID>
<USERPASS>Pa$$word</USERPASS>
<LANGUAGE>ENG</LANGUAGE>
<DTCLIENT>20060115112300.000[-5:EST]
<USERID>myusername
<USERPASS>Pa$$word
<LANGUAGE>ENG
<FI>
<ORG>BNK</ORG>
<FID>1987</FID>
<ORG>BNK
<FID>1987
</FI>
<APPID>OFXGO</APPID>
<APPVER>0001</APPVER>
<APPID>OFXGO
<APPVER>0001
</SONRQ>
</SIGNONMSGSRQV1>
<BANKMSGSRQV1>
<STMTTRNRQ>
<TRNUID>123</TRNUID>
<TRNUID>123
<STMTRQ>
<BANKACCTFROM>
<BANKID>318398732</BANKID>
<ACCTID>78346129</ACCTID>
<ACCTTYPE>CHECKING</ACCTTYPE>
<BANKID>318398732
<ACCTID>78346129
<ACCTTYPE>CHECKING
</BANKACCTFROM>
<INCTRAN>
<INCLUDE>Y</INCLUDE>
<INCLUDE>Y
</INCTRAN>
</STMTRQ>
</STMTTRNRQ>
</BANKMSGSRQV1>
</OFX>`
var client = ofxgo.Client{
var client = BasicClient{
AppID: "OFXGO",
AppVer: "0001",
SpecVersion: ofxgo.OfxVersion103,
SpecVersion: OfxVersion103,
}
var request ofxgo.Request
var request Request
request.Signon.UserID = "myusername"
request.Signon.UserPass = "Pa$$word"
request.Signon.Org = "BNK"
request.Signon.Fid = "1987"
statementRequest := ofxgo.StatementRequest{
statementRequest := StatementRequest{
TrnUID: "123",
BankAcctFrom: ofxgo.BankAcct{
BankAcctFrom: BankAcct{
BankID: "318398732",
AcctID: "78346129",
AcctType: ofxgo.AcctTypeChecking,
AcctType: AcctTypeChecking,
},
Include: true,
}
@ -142,7 +141,7 @@ NEWFILEUID:NONE
request.SetClientFields(&client)
// Overwrite the DtClient value set by SetClientFields to time.Now()
EST := time.FixedZone("EST", -5*60*60)
request.Signon.DtClient = *ofxgo.NewDate(2006, 1, 15, 11, 23, 0, 0, EST)
request.Signon.DtClient = *NewDate(2006, 1, 15, 11, 23, 0, 0, EST)
marshalCheckRequest(t, &request, expectedString)
}
@ -211,76 +210,174 @@ func TestUnmarshalBankStatementResponse(t *testing.T) {
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>`)
var expected ofxgo.Response
var expected Response
expected.Version = ofxgo.OfxVersion203
expected.Version = OfxVersion203
expected.Signon.Status.Code = 0
expected.Signon.Status.Severity = "INFO"
expected.Signon.DtServer = *ofxgo.NewDateGMT(2006, 1, 15, 11, 23, 03, 0)
expected.Signon.DtServer = *NewDateGMT(2006, 1, 15, 11, 23, 03, 0)
expected.Signon.Language = "ENG"
expected.Signon.DtProfUp = ofxgo.NewDateGMT(2005, 2, 21, 9, 13, 0, 0)
expected.Signon.DtAcctUp = ofxgo.NewDateGMT(2006, 1, 2, 16, 0, 0, 0)
expected.Signon.DtProfUp = NewDateGMT(2005, 2, 21, 9, 13, 0, 0)
expected.Signon.DtAcctUp = NewDateGMT(2006, 1, 2, 16, 0, 0, 0)
expected.Signon.Org = "BNK"
expected.Signon.Fid = "1987"
var trnamt1, trnamt2 ofxgo.Amount
var trnamt1, trnamt2 Amount
trnamt1.SetFrac64(-20000, 100)
trnamt2.SetFrac64(-30000, 100)
banktranlist := ofxgo.TransactionList{
DtStart: *ofxgo.NewDateGMT(2006, 1, 1, 0, 0, 0, 0),
DtEnd: *ofxgo.NewDateGMT(2006, 1, 15, 0, 0, 0, 0),
Transactions: []ofxgo.Transaction{
banktranlist := TransactionList{
DtStart: *NewDateGMT(2006, 1, 1, 0, 0, 0, 0),
DtEnd: *NewDateGMT(2006, 1, 15, 0, 0, 0, 0),
Transactions: []Transaction{
{
TrnType: ofxgo.TrnTypeCheck,
DtPosted: *ofxgo.NewDateGMT(2006, 1, 4, 0, 0, 0, 0),
TrnType: TrnTypeCheck,
DtPosted: *NewDateGMT(2006, 1, 4, 0, 0, 0, 0),
TrnAmt: trnamt1,
FiTID: "00592",
CheckNum: "2002",
},
{
TrnType: ofxgo.TrnTypeATM,
DtPosted: *ofxgo.NewDateGMT(2006, 1, 12, 0, 0, 0, 0),
DtUser: ofxgo.NewDateGMT(2006, 1, 12, 0, 0, 0, 0),
TrnType: TrnTypeATM,
DtPosted: *NewDateGMT(2006, 1, 12, 0, 0, 0, 0),
DtUser: NewDateGMT(2006, 1, 12, 0, 0, 0, 0),
TrnAmt: trnamt2,
FiTID: "00679",
},
},
}
var balamt, availbalamt ofxgo.Amount
var balamt, availbalamt Amount
balamt.SetFrac64(20029, 100)
availbalamt.SetFrac64(20029, 100)
usd, err := ofxgo.NewCurrSymbol("USD")
usd, err := NewCurrSymbol("USD")
if err != nil {
t.Fatalf("Unexpected error creating CurrSymbol for USD\n")
}
statementResponse := ofxgo.StatementResponse{
statementResponse := StatementResponse{
TrnUID: "1001",
Status: ofxgo.Status{
Status: Status{
Code: 0,
Severity: "INFO",
},
CurDef: *usd,
BankAcctFrom: ofxgo.BankAcct{
BankAcctFrom: BankAcct{
BankID: "318398732",
AcctID: "78346129",
AcctType: ofxgo.AcctTypeChecking,
AcctType: AcctTypeChecking,
},
BankTranList: &banktranlist,
BalAmt: balamt,
DtAsOf: *ofxgo.NewDateGMT(2006, 1, 14, 16, 0, 0, 0),
DtAsOf: *NewDateGMT(2006, 1, 14, 16, 0, 0, 0),
AvailBalAmt: &availbalamt,
AvailDtAsOf: ofxgo.NewDateGMT(2006, 1, 14, 16, 0, 0, 0),
AvailDtAsOf: NewDateGMT(2006, 1, 14, 16, 0, 0, 0),
}
expected.Bank = append(expected.Bank, &statementResponse)
response, err := ofxgo.ParseResponse(responseReader)
response, err := ParseResponse(responseReader)
if err != nil {
t.Fatalf("Unexpected error unmarshalling response: %s\n", err)
}
checkResponsesEqual(t, &expected, response)
checkResponseRoundTrip(t, response)
}
func TestPayeeValid(t *testing.T) {
p := Payee{
Name: "Jane",
Addr1: "Sesame Street",
City: "Mytown",
State: "AA",
PostalCode: "12345",
Phone: "12345678901",
}
valid, err := p.Valid()
if !valid {
t.Fatalf("Unexpected error from calling Valid: %s\n", err)
}
// Ensure some empty fields trigger invalid response
badp := p
badp.Name = ""
valid, err = badp.Valid()
if valid || err == nil {
t.Fatalf("Expected error from calling Valid with empty name\n")
}
badp = p
badp.Addr1 = ""
valid, err = badp.Valid()
if valid || err == nil {
t.Fatalf("Expected error from calling Valid with empty address\n")
}
badp = p
badp.City = ""
valid, err = badp.Valid()
if valid || err == nil {
t.Fatalf("Expected error from calling Valid with empty city\n")
}
badp = p
badp.State = ""
valid, err = badp.Valid()
if valid || err == nil {
t.Fatalf("Expected error from calling Valid with empty state\n")
}
badp = p
badp.PostalCode = ""
valid, err = badp.Valid()
if valid || err == nil {
t.Fatalf("Expected error from calling Valid with empty postal code\n")
}
badp = p
badp.Phone = ""
valid, err = badp.Valid()
if valid || err == nil {
t.Fatalf("Expected error from calling Valid with empty phone\n")
}
}
func TestBalanceValid(t *testing.T) {
var a Amount
a.SetFrac64(8, 1)
b := Balance{
Name: "Checking",
Desc: "Jane's Personal Checking",
BalType: BalTypeDollar,
Value: a,
}
valid, err := b.Valid()
if !valid {
t.Fatalf("Unexpected error from calling Valid: %s\n", err)
}
badb := b
badb.Name = ""
valid, err = badb.Valid()
if valid || err == nil {
t.Fatalf("Expected error from calling Valid with empty name\n")
}
badb = b
badb.Desc = ""
valid, err = badb.Valid()
if valid || err == nil {
t.Fatalf("Expected error from calling Valid with empty description\n")
}
badb = Balance{
Name: "Checking",
Desc: "Jane's Personal Checking",
Value: a,
}
valid, err = badb.Valid()
if valid || err == nil {
t.Fatalf("Expected error from calling Valid with unspecified balance type\n")
}
}

104
basic_client.go Normal file
View File

@ -0,0 +1,104 @@
package ofxgo
import (
"errors"
"io"
"net/http"
"strings"
)
// BasicClient provides a standard Client implementation suitable for most
// financial institutions. BasicClient uses default, non-zero settings, even if
// its fields are not initialized.
type BasicClient struct {
// Request fields to overwrite with the client's values. If nonempty,
// defaults are used
SpecVersion ofxVersion // VERSION in header
AppID string // SONRQ>APPID
AppVer string // SONRQ>APPVER
// Don't insert newlines or indentation when marshalling to SGML/XML
NoIndent bool
// Use carriage returns on new lines
CarriageReturn bool
// Set User-Agent header to this string, if not empty
UserAgent string
}
// OfxVersion returns the OFX specification version this BasicClient will marshal
// Requests as. Defaults to "203" if the client's SpecVersion field is empty.
func (c *BasicClient) OfxVersion() ofxVersion {
if c.SpecVersion.Valid() {
return c.SpecVersion
}
return OfxVersion203
}
// ID returns this BasicClient's OFX AppID field, defaulting to "OFXGO" if
// unspecified.
func (c *BasicClient) ID() String {
if len(c.AppID) > 0 {
return String(c.AppID)
}
return String("OFXGO")
}
// Version returns this BasicClient's version number as a string, defaulting to
// "0001" if unspecified.
func (c *BasicClient) Version() String {
if len(c.AppVer) > 0 {
return String(c.AppVer)
}
return String("0001")
}
// IndentRequests returns true if the marshaled XML should be indented (and
// contain newlines, since the two are linked in the current implementation)
func (c *BasicClient) IndentRequests() bool {
return !c.NoIndent
}
// CarriageReturnNewLines returns true if carriage returns should be used on new lines, false otherwise
func (c *BasicClient) CarriageReturnNewLines() bool {
return c.CarriageReturn
}
// RawRequest is a convenience wrapper around http.Post. It is exposed only for
// when you need to read/inspect the raw HTTP response yourself.
func (c *BasicClient) RawRequest(URL string, r io.Reader) (*http.Response, error) {
if !strings.HasPrefix(URL, "https://") {
return nil, errors.New("Refusing to send OFX request with possible plain-text password over non-https protocol")
}
request, err := http.NewRequest("POST", URL, r)
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/x-ofx")
request.Header.Add("Accept", "*/*, application/x-ofx")
if c.UserAgent != "" {
request.Header.Set("User-Agent", c.UserAgent)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, err
}
if response.StatusCode != 200 {
return response, errors.New("OFXQuery request status: " + response.Status)
}
return response, nil
}
// RequestNoParse marshals a Request to XML, makes an HTTP request, and returns
// the raw HTTP response
func (c *BasicClient) RequestNoParse(r *Request) (*http.Response, error) {
return clientRequestNoParse(c, r)
}
// Request marshals a Request to XML, makes an HTTP request, and then
// unmarshals the response into a Response object.
func (c *BasicClient) Request(r *Request) (*Response, error) {
return clientRequest(c, r)
}

202
client.go
View File

@ -1,127 +1,83 @@
package ofxgo
import (
"errors"
"io"
"net/http"
"strings"
)
// Client serves to aggregate OFX client settings that may be necessary to talk
// to a particular server due to quirks in that server's implementation. Client
// also provides the Request, RequestNoParse, and RawRequest helper methods to
// aid in making and parsing requests. Client uses default, non-zero settings,
// even if its fields are not initialized.
type Client struct {
// Request fields to overwrite with the client's values. If nonempty,
// defaults are used
SpecVersion ofxVersion // VERSION in header
AppID string // SONRQ>APPID
AppVer string // SONRQ>APPVER
// to a particular server due to quirks in that server's implementation.
// Client also provides the Request and RequestNoParse helper methods to aid in
// making and parsing requests.
type Client interface {
// Used to fill out a Request object
OfxVersion() ofxVersion
ID() String
Version() String
IndentRequests() bool
CarriageReturnNewLines() bool
// Don't insert newlines or indentation when marshalling to SGML/XML
NoIndent bool
// Request marshals a Request object into XML, makes an HTTP request
// against it's URL, and then unmarshals the response into a Response
// object.
//
// Before being marshaled, some of the the Request object's values are
// overwritten, namely those dictated by the BasicClient's configuration
// (Version, AppID, AppVer fields), and the client's current time
// (DtClient). These are updated in place in the supplied Request object so
// they may later be inspected by the caller.
Request(r *Request) (*Response, error)
// RequestNoParse marshals a Request object into XML, makes an HTTP
// request, and returns the raw HTTP response. Unlike RawRequest(), it
// takes client settings into account. Unlike Request(), it doesn't parse
// the response into an ofxgo.Request object.
//
// Caveat: The caller is responsible for closing the http Response.Body
// (see the http module's documentation for more information)
RequestNoParse(r *Request) (*http.Response, error)
// RawRequest is little more than a thin wrapper around http.Post
//
// In most cases, you should probably be using Request() instead, but
// RawRequest can be useful if you need to read the raw unparsed http
// response yourself (perhaps for downloading an OFX file for use by an
// external program, or debugging server behavior), or have a handcrafted
// request you'd like to try.
//
// Caveats: RawRequest does *not* take client settings into account as
// Client.Request() does, so your particular server may or may not like
// whatever we read from 'r'. The caller is responsible for closing the
// http Response.Body (see the http module's documentation for more
// information)
RawRequest(URL string, r io.Reader) (*http.Response, error)
}
var defaultClient Client
type clientCreationFunc func(*BasicClient) Client
// OfxVersion returns the OFX specification version this Client will marshal
// Requests as. Defaults to "203" if the client's SpecVersion field is empty.
func (c *Client) OfxVersion() ofxVersion {
if c.SpecVersion.Valid() {
return c.SpecVersion
// GetClient returns a new Client for a given URL. It attempts to find a
// specialized client for this URL, but simply returns the passed-in
// BasicClient if no such match is found.
func GetClient(URL string, bc *BasicClient) Client {
clients := []struct {
URL string
Func clientCreationFunc
}{
{"https://ofx.discovercard.com", NewDiscoverCardClient},
{"https://vesnc.vanguard.com/us/OfxDirectConnectServlet", NewVanguardClient},
}
return OfxVersion203
for _, client := range clients {
if client.URL == strings.Trim(URL, "/") {
return client.Func(bc)
}
}
return bc
}
// ID returns this Client's OFX AppID field, defaulting to "OFXGO" if
// unspecified.
func (c *Client) ID() String {
if len(c.AppID) > 0 {
return String(c.AppID)
}
return String("OFXGO")
}
// Version returns this Client's version number as a string, defaulting to
// "0001" if unspecified.
func (c *Client) Version() String {
if len(c.AppVer) > 0 {
return String(c.AppVer)
}
return String("0001")
}
// IndentRequests returns true if the marshaled XML should be indented (and
// contain newlines, since the two are linked in the current implementation)
func (c *Client) IndentRequests() bool {
return !c.NoIndent
}
// RawRequest is little more than a thin wrapper around http.Post
//
// In most cases, you should probably be using Request() instead, but
// RawRequest can be useful if you need to read the raw unparsed http response
// yourself (perhaps for downloading an OFX file for use by an external
// program, or debugging server behavior), or have a handcrafted request you'd
// like to try.
//
// Caveats: RawRequest does *not* take client settings into account as
// Request() does, so your particular server may or may not like whatever we
// read from 'r'. The caller is responsible for closing the http Response.Body
// (see the http module's documentation for more information)
func RawRequest(URL string, r io.Reader) (*http.Response, error) {
if !strings.HasPrefix(URL, "https://") {
return nil, errors.New("Refusing to send OFX request with possible plain-text password over non-https protocol")
}
response, err := http.Post(URL, "application/x-ofx", r)
if err != nil {
return nil, err
}
if response.StatusCode != 200 {
return nil, errors.New("OFXQuery request status: " + response.Status)
}
return response, nil
}
// RawRequestCookies is RawRequest with the added feature of sending cookies
func RawRequestCookies(URL string, r io.Reader, cookies []*http.Cookie) (*http.Response, error) {
if !strings.HasPrefix(URL, "https://") {
return nil, errors.New("Refusing to send OFX request with possible plain-text password over non-https protocol")
}
request, err := http.NewRequest("POST", URL, r)
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/x-ofx")
for _, cookie := range cookies {
request.AddCookie(cookie)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, err
}
if response.StatusCode != 200 {
return nil, errors.New("OFXQuery request status: " + response.Status)
}
return response, nil
}
// RequestNoParse marshals a Request object into XML, makes an HTTP request,
// and returns the raw HTTP response. Unlike RawRequest(), it takes client
// settings into account. Unlike Request(), it doesn't parse the response into
// an ofxgo.Request object.
//
// Caveat: The caller is responsible for closing the http Response.Body (see
// the http module's documentation for more information)
func (c *Client) RequestNoParse(r *Request) (*http.Response, error) {
// clientRequestNoParse can be used for building clients' RequestNoParse
// methods if they require fairly standard behavior
func clientRequestNoParse(c Client, r *Request) (*http.Response, error) {
r.SetClientFields(c)
b, err := r.Marshal()
@ -129,34 +85,12 @@ func (c *Client) RequestNoParse(r *Request) (*http.Response, error) {
return nil, err
}
response, err := RawRequest(r.URL, b)
// Some financial institutions (cough, Vanguard, cough), require a cookie
// to be set on the http request, or they return empty responses.
// Fortunately, the initial response contains the cookie we need, so if we
// detect an empty response with cookies set that didn't have any errors,
// re-try the request while sending their cookies back to them.
if err == nil && response.ContentLength <= 0 && len(response.Cookies()) > 0 {
b, err = r.Marshal()
if err != nil {
return nil, err
}
return RawRequestCookies(r.URL, b, response.Cookies())
}
return response, err
return c.RawRequest(r.URL, b)
}
// Request marshals a Request object into XML, makes an HTTP request against
// it's URL, and then unmarshals the response into a Response object.
//
// Before being marshaled, some of the the Request object's values are
// overwritten, namely those dictated by the Client's configuration (Version,
// AppID, AppVer fields), and the client's curren time (DtClient). These are
// updated in place in the supplied Request object so they may later be
// inspected by the caller.
func (c *Client) Request(r *Request) (*Response, error) {
// clientRequest can be used for building clients' Request methods if they
// require fairly standard behavior
func clientRequest(c Client, r *Request) (*Response, error) {
response, err := c.RequestNoParse(r)
if err != nil {
return nil, err

View File

@ -20,7 +20,7 @@ var filename, bankID, acctID, acctType string
func init() {
defineServerFlags(downloadCommand.Flags)
downloadCommand.Flags.StringVar(&filename, "filename", "./download.ofx", "The file to save to")
downloadCommand.Flags.StringVar(&filename, "filename", "./response.ofx", "The file to save to")
downloadCommand.Flags.StringVar(&bankID, "bankid", "", "BankID (from `get-accounts` subcommand)")
downloadCommand.Flags.StringVar(&acctID, "acctid", "", "AcctID (from `get-accounts` subcommand)")
downloadCommand.Flags.StringVar(&acctType, "accttype", "CHECKING", "AcctType (from `get-accounts` subcommand)")
@ -64,6 +64,11 @@ func download() {
query.Bank = append(query.Bank, &statementRequest)
if dryrun {
printRequest(client, query)
return
}
response, err := client.RequestNoParse(query)
if err != nil {
fmt.Println("Error requesting account statement:", err)

View File

@ -49,6 +49,11 @@ func bankTransactions() {
query.Bank = append(query.Bank, &statementRequest)
if dryrun {
printRequest(client, query)
return
}
response, err := client.Request(query)
if err != nil {
fmt.Println("Error requesting account statement:", err)
@ -77,7 +82,7 @@ func bankTransactions() {
func printTransaction(defCurrency ofxgo.CurrSymbol, tran *ofxgo.Transaction) {
currency := defCurrency
if ok, _ := tran.Currency.Valid(); ok {
if tran.Currency != nil {
currency = tran.Currency.CurSym
}

View File

@ -18,7 +18,7 @@ var ccDownloadCommand = command{
func init() {
defineServerFlags(ccDownloadCommand.Flags)
ccDownloadCommand.Flags.StringVar(&filename, "filename", "./download.ofx", "The file to save to")
ccDownloadCommand.Flags.StringVar(&filename, "filename", "./response.ofx", "The file to save to")
ccDownloadCommand.Flags.StringVar(&acctID, "acctid", "", "AcctID (from `get-accounts` subcommand)")
}
@ -51,6 +51,11 @@ func ccDownload() {
}
query.CreditCard = append(query.CreditCard, &statementRequest)
if dryrun {
printRequest(client, query)
return
}
response, err := client.RequestNoParse(query)
if err != nil {

View File

@ -38,6 +38,11 @@ func ccTransactions() {
}
query.CreditCard = append(query.CreditCard, &statementRequest)
if dryrun {
printRequest(client, query)
return
}
response, err := client.Request(query)
if err != nil {
fmt.Println("Error requesting account statement:", err)
@ -60,7 +65,7 @@ func ccTransactions() {
fmt.Println("Transactions:")
for _, tran := range stmt.BankTranList.Transactions {
currency := stmt.CurDef
if ok, _ := tran.Currency.Valid(); ok {
if tran.Currency != nil {
currency = tran.Currency.CurSym
}

View File

@ -3,7 +3,8 @@ package main
import (
"flag"
"fmt"
"github.com/howeyc/gopass"
"golang.org/x/term"
"os"
)
type command struct {
@ -22,6 +23,9 @@ func (c *command) usage() {
// flags common to all server transactions
var serverURL, username, password, org, fid, appID, appVer, ofxVersion, clientUID string
var noIndentRequests bool
var carriageReturn bool
var dryrun bool
var userAgent string
func defineServerFlags(f *flag.FlagSet) {
f.StringVar(&serverURL, "url", "", "Financial institution's OFX Server URL (see ofxhome.com if you don't know it)")
@ -34,6 +38,9 @@ func defineServerFlags(f *flag.FlagSet) {
f.StringVar(&ofxVersion, "ofxversion", "203", "OFX version to use")
f.StringVar(&clientUID, "clientuid", "", "Client UID (only required by a few FIs, like Chase)")
f.BoolVar(&noIndentRequests, "noindent", false, "Don't indent OFX requests")
f.BoolVar(&carriageReturn, "carriagereturn", false, "Use carriage return as line separator")
f.StringVar(&userAgent, "useragent", "", "Use string as User-Agent header when sending request")
f.BoolVar(&dryrun, "dryrun", false, "Don't send request - print content of request instead")
}
func checkServerFlags() bool {
@ -49,7 +56,7 @@ func checkServerFlags() bool {
if ret && len(password) == 0 {
fmt.Printf("Password for %s: ", username)
pass, err := gopass.GetPasswd()
pass, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
fmt.Printf("Error reading password: %s\n", err)
ret = false

View File

@ -43,6 +43,8 @@ var appVersions = map[string][]string{
"0001",
},
"QWIN": { // Intuit Quicken Windows
"2600", // 2017
"2500", // 2016
"2400", // 2015
"2300", // 2014
"2200", // 2013
@ -126,12 +128,13 @@ func tryProfile(appID, appVer, version string, noindent bool) bool {
fmt.Println("Error creating new OfxVersion enum:", err)
os.Exit(1)
}
var client = ofxgo.Client{
AppID: appID,
AppVer: appVer,
SpecVersion: ver,
NoIndent: noindent,
}
var client = ofxgo.GetClient(serverURL,
&ofxgo.BasicClient{
AppID: appID,
AppVer: appVer,
SpecVersion: ver,
NoIndent: noindent,
})
var query ofxgo.Request
query.URL = serverURL

View File

@ -35,6 +35,11 @@ func getAccounts() {
}
query.Signup = append(query.Signup, &acctInfo)
if dryrun {
printRequest(client, query)
return
}
response, err := client.Request(query)
if err != nil {
fmt.Println("Error requesting account information:", err)

View File

@ -20,7 +20,7 @@ var brokerID string
func init() {
defineServerFlags(invDownloadCommand.Flags)
invDownloadCommand.Flags.StringVar(&filename, "filename", "./download.ofx", "The file to save to")
invDownloadCommand.Flags.StringVar(&filename, "filename", "./response.ofx", "The file to save to")
invDownloadCommand.Flags.StringVar(&acctID, "acctid", "", "AcctID (from `get-accounts` subcommand)")
invDownloadCommand.Flags.StringVar(&brokerID, "brokerid", "", "BrokerID (from `get-accounts` subcommand)")
}
@ -60,6 +60,11 @@ func invDownload() {
}
query.InvStmt = append(query.InvStmt, &statementRequest)
if dryrun {
printRequest(client, query)
return
}
response, err := client.RequestNoParse(query)
if err != nil {

View File

@ -45,6 +45,11 @@ func invTransactions() {
}
query.InvStmt = append(query.InvStmt, &statementRequest)
if dryrun {
printRequest(client, query)
return
}
response, err := client.Request(query)
if err != nil {
os.Exit(1)

View File

@ -7,6 +7,7 @@ import (
)
var commands = []command{
profileDownloadCommand,
getAccountsCommand,
downloadCommand,
ccDownloadCommand,

View File

@ -0,0 +1,81 @@
package main
import (
"flag"
"fmt"
"github.com/aclindsa/ofxgo"
"io"
"os"
)
var profileDownloadCommand = command{
Name: "download-profile",
Description: "Download a FI profile to a file",
Flags: flag.NewFlagSet("download-profile", flag.ExitOnError),
CheckFlags: downloadProfileCheckFlags,
Do: downloadProfile,
}
func init() {
defineServerFlags(profileDownloadCommand.Flags)
profileDownloadCommand.Flags.StringVar(&filename, "filename", "./response.ofx", "The file to save to")
}
func downloadProfileCheckFlags() bool {
// Assume if the user didn't specify username that we should use anonymous
// values for it and password
if len(username) == 0 {
username = "anonymous00000000000000000000000"
password = "anonymous00000000000000000000000"
}
ret := checkServerFlags()
if len(filename) == 0 {
fmt.Println("Error: Filename empty")
return false
}
return ret
}
func downloadProfile() {
client, query := newRequest()
uid, err := ofxgo.RandomUID()
if err != nil {
fmt.Println("Error creating uid for transaction:", err)
os.Exit(1)
}
profileRequest := ofxgo.ProfileRequest{
TrnUID: *uid,
}
query.Prof = append(query.Prof, &profileRequest)
if dryrun {
printRequest(client, query)
return
}
response, err := client.RequestNoParse(query)
if err != nil {
fmt.Println("Error requesting FI profile:", err)
os.Exit(1)
}
defer response.Body.Close()
file, err := os.Create(filename)
if err != nil {
fmt.Println("Error creating file to write to:", err)
os.Exit(1)
}
defer file.Close()
_, err = io.Copy(file, response.Body)
if err != nil {
fmt.Println("Error writing response to file:", err)
os.Exit(1)
}
}

View File

@ -6,18 +6,21 @@ import (
"os"
)
func newRequest() (*ofxgo.Client, *ofxgo.Request) {
func newRequest() (ofxgo.Client, *ofxgo.Request) {
ver, err := ofxgo.NewOfxVersion(ofxVersion)
if err != nil {
fmt.Println("Error creating new OfxVersion enum:", err)
os.Exit(1)
}
var client = ofxgo.Client{
AppID: appID,
AppVer: appVer,
SpecVersion: ver,
NoIndent: noIndentRequests,
}
var client = ofxgo.GetClient(serverURL,
&ofxgo.BasicClient{
AppID: appID,
AppVer: appVer,
SpecVersion: ver,
NoIndent: noIndentRequests,
CarriageReturn: carriageReturn,
UserAgent: userAgent,
})
var query ofxgo.Request
query.URL = serverURL
@ -27,5 +30,16 @@ func newRequest() (*ofxgo.Client, *ofxgo.Request) {
query.Signon.Org = ofxgo.String(org)
query.Signon.Fid = ofxgo.String(fid)
return &client, &query
return client, &query
}
func printRequest(c ofxgo.Client, r *ofxgo.Request) {
r.SetClientFields(c)
b, err := r.Marshal()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(b)
}

View File

@ -3,10 +3,50 @@ package ofxgo
//go:generate ./generate_constants.py
import (
"bytes"
"errors"
"fmt"
"strings"
"github.com/aclindsa/xml"
)
func writeHeader(b *bytes.Buffer, v ofxVersion, carriageReturn bool) error {
// Write the header appropriate to our version
switch v {
case OfxVersion102, OfxVersion103, OfxVersion151, OfxVersion160:
header := `OFXHEADER:100
DATA:OFXSGML
VERSION:` + v.String() + `
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
`
if carriageReturn {
header = strings.Replace(header, "\n", "\r\n", -1)
}
b.WriteString(header)
case OfxVersion200, OfxVersion201, OfxVersion202, OfxVersion203, OfxVersion210, OfxVersion211, OfxVersion220:
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="no"?>`)
if carriageReturn {
b.WriteByte('\r')
}
b.WriteByte('\n')
b.WriteString(`<?OFX OFXHEADER="200" VERSION="` + v.String() + `" SECURITY="NONE" OLDFILEUID="NONE" NEWFILEUID="NONE"?>`)
if carriageReturn {
b.WriteByte('\r')
}
b.WriteByte('\n')
default:
return fmt.Errorf("%d is not a valid OFX version string", v)
}
return nil
}
// Message represents an OFX message in a message set. it is used to ease
// marshalling and unmarshalling.
type Message interface {

View File

@ -1,12 +1,11 @@
package ofxgo_test
package ofxgo
import (
"github.com/aclindsa/ofxgo"
"testing"
)
func TestStatusValid(t *testing.T) {
s := ofxgo.Status{
s := Status{
Code: 0,
Severity: "INFO",
Message: "Success",
@ -32,7 +31,7 @@ func TestStatusValid(t *testing.T) {
}
func TestStatusCodeMeaning(t *testing.T) {
s := ofxgo.Status{
s := Status{
Code: 15500,
Severity: "ERROR",
}
@ -51,7 +50,7 @@ func TestStatusCodeMeaning(t *testing.T) {
}
func TestStatusCodeConditions(t *testing.T) {
s := ofxgo.Status{
s := Status{
Code: 2006,
Severity: "ERROR",
}

View File

@ -9,8 +9,9 @@ package ofxgo
import (
"errors"
"fmt"
"github.com/aclindsa/xml"
"strings"
"github.com/aclindsa/xml"
)
type ofxVersion uint
@ -68,11 +69,11 @@ func (e *ofxVersion) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *ofxVersion) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e ofxVersion) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(ofxVersions[*e-1], start)
enc.EncodeElement(ofxVersions[e-1], start)
return nil
}
@ -136,11 +137,11 @@ func (e *acctType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *acctType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e acctType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(acctTypes[*e-1], start)
enc.EncodeElement(acctTypes[e-1], start)
return nil
}
@ -217,11 +218,11 @@ func (e *trnType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *trnType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e trnType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(trnTypes[*e-1], start)
enc.EncodeElement(trnTypes[e-1], start)
return nil
}
@ -283,11 +284,11 @@ func (e *imageType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *imageType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e imageType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(imageTypes[*e-1], start)
enc.EncodeElement(imageTypes[e-1], start)
return nil
}
@ -349,11 +350,11 @@ func (e *imageRefType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) erro
return e.FromString(value)
}
func (e *imageRefType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e imageRefType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(imageRefTypes[*e-1], start)
enc.EncodeElement(imageRefTypes[e-1], start)
return nil
}
@ -415,11 +416,11 @@ func (e *checkSup) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *checkSup) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e checkSup) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(checkSups[*e-1], start)
enc.EncodeElement(checkSups[e-1], start)
return nil
}
@ -480,11 +481,11 @@ func (e *correctAction) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
return e.FromString(value)
}
func (e *correctAction) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e correctAction) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(correctActions[*e-1], start)
enc.EncodeElement(correctActions[e-1], start)
return nil
}
@ -546,11 +547,11 @@ func (e *balType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *balType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e balType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(balTypes[*e-1], start)
enc.EncodeElement(balTypes[e-1], start)
return nil
}
@ -616,11 +617,11 @@ func (e *inv401kSource) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
return e.FromString(value)
}
func (e *inv401kSource) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e inv401kSource) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(inv401kSources[*e-1], start)
enc.EncodeElement(inv401kSources[e-1], start)
return nil
}
@ -683,11 +684,11 @@ func (e *subAcctType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *subAcctType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e subAcctType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(subAcctTypes[*e-1], start)
enc.EncodeElement(subAcctTypes[e-1], start)
return nil
}
@ -748,11 +749,11 @@ func (e *buyType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *buyType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e buyType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(buyTypes[*e-1], start)
enc.EncodeElement(buyTypes[e-1], start)
return nil
}
@ -814,11 +815,11 @@ func (e *optAction) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *optAction) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e optAction) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(optActions[*e-1], start)
enc.EncodeElement(optActions[e-1], start)
return nil
}
@ -879,11 +880,11 @@ func (e *tferAction) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *tferAction) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e tferAction) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(tferActions[*e-1], start)
enc.EncodeElement(tferActions[e-1], start)
return nil
}
@ -944,11 +945,11 @@ func (e *posType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *posType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e posType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(posTypes[*e-1], start)
enc.EncodeElement(posTypes[e-1], start)
return nil
}
@ -1009,11 +1010,11 @@ func (e *secured) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *secured) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e secured) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(secureds[*e-1], start)
enc.EncodeElement(secureds[e-1], start)
return nil
}
@ -1075,11 +1076,11 @@ func (e *duration) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *duration) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e duration) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(durations[*e-1], start)
enc.EncodeElement(durations[e-1], start)
return nil
}
@ -1141,11 +1142,11 @@ func (e *restriction) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *restriction) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e restriction) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(restrictions[*e-1], start)
enc.EncodeElement(restrictions[e-1], start)
return nil
}
@ -1206,11 +1207,11 @@ func (e *unitType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *unitType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e unitType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(unitTypes[*e-1], start)
enc.EncodeElement(unitTypes[e-1], start)
return nil
}
@ -1271,11 +1272,11 @@ func (e *optBuyType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *optBuyType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e optBuyType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(optBuyTypes[*e-1], start)
enc.EncodeElement(optBuyTypes[e-1], start)
return nil
}
@ -1336,11 +1337,11 @@ func (e *sellType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *sellType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e sellType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(sellTypes[*e-1], start)
enc.EncodeElement(sellTypes[e-1], start)
return nil
}
@ -1409,11 +1410,11 @@ func (e *loanPmtFreq) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *loanPmtFreq) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e loanPmtFreq) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(loanPmtFreqs[*e-1], start)
enc.EncodeElement(loanPmtFreqs[e-1], start)
return nil
}
@ -1477,11 +1478,11 @@ func (e *incomeType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *incomeType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e incomeType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(incomeTypes[*e-1], start)
enc.EncodeElement(incomeTypes[e-1], start)
return nil
}
@ -1543,11 +1544,11 @@ func (e *sellReason) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *sellReason) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e sellReason) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(sellReasons[*e-1], start)
enc.EncodeElement(sellReasons[e-1], start)
return nil
}
@ -1608,11 +1609,11 @@ func (e *optSellType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *optSellType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e optSellType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(optSellTypes[*e-1], start)
enc.EncodeElement(optSellTypes[e-1], start)
return nil
}
@ -1675,11 +1676,11 @@ func (e *relType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *relType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e relType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(relTypes[*e-1], start)
enc.EncodeElement(relTypes[e-1], start)
return nil
}
@ -1742,11 +1743,11 @@ func (e *charType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *charType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e charType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(charTypes[*e-1], start)
enc.EncodeElement(charTypes[e-1], start)
return nil
}
@ -1807,11 +1808,11 @@ func (e *syncMode) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *syncMode) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e syncMode) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(syncModes[*e-1], start)
enc.EncodeElement(syncModes[e-1], start)
return nil
}
@ -1872,11 +1873,11 @@ func (e *ofxSec) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *ofxSec) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e ofxSec) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(ofxSecs[*e-1], start)
enc.EncodeElement(ofxSecs[e-1], start)
return nil
}
@ -1937,11 +1938,11 @@ func (e *debtType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *debtType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e debtType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(debtTypes[*e-1], start)
enc.EncodeElement(debtTypes[e-1], start)
return nil
}
@ -2004,11 +2005,11 @@ func (e *debtClass) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *debtClass) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e debtClass) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(debtClasss[*e-1], start)
enc.EncodeElement(debtClasss[e-1], start)
return nil
}
@ -2072,11 +2073,11 @@ func (e *couponFreq) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *couponFreq) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e couponFreq) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(couponFreqs[*e-1], start)
enc.EncodeElement(couponFreqs[e-1], start)
return nil
}
@ -2139,11 +2140,11 @@ func (e *callType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *callType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e callType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(callTypes[*e-1], start)
enc.EncodeElement(callTypes[e-1], start)
return nil
}
@ -2209,11 +2210,11 @@ func (e *assetClass) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *assetClass) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e assetClass) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(assetClasss[*e-1], start)
enc.EncodeElement(assetClasss[e-1], start)
return nil
}
@ -2275,11 +2276,11 @@ func (e *mfType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *mfType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e mfType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(mfTypes[*e-1], start)
enc.EncodeElement(mfTypes[e-1], start)
return nil
}
@ -2340,11 +2341,11 @@ func (e *optType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *optType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e optType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(optTypes[*e-1], start)
enc.EncodeElement(optTypes[e-1], start)
return nil
}
@ -2407,11 +2408,11 @@ func (e *stockType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *stockType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e stockType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(stockTypes[*e-1], start)
enc.EncodeElement(stockTypes[e-1], start)
return nil
}
@ -2475,11 +2476,11 @@ func (e *holderType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}
func (e *holderType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e holderType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(holderTypes[*e-1], start)
enc.EncodeElement(holderTypes[e-1], start)
return nil
}
@ -2542,11 +2543,11 @@ func (e *acctClassification) UnmarshalXML(d *xml.Decoder, start xml.StartElement
return e.FromString(value)
}
func (e *acctClassification) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e acctClassification) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(acctClassifications[*e-1], start)
enc.EncodeElement(acctClassifications[e-1], start)
return nil
}
@ -2608,11 +2609,11 @@ func (e *svcStatus) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
return e.FromString(value)
}
func (e *svcStatus) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e svcStatus) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(svcStatuss[*e-1], start)
enc.EncodeElement(svcStatuss[e-1], start)
return nil
}
@ -2682,11 +2683,11 @@ func (e *usProductType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) err
return e.FromString(value)
}
func (e *usProductType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
func (e usProductType) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {
if !e.Valid() {
return nil
}
enc.EncodeElement(usProductTypes[*e-1], start)
enc.EncodeElement(usProductTypes[e-1], start)
return nil
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
package ofxgo_test
package ofxgo
import (
"github.com/aclindsa/ofxgo"
"strings"
"testing"
"time"
@ -41,31 +40,31 @@ func TestMarshalCCStatementRequest(t *testing.T) {
</CREDITCARDMSGSRQV1>
</OFX>`
var client = ofxgo.Client{
var client = BasicClient{
AppID: "OFXGO",
AppVer: "0001",
SpecVersion: ofxgo.OfxVersion203,
SpecVersion: OfxVersion203,
}
var request ofxgo.Request
var request Request
request.Signon.UserID = "myusername"
request.Signon.UserPass = "Pa$$word"
request.Signon.Org = "BNK"
request.Signon.Fid = "1987"
statementRequest := ofxgo.CCStatementRequest{
statementRequest := CCStatementRequest{
TrnUID: "913846",
CCAcctFrom: ofxgo.CCAcct{
CCAcctFrom: CCAcct{
AcctID: "XXXXXXXXXXXX1234",
},
DtStart: ofxgo.NewDateGMT(2017, 1, 1, 0, 0, 0, 0),
DtStart: NewDateGMT(2017, 1, 1, 0, 0, 0, 0),
Include: true,
}
request.CreditCard = append(request.CreditCard, &statementRequest)
request.SetClientFields(&client)
// Overwrite the DtClient value set by SetClientFields to time.Now()
request.Signon.DtClient = *ofxgo.NewDateGMT(2017, 3, 31, 15, 38, 48, 0)
request.Signon.DtClient = *NewDateGMT(2017, 3, 31, 15, 38, 48, 0)
marshalCheckRequest(t, &request, expectedString)
}
@ -82,45 +81,45 @@ OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX><SIGNONMSGSRSV1><SONRS><STATUS><CODE>0<SEVERITY>INFO<MESSAGE>SUCCESS</STATUS><DTSERVER>20170331154648.331[-4:EDT]<LANGUAGE>ENG<FI><ORG>01<FID>81729</FI></SONRS></SIGNONMSGSRSV1><CREDITCARDMSGSRSV1><CCSTMTTRNRS><TRNUID>59e850ad-7448-b4ce-4b71-29057763b306<STATUS><CODE>0<SEVERITY>INFO</STATUS><CCSTMTRS><CURDEF>USD<CCACCTFROM><ACCTID>9283744488463775</CCACCTFROM><BANKTRANLIST><DTSTART>20161201154648.688[-5:EST]<DTEND>20170331154648.688[-4:EDT]<STMTTRN><TRNTYPE>DEBIT<DTPOSTED>20170209120000[0:GMT]<TRNAMT>-7.96<FITID>2017020924435657040207171600195<NAME>SLICE OF NY</STMTTRN><STMTTRN><TRNTYPE>CREDIT<DTPOSTED>20161228120000[0:GMT]<TRNAMT>3830.46<FITID>2016122823633637200000258482730<NAME>Payment Thank You Electro</STMTTRN><STMTTRN><TRNTYPE>DEBIT<DTPOSTED>20170327120000[0:GMT]<TRNAMT>-17.7<FITID>2017032724445727085300442885680<NAME>KROGER FUEL #9999</STMTTRN></BANKTRANLIST><LEDGERBAL><BALAMT>-9334<DTASOF>20170331080000.000[-4:EDT]</LEDGERBAL><AVAILBAL><BALAMT>7630.17<DTASOF>20170331080000.000[-4:EDT]</AVAILBAL></CCSTMTRS></CCSTMTTRNRS></CREDITCARDMSGSRSV1></OFX>`)
var expected ofxgo.Response
var expected Response
EDT := time.FixedZone("EDT", -4*60*60)
EST := time.FixedZone("EST", -5*60*60)
expected.Version = ofxgo.OfxVersion102
expected.Version = OfxVersion102
expected.Signon.Status.Code = 0
expected.Signon.Status.Severity = "INFO"
expected.Signon.Status.Message = "SUCCESS"
expected.Signon.DtServer = *ofxgo.NewDate(2017, 3, 31, 15, 46, 48, 331000000, EDT)
expected.Signon.DtServer = *NewDate(2017, 3, 31, 15, 46, 48, 331000000, EDT)
expected.Signon.Language = "ENG"
expected.Signon.Org = "01"
expected.Signon.Fid = "81729"
var trnamt1, trnamt2, trnamt3 ofxgo.Amount
var trnamt1, trnamt2, trnamt3 Amount
trnamt1.SetFrac64(-796, 100)
trnamt2.SetFrac64(383046, 100)
trnamt3.SetFrac64(-1770, 100)
banktranlist := ofxgo.TransactionList{
DtStart: *ofxgo.NewDate(2016, 12, 1, 15, 46, 48, 688000000, EST),
DtEnd: *ofxgo.NewDate(2017, 3, 31, 15, 46, 48, 688000000, EDT),
Transactions: []ofxgo.Transaction{
banktranlist := TransactionList{
DtStart: *NewDate(2016, 12, 1, 15, 46, 48, 688000000, EST),
DtEnd: *NewDate(2017, 3, 31, 15, 46, 48, 688000000, EDT),
Transactions: []Transaction{
{
TrnType: ofxgo.TrnTypeDebit,
DtPosted: *ofxgo.NewDateGMT(2017, 2, 9, 12, 0, 0, 0),
TrnType: TrnTypeDebit,
DtPosted: *NewDateGMT(2017, 2, 9, 12, 0, 0, 0),
TrnAmt: trnamt1,
FiTID: "2017020924435657040207171600195",
Name: "SLICE OF NY",
},
{
TrnType: ofxgo.TrnTypeCredit,
DtPosted: *ofxgo.NewDateGMT(2016, 12, 28, 12, 0, 0, 0),
TrnType: TrnTypeCredit,
DtPosted: *NewDateGMT(2016, 12, 28, 12, 0, 0, 0),
TrnAmt: trnamt2,
FiTID: "2016122823633637200000258482730",
Name: "Payment Thank You Electro",
},
{
TrnType: ofxgo.TrnTypeDebit,
DtPosted: *ofxgo.NewDateGMT(2017, 3, 27, 12, 0, 0, 0),
TrnType: TrnTypeDebit,
DtPosted: *NewDateGMT(2017, 3, 27, 12, 0, 0, 0),
TrnAmt: trnamt3,
FiTID: "2017032724445727085300442885680",
Name: "KROGER FUEL #9999",
@ -128,37 +127,38 @@ NEWFILEUID:NONE
},
}
var balamt, availbalamt ofxgo.Amount
var balamt, availbalamt Amount
balamt.SetFrac64(-933400, 100)
availbalamt.SetFrac64(763017, 100)
usd, err := ofxgo.NewCurrSymbol("USD")
usd, err := NewCurrSymbol("USD")
if err != nil {
t.Fatalf("Unexpected error creating CurrSymbol for USD\n")
}
statementResponse := ofxgo.CCStatementResponse{
statementResponse := CCStatementResponse{
TrnUID: "59e850ad-7448-b4ce-4b71-29057763b306",
Status: ofxgo.Status{
Status: Status{
Code: 0,
Severity: "INFO",
},
CurDef: *usd,
CCAcctFrom: ofxgo.CCAcct{
CCAcctFrom: CCAcct{
AcctID: "9283744488463775",
},
BankTranList: &banktranlist,
BalAmt: balamt,
DtAsOf: *ofxgo.NewDate(2017, 3, 31, 8, 0, 0, 0, EDT),
DtAsOf: *NewDate(2017, 3, 31, 8, 0, 0, 0, EDT),
AvailBalAmt: &availbalamt,
AvailDtAsOf: ofxgo.NewDate(2017, 3, 31, 8, 0, 0, 0, EDT),
AvailDtAsOf: NewDate(2017, 3, 31, 8, 0, 0, 0, EDT),
}
expected.CreditCard = append(expected.CreditCard, &statementResponse)
response, err := ofxgo.ParseResponse(responseReader)
response, err := ParseResponse(responseReader)
if err != nil {
t.Fatalf("Unexpected error unmarshalling response: %s\n", err)
}
checkResponsesEqual(t, &expected, response)
checkResponseRoundTrip(t, response)
}

109
discovercard_client.go Normal file
View File

@ -0,0 +1,109 @@
package ofxgo
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// DiscoverCardClient provides a Client implementation which handles
// DiscoverCard's broken HTTP header behavior. DiscoverCardClient uses default,
// non-zero settings, if its fields are not initialized.
type DiscoverCardClient struct {
*BasicClient
}
// NewDiscoverCardClient returns a Client interface configured to handle
// Discover Card's brand of idiosyncrasy
func NewDiscoverCardClient(bc *BasicClient) Client {
return &DiscoverCardClient{bc}
}
func discoverCardHTTPPost(URL string, r io.Reader) (*http.Response, error) {
// Either convert or copy to a bytes.Buffer to be able to determine the
// request length for the Content-Length header
buf, ok := r.(*bytes.Buffer)
if !ok {
buf = &bytes.Buffer{}
_, err := io.Copy(buf, r)
if err != nil {
return nil, err
}
}
url, err := url.Parse(URL)
if err != nil {
return nil, err
}
path := url.Path
if path == "" {
path = "/"
}
// Discover requires only these headers and in this exact order, or it
// returns HTTP 403
headers := fmt.Sprintf("POST %s HTTP/1.1\r\n"+
"Content-Type: application/x-ofx\r\n"+
"Host: %s\r\n"+
"Content-Length: %d\r\n"+
"Connection: Keep-Alive\r\n"+
"\r\n", path, url.Hostname(), buf.Len())
host := url.Host
if url.Port() == "" {
host += ":443"
}
// BUGBUG: cannot do defer conn.Close() until body is read,
// we are "leaking" a socket here, but it will be finalized
conn, err := tls.Dial("tcp", host, nil)
if err != nil {
return nil, err
}
fmt.Fprint(conn, headers)
_, err = io.Copy(conn, buf)
if err != nil {
return nil, err
}
return http.ReadResponse(bufio.NewReader(conn), nil)
}
// RawRequest is a convenience wrapper around http.Post. It is exposed only for
// when you need to read/inspect the raw HTTP response yourself.
func (c *DiscoverCardClient) RawRequest(URL string, r io.Reader) (*http.Response, error) {
if !strings.HasPrefix(URL, "https://") {
return nil, errors.New("Refusing to send OFX request with possible plain-text password over non-https protocol")
}
response, err := discoverCardHTTPPost(URL, r)
if err != nil {
return nil, err
}
if response.StatusCode != 200 {
return nil, errors.New("OFXQuery request status: " + response.Status)
}
return response, nil
}
// RequestNoParse marshals a Request to XML, makes an HTTP request, and returns
// the raw HTTP response
func (c *DiscoverCardClient) RequestNoParse(r *Request) (*http.Response, error) {
return clientRequestNoParse(c, r)
}
// Request marshals a Request to XML, makes an HTTP request, and then
// unmarshals the response into a Response object.
func (c *DiscoverCardClient) Request(r *Request) (*Response, error) {
return clientRequest(c, r)
}

27
doc.go
View File

@ -71,33 +71,32 @@ account and print the balance:
import (
"fmt"
"github.com/aclindsa/ofxgo"
"os"
)
var client ofxgo.Client // By not initializing them, we accept all default
var client Client // By not initializing them, we accept all default
// client values
var request ofxgo.Request
var request Request
// These are all specific to you and your financial institution
request.URL = "https://ofx.example.com"
request.Signon.UserID = ofxgo.String("john")
request.Signon.UserPass = ofxgo.String("hunter2")
request.Signon.Org = ofxgo.String("MyBank")
request.Signon.Fid = ofxgo.String("0001")
request.Signon.UserID = String("john")
request.Signon.UserPass = String("hunter2")
request.Signon.Org = String("MyBank")
request.Signon.Fid = String("0001")
uid, err := ofxgo.RandomUID()
uid, err := RandomUID()
if err != nil {
fmt.Println("Error creating uid for transaction:", err)
os.Exit(1)
}
statementRequest := ofxgo.StatementRequest{
statementRequest := StatementRequest{
TrnUID: *uid,
BankAcctFrom: ofxgo.BankAcct{
BankID: ofxgo.String("123456789"),
AcctID: ofxgo.String("11111111111"),
AcctType: ofxgo.AcctTypeChecking,
BankAcctFrom: BankAcct{
BankID: String("123456789"),
AcctID: String("11111111111"),
AcctType: AcctTypeChecking,
},
}
@ -117,7 +116,7 @@ account and print the balance:
if len(response.Bank) < 1 {
fmt.Println("No banking messages received")
} else if stmt, ok := response.Bank[0].(*ofxgo.StatementResponse); ok {
} else if stmt, ok := response.Bank[0].(*StatementResponse); ok {
fmt.Printf("Balance: %s %s (as of %s)\n", stmt.BalAmt, stmt.CurDef, stmt.DtAsOf)
}

View File

@ -65,8 +65,9 @@ header = """package ofxgo
import (
"errors"
"fmt"
"github.com/aclindsa/xml"
"strings"
"github.com/aclindsa/xml"
)
"""
@ -115,11 +116,11 @@ func (e *{enumLower}) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
return e.FromString(value)
}}
func (e *{enumLower}) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {{
func (e {enumLower}) MarshalXML(enc *xml.Encoder, start xml.StartElement) error {{
if !e.Valid() {{
return nil
}}
enc.EncodeElement({enumLower}s[*e-1], start)
enc.EncodeElement({enumLower}s[e-1], start)
return nil
}}
@ -164,7 +165,7 @@ with open("constants.go", 'w') as f:
constNames=constNames,
upperValueString=upperValueString))
test_header = """package ofxgo_test
test_header = """package ofxgo
/*
* Do not edit this file by hand. It is auto-generated by calling `go generate`.
@ -173,16 +174,16 @@ test_header = """package ofxgo_test
*/
import (
"github.com/aclindsa/xml"
"github.com/aclindsa/ofxgo"
"strings"
"testing"
"github.com/aclindsa/xml"
)
"""
test_template = """
func Test{enum}(t *testing.T) {{
e, err := ofxgo.New{enum}("{firstValueUpper}")
e, err := New{enum}("{firstValueUpper}")
if err != nil {{
t.Fatalf("Unexpected error creating new {enum} from string \\\"{firstValueUpper}\\\"\\n")
}}
@ -199,7 +200,7 @@ func Test{enum}(t *testing.T) {{
marshalHelper(t, "{lastValueUpper}", &e)
overwritten, err := ofxgo.New{enum}("THISWILLNEVERBEAVALIDENUMSTRING")
overwritten, err := New{enum}("THISWILLNEVERBEAVALIDENUMSTRING")
if err == nil {{
t.Fatalf("Expected error creating new {enum} from string \\\"THISWILLNEVERBEAVALIDENUMSTRING\\\"\\n")
}}
@ -224,6 +225,18 @@ func Test{enum}(t *testing.T) {{
if err == nil {{
t.Fatalf("Expected error unmarshalling garbage value\\n")
}}
type SC struct {{
E {enumLower}
}}
sc := SC{{E: e}}
b, err = xml.Marshal(sc)
if err != nil {{
t.Fatalf("Unexpected error on xml.Marshal(struct {enum}): %s\\n", err)
}}
if string(b) != "<SC><E>{lastValueUpper}</E></SC>" {{
t.Fatalf("Expected '%s', got '%s'\\n", "<SC><E>{lastValueUpper}</E></SC>", string(b))
}}
}}
"""
@ -231,8 +244,10 @@ with open("constants_test.go", 'w') as f:
f.write(test_header)
for enum in enums:
enumLower = enum[:1].lower() + enum[1:].replace(" ", "")
firstValueUpper = enums[enum][0][0].upper()
lastValueUpper = enums[enum][0][-1].upper()
f.write(test_template.format(enum=enum,
enumLower=enumLower,
firstValueUpper=firstValueUpper,
lastValueUpper=lastValueUpper))

9
go.mod Normal file
View File

@ -0,0 +1,9 @@
module github.com/aclindsa/ofxgo
require (
github.com/aclindsa/xml v0.0.0-20201125035057-bbd5c9ec99ac
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/text v0.3.7
)
go 1.9

9
go.sum Normal file
View File

@ -0,0 +1,9 @@
github.com/aclindsa/xml v0.0.0-20201125035057-bbd5c9ec99ac h1:xCNSfPWpcx3Sdz/+aB/Re4L8oA6Y4kRRRuTh1CHCDEw=
github.com/aclindsa/xml v0.0.0-20201125035057-bbd5c9ec99ac/go.mod h1:GjqOUT8xlg5+T19lFv6yAGNrtMKkZ839Gt4e16mBXlY=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@ -103,7 +103,7 @@ type InvSell struct {
Taxes Amount `xml:"TAXES,omitempty"`
Fees Amount `xml:"FEES,omitempty"`
Load Amount `xml:"LOAD,omitempty"`
Witholding Amount `xml:"WITHHOLDING,omitempty"` // Federal tax witholdings
Withholding Amount `xml:"WITHHOLDING,omitempty"` // Federal tax withholdings
TaxExempt Boolean `xml:"TAXEXEMPT,omitempty"` // Tax-exempt transaction
Total Amount `xml:"TOTAL"` // Transaction total. Buys, sells, etc.:((quan. * (price +/- markup/markdown)) +/-(commission + fees + load + taxes + penalty + withholding + statewithholding)). Distributions, interest, margin interest, misc. expense, etc.: amount. Return of cap: cost basis
Gain Amount `xml:"GAIN,omitempty"` // Total gain
@ -112,9 +112,9 @@ type InvSell struct {
SubAcctSec subAcctType `xml:"SUBACCTSEC"` // Sub-account type for this security. One of CASH, MARGIN, SHORT, OTHER
SubAcctFund subAcctType `xml:"SUBACCTFUND"` // Where did the money for the transaction come from or go to? CASH, MARGIN, SHORT, OTHER
LoanID String `xml:"LOANID,omitempty"` // For 401(k) accounts only. Indicates that the transaction was due to a loan or a loan repayment, and which loan it was
StateWitholding Amount `xml:"STATEWITHHOLDING,omitempty"` // State tax witholdings
Penalty Amount `xml:"PENALTY,omitempty"` // Amount withheld due to penalty
LoanID String `xml:"LOANID,omitempty"` // For 401(k) accounts only. Indicates that the transaction was due to a loan or a loan repayment, and which loan it was
StateWithholding Amount `xml:"STATEWITHHOLDING,omitempty"` // State tax withholdings
Penalty Amount `xml:"PENALTY,omitempty"` // Amount withheld due to penalty
Inv401kSource inv401kSource `xml:"INV401KSOURCE,omitempty"` // Source of money for this transaction. One of PRETAX, AFTERTAX, MATCH, PROFITSHARING, ROLLOVER, OTHERVEST, OTHERNONVEST for 401(k) accounts. Default if not present is OTHERNONVEST. The following cash source types are subject to vesting: MATCH, PROFITSHARING, and OTHERVEST
}
@ -131,6 +131,10 @@ func (t BuyDebt) TransactionType() string {
return "BUYDEBT"
}
func (t BuyDebt) InvTransaction() InvTran {
return t.InvBuy.InvTran
}
// BuyMF represents a transaction purchasing a mutual fund
type BuyMF struct {
XMLName xml.Name `xml:"BUYMF"`
@ -144,6 +148,10 @@ func (t BuyMF) TransactionType() string {
return "BUYMF"
}
func (t BuyMF) InvTransaction() InvTran {
return t.InvBuy.InvTran
}
// BuyOpt represents a transaction purchasing an option
type BuyOpt struct {
XMLName xml.Name `xml:"BUYOPT"`
@ -157,6 +165,10 @@ func (t BuyOpt) TransactionType() string {
return "BUYOPT"
}
func (t BuyOpt) InvTransaction() InvTran {
return t.InvBuy.InvTran
}
// BuyOther represents a transaction purchasing a type of security not covered
// by the other Buy* structs
type BuyOther struct {
@ -169,6 +181,10 @@ func (t BuyOther) TransactionType() string {
return "BUYOTHER"
}
func (t BuyOther) InvTransaction() InvTran {
return t.InvBuy.InvTran
}
// BuyStock represents a transaction purchasing stock
type BuyStock struct {
XMLName xml.Name `xml:"BUYSTOCK"`
@ -181,6 +197,10 @@ func (t BuyStock) TransactionType() string {
return "BUYSTOCK"
}
func (t BuyStock) InvTransaction() InvTran {
return t.InvBuy.InvTran
}
// ClosureOpt represents a transaction closing a position for an option
type ClosureOpt struct {
XMLName xml.Name `xml:"CLOSUREOPT"`
@ -199,6 +219,10 @@ func (t ClosureOpt) TransactionType() string {
return "CLOSUREOPT"
}
func (t ClosureOpt) InvTransaction() InvTran {
return t.InvTran
}
// Income represents a transaction where investment income is being realized as
// cash into the investment account
type Income struct {
@ -210,7 +234,7 @@ type Income struct {
SubAcctSec subAcctType `xml:"SUBACCTSEC"` // Sub-account type for this security. One of CASH, MARGIN, SHORT, OTHER
SubAcctFund subAcctType `xml:"SUBACCTFUND"` // Where did the money for the transaction come from or go to? CASH, MARGIN, SHORT, OTHER
TaxExempt Boolean `xml:"TAXEXEMPT,omitempty"` // Tax-exempt transaction
Witholding Amount `xml:"WITHHOLDING,omitempty"` // Federal tax witholdings
Withholding Amount `xml:"WITHHOLDING,omitempty"` // Federal tax withholdings
Currency Currency `xml:"CURRENCY,omitempty"` // Represents the currency this transaction is in (instead of CURDEF in INVSTMTRS) if Valid()
OrigCurrency Currency `xml:"ORIGCURRENCY,omitempty"` // Represents the currency this transaction was converted to INVSTMTRS' CURDEF from if Valid
Inv401kSource inv401kSource `xml:"INV401KSOURCE,omitempty"` // Source of money for this transaction. One of PRETAX, AFTERTAX, MATCH, PROFITSHARING, ROLLOVER, OTHERVEST, OTHERNONVEST for 401(k) accounts. Default if not present is OTHERNONVEST. The following cash source types are subject to vesting: MATCH, PROFITSHARING, and OTHERVEST
@ -221,6 +245,10 @@ func (t Income) TransactionType() string {
return "INCOME"
}
func (t Income) InvTransaction() InvTran {
return t.InvTran
}
// InvExpense represents a transaction realizing an expense associated with an
// investment
type InvExpense struct {
@ -240,6 +268,10 @@ func (t InvExpense) TransactionType() string {
return "INVEXPENSE"
}
func (t InvExpense) InvTransaction() InvTran {
return t.InvTran
}
// JrnlFund represents a transaction journaling cash holdings between
// sub-accounts within the same investment account
type JrnlFund struct {
@ -255,6 +287,10 @@ func (t JrnlFund) TransactionType() string {
return "JRNLFUND"
}
func (t JrnlFund) InvTransaction() InvTran {
return t.InvTran
}
// JrnlSec represents a transaction journaling security holdings between
// sub-accounts within the same investment account
type JrnlSec struct {
@ -271,6 +307,10 @@ func (t JrnlSec) TransactionType() string {
return "JRNLSEC"
}
func (t JrnlSec) InvTransaction() InvTran {
return t.InvTran
}
// MarginInterest represents a transaction realizing a margin interest expense
type MarginInterest struct {
XMLName xml.Name `xml:"MARGININTEREST"`
@ -286,6 +326,10 @@ func (t MarginInterest) TransactionType() string {
return "MARGININTEREST"
}
func (t MarginInterest) InvTransaction() InvTran {
return t.InvTran
}
// Reinvest is a single transaction that contains both income and an investment
// transaction. If servers cant track this as a single transaction they should
// return an Income transaction and an InvTran.
@ -313,6 +357,10 @@ func (t Reinvest) TransactionType() string {
return "REINVEST"
}
func (t Reinvest) InvTransaction() InvTran {
return t.InvTran
}
// RetOfCap represents a transaction where capital is being returned to the
// account holder
type RetOfCap struct {
@ -332,6 +380,10 @@ func (t RetOfCap) TransactionType() string {
return "RETOFCAP"
}
func (t RetOfCap) InvTransaction() InvTran {
return t.InvTran
}
// SellDebt represents the sale of a debt security. Used when debt is sold,
// called, or reaches maturity.
type SellDebt struct {
@ -346,6 +398,10 @@ func (t SellDebt) TransactionType() string {
return "SELLDEBT"
}
func (t SellDebt) InvTransaction() InvTran {
return t.InvSell.InvTran
}
// SellMF represents a transaction selling a mutual fund
type SellMF struct {
XMLName xml.Name `xml:"SELLMF"`
@ -360,6 +416,10 @@ func (t SellMF) TransactionType() string {
return "SELLMF"
}
func (t SellMF) InvTransaction() InvTran {
return t.InvSell.InvTran
}
// SellOpt represents a transaction selling an option. Depending on the value
// of OptSellType, can be used to sell a previously bought option or write a
// new option.
@ -378,6 +438,10 @@ func (t SellOpt) TransactionType() string {
return "SELLOPT"
}
func (t SellOpt) InvTransaction() InvTran {
return t.InvSell.InvTran
}
// SellOther represents a transaction selling a security type not covered by
// the other Sell* structs
type SellOther struct {
@ -390,6 +454,10 @@ func (t SellOther) TransactionType() string {
return "SELLOTHER"
}
func (t SellOther) InvTransaction() InvTran {
return t.InvSell.InvTran
}
// SellStock represents a transaction selling stock
type SellStock struct {
XMLName xml.Name `xml:"SELLSTOCK"`
@ -402,6 +470,10 @@ func (t SellStock) TransactionType() string {
return "SELLSTOCK"
}
func (t SellStock) InvTransaction() InvTran {
return t.InvSell.InvTran
}
// Split represents a stock or mutual fund split
type Split struct {
XMLName xml.Name `xml:"SPLIT"`
@ -424,6 +496,10 @@ func (t Split) TransactionType() string {
return "SPLIT"
}
func (t Split) InvTransaction() InvTran {
return t.InvTran
}
// Transfer represents the transfer of securities into or out of an account
type Transfer struct {
XMLName xml.Name `xml:"TRANSFER"`
@ -445,10 +521,15 @@ func (t Transfer) TransactionType() string {
return "TRANSFER"
}
func (t Transfer) InvTransaction() InvTran {
return t.InvTran
}
// InvTransaction is a generic interface met by all investment transactions
// (Buy*, Sell*, & co.)
type InvTransaction interface {
TransactionType() string
InvTransaction() InvTran
}
// InvBankTransaction is a banking transaction performed in an investment
@ -465,6 +546,7 @@ type InvBankTransaction struct {
// security-related transactions themselves. It must be unmarshalled manually
// due to the structure (don't know what kind of InvTransaction is coming next)
type InvTranList struct {
XMLName xml.Name `xml:"INVTRANLIST"`
DtStart Date
DtEnd Date // This is the value that should be sent as <DTSTART> in the next InvStatementRequest to ensure that no transactions are missed
InvTransactions []InvTransaction
@ -630,6 +712,119 @@ func (l *InvTranList) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
}
}
// MarshalXML handles marshalling an InvTranList element to an SGML/XML string
func (l *InvTranList) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
invTranListElement := xml.StartElement{Name: xml.Name{Local: "INVTRANLIST"}}
if err := e.EncodeToken(invTranListElement); err != nil {
return err
}
err := e.EncodeElement(&l.DtStart, xml.StartElement{Name: xml.Name{Local: "DTSTART"}})
if err != nil {
return err
}
err = e.EncodeElement(&l.DtEnd, xml.StartElement{Name: xml.Name{Local: "DTEND"}})
if err != nil {
return err
}
for _, t := range l.InvTransactions {
start := xml.StartElement{Name: xml.Name{Local: t.TransactionType()}}
switch tran := t.(type) {
case BuyDebt:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case BuyMF:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case BuyOpt:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case BuyOther:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case BuyStock:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case ClosureOpt:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case Income:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case InvExpense:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case JrnlFund:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case JrnlSec:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case MarginInterest:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case Reinvest:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case RetOfCap:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case SellDebt:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case SellMF:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case SellOpt:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case SellOther:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case SellStock:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case Split:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
case Transfer:
if err := e.EncodeElement(&tran, start); err != nil {
return err
}
default:
return errors.New("Invalid INVTRANLIST child type: " + tran.TransactionType())
}
}
for _, tran := range l.BankTransactions {
err = e.EncodeElement(&tran, xml.StartElement{Name: xml.Name{Local: "INVBANKTRAN"}})
if err != nil {
return err
}
}
if err := e.EncodeToken(invTranListElement.End()); err != nil {
return err
}
return nil
}
// InvPosition contains generic position information included in each of the
// other *Position types
type InvPosition struct {
@ -650,6 +845,7 @@ type InvPosition struct {
// Position is an interface satisfied by all the other *Position types
type Position interface {
PositionType() string
InvPosition() InvPosition
}
// DebtPosition represents a position held in a debt security
@ -663,6 +859,11 @@ func (p DebtPosition) PositionType() string {
return "POSDEBT"
}
// InvPosition returns InvPos
func (p DebtPosition) InvPosition() InvPosition {
return p.InvPos
}
// MFPosition represents a position held in a mutual fund
type MFPosition struct {
XMLName xml.Name `xml:"POSMF"`
@ -678,6 +879,11 @@ func (p MFPosition) PositionType() string {
return "POSMF"
}
// InvPosition returns InvPos
func (p MFPosition) InvPosition() InvPosition {
return p.InvPos
}
// OptPosition represents a position held in an option
type OptPosition struct {
XMLName xml.Name `xml:"POSOPT"`
@ -690,6 +896,11 @@ func (p OptPosition) PositionType() string {
return "POSOPT"
}
// InvPosition returns InvPos
func (p OptPosition) InvPosition() InvPosition {
return p.InvPos
}
// OtherPosition represents a position held in a security type not covered by
// the other *Position elements
type OtherPosition struct {
@ -702,6 +913,11 @@ func (p OtherPosition) PositionType() string {
return "POSOTHER"
}
// InvPosition returns InvPos
func (p OtherPosition) InvPosition() InvPosition {
return p.InvPos
}
// StockPosition represents a position held in a stock
type StockPosition struct {
XMLName xml.Name `xml:"POSSTOCK"`
@ -716,6 +932,11 @@ func (p StockPosition) PositionType() string {
return "POSSTOCK"
}
// InvPosition returns InvPos
func (p StockPosition) InvPosition() InvPosition {
return p.InvPos
}
// PositionList represents a list of positions held in securities in an
// investment account
type PositionList []Position
@ -770,6 +991,45 @@ func (p *PositionList) UnmarshalXML(d *xml.Decoder, start xml.StartElement) erro
}
}
// MarshalXML handles marshalling a PositionList to an XML string
func (p PositionList) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
invPosListElement := xml.StartElement{Name: xml.Name{Local: "INVPOSLIST"}}
if err := e.EncodeToken(invPosListElement); err != nil {
return err
}
for _, position := range p {
start := xml.StartElement{Name: xml.Name{Local: position.PositionType()}}
switch pos := position.(type) {
case DebtPosition:
if err := e.EncodeElement(&pos, start); err != nil {
return err
}
case MFPosition:
if err := e.EncodeElement(&pos, start); err != nil {
return err
}
case OptPosition:
if err := e.EncodeElement(&pos, start); err != nil {
return err
}
case OtherPosition:
if err := e.EncodeElement(&pos, start); err != nil {
return err
}
case StockPosition:
if err := e.EncodeElement(&pos, start); err != nil {
return err
}
default:
return errors.New("Invalid INVPOSLIST child type: " + pos.PositionType())
}
}
if err := e.EncodeToken(invPosListElement.End()); err != nil {
return err
}
return nil
}
// InvBalance contains three (or optionally four) specified balances as well as
// a free-form list of generic balance information which may be provided by an
// FI.
@ -1036,6 +1296,69 @@ func (o *OOList) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
}
}
// MarshalXML handles marshalling an OOList to an XML string
func (o OOList) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
ooListElement := xml.StartElement{Name: xml.Name{Local: "INVOOLIST"}}
if err := e.EncodeToken(ooListElement); err != nil {
return err
}
for _, openorder := range o {
start := xml.StartElement{Name: xml.Name{Local: openorder.OrderType()}}
switch oo := openorder.(type) {
case OOBuyDebt:
if err := e.EncodeElement(&oo, start); err != nil {
return err
}
case OOBuyMF:
if err := e.EncodeElement(&oo, start); err != nil {
return err
}
case OOBuyOpt:
if err := e.EncodeElement(&oo, start); err != nil {
return err
}
case OOBuyOther:
if err := e.EncodeElement(&oo, start); err != nil {
return err
}
case OOBuyStock:
if err := e.EncodeElement(&oo, start); err != nil {
return err
}
case OOSellDebt:
if err := e.EncodeElement(&oo, start); err != nil {
return err
}
case OOSellMF:
if err := e.EncodeElement(&oo, start); err != nil {
return err
}
case OOSellOpt:
if err := e.EncodeElement(&oo, start); err != nil {
return err
}
case OOSellOther:
if err := e.EncodeElement(&oo, start); err != nil {
return err
}
case OOSellStock:
if err := e.EncodeElement(&oo, start); err != nil {
return err
}
case OOSwitchMF:
if err := e.EncodeElement(&oo, start); err != nil {
return err
}
default:
return errors.New("Invalid OOLIST child type: " + oo.OrderType())
}
}
if err := e.EncodeToken(ooListElement.End()); err != nil {
return err
}
return nil
}
// ContribSecurity identifies current contribution allocation for a security in
// a 401(k) account
type ContribSecurity struct {

File diff suppressed because it is too large Load Diff

View File

@ -172,6 +172,7 @@ var ofxLeafElements = []string{
"IDSCOPE",
"INCBAL",
"INCIMAGES",
"INCLUDE",
"INCOMETYPE",
"INCOO",
"INITIALAMT",

View File

@ -3,6 +3,7 @@ package ofxgo
import (
"errors"
"github.com/aclindsa/xml"
"strings"
)
// ProfileRequest represents a request for a server to provide a profile of its
@ -126,6 +127,35 @@ func (msl *MessageSetList) UnmarshalXML(d *xml.Decoder, start xml.StartElement)
}
}
// MarshalXML handles marshalling a MessageSetList element to an XML string
func (msl *MessageSetList) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
messageSetListElement := xml.StartElement{Name: xml.Name{Local: "MSGSETLIST"}}
if err := e.EncodeToken(messageSetListElement); err != nil {
return err
}
for _, messageset := range *msl {
if !strings.HasSuffix(messageset.Name, "V1") {
return errors.New("Expected MessageSet.Name to end with \"V1\"")
}
messageSetName := strings.TrimSuffix(messageset.Name, "V1")
messageSetElement := xml.StartElement{Name: xml.Name{Local: messageSetName}}
if err := e.EncodeToken(messageSetElement); err != nil {
return err
}
start := xml.StartElement{Name: xml.Name{Local: messageset.Name}}
if err := e.EncodeElement(&messageset, start); err != nil {
return err
}
if err := e.EncodeToken(messageSetElement.End()); err != nil {
return err
}
}
if err := e.EncodeToken(messageSetListElement.End()); err != nil {
return err
}
return nil
}
// ProfileResponse contains a requested profile of the server's capabilities
// (which message sets and versions it supports, how to access them, which
// languages and which types of synchronization they support, etc.). Note that

View File

@ -1,7 +1,6 @@
package ofxgo_test
package ofxgo
import (
"github.com/aclindsa/ofxgo"
"strings"
"testing"
"time"
@ -36,13 +35,13 @@ func TestMarshalProfileRequest(t *testing.T) {
</PROFMSGSRQV1>
</OFX>`
var client = ofxgo.Client{
var client = BasicClient{
AppID: "OFXGO",
AppVer: "0001",
SpecVersion: ofxgo.OfxVersion203,
SpecVersion: OfxVersion203,
}
var request ofxgo.Request
var request Request
request.Signon.UserID = "anonymous00000000000000000000000"
request.Signon.UserPass = "anonymous00000000000000000000000"
request.Signon.Org = "BNK"
@ -50,15 +49,15 @@ func TestMarshalProfileRequest(t *testing.T) {
EST := time.FixedZone("EST", -5*60*60)
profileRequest := ofxgo.ProfileRequest{
profileRequest := ProfileRequest{
TrnUID: "983373",
DtProfUp: *ofxgo.NewDate(2016, 1, 1, 0, 0, 0, 0, EST),
DtProfUp: *NewDate(2016, 1, 1, 0, 0, 0, 0, EST),
}
request.Prof = append(request.Prof, &profileRequest)
request.SetClientFields(&client)
// Overwrite the DtClient value set by SetClientFields to time.Now()
request.Signon.DtClient = *ofxgo.NewDate(2016, 6, 14, 7, 34, 0, 0, EST)
request.Signon.DtClient = *NewDate(2016, 6, 14, 7, 34, 0, 0, EST)
marshalCheckRequest(t, &request, expectedString)
}
@ -213,89 +212,89 @@ NEWFILEUID:NONE
</PROFTRNRS>
</PROFMSGSRSV1>
</OFX>`)
var expected ofxgo.Response
var expected Response
expected.Version = ofxgo.OfxVersion102
expected.Version = OfxVersion102
expected.Signon.Status.Code = 0
expected.Signon.Status.Severity = "INFO"
expected.Signon.DtServer = *ofxgo.NewDateGMT(2017, 4, 3, 9, 34, 58, 0)
expected.Signon.DtServer = *NewDateGMT(2017, 4, 3, 9, 34, 58, 0)
expected.Signon.Language = "ENG"
expected.Signon.DtProfUp = ofxgo.NewDateGMT(2002, 11, 19, 14, 0, 0, 0)
expected.Signon.DtProfUp = NewDateGMT(2002, 11, 19, 14, 0, 0, 0)
profileResponse := ofxgo.ProfileResponse{
profileResponse := ProfileResponse{
TrnUID: "0f94ce83-13b7-7568-e4fc-c02c7b47e7ab",
Status: ofxgo.Status{
Status: Status{
Code: 0,
Severity: "INFO",
},
MessageSetList: ofxgo.MessageSetList{
ofxgo.MessageSet{
MessageSetList: MessageSetList{
MessageSet{
Name: "SIGNONMSGSETV1",
Ver: 1,
URL: "https://ofx.example.com/cgi-ofx/exampleofx",
OfxSec: ofxgo.OfxSecNone,
OfxSec: OfxSecNone,
TranspSec: true,
SignonRealm: "Example Trade",
Language: []ofxgo.String{"ENG"},
SyncMode: ofxgo.SyncModeLite,
Language: []String{"ENG"},
SyncMode: SyncModeLite,
RespFileER: false,
// Ignored: <INTU.TIMEOUT>300
},
ofxgo.MessageSet{
MessageSet{
Name: "SIGNUPMSGSETV1",
Ver: 1,
URL: "https://ofx.example.com/cgi-ofx/exampleofx",
OfxSec: ofxgo.OfxSecNone,
OfxSec: OfxSecNone,
TranspSec: true,
SignonRealm: "Example Trade",
Language: []ofxgo.String{"ENG"},
SyncMode: ofxgo.SyncModeLite,
Language: []String{"ENG"},
SyncMode: SyncModeLite,
RespFileER: false,
// Ignored: <INTU.TIMEOUT>300
},
ofxgo.MessageSet{
MessageSet{
Name: "INVSTMTMSGSETV1",
Ver: 1,
URL: "https://ofx.example.com/cgi-ofx/exampleofx",
OfxSec: ofxgo.OfxSecNone,
OfxSec: OfxSecNone,
TranspSec: true,
SignonRealm: "Example Trade",
Language: []ofxgo.String{"ENG"},
SyncMode: ofxgo.SyncModeLite,
Language: []String{"ENG"},
SyncMode: SyncModeLite,
RespFileER: false,
// Ignored: <INTU.TIMEOUT>300
},
ofxgo.MessageSet{
MessageSet{
Name: "SECLISTMSGSETV1",
Ver: 1,
URL: "https://ofx.example.com/cgi-ofx/exampleofx",
OfxSec: ofxgo.OfxSecNone,
OfxSec: OfxSecNone,
TranspSec: true,
SignonRealm: "Example Trade",
Language: []ofxgo.String{"ENG"},
SyncMode: ofxgo.SyncModeLite,
Language: []String{"ENG"},
SyncMode: SyncModeLite,
RespFileER: false,
// Ignored: <INTU.TIMEOUT>300
},
ofxgo.MessageSet{
MessageSet{
Name: "PROFMSGSETV1",
Ver: 1,
URL: "https://ofx.example.com/cgi-ofx/exampleofx",
OfxSec: ofxgo.OfxSecNone,
OfxSec: OfxSecNone,
TranspSec: true,
SignonRealm: "Example Trade",
Language: []ofxgo.String{"ENG"},
SyncMode: ofxgo.SyncModeLite,
Language: []String{"ENG"},
SyncMode: SyncModeLite,
RespFileER: false,
// Ignored: <INTU.TIMEOUT>300
},
},
SignonInfoList: []ofxgo.SignonInfo{
SignonInfoList: []SignonInfo{
{
SignonRealm: "Example Trade",
Min: 1,
Max: 32,
CharType: ofxgo.CharTypeAlphaOrNumeric,
CharType: CharTypeAlphaOrNumeric,
CaseSen: false,
Special: true,
Spaces: false,
@ -303,7 +302,7 @@ NEWFILEUID:NONE
ChgPinFirst: false,
},
},
DtProfUp: *ofxgo.NewDateGMT(2002, 11, 19, 14, 0, 0, 0),
DtProfUp: *NewDateGMT(2002, 11, 19, 14, 0, 0, 0),
FiName: "Example Trade Financial",
Addr1: "5555 Buhunkus Drive",
City: "Someville",
@ -319,10 +318,11 @@ NEWFILEUID:NONE
}
expected.Prof = append(expected.Prof, &profileResponse)
response, err := ofxgo.ParseResponse(responseReader)
response, err := ParseResponse(responseReader)
if err != nil {
t.Fatalf("Unexpected error unmarshalling response: %s\n", err)
}
checkResponsesEqual(t, &expected, response)
checkResponseRoundTrip(t, response)
}

View File

@ -3,7 +3,6 @@ package ofxgo
import (
"bytes"
"errors"
"fmt"
"github.com/aclindsa/xml"
"time"
)
@ -32,10 +31,11 @@ type Request struct {
Prof []Message //<PROFMSGSETV1>
Image []Message //<IMAGEMSGSETV1>
indent bool // Whether to indent the marshaled XML
indent bool // Whether to indent the marshaled XML
carriageReturn bool // Whether to user carriage returns in new lines for marshaled XML
}
func marshalMessageSet(e *xml.Encoder, requests []Message, set messageType, version ofxVersion) error {
func encodeMessageSet(e *xml.Encoder, requests []Message, set messageType, version ofxVersion) error {
if len(requests) > 0 {
messageSetElement := xml.StartElement{Name: xml.Name{Local: set.String()}}
if err := e.EncodeToken(messageSetElement); err != nil {
@ -63,7 +63,7 @@ func marshalMessageSet(e *xml.Encoder, requests []Message, set messageType, vers
// SetClientFields overwrites the fields in this Request object controlled by
// the Client
func (oq *Request) SetClientFields(c *Client) {
func (oq *Request) SetClientFields(c Client) {
oq.Signon.DtClient.Time = time.Now()
// Overwrite fields that the client controls
@ -71,6 +71,7 @@ func (oq *Request) SetClientFields(c *Client) {
oq.Signon.AppID = c.ID()
oq.Signon.AppVer = c.Version()
oq.indent = c.IndentRequests()
oq.carriageReturn = c.CarriageReturnNewLines()
}
// Marshal this Request into its SGML/XML representation held in a bytes.Buffer
@ -80,30 +81,19 @@ func (oq *Request) Marshal() (*bytes.Buffer, error) {
var b bytes.Buffer
// Write the header appropriate to our version
switch oq.Version {
case OfxVersion102, OfxVersion103, OfxVersion151, OfxVersion160:
b.WriteString(`OFXHEADER:100
DATA:OFXSGML
VERSION:` + oq.Version.String() + `
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
`)
case OfxVersion200, OfxVersion201, OfxVersion202, OfxVersion203, OfxVersion210, OfxVersion211, OfxVersion220:
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="no"?>` + "\n")
b.WriteString(`<?OFX OFXHEADER="200" VERSION="` + oq.Version.String() + `" SECURITY="NONE" OLDFILEUID="NONE" NEWFILEUID="NONE"?>` + "\n")
default:
return nil, fmt.Errorf("%d is not a valid OFX version string", oq.Version)
}
writeHeader(&b, oq.Version, oq.carriageReturn)
encoder := xml.NewEncoder(&b)
if oq.indent {
encoder.Indent("", " ")
}
if oq.carriageReturn {
encoder.CarriageReturn(true)
}
if oq.Version < OfxVersion200 {
// OFX 100 series versions should avoid element close tags for compatibility
encoder.SetDisableAutoClose(ofxLeafElements...)
}
ofxElement := xml.StartElement{Name: xml.Name{Local: "OFX"}}
@ -145,7 +135,7 @@ NEWFILEUID:NONE
{oq.Image, ImageRq},
}
for _, set := range messageSets {
if err := marshalMessageSet(encoder, set.Messages, set.Type, oq.Version); err != nil {
if err := encodeMessageSet(encoder, set.Messages, set.Type, oq.Version); err != nil {
return nil, err
}
}

View File

@ -1,15 +1,16 @@
package ofxgo_test
package ofxgo
import (
"github.com/aclindsa/ofxgo"
"regexp"
"strings"
"testing"
)
var ignoreSpacesRe = regexp.MustCompile(">[ \t\r\n]+<")
// match leading and trailing whitespace on each line
var ignoreSpacesRe = regexp.MustCompile("(?m)^[ \t]+|[ \t]*$[\r\n]+")
func marshalCheckRequest(t *testing.T, request *ofxgo.Request, expected string) {
func marshalCheckRequest(t *testing.T, request *Request, expected string) {
t.Helper()
buf, err := request.Marshal()
if err != nil {
t.Fatalf("%s: Unexpected error marshalling request: %s\n", t.Name(), err)
@ -17,8 +18,8 @@ func marshalCheckRequest(t *testing.T, request *ofxgo.Request, expected string)
actualString := buf.String()
// Ignore spaces between XML elements
expectedString := ignoreSpacesRe.ReplaceAllString(expected, "><")
actualString = ignoreSpacesRe.ReplaceAllString(actualString, "><")
expectedString := ignoreSpacesRe.ReplaceAllString(expected, "")
actualString = ignoreSpacesRe.ReplaceAllString(actualString, "")
if expectedString != actualString {
compareLength := len(expectedString)

View File

@ -4,10 +4,13 @@ import (
"bufio"
"bytes"
"errors"
"github.com/aclindsa/xml"
"fmt"
"io"
"reflect"
"regexp"
"strings"
"github.com/aclindsa/xml"
)
// Response is the top-level object returned from a parsed OFX response file.
@ -33,68 +36,75 @@ type Response struct {
}
func (or *Response) 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)
b, err := r.ReadSlice('<')
if err != nil {
return err
}
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")
s := string(b)
err = r.UnreadByte()
if err != nil {
return err
}
// According to the latest OFX SGML spec (1.6), headers should be CRLF-separated
// and written as KEY:VALUE. However, some banks include a whitespace after the
// colon (KEY: VALUE), while others include no line breaks at all. The spec doesn't
// require a line break after the OFX headers, but it is allowed, and will be
// optionally captured & discarded by the trailing `\s*`. Valid SGML headers must
// always be present in exactly this order, so a regular expression is acceptable.
headerExp := regexp.MustCompile(
`^OFXHEADER:\s*(?P<OFXHEADER>\d+)\s*` +
`DATA:\s*(?P<DATA>[A-Z]+)\s*` +
`VERSION:\s*(?P<VERSION>\d+)\s*` +
`SECURITY:\s*(?P<SECURITY>[\w]+)\s*` +
`ENCODING:\s*(?P<ENCODING>[A-Z0-9-]+)\s*` +
`CHARSET:\s*(?P<CHARSET>[\w-]+)\s*` +
`COMPRESSION:\s*(?P<COMPRESSION>[A-Z]+)\s*` +
`OLDFILEUID:\s*(?P<OLDFILEUID>[\w-]+)\s*` +
`NEWFILEUID:\s*(?P<NEWFILEUID>[\w-]+)\s*<$`)
matches := headerExp.FindStringSubmatch(s)
if len(matches) == 0 {
return errors.New("OFX headers malformed")
}
for i, name := range headerExp.SubexpNames() {
if i == 0 {
continue
}
// Some OFX servers put a space after the colon
headervalue := strings.TrimSpace(header[1])
switch header[0] {
headerValue := matches[i]
switch name {
case "OFXHEADER":
if headervalue != "100" {
if headerValue != "100" {
return errors.New("OFXHEADER is not 100")
}
seenHeader = true
case "DATA":
if headervalue != "OFXSGML" {
if headerValue != "OFXSGML" {
return errors.New("OFX DATA header does not contain OFXSGML")
}
case "VERSION":
err := or.Version.FromString(headervalue)
err := or.Version.FromString(headerValue)
if err != nil {
return err
}
seenVersion = true
if or.Version > OfxVersion160 {
return errors.New("OFX VERSION > 160 in SGML header")
}
case "SECURITY":
if headervalue != "NONE" {
return errors.New("OFX SECURITY header not NONE")
if !(headerValue == "NONE" || headerValue == "TYPE1") {
return errors.New("OFX SECURITY header must be NONE or TYPE1")
}
case "COMPRESSION":
if headervalue != "NONE" {
if headerValue != "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])
// TODO: check/handle these headers?
}
}
if !seenVersion {
return errors.New("OFX VERSION header missing")
}
return nil
}
@ -175,6 +185,7 @@ const guessVersionCheckBytes = 1024
// Defaults to XML if it can't determine the version or if there is any
// ambiguity
// Returns false for SGML, true (for XML) otherwise.
func guessVersion(r *bufio.Reader) (bool, error) {
b, _ := r.Peek(guessVersionCheckBytes)
if b == nil {
@ -245,9 +256,6 @@ func decodeMessageSet(d *xml.Decoder, start xml.StartElement, msgs *[]Message, v
if err := d.DecodeElement(responseMessage, &startElement); err != nil {
return err
}
if ok, err := responseMessage.Valid(version); !ok {
return err
}
*msgs = append(*msgs, responseMessage)
} else {
return errors.New("Didn't find an opening element")
@ -255,14 +263,25 @@ func decodeMessageSet(d *xml.Decoder, start xml.StartElement, msgs *[]Message, v
}
}
// ParseResponse parses an OFX response in SGML or XML into a Response object
// from the given io.Reader
// ParseResponse parses and validates an OFX response in SGML or XML into a
// Response object from the given io.Reader
//
// It is commonly used as part of Client.Request(), but may be used on its own
// to parse already-downloaded OFX files (such as those from 'Web Connect'). It
// performs version autodetection if it can and attempts to be as forgiving as
// possible about the input format.
func ParseResponse(reader io.Reader) (*Response, error) {
resp, err := DecodeResponse(reader)
if err != nil {
return nil, err
}
_, err = resp.Valid()
return resp, err
}
// DecodeResponse parses an OFX response in SGML or XML into a Response object
// from the given io.Reader
func DecodeResponse(reader io.Reader) (*Response, error) {
var or Response
r := bufio.NewReaderSize(reader, guessVersionCheckBytes)
@ -319,9 +338,6 @@ func ParseResponse(reader io.Reader) (*Response, error) {
} else if signonEnd, ok := tok.(xml.EndElement); !ok || signonEnd.Name.Local != SignonRs.String() {
return nil, errors.New("Missing closing SIGNONMSGSRSV1 xml element")
}
if ok, err := or.Signon.Valid(or.Version); !ok {
return nil, err
}
var messageSlices = map[string]*[]Message{
SignupRs.String(): &or.Signup,
@ -359,3 +375,131 @@ func ParseResponse(reader io.Reader) (*Response, error) {
}
}
}
// Valid returns whether the Response is valid according to the OFX spec
func (or *Response) Valid() (bool, error) {
var errs errInvalid
if ok, err := or.Signon.Valid(or.Version); !ok {
errs.AddErr(err)
}
for _, messageSet := range [][]Message{
or.Signup,
or.Bank,
or.CreditCard,
or.Loan,
or.InvStmt,
or.InterXfer,
or.WireXfer,
or.Billpay,
or.Email,
or.SecList,
or.PresDir,
or.PresDlv,
or.Prof,
or.Image,
} {
for _, message := range messageSet {
if ok, err := message.Valid(or.Version); !ok {
errs.AddErr(err)
}
}
}
err := errs.ErrOrNil()
return err == nil, err
}
// Marshal this Response into its SGML/XML representation held in a bytes.Buffer
//
// If error is non-nil, this bytes.Buffer is ready to be sent to an OFX client
func (or *Response) Marshal() (*bytes.Buffer, error) {
var b bytes.Buffer
// Write the header appropriate to our version
writeHeader(&b, or.Version, false)
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 := or.Signon.Valid(or.Version); !ok {
return nil, err
}
signonMsgSet := xml.StartElement{Name: xml.Name{Local: SignonRs.String()}}
if err := encoder.EncodeToken(signonMsgSet); err != nil {
return nil, err
}
if err := encoder.Encode(&or.Signon); err != nil {
return nil, err
}
if err := encoder.EncodeToken(signonMsgSet.End()); err != nil {
return nil, err
}
messageSets := []struct {
Messages []Message
Type messageType
}{
{or.Signup, SignupRs},
{or.Bank, BankRs},
{or.CreditCard, CreditCardRs},
{or.Loan, LoanRs},
{or.InvStmt, InvStmtRs},
{or.InterXfer, InterXferRs},
{or.WireXfer, WireXferRs},
{or.Billpay, BillpayRs},
{or.Email, EmailRs},
{or.SecList, SecListRs},
{or.PresDir, PresDirRs},
{or.PresDlv, PresDlvRs},
{or.Prof, ProfRs},
{or.Image, ImageRs},
}
for _, set := range messageSets {
if err := encodeMessageSet(encoder, set.Messages, set.Type, or.Version); 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
}
// errInvalid represents validation failures while parsing an OFX response
// If an institution returns slightly malformed data, ParseResponse will return a best-effort parsed response and a validation error.
type errInvalid []error
func (e errInvalid) Error() string {
var errStrings []string
for _, err := range e {
errStrings = append(errStrings, err.Error())
}
return fmt.Sprintf("Validation failed: %s", strings.Join(errStrings, "; "))
}
func (e *errInvalid) AddErr(err error) {
if err != nil {
if errs, ok := err.(errInvalid); ok {
*e = append(*e, errs...)
} else {
*e = append(*e, err)
}
}
}
func (e errInvalid) ErrOrNil() error {
if len(e) > 0 {
return e
}
return nil
}

View File

@ -1,13 +1,15 @@
package ofxgo_test
package ofxgo
import (
"bytes"
"errors"
"fmt"
"github.com/aclindsa/xml"
"github.com/aclindsa/ofxgo"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/aclindsa/xml"
)
// Attempt to find a method on the provided Value called 'Equal' which is a
@ -132,27 +134,190 @@ func checkEqual(t *testing.T, fieldName string, expected, actual reflect.Value)
}
}
func checkResponsesEqual(t *testing.T, expected, actual *ofxgo.Response) {
func checkResponsesEqual(t *testing.T, expected, actual *Response) {
checkEqual(t, "", reflect.ValueOf(expected), reflect.ValueOf(actual))
}
func checkResponseRoundTrip(t *testing.T, response *Response) {
b, err := response.Marshal()
if err != nil {
t.Fatalf("Unexpected error re-marshaling OFX response: %s\n", err)
}
roundtripped, err := ParseResponse(b)
if err != nil {
t.Fatalf("Unexpected error re-parsing OFX response: %s\n", err)
}
checkResponsesEqual(t, response, roundtripped)
}
// Ensure that these samples both parse without errors, and can be converted
// back and forth without changing.
func TestValidSamples(t *testing.T) {
fn := func(path string, info os.FileInfo, err error) error {
if info.IsDir() {
return nil
} else if filepath.Ext(path) != ".ofx" {
} else if ext := filepath.Ext(path); ext != ".ofx" && ext != ".qfx" {
return nil
}
file, err := os.Open(path)
if err != nil {
t.Fatalf("Unexpected error opening %s: %s\n", path, err)
}
_, err = ofxgo.ParseResponse(file)
response, err := ParseResponse(file)
if err != nil {
t.Fatalf("Unexpected error parsing OFX response in %s: %s\n", path, err)
}
checkResponseRoundTrip(t, response)
return nil
}
filepath.Walk("samples/valid_responses", fn)
filepath.Walk("samples/busted_responses", fn)
}
func TestInvalidResponse(t *testing.T) {
// in this example, the severity is invalid due to mixed upper and lower case letters
const invalidResponse = `OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX>
<SIGNONMSGSRSV1>
<SONRS>
<STATUS>
<CODE>0</CODE>
<SEVERITY>Info</SEVERITY>
</STATUS>
<LANGUAGE>ENG</LANGUAGE>
</SONRS>
</SIGNONMSGSRSV1>
<BANKMSGSRSV1>
<STMTTRNRS>
<TRNUID>0</TRNUID>
<STATUS>
<CODE>0</CODE>
<SEVERITY>Info</SEVERITY>
</STATUS>
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>
`
const expectedErr = "Validation failed: Invalid STATUS>SEVERITY; Invalid STATUS>SEVERITY"
t.Run("parse response", func(t *testing.T) {
resp, err := ParseResponse(bytes.NewReader([]byte(invalidResponse)))
expectedErr := "Validation failed: Invalid STATUS>SEVERITY; Invalid STATUS>SEVERITY"
if err == nil {
t.Fatalf("ParseResponse should fail with %q, found nil", expectedErr)
}
if _, ok := err.(errInvalid); !ok {
t.Errorf("ParseResponse should return an error with type ErrInvalid, found %T", err)
}
if err.Error() != expectedErr {
t.Errorf("ParseResponse should fail with %q, found %v", expectedErr, err)
}
if resp == nil {
t.Errorf("Response must not be nil if only validation errors are present")
}
})
t.Run("parse failed", func(t *testing.T) {
resp, err := ParseResponse(bytes.NewReader(nil))
if err == nil {
t.Error("ParseResponse should fail to decode")
}
if resp != nil {
t.Errorf("ParseResponse should return a nil response, found: %v", resp)
}
})
t.Run("decode, then validate response", func(t *testing.T) {
resp, err := DecodeResponse(bytes.NewReader([]byte(invalidResponse)))
if err != nil {
t.Errorf("Unexpected error: %s", err.Error())
}
if resp == nil {
t.Fatal("Response should not be nil from successful decode")
}
valid, err := resp.Valid()
if valid {
t.Error("Response should not be valid")
}
if err == nil {
t.Fatalf("response.Valid() should fail with %q, found nil", expectedErr)
}
if _, ok := err.(errInvalid); !ok {
t.Errorf("response.Valid() should return an error of type ErrInvalid, found: %T", err)
}
if err.Error() != expectedErr {
t.Errorf("response.Valid() should return an error with message %q, but found %q", expectedErr, err.Error())
}
})
}
func TestErrInvalidError(t *testing.T) {
expectedErr := `Validation failed: A; B; C`
actualErr := errInvalid{
errors.New("A"),
errors.New("B"),
errors.New("C"),
}.Error()
if expectedErr != actualErr {
t.Errorf("Unexpected invalid error message to be %q, but was: %s", expectedErr, actualErr)
}
}
func TestErrInvalidAddErr(t *testing.T) {
t.Run("nil error should be a no-op", func(t *testing.T) {
var errs errInvalid
errs.AddErr(nil)
if len(errs) != 0 {
t.Errorf("Nil err should not be added")
}
})
t.Run("adds an error normally", func(t *testing.T) {
var errs errInvalid
errs.AddErr(errors.New("some error"))
})
t.Run("adding the same type should flatten the errors", func(t *testing.T) {
var errs errInvalid
errs.AddErr(errInvalid{
errors.New("A"),
errors.New("B"),
})
errs.AddErr(errInvalid{
errors.New("C"),
})
if len(errs) != 3 {
t.Errorf("Errors should be flattened like [A, B, C], but found: %+v", errs)
}
})
}
func TestErrInvalidErrOrNil(t *testing.T) {
var errs errInvalid
if err := errs.ErrOrNil(); err != nil {
t.Errorf("No added errors should return nil, found: %v", err)
}
someError := errors.New("some error")
errs.AddErr(someError)
err := errs.ErrOrNil()
if err == nil {
t.Fatal("Expected an error, found nil.")
}
if _, ok := err.(errInvalid); !ok {
t.Fatalf("Expected err to be of type errInvalid, found: %T", err)
}
errInv := err.(errInvalid)
if len(errInv) != 1 || errInv[0] != someError {
t.Errorf("Expected ErrOrNil to return itself, found: %v", err)
}
}

View File

@ -0,0 +1,59 @@
OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX>
<SIGNONMSGSRSV1>
<SONRS>
<STATUS>
<CODE>0
<SEVERITY>INFO
<MESSAGE>OK
</STATUS>
<DTSERVER>20181202184906.217[-5:EDT]
<USERKEY>SJLDF802DV09DF80
<LANGUAGE>ENG
<INTU.BID>00017
</SONRS>
</SIGNONMSGSRSV1>
<CREDITCARDMSGSRSV1>
<CCSTMTTRNRS>
<TRNUID>1
<STATUS>
<CODE>0
<SEVERITY>INFO
<MESSAGE>OK
</STATUS>
<CCSTMTRS>
<CURDEF>CAD
<CCACCTFROM>
<ACCTID>2380370270281083
</CCACCTFROM>
<BANKTRANLIST>
<DTSTART>20181202184905.909[-5:EDT]
<DTEND>20181202184905.909[-5:EDT]
<STMTTRN>
<TRNTYPE>CREDIT
<DTPOSTED>20181030000000.000[-5:EDT]
<TRNAMT>2042.24
<FITID>2380370270281083201810302054456
<NAME>PAYMENT RECEIVED - THANK YOU
</STMTTRN>
</BANKTRANLIST>
<LEDGERBAL>
<BALAMT>-552.63
<DTASOF>20181202184906.217[-5:EDT]
</LEDGERBAL>
<AVAILBAL>
<BALAMT>-552.63
<DTASOF>20181202184906.217[-5:EDT]
</AVAILBAL>
</CCSTMTRS>
</CCSTMTTRNRS>
</CREDITCARDMSGSRSV1>
</OFX>

View File

@ -0,0 +1 @@
OFXHEADER:100DATA:OFXSGMLVERSION:102SECURITY:NONEENCODING:USASCIICHARSET:1252COMPRESSION:NONEOLDFILEUID:NONENEWFILEUID:NONE<OFX><SIGNONMSGSRSV1><SONRS><STATUS><CODE>0<SEVERITY>INFO<MESSAGE>SUCCESS</STATUS><DTSERVER>20210102211014.201[-8:PST]<LANGUAGE>ENG<FI><ORG>WF<FID>1000</FI><SESSCOOKIE>abc-123<INTU.BID>1000<INTU.USERID>jane_doe</SONRS></SIGNONMSGSRSV1><BANKMSGSRSV1><STMTTRNRS><TRNUID>0<STATUS><CODE>0<SEVERITY>INFO<MESSAGE>SUCCESS</STATUS><STMTRS><CURDEF>USD<BANKACCTFROM><BANKID>123456789<ACCTID>9876543210<ACCTTYPE>CHECKING</BANKACCTFROM><BANKTRANLIST><DTSTART>20201201120000.000[-8:PST]<DTEND>20201231120000.000[-8:PST]<STMTTRN><TRNTYPE>DIRECTDEBIT<DTPOSTED>20201201120000.000[-8:PST]<TRNAMT>-12.34<FITID>202012011<NAME>AE Visa Card AE EPAY<MEMO> XXXXX1234</STMTTRN></BANKTRANLIST><LEDGERBAL><BALAMT>123.45<DTASOF>20201231120000.000[-8:PST]</LEDGERBAL><AVAILBAL><BALAMT>123.45<DTASOF>20201231120000.000[-8:PST]</AVAILBAL></STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>

View File

@ -0,0 +1,75 @@
OFXHEADER:100
DATA:OFXSGML
VERSION:103
SECURITY:TYPE1
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX>
<SIGNONMSGSRSV1><SONRS>
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<DTSERVER>20170407001840.607[0:GMT]
<LANGUAGE>ENG
<FI>
<ORG>UJKDO
<FID>3534
</FI>
</SONRS>
</SIGNONMSGSRSV1>
<BANKMSGSRSV1>
<STMTTRNRS>
<TRNUID>e1707dfd-695d-4451-8d9c-0e142fdc456a
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<STMTRS>
<CURDEF>USD
<BANKACCTFROM>
<BANKID>598813374
<ACCTID>35342483513
<ACCTTYPE>MONEYMRKT
</BANKACCTFROM>
<BANKTRANLIST>
<DTSTART>20170107011841.262[0:GMT]
<DTEND>20170407001841.262[0:GMT]
<STMTTRN>
<TRNTYPE>CREDIT
<DTPOSTED>20170117120000.000[0:GMT]
<TRNAMT>-995.4190396554627
<FITID>2fb2640c-cee3-4643-8ba3-ea21a4d18954
<NAME>Dividend Earned
</STMTTRN>
<STMTTRN>
<TRNTYPE>CREDIT
<DTPOSTED>20170215120000.000[0:GMT]
<TRNAMT>788.5385340523635
<FITID>c9d856df-339c-47c6-9f6a-8c2e2910f62e
<NAME>Dividend Earned
</STMTTRN>
<STMTTRN>
<TRNTYPE>CREDIT
<DTPOSTED>20170315120000.000[0:GMT]
<TRNAMT>3070.1328011762807
<FITID>1107ace0-048b-4c0c-b5f3-45b6be4cd71d
<NAME>Dividend Earned
</STMTTRN>
</BANKTRANLIST>
<LEDGERBAL>
<BALAMT>2607.1664944585727
<DTASOF>20170407001841.262[0:GMT]
</LEDGERBAL>
<AVAILBAL>
<BALAMT>4503.683156768119
<DTASOF>20170407001841.262[0:GMT]
</AVAILBAL>
</STMTRS>
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>

View File

@ -94,6 +94,7 @@ func (r *SecListResponse) Type() messageType {
// securities for SecurityList
type Security interface {
SecurityType() string
SecurityInfo() SecInfo
}
// SecInfo represents the generic information about a security. It is included
@ -136,6 +137,11 @@ func (i DebtInfo) SecurityType() string {
return "DEBTINFO"
}
// SecurityInfo returns SecInfo
func (i DebtInfo) SecurityInfo() SecInfo {
return i.SecInfo
}
// AssetPortion represents the percentage of a mutual fund with the given asset
// classification
type AssetPortion struct {
@ -169,6 +175,11 @@ func (i MFInfo) SecurityType() string {
return "MFINFO"
}
// SecurityInfo returns SecInfo
func (i MFInfo) SecurityInfo() SecInfo {
return i.SecInfo
}
// OptInfo provides information about an option
type OptInfo struct {
XMLName xml.Name `xml:"OPTINFO"`
@ -187,6 +198,11 @@ func (i OptInfo) SecurityType() string {
return "OPTINFO"
}
// SecurityInfo returns SecInfo
func (i OptInfo) SecurityInfo() SecInfo {
return i.SecInfo
}
// OtherInfo provides information about a security type not covered by the
// other *Info elements
type OtherInfo struct {
@ -202,6 +218,11 @@ func (i OtherInfo) SecurityType() string {
return "OTHERINFO"
}
// SecurityInfo returns SecInfo
func (i OtherInfo) SecurityInfo() SecInfo {
return i.SecInfo
}
// StockInfo provides information about a security type
type StockInfo struct {
XMLName xml.Name `xml:"STOCKINFO"`
@ -218,9 +239,15 @@ func (i StockInfo) SecurityType() string {
return "STOCKINFO"
}
// SecurityInfo returns SecInfo
func (i StockInfo) SecurityInfo() SecInfo {
return i.SecInfo
}
// SecurityList is a container for Security objects containaing information
// about securities
type SecurityList struct {
XMLName xml.Name `xml:"SECLIST"`
Securities []Security
}
@ -290,3 +317,42 @@ func (r *SecurityList) UnmarshalXML(d *xml.Decoder, start xml.StartElement) erro
}
}
}
// MarshalXML handles marshalling a SecurityList to an SGML/XML string
func (r *SecurityList) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
secListElement := xml.StartElement{Name: xml.Name{Local: "SECLIST"}}
if err := e.EncodeToken(secListElement); err != nil {
return err
}
for _, s := range r.Securities {
start := xml.StartElement{Name: xml.Name{Local: s.SecurityType()}}
switch sec := s.(type) {
case DebtInfo:
if err := e.EncodeElement(&sec, start); err != nil {
return err
}
case MFInfo:
if err := e.EncodeElement(&sec, start); err != nil {
return err
}
case OptInfo:
if err := e.EncodeElement(&sec, start); err != nil {
return err
}
case OtherInfo:
if err := e.EncodeElement(&sec, start); err != nil {
return err
}
case StockInfo:
if err := e.EncodeElement(&sec, start); err != nil {
return err
}
default:
return errors.New("Invalid SECLIST child type: " + sec.SecurityType())
}
}
if err := e.EncodeToken(secListElement.End()); err != nil {
return err
}
return nil
}

View File

@ -9,17 +9,18 @@ import (
// SignonRequest identifies and authenticates a user to their FI and is
// provided with every Request
type SignonRequest struct {
XMLName xml.Name `xml:"SONRQ"`
DtClient Date `xml:"DTCLIENT"` // Current time on client, overwritten in Client.Request()
UserID String `xml:"USERID"`
UserPass String `xml:"USERPASS,omitempty"`
UserKey String `xml:"USERKEY,omitempty"`
Language String `xml:"LANGUAGE"` // Defaults to ENG
Org String `xml:"FI>ORG"`
Fid String `xml:"FI>FID"`
AppID String `xml:"APPID"` // Overwritten in Client.Request()
AppVer String `xml:"APPVER"` // Overwritten in Client.Request()
ClientUID UID `xml:"CLIENTUID,omitempty"`
XMLName xml.Name `xml:"SONRQ"`
DtClient Date `xml:"DTCLIENT"` // Current time on client, overwritten in Client.Request()
UserID String `xml:"USERID"`
UserPass String `xml:"USERPASS,omitempty"`
UserKey String `xml:"USERKEY,omitempty"`
GenUserKey Boolean `xml:"GENUSERKEY,omitempty"`
Language String `xml:"LANGUAGE"` // Defaults to ENG
Org String `xml:"FI>ORG"`
Fid String `xml:"FI>FID"`
AppID String `xml:"APPID"` // Overwritten in Client.Request()
AppVer String `xml:"APPVER"` // Overwritten in Client.Request()
ClientUID UID `xml:"CLIENTUID,omitempty"`
}
// Name returns the name of the top-level transaction XML/SGML element

View File

@ -1,18 +1,17 @@
package ofxgo_test
package ofxgo
import (
"github.com/aclindsa/ofxgo"
"testing"
)
func TestMarshalInvalidSignons(t *testing.T) {
var client = ofxgo.Client{
var client = BasicClient{
AppID: "OFXGO",
AppVer: "0001",
SpecVersion: ofxgo.OfxVersion203,
SpecVersion: OfxVersion203,
}
var request ofxgo.Request
var request Request
request.Signon.UserID = "myusername"
request.Signon.UserPass = "Pa$$word"
request.Signon.Org = "BNK"

View File

@ -1,7 +1,6 @@
package ofxgo_test
package ofxgo
import (
"github.com/aclindsa/ofxgo"
"strings"
"testing"
"time"
@ -37,27 +36,27 @@ func TestMarshalAcctInfoRequest(t *testing.T) {
EST := time.FixedZone("EST", -5*60*60)
var client = ofxgo.Client{
var client = BasicClient{
AppID: "OFXGO",
AppVer: "0001",
SpecVersion: ofxgo.OfxVersion203,
SpecVersion: OfxVersion203,
}
var request ofxgo.Request
var request Request
request.Signon.UserID = "myusername"
request.Signon.UserPass = "Pa$$word"
request.Signon.Org = "BNK"
request.Signon.Fid = "1987"
acctInfoRequest := ofxgo.AcctInfoRequest{
acctInfoRequest := AcctInfoRequest{
TrnUID: "e3ad9bda-38fa-4e5b-8099-1bd567ddef7a",
DtAcctUp: *ofxgo.NewDate(2015, 12, 21, 18, 29, 45, 0, EST),
DtAcctUp: *NewDate(2015, 12, 21, 18, 29, 45, 0, EST),
}
request.Signup = append(request.Signup, &acctInfoRequest)
request.SetClientFields(&client)
// Overwrite the DtClient value set by SetClientFields to time.Now()
request.Signon.DtClient = *ofxgo.NewDate(2016, 1, 15, 11, 23, 0, 0, EST)
request.Signon.DtClient = *NewDate(2016, 1, 15, 11, 23, 0, 0, EST)
marshalCheckRequest(t, &request, expectedString)
}
@ -110,38 +109,38 @@ func TestUnmarshalAcctInfoResponse(t *testing.T) {
</ACCTINFOTRNRS>
</SIGNUPMSGSRSV1>
</OFX>`)
var expected ofxgo.Response
var expected Response
expected.Version = ofxgo.OfxVersion203
expected.Version = OfxVersion203
expected.Signon.Status.Code = 0
expected.Signon.Status.Severity = "INFO"
expected.Signon.DtServer = *ofxgo.NewDateGMT(2006, 1, 15, 11, 23, 03, 0)
expected.Signon.DtServer = *NewDateGMT(2006, 1, 15, 11, 23, 03, 0)
expected.Signon.Language = "ENG"
expected.Signon.DtProfUp = ofxgo.NewDateGMT(2005, 2, 21, 9, 13, 0, 0)
expected.Signon.DtAcctUp = ofxgo.NewDateGMT(2006, 1, 2, 16, 0, 0, 0)
expected.Signon.DtProfUp = NewDateGMT(2005, 2, 21, 9, 13, 0, 0)
expected.Signon.DtAcctUp = NewDateGMT(2006, 1, 2, 16, 0, 0, 0)
expected.Signon.Org = "BNK"
expected.Signon.Fid = "1987"
bankacctinfo := ofxgo.BankAcctInfo{
BankAcctFrom: ofxgo.BankAcct{
bankacctinfo := BankAcctInfo{
BankAcctFrom: BankAcct{
BankID: "8367556009",
AcctID: "000999847",
AcctType: ofxgo.AcctTypeMoneyMrkt,
AcctType: AcctTypeMoneyMrkt,
},
SupTxDl: true,
XferSrc: true,
XferDest: true,
SvcStatus: ofxgo.SvcStatusActive,
SvcStatus: SvcStatusActive,
}
acctInfoResponse := ofxgo.AcctInfoResponse{
acctInfoResponse := AcctInfoResponse{
TrnUID: "10938754",
Status: ofxgo.Status{
Status: Status{
Code: 0,
Severity: "INFO",
},
DtAcctUp: *ofxgo.NewDateGMT(2005, 2, 28, 0, 0, 0, 0),
AcctInfo: []ofxgo.AcctInfo{{
DtAcctUp: *NewDateGMT(2005, 2, 28, 0, 0, 0, 0),
AcctInfo: []AcctInfo{{
Desc: "Personal Checking",
Phone: "888-222-5827",
BankAcctInfo: &bankacctinfo,
@ -149,10 +148,11 @@ func TestUnmarshalAcctInfoResponse(t *testing.T) {
}
expected.Signup = append(expected.Signup, &acctInfoResponse)
response, err := ofxgo.ParseResponse(responseReader)
response, err := ParseResponse(responseReader)
if err != nil {
t.Fatalf("Unexpected error unmarshalling response: %s\n", err)
}
checkResponsesEqual(t, &expected, response)
checkResponseRoundTrip(t, response)
}

View File

@ -76,7 +76,7 @@ func (a Amount) String() string {
}
// MarshalXML marshals an Amount to SGML/XML
func (a *Amount) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
func (a Amount) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return e.EncodeElement(a.String(), start)
}
@ -188,7 +188,7 @@ func (od Date) String() string {
}
// MarshalXML marshals a Date to XML
func (od *Date) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
func (od Date) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return e.EncodeElement(od.String(), start)
}
@ -260,8 +260,8 @@ func (ob *Boolean) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
}
// MarshalXML marshals a Boolean to XML
func (ob *Boolean) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if *ob {
func (ob Boolean) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if ob {
return e.EncodeElement("Y", start)
}
return e.EncodeElement("N", start)
@ -358,7 +358,7 @@ func (c *CurrSymbol) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
}
// MarshalXML marshals a CurrSymbol to SGML/XML
func (c *CurrSymbol) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
func (c CurrSymbol) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return e.EncodeElement(c.String(), start)
}

View File

@ -1,9 +1,8 @@
package ofxgo_test
package ofxgo
import (
"fmt"
"github.com/aclindsa/xml"
"github.com/aclindsa/ofxgo"
"reflect"
"testing"
"time"
@ -57,7 +56,7 @@ func unmarshalHelper(t *testing.T, input string, expected interface{}, overwritt
}
func TestMarshalInt(t *testing.T) {
var i ofxgo.Int = 927
var i Int = 927
marshalHelper(t, "927", &i)
i = 0
marshalHelper(t, "0", &i)
@ -66,7 +65,7 @@ func TestMarshalInt(t *testing.T) {
}
func TestUnmarshalInt(t *testing.T) {
var i, overwritten ofxgo.Int = -48394, 0
var i, overwritten Int = -48394, 0
unmarshalHelper(t, "-48394", &i, &overwritten)
i = 0
unmarshalHelper(t, "0", &i, &overwritten)
@ -78,7 +77,7 @@ func TestUnmarshalInt(t *testing.T) {
}
func TestMarshalAmount(t *testing.T) {
var a ofxgo.Amount
var a Amount
a.SetFrac64(8, 1)
marshalHelper(t, "8", &a)
@ -92,16 +91,23 @@ func TestMarshalAmount(t *testing.T) {
marshalHelper(t, "-768276587425", &a)
a.SetFrac64(1, 12)
marshalHelper(t, "0.0833333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333", &a)
type AmountStruct struct {
A Amount
}
var as AmountStruct
as.A.SetFrac64(1, 8)
marshalHelper(t, "<A>0.125</A>", as)
}
func TestUnmarshalAmount(t *testing.T) {
var a, overwritten ofxgo.Amount
var a, overwritten Amount
// Amount/big.Rat needs a special equality test because reflect.DeepEqual
// doesn't always return equal for two values that big.Rat.Cmp() does
eq := func(a, b interface{}) bool {
if amountA, ok := a.(*ofxgo.Amount); ok {
if amountB, ok2 := b.(*ofxgo.Amount); ok2 {
if amountA, ok := a.(*Amount); ok {
if amountB, ok2 := b.(*Amount); ok2 {
return amountA.Cmp(&amountB.Rat) == 0
}
}
@ -127,18 +133,18 @@ func TestUnmarshalAmount(t *testing.T) {
}
func TestAmountEqual(t *testing.T) {
assertEq := func(a, b ofxgo.Amount) {
assertEq := func(a, b Amount) {
if !a.Equal(b) {
t.Fatalf("Amounts should be equal but Equal returned false: %s and %s\n", a, b)
}
}
assertNEq := func(a, b ofxgo.Amount) {
assertNEq := func(a, b Amount) {
if a.Equal(b) {
t.Fatalf("Amounts should not be equal but Equal returned true: %s and %s\n", a, b)
}
}
var a, b ofxgo.Amount
var a, b Amount
a.SetInt64(-19487135)
b.SetInt64(-19487135)
assertEq(a, b)
@ -154,53 +160,60 @@ func TestAmountEqual(t *testing.T) {
}
func TestMarshalDate(t *testing.T) {
var d *ofxgo.Date
var d *Date
UTC := time.FixedZone("UTC", 0)
GMT_nodesc := time.FixedZone("", 0)
GMTNodesc := time.FixedZone("", 0)
EST := time.FixedZone("EST", -5*60*60)
NPT := time.FixedZone("NPT", (5*60+45)*60)
IST := time.FixedZone("IST", (5*60+30)*60)
NST := time.FixedZone("NST", -(3*60+30)*60)
d = ofxgo.NewDateGMT(2017, 3, 14, 15, 9, 26, 53*1000*1000)
d = NewDateGMT(2017, 3, 14, 15, 9, 26, 53*1000*1000)
marshalHelper(t, "20170314150926.053[0:GMT]", d)
d = ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, NPT)
d = NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, NPT)
marshalHelper(t, "20170314150926.053[5.75:NPT]", d)
d = ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, EST)
d = NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, EST)
marshalHelper(t, "20170314150926.053[-5:EST]", d)
d = ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, UTC)
d = NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, UTC)
marshalHelper(t, "20170314150926.053[0:UTC]", d)
d = ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, IST)
d = NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, IST)
marshalHelper(t, "20170314150926.053[5.50:IST]", d)
d = ofxgo.NewDate(9999, 11, 1, 23, 59, 59, 1000, EST)
d = NewDate(9999, 11, 1, 23, 59, 59, 1000, EST)
marshalHelper(t, "99991101235959.000[-5:EST]", d)
d = ofxgo.NewDate(0, 1, 1, 0, 0, 0, 0, IST)
d = NewDate(0, 1, 1, 0, 0, 0, 0, IST)
marshalHelper(t, "00000101000000.000[5.50:IST]", d)
d = &ofxgo.Date{Time: time.Unix(0, 0).In(UTC)}
d = &Date{Time: time.Unix(0, 0).In(UTC)}
marshalHelper(t, "19700101000000.000[0:UTC]", d)
d = ofxgo.NewDate(2017, 3, 14, 0, 0, 26, 53*1000*1000, EST)
d = NewDate(2017, 3, 14, 0, 0, 26, 53*1000*1000, EST)
marshalHelper(t, "20170314000026.053[-5:EST]", d)
d = ofxgo.NewDate(2017, 3, 14, 0, 0, 26, 53*1000*1000, NST)
d = NewDate(2017, 3, 14, 0, 0, 26, 53*1000*1000, NST)
marshalHelper(t, "20170314000026.053[-3.50:NST]", d)
// Time zone without textual description
d = ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, GMT_nodesc)
d = NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, GMTNodesc)
marshalHelper(t, "20170314150926.053[0]", d)
type DateStruct struct {
D Date
}
d = NewDateGMT(2017, 3, 14, 15, 9, 26, 53*1000*1000)
ds := DateStruct{D: *d}
marshalHelper(t, "<D>20170314150926.053[0:GMT]</D>", ds)
}
func TestUnmarshalDate(t *testing.T) {
var d *ofxgo.Date
var overwritten ofxgo.Date
var d *Date
var overwritten Date
GMT := time.FixedZone("GMT", 0)
EST := time.FixedZone("EST", -5*60*60)
NPT := time.FixedZone("NPT", (5*60+45)*60)
IST := time.FixedZone("IST", (5*60+30)*60)
NST := time.FixedZone("NST", -(3*60+30)*60)
NST_nodesc := time.FixedZone("", -(3*60+30)*60)
NSTNodesc := time.FixedZone("", -(3*60+30)*60)
eq := func(a, b interface{}) bool {
if dateA, ok := a.(*ofxgo.Date); ok {
if dateB, ok2 := b.(*ofxgo.Date); ok2 {
if dateA, ok := a.(*Date); ok {
if dateB, ok2 := b.(*Date); ok2 {
return dateA.Equal(*dateB)
}
}
@ -208,14 +221,14 @@ func TestUnmarshalDate(t *testing.T) {
}
// Ensure omitted fields default to the correct values
d = ofxgo.NewDateGMT(2017, 3, 14, 15, 9, 26, 53*1000*1000)
d = NewDateGMT(2017, 3, 14, 15, 9, 26, 53*1000*1000)
unmarshalHelper2(t, "20170314150926.053[0]", d, &overwritten, eq)
unmarshalHelper2(t, "20170314150926.053", d, &overwritten, eq)
d = ofxgo.NewDate(2017, 3, 14, 0, 0, 0, 0, GMT)
d = NewDate(2017, 3, 14, 0, 0, 0, 0, GMT)
unmarshalHelper2(t, "20170314", d, &overwritten, eq)
// Ensure all signs on time zone offsets are properly handled
d = ofxgo.NewDateGMT(2017, 3, 14, 15, 9, 26, 53*1000*1000)
d = NewDateGMT(2017, 3, 14, 15, 9, 26, 53*1000*1000)
unmarshalHelper2(t, "20170314150926.053[0:GMT]", d, &overwritten, eq)
unmarshalHelper2(t, "20170314150926.053[+0:GMT]", d, &overwritten, eq)
unmarshalHelper2(t, "20170314150926.053[-0:GMT]", d, &overwritten, eq)
@ -223,38 +236,38 @@ func TestUnmarshalDate(t *testing.T) {
unmarshalHelper2(t, "20170314150926.053[+0]", d, &overwritten, eq)
unmarshalHelper2(t, "20170314150926.053[-0]", d, &overwritten, eq)
d = ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, NPT)
d = NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, NPT)
unmarshalHelper2(t, "20170314150926.053[5.75:NPT]", d, &overwritten, eq)
d = ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, EST)
d = NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, EST)
unmarshalHelper2(t, "20170314150926.053[-5:EST]", d, &overwritten, eq)
d = ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, GMT)
d = NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, GMT)
unmarshalHelper2(t, "20170314150926.053[0:GMT]", d, &overwritten, eq)
d = ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, IST)
d = NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, IST)
unmarshalHelper2(t, "20170314150926.053[5.50:IST]", d, &overwritten, eq)
d = ofxgo.NewDate(2018, 11, 1, 23, 59, 58, 0, EST)
d = NewDate(2018, 11, 1, 23, 59, 58, 0, EST)
unmarshalHelper2(t, "20181101235958.000[-5:EST]", d, &overwritten, eq)
d = ofxgo.NewDate(0, 1, 1, 0, 0, 0, 0, IST)
d = NewDate(0, 1, 1, 0, 0, 0, 0, IST)
unmarshalHelper2(t, "00000101000000.000[5.50:IST]", d, &overwritten, eq)
d = &ofxgo.Date{Time: time.Unix(0, 0).In(GMT)}
d = &Date{Time: time.Unix(0, 0).In(GMT)}
unmarshalHelper2(t, "19700101000000.000[0:GMT]", d, &overwritten, eq)
d = ofxgo.NewDate(2017, 3, 14, 0, 0, 26, 53*1000*1000, EST)
d = NewDate(2017, 3, 14, 0, 0, 26, 53*1000*1000, EST)
unmarshalHelper2(t, "20170314000026.053[-5:EST]", d, &overwritten, eq)
d = ofxgo.NewDate(2017, 3, 14, 0, 0, 26, 53*1000*1000, NST)
d = NewDate(2017, 3, 14, 0, 0, 26, 53*1000*1000, NST)
unmarshalHelper2(t, "20170314000026.053[-3.50:NST]", d, &overwritten, eq)
// Autopopulate zone without textual description for GMT
d = ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, GMT)
d = NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, GMT)
unmarshalHelper2(t, "20170314150926.053[0]", d, &overwritten, eq)
// but not for others:
d = ofxgo.NewDate(2017, 3, 14, 0, 0, 26, 53*1000*1000, NST_nodesc)
d = NewDate(2017, 3, 14, 0, 0, 26, 53*1000*1000, NSTNodesc)
unmarshalHelper2(t, "20170314000026.053[-3.50]", d, &overwritten, eq)
// Make sure we handle poorly-formatted dates (from Vanguard)
d = ofxgo.NewDate(2016, 12, 7, 16, 0, 0, 0, EST)
d = NewDate(2016, 12, 7, 16, 0, 0, 0, EST)
unmarshalHelper2(t, "20161207160000.000[-5:EST]610900.500[-9:BST]", d, &overwritten, eq) // extra part intentionally different to ensure the first timezone is parsed
// Make sure we properly handle ending newlines
d = ofxgo.NewDate(2018, 11, 1, 23, 59, 58, 0, EST)
d = NewDate(2018, 11, 1, 23, 59, 58, 0, EST)
unmarshalHelper2(t, "20181101235958.000[-5:EST]\n", d, &overwritten, eq)
unmarshalHelper2(t, "20181101235958.000[-5:EST]\n\t", d, &overwritten, eq)
}
@ -263,23 +276,23 @@ func TestDateEqual(t *testing.T) {
GMT := time.FixedZone("GMT", 0)
EST := time.FixedZone("EST", -5*60*60)
assertEq := func(a, b *ofxgo.Date) {
assertEq := func(a, b *Date) {
if !a.Equal(*b) {
t.Fatalf("Dates should be equal but Equal returned false: %s and %s\n", *a, *b)
}
}
assertNEq := func(a, b *ofxgo.Date) {
assertNEq := func(a, b *Date) {
if a.Equal(*b) {
t.Fatalf("Dates should not be equal but Equal returned true: %s and %s\n", *a, *b)
}
}
// Ensure omitted fields default to the correct values
gmt1 := ofxgo.NewDateGMT(2017, 3, 14, 15, 9, 26, 53*1000*1000)
gmt2 := ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, GMT)
est1 := ofxgo.NewDate(2017, 3, 14, 10, 9, 26, 53*1000*1000, EST)
est2 := ofxgo.NewDate(2017, 3, 14, 10, 9, 26, 53*1000*1000+1, EST)
est3 := ofxgo.NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, EST)
gmt1 := NewDateGMT(2017, 3, 14, 15, 9, 26, 53*1000*1000)
gmt2 := NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, GMT)
est1 := NewDate(2017, 3, 14, 10, 9, 26, 53*1000*1000, EST)
est2 := NewDate(2017, 3, 14, 10, 9, 26, 53*1000*1000+1, EST)
est3 := NewDate(2017, 3, 14, 15, 9, 26, 53*1000*1000, EST)
assertEq(gmt1, gmt2)
assertEq(gmt2, gmt1)
@ -291,7 +304,7 @@ func TestDateEqual(t *testing.T) {
}
func TestMarshalString(t *testing.T) {
var s ofxgo.String = ""
var s String = ""
marshalHelper(t, "", &s)
s = "foo&bar"
marshalHelper(t, "foo&amp;bar", &s)
@ -302,7 +315,7 @@ func TestMarshalString(t *testing.T) {
}
func TestUnmarshalString(t *testing.T) {
var s, overwritten ofxgo.String = "", ""
var s, overwritten String = "", ""
unmarshalHelper(t, "", &s, &overwritten)
s = "foo&bar"
unmarshalHelper(t, "foo&amp;bar", &s, &overwritten)
@ -317,15 +330,28 @@ func TestUnmarshalString(t *testing.T) {
unmarshalHelper(t, "Some Name\n ", &s, &overwritten)
}
func TestStringString(t *testing.T) {
var s String = "foobar"
if s.String() != "foobar" {
t.Fatalf("Unexpected result when returning String.String(): %s\n", s.String())
}
}
func TestMarshalBoolean(t *testing.T) {
var b ofxgo.Boolean = true
var b Boolean = true
marshalHelper(t, "Y", &b)
b = false
marshalHelper(t, "N", &b)
type BooleanStruct struct {
B Boolean
}
bs := BooleanStruct{B: true}
marshalHelper(t, "<B>Y</B>", bs)
}
func TestUnmarshalBoolean(t *testing.T) {
var b, overwritten ofxgo.Boolean = true, false
var b, overwritten Boolean = true, false
unmarshalHelper(t, "Y", &b, &overwritten)
b = false
unmarshalHelper(t, "N", &b, &overwritten)
@ -334,13 +360,24 @@ func TestUnmarshalBoolean(t *testing.T) {
unmarshalHelper(t, "N\n \t", &b, &overwritten)
}
func TestStringBoolean(t *testing.T) {
var b Boolean = true
if b.String() != "true" {
t.Fatalf("Unexpected string for Boolean.String() for true: %s\n", b.String())
}
b = false
if b.String() != "false" {
t.Fatalf("Unexpected string for Boolean.String() for false: %s\n", b.String())
}
}
func TestMarshalUID(t *testing.T) {
var u ofxgo.UID = "d1cf3d3d-9ef9-4a97-b180-81706829cb04"
var u UID = "d1cf3d3d-9ef9-4a97-b180-81706829cb04"
marshalHelper(t, "d1cf3d3d-9ef9-4a97-b180-81706829cb04", &u)
}
func TestUnmarshalUID(t *testing.T) {
var u, overwritten ofxgo.UID = "d1cf3d3d-9ef9-4a97-b180-81706829cb04", ""
var u, overwritten UID = "d1cf3d3d-9ef9-4a97-b180-81706829cb04", ""
unmarshalHelper(t, "d1cf3d3d-9ef9-4a97-b180-81706829cb04", &u, &overwritten)
// Make sure stray newlines are handled properly
u = "0f94ce83-13b7-7568-e4fc-c02c7b47e7ab"
@ -349,7 +386,7 @@ func TestUnmarshalUID(t *testing.T) {
}
func TestUIDRecommendedFormat(t *testing.T) {
var u ofxgo.UID = "d1cf3d3d-9ef9-4a97-b180-81706829cb04"
var u UID = "d1cf3d3d-9ef9-4a97-b180-81706829cb04"
if ok, err := u.RecommendedFormat(); !ok || err != nil {
t.Fatalf("UID unexpectedly failed validation\n")
}
@ -368,7 +405,7 @@ func TestUIDRecommendedFormat(t *testing.T) {
}
func TestUIDValid(t *testing.T) {
var u ofxgo.UID = ""
var u UID = ""
if ok, err := u.Valid(); ok || err == nil {
t.Fatalf("Empty UID unexpectedly valid\n")
}
@ -383,7 +420,7 @@ func TestUIDValid(t *testing.T) {
}
func TestRandomUID(t *testing.T) {
uid, err := ofxgo.RandomUID()
uid, err := RandomUID()
if err != nil {
t.Fatalf("Unexpected error when calling RandomUID: %s\n", err)
}
@ -393,46 +430,52 @@ func TestRandomUID(t *testing.T) {
}
func TestMarshalCurrSymbol(t *testing.T) {
c, _ := ofxgo.NewCurrSymbol("USD")
c, _ := NewCurrSymbol("USD")
marshalHelper(t, "USD", &c)
type CurrSymbolStruct struct {
CS CurrSymbol
}
css := CurrSymbolStruct{CS: *c}
marshalHelper(t, "<CS>USD</CS>", css)
}
func TestUnmarshalCurrSymbol(t *testing.T) {
var overwritten ofxgo.CurrSymbol
c, _ := ofxgo.NewCurrSymbol("USD")
var overwritten CurrSymbol
c, _ := NewCurrSymbol("USD")
unmarshalHelper(t, "USD", c, &overwritten)
// Make sure stray newlines are handled properly
c, _ = ofxgo.NewCurrSymbol("EUR")
c, _ = NewCurrSymbol("EUR")
unmarshalHelper(t, "EUR\n", c, &overwritten)
unmarshalHelper(t, "EUR\n\t", c, &overwritten)
}
func TestCurrSymbolEqual(t *testing.T) {
usd1, _ := ofxgo.NewCurrSymbol("USD")
usd2, _ := ofxgo.NewCurrSymbol("USD")
usd1, _ := NewCurrSymbol("USD")
usd2, _ := NewCurrSymbol("USD")
if !usd1.Equal(*usd2) {
t.Fatalf("Two \"USD\" CurrSymbols returned !Equal()\n")
}
xxx, _ := ofxgo.NewCurrSymbol("XXX")
xxx, _ := NewCurrSymbol("XXX")
if usd1.Equal(*xxx) {
t.Fatalf("\"USD\" and \"XXX\" CurrSymbols returned Equal()\n")
}
}
func TestCurrSymbolValid(t *testing.T) {
var initial ofxgo.CurrSymbol
var initial CurrSymbol
ok, err := initial.Valid()
if ok || err == nil {
t.Fatalf("CurrSymbol unexpectedly returned Valid() for initial value\n")
}
ars, _ := ofxgo.NewCurrSymbol("ARS")
ars, _ := NewCurrSymbol("ARS")
ok, err = ars.Valid()
if !ok || err != nil {
t.Fatalf("CurrSymbol unexpectedly returned !Valid() for \"ARS\": %s\n", err.Error())
}
xxx, _ := ofxgo.NewCurrSymbol("XXX")
xxx, _ := NewCurrSymbol("XXX")
ok, err = xxx.Valid()
if ok || err == nil {
t.Fatalf("CurrSymbol unexpectedly returned Valid() for \"XXX\"\n")
@ -440,21 +483,21 @@ func TestCurrSymbolValid(t *testing.T) {
}
func TestNewCurrSymbol(t *testing.T) {
curr, err := ofxgo.NewCurrSymbol("GBP")
curr, err := NewCurrSymbol("GBP")
if err != nil {
t.Fatalf("Unexpected error calling NewCurrSymbol: %s\n", err)
}
if curr.String() != "GBP" {
t.Fatalf("Created CurrSymbol doesn't print \"GBP\" as string representation\n")
}
curr, err = ofxgo.NewCurrSymbol("AFN")
curr, err = NewCurrSymbol("AFN")
if err != nil {
t.Fatalf("Unexpected error calling NewCurrSymbol: %s\n", err)
}
if curr.String() != "AFN" {
t.Fatalf("Created CurrSymbol doesn't print \"AFN\" as string representation\n")
}
curr, err = ofxgo.NewCurrSymbol("BLAH")
curr, err = NewCurrSymbol("BLAH")
if err == nil {
t.Fatalf("NewCurrSymbol didn't error on invalid currency identifier\n")
}

83
vanguard_client.go Normal file
View File

@ -0,0 +1,83 @@
package ofxgo
import (
"errors"
"io"
"net/http"
"strings"
)
// VanguardClient provides a Client implementation which handles Vanguard's
// cookie-passing requirements. VanguardClient uses default, non-zero settings,
// if its fields are not initialized.
type VanguardClient struct {
*BasicClient
}
// NewVanguardClient returns a Client interface configured to handle Vanguard's
// brand of idiosyncrasy
func NewVanguardClient(bc *BasicClient) Client {
return &VanguardClient{bc}
}
// rawRequestCookies is RawRequest with the added feature of sending cookies
func rawRequestCookies(URL string, r io.Reader, cookies []*http.Cookie) (*http.Response, error) {
if !strings.HasPrefix(URL, "https://") {
return nil, errors.New("Refusing to send OFX request with possible plain-text password over non-https protocol")
}
request, err := http.NewRequest("POST", URL, r)
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/x-ofx")
for _, cookie := range cookies {
request.AddCookie(cookie)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, err
}
if response.StatusCode != 200 {
return nil, errors.New("OFXQuery request status: " + response.Status)
}
return response, nil
}
// RequestNoParse marshals a Request to XML, makes an HTTP request, and returns
// the raw HTTP response
func (c *VanguardClient) RequestNoParse(r *Request) (*http.Response, error) {
r.SetClientFields(c)
b, err := r.Marshal()
if err != nil {
return nil, err
}
response, err := c.RawRequest(r.URL, b)
// Some financial institutions (cough, Vanguard, cough), require a cookie
// to be set on the http request, or they return empty responses.
// Fortunately, the initial response contains the cookie we need, so if we
// detect an empty response with cookies set that didn't have any errors,
// re-try the request while sending their cookies back to them.
if response != nil && response.ContentLength <= 0 && len(response.Cookies()) > 0 {
b, err = r.Marshal()
if err != nil {
return nil, err
}
return rawRequestCookies(r.URL, b, response.Cookies())
}
return response, err
}
// Request marshals a Request to XML, makes an HTTP request, and then
// unmarshals the response into a Response object.
func (c *VanguardClient) Request(r *Request) (*Response, error) {
return clientRequest(c, r)
}