• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

csingley / ofxtools / 27383196834

11 Jun 2026 11:06PM UTC coverage: 94.175% (-0.02%) from 94.194%
27383196834

push

github

csingley
Fix requests header ordering for Amex; add readthedocs config

Issue #162: Amex (and possibly other FIs) reject requests when HTTP headers
are not in the expected order. The requests library prepends its own default
headers before ours when headers are passed via the headers= kwarg. Fix by
assigning sess.headers directly, replacing the defaults so our header order
(User-Agent first) is preserved.

Issue #195: Add .readthedocs.yaml so readthedocs can build the docs. Also
update docs/conf.py to pull project metadata from importlib.metadata instead
of the now-removed __version__.py module.

0 of 1 new or added line in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

4575 of 4858 relevant lines covered (94.17%)

3.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.07
/ofxtools/Client.py
1
"""
2
Network client that composes/transmits Open Financial Exchange (OFX) requests,
3
and receives OFX responses in reply.  A basic CLI utility is included.
4

5
To use, create an OFXClient instance configured with OFX connection parameters:
6
server URL, OFX protocol version, financial institution identifiers, client
7
identifiers, etc.
8

9
``config/fi.cfg`` contains a database of these parameters, most conveniently
10
accessed via ``scripts/ofxget.py``.
11

12
Using the configured ``OFXClient`` instance, make a request by calling the
13
relevant method, e.g. ``OFXClient.request_statements()``.  Provide the password
14
as the first positional argument; any remaining positional arguments are parsed
15
as requests.  Simple data containers for each statement (``StmtRq``,
16
``CcStmtRq``, etc.) are provided for this purpose.  Options follow as keyword
17
arguments.
18

19
For example:
20

21
>>> import datetime; import ofxtools
22
>>> from ofxtools.Client import OFXClient, StmtRq, CcStmtEndRq
23
>>> client = OFXClient("https://ofx.chase.com", userid="MoMoney",
24
...                    org="B1", fid="10898",
25
...                    version=220, prettyprint=True,
26
...                    bankid="111000614")
27
>>> dtstart = datetime.datetime(2015, 1, 1, tzinfo=ofxtools.utils.UTC)
28
>>> dtend = datetime.datetime(2015, 1, 31, tzinfo=ofxtools.utils.UTC)
29
>>> s0 = StmtRq(acctid="1", accttype="CHECKING", dtstart=dtstart, dtend=dtend)
30
>>> s1 = StmtRq(acctid="2", accttype="SAVINGS", dtstart=dtstart, dtend=dtend)
31
>>> c0 = CcStmtEndRq(acctid="3", dtstart=dtstart, dtend=dtend)
32
>>> response = client.request_statements("t0ps3kr1t", s0, s1, c0)
33
"""
34

35
__all__ = [
4✔
36
    "AUTH_PLACEHOLDER",
37
    "StmtRq",
38
    "CcStmtRq",
39
    "InvStmtRq",
40
    "StmtEndRq",
41
    "CcStmtEndRq",
42
    "OFXClient",
43
    "wrap_stmtrq",
44
]
45

46

47
# stdlib imports
48
import datetime
4✔
49
import http.cookiejar
4✔
50
import itertools
4✔
51
import logging
4✔
52
import urllib.request as urllib_request
4✔
53
import uuid
4✔
54
import xml.etree.ElementTree as ET
4✔
55
from collections.abc import Iterator
4✔
56
from functools import singledispatch
4✔
57
from io import BytesIO
4✔
58
from operator import attrgetter, itemgetter
4✔
59
from typing import (
4✔
60
    BinaryIO,
61
    NamedTuple,
62
)
63

64
# 3rd party libs
65
try:
4✔
66
    import requests
4✔
67

68
    USE_REQUESTS = True
4✔
69
except ImportError:
×
70
    USE_REQUESTS = False
×
71

72

73
# local imports
74
from ofxtools import config, utils
4✔
75
from ofxtools.header import make_header
4✔
76
from ofxtools.models import ACCTINFORQ, ACCTINFOTRNRQ
4✔
77
from ofxtools.models.bank import (
4✔
78
    BANKACCTFROM,
79
    BANKMSGSET,
80
    BANKMSGSRQV1,
81
    CCACCTFROM,
82
    CCSTMTENDRQ,
83
    CCSTMTENDTRNRQ,
84
    CCSTMTRQ,
85
    CCSTMTTRNRQ,
86
    CREDITCARDMSGSET,
87
    CREDITCARDMSGSRQV1,
88
    INCTRAN,
89
    INTERXFERMSGSET,
90
    STMTENDRQ,
91
    STMTENDTRNRQ,
92
    STMTRQ,
93
    STMTTRNRQ,
94
    WIREXFERMSGSET,
95
)
96
from ofxtools.models.billpay.msgsets import BILLPAYMSGSET
4✔
97
from ofxtools.models.email import EMAILMSGSET
4✔
98
from ofxtools.models.invest import (
4✔
99
    INCPOS,
100
    INVACCTFROM,
101
    INVSTMTMSGSET,
102
    INVSTMTMSGSRQV1,
103
    INVSTMTRQ,
104
    INVSTMTTRNRQ,
105
    SECLISTMSGSET,
106
)
107
from ofxtools.models.ofx import OFX
4✔
108
from ofxtools.models.profile import PROFMSGSET, PROFMSGSRQV1, PROFRQ, PROFTRNRQ
4✔
109
from ofxtools.models.signon import FI, SIGNONMSGSET, SIGNONMSGSRQV1, SONRQ
4✔
110
from ofxtools.models.signup import SIGNUPMSGSET, SIGNUPMSGSRQV1
4✔
111
from ofxtools.models.tax1099 import (
4✔
112
    TAX1099MSGSET,
113
    TAX1099MSGSRQV1,
114
    TAX1099RQ,
115
    TAX1099TRNRQ,
116
)
117
from ofxtools.Parser import OFXTree
4✔
118
from ofxtools.utils import UTC, classproperty
4✔
119

