├── .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 |
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 | {{ months[i - 1] }}
25 |
26 |
27 |
28 |
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 |
22 | {% endblock %}
23 |
24 | {%- block content %}
25 |
29 |
30 |
31 |
Cashflow
32 |
33 |
34 |
35 |
36 |
37 |
38 | Envelope Budget
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | Income
49 |
50 |
51 |
52 |
53 |
Deltas are compared to .
54 |
55 |
56 |
57 |
58 |
59 | Expenses
60 |
61 |
62 |
63 |
64 |
Deltas are compared to .
65 |
66 |
67 |
68 |
69 |
Transactions
70 |
71 |
72 |
73 |
74 |
75 |
76 | Recent Transactions
77 |
78 |
79 |
80 |
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 |
21 | {% endblock %}
22 |
23 | {%- block content %}
24 |
25 |
26 |
27 |
28 | Assets
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | Equity
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | Liabilities
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | Income
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | Expenses
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | Total
81 |
82 |
83 |
84 |
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 | 
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 |
28 |
29 | {% for entry in navlinks %}
30 |
35 |
36 |
37 | {% endfor %}
38 |
39 |
40 |
41 |
42 |
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} ',
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 |
--------------------------------------------------------------------------------