├── .gitignore ├── backend ├── .gitignore ├── stack.yaml ├── package.yaml ├── tpl │ ├── cashflow.tpl │ ├── history.tpl │ ├── index.tpl │ ├── balancesheet.tpl │ └── wrapper.tpl ├── Template.hs ├── Report.hs ├── Config.hs └── Main.hs ├── screenshot.png ├── .stylish-haskell.yaml ├── frontend ├── style.css ├── history.js ├── cashflow.js ├── balancesheet.js ├── lib.js └── finance.js ├── README.md └── config.example.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | config.yaml 2 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .stack-work 2 | *.cabal 3 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barrucadu/finances/HEAD/screenshot.png -------------------------------------------------------------------------------- /backend/stack.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-9.0 2 | 3 | packages: 4 | - '.' 5 | 6 | nix: 7 | pure: false 8 | packages: [zlib] 9 | -------------------------------------------------------------------------------- /backend/package.yaml: -------------------------------------------------------------------------------- 1 | executables: 2 | backend: 3 | main: Main.hs 4 | other-modules: 5 | - Config 6 | - Report 7 | - Template 8 | dependencies: 9 | - base 10 | - aeson 11 | - containers 12 | - data-default-class 13 | - directory 14 | - filepath 15 | - ginger 16 | - hledger-lib 17 | - http-types 18 | - text 19 | - time 20 | - unordered-containers 21 | - wai 22 | - wai-middleware-static 23 | - warp 24 | - yaml 25 | ghc-options: -Wall 26 | -------------------------------------------------------------------------------- /backend/tpl/cashflow.tpl: -------------------------------------------------------------------------------- 1 | {% extends "wrapper" %} 2 | 3 | {%- block title %}Cashflow{% endblock %} 4 | 5 | {%- block scripts %} 6 | 7 | {% endblock %} 8 | 9 | {%- block content %} 10 |
11 | 12 |
13 |

Income Breakdown

14 |
15 | 16 |
17 | 18 |
19 |

Expenditure Breakdown

20 |
21 | 22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /backend/tpl/history.tpl: -------------------------------------------------------------------------------- 1 | {% extends "wrapper" %} 2 | 3 | {%- block title %}Transaction History{% endblock %} 4 | 5 | {%- block scripts %} 6 | 7 | {% endblock %} 8 | 9 | {%- block header %} 10 |
11 | 12 |
13 |
14 | 15 |
16 |
17 | {% endblock %} 18 | 19 | {%- block content %} 20 | {% set months = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] %} 21 | {% for i in [12,11,10,9,8,7,6,5,4,3,2,1] %} 22 | 23 | 24 | 25 | 26 | 27 | 28 |
{{ months[i - 1] }}
29 | {% endfor %} 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /.stylish-haskell.yaml: -------------------------------------------------------------------------------- 1 | # stylish-haskell configuration file 2 | # https://github.com/jaspervdj/stylish-haskell 3 | ########################## 4 | 5 | steps: 6 | # Import cleanup 7 | - imports: 8 | # Align the import names and import list throughout the entire 9 | # file. 10 | align: global 11 | 12 | # Import list is aligned with end of import including 'as' and 13 | # 'hiding' keywords. 14 | # 15 | # > import qualified Data.List as List (concat, foldl, foldr, head, 16 | # > init, last, length) 17 | list_align: after_alias 18 | 19 | # Put as many import specs on same line as possible. 20 | long_list_align: inline 21 | 22 | # () is right after the module name: 23 | # 24 | # > import Vector.Instances () 25 | empty_list_align: right_after 26 | 27 | # Align import list on lines after the import under the start of 28 | # the module name. 29 | list_padding: module_name 30 | 31 | # There is no space between classes and constructors and the 32 | # list of it's members. 33 | # 34 | # > import Data.Foldable (Foldable(fold, foldl, foldMap)) 35 | separate_lists: false 36 | 37 | # Language pragmas 38 | - language_pragmas: 39 | # Vertical-spaced language pragmas, one per line. 40 | style: vertical 41 | 42 | # Brackets are not aligned together. There is only one space 43 | # between actual import and closing bracket. 44 | align: false 45 | 46 | # Remove redundant language pragmas. 47 | remove_redundant: true 48 | 49 | # Remove trailing whitespace 50 | - trailing_whitespace: {} 51 | 52 | # Maximum line length, used by some of the steps above. 53 | columns: 80 54 | 55 | # Convert newlines to LF ("\n"). 56 | newline: lf 57 | 58 | # For some reason, stylish-haskell thinks I need these extensions 59 | # turning on in order to parse the code. 60 | language_extensions: 61 | - MultiParamTypeClasses 62 | - TemplateHaskell 63 | -------------------------------------------------------------------------------- /backend/Template.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE LambdaCase #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | 4 | module Template where 5 | 6 | import qualified Data.Default.Class as D 7 | import qualified Data.Text as T 8 | import System.FilePath (FilePath, (<.>), ()) 9 | import System.IO.Error (tryIOError) 10 | import qualified Text.Ginger as G 11 | import qualified Text.Ginger.Html as G 12 | 13 | -- | The summary / index page. 14 | summaryPage :: IO (Either T.Text T.Text) 15 | summaryPage = loadAndRenderTemplate "index" 16 | 17 | -- | The balance sheet page. 18 | balancesheetPage :: IO (Either T.Text T.Text) 19 | balancesheetPage = loadAndRenderTemplate "balancesheet" 20 | 21 | -- | The cashflow page. 22 | cashflowPage :: IO (Either T.Text T.Text) 23 | cashflowPage = loadAndRenderTemplate "cashflow" 24 | 25 | -- | The history page. 26 | historyPage :: IO (Either T.Text T.Text) 27 | historyPage = loadAndRenderTemplate "history" 28 | 29 | 30 | ------------------------------------------------------------------------------- 31 | -- Template utilities 32 | 33 | -- | Load and render a Ginger template, or return an error. 34 | loadAndRenderTemplate :: String -> IO (Either T.Text T.Text) 35 | loadAndRenderTemplate tplname = 36 | fmap (renderTemplate tplname) <$> loadTemplate tplname 37 | 38 | -- | Render a Ginger template. 39 | renderTemplate :: String -> G.Template -> T.Text 40 | renderTemplate tplname tpl = 41 | let ctx = G.makeContextHtml $ \case 42 | "page" -> G.toGVal (T.pack tplname) 43 | _ -> D.def 44 | in G.htmlSource (G.runGinger ctx tpl) 45 | 46 | -- | Load a Ginger template, or return an error. 47 | loadTemplate :: String -> IO (Either T.Text G.Template) 48 | loadTemplate tplname = 49 | either (Left . T.pack . G.formatParserError Nothing) Right <$> G.parseGingerFile resolveInclude tplname 50 | 51 | -- | Read a file, returning @Nothing@ on failure. 52 | resolveInclude :: G.IncludeResolver IO 53 | resolveInclude tplname = 54 | either (const Nothing) Just <$> tryIOError (readFile (toTemplatePath tplname)) 55 | 56 | -- | Given a template file name, give its path. 57 | toTemplatePath :: String -> FilePath 58 | toTemplatePath tplname = "tpl" tplname <.> "tpl" 59 | -------------------------------------------------------------------------------- /frontend/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Calluna Sans'; 3 | padding-top: 20px; 4 | padding-bottom: 50px; 5 | margin-left: 70px; 6 | } 7 | 8 | header { 9 | padding-bottom: 1.5rem; 10 | } 11 | 12 | h1 { 13 | font-size: 2rem; 14 | } 15 | 16 | tbody th { 17 | width: 70px; 18 | } 19 | 20 | thead th { 21 | border-top: none !important; 22 | } 23 | 24 | 25 | /* **** navigation ********************************************************* */ 26 | 27 | nav { 28 | position: fixed; 29 | top: 0; 30 | bottom: 0; 31 | left: 0; 32 | right: 0; 33 | margin-top: 0; 34 | margin-bottom: 0; 35 | background-color: #eee; 36 | overflow-y: auto; 37 | width: 70px; 38 | } 39 | 40 | nav span { 41 | display: block; 42 | text-align: center; 43 | } 44 | 45 | nav li + li { 46 | border-top: 1px solid #fff; 47 | } 48 | 49 | nav a { 50 | padding-top: 1.5em !important; 51 | padding-bottom: 1.5em !important; 52 | color: #333 !important; 53 | } 54 | 55 | nav a.active { 56 | background-color: #fff !important; 57 | } 58 | 59 | 60 | /* **** dividers *********************************************************** */ 61 | 62 | .divider { 63 | margin-top: 3rem; 64 | margin-bottom: 1rem; 65 | font-size: 12px; 66 | line-height: 20px; 67 | position: relative; 68 | text-align: center; 69 | text-transform: uppercase; 70 | } 71 | 72 | .divider:before { 73 | position: absolute; 74 | top: 50%; 75 | display: block; 76 | content: ""; 77 | width: 100%; 78 | height: 1px; 79 | background-color: #eee; 80 | } 81 | 82 | .divider h2 { 83 | position: relative; 84 | z-index: 2; 85 | display: inline-block; 86 | padding-left: 1rem; 87 | padding-right: 1rem; 88 | vertical-align: middle; 89 | background-color: #fff; 90 | margin-top: 0; 91 | margin-bottom: 0; 92 | font-size: 100%; 93 | } 94 | 95 | 96 | /* **** month picker ******************************************************* */ 97 | 98 | .monthpicker .dropdown-caption, .monthpicker .dropdown-menu { 99 | width: 120px; 100 | text-align: left; 101 | } 102 | 103 | 104 | /* **** forms ************************************************************** */ 105 | 106 | .form-inline { 107 | margin-bottom: 0; 108 | } 109 | 110 | 111 | /* **** webfonts *********************************************************** */ 112 | 113 | @font-face { 114 | font-family: 'Calluna Sans'; 115 | src: local('Calluna Sans'), local('CallunaSans'), url('/font/CallunaSansRegular.woff2') format('woff2'); 116 | font-weight: normal; 117 | font-style: normal; 118 | } 119 | -------------------------------------------------------------------------------- /backend/tpl/index.tpl: -------------------------------------------------------------------------------- 1 | {% extends "wrapper" %} 2 | 3 | {%- block title %}Summary{% endblock %} 4 | 5 | {%- block scripts %} 6 | 7 | 8 | {% endblock %} 9 | 10 | {%- block header %} 11 |
12 |
13 |
14 | 17 | 20 |
21 |
22 | {% endblock %} 23 | 24 | {%- block content %} 25 |
26 |
27 |
28 |
29 | 30 |
31 |