120
AUTH_PLACEHOLDER = "{:0<32}".format("anonymous")
4✔
121

122

123
logger = logging.getLogger(__name__)
4✔
124

125

126
# Statement request data containers
127
# Pass instances of these containers as args to OFXClient.request_statement()
128
class StmtRq(NamedTuple):
4✔
129
    """
130
    Parameters of a bank statement request
131
    """
132

133
    acctid: str | None = None
4✔
134
    accttype: str | None = None
4✔
135
    dtstart: datetime.datetime | None = None
4✔
136
    dtend: datetime.datetime | None = None
4✔
137
    inctran: bool | None = True
4✔
138

139

140
class CcStmtRq(NamedTuple):
4✔
141
    """
142
    Parameters of a credit card statement request
143
    """
144

145
    acctid: str | None = None
4✔
146
    dtstart: datetime.datetime | None = None
4✔
147
    dtend: datetime.datetime | None = None
4✔
148
    inctran: bool | None = True
4✔
149

150

151
class InvStmtRq(NamedTuple):
4✔
152
    """
153
    Parameters of an investment account statement request
154
    """
155

156
    acctid: str | None = None
4✔
157
    dtstart: datetime.datetime | None = None
4✔
158
    dtend: datetime.datetime | None = None
4✔
159
    dtasof: datetime.datetime | None = None
4✔
160
    inctran: bool | None = True
4✔
161
    incoo: bool | None = False
4✔
162
    incpos: bool | None = True
4✔
163
    incbal: bool | None = True
4✔
164

165

166
class StmtEndRq(NamedTuple):
4✔
167
    """
168
    Parameters of a bank statement ending balance request
169
    """
170

171
    acctid: str | None = None
4✔
172
    accttype: str | None = None
4✔
173
    dtstart: datetime.datetime | None = None
4✔
174
    dtend: datetime.datetime | None = None
4✔
175

176

177
class CcStmtEndRq(NamedTuple):
4✔
178
    """
179
    Parameters of a credit card statement ending balance request
180
    """
181

182
    acctid: str | None = None
4✔
183
    dtstart: datetime.datetime | None = None
4✔
184
    dtend: datetime.datetime | None = None
4✔
185

186

187
# TYPE ALIASES
188
RequestParam = StmtRq | CcStmtRq | InvStmtRq | StmtEndRq | CcStmtEndRq
4✔
189
Request = STMTRQ | CCSTMTRQ | INVSTMTRQ | STMTENDRQ | CCSTMTENDRQ
4✔
190
Message = BANKMSGSRQV1 | CREDITCARDMSGSRQV1 | INVSTMTMSGSRQV1
4✔
191
MsgsetClass = (
4✔
192
    type[SIGNONMSGSET]
193
    | type[SIGNUPMSGSET]
194
    | type[BANKMSGSET]
195
    | type[CREDITCARDMSGSET]
196
    | type[INVSTMTMSGSET]
197
    | type[INTERXFERMSGSET]
198
    | type[WIREXFERMSGSET]
199
    | type[BILLPAYMSGSET]
200
    | type[EMAILMSGSET]
201
    | type[SECLISTMSGSET]
202
    | type[PROFMSGSET]
203
    | type[TAX1099MSGSET]
204
)
205

206

207
class OFXClient:
4✔
208
    """
209
    Basic OFX client to download statement and profile requests.
210
    """
211

212
    # OFX header/signon defaults
213
    userid: str = AUTH_PLACEHOLDER
4✔
214
    clientuid: str | None = None
4✔
215
    org: str | None = None
4✔
216
    fid: str | None = None
4✔
217
    version: int = 203
4✔
218
    appid: str = "QWIN"
4✔
219
    appver: str = "2700"
4✔
220
    language: str = "ENG"
4✔
221
    useragent: str = "InetClntApp/3.0"
4✔
222

223
    # Formatting defaults
224
    prettyprint: bool = False
4✔
225
    close_elements: bool = True
4✔
226

227
    # Stmt request
228
    bankid: str | None = None
4✔
229
    brokerid: str | None = None
4✔
230
    persist_cookies: bool = True
4✔
231

232
    def __repr__(self) -> str:
4✔
233
        r = (
4✔
234
            "{cls}(url={url!r}, userid={userid!r}, clientuid={clientuid!r}, "
235
            "org={org!r}, fid={fid!r}, version={version}, appid={appid!r}, "
236
            "appver={appver!r}, language={language!r}, prettyprint={prettyprint}, "
237
            "close_elements={close_elements}, bankid={bankid!r}, brokerid={brokerid!r})"
238
        )
