ofxtools 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. docs/conf.py +76 -0
  2. ofxtools/Client.py +1003 -0
  3. ofxtools/Parser.py +262 -0
  4. ofxtools/Types.py +821 -0
  5. ofxtools/__init__.py +6 -0
  6. ofxtools/config/__init__.py +133 -0
  7. ofxtools/config/fi.cfg +4496 -0
  8. ofxtools/config/ofxget_example.cfg +64 -0
  9. ofxtools/header.py +350 -0
  10. ofxtools/lib.py +82 -0
  11. ofxtools/models/__init__.py +35 -0
  12. ofxtools/models/bank/__init__.py +10 -0
  13. ofxtools/models/bank/interxfer.py +90 -0
  14. ofxtools/models/bank/mail.py +77 -0
  15. ofxtools/models/bank/msgsets.py +324 -0
  16. ofxtools/models/bank/recur.py +185 -0
  17. ofxtools/models/bank/stmt.py +341 -0
  18. ofxtools/models/bank/stmtend.py +157 -0
  19. ofxtools/models/bank/stpchk.py +84 -0
  20. ofxtools/models/bank/sync.py +167 -0
  21. ofxtools/models/bank/wire.py +112 -0
  22. ofxtools/models/bank/xfer.py +118 -0
  23. ofxtools/models/base.py +586 -0
  24. ofxtools/models/billpay/__init__.py +7 -0
  25. ofxtools/models/billpay/common.py +166 -0
  26. ofxtools/models/billpay/list.py +89 -0
  27. ofxtools/models/billpay/mail.py +61 -0
  28. ofxtools/models/billpay/msgsets.py +97 -0
  29. ofxtools/models/billpay/pmt.py +109 -0
  30. ofxtools/models/billpay/recur.py +100 -0
  31. ofxtools/models/billpay/sync.py +61 -0
  32. ofxtools/models/common.py +81 -0
  33. ofxtools/models/email.py +170 -0
  34. ofxtools/models/i18n.py +1227 -0
  35. ofxtools/models/invest/__init__.py +7 -0
  36. ofxtools/models/invest/acct.py +61 -0
  37. ofxtools/models/invest/mail.py +48 -0
  38. ofxtools/models/invest/msgsets.py +126 -0
  39. ofxtools/models/invest/openorders.py +162 -0
  40. ofxtools/models/invest/positions.py +86 -0
  41. ofxtools/models/invest/securities.py +277 -0
  42. ofxtools/models/invest/stmt.py +316 -0
  43. ofxtools/models/invest/transactions.py +370 -0
  44. ofxtools/models/ofx.py +129 -0
  45. ofxtools/models/profile.py +169 -0
  46. ofxtools/models/signon.py +292 -0
  47. ofxtools/models/signup.py +397 -0
  48. ofxtools/models/tax1099.py +538 -0
  49. ofxtools/models/wrapperbases.py +72 -0
  50. ofxtools/py.typed +0 -0
  51. ofxtools/scripts/__init__.py +0 -0
  52. ofxtools/scripts/ofxget.py +1560 -0
  53. ofxtools/utils.py +406 -0
  54. ofxtools-1.0.0.dist-info/METADATA +136 -0
  55. ofxtools-1.0.0.dist-info/RECORD +99 -0
  56. ofxtools-1.0.0.dist-info/WHEEL +5 -0
  57. ofxtools-1.0.0.dist-info/entry_points.txt +2 -0
  58. ofxtools-1.0.0.dist-info/licenses/LICENSE +14 -0
  59. ofxtools-1.0.0.dist-info/top_level.txt +3 -0
  60. tests/base.py +543 -0
  61. tests/test_client.py +875 -0
  62. tests/test_header.py +634 -0
  63. tests/test_models_bank_interxfer.py +342 -0
  64. tests/test_models_bank_mail.py +233 -0
  65. tests/test_models_bank_recur.py +630 -0
  66. tests/test_models_bank_stmt.py +856 -0
  67. tests/test_models_bank_stmtend.py +392 -0
  68. tests/test_models_bank_stpchk.py +254 -0
  69. tests/test_models_bank_sync.py +1133 -0
  70. tests/test_models_bank_wire.py +322 -0
  71. tests/test_models_bank_xfer.py +461 -0
  72. tests/test_models_base.py +796 -0
  73. tests/test_models_billpay_common.py +488 -0
  74. tests/test_models_billpay_list.py +413 -0
  75. tests/test_models_billpay_mail.py +186 -0
  76. tests/test_models_billpay_pmt.py +401 -0
  77. tests/test_models_billpay_recur.py +229 -0
  78. tests/test_models_billpay_sync.py +270 -0
  79. tests/test_models_common.py +169 -0
  80. tests/test_models_email.py +393 -0
  81. tests/test_models_i18n.py +52 -0
  82. tests/test_models_invest.py +1301 -0
  83. tests/test_models_invest_oo.py +425 -0
  84. tests/test_models_invest_transactions.py +1227 -0
  85. tests/test_models_msgsets.py +1604 -0
  86. tests/test_models_ofx.py +240 -0
  87. tests/test_models_profile.py +297 -0
  88. tests/test_models_securities.py +642 -0
  89. tests/test_models_signon.py +507 -0
  90. tests/test_models_signup.py +1208 -0
  91. tests/test_ofxget.py +1376 -0
  92. tests/test_parser.py +591 -0
  93. tests/test_spec_bank.py +1154 -0
  94. tests/test_spec_billpay.py +1671 -0
  95. tests/test_spec_invest.py +887 -0
  96. tests/test_spec_signon.py +631 -0
  97. tests/test_spec_tax1099.py +638 -0
  98. tests/test_types.py +677 -0
  99. tests/test_utils.py +96 -0
