bal: --budget shows budget performance
Budget goals specified with periodic transactions (as with hledger-budget) can now be displayed in balance report (but not in bs/is/cf). --budget shows the target amount and percentage alongside the actual amount, per account and period. Unbudgeted accounts will be hidden, unless --show-unbudgeted is used. Budgeted accounts are displayed folded (depth-clipped) at a depth matching the budget specification. Unbudgeted accounts, if shown, are displayed at their usual depth (in full detail, or according to --depth).
This commit is contained in:
parent
23f3da4e92
commit
6ea5da2d9d
@ -246,8 +246,9 @@ module Hledger.Cli.Commands.Balance (
|
|||||||
,tests_Hledger_Cli_Commands_Balance
|
,tests_Hledger_Cli_Commands_Balance
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Data.List (intercalate)
|
import Data.List (intercalate, nub)
|
||||||
import Data.Maybe
|
import Data.Maybe
|
||||||
|
import qualified Data.Map as Map
|
||||||
-- import Data.Monoid
|
-- import Data.Monoid
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import System.Console.CmdArgs.Explicit as C
|
import System.Console.CmdArgs.Explicit as C
|
||||||
@ -283,6 +284,8 @@ balancemode = (defCommandMode $ ["balance"] ++ aliases) { -- also accept but don
|
|||||||
,flagReq ["format"] (\s opts -> Right $ setopt "format" s opts) "FORMATSTR" "use this custom line format (in simple reports)"
|
,flagReq ["format"] (\s opts -> Right $ setopt "format" s opts) "FORMATSTR" "use this custom line format (in simple reports)"
|
||||||
,flagNone ["pretty-tables"] (\opts -> setboolopt "pretty-tables" opts) "use unicode when displaying tables"
|
,flagNone ["pretty-tables"] (\opts -> setboolopt "pretty-tables" opts) "use unicode when displaying tables"
|
||||||
,flagNone ["sort-amount","S"] (\opts -> setboolopt "sort-amount" opts) "sort by amount instead of account name"
|
,flagNone ["sort-amount","S"] (\opts -> setboolopt "sort-amount" opts) "sort by amount instead of account name"
|
||||||
|
,flagNone ["budget"] (setboolopt "budget") "compute budget from periodic transactions and compare real balances to it"
|
||||||
|
,flagNone ["show-unbudgeted"] (setboolopt "show-unbudgeted") "show full names of accounts not mentioned in budget"
|
||||||
]
|
]
|
||||||
++ outputflags
|
++ outputflags
|
||||||
,groupHidden = []
|
,groupHidden = []
|
||||||
@ -293,7 +296,7 @@ balancemode = (defCommandMode $ ["balance"] ++ aliases) { -- also accept but don
|
|||||||
|
|
||||||
-- | The balance command, prints a balance report.
|
-- | The balance command, prints a balance report.
|
||||||
balance :: CliOpts -> Journal -> IO ()
|
balance :: CliOpts -> Journal -> IO ()
|
||||||
balance opts@CliOpts{reportopts_=ropts} j = do
|
balance opts@CliOpts{rawopts_=rawopts,reportopts_=ropts} j = do
|
||||||
d <- getCurrentDay
|
d <- getCurrentDay
|
||||||
case lineFormatFromOpts ropts of
|
case lineFormatFromOpts ropts of
|
||||||
Left err -> error' $ unlines [err]
|
Left err -> error' $ unlines [err]
|
||||||
@ -319,12 +322,58 @@ balance opts@CliOpts{reportopts_=ropts} j = do
|
|||||||
"csv" -> \ropts r -> (++ "\n") $ printCSV $ balanceReportAsCsv ropts r
|
"csv" -> \ropts r -> (++ "\n") $ printCSV $ balanceReportAsCsv ropts r
|
||||||
_ -> balanceReportAsText
|
_ -> balanceReportAsText
|
||||||
writeOutput opts $ render ropts report
|
writeOutput opts $ render ropts report
|
||||||
_ -> do
|
|
||||||
|
_ | boolopt "budget" rawopts -> do
|
||||||
|
let budget = budgetJournal opts j
|
||||||
|
j' = budgetRollUp opts budget j
|
||||||
|
report = multiBalanceReport ropts (queryFromOpts d ropts) j'
|
||||||
|
budgetReport = multiBalanceReport ropts (queryFromOpts d ropts) budget
|
||||||
|
render = case format of
|
||||||
|
-- XXX: implement csv rendering
|
||||||
|
"csv" -> (++ "\n") . printCSV . multiBalanceReportAsCsv ropts
|
||||||
|
_ -> multiBalanceReportWithBudgetAsText ropts budgetReport
|
||||||
|
writeOutput opts $ render report
|
||||||
|
|
||||||
|
| otherwise -> do
|
||||||
let report = multiBalanceReport ropts (queryFromOpts d ropts) j
|
let report = multiBalanceReport ropts (queryFromOpts d ropts) j
|
||||||
render = case format of
|
render = case format of
|
||||||
"csv" -> \ropts r -> (++ "\n") $ printCSV $ multiBalanceReportAsCsv ropts r
|
"csv" -> (++ "\n") . printCSV . multiBalanceReportAsCsv ropts
|
||||||
_ -> multiBalanceReportAsText
|
_ -> multiBalanceReportAsText ropts
|
||||||
writeOutput opts $ render ropts report
|
writeOutput opts $ render report
|
||||||
|
|
||||||
|
-- | Re-map account names to closet parent with periodic transaction from budget.
|
||||||
|
-- Accounts that dont have suitable parent are either remapped to "<unbudgeted>:topAccount"
|
||||||
|
-- or left as-is if --show-unbudgeted is provided
|
||||||
|
budgetRollUp :: CliOpts -> Journal -> Journal -> Journal
|
||||||
|
budgetRollUp CliOpts{rawopts_=rawopts} budget j = j { jtxns = remapTxn <$> jtxns j }
|
||||||
|
where
|
||||||
|
budgetAccounts = nub $ concatMap (map paccount . ptpostings) $ jperiodictxns budget
|
||||||
|
remapAccount origAcctName = remapAccount' origAcctName
|
||||||
|
where
|
||||||
|
remapAccount' acctName
|
||||||
|
| acctName `elem` budgetAccounts = acctName
|
||||||
|
| otherwise =
|
||||||
|
case parentAccountName acctName of
|
||||||
|
"" | boolopt "show-unbudgeted" rawopts -> origAcctName
|
||||||
|
| otherwise -> T.append (T.pack "<unbudgeted>:") acctName
|
||||||
|
parent -> remapAccount' parent
|
||||||
|
remapPosting p = p { paccount = remapAccount $ paccount p, porigin = Just . fromMaybe p $ porigin p }
|
||||||
|
remapTxn = mapPostings (map remapPosting)
|
||||||
|
mapPostings f t = txnTieKnot $ t { tpostings = f $ tpostings t }
|
||||||
|
|
||||||
|
-- | Generate journal of all periodic transactions in the given journal for the
|
||||||
|
-- entireity of its history or reporting period, whatever is smaller
|
||||||
|
budgetJournal :: CliOpts -> Journal -> Journal
|
||||||
|
budgetJournal opts j = journalBalanceTransactions' opts j { jtxns = budget }
|
||||||
|
where
|
||||||
|
dates = spanIntersect (jdatespan j) (periodAsDateSpan $ period_ $ reportopts_ opts)
|
||||||
|
budget = [makeBudget t | pt <- jperiodictxns j, t <- runPeriodicTransaction pt dates]
|
||||||
|
makeBudget t = txnTieKnot $ t { tdescription = T.pack "Budget transaction" }
|
||||||
|
journalBalanceTransactions' opts j =
|
||||||
|
let assrt = not . ignore_assertions_ $ inputopts_ opts
|
||||||
|
in
|
||||||
|
either error' id $ journalBalanceTransactions assrt j
|
||||||
|
|
||||||
|
|
||||||
-- single-column balance reports
|
-- single-column balance reports
|
||||||
|
|
||||||
@ -494,16 +543,66 @@ multiBalanceReportAsText opts r =
|
|||||||
CumulativeChange -> "Ending balances (cumulative)"
|
CumulativeChange -> "Ending balances (cumulative)"
|
||||||
HistoricalBalance -> "Ending balances (historical)"
|
HistoricalBalance -> "Ending balances (historical)"
|
||||||
|
|
||||||
|
-- | Render two multi-column balance reports as plain text suitable for console output.
|
||||||
|
-- They are assumed to have same number of columns, one of them representing
|
||||||
|
-- a budget
|
||||||
|
multiBalanceReportWithBudgetAsText :: ReportOpts -> MultiBalanceReport -> MultiBalanceReport -> String
|
||||||
|
multiBalanceReportWithBudgetAsText opts budget r =
|
||||||
|
printf "%s in %s:\n\n" typeStr (showDateSpan $ multiBalanceReportSpan r)
|
||||||
|
++ renderBalanceReportTable' opts showcell tabl
|
||||||
|
where
|
||||||
|
tabl = combine (balanceReportAsTable opts r) (balanceReportAsTable opts budget)
|
||||||
|
typeStr :: String
|
||||||
|
typeStr = case balancetype_ opts of
|
||||||
|
PeriodChange -> "Balance changes"
|
||||||
|
CumulativeChange -> "Ending balances (cumulative)"
|
||||||
|
HistoricalBalance -> "Ending balances (historical)"
|
||||||
|
showcell (real, Nothing) = showamt real
|
||||||
|
showcell (real, Just budget) =
|
||||||
|
printf "%s [%s]" (showamt real) (showamt budget)
|
||||||
|
showamt | color_ opts = cshowMixedAmountOneLineWithoutPrice
|
||||||
|
| otherwise = showMixedAmountOneLineWithoutPrice
|
||||||
|
-- combine reportTable budgetTable will combine them into a single table where cells
|
||||||
|
-- are tuples of (actual, Maybe budget) numbers. Main assumptions is that
|
||||||
|
-- row/column titles of budgetTable are subset of row/column titles or reportTable,
|
||||||
|
-- and there are now row/column titles in budgetTable that are not mentioned in reporTable.
|
||||||
|
-- Both of these are satisfied by construction of budget report and process of rolling up
|
||||||
|
-- account names.
|
||||||
|
combine (Table l t d) (Table l' t' d') = Table l t combinedRows
|
||||||
|
where
|
||||||
|
-- For all accounts that are present in the budget, zip real amounts with budget amounts
|
||||||
|
combinedRows = [ combineRow row budgetRow
|
||||||
|
| (acct, row) <- zip (headerContents l) d
|
||||||
|
, let budgetRow =
|
||||||
|
if acct == "" then [] -- "" is totals row
|
||||||
|
else fromMaybe [] $ Map.lookup acct budgetAccts
|
||||||
|
]
|
||||||
|
-- Budget could cover smaller interval of time than the whole journal.
|
||||||
|
-- Headers for budget row will always be a sublist of headers of row
|
||||||
|
combineRow r br =
|
||||||
|
let reportRow = zip (headerContents t) r
|
||||||
|
budgetRow = Map.fromList $ zip (headerContents t') br
|
||||||
|
findBudgetVal hdr = Map.lookup hdr budgetRow
|
||||||
|
in map (\(hdr, val) -> (val, findBudgetVal hdr)) reportRow
|
||||||
|
budgetAccts = Map.fromList $ zip (headerContents l') d'
|
||||||
|
|
||||||
-- | Given a table representing a multi-column balance report (for example,
|
-- | Given a table representing a multi-column balance report (for example,
|
||||||
-- made using 'balanceReportAsTable'), render it in a format suitable for
|
-- made using 'balanceReportAsTable'), render it in a format suitable for
|
||||||
-- console output.
|
-- console output.
|
||||||
renderBalanceReportTable :: ReportOpts -> Table String String MixedAmount -> String
|
renderBalanceReportTable :: ReportOpts -> Table String String MixedAmount -> String
|
||||||
renderBalanceReportTable (ReportOpts { pretty_tables_ = pretty, color_=usecolor }) =
|
renderBalanceReportTable ropts =
|
||||||
|
renderBalanceReportTable' ropts showamt
|
||||||
|
where
|
||||||
|
showamt | color_ ropts = cshowMixedAmountOneLineWithoutPrice
|
||||||
|
| otherwise = showMixedAmountOneLineWithoutPrice
|
||||||
|
|
||||||
|
renderBalanceReportTable' :: ReportOpts -> (a -> String) -> Table String String a -> String
|
||||||
|
renderBalanceReportTable' (ReportOpts { pretty_tables_ = pretty}) showCell =
|
||||||
unlines
|
unlines
|
||||||
. addtrailingblank
|
. addtrailingblank
|
||||||
. trimborder
|
. trimborder
|
||||||
. lines
|
. lines
|
||||||
. render pretty id id showamt
|
. render pretty id id showCell
|
||||||
. align
|
. align
|
||||||
where
|
where
|
||||||
addtrailingblank = (++[""])
|
addtrailingblank = (++[""])
|
||||||
@ -512,8 +611,6 @@ renderBalanceReportTable (ReportOpts { pretty_tables_ = pretty, color_=usecolor
|
|||||||
where
|
where
|
||||||
acctswidth = maximum' $ map strWidth (headerContents l)
|
acctswidth = maximum' $ map strWidth (headerContents l)
|
||||||
l' = padRightWide acctswidth <$> l
|
l' = padRightWide acctswidth <$> l
|
||||||
showamt | usecolor = cshowMixedAmountOneLineWithoutPrice
|
|
||||||
| otherwise = showMixedAmountOneLineWithoutPrice
|
|
||||||
|
|
||||||
-- | Build a 'Table' from a multi-column balance report.
|
-- | Build a 'Table' from a multi-column balance report.
|
||||||
balanceReportAsTable :: ReportOpts -> MultiBalanceReport -> Table String String MixedAmount
|
balanceReportAsTable :: ReportOpts -> MultiBalanceReport -> Table String String MixedAmount
|
||||||
|
|||||||
92
tests/budget/budget.test
Normal file
92
tests/budget/budget.test
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
# Test --budget switch
|
||||||
|
hledger bal -D -b 2016-12-01 -e 2016-12-04 -f - --budget
|
||||||
|
<<<
|
||||||
|
2016/12/01
|
||||||
|
expenses:food $10
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
2016/12/02
|
||||||
|
expenses:food $9
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
2016/12/03
|
||||||
|
expenses:food $11
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
2016/12/02
|
||||||
|
expenses:leisure $5
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
2016/12/03
|
||||||
|
expenses:movies $25
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
2016/12/03
|
||||||
|
expenses:cab $15
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
~ daily from 2016/1/1
|
||||||
|
expenses:food $10
|
||||||
|
expenses:leisure $15
|
||||||
|
assets:cash
|
||||||
|
>>>
|
||||||
|
Balance changes in 2016/12/01-2016/12/03:
|
||||||
|
|
||||||
|
|| 2016/12/01 2016/12/02 2016/12/03
|
||||||
|
=======================++=======================================
|
||||||
|
<unbudgeted>:expenses || 0 0 $40
|
||||||
|
assets:cash || $-10 [$-25] $-14 [$-25] $-51 [$-25]
|
||||||
|
expenses:food || $10 [$10] $9 [$10] $11 [$10]
|
||||||
|
expenses:leisure || 0 [$15] $5 [$15] 0 [$15]
|
||||||
|
-----------------------++---------------------------------------
|
||||||
|
|| 0 0 0
|
||||||
|
|
||||||
|
>>>2
|
||||||
|
>>>=0
|
||||||
|
|
||||||
|
# --show-unbudgeted
|
||||||
|
hledger bal -D -b 2016-12-01 -e 2016-12-04 -f - --budget --show-unbudgeted
|
||||||
|
<<<
|
||||||
|
2016/12/01
|
||||||
|
expenses:food $10
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
2016/12/02
|
||||||
|
expenses:food $9
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
2016/12/03
|
||||||
|
expenses:food $11
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
2016/12/02
|
||||||
|
expenses:leisure $5
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
2016/12/03
|
||||||
|
expenses:movies $25
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
2016/12/03
|
||||||
|
expenses:cab $15
|
||||||
|
assets:cash
|
||||||
|
|
||||||
|
~ daily from 2016/1/1
|
||||||
|
expenses:food $10
|
||||||
|
expenses:leisure $15
|
||||||
|
assets:cash
|
||||||
|
>>>
|
||||||
|
Balance changes in 2016/12/01-2016/12/03:
|
||||||
|
|
||||||
|
|| 2016/12/01 2016/12/02 2016/12/03
|
||||||
|
==================++=======================================
|
||||||
|
assets:cash || $-10 [$-25] $-14 [$-25] $-51 [$-25]
|
||||||
|
expenses:cab || 0 0 $15
|
||||||
|
expenses:food || $10 [$10] $9 [$10] $11 [$10]
|
||||||
|
expenses:leisure || 0 [$15] $5 [$15] 0 [$15]
|
||||||
|
expenses:movies || 0 0 $25
|
||||||
|
------------------++---------------------------------------
|
||||||
|
|| 0 0 0
|
||||||
|
|
||||||
|
>>>2
|
||||||
|
>>>=0
|
||||||
Loading…
Reference in New Issue
Block a user