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

csingley / ofxtools / 27386526705

12 Jun 2026 12:30AM UTC coverage: 94.009% (-0.01%) from 94.022%
27386526705

push

github

csingley
Fix mypy errors from __init_subclass__ refactor and OFXHeaderType alias

10 of 10 new or added lines in 2 files covered. (100.0%)

62 existing lines in 2 files now uncovered.

4535 of 4824 relevant lines covered (94.01%)

3.76 hits per line

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

74.0
/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
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
    @classmethod
4✔
292
    def uuid(cls) -> str:
4✔
293
        """Return a new UUID each time called. Wrapper we can mock for testing."""
294
        return str(uuid.uuid4()).upper()
4✔
295

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

311
    def dtclient(self) -> datetime.datetime:
4✔
312
        """
313
        Wrapper we can mock for testing.
314
        (as opposed to datetime.datetime, which is a C extension)
315
        """
316
        return datetime.datetime.now(UTC)
4✔
317

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

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

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

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

374
        trnGroupKey = itemgetter(0)
4✔
375
        trnrqs.sort(key=trnSortKey)
4✔
376

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

389
        msgs = dict(
4✔
390
            msg_args(msgcls, _trnrqs)
391
            for msgcls, _trnrqs in itertools.groupby(trnrqs, key=trnGroupKey)
392
        )
393
        logger.debug(f"Wrapped statement request messages: {msgs}")
4✔
394

395
        signon = self.signon(password)
4✔
396
        ofx = OFX(signonmsgsrqv1=signon, **msgs)
4✔
397

398
        if gen_newfileuid:
4✔
399
            newfileuid = self.uuid()
4✔
400
        else:
UNCOV
401
            newfileuid = None
×
402

403
        return self.download(
4✔
404
            ofx,
405
            newfileuid=newfileuid,
406
            dryrun=dryrun,
407
            timeout=timeout,
408
            url=url,
409
        )
410

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

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

UNCOV
453
        map_stmtendrq_urls(BANKMSGSET, StmtEndRq)
×
454
        map_stmtendrq_urls(CREDITCARDMSGSET, CcStmtEndRq)
×
455

456
        return urls
×
457

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

471
        ofxget.scan_profile() overrides version/prettyprint/close_elements.
