├── README.md ├── LICENSE ├── example.ledger ├── web.py ├── css ├── normalize.css └── skeleton.css ├── test.py └── uledger.py /README.md: -------------------------------------------------------------------------------- 1 | # uledger 2 | Minimalist python clone of ledger-cli. 3 | 4 | This is a small, pure-python implementation that supports some of the `ledger-cli` file format. 5 | 6 | It primarily exists because `ledger-cli` does not sort by date. This makes it difficult to import 7 | transactions out-of-order, and perform balance assertions periodically in the journal. `hledger` 8 | supports this, but I found the installation rather onerous for my needs. 9 | 10 | This implementation currently supports the following: 11 | 12 | alias groceries Expenses:Groceries 13 | bucket Asssets:Bank1 14 | YYYY-MM-DD Example description 15 | groceries ($123 * 1.05) 16 | 17 | Also supported is the "include" keyword. 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Daniel Patterson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /example.ledger: -------------------------------------------------------------------------------- 1 | ; Personal book-keeping typically has 4 account types: 2 | ; Expense : where you record what you spent. 3 | ; Income : where you got money from others. 4 | ; Asset : money you have right now 5 | ; Liability : money you owe to others 6 | ; 7 | ; Business bookkeeping adds a 5th type: 8 | ; Equity : assets supplied by the owners 9 | ; 10 | ; The rules are: 11 | ; - When you buy something, add value to the expense account 12 | ; and subtract it from the source of the funds 13 | ; - When you receive income, subtract it from the income account 14 | ; and add it to the account it went to 15 | ; 16 | ; Income accounts read negative the more money you receive 17 | ; (i.e. the source of the income, your job, has less money) 18 | ; Expense accounts go positive as you spend more money 19 | ; Liability accounts to more negative as you owe more money 20 | ; Asset accounts go more positive as you have more money 21 | 22 | ; You can record this two ways: 23 | 2015-05-30 Salary 24 | ; Recording the gross pay here makes it easier to just look at your books 25 | ; when it comes time to fill in your tax forms at the end of the year 26 | Personal:Income:Day Job $-1000 27 | Personal:Expenses:Federal income tax withheld $100 28 | Personal:Expenses:State income tax withheld $100 29 | Personal:Assets:Checking Account 30 | 31 | 2015-06-01 Groceries 32 | Personal:Expenses:Groceries $22 33 | Personal:Assets:Checking Account 34 | 35 | 2015-06-02 Car servicing 36 | Personal:Expenses:Vehicle $422.98 37 | Personal:Liabilities:VISA 38 | 39 | ; The bucket command sets a default balancing account. Unbalanced transactions 40 | ; will be balanced with an amount to this account. 41 | bucket Personal:Assets:Checking Account 42 | 2015-06-03 Groceries again 43 | Personal:Expenses:Groceries $32.73 44 | 45 | 46 | ; You can verify balances using the 'assert balance' command 47 | ; This will check that the balance of the account using everything 48 | ; seen so far matches what you've got. This can be useful for verifying 49 | ; against account balances from your bank after you've entered a bunch 50 | ; of transactions. 51 | assert balance Personal:Expenses:Groceries $54.73 52 | ; You can also use an 'asof' date for a balance check, helpful if you 53 | ; have a bank statement with a single balance and date, but you just 54 | ; want to append the check to the bottom of your ledger file (e.g. 55 | ; if doing one file per month). This checks the balance at the end of 56 | ; a certain date, using data seen up until now 57 | assert balance 2015-06-02 Personal:Expenses:Groceries $22 58 | 59 | ; You can define alias shortcuts (just single words) 60 | alias groceries Personal:Expenses:Groceries 61 | 2015-06-04 Even more groceries 62 | groceries $23.99 63 | 64 | ; In my state, if you buy something online, you often don't 65 | ; pay sales tax on it. However, technically you still owe 66 | ; the 6% tax. This shows how to track that outstanding 67 | ; liability so you can send payment to the tax department 68 | ; at the end of the year 69 | ; Note that the "bucket" account is still in effect 70 | 2015-02-05 Chair from amazon 71 | Personal:Expenses:Office ($139 * 1.06) 72 | Personal:Liabilities:State Sales Tax ($1.39 * -0.06) 73 | 74 | 75 | ; uledger also supports the "accounting equation", where you can check 76 | ; that everything balances as it should 77 | assert equation 2015-07-01 Personal:Assets - Personal:Liabilities = Personal:Equity + Personal:Income - Personal:Expenses 78 | -------------------------------------------------------------------------------- /web.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | def make_category(f,org,category,balances,ledger,asof,positive): 5 | accountnames = balances.keys() 6 | accountnames.sort() 7 | f.write("") 8 | f.write("" % category) 9 | f.write("") 10 | for account in [i[len(org+":"+category)+1:] for i in accountnames if (org+":"+category in i and org+":"+category != i)]: 11 | if len(balances[org+":"+category+":"+account]) == 0 or \ 12 | sum(balances[org+":"+category+":"+account].values()) == 0: 13 | continue 14 | 15 | f.write("") 23 | 24 | f.write("") 25 | f.write("") 33 | f.write("
%s
%s" % account) 16 | if len(balances[org+":"+category+":"+account]) == 0: 17 | f.write("-") 18 | else: 19 | f.write("
".join( 20 | "%s %.2f" % (commodity, amount * (1 if positive else -1)) for (commodity,amount) in balances[org+":"+category+":"+account].items() if amount != 0 21 | )) 22 | f.write("
Total") 26 | if len(ledger.balance_children(org+":"+category,asof)) == 0: 27 | f.write("-") 28 | else: 29 | f.write("
".join( 30 | "%s %.2f" % (commodity, amount * (1 if positive else -1)) for (commodity,amount) in ledger.balance_children(org+":"+category,asof).items() 31 | )) 32 | f.write("
") 34 | 35 | def make_report(ledger,destdir): 36 | 37 | startdate = ledger.startdate() 38 | enddate = ledger.enddate() 39 | 40 | firstyear = startdate.split("-")[0] 41 | endyear = enddate.split("-")[0] 42 | 43 | # Year by year 44 | 45 | categories = ["Expenses","Assets","Liabilities","Income","Equity"] 46 | 47 | positive = { "Expenses": True, "Assets":True, "Liabilities":False, "Income": False, "Equity":False } 48 | 49 | if not os.path.isdir(os.path.join(destdir,"css")): 50 | shutil.copytree(os.path.join(os.path.dirname(__file__), "css"), os.path.join(destdir,"css")) 51 | 52 | with open(os.path.join(destdir,"report.html"),"w") as f: 53 | f.write("") 54 | f.write("Report") 55 | 56 | f.write(""" 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | """) 79 | f.write(""); 80 | f.write("") 81 | 82 | for year in range(int(endyear),int(firstyear)-1,-1): 83 | asof = "%d-12-31" % year 84 | balances = ledger.balances(asof) 85 | 86 | orgs = set() 87 | for account in balances.keys(): 88 | orgs.add(account.split(":")[0]) 89 | 90 | for org in orgs: 91 | f.write("
") 92 | f.write("

%s EOY %d

" % (org, year)) 93 | f.write("
") 94 | for categories in [["Assets"],["Liabilities","Equity"]]: 95 | f.write("
") 96 | for category in categories: 97 | make_category(f, org, category, balances, ledger, asof, positive[category]) 98 | f.write("
") 99 | f.write("
") 100 | 101 | f.write("
") 102 | for categories in [["Income"],["Expenses"]]: 103 | f.write("
") 104 | for category in categories: 105 | make_category(f, org, category, balances, ledger, asof, positive[category]) 106 | f.write("
") 107 | f.write("
") 108 | 109 | 110 | f.write("
") 111 | f.write("") 112 | f.write("") 113 | 114 | print "Report written to %s" % os.path.join(destdir,"report.html") 115 | -------------------------------------------------------------------------------- /css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } -------------------------------------------------------------------------------- /css/skeleton.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Skeleton V2.0.4 3 | * Copyright 2014, Dave Gamache 4 | * www.getskeleton.com 5 | * Free to use under the MIT license. 6 | * http://www.opensource.org/licenses/mit-license.php 7 | * 12/29/2014 8 | */ 9 | 10 | 11 | /* Table of contents 12 | –––––––––––––––––––––––––––––––––––––––––––––––––– 13 | - Grid 14 | - Base Styles 15 | - Typography 16 | - Links 17 | - Buttons 18 | - Forms 19 | - Lists 20 | - Code 21 | - Tables 22 | - Spacing 23 | - Utilities 24 | - Clearing 25 | - Media Queries 26 | */ 27 | 28 | 29 | /* Grid 30 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 31 | .container { 32 | position: relative; 33 | width: 100%; 34 | max-width: 960px; 35 | margin: 0 auto; 36 | padding: 0 20px; 37 | box-sizing: border-box; } 38 | .column, 39 | .columns { 40 | width: 100%; 41 | float: left; 42 | box-sizing: border-box; } 43 | 44 | /* For devices larger than 400px */ 45 | @media (min-width: 400px) { 46 | .container { 47 | width: 85%; 48 | padding: 0; } 49 | } 50 | 51 | /* For devices larger than 550px */ 52 | @media (min-width: 550px) { 53 | .container { 54 | width: 80%; } 55 | .column, 56 | .columns { 57 | margin-left: 4%; } 58 | .column:first-child, 59 | .columns:first-child { 60 | margin-left: 0; } 61 | 62 | .one.column, 63 | .one.columns { width: 4.66666666667%; } 64 | .two.columns { width: 13.3333333333%; } 65 | .three.columns { width: 22%; } 66 | .four.columns { width: 30.6666666667%; } 67 | .five.columns { width: 39.3333333333%; } 68 | .six.columns { width: 48%; } 69 | .seven.columns { width: 56.6666666667%; } 70 | .eight.columns { width: 65.3333333333%; } 71 | .nine.columns { width: 74.0%; } 72 | .ten.columns { width: 82.6666666667%; } 73 | .eleven.columns { width: 91.3333333333%; } 74 | .twelve.columns { width: 100%; margin-left: 0; } 75 | 76 | .one-third.column { width: 30.6666666667%; } 77 | .two-thirds.column { width: 65.3333333333%; } 78 | 79 | .one-half.column { width: 48%; } 80 | 81 | /* Offsets */ 82 | .offset-by-one.column, 83 | .offset-by-one.columns { margin-left: 8.66666666667%; } 84 | .offset-by-two.column, 85 | .offset-by-two.columns { margin-left: 17.3333333333%; } 86 | .offset-by-three.column, 87 | .offset-by-three.columns { margin-left: 26%; } 88 | .offset-by-four.column, 89 | .offset-by-four.columns { margin-left: 34.6666666667%; } 90 | .offset-by-five.column, 91 | .offset-by-five.columns { margin-left: 43.3333333333%; } 92 | .offset-by-six.column, 93 | .offset-by-six.columns { margin-left: 52%; } 94 | .offset-by-seven.column, 95 | .offset-by-seven.columns { margin-left: 60.6666666667%; } 96 | .offset-by-eight.column, 97 | .offset-by-eight.columns { margin-left: 69.3333333333%; } 98 | .offset-by-nine.column, 99 | .offset-by-nine.columns { margin-left: 78.0%; } 100 | .offset-by-ten.column, 101 | .offset-by-ten.columns { margin-left: 86.6666666667%; } 102 | .offset-by-eleven.column, 103 | .offset-by-eleven.columns { margin-left: 95.3333333333%; } 104 | 105 | .offset-by-one-third.column, 106 | .offset-by-one-third.columns { margin-left: 34.6666666667%; } 107 | .offset-by-two-thirds.column, 108 | .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } 109 | 110 | .offset-by-one-half.column, 111 | .offset-by-one-half.columns { margin-left: 52%; } 112 | 113 | } 114 | 115 | 116 | /* Base Styles 117 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 118 | /* NOTE 119 | html is set to 62.5% so that all the REM measurements throughout Skeleton 120 | are based on 10px sizing. So basically 1.5rem = 15px :) */ 121 | html { 122 | font-size: 62.5%; } 123 | body { 124 | font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ 125 | line-height: 1.6; 126 | font-weight: 400; 127 | font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; 128 | color: #222; } 129 | 130 | 131 | /* Typography 132 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 133 | h1, h2, h3, h4, h5, h6 { 134 | margin-top: 0; 135 | margin-bottom: 2rem; 136 | font-weight: 300; } 137 | h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} 138 | h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } 139 | h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } 140 | h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } 141 | h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } 142 | h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } 143 | 144 | /* Larger than phablet */ 145 | @media (min-width: 550px) { 146 | h1 { font-size: 5.0rem; } 147 | h2 { font-size: 4.2rem; } 148 | h3 { font-size: 3.6rem; } 149 | h4 { font-size: 3.0rem; } 150 | h5 { font-size: 2.4rem; } 151 | h6 { font-size: 1.5rem; } 152 | } 153 | 154 | p { 155 | margin-top: 0; } 156 | 157 | 158 | /* Links 159 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 160 | a { 161 | color: #1EAEDB; } 162 | a:hover { 163 | color: #0FA0CE; } 164 | 165 | 166 | /* Buttons 167 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 168 | .button, 169 | button, 170 | input[type="submit"], 171 | input[type="reset"], 172 | input[type="button"] { 173 | display: inline-block; 174 | height: 38px; 175 | padding: 0 30px; 176 | color: #555; 177 | text-align: center; 178 | font-size: 11px; 179 | font-weight: 600; 180 | line-height: 38px; 181 | letter-spacing: .1rem; 182 | text-transform: uppercase; 183 | text-decoration: none; 184 | white-space: nowrap; 185 | background-color: transparent; 186 | border-radius: 4px; 187 | border: 1px solid #bbb; 188 | cursor: pointer; 189 | box-sizing: border-box; } 190 | .button:hover, 191 | button:hover, 192 | input[type="submit"]:hover, 193 | input[type="reset"]:hover, 194 | input[type="button"]:hover, 195 | .button:focus, 196 | button:focus, 197 | input[type="submit"]:focus, 198 | input[type="reset"]:focus, 199 | input[type="button"]:focus { 200 | color: #333; 201 | border-color: #888; 202 | outline: 0; } 203 | .button.button-primary, 204 | button.button-primary, 205 | input[type="submit"].button-primary, 206 | input[type="reset"].button-primary, 207 | input[type="button"].button-primary { 208 | color: #FFF; 209 | background-color: #33C3F0; 210 | border-color: #33C3F0; } 211 | .button.button-primary:hover, 212 | button.button-primary:hover, 213 | input[type="submit"].button-primary:hover, 214 | input[type="reset"].button-primary:hover, 215 | input[type="button"].button-primary:hover, 216 | .button.button-primary:focus, 217 | button.button-primary:focus, 218 | input[type="submit"].button-primary:focus, 219 | input[type="reset"].button-primary:focus, 220 | input[type="button"].button-primary:focus { 221 | color: #FFF; 222 | background-color: #1EAEDB; 223 | border-color: #1EAEDB; } 224 | 225 | 226 | /* Forms 227 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 228 | input[type="email"], 229 | input[type="number"], 230 | input[type="search"], 231 | input[type="text"], 232 | input[type="tel"], 233 | input[type="url"], 234 | input[type="password"], 235 | textarea, 236 | select { 237 | height: 38px; 238 | padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ 239 | background-color: #fff; 240 | border: 1px solid #D1D1D1; 241 | border-radius: 4px; 242 | box-shadow: none; 243 | box-sizing: border-box; } 244 | /* Removes awkward default styles on some inputs for iOS */ 245 | input[type="email"], 246 | input[type="number"], 247 | input[type="search"], 248 | input[type="text"], 249 | input[type="tel"], 250 | input[type="url"], 251 | input[type="password"], 252 | textarea { 253 | -webkit-appearance: none; 254 | -moz-appearance: none; 255 | appearance: none; } 256 | textarea { 257 | min-height: 65px; 258 | padding-top: 6px; 259 | padding-bottom: 6px; } 260 | input[type="email"]:focus, 261 | input[type="number"]:focus, 262 | input[type="search"]:focus, 263 | input[type="text"]:focus, 264 | input[type="tel"]:focus, 265 | input[type="url"]:focus, 266 | input[type="password"]:focus, 267 | textarea:focus, 268 | select:focus { 269 | border: 1px solid #33C3F0; 270 | outline: 0; } 271 | label, 272 | legend { 273 | display: block; 274 | margin-bottom: .5rem; 275 | font-weight: 600; } 276 | fieldset { 277 | padding: 0; 278 | border-width: 0; } 279 | input[type="checkbox"], 280 | input[type="radio"] { 281 | display: inline; } 282 | label > .label-body { 283 | display: inline-block; 284 | margin-left: .5rem; 285 | font-weight: normal; } 286 | 287 | 288 | /* Lists 289 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 290 | ul { 291 | list-style: circle inside; } 292 | ol { 293 | list-style: decimal inside; } 294 | ol, ul { 295 | padding-left: 0; 296 | margin-top: 0; } 297 | ul ul, 298 | ul ol, 299 | ol ol, 300 | ol ul { 301 | margin: 1.5rem 0 1.5rem 3rem; 302 | font-size: 90%; } 303 | li { 304 | margin-bottom: 1rem; } 305 | 306 | 307 | /* Code 308 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 309 | code { 310 | padding: .2rem .5rem; 311 | margin: 0 .2rem; 312 | font-size: 90%; 313 | white-space: nowrap; 314 | background: #F1F1F1; 315 | border: 1px solid #E1E1E1; 316 | border-radius: 4px; } 317 | pre > code { 318 | display: block; 319 | padding: 1rem 1.5rem; 320 | white-space: pre; } 321 | 322 | 323 | /* Tables 324 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 325 | th, 326 | td { 327 | padding: 12px 15px; 328 | text-align: left; 329 | border-bottom: 1px solid #E1E1E1; } 330 | th:first-child, 331 | td:first-child { 332 | padding-left: 0; } 333 | th:last-child, 334 | td:last-child { 335 | padding-right: 0; } 336 | 337 | 338 | /* Spacing 339 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 340 | button, 341 | .button { 342 | margin-bottom: 1rem; } 343 | input, 344 | textarea, 345 | select, 346 | fieldset { 347 | margin-bottom: 1.5rem; } 348 | pre, 349 | blockquote, 350 | dl, 351 | figure, 352 | table, 353 | p, 354 | ul, 355 | ol, 356 | form { 357 | margin-bottom: 2.5rem; } 358 | 359 | 360 | /* Utilities 361 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 362 | .u-full-width { 363 | width: 100%; 364 | box-sizing: border-box; } 365 | .u-max-full-width { 366 | max-width: 100%; 367 | box-sizing: border-box; } 368 | .u-pull-right { 369 | float: right; } 370 | .u-pull-left { 371 | float: left; } 372 | 373 | 374 | /* Misc 375 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 376 | hr { 377 | margin-top: 3rem; 378 | margin-bottom: 3.5rem; 379 | border-width: 0; 380 | border-top: 1px solid #E1E1E1; } 381 | 382 | 383 | /* Clearing 384 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 385 | 386 | /* Self Clearing Goodness */ 387 | .container:after, 388 | .row:after, 389 | .u-cf { 390 | content: ""; 391 | display: table; 392 | clear: both; } 393 | 394 | 395 | /* Media Queries 396 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 397 | /* 398 | Note: The best way to structure the use of media queries is to create the queries 399 | near the relevant code. For example, if you wanted to change the styles for buttons 400 | on small devices, paste the mobile query code up in the buttons section and style it 401 | there. 402 | */ 403 | 404 | 405 | /* Larger than mobile */ 406 | @media (min-width: 400px) {} 407 | 408 | /* Larger than phablet (also point when grid becomes active) */ 409 | @media (min-width: 550px) {} 410 | 411 | /* Larger than tablet */ 412 | @media (min-width: 750px) {} 413 | 414 | /* Larger than desktop */ 415 | @media (min-width: 1000px) {} 416 | 417 | /* Larger than Desktop HD */ 418 | @media (min-width: 1200px) {} 419 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import unittest 4 | import uledger 5 | import textwrap 6 | import decimal 7 | 8 | class LedgerTest(unittest.TestCase): 9 | def setUp(self): 10 | self.ledger = uledger.Ledger() 11 | 12 | 13 | class Parsing(LedgerTest): 14 | 15 | def test_baddata1(self): 16 | data = textwrap.dedent(""" 17 | Line1 18 | Line2 19 | Line3 20 | """ 21 | ) 22 | 23 | with self.assertRaises(uledger.ParseError): 24 | self.ledger.parse(data.splitlines(),"TESTDATA") 25 | 26 | def test_noposts(self): 27 | data = """2015-06-01 Dummy Transaction""" 28 | with self.assertRaises(uledger.ParseError): 29 | self.ledger.parse(data.splitlines(),"TESTDATA") 30 | 31 | def test_nocommodity(self): 32 | data = """2015-01-01 Dummy 33 | Source 50 34 | Dest""" 35 | with self.assertRaises(uledger.ParseError): 36 | self.ledger.parse(data.splitlines(), "TESTDATA") 37 | 38 | def test_multiblank(self): 39 | data = """2015-01-01 Dummy 40 | Source 41 | Dest""" 42 | with self.assertRaises(uledger.ParseError): 43 | self.ledger.parse(data.splitlines(), "TESTDATA") 44 | 45 | def test_bucketblank(self): 46 | data = textwrap.dedent(""" 47 | bucket Dest 48 | 2015-01-01 Dummy 49 | Source $50 50 | Dest""") 51 | self.ledger.parse(data.splitlines(), "TESTDATA") 52 | 53 | def test_bucketblank(self): 54 | data = textwrap.dedent(""" 55 | bucket Dest 56 | 2015-01-01 Dummy 57 | Source $50 58 | Dest2""") 59 | self.ledger.parse(data.splitlines(), "TESTDATA") 60 | balance = self.ledger.balance("Dest2") 61 | self.assertEquals(balance, {"$": -50 }) 62 | with self.assertRaises(uledger.AccountNotFoundError): 63 | balance = self.ledger.balance("Dest") 64 | 65 | def test_bucketmulti(self): 66 | data = textwrap.dedent(""" 67 | bucket Dest 68 | 2015-01-01 Dummy 69 | Source $50 70 | Source 50 CAD 71 | Dest2 72 | """) 73 | self.ledger.parse(data.splitlines(), "TESTDATA") 74 | balance = self.ledger.balance("Dest2") 75 | self.assertEquals(balance, {"$": -50,"CAD":-50 }) 76 | 77 | def test_bucket(self): 78 | data = textwrap.dedent(""" 79 | bucket Dest 80 | 2015-01-01 Dummy 81 | Source $50 82 | Dest2 83 | 84 | 2015-01-02 Dummy2 85 | Source $50""") 86 | self.ledger.parse(data.splitlines(), "TESTDATA") 87 | balance = self.ledger.balance("Dest2") 88 | self.assertEquals(balance, {"$": -50 }) 89 | balance = self.ledger.balance("Dest") 90 | self.assertEquals(balance, {"$": -50 }) 91 | 92 | def test_spacing(self): 93 | data = """2015-06-01\tDuummy transaction 94 | \tSrc\t\t$1234 95 | \tDest""" 96 | self.ledger.parse(data.splitlines(),"TESTDATA") 97 | 98 | # For example, credit cards. The event was on the 1st, the charge landed on the 3rd 99 | def test_postdates(self): 100 | data = """2015-06-01=2015-06-03 Some kind of payment 101 | Source $50 102 | Dest""" 103 | 104 | self.ledger.parse(data.splitlines(),"TESTDATA") 105 | 106 | balance = self.ledger.balance("Dest") 107 | self.assertEquals(balance, {"$": -50 }) 108 | 109 | balance = self.ledger.balance("Dest","2015-06-02") 110 | self.assertEquals(balance, {}) 111 | 112 | class Math(LedgerTest): 113 | 114 | def test_basic(self): 115 | data = textwrap.dedent(""" 116 | 2015-01-01 Test 117 | SourceAccount $50 118 | DestAccount""") 119 | 120 | self.ledger.parse(data.splitlines(),"TESTDATA") 121 | balance = self.ledger.balance("SourceAccount") 122 | self.assertEquals(balance, {"$": 50 }) 123 | balance = self.ledger.balance("DestAccount") 124 | self.assertEquals(balance, {"$": -50 }) 125 | 126 | with self.assertRaises(uledger.AccountNotFoundError): 127 | balance = self.ledger.balance("SourceAccount2") 128 | 129 | def test_precision1(self): 130 | data = """2015-01-01 Dummy 131 | Source ($3.20 * 1.06) 132 | Dest""" 133 | 134 | self.ledger.parse(data.splitlines(),"TESTDATA") 135 | balance = self.ledger.balance("Source") 136 | self.assertEquals(balance, {"$": decimal.Decimal("3.39")}) 137 | 138 | def test_precision2(self): 139 | data = textwrap.dedent(""" 140 | 2015-01-01 Test 141 | Source1 ($3.20 * 1.0609) 142 | Dest 143 | 144 | 2015-01-02 Test2 145 | Source2 ($3.20 * 1.0610) 146 | Dest""") 147 | 148 | # calculated value should be 3.3952, which should round up because the 3rd digit is >=5 149 | self.ledger.parse(data.splitlines(),"TESTDATA") 150 | balance = self.ledger.balance("Source1") 151 | self.assertEquals(balance, {"$": decimal.Decimal("3.39")}) 152 | balance = self.ledger.balance("Source2") 153 | self.assertEquals(balance, {"$": decimal.Decimal("3.40")}) 154 | 155 | balance = self.ledger.balance("Dest") 156 | self.assertEquals(balance, {"$": decimal.Decimal("-6.79")}) 157 | 158 | def test_assert1(self): 159 | data = textwrap.dedent(""" 160 | 2015-01-01 Test 161 | SourceAccount $50 162 | DestAccount 163 | 164 | assert balance SourceAccount $50 165 | assert balance DestAccount $-50""") 166 | 167 | self.ledger.parse(data.splitlines(),"TESTDATA") 168 | 169 | def test_assert2(self): 170 | data = textwrap.dedent(""" 171 | 2015-01-01 Test 172 | SourceAccount $50 173 | DestAccount 174 | 175 | assert balance SourceAccount $33 176 | assert balance DestAccount $50""") 177 | 178 | with self.assertRaises(uledger.AssertionError): 179 | self.ledger.parse(data.splitlines(),"TESTDATA") 180 | 181 | def test_assert3(self): 182 | data = textwrap.dedent(""" 183 | 2015-01-01 Test 184 | SourceAccount $50 185 | DestAccount 186 | 187 | assert balance 2014-12-31 SourceAccount $0 188 | assert balance 2015-01-02 SourceAccount $50""") 189 | 190 | self.ledger.parse(data.splitlines(),"TESTDATA") 191 | 192 | def test_assert4(self): 193 | data = textwrap.dedent(""" 194 | 2015-01-01 Test 195 | SourceAccount $50 196 | DestAccount 197 | 198 | assert balance 2014-12-31 SourceAccount $0 199 | assert balance 2015-01-02 SourceAccount $60""") 200 | 201 | with self.assertRaises(uledger.AssertionError): 202 | self.ledger.parse(data.splitlines(),"TESTDATA") 203 | 204 | def test_assert5(self): 205 | data = textwrap.dedent(""" 206 | 2015-01-01 Test 207 | SourceAccount $50 208 | DestAccount 209 | 210 | assert balance 2014-12-31 SourceAccount $20""") 211 | 212 | with self.assertRaises(uledger.AssertionError): 213 | self.ledger.parse(data.splitlines(),"TESTDATA") 214 | 215 | def test_assert6(self): 216 | data = textwrap.dedent(""" 217 | 2015-01-01 Test 218 | SourceAccount $50 219 | DestAccount 220 | 221 | assert balance 2015-01-01 SourceAccount $50""") 222 | 223 | self.ledger.parse(data.splitlines(),"TESTDATA") 224 | 225 | 226 | def test_assert7(self): 227 | # Testing out of order transactions 228 | data = textwrap.dedent(""" 229 | 2015-01-07 Test 230 | SourceAccount $24 231 | DestAccount 232 | 233 | 2015-01-01 Test 234 | SourceAccount $50 235 | DestAccount 236 | 237 | assert balance 2015-01-02 SourceAccount $50 238 | assert balance 2015-01-07 SourceAccount $74 239 | 240 | 2015-01-03 Test 241 | SourceAccount $50 242 | DestAccount 243 | 244 | assert balance 2015-01-07 SourceAccount $124 245 | 246 | 2015-01-01 Test 247 | SourceAccount $50 248 | DestAccount 249 | 250 | assert balance 2015-01-07 SourceAccount $174 251 | assert balance 2015-01-03 SourceAccount $150 252 | """) 253 | 254 | self.ledger.parse(data.splitlines(),"TESTDATA") 255 | 256 | balance = self.ledger.balance("SourceAccount","2015-01-06") 257 | self.assertEquals(balance, {"$": 150 }) 258 | 259 | 260 | 261 | def test_nobalance(self): 262 | data = textwrap.dedent(""" 263 | 2015-01-01 Test 264 | SourceAccount $50 265 | DestAccount $40""") 266 | 267 | with self.assertRaises(uledger.ParseError): 268 | self.ledger.parse(data.splitlines(),"TESTDATA") 269 | 270 | 271 | def test_multipleposts(self): 272 | data = textwrap.dedent(""" 273 | 2015-01-01 Test 274 | SourceAccount $50 275 | SourceAccount2 $50 276 | SourceAccount3 $-50 277 | DestAccount""") 278 | 279 | self.ledger.parse(data.splitlines(),"TESTDATA") 280 | balance = self.ledger.balance("SourceAccount") 281 | self.assertEquals(balance, {"$": 50 }) 282 | balance = self.ledger.balance("DestAccount") 283 | self.assertEquals(balance, {"$": -50 }) 284 | balance = self.ledger.balance("SourceAccount2") 285 | self.assertEquals(balance, {"$": 50 }) 286 | balance = self.ledger.balance("SourceAccount3") 287 | self.assertEquals(balance, {"$": -50 }) 288 | 289 | def test_multipletransactions(self): 290 | data = textwrap.dedent(""" 291 | 2015-01-01 Test 292 | SourceAccount $50 293 | SourceAccount2 $50 294 | SourceAccount3 $-50 295 | DestAccount 296 | 297 | 2015-01-02 Test2 298 | SourceAccount $25 299 | DestAccount""") 300 | 301 | self.ledger.parse(data.splitlines(),"TESTDATA") 302 | balance = self.ledger.balance("SourceAccount") 303 | self.assertEquals(balance, {"$": 75 }) 304 | balance = self.ledger.balance("DestAccount") 305 | self.assertEquals(balance, {"$": -75 }) 306 | balance = self.ledger.balance("SourceAccount2") 307 | self.assertEquals(balance, {"$": 50 }) 308 | balance = self.ledger.balance("SourceAccount3") 309 | self.assertEquals(balance, {"$": -50 }) 310 | 311 | def test_multiplecommodities(self): 312 | data = textwrap.dedent(""" 313 | 2015-01-01 Test 314 | SourceAccount $50 315 | SourceAccount 50 CAD 316 | DestAccount""") 317 | 318 | self.ledger.parse(data.splitlines(),"TESTDATA") 319 | balance = self.ledger.balance("SourceAccount") 320 | self.assertEquals(balance, {"$": 50, "CAD": 50 }) 321 | balance = self.ledger.balance("DestAccount") 322 | self.assertEquals(balance, {"$": -50, "CAD": -50 }) 323 | 324 | def test_startend(self): 325 | data = textwrap.dedent(""" 326 | 2015-01-01 Test 327 | SourceAccount $50 328 | SourceAccount2 $50 329 | SourceAccount3 $-50 330 | DestAccount 331 | 332 | 2015-01-02 Test2 333 | SourceAccount $25 334 | DestAccount""") 335 | 336 | self.ledger.parse(data.splitlines(),"TESTDATA") 337 | 338 | self.assertEquals("2015-01-01", self.ledger.startdate()) 339 | self.assertEquals("2015-01-02", self.ledger.enddate()) 340 | 341 | def test_balance_children(self): 342 | data = textwrap.dedent(""" 343 | 2015-01-01 Test 344 | Source:Account1 $50 345 | Source:Account2 50 CAD 346 | Source:Account3 $50 347 | DestAccount 348 | """) 349 | 350 | self.ledger.parse(data.splitlines(),"TESTDATA") 351 | 352 | self.assertEquals(self.ledger.balance("DestAccount"), {"$":-100,"CAD":-50}) 353 | self.assertEquals(self.ledger.balance_children("Source"), {"$":100,"CAD":50}) 354 | 355 | def test_closeall(self): 356 | data = textwrap.dedent(""" 357 | 2015-01-01 Test 358 | Source:Account1 $50 359 | Source $50 360 | Source:Account2 50 CAD 361 | Source:Account3 $50 362 | DestAccount 363 | 364 | closeall 2015-01-01 Source DestAccount2""") 365 | 366 | self.ledger.parse(data.splitlines(),"TESTDATA") 367 | 368 | self.assertEquals(self.ledger.balance("DestAccount"), {"$":-150,"CAD":-50}) 369 | self.assertEquals(self.ledger.balance_children("Source"), {"$":0,"CAD":0}) 370 | self.assertEquals(self.ledger.balance("DestAccount2"), {"$":150,"CAD":50}) 371 | 372 | def test_multitotal(self): 373 | data = textwrap.dedent(""" 374 | bucket Savings 375 | 2015-07-15 July pay 376 | Personal:Income:Example INC $-4,791.67 377 | Personal:Expenses:US Federal Income Tax $716.15 378 | Personal:Expenses:US Social Security $297.09 379 | Personal:Expenses:US Medicare $69.48 380 | Personal:Expenses:US District of Columbia State Income Tax $338.51 """) 381 | 382 | self.ledger.parse(data.splitlines(),"TESTDATA") 383 | self.assertEquals(self.ledger.balance("Savings"), {"$": decimal.Decimal("3370.44")}) 384 | 385 | def test_account_equation1(self): 386 | data = textwrap.dedent(""" 387 | 2015-01-01 Opening Balance 388 | Equity:Owners Contributions $-100 389 | Assets:Bank $100 390 | 391 | 2015-01-01 Buying widgets 392 | Liabilities:Credit Card $-50 393 | Expenses:Parts 394 | 395 | 2015-01-01 Some Income 396 | Income:Consulting $-200 397 | Assets:Bank $200 398 | 399 | assert equation 2015-01-02 Assets - Liabilities = Equity + Income - Expenses""") 400 | 401 | self.ledger.parse(data.splitlines(),"TESTDATA") 402 | 403 | def test_account_equation2(self): 404 | data = textwrap.dedent(""" 405 | 2015-01-01 Opening Balance 406 | Equity:Owners Contributions $-100 407 | Assets:Bank $100 408 | 409 | 2015-01-01 Buying widgets 410 | Liabilities:Credit Card $-50 411 | Expenses:Parts 412 | 413 | 2015-01-01 Buying widgets 2 414 | Liabilities:Credit Card $-50 415 | Something 416 | 417 | 2015-01-01 Some Income 418 | Income:Consulting $-200 419 | Assets:Bank $200 420 | 421 | assert equation 2015-01-02 Assets - Liabilities = Equity + Income - Expenses""") 422 | 423 | with self.assertRaises(uledger.AssertionError): 424 | self.ledger.parse(data.splitlines(),"TESTDATA") 425 | 426 | 427 | 428 | if __name__ == '__main__': 429 | unittest.main() 430 | -------------------------------------------------------------------------------- /uledger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import re 5 | import decimal 6 | import sys 7 | from collections import namedtuple 8 | import datetime 9 | 10 | Amount = namedtuple("Amount", ["commodity","value"]) 11 | Post = namedtuple('Post', ['account', "amount","filename","linenum"]) 12 | Transaction = namedtuple("Transaction",["date","description","linenum","filename"]) 13 | Entry = namedtuple('Entry',['description','amount']) 14 | 15 | class ParseError(Exception): 16 | def __init__(self, filename, linenum, msg): 17 | self.msg = msg 18 | self.filename = filename 19 | self.linenum = linenum 20 | def __str__(self): 21 | return "ERROR: %s:%s: %s" % (self.filename, self.linenum, self.msg) 22 | 23 | class AssertionError(Exception): 24 | def __init__(self, filename, linenum, msg): 25 | self.msg = msg 26 | self.filename = filename 27 | self.linenum = linenum 28 | def __str__(self): 29 | return "ASSERT FAILED: %s:%s: %s" % (self.filename, self.linenum, self.msg) 30 | 31 | 32 | class AccountNotFoundError(Exception): 33 | def __init__(self, account): 34 | self.account = account 35 | def __str__(self): 36 | return "ERROR: Account '%s' not found" % (self.account) 37 | 38 | 39 | class Ledger(object): 40 | 41 | # This is a dict of dates 42 | # each member is a transaction, sorted by parse order 43 | transactions = {} 44 | accounts = {} 45 | aliases = {} 46 | commodities = set() 47 | 48 | def __init__(self, assertions=True): 49 | self.transactions = {} 50 | self.accounts = {} 51 | self.aliases = {} 52 | self.commodities = set() 53 | self.assertions = assertions 54 | 55 | def parseamount(self, amountstr, filename, linenum): 56 | m = re.match("\((.*?)\)",amountstr) 57 | if m: 58 | # $1234.12 + $123432.23 59 | m = re.match("\(\s*(?P.*?)\s+\+\s+(?P.*?)\s*\)",amountstr) 60 | if m: 61 | a = self.parseamount(m.group("left"),filename,linenum) 62 | b = self.parseamount(m.group("right"),filename,linenum) 63 | return Amount(a.commodity,a.value+b.value) 64 | 65 | m = re.match("\(\s*(?P.*?)\s+\*\s+(?P-?\d+(\.\d+)?)\s*\)",amountstr) 66 | if m: 67 | a = self.parseamount(m.group("left"),filename,linenum) 68 | b = decimal.Decimal(m.group("right")) 69 | return Amount(a.commodity,(a.value*b).quantize(decimal.Decimal('.01'), rounding=decimal.ROUND_HALF_UP)) 70 | 71 | # $-1234.34 72 | m = re.match("(?P\$)\s*(?P-?[\d,]+(\.\d+)?)",amountstr) 73 | if m: 74 | return Amount(m.group("commodity"),decimal.Decimal(m.group("value").replace(",",""))) 75 | 76 | # -123.43 CAD 77 | m = re.match("(?P-?[\d,]+(\.\d+)?) (?P\w+)",amountstr) 78 | if m: 79 | return Amount(m.group("commodity"),decimal.Decimal(m.group("value").replace(",",""))) 80 | 81 | raise ParseError(filename, linenum, "Don't know how to interpret '%s' as a value, did you include a commodity type ($, USD, etc)?" % amountstr) 82 | return None 83 | 84 | def makepost(self, account,date,description,commodity,value): 85 | self.commodities.add(commodity) 86 | if account not in self.accounts: 87 | self.accounts[account] = {} 88 | if date not in self.accounts[account]: 89 | self.accounts[account][date] = [] 90 | 91 | self.accounts[account][date].append(Entry(description,Amount(commodity,value))) 92 | 93 | 94 | # We lexically sort the date keys, and start from 95 | # the beginning to get the current balance 96 | def balance(self, account, asof=None): 97 | 98 | if account not in self.accounts: 99 | raise AccountNotFoundError(account) 100 | 101 | balances = {} 102 | datekeys = self.accounts[account].keys() 103 | datekeys.sort() 104 | for date in datekeys: 105 | # We assumd 2015-02-32 which will compare lexically 106 | if asof is None or date <= asof: 107 | for entry in self.accounts[account][date]: 108 | if entry.amount.commodity not in balances: 109 | balances[entry.amount.commodity] = decimal.Decimal(0) 110 | balances[entry.amount.commodity] += entry.amount.value 111 | else: 112 | break 113 | return balances 114 | 115 | def balances(self, asof=None): 116 | result = {} 117 | for account in self.accounts: 118 | result[account] = self.balance(account, asof) 119 | return result 120 | 121 | # Fetches the balance of all sub-accounts that have this name as 122 | # a prefix 123 | def balance_children(self, prefix, asof=None): 124 | b = self.balances(asof) 125 | result = {} 126 | for account in [i for i in self.accounts if i.startswith(prefix)]: 127 | for commodity in b[account]: 128 | if commodity not in result: 129 | result[commodity] = decimal.Decimal(0) 130 | result[commodity] += b[account][commodity] 131 | return result 132 | 133 | def commodities(self): 134 | return self.commodities 135 | 136 | def startdate(self): 137 | start = None 138 | for account in self.accounts: 139 | datekeys = self.accounts[account].keys() 140 | datekeys.sort() 141 | if start is None or start > datekeys[0]: 142 | start = datekeys[0] 143 | return start 144 | 145 | def enddate(self): 146 | start = None 147 | for account in self.accounts: 148 | datekeys = self.accounts[account].keys() 149 | datekeys.sort() 150 | if start < datekeys[-1]: 151 | start = datekeys[-1] 152 | return start 153 | 154 | 155 | def maketransaction(self, transaction, posts, bucket = None): 156 | balanceaccount = bucket 157 | values = {} 158 | if len(posts) == 0 or len(posts) == 1 and posts[0].amount.commodity is None: 159 | raise ParseError(transaction.filename, transaction.linenum, "No transactions") 160 | 161 | for post in posts: 162 | account = post.account 163 | if account in self.aliases: 164 | account = self.aliases[post.account] 165 | if post.amount is None or post.amount.value is None: 166 | if balanceaccount is None or balanceaccount == bucket: 167 | balanceaccount = account 168 | else: 169 | raise ParseError(post.filename, post.linenum, "Cannot have multiple empty posts") 170 | else: 171 | if post.amount.commodity not in values: 172 | values[post.amount.commodity] = 0 173 | 174 | values[post.amount.commodity] += post.amount.value 175 | 176 | self.makepost(account, transaction.date, transaction.description, post.amount.commodity, post.amount.value) 177 | 178 | for commodity in values: 179 | if values[commodity] != decimal.Decimal("0"): 180 | if balanceaccount is not None: 181 | self.makepost(balanceaccount, transaction.date, transaction.description, commodity, -values[commodity]) 182 | else: 183 | raise ParseError(post.filename, post.linenum, "Transaction does not balance: %f %s outstanding" % (values[commodity], commodity)) 184 | 185 | # Parses a file, can be called recursively 186 | def parse(self, reader,filename=None): 187 | 188 | bucket = None 189 | transaction = None 190 | accountdef = None 191 | posts = [] 192 | for linenum, line in enumerate(reader): 193 | linenum += 1 194 | 195 | line = line.rstrip() 196 | m = re.match(" *;", line) 197 | if line == '' or m: 198 | continue 199 | 200 | 201 | if transaction is not None: 202 | m = re.match("^\s+(?P.*?)(\s\s+(?P.*))?$", line) 203 | if m: 204 | amount = None 205 | if m.group("amount") is not None: 206 | amount = self.parseamount(m.group("amount"),filename,linenum) 207 | post = Post(m.group("account"),amount,filename,linenum) 208 | posts.append(post) 209 | continue 210 | else: 211 | try: 212 | self.maketransaction(transaction, posts, bucket) 213 | except Exception as e: 214 | e.args = (ParseError(filename, linenum, "Parse error: %s" % e),) 215 | raise 216 | 217 | posts = [] 218 | transaction = None 219 | 220 | if accountdef is not None: 221 | # Ignore things under accountdef for now 222 | m = re.match("^\s+(.*)$",line) 223 | if m: 224 | continue 225 | else: 226 | accountdef = None 227 | 228 | m = re.match("(?P\d{4}-\d{2}-\d{2})(=(?P\d{4}-\d{2}-\d{2}))?\s+(?P.*)", line) 229 | if m: 230 | if m.group("postdate") is not None: 231 | transaction = Transaction(m.group("postdate"),m.group("description"),filename,linenum) 232 | else: 233 | transaction = Transaction(m.group("date"),m.group("description"),filename,linenum) 234 | continue 235 | 236 | m = re.match("commodity\s+(?P.*)", line) 237 | if m: 238 | continue 239 | 240 | m = re.match("account\s+(?P.*)", line) 241 | if m: 242 | accountdef = m.groups() 243 | continue 244 | 245 | m = re.match("include\s+(?P.*)",line) 246 | if m: 247 | includefile = m.group("filename") 248 | with open(includefile) as f: 249 | self.parse(f,includefile) 250 | continue 251 | 252 | m = re.match("bucket\s+(?P.*)",line) 253 | if m: 254 | bucket = m.group("account") 255 | continue 256 | 257 | m = re.match("print\s+(?P.*)",line) 258 | if m: 259 | print m.group("str") 260 | continue 261 | 262 | m = re.match("alias\s+(?P.*?)\s+(?P.*)",line) 263 | if m: 264 | self.aliases[m.group("alias")] = m.group("account") 265 | continue 266 | 267 | m = re.match("closeall\s+(?P\d{4}-\d{2}-\d{2})\s+(?P.+?)\s\s+(?P.*)",line) 268 | if m: 269 | transaction = Transaction(m.group("asof"),"Automatic closing transaction",filename,linenum) 270 | posts = [] 271 | closing = {} 272 | for account in self.accounts: 273 | if account.startswith(m.group("prefix")): 274 | balance = self.balance(account,m.group("asof")) 275 | for commodity,value in balance.items(): 276 | if commodity not in closing: 277 | closing[commodity] = decimal.Decimal(0) 278 | closing[commodity] += value 279 | posts.append(Post(account,Amount(commodity,-1*value),filename,linenum)) 280 | 281 | self.maketransaction(transaction, posts, m.group("closingaccount")) 282 | transaction = None 283 | posts = None 284 | continue 285 | 286 | 287 | m = re.match("assert\s+balance\s+(?P\d{4}-\d{2}-\d{2})?\s*(?P.*?)\s\s+(?P.*)$",line) 288 | if m: 289 | if not self.assertions: 290 | continue 291 | balance = self.balance_children(m.group("account"),m.group("asof")) 292 | amount = self.parseamount(m.group("amount"),filename,linenum) 293 | 294 | if not (amount.value == 0 and amount.commodity not in balance) and \ 295 | (amount.commodity not in balance or balance[amount.commodity] != amount.value): 296 | raise AssertionError(filename, linenum, "Account %s actual balance of %s on %s does not match assertion value %s" % (m.group("account"),m.group("asof"), repr(balance), repr(amount))) 297 | 298 | continue 299 | 300 | m = re.match("assert\s+equation\s+(?P\d{4}-\d{2}-\d{2})?\s*(?P.*?)\s+-\s+(?P.*?)\s+=\s+(?P.*?)\s+\+\s+(?P.*?)\s+-\s+(?P.*?)$", line) 301 | if m: 302 | if not self.assertions: 303 | continue 304 | data = {} 305 | for acct in ["assets","liabilities","equity","income","expense"]: 306 | balance = self.balance_children(m.group("%saccount" % acct),m.group("asof")) 307 | data[acct] = balance 308 | 309 | 310 | # Assets + liabilities 311 | left = {} 312 | right = {} 313 | for commodity in self.commodities: 314 | left[commodity] = decimal.Decimal(0) 315 | right[commodity] = decimal.Decimal(0) 316 | 317 | # Left 318 | if commodity in data["assets"]: 319 | left[commodity] += data["assets"][commodity] 320 | if commodity in data["liabilities"]: 321 | left[commodity] += data["liabilities"][commodity] 322 | 323 | # Right 324 | if commodity in data["equity"]: 325 | right[commodity] -= data["equity"][commodity] 326 | if commodity in data["income"]: 327 | right[commodity] -= data["income"][commodity] 328 | if commodity in data["expense"]: 329 | right[commodity] -= data["expense"][commodity] 330 | 331 | 332 | 333 | if left != right: 334 | print data 335 | raise AssertionError(filename, linenum, "Accounting equation not satisified: %s != %s" % (repr(left), repr(right))) 336 | 337 | continue 338 | 339 | 340 | raise ParseError(filename, linenum, "Don't know how to parse \"%s\"" % line) 341 | 342 | if transaction is not None: 343 | self.maketransaction(transaction,posts,bucket) 344 | 345 | 346 | if __name__ == "__main__": 347 | 348 | parser = argparse.ArgumentParser(description=' some integers.') 349 | parser.add_argument('-f','--filename', required=True, help='filename to load') 350 | parser.add_argument("command", default='balance', choices=['balance','register', 'web']) 351 | parser.add_argument('-a','--account', help='Apply to which account') 352 | parser.add_argument('-s','--start', help='Start at which date') 353 | parser.add_argument('-e','--end', help='End at which date') 354 | 355 | args = parser.parse_args() 356 | 357 | if args.command == "register": 358 | ledger = Ledger(assertions=False) 359 | else: 360 | ledger = Ledger() 361 | 362 | try: 363 | with open(args.filename) as f: 364 | ledger.parse(f,args.filename) 365 | except AssertionError,e: 366 | print e 367 | sys.exit(1) 368 | except ParseError,e: 369 | print e 370 | sys.exit(1) 371 | 372 | if args.command == "balance": 373 | accountkeys = ledger.accounts.keys() 374 | accountkeys.sort() 375 | 376 | enddate = args.end 377 | # TODO: validate date formats 378 | 379 | maxlen = 0 380 | for account in accountkeys: 381 | maxlen = max(maxlen,len(account)) 382 | 383 | for commodity in ledger.commodities: 384 | print commodity.rjust(10," "), 385 | 386 | if enddate: 387 | print "Balances asof %s" % enddate 388 | print "Account".ljust(maxlen+1," ") 389 | print "-" * (maxlen+1 + len(ledger.commodities)*11) 390 | balances = ledger.balances(enddate) 391 | for account in accountkeys: 392 | b = balances[account] 393 | for i, commodity in enumerate(ledger.commodities): 394 | if commodity in b: 395 | print str(b[commodity]).rjust(10," "), 396 | else: 397 | print "-".rjust(10," "), 398 | print account 399 | 400 | elif args.command == "web": 401 | import web 402 | web.make_report(ledger, ".") 403 | 404 | elif args.command == "register": 405 | accountkeys = ledger.accounts.keys() 406 | accountkeys.sort() 407 | 408 | firstdate = None 409 | startdate = args.start 410 | enddate = args.end 411 | balances = {} 412 | # If no start date, find the earliest/last as bounds 413 | for account in ledger.accounts: 414 | balances[account] = {} 415 | datekeys = ledger.accounts[account].keys() 416 | datekeys.sort() 417 | if firstdate is None or datekeys[0] < firstdate: 418 | firstdate = datekeys[0] 419 | if args.end is None and (enddate is None or datekeys[-1] > enddate): 420 | enddate = datekeys[-1] 421 | 422 | if startdate is None: 423 | startdate = firstdate 424 | 425 | firstdate = datetime.datetime.strptime( firstdate, "%Y-%m-%d" ) 426 | startdate = datetime.datetime.strptime( startdate, "%Y-%m-%d" ) 427 | enddate = datetime.datetime.strptime( enddate, "%Y-%m-%d" ) 428 | 429 | 430 | while firstdate <= enddate: 431 | today = firstdate.strftime("%Y-%m-%d") 432 | for account in ledger.accounts: 433 | if args.account is None or account.startswith(args.account): 434 | if today in ledger.accounts[account]: 435 | for transaction in ledger.accounts[account][today]: 436 | if transaction.amount.commodity not in balances[account]: 437 | balances[account][transaction.amount.commodity] = 0 438 | balances[account][transaction.amount.commodity] += transaction.amount.value 439 | 440 | if firstdate >= startdate: 441 | print today, str(balances[account][transaction.amount.commodity]).rjust(8," "), transaction.amount.commodity, str(transaction.amount.value).rjust(8," "), transaction.description 442 | if args.account is None or account.startswith(args.account): 443 | print "\t",account 444 | 445 | firstdate += datetime.timedelta(days=1) 446 | --------------------------------------------------------------------------------