239
        attrs = dict(vars(self.__class__))
4✔
240
        attrs.update(vars(self))
4✔
241
        attrs["cls"] = self.__class__.__name__
4✔
242
        return r.format(**attrs)
4✔
243

244
    def __init__(
4✔
245
        self,
246
        url: str,
247
        userid: str | None = None,
248
        clientuid: str | None = None,
249
        org: str | None = None,
250
        fid: str | None = None,
251
        version: int | None = None,
252
        appid: str | None = None,
253
        appver: str | None = None,
254
        language: str | None = None,
255
        prettyprint: bool | None = None,
256
        close_elements: bool | None = None,
257
        bankid: str | None = None,
258
        brokerid: str | None = None,
259
        useragent: str | None = None,
260
        persist_cookies: bool | None = None,
261
    ):
262
        self.url = url
4✔
263

264
        for attr in [
4✔
265
            "userid",
266
            "clientuid",
267
            "org",
268
            "fid",
269
            "version",
270
            "appid",
271
            "appver",
272
            "language",
273
            "prettyprint",
274
            "close_elements",
275
            "bankid",
276
            "brokerid",
277
            "useragent",
278
            "persist_cookies",
279
        ]:
280
            value = locals()[attr]
4✔
281
            if value is not None:
4✔
282
                setattr(self, attr, value)
4✔
283

284
        if (not self.close_elements) and self.version >= 200:
4✔
285
            raise ValueError(f"OFX version {self.version} must close all tags")
4✔
286

287
        # Allow persistent cookies in case PROFRS sets cookies that are needed by
288
        # subsequent STMTRQ or what have you.
289
        self.cookiejar = http.cookiejar.CookieJar()
4✔
290

291
    @classproperty
4✔
292
    @classmethod
4✔
293
    def uuid(cls) -> str:
4✔
294
        """
295
        Return a new UUID each time called.
296

297
        Wrapper we can mock for testing.
298
        """
299
        return str(uuid.uuid4()).upper()
4✔
300

301
    @property
4✔
302
    def http_headers(self) -> dict[str, str]:
4✔
303
        """Pass to urllib.request.urlopen()"""
304
        mimetype = "application/x-ofx"
×
305
        # Python libraries such as ``urllib.request`` and ``requests``
306
        # identify themselves in the ``User-Agent`` header,
307
        # which apparently displeases some FIs
308
        return {
×
309
            "User-Agent": self.useragent,
310
            "Content-Type": mimetype,
311
            # Apparently Amex is unhappy unless it sees a MIME type of application/xml
312
            # with some quality rating - ANY quality rating, it seems.
313
            "Accept": f"*/*, {mimetype}, application/xml;q=0.9",
314
        }
315

316
    def dtclient(self) -> datetime.datetime:
4✔
317
        """
318
        Wrapper we can mock for testing.
319
        (as opposed to datetime.datetime, which is a C extension)
320
        """
321
        return datetime.datetime.now(UTC)
4✔
322

323
    def request_statements(
4✔
324
        self,
325
        password: str,
326
        *requests: RequestParam,
327
        gen_newfileuid: bool = True,
328
        dryrun: bool = False,
329
        timeout: float | None = None,
330
        skip_profile: bool = False,
331
    ) -> BinaryIO:
332
        """
333
        Package and send OFX statement requests
334
        (STMTRQ/CCSTMTRQ/INVSTMTRQ/STMTENDRQ/CCSTMTENDRQ).
335
        """
336
        if dryrun:
4✔
337
            url = ""
4✔
338
            logger.info("Dry run for statement request")
4✔
339
        elif skip_profile:
4✔
340
            url = self.url
×
341
            logger.info(f"Skipping profile request; using url='{url}'")
×
342
        else:
343
            logger.info("Requesting OFX profile to extract service URLs")
4✔
344
            RqCls2url = self._get_service_urls(
4✔
345
                timeout=timeout,
346
                gen_newfileuid=gen_newfileuid,
347
            )
348

349
            # HACK FIXME
350
            # As a simplification, we assume that FIs handle all classes
351
            # of statement request from a single URL.
352
            urls = set(RqCls2url.values())
4✔
353
            assert len(urls) == 1
4✔
354
            url = urls.pop()
4✔
355
            logger.info(f"Received service url={url} from OFX profile response")
4✔
356

357
        logger.info(f"Creating statement requests for {requests}")
4✔
358
        # Group requests by type and pass to the appropriate *TRNRQ handler
359
        # function (see singledispatch setup below).
360
        #
361
        # Classes don't have rich comparison methods, so we can't sort by class.
362
        # As a proxy, we sort by class name, even though we actually group by class
363
        # so we can use it when iterating over groupby().
364
        sortKey = attrgetter("__class__.__name__")
4✔
365
        groupKey = attrgetter("__class__")
4✔
366
        trnrqs = [
4✔
367
            wrap_stmtrq(cls(), rqs, self)
368
            for cls, rqs in itertools.groupby(
369
                sorted(requests, key=sortKey), key=groupKey
370
            )
371
        ]
372

