├── .flake8 ├── .git-blame-ignore-revs ├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── .hgignore ├── CHANGELOG.md ├── CONTRIBUTING ├── LICENSE ├── README.rst ├── examples └── plugins │ ├── aib.py │ ├── firstdirect.py │ ├── n26.py │ ├── revolut.py │ └── wise.py ├── fix_ofxid.py ├── fixtures ├── accented_characters_latin1.ofx ├── amazon.csv ├── amazon2.csv ├── apostrophe.ofx ├── checking-dynamic-account.lgr ├── checking-partial.lgr ├── checking-payee-match.ofx ├── checking.lgr ├── checking.ofx ├── checking_order.ofx ├── comments.ofx ├── cusip.ofx ├── empty.lgr ├── fidelity-one-dtsettle.ofx ├── fidelity.ofx ├── fidelity_fee.ofx ├── income.ofx ├── investment_401k.ofx ├── mint.csv ├── multiple.lgr ├── no-institution.ofx ├── ofxclient.ini ├── paypal.csv ├── paypal.lgr ├── paypal_alternate.csv └── venmo.csv ├── ledgerautosync ├── __init__.py ├── cli.py ├── converter.py ├── ledgerwrap.py ├── plugins.py └── sync.py ├── poetry.lock ├── pyproject.toml ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── test_cli.py ├── test_converter.py ├── test_ledger.py ├── test_ofx_formatter.py ├── test_sync.py └── test_weird_ofx.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=100 -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 039ee5e17ead3fc2f887351a22d98a1b43b79448 -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v3 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | sudo apt install -y ledger hledger 25 | python -m pip install --upgrade pip 26 | python -m pip install --upgrade poetry 27 | python -m pip install flake8 pytest 28 | poetry install --with=dev 29 | - name: Lint with black 30 | run: | 31 | poetry run black --check . 32 | - name: Test with pytest 33 | run: | 34 | poetry run python -m pytest 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | *.pyc 3 | *.egg 4 | .eggs 5 | build/ 6 | dist/ 7 | ledger_autosync.egg-info 8 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .*pyc 3 | build/ 4 | dist/ 5 | ledger_autosync.egg-info -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | - Migrated to python3 3 | - Add --reverse option to print txns in reverse 4 | - Better error handling on OFX server error 5 | - Add --shorten-account, --hardcode-account options 6 | improve user privacy 7 | - Add --payee-format argument 8 | - Move ofxid metadata to correct posting 9 | - Misc bugfixes 10 | 11 | ## Version 0.3.5 12 | - Disable default usage of python bindings 13 | - Change to using 65 spaces to align txns (per ledger-mode) 14 | - Improve .ledgerrc parsing 15 | - Better error messages 16 | - Add basic plugin system 17 | - Misc bugfixes 18 | 19 | ## Version 0.3.4 20 | - Packaging fixes 21 | 22 | ## Version 0.3.3 23 | - Fix problem building on ubuntu trusty 24 | 25 | ## Version 0.3.2 26 | - Fix problem with certain characters in transaction id 27 | 28 | ## Version 0.3.0 29 | - Support CSV files (Mint, Paypal and Amazon flavors) 30 | - Uses ticker symbol by for currencies, not CUSIP (You will need to change 31 | previous transactions which used the CUSIP so they work with new transactions) 32 | - Dividends will now be formatted correctly 33 | - Fuzzy payee matching 34 | 35 | ## Version 0.2.5 36 | - Support advanced investment transactions 37 | - Upgrade to ofxparse 0.15 38 | 39 | ## Version 0.2.4 40 | - Add `--unknown-account` argument 41 | 42 | ## Version 0.2.2 43 | - Better support for strange OFX, payee characters 44 | 45 | ## Version 0.2.1 46 | - Support ledger python API 47 | 48 | ## Version 0.2.0 49 | - Improved hledger support 50 | 51 | ## Version 0.1.4 52 | - Balance assertions 53 | - Initial balance 54 | 55 | ## Version 0.1.3 56 | - Reverse transactions 57 | 58 | ## Version 0.1.0 59 | - Initial release 60 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | Pull request for bugs and feature enhancements are welcome at: 2 | 3 | https://github.com/egh/ledger-autosync 4 | 5 | If you are considering a large change, please open an issue first to 6 | discuss any possible implications or best practices. 7 | 8 | If writing a bug fix, please include a regression test that would fail 9 | without the fix. If writing a new feature, testing that it works as 10 | expected is very much appreciated. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ledger-autosync 2 | =============== 3 | 4 | .. image:: https://travis-ci.org/egh/ledger-autosync.svg?branch=master 5 | :target: https://travis-ci.org/egh/ledger-autosync 6 | 7 | ledger-autosync is a program to pull down transactions from your bank 8 | and create `ledger `__ transactions for them. 9 | It is designed to only create transactions that are not already 10 | present in your ledger files (that is, it will deduplicate 11 | transactions). This should make it comparable to some of the automated 12 | synchronization features available in products like GnuCash, Mint, 13 | etc. In fact, ledger-autosync performs OFX import and synchronization 14 | better than all the alternatives I have seen. 15 | 16 | News 17 | ---- 18 | 19 | v1.0.0 20 | ~~~~~~ 21 | 22 | Versions of ledger-autosync before 1.0.0 printed the ofxid in a 23 | slightly incorrect position. This should not effect usage of the 24 | program, but if you would like to correct the error, see below for 25 | more details. 26 | 27 | Features 28 | -------- 29 | 30 | - supports `ledger `__ 3 and 31 | `hledger `__ 32 | - like ledger, ledger-autosync will never modify your files directly 33 | - interactive banking setup via 34 | `ofxclient `__ 35 | - multiple banks and accounts 36 | - support for non-US currencies 37 | - support for 401k and investment accounts 38 | 39 | - tracks investments by share, not dollar value 40 | - support for complex transaction types, including transfers, buys, 41 | sells, etc. 42 | 43 | - import of downloaded OFX files, for banks not supporting automatic 44 | download 45 | - import of downloaded CSV files from Paypal, Simple, Amazon and Mint 46 | - any CSV file can be supported via plugins 47 | 48 | Platforms 49 | --------- 50 | 51 | ledger-autosync is developed on Linux with ledger 3 and python 3; it 52 | has been tested on Windows (although it will run slower) and should 53 | run on OS X. It requires ledger 3 or hledger, but it should run faster 54 | with ledger, because it will not need to start a command to check 55 | every transaction. 56 | 57 | 58 | Quickstart 59 | ---------- 60 | 61 | Installation 62 | ~~~~~~~~~~~~ 63 | 64 | If you are on Debian or Ubuntu, an (older) version of ledger-autosync 65 | should be available for installation. Try: 66 | 67 | :: 68 | 69 | $ sudo apt-get install ledger-autosync 70 | 71 | If you use pip, you can install the latest released version: 72 | 73 | :: 74 | 75 | $ pip install ledger-autosync 76 | 77 | You can also install from source, if you have downloaded the source: 78 | 79 | :: 80 | 81 | $ python setup.py install 82 | 83 | You may need to install the following libraries (on debian/ubuntu): 84 | 85 | :: 86 | 87 | $ sudo apt-get install libffi-dev libpython-dev libssl-dev libxml2-dev python-pip libxslt-dev 88 | 89 | Running 90 | ~~~~~~~ 91 | 92 | Once you have ledger-autosync installed, you can download an OFX file 93 | from your bank and run ledger-autosync against it: 94 | 95 | :: 96 | 97 | $ ledger-autosync download.ofx 98 | 99 | This should print a number of transactions to stdout. If you add these 100 | transactions to your default ledger file (whatever is read when you 101 | run ``ledger`` without arguments), you should find that if you run 102 | ledger-autosync again, it should print no transactions. This is 103 | because of the deduplicating feature: only new transactions will be 104 | printed for insertion into your ledger files. 105 | 106 | Using the ofx protocol for automatic download 107 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 108 | 109 | ledger-autosync also supports using the OFX protocol to automatically 110 | connect to banks and download data. You can use the ofxclient program 111 | (which should have been installed with ledger-autosync) to set up 112 | banking: 113 | 114 | :: 115 | 116 | $ ofxclient 117 | 118 | When you have added your institution, quit ofxclient. 119 | 120 | (At least one user has reported being signed up for a pay service by 121 | setting up OFX direct connect. Although this seems unusual, please be 122 | aware of this.) 123 | 124 | Edit the generated ``~/ofxclient.ini`` file. Change the 125 | ``description`` field of your accounts to the name used in ledger. 126 | Optionally, move the ``~/ofxclient.ini`` file to your ``~/.config`` 127 | directory. 128 | 129 | Run: 130 | 131 | :: 132 | 133 | ledger-autosync 134 | 135 | This will download a maximum of 90 days previous activity from your 136 | accounts. The output will be in ledger format and printed to stdout. 137 | Add this output to your ledger file. When that is done, you can call: 138 | 139 | :: 140 | 141 | ledger-autosync 142 | 143 | again, and it should print nothing to stdout, because you already have 144 | those transactions in your ledger. 145 | 146 | How it works 147 | ------------ 148 | 149 | ledger-autosync stores a unique identifier as metadata with each 150 | transaction. (For OFX files, this is a unique ID provided by your 151 | institution for each transaction.) When syncing with your bank, it 152 | will check if the transaction exists by running the ledger or hledger 153 | command. If the transaction exists, it does nothing. If it does not 154 | exist, the transaction is printed to stdout. 155 | 156 | Payee matching 157 | ~~~~~~~~~~~~~~ 158 | 159 | When generating transactions, ledger-autosync attempts to match 160 | previous payees to determine the appropriate accounts. For instance, 161 | if a previous payee was ``Grocery store``, and one posting was to the 162 | account ``Expenses:Food``, ledger-autosync will use that account again. 163 | If ledger-autosync can’t find a matching payee, it will use 164 | ``Expenses:Misc`` or the value of the ``--unknown-account`` argument. 165 | 166 | The matching is not very sophisticated: it only does exact matching on 167 | the payee, and it uses transaction with the matching payee. It is also 168 | not currently working with CSV files. 169 | 170 | If you prefer to modify the payees to make them shorter than what is 171 | generated by ledger-autosync, you can use the ``AutosyncPayee`` metadata 172 | field to indicate to ledger-autosync that it should use the longer 173 | form for matching. For instance: 174 | 175 | :: 176 | 177 | 2011/03/31 Grocery 178 | ; AutosyncPayee: Payment to Grocery store #12345 CALIFORNIA 179 | Assets:Bank -$0.01 180 | Expenses:Food $0.01 181 | 182 | would indicate to ledger-autosync that any payee with the name 183 | ``Payment to Grocery store #12345 CALIFORNIA`` should use the 184 | ``Expenses:Food`` account. 185 | 186 | ofxid/csvid metadata tag 187 | ~~~~~~~~~~~~~~~~~~~~~~~~ 188 | 189 | ledger-autosync stores a metatag with every posting that it outputs to 190 | support deduplication. This metadata tag is either ``ofxid`` (for OFX 191 | imports) or ``csvid`` for CSV imports. 192 | 193 | Pre-1.0.0 versions of ledger-autosync put this metadata tag in a 194 | slightly incorrect place, associating the metadata tag with the 195 | transaction itself, and not simply one posting. This should not effect 196 | the usage of ledger-autosync, but if you would like to correct your 197 | ledger files, there is a small python script ``fix_ofxid.py`` included 198 | with ledger-autosync. It can be run as: 199 | 200 | :: 201 | 202 | python fix_ofxid.py 203 | 204 | and will print a corrected file to stdout. 205 | 206 | Syncing a CSV file 207 | ------------------ 208 | 209 | If you have a CSV file, you may also be able to import it using a 210 | recent (installed via source) version of ledger-autosync. 211 | ledger-autosync can currently process CSV files as provided by Paypal, 212 | Amazon, or Mint. You can process the CSV file as follows: 213 | 214 | :: 215 | 216 | ledger-autosync /path/to/file.csv -a Assets:Paypal 217 | 218 | With Amazon and Paypal CSV files, each row includes a unique 219 | identifier, so ledger-autosync will be able to deduplicate against any 220 | previously imported entries in your ledger files. 221 | 222 | With Mint, a unique identifier based on the data in the row is 223 | generated and stored. If future downloads contain identical rows, they 224 | will be deduplicated. This method is probably not as robust as a 225 | method based on unique ids, but Mint does not provide a unique id, and 226 | it should be better than nothing. It is likely to generate false 227 | negatives: transactions that seem new, but are in fact old. It will 228 | not generate false positives: transactions that are not generated 229 | because they seem old. 230 | 231 | If you are a developer, you should fine it easy enough to add a new 232 | CSV format to ledger-autosync. See, for example, the ``MintConverter`` 233 | class in the ``ledgerautosync/converter.py`` file in this repository. 234 | See below for how to add these as plugins. 235 | 236 | Assertions 237 | ---------- 238 | 239 | If you supply the ``--assertions`` flag, ledger-autosync will also 240 | print out valid ledger assertions based on your bank balances at the 241 | time of the sync. These otherwise empty transactions tell ledger that 242 | your balance *should* be something at a given time, and if not, ledger 243 | will fail with an error. 244 | 245 | 401k and investment accounts 246 | ---------------------------- 247 | 248 | If you have a 401k account, ledger-autosync can help you to track the 249 | state of it. You will need OFX files (or an OFX protocol connection as 250 | set up by ofxclient) provided by your 401k. 251 | 252 | In general, your 401k account will consist of buy transactions, 253 | transfers and reinvestments. The type will be printed in the payee 254 | line after a colon (``:``) 255 | 256 | The buy transactions are your contributions to the 401k. These will be 257 | printed as follows: 258 | 259 | :: 260 | 261 | 2016/01/29 401k: buymf 262 | Assets:Retirement:401k 1.12345 FOOBAR @ $123.123456 263 | ; ofxid: 1234 264 | Income:Salary -$138.32 265 | 266 | This means that you bought (contributed) $138.32 worth of FOOBAR (your 267 | investment fund) at the price of $123.123456. The money to buy the 268 | investment came from your income. In ledger-autosync, the 269 | ``Assets:Retirement:401k`` account is the one specified using the 270 | ``--account`` command line, or configured in your ``ofxclient.ini``. 271 | The ``Income:Salary`` is specified by the ``--unknown-account`` 272 | option. 273 | 274 | If the transaction is a “transfer” transaction, this usually means 275 | either a fee or a change in your investment option: 276 | 277 | :: 278 | 279 | 2014/06/30 401k: transfer: out 280 | Assets:Retirement:401k -1.61374 FOOBAR @ $123.123456 281 | ; ofxid: 1234 282 | Transfer $198.69 283 | 284 | You will need to examine your statements to determine if this was a 285 | fee or a real transfer back into your 401k. 286 | 287 | Another type of transaction is a “reinvest” transaction: 288 | 289 | :: 290 | 291 | 2014/06/30 401k: reinvest 292 | Assets:Retirement:401k 0.060702 FOOBAR @ $123.123456 293 | ; ofxid: 1234 294 | Income:Interest -$7.47 295 | 296 | This probably indicates a reinvestment of dividends. ledger-autosync 297 | will print ``Income:Interest`` as the other account. 298 | 299 | resync 300 | ------ 301 | 302 | By default, ledger-autosync will process transactions backwards, and 303 | stop when it sees a transaction that is already in ledger. To force it 304 | to process all transactions up to the ``--max`` days back in time 305 | (default: 90), use the ``--resync`` option. This can be useful when 306 | increasing the ``--max`` option. For instance, if you previously 307 | synchronized 90 days and now want to get 180 days of transactions, 308 | ledger-autosync would stop before going back to 180 days without the 309 | ``--resync`` option. 310 | 311 | payee format 312 | ------------ 313 | 314 | By default, ledger-autosync attempts to generate a decent payee line 315 | (the information that follows the date in a ledger transaction). 316 | Unfortunately, because of differences in preference and in the format 317 | of OFX files, it is not always possible to generate the user’s 318 | preferred payee format. ledger-autosync supports a ``payee-format`` 319 | option that can be used to generate your preferred payee line. This 320 | option is of the format ``Text {memo}``, where ``memo`` is a 321 | substitution based on the value of the transaction. Available 322 | substitutions are ``memo``, ``payee``, ``txntype``, ``account`` and 323 | ``tferaction``. For example: 324 | 325 | :: 326 | 327 | $ ledger-autosync --payee-format "Memo: {memo}" 328 | 2011/03/31 Memo: DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 329 | 330 | This option is also available for CSV conversion. For CSV files, you 331 | can substitution any of the values of the rows in the CSV file by 332 | name. For instance, for Paypal files: 333 | 334 | :: 335 | 336 | $ ledger-autosync --payee-format "{Name} ({To Email Address})" -a Paypal paypal.csv 337 | 2016/06/04 Jane Doe (someone@example.net) 338 | 339 | python bindings 340 | --------------- 341 | 342 | If the ledger python bindings are available, ledger-autosync can use 343 | them if you pass in the ``--python`` argument. Note, however, they can 344 | be buggy, which is why they are disabled by default 345 | 346 | Plugin support 347 | -------------- 348 | 349 | ledger-autosync has support for plugins. By placing python files a 350 | directory named ``~/.config/ledger-autosync/plugins/`` it should be 351 | possible to automatically load python files from there. You may place 352 | ``CsvCconverter`` subclasses here, which will be selected based on the 353 | columns in the CSV file being parsed and the FIELDSET of the CSV 354 | converters. You may also place a single ``OfxConverter`` in the plugin 355 | directory, which will be used in place of the stock ``OfxConverter``. 356 | 357 | Below is an example CSV converter, starting with the input CSV file: 358 | 359 | :: 360 | 361 | "Date","Name","Amount","Balance" 362 | "11/30/2016","Dividend","$1.06","$1,000“ 363 | 364 | The following converter in the file ``~/.config/ledger-autosync/plugins/my.py``: 365 | 366 | :: 367 | 368 | from ledgerautosync.converter import CsvConverter, Posting, Transaction, Amount 369 | import datetime 370 | import re 371 | 372 | class SomeConverter(CsvConverter): 373 | FIELDSET = set(["Date", "Name", "Amount", "Balance"]) 374 | 375 | def __init__(self, *args, **kwargs): 376 | super(SomeConverter, self).__init__(*args, **kwargs) 377 | 378 | def convert(self, row): 379 | md = re.match(r"^(\(?)\$([0-9,\.]+)", row['Amount']) 380 | amount = md.group(2).replace(",", "") 381 | if md.group(1) == "(": 382 | reverse = True 383 | else: 384 | reverse = False 385 | if reverse: 386 | account = 'expenses' 387 | else: 388 | account = 'income' 389 | return Transaction( 390 | date=datetime.datetime.strptime(row['Date'], "%m/%d/%Y"), 391 | payee=row['Name'], 392 | postings=[Posting(self.name, Amount(amount, '$', reverse=reverse)), 393 | Posting(account, Amount(amount, '$', reverse=not(reverse)))]) 394 | 395 | Running ``ledger-autosync file.csv -a assets:bank`` will generate: 396 | 397 | :: 398 | 399 | 2016/11/30 Dividend 400 | assets:bank $1.06 401 | income -$1.06 402 | 403 | To ignore a row you can return ``None`` from your ``convert`` method. 404 | ledger-autosync will produce no output for that row. 405 | 406 | For more examples, see 407 | https://gitlab.com/egh/ledger-autosync/blob/master/ledgerautosync/converter.py#L421 408 | or the `example plugins directory `_. 409 | 410 | If you develop a converter that you think will be generally 411 | useful, please consider submitting a pull request. 412 | 413 | Testing 414 | ------- 415 | 416 | ledger-autosync uses pytest for tests. To test, run pytest in the project directory. This will test the ledger, hledger and ledger-python interfaces. If hledger or the ledger-python interface is not found, these tests will be skipped. 417 | -------------------------------------------------------------------------------- /examples/plugins/aib.py: -------------------------------------------------------------------------------- 1 | # ledger-autosync plugin for CSV files from AIB, an Irish bank. 2 | 3 | import datetime 4 | from decimal import Decimal 5 | import re 6 | 7 | from ledgerautosync.converter import ( 8 | Amount, 9 | Converter, 10 | CsvConverter, 11 | Posting, 12 | Transaction, 13 | ) 14 | 15 | 16 | class AIBConverter(CsvConverter): 17 | FIELDSET = set( 18 | [ 19 | "Posted Account", 20 | "Posted Transactions Date", 21 | "Description", 22 | "Debit Amount", 23 | "Credit Amount", 24 | "Balance", 25 | "Transaction Type", 26 | ] 27 | ) 28 | 29 | def __init__(self, *args, **kwargs): 30 | super(AIBConverter, self).__init__(*args, **kwargs) 31 | 32 | def convert(self, row): 33 | meta = {"csvid": self.get_csv_id(row)} 34 | debit = Decimal(row["Debit Amount"]) if row["Debit Amount"] else None 35 | credit = Decimal(row["Credit Amount"]) if row["Credit Amount"] else None 36 | if debit and debit > 0: 37 | posting_from = Posting( 38 | self.name, Amount(debit, "€", reverse=True), metadata=meta 39 | ) 40 | posting_to = posting_from.clone_inverted("Expenses:Misc") 41 | elif credit and credit > 0: 42 | posting_from = Posting("Assets:Other", Amount(credit, "€", reverse=True)) 43 | posting_to = posting_from.clone_inverted(self.name, metadata=meta) 44 | else: 45 | return "" 46 | 47 | payee = re.sub( 48 | r"[A-Za-z]+('[A-Za-z]+)?", 49 | lambda word: word.group(0).capitalize(), 50 | row["Description"], 51 | ) 52 | 53 | return Transaction( 54 | date=datetime.datetime.strptime( 55 | row["Posted Transactions Date"], "%d/%m/%y" 56 | ), 57 | cleared=True, 58 | date_format="%Y-%m-%d", 59 | payee=payee, 60 | postings=[posting_to, posting_from], 61 | ) 62 | -------------------------------------------------------------------------------- /examples/plugins/firstdirect.py: -------------------------------------------------------------------------------- 1 | # ledger-autosync plugin for CSV files from First Direct, a UK bank. 2 | # The currency is fixed to GBP for that reason. 3 | 4 | import datetime 5 | import re 6 | 7 | from ledgerautosync.converter import Amount, CsvConverter, Posting, Transaction 8 | 9 | 10 | class SomeConverter(CsvConverter): 11 | FIELDSET = set(["Date", "Description", "Amount"]) 12 | 13 | def __init__(self, *args, **kwargs): 14 | super(SomeConverter, self).__init__(*args, **kwargs) 15 | 16 | def convert(self, row): 17 | amount = row["Amount"] 18 | return Transaction( 19 | date=datetime.datetime.strptime(row["Date"], "%d/%m/%Y"), 20 | payee=row["Description"].strip(), 21 | postings=[ 22 | Posting(self.name, Amount(amount, "GBP")), 23 | Posting(self.unknownaccount, Amount(amount, "GBP", reverse=True)), 24 | ], 25 | ) 26 | -------------------------------------------------------------------------------- /examples/plugins/n26.py: -------------------------------------------------------------------------------- 1 | # ledger-autosync plugin for CSV files from N26, a Berlin-based online bank. 2 | 3 | import datetime 4 | from decimal import Decimal 5 | import re 6 | 7 | from ledgerautosync.converter import ( 8 | Amount, 9 | Converter, 10 | CsvConverter, 11 | Posting, 12 | Transaction, 13 | ) 14 | 15 | 16 | class N26Converter(CsvConverter): 17 | FIELDSET = set( 18 | [ 19 | "Date", 20 | "Payee", 21 | "Account number", 22 | "Transaction type", 23 | "Payment reference", 24 | "Amount (EUR)", 25 | "Amount (Foreign Currency)", 26 | "Type Foreign Currency", 27 | "Exchange Rate", 28 | ] 29 | ) 30 | 31 | def __init__(self, *args, **kwargs): 32 | super(N26Converter, self).__init__(*args, **kwargs) 33 | 34 | def mk_currency(self, currency): 35 | if currency == "USD": 36 | currency = "$" 37 | elif currency == "GBP": 38 | currency = "£" 39 | elif currency == "EUR": 40 | currency = "€" 41 | return currency 42 | 43 | def convert(self, row): 44 | amt = Decimal(row["Amount (EUR)"]) 45 | curr_foreign = self.mk_currency(row["Type Foreign Currency"] or "EUR") 46 | amt_foreign = ( 47 | Decimal(row["Amount (Foreign Currency)"]) 48 | if row["Amount (Foreign Currency)"] 49 | else amt 50 | ) 51 | if amt < 0: 52 | reverse = False 53 | acct_from = self.name 54 | curr_from = self.mk_currency("EUR") 55 | amt_from = Amount(amt, curr_from, reverse=reverse) 56 | acct_to = "Expenses:Misc" 57 | curr_to = curr_foreign 58 | amt_to = Amount(amt_foreign, curr_to, reverse=not reverse) 59 | else: 60 | reverse = True 61 | acct_from = "Assets:Other" 62 | curr_from = curr_foreign 63 | amt_from = Amount(amt_foreign, curr_from, reverse=reverse) 64 | acct_to = self.name 65 | curr_to = self.mk_currency("EUR") 66 | amt_to = Amount(amt, curr_to, reverse=not reverse) 67 | 68 | payee = re.sub( 69 | r"[A-Za-z]+('[A-Za-z]+)?", 70 | lambda word: word.group(0).capitalize(), 71 | row["Payee"], 72 | ) 73 | meta = {"csvid": self.get_csv_id(row)} 74 | 75 | posting_from = Posting( 76 | acct_from, amt_from, metadata=meta if acct_from == self.name else {} 77 | ) 78 | posting_to = Posting( 79 | acct_to, amt_to, metadata=meta if acct_to == self.name else {} 80 | ) 81 | 82 | return Transaction( 83 | date=datetime.datetime.strptime(row["Date"], "%Y-%m-%d"), 84 | cleared=True, 85 | date_format="%Y-%m-%d", 86 | payee=payee, 87 | postings=[posting_to, posting_from], 88 | ) 89 | -------------------------------------------------------------------------------- /examples/plugins/revolut.py: -------------------------------------------------------------------------------- 1 | # ledger-autosync plugin for CSV files from Revolut, a Lithuania-based online bank. 2 | 3 | import datetime 4 | from decimal import Decimal 5 | import re 6 | 7 | from ledgerautosync.converter import ( 8 | Amount, 9 | Converter, 10 | CsvConverter, 11 | Posting, 12 | Transaction, 13 | ) 14 | 15 | 16 | class RevolutConverter(CsvConverter): 17 | FIELDSET = set( 18 | [ 19 | "Type", 20 | "Product", 21 | "Started Date", 22 | "Completed Date", 23 | "Description", 24 | "Amount", 25 | "Fee", 26 | "Currency", 27 | "State", 28 | "Balance", 29 | ] 30 | ) 31 | 32 | def __init__(self, *args, **kwargs): 33 | super(RevolutConverter, self).__init__(*args, **kwargs) 34 | 35 | def mk_currency(self, currency): 36 | if currency == "USD": 37 | currency = "$" 38 | elif currency == "GBP": 39 | currency = "£" 40 | elif currency == "EUR": 41 | currency = "€" 42 | return currency 43 | 44 | def convert(self, row): 45 | amt = Decimal(row["Amount"]) 46 | if not amt: 47 | return "" 48 | currency = self.mk_currency(row["Currency"]) 49 | cleared = row["State"] == "COMPLETED" 50 | if row["Type"] == "TOPUP": 51 | reverse = True 52 | acct_from = "Assets:Other" 53 | amt_from = Amount(amt, currency, reverse=reverse) 54 | acct_to = self.name 55 | amt_to = Amount(amt, currency, reverse=not reverse) 56 | else: 57 | reverse = False 58 | acct_from = self.name 59 | amt_from = Amount(amt, currency, reverse=reverse) 60 | acct_to = "Expenses:Misc" 61 | amt_to = Amount(amt, currency, reverse=not reverse) 62 | 63 | payee = row["Description"] 64 | meta = {"csvid": self.get_csv_id(row)} 65 | 66 | posting_from = Posting( 67 | acct_from, amt_from, metadata=meta if acct_from == self.name else {} 68 | ) 69 | posting_fee = ( 70 | Posting( 71 | "Expenses:Bank Charges", 72 | Amount(Decimal(row["Fee"]), currency, reverse=True), 73 | ) 74 | if row["Fee"] != "0.00" 75 | else None 76 | ) 77 | posting_to = Posting( 78 | acct_to, amt_to, metadata=meta if acct_to == self.name else {} 79 | ) 80 | 81 | date = datetime.datetime.strptime(row["Started Date"], "%Y-%m-%d %H:%M:%S") 82 | aux_date = ( 83 | datetime.datetime.strptime(row["Completed Date"], "%Y-%m-%d %H:%M:%S") 84 | if row["Completed Date"] 85 | else None 86 | ) 87 | if aux_date and (date.date() == aux_date.date()): 88 | aux_date = None 89 | 90 | postings = [posting_to, posting_from] 91 | if posting_fee: 92 | postings.append(posting_fee) 93 | postings.append(posting_fee.clone_inverted(self.name)) 94 | return Transaction( 95 | date=date, 96 | cleared=cleared, 97 | aux_date=aux_date, 98 | date_format="%Y-%m-%d", 99 | payee=payee, 100 | postings=postings, 101 | ) 102 | -------------------------------------------------------------------------------- /examples/plugins/wise.py: -------------------------------------------------------------------------------- 1 | # ledger-autosync plugin for CSV files from Wise, formerly TransferWise. 2 | 3 | # You must download and sync a CSV statement for all currencies that 4 | # cover the same time period. Since Wise lists the transactions in 5 | # reverse chronological order, you might want to run with --reverse flag. 6 | # The code assumes some text is in English. Adjust mk_currency() and 7 | # anything else to suit. No warranty, YMMV, etc. 8 | 9 | import datetime 10 | from decimal import Decimal 11 | import re 12 | 13 | from ledgerautosync.converter import ( 14 | Amount, 15 | Converter, 16 | CsvConverter, 17 | Posting, 18 | Transaction, 19 | ) 20 | 21 | 22 | class WiseConverter(CsvConverter): 23 | FIELDSET = set( 24 | [ 25 | "TransferWise ID", 26 | "Date", 27 | "Amount", 28 | "Currency", 29 | "Description", 30 | "Payment Reference", 31 | "Running Balance", 32 | "Exchange From", 33 | "Exchange To", 34 | "Exchange Rate", 35 | "Payer Name", 36 | "Payee Name", 37 | "Payee Account Number", 38 | "Merchant", 39 | "Card Last Four Digits", 40 | "Card Holder Full Name", 41 | "Attachment", 42 | "Note", 43 | "Total fees", 44 | ] 45 | ) 46 | 47 | def __init__(self, *args, **kwargs): 48 | super(WiseConverter, self).__init__(*args, **kwargs) 49 | 50 | def mk_currency(self, currency): 51 | if currency == "USD": 52 | currency = "$" 53 | elif currency == "GBP": 54 | currency = "£" 55 | elif currency == "EUR": 56 | currency = "€" 57 | return currency 58 | 59 | def mk_amount(self, amt, currency, reverse=False): 60 | currency = self.mk_currency(currency) 61 | return Amount(Decimal(amt), currency, reverse=reverse) 62 | 63 | def convert(self, row): 64 | tid = row["TransferWise ID"] 65 | checknum = int(tid.split("-")[1]) 66 | amt = Decimal(row["Amount"]) 67 | acct_from = self.name 68 | curr_from = self.mk_currency(row["Currency"]) 69 | acct_to = "Expenses:Misc" 70 | curr_to = curr_from 71 | amt_from = Amount(amt, curr_from) 72 | amt_to = Amount(amt, curr_to, reverse=True) 73 | 74 | fee_not_included = ( 75 | tid.startswith("CARD-") and row["Currency"] == "USD" and row["Exchange To"] 76 | ) 77 | 78 | if row["Exchange To"]: 79 | rate = Decimal(row["Exchange Rate"]) 80 | curr = self.mk_currency(row["Currency"]) 81 | curr_from = self.mk_currency(row["Exchange From"]) 82 | curr_to = self.mk_currency(row["Exchange To"]) 83 | if curr == curr_from: 84 | amt_from = Amount(amt, curr_from) 85 | # Card transactions from USD to other currencies do not consider the fees in the exchange rate 86 | amt_to = Amount( 87 | ( 88 | amt 89 | + ( 90 | Decimal(row["Total fees"]) 91 | if tid.startswith("CARD-") 92 | and not (row["Currency"] == "USD" and row["Exchange To"]) 93 | else Decimal(0) 94 | ) 95 | ) 96 | * rate, 97 | curr_to, 98 | reverse=True, 99 | ) 100 | acct_from = self.name 101 | else: 102 | # Do not import this exchange from this statement; instead use the statement for the matching "from" currency 103 | if tid.startswith("BALANCE-"): 104 | return "" 105 | amt_from = Amount(amt / rate, curr_from, reverse=True) 106 | amt_to = Amount(amt, curr_to) 107 | acct_from = self.name if tid.startswith("BALANCE-") else "Expenses:Misc" 108 | if tid.startswith("BALANCE-"): 109 | acct_to = self.name 110 | 111 | if row["Description"].startswith("Wise Charges for:"): 112 | acct_to = "Expenses:Bank Charges" 113 | 114 | if tid.startswith("TRANSFER-"): 115 | payee = row["Payee Name"] or row["Payer Name"] 116 | elif tid.startswith("BALANCE-"): 117 | payee = row["Description"] 118 | else: 119 | payee = row["Merchant"] 120 | 121 | meta = {"csvid": self.get_csv_id(row)} 122 | 123 | posting_from = Posting(acct_from, amt_from, metadata=meta) 124 | posting_to = Posting(acct_to, amt_to) 125 | 126 | return Transaction( 127 | date=datetime.datetime.strptime(row["Date"], "%d-%m-%Y"), 128 | cleared=True, 129 | date_format="%Y-%m-%d", 130 | checknum=checknum, 131 | payee=payee, 132 | postings=[posting_to, posting_from], 133 | ) 134 | 135 | def get_csv_id(self, row): 136 | fmt = ( 137 | "wise.fee.{}" 138 | if row["Description"].startswith("Wise Charges for:") 139 | else "wise.{}" 140 | ) 141 | return fmt.format(Converter.clean_id(row["TransferWise ID"])) 142 | -------------------------------------------------------------------------------- /fix_ofxid.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import re 3 | import sys 4 | 5 | first_line = False 6 | ofxline = None 7 | 8 | with open(sys.argv[1]) as f: 9 | for line in f.readlines(): 10 | md = re.match(r"^(19|20)[0-9][0-9]", line) 11 | if md is not None: 12 | # Mark the next line as the first line in a txn 13 | first_line = True 14 | else: 15 | if first_line: 16 | first_line = False 17 | # Check if there is an ofxid on this line 18 | md = re.match(r"^\s+; ofxid:", line) 19 | if md is not None: 20 | ofxline = line 21 | continue 22 | # In every case except the one above where we call next, print the line 23 | sys.stdout.write(line) 24 | # We had a misplaced ofxid last, print it now 25 | if ofxline: 26 | sys.stdout.write(ofxline) 27 | ofxline = None 28 | -------------------------------------------------------------------------------- /fixtures/accented_characters_latin1.ofx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egh/ledger-autosync/ffa3fc66050f3c194a7716905103146063d7cad4/fixtures/accented_characters_latin1.ofx -------------------------------------------------------------------------------- /fixtures/amazon.csv: -------------------------------------------------------------------------------- 1 | Order Date,Order ID,Title,Category,ASIN/ISBN,UNSPSC Code,Website,Release Date,Condition,Seller,Seller Credentials,List Price Per Unit,Purchase Price Per Unit,Quantity,Payment Instrument Type,Purchase Order Number,PO Line Number,Ordering Customer Email,Shipment Date,Shipping Address Name,Shipping Address Street 1,Shipping Address Street 2,Shipping Address City,Shipping Address State,Shipping Address Zip,Order Status,Carrier Name & Tracking Number,Item Subtotal,Item Subtotal Tax,Item Total,Tax Exemption Applied,Tax Exemption Type,Exemption Opt-Out,Buyer Name,Currency,Group Name 2 | 01/29/16,123-4567890-1234567,"Best Soap Ever",Health and Beauty,"B001XYZX00","01234567",Amazon.com,,new,Amazon.com,,$10.00,$10.00,2,"MasterCard - 1234",,,me@mailinator.com,01/31/16,M.E.,123 Main St,,Springfield,CA,00000-0000,Shipped,ONTRAC(XYZ),$20.00,$1.90,$21.90,,,,ME,USD, 3 | -------------------------------------------------------------------------------- /fixtures/amazon2.csv: -------------------------------------------------------------------------------- 1 | Order Date,Order ID,Title,Category,ASIN/ISBN,UNSPSC Code,Website,Release Date,Condition,Seller,Seller Credentials,List Price Per Unit,Purchase Price Per Unit,Quantity,Payment Instrument Type,Purchase Order Number,PO Line Number,Ordering Customer Email,Shipment Date,Shipping Address Name,Shipping Address Street 1,Shipping Address Street 2,Shipping Address City,Shipping Address State,Shipping Address Zip,Order Status,Carrier Name & Tracking Number,Item Subtotal,Item Subtotal Tax,Item Total,Tax Exemption Applied,Tax Exemption Type,Exemption Opt-Out,Buyer Name,Currency,Group Name 2 | 06/05/17,111-1111111-1111111,"Test "" double quote",Kitchen,"A00AA1A11A",,Amazon.com,,new,,,$0.00,$9.99,1,"MasterCard - 1234",,,me@example.com,06/06/17,Me,123 Main St,,Springfield,CA,00000-0000,Shipped,AMZN_US(TBA000000000000),$9.99,$0.00,$9.99,,,,Me,USD, 3 | -------------------------------------------------------------------------------- /fixtures/apostrophe.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | 18 | 20130525225731.258 19 | ENG 20 | 20050531060000.000 21 | 22 | FAKE 23 | 1101 24 | 25 | 51123 26 | 9774652 27 | 28 | 29 | 30 | 31 | 0 32 | 33 | 0 34 | INFO 35 | 36 | 37 | USD 38 | 39 | 5472369148 40 | 1452687~7 41 | CHECKING 42 | 43 | 44 | 20000101070000.000 45 | 20140920170000.000 46 | 47 | PAYMENT 48 | 20140920170000[0:GMT] 49 | -58.73 50 | 201409206 51 | 201409206 52 | REPLACE 53 | TRADER JOE'S #541 QPS 54 | 55 | 56 | 57 | 100.99 58 | 20140920170000.000 59 | 60 | 61 | 75.99 62 | 20140920170000.000 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /fixtures/checking-dynamic-account.lgr: -------------------------------------------------------------------------------- 1 | 2011/01/01 AUTOMATIC WITHDRAWAL, ELECTRIC BILL WEB(S ) 2 | Assets:Foo -$10.00 3 | Expenses:Bar 4 | 5 | 2011/01/02 Generic 6 | Assets:Foo -$20.00 7 | Expenses:Foo 8 | 9 | 2011/02/02 Generic 10 | Assets:Foo -$15.00 11 | Expenses:Bar 12 | -------------------------------------------------------------------------------- /fixtures/checking-partial.lgr: -------------------------------------------------------------------------------- 1 | 2011/03/31 DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 2 | ; ofxid: 1101.1452687~7.0000486 3 | Foo $0.01 4 | Bar -$0.01 5 | 6 | 2011/04/05 AUTOMATIC WITHDRAWAL, ELECTRIC BILL WEB(S ) 7 | ; ofxid: 1101.1452687~7.0000487 8 | Foo -$34.51 9 | Bar $34.51 10 | -------------------------------------------------------------------------------- /fixtures/checking-payee-match.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | 18 | 20130525225731.258 19 | ENG 20 | 20050531060000.000 21 | 22 | FAKE 23 | 1101 24 | 25 | 51123 26 | 9774652 27 | 28 | 29 | 30 | 31 | 0 32 | 33 | 0 34 | INFO 35 | 36 | 37 | USD 38 | 39 | 5472369148 40 | 1452687~7 41 | CHECKING 42 | 43 | 44 | 20000101070000.000 45 | 20130525060000.000 46 | 47 | DEBIT 48 | 20110331120000.000 49 | -0.01 50 | 0000489 51 | Payment to MATCH PAYEE and so on and so forth 52 | 53 | 54 | 55 | 56 | 100.99 57 | 20130525225731.258 58 | 59 | 60 | 75.99 61 | 20130525225731.258 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /fixtures/checking.lgr: -------------------------------------------------------------------------------- 1 | 2011/03/31 DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 2 | ; ofxid: 1101.1452687~7.0000486 3 | Assets:Foo $0.01 4 | Income:Bar -$0.01 5 | 6 | 2011/04/05 AUTOMATIC WITHDRAWAL, ELECTRIC BILL WEB(S ) 7 | ; ofxid: 1101.1452687~7.0000487 8 | Assets:Foo -$34.51 9 | Expenses:Bar $34.51 10 | 11 | 2011/04/07 RETURNED CHECK FEE, CHECK # 319 FOR $45.33 ON 04/07/11 12 | ; ofxid: 1102.1452687~7.0000488 13 | Assets:Foo -$25.00 14 | Expenses:Bar $25.00 15 | 16 | 2011/04/07 Empty 17 | ; ofxid: empty 18 | Assets:Foo -$0.00 19 | Expenses:Bar $0.00 20 | 21 | 2011/03/31 TEST SLASH 22 | ; ofxid: 1_2 23 | Assets:Foo $0.01 24 | Income:Bar -$0.01 25 | 26 | 2011/03/31 PAYEE TEST:COLON 27 | Assets:Foo $0.01 28 | Income:Bar -$0.01 29 | 30 | 2011/03/31 PAYEE TEST/SLASH 31 | Assets:Foo $0.01 32 | Income:Bar -$0.01 33 | 34 | 2011/03/31 PAYEE TEST,COMMA 35 | Assets:Foo $0.01 36 | Income:Bar -$0.01 37 | 38 | 2011/03/31 PAYEE TEST.PERIOD 39 | Assets:Foo $0.01 40 | Income:Bar -$0.01 41 | 42 | 2011/03/31 PAYEE TEST*STAR 43 | Assets:Foo $0.01 44 | Income:Bar -$0.01 45 | 46 | 2011/03/31 PAYEE TEST#HASH 47 | Assets:Foo $0.01 48 | Income:Bar -$0.01 49 | 50 | 2011/03/31 PAYEE TEST"QUOTE 51 | Assets:Foo $0.01 52 | Income:Bar -$0.01 53 | 54 | 2011/03/31 Match Payee 55 | ; AutosyncPayee: Payment to MATCH PAYEE and so on and so forth 56 | Assets:Foo -$0.01 57 | Expenses:Bar $0.01 58 | -------------------------------------------------------------------------------- /fixtures/checking.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | 18 | 20130525225731.258 19 | ENG 20 | 20050531060000.000 21 | 22 | FAKE 23 | 1101 24 | 25 | 51123 26 | 9774652 27 | 28 | 29 | 30 | 31 | 0 32 | 33 | 0 34 | INFO 35 | 36 | 37 | USD 38 | 39 | 5472369148 40 | 1452687~7 41 | CHECKING 42 | 43 | 44 | 20000101070000.000 45 | 20130525060000.000 46 | 47 | CREDIT 48 | 20110331120000.000 49 | 0.01 50 | 0000486 51 | DIVIDEND EARNED FOR PERIOD OF 03 52 | DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 53 | 54 | 55 | DEBIT 56 | 20110405120000.000 57 | -34.51 58 | 0000487 59 | AUTOMATIC WITHDRAWAL, ELECTRIC BILL 60 | AUTOMATIC WITHDRAWAL, ELECTRIC BILL WEB(S ) 61 | 62 | 63 | CHECK 64 | 20110407120000.000 65 | -25.00 66 | 0000488 67 | 319 68 | RETURNED CHECK FEE, CHECK # 319 69 | RETURNED CHECK FEE, CHECK # 319 FOR $45.33 ON 04/07/11 70 | 71 | 72 | 73 | 100.99 74 | 20130525225731.258 75 | 76 | 77 | 75.99 78 | 20130525225731.258 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /fixtures/checking_order.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | 18 | 20130525225731.258 19 | ENG 20 | 20050531060000.000 21 | 22 | FAKE 23 | 1101 24 | 25 | 51123 26 | 9774652 27 | 28 | 29 | 30 | 31 | 0 32 | 33 | 0 34 | INFO 35 | 36 | 37 | USD 38 | 39 | 5472369148 40 | 1452687~7 41 | CHECKING 42 | 43 | 44 | 20000101070000.000 45 | 20130525060000.000 46 | 47 | CHECK 48 | 20110407120000.000 49 | -25.00 50 | 0000488 51 | 319 52 | RETURNED CHECK FEE, CHECK # 319 53 | RETURNED CHECK FEE, CHECK # 319 FOR $45.33 ON 04/07/11 54 | 55 | 56 | DEBIT 57 | 20110405120000.000 58 | -34.51 59 | 0000487 60 | AUTOMATIC WITHDRAWAL, ELECTRIC BILL 61 | AUTOMATIC WITHDRAWAL, ELECTRIC BILL WEB(S ) 62 | 63 | 64 | CREDIT 65 | 20110331120000.000 66 | 0.01 67 | 0000486 68 | DIVIDEND EARNED FOR PERIOD OF 03 69 | DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 70 | 71 | 72 | 73 | 100.99 74 | 20130525225731.258 75 | 76 | 77 | 75.99 78 | 20130525225731.258 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /fixtures/comments.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | 18 | 20130525225731.258 19 | ENG 20 | 20050531060000.000 21 | 22 | FAKE 23 | 1101 24 | 25 | 51123 26 | 9774652 27 | 28 | 29 | 30 | 31 | 0 32 | 33 | 0 34 | INFO 35 | 36 | 37 | USD 38 | 39 | 5472369148 40 | 1452687~7 41 | CHECKING 42 | 43 | 44 | 20000101070000.000 45 | 20161027160000.000 46 | 47 | CREDIT 48 | 20161027160000.000 49 | 123.45 50 | FITID20161027123.45ABCDE 51 | AMAZON MKTPLACE PMTS AMZN.COM/BI 52 | 53 | 54 | DEBIT 55 | 20161027160000.000 56 | -0 57 | FITID20161027-0.0ABCDE 58 | XXXEXTRASTUFFXXX 59 | 60 | 61 | 62 | 100.99 63 | 20161027160000.000 64 | 65 | 66 | 75.99 67 | 20161027160000.000 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /fixtures/cusip.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:d0a0380757f14baba37a454823980c7c 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | SUCCESS 18 | 19 | 20161008122549.359[-4:EDT] 20 | ENG 21 | 22 | fidelity.com 23 | 7776 24 | 25 | 26 | 27 | 28 | 29 | 4335f294b6fb406ba99d3bf473b89e8a 30 | 31 | 0 32 | INFO 33 | SUCCESS 34 | 35 | 36 | 20161008033008.000[-4:EDT] 37 | USD 38 | 39 | fidelity.com 40 | 123456789 41 | 42 | 43 | 20160908000000.000[-4:EDT] 44 | 20161008122549.439[-4:EDT] 45 | 46 | 47 | 48 | 123456789000987654321 49 | 20161005000000.000[-4:EDT] 50 | YOU BOUGHT PROSPECTUS 51 | 52 | 53 | 957904675 54 | CUSIP 55 | 56 | +0000000001000.00000 57 | 000000047.860000000 58 | +00000000000000.0000 59 | +00000000000000.0000 60 | -00000000047860.0000 61 | 62 | 1.00 63 | USD 64 | 65 | CASH 66 | CASH 67 | 68 | BUY 69 | 70 | 71 | 72 | 123456789000987654322 73 | 20160929000000.000[-4:EDT] 74 | DIVIDEND RECEIVED 75 | 76 | 77 | 957904675 78 | CUSIP 79 | 80 | DIV 81 | +00000000000005.2300 82 | CASH 83 | CASH 84 | 85 | 1.00 86 | USD 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 957904675 95 | CUSIP 96 | 97 | CASH 98 | LONG 99 | 2000.00000 100 | 47.8600000 101 | +00000095720.00 102 | 20161008033008.000[-4:EDT] 103 | 104 | 1.0 105 | USD 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 957904675 119 | CUSIP 120 | 121 | BLACKROCK HEALTH SCIENCES OPP PRT A 122 | SHSAX 123 | 47.8600000 124 | 20161008033008.000[-4:EDT] 125 | 126 | 1.000 127 | USD 128 | 129 | 130 | OTHER 131 | 20161008033008.000[-4:EDT] 132 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /fixtures/empty.lgr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egh/ledger-autosync/ffa3fc66050f3c194a7716905103146063d7cad4/fixtures/empty.lgr -------------------------------------------------------------------------------- /fixtures/fidelity-one-dtsettle.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 10 | 11 | 0INFOSUCCESS20120908190849.317[-4:EDT]ENGfidelity.com7776a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a00INFOSUCCESS20120908033034.000[-4:EDT]USDfidelity.com0123456789020120710000000.000[-4:EDT]20120908190849.555[-4:EDT]012345678902020112012072020120720000000.000[-4:EDT]YOU BOUGHT458140100CUSIP+0000000000100.00000000000025.635000000+00000000000007.9500+00000000000000.0000-00000000002571.45001.00USDCASHCASHBUY 012345678902090112012072720120727000000.000[-4:EDT]G7945E105CUSIP+0000000000128.00000000000039.390900000+00000000000007.9500+00000000000000.0000-00000000005049.99001.00USDCASHCASHBUY 012345678902090122012072720120727000000.000[-4:EDT]20120727000000.000[-4:EDT]YOU BOUGHT431571108CUSIP+0000000000115.00000000000017.250000000+00000000000007.9500+00000000000000.0000-00000000001991.70001.00USDCASHCASHBUY 012345678902130112012073120120731000000.000[-4:EDT]YOU BOUGHT19421R200CUSIP+0000000000069.00000000000014.469900000+00000000000007.9500+00000000000000.0000-00000000001006.37001.00USDCASHCASHBUY 012345678902130162012073120120731000000.000[-4:EDT]YOU BOUGHT98417P105CUSIP+0000000000386.00000000000002.588700000+00000000000007.9500+00000000000000.0000-00000000001007.19001.00USDCASHCASHBUY 012345678902350122012082020120820000000.000[-4:EDT]REINVESTMENT98417P105CUSIP+0000000000004.90900000000002.947400000+00000000000000.0000+00000000000000.0000-00000000000014.47001.00USDCASHCASHBUY 012345678902440112012083120120831000000.000[-4:EDT]REINVESTMENT19421R200CUSIP+0000000000001.57300000000014.257000000+00000000000000.0000+00000000000000.0000-00000000000022.43001.00USDCASHCASHBUY 012345678902480112012090120120901000000.000[-4:EDT]REINVESTMENT458140100CUSIP+0000000000000.91100000000024.705500000+00000000000000.0000+00000000000000.0000-00000000000022.50001.00USDCASHCASHBUY 012345678902130152012073120120731000000.000[-4:EDT]DIVIDEND RECEIVED78462F103CUSIPDIV+00000000000005.5300CASHCASH1.00USD 012345678902350132012082020120820000000.000[-4:EDT]DIVIDEND RECEIVED98417P105CUSIPDIV+00000000000015.4400CASHCASH1.00USD 012345678902440122012083120120831000000.000[-4:EDT]DIVIDEND RECEIVED19421R200CUSIPDIV+00000000000022.4300CASHCASH1.00USD 012345678902480122012090120120901000000.000[-4:EDT]DIVIDEND RECEIVED458140100CUSIPDIV+00000000000022.5000CASHCASH1.00USD 012345678902090132012072720120727000000.000[-4:EDT]YOU SOLD78462F103CUSIP-0000000000008.00000000000137.160000000+00000000000007.9500+00000000000000.0000+00000000001089.30001.00USDCASHCASHSELL 012345678902140142012080120120801000000.000[-4:EDT]IN LIEU OF FRX SHARE78462F103CUSIP-0000000000000.03500000000137.142857143+00000000000000.0000+00000000000000.0000+00000000000004.80001.00USDCASHCASHSELL DEP20120731000000.000[-4:EDT]+00000000000000.24000123456789021301320120731INTEREST EARNEDINTEREST EARNED1.00USD CASH OTHER20120820000000.000[-4:EDT]-00000000000000.97000123456789023501120120820LATE SETTLEMENT FEELATE SETTLEMENT FEE1.00USD CASH DEP20120831000000.000[-4:EDT]+00000000000000.16000123456789024401420120831INTEREST EARNEDINTEREST EARNED1.00USD CASH G7945E105CUSIPCASHLONG128.0000040.8700000+00000005231.3620120908033034.000[-4:EDT]1.0USD19421R200CUSIPCASHLONG70.5730014.3200000+00000001010.6020120908033034.000[-4:EDT]1.0USD431571108CUSIPCASHLONG115.0000018.9300000+00000002176.9520120908033034.000[-4:EDT]1.0USD458140100CUSIPCASHLONG100.9110024.1900000+00000002441.0320120908033034.000[-4:EDT]1.0USD756577102CUSIPCASHLONG50.0000059.1500000+00000002957.5020120908033034.000[-4:EDT]1.0USD98417P105CUSIPCASHLONG390.909002.8200000+00000001102.3620120908033034.000[-4:EDT]1.0USD18073.98+00000000000.00+00000000000.00+00000000000.00NetworthThe net market value of all long and short positions in the accountDOLLAR32993.7920120908033034.000[-4:EDT]1.000USDMargin EquityThe margin market value less any margin debit balanceDOLLAR0.020120908033034.000[-4:EDT]1.000USDMargin Equity PercentageMargin equity / market value of long and short positionsPERCENT0.020120908033034.000[-4:EDT]1.000USDCash Debit BalanceCash Debit BalanceDOLLAR0.020120908033034.000[-4:EDT]1.000USDTotal Money MarketsThe total value of all money market positions in the cash accountDOLLAR18073.9820120908033034.000[-4:EDT]1.000USDHouse SurplusEquity amount above house requirementsDOLLAR0.020120908033034.000[-4:EDT]1.000USDNYSE SurplusEquity amount above exchange requirementsDOLLAR0.020120908033034.000[-4:EDT]1.000USDFederal SurplusAmount above federal requirementsDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - EquitiesAmount of equities you can buy on margin without generating a margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - Municipal BondsAmount of municipal bonds you can buy on margin without generating a margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - Government BondsAmount of government bonds you can buy on margin without generating a margin calDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - Corporate BondsAmount you can buy of corporate bonds on margin with no margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDOption Market ValueThe market value of all options in the accountDOLLAR0.020120908033034.000[-4:EDT]1.000USDOption In The Money AmountThe in-the-money amount on covered optionsDOLLAR0.020120908033034.000[-4:EDT]1.000USDCash Market valueTotal value of all cash account positionsDOLLAR14919.820120908033034.000[-4:EDT]1.000USDMargin Market ValueTotal value of positions in margin less in-the-money amount of covered optionsDOLLAR0.020120908033034.000[-4:EDT]1.000USDShort Market ValueTotal value of short positions less in-the-money amount of covered put optionsDOLLAR0.020120908033034.000[-4:EDT]1.000USDAvailable to BorrowCash amount that can be borrowed without generating a margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDG7945E105CUSIPSEADRILL LTD USD2SDRL20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]19421R200CUSIPCOLLECTORS UNIVERSE INCCLCT20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]431571108CUSIPHILLENBRAND INC COMHI20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]458140100CUSIPINTEL CORPINTC20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]756577102CUSIPRED HAT INCRHT20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]98417P105CUSIPXINYUAN REAL ESTATE ADR EACH REPR 2 ORD SHSXIN20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]78462F103CUSIPSPDR S&P 500 ETF TRUST UNIT SER 1 S&PSPY20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT] 12 | -------------------------------------------------------------------------------- /fixtures/fidelity.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 10 | 11 | 0INFOSUCCESS20120908190849.317[-4:EDT]ENGfidelity.com7776a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a00INFOSUCCESS20120908033034.000[-4:EDT]USDfidelity.com0123456789020120710000000.000[-4:EDT]20120908190849.555[-4:EDT]012345678902020112012072020120720000000.000[-4:EDT]YOU BOUGHT458140100CUSIP+0000000000100.00000000000025.635000000+00000000000007.9500+00000000000000.0000-00000000002571.45001.00USDCASHCASHBUY 012345678902090112012072720120727000000.000[-4:EDT]G7945E105CUSIP+0000000000128.00000000000039.390900000+00000000000007.9500+00000000000000.0000-00000000005049.99001.00USDCASHCASHBUY 012345678902090122012072720120727000000.000[-4:EDT]YOU BOUGHT431571108CUSIP+0000000000115.00000000000017.250000000+00000000000007.9500+00000000000000.0000-00000000001991.70001.00USDCASHCASHBUY 012345678902130112012073120120731000000.000[-4:EDT]YOU BOUGHT19421R200CUSIP+0000000000069.00000000000014.469900000+00000000000007.9500+00000000000000.0000-00000000001006.37001.00USDCASHCASHBUY 012345678902130162012073120120731000000.000[-4:EDT]YOU BOUGHT98417P105CUSIP+0000000000386.00000000000002.588700000+00000000000007.9500+00000000000000.0000-00000000001007.19001.00USDCASHCASHBUY 012345678902350122012082020120820000000.000[-4:EDT]REINVESTMENT98417P105CUSIP+0000000000004.90900000000002.947400000+00000000000000.0000+00000000000000.0000-00000000000014.47001.00USDCASHCASHBUY 012345678902440112012083120120831000000.000[-4:EDT]REINVESTMENT19421R200CUSIP+0000000000001.57300000000014.257000000+00000000000000.0000+00000000000000.0000-00000000000022.43001.00USDCASHCASHBUY 012345678902480112012090120120901000000.000[-4:EDT]REINVESTMENT458140100CUSIP+0000000000000.91100000000024.705500000+00000000000000.0000+00000000000000.0000-00000000000022.50001.00USDCASHCASHBUY 012345678902130152012073120120731000000.000[-4:EDT]DIVIDEND RECEIVED78462F103CUSIPDIV+00000000000005.5300CASHCASH1.00USD 012345678902350132012082020120820000000.000[-4:EDT]DIVIDEND RECEIVED98417P105CUSIPDIV+00000000000015.4400CASHCASH1.00USD 012345678902440122012083120120831000000.000[-4:EDT]DIVIDEND RECEIVED19421R200CUSIPDIV+00000000000022.4300CASHCASH1.00USD 012345678902480122012090120120901000000.000[-4:EDT]DIVIDEND RECEIVED458140100CUSIPDIV+00000000000022.5000CASHCASH1.00USD 012345678902090132012072720120727000000.000[-4:EDT]YOU SOLD78462F103CUSIP-0000000000008.00000000000137.160000000+00000000000007.9500+00000000000000.0000+00000000001089.30001.00USDCASHCASHSELL 012345678902140142012080120120801000000.000[-4:EDT]IN LIEU OF FRX SHARE78462F103CUSIP-0000000000000.03500000000137.142857143+00000000000000.0000+00000000000000.0000+00000000000004.80001.00USDCASHCASHSELL DEP20120731000000.000[-4:EDT]+00000000000000.24000123456789021301320120731INTEREST EARNEDINTEREST EARNED1.00USD CASH OTHER20120820000000.000[-4:EDT]-00000000000000.97000123456789023501120120820LATE SETTLEMENT FEELATE SETTLEMENT FEE1.00USD CASH DEP20120831000000.000[-4:EDT]+00000000000000.16000123456789024401420120831INTEREST EARNEDINTEREST EARNED1.00USD CASH G7945E105CUSIPCASHLONG128.0000040.8700000+00000005231.3620120908033034.000[-4:EDT]1.0USD19421R200CUSIPCASHLONG70.5730014.3200000+00000001010.6020120908033034.000[-4:EDT]1.0USD431571108CUSIPCASHLONG115.0000018.9300000+00000002176.9520120908033034.000[-4:EDT]1.0USD458140100CUSIPCASHLONG100.9110024.1900000+00000002441.0320120908033034.000[-4:EDT]1.0USD756577102CUSIPCASHLONG50.0000059.1500000+00000002957.5020120908033034.000[-4:EDT]1.0USD98417P105CUSIPCASHLONG390.909002.8200000+00000001102.3620120908033034.000[-4:EDT]1.0USD18073.98+00000000000.00+00000000000.00+00000000000.00NetworthThe net market value of all long and short positions in the accountDOLLAR32993.7920120908033034.000[-4:EDT]1.000USDMargin EquityThe margin market value less any margin debit balanceDOLLAR0.020120908033034.000[-4:EDT]1.000USDMargin Equity PercentageMargin equity / market value of long and short positionsPERCENT0.020120908033034.000[-4:EDT]1.000USDCash Debit BalanceCash Debit BalanceDOLLAR0.020120908033034.000[-4:EDT]1.000USDTotal Money MarketsThe total value of all money market positions in the cash accountDOLLAR18073.9820120908033034.000[-4:EDT]1.000USDHouse SurplusEquity amount above house requirementsDOLLAR0.020120908033034.000[-4:EDT]1.000USDNYSE SurplusEquity amount above exchange requirementsDOLLAR0.020120908033034.000[-4:EDT]1.000USDFederal SurplusAmount above federal requirementsDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - EquitiesAmount of equities you can buy on margin without generating a margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - Municipal BondsAmount of municipal bonds you can buy on margin without generating a margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - Government BondsAmount of government bonds you can buy on margin without generating a margin calDOLLAR0.020120908033034.000[-4:EDT]1.000USDBuying Power - Corporate BondsAmount you can buy of corporate bonds on margin with no margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDOption Market ValueThe market value of all options in the accountDOLLAR0.020120908033034.000[-4:EDT]1.000USDOption In The Money AmountThe in-the-money amount on covered optionsDOLLAR0.020120908033034.000[-4:EDT]1.000USDCash Market valueTotal value of all cash account positionsDOLLAR14919.820120908033034.000[-4:EDT]1.000USDMargin Market ValueTotal value of positions in margin less in-the-money amount of covered optionsDOLLAR0.020120908033034.000[-4:EDT]1.000USDShort Market ValueTotal value of short positions less in-the-money amount of covered put optionsDOLLAR0.020120908033034.000[-4:EDT]1.000USDAvailable to BorrowCash amount that can be borrowed without generating a margin callDOLLAR0.020120908033034.000[-4:EDT]1.000USDG7945E105CUSIPSEADRILL LTD USD2SDRL20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]19421R200CUSIPCOLLECTORS UNIVERSE INCCLCT20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]431571108CUSIPHILLENBRAND INC COMHI20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]458140100CUSIPINTEL CORPINTC20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]756577102CUSIPRED HAT INCRHT20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]98417P105CUSIPXINYUAN REAL ESTATE ADR EACH REPR 2 ORD SHSXIN20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT]78462F103CUSIPSPDR S&P 500 ETF TRUST UNIT SER 1 S&PSPY20120908033034.000[-4:EDT]1.000USD COMMON20120908033034.000[-4:EDT] 12 | -------------------------------------------------------------------------------- /fixtures/fidelity_fee.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | SUCCESS 18 | 19 | 20120908190849.317[-4:EDT] 20 | ENG 21 | 22 | fidelity.com 23 | 7776 24 | 25 | 26 | 27 | 28 | 29 | a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 30 | 31 | 0 32 | INFO 33 | SUCCESS 34 | 35 | 36 | 20120908033034.000[-4:EDT] 37 | USD 38 | 39 | fidelity.com 40 | 01234567890 41 | 42 | 43 | 20120710000000.000[-4:EDT] 44 | 20120908190849.555[-4:EDT] 45 | 46 | 47 | 48 | 0123456789021401420120801 49 | 20120801000000.000[-4:EDT] 50 | SELL 51 | 52 | 53 | 929042109 54 | CUSIP 55 | 56 | -100.0 57 | 32.3944 58 | 0.07 59 | 3239.37 60 | CASH 61 | CASH 62 | 63 | SELL 64 | 65 | 66 | 67 | 68 | 987654321 69 | 20200522160000.000[-5:EST] 70 | 20200526160000.000[-5:EST] 71 | SELL 72 | 73 | 74 | Z9977810Z 75 | CUSIP 76 | 77 | -1.0 78 | 8.27 79 | 1.0 80 | 0.02 81 | 825.98 82 | CASH 83 | CASH 84 | 85 | SELLTOCLOSE 86 | 0 87 | 88 | 89 | 90 | DEP 91 | 20120731000000.000[-4:EDT] 92 | +00000000000000.2400 93 | 0123456789021301320120731 94 | INTEREST EARNED 95 | INTEREST EARNED 96 | 97 | 1.00 98 | USD 99 | 100 | 101 | CASH 102 | 103 | 104 | 105 | OTHER 106 | 20120820000000.000[-4:EDT] 107 | -00000000000000.9700 108 | 0123456789023501120120820 109 | LATE SETTLEMENT FEE 110 | LATE SETTLEMENT FEE 111 | 112 | 1.00 113 | USD 114 | 115 | 116 | CASH 117 | 118 | 119 | 120 | DEP 121 | 20120831000000.000[-4:EDT] 122 | +00000000000000.1600 123 | 0123456789024401420120831 124 | INTEREST EARNED 125 | INTEREST EARNED 126 | 127 | 1.00 128 | USD 129 | 130 | 131 | CASH 132 | 133 | 134 | 135 | 136 | 137 | 138 | G7945E105 139 | CUSIP 140 | 141 | CASH 142 | LONG 143 | 128.00000 144 | 40.8700000 145 | +00000005231.36 146 | 20120908033034.000[-4:EDT] 147 | 148 | 1.0 149 | USD 150 | 151 | 152 | 153 | 154 | 155 | 156 | 19421R200 157 | CUSIP 158 | 159 | CASH 160 | LONG 161 | 70.57300 162 | 14.3200000 163 | +00000001010.60 164 | 20120908033034.000[-4:EDT] 165 | 166 | 1.0 167 | USD 168 | 169 | 170 | 171 | 172 | 173 | 174 | 431571108 175 | CUSIP 176 | 177 | CASH 178 | LONG 179 | 115.00000 180 | 18.9300000 181 | +00000002176.95 182 | 20120908033034.000[-4:EDT] 183 | 184 | 1.0 185 | USD 186 | 187 | 188 | 189 | 190 | 191 | 192 | 458140100 193 | CUSIP 194 | 195 | CASH 196 | LONG 197 | 100.91100 198 | 24.1900000 199 | +00000002441.03 200 | 20120908033034.000[-4:EDT] 201 | 202 | 1.0 203 | USD 204 | 205 | 206 | 207 | 208 | 209 | 210 | 756577102 211 | CUSIP 212 | 213 | CASH 214 | LONG 215 | 50.00000 216 | 59.1500000 217 | +00000002957.50 218 | 20120908033034.000[-4:EDT] 219 | 220 | 1.0 221 | USD 222 | 223 | 224 | 225 | 226 | 227 | 228 | 98417P105 229 | CUSIP 230 | 231 | CASH 232 | LONG 233 | 390.90900 234 | 2.8200000 235 | +00000001102.36 236 | 20120908033034.000[-4:EDT] 237 | 238 | 1.0 239 | USD 240 | 241 | 242 | 243 | 244 | 245 | 18073.98 246 | +00000000000.00 247 | +00000000000.00 248 | +00000000000.00 249 | 250 | 251 | Networth 252 | The net market value of all long and short positions in the account 253 | DOLLAR 254 | 32993.79 255 | 20120908033034.000[-4:EDT] 256 | 257 | 1.000 258 | USD 259 | 260 | 261 | 262 | Margin Equity 263 | The margin market value less any margin debit balance 264 | DOLLAR 265 | 0.0 266 | 20120908033034.000[-4:EDT] 267 | 268 | 1.000 269 | USD 270 | 271 | 272 | 273 | Margin Equity Percentage 274 | Margin equity / market value of long and short positions 275 | PERCENT 276 | 0.0 277 | 20120908033034.000[-4:EDT] 278 | 279 | 1.000 280 | USD 281 | 282 | 283 | 284 | Cash Debit Balance 285 | Cash Debit Balance 286 | DOLLAR 287 | 0.0 288 | 20120908033034.000[-4:EDT] 289 | 290 | 1.000 291 | USD 292 | 293 | 294 | 295 | Total Money Markets 296 | The total value of all money market positions in the cash account 297 | DOLLAR 298 | 18073.98 299 | 20120908033034.000[-4:EDT] 300 | 301 | 1.000 302 | USD 303 | 304 | 305 | 306 | House Surplus 307 | Equity amount above house requirements 308 | DOLLAR 309 | 0.0 310 | 20120908033034.000[-4:EDT] 311 | 312 | 1.000 313 | USD 314 | 315 | 316 | 317 | NYSE Surplus 318 | Equity amount above exchange requirements 319 | DOLLAR 320 | 0.0 321 | 20120908033034.000[-4:EDT] 322 | 323 | 1.000 324 | USD 325 | 326 | 327 | 328 | Federal Surplus 329 | Amount above federal requirements 330 | DOLLAR 331 | 0.0 332 | 20120908033034.000[-4:EDT] 333 | 334 | 1.000 335 | USD 336 | 337 | 338 | 339 | Buying Power - Equities 340 | Amount of equities you can buy on margin without generating a margin call 341 | DOLLAR 342 | 0.0 343 | 20120908033034.000[-4:EDT] 344 | 345 | 1.000 346 | USD 347 | 348 | 349 | 350 | Buying Power - Municipal Bonds 351 | Amount of municipal bonds you can buy on margin without generating a margin call 352 | DOLLAR 353 | 0.0 354 | 20120908033034.000[-4:EDT] 355 | 356 | 1.000 357 | USD 358 | 359 | 360 | 361 | Buying Power - Government Bonds 362 | Amount of government bonds you can buy on margin without generating a margin cal 363 | DOLLAR 364 | 0.0 365 | 20120908033034.000[-4:EDT] 366 | 367 | 1.000 368 | USD 369 | 370 | 371 | 372 | Buying Power - Corporate Bonds 373 | Amount you can buy of corporate bonds on margin with no margin call 374 | DOLLAR 375 | 0.0 376 | 20120908033034.000[-4:EDT] 377 | 378 | 1.000 379 | USD 380 | 381 | 382 | 383 | Option Market Value 384 | The market value of all options in the account 385 | DOLLAR 386 | 0.0 387 | 20120908033034.000[-4:EDT] 388 | 389 | 1.000 390 | USD 391 | 392 | 393 | 394 | Option In The Money Amount 395 | The in-the-money amount on covered options 396 | DOLLAR 397 | 0.0 398 | 20120908033034.000[-4:EDT] 399 | 400 | 1.000 401 | USD 402 | 403 | 404 | 405 | Cash Market value 406 | Total value of all cash account positions 407 | DOLLAR 408 | 14919.8 409 | 20120908033034.000[-4:EDT] 410 | 411 | 1.000 412 | USD 413 | 414 | 415 | 416 | Margin Market Value 417 | Total value of positions in margin less in-the-money amount of covered options 418 | DOLLAR 419 | 0.0 420 | 20120908033034.000[-4:EDT] 421 | 422 | 1.000 423 | USD 424 | 425 | 426 | 427 | Short Market Value 428 | Total value of short positions less in-the-money amount of covered put options 429 | DOLLAR 430 | 0.0 431 | 20120908033034.000[-4:EDT] 432 | 433 | 1.000 434 | USD 435 | 436 | 437 | 438 | Available to Borrow 439 | Cash amount that can be borrowed without generating a margin call 440 | DOLLAR 441 | 0.0 442 | 20120908033034.000[-4:EDT] 443 | 444 | 1.000 445 | USD 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | G7945E105 459 | CUSIP 460 | 461 | SEADRILL LTD USD2 462 | SDRL 463 | 20120908033034.000[-4:EDT] 464 | 465 | 1.000 466 | USD 467 | 468 | 469 | COMMON 470 | 20120908033034.000[-4:EDT] 471 | 472 | 473 | 474 | 475 | 19421R200 476 | CUSIP 477 | 478 | COLLECTORS UNIVERSE INC 479 | CLCT 480 | 20120908033034.000[-4:EDT] 481 | 482 | 1.000 483 | USD 484 | 485 | 486 | COMMON 487 | 20120908033034.000[-4:EDT] 488 | 489 | 490 | 491 | 492 | 431571108 493 | CUSIP 494 | 495 | HILLENBRAND INC COM 496 | HI 497 | 20120908033034.000[-4:EDT] 498 | 499 | 1.000 500 | USD 501 | 502 | 503 | COMMON 504 | 20120908033034.000[-4:EDT] 505 | 506 | 507 | 508 | 509 | 458140100 510 | CUSIP 511 | 512 | INTEL CORP 513 | INTC 514 | 20120908033034.000[-4:EDT] 515 | 516 | 1.000 517 | USD 518 | 519 | 520 | COMMON 521 | 20120908033034.000[-4:EDT] 522 | 523 | 524 | 525 | 526 | 756577102 527 | CUSIP 528 | 529 | RED HAT INC 530 | RHT 531 | 20120908033034.000[-4:EDT] 532 | 533 | 1.000 534 | USD 535 | 536 | 537 | COMMON 538 | 20120908033034.000[-4:EDT] 539 | 540 | 541 | 542 | 543 | 98417P105 544 | CUSIP 545 | 546 | XINYUAN REAL ESTATE ADR EACH REPR 2 ORD SHS 547 | XIN 548 | 20120908033034.000[-4:EDT] 549 | 550 | 1.000 551 | USD 552 | 553 | 554 | COMMON 555 | 20120908033034.000[-4:EDT] 556 | 557 | 558 | 559 | 560 | 78462F103 561 | CUSIP 562 | 563 | SPDR S&P 500 ETF TRUST UNIT SER 1 S&P 564 | SPY 565 | 20120908033034.000[-4:EDT] 566 | 567 | 1.000 568 | USD 569 | 570 | 571 | COMMON 572 | 20120908033034.000[-4:EDT] 573 | 574 | 575 | 576 | 577 | -------------------------------------------------------------------------------- /fixtures/income.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | SUCCESS 18 | 19 | 20150909084609.717[-6:MDT] 20 | ENG 21 | 22 | EXAMPLE 23 | 1234 24 | 25 | 1234 26 | 27 | 28 | 29 | 30 | 0 31 | 32 | 0 33 | INFO 34 | SUCCESS 35 | 36 | 37 | 20140630000000.000[-6:MDT] 38 | USD 39 | 40 | example.org 41 | 12345678.123456-01 42 | 43 | 44 | 20160908000000.000[-4:EDT] 45 | 20161008121253.321[-4:EDT] 46 | 47 | 48 | redacted 49 | 20161012000000.000[-4:EDT] 50 | DIVIDEND RECEIVED 51 | 52 | 53 | cusip_redacted 54 | CUSIP 55 | 56 | DIV 57 | +00000000001234.5600 58 | CASH 59 | CASH 60 | 61 | 1.00 62 | USD 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /fixtures/investment_401k.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | SUCCESS 18 | 19 | 20150909084609.717[-6:MDT] 20 | ENG 21 | 22 | EXAMPLE 23 | 1234 24 | 25 | 1234 26 | 27 | 28 | 29 | 30 | 0 31 | 32 | 0 33 | INFO 34 | SUCCESS 35 | 36 | 37 | 20140630000000.000[-6:MDT] 38 | USD 39 | 40 | example.org 41 | 12345678.123456-01 42 | 43 | 44 | 20140401000000.000[-6:MDT] 45 | 20140630000000.000[-6:MDT] 46 | 47 | 48 | 49 | 1 50 | 20140617000000.000[-6:MDT] 51 | 52 | 53 | FOO 54 | PRIVATE 55 | 56 | 8.846699 57 | 22.2908 58 | -197.2 59 | OTHER 60 | OTHER 61 | 62 | BUY 63 | 64 | 65 | 66 | 2 67 | 20140630000000.000[-6:MDT] 68 | 69 | 70 | BAR 71 | PRIVATE 72 | 73 | OTHER 74 | 6.800992 75 | IN 76 | LONG 77 | 29.214856 78 | 79 | 80 | 81 | 3 82 | 20140630000000.000[-6:MDT] 83 | 84 | 85 | BAZ 86 | PRIVATE 87 | 88 | OTHER 89 | -9.060702 90 | OUT 91 | LONG 92 | 21.928764 93 | 94 | 95 | 96 | 97 | 98 | 99 | FOO 100 | PRIVATE 101 | 102 | CASH 103 | LONG 104 | 17.604312 105 | 22.517211 106 | 396.4 107 | 20140630000000.000[-6:MDT] 108 | 109 | 110 | 111 | 112 | 113 | BAR 114 | PRIVATE 115 | 116 | CASH 117 | LONG 118 | 13.550983 119 | 29.214855 120 | 395.89 121 | 20140630000000.000[-6:MDT] 122 | 123 | 124 | 125 | 126 | 127 | BAZ 128 | PRIVATE 129 | 130 | CASH 131 | LONG 132 | 0.0 133 | 0.0 134 | 0.0 135 | 20140630000000.000[-6:MDT] 136 | 137 | 138 | 139 | 140 | 1000.00 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | BAR 151 | PRIVATE 152 | 153 | BAR Index Fund 154 | BAR 155 | 156 | 157 | 158 | 159 | 160 | FOO 161 | PRIVATE 162 | 163 | Foo Index Fund 164 | FOO 165 | 166 | 167 | 168 | 169 | 170 | BAZ 171 | PRIVATE 172 | 173 | Baz Fund 174 | BAZ 175 | 176 | 177 | 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /fixtures/mint.csv: -------------------------------------------------------------------------------- 1 | "Date","Description","Original Description","Amount","Transaction Type","Category","Account Name","Labels","Notes" 2 | "8/02/2016","Amazon","AMAZON MKTPLACE PMTS AMZN.COM/BILL WA","29.99","debit","Shopping","1234","","" 3 | "6/02/2016","Autopay Rautopay Auto","AUTOPAY 000000000000000RAUTOPAY AUTO-PMT","123.45","credit","Credit Card Payment","1234","","" 4 | -------------------------------------------------------------------------------- /fixtures/multiple.lgr: -------------------------------------------------------------------------------- 1 | 2011/03/01 Baz 2 | Foo $21.01 3 | Bar 4 | 5 | 2011/04/01 Baz 6 | Foo $10.01 7 | Bar 8 | -------------------------------------------------------------------------------- /fixtures/no-institution.ofx: -------------------------------------------------------------------------------- 1 | OFXHEADER:100 2 | DATA:OFXSGML 3 | VERSION:102 4 | SECURITY:NONE 5 | ENCODING:USASCII 6 | CHARSET:1252 7 | COMPRESSION:NONE 8 | OLDFILEUID:NONE 9 | NEWFILEUID:NONE 10 | 11 | 12 | 13 | 14 | 15 | 0 16 | INFO 17 | 18 | 20130525225731.258 19 | ENG 20 | 20050531060000.000 21 | 51123 22 | 9774652 23 | 24 | 25 | 26 | 27 | 0 28 | 29 | 0 30 | INFO 31 | 32 | 33 | USD 34 | 35 | 5472369148 36 | 1452687~7 37 | CHECKING 38 | 39 | 40 | 20000101070000.000 41 | 20130525060000.000 42 | 43 | CREDIT 44 | 20110331120000.000 45 | 0.01 46 | 0000486 47 | DIVIDEND EARNED FOR PERIOD OF 03 48 | DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 49 | 50 | 51 | DEBIT 52 | 20110405120000.000 53 | -34.51 54 | 0000487 55 | AUTOMATIC WITHDRAWAL, ELECTRIC BILL 56 | AUTOMATIC WITHDRAWAL, ELECTRIC BILL WEB(S ) 57 | 58 | 59 | CHECK 60 | 20110407120000.000 61 | -25.00 62 | 0000488 63 | 319 64 | RETURNED CHECK FEE, CHECK # 319 65 | RETURNED CHECK FEE, CHECK # 319 FOR $45.33 ON 04/07/11 66 | 67 | 68 | 69 | 100.99 70 | 20130525225731.258 71 | 72 | 73 | 75.99 74 | 20130525225731.258 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /fixtures/ofxclient.ini: -------------------------------------------------------------------------------- 1 | [FOO] 2 | account_type = SAVINGS 3 | description = Assets:Savings:Foo 4 | institution.id = FOOBANK 5 | institution.org = FOO 6 | institution.password = SECRET 7 | institution.url = https://foo.example.org 8 | institution.username = ME 9 | local_id = FOO 10 | number = 0123456789 11 | routing_number = 0123456789 12 | 13 | [BAR] 14 | account_type = CHECKING 15 | description = Assets:Checking:Bar 16 | institution.id = FOOBANK 17 | institution.org = FOO 18 | institution.password = SECRET 19 | institution.url = https://foo.example.org 20 | institution.username = ME 21 | local_id = BAR 22 | number = 0123456798 23 | routing_number = 0123456789 24 | -------------------------------------------------------------------------------- /fixtures/paypal.csv: -------------------------------------------------------------------------------- 1 | Date, Time, Time Zone, Name, Type, Status, Currency, Gross, Fee, Net, From Email Address, To Email Address, Transaction ID, Counterparty Status, Shipping Address, Address Status, Item Title, Item ID, Shipping and Handling Amount, Insurance Amount, Sales Tax, Option 1 Name, Option 1 Value, Option 2 Name, Option 2 Value, Auction Site, Buyer ID, Item URL, Closing Date, Escrow Id, Invoice Id, Reference Txn ID, Invoice Number, Custom Number, Receipt ID, Balance, Contact Phone Number, 2 | "6/4/2016","10:46:49","PDT","Jane Doe","Recurring Payment Sent","Completed","USD","-20.00","0.00","-20.00","me@example.com","someone@example.net","XYZ1","Verified","John Doe, 123 Main St, Springfield, XX 00000, United States","Confirmed","My Friend","friend","","","","","","","","","","","","","","","","","","0.00","", 3 | "6/4/2016","10:46:49","PDT","Debit Card","Charge From Debit Card","Completed","USD","1,120.00","0.00","1,120.00","","","XYZ2","","","","","","","","","","","","","","","","","","","XYZ3","","","","20.00","", 4 | -------------------------------------------------------------------------------- /fixtures/paypal.lgr: -------------------------------------------------------------------------------- 1 | 2016/06/04 Jane Doe someone@example.net My Friend ID: XYZ1, Recurring Payment Sent 2 | ; csvid: paypal.XYZ1 3 | Paypal -20.00 USD 4 | Expenses:Misc 20.00 USD 5 | -------------------------------------------------------------------------------- /fixtures/paypal_alternate.csv: -------------------------------------------------------------------------------- 1 | Date, Time, Time Zone, Name, Type, Status, Amount, Receipt ID, Balance, 2 | "12/31/2016","23:59:59","PST","Some User","Payment Sent","Completed","-12.34","","0.00", 3 | "12/31/2016","23:59:59","PST","Bank Account","Add Funds from a Bank Account","Completed","12.34","","12.34", 4 | -------------------------------------------------------------------------------- /fixtures/venmo.csv: -------------------------------------------------------------------------------- 1 | Username,ID,Datetime,Type,Status,Note,From,To,Amount (total),Amount (fee),Funding Source,Destination,Beginning Balance,Ending Balance,Statement Period Venmo Fees,Year to Date Venmo Fees,Disclaimer 2 | my_username,,,,,,,,,,,,"$0",,,, 3 | ,1,2020-01-01T13:00:00,Charge,Complete,foobar charge positive,Me,Friend A,+ $73.01,,,Venmo balance,,,,, 4 | ,2,2020-01-02T17:00:00,Payment,Complete,foobar payment positive,Friend B,My Name,+ $50.03,,,Venmo balance,,,,, 5 | ,3,2020-01-03T20:00:00,Payment,Complete,foobar payment negative,Me,Friend A,- $12.01,,Venmo balance,,,,,, 6 | ,4,2020-01-04T04:00:00,Charge,Complete,foobar charge negative,Friend B,Me,- $35.02,,Venmo balance,,,,,, 7 | ,,,,,,,,,,,,,"$76.01",,, 8 | ,,,,,,,,,,,,,,$0.00,, 9 | ,,,,,,,,,,,,,,,$0.00, 10 | ,,,,,,,,,,,,,,,,"In case of errors or questions about your 11 | electronic transfers: 12 | - Telephone us at 855-812-4430 13 | - Write the Venmo Error Resolution Department at 14 | 222 W. Merchandise Plaza, Suite 800, Chicago, IL 60654; or 15 | - Write to us through the Contact Us page 16 | (https://help.venmo.com/hc/en-us/requests/new) 17 | Contact us as soon as you can if you think your statement or 18 | receipt is wrong or if you need more information about 19 | a transfer on the statement or receipt. We must hear from 20 | you no later than 60 days after we sent you the FIRST 21 | statement on which the error or problem appeared. 22 | 1. Tell us your name and username or phone number. 23 | 2. Describe the error or the transfer you are unsure about, 24 | and explain as clearly as you can why you believe it is 25 | an error or why you need more information. 26 | 3. Tell us the dollar amount of the suspected error. 27 | We will investigate your complaint and will correct any 28 | error promptly. If we take more than 10 business days to do 29 | this, we will credit your account for the amount you think 30 | is in error, so that you will have the use of the money 31 | during the time it takes us to complete our investigation. 32 | In case of errors or questions about your transactions made 33 | with your Venmo Mastercard, please consult your 34 | Cardholder Agreement 35 | (https://venmo.com/legal/bancorp-cardholder-agreement). 36 | " 37 | -------------------------------------------------------------------------------- /ledgerautosync/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2021 Erik Hetzner 2 | # 3 | # This file is part of ledger-autosync 4 | # 5 | # ledger-autosync is free software: you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # ledger-autosync is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with ledger-autosync. If not, see 17 | # . 18 | 19 | 20 | class EmptyInstitutionException(Exception): 21 | def __init__(self, value): 22 | self.value = value 23 | 24 | def __str__(self): 25 | return repr(self.value) 26 | 27 | 28 | class LedgerAutosyncException(Exception): 29 | def __init__(self, value): 30 | self.value = value 31 | 32 | def __str__(self): 33 | return repr(self.value) 34 | -------------------------------------------------------------------------------- /ledgerautosync/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright (c) 2013-2021 Erik Hetzner 4 | # Portions Copyright (c) 2016 James S Blachly, MD 5 | # 6 | # This file is part of ledger-autosync 7 | # 8 | # ledger-autosync is free software: you can redistribute it and/or 9 | # modify it under the terms of the GNU General Public License as 10 | # published by the Free Software Foundation, either version 3 of the 11 | # License, or (at your option) any later version. 12 | # 13 | # ledger-autosync is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 16 | # General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with ledger-autosync. If not, see 20 | # . 21 | 22 | 23 | import argparse 24 | import importlib.util 25 | import logging 26 | import os 27 | import os.path 28 | import re 29 | import sys 30 | import traceback 31 | 32 | from ofxclient.config import OfxConfig 33 | 34 | from ledgerautosync import LedgerAutosyncException 35 | from ledgerautosync.converter import ( 36 | ALL_AUTOSYNC_INITIAL, 37 | AUTOSYNC_INITIAL, 38 | UNKNOWN_BANK_ACCOUNT, 39 | OfxConverter, 40 | SecurityList, 41 | ) 42 | from ledgerautosync.ledgerwrap import HLedger, Ledger, LedgerPython, mk_ledger 43 | from ledgerautosync.sync import CsvSynchronizer, OfxSynchronizer 44 | 45 | 46 | def find_ledger_file(ledgerrcpath=None): 47 | """Returns main ledger file path or raise exception if it cannot be \ 48 | found.""" 49 | if ledgerrcpath is None: 50 | ledgerrcpath = os.path.abspath(os.path.expanduser("~/.ledgerrc")) 51 | if "LEDGER_FILE" in os.environ: 52 | return os.path.abspath(os.path.expanduser(os.environ["LEDGER_FILE"])) 53 | elif os.path.exists(ledgerrcpath): 54 | # hacky 55 | ledgerrc = open(ledgerrcpath) 56 | for line in ledgerrc.readlines(): 57 | md = re.match(r"--file\s+([^\s]+).*", line) 58 | if md is not None: 59 | return os.path.abspath(os.path.expanduser(md.group(1))) 60 | else: 61 | return None 62 | 63 | 64 | def print_results(converter, ofx, ledger, txns, args): 65 | """ 66 | This function is the final common pathway of program: 67 | 68 | Print initial balance if requested; 69 | Print transactions surviving de-duplication filter; 70 | Print balance assertions if requested; 71 | Print commodity prices obtained from position statements 72 | """ 73 | 74 | if args.initial: 75 | if not ( 76 | ledger.check_transaction_by_id( 77 | "ofxid", converter.mk_ofxid(AUTOSYNC_INITIAL) 78 | ) 79 | ) and not (ledger.check_transaction_by_id("ofxid", ALL_AUTOSYNC_INITIAL)): 80 | print(converter.format_initial_balance(ofx.account.statement)) 81 | for txn in txns: 82 | print(converter.convert(txn).format(args.indent)) 83 | if args.assertions: 84 | print(converter.format_balance(ofx.account.statement)) 85 | 86 | # if OFX has positions use these to obtain commodity prices 87 | # and print "P" records to provide dated/timed valuations 88 | # Note that this outputs only the commodity price, 89 | # not your position (e.g. # shares), even though this is in the OFX record 90 | if hasattr(ofx.account.statement, "positions"): 91 | for pos in ofx.account.statement.positions: 92 | print(converter.format_position(pos)) 93 | 94 | 95 | def make_ofx_converter( 96 | account, 97 | name, 98 | ledger, 99 | indent, 100 | fid, 101 | unknownaccount, 102 | payee_format, 103 | hardcodeaccount, 104 | shortenaccount, 105 | security_list, 106 | date_format, 107 | infer_account, 108 | ): 109 | klasses = OfxConverter.__subclasses__() 110 | if len(klasses) > 1: 111 | raise Exception( 112 | "I found more than 1 OfxConverter subclass, but only " 113 | "know how to handle 1. Remove extra subclasses from " 114 | "the plugin directory" 115 | ) 116 | elif len(klasses) == 1: 117 | return klasses[0]( 118 | account=account, 119 | name=name, 120 | ledger=ledger, 121 | indent=indent, 122 | fid=fid, 123 | unknownaccount=unknownaccount, 124 | payee_format=payee_format, 125 | hardcodeaccount=hardcodeaccount, 126 | shortenaccount=shortenaccount, 127 | security_list=security_list, 128 | date_format=date_format, 129 | infer_account=infer_account, 130 | ) 131 | else: 132 | return OfxConverter( 133 | account=account, 134 | name=name, 135 | ledger=ledger, 136 | indent=indent, 137 | fid=fid, 138 | unknownaccount=unknownaccount, 139 | payee_format=payee_format, 140 | hardcodeaccount=hardcodeaccount, 141 | shortenaccount=shortenaccount, 142 | security_list=security_list, 143 | date_format=date_format, 144 | infer_account=infer_account, 145 | ) 146 | 147 | 148 | def sync(ledger, accounts, args): 149 | sync = OfxSynchronizer(ledger, shortenaccount=args.shortenaccount) 150 | for acct in accounts: 151 | try: 152 | (ofx, txns) = sync.get_new_txns(acct, resync=args.resync, max_days=args.max) 153 | if ofx is not None: 154 | converter = make_ofx_converter( 155 | account=ofx.account, 156 | name=acct.description, 157 | ledger=ledger, 158 | indent=args.indent, 159 | fid=None, 160 | unknownaccount=args.unknownaccount, 161 | payee_format=args.payee_format, 162 | hardcodeaccount=None, 163 | shortenaccount=args.shortenaccount, 164 | security_list=SecurityList(ofx), 165 | date_format=args.date_format, 166 | infer_account=args.infer_account, 167 | ) 168 | print_results(converter, ofx, ledger, txns, args) 169 | except KeyboardInterrupt: 170 | raise 171 | except BaseException: 172 | sys.stderr.write("Caught exception processing %s\n" % (acct.description)) 173 | traceback.print_exc(file=sys.stderr) 174 | 175 | 176 | def import_ofx(ledger, args): 177 | sync = OfxSynchronizer( 178 | ledger, hardcodeaccount=args.hardcodeaccount, shortenaccount=args.shortenaccount 179 | ) 180 | ofx = OfxSynchronizer.parse_file(args.PATH) 181 | txns = sync.filter(ofx.account.statement.transactions, ofx.account.account_id) 182 | accountname = args.account 183 | if accountname is None: 184 | if ofx.account.institution is not None: 185 | accountname = "%s:%s" % ( 186 | ofx.account.institution.organization, 187 | ofx.account.account_id, 188 | ) 189 | else: 190 | accountname = UNKNOWN_BANK_ACCOUNT 191 | 192 | # build SecurityList (including indexing by CUSIP and ticker symbol) 193 | security_list = SecurityList(ofx) 194 | 195 | converter = make_ofx_converter( 196 | account=ofx.account, 197 | name=accountname, 198 | ledger=ledger, 199 | indent=args.indent, 200 | fid=args.fid, 201 | unknownaccount=args.unknownaccount, 202 | payee_format=args.payee_format, 203 | hardcodeaccount=args.hardcodeaccount, 204 | shortenaccount=args.shortenaccount, 205 | security_list=security_list, 206 | date_format=args.date_format, 207 | infer_account=args.infer_account, 208 | ) 209 | print_results(converter, ofx, ledger, txns, args) 210 | 211 | 212 | def import_csv(ledger, args): 213 | if args.account is None: 214 | raise Exception("When importing a CSV file, you must specify an account name.") 215 | sync = CsvSynchronizer( 216 | ledger, payee_format=args.payee_format, date_format=args.date_format 217 | ) 218 | txns = sync.parse_file( 219 | args.PATH, accountname=args.account, unknownaccount=args.unknownaccount 220 | ) 221 | if args.reverse: 222 | txns = reversed(txns) 223 | for txn in txns: 224 | if txn is not None: 225 | print(txn.format(args.indent, args.assertions)) 226 | 227 | 228 | def load_plugins(config_dir): 229 | plugin_dir = os.path.join(config_dir, "ledger-autosync", "plugins") 230 | if os.path.isdir(plugin_dir): 231 | for plugin in filter( 232 | re.compile(".py$", re.IGNORECASE).search, os.listdir(plugin_dir) 233 | ): 234 | # Quiet loader 235 | import ledgerautosync.plugins # noqa: F401 236 | 237 | file_path = os.path.join(plugin_dir, plugin) 238 | module_name = f"ledgerautosync.plugins.{os.path.splitext(plugin)[0]}" 239 | spec = importlib.util.spec_from_file_location(module_name, file_path) 240 | module = importlib.util.module_from_spec(spec) 241 | sys.modules[module_name] = module 242 | spec.loader.exec_module(module) 243 | 244 | 245 | def run(args=None, config=None): 246 | if args is None: 247 | args = sys.argv[1:] 248 | 249 | parser = argparse.ArgumentParser(description="Synchronize ledger.") 250 | parser.add_argument( 251 | "-m", "--max", type=int, default=90, help="maximum number of days to process" 252 | ) 253 | parser.add_argument( 254 | "-r", 255 | "--resync", 256 | action="store_true", 257 | default=False, 258 | help="do not stop until max days reached", 259 | ) 260 | parser.add_argument( 261 | "PATH", 262 | nargs="?", 263 | help="do not sync; import from OFX \ 264 | file", 265 | ) 266 | parser.add_argument( 267 | "-a", 268 | "--account", 269 | type=str, 270 | default=None, 271 | help="sync only the named account; \ 272 | if importing from file, set account name for import", 273 | ) 274 | parser.add_argument( 275 | "-l", 276 | "--ledger", 277 | type=str, 278 | default=None, 279 | help="specify ledger file to READ for syncing", 280 | ) 281 | parser.add_argument( 282 | "--rules", 283 | type=str, 284 | default=None, 285 | help="specify rule file to READ for Payee matching", 286 | ) 287 | parser.add_argument( 288 | "-L", 289 | "--no-ledger", 290 | dest="no_ledger", 291 | action="store_true", 292 | default=False, 293 | help="do not de-duplicate against a ledger file", 294 | ) 295 | parser.add_argument( 296 | "-i", 297 | "--indent", 298 | type=int, 299 | default=4, 300 | help="number of spaces to use for indentation", 301 | ) 302 | parser.add_argument( 303 | "--initial", 304 | action="store_true", 305 | default=False, 306 | help="create initial balance entries", 307 | ) 308 | parser.add_argument( 309 | "--fid", 310 | type=int, 311 | default=None, 312 | help="pass in fid value for OFX files that do not \ 313 | supply it", 314 | ) 315 | parser.add_argument( 316 | "--hardcode-account", 317 | type=str, 318 | default=None, 319 | dest="hardcodeaccount", 320 | help="pass in hardcoded account number for OFX files \ 321 | to maintain ledger files without real account numbers", 322 | ) 323 | parser.add_argument( 324 | "--shorten-account", 325 | default=False, 326 | action="store_true", 327 | dest="shortenaccount", 328 | help="shorten all account numbers to last 4 digits \ 329 | to maintain ledger files without full account numbers", 330 | ) 331 | parser.add_argument( 332 | "--unknown-account", 333 | type=str, 334 | dest="unknownaccount", 335 | default=None, 336 | help="specify account name to use when one can't be \ 337 | found by payee", 338 | ) 339 | parser.add_argument( 340 | "--assertions", 341 | action="store_true", 342 | default=False, 343 | help="create balance assertion entries", 344 | ) 345 | parser.add_argument( 346 | "-d", "--debug", action="store_true", default=False, help="enable debug logging" 347 | ) 348 | parser.add_argument( 349 | "--hledger", 350 | action="store_true", 351 | default=False, 352 | help="force use of hledger (on by default if invoked \ 353 | as hledger-autosync)", 354 | ) 355 | parser.add_argument( 356 | "--payee-format", 357 | type=str, 358 | default=None, 359 | dest="payee_format", 360 | help="""Format string to use for generating the payee line. Substitutions 361 | can be written using {memo}, {payee}, {txntype}, {account} or 362 | {tferaction} for OFX. If the input file is a CSV file, 363 | substitutions are written using the CSV file column names 364 | between {}.""", 365 | ) 366 | parser.add_argument( 367 | "--python", 368 | action="store_true", 369 | default=False, 370 | help="use the ledger python interface", 371 | ) 372 | parser.add_argument( 373 | "--slow", 374 | action="store_true", 375 | default=False, 376 | help="use slow, but possibly more robust, method of \ 377 | calling ledger (no subprocess)", 378 | ) 379 | parser.add_argument( 380 | "--which", 381 | action="store_true", 382 | default=False, 383 | help="display which version of ledger (cli), hledger, \ 384 | or ledger (python) will be used by ledger-autosync to check for previous \ 385 | transactions", 386 | ) 387 | parser.add_argument( 388 | "--reverse", 389 | action="store_true", 390 | default=False, 391 | help="print CSV transactions in reverse order", 392 | ) 393 | parser.add_argument( 394 | "-o", 395 | "--ofxconfig", 396 | type=str, 397 | default=None, 398 | help="specify config file for ofxclient", 399 | ) 400 | parser.add_argument( 401 | "-y", 402 | "--date-format", 403 | type=str, 404 | default=None, 405 | dest="date_format", 406 | help="""Format string to use for printing dates. 407 | See strftime for details on format string syntax. Default is "%%Y/%%m/%%d".""", 408 | ) 409 | parser.add_argument( 410 | "--no-infer-account", 411 | dest="infer_account", 412 | action="store_false", 413 | default=True, 414 | help="disable inference of offset account from payee", 415 | ) 416 | args = parser.parse_args(args) 417 | if sys.argv[0][-16:] == "hledger-autosync": 418 | args.hledger = True 419 | 420 | ledger_file = None 421 | if args.ledger and args.no_ledger: 422 | raise LedgerAutosyncException("You cannot specify a ledger file and -L") 423 | elif args.ledger: 424 | ledger_file = args.ledger 425 | else: 426 | ledger_file = find_ledger_file() 427 | if args.debug: 428 | logging.basicConfig(level=logging.DEBUG) 429 | 430 | if ledger_file is None: 431 | sys.stderr.write( 432 | "LEDGER_FILE environment variable not set, and no \ 433 | .ledgerrc file found, and -l argument was not supplied: running with deduplication disabled. \ 434 | All transactions will be printed!\n" 435 | ) 436 | ledger = None 437 | elif args.no_ledger: 438 | ledger = None 439 | elif args.hledger: 440 | ledger = HLedger(ledger_file) 441 | elif args.python: 442 | ledger = LedgerPython(ledger_file=ledger_file) 443 | elif args.slow: 444 | ledger = Ledger(ledger_file=ledger_file, no_pipe=True) 445 | else: 446 | ledger = mk_ledger(ledger_file) 447 | 448 | if args.which: 449 | sys.stderr.write("ledger-autosync is using ") 450 | if isinstance(ledger, Ledger): 451 | sys.stderr.write("ledger (cli)\n") 452 | elif isinstance(ledger, HLedger): 453 | sys.stderr.write("hledger\n") 454 | elif isinstance(ledger, LedgerPython): 455 | sys.stderr.write("ledger.so (python)\n") 456 | exit() 457 | 458 | config_dir = os.environ.get( 459 | "XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config") 460 | ) 461 | 462 | if args.rules and os.path.exists(args.rules): 463 | with open(args.rules) as f: 464 | for line in f: 465 | regex, account = line.strip().split("\t") 466 | ledger.add_rule(re.compile(regex, re.IGNORECASE), account) 467 | 468 | load_plugins(config_dir) 469 | 470 | if args.PATH is None: 471 | if config is None: 472 | if args.ofxconfig is None: 473 | config_file = os.path.join(config_dir, "ofxclient.ini") 474 | else: 475 | config_file = args.ofxconfig 476 | if os.path.exists(config_file): 477 | config = OfxConfig(file_name=config_file) 478 | else: 479 | config = OfxConfig() 480 | accounts = config.accounts() 481 | if args.account: 482 | accounts = [acct for acct in accounts if acct.description == args.account] 483 | sync(ledger, accounts, args) 484 | else: 485 | _, file_extension = os.path.splitext(args.PATH.lower()) 486 | if file_extension == ".csv": 487 | import_csv(ledger, args) 488 | else: 489 | import_ofx(ledger, args) 490 | 491 | 492 | if __name__ == "__main__": 493 | run() 494 | -------------------------------------------------------------------------------- /ledgerautosync/ledgerwrap.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2021 Erik Hetzner 2 | # 3 | # This file is part of ledger-autosync 4 | # 5 | # ledger-autosync is free software: you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # ledger-autosync is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with ledger-autosync. If not, see 17 | # . 18 | 19 | 20 | import csv 21 | import logging 22 | import os 23 | import re 24 | import subprocess 25 | from queue import Empty, Queue 26 | from shutil import which as find_executable 27 | from subprocess import PIPE, Popen 28 | from threading import Thread 29 | 30 | from ledgerautosync.converter import Converter 31 | 32 | csv.register_dialect("ledger", delimiter=",", quoting=csv.QUOTE_ALL, escapechar="\\") 33 | 34 | 35 | def mk_ledger(ledger_file): 36 | if Ledger.available(): 37 | return Ledger(ledger_file) 38 | elif HLedger.available(): 39 | return HLedger(ledger_file) 40 | elif LedgerPython.available(): 41 | # string_read=True works around 42 | # http://bugs.ledger-cli.org/show_bug.cgi?id=973 43 | return LedgerPython(ledger_file, string_read=True) 44 | else: 45 | raise Exception("Neither ledger 3 nor hledger found!") 46 | 47 | 48 | class MetaLedger(object): 49 | @staticmethod 50 | def windows_clean(a): 51 | def clean_str(s): 52 | s = s.replace("%", "") 53 | s = s.replace(" ", "\\ ") 54 | s = s.replace("/", "\\/") 55 | return s 56 | 57 | return [clean_str(s) for s in a] 58 | 59 | # Return True if this ledgerlike interface is available 60 | @staticmethod 61 | def available(): 62 | return False 63 | 64 | def add_payee(self, payee, account): 65 | if payee not in self.payees: 66 | self.payees[payee] = [] 67 | if account not in self.payees[payee]: 68 | self.payees[payee].append(account) 69 | 70 | def filter_accounts(self, accts, exclude): 71 | accts_filtered = [a for a in accts if a != exclude] 72 | if accts_filtered: 73 | return accts_filtered[-1] 74 | else: 75 | return None 76 | 77 | def get_account_by_payee(self, payee, exclude): 78 | for regex, account in self.rules: 79 | if regex.match(payee): 80 | return account 81 | 82 | self.load_payees() 83 | return self.filter_accounts(self.payees.get(payee, []), exclude) 84 | 85 | def add_rule(self, regex, account): 86 | self.rules.append((regex, account)) 87 | 88 | def __init__(self): 89 | self.payees = None 90 | self.rules = [] 91 | 92 | 93 | class Ledger(MetaLedger): 94 | @staticmethod 95 | def available(): 96 | return (find_executable("ledger") is not None) and ( 97 | Popen( 98 | ["ledger", "--version"], stdout=PIPE, universal_newlines=True 99 | ).communicate()[0] 100 | ).startswith("Ledger 3") 101 | 102 | def __init__(self, ledger_file=None, no_pipe=True): 103 | if find_executable("ledger") is None: 104 | raise Exception("ledger was not found in $PATH") 105 | self._item = "" 106 | 107 | def enqueue_output(out, queue): 108 | buff = "" 109 | while buff is not None: 110 | buff = out.read(1) 111 | if buff is not None: 112 | self._item += buff 113 | if self._item.endswith("] "): # prompt 114 | queue.put(self._item[0:-2]) 115 | self._item = "" 116 | out.close() 117 | 118 | self.use_pipe = (os.name == "posix") and not (no_pipe) 119 | self.args = ["ledger", "--args-only"] 120 | if ledger_file is not None: 121 | self.args += ["-f", ledger_file] 122 | if self.use_pipe: 123 | self.p = Popen( 124 | self.args, 125 | bufsize=1, 126 | stdin=PIPE, 127 | stdout=PIPE, 128 | universal_newlines=True, 129 | close_fds=True, 130 | ) 131 | self.q = Queue() 132 | self.t = Thread(target=enqueue_output, args=(self.p.stdout, self.q)) 133 | self.t.daemon = True # thread dies with the program 134 | self.t.start() 135 | # read output until prompt 136 | try: 137 | self.q.get(True, 5) 138 | except Empty: 139 | logging.error("Could not get prompt (]) from ledger!") 140 | logging.error("Received: %s" % (self._item)) 141 | exit(1) 142 | super(Ledger, self).__init__() 143 | 144 | @staticmethod 145 | def pipe_quote(a): 146 | def quote(s): 147 | s = s.replace("/", "\\\\/") 148 | s = s.replace("%", "") 149 | if not (re.match(r"^\w+$", s)): 150 | s = '"%s"' % (s) 151 | return s 152 | 153 | return [quote(s) for s in a] 154 | 155 | def run(self, cmd): 156 | if self.use_pipe: 157 | self.p.stdin.write("csv ") 158 | self.p.stdin.write(" ".join(Ledger.pipe_quote(cmd))) 159 | self.p.stdin.write("\n") 160 | logging.debug(" ".join(Ledger.pipe_quote(cmd))) 161 | try: 162 | return csv.reader(self.q.get(True, 5), dialect="ledger") 163 | except Empty: 164 | logging.error("Could not get prompt from ledger!") 165 | exit(1) 166 | else: 167 | cmd = self.args + ["csv"] + cmd 168 | if os.name == "nt": 169 | cmd = MetaLedger.windows_clean(cmd) 170 | return csv.reader( 171 | subprocess.check_output(cmd, universal_newlines=True).splitlines(), 172 | dialect="ledger", 173 | ) 174 | 175 | def check_transaction_by_id(self, key, value): 176 | q = ["-E", "meta", "%s=%s" % (key, Converter.clean_id(value))] 177 | try: 178 | next(self.run(q)) 179 | return True 180 | except StopIteration: 181 | return False 182 | 183 | def load_payees(self): 184 | if self.payees is None: 185 | self.payees = {} 186 | r = self.run(["show", "--actual"]) 187 | for line in r: 188 | self.add_payee(line[2], line[3]) 189 | 190 | def get_autosync_payee(self, payee, account): 191 | q = [ 192 | account, 193 | "--last", 194 | "1", 195 | "--format", 196 | "%(quoted(payee))\n", 197 | "--limit", 198 | 'tag("AutosyncPayee") == "%s"' % (payee), 199 | ] 200 | r = self.run(q) 201 | try: 202 | return next(r)[0] 203 | except StopIteration: 204 | return payee 205 | 206 | 207 | class LedgerPython(MetaLedger): 208 | @staticmethod 209 | def available(): 210 | try: 211 | import ledger # noqa: F401 212 | 213 | return True 214 | except ImportError: 215 | return False 216 | 217 | def __init__(self, ledger_file=None, string_read=True): 218 | # sanity check for ledger python interface 219 | try: 220 | import ledger 221 | except ImportError: 222 | raise Exception("Ledger python interface not found!") 223 | if ledger_file is None: 224 | # TODO - better loading 225 | raise Exception 226 | else: 227 | if string_read: 228 | self.session = ledger.Session() 229 | self.journal = self.session.read_journal_from_string( 230 | open(ledger_file).read() 231 | ) 232 | else: 233 | self.journal = ledger.read_journal(ledger_file) 234 | 235 | super(LedgerPython, self).__init__() 236 | 237 | def load_payees(self): 238 | if self.payees is None: 239 | self.payees = {} 240 | for xact in self.journal: 241 | for post in xact.posts(): 242 | self.add_payee(xact.payee, post.reported_account().fullname()) 243 | 244 | def check_transaction_by_id(self, key, value): 245 | q = self.journal.query('-E meta %s="%s"' % (key, Converter.clean_id(value))) 246 | return len(q) > 0 247 | 248 | def get_autosync_payee(self, payee, account): 249 | logging.error("payee lookup not implemented for LedgerPython, using raw payee") 250 | return payee 251 | 252 | 253 | class HLedger(MetaLedger): 254 | @staticmethod 255 | def available(): 256 | return find_executable("hledger") is not None 257 | 258 | @staticmethod 259 | def quote(a): 260 | def quote_str(s): 261 | s = s.replace("(", "\\(") 262 | s = s.replace(")", "\\)") 263 | return s 264 | 265 | return [quote_str(s) for s in a] 266 | 267 | def __init__(self, ledger_file=None): 268 | if find_executable("hledger") is None: 269 | raise Exception("hledger was not found in $PATH") 270 | self.args = ["hledger"] 271 | if ledger_file is not None: 272 | self.args += ["-f", ledger_file] 273 | super(HLedger, self).__init__() 274 | 275 | def run(self, cmd): 276 | cmd = HLedger.quote(self.args + cmd) 277 | if os.name == "nt": 278 | cmd = MetaLedger.windows_clean(cmd) 279 | logging.debug(" ".join(cmd)) 280 | return subprocess.check_output(cmd, universal_newlines=True) 281 | 282 | def check_transaction_by_id(self, key, value): 283 | cmd = ["reg", "tag:%s=%s" % (key, Converter.clean_id(value))] 284 | return self.run(cmd) != "" 285 | 286 | def load_payees(self): 287 | if self.payees is None: 288 | self.payees = {} 289 | cmd = ["reg", "-O", "csv", "--real"] 290 | r = csv.DictReader(self.run(cmd).splitlines()) 291 | for line in r: 292 | self.add_payee(line["description"], line["account"]) 293 | 294 | def get_autosync_payee(self, payee, account): 295 | logging.error("payee lookup not implemented for HLedger, using raw payee") 296 | return payee 297 | -------------------------------------------------------------------------------- /ledgerautosync/plugins.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egh/ledger-autosync/ffa3fc66050f3c194a7716905103146063d7cad4/ledgerautosync/plugins.py -------------------------------------------------------------------------------- /ledgerautosync/sync.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2021 Erik Hetzner 2 | # 3 | # This file is part of ledger-autosync 4 | # 5 | # ledger-autosync is free software: you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # ledger-autosync is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with ledger-autosync. If not, see 17 | # . 18 | 19 | 20 | import codecs 21 | import csv 22 | import logging 23 | 24 | from ofxparse import OfxParser, OfxParserException 25 | 26 | from ledgerautosync.converter import CsvConverter 27 | 28 | 29 | class Synchronizer(object): 30 | def __init__(self, lgr): 31 | self.lgr = lgr 32 | 33 | 34 | class OfxSynchronizer(Synchronizer): 35 | def __init__(self, lgr, hardcodeaccount=None, shortenaccount=None): 36 | self.hardcodeaccount = hardcodeaccount 37 | self.shortenaccount = shortenaccount 38 | super(OfxSynchronizer, self).__init__(lgr) 39 | 40 | @staticmethod 41 | def parse_file(path): 42 | with open(path, "rb") as ofx_file: 43 | return OfxParser.parse(ofx_file) 44 | 45 | def is_txn_synced(self, acctid, txn): 46 | if self.lgr is None: 47 | # User called with --no-ledger 48 | # All transactions are considered "synced" in this case. 49 | return False 50 | else: 51 | acctid_to_use = acctid 52 | txnid_to_use = txn.id 53 | if self.hardcodeaccount: 54 | acctid_to_use = self.hardcodeaccount 55 | txnid_to_use = txnid_to_use.replace(acctid, acctid_to_use) 56 | elif self.shortenaccount: 57 | acctid_to_use = acctid[-4:] 58 | txnid_to_use = txnid_to_use.replace(acctid, acctid_to_use) 59 | ofxid = "%s.%s" % (acctid_to_use, txnid_to_use) 60 | return self.lgr.check_transaction_by_id("ofxid", ofxid) 61 | 62 | # Filter out comment transactions. These have an amount of 0 and the same 63 | # datetime as the previous transactions. 64 | def filter_comment_txns(self, txns): 65 | last_txn = None 66 | retval = [] 67 | for txn in txns: 68 | if ( 69 | (last_txn is not None) 70 | and hasattr(txn, "amount") 71 | and (txn.amount == 0) 72 | and hasattr(last_txn, "date") 73 | and hasattr(txn, "date") 74 | and (last_txn.date == txn.date) 75 | ): 76 | # This is a comment transaction 77 | pass 78 | else: 79 | last_txn = txn 80 | retval.append(txn) 81 | return retval 82 | 83 | @staticmethod 84 | def extract_sort_key(txn): 85 | if hasattr(txn, "tradeDate"): 86 | return txn.tradeDate 87 | elif hasattr(txn, "date"): 88 | return txn.date 89 | elif hasattr(txn, "settleDate"): 90 | return txn.settleDate 91 | return None 92 | 93 | def filter(self, txns, acctid): 94 | if len(txns) == 0: 95 | sorted_txns = txns 96 | else: 97 | sorted_txns = sorted(txns, key=OfxSynchronizer.extract_sort_key) 98 | retval = [txn for txn in sorted_txns if not (self.is_txn_synced(acctid, txn))] 99 | return self.filter_comment_txns(retval) 100 | 101 | def get_new_txns(self, acct, max_days=999999, resync=False): 102 | if resync or (max_days < 7): 103 | days = max_days 104 | else: 105 | days = 7 106 | last_txns_len = 0 107 | while True: 108 | logging.debug( 109 | "Downloading %d days of transactions for %s (max_days=%d)." 110 | % (days, acct.description, max_days) 111 | ) 112 | raw = acct.download(days=days) 113 | 114 | if raw.read() == "Server error occured. Received HttpStatusCode of 400": 115 | raise Exception("Error connecting to account %s" % (acct.description)) 116 | raw.seek(0) 117 | ofx = None 118 | try: 119 | ofx = OfxParser.parse(raw) 120 | except OfxParserException as ex: 121 | if ex.message == "The ofx file is empty!": 122 | return (ofx, []) 123 | else: 124 | raise ex 125 | if ofx.signon is not None: 126 | if ofx.signon.severity == "ERROR": 127 | raise Exception( 128 | "Error returned from server for %s: %s" 129 | % (acct.description, ofx.signon.message) 130 | ) 131 | if not (hasattr(ofx, "account")): 132 | # some banks return this for no txns 133 | if days >= max_days: 134 | logging.debug("Hit max days.") 135 | # return None to let the caller know that we don't 136 | # even have account info 137 | return (None, None) 138 | else: 139 | days = days * 2 140 | if days > max_days: 141 | days = max_days 142 | logging.debug("empty account: increasing days ago to %d." % (days)) 143 | last_txns_len = 0 144 | else: 145 | txns = ofx.account.statement.transactions 146 | new_txns = self.filter(txns, ofx.account.account_id) 147 | logging.debug("txns: %d" % (len(txns))) 148 | logging.debug("new txns: %d" % (len(new_txns))) 149 | if (len(txns) > 0) and (last_txns_len == len(txns)): 150 | # not getting more txns than last time; we have 151 | # reached the beginning 152 | logging.debug("Not getting more txns than last time, done.") 153 | return (ofx, new_txns) 154 | elif (len(txns) > len(new_txns)) or (days >= max_days): 155 | # got more txns than were new or hit max_days, we've 156 | # reached a stopping point 157 | if days >= max_days: 158 | logging.debug("Hit max days.") 159 | else: 160 | logging.debug("Got some stale txns.") 161 | return (ofx, new_txns) 162 | else: 163 | # all txns were new, increase how far back we go 164 | days = days * 2 165 | if days > max_days: 166 | days = max_days 167 | logging.debug("Increasing days ago to %d." % (days)) 168 | last_txns_len = len(txns) 169 | 170 | 171 | class CsvSynchronizer(Synchronizer): 172 | def __init__(self, lgr, payee_format=None, date_format=None): 173 | super(CsvSynchronizer, self).__init__(lgr) 174 | self.payee_format = payee_format 175 | self.date_format = date_format 176 | 177 | def is_row_synced(self, converter, row): 178 | if self.lgr is None: 179 | # User called with --no-ledger 180 | # All transactions are considered "synced" in this case. 181 | return False 182 | else: 183 | return self.lgr.check_transaction_by_id("csvid", converter.get_csv_id(row)) 184 | 185 | def parse_file(self, path, accountname=None, unknownaccount=None): 186 | with open(path) as f: 187 | has_bom = f.read(3) == codecs.BOM_UTF8 188 | if not (has_bom): 189 | f.seek(0) 190 | else: 191 | f.seek(3) 192 | dialect = csv.Sniffer().sniff(f.readline()) 193 | if not (has_bom): 194 | f.seek(0) 195 | else: 196 | f.seek(3) 197 | dialect.skipinitialspace = True 198 | reader = csv.DictReader(f, dialect=dialect) 199 | converter = CsvConverter.make_converter( 200 | set(reader.fieldnames), 201 | dialect, 202 | ledger=self.lgr, 203 | name=accountname, 204 | unknownaccount=unknownaccount, 205 | payee_format=self.payee_format, 206 | date_format=self.date_format, 207 | ) 208 | # Create a new reader in case the converter modified the dialect 209 | if not (has_bom): 210 | f.seek(0) 211 | else: 212 | f.seek(3) 213 | reader = csv.DictReader(f, dialect=dialect) 214 | return [ 215 | converter.convert(row) 216 | for row in reader 217 | if not (self.is_row_synced(converter, row)) 218 | ] 219 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | multi_line_output = 3 4 | 5 | [tool.pytest.ini_options] 6 | markers = [ 7 | "ofx_file: ofx fixture file to use in test", 8 | "lgr_file: lgr fixture file to use in test", 9 | "csv_file: csv fixture file to use in test", 10 | "ledger_impls: List of Ledger implementations (as wrapper classes) that this test applies to. Default is all." 11 | ] 12 | 13 | [tool.poetry] 14 | name = "ledger-autosync" 15 | version = "1.2.0" 16 | description = "Automatically sync your bank's data with ledger" 17 | authors = ["Erik Hetzner "] 18 | license = "GPLv3" 19 | homepage = "https://github.com/egh/ledger-autosync" 20 | repository = "https://github.com/egh/ledger-autosync" 21 | keywords = ["ledger", "accounting"] 22 | readme = "README.rst" 23 | classifiers = [ 24 | "Development Status :: 5 - Production/Stable", 25 | "Intended Audience :: End Users/Desktop", 26 | "Operating System :: OS Independent", 27 | "Topic :: Office/Business :: Financial :: Accounting", 28 | "Topic :: Office/Business :: Financial :: Investment", 29 | "Topic :: Office/Business :: Financial", 30 | ] 31 | packages = [ 32 | { include = "ledgerautosync" } 33 | ] 34 | [tool.poetry.scripts] 35 | "ledger-autosync" = "ledgerautosync.cli:run" 36 | "hledger-autosync" = "ledgerautosync.cli:run" 37 | 38 | [tool.poetry.dependencies] 39 | python = ">=3.8" 40 | ofxclient = "*" 41 | ofxparse = "*" 42 | lxml = "*" 43 | 44 | [tool.poetry.dev-dependencies] 45 | pytest = "*" 46 | black = "*" 47 | 48 | [build-system] 49 | requires = ["poetry-core>=1.0.0"] 50 | build-backend = "poetry.core.masonry.api" 51 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | max-line-length = 88 3 | extend-ignore = E203 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2021 Erik Hetzner 2 | # 3 | # This file is part of ledger-autosync 4 | # 5 | # ledger-autosync is free software: you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # ledger-autosync is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with ledger-autosync. If not, see 17 | # . 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2021 Erik Hetzner 2 | # 3 | # This file is part of ledger-autosync 4 | # 5 | # ledger-autosync is free software: you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # ledger-autosync is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with ledger-autosync. If not, see 17 | # . 18 | 19 | 20 | import os 21 | 22 | import pytest 23 | from ofxparse import OfxParser 24 | 25 | from ledgerautosync.ledgerwrap import HLedger, Ledger, LedgerPython 26 | 27 | LEDGER = [HLedger, Ledger, LedgerPython] 28 | 29 | 30 | @pytest.fixture(params=LEDGER) 31 | def ledger(request): 32 | lgr_name = request.node.get_closest_marker("lgr_file").args[0] 33 | ledger_impls = request.node.get_closest_marker("ledger_impls") 34 | kwargs = {} 35 | if request.param == Ledger: 36 | kwargs["no_pipe"] = True 37 | if not request.param.available(): 38 | pytest.skip(f"{request.param} not found") 39 | if ledger_impls is not None: 40 | if request.param not in ledger_impls.args[0]: 41 | pytest.skip("Test not applicable for this ledger.") 42 | return request.param(os.path.join("fixtures", lgr_name), **kwargs) 43 | 44 | 45 | @pytest.fixture 46 | def ofx(request): 47 | ofx_name = request.node.get_closest_marker("ofx_file").args[0] 48 | with open(os.path.join("fixtures", ofx_name), "rb") as ofx_file: 49 | yield OfxParser.parse(ofx_file) 50 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2021 Erik Hetzner 2 | # 3 | # This file is part of ledger-autosync 4 | # 5 | # ledger-autosync is free software: you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # ledger-autosync is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with ledger-autosync. If not, see 17 | # . 18 | 19 | import os.path 20 | import re 21 | import tempfile 22 | from io import StringIO 23 | from unittest.mock import Mock, call, patch 24 | 25 | import pytest 26 | from ofxclient.config import OfxConfig 27 | 28 | from ledgerautosync import LedgerAutosyncException 29 | from ledgerautosync.cli import find_ledger_file, run 30 | 31 | 32 | def test_run(): 33 | config = OfxConfig(os.path.join("fixtures", "ofxclient.ini")) 34 | acct = config.accounts()[0] 35 | acct.download = Mock( 36 | side_effect=lambda *args, **kwargs: open( 37 | os.path.join("fixtures", "checking.ofx"), "rb" 38 | ) 39 | ) 40 | config.accounts = Mock(return_value=[acct]) 41 | run(["-l", os.path.join("fixtures", "empty.lgr")], config) 42 | acct.download.assert_has_calls([call(days=7), call(days=14)]) 43 | assert config.accounts.call_count == 1 44 | 45 | 46 | def test_run_csv_file(): 47 | config = OfxConfig(os.path.join("fixtures", "ofxclient.ini")) 48 | run( 49 | [ 50 | "-a", 51 | "Paypal", 52 | "-l", 53 | os.path.join("fixtures", "empty.lgr"), 54 | os.path.join("fixtures", "paypal.csv"), 55 | ], 56 | config, 57 | ) 58 | 59 | 60 | def test_filter_account(): 61 | config = OfxConfig(os.path.join("fixtures", "ofxclient.ini")) 62 | foo = next( 63 | acct for acct in config.accounts() if acct.description == "Assets:Savings:Foo" 64 | ) 65 | bar = next( 66 | acct for acct in config.accounts() if acct.description == "Assets:Checking:Bar" 67 | ) 68 | foo.download = Mock( 69 | side_effect=lambda *args, **kwargs: open( 70 | os.path.join("fixtures", "checking.ofx"), "rb" 71 | ) 72 | ) 73 | bar.download = Mock() 74 | config.accounts = Mock(return_value=[foo, bar]) 75 | run( 76 | [ 77 | "-l", 78 | os.path.join("fixtures", "checking.lgr"), 79 | "-a", 80 | "Assets:Savings:Foo", 81 | ], 82 | config, 83 | ) 84 | foo.download.assert_has_calls([call(days=7)]) 85 | bar.download.assert_not_called() 86 | 87 | 88 | def test_find_ledger_path(): 89 | os.environ["LEDGER_FILE"] = "/tmp/foo" 90 | assert ( 91 | find_ledger_file() == "/tmp/foo" 92 | ), "Should use LEDGER_FILE to find ledger path." 93 | 94 | (f, tmprcpath) = tempfile.mkstemp(".ledgerrc") 95 | os.close(f) # Who wants to deal with low-level file descriptors? 96 | with open(tmprcpath, "w") as f: 97 | f.write("--bar foo\n") 98 | f.write("--file /tmp/bar\n") 99 | f.write("--foo bar\n") 100 | assert ( 101 | find_ledger_file(tmprcpath) == "/tmp/foo" 102 | ), "Should prefer LEDGER_FILE to --file arg in ledgerrc" 103 | del os.environ["LEDGER_FILE"] 104 | assert find_ledger_file(tmprcpath) == "/tmp/bar", "Should parse ledgerrc" 105 | os.unlink(tmprcpath) 106 | 107 | 108 | def test_no_ledger_arg(): 109 | with pytest.raises(LedgerAutosyncException): 110 | config = OfxConfig(os.path.join("fixtures", "ofxclient.ini")) 111 | run(["-l", os.path.join("fixtures", "checking.lgr"), "-L"], config) 112 | 113 | 114 | def test_format_payee(): 115 | with patch("sys.stdout", new_callable=StringIO) as mock_stdout: 116 | run( 117 | [ 118 | os.path.join("fixtures", "paypal.csv"), 119 | "-a", 120 | "Assets:Foo", 121 | "--payee-format", 122 | "GROSS:{Gross}", 123 | "-L", 124 | ] 125 | ) 126 | assert re.search(r"GROSS:-20\.00", mock_stdout.getvalue()) 127 | 128 | 129 | # def test_multi_account(): 130 | # with patch('sys.stdout', new_callable=StringIO) as mock_stdout: 131 | # run([os.path.join('fixtures', 'multi_account.ofx'), '-a', 'Assets:Foo']) 132 | # self.assertRegexpMatches(mock_stdout.getvalue(), r"GROSS:-20\.00") 133 | 134 | 135 | def test_no_ledger(): 136 | config = OfxConfig(os.path.join("fixtures", "ofxclient.ini")) 137 | acct = config.accounts()[0] 138 | acct.download = Mock( 139 | side_effect=lambda *args, **kwargs: open( 140 | os.path.join("fixtures", "checking.ofx"), "rb" 141 | ) 142 | ) 143 | config.accounts = Mock(return_value=[acct]) 144 | with patch("ledgerautosync.cli.find_ledger_file", return_value=None): 145 | with patch("sys.stderr", new_callable=StringIO) as mock_stdout: 146 | run([], config) 147 | assert ( 148 | mock_stdout.getvalue() 149 | == "LEDGER_FILE environment variable not set, and no .ledgerrc file found, and -l argument was not supplied: running with deduplication disabled. All transactions will be printed!\n" 150 | ) 151 | -------------------------------------------------------------------------------- /tests/test_converter.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2021 Erik Hetzner 2 | # 3 | # This file is part of ledger-autosync 4 | # 5 | # ledger-autosync is free software: you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # ledger-autosync is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with ledger-autosync. If not, see 17 | # . 18 | 19 | import csv 20 | import hashlib 21 | import os 22 | from decimal import Decimal 23 | 24 | import pytest 25 | 26 | from ledgerautosync.converter import ( 27 | AmazonConverter, 28 | Amount, 29 | CsvConverter, 30 | MintConverter, 31 | PaypalAlternateConverter, 32 | PaypalConverter, 33 | Posting, 34 | ) 35 | 36 | 37 | def test_format(): 38 | assert ( 39 | Posting("Foo", Amount(Decimal("10.00"), "$"), metadata={"foo": "bar"}).format( 40 | indent=2 41 | ) 42 | == " Foo $10.00\n ; foo: bar\n" 43 | ) 44 | 45 | 46 | def test_amount(): 47 | assert ( 48 | "$10.00" == Amount(Decimal("10.001"), "$").format() 49 | ), "Formats to 2 points precision by default" 50 | assert ( 51 | "10.00 USD" == Amount(Decimal(10), "USD").format() 52 | ), "Longer commodity names come after" 53 | assert ( 54 | "-$10.00" == Amount(Decimal(10), "$", reverse=True).format() 55 | ), "Reverse flag works." 56 | assert ( 57 | '10.00 "ABC123"' == Amount(Decimal(10), "ABC123").format() 58 | ), "Currencies with numbers are quoted" 59 | assert ( 60 | '10.00 "A BC"' == Amount(Decimal(10), "A BC").format() 61 | ), "Currencies with whitespace are quoted" 62 | assert "$10.001" == Amount(Decimal("10.001"), "$", unlimited=True).format() 63 | 64 | 65 | def test_get_csv_id(): 66 | converter = CsvConverter(None) 67 | h = {"foo": "bar", "bar": "foo"} 68 | assert ( 69 | converter.get_csv_id(h) 70 | == hashlib.md5("bar=foo\nfoo=bar\n".encode("utf-8")).hexdigest() 71 | ) 72 | 73 | 74 | @pytest.fixture 75 | def reader(request): 76 | csv_name = request.node.get_closest_marker("csv_file").args[0] 77 | with open(os.path.join("fixtures", csv_name), "r") as csv_file: 78 | dialect = csv.Sniffer().sniff(csv_file.read(1024)) 79 | csv_file.seek(0) 80 | dialect.skipinitialspace = True 81 | reader = csv.DictReader(csv_file, dialect=dialect) 82 | csv_file.seek(0) 83 | reader = csv.DictReader(csv_file, dialect=dialect) 84 | yield reader 85 | 86 | 87 | @pytest.fixture 88 | def converter(reader): 89 | return CsvConverter.make_converter(set(reader.fieldnames), reader.dialect, "Foo") 90 | 91 | 92 | @pytest.mark.csv_file("paypal.csv") 93 | def test_paypal_format(reader, converter): 94 | assert type(converter) == PaypalConverter 95 | assert ( 96 | converter.convert(next(reader)).format() 97 | == """2016/06/04 Jane Doe someone@example.net My Friend ID: XYZ1, Recurring Payment Sent 98 | Foo -20.00 USD 99 | ; csvid: paypal.XYZ1 100 | Expenses:Misc 20.00 USD 101 | """ 102 | ) 103 | assert ( 104 | converter.convert(next(reader)).format() 105 | == """2016/06/04 Debit Card ID: XYZ2, Charge From Debit Card 106 | Foo 1120.00 USD 107 | ; csvid: paypal.XYZ2 108 | Transfer:Paypal -1120.00 USD 109 | """ 110 | ) 111 | 112 | 113 | @pytest.mark.csv_file("paypal_alternate.csv") 114 | def test_paypal_alternate_format(reader, converter): 115 | assert type(converter) == PaypalAlternateConverter 116 | assert ( 117 | converter.convert(next(reader)).format() 118 | == """2016/12/31 Some User: Payment Sent 119 | Foo -$12.34 120 | ; csvid: 1209a7bb0d17276248d463b71a6a8b8c 121 | Expenses:Misc $12.34 122 | """ 123 | ) 124 | assert ( 125 | converter.convert(next(reader)).format() 126 | == """2016/12/31 Bank Account: Add Funds from a Bank Account 127 | Foo $12.34 128 | ; csvid: 581e62da71bab74c7ce61854c2b6b6a5 129 | Transfer:Paypal -$12.34 130 | """ 131 | ) 132 | 133 | 134 | def test_mk_amount_alternate(): 135 | converter = PaypalAlternateConverter(None) 136 | row = {"Currency": "USD", "Amount": "12.34"} 137 | assert converter.mk_amount(row) == Amount(Decimal("12.34"), "USD") 138 | 139 | 140 | @pytest.mark.csv_file("amazon.csv") 141 | def test_amazon_format(reader, converter): 142 | assert type(converter) == AmazonConverter 143 | assert ( 144 | converter.convert(next(reader)).format() 145 | == """2016/01/29 Best Soap Ever 146 | Foo $21.90 147 | ; csvid: amazon.123-4567890-1234567 148 | ; url: https://www.amazon.com/gp/css/summary/print.html/ref=od_aui_print_invoice?ie=UTF8&orderID=123-4567890-1234567 149 | Expenses:Misc -$21.90 150 | """ 151 | ) 152 | 153 | 154 | @pytest.mark.csv_file("amazon2.csv") 155 | def test_amazon2_format(reader, converter): 156 | assert type(converter) == AmazonConverter 157 | assert ( 158 | converter.convert(next(reader)).format() 159 | == """2017/06/05 Test " double quote 160 | Foo $9.99 161 | ; csvid: amazon.111-1111111-1111111 162 | ; url: https://www.amazon.com/gp/css/summary/print.html/ref=od_aui_print_invoice?ie=UTF8&orderID=111-1111111-1111111 163 | Expenses:Misc -$9.99 164 | """ 165 | ) 166 | 167 | 168 | @pytest.mark.csv_file("mint.csv") 169 | def test_mint_format(reader): 170 | converter = CsvConverter.make_converter(set(reader.fieldnames), reader.dialect) 171 | assert type(converter) == MintConverter 172 | assert ( 173 | converter.convert(next(reader)).format() 174 | == """2016/08/02 Amazon 175 | 1234 $29.99 176 | ; csvid: mint.a7c028a73d76956453dab634e8e5bdc1 177 | Expenses:Shopping -$29.99 178 | """ 179 | ) 180 | assert ( 181 | converter.convert(next(reader)).format() 182 | == """2016/06/02 Autopay Rautopay Auto 183 | 1234 -$123.45 184 | ; csvid: mint.a404e70594502dd62bfc6f15d80b7cd7 185 | Credit Card Payment $123.45 186 | """ 187 | ) 188 | -------------------------------------------------------------------------------- /tests/test_ledger.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2021 Erik Hetzner 2 | # 3 | # This file is part of ledger-autosync 4 | # 5 | # ledger-autosync is free software: you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # ledger-autosync is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with ledger-autosync. If not, see 17 | # . 18 | 19 | 20 | import os 21 | import os.path 22 | import tempfile 23 | 24 | import pytest 25 | 26 | from ledgerautosync.ledgerwrap import Ledger, LedgerPython 27 | 28 | 29 | @pytest.mark.lgr_file("checking.lgr") 30 | def test_check_transaction(ledger): 31 | assert ledger.check_transaction_by_id("ofxid", "1101.1452687~7.0000486") 32 | 33 | 34 | @pytest.mark.lgr_file("checking.lgr") 35 | def test_nonexistent_transaction(ledger): 36 | assert not ledger.check_transaction_by_id("ofxid", "FOO") 37 | 38 | 39 | @pytest.mark.lgr_file("checking.lgr") 40 | def test_empty_transaction(ledger): 41 | assert ledger.check_transaction_by_id("ofxid", "empty") 42 | 43 | 44 | @pytest.mark.lgr_file("checking.lgr") 45 | def test_get_account_by_payee(ledger): 46 | account = ledger.get_account_by_payee( 47 | "AUTOMATIC WITHDRAWAL, ELECTRIC BILL WEB(S )", exclude="Assets:Foo" 48 | ) 49 | assert account == "Expenses:Bar" 50 | 51 | 52 | @pytest.mark.lgr_file("checking-dynamic-account.lgr") 53 | def test_get_ambiguous_account_by_payee(ledger): 54 | account = ledger.get_account_by_payee("Generic", exclude="Assets:Foo") 55 | # shoud use the latest 56 | assert account == "Expenses:Bar" 57 | 58 | 59 | @pytest.mark.lgr_file("checking.lgr") 60 | def test_ofx_payee_quoting(ledger): 61 | payees = [ 62 | "PAYEE TEST/SLASH", 63 | "PAYEE TEST,COMMA", 64 | "PAYEE TEST:COLON", 65 | "PAYEE TEST*STAR", 66 | "PAYEE TEST#HASH", 67 | "PAYEE TEST.PERIOD", 68 | ] 69 | for payee in payees: 70 | assert ( 71 | ledger.get_account_by_payee(payee, ["Assets:Foo"]) is not None 72 | ), "Did not find %s in %s" % (payee, ledger) 73 | 74 | 75 | # TODO Broken on current hledger 76 | @pytest.mark.lgr_file("checking.lgr") 77 | @pytest.mark.ledger_impls([Ledger, LedgerPython]) 78 | def test_ofx_payee_quote_quote(ledger): 79 | payees = [ 80 | 'PAYEE TEST"QUOTE', 81 | ] 82 | for payee in payees: 83 | assert ( 84 | ledger.get_account_by_payee(payee, ["Assets:Foo"]) is not None 85 | ), "Did not find %s in %s" % (payee, ledger) 86 | 87 | 88 | @pytest.mark.lgr_file("checking.lgr") 89 | def test_ofx_id_quoting(ledger): 90 | assert ( 91 | ledger.check_transaction_by_id("ofxid", "1/2") is True 92 | ), "Did not find 1/2 in %s" % (ledger) 93 | 94 | 95 | @pytest.mark.lgr_file("checking.lgr") 96 | def test_load_payees(ledger): 97 | ledger.load_payees() 98 | assert ledger.payees["PAYEE TEST:COLON"] == ["Assets:Foo", "Income:Bar"] 99 | 100 | 101 | @pytest.mark.lgr_file("empty.lgr") 102 | def test_load_payees_with_empty_ledger(ledger): 103 | ledger.load_payees() 104 | 105 | 106 | # class TestLedger(LedgerTest, TestCase): 107 | # def setUp(ledger): 108 | # self.empty_lgr = Ledger(os.path.join("fixtures", "empty.lgr"), no_pipe=True) 109 | # ledger = Ledger(self.ledger_path, no_pipe=True) 110 | # self.dynamic_lgr = Ledger(self.dynamic_ledger_path, no_pipe=True) 111 | 112 | 113 | @pytest.mark.lgr_file("checking.lgr") 114 | @pytest.mark.ledger_impls([Ledger]) 115 | def test_args_only(ledger): 116 | (f, tmprcpath) = tempfile.mkstemp(".ledgerrc") 117 | os.close(f) # Who wants to deal with low-level file descriptors? 118 | # Create an init file that will narrow the test data to a period that 119 | # contains no trasnactions 120 | with open(tmprcpath, "w") as f: 121 | f.write("--period 2012") 122 | # If the command returns no trasnactions, as we would expect if we 123 | # parsed the init file, then this will throw an exception. 124 | next(ledger.run([""])) 125 | os.unlink(tmprcpath) 126 | -------------------------------------------------------------------------------- /tests/test_ofx_formatter.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2021 Erik Hetzner 2 | # 3 | # This file is part of ledger-autosync 4 | # 5 | # ledger-autosync is free software: you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # ledger-autosync is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with ledger-autosync. If not, see 17 | # . 18 | 19 | import re 20 | 21 | import pytest 22 | 23 | from ledgerautosync.converter import OfxConverter, SecurityList 24 | from ledgerautosync.ledgerwrap import Ledger 25 | 26 | 27 | def clean_posting(posting): 28 | return re.sub(" +", " ", posting) 29 | 30 | 31 | def make_clean_posting(converter, txn): 32 | return clean_posting(converter.convert(txn).format()) 33 | 34 | 35 | @pytest.mark.ofx_file("checking.ofx") 36 | def test_checking(ofx): 37 | converter = OfxConverter(account=ofx.account, name="Foo") 38 | assert ( 39 | make_clean_posting(converter, ofx.account.statement.transactions[0]) 40 | == """2011/03/31 DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 41 | Foo $0.01 42 | ; ofxid: 1101.1452687~7.0000486 43 | Expenses:Misc -$0.01 44 | """ 45 | ) 46 | assert ( 47 | make_clean_posting(converter, ofx.account.statement.transactions[1]) 48 | == """2011/04/05 AUTOMATIC WITHDRAWAL, ELECTRIC BILL WEB(S ) 49 | Foo -$34.51 50 | ; ofxid: 1101.1452687~7.0000487 51 | Expenses:Misc $34.51 52 | """ 53 | ) 54 | 55 | assert ( 56 | make_clean_posting(converter, ofx.account.statement.transactions[2]) 57 | == """2011/04/07 (319) RETURNED CHECK FEE, CHECK # 319 FOR $45.33 ON 04/07/11 58 | Foo -$25.00 59 | ; ofxid: 1101.1452687~7.0000488 60 | Expenses:Misc $25.00 61 | """ 62 | ) 63 | 64 | 65 | @pytest.mark.ofx_file("checking.ofx") 66 | def test_indent(ofx): 67 | converter = OfxConverter(account=ofx.account, name="Foo", indent=4) 68 | # testing indent, so do not use the string collapsing version of assert 69 | assert ( 70 | converter.convert(ofx.account.statement.transactions[0]).format() 71 | == """2011/03/31 DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 72 | Foo $0.01 73 | ; ofxid: 1101.1452687~7.0000486 74 | Expenses:Misc -$0.01 75 | """ 76 | ) 77 | 78 | 79 | @pytest.mark.ofx_file("checking.ofx") 80 | def test_shortenaccount(ofx): 81 | converter = OfxConverter( 82 | account=ofx.account, name="Foo", indent=4, shortenaccount=True 83 | ) 84 | # testing indent, so do not use the string collapsing version of assert 85 | assert ( 86 | converter.convert(ofx.account.statement.transactions[0]).format() 87 | == """2011/03/31 DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 88 | Foo $0.01 89 | ; ofxid: 1101.87~7.0000486 90 | Expenses:Misc -$0.01 91 | """ 92 | ) 93 | 94 | 95 | @pytest.mark.ofx_file("checking.ofx") 96 | def test_hardcodeaccount(ofx): 97 | converter = OfxConverter( 98 | account=ofx.account, name="Foo", indent=4, hardcodeaccount="9999" 99 | ) 100 | # testing indent, so do not use the string collapsing version of assert 101 | assert ( 102 | converter.convert(ofx.account.statement.transactions[0]).format() 103 | == """2011/03/31 DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 104 | Foo $0.01 105 | ; ofxid: 1101.9999.0000486 106 | Expenses:Misc -$0.01 107 | """ 108 | ) 109 | 110 | 111 | @pytest.mark.ofx_file("fidelity.ofx") 112 | def test_investments(ofx): 113 | converter = OfxConverter( 114 | account=ofx.account, 115 | name="Foo", 116 | security_list=SecurityList(ofx), 117 | ) 118 | assert ( 119 | make_clean_posting(converter, ofx.account.statement.transactions[0]) 120 | == """2012/07/20 YOU BOUGHT 121 | Foo 100.00000 INTC @ $25.635000000 122 | ; ofxid: 7776.01234567890.0123456789020201120120720 123 | Assets:Unknown -$2563.50 124 | Expenses:Commission $7.95 125 | """ 126 | ) 127 | # test no payee/memo 128 | assert ( 129 | make_clean_posting(converter, ofx.account.statement.transactions[1]) 130 | == """2012/07/27 Foo: buystock 131 | Foo 128.00000 SDRL @ $39.390900000 132 | ; ofxid: 7776.01234567890.0123456789020901120120727 133 | Assets:Unknown -$5042.04 134 | Expenses:Commission $7.95 135 | """ 136 | ) 137 | 138 | 139 | @pytest.mark.ofx_file("fidelity_fee.ofx") 140 | def test_fee(ofx): 141 | """Test that fees are parsed correctly. 142 | 143 | In this case we have a 7-cent fee. We need to make sure that 144 | the net sale price which shows up is the gross price of 3239.44 145 | minus 7 cents which equals 3239.37 and that the 7 cent fee 146 | shows up as an extra posting. 147 | """ 148 | converter = OfxConverter( 149 | account=ofx.account, name="Foo", security_list=SecurityList(ofx) 150 | ) 151 | # test fee 152 | assert ( 153 | make_clean_posting(converter, ofx.account.statement.transactions[1]) 154 | == """2012/08/01 SELL 155 | Foo -100.0 "929042109" @ $32.3944 156 | ; ofxid: 7776.01234567890.0123456789021401420120801 157 | Assets:Unknown $3239.37 158 | Expenses:Fees $0.07 159 | """ 160 | ) 161 | # test fee and comission 162 | assert ( 163 | make_clean_posting(converter, ofx.account.statement.transactions[0]) 164 | == """2020/05/22=2020/05/26 SELL 165 | Foo -1.0 "Z9977810Z" @ $8.27 166 | ; ofxid: 7776.01234567890.987654321 167 | Assets:Unknown $8.25 168 | Expenses:Fees $0.02 169 | Expenses:Commission $1.00 170 | """ 171 | ) 172 | 173 | 174 | @pytest.mark.ofx_file("checking.ofx") 175 | @pytest.mark.lgr_file("checking-dynamic-account.lgr") 176 | def test_dynamic_account(ofx, ledger): 177 | converter = OfxConverter(account=ofx.account, name="Assets:Foo", ledger=ledger) 178 | assert ( 179 | make_clean_posting(converter, ofx.account.statement.transactions[1]) 180 | == """2011/04/05 AUTOMATIC WITHDRAWAL, ELECTRIC BILL WEB(S ) 181 | Assets:Foo -$34.51 182 | ; ofxid: 1101.1452687~7.0000487 183 | Expenses:Bar $34.51 184 | """ 185 | ) 186 | 187 | 188 | @pytest.mark.ofx_file("checking.ofx") 189 | @pytest.mark.lgr_file("checking.lgr") 190 | def test_balance_assertion(ofx, ledger): 191 | converter = OfxConverter(account=ofx.account, name="Assets:Foo", ledger=ledger) 192 | assert ( 193 | clean_posting(converter.format_balance(ofx.account.statement)) 194 | == """2013/05/25 * --Autosync Balance Assertion 195 | Assets:Foo $0.00 = $100.99 196 | """ 197 | ) 198 | 199 | 200 | @pytest.mark.ofx_file("checking.ofx") 201 | @pytest.mark.lgr_file("checking.lgr") 202 | def test_initial_balance(ofx, ledger): 203 | converter = OfxConverter(account=ofx.account, name="Assets:Foo", ledger=ledger) 204 | assert ( 205 | clean_posting(converter.format_initial_balance(ofx.account.statement)) 206 | == """2000/01/01 * --Autosync Initial Balance 207 | Assets:Foo $160.49 208 | ; ofxid: 1101.1452687~7.autosync_initial 209 | Assets:Equity -$160.49 210 | """ 211 | ) 212 | 213 | 214 | @pytest.mark.ofx_file("checking.ofx") 215 | def test_unknownaccount(ofx): 216 | converter = OfxConverter( 217 | account=ofx.account, name="Foo", unknownaccount="Expenses:Unknown" 218 | ) 219 | assert ( 220 | make_clean_posting(converter, ofx.account.statement.transactions[0]) 221 | == """2011/03/31 DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05% 222 | Foo $0.01 223 | ; ofxid: 1101.1452687~7.0000486 224 | Expenses:Unknown -$0.01 225 | """ 226 | ) 227 | 228 | 229 | @pytest.mark.ofx_file("fidelity.ofx") 230 | def test_quote_commodity(ofx): 231 | converter = OfxConverter( 232 | account=ofx.account, name="Foo", security_list=SecurityList(ofx) 233 | ) 234 | assert ( 235 | make_clean_posting(converter, ofx.account.statement.transactions[0]) 236 | == """2012/07/20 YOU BOUGHT 237 | Foo 100.00000 INTC @ $25.635000000 238 | ; ofxid: 7776.01234567890.0123456789020201120120720 239 | Assets:Unknown -$2563.50 240 | Expenses:Commission $7.95 241 | """ 242 | ) 243 | 244 | 245 | # Check that txns are parsed. 246 | @pytest.mark.ofx_file("investment_401k.ofx") 247 | def test_transfer_txn(ofx): 248 | converter = OfxConverter( 249 | account=ofx.account, 250 | name="Foo", 251 | unknownaccount="Expenses:Unknown", 252 | ) 253 | if len(ofx.account.statement.transactions) > 2: 254 | # older versions of ofxparse would skip these transactions 255 | if hasattr(ofx.account.statement.transactions[2], "tferaction"): 256 | # unmerged pull request 257 | assert ( 258 | make_clean_posting(converter, ofx.account.statement.transactions[2]) 259 | == """2014/06/30 Foo: transfer: out 260 | Foo -9.060702 BAZ @ $21.928764 261 | ; ofxid: 1234.12345678.123456-01.3 262 | Transfer $198.69 263 | """ 264 | ) 265 | else: 266 | assert ( 267 | make_clean_posting(converter, ofx.account.statement.transactions[2]) 268 | == """2014/06/30 Foo: transfer 269 | Foo -9.060702 BAZ @ $21.928764 270 | ; ofxid: 1234.12345678.123456-01.3 271 | Transfer $198.69 272 | """ 273 | ) 274 | 275 | 276 | @pytest.mark.ofx_file("cusip.ofx") 277 | def test_position(ofx): 278 | converter = OfxConverter( 279 | account=ofx.account, 280 | name="Foo", 281 | indent=4, 282 | unknownaccount="Expenses:Unknown", 283 | security_list=SecurityList(ofx), 284 | ) 285 | assert ( 286 | converter.format_position(ofx.account.statement.positions[0]) 287 | == """P 2016/10/08 07:30:08 SHSAX 47.8600000 288 | """ 289 | ) 290 | 291 | 292 | @pytest.mark.ofx_file("income.ofx") 293 | def test_dividend(ofx): 294 | converter = OfxConverter(account=ofx.account, name="Foo") 295 | assert ( 296 | make_clean_posting(converter, ofx.account.statement.transactions[0]) 297 | == """2016/10/12 DIVIDEND RECEIVED 298 | ; dividend_from: cusip_redacted 299 | Foo $1234.56 300 | ; ofxid: 1234.12345678.123456-01.redacted 301 | Income:Dividends -$1234.56 302 | """ 303 | ) 304 | 305 | 306 | @pytest.mark.ofx_file("checking.ofx") 307 | def test_checking_custom_payee(ofx): 308 | converter = OfxConverter(account=ofx.account, name="Foo", payee_format="{memo}") 309 | assert ( 310 | converter.format_payee(ofx.account.statement.transactions[0]) 311 | == "DIVIDEND EARNED FOR PERIOD OF 03/01/2011 THROUGH 03/31/2011 ANNUAL PERCENTAGE YIELD EARNED IS 0.05%" 312 | ) 313 | converter = OfxConverter(account=ofx.account, name="Foo", payee_format="{payee}") 314 | assert ( 315 | converter.format_payee(ofx.account.statement.transactions[0]) 316 | == "DIVIDEND EARNED FOR PERIOD OF 03" 317 | ) 318 | converter = OfxConverter(account=ofx.account, name="Foo", payee_format="{account}") 319 | assert converter.format_payee(ofx.account.statement.transactions[0]) == "Foo" 320 | converter = OfxConverter( 321 | account=ofx.account, name="Foo", payee_format=" {account} " 322 | ) 323 | assert converter.format_payee(ofx.account.statement.transactions[0]) == "Foo" 324 | 325 | 326 | @pytest.mark.ofx_file("investment_401k.ofx") 327 | def test_investments_custom_payee(ofx): 328 | converter = OfxConverter(account=ofx.account, name="Foo", payee_format="{txntype}") 329 | assert converter.format_payee(ofx.account.statement.transactions[1]) == "transfer" 330 | converter = OfxConverter( 331 | account=ofx.account, name="Foo", payee_format="{tferaction}" 332 | ) 333 | assert converter.format_payee(ofx.account.statement.transactions[1]) == "in" 334 | 335 | 336 | @pytest.mark.ofx_file("checking-payee-match.ofx") 337 | @pytest.mark.lgr_file("checking.lgr") 338 | @pytest.mark.ledger_impls([Ledger]) 339 | def test_payee_match(ofx, ledger): 340 | converter = OfxConverter(account=ofx.account, name="Foo", ledger=ledger) 341 | assert ( 342 | make_clean_posting(converter, ofx.account.statement.transactions[0]) 343 | == """2011/03/31 Match Payee 344 | Foo -$0.01 345 | ; ofxid: 1101.1452687~7.0000489 346 | Expenses:Bar $0.01 347 | """ 348 | ) 349 | -------------------------------------------------------------------------------- /tests/test_sync.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013-2021 Erik Hetzner 2 | # 3 | # This file is part of ledger-autosync 4 | # 5 | # ledger-autosync is free software: you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # ledger-autosync is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | # General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with ledger-autosync. If not, see 17 | # . 18 | 19 | 20 | import os 21 | import os.path 22 | from unittest.mock import Mock 23 | 24 | from ofxparse import OfxParser 25 | 26 | from ledgerautosync.ledgerwrap import Ledger 27 | from ledgerautosync.sync import CsvSynchronizer, OfxSynchronizer 28 | 29 | 30 | def test_fresh_sync(): 31 | ledger = Ledger(os.path.join("fixtures", "empty.lgr")) 32 | sync = OfxSynchronizer(ledger) 33 | with open(os.path.join("fixtures", "checking.ofx"), "rb") as ofx_file: 34 | ofx = OfxParser.parse(ofx_file) 35 | txns1 = ofx.account.statement.transactions 36 | txns2 = sync.filter(txns1, ofx.account.account_id) 37 | assert txns1 == txns2 38 | 39 | 40 | def test_sync_order(): 41 | ledger = Ledger(os.path.join("fixtures", "empty.lgr")) 42 | sync = OfxSynchronizer(ledger) 43 | with open(os.path.join("fixtures", "checking_order.ofx"), "rb") as ofx_file: 44 | ofx = OfxParser.parse(ofx_file) 45 | txns = sync.filter(ofx.account.statement.transactions, ofx.account.account_id) 46 | assert txns[0].date < txns[1].date 47 | assert txns[1].date < txns[2].date 48 | 49 | 50 | def test_fully_synced(): 51 | ledger = Ledger(os.path.join("fixtures", "checking.lgr")) 52 | sync = OfxSynchronizer(ledger) 53 | ofx = OfxSynchronizer.parse_file(os.path.join("fixtures", "checking.ofx")) 54 | txns = sync.filter(ofx.account.statement.transactions, ofx.account.account_id) 55 | assert txns == [] 56 | 57 | 58 | def test_partial_sync(): 59 | ledger = Ledger(os.path.join("fixtures", "checking-partial.lgr")) 60 | sync = OfxSynchronizer(ledger) 61 | ofx = OfxSynchronizer.parse_file(os.path.join("fixtures", "checking.ofx")) 62 | txns = sync.filter(ofx.account.statement.transactions, ofx.account.account_id) 63 | assert len(txns) == 1 64 | 65 | 66 | def test_no_new_txns(): 67 | ledger = Ledger(os.path.join("fixtures", "checking.lgr")) 68 | acct = Mock() 69 | acct.download = Mock( 70 | return_value=open(os.path.join("fixtures", "checking.ofx"), "rb") 71 | ) 72 | sync = OfxSynchronizer(ledger) 73 | assert len(sync.get_new_txns(acct, 7, 7)[1]) == 0 74 | 75 | 76 | def test_all_new_txns(): 77 | ledger = Ledger(os.path.join("fixtures", "empty.lgr")) 78 | acct = Mock() 79 | acct.download = Mock( 80 | return_value=open(os.path.join("fixtures", "checking.ofx"), "rb") 81 | ) 82 | sync = OfxSynchronizer(ledger) 83 | assert len(sync.get_new_txns(acct, 7, 7)[1]) == 3 84 | 85 | 86 | def test_comment_txns(): 87 | ledger = Ledger(os.path.join("fixtures", "empty.lgr")) 88 | sync = OfxSynchronizer(ledger) 89 | ofx = OfxSynchronizer.parse_file(os.path.join("fixtures", "comments.ofx")) 90 | txns = sync.filter(ofx.account.statement.transactions, ofx.account.account_id) 91 | assert len(txns) == 1 92 | 93 | 94 | def test_sync_no_ledger(): 95 | acct = Mock() 96 | acct.download = Mock( 97 | return_value=open(os.path.join("fixtures", "checking.ofx"), "rb") 98 | ) 99 | sync = OfxSynchronizer(None) 100 | assert len(sync.get_new_txns(acct, 7, 7)[1]) == 3 101 | 102 | 103 | def test_paypal_fresh_sync(): 104 | ledger = Ledger(os.path.join("fixtures", "empty.lgr")) 105 | sync = CsvSynchronizer(ledger) 106 | assert 2 == len(sync.parse_file(os.path.join("fixtures", "paypal.csv"))) 107 | 108 | 109 | def test_sync_paypal_no_ledger(): 110 | sync = CsvSynchronizer(None) 111 | assert 2 == len(sync.parse_file(os.path.join("fixtures", "paypal.csv"))) 112 | 113 | 114 | def test_paypal_partial_sync(): 115 | ledger = Ledger(os.path.join("fixtures", "paypal.lgr")) 116 | sync = CsvSynchronizer(ledger) 117 | assert 1 == len(sync.parse_file(os.path.join("fixtures", "paypal.csv"))) 118 | -------------------------------------------------------------------------------- /tests/test_weird_ofx.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2013-2021 Erik Hetzner 3 | # 4 | # This file is part of ledger-autosync 5 | # 6 | # ledger-autosync is free software: you can redistribute it and/or 7 | # modify it under the terms of the GNU General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version. 10 | # 11 | # ledger-autosync is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 14 | # General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with ledger-autosync. If not, see 18 | # . 19 | 20 | 21 | import os.path 22 | 23 | import pytest 24 | from ofxclient.config import OfxConfig 25 | 26 | from ledgerautosync import EmptyInstitutionException 27 | from ledgerautosync.cli import run 28 | from ledgerautosync.converter import OfxConverter 29 | from ledgerautosync.sync import OfxSynchronizer 30 | 31 | 32 | @pytest.fixture 33 | def ofx_sync(ledger): 34 | return OfxSynchronizer(ledger) 35 | 36 | 37 | @pytest.mark.lgr_file("empty.lgr") 38 | def test_no_institution_no_fid(ledger): 39 | with pytest.raises(EmptyInstitutionException): 40 | config = OfxConfig(os.path.join("fixtures", "ofxclient.ini")) 41 | run( 42 | [ 43 | os.path.join("fixtures", "no-institution.ofx"), 44 | "-l", 45 | os.path.join("fixtures", "empty.lgr"), 46 | "-a", 47 | "Assets:Savings:Foo", 48 | ], 49 | config, 50 | ) 51 | 52 | 53 | @pytest.mark.ofx_file("no-institution.ofx") 54 | @pytest.mark.lgr_file("empty.lgr") 55 | def test_no_institution(ofx, ofx_sync): 56 | txns = ofx_sync.filter(ofx.account.statement.transactions, ofx.account.account_id) 57 | assert len(txns) == 3 58 | 59 | 60 | @pytest.mark.ofx_file("no-institution.ofx") 61 | @pytest.mark.lgr_file("empty.lgr") 62 | def test_no_institution_no_accountname(ofx): 63 | with pytest.raises(EmptyInstitutionException): 64 | OfxConverter(account=ofx.account, name=None) 65 | 66 | 67 | @pytest.mark.ofx_file("apostrophe.ofx") 68 | @pytest.mark.lgr_file("empty.lgr") 69 | def test_apostrophe(ofx, ofx_sync): 70 | txns = ofx_sync.filter(ofx.account.statement.transactions, ofx.account.account_id) 71 | assert len(txns) == 1 72 | 73 | 74 | @pytest.mark.ofx_file("fidelity-one-dtsettle.ofx") 75 | @pytest.mark.lgr_file("empty.lgr") 76 | def test_one_settleDate(ofx, ofx_sync): 77 | txns = ofx_sync.filter(ofx.account.statement.transactions, ofx.account.account_id) 78 | assert len(txns) == 17 79 | 80 | 81 | @pytest.mark.ofx_file("accented_characters_latin1.ofx") 82 | @pytest.mark.lgr_file("empty.lgr") 83 | def test_accented_characters_latin1(ofx, ofx_sync): 84 | txns = ofx_sync.filter(ofx.account.statement.transactions, ofx.account.account_id) 85 | converter = OfxConverter(account=ofx.account, name="Foo") 86 | assert converter.format_payee(txns[0]) == "Virement Interac à: Jean" 87 | assert len(txns) == 1 88 | --------------------------------------------------------------------------------