Cashflow

32 |
33 | 34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 |
Envelope Budget
43 |
44 | 45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 |
Income
53 |

Deltas are compared to .

54 |
55 | 56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 |
Expenses
64 |

Deltas are compared to .

65 |
66 |
67 | 68 |
69 |

Transactions

70 |
71 | 72 |
73 |
74 | 75 | 76 | 77 | 78 | 79 | 80 |
Recent Transactions
81 |
82 |
83 | {% endblock %} 84 | -------------------------------------------------------------------------------- /frontend/history.js: -------------------------------------------------------------------------------- 1 | // Render a transaction history 2 | function renderHistoryFor(raw_data, eleid, filter='', hide_empty=false) { 3 | let entries = []; 4 | let totalDelta = 0; 5 | let days = Object.keys(raw_data).sort().reverse(); 6 | for (let i = 0; i < days.length; i ++) { 7 | let day = days[i]; 8 | let data = raw_data[day]; 9 | 10 | let transactions = []; 11 | for (let j = 0; j < data.length; j ++) { 12 | let transaction = data[j]; 13 | let virtual = zeroish(transaction.delta); 14 | 15 | if(transaction.title.toLowerCase().indexOf(filter) == -1) { 16 | continue; 17 | } 18 | 19 | transactions.push({ 20 | 'title': transaction.title, 21 | 'good': transaction.delta > 0, 22 | 'bad': transaction.delta < 0, 23 | 'delta': virtual ? '' : strAmount(transaction.delta, true), 24 | 'virtual': virtual 25 | }); 26 | 27 | totalDelta += transaction.delta; 28 | } 29 | 30 | if (transactions.length > 0) { 31 | entries.push({ 32 | 'day': day.substr(5), 33 | 'first': transactions[0], 34 | 'rest': transactions.slice(1) 35 | }); 36 | } 37 | } 38 | 39 | if (hide_empty) { 40 | document.getElementById(`history_${eleid}_table`).style.display = (entries.length == 0) ? 'none' : 'table'; 41 | } 42 | 43 | document.getElementById(`history_${eleid}_tbody`).innerHTML = Mustache.render(TPL_HISTORY_TABLE_BODY, { 44 | entry: entries 45 | }); 46 | document.getElementById(`history_${eleid}_tfoot`).innerHTML = Mustache.render(TPL_HISTORY_TABLE_FOOT, { 47 | delta: strAmount(totalDelta, true), 48 | good: totalDelta > 0, 49 | bad: totalDelta < 0 50 | }); 51 | } 52 | 53 | // Extract one month of data from the conglomerate. 54 | function getMonthlyTxns(raw_data, year, month) { 55 | let out = {}; 56 | for (let i = 1; i < 32; i ++) { 57 | let key = `${year}-${month.toString().padStart(2, '0')}-${i.toString().padStart(2, '0')}`; 58 | if (key in raw_data) { 59 | out[key] = raw_data[key]; 60 | } 61 | } 62 | return out; 63 | } 64 | 65 | // Render the history tables. 66 | function renderHistory() { 67 | let filter = document.getElementById('search').value.toLowerCase(); 68 | for (let i = 1; i < 13; i ++) { 69 | renderHistoryFor(cached_data[i-1], i, filter, true); 70 | } 71 | } 72 | 73 | window.onload = () => ajax(`/history`, all_data => { 74 | let year = new Date().getFullYear(); 75 | 76 | cached_data = []; 77 | for (let i = 1; i < 13; i ++) { 78 | cached_data.push(getMonthlyTxns(all_data, year, i)); 79 | } 80 | 81 | renderHistory(); 82 | }); 83 | -------------------------------------------------------------------------------- /backend/tpl/balancesheet.tpl: -------------------------------------------------------------------------------- 1 | {% extends "wrapper" %} 2 | 3 | {%- block title %}Balance Sheet{% endblock %} 4 | 5 | {%- block scripts %} 6 | 7 | {% endblock %} 8 | 9 | {%- block header %} 10 |
11 |
12 |
13 | 16 | 19 |
20 |
21 | {% endblock %} 22 | 23 | {%- block content %} 24 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 |
Assets
33 |
34 | 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 |
Equity
43 |
44 | 45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 |
Liabilities
53 |
54 |
55 | 56 |
57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 |
Income
65 |
66 | 67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 |
Expenses
75 |
76 | 77 |
78 | 79 | 80 | 81 | 82 | 83 | 84 |
Total
85 |

Expanded accounting equation for double-entry accounting with signed amounts:
86 | assets + equity + expenses + income + liabilities = 0

87 |
88 |
89 | {% endblock %} 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | hledger visualiser 2 | ================== 3 | 4 | This is a little tool I've been writing to visualise 5 | my [hledger](http://hledger.org/) records. Tables and textual reports 6 | are nice but sometimes visualisations, such as graphs, can really give 7 | you new insights into your data. 8 | 9 | ![Cashflow Screenshot](screenshot.png) 10 | 11 | **Disclaimer:** This is primarily written for me, by me. It probably 12 | won't work if you have a more financially complicated life than me. 13 | The following things definitely don't work, and there may be more 14 | things that accidentally don't work: 15 | 16 | 1. Postings with an implicit amount which involves multiple 17 | commodities. For example: 18 | 19 | ``` 20 | 2017/07/30 I am very international and have multiple currencies 21 | assets:cash:paypal -20 GBP 22 | assets:cash:paypal -10 EUR 23 | assets:cash:bank:checking 24 | ``` 25 | 26 | 2. Postings which do not involve your *default commodity* (see the 27 | example config file) at all. So if my default commodity is not 28 | "CAD", this would not work: 29 | 30 | ``` 31 | 2017/07/30 I am currently in Canada 32 | expenses:syrup 30 CAD 33 | assets:cash:wallet 34 | ``` 35 | 36 | On the other hand, if I paid with my UK debit card which did the 37 | currency conversion for me, this would be fine: 38 | 39 | ``` 40 | 2017/07/30 I am currently in Canada 41 | expenses:syrup 30 CAD @@ £19 42 | assets:cash:bank:checking 43 | ``` 44 | 45 | 3. Asset or expense accounts with negative balances; liability or 46 | income accounts with positive balances. 47 | 48 | But if you just have a fairly simple financial life where you do 49 | everything in one currency (or only buy and sell other commodities in 50 | your normal currency), this should work for you. 51 | 52 | 53 | Building 54 | -------- 55 | 56 | ### Backend 57 | 58 | Build and run with `stack`: 59 | 60 | ```bash 61 | cd backend 62 | stack build 63 | stack exec backend ../config.example.yaml 64 | ``` 65 | 66 | If no argument is given, a file `./config.yaml` is tried. 67 | 68 | ### Frontend 69 | 70 | In this section, `$WEBDIR` refers to the directory you specified in 71 | your configuration file to serve static files from. 72 | 73 | 1. Make the directories: 74 | 75 | ```bash 76 | mkdir -p $WEBDIR/{fonts,vendor} 77 | ``` 78 | 79 | 2. Use a CDN (set the `use_cdn` flag in `backend/tpl/wrapper.tpl`) *OR* fetch the dependencies (save all in `$WEBDIR/vendor`): 80 | 81 | - `highstock.js` (version 5) from https://www.highcharts.com 82 | **Note**: Highcharts is only free for non-commercial use. 83 | - `jquery.min.js` (version 3) from https://jquery.com 84 | - `mustache.min.js` (version 2) from https://github.com/janl/mustache.js 85 | - `tether.min.js` (version 1) from http://tether.io 86 | - `font-awesome` (version 4) from http://fontawesome.io 87 | - `bootstrap` (version 4) from https://v4-alpha.getbootstrap.com 88 | 89 | 3. Fetch the Calluna Sans regular font face (the free one) from https://www.fontspring.com/fonts/exljbris/calluna-sans, 90 | saved as `$WEBDIR/fonts/CallunaSansRegular.woff2` 91 | 92 | 4. Finally, copy over the frontend files: 93 | 94 | ```bash 95 | cp frontend/* $WEBDIR 96 | ``` 97 | -------------------------------------------------------------------------------- /backend/tpl/wrapper.tpl: -------------------------------------------------------------------------------- 1 | {# if you want to use a CDN, set this to "true" #} 2 | {% set use_cdn = false %} 3 | 4 | {% set navlinks = 5 | [ {"title": "Summary", "page": "index", "icon": "home"} 6 | , {"title": "Balance Sheet", "page": "balancesheet", "icon": "balance-scale"} 7 | , {"title": "Cashflow", "page": "cashflow", "icon": "bank"} 8 | , {"title": "Transaction History", "page": "history", "icon": "list"} 9 | ] 10 | %} 11 | 12 | 13 | 14 | {% block title %}{% endblock %} 15 | 16 | 17 | {% if use_cdn %} 18 | 19 | 20 | {% else %} 21 | 22 | 23 | {% endif %} 24 | 25 | 26 | 27 | 40 | 41 |
42 |
43 |
44 |
45 | Finances 46 |

{% block title %}{% endblock %}