373
        # trnrqs is a pair of (models.*MSGSRQV1, [*TRNRQ])
374
        # Can't sort *MSGSRQV1 by class, either, so we use the same trick
375
        # of sorting by class name and grouping by class.
376
        def trnSortKey(pair):
4✔
377
            return pair[0].__name__
4✔
378

379
        trnGroupKey = itemgetter(0)
4✔
380
        trnrqs.sort(key=trnSortKey)
4✔
381

382
        # N.B. we need to annotate first arg as typing.Type here to indicate that
383
        # we're passing in a class not an instance.
384
        def msg_args(
4✔
385
            msgcls: type[BANKMSGSRQV1]
386
            | type[CREDITCARDMSGSRQV1]
387
            | type[INVSTMTMSGSRQV1],
388
            trnrqs: Iterator[Request],
389
        ) -> tuple[str, Message]:
390
            trnrqs_ = list(itertools.chain.from_iterable(t[1] for t in trnrqs))
4✔
391
            attr_name = msgcls.__name__.lower()
4✔
392
            return (attr_name, msgcls(*trnrqs_))
4✔
393

394
        msgs = dict(
4✔
395
            msg_args(msgcls, _trnrqs)
396
            for msgcls, _trnrqs in itertools.groupby(trnrqs, key=trnGroupKey)
397
        )
398
        logger.debug(f"Wrapped statement request messages: {msgs}")
4✔
399

400
        signon = self.signon(password)
4✔
401
        ofx = OFX(signonmsgsrqv1=signon, **msgs)
4✔
402

403
        if gen_newfileuid:
4✔
404
            newfileuid = self.uuid
4✔
405
        else:
406
            newfileuid = None
×
407

408
        return self.download(
4✔
409
            ofx,
410
            newfileuid=newfileuid,
411
            dryrun=dryrun,
412
            timeout=timeout,
413
            url=url,
414
        )
415

416
    def _get_service_urls(
4✔
417
        self,
418
        timeout: float | None = None,
419
        gen_newfileuid: bool = True,
420
    ) -> dict:
421
        """Query OFX profile endpoint to construct mapping of statement request
422
        data container to URL providing that service.
423
        """
424
        profile = self.request_profile(
4✔
425
            gen_newfileuid=gen_newfileuid,
426
            timeout=timeout,
427
        )
428
        parser = OFXTree()
×
429
        parser.parse(profile)
×
430
        ofx = parser.convert()
×
431
        proftrnrs = ofx.profmsgsrsv1[0]
×
432
        msgsetlist = proftrnrs.msgsetlist  # proxy access to SubAggregate attributes
×
433
        classmap = {
×
434
            BANKMSGSET: StmtRq,
435
            CREDITCARDMSGSET: CcStmtRq,
436
            INVSTMTMSGSET: InvStmtRq,
437
        }
438
        urls = {
×
439
            RqCls: msgset.url  # proxy access to SubAggregate attributes
440
            for msgset in msgsetlist
441
            if (RqCls := classmap.get(type(msgset), None)) is not None
442
        }
443

444
        # Also map *STMTENDRQ
445
        def map_stmtendrq_urls(
×
446
            msgsetCls: MsgsetClass,
447
            stmtendrqCls: type[StmtEndRq] | type[CcStmtEndRq],
448
        ):
449
            try:
×
450
                index = [type(msgset) for msgset in msgsetlist].index(msgsetCls)
×
451
            except ValueError:
×
452
                pass
×
453
            else:
454
                msgset = msgsetlist[index]
×
455
                if msgset.closingavail:  # proxy access to SubAggregate attributes
×
456
                    urls[stmtendrqCls] = msgset.url  # proxy access to SubAgg attributes
×
457

458
        map_stmtendrq_urls(BANKMSGSET, StmtEndRq)
×
459
        map_stmtendrq_urls(CREDITCARDMSGSET, CcStmtEndRq)
×
460

461
        return urls
×
462

463
    def request_profile(
4✔
464
        self,
465
        version: int | None = None,
466
        gen_newfileuid: bool = True,
467
        prettyprint: bool | None = None,
468
        close_elements: bool | None = None,
469
        dryrun: bool = False,
470
        timeout: float | None = None,
471
        url: str | None = None,
472
        persist: bool = True,
473
    ) -> BinaryIO:
474
        """Request/cache OFX profiles (PROFRS).
475

476
        ofxget.scan_profile() overrides version/prettyprint/close_elements.
477
        """
478
        filename = f"{self.org}-{self.fid}.profrs"
4✔
479
        persistdir = config.DATADIR / "fiprofiles"
4✔
480
        persistpath = persistdir / filename
4✔
481

482
        if persistpath.exists():
4✔
483
            with open(persistpath, "rb") as f:
×
484
                profrs: BytesIO | None = BytesIO(f.read())
×
485

486
            parser = OFXTree()
×
487
            parser.parse(profrs)
×
488
            ofx = parser.convert()
×
489
            proftrnrs = ofx.profmsgsrsv1[0]
×
490
            dtprofup = proftrnrs.profrs.dtprofup
×
491
        else:
492
            persistdir.mkdir(parents=True, exist_ok=True)
4✔
493
            profrs = None
4✔
494
            dtprofup = None
4✔
495