472
        """
473
        filename = f"{self.org}-{self.fid}.profrs"
4✔
474
        persistdir = config.DATADIR / "fiprofiles"
4✔
475
        persistpath = persistdir / filename
4✔
476

477
        if persistpath.exists():
4✔
UNCOV
478
            with open(persistpath, "rb") as f:
×
UNCOV
479
                profrs: BytesIO | None = BytesIO(f.read())
×
480

UNCOV
481
            parser = OFXTree()
×
UNCOV
482
            parser.parse(profrs)
×
483
            ofx = parser.convert()
×
484
            proftrnrs = ofx.profmsgsrsv1[0]
×
UNCOV
485
            dtprofup = proftrnrs.profrs.dtprofup
×
486
        else:
487
            persistdir.mkdir(parents=True, exist_ok=True)
4✔
488
            profrs = None
4✔
489
            dtprofup = None
4✔
490

491
        response = self._request_profile(
4✔
492
            dtprofup=dtprofup,
493
            version=version,
494
            gen_newfileuid=gen_newfileuid,
495
            prettyprint=prettyprint,
496
            close_elements=close_elements,
497
            dryrun=dryrun,
498
            timeout=timeout,
499
            url=url,
500
        )
501

UNCOV
502
        if dryrun:
×
UNCOV
503
            return response
×
504

UNCOV
505
        parser = OFXTree()
×
UNCOV
506
        parser.parse(response)
×
507
        ofx = parser.convert()
×
508

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

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

525
            # Cache the updated PROFRS sent by the server
526
            response.seek(0)
×
527
            with open(persistpath, "wb") as f:
×
528
                f.write(response.read())
×
529

530
        # Rewind PROFRS so it can be returned cleanly after having been parsed.
531
        response.seek(0)
×
532

533
        return response
×
534

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

549
        if dtprofup is None:
4✔
550
            dtprofup = datetime.datetime(1990, 1, 1, tzinfo=UTC)
4✔
551
        profrq = PROFRQ(clientrouting="NONE", dtprofup=dtprofup)
4✔
552
        proftrnrq = PROFTRNRQ(trnuid=self.uuid(), profrq=profrq)
4✔
553

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

556
        user = password = AUTH_PLACEHOLDER
4✔
557
        signon = self.signon(password, userid=user)
4✔
558

559
        ofx = OFX(signonmsgsrqv1=signon, profmsgsrqv1=PROFMSGSRQV1(proftrnrq))
4✔
560

561
        if gen_newfileuid:
4✔
562
            newfileuid = self.uuid()
4✔
563
        else:
UNCOV
564
            newfileuid = None
×
565

566
        return self.download(
4✔
567
            ofx,
568
            version=version,
569
            newfileuid=newfileuid,
570
            prettyprint=prettyprint,
571
            close_elements=close_elements,
572
            dryrun=dryrun,
573
            timeout=timeout,
574
            url=url,
575
        )
576

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

600
            # HACK FIXME
601
            # As a simplification, we assume that FIs handle all classes
602
            # of statement request from a single URL.
603
            urls = set(RqCls2url.values())
4✔
604
            assert len(urls) == 1
4✔
605
            url = urls.pop()
4✔
606

607
        logger.info("Creating account info request")
4✔
608
        signon = self.signon(password)
4✔
609

610
        acctinforq = ACCTINFORQ(dtacctup=dtacctup)
4✔
611
        acctinfotrnrq = ACCTINFOTRNRQ(trnuid=self.uuid(), acctinforq=acctinforq)
4✔
612
        msgs = SIGNUPMSGSRQV1(acctinfotrnrq)
4✔
613

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

616
        ofx = OFX(signonmsgsrqv1=signon, signupmsgsrqv1=msgs)
4✔
617

618
        if gen_newfileuid:
4✔
619
            newfileuid = self.uuid()
4✔
620
        else:
UNCOV
621
            newfileuid = None
×
622

623
        return self.download(
4✔
624
            ofx,
625
            newfileuid=newfileuid,
626
            dryrun=dryrun,
627
            timeout=timeout,
628
            url=url,
629
        )
630

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

655
            # HACK FIXME
656
            # As a simplification, we assume that FIs handle all classes
657
            # of statement request from a single URL.
UNCOV
658
            urls = set(RqCls2url.values())
×
UNCOV
659
            assert len(urls) == 1
×
UNCOV
660
            url = urls.pop()
×
661

UNCOV
662
        logger.info("Creating tax 1099 request")
×
663
        signon = self.signon(password)
×
664

665
        rq = TAX1099RQ(*taxyears, recid=recid or None)
×
UNCOV
666
        msgs = TAX1099MSGSRQV1(TAX1099TRNRQ(trnuid=self.uuid(), tax1099rq=rq))
×
667

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

670
        ofx = OFX(signonmsgsrqv1=signon, tax1099msgsrqv1=msgs)
×
671

UNCOV
672
        if gen_newfileuid:
×
673
            newfileuid = self.uuid()
×
674
        else:
675
            newfileuid = None
×
676

677
        return self.download(
×
678
            ofx,
679
            newfileuid=newfileuid,
680
            dryrun=dryrun,
681
            timeout=timeout,
682
            url=url,
683
        )
684

685
    def signon(
4✔
686
        self,
687
        userpass: str,
688
        userid: str | None = None,
689
        sesscookie: str | None = None,
690
    ) -> SIGNONMSGSRQV1:
691
        """Construct SONRQ; package in SIGNONMSGSRQV1"""
692
        if self.org:
4✔
693
            fi: FI | None = FI(org=self.org, fid=self.fid)
4✔
694
        else:
695
            fi = None
4✔
696

697
        if userid is None:
4✔
698
            userid = self.userid
4✔
699

700
        # CLIENTUID was introduced to the spec in OFXv1.0.3
701
        if self.version < 103:
4✔
702
            clientuid = None
4✔
703
        else:
704
            clientuid = self.clientuid
4✔
705

706
        sonrq = SONRQ(
4✔
707
            dtclient=self.dtclient(),
708
            userid=userid,
709
            userpass=userpass,
710
            language=self.language,
711
            fi=fi,
712
            sesscookie=sesscookie,
713
            appid=self.appid,
714
            appver=self.appver,
715
            clientuid=clientuid,
716
        )
717
        return SIGNONMSGSRQV1(sonrq=sonrq)
4✔
718

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

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

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

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

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

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

821
        N.B. ``version`` / ``prettyprint`` / ``close_elements`` kwargs are
822
        basically hacks for ``scripts.ofxget.scan_profile()``; ordinarily you
823
        should initialize the ``OFXClient`` with the proper version# and
824
        formatting parameters, rather than overriding the client config here.
825

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

845
        if dryrun:
4✔
846
            return BytesIO(request)
4✔
847

848
        if url is None:
4✔
UNCOV
849
            url = self.url
×
850

851
        # NB: we resolve the url opener here instead of in __init__ because the tests
852
        #     mock urlopen after instantiating the OFXClient object
853
        response = self.post_request(url, request, timeout)
4✔
854
        return BytesIO(response)
4✔
855

856
    def post_request(
4✔
857
        self, url: str, serialized_request: bytes, timeout: float | None
858
    ) -> bytes:
859
        """Separated out to facilitate mocking in unit tests."""
UNCOV
860
        if timeout in (None, False):
×
861
            #  timeout = socket._GLOBAL_DEFAULT_TIMEOUT  # type: ignore
UNCOV
862
            timeout = 10.0
×
863

UNCOV
864
        if USE_REQUESTS:
×
865
            logger.info("Using requests lib to post request")
×
UNCOV
866
            with requests.Session() as sess:
×
867
                if self.persist_cookies:
×
UNCOV
868
                    sess.cookies = self.cookiejar  # type: ignore
×
869

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

UNCOV
877
                response = sess.request(
×
878
                    method="POST",
879
                    url=url,
880
                    data=serialized_request,
881
                    timeout=timeout,
882
                )
UNCOV
883
            return response.content
×
884

885
        else:
UNCOV
886
            logger.info("Using urllib to post request")
×
UNCOV
887
            handlers = []
×
888
            if self.persist_cookies:
×
UNCOV
889
                handlers.append(urllib_request.HTTPCookieProcessor(self.cookiejar))
×
UNCOV
890
            opener = urllib_request.build_opener(*handlers)
×
891

892
            req = urllib_request.Request(
×
893
                url, method="POST", data=serialized_request, headers=self.http_headers
894
            )
895

UNCOV
896
            response = opener.open(req, timeout=timeout)
×
897
            return response.read()  # type: ignore
×
898

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

912
        N.B. ``version`` / ``prettyprint`` / ``close_elements`` kwargs are
913
        basically hacks for ``scripts.ofxget.scan_profile()``; ordinarily you
914
        should initialize the ``OFXClient`` with the proper version# and
915
        formatting parameters, rather than overriding the client config here.
916

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

931
        header = bytes(
4✔
932
            str(
933
                make_header(
934
                    version=version, oldfileuid=oldfileuid, newfileuid=newfileuid
935
                )
936
            ),
937
            "utf_8",
938
        )
939

940
        tree = ofx.to_etree()
4✔
941
        if prettyprint:
4✔
942
            utils.indent(tree)
4✔
943

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

956
        return header + body
4✔
957

958

959
@singledispatch
4✔
960
def wrap_stmtrq(nt, rqs, client):
4✔
961
    raise ValueError(f"Not a *StmtRq/*StmtEndRq: {nt.__class__.__name__}")
4✔
962

963

964
@wrap_stmtrq.register(StmtRq)
4✔
965
def wrap_stmtrq_stmtrq(nt, rqs, client):
4✔
966
    return (
4✔
967
        BANKMSGSRQV1,
968
        [client.stmttrnrq(**dict(rq._asdict(), bankid=client.bankid)) for rq in rqs],
969
    )
970

971

972
@wrap_stmtrq.register(CcStmtRq)
4✔
973
def wrap_stmtrq_ccstmtrq(nt, rqs, client):
4✔
974
    return (CREDITCARDMSGSRQV1, [client.ccstmttrnrq(**rq._asdict()) for rq in rqs])
4✔
975

976

977
@wrap_stmtrq.register(InvStmtRq)
4✔
978
def wrap_stmtrq_invstmtrq(nt, rqs, client):
4✔
979
    return (
4✔
980
        INVSTMTMSGSRQV1,
981
        [
982
            client.invstmttrnrq(**dict(r._asdict(), brokerid=client.brokerid))
983
            for r in rqs
984
        ],
985
    )
986

987

988
@wrap_stmtrq.register(StmtEndRq)
4✔
989
def wrap_stmtrq_stmtendrq(nt, rqs, client):
4✔
990
    return (
4✔
991
        BANKMSGSRQV1,
992
        [client.stmtendtrnrq(**dict(rq._asdict(), bankid=client.bankid)) for rq in rqs],
993
    )
994

995

996
@wrap_stmtrq.register(CcStmtEndRq)
4✔
997
def wrap_stmtrq_ccstmtendrq(nt, rqs, client):
4✔
998
    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