├── README.md ├── LICENSE └── bean-add /README.md: -------------------------------------------------------------------------------- 1 | # bean-add 2 | ## A beancount transaction entry assistant 3 | 4 | Bean-add is a relatively simple tool designed to ease adding transactions to your [beancount](http://furius.ca/beancount/) journal. While both vi and EMACS have extensions for dealing with ledger and beancount files, some people prefer to edit their text with Nano. 5 | 6 | Bean-add requires Python 3.6 or later to run, but Beancount itself it optional. Bean-add is built around *the format*, not the binary. 7 | 8 | ### Basic Usage 9 | 10 | To begin, run `bean-add` with your journal file name as the argument. The journal file will be parsed, and you will be presented with a command prompt, not unlike that of `fdisk` or `parted`. 11 | 12 | Type `h` for a list of available commands. 13 | 14 | To add a new transaction, type `n`. Enter the transaction's details: date, description, and the participating accounts and their amounts. You can press `tab` during entry to trigger completion. If there was a transaction with matching description in the past (and there almost always is), you will be presented with an option to reuse the accounts for the new transaction. 15 | 16 | To add a sequence of almost identical transactions, type `nn`; You will only be prompted for the date and the amounts. Press `Control-C` to end this process. 17 | 18 | If, during transaction entry, you enter an amount of zero for any of the accounts, that account will be removed from the transaction, allowing you to correct any mistakes on the fly. For more elaborate corrections, use the `e` command. If you plan on using this feature, make sure `$EDITOR` is set to your preferred editor. The transaction will be reinserted into an appropriate place in the journal when you finish editing. 19 | 20 | Once you are done with the transaction entry, it will be inserted chronologically into your file and you will be returned to the command prompt. Note that this assumes that your beancount file is sorted by date; If it isn't, the results will be unpredictable. Fortunately, this sorting method can be disabled through the options (see below). 21 | 22 | Certain special directives, such as `balance` and `pad` can also be entered using `bean-add`. The commands for those are usually an uppercase letter. 23 | 24 | Type `f` to flag the transaction. Type `t` to add or remove transaction tags. 25 | 26 | You can navigate to another record in the journal file with `s`. This is useful if you suddenly remember you forgot some minor detail in a previous transaction. There are also shortcuts available for seeking one record forward and backwards, as well as to the beginning and end of the journal file. To find the record number you want, you can either type `l` to search for records that contain a certain string, or `j` to examine the transaction's immediate context. You can also type `b` to attempt to calculate the final balance of an account on the fly. 27 | 28 | To remove a record from the journal file, type `r`. To undo a recent removal, type `u`. To duplicate the current record at another date (or at the end of the file, if it is undated), type `d`. 29 | 30 | When you are done entering your transactions, type `w` to write the journal file to disk and `q` to quit. 31 | 32 | Type `c` at any point to validate the journal file with `bean-check`. If you plan on using this feature, make sure `bean-check` is in your `$PATH`. 33 | 34 | Type `o` to view a list of option commands that modify the way bean-add works and their current settings. 35 | 36 | ### Advanced Usage 37 | 38 | Some degree of scripting is possible by piping input into `bean-add`. For example, if you know that your bank charges you $1.95 every month for holding a checking account (and most banks do), then you could write a script, replicating your keystrokes, similar to: 39 | 40 | #!/bin/sh 41 | echo 'n 42 | 43 | Bank service charge 44 | y 45 | 1.95 USD 46 | 47 | 48 | wq' | bean-add journal.bnct 49 | 50 | And then have `cron` or another scheduler execute it every month, on the 1st, thus saving you the effort. 51 | 52 | Another use for scripting has been proposed in the comments on the issue regarding [split-file beancount journals](https://github.com/simon-v/bean-add/issues/3#issuecomment-250917599). 53 | 54 | `bean-add` provides a basic capability to perform credit card statement verification. To begin, type `v`, then feed in the name of the account which contains the transactions on the statement (likely, something like `Liabilities:CreditCard`), the name of the account from which the statement is paid (likely, something like `Assets:Bank:Checking`) and the statement's grand total. Select the records that appear on the statement by typing their numbers. Typing a record number that is already in the statement will remove it. For this to work as expected, be sure to `f`lag every record that hasn't yet appeared on a statement; Only flagged records are considered for addition. In addition, if the record contains multiple legs that are not paid out at the same time, make sure to also flag each of those legs (the `ff` command is helpful here); The flags will be used to track the legs' "paid" status. 55 | 56 | To make things easier for you, the `of` option exists, which automatically flags new transactions that contain any of the specified accounts. 57 | 58 | If, at some point during statement verification, you discover an error in one of the records, you can interrupt the process by pressing `Control-C`. When you next press `v`, you will be offered to continue from where you left off. 59 | 60 | Once the sum of balances of all transactions in the statement matches the statement's target, you will be prompted for the statement's date and description. `bean-add` will then unset the flags of all records on the statement to prevent them from being used on another statement and add the statement's own record. 61 | 62 | Type `bv` to estimate the amount of your next statement, to help you plan your budget. 63 | 64 | You can use the `-n` command line switch to skip right to entering a new transaction with today's date immediately on startup. 65 | 66 | ### Other Notes 67 | 68 | This program is free software, released under the Apache License, Version 2.0. See the LICENSE file for more information. 69 | 70 | The program's canonical project page resides at https://simonvolpert.com/bean-add/ 71 | 72 | I gratefully accept appreciation for my work in material form at __[bitcoincash:qp0q05mx8d7akfwrndsnns9hjf3g4dnpx5ucez59qv](bitcoincash:qp0q05mx8d7akfwrndsnns9hjf3g4dnpx5ucez59qv)__. 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2020 Simon Volpert 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /bean-add: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # bean-add - Beancount transaction entry assistant 3 | # Author: Simon Volpert 4 | # Project page: https://simonvolpert.com/bean-add/ 5 | # This program is free software, released under the Apache License, Version 2.0. See the LICENSE file for more information 6 | # Consult the README file for usage instructions and other helpful hints 7 | 8 | import locale 9 | import re 10 | import readline 11 | import sys 12 | import datetime 13 | import subprocess 14 | import os 15 | 16 | # Set global constants 17 | tempfile = '/tmp/bean-add.%s.tmp.bnct' % os.environ['USER'] 18 | 19 | usage = '''Usage: bean-add [-n] FILENAME 20 | Add or edit transactions in the beancount journal FILENAME. 21 | 22 | -n immediately enter a new transaction with today's date 23 | -h, --help show this help message 24 | 25 | See the README file for more information.''' 26 | 27 | 28 | # Init transaction data 29 | class data(object): 30 | # Journal/transactions 31 | journal = [] 32 | saved = [] 33 | external_journal = [] 34 | file_name = '' 35 | defaults = {} 36 | restore = [] 37 | mtime = 0 38 | prices = {} 39 | quote_currency = '' 40 | constraints = {} 41 | # A mapping from known accounts to their respective default currencies 42 | accounts = {} 43 | # Completion 44 | currencies = [] 45 | tags = [] 46 | vocab = [] 47 | history = { 48 | 'command': [], 49 | 'date': [], 50 | 'description': [], 51 | 'account': [], 52 | 'amount': [], 53 | 'tag': [], 54 | 'seek': [], 55 | 'lookup': [], 56 | 'note': [], 57 | 'txids': [], 58 | 'currency': [] 59 | } 60 | history_context = 'command' 61 | # Current transaction 62 | description = '' 63 | last_currency = None 64 | balance = 0 65 | balance_currency = None 66 | known_balances = {} 67 | tag = 'bean-add' 68 | txid = -1 69 | date = datetime.date.today().isoformat() 70 | date_prompt = '%Y-%m-%d' 71 | lookup_string = '' 72 | eof = False 73 | # Statements 74 | statement = [] 75 | statement_pattern = ' ! ' 76 | statement_replacement = ' * ' 77 | target = 0 78 | statement_currency = '' 79 | transaction_account = '' 80 | funding_account = '' 81 | # Options 82 | sort_by_date = True 83 | date_delimiter = '-' 84 | indentation = None 85 | use_defaults = None 86 | paranoid_write = False 87 | external_write = False 88 | use_beancount_accounts = False 89 | date_preview = True 90 | context = 10 91 | auto_flag = [] 92 | lookup_seek = None 93 | max_expr_length = 40 94 | auto_new = False 95 | amount_width = 1 96 | colors = False 97 | last_bold = True 98 | precisions = {} 99 | default_precision = 8 100 | 101 | 102 | # Ask for confirmation: str(prompt) -> bool(result) 103 | def confirm(prompt='', default_yes=None): 104 | # Unset completion 105 | _vocab = data.vocab 106 | _delims = readline.get_completer_delims() 107 | data.vocab = [] 108 | readline.set_completer_delims('') 109 | # Get a valid confirmation 110 | ch = 'x' 111 | while ch not in 'yn': 112 | ch = input(prompt).lower() 113 | if ch == '': 114 | if default_yes is None: 115 | continue 116 | ch = 'y' if default_yes else 'n' 117 | # Reset completion 118 | data.vocab = _vocab 119 | readline.set_completer_delims(_delims) 120 | if ch == 'y': 121 | return True 122 | elif ch == 'n': 123 | return False 124 | 125 | 126 | # Cast a number into the appropriate format: number -> int or rounded float 127 | def cast_number(number, precision=data.default_precision): 128 | number = round(float(number), precision) 129 | return int(number) if number.is_integer() else number 130 | 131 | 132 | # Condense whitespace and return a list 133 | def condense(line): 134 | line = line.strip().split(' ') 135 | _data = [] 136 | for i in line: 137 | if i.strip() != '': 138 | _data.append(i) 139 | return _data 140 | 141 | 142 | # Naive word pluralization 143 | def pluralize(number, word): 144 | number = str(number) 145 | return '%s %s' % (number, word if number.endswith('1') and not number.endswith('11') else word + 's') 146 | 147 | 148 | # Completion 149 | def complete(text, state): 150 | results = [x for x in data.vocab if x not in ['__balance__', '__pad__', '__note__'] and text.lower() in x.lower()] + [None] 151 | return results[state] 152 | 153 | 154 | # Set up histories 155 | def set_history_context(context): 156 | # Cache history for current context 157 | data.history[data.history_context] = [] 158 | for i in range(readline.get_current_history_length()): 159 | _hist = readline.get_history_item(i + 1) 160 | # Remove duplicate history items 161 | if _hist in data.history[data.history_context]: 162 | data.history[data.history_context].remove(_hist) 163 | data.history[data.history_context].append(_hist) 164 | # Populate completion history with new context 165 | readline.clear_history() 166 | for i in data.history[context]: 167 | readline.add_history(i) 168 | data.history_context = context 169 | 170 | 171 | # Insert a transaction in an appropriate place in the file: str(transaction) -> int(index) 172 | def insert_transaction(tx): 173 | _found = False 174 | for _txid, jtx in enumerate(data.journal): 175 | # Ignore records that don't start with a date 176 | if jtx[0] not in '0123456789': 177 | continue 178 | # Insert balance and price statements as the first transaction of the date 179 | if (tx[10:19] == ' balance ' or tx[10:17] == ' price ') and jtx[0:10] == tx[0:10]: 180 | _found = True 181 | break 182 | # Seek forward to first bigger date 183 | if jtx[0:10] > tx[0:10]: 184 | _found = True 185 | break 186 | if _found and data.sort_by_date: 187 | data.journal.insert(_txid, tx) 188 | else: 189 | # If not found, insert at the end of file 190 | _txid = len(data.journal) 191 | data.journal.append(tx) 192 | print('\nTransaction added as record %s\n\n%s' % (_txid, tx)) 193 | data.txid = _txid 194 | update_statement_refs(_txid, True) 195 | 196 | 197 | # Date from string wrapper: str(date) -> date(date) 198 | def strpdate(d): 199 | return datetime.datetime.strptime(d, data.date_prompt).date() 200 | 201 | 202 | # Figure out the full date from a day-only input 203 | def sliding_window(day): 204 | today = strpdate(data.date) 205 | if today.day == day: 206 | return today 207 | future = today 208 | # Seek to the nearest same-date in the next month, mark the delta 209 | for future_delta in range(32): 210 | future = future + datetime.timedelta(days=1) 211 | if future.day == day: 212 | break 213 | past = today 214 | # Seek to the nearest same-date in the previous month, mark the delta 215 | for past_delta in range(32): 216 | past = past - datetime.timedelta(days=1) 217 | if past.day == day: 218 | break 219 | # Compare the deltas and pick the smallest 220 | if past_delta <= future_delta and past_delta < 31: 221 | return past 222 | elif future_delta < past_delta: 223 | return future 224 | elif past_delta == future_delta == 31: 225 | return None 226 | 227 | 228 | # Figure out the full date from a day-month input. 229 | def sliding_window_with_month(day, month): 230 | today = strpdate(data.date) 231 | dates = [] 232 | deltas = [] 233 | # Pick the selected date in the three nearest years 234 | for i in [0, -1, 1]: 235 | try: 236 | dates.append(datetime.date(today.year + i, month, day)) 237 | deltas.append(abs(today - dates[-1])) 238 | except ValueError: 239 | continue 240 | if dates == []: 241 | return None 242 | # Return the date which is closest to the present 243 | return dates[deltas.index(min(deltas))] 244 | 245 | 246 | # Read and validate a date: str(default) -> str(date) 247 | def read_date(default): 248 | # Unset completion 249 | data.vocab = ['today'] 250 | readline.set_completer_delims('') 251 | set_history_context('date') 252 | # Get a valid date 253 | while True: 254 | reading = input('Enter transaction date (`h` or `?` for a format hint) [%s]: ' % default) 255 | # Accepting the default 256 | if reading == '': 257 | return default 258 | # Formatting hint 259 | elif reading in '?h': 260 | partial_prompt = '%m/%d' if locale.getlocale()[0] == 'en_US' else '%d/%m' 261 | print('\nAccepted formats: %s, or %%d, or %s, or +/-days, or "today"\n' % (data.date_prompt, partial_prompt)) 262 | continue 263 | date = strpdate(default) 264 | # Relative date 265 | if reading.startswith('+') or reading.startswith('-'): 266 | try: 267 | date += datetime.timedelta(int(reading)) 268 | except ValueError: 269 | continue 270 | # Explicitly today 271 | elif reading == 'today': 272 | date = datetime.date.today() 273 | # Partial notation (day only) 274 | elif len(reading) == 1 or len(reading) == 2: 275 | try: 276 | date = sliding_window(int(reading)) 277 | except ValueError: 278 | continue 279 | if date is None: 280 | continue 281 | # Partial notation (day and month) 282 | elif len(reading) <= 5 and ('.' in reading or '/' in reading): 283 | separator = '.' if '.' in reading else '/' 284 | try: 285 | day, month = reading.split(separator) 286 | # American order with month first 287 | if locale.getlocale()[0] == 'en_US': 288 | month, day = day, month 289 | date = sliding_window_with_month(int(day), int(month)) 290 | except ValueError: 291 | continue 292 | if date is None: 293 | continue 294 | # Absolute date 295 | else: 296 | try: 297 | date = strpdate(reading) 298 | except ValueError: 299 | continue 300 | date = date.isoformat() 301 | if default != date: 302 | if data.date_preview: 303 | default = date 304 | continue 305 | print('\nAdding new transaction at %s.\n' % date) 306 | return date 307 | 308 | 309 | # Read a description -> str(description) 310 | def read_description(): 311 | # Set completion 312 | data.vocab = data.defaults 313 | readline.set_completer_delims('') 314 | set_history_context('description') 315 | # Get a non-empty description 316 | reading = input('Enter transaction description [%s]: ' % data.description).strip(' "') 317 | if reading == '': 318 | if data.description != '': 319 | return data.description 320 | return None 321 | data.description = reading 322 | return reading 323 | 324 | 325 | # Read a valid account name 326 | def read_account(): 327 | # Set completion 328 | data.vocab = data.accounts 329 | readline.set_completer_delims('') 330 | set_history_context('account') 331 | while True: 332 | # Read a valid account 333 | reading = input('Enter an account name (tab to complete, enter to finish): ') 334 | if reading == '': 335 | return None 336 | # Add new account option 337 | if reading not in data.accounts: 338 | if not confirm('The account `%s` is not in the journal file. Add it? (Y/n) ' % reading, True): 339 | continue 340 | insert_transaction('%s open %s' % (data.date, reading)) 341 | print() 342 | data.accounts[reading] = None 343 | return reading 344 | 345 | 346 | # Normalize amounts with unnecessary decimal places 347 | # number, rounding precision -> string 348 | def undecimal(number, precision=data.default_precision): 349 | try: 350 | number = round(float(number), precision) 351 | except ValueError: 352 | print('\nThis is not a valid number.') 353 | raise KeyboardInterrupt 354 | number = f'{number:.8f}'.rstrip('0') 355 | if number.endswith('.'): 356 | number = number[0:-1] 357 | elif number == '': 358 | number = '0' 359 | elif '.' in number and len(number.split('.')[1]) == 1: 360 | number += '0' 361 | return number 362 | 363 | 364 | # Calculate simple arithmetic expressions 365 | def evaluate(expression): 366 | try: 367 | expression = str(expression) 368 | # Restrict to nothing but numbers and basic arithmetic operators and attempt to limit computation cost 369 | if len(expression) > data.max_expr_length: 370 | raise ValueError 371 | if '**' in expression: 372 | raise ValueError 373 | for n in expression: 374 | if n not in '0123456789.+-*/()e': 375 | raise ValueError 376 | return eval(expression) 377 | except (SyntaxError, ValueError, ArithmeticError): 378 | print('\nInvalid arithmetic expression.') 379 | raise 380 | 381 | 382 | # Add previously unseen currency signs to the completion list 383 | def detect_currencies(line): 384 | if type(line) == str: 385 | line = line.split() 386 | _currencies = [line[-1]] 387 | if '@' in line: 388 | _currencies.append(line[line.index('@') -1]) 389 | for cur in _currencies: 390 | try: 391 | int(cur) 392 | except ValueError: 393 | data.last_currency = cur 394 | if cur not in data.currencies: 395 | data.currencies.append(cur) 396 | 397 | 398 | # Read and normalize transaction amount 399 | def read_amount(account): 400 | # Set completion 401 | data.vocab = data.currencies 402 | readline.set_completer_delims(' ') 403 | set_history_context('amount') 404 | # Read an amount, taking defaults into account (balancing currency, then default currency, then last used currency) 405 | default_currency = data.last_currency 406 | if account in data.accounts and data.accounts[account] is not None: 407 | default_currency = data.accounts[account] 408 | if data.balance_currency is not None: 409 | default_currency = data.balance_currency 410 | amount = input('Enter the amount for `%s` (including currency symbol) [%s %s]: ' % (account, undecimal(data.balance), default_currency)).replace(',', '') 411 | amount = [data.balance, default_currency] if amount == '' else amount.split() 412 | # Process currency sign 413 | if len(amount) == 1: 414 | amount.append(default_currency) 415 | data.accounts[account] = data.last_currency = default_currency 416 | else: 417 | if account in data.constraints and amount[1] not in data.constraints[account]: 418 | print('\nThis account is constrained to: {}'.format(', '.join(data.constraints[account]))) 419 | raise ValueError 420 | if len(amount) == 4: 421 | amount.append(data.last_currency) 422 | detect_currencies(amount) 423 | data.accounts[account] = amount[1] 424 | data.last_currency = amount[-1] 425 | # Normalize numbers and process conversion rate 426 | amount[0] = undecimal(evaluate(amount[0])) 427 | _amt = amount[0].split('.')[0] if '.' in amount[0] else amount[0] 428 | data.amount_width = max(data.amount_width, len(_amt.strip('-')) + 3) 429 | if '@' in amount: 430 | amount[-2] = undecimal(evaluate(amount[-2]), 3) 431 | data.balance -= float(amount[0]) * float(amount[-2]) 432 | else: 433 | data.balance -= float(amount[0]) 434 | if amount[-1] in data.precisions: 435 | data.balance = round(data.balance, data.precisions[amount[-1]]) 436 | deduce_commodity_price(amount) 437 | data.balance = cast_number(data.balance) 438 | data.balance_currency = amount[-1] 439 | # Finalize and return 440 | return ' '.join(amount) 441 | 442 | 443 | # Read a currency sign 444 | def read_currency(): 445 | # Set completion 446 | data.vocab = data.currencies 447 | readline.set_completer_delims('') 448 | set_history_context('currency') 449 | cur = input('Enter currency symbol: ') 450 | return cur 451 | 452 | 453 | # Figure out the implied price of a currency 454 | def deduce_commodity_price(line): 455 | if data.quote_currency == '': 456 | return 457 | if type(line) == str: 458 | line = condense(line) 459 | if 'price' in line and line[1] == 'price': 460 | base_currency = line[2] 461 | rate, currency = line[3:5] 462 | elif '@' in line: 463 | if len(line) < 5: 464 | print('\nInvalid exchange rate declaration.') 465 | raise ValueError 466 | p = line.index('@') 467 | base_currency = line[p - 1] 468 | rate, currency = line[p + 1:p + 3] 469 | else: 470 | return 471 | rate = float(rate) 472 | # base_currency @ rate [currency] <= quote_currency 473 | if currency == data.quote_currency: 474 | data.prices[base_currency] = rate 475 | # quote_currency => [base_currency] @ rate currency 476 | elif base_currency == data.quote_currency: 477 | data.prices[currency] = 1 / rate 478 | 479 | 480 | # Toggle transaction flag 481 | def toggle_flag(): 482 | if data.txid < 0: 483 | print('\nNothing to flag, no current record.') 484 | return 485 | if ' * ' in data.journal[data.txid]: 486 | data.journal[data.txid] = data.journal[data.txid].replace(' * ', ' ! ') 487 | flagged = True 488 | elif ' ! ' in data.journal[data.txid]: 489 | data.journal[data.txid] = data.journal[data.txid].replace(' ! ', ' * ') 490 | flagged = False 491 | else: 492 | print('\nRecord type does not support flags.') 493 | return 494 | print('\nTransaction flag %sset.\n' % ('un' if not flagged else '')) 495 | print(data.journal[data.txid]) 496 | 497 | 498 | # Toggle the flags on individual transaction legs 499 | def toggle_flag_with_legs(account=None): 500 | if ' * ' not in data.journal[data.txid] and ' ! ' not in data.journal[data.txid]: 501 | print('\nRecord type does not support flags.') 502 | return 503 | if ' * ' in data.journal[data.txid]: 504 | if account is None: 505 | print('\nFlag which transaction legs?') 506 | account = read_account() 507 | data.journal[data.txid] = data.journal[data.txid].replace(account, '! %s' % account).replace('! !', '!') 508 | else: 509 | # Replace any type/combination of whitespace used for indentation, followed by "! ", with just the leading whitespace 510 | data.journal[data.txid] = re.sub('\n(\\s+)! ', '\n\\1', data.journal[data.txid]) 511 | toggle_flag() 512 | 513 | 514 | # Add or remove transaction tags 515 | def add_remove_tag(): 516 | if data.txid < 0: 517 | print('\nNothing to tag, no current record.') 518 | return 519 | # Set completion 520 | data.vocab = data.tags 521 | readline.set_completer_delims('') 522 | set_history_context('tag') 523 | # Make sure the transaction can be tagged 524 | tx = data.journal[data.txid].split('\n') 525 | if len(tx) == 1: 526 | print('\nCannot add tags to special transactions.') 527 | return 528 | # Get and normalize a tag 529 | try: 530 | _tag = input('Enter a tag [%s]: ' % data.tag) 531 | except KeyboardInterrupt: 532 | print('\nTagging cancelled.') 533 | return 534 | except EOFError: 535 | data.eof = True 536 | return 537 | _tag = _tag.strip('# ') 538 | if _tag == '': 539 | _tag = data.tag 540 | else: 541 | data.tag = _tag 542 | _tag = '#' + _tag 543 | # If the tag is already there, remove it instead 544 | tx_tags = tx[0].split() 545 | if _tag in tx_tags: 546 | tx_tags.remove(_tag) 547 | # Otherwise, apply the tag 548 | else: 549 | tx_tags.append(_tag) 550 | tx[0] = ' '.join(tx_tags) 551 | data.journal[data.txid] = '\n'.join(tx) 552 | print() 553 | print(data.journal[data.txid]) 554 | 555 | 556 | # Seek to a specific record in the journal file 557 | def seek_to_transaction(autoseek=None): 558 | while True: 559 | # Early return 560 | if data.journal == []: 561 | print('\nThe journal is empty.') 562 | return 563 | last_tx = len(data.journal) - 1 564 | if autoseek is None: 565 | # Manual seeking input 566 | set_history_context('seek') 567 | # Unset completion 568 | data.vocab = [] 569 | readline.set_completer_delims('') 570 | if data.txid >= 0: 571 | print('\nCurrently at record %s.' % data.txid) 572 | or_count = ', or +/-COUNT' 573 | else: 574 | print('\nNo current record.') 575 | or_count = '' 576 | try: 577 | reading = input('Enter record number (0-%s%s): ' % (last_tx, or_count)) 578 | except KeyboardInterrupt: 579 | print('\nSeeking cancelled.') 580 | return 581 | except EOFError: 582 | data.eof = True 583 | return 584 | else: 585 | # Scripted seeking 586 | reading = str(autoseek) 587 | try: 588 | _txid = int(reading) 589 | except ValueError: 590 | if reading != '': 591 | print('\nSeeking cancelled.') 592 | return 593 | _txid = data.txid 594 | # Relative value 595 | if reading.startswith('+') or reading.startswith('-'): 596 | if data.txid == -1: 597 | if autoseek is not None: 598 | print('\nNo current record.') 599 | return 600 | else: 601 | print('\nSeeking cancelled.') 602 | return 603 | else: 604 | _txid += data.txid 605 | # Beginning of journal 606 | if _txid < 0: 607 | if data.txid == 0: 608 | print('\nYou are at the first record.') 609 | return 610 | else: 611 | _txid = 0 612 | break 613 | # End of journal 614 | elif _txid > last_tx: 615 | if data.txid == last_tx: 616 | print('\nYou are at the last record.') 617 | return 618 | else: 619 | _txid = last_tx 620 | break 621 | # Explicit and absolute value 622 | else: 623 | break 624 | # Change to the specified record 625 | print('\nRecord %s:\n' % _txid) 626 | print(data.journal[_txid]) 627 | data.txid = _txid 628 | 629 | 630 | # Return a list of txids which contain all of the passed strings 631 | def tx_lookup(patterns, prune_txids=[]): 632 | if type(patterns) == str: 633 | patterns = [patterns] 634 | patterns = [x.lower() for x in patterns] 635 | results = [] 636 | for i, tx in enumerate(data.journal): 637 | if i not in prune_txids: 638 | match = True 639 | record = tx.lower() 640 | for pattern in patterns: 641 | # Use an AND keyword joining 642 | if pattern not in record: 643 | match = False 644 | break 645 | if match: 646 | results.append(i) 647 | return results 648 | 649 | 650 | # Find transactions that contain a string 651 | def find_transactions(autoseek=None): 652 | # Use all possible completion contenxts for lookups 653 | data.vocab = list(data.accounts) + list(data.defaults) + ['#' + x for x in data.tags] 654 | readline.set_completer_delims('') 655 | set_history_context('lookup') 656 | # Autoseeking without doing a lookup first 657 | if autoseek is not None and data.lookup_string == '': 658 | print('\nNo lookup pattern.') 659 | return 660 | # Get a lookup pattern or reuse the previous one if autoseeking 661 | if autoseek is None: 662 | try: 663 | reading = input('Enter a search string: ').strip('\n') 664 | except KeyboardInterrupt: 665 | print('\nLookup cancelled.') 666 | return 667 | except EOFError: 668 | data.eof = True 669 | return 670 | else: 671 | reading = data.lookup_string 672 | if reading == '': 673 | print('\nLookup cancelled.') 674 | return 675 | results = tx_lookup(reading) 676 | # Use default lookup-seek option 677 | if autoseek is None: 678 | autoseek = data.lookup_seek 679 | data.lookup_string = reading 680 | if results == []: 681 | print('\nNo results.') 682 | return 683 | # Check for autoseek 684 | _txid = -1 685 | if autoseek is not None: 686 | # Last result 687 | if autoseek is True: 688 | _txid = results[-1] 689 | # First result 690 | elif autoseek is False: 691 | _txid = results[0] 692 | # Next and previous results 693 | elif autoseek == 1 or autoseek == -1: 694 | _txid = data.txid 695 | while _txid >= 0 and _txid < len(data.journal): 696 | _txid += autoseek 697 | if _txid in results: 698 | break 699 | if _txid == len(data.journal): 700 | if data.txid > results[-1]: 701 | print('\nNo more results.') 702 | else: 703 | print('\nYou are on the last result.') 704 | return 705 | elif _txid == -1: 706 | if data.txid < results[0]: 707 | print('\nNo more results.') 708 | else: 709 | print('\nYou are on the first result.') 710 | return 711 | else: 712 | _txid = data.txid 713 | # Print the transaction list 714 | if autoseek is not None: 715 | data.txid = _txid 716 | # Show a slice of the results around the current record 717 | idx = results.index(_txid) 718 | start = max(0, idx - data.context) 719 | end = min(len(results), idx + data.context + 1) 720 | try: 721 | for tx in results[start:end]: 722 | print_transaction(tx) 723 | except KeyboardInterrupt: 724 | pass 725 | # Seek to the proper record 726 | if autoseek is not None: 727 | seek_to_transaction(_txid) 728 | return results 729 | 730 | 731 | # Get default accounts for a transaction 732 | def get_default_accounts(description): 733 | if description in data.defaults: 734 | print('Previously used accounts for this transaction:') 735 | for _account in data.defaults[description]: 736 | print('\t%s' % _account) 737 | print() 738 | _use = confirm('Use these accounts? (Y/n) ', True) if data.use_defaults is None else data.use_defaults 739 | if _use: 740 | return data.defaults[description].copy() 741 | return [] 742 | 743 | 744 | # Enter a normal transaction 745 | def read_normal_transaction(description=None): 746 | output = '' 747 | _transaction = [] 748 | data.balance = 0 749 | data.balance_currency = None 750 | if not data.auto_new: 751 | data.date = read_date(data.date) 752 | if description is None: 753 | description = read_description() 754 | if description is None: 755 | raise KeyboardInterrupt 756 | else: 757 | output += '%s * "%s"\n' % (data.date, description) 758 | defaults = get_default_accounts(description) 759 | while True: 760 | # Use defaults, if available, then proceed to manual account input 761 | if len(defaults) > 0: 762 | account = defaults.pop(0) 763 | else: 764 | account = read_account() 765 | if account is None: 766 | if data.balance != 0: 767 | if not confirm('\nThe transaction is unbalanced! Really finish? (y/N) ', False): 768 | continue 769 | break 770 | _transaction.append(account) 771 | # Retry amount entry until valid input is received 772 | while True: 773 | try: 774 | amount = read_amount(account) 775 | except KeyboardInterrupt: 776 | raise 777 | except (SyntaxError, ValueError, IndexError, ArithmeticError): 778 | continue 779 | break 780 | # Zero amounts are meaningless in this context 781 | if amount.startswith('0 '): 782 | print('Removing zero-amount `%s` from transaction.' % account) 783 | del(_transaction[-1]) 784 | else: 785 | output += '%s%s %s\n' % (data.indentation, account, amount) 786 | # The transaction is empty, no money moved anywhere 787 | if _transaction == []: 788 | raise KeyboardInterrupt 789 | # Update defaults and insert the transaction 790 | data.defaults[description] = _transaction 791 | insert_transaction(output.strip()) 792 | # Auto-flag if appropriate 793 | for _account in data.auto_flag: 794 | _count = output.count(_account) 795 | if _count > 1: 796 | toggle_flag_with_legs(_account) 797 | break 798 | elif _count == 1: 799 | toggle_flag() 800 | break 801 | 802 | 803 | # Read a balance assertion transaction 804 | def read_balance_transaction(): 805 | print('\nAdding a new balance assertion.') 806 | data.date = read_date(data.date) 807 | defaults = get_default_accounts('__balance__') 808 | if defaults == []: 809 | account = read_account() 810 | data.defaults['__balance__'] = [account] 811 | else: 812 | account = defaults[0] 813 | data.balance = 0 814 | amount = read_amount(account) 815 | output = '%s balance %s %s' % (data.date, account, amount) 816 | cur = amount.split()[1] 817 | balance = calculate_balance(account, before=data.date)[cur] 818 | insert_transaction(output) 819 | # Warn about mismatching balances 820 | delta = float(amount.split()[0]) - balance 821 | difference = 'missing' if delta < 0 else 'extra' 822 | if delta != 0: 823 | print('\nWarning: The balance does not match ({expected} {cur} expected, {delta} {cur} {difference}).'.format(expected=undecimal(balance), difference=difference, delta=undecimal(abs(delta)), cur=cur)) 824 | 825 | 826 | # Read a pad statement 827 | def read_pad_transaction(): 828 | print('\nAdding a new pad statement.') 829 | data.date = read_date(data.date) 830 | # Print a helpful hint 831 | if '__pad__' not in data.defaults: 832 | print('\nEnter the source and destination accounts.') 833 | defaults = get_default_accounts('__pad__') 834 | if defaults == []: 835 | account1 = read_account() 836 | account2 = read_account() 837 | data.defaults['__pad__'] = [account1, account2] 838 | else: 839 | account1, account2 = defaults 840 | output = '%s pad %s %s' % (data.date, account1, account2) 841 | insert_transaction(output) 842 | 843 | 844 | # Read a note statement 845 | def read_note_transaction(): 846 | # Unset completion 847 | data.vocab = [] 848 | readline.set_completer_delims('') 849 | set_history_context('note') 850 | print('\nAdding a new note statement.') 851 | data.date = read_date(data.date) 852 | defaults = get_default_accounts('__note__') 853 | if defaults == []: 854 | account = read_account() 855 | data.defaults['__note__'] = [account] 856 | else: 857 | account = defaults[0] 858 | description = input('Enter the note text: ').strip(' "') 859 | output = '%s note %s "%s"' % (data.date, account, description) 860 | insert_transaction(output) 861 | 862 | 863 | # Read a price statement 864 | def read_price_statement(): 865 | # Unset completion 866 | data.vocab = [] 867 | readline.set_completer_delims('') 868 | set_history_context('currency') 869 | print('\nAdding a new price statement.') 870 | data.date = read_date(data.date) 871 | currency = read_currency() 872 | try: 873 | rate, quote_currency = read_amount('the quote').split() 874 | except ValueError: 875 | raise KeyboardInterrupt 876 | tx = f'{data.date} price {currency} {rate} {quote_currency}' 877 | insert_transaction(tx) 878 | 879 | 880 | # Remove a transaction 881 | def remove_transaction(silent_drop): 882 | if data.txid >= 0: 883 | if silent_drop: 884 | del(data.journal[data.txid]) 885 | else: 886 | data.restore.append(data.journal.pop(data.txid)) 887 | print('\nRemoved record %s' % data.txid) 888 | if data.txid in data.statement: 889 | data.statement.remove(data.txid) 890 | update_statement_refs(data.txid, False) 891 | if data.txid >= len(data.journal): 892 | data.txid -= 1 893 | else: 894 | print('\nNothing to remove, no current record.') 895 | 896 | 897 | # Edit the current transaction with an external editor 898 | def edit_transaction(): 899 | if 'EDITOR' not in os.environ or os.environ['EDITOR'] == '': 900 | print('\nThe $EDITOR environmental variable is not set, editing unavailable.') 901 | return 902 | if data.txid < 0: 903 | print('\nNothing to edit, no current record.') 904 | return 905 | # Save the transaction in a temp file 906 | with open(tempfile, 'w') as tmp_file: 907 | tmp_file.write(data.journal[data.txid]) 908 | try: 909 | subprocess.call([os.environ['EDITOR'], tempfile]) 910 | except Exception as exc: 911 | print('\nCould not start the editor: %s\n' % exc) 912 | # Reload the transaction from the file 913 | with open(tempfile, 'r') as tmp_file: 914 | _tx = ''.join(tmp_file.readlines()).strip('\n') 915 | if _tx == data.journal[data.txid]: 916 | print('Nothing changed.') 917 | else: 918 | # Update the record 919 | if data.journal[data.txid][0:10] != _tx[0:10]: 920 | # Reinsert the transaction where appropriate if the date changed... 921 | remove_transaction(True) 922 | insert_transaction(_tx) 923 | else: 924 | # ...Update in-place otherwise 925 | data.journal[data.txid] = _tx 926 | print('Record updated.') 927 | os.unlink(tempfile) 928 | 929 | 930 | # Print a transaction in short format 931 | def print_transaction(tx, tx_amount=None): 932 | tx_string = data.journal[tx].split('\n') 933 | # Display the amount on the first leg in the preview unless given an explicit amount 934 | if tx_amount is None: 935 | try: 936 | # Don't bother figuring out amounts if it's not a standard transaction 937 | if '!' not in tx_string[0] and '*' not in tx_string[0]: 938 | raise IndexError 939 | first_account = condense(tx_string[1]) 940 | if first_account[0] == '!': 941 | del first_account[0] 942 | tx_amount = first_account[1:] 943 | except IndexError: 944 | tx_amount = '' 945 | if tx_amount != '': 946 | # Extract number from amount 947 | try: 948 | number = tx_amount[0] 949 | except IndexError: 950 | number = '' 951 | try: 952 | remainder = ' '.join(tx_amount[1:]) 953 | except IndexError: 954 | remainder = '' 955 | # Pad integer amounts 956 | if '.' not in number: 957 | number += ' ' 958 | # Pad positive amounts 959 | if not number.startswith('-'): 960 | number = ' ' + number 961 | # Justify amount 962 | tx_amount = ' '.join([number.rjust(data.amount_width), remainder[:3]]) 963 | # Trim and pad description 964 | txid_width = len(str(len(data.journal))) 965 | desc_width = os.get_terminal_size()[0] - txid_width - len(tx_amount) - 5 966 | description = tx_string[0][:desc_width].ljust(desc_width) 967 | # Mark current transaction 968 | tx_number = '>' * (txid_width + 2) if tx == data.txid else '[{}]'.format(tx).ljust(txid_width + 2) 969 | bold = '%s' if not data.colors or data.last_bold else '\33[1m%s\33[0m' 970 | data.last_bold = not data.last_bold 971 | print(bold % '%s %s %s' % (tx_number, description, tx_amount)) 972 | 973 | 974 | # Print the context to the current transaction (default: +-10 transactions) 975 | def print_journal(): 976 | if data.txid == -1: 977 | print('\nNo current record.') 978 | return 979 | start = max(data.txid - data.context, 0) 980 | end = min(data.txid + data.context, len(data.journal)) 981 | data.last_bold = True 982 | for tx in range(start, end): 983 | print_transaction(tx) 984 | 985 | 986 | # Attempt to deduce an implied amount: int txid -> bool transaction is correct 987 | def deduce_implied_amount(txid): 988 | _tx = data.journal[txid].split('\n') 989 | lineid = -1 990 | amount = 0 991 | cur = '' 992 | for lineno, line in enumerate(_tx): 993 | line = condense(line) 994 | # Ignore anything that isn't a transaction leg 995 | if line[0] == '!': 996 | del(line[0]) 997 | if line[0] not in data.accounts: 998 | continue 999 | # Find the leg that needs to be deduced 1000 | if len(line) == 1: 1001 | if lineid != -1: 1002 | print('\nError: More than one implied amount in record %s.' % txid) 1003 | return False 1004 | else: 1005 | lineid = lineno 1006 | else: 1007 | # FIXME multiple currencies are not supported 1008 | if cur != line[-1] and cur != '': 1009 | print('\nWarning: Multiple transactional currencies in record %s.' % txid) 1010 | return True 1011 | cur = line[-1] 1012 | if '@' in line and len(line) == 5: 1013 | line[-2] = undecimal(line[-2], 3) 1014 | amount -= float(line[1]) * float(line[-2]) 1015 | else: 1016 | amount -= float(line[1]) 1017 | amount = cast_number(amount) 1018 | # The record is perfectly fine as-is 1019 | if lineid == -1: 1020 | return True 1021 | # Something needs to be done 1022 | if amount != 0: 1023 | _tx[lineid] = _tx[lineid] + ' %s %s' % (undecimal(amount), cur) 1024 | else: 1025 | print('\nCould not figure out implied amount in record %s.' % txid) 1026 | return False 1027 | # Update the record 1028 | data.journal[txid] = '\n'.join(_tx) 1029 | print('Implied amounts corrected in record %s.' % txid) 1030 | return True 1031 | 1032 | 1033 | # Calculate the balance of an account 1034 | def calculate_balance(account, txlist=None, statement_verification_quirks=False, before=None): 1035 | # If no list of transactions to consider is given, default to the entire journal 1036 | if txlist is None: 1037 | if before is None: 1038 | txlist = list(range(len(data.journal))) 1039 | # If end date is given, summarize only records before it 1040 | else: 1041 | txlist = [] 1042 | for txid, tx in enumerate(data.journal): 1043 | if tx[:10] < before: 1044 | txlist.append(txid) 1045 | amounts = {} 1046 | for currency in data.currencies: 1047 | amounts[currency] = 0 1048 | # Go through the transaction list 1049 | for tx in txlist: 1050 | # Ignore comments 1051 | if data.journal[tx].startswith(';'): 1052 | continue 1053 | # See if it need corrections 1054 | if account in data.journal[tx]: 1055 | if not deduce_implied_amount(tx): 1056 | continue 1057 | lines = data.journal[tx].split('\n') 1058 | for _line in lines: 1059 | if account in _line and len(_line) < 3: 1060 | if not deduce_implied_amount(tx): 1061 | continue 1062 | # If the record is a balance statement, use that 1063 | line = condense(lines[0]) 1064 | if len(line) == 5 and line[1] == 'balance' and line[2] == account: 1065 | amounts[line[4]] = cast_number(line[3]) 1066 | continue 1067 | # If statement verification, look for the first flagged leg 1068 | if statement_verification_quirks: 1069 | flagid = -1 1070 | for _line in lines[1:]: 1071 | line = condense(_line) 1072 | if line[0] == '!': 1073 | flagid = lines.index(_line) 1074 | break 1075 | if flagid > -1: 1076 | if len(line) < 3: 1077 | print('Missing amount ignored in record %s.' % tx) 1078 | continue 1079 | line = condense(data.journal[tx].split('\n')[flagid])[1:] 1080 | amounts[line[2]] = cast_number(amounts[line[2]] + float(line[1])) 1081 | continue 1082 | # Else, just add the amount to the balance 1083 | for _line in lines[1:]: 1084 | line = condense(_line) 1085 | if line[0] == '!': 1086 | del(line[0]) 1087 | if line[0] == account: 1088 | if len(line) < 3: 1089 | print('Missing amount ignored in record %s.' % tx) 1090 | continue 1091 | deduce_commodity_price(line) 1092 | amounts[line[2]] = cast_number(amounts[line[2]] + float(line[1])) 1093 | return amounts 1094 | 1095 | 1096 | # Count and print the balances for an account 1097 | def print_account_balances(): 1098 | try: 1099 | account = read_account() 1100 | if account is None: 1101 | raise KeyboardInterrupt 1102 | except KeyboardInterrupt: 1103 | print('\nCancelled.') 1104 | return 1105 | except EOFError: 1106 | data.eof = True 1107 | return 1108 | balances = data.known_balances[account].copy() if account in data.known_balances else None 1109 | data.known_balances[account] = calculate_balance(account) 1110 | if balances is None: 1111 | balances = data.known_balances[account].copy() 1112 | print('\nBalances for %s:' % account) 1113 | output = '' 1114 | for currency in data.currencies: 1115 | value = '' 1116 | if data.quote_currency != '' and currency != data.quote_currency: 1117 | if currency not in data.prices: 1118 | value = ' [Unknown conversion rate]' 1119 | else: 1120 | value = ' [%s %s @ %s]' % (undecimal(balances[currency] * data.prices[currency]), data.quote_currency, undecimal(data.prices[currency], 3)) 1121 | difference = data.known_balances[account][currency] if currency not in balances else data.known_balances[account][currency] - balances[currency] 1122 | if difference == 0: 1123 | if data.known_balances[account][currency] != 0: 1124 | output += '%s %s%s\n' % (undecimal(data.known_balances[account][currency]), currency, value) 1125 | else: 1126 | difference = '%s%s' % ('+' if difference > 0 else '', undecimal(difference)) 1127 | output += '%s %s (%s %s)%s\n' % (undecimal(data.known_balances[account][currency]), currency, difference, currency, value) 1128 | if output == '': 1129 | output = '(nothing)' 1130 | print(output) 1131 | 1132 | 1133 | # Estimate the balance of the next statement 1134 | def estimate_statement_amount(account=None): 1135 | interactive = True if account is None else False 1136 | if interactive: 1137 | try: 1138 | account = read_account() 1139 | if account is None: 1140 | raise KeyboardInterrupt 1141 | except KeyboardInterrupt: 1142 | print('\nCancelled.') 1143 | return 1144 | except EOFError: 1145 | data.eof = True 1146 | return 1147 | transactions = tx_lookup([account, data.statement_pattern]) 1148 | balances = calculate_balance(account, transactions, True) 1149 | if interactive: 1150 | print('Estimated amount of the next statement:') 1151 | for currency in balances: 1152 | if balances[currency] != 0: 1153 | line = '%s %s' % (undecimal(abs(balances[currency])), currency) 1154 | if not interactive: 1155 | return line 1156 | print(line) 1157 | 1158 | 1159 | # Nudge txids in the statement following a journal modification 1160 | def update_statement_refs(txid, insertion): 1161 | for tx, statement in enumerate(data.statement): 1162 | if insertion: 1163 | if statement >= txid: 1164 | data.statement[tx] += 1 1165 | else: 1166 | if statement > txid: 1167 | data.statement[tx] -= 1 1168 | 1169 | 1170 | # Verify a statement 1171 | def verify_statement(): 1172 | # If wasn't working on a statement before 1173 | if data.target == 0 or not confirm('Continue ongoing statement verification? (Y/n) ', True): 1174 | # Get some general information 1175 | try: 1176 | data.statement = [] 1177 | print('\nEnter the account the statement refers to.') 1178 | data.transaction_account = read_account() 1179 | print('\nEnter the account which funds the statement.') 1180 | data.funding_account = read_account() 1181 | print() 1182 | data.balance, data.last_currency = estimate_statement_amount(data.transaction_account).split() 1183 | data.balance = float(data.balance) 1184 | while True: 1185 | try: 1186 | amount = read_amount('the statement') 1187 | except (SyntaxError, ValueError, ArithmeticError): 1188 | continue 1189 | break 1190 | data.target = cast_number(amount.split()[0]) 1191 | if data.target == 0: 1192 | raise KeyboardInterrupt 1193 | data.statement_currency = amount.split()[1] 1194 | except KeyboardInterrupt: 1195 | print('\nCancelled.') 1196 | return 1197 | except EOFError: 1198 | data.eof = True 1199 | return 1200 | neg = -1 if data.transaction_account.startswith('Liabilities:') or data.transaction_account.startswith('Income:') else 1 1201 | set_history_context('txids') 1202 | while True: 1203 | # Tally the balance 1204 | balance = calculate_balance(data.transaction_account, data.statement, True)[data.statement_currency] * neg 1205 | print('\nTotal: %s %s (target: %s %s, difference: %s %s)' % (undecimal(balance), data.statement_currency, undecimal(data.target), data.statement_currency, undecimal(balance - data.target), data.statement_currency)) 1206 | if balance == data.target: 1207 | break 1208 | transactions = tx_lookup([data.transaction_account, data.statement_pattern], data.statement) 1209 | # List matching records neatly justified 1210 | for title, txlist in [ 1211 | ['Statement Records', data.statement], 1212 | ['Available Records', transactions] 1213 | ]: 1214 | print('\n------- {} '.format(title).ljust(os.get_terminal_size()[0] + 1, '-')) 1215 | if txlist == []: 1216 | print(' (nothing)') 1217 | continue 1218 | data.last_bold = True 1219 | for tx in txlist: 1220 | # Ignore commented out records 1221 | if data.journal[tx].startswith(';'): 1222 | continue 1223 | _amount = undecimal(calculate_balance(data.transaction_account, [tx], True)[data.statement_currency] * neg) 1224 | print_transaction(tx, [_amount, data.statement_currency]) 1225 | # Select records to add or remove 1226 | print('\nSelect records to add to the statement. Type a record number already on the statement to remove it.') 1227 | try: 1228 | reading = int(input('Enter a record number: ').strip()) 1229 | if reading < 0 or reading > len(data.journal) - 1: 1230 | raise ValueError 1231 | except ValueError: 1232 | print('\nInvalid record number.') 1233 | continue 1234 | except KeyboardInterrupt: 1235 | print('\nPostponing statement verification.') 1236 | return 1237 | except EOFError: 1238 | data.eof = True 1239 | return 1240 | # Add the input to history 1241 | if str(reading) in data.history['seek']: 1242 | data.history['seek'].remove(str(reading)) 1243 | data.history['seek'].append(str(reading)) 1244 | # Add or remove records 1245 | if reading in data.statement: 1246 | data.statement.remove(reading) 1247 | elif reading in transactions: 1248 | data.statement.append(reading) 1249 | data.statement.sort() 1250 | else: 1251 | print('\nThis record is not relevant to the statement.') 1252 | continue 1253 | # Modify the journal 1254 | if confirm('Commit statement to journal? (Y/n) ', True): 1255 | try: 1256 | data.date = read_date(data.date) 1257 | desc = read_description() 1258 | except KeyboardInterrupt: 1259 | print('\nPostponing statement verification.') 1260 | return 1261 | except EOFError: 1262 | data.eof = True 1263 | return 1264 | print('\nSetting transaction flags.') 1265 | lookup_string = '! %s' % data.transaction_account 1266 | for tx in data.statement: 1267 | # Unflag one of the legs first 1268 | if lookup_string in data.journal[tx]: 1269 | data.journal[tx] = data.journal[tx].replace(lookup_string, data.transaction_account, 1) 1270 | # And if it was the last one, unflag the transaction also 1271 | if lookup_string not in data.journal[tx]: 1272 | data.journal[tx] = data.journal[tx].replace(data.statement_pattern, data.statement_replacement) 1273 | # Add the statement transaction 1274 | output = '%s * "%s"\n%s%s %s %s\n%s%s %s %s' % (data.date, desc, data.indentation, data.funding_account, undecimal(balance * -1), data.statement_currency, data.indentation, data.transaction_account, undecimal(balance), data.statement_currency) 1275 | insert_transaction(output) 1276 | # Mark the verification as finished 1277 | data.target = 0 1278 | 1279 | 1280 | # Move any new transactions to the external journal 1281 | def externalize_transactions(): 1282 | journal = [] 1283 | for _tx in data.journal: 1284 | if _tx not in data.saved: 1285 | journal.append(_tx) 1286 | return journal 1287 | 1288 | 1289 | # Duplicate the current transaction at another date 1290 | def duplicate_transaction(): 1291 | if data.txid == -1: 1292 | print('\nNo current record.') 1293 | return 1294 | _tx = data.journal[data.txid] 1295 | # If the record does not start with a date, just copy it to the end of the file 1296 | if _tx[0] not in '0123456789': 1297 | print('\nTransaction added as record %s\n\n%s' % (len(data.journal), _tx)) 1298 | data.journal.append(_tx) 1299 | else: 1300 | data.date = read_date(data.date) 1301 | new_tx = data.date + data.journal[data.txid][10:] 1302 | insert_transaction(new_tx) 1303 | 1304 | 1305 | # Print changes from saved journal 1306 | def print_journal_diff(): 1307 | if data.journal == data.saved: 1308 | print('\nNo changes.') 1309 | return 1310 | endcolor = '\33[0m' if data.colors else '' 1311 | journal = set(externalize_transactions() if data.external_write else data.journal) 1312 | saved = set(data.external_journal if data.external_write else data.saved) 1313 | # Look for additions to the journal 1314 | for record in journal: 1315 | if record not in saved: 1316 | print() 1317 | color = '\33[32m' if data.colors else '' 1318 | record = f'{color}+' + f'{endcolor}\n{color}+'.join(record.split('\n')) + f'{endcolor}' 1319 | print(record) 1320 | # Look for deletions from the journal (except for external writes) 1321 | if data.external_write: 1322 | return 1323 | for record in saved: 1324 | if record not in journal: 1325 | print() 1326 | color = '\33[31m' if data.colors else '' 1327 | record = f'{color}-' + f'{endcolor}\n{color}-'.join(record.split('\n')) + f'{endcolor}' 1328 | print(record) 1329 | 1330 | 1331 | # Process command line arguments 1332 | if len(sys.argv) < 2: 1333 | print(usage) 1334 | sys.exit(1) 1335 | elif sys.argv[1] == '-h' or sys.argv[1] == '--help': 1336 | print(usage) 1337 | sys.exit() 1338 | elif '-n' in sys.argv: 1339 | sys.argv.remove('-n') 1340 | data.auto_new = True 1341 | 1342 | # Read and process journal file 1343 | data.file_name = sys.argv[1] 1344 | try: 1345 | with open(data.file_name, 'r') as source_file: 1346 | tx_file = source_file.read() 1347 | data.mtime = os.stat(data.file_name).st_mtime 1348 | except FileNotFoundError: 1349 | tx_file = '' 1350 | except Exception as exc: 1351 | print('Could not open the file: %s' % exc) 1352 | sys.exit(2) 1353 | 1354 | description = '' 1355 | lastdate = '0' 1356 | for full_line in tx_file.split('\n'): 1357 | line = full_line.strip() 1358 | if line == '': 1359 | # Transaction came out empty 1360 | if description in data.defaults and data.defaults[description] == []: 1361 | del(data.defaults[description]) 1362 | description = '' 1363 | continue 1364 | # Skip comments, but make use of the option strings 1365 | elif line.startswith(';'): 1366 | if line.startswith(';*bean-add* '): 1367 | _auto_flag = False 1368 | _quote = False 1369 | _precision = False 1370 | for i in line.split()[1:]: 1371 | if i == 'a': 1372 | data.use_defaults = True 1373 | elif i == 'aa': 1374 | data.use_defaults = False 1375 | elif i == 's': 1376 | data.sort_by_date = False 1377 | elif i == 'w': 1378 | data.paranoid_write = True 1379 | elif i == 'e': 1380 | data.external_write = True 1381 | elif i == 'd': 1382 | data.date_preview = False 1383 | elif i == 'l': 1384 | data.lookup_seek = True 1385 | elif i == 'll': 1386 | data.lookup_seek = False 1387 | elif i == 'c': 1388 | data.colors = True 1389 | elif i == 'b': 1390 | data.use_beancount_accounts = True 1391 | elif i == 'f': 1392 | _auto_flag = True 1393 | elif _auto_flag: 1394 | data.auto_flag.append(i) 1395 | _auto_flag = False 1396 | elif i == 'q': 1397 | _quote = True 1398 | elif _quote: 1399 | data.quote_currency = i 1400 | _quote = False 1401 | elif i == 'p': 1402 | _precision = True 1403 | elif _precision: 1404 | try: 1405 | currency, digits = i.split(':') 1406 | data.precisions[currency] = int(digits) 1407 | except ValueError: 1408 | print('Malformed option: p %s' % i) 1409 | _precision = False 1410 | else: 1411 | print('Unrecognized option: %s' % i) 1412 | print('Loaded options from journal file.') 1413 | continue 1414 | # Process and cache commodity prices 1415 | _data = condense(line) 1416 | deduce_commodity_price(_data) 1417 | try: 1418 | # Attach transaction legs to previous description 1419 | if _data[0] == '!': 1420 | del _data[0] 1421 | if _data[0] in data.accounts: 1422 | # Detect indentation type 1423 | if data.indentation is None: 1424 | _match = re.match('\\s+', full_line) 1425 | if _match is not None: 1426 | data.indentation = _match.group() 1427 | data.defaults[description].append(_data[0]) 1428 | # Detect involved currencies and amounts 1429 | if len(_data) > 1: 1430 | detect_currencies(_data) 1431 | data.accounts[_data[0]] = data.last_currency 1432 | _amount = str(int(float(_data[1]))) 1433 | data.amount_width = max(data.amount_width, len(_amount)) 1434 | # Add account name to account list 1435 | elif _data[1] == 'open': 1436 | data.accounts[_data[2]] = None 1437 | if len(_data) > 3: 1438 | data.constraints[_data[2]] = _data[3].split(',') 1439 | data.accounts[_data[2]] = data.constraints[_data[2]][0] 1440 | # Remove closed accounts from completion 1441 | elif _data[1] == 'close' and _data[2] in data.accounts: 1442 | del data.accounts[_data[2]] 1443 | # Cache command data 1444 | elif _data[1] == 'balance': 1445 | data.defaults['__balance__'] = [_data[2]] 1446 | elif _data[1] == 'pad': 1447 | data.defaults['__pad__'] = [_data[2], _data[3]] 1448 | # Record a description 1449 | elif _data[1] in '!*': 1450 | _date = _data[0][0:10] 1451 | if data.sort_by_date and _date < lastdate: 1452 | print('Sorting by date disabled due to inconsistent ordering at %s.' % _date) 1453 | data.sort_by_date = False 1454 | else: 1455 | lastdate = _date 1456 | data.date_delimiter = _data[0][4] 1457 | description = line[13:].split('#')[0].strip('" ') 1458 | data.defaults[description] = [] 1459 | # Cache any tags present 1460 | if '#' in line: 1461 | for word in _data: 1462 | if word.startswith('#'): 1463 | if not word[1:] in data.tags: 1464 | data.tags.append(word[1:]) 1465 | data.tag = word[1:] 1466 | # Pass through malformed (or unrecognized) lines without stopping 1467 | except IndexError: 1468 | continue 1469 | 1470 | # Try to use beancount to get the account list 1471 | if data.use_beancount_accounts: 1472 | try: 1473 | beancount = subprocess.Popen(['bean-report', data.file_name, 'accounts'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 1474 | stdout, stderr = beancount.communicate() 1475 | for _line in str(stdout, 'UTF-8').split('\n'): 1476 | _line = condense(_line) 1477 | if len(_line) == 2 and _line[0] not in data.accounts: 1478 | data.accounts[_line[0]] = None 1479 | elif len(_line) == 3 and _line[0] in data.accounts: 1480 | del data.accounts[_line[0]] 1481 | except Exception as exc: 1482 | print('Unable to use beancount to get the account list: %s' % exc) 1483 | 1484 | 1485 | data.amount_width += 3 1486 | # Prepare file for transaction insertion 1487 | data.journal = tx_file.split('\n\n') 1488 | data.journal = [i.strip() for i in data.journal] 1489 | # Initialize modification tracking 1490 | data.saved = data.journal.copy() 1491 | # Remove empty records 1492 | while '' in data.journal: 1493 | data.journal.remove('') 1494 | # Move balance statements to the beginning of each day 1495 | for i, jtx in enumerate(data.journal): 1496 | if jtx[0] in '0123456789' and (jtx[10:19] == ' balance ' or jtx[10:17] == ' price '): 1497 | _date = jtx[0:10] 1498 | _tx = i 1499 | while True: 1500 | # Seek backwards until the first non-comment transaction or another balance statement with an earlier date 1501 | _tx -= 1 1502 | if data.journal[_tx][0] not in '0123456789': 1503 | continue 1504 | if not data.journal[_tx].startswith(_date) or (data.journal[_tx][10:19] == ' balance ' or data.journal[_tx][10:17] == ' price '): 1505 | # Attempt to move the balance statement up 1506 | if _tx == i - 1: 1507 | break 1508 | data.journal.insert(_tx + 1, data.journal[i]) 1509 | del(data.journal[i + 1]) 1510 | break 1511 | 1512 | # Notify that some trivial fixes were made 1513 | if data.journal != data.saved: 1514 | print('Automatic normalizations applied.') 1515 | data.saved = data.journal.copy() 1516 | 1517 | # Set up the completer 1518 | readline.parse_and_bind('tab: menu-complete') 1519 | readline.parse_and_bind('"\e[Z": menu-complete-backward') 1520 | readline.parse_and_bind('set show-all-if-ambiguous on') 1521 | readline.parse_and_bind('set menu-complete-display-prefix on') 1522 | 1523 | readline.set_completer(complete) 1524 | 1525 | # Print some statistics 1526 | print('\n%s processed' % pluralize(len(data.journal), 'record')) 1527 | if len(data.accounts) > 0: 1528 | print('%s, %s, %s and %s loaded' % ( 1529 | pluralize(len(data.accounts), 'account name'), 1530 | pluralize(len(data.defaults), 'unique description'), 1531 | pluralize(len(data.currencies), 'currency sign'), 1532 | pluralize(len(data.tags), 'tag'))) 1533 | else: 1534 | # Empty file 1535 | if tx_file == '': 1536 | print('File does not exist, or is empty.') 1537 | # Not empty, but un-useful file 1538 | else: 1539 | print('File contains no account definitions.') 1540 | if not confirm('Proceed with editing? (y/N) ', False): 1541 | sys.exit() 1542 | 1543 | # Date delimiter handling 1544 | if data.date_delimiter != '-': 1545 | print('\nWarning: Non-standard date delimiter.') 1546 | data.date = data.date.replace('-', data.date_delimiter) 1547 | data.date_prompt = data.date_delimiter.join(['%Y', '%m', '%d']) 1548 | 1549 | # Default to indentation by a single tab 1550 | if data.indentation is None: 1551 | data.indentation = ' ' 1552 | if data.indentation != ' ': 1553 | print('\nWarning: Non-standard indentation.') 1554 | 1555 | # Init date and currencies 1556 | if data.currencies == []: 1557 | data.last_currency = 'USD' 1558 | data.currencies.append('USD') 1559 | elif data.last_currency is None: 1560 | data.last_currency = data.currencies[0] 1561 | 1562 | # Main data entry loop 1563 | if data.paranoid_write: 1564 | print('\nChanges are written to disk immediately.') 1565 | else: 1566 | print('\nChanges stay in memory until you [w]rite them out.') 1567 | print('Type `h` or `?` for a list of available commands.') 1568 | 1569 | while True: 1570 | # Check journal modifications for paranoid write mode 1571 | if data.paranoid_write and not data.external_write and data.journal != data.saved: 1572 | cmd = 'w' 1573 | elif data.paranoid_write and data.external_write and externalize_transactions() != data.external_journal: 1574 | cmd = 'w' 1575 | elif data.eof: 1576 | cmd = 'q' 1577 | print() 1578 | data.eof = False 1579 | # Pass-through transaction entry 1580 | elif data.auto_new: 1581 | print('\nAdding a new transaction with today\'s date.\n') 1582 | cmd = 'n' 1583 | # Get a command 1584 | else: 1585 | # Unset completion 1586 | data.vocab = [] 1587 | # Set history 1588 | set_history_context('command') 1589 | try: 1590 | cmd = input('\nEnter command (h for help) ').strip() 1591 | except (KeyboardInterrupt, EOFError): 1592 | print() 1593 | cmd = 'q' 1594 | # Write the journal 1595 | if cmd in ['w', 'wq']: 1596 | if data.mtime != os.stat(data.file_name).st_mtime: 1597 | print('\nWARNING: The journal file was modified on disk after opening; The journal is most likely inconsistent.') 1598 | if not data.external_write and not confirm('Do you really want to overwrite it? (y/N) ', False): 1599 | continue 1600 | if data.external_write: 1601 | file_name = data.file_name + '.new' 1602 | data.external_journal = externalize_transactions() 1603 | journal = data.external_journal 1604 | else: 1605 | file_name = data.file_name 1606 | journal = data.journal 1607 | try: 1608 | with open(file_name, 'w') as source_file: 1609 | source_file.write('\n\n'.join(journal) + '\n') 1610 | print('\n%s written' % pluralize(len(journal), 'record')) 1611 | data.mtime = os.stat(data.file_name).st_mtime 1612 | except Exception as exc: 1613 | print('\nCould not write the file: %s' % exc) 1614 | continue 1615 | else: 1616 | if not data.external_write: 1617 | data.saved = data.journal.copy() 1618 | if cmd == 'wq': 1619 | sys.exit() 1620 | # Quit 1621 | elif cmd == 'q': 1622 | try: 1623 | if not data.external_write and data.journal != data.saved: 1624 | if not confirm('The journal file was modified. Do you really want to DESTROY ALL CHANGES and quit? (y/N) ', False): 1625 | continue 1626 | elif data.external_write: 1627 | journal = externalize_transactions() 1628 | modified = False 1629 | for _tx in journal: 1630 | if _tx not in data.external_journal: 1631 | modified = True 1632 | break 1633 | if modified: 1634 | if not confirm('There are unsaved records in the external file buffer. Do you really want to DESTROY ALL CHANGES and quit? (y/N) ', False): 1635 | continue 1636 | elif data.target != 0: 1637 | if not confirm('Statement verification in progress. Do you really want to DESTROY ALL CHANGES and quit? (y/N) ', False): 1638 | continue 1639 | # Treat a second CTRL-C as a confirmation 1640 | except (KeyboardInterrupt, EOFError): 1641 | pass 1642 | sys.exit() 1643 | # Run bean-check 1644 | elif cmd == 'c': 1645 | with open(tempfile, 'w') as tmp_file: 1646 | tmp_file.write('\n\n'.join(data.journal) + '\n') 1647 | try: 1648 | subprocess.call(['bean-check', tempfile]) 1649 | except Exception as exc: 1650 | print('\nCould not start the validator: %s' % exc) 1651 | else: 1652 | print('\nValidation finished.') 1653 | os.unlink(tempfile) 1654 | # New transaction 1655 | elif cmd == 'n': 1656 | try: 1657 | read_normal_transaction() 1658 | except KeyboardInterrupt: 1659 | print('\nTransaction entry cancelled.') 1660 | except EOFError: 1661 | data.eof = True 1662 | # Unset pass-through transaction entry 1663 | if data.auto_new: 1664 | data.auto_new = False 1665 | # New transaction sequence 1666 | elif cmd == 'nn': 1667 | tx_count = 0 1668 | defaults = data.use_defaults 1669 | try: 1670 | read_normal_transaction() 1671 | # Set to auto-accept as much as possible 1672 | data.use_defaults = True 1673 | while True: 1674 | tx_count += 1 1675 | print('\nAdding transaction #%s\n' % (tx_count + 1)) 1676 | read_normal_transaction(data.description) 1677 | except KeyboardInterrupt: 1678 | print('\nTransaction entry cancelled.') 1679 | except EOFError: 1680 | data.eof = True 1681 | finally: 1682 | print('\n%s added.' % pluralize(tx_count, 'transaction')) 1683 | # Restore original behavior 1684 | data.use_defaults = defaults 1685 | # New balance assertion 1686 | elif cmd == 'B': 1687 | try: 1688 | read_balance_transaction() 1689 | except KeyboardInterrupt: 1690 | print('\nTransaction entry cancelled.') 1691 | except EOFError: 1692 | data.eof = True 1693 | # New padding transaction 1694 | elif cmd == 'P': 1695 | try: 1696 | read_pad_transaction() 1697 | except KeyboardInterrupt: 1698 | print('\nTransaction entry cancelled.') 1699 | except EOFError: 1700 | data.eof = True 1701 | # New note transaction 1702 | elif cmd == 'N': 1703 | try: 1704 | read_note_transaction() 1705 | except KeyboardInterrupt: 1706 | print('\nTransaction entry cancelled.') 1707 | except EOFError: 1708 | data.eof = True 1709 | elif cmd == 'R': 1710 | try: 1711 | read_price_statement() 1712 | except KeyboardInterrupt: 1713 | print('\nTransaction entry cancelled.') 1714 | except EOFError: 1715 | data.eof = True 1716 | # Toggle flag 1717 | elif cmd == 'f': 1718 | toggle_flag() 1719 | # Toggle deep flag 1720 | elif cmd == 'ff': 1721 | try: 1722 | toggle_flag_with_legs() 1723 | except KeyboardInterrupt: 1724 | print('\nCancelled.') 1725 | except EOFError: 1726 | data.eof = True 1727 | # Edit tags 1728 | elif cmd == 't': 1729 | add_remove_tag() 1730 | # Seeking 1731 | elif cmd == 's': 1732 | seek_to_transaction() 1733 | elif cmd == 'sa': 1734 | seek_to_transaction(-1) 1735 | elif cmd == 'sd': 1736 | seek_to_transaction('+1') 1737 | elif cmd == 'sq': 1738 | seek_to_transaction(0) 1739 | elif cmd == 'se': 1740 | seek_to_transaction(len(data.journal) - 1) 1741 | # Lookup 1742 | elif cmd == 'l': 1743 | find_transactions() 1744 | elif cmd == 'ld': 1745 | find_transactions(1) 1746 | elif cmd == 'la': 1747 | find_transactions(-1) 1748 | elif cmd == 'le': 1749 | find_transactions(True) 1750 | elif cmd == 'lq': 1751 | find_transactions(False) 1752 | # Remove record 1753 | elif cmd == 'r': 1754 | remove_transaction(False) 1755 | # Unremove record 1756 | elif cmd == 'u': 1757 | if len(data.restore) > 0: 1758 | insert_transaction(data.restore.pop()) 1759 | else: 1760 | print('\nNothing to undo, the restore buffer is empty.') 1761 | # Duplicate record 1762 | elif cmd == 'd': 1763 | try: 1764 | duplicate_transaction() 1765 | except KeyboardInterrupt: 1766 | print('\nCancelled.') 1767 | except EOFError: 1768 | data.eof = True 1769 | # External edit 1770 | elif cmd == 'e': 1771 | edit_transaction() 1772 | # Show context 1773 | elif cmd == 'j': 1774 | print_journal() 1775 | # Show changes 1776 | elif cmd == 'jj': 1777 | print_journal_diff() 1778 | # Tally account balances 1779 | elif cmd == 'b': 1780 | print_account_balances() 1781 | # Estimate statement amount 1782 | elif cmd == 'bv': 1783 | estimate_statement_amount() 1784 | # Begin statement verification 1785 | elif cmd == 'v': 1786 | # Disable marking of current record 1787 | txid_cache = data.txid 1788 | data.txid = -1 1789 | verify_statement() 1790 | # Restore marking of current record 1791 | data.txid = txid_cache 1792 | # Show options 1793 | elif cmd == 'o': 1794 | print('Available option commands:') 1795 | print('oo write current options to the journal') 1796 | print('os sort transactions by date (%s)' % ('enabled' if data.sort_by_date else 'disabled')) 1797 | print('oa reuse last used accounts in new transactions (%s)' % ('ask' if data.use_defaults is None else 'always' if data.use_defaults else 'never')) 1798 | print('ow write journal after every change (%s)' % ('enabled' if data.paranoid_write else 'disabled')) 1799 | print('oe write changes to an external file (%s)' % ('enabled' if data.external_write else 'disabled')) 1800 | print('od preview non-absoulute dates during input (%s)' % ('enabled' if data.date_preview else 'disabled')) 1801 | print('of automatically flag transactions containing (%s)' % (None if data.auto_flag == [] else ', '.join(data.auto_flag))) 1802 | print('ol lookup result to seek to (%s)' % ('none' if data.lookup_seek is None else 'last' if data.lookup_seek else 'first')) 1803 | print('oc colorize output (%s)' % ('enabled' if data.colors else 'disabled')) 1804 | print('oq quote currency for all conversions (%s)' % ('none' if data.quote_currency == '' else data.quote_currency)) 1805 | precision_string = ', '.join(['%s:%s' % (k, v) for k, v in data.precisions.items()]) 1806 | print('op custom precision for commodities (%s)' % ('none' if precision_string == '' else precision_string)) 1807 | print('ob use Beancount to load account names (%s)' % ('enabled' if data.use_beancount_accounts else 'disabled')) 1808 | # Option: sort by date 1809 | elif cmd == 'os': 1810 | data.sort_by_date = not data.sort_by_date 1811 | print('\nSorting by date is now %s.' % ('enabled' if data.sort_by_date else 'disabled')) 1812 | # Option: accept defaults 1813 | elif cmd == 'oa': 1814 | data.use_defaults = True if data.use_defaults is None else False if data.use_defaults else None 1815 | print('\nThe policy on reusing accounts for new transactions is now `%s`.' % ('ask' if data.use_defaults is None else 'always' if data.use_defaults else 'never')) 1816 | # Option: paranoid write 1817 | elif cmd == 'ow': 1818 | data.paranoid_write = not data.paranoid_write 1819 | print('\nWriting journal after every change is now %s.' % ('enabled' if data.paranoid_write else 'disabled')) 1820 | # Option: external write 1821 | elif cmd == 'oe': 1822 | data.external_write = not data.external_write 1823 | print('\nWriting changes to an external file is now %s.' % ('enabled' if data.external_write else 'disabled')) 1824 | # Option: date preview 1825 | elif cmd == 'od': 1826 | data.date_preview = not data.date_preview 1827 | print('\nPreviewing of non-absolute dates during input is now %s.' % ('enabled' if data.date_preview else 'disabled')) 1828 | # Option: output colorization 1829 | elif cmd == 'oc': 1830 | data.colors = not data.colors 1831 | print('\nColorizing of output is now %s.' % ('enabled' if data.colors else 'disabled')) 1832 | # Option: Beancount account loading 1833 | elif cmd == 'ob': 1834 | data.use_beancount_accounts = not data.use_beancount_accounts 1835 | print('\nUsing Beancount to load account names is now %s.' % ('enabled' if data.use_beancount_accounts else 'disabled')) 1836 | print('Save the options to the journal file with `oo` for it to take effect on the next journal load.') 1837 | # Option: auto-flagging of new records 1838 | elif cmd == 'of': 1839 | try: 1840 | _account = read_account() 1841 | if _account is None: 1842 | raise KeyboardInterrupt 1843 | if _account in data.auto_flag: 1844 | print('Removed from auto-flag list.') 1845 | data.auto_flag.remove(_account) 1846 | else: 1847 | print('Added to auto-flag list.') 1848 | data.auto_flag.append(_account) 1849 | data.auto_flag.sort() 1850 | print('\nAccounts to trigger automatic flagging:\n %s' % (None if data.auto_flag == [] else '\n '.join(data.auto_flag))) 1851 | except KeyboardInterrupt: 1852 | print('\nCancelled.') 1853 | except EOFError: 1854 | data.eof = True 1855 | # Option: auto-seek to a lookup result 1856 | elif cmd == 'ol': 1857 | data.lookup_seek = True if data.lookup_seek is None else False if data.lookup_seek else None 1858 | print('\nSeeking to lookup result is set to %s.' % ('none' if data.lookup_seek is None else 'last' if data.lookup_seek else 'first')) 1859 | # Option: default quote currency 1860 | elif cmd == 'oq': 1861 | try: 1862 | print('Enter new quote currency for all conversions, or leave blank to disable.') 1863 | data.quote_currency = read_currency() 1864 | print('\nQuote currency set to %s.' % ('None' if data.quote_currency == '' else data.quote_currency)) 1865 | data.prices = {} 1866 | for tx in data.journal: 1867 | for line in tx.split('\n'): 1868 | deduce_commodity_price(line) 1869 | except KeyboardInterrupt: 1870 | print('\nCancelled.') 1871 | except EOFError: 1872 | data.eof = True 1873 | # Option: custom precisions 1874 | elif cmd == 'op': 1875 | try: 1876 | currency = read_currency() 1877 | precision = input(f'Enter precision [{data.default_precision}]: ') 1878 | if precision == '': 1879 | precision = data.default_precision 1880 | precision = int(precision) 1881 | if precision == data.default_precision: 1882 | if currency in data.precisions: 1883 | del data.precisions[currency] 1884 | print(f'\nPrecision for {currency} reset to default.') 1885 | else: 1886 | data.precisions[currency] = precision 1887 | print(f'\nPrecision for {currency} set to {precision} decimals.') 1888 | except (KeyboardInterrupt, ValueError): 1889 | print('\nCancelled.') 1890 | except EOFError: 1891 | data.eof = True 1892 | # Save options to the journal 1893 | elif cmd == 'oo': 1894 | # Generate the option string 1895 | optstring = ';*bean-add*' 1896 | optstring += '' if data.sort_by_date else ' s' 1897 | optstring += '' if data.use_defaults is None else ' a' if data.use_defaults else ' aa' 1898 | optstring += ' w' if data.paranoid_write else '' 1899 | optstring += ' e' if data.external_write else '' 1900 | optstring += '' if data.date_preview else ' d' 1901 | optstring += '' if data.lookup_seek is None else ' l' if data.lookup_seek else ' ll' 1902 | optstring += ' c' if data.colors else '' 1903 | optstring += ' q ' + data.quote_currency if data.quote_currency != '' else '' 1904 | optstring += ' b' if data.use_beancount_accounts else '' 1905 | for _account in data.auto_flag: 1906 | optstring += ' f %s' % _account 1907 | for currency, precision in data.precisions.items(): 1908 | optstring += f' p {currency}:{precision}' 1909 | # Apply the option string 1910 | if data.journal[0].startswith(';*bean-add* '): 1911 | data.journal[0] = optstring 1912 | else: 1913 | data.journal.insert(0, optstring) 1914 | # Delete all other option strings 1915 | for _tx in data.journal[1:]: 1916 | if _tx.startswith(';*bean-add* '): 1917 | data.journal.remove(_tx) 1918 | print('\nOptions saved to the journal as record 0.') 1919 | # Help 1920 | elif cmd == 'h' or cmd == '?': 1921 | print('''Available commands: 1922 | n new transaction 1923 | nn add a sequence of near-identical transactions 1924 | d duplicate the current transaction at another date 1925 | f toggle transaction flag 1926 | ff flag a transaction including its legs 1927 | t add or remove tags 1928 | s seek to another record 1929 | sa seek one record forward 1930 | sd seek one record backwards 1931 | sq seek to the first record 1932 | se seek to the last record 1933 | l look up records containing a string 1934 | ld go one lookup result forward 1935 | la go one lookup result backwards 1936 | le go to the last lookup result 1937 | lq go to the first lookup result 1938 | j show the context of the current record 1939 | jj show changes from saved journal file 1940 | b show the balance of an account 1941 | bv estimate the amount of the next statement 1942 | r remove the current record 1943 | u undo record removal 1944 | e edit the current record with your preferred editor 1945 | v verify a statement 1946 | B new balance assertion 1947 | P new pad statement 1948 | N new note statement 1949 | R new price statement 1950 | c run bean-check on the journal file 1951 | o view or change options 1952 | w write journal file 1953 | q quit 1954 | wq write journal file and quit immediately 1955 | h,? show this help message''') 1956 | # Repeat the prompt hint 1957 | elif cmd == '': 1958 | print('\nType `h` or `?` for a list of available commands.') 1959 | # Say what 1960 | else: 1961 | print('\nUnknown command.') 1962 | --------------------------------------------------------------------------------