496
        response = self._request_profile(
4✔
497
            dtprofup=dtprofup,
498
            version=version,
499
            gen_newfileuid=gen_newfileuid,
500
            prettyprint=prettyprint,
501
            close_elements=close_elements,
502
            dryrun=dryrun,
503
            timeout=timeout,
504
            url=url,
505
        )
506

507
        if dryrun:
×
508
            return response
×
509

510
        parser = OFXTree()
×
511
        parser.parse(response)
×
512
        ofx = parser.convert()
×
513

514
        #  If the client has the latest version of the FIs profile, the server returns
515
        #  status code 1 in the <STATUS> aggregate of the profile-transaction aggregate
516
        #  <PROFTRNRS>. The server does not return a profile- response aggregate <PROFRS>.
517

518
        #  If the client does not have the latest version of the FI profile, the server
519
        #  responds with the profile-response aggregate <PROFRS> in the profile-transaction
520
        #  aggregate <PROFTRNRS>.
521
        proftrnrs = ofx.profmsgsrsv1[0]
×
522
        if proftrnrs.status.code == 1:
×
523
            assert profrs is not None
×
524
            response = profrs
×
525
        else:
526
            assert proftrnrs.status.code == 0
×
527
            dtprofup_server = proftrnrs.profrs.dtprofup
×
528
            assert dtprofup is None or dtprofup <= dtprofup_server
×
529

530
            # Cache the updated PROFRS sent by the server
531
            response.seek(0)
×
532
            with open(persistpath, "wb") as f:
×
533
                f.write(response.read())
×
534

535
        # Rewind PROFRS so it can be returned cleanly after having been parsed.
536
        response.seek(0)
×
537

538
        return response
×
539

540
    def _request_profile(
4✔
541
        self,
542
        dtprofup: datetime.datetime | None = None,
543
        version: int | None = None,
544
        gen_newfileuid: bool = True,
545
        prettyprint: bool | None = None,
546
        close_elements: bool | None = None,
547
        dryrun: bool = False,
548
        timeout: float | None = None,
549
        url: str | None = None,
550
    ) -> BytesIO:
551
        """Package and send OFX profile requests (PROFRQ)."""
552
        logger.info("Creating profile request")
4✔
553

554
        if dtprofup is None:
4✔
555
            dtprofup = datetime.datetime(1990, 1, 1, tzinfo=UTC)
4✔
556
        profrq = PROFRQ(clientrouting="NONE", dtprofup=dtprofup)
4✔
557
        proftrnrq = PROFTRNRQ(trnuid=self.uuid, profrq=profrq)
4✔
558

559
        logger.debug(f"Wrapped profile request: {proftrnrq}")
4✔
560

561
        user = password = AUTH_PLACEHOLDER
4✔
562
        signon = self.signon(password, userid=user)
4✔
563

564
        ofx = OFX(signonmsgsrqv1=signon, profmsgsrqv1=PROFMSGSRQV1(proftrnrq))
4✔
565

566
        if gen_newfileuid:
4✔
567
            newfileuid = self.uuid
4✔
568
        else:
569
            newfileuid = None
×
570

571
        return self.download(
4✔
572
            ofx,
573
            version=version,
574
            newfileuid=newfileuid,
575
            prettyprint=prettyprint,
576
            close_elements=close_elements,
577
            dryrun=dryrun,
578
            timeout=timeout,
579
            url=url,
580
        )
581

582
    def request_accounts(
4✔
583
        self,
584
        password: str,
585
        dtacctup: datetime.datetime,
586
        dryrun: bool = False,
587
        version: int | None = None,
588
        gen_newfileuid: bool = True,
589
        timeout: float | None = None,
590
        skip_profile: bool = False,
591
    ) -> BinaryIO:
592
        """
593
        Package and send OFX account info requests (ACCTINFORQ)
594
        """
595
        if dryrun:
4✔
596
            url = ""
×
597
        elif skip_profile:
4✔
598
            url = self.url
×
599
        else:
600
            RqCls2url = self._get_service_urls(
4✔
601
                timeout=timeout,
602
                gen_newfileuid=gen_newfileuid,
603
            )
604

605
            # HACK FIXME
606
            # As a simplification, we assume that FIs handle all classes
607
            # of statement request from a single URL.
608
            urls = set(RqCls2url.values())
4✔
609
            assert len(urls) == 1
4✔
610
            url = urls.pop()
4✔
611

612
        logger.info("Creating account info request")
4✔
613
        signon = self.signon(password)
4✔
614

615
        acctinforq = ACCTINFORQ(dtacctup=dtacctup)
4✔
616
        acctinfotrnrq = ACCTINFOTRNRQ(trnuid=self.uuid, acctinforq=acctinforq)
4✔
617
        msgs = SIGNUPMSGSRQV1(acctinfotrnrq)
4✔
618

619
        logger.debug(f"Wrapped account info request messages: {msgs}")
4✔
620

621
        ofx = OFX(signonmsgsrqv1=signon, signupmsgsrqv1=msgs)
4✔
622

623
        if gen_newfileuid:
4✔
624
            newfileuid = self.uuid
4✔
625
        else:
626
            newfileuid = None
×
627