ofxtools/Client.py ADDED
@@ -0,0 +1,1003 @@
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__ = [
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
49
+ import http.cookiejar
50
+ import itertools
51
+ import logging
52
+ import urllib.request as urllib_request
53
+ import uuid
54
+ import xml.etree.ElementTree as ET
55
+ from collections.abc import Iterator
56
+ from functools import singledispatch
57
+ from io import BytesIO
58
+ from operator import attrgetter, itemgetter
59
+ from typing import (
60
+ BinaryIO,
61
+ NamedTuple,
62
+ )
63
+
64
+ # 3rd party libs
65
+ try:
66
+ import requests
67
+
68
+ USE_REQUESTS = True
69
+ except ImportError:
70
+ USE_REQUESTS = False
71
+
72
+
73
+ # local imports
74
+ from ofxtools import config, utils
75
+ from ofxtools.header import make_header
76
+ from ofxtools.models import ACCTINFORQ, ACCTINFOTRNRQ
77
+ from ofxtools.models.bank import (
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
97
+ from ofxtools.models.email import EMAILMSGSET
98
+ from ofxtools.models.invest import (
99
+ INCPOS,
100
+ INVACCTFROM,
101
+ INVSTMTMSGSET,
102
+ INVSTMTMSGSRQV1,
103
+ INVSTMTRQ,
104
+ INVSTMTTRNRQ,
105
+ SECLISTMSGSET,
106
+ )
107
+ from ofxtools.models.ofx import OFX
108
+ from ofxtools.models.profile import PROFMSGSET, PROFMSGSRQV1, PROFRQ, PROFTRNRQ
109
+ from ofxtools.models.signon import FI, SIGNONMSGSET, SIGNONMSGSRQV1, SONRQ
110
+ from ofxtools.models.signup import SIGNUPMSGSET, SIGNUPMSGSRQV1
111
+ from ofxtools.models.tax1099 import (
112
+ TAX1099MSGSET,
113
+ TAX1099MSGSRQV1,
114
+ TAX1099RQ,
115
+ TAX1099TRNRQ,
116
+ )
117
+ from ofxtools.Parser import OFXTree
118
+ from ofxtools.utils import UTC, classproperty
119
+
120
+ AUTH_PLACEHOLDER = "{:0<32}".format("anonymous")
121
+
122
+
123
+ logger = logging.getLogger(__name__)
124
+
125
+
126
+ # Statement request data containers
127
+ # Pass instances of these containers as args to OFXClient.request_statement()
128
+ class StmtRq(NamedTuple):
129
+ """
130
+ Parameters of a bank statement request
131
+ """
132
+
133
+ acctid: str | None = None
134
+ accttype: str | None = None
135
+ dtstart: datetime.datetime | None = None
136
+ dtend: datetime.datetime | None = None
137
+ inctran: bool | None = True
138
+
139
+
140
+ class CcStmtRq(NamedTuple):
141
+ """
142
+ Parameters of a credit card statement request
143
+ """
144
+
145
+ acctid: str | None = None
146
+ dtstart: datetime.datetime | None = None
147
+ dtend: datetime.datetime | None = None
148
+ inctran: bool | None = True
149
+
150
+
151
+ class InvStmtRq(NamedTuple):
152
+ """
153
+ Parameters of an investment account statement request
154
+ """
155
+
156
+ acctid: str | None = None
157
+ dtstart: datetime.datetime | None = None
158
+ dtend: datetime.datetime | None = None
159
+ dtasof: datetime.datetime | None = None
160
+ inctran: bool | None = True
161
+ incoo: bool | None = False
162
+ incpos: bool | None = True
163
+ incbal: bool | None = True
164
+
165
+
166
+ class StmtEndRq(NamedTuple):
167
+ """
168
+ Parameters of a bank statement ending balance request
169
+ """
170
+
171
+ acctid: str | None = None
172
+ accttype: str | None = None
173
+ dtstart: datetime.datetime | None = None
174
+ dtend: datetime.datetime | None = None
175
+
176
+
177
+ class CcStmtEndRq(NamedTuple):
178
+ """
179
+ Parameters of a credit card statement ending balance request
180
+ """
181
+
182
+ acctid: str | None = None
183
+ dtstart: datetime.datetime | None = None
184
+ dtend: datetime.datetime | None = None
185
+
186
+
187
+ # TYPE ALIASES
188
+ RequestParam = StmtRq | CcStmtRq | InvStmtRq | StmtEndRq | CcStmtEndRq
189
+ Request = STMTRQ | CCSTMTRQ | INVSTMTRQ | STMTENDRQ | CCSTMTENDRQ
190
+ Message = BANKMSGSRQV1 | CREDITCARDMSGSRQV1 | INVSTMTMSGSRQV1
191
+ MsgsetClass = (
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:
208
+ """
209
+ Basic OFX client to download statement and profile requests.
210
+ """
211
+
212
+ # OFX header/signon defaults
213
+ userid: str = AUTH_PLACEHOLDER
214
+ clientuid: str | None = None
215
+ org: str | None = None
216
+ fid: str | None = None
217
+ version: int = 203
218
+ appid: str = "QWIN"
219
+ appver: str = "2700"
220
+ language: str = "ENG"
221
+ useragent: str = "InetClntApp/3.0"
222
+
223
+ # Formatting defaults
224
+ prettyprint: bool = False
225
+ close_elements: bool = True
226
+
227
+ # Stmt request
228
+ bankid: str | None = None
229
+ brokerid: str | None = None
230
+ persist_cookies: bool = True
231
+
232
+ def __repr__(self) -> str:
233
+ r = (
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__))
240
+ attrs.update(vars(self))
241
+ attrs["cls"] = self.__class__.__name__
242
+ return r.format(**attrs)
243
+
244
+ def __init__(
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
263
+
264
+ for attr in [
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]
281
+ if value is not None:
282
+ setattr(self, attr, value)
283
+
284
+ if (not self.close_elements) and self.version >= 200:
285
+ raise ValueError(f"OFX version {self.version} must close all tags")
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()
290
+
291
+ @classproperty
292
+ @classmethod
293
+ def uuid(cls) -> str:
294
+ """
295
+ Return a new UUID each time called.
296
+
297
+ Wrapper we can mock for testing.
298
+ """
299
+ return str(uuid.uuid4()).upper()
300
+
301
+ @property
302
+ def http_headers(self) -> dict[str, str]:
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:
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)
322
+
323
+ def request_statements(
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:
337
+ url = ""
338
+ logger.info("Dry run for statement request")
339
+ elif skip_profile:
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")
344
+ RqCls2url = self._get_service_urls(
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())
353
+ assert len(urls) == 1
354
+ url = urls.pop()
355
+ logger.info(f"Received service url={url} from OFX profile response")
356
+
357
+ logger.info(f"Creating statement requests for {requests}")
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__")
365
+ groupKey = attrgetter("__class__")
366
+ trnrqs = [
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):
377
+ return pair[0].__name__
378
+
379
+ trnGroupKey = itemgetter(0)
380
+ trnrqs.sort(key=trnSortKey)
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(
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))
391
+ attr_name = msgcls.__name__.lower()
392
+ return (attr_name, msgcls(*trnrqs_))
393
+
394
+ msgs = dict(
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}")
399
+
400
+ signon = self.signon(password)
401
+ ofx = OFX(signonmsgsrqv1=signon, **msgs)
402
+
403
+ if gen_newfileuid:
404
+ newfileuid = self.uuid
405
+ else:
406
+ newfileuid = None
407
+
408
+ return self.download(
409
+ ofx,
410
+ newfileuid=newfileuid,
411
+ dryrun=dryrun,
412
+ timeout=timeout,
413
+ url=url,
414
+ )
415
+
416
+ def _get_service_urls(
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(
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(
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"
479
+ persistdir = config.DATADIR / "fiprofiles"
480
+ persistpath = persistdir / filename
481
+
482
+ if persistpath.exists():
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)
493
+ profrs = None
494
+ dtprofup = None
495
+
496
+ response = self._request_profile(
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(
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")
553
+
554
+ if dtprofup is None:
555
+ dtprofup = datetime.datetime(1990, 1, 1, tzinfo=UTC)
556
+ profrq = PROFRQ(clientrouting="NONE", dtprofup=dtprofup)
557
+ proftrnrq = PROFTRNRQ(trnuid=self.uuid, profrq=profrq)
558
+
559
+ logger.debug(f"Wrapped profile request: {proftrnrq}")
560
+
561
+ user = password = AUTH_PLACEHOLDER
562
+ signon = self.signon(password, userid=user)
563
+
564
+ ofx = OFX(signonmsgsrqv1=signon, profmsgsrqv1=PROFMSGSRQV1(proftrnrq))
565
+
566
+ if gen_newfileuid:
567
+ newfileuid = self.uuid
568
+ else:
569
+ newfileuid = None
570
+
571
+ return self.download(
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(
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:
596
+ url = ""
597
+ elif skip_profile:
598
+ url = self.url
599
+ else:
600
+ RqCls2url = self._get_service_urls(
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())
609
+ assert len(urls) == 1
610
+ url = urls.pop()
611
+
612
+ logger.info("Creating account info request")
613
+ signon = self.signon(password)
614
+
615
+ acctinforq = ACCTINFORQ(dtacctup=dtacctup)
616
+ acctinfotrnrq = ACCTINFOTRNRQ(trnuid=self.uuid, acctinforq=acctinforq)
617
+ msgs = SIGNUPMSGSRQV1(acctinfotrnrq)
618
+
619
+ logger.debug(f"Wrapped account info request messages: {msgs}")
620
+
621
+ ofx = OFX(signonmsgsrqv1=signon, signupmsgsrqv1=msgs)
622
+
623
+ if gen_newfileuid:
624
+ newfileuid = self.uuid
625
+ else:
626
+ newfileuid = None
627
+
628
+ return self.download(
629
+ ofx,
630
+ newfileuid=newfileuid,
631
+ dryrun=dryrun,
632
+ timeout=timeout,
633
+ url=url,
634
+ )
635
+
636
+ def request_tax1099(
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(
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:
698
+ fi: FI | None = FI(org=self.org, fid=self.fid)
699
+ else:
700
+ fi = None
701
+
702
+ if userid is None:
703
+ userid = self.userid
704
+
705
+ # CLIENTUID was introduced to the spec in OFXv1.0.3
706
+ if self.version < 103:
707
+ clientuid = None
708
+ else:
709
+ clientuid = self.clientuid
710
+
711
+ sonrq = SONRQ(
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)
723
+
724
+ def stmttrnrq(
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)
735
+ inctran_ = INCTRAN(dtstart=dtstart, dtend=dtend, include=inctran)
736
+ stmtrq = STMTRQ(bankacctfrom=acct, inctran=inctran_)
737
+ trnuid = self.uuid
738
+ return STMTTRNRQ(trnuid=trnuid, stmtrq=stmtrq)
739
+
740
+ def stmtendtrnrq(
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)
750
+ stmtrq = STMTENDRQ(bankacctfrom=acct, dtstart=dtstart, dtend=dtend)
751
+ trnuid = self.uuid
752
+ return STMTENDTRNRQ(trnuid=trnuid, stmtendrq=stmtrq)
753
+
754
+ def ccstmttrnrq(
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)
763
+ inctran_ = INCTRAN(dtstart=dtstart, dtend=dtend, include=inctran)
764
+ stmtrq = CCSTMTRQ(ccacctfrom=acct, inctran=inctran_)
765
+ trnuid = self.uuid
766
+ return CCSTMTTRNRQ(trnuid=trnuid, ccstmtrq=stmtrq)
767
+
768
+ def ccstmtendtrnrq(
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)
776
+ stmtrq = CCSTMTENDRQ(ccacctfrom=acct, dtstart=dtstart, dtend=dtend)
777
+ trnuid = self.uuid
778
+ return CCSTMTENDTRNRQ(trnuid=trnuid, ccstmtendrq=stmtrq)
779
+
780
+ def invstmttrnrq(
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)
794
+ if inctran:
795
+ inctran_: INCTRAN | None = INCTRAN(
796
+ dtstart=dtstart, dtend=dtend, include=inctran
797
+ )
798
+ else:
799
+ inctran_ = None
800
+ incpos_ = INCPOS(dtasof=dtasof, include=incpos)
801
+ stmtrq = INVSTMTRQ(
802
+ invacctfrom=acct,
803
+ inctran=inctran_,
804
+ incoo=incoo,
805
+ incpos=incpos_,
806
+ incbal=incbal,
807
+ )
808
+ trnuid = self.uuid
809
+ return INVSTMTTRNRQ(trnuid=trnuid, invstmtrq=stmtrq)
810
+
811
+ def download(
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(
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()}")
849
+
850
+ if dryrun:
851
+ return BytesIO(request)
852
+
853
+ if url is None:
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)
859
+ return BytesIO(response)
860
+
861
+ def post_request(
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.
880
+ sess.headers = self.http_headers # type: ignore
881
+
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(
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:
930
+ version = self.version
931
+ if prettyprint is None:
932
+ prettyprint = self.prettyprint
933
+ if close_elements is None:
934
+ close_elements = self.close_elements
935
+
936
+ header = bytes(
937
+ str(
938
+ make_header(
939
+ version=version, oldfileuid=oldfileuid, newfileuid=newfileuid
940
+ )
941
+ ),
942
+ "utf_8",
943
+ )
944
+
945
+ tree = ofx.to_etree()
946
+ if prettyprint:
947
+ utils.indent(tree)
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:
952
+ if version >= 200:
953
+ raise ValueError(
954
+ f"OFX version {version} requires ending tags for elements"
955
+ )
956
+ body = utils.tostring_unclosed_elements(tree)
957
+ else:
958
+ # ``method="html"`` skips the initial XML declaration
959
+ body = ET.tostring(tree, encoding="utf_8", method="html")
960
+
961
+ return header + body
962
+
963
+
964
+ @singledispatch
965
+ def wrap_stmtrq(nt, rqs, client):
966
+ raise ValueError(f"Not a *StmtRq/*StmtEndRq: {nt.__class__.__name__}")
967
+
968
+
969
+ @wrap_stmtrq.register(StmtRq)
970
+ def wrap_stmtrq_stmtrq(nt, rqs, client):
971
+ return (
972
+ BANKMSGSRQV1,
973
+ [client.stmttrnrq(**dict(rq._asdict(), bankid=client.bankid)) for rq in rqs],
974
+ )
975
+
976
+
977
+ @wrap_stmtrq.register(CcStmtRq)
978
+ def wrap_stmtrq_ccstmtrq(nt, rqs, client):
979
+ return (CREDITCARDMSGSRQV1, [client.ccstmttrnrq(**rq._asdict()) for rq in rqs])
980
+
981
+
982
+ @wrap_stmtrq.register(InvStmtRq)
983
+ def wrap_stmtrq_invstmtrq(nt, rqs, client):
984
+ return (
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)
994
+ def wrap_stmtrq_stmtendrq(nt, rqs, client):
995
+ return (
996
+ BANKMSGSRQV1,
997
+ [client.stmtendtrnrq(**dict(rq._asdict(), bankid=client.bankid)) for rq in rqs],
998
+ )
999
+
1000
+
1001
+ @wrap_stmtrq.register(CcStmtEndRq)
1002
+ def wrap_stmtrq_ccstmtendrq(nt, rqs, client):
1003
+ return (CREDITCARDMSGSRQV1, [client.ccstmtendtrnrq(**rq._asdict()) for rq in rqs])