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

csingley / ofxtools / 27392199904

12 Jun 2026 03:14AM UTC coverage: 93.873% (+0.06%) from 93.817%
27392199904

push

github

csingley
Format test_utils.py with ruff

4566 of 4864 relevant lines covered (93.87%)

3.75 hits per line

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

74.7
/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 Iterable, Iterator
4✔
56
from io import BytesIO
4✔
57
from operator import attrgetter, itemgetter
4✔
58
from typing import (
4✔
59
    BinaryIO,
60
    NamedTuple,
61
)
62

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

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

71

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

119
AUTH_PLACEHOLDER = f"{'anonymous':0<32}"
4✔
120

121

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

124

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

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

138

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

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

149

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

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

164

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

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

175

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

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

185

186
# TYPE ALIASES
187
RequestParam = StmtRq | CcStmtRq | InvStmtRq | StmtEndRq | CcStmtEndRq
4✔
188
Request = STMTRQ | CCSTMTRQ | INVSTMTRQ | STMTENDRQ | CCSTMTENDRQ
4✔
189
TrnRequest = STMTTRNRQ | CCSTMTTRNRQ | INVSTMTTRNRQ | STMTENDTRNRQ | CCSTMTENDTRNRQ
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()"""
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
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 _url_from_profile(
4✔
319
        self,
320
        dryrun: bool,
321
        skip_profile: bool,
322
        timeout: float | None,
323
        gen_newfileuid: bool,
324
    ) -> str:
325
        if dryrun:
4✔
326
            return ""
4✔
327
        if skip_profile:
4✔
328
            return self.url
×
329
        RqCls2url = self._get_service_urls(
4✔
330
            timeout=timeout, gen_newfileuid=gen_newfileuid
331
        )
332
        urls = set(RqCls2url.values())
4✔
333
        if len(urls) != 1:
4✔
334
            raise ValueError(f"Expected 1 service URL, got {len(urls)}: {urls}")
×
335
        return urls.pop()
4✔
336

337
    def request_statements(
4✔
338
        self,
339
        password: str,
340
        *requests: RequestParam,
341
        gen_newfileuid: bool = True,
342
        dryrun: bool = False,
343
        timeout: float | None = None,
344
        skip_profile: bool = False,
345
    ) -> BinaryIO:
346
        """
347
        Package and send OFX statement requests
348
        (STMTRQ/CCSTMTRQ/INVSTMTRQ/STMTENDRQ/CCSTMTENDRQ).
349
        """
350
        url = self._url_from_profile(dryrun, skip_profile, timeout, gen_newfileuid)
4✔
351
        if url:
4✔
352
            logger.info(f"Service url={url}")
4✔
353

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

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

376
        trnGroupKey = itemgetter(0)
4✔
377
        trnrqs.sort(key=trnSortKey)
4✔
378

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

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

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

400
        if gen_newfileuid:
4✔
401
            newfileuid = self.uuid()
4✔
402
        else:
403
            newfileuid = None
×
404

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

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

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

455
        map_stmtendrq_urls(BANKMSGSET, StmtEndRq)
×
456
        map_stmtendrq_urls(CREDITCARDMSGSET, CcStmtEndRq)
×
457

458
        return urls
×
459

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

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

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

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

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

504
        if dryrun:
×
505
            return response
×
506

507
        parser = OFXTree()
×
508
        parser.parse(response)
×
509
        ofx = parser.convert()
×
510

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

515
        #  If the client does not have the latest version of the FI profile, the server
516
        #  responds with the profile-response aggregate <PROFRS> in the profile-transaction
517
        #  aggregate <PROFTRNRS>.
518
        proftrnrs = ofx.profmsgsrsv1[0]
×
519
        if proftrnrs.status.code == 1:
×
520
            if profrs is None:
×
521
                raise ValueError("Profile response required but not provided")
×
522
            response = profrs
×
523
        else:
524
            if proftrnrs.status.code != 0:
×
525
                raise ValueError(
×
526
                    f"Profile response status code {proftrnrs.status.code}"
527
                )
528
            dtprofup_server = proftrnrs.profrs.dtprofup
×
529
            if dtprofup is not None and dtprofup > dtprofup_server:
×
530
                raise ValueError(
×
531
                    f"Profile update date {dtprofup} is newer than server's {dtprofup_server}"
532
                )
533

534
            # Cache the updated PROFRS sent by the server
535
            response.seek(0)
×
536
            with open(persistpath, "wb") as f:
×
537
                f.write(response.read())
×
538

539
        # Rewind PROFRS so it can be returned cleanly after having been parsed.
540
        response.seek(0)
×
541

542
        return response
×
543

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

558
        if dtprofup is None:
4✔
559
            dtprofup = datetime.datetime(1990, 1, 1, tzinfo=UTC)
4✔
560
        profrq = PROFRQ(clientrouting="NONE", dtprofup=dtprofup)
4✔
561
        proftrnrq = PROFTRNRQ(trnuid=self.uuid(), profrq=profrq)
4✔
562

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

565
        user = password = AUTH_PLACEHOLDER
4✔
566
        signon = self.signon(password, userid=user)
4✔
567

568
        ofx = OFX(signonmsgsrqv1=signon, profmsgsrqv1=PROFMSGSRQV1(proftrnrq))
4✔
569

570
        if gen_newfileuid:
4✔
571
            newfileuid = self.uuid()
4✔
572
        else:
573
            newfileuid = None
×
574

575
        return self.download(
4✔
576
            ofx,
577
            version=version,
578
            newfileuid=newfileuid,
579
            prettyprint=prettyprint,
580
            close_elements=close_elements,
581
            dryrun=dryrun,
582
            timeout=timeout,
583
            url=url,
584
        )
585

586
    def request_accounts(
4✔
587
        self,
588
        password: str,
589
        dtacctup: datetime.datetime,
590
        dryrun: bool = False,
591
        version: int | None = None,
592
        gen_newfileuid: bool = True,
593
        timeout: float | None = None,
594
        skip_profile: bool = False,
595
    ) -> BinaryIO:
596
        """