628
        return self.download(
4✔
629
            ofx,
630
            newfileuid=newfileuid,
631
            dryrun=dryrun,
632
            timeout=timeout,
633
            url=url,
634
        )
635

636
    def request_tax1099(
4✔
637
        self,
638
        password: str,
639
        *taxyears: str,
640
        acctnum: str | None = None,
641
        recid: str | None = None,
642
        gen_newfileuid: bool = True,
643
        dryrun: bool = False,
644
        timeout: float | None = None,
645
        skip_profile: bool = False,
646
    ) -> BinaryIO:
647
        """
648
        Request US federal income tax form 1099 (TAX1099RQ)
649
        """
650
        if dryrun:
×
651
            url = ""
×
652
        elif skip_profile:
×
653
            url = self.url
×
654
        else:
655
            RqCls2url = self._get_service_urls(
×
656
                timeout=timeout,
657
                gen_newfileuid=gen_newfileuid,
658
            )
659

660
            # HACK FIXME
661
            # As a simplification, we assume that FIs handle all classes
662
            # of statement request from a single URL.
663
            urls = set(RqCls2url.values())
×
664
            assert len(urls) == 1
×
665
            url = urls.pop()
×
666

667
        logger.info("Creating tax 1099 request")
×
668
        signon = self.signon(password)
×
669

670
        rq = TAX1099RQ(*taxyears, recid=recid or None)
×
671
        msgs = TAX1099MSGSRQV1(TAX1099TRNRQ(trnuid=self.uuid, tax1099rq=rq))
×
672

673
        logger.debug(f"Wrapped tax 1099 request messages: {msgs}")
×
674

675
        ofx = OFX(signonmsgsrqv1=signon, tax1099msgsrqv1=msgs)
×
676

677
        if gen_newfileuid:
×
678
            newfileuid = self.uuid
×
679
        else:
680
            newfileuid = None
×
681

682
        return self.download(
×
683
            ofx,
684
            newfileuid=newfileuid,
685
            dryrun=dryrun,
686
            timeout=timeout,
687
            url=url,
688
        )
689

690
    def signon(
4✔
691
        self,
692
        userpass: str,
693
        userid: str | None = None,
694
        sesscookie: str | None = None,
695
    ) -> SIGNONMSGSRQV1:
696
        """Construct SONRQ; package in SIGNONMSGSRQV1"""
697
        if self.org:
4✔
698
            fi: FI | None = FI(org=self.org, fid=self.fid)
4✔
699
        else:
700
            fi = None
4✔
701

702
        if userid is None:
4✔
703
            userid = self.userid
4✔
704

705
        # CLIENTUID was introduced to the spec in OFXv1.0.3
706
        if self.version < 103:
4✔
707
            clientuid = None
4✔
708
        else:
709
            clientuid = self.clientuid
4✔
710

711
        sonrq = SONRQ(
4✔
712
            dtclient=self.dtclient(),
713
            userid=userid,
714
            userpass=userpass,
715
            language=self.language,
716
            fi=fi,
717
            sesscookie=sesscookie,
718
            appid=self.appid,
719
            appver=self.appver,
720
            clientuid=clientuid,
721
        )
722
        return SIGNONMSGSRQV1(sonrq=sonrq)
4✔
723

724
    def stmttrnrq(
4✔
725
        self,
726
        bankid: str,
727
        acctid: str,
728
        accttype: str,
729
        dtstart: datetime.datetime | None = None,
730
        dtend: datetime.datetime | None = None,
731
        inctran: bool = True,
732
    ) -> STMTTRNRQ:
733
        """Construct STMTRQ; package in STMTTRNRQ"""
734
        acct = BANKACCTFROM(bankid=bankid, acctid=acctid, accttype=accttype)
4✔
735
        inctran_ = INCTRAN(dtstart=dtstart, dtend=dtend, include=inctran)
4✔
736
        stmtrq = STMTRQ(bankacctfrom=acct, inctran=inctran_)
4✔
737
        trnuid = self.uuid
4✔
738
        return STMTTRNRQ(trnuid=trnuid, stmtrq=stmtrq)
4✔
739

740
    def stmtendtrnrq(
4✔
741
        self,
742
        bankid: str,
743
        acctid: str,
744
        accttype: str,
745
        dtstart: datetime.datetime | None = None,
746
        dtend: datetime.datetime | None = None,
747
    ) -> STMTENDTRNRQ:
748
        """Construct STMTENDRQ; package in STMTENDTRNRQ"""
749
        acct = BANKACCTFROM(bankid=bankid, acctid=acctid, accttype=accttype)
4✔
750
        stmtrq = STMTENDRQ(bankacctfrom=acct, dtstart=dtstart, dtend=dtend)
4✔
751
        trnuid = self.uuid
4✔
752
        return STMTENDTRNRQ(trnuid=trnuid, stmtendrq=stmtrq)
4✔
753

754
    def ccstmttrnrq(
4✔
755
        self,
756
        acctid: str,
757
        dtstart: datetime.datetime | None = None,
758
        dtend: datetime.datetime | None = None,
759
        inctran: bool = True,
760
    ) -> CCSTMTTRNRQ:
761
        """Construct CCSTMTRQ; package in CCSTMTTRNRQ"""
