hledger/BalanceCommand.hs

376 lines
13 KiB
Haskell

{-|
A ledger-compatible @balance@ command.
ledger's balance command is easy to use but hard to describe precisely.
Here are some attempts.
I. high level description with examples
---------------------------------------
We'll use sample.ledger, which has the following account tree:
@
assets
bank
checking
saving
cash
expenses
food
supplies
income
gifts
salary
liabilities
debts
@
The balance command shows accounts with their aggregate balances.
Subaccounts are displayed with more indentation. Each balance is the sum
of any transactions in that account plus any balances from subaccounts:
@
$ hledger -f sample.ledger balance
$-1 assets
$1 bank:saving
$-2 cash
$2 expenses
$1 food
$1 supplies
$-2 income
$-1 gifts
$-1 salary
$1 liabilities:debts
@
Usually, the non-interesting accounts are elided or omitted. To be
precise, an interesting account is one with: a non-zero balance, or a
balance different from its single subaccount, or two or more interesting
subaccounts. (More subtleties to be filled in here.)
So, above, @checking@ is omitted because it has no interesting subaccounts
and a zero balance. @bank@ is elided because it has only a single
interesting subaccount (saving) and it would be showing the same balance
($1). Ditto for @liabilities@.
With one or more account pattern arguments, the balance command shows
accounts whose name matches one of the patterns, plus their parents
(elided) and subaccounts. So with the pattern o we get:
@
$ hledger -f sample.ledger balance o
$1 expenses:food
$-2 income
$-1 gifts
$-1 salary
--------------------
$-1
@
The o pattern matched @food@ and @income@, so they are shown. Unmatched
parents of matched accounts are also shown (elided) for context (@expenses@).
Also, the balance report shows the total of all displayed accounts, when
that is non-zero. Here, it is displayed because the accounts shown add up
to $-1.
II. Some notes for the implementation
-------------------------------------
- a simple balance report shows top-level accounts
- with an account pattern, it shows accounts whose leafname matches, plus their parents
- with the subtotal option, it also shows all subaccounts of the above
- zero-balance leaf accounts are removed
- the resulting account tree is displayed with each account's aggregated
balance, with boring parents prefixed to the next line
- a boring parent has the same balance as its child and is not explicitly
matched by the display options.
- the sum of the balances shown is displayed at the end, if it is non-zero
III. John's description 2009/02
-------------------------------
johnw: \"Since you've been re-implementing the balance report in Haskell, I thought
I'd share with you in pseudocode how I rewrote it for Ledger 3.0, since
the old method never stopped with the bugs. The new scheme uses a 5 stage
algorithm, with each stage gathering information for the next:
STEP 1
Based on the user's query, walk through all the transactions in their
journal, finding which ones to include in the account subtotals. For each
transaction that matches, mark the account as VISITED.
STEP 2
Recursively walk the accounts tree, depth-first, computing aggregate
totals and aggregate \"counts\" (number of transactions contributing to the
aggregate total).
STEP 3
Walk the account tree again, breadth-first, and for every VISITED account,
check whether it matches the user's \"display predicate\". If so, mark the
account as MATCHING.
STEP 4
Do an in-order traversal of the account tree. Except for the top-most
account (which serves strictly as a container for the other accounts):
a. If the account was MATCHING, or two or more of its children are
MATCHING or had descendents who were MATCHING, display the account.
b. Otherwise, if the account had *any* children or descendants who
were VISITED and *no* children or descendants who were MATCHING,
then apply the display predicate from STEP 3 to the account. If
it matches, also print this account. (This step allows -E to
report empty accounts which otherwise did match the original
query).
STEP 5
When printing an account, display a \"depth spacer\" followed by the \"partial name\".
tal
The partial name is found by taking the base account's name, then
prepending to it every non-MATCHING parent until a MATCHING parent is
found.
The depth spacer is found by outputting two spaces for every MATCHING parent.
This way, \"Assets:Bank:Checking\" might be reported as:
Assets
Bank
Checking
or
Assets
Bank:Checking
or
Assets:Bank:Checking
Depending on whether the parents were or were not reported for their own reasons.
\"
\"I just had to add one more set of tree traversals, to correctly determine
whether a final balance should be displayed
without --flat, sort at each level in the hierarchy
with --flat, sort across all accounts\"
IV. A functional description
-----------------------------
1. filter the transactions, keeping only those included in the calculation.
Remember the subset of accounts involved. (VISITED)
2. generate a full account & balance tree from all transactions
3. Remember the subset of VISITED accounts which are matched for display.
(MATCHING)
4. walk through the account tree:
a. If the account is in MATCHING, or two or more of its children are or
have descendants who are, display it.
b. Otherwise, if the account has any children or descendants in VISITED
but none in MATCHING, and it is matched for display, display it.
(allows -E to report empty accounts which otherwise did match the
original query).
5. when printing an account, display a \"depth spacer\" followed by the
\"partial name\". The partial name is found by taking the base account's
name, then prepending to it every non-MATCHING parent until a MATCHING
parent is found. The depth spacer is two spaces per MATCHING parent.
6. I just had to add one more set of tree traversals, to correctly
determine whether a final balance should be displayed
7. without --flat, sort at each level in the hierarchy
with --flat, sort across all accounts
V. Another functional description with new terminology
------------------------------------------------------
- included transactions are those included in the calculation, specified
by -b, -e, -p, -C, -R, account patterns and description patterns.
- included accounts are the accounts referenced by included transactions.
- matched transactions are the included transactions which match the
display predicate, specified by -d.
- matched accounts are the included accounts which match the display
predicate, specified by -d, --depth, -E, -s
- an account name tree is the full hierarchy of account names implied by a
set of transactions
- an account tree is an account name tree augmented with the aggregate
balances and transaction counts for each named account
- the included account tree is the account tree for the included transactions
- a matched account tree contains one or more matched accounts
- to generate the balance report, walk through the included account tree
and display each account if
- it is matching
- or it has two more more matching subtrees
- or it has included offspring but no matching offspring
- to display an account, display an indent then the \"partial name\". The
partial name is the account's name, prefixed by each unmatched parent
until a matched parent is found. The indent is two spaces per matched
parent.
VI. John's description 2009/03/11
---------------------------------
johnw: \"Well, I had to rewrite the balance reporting code yet again,
because it wouldn't work with --depth correctly. Here's the new algorithm.
STEP 1: Walk all postings, looking for those that match the user's query.
As a match is found, mark its account VISITED.
STEP 2: Do a traversal of all accounts, sorting as need be, and collect
them all into an ordered list.
STEP 3: Keeping that list on the side, do a *depth-first* traversal of
the account tree.
visited = 0
to_display = 0
(visited, to_display) += <recurse for all immediate children>
if account is VISITED or (no --flat and visited > 0):
if account matches display predicate and
(--flat or to_display != 1):
mark account as TO_DISPLAY
to_display = 1
visited = 1
return (visited, to_display)
STEP 4: top_displayed = 0
for every account in the ordered list:
if account has been marked TO_DISPLAY:
mark account as DISPLAYED
format the account and print
if --flat and account is DISPLAYED:
top_displayed += 1
if no --flat:
for every top-most account:
if account is DISPLAYED or any children or DISPLAYED:
top_displayed += 1
if no --no-total and top_displayed > 1 and
top-most accounts sum to a non-zero balance:
output separator
output balance sum account as DISPLAYED
format the account and print
if --flat and account is DISPLAYED:
top_displayed += 1
if no --flat:
for every top-most account:
if account is DISPLAYED or any children or DISPLAYED:
top_displayed += 1
if no --no-total and top_displayed > 1 and
top-most accounts sum to a non-zero balance:
output separator
output balance sum
\"
-}
module BalanceCommand
where
import Ledger.Utils
import Ledger.Types
import Ledger.Amount
import Ledger.AccountName
import Ledger.Transaction
import Ledger.Ledger
import Ledger.Parse
import Options
import Utils
-- | Print a balance report.
balance :: [Opt] -> [String] -> Ledger -> IO ()
balance opts args l = putStr $ showBalanceReport opts args l
-- | Generate a balance report with the specified options for this ledger.
showBalanceReport :: [Opt] -> [String] -> Ledger -> String
showBalanceReport opts args l = acctsstr ++ totalstr
where
acctsstr = unlines $ map showacct interestingaccts
where
showacct = showInterestingAccount l interestingaccts
interestingaccts = filter (isInteresting opts l) acctnames
acctnames = sort $ tail $ flatten $ treemap aname accttree
accttree = ledgerAccountTree (depthFromOpts opts) l
totalstr | NoTotal `elem` opts = ""
| not (Empty `elem` opts) && isZeroMixedAmount total = ""
| otherwise = printf "--------------------\n%s\n" $ padleft 20 $ showMixedAmount total
where
total = sum $ map abalance $ topAccounts l
-- | Display one line of the balance report with appropriate indenting and eliding.
showInterestingAccount :: Ledger -> [AccountName] -> AccountName -> String
showInterestingAccount l interestingaccts a = concatTopPadded [amt, " ", depthspacer ++ partialname]
where
amt = padleft 20 $ showMixedAmount $ abalance $ ledgerAccount l a
-- the depth spacer (indent) is two spaces for each interesting parent
parents = parentAccountNames a
interestingparents = filter (`elem` interestingaccts) parents
depthspacer = replicate (2 * length interestingparents) ' '
-- the partial name is the account's leaf name, prefixed by the
-- names of any boring parents immediately above
partialname = accountNameFromComponents $ (reverse $ map accountLeafName ps) ++ [accountLeafName a]
where ps = takeWhile boring parents where boring = not . (`elem` interestingparents)
-- | Is the named account considered interesting for this ledger's balance report ?
isInteresting :: [Opt] -> Ledger -> AccountName -> Bool
isInteresting opts l a
| numinterestingsubs==1 && not atmaxdepth = notlikesub
| otherwise = notzero || emptyflag
where
atmaxdepth = accountNameLevel a == depthFromOpts opts
emptyflag = Empty `elem` opts
acct = ledgerAccount l a
notzero = not $ isZeroMixedAmount inclbalance where inclbalance = abalance acct
notlikesub = not $ isZeroMixedAmount exclbalance where exclbalance = sumTransactions $ atransactions acct
numinterestingsubs = length $ filter isInterestingTree subtrees
where
isInterestingTree t = treeany (isInteresting opts l . aname) t
subtrees = map (fromJust . ledgerAccountTreeAt l) $ subAccounts l $ ledgerAccount l a