597
        Package and send OFX account info requests (ACCTINFORQ)
598
        """
599
        url = self._url_from_profile(dryrun, skip_profile, timeout, gen_newfileuid)
4✔
600
        logger.info("Creating account info request")
4✔
601
        signon = self.signon(password)
4✔
602

603
        acctinforq = ACCTINFORQ(dtacctup=dtacctup)
4✔
604
        acctinfotrnrq = ACCTINFOTRNRQ(trnuid=self.uuid(), acctinforq=acctinforq)
4✔
605
        msgs = SIGNUPMSGSRQV1(acctinfotrnrq)
4✔
606

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

609
        ofx = OFX(signonmsgsrqv1=signon, signupmsgsrqv1=msgs)
4✔
610

611
        if gen_newfileuid:
4✔
612
            newfileuid = self.uuid()
4✔
613
        else:
614
            newfileuid = None
×
615

616
        return self.download(
4✔
617
            ofx,
618
            newfileuid=newfileuid,
619
            dryrun=dryrun,
620
            timeout=timeout,
621
            url=url,
622
        )
623

624
    def request_tax1099(
4✔
625
        self,
626
        password: str,
627
        *taxyears: str,
628
        acctnum: str | None = None,
629
        recid: str | None = None,
630
        gen_newfileuid: bool = True,
631
        dryrun: bool = False,
632
        timeout: float | None = None,
633
        skip_profile: bool = False,
634
    ) -> BinaryIO:
635
        """
636
        Request US federal income tax form 1099 (TAX1099RQ)