762
        acct = CCACCTFROM(acctid=acctid)
4✔
763
        inctran_ = INCTRAN(dtstart=dtstart, dtend=dtend, include=inctran)
4✔
764
        stmtrq = CCSTMTRQ(ccacctfrom=acct, inctran=inctran_)
4✔
765
        trnuid = self.uuid
4✔
766
        return CCSTMTTRNRQ(trnuid=trnuid, ccstmtrq=stmtrq)
4✔
767

768
    def ccstmtendtrnrq(
4✔
769
        self,
770
        acctid: str,
771
        dtstart: datetime.datetime | None = None,
772
        dtend: datetime.datetime | None = None,
773
    ) -> CCSTMTENDTRNRQ:
774
        """Construct CCSTMTENDRQ; package in CCSTMTENDTRNRQ"""
775
        acct = CCACCTFROM(acctid=acctid)
4✔
776
        stmtrq = CCSTMTENDRQ(ccacctfrom=acct, dtstart=dtstart, dtend=dtend)
4✔
777
        trnuid = self.uuid
4✔
778
        return CCSTMTENDTRNRQ(trnuid=trnuid, ccstmtendrq=stmtrq)
4✔
779

780
    def invstmttrnrq(
4✔
781
        self,
782
        acctid: str,
783
        brokerid: str,
784
        dtstart: datetime.datetime | None = None,
785
        dtend: datetime.datetime | None = None,
786
        inctran: bool = True,
787
        incoo: bool = False,
788
        dtasof: datetime.datetime | None = None,
789
        incpos: bool = True,
790
        incbal: bool = True,
791
    ) -> INVSTMTTRNRQ:
792
        """Construct INVSTMTRQ; package in INVSTMTTRNRQ"""
793
        acct = INVACCTFROM(acctid=acctid, brokerid=brokerid)
4✔
794
        if inctran:
4✔
795
            inctran_: INCTRAN | None = INCTRAN(
4✔
796
                dtstart=dtstart, dtend=dtend, include=inctran
797
            )
798
        else:
799
            inctran_ = None
×
800
        incpos_ = INCPOS(dtasof=dtasof, include=incpos)
4✔
801
        stmtrq = INVSTMTRQ(
4✔
802
            invacctfrom=acct,
803
            inctran=inctran_,
804
            incoo=incoo,
805
            incpos=incpos_,
806
            incbal=incbal,
807
        )
808
        trnuid = self.uuid
4✔
809
        return INVSTMTTRNRQ(trnuid=trnuid, invstmtrq=stmtrq)
4✔
810

811
    def download(
4✔
812
        self,
813
        ofx: OFX,
814
        version: int | None = None,
815
        oldfileuid: str | None = None,
816
        newfileuid: str | None = None,
817
        prettyprint: bool | None = None,
818
        close_elements: bool | None = None,
819
        dryrun: bool = False,
820
        timeout: float | None = None,
821
        url: str | None = None,
822
    ) -> BytesIO:
823
        """
824
        Package complete OFX tree and POST to server.
825

826
        N.B. ``version`` / ``prettyprint`` / ``close_elements`` kwargs are
827
        basically hacks for ``scripts.ofxget.scan_profile()``; ordinarily you
828
        should initialize the ``OFXClient`` with the proper version# and
829
        formatting parameters, rather than overriding the client config here.
830

831
        Optional kwargs:
832
            ``version`` - OFX version to report in header
833
            ``oldfileuid`` - OLDFILEUID to report in header
834
            ``newfileuid`` - NEWFILEUID to report in header
835
            ``prettyprint`` - add newlines between tags and indentation
836
            ``close_elements`` - add markup closing tags to leaf elements
837
            ``dryrun`` - dump serialized request to stdout instead of POSTing
838
            ``timeout`` - HTTP connection timeout (in seconds)
839
        """
840
        request = self.serialize(
4✔
841
            ofx,
842
            version=version,
843
            oldfileuid=oldfileuid,
844
            newfileuid=newfileuid,
845
            prettyprint=prettyprint,
846
            close_elements=close_elements,
847
        )
848
        logger.debug(f"Finished request: {request.decode()}")
4✔
849

850
        if dryrun:
4✔
851
            return BytesIO(request)
4✔
852

853
        if url is None:
4✔
854
            url = self.url
×
855

856
        # NB: we resolve the url opener here instead of in __init__ because the tests
857
        #     mock urlopen after instantiating the OFXClient object
858
        response = self.post_request(url, request, timeout)
4✔
859
        return BytesIO(response)
4✔
860

861
    def post_request(
4✔
862
        self, url: str, serialized_request: bytes, timeout: float | None
863
    ) -> bytes:
864
        """Separated out to facilitate mocking in unit tests."""
865
        if timeout in (None, False):
×
866
            #  timeout = socket._GLOBAL_DEFAULT_TIMEOUT  # type: ignore
867
            timeout = 10.0
×
868

869
        if USE_REQUESTS:
×
870
            logger.info("Using requests lib to post request")
×
871
            with requests.Session() as sess:
×
872
                if self.persist_cookies:
×
873
                    sess.cookies = self.cookiejar  # type: ignore
×
874

875
                # Replace session default headers entirely rather than merging
876
                # via the headers= kwarg. Some FIs (e.g. Amex) validate HTTP