47 |
48 | 49 |
50 | {% block header %}{% endblock %} 51 |
52 |
53 |
54 | 55 | {% block content %}{% endblock %} 56 |
57 | 58 | {% if use_cdn %} 59 | 60 | 61 | 62 | 63 | 64 | {% else %} 65 | 66 | 67 | 68 | 69 | 70 | {% endif %} 71 | 72 | {% block scripts %}{% endblock %} 73 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /frontend/cashflow.js: -------------------------------------------------------------------------------- 1 | // Common configuration for column tooltips. For some reason this doesn't work when set as a global default. 2 | const columnTooltip = { 3 | shared: true, 4 | useHTML: true, 5 | headerFormat: '{point.key}', 6 | footerFormat: '
', 7 | pointFormatter: function () 8 | { 9 | return `${this.series.name}${strAmount(this.y)}`; 10 | } 11 | }; 12 | 13 | // Condense an account history into 12 monthly totals 14 | function monthise(history_data) { 15 | let out = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; 16 | 17 | let last = 0; 18 | for (let entry of history_data) { 19 | let m = new Date(Date.parse(entry.date)).getMonth(); 20 | let amount = Math.abs(entry.amount); 21 | out[m] += amount - last; 22 | last = amount; 23 | } 24 | 25 | return out; 26 | } 27 | 28 | function renderCashflowChart(income_data, expense_data) { 29 | function gather(raw_data) { 30 | let out = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; 31 | for (let history of Object.values(raw_data)) { 32 | let summary = monthise(history); 33 | for (let j = 0; j < 12; j ++) { 34 | out[j] += summary[j]; 35 | } 36 | } 37 | return out; 38 | } 39 | 40 | let incomes = gather(income_data); 41 | let expenditures = gather(expense_data); 42 | 43 | let balances = []; 44 | let cur = 0; 45 | for (let i = 0; i < incomes.length; i ++) { 46 | cur = cur + incomes[i] - expenditures[i]; 47 | balances.push(cur); 48 | } 49 | 50 | let greenColour = 'rgb(100,200,100)'; 51 | let redColour = 'rgb(250,100,100)'; 52 | Highcharts.chart('cashflow_chart_container', { 53 | chart: { type: 'column' }, 54 | xAxis: { categories: MONTH_NAMES, crosshair: true }, 55 | yAxis: { title: { text: '' }, labels: { formatter: function () { return strAmount(this.value); } } }, 56 | tooltip: columnTooltip, 57 | series: [{ 58 | name: 'Income', 59 | color: greenColour, 60 | data: incomes 61 | }, { 62 | name: 'Expenditure', 63 | color: redColour, 64 | data: expenditures 65 | }, { 66 | type: 'spline', 67 | name: 'Cumulative Change', 68 | data: balances, 69 | showPlus: true, 70 | color: 'rgb(100,100,250)', 71 | tooltip: { 72 | pointFormatter: function () { 73 | let tag = `${this.series.name}`; 74 | let amount = strAmount(this.y, true); 75 | let col = zeroish(this.y) ? 'black' : ((this.y < 0) ? redColour : greenColour); 76 | return `${tag}${amount}`; 77 | } 78 | } 79 | }] 80 | }); 81 | } 82 | 83 | function renderBreakdownChart(ele_id, raw_data) { 84 | Highcharts.chart(ele_id, { 85 | chart: { type: 'column' }, 86 | xAxis: { categories: MONTH_NAMES, crosshair: true }, 87 | yAxis: { title: { text: '' }, min: 0, labels: { formatter: function () { return strAmount(this.value); } } }, 88 | tooltip: columnTooltip, 89 | series: Object.keys(raw_data).sort().map(k => { 90 | return { 91 | name: k, 92 | color: colour(k), 93 | data: monthise(raw_data[k]).map(x => zeroish(x) ? NaN : x) 94 | }; 95 | }) 96 | }); 97 | } 98 | 99 | function renderCharts(income_data, expense_data) { 100 | if (income_data == undefined) { income_data = cached_data.income; } 101 | if (expense_data == undefined) { expense_data = cached_data.expenses; } 102 | 103 | renderCashflowChart(income_data, expense_data); 104 | renderBreakdownChart('expenses_breakdown_chart_container', expense_data); 105 | renderBreakdownChart('income_breakdown_chart_container', income_data); 106 | } 107 | 108 | window.onload = () => ajax('/data', account_data => renderCharts(account_data.income, account_data.expenses)); 109 | -------------------------------------------------------------------------------- /backend/Report.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | module Report where 4 | 5 | import qualified Data.Aeson as A 6 | import Data.List.NonEmpty (toList) 7 | import Data.Maybe (catMaybes) 8 | import Data.Ratio (Rational) 9 | import qualified Data.Text as T 10 | import qualified Data.Time.Calendar as C 11 | import qualified Data.Time.Format as C 12 | 13 | import qualified Config as FC 14 | 15 | -- | A report to send to the user. 16 | data Report = Report 17 | { rpAssets :: [AccountReport] 18 | , rpLiabilities :: [AccountReport] 19 | , rpIncome :: BasicReport 20 | , rpBudget :: BasicReport 21 | , rpExpenses :: BasicReport 22 | , rpEquity :: BasicReport 23 | , rpCommodities :: [(T.Text, CommodityReport)] 24 | } deriving Show 25 | 26 | instance A.ToJSON Report where 27 | toJSON rp = A.object 28 | [ "assets" A..= rpAssets rp 29 | , "liabilities" A..= rpLiabilities rp 30 | , "income" A..= rpIncome rp 31 | , "budget" A..= rpBudget rp 32 | , "expenses" A..= rpExpenses rp 33 | , "equity" A..= rpEquity rp 34 | , "commodities" A..= A.object [ name A..= cr | (name, cr) <- rpCommodities rp ] 35 | ] 36 | 37 | -- | A summary of an account. 38 | data AccountReport = AccountReport 39 | { arName :: T.Text 40 | , arBreakdown :: [SubaccountReport] 41 | } deriving Show 42 | 43 | instance A.ToJSON AccountReport where 44 | toJSON ar = A.object 45 | [ "name" A..= arName ar 46 | , "breakdown" A..= arBreakdown ar 47 | ] 48 | 49 | -- | A subaccount of one account. 50 | data SubaccountReport = SubaccountReport 51 | { srName :: T.Text 52 | , srBalTag :: T.Text 53 | , srURL :: Maybe T.Text 54 | , srHistory :: HistoryReport 55 | , srCommodities :: [(T.Text, CommodityHistoryReport)] 56 | } deriving Show 57 | 58 | instance A.ToJSON SubaccountReport where 59 | toJSON sr = A.object $ 60 | [ "name" A..= srName sr 61 | , "category" A..= srBalTag sr 62 | , "history" A..= srHistory sr 63 | , "commodities" A..= A.object [ n A..= cr | (n, cr) <- srCommodities sr ] 64 | ] ++ maybe [] (\u -> [ "url" A..= u ]) (srURL sr) 65 | 66 | -- | Information about a commodity in an account. 67 | data CommodityHistoryReport = CommodityHistoryReport 68 | { chrWorth :: HistoryReport 69 | , chrAmount :: HistoryReport 70 | } deriving Show 71 | 72 | instance A.ToJSON CommodityHistoryReport where 73 | toJSON chr = A.object 74 | [ "worth" A..= chrWorth chr 75 | , "amount" A..= chrAmount chr 76 | ] 77 | 78 | -- | A list of balances. 79 | newtype HistoryReport = HistoryReport 80 | { hrValues :: [(C.Day, Rational)] 81 | } deriving Show 82 | 83 | instance A.ToJSON HistoryReport where 84 | toJSON hr = A.toJSON 85 | [ A.object [ "date" A..= date, "amount" A..= toDouble amount ] 86 | | (day, amount) <- hrValues hr 87 | , let date = C.formatTime C.defaultTimeLocale "%F" day 88 | ] 89 | 90 | -- | A much simpler report than a full 'AccountReport'. 91 | newtype BasicReport = BasicReport 92 | { brAccounts :: [(T.Text, HistoryReport)] 93 | } deriving Show 94 | 95 | instance A.ToJSON BasicReport where 96 | toJSON br = A.object [ name A..= hr | (name, hr) <- brAccounts br ] 97 | 98 | -- | A report about a single transaction. 99 | data TransactionReport = TransactionReport 100 | { trTitle :: T.Text 101 | , trDelta :: Rational 102 | } deriving Show 103 | 104 | instance A.ToJSON TransactionReport where 105 | toJSON tr = A.object 106 | [ "title" A..= trTitle tr 107 | , "delta" A..= toDouble (trDelta tr) 108 | ] 109 | 110 | -- | A history of transactions, grouped by day. 111 | newtype DatedTransactionsReport = DatedTransactionsReport 112 | { dtrValues :: [(C.Day, [TransactionReport])] 113 | } deriving Show 114 | 115 | instance A.ToJSON DatedTransactionsReport where 116 | toJSON dtr = A.object 117 | [ date A..= txns 118 | | (day, txns) <- dtrValues dtr 119 | , let date = T.pack (C.formatTime C.defaultTimeLocale "%F" day) 120 | ] 121 | 122 | -- | Information about a commodity. 123 | newtype CommodityReport = CommodityReport FC.CommodityConfig 124 | deriving Show 125 | 126 | instance A.ToJSON CommodityReport where 127 | toJSON (CommodityReport cc) = A.object $ catMaybes 128 | [ ("name" A..=) <$> FC.cName cc 129 | , ("url" A..=) <$> FC.cURL cc 130 | , Just $ "allocation" A..= A.object [ n A..= w | (n, w) <- toList (FC.cAllocation cc) ] 131 | ] 132 | 133 | -- | Turn a 'Rational' into a 'Double'. This is lossy! 134 | toDouble :: Rational -> Double 135 | toDouble = fromRational 136 | -------------------------------------------------------------------------------- /frontend/balancesheet.js: -------------------------------------------------------------------------------- 1 | // Render one component of the balance sheet. 2 | function renderComponent(name, data) { 3 | let total = Object.values(data).reduce((acc, d) => acc + d.reduce((acc, a) => acc + a.amount, 0), 0); 4 | 5 | document.getElementById(`bs_${name}_tbody`).innerHTML = Mustache.render(TPL_BALANCE_TABLE_BODY, { 6 | 'category': Object.keys(data).sort().map(k => { 7 | let entries = []; 8 | for (let i = 0; i < data[k].length; i ++) { 9 | entries.push({ name: data[k][i].name, amount: strAmount(data[k][i].amount) }); 10 | } 11 | return { title: k, entry: entries }; 12 | }) 13 | }); 14 | document.getElementById(`bs_${name}_tfoot`).innerHTML = Mustache.render(TPL_BALANCE_TABLE_FOOT, { 15 | caption: 'Total', 16 | value: strAmount(total) 17 | }); 18 | 19 | return total; 20 | } 21 | 22 | // Gather balance data from an account report. 23 | function gatherFromAccountReport(raw_data, date) { 24 | let out = {}; 25 | for (let key in raw_data) { 26 | let datum = raw_data[key]; 27 | for (let i = 0; i < datum.breakdown.length; i ++) { 28 | let account = datum.breakdown[i]; 29 | let amount = summariseHistory(account.history, date); 30 | if (amount == 0) continue; 31 | if (!(account.category in out)) { 32 | out[account.category] = []; 33 | } 34 | out[account.category].push({ name: account.name, amount: amount }); 35 | } 36 | } 37 | return out; 38 | } 39 | 40 | // Gather balance data from a basic report. 41 | function gatherFromBasicReport(raw_data, date, k='End of Period') { 42 | let out = {}; 43 | out[k] = []; 44 | let keys = Object.keys(raw_data).sort(); 45 | for (let i = 0; i < keys.length; i ++) { 46 | let key = keys[i]; 47 | let history = raw_data[key]; 48 | out[k].push({ name: key, amount: summariseHistory(history, date) }); 49 | } 50 | return out; 51 | } 52 | 53 | // Render the balance sheet 54 | function renderBalanceSheet(raw_data, date) { 55 | let assets_data = gatherFromAccountReport(raw_data.assets, date); 56 | let liabilities_data = gatherFromAccountReport(raw_data.liabilities, date); 57 | 58 | let expenses_data = gatherFromBasicReport(raw_data.expenses, date); 59 | let income_data = gatherFromBasicReport(raw_data.income, date); 60 | let equity_data = gatherFromBasicReport(raw_data.equity, date, 'Start of Period'); 61 | 62 | let assets_total = renderComponent('assets', assets_data); 63 | let equity_total = renderComponent('equity', equity_data, 'Start of Period'); 64 | let expenses_total = renderComponent('expenses', expenses_data, 'End of Period'); 65 | let income_total = renderComponent('income', income_data, 'End of Period'); 66 | let liabilities_total = renderComponent('liabilities', liabilities_data); 67 | 68 | document.getElementById(`bs_total_tbody`).innerHTML = Mustache.render(TPL_BALANCE_TABLE_BODY, { 69 | category: [{ 70 | title: 'Balance', 71 | entry: [ { name: 'Assets', amount: strAmount(assets_total) }, 72 | { name: 'Equity', amount: strAmount(equity_total) }, 73 | { name: 'Expenses', amount: strAmount(expenses_total) }, 74 | { name: 'Income', amount: strAmount(income_total) }, 75 | { name: 'Liabilities', amount: strAmount(liabilities_total) }] 76 | }], 77 | }); 78 | document.getElementById(`bs_total_tfoot`).innerHTML = Mustache.render(TPL_BALANCE_TABLE_FOOT, { 79 | caption: 'Overall Total', 80 | value: strAmount(assets_total + equity_total + expenses_total + income_total + liabilities_total) 81 | }); 82 | } 83 | 84 | function renderFinances(data, month) { 85 | let this_year = new Date().getFullYear(); 86 | let date = new Date(this_year, month, daysInMonth(this_year, month)); 87 | 88 | renderBalanceSheet(data, date); 89 | } 90 | 91 | window.onload = () => { 92 | // Set up the month picker 93 | let visible_month = undefined; 94 | let cached_account_data = undefined; 95 | monthpicker(i => { 96 | if (i == visible_month) return; 97 | visible_month = i; 98 | document.getElementById('month-name').innerText = MONTH_NAMES[i]; 99 | 100 | if (cached_account_data == undefined) { 101 | ajax('/data', account_data => { 102 | cached_account_data = account_data; 103 | renderFinances(account_data, i); 104 | }); 105 | } else { 106 | renderFinances(cached_account_data, visible_month); 107 | } 108 | }); 109 | }; 110 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # 3 | # Path (relative to the location of this file, or absolute) to the 4 | # hledger journal. Optional. 5 | journal_file: ~/hledger.journal 6 | 7 | # 8 | # 9 | # The default commodity. Currently only postings which have a value 10 | # in this commodity are supported. Let's say your default commodity 11 | # is "£", this means all of your postings must have one of these three 12 | # forms: 13 | # 14 | # - "account £xxx" 15 | # - "account xxx @ £yyy" 16 | # - "account xxx @@ £yyy" 17 | # 18 | # So if you have non-default commodities, all you can do is buy or 19 | # sell them with your default commodity. Postings which do not 20 | # mention your default commodity at all will just cause the server to 21 | # throw an error, sorry! 22 | default_commodity: £ 23 | 24 | # The web server configuration. 25 | http: 26 | # 27 | # 28 | # Port to listen on. 29 | port: 5000 30 | # 31 | # 32 | # Path (relative to the location of this file, or absolute) to 33 | # static assets. 34 | static_dir: ./web 35 | 36 | # The account tree configuration. 37 | tree: 38 | # 39 | # 40 | # These accounts are considered asset accounts. "*" may be used as 41 | # a wildcard. Defaults to "assets:*". 42 | assets: assets:*:* 43 | 44 | # 45 | # 46 | # These accounts are considered to be equity accounts. "*" may be 47 | # used as a wildcard. Defaults to "equity:*". 48 | equity: equity:* 49 | 50 | # 51 | # 52 | # These accounts are considered to be expense accounts. "*" may be 53 | # used as a wildcard. Defaults to "expenses:*". 54 | expenses: expenses:* 55 | 56 | # 57 | # 58 | # These accounts are considered to be income accounts. "*" may be 59 | # used as a wildcard. Defaults to "income:*". 60 | income: income:* 61 | 62 | # 63 | # 64 | # These accounts are considered to be liability accounts. "*" may 65 | # be used as a wildcard. Defaults to "liabilities:*". 66 | liabilities: liabilities:* 67 | 68 | # 69 | # 70 | # These accounts are considered to be envelope budget accounts. "*" 71 | # may be used as a wildcard. Defaults to "budget:*". 72 | budget: assets:cash:santander:current:budget:* 73 | 74 | # (account OR string)> 75 | # 76 | # Information about accounts. Keys are account names. Each entry has 77 | # four fields: 78 | # 79 | # - name: a nice name. Defaults to the bit after the last 80 | # ":" in titlecase. 81 | # 82 | # - url: a URL for further information. Optional. 83 | # 84 | # - category: where to file this in the balance sheet. Has 85 | # the following defaults: 86 | # - assets: "Current" 87 | # - equity: "Start of Period" 88 | # - liabilities: "Current" 89 | # - income: "End of Period" 90 | # - expenses: "End of Period" 91 | # 92 | # Those assets and liabilities filed under "Current" are used in 93 | # the net worth calculation in the sidebar. 94 | # 95 | # Just giving the account name as a string is allowed as a short-hand, 96 | # which leaves all the other fields at their default values. 97 | # 98 | accounts: 99 | "assets:cash:paypal": PayPal 100 | "assets:cash:santander:current:budget:tfp food": TFP Food 101 | "assets:cash:santander:esaver": eSaver 102 | "assets:cash:santander:esaver:rainyday": Rainy Day 103 | "assets:investments:cavendish": 104 | name: Cavendish Online 105 | category: Investments 106 | "income:pta": PTA 107 | "liabilities:slc": 108 | name: Student Loans Company 109 | category: Long Term 110 | 111 | # (commodity OR string)> 112 | # 113 | # Information about commodities. Keys are commodity names. Each 114 | # entry has three fields: 115 | # 116 | # - name: a nice name. Defaults to the commodity symbol. 117 | # 118 | # - url: a URL for further information. Optional. 119 | # 120 | # - allocation: int)> what this commodity is 121 | # made up of. If given as an object, the commodity is split into 122 | # parts weighted by the given ints. Defaults to "Cash". 123 | # 124 | # Just giving the commodity name as a string is allowed as a 125 | # short-hand, which leaves all the other fields at their default 126 | # values. 127 | # 128 | commodities: 129 | MHMIA: 130 | name: Marlborough UK Micro-Cap 131 | url: https://markets.ft.com/data/funds/tearsheet/summary?s=gb00b8f8yx59:gbx 132 | allocation: Equities 133 | 134 | NPMKA: 135 | name: Neptune Emerging Markets 136 | url: https://markets.ft.com/data/funds/tearsheet/summary?s=gb00b8j6sv12:gbp 137 | allocation: Equities 138 | 139 | VALEA: 140 | name: Vanguard LifeStrategy 20% 141 | url: https://markets.ft.com/data/funds/tearsheet/summary?s=gb00b4nxy349:gbp 142 | allocation: 143 | Equities: 80 144 | Bonds: 20 145 | 146 | WIFCA: 147 | name: CF Woodford Income Focus 148 | url: https://markets.ft.com/data/funds/tearsheet/summary?s=GB00BD9X6D51:gbx 149 | allocation: Equities 150 | -------------------------------------------------------------------------------- /backend/Config.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE LambdaCase #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | 4 | module Config where 5 | 6 | import Control.Monad (mapM) 7 | import qualified Data.Aeson as A 8 | import qualified Data.Aeson.Types as A 9 | import qualified Data.Foldable as F 10 | import qualified Data.HashMap.Lazy as HM 11 | import Data.List.NonEmpty (NonEmpty(..), nonEmpty) 12 | import Data.String (IsString(..)) 13 | import qualified Data.Text as T 14 | import qualified Data.Yaml as Y 15 | import System.FilePath (FilePath) 16 | 17 | 18 | -- | The parsed configuration file. 19 | data Config = Config 20 | { journalPath :: Maybe FilePath 21 | , defcommodity :: T.Text 22 | , http :: HTTPConfig 23 | , tree :: TreeConfig 24 | , accounts :: [(T.Text, AccountConfig)] 25 | , commodities :: [(T.Text, CommodityConfig)] 26 | } deriving Show 27 | 28 | instance Y.FromJSON Config where 29 | parseJSON (Y.Object o) = Config 30 | <$> o Y..:? "journal_file" 31 | <*> o Y..: "default_commodity" 32 | <*> (Y.parseJSON =<< o Y..: "http") 33 | <*> (Y.parseJSON =<< (o Y..:? "tree" Y..!= Y.Null)) 34 | <*> fmap HM.toList (Y.parseJSON =<< (o Y..:? "accounts" Y..!= Y.Null)) 35 | <*> fmap HM.toList (Y.parseJSON =<< (o Y..:? "commodities" Y..!= Y.Null)) 36 | parseJSON x = A.typeMismatch "config" x 37 | 38 | -- | Configuration for the HTTP server 39 | data HTTPConfig = HTTPConfig 40 | { port :: Int 41 | , staticDir :: FilePath 42 | } deriving Show 43 | 44 | instance Y.FromJSON HTTPConfig where 45 | parseJSON (Y.Object o) = HTTPConfig 46 | <$> o Y..: "port" 47 | <*> o Y..: "static_dir" 48 | parseJSON x = A.typeMismatch "http" x 49 | 50 | -- | Configuration for the account tree. 51 | data TreeConfig = TreeConfig 52 | { assetAccounts :: NonEmpty Pattern 53 | , equityAccounts :: NonEmpty Pattern 54 | , expenseAccounts :: NonEmpty Pattern 55 | , incomeAccounts :: NonEmpty Pattern 56 | , liabilityAccounts :: NonEmpty Pattern 57 | , budgetAccounts :: NonEmpty Pattern 58 | } deriving Show 59 | 60 | instance Y.FromJSON TreeConfig where 61 | parseJSON = \case 62 | Y.Object o -> TreeConfig 63 | <$> (atLeastOne =<< o Y..:? "assets") Y..!= defAssets 64 | <*> (atLeastOne =<< o Y..:? "equity") Y..!= defEquity 65 | <*> (atLeastOne =<< o Y..:? "expenses") Y..!= defExpenses 66 | <*> (atLeastOne =<< o Y..:? "income") Y..!= defIncome 67 | <*> (atLeastOne =<< o Y..:? "liabilities") Y..!= defLiabilities 68 | <*> (atLeastOne =<< o Y..:? "budget") Y..!= defBudget 69 | Y.Null -> pure $ 70 | TreeConfig defAssets defEquity defExpenses defIncome defLiabilities defBudget 71 | x -> A.typeMismatch "tree" x 72 | where 73 | atLeastOne (Just (Y.String pat)) = pure . Just $ Pattern pat:|[] 74 | atLeastOne (Just (Y.Array arr)) = do 75 | pats <- mapM Y.parseJSON (F.toList arr) 76 | maybe (A.typeMismatch "pattern_list" Y.Null) (pure . Just) (nonEmpty pats) 77 | atLeastOne (Just x) = A.typeMismatch "pattern" x 78 | atLeastOne Nothing = pure Nothing 79 | 80 | defAssets = "assets:*":|[] 81 | defEquity = "equity:*":|[] 82 | defExpenses = "expenses:*":|[] 83 | defIncome = "income:*":|[] 84 | defLiabilities = "liabilities:*":|[] 85 | defBudget = "budget:*":|[] 86 | 87 | -- | An account name pattern. 88 | newtype Pattern = Pattern T.Text 89 | deriving Show 90 | 91 | instance Y.FromJSON Pattern where 92 | parseJSON (Y.String s) = pure (Pattern s) 93 | parseJSON x = A.typeMismatch "pattern" x 94 | 95 | instance IsString Pattern where 96 | fromString = Pattern . T.pack 97 | 98 | -- | Configuration for an account 99 | data AccountConfig = AccountConfig 100 | { aName :: Maybe T.Text 101 | , aURL :: Maybe T.Text 102 | , aCategory :: Maybe T.Text 103 | } deriving Show 104 | 105 | instance Y.FromJSON AccountConfig where 106 | parseJSON (Y.Object o) = AccountConfig 107 | <$> o Y..:? "name" 108 | <*> o Y..:? "url" 109 | <*> o Y..:? "category" 110 | parseJSON (Y.String s) = pure $ 111 | AccountConfig (Just s) Nothing Nothing 112 | parseJSON Y.Null = pure $ 113 | AccountConfig Nothing Nothing Nothing 114 | parseJSON x = A.typeMismatch "account" x 115 | 116 | -- | Configuration for a commodity 117 | data CommodityConfig = CommodityConfig 118 | { cName :: Maybe T.Text 119 | , cURL :: Maybe T.Text 120 | , cAllocation :: NonEmpty (T.Text, Int) 121 | } deriving Show 122 | 123 | instance Y.FromJSON CommodityConfig where 124 | parseJSON = \case 125 | Y.Object o -> CommodityConfig 126 | <$> o Y..:? "name" 127 | <*> o Y..:? "url" 128 | <*> (allocation =<< o Y..:? "allocation") 129 | Y.String s -> pure $ 130 | CommodityConfig (Just s) Nothing defAllocation 131 | Y.Null -> pure $ 132 | CommodityConfig Nothing Nothing defAllocation 133 | x -> A.typeMismatch "commodity" x 134 | where 135 | allocation (Just (Y.Object o)) = do 136 | bits <- mapM (\(s,n) -> (,) s <$> Y.parseJSON n) (HM.toList o) 137 | maybe (A.typeMismatch "allocation_list" Y.Null) pure (nonEmpty bits) 138 | allocation (Just (Y.String s)) = pure ((s, 1):|[]) 139 | allocation (Just Y.Null) = pure defAllocation 140 | allocation (Just x) = A.typeMismatch "allocation" x 141 | allocation Nothing = pure defAllocation 142 | 143 | defAllocation = ("Cash", 1):|[] 144 | -------------------------------------------------------------------------------- /frontend/lib.js: -------------------------------------------------------------------------------- 1 | // Month names 2 | const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; 3 | 4 | 5 | /***************************************************************************** 6 | * functions 7 | *****************************************************************************/ 8 | 9 | // Get the length of a month (0-based) 10 | function daysInMonth(year, month) { 11 | let d = new Date(year, month + 1, 0); 12 | return d.getDate(); 13 | } 14 | 15 | // Make an ajax request and do something with the result 16 | function ajax(url, cb) { 17 | let httpRequest = new XMLHttpRequest(); 18 | httpRequest.onreadystatechange = function() { 19 | if(httpRequest.readyState === XMLHttpRequest.DONE && httpRequest.status === 200) { 20 | let data = JSON.parse(httpRequest.responseText); 21 | cb(data); 22 | } 23 | }; 24 | httpRequest.open('GET', url); 25 | httpRequest.send(); 26 | } 27 | 28 | // Render finances for a given month, if the current page allows time 29 | // scrolling. 30 | function renderFinances(cb) { 31 | if (cached_data == undefined) { 32 | ajax('/data', data => { 33 | cached_data = data; 34 | cb(data); 35 | }); 36 | } else { 37 | cb(cached_data); 38 | } 39 | } 40 | 41 | 42 | // Pretty-print an amount or delta. 43 | function strAmount(amount, showPlus=false, showSymbol=true) { 44 | let sign = (amount > -0.01) ? ((amount > 0 && showPlus) ? '+' : '') : '-'; 45 | let amt = Math.abs(amount).toFixed(2); 46 | let sym = showSymbol ? '£' : ''; 47 | return `${sign}${sym}${amt}`; 48 | } 49 | 50 | // Pretty-print a date. 51 | function strDate(date) { 52 | return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`; 53 | } 54 | 55 | // Check if an amount if roughly equal to zero. 56 | function zeroish(val) { 57 | return val < 0.01 && val > -0.01; 58 | } 59 | 60 | // Generate a colour value by hashing a string. 61 | function colour(str) { 62 | let hash = 0; 63 | for (let i = 0; i < str.length; i ++) { 64 | hash = ((hash << 5) - hash) + str.charCodeAt(i); 65 | hash |= 0; 66 | } 67 | hash = Math.abs(hash); 68 | 69 | return `rgb(${(hash * 37 * str.length) % 255}, ${(hash * 131 * str.length) % 255}, ${(hash * 239 * str.length) % 255})` 70 | } 71 | 72 | // Default values for charts 73 | function setChartDefaults() { 74 | Highcharts.setOptions({ 75 | chart: { backgroundColor: null }, 76 | title: { text: '' }, 77 | subTitle: { text: '' }, 78 | credits: { enabled: false }, 79 | plotOptions: { 80 | pie: { 81 | borderWidth: 2, 82 | shadow: false, 83 | center: ['50%', '50%'], 84 | tooltip: { 85 | headerFormat: '{point.key}
' 86 | } 87 | }, 88 | series: { 89 | dataLabels: { enabled: false } 90 | } 91 | } 92 | }); 93 | } 94 | 95 | // Get the most recent balance from a history report as of the given 96 | // date. 97 | function summariseHistory(history, date) { 98 | let amount = 0; 99 | for (let entry of history) { 100 | if (Date.parse(entry.date) <= date.getTime()) { 101 | amount = entry.amount; 102 | } else { 103 | break; 104 | } 105 | } 106 | return amount; 107 | } 108 | 109 | // Manage the month picker 110 | function monthpicker(cb) { 111 | let month = new Date().getMonth(); 112 | let matches = document.querySelectorAll('[data-month-picker]'); 113 | 114 | for (let ele of matches) { 115 | let monthlist = document.createElement('div'); 116 | monthlist.className = 'dropdown-menu'; 117 | 118 | for (let i = 0; i < MONTH_NAMES.length; i ++) { 119 | let link = document.createElement('a'); 120 | link.className = 'dropdown-item'; 121 | link.href = '#'; 122 | link.onclick = () => cb(i, ele); 123 | link.innerText = MONTH_NAMES[i]; 124 | monthlist.appendChild(link); 125 | } 126 | 127 | ele.appendChild(monthlist); 128 | 129 | cb(month, ele); 130 | } 131 | } 132 | 133 | 134 | /***************************************************************************** 135 | * templates 136 | *****************************************************************************/ 137 | 138 | // The "assets", "equity", and "liabilities" balance sheet tables. 139 | const TPL_BALANCE_TABLE_BODY = ` 140 | {{#category}} 141 | 142 | {{title}} 143 | 144 | {{#entry}} 145 | 146 | {{name}} 147 | {{amount}} 148 | 149 | {{/entry}} 150 | {{/category}} 151 | `; 152 | const TPL_BALANCE_TABLE_FOOT = ` 153 | 154 | {{caption}} 155 | {{value}} 156 | 157 | `; 158 | 159 | // The "income", "budget", and "expenses" summary tables. 160 | const TPL_SUMMARY_TABLE_BODY = ` 161 | {{#entry}} 162 | 163 | {{source}} 164 | {{delta}} 165 | {{amount}} 166 | 167 | {{/entry}} 168 | `; 169 | const TPL_SUMMARY_TABLE_FOOT = ` 170 | 171 | Total 172 | {{delta}} 173 | {{amount}} 174 | 175 | `; 176 | 177 | // The "history" table. 178 | const TPL_HISTORY_TABLE_BODY = ` 179 | {{#entry}} 180 | 181 | {{day}} 182 | {{first.title}} 183 | {{first.delta}} 184 | 185 | {{#rest}} 186 | 187 | 188 | {{title}} 189 | {{delta}} 190 | 191 | {{/rest}} 192 | {{/entry}} 193 | `; 194 | const TPL_HISTORY_TABLE_FOOT = ` 195 | 196 | Total 197 | {{delta}} 198 | 199 | `; 200 | -------------------------------------------------------------------------------- /frontend/finance.js: -------------------------------------------------------------------------------- 1 | var hidden_assets = {}; 2 | var hidden_allocations = {}; 3 | 4 | // Toggle points in a pie chart 5 | function legendItemClick(hider) { 6 | return function() { 7 | if (this.name in hider) { 8 | delete hider[this.name]; 9 | } else { 10 | hider[this.name] = true; 11 | } 12 | 13 | for (let point of this.series.chart.series[1].points) { 14 | if (point.owner == this.name) { 15 | point.update({ visible: !(this.name in hider) }); 16 | } 17 | } 18 | } 19 | } 20 | 21 | // Render a two-ring pie chart where the outer doughnut is a breakdown 22 | // of the inner. 23 | function renderBreakdownPie(ele_id, hider, raw_data, category_series_name, category_tooltip, breakdown_series_name, breakdown_tooltip) { 24 | let categoryData = []; 25 | let breakdownData = []; 26 | let categoryTotals = {}; 27 | let overallTotal = 0; 28 | 29 | for (let ckey in raw_data) { 30 | let category = raw_data[ckey]; 31 | let categoryTotal = Object.values(category).reduce((acc, b) => acc + b.worth, 0); 32 | overallTotal += categoryTotal; 33 | 34 | if(zeroish(categoryTotal)) continue; 35 | 36 | categoryData.push({ 37 | name: ckey, 38 | y: categoryTotal, 39 | color: colour(ckey), 40 | visible: !(ckey in hider) 41 | }); 42 | 43 | for (let bkey in category) { 44 | let breakdown = category[bkey] 45 | 46 | if (zeroish(breakdown.worth)) continue; 47 | 48 | breakdown.name = bkey; 49 | breakdown.y = breakdown.worth; 50 | breakdown.color = colour(bkey); 51 | breakdown.owner = ckey; 52 | breakdown.ownerTotal = categoryTotal; 53 | breakdown.visible = !(ckey in hider); 54 | 55 | breakdownData.push(breakdown); 56 | } 57 | } 58 | 59 | Highcharts.chart(ele_id, { 60 | chart: { type: 'pie' }, 61 | series: [{ 62 | name: category_series_name, 63 | data: categoryData, 64 | size: '60%', 65 | dataLabels: { enabled: false }, 66 | showInLegend: true, 67 | tooltip: { pointFormatter: category_tooltip(overallTotal) }, 68 | point: { events: { legendItemClick: legendItemClick(hider) } } 69 | },{ 70 | name: breakdown_series_name, 71 | data: breakdownData, 72 | size: '80%', 73 | innerSize: '60%', 74 | dataLabels: { 75 | enabled: true, 76 | formatter: function() { return (this.percentage >= 0.5) ? this.point.name : null; } 77 | }, 78 | tooltip: { pointFormatter: breakdown_tooltip } 79 | }] 80 | }); 81 | } 82 | 83 | function renderAllocationChart(assets_data, commodities_data, date) { 84 | let commodityWorthTotals = {}; 85 | let commodityAmountTotals = {}; 86 | for (let asset of Object.values(assets_data)) { 87 | for (let breakdown of asset.breakdown) { 88 | for (let ckey in breakdown.commodities) { 89 | let commodity = breakdown.commodities[ckey]; 90 | 91 | if (!(ckey in commodityWorthTotals)) { 92 | commodityWorthTotals[ckey] = 0; 93 | commodityAmountTotals[ckey] = 0; 94 | } 95 | 96 | commodityWorthTotals[ckey] += summariseHistory(commodity.worth, date); 97 | commodityAmountTotals[ckey] += summariseHistory(commodity.amount, date); 98 | } 99 | } 100 | } 101 | 102 | let allocations = { 'Cash': true } 103 | for (let commodity of Object.values(commodities_data)) { 104 | for (let akey in commodity.allocation) { 105 | allocations[akey] = true; 106 | } 107 | } 108 | 109 | let data = {}; 110 | for (let akey in allocations) { 111 | data[akey] = {}; 112 | for (let ckey in commodityWorthTotals) { 113 | function push (portion) { 114 | let name = (ckey in commodities_data && 'name' in commodities_data[ckey]) ? commodities_data[ckey].name : ckey; 115 | data[akey][name] = { 116 | worth: commodityWorthTotals[ckey] * portion, 117 | amount: commodityAmountTotals[ckey] * portion, 118 | ckey: ckey 119 | }; 120 | } 121 | 122 | if (ckey in commodities_data) { 123 | let totalShare = Object.values(commodities_data[ckey].allocation).reduce((acc, s) => acc + s, 0); 124 | if (akey in commodities_data[ckey].allocation) { 125 | let share = commodities_data[ckey].allocation[akey]; 126 | push(share / totalShare); 127 | } 128 | } else if (akey == 'Cash') { 129 | push(1); 130 | } 131 | } 132 | } 133 | 134 | renderBreakdownPie( 135 | 'allocation_chart_container', hidden_allocations, data, 136 | 'Allocation', overallTotal => function () { 137 | return `${strAmount(this.y)} (${(100*this.y/overallTotal).toFixed(2)}% of overall allocation)
`; 138 | }, 139 | 'Commodities', function () { 140 | let naked = strAmount(this.amount, false, false); 141 | let worth = (this.ckey == '£') ? '' : `(worth ${strAmount(this.y)})`; 142 | let fraction = (100 * this.worth / this.ownerTotal).toFixed(2); 143 | if (this.ckey.match(/^[0-9a-zA-Z]+$/)) { 144 | return `${naked} ${this.ckey} ${worth} (${fraction}% of ${this.owner})
`; 145 | } else { 146 | return `${this.ckey}${naked} ${worth} (${fraction}% of ${this.owner})
`; 147 | } 148 | }); 149 | } 150 | 151 | function renderAssetsSnapshotChart(raw_data, date) { 152 | let data = {}; 153 | 154 | for (let akey in raw_data) { 155 | let asset = raw_data[akey]; 156 | data[asset.name] = {}; 157 | for (let breakdown of asset.breakdown) { 158 | data[asset.name][breakdown.name] = { worth: summariseHistory(breakdown.history, date) }; 159 | } 160 | } 161 | 162 | renderBreakdownPie( 163 | 'balances_chart_container', hidden_assets, data, 164 | 'Accounts', overallTotal => function () { 165 | return `${strAmount(this.y)} (${(100*this.y/overallTotal).toFixed(2)}% of overall portfolio)
`; 166 | }, 167 | 'Breakdown', function () { 168 | return `${strAmount(this.y)} (${(100*this.y/this.ownerTotal).toFixed(2)}% of ${this.owner})
`; 169 | }); 170 | } 171 | 172 | function renderIncomeExpensesTable(raw_data, name, date) { 173 | let startDate = new Date(date.getFullYear(), date.getMonth(), 1); 174 | let priorDate = new Date(date.getFullYear(), date.getMonth(), 0); 175 | let priorStartDate = new Date(priorDate.getFullYear(), priorDate.getMonth(), 1); 176 | 177 | let entries = []; 178 | let sources = Object.keys(raw_data).sort(); 179 | let totalAmount = 0; 180 | let totalDelta = 0; 181 | for (let i = 0; i < sources.length; i ++) { 182 | let source = sources[i]; 183 | let history = raw_data[source]; 184 | let amount = summariseHistory(history, date) - summariseHistory(history, startDate); 185 | let prior = summariseHistory(history, priorDate) - summariseHistory(history, priorStartDate); 186 | let delta = amount - prior; 187 | 188 | if (zeroish(amount)) continue; 189 | 190 | entries.push({ 191 | 'source': source, 192 | 'good': (delta > -0.01) ? false : true, 193 | 'bad': (delta < 0.01) ? false : true, 194 | 'delta': zeroish(delta) ? '' : strAmount(delta, true), 195 | 'amount': strAmount(amount) 196 | }); 197 | totalAmount += amount; 198 | totalDelta += delta; 199 | } 200 | 201 | document.getElementById(`cur_${name}_tbody`).innerHTML = Mustache.render(TPL_SUMMARY_TABLE_BODY, { 202 | entry: entries 203 | }); 204 | document.getElementById(`cur_${name}_tfoot`).innerHTML = Mustache.render(TPL_SUMMARY_TABLE_FOOT, { 205 | good: (totalDelta > -0.01) ? false : true, 206 | bad: (totalDelta < 0.01) ? false : true, 207 | delta: (zeroish(totalDelta)) ? '' : strAmount(totalDelta, true), 208 | amount: strAmount(totalAmount) 209 | }); 210 | if (true) { 211 | document.getElementById(`cur_${name}_prior_date`).innerText = strDate(priorDate); 212 | } 213 | } 214 | 215 | function renderIncome(raw_income_data, date) { 216 | renderIncomeExpensesTable(raw_income_data, 'income', date); 217 | } 218 | 219 | function renderExpenses(raw_expenses_data, date) { 220 | renderIncomeExpensesTable(raw_expenses_data, 'expenses', date); 221 | } 222 | 223 | function renderBudget(raw_data, date) { 224 | let entries = []; 225 | let sources = Object.keys(raw_data).sort(); 226 | let totalAmount = 0; 227 | for (let i = 0; i < sources.length; i ++) { 228 | let source = sources[i]; 229 | let history = raw_data[source]; 230 | let amount = summariseHistory(history, date); 231 | 232 | if (zeroish(amount)) continue; 233 | 234 | entries.push({ 235 | 'source': source, 236 | 'abad': amount < 0, 237 | 'amount': strAmount(amount) 238 | }); 239 | totalAmount += amount; 240 | } 241 | 242 | document.getElementById(`cur_budget_tbody`).innerHTML = Mustache.render(TPL_SUMMARY_TABLE_BODY, { 243 | entry: entries 244 | }); 245 | document.getElementById(`cur_budget_tfoot`).innerHTML = Mustache.render(TPL_SUMMARY_TABLE_FOOT, { 246 | abad: totalAmount < 0, 247 | amount: strAmount(totalAmount) 248 | }); 249 | } 250 | 251 | function renderFinances(data, month) { 252 | let this_year = new Date().getFullYear(); 253 | let date = new Date(this_year, month, daysInMonth(this_year, month)); 254 | 255 | renderAllocationChart(data.assets, data.commodities, date); 256 | renderAssetsSnapshotChart(data.assets, date); 257 | renderIncome(data.income, date); 258 | renderBudget(data.budget, date); 259 | renderExpenses(data.expenses, date); 260 | } 261 | 262 | function renderHistory(data, month) { 263 | let this_year = new Date().getFullYear(); 264 | 265 | renderHistoryFor(getMonthlyTxns(data, this_year, month+1), 'recent'); 266 | } 267 | 268 | window.onload = () => { 269 | // Set up the month picker 270 | let visible_month = undefined; 271 | let cached_account_data = undefined; 272 | let cached_history_data = undefined; 273 | monthpicker(i => { 274 | if (i == visible_month) return; 275 | visible_month = i; 276 | document.getElementById('month-name').innerText = MONTH_NAMES[i]; 277 | 278 | if (cached_account_data == undefined) { 279 | ajax('/data', account_data => { 280 | cached_account_data = account_data; 281 | renderFinances(account_data, i); 282 | }); 283 | } else { 284 | renderFinances(cached_account_data, visible_month); 285 | } 286 | 287 | if (cached_history_data == undefined) { 288 | ajax('/history', history_data => { 289 | cached_history_data = history_data; 290 | renderHistory(history_data, i); 291 | }); 292 | } else { 293 | renderHistory(cached_history_data, visible_month); 294 | } 295 | }); 296 | }; 297 | -------------------------------------------------------------------------------- /backend/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE LambdaCase #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | 4 | module Main (main) where 5 | 6 | import Control.Arrow (second) 7 | import qualified Data.Aeson as A 8 | import Data.Char (toUpper) 9 | import Data.List (inits, isPrefixOf, mapAccumL, 10 | nub, sortOn) 11 | import Data.List.NonEmpty (NonEmpty) 12 | import qualified Data.Map as M 13 | import Data.Maybe (fromMaybe, listToMaybe) 14 | import Data.Ratio (Rational) 15 | import qualified Data.Text as T 16 | import qualified Data.Text.Encoding as T 17 | import qualified Data.Time.Calendar as C 18 | import qualified Data.Yaml as Y 19 | import qualified Hledger.Data.Types as H 20 | import qualified Hledger.Read as H 21 | import qualified Network.HTTP.Types.Status as W 22 | import qualified Network.Wai as W 23 | import qualified Network.Wai.Handler.Warp as W 24 | import qualified Network.Wai.Middleware.Static as W 25 | import System.Directory (getHomeDirectory) 26 | import System.Environment (getArgs) 27 | import System.Exit (exitFailure) 28 | import System.FilePath (FilePath, takeDirectory, ()) 29 | 30 | import qualified Config as FC 31 | import qualified Report as FR 32 | import qualified Template as FT 33 | 34 | main :: IO () 35 | main = do 36 | fp <- fromMaybe "config.yaml" . listToMaybe <$> getArgs 37 | config <- Y.decodeFileEither fp 38 | case config of 39 | Right cfg -> do 40 | let baseDir = takeDirectory fp 41 | staticDir <- canonicalise baseDir (FC.staticDir (FC.http cfg)) 42 | journalPath <- maybe (pure Nothing) (fmap Just . canonicalise baseDir) (FC.journalPath cfg) 43 | let cfg' = cfg { FC.journalPath = journalPath, FC.http = (FC.http cfg) { FC.staticDir = staticDir } } 44 | run cfg' 45 | Left err -> do 46 | putStrLn ("Couldn't parse configuration file " ++ fp ++ ":") 47 | putStrLn (Y.prettyPrintParseException err) 48 | exitFailure 49 | 50 | 51 | ------------------------------------------------------------------------------- 52 | 53 | -- | Run the web server. 54 | run :: FC.Config -> IO () 55 | run cfg = W.runEnv (FC.port . FC.http $ cfg) $ serveStatic (\req respond -> respond =<< serveDynamic req) where 56 | serveStatic = 57 | let staticdir = FC.staticDir (FC.http cfg) 58 | in W.staticPolicy (W.addBase staticdir) 59 | 60 | serveDynamic req = do 61 | journalPath <- maybe H.defaultJournalPath pure (FC.journalPath cfg) 62 | H.readJournalFile Nothing Nothing True journalPath >>= \case 63 | Right journal -> serveDynamic' req journal 64 | Left err -> do 65 | putStrLn err 66 | pure (W.responseLBS W.internalServerError500 [] "cannot read journal file") 67 | 68 | serveDynamic' req journal = case W.pathInfo req of 69 | [] -> serveTemplate <$> FT.summaryPage 70 | ["index.html"] -> serveTemplate <$> FT.summaryPage 71 | ["balancesheet.html"] -> serveTemplate <$> FT.balancesheetPage 72 | ["cashflow.html"] -> serveTemplate <$> FT.cashflowPage 73 | ["history.html"] -> serveTemplate <$> FT.historyPage 74 | 75 | ["data"] -> pure $ serveJSON (A.toJSON . accountsReport cfg $ H.jtxns journal) 76 | ["history"] -> pure $ serveJSON (A.toJSON . historyReport cfg . Left $ H.jtxns journal) 77 | 78 | _ -> pure $ W.responseLBS W.notFound404 [] "not found" 79 | 80 | serveJSON = W.responseLBS W.ok200 [] . A.encode 81 | 82 | serveTemplate (Right html) = W.responseBuilder W.ok200 [] (T.encodeUtf8Builder html) 83 | serveTemplate (Left perr) = W.responseBuilder W.internalServerError500 [] (T.encodeUtf8Builder perr) 84 | 85 | -- | Get the account historical balances. 86 | accountsReport :: FC.Config -> [H.Transaction] -> FR.Report 87 | accountsReport cfg txns = FR.Report 88 | { FR.rpAssets = map accountReport assetAccounts 89 | , FR.rpLiabilities = map accountReport liabilityAccounts 90 | , FR.rpIncome = balanceFrom incomeAccounts 91 | , FR.rpBudget = balanceFrom budgetAccounts 92 | , FR.rpExpenses = balanceFrom expenseAccounts 93 | , FR.rpEquity = balanceFrom equityAccounts 94 | , FR.rpCommodities = map (second FR.CommodityReport) (FC.commodities cfg) 95 | } 96 | where 97 | accounts = sortOn (accountName cfg) $ M.keys (getBalances balances) 98 | assetAccounts = matching False (FC.assetAccounts . FC.tree $ cfg) accounts 99 | budgetAccounts = matching False (FC.budgetAccounts . FC.tree $ cfg) accounts 100 | equityAccounts = matching False (FC.equityAccounts . FC.tree $ cfg) accounts 101 | expenseAccounts = matching False (FC.expenseAccounts . FC.tree $ cfg) accounts 102 | incomeAccounts = matching False (FC.incomeAccounts . FC.tree $ cfg) accounts 103 | liabilityAccounts = matching False (FC.liabilityAccounts . FC.tree $ cfg) accounts 104 | 105 | -- todo: factor this out to a new top-level definition 106 | accountReport account = FR.AccountReport 107 | { FR.arName = accountName cfg account 108 | , FR.arBreakdown = 109 | [ FR.SubaccountReport 110 | { FR.srName = accountName cfg subaccount 111 | , FR.srBalTag = accountCategory cfg subaccount 112 | , FR.srURL = accountURL cfg subaccount 113 | , FR.srHistory = allHistory subaccount 114 | , FR.srCommodities = [ (c, commodityHistory subaccount c) 115 | | c <- commoditiesIn subaccount 116 | ] 117 | } 118 | | let subaccounts = filter (`isDirectSubaccountOf` account) accounts 119 | , subaccount <- if null subaccounts then [account] else subaccounts 120 | ] 121 | } 122 | 123 | -- todo: factor this out to a new top-level definition 124 | balanceFrom accounts = FR.BasicReport 125 | { FR.brAccounts = 126 | [ (accountName cfg account, allHistory account) | account <- accounts ] 127 | } 128 | 129 | commoditiesIn account = M.keys (M.findWithDefault M.empty account (getCommodities balances)) 130 | 131 | allHistory account = 132 | let amountIn = M.findWithDefault 0 account 133 | in FR.HistoryReport { FR.hrValues = [ (dbDay db, amountIn (dbBalances db)) | db <- balancesAsc ] } 134 | 135 | commodityHistory account c = 136 | let amountIn = M.findWithDefault (0, 0) c . M.findWithDefault M.empty account 137 | history = [ (dbDay db, amountIn (dbCommodities db)) | db <- balancesAsc ] 138 | report f = FR.HistoryReport [ (day, f x) | (day, x) <- history ] 139 | in FR.CommodityHistoryReport { FR.chrWorth = report fst 140 | , FR.chrAmount = report snd 141 | } 142 | 143 | getBalances = dbBalances . headOr initial 144 | getCommodities = dbCommodities . headOr initial 145 | 146 | balancesAsc = dailyBalances cfg txns 147 | balances = reverse balancesAsc 148 | initial = DailyBalance epoch M.empty [] 149 | epoch = C.fromGregorian 1970 1 1 150 | 151 | -- | Get the transaction history. 152 | historyReport :: FC.Config -> Either [H.Transaction] [DailyBalance] -> FR.DatedTransactionsReport 153 | historyReport cfg txnbals = FR.DatedTransactionsReport 154 | [ ( dbDay bal 155 | , [ FR.TransactionReport { FR.trTitle = txntitle, FR.trDelta = assetDelta } 156 | | (txntitle, txndeltas) <- reverse (dbTxns bal) 157 | , let assetDelta = M.findWithDefault 0 "assets" txndeltas 158 | ] 159 | ) 160 | | bal <- balances 161 | ] 162 | where 163 | balances = case txnbals of 164 | Right bals -> bals 165 | Left txns -> reverse (dailyBalances cfg txns) 166 | 167 | 168 | ------------------------------------------------------------------------------- 169 | 170 | -- | The events of one day. 171 | data DailyBalance = DailyBalance 172 | { dbDay :: C.Day 173 | , dbCommodities :: M.Map T.Text (M.Map T.Text (Rational, Rational)) 174 | , dbTxns :: [(T.Text, M.Map T.Text Rational)] 175 | } deriving Show 176 | 177 | -- | Get a set of balance deltas from a 'DailyBalance'. 178 | dbBalances :: DailyBalance -> M.Map T.Text Rational 179 | dbBalances db = (sum . map fst . M.elems) <$> dbCommodities db 180 | 181 | -- | Get the final balance after every day, and all the transaction 182 | -- deltas on that day. 183 | dailyBalances :: FC.Config -> [H.Transaction] -> [DailyBalance] 184 | dailyBalances cfg = foldr squish [] . snd . mapAccumL process M.empty . sortOn H.tdate where 185 | process comms txn = 186 | let cdeltas = toDeltas cfg txn 187 | bdeltas = (sum . map fst . M.elems) <$> cdeltas 188 | comms' = M.unionWith (M.unionWith (\(a,b) (c,d) -> (a+c, b+d))) comms cdeltas 189 | in (comms', (H.tdate txn, H.tdescription txn, comms', bdeltas)) 190 | 191 | squish x@(day, desc, _, delta) (db:rest) 192 | | day == dbDay db = db { dbTxns = (desc, delta) : dbTxns db } : rest 193 | | otherwise = makedb x : db : rest 194 | squish x [] = [makedb x] 195 | 196 | makedb (day, desc, comms, delta) = DailyBalance 197 | { dbDay = day 198 | , dbCommodities = comms 199 | , dbTxns = [(desc, delta)] 200 | } 201 | 202 | -- | Produce a collection of commodity-level balance changes from a 203 | -- transaction. 204 | toDeltas :: FC.Config -> H.Transaction -> M.Map T.Text (M.Map T.Text (Rational, Rational)) 205 | toDeltas cfg txn = 206 | let postings = concatMap explodeAccount (H.tpostings txn) 207 | accounts = nub (map H.paccount postings) 208 | val a = fromMaybe (error ("amount which reduced to non-default commodity: " ++ show a)) (value cfg a) 209 | qty = toRational . H.aquantity 210 | in M.fromList [ (a, M.unionsWith (\(a,b) (c,d) -> (a+c, b+d)) cs) 211 | | a <- accounts 212 | , let ps = filter ((==a) . H.paccount) postings 213 | , let cs = [ M.singleton (H.acommodity a) (val a, qty a) 214 | | H.Mixed [a] <- map H.pamount ps 215 | ] 216 | ] 217 | 218 | -- | Convert a posting into a collection of posting to an account and 219 | -- all of its superaccounts. 220 | explodeAccount :: H.Posting -> [H.Posting] 221 | explodeAccount p = 222 | [ p { H.paccount = a } 223 | | a <- tail . map (T.intercalate ":") . inits . T.splitOn ":" $ H.paccount p 224 | ] 225 | 226 | -- | Get the value of an 'H.Amount' as a 'Rational'. 227 | value :: FC.Config -> H.Amount -> Maybe Rational 228 | value cfg = go where 229 | go (H.Amount c q p _) 230 | | c == FC.defcommodity cfg = Just (toRational q) 231 | | otherwise = case p of 232 | H.TotalPrice a -> go a 233 | H.UnitPrice a -> (toRational q *) <$> go a 234 | H.NoPrice -> Nothing 235 | 236 | -- | Get the head of a list, or a default value. 237 | headOr :: a -> [a] -> a 238 | headOr x [] = x 239 | headOr _ (x:_) = x 240 | 241 | -- | Give a nice name to an account, defaults to the bit after the 242 | -- last ":" in titlecase. 243 | accountName :: FC.Config -> T.Text -> T.Text 244 | accountName cfg acc = fromMaybe def $ FC.aName =<< lookup acc (FC.accounts cfg) where 245 | def = 246 | let bits = T.words $ last (T.splitOn ":" acc) 247 | cap w = case T.uncons w of 248 | Just (c, cs) -> T.cons (toUpper c) cs 249 | Nothing -> "" 250 | in T.unwords (map cap bits) 251 | 252 | -- | Get the category of an account, with defaulting. 253 | accountCategory :: FC.Config -> T.Text -> T.Text 254 | accountCategory cfg acc = fromMaybe def $ FC.aCategory =<< lookup acc (FC.accounts cfg) where 255 | def | matches True (FC.assetAccounts tree) acc = "Current" 256 | | matches True (FC.equityAccounts tree) acc = "Start of Period" 257 | | matches True (FC.liabilityAccounts tree) acc = "Current" 258 | | otherwise = "End of Period" 259 | 260 | tree = FC.tree cfg 261 | 262 | -- | Get the URL of an account. 263 | accountURL :: FC.Config -> T.Text -> Maybe T.Text 264 | accountURL cfg acc = FC.aURL =<< lookup acc (FC.accounts cfg) 265 | 266 | -- | Expand relative paths and a "~/" at the start of a path. 267 | canonicalise :: FilePath -> FilePath -> IO FilePath 268 | canonicalise _ ('~':'/':rest) = ( rest) <$> getHomeDirectory 269 | canonicalise basedir (fp@(c:_)) 270 | | c /= '/' = pure (basedir fp) 271 | canonicalise _ fp = pure fp 272 | 273 | -- | Get account names matching any of a collection of patterns. 274 | matching :: Bool -> NonEmpty FC.Pattern -> [T.Text] -> [T.Text] 275 | matching subaccounts = filter . matches subaccounts 276 | 277 | -- | Check if an account name matches any of a collection of patterns. 278 | matches :: Bool -> NonEmpty FC.Pattern -> T.Text -> Bool 279 | matches subaccounts pats acc = or (fmap go pats) where 280 | accBits = T.splitOn ":" acc 281 | 282 | go (FC.Pattern pat) = 283 | let patBits = T.splitOn ":" pat 284 | check p a = p == "*" || p == a 285 | in and (zipWith check patBits accBits) 286 | && (if subaccounts 287 | then length patBits <= length accBits 288 | else length patBits == length accBits) 289 | 290 | -- | Check if an account is a direct subaccount of another. 291 | isDirectSubaccountOf :: T.Text -> T.Text -> Bool 292 | isDirectSubaccountOf sub acc = 293 | let subBits = T.splitOn ":" sub 294 | accBits = T.splitOn ":" acc 295 | in 1 + length accBits == length subBits && accBits `isPrefixOf` subBits 296 | --------------------------------------------------------------------------------