637
        """
638
        url = self._url_from_profile(dryrun, skip_profile, timeout, gen_newfileuid)
×
639
        logger.info("Creating tax 1099 request")
×
640
        signon = self.signon(password)
×
641

642
        rq = TAX1099RQ(*taxyears, recid=recid or None)
×
643
        msgs = TAX1099MSGSRQV1(TAX1099TRNRQ(trnuid=self.uuid(), tax1099rq=rq))
×
644

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

647
        ofx = OFX(signonmsgsrqv1=signon, tax1099msgsrqv1=msgs)
×
648

649
        if gen_newfileuid:
×
650
            newfileuid = self.uuid()
×
651
        else:
652
            newfileuid = None
×
653

654
        return self.download(
×
655
            ofx,
656
            newfileuid=newfileuid,
657
            dryrun=dryrun,
658
            timeout=timeout,
659
            url=url,
660
        )
661

662
    def signon(
4✔
663
        self,
664
        userpass: str,
665
        userid: str | None = None,
666
        sesscookie: str | None = None,
667
    ) -> SIGNONMSGSRQV1:
668
        """Construct SONRQ; package in SIGNONMSGSRQV1"""
669
        if self.org:
4✔
670
            fi: FI | None = FI(org=self.org, fid=self.fid)
4✔
671
        else:
672
            fi = None
4✔
673

674
        if userid is None:
4✔
675
            userid = self.userid
4✔
676

677
        # CLIENTUID was introduced to the spec in OFXv1.0.3
678
        if self.version < 103:
4✔
679
            clientuid = None
4✔
680
        else:
681
            clientuid = self.clientuid
4✔
682

683
        sonrq = SONRQ(
4✔
684
            dtclient=self.dtclient(),
685
            userid=userid,
686
            userpass=userpass,
687
            language=self.language,
688
            fi=fi,
689
            sesscookie=sesscookie,
690
            appid=self.appid,
691
            appver=self.appver,
692
            clientuid=clientuid,
693
        )
694
        return SIGNONMSGSRQV1(sonrq=sonrq)
4✔
695

696
    def stmttrnrq(
4✔
697
        self,
698
        bankid: str,
699
        acctid: str,
700
        accttype: str,
701
        dtstart: datetime.datetime | None = None,
702
        dtend: datetime.datetime | None = None,
703
        inctran: bool = True,
704
    ) -> STMTTRNRQ:
705
        """Construct STMTRQ; package in STMTTRNRQ"""
706
        acct = BANKACCTFROM(bankid=bankid, acctid=acctid, accttype=accttype)
4✔
707
        inctran_ = INCTRAN(dtstart=dtstart, dtend=dtend, include=inctran)
4✔
708
        stmtrq = STMTRQ(bankacctfrom=acct, inctran=inctran_)
4✔
709
        trnuid = self.uuid()
4✔
710
        return STMTTRNRQ(trnuid=trnuid, stmtrq=stmtrq)
4✔
711

712
    def stmtendtrnrq(
4✔
713
        self,
714
        bankid: str,
715
        acctid: str,
716
        accttype: str,
717
        dtstart: datetime.datetime | None = None,
718
        dtend: datetime.datetime | None = None,
719
    ) -> STMTENDTRNRQ:
720
        """Construct STMTENDRQ; package in STMTENDTRNRQ"""
721
        acct = BANKACCTFROM(bankid=bankid, acctid=acctid, accttype=accttype)
4✔
722
        stmtrq = STMTENDRQ(bankacctfrom=acct, dtstart=dtstart, dtend=dtend)
4✔
723
        trnuid = self.uuid()
4✔
724
        return STMTENDTRNRQ(trnuid=trnuid, stmtendrq=stmtrq)
4✔
725

726
    def ccstmttrnrq(
4✔
727
        self,
728
        acctid: str,
729
        dtstart: datetime.datetime | None = None,
730
        dtend: datetime.datetime | None = None,
731
        inctran: bool = True,
732
    ) -> CCSTMTTRNRQ:
733
        """Construct CCSTMTRQ; package in CCSTMTTRNRQ"""
734
        acct = CCACCTFROM(acctid=acctid)
4✔
735
        inctran_ = INCTRAN(dtstart=dtstart, dtend=dtend, include=inctran)
4✔
736
        stmtrq = CCSTMTRQ(ccacctfrom=acct, inctran=inctran_)
4✔
737
        trnuid = self.uuid()
4✔
738
        return CCSTMTTRNRQ(trnuid=trnuid, ccstmtrq=stmtrq)
4✔
739

740
    def ccstmtendtrnrq(
4✔
741
        self,
742
        acctid: str,
743
        dtstart: datetime.datetime | None = None,
744
        dtend: datetime.datetime | None = None,
745
    ) -> CCSTMTENDTRNRQ:
746
        """Construct CCSTMTENDRQ; package in CCSTMTENDTRNRQ"""
747
        acct = CCACCTFROM(acctid=acctid)
4✔
748
        stmtrq = CCSTMTENDRQ(ccacctfrom=acct, dtstart=dtstart, dtend=dtend)
4✔
749
        trnuid = self.uuid()
4✔
750
        return CCSTMTENDTRNRQ(trnuid=trnuid, ccstmtendrq=stmtrq)
4✔
751

752
    def invstmttrnrq(
4✔
753
        self,
754
        acctid: str,
755
        brokerid: str,
756
        dtstart: datetime.datetime | None = None,
757
        dtend: datetime.datetime | None = None,
758
        inctran: bool = True,
759
        incoo: bool = False,
760
        dtasof: datetime.datetime | None = None,
761
        incpos: bool = True,
762
        incbal: bool = True,
763
    ) -> INVSTMTTRNRQ:
764
        """Construct INVSTMTRQ; package in INVSTMTTRNRQ"""
765
        acct = INVACCTFROM(acctid=acctid, brokerid=brokerid)
4✔
766
        if inctran:
4✔
767
            inctran_: INCTRAN | None = INCTRAN(
4✔
768
                dtstart=dtstart, dtend=dtend, include=inctran
769
            )
770
        else:
771
            inctran_ = None
×
772
        incpos_ = INCPOS(dtasof=dtasof, include=incpos)
4✔
773
        stmtrq = INVSTMTRQ(
4✔
774
            invacctfrom=acct,
775
            inctran=inctran_,
776
            incoo=incoo,
777
            incpos=incpos_,
778
            incbal=incbal,
779
        )
780
        trnuid = self.uuid()
4✔
781
        return INVSTMTTRNRQ(trnuid=trnuid, invstmtrq=stmtrq)
4✔
782

783
    def download(
4✔
784
        self,
785
        ofx: OFX,
786
        version: int | None = None,
787
        oldfileuid: str | None = None,
788
        newfileuid: str | None = None,
789
        prettyprint: bool | None = None,
790
        close_elements: bool | None = None,
791
        dryrun: bool = False,
792
        timeout: float | None = None,
793
        url: str | None = None,
794
    ) -> BytesIO:
795
        """
796
        Package complete OFX tree and POST to server.
797

798
        N.B. ``version`` / ``prettyprint`` / ``close_elements`` kwargs are
799
        basically hacks for ``scripts.ofxget.scan_profile()``; ordinarily you
800
        should initialize the ``OFXClient`` with the proper version# and
801
        formatting parameters, rather than overriding the client config here.
802

803
        Optional kwargs:
804
            ``version`` - OFX version to report in header
805
            ``oldfileuid`` - OLDFILEUID to report in header
806
            ``newfileuid`` - NEWFILEUID to report in header
807
            ``prettyprint`` - add newlines between tags and indentation
808
            ``close_elements`` - add markup closing tags to leaf elements
809
            ``dryrun`` - dump serialized request to stdout instead of POSTing
810
            ``timeout`` - HTTP connection timeout (in seconds)
811
        """
812
        request = self.serialize(
4✔
813
            ofx,
814
            version=version,
815
            oldfileuid=oldfileuid,
816
            newfileuid=newfileuid,
817
            prettyprint=prettyprint,
818
            close_elements=close_elements,
819
        )
820
        logger.debug(f"Finished request: {request.decode()}")
4✔
821

822
        if dryrun:
4✔
823
            return BytesIO(request)
4✔
824

825
        if url is None:
4✔
826
            url = self.url
×
827

828
        # NB: we resolve the url opener here instead of in __init__ because the tests
829
        #     mock urlopen after instantiating the OFXClient object
830
        response = self.post_request(url, request, timeout)
4✔
831
        return BytesIO(response)
4✔
832

833
    def post_request(
4✔
834
        self, url: str, serialized_request: bytes, timeout: float | None
835
    ) -> bytes:
836
        """Separated out to facilitate mocking in unit tests."""
837
        if timeout in (None, False):
×
838
            timeout = 10.0
×
839

840
        if USE_REQUESTS:
×
841
            logger.info("Using requests lib to post request")
×
842
            with requests.Session() as sess:
×
843
                if self.persist_cookies:
×
844
                    sess.cookies = self.cookiejar  # type: ignore[assignment]
×
845

846
                # Replace session default headers entirely rather than merging
847
                # via the headers= kwarg. Some FIs (e.g. Amex) validate HTTP
848
                # header ordering and reject requests where User-Agent is not
849
                # the first header — which happens when requests prepends its
850
                # own defaults before ours.
851
                sess.headers = self.http_headers  # type: ignore[assignment]
×
852

853
                response = sess.request(
×
854
                    method="POST",
855
                    url=url,
856
                    data=serialized_request,
857
                    timeout=timeout,
858
                )
859
            return response.content
×
860

861
        else:
862
            logger.info("Using urllib to post request")
×
863
            handlers = []
×
864
            if self.persist_cookies:
×
865
                handlers.append(urllib_request.HTTPCookieProcessor(self.cookiejar))
×
866
            opener = urllib_request.build_opener(*handlers)
×
867

868
            req = urllib_request.Request(
×
869
                url, method="POST", data=serialized_request, headers=self.http_headers
870
            )
871

872
            response = opener.open(req, timeout=timeout)
×
873
            return response.read()
×
874

875
    def serialize(
4✔
876
        self,
877
        ofx: OFX,
878
        version: int | None = None,
879
        oldfileuid: str | None = None,
880
        newfileuid: str | None = None,
881
        prettyprint: bool | None = None,
882
        close_elements: bool | None = None,
883
    ) -> bytes:
884
        """
885
        Transform a ``models.OFX`` instance into bytestring representation
886
        with OFX header prepended.
887

888
        N.B. ``version`` / ``prettyprint`` / ``close_elements`` kwargs are
889
        basically hacks for ``scripts.ofxget.scan_profile()``; ordinarily you
890
        should initialize the ``OFXClient`` with the proper version# and
891
        formatting parameters, rather than overriding the client config here.
892

893
        Optional kwargs:
894
            ``version`` - OFX version to report in header
895
            ``oldfileuid`` - OLDFILEUID to report in header
896
            ``newfileuid`` - NEWFILEUID to report in header
897
            ``prettyprint`` - add newlines between tags and indentation
898
            ``close_elements`` - add markup closing tags to leaf elements
899
        """
900
        if version is None:
4✔
901
            version = self.version
4✔
902
        if prettyprint is None:
4✔
903
            prettyprint = self.prettyprint
4✔
904
        if close_elements is None:
4✔
905
            close_elements = self.close_elements
4✔
906

907
        header = bytes(
4✔
908
            str(
909
                make_header(
910
                    version=version, oldfileuid=oldfileuid, newfileuid=newfileuid
911
                )
912
            ),
913
            "utf_8",
914
        )
915

916
        tree = ofx.to_etree()
4✔
917
        if prettyprint:
4✔
918
            utils.indent(tree)
4✔
919

920
        # Some servers choke on OFXv1 requests including ending tags for
921
        # elements (which are optional per the spec).
922
        if not close_elements:
4✔
923
            if version >= 200:
4✔
924
                raise ValueError(
4✔
925
                    f"OFX version {version} requires ending tags for elements"
926
                )
927
            body = utils.tostring_unclosed_elements(tree)
4✔
928
        else:
929
            # ``method="html"`` skips the initial XML declaration
930
            body = ET.tostring(tree, encoding="utf_8", method="html")
4✔
931

932
        return header + body
4✔
933

934

935
def wrap_stmtrq(
4✔
936
    nt: RequestParam, rqs: Iterable[RequestParam], client: "OFXClient"
937
) -> tuple[type[Message], list[TrnRequest]]:
938
    match nt:
4✔
939
        case StmtRq():
4✔
940
            return (
4✔
941
                BANKMSGSRQV1,
942
                [
943
                    client.stmttrnrq(**dict(rq._asdict(), bankid=client.bankid))
944
                    for rq in rqs
945
                ],
946
            )
947
        case CcStmtRq():
4✔
948
            return (
4✔
949
                CREDITCARDMSGSRQV1,
950
                [client.ccstmttrnrq(**rq._asdict()) for rq in rqs],
951
            )
952
        case InvStmtRq():
4✔
953
            return (
4✔
954
                INVSTMTMSGSRQV1,
955
                [
956
                    client.invstmttrnrq(**dict(r._asdict(), brokerid=client.brokerid))
957
                    for r in rqs
958
                ],
959
            )
960
        case StmtEndRq():
4✔
961
            return (
4✔
962
                BANKMSGSRQV1,
963
                [
964
                    client.stmtendtrnrq(**dict(rq._asdict(), bankid=client.bankid))
965
                    for rq in rqs
966
                ],
967
            )
968
        case CcStmtEndRq():
4✔
969
            return (
4✔
970
                CREDITCARDMSGSRQV1,
971
                [client.ccstmtendtrnrq(**rq._asdict()) for rq in rqs],
972
            )
973
        case _:
4✔
974
            raise ValueError(f"Not a *StmtRq/*StmtEndRq: {nt.__class__.__name__}")
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