├── 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("%s " % 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("%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(" ")
23 |
24 | f.write(" ")
25 | 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(" ")
33 | 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 |
--------------------------------------------------------------------------------