877
                # header ordering and reject requests where User-Agent is not
878
                # the first header — which happens when requests prepends its
879
                # own defaults before ours.
NEW
880
                sess.headers = self.http_headers  # type: ignore
×
881

UNCOV
882
                response = sess.request(
×
883
                    method="POST",
884
                    url=url,
885
                    data=serialized_request,
886
                    timeout=timeout,
887
                )
888
            return response.content
×
889

890
        else:
891
            logger.info("Using urllib to post request")
×
892
            handlers = []
×
893
            if self.persist_cookies:
×
894
                handlers.append(urllib_request.HTTPCookieProcessor(self.cookiejar))
×
895
            opener = urllib_request.build_opener(*handlers)
×
896

897
            req = urllib_request.Request(
×
898
                url, method="POST", data=serialized_request, headers=self.http_headers
899
            )
900

901
            response = opener.open(req, timeout=timeout)
×
902
            return response.read()  # type: ignore
×
903

904
    def serialize(
4✔
905
        self,
906
        ofx: OFX,
907
        version: int | None = None,
908
        oldfileuid: str | None = None,
909
        newfileuid: str | None = None,
910
        prettyprint: bool | None = None,
911
        close_elements: bool | None = None,
912
    ) -> bytes:
913
        """
914
        Transform a ``models.OFX`` instance into bytestring representation
915
        with OFX header prepended.
916

917
        N.B. ``version`` / ``prettyprint`` / ``close_elements`` kwargs are
918
        basically hacks for ``scripts.ofxget.scan_profile()``; ordinarily you
919
        should initialize the ``OFXClient`` with the proper version# and
920
        formatting parameters, rather than overriding the client config here.
921

922
        Optional kwargs:
923
            ``version`` - OFX version to report in header
924
            ``oldfileuid`` - OLDFILEUID to report in header
925
            ``newfileuid`` - NEWFILEUID to report in header
926
            ``prettyprint`` - add newlines between tags and indentation
927
            ``close_elements`` - add markup closing tags to leaf elements
928
        """
929
        if version is None:
4✔
930
            version = self.version
4✔
931
        if prettyprint is None:
4✔
932
            prettyprint = self.prettyprint
4✔
933
        if close_elements is None:
4✔
934
            close_elements = self.close_elements
4✔
935

936
        header = bytes(
4✔
937
            str(
938
                make_header(
939
                    version=version, oldfileuid=oldfileuid, newfileuid=newfileuid
940
                )
941
            ),
942
            "utf_8",
943
        )
944

945
        tree = ofx.to_etree()
4✔
946
        if prettyprint:
4✔
947
            utils.indent(tree)
4✔
948

949
        # Some servers choke on OFXv1 requests including ending tags for
950
        # elements (which are optional per the spec).
951
        if close_elements is False:
4✔
952
            if version >= 200:
4✔
953
                raise ValueError(
4✔
954
                    f"OFX version {version} requires ending tags for elements"
955
                )
956
            body = utils.tostring_unclosed_elements(tree)
4✔
957
        else:
958
            # ``method="html"`` skips the initial XML declaration
959
            body = ET.tostring(tree, encoding="utf_8", method="html")
4✔
960

961
        return header + body
4✔
962

963

964
@singledispatch
4✔
965
def wrap_stmtrq(nt, rqs, client):
4✔
966
    raise ValueError(f"Not a *StmtRq/*StmtEndRq: {nt.__class__.__name__}")
4✔
967

968

969
@wrap_stmtrq.register(StmtRq)
4✔
970
def wrap_stmtrq_stmtrq(nt, rqs, client):
4✔
971
    return (
4✔
972
        BANKMSGSRQV1,
973
        [client.stmttrnrq(**dict(rq._asdict(), bankid=client.bankid)) for rq in rqs],
974
    )
975

976

977
@wrap_stmtrq.register(CcStmtRq)
4✔
978
def wrap_stmtrq_ccstmtrq(nt, rqs, client):
4✔
979
    return (CREDITCARDMSGSRQV1, [client.ccstmttrnrq(**rq._asdict()) for rq in rqs])
4✔
980

981

982
@wrap_stmtrq.register(InvStmtRq)
4✔
983
def wrap_stmtrq_invstmtrq(nt, rqs, client):
4✔
984
    return (
4✔
985
        INVSTMTMSGSRQV1,
986
        [
987
            client.invstmttrnrq(**dict(r._asdict(), brokerid=client.brokerid))
988
            for r in rqs
989
        ],
990
    )
991

992

993
@wrap_stmtrq.register(StmtEndRq)
4✔
994
def wrap_stmtrq_stmtendrq(nt, rqs, client):
4✔
995
    return (
4✔
996
        BANKMSGSRQV1,
997
        [client.stmtendtrnrq(**dict(rq._asdict(), bankid=client.bankid)) for rq in rqs],
998
    )
999

1000

1001
@wrap_stmtrq.register(CcStmtEndRq)
4✔
1002
def wrap_stmtrq_ccstmtendrq(nt, rqs, client):
4✔
1003
    return (CREDITCARDMSGSRQV1, [client.ccstmtendtrnrq(**rq._asdict()) for rq in rqs])
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc