{-| Options common to most hledger reports. -} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeOperators #-} module Hledger.Reports.ReportOptions ( ReportOpts(..), HasReportOptsNoUpdate(..), HasReportOpts(..), ReportSpec(..), HasReportSpec(..), overEither, setEither, BalanceCalculation(..), BalanceAccumulation(..), AccountListMode(..), ValuationType(..), Layout(..), defreportopts, rawOptsToReportOpts, defreportspec, setDefaultConversionOp, reportOptsToSpec, updateReportSpec, updateReportSpecWith, rawOptsToReportSpec, balanceAccumulationOverride, flat_, tree_, reportOptsToggleStatus, simplifyStatuses, whichDate, journalValueAndFilterPostings, journalValueAndFilterPostingsWith, journalApplyValuationFromOpts, journalApplyValuationFromOptsWith, mixedAmountApplyValuationAfterSumFromOptsWith, valuationAfterSum, intervalFromRawOpts, queryFromFlags, transactionDateFn, postingDateFn, reportSpan, reportSpanBothDates, reportStartDate, reportEndDate, reportPeriodStart, reportPeriodOrJournalStart, reportPeriodLastDay, reportPeriodOrJournalLastDay, reportPeriodName ) where import Control.Applicative (Const(..), (<|>), liftA2) import Control.Monad ((<=<), guard, join) import Data.Char (toLower) import Data.Either (fromRight) import Data.Either.Extra (eitherToMaybe) import Data.Functor.Identity (Identity(..)) import Data.List.Extra (find, isPrefixOf, nubSort) import Data.Maybe (fromMaybe, isJust) import qualified Data.Text as T import Data.Time.Calendar (Day, addDays) import Data.Default (Default(..)) import Safe (headMay, lastDef, lastMay, maximumMay, readMay) import Text.Megaparsec.Custom import Hledger.Data import Hledger.Query import Hledger.Utils -- | What to calculate for each cell in a balance report. -- "Balance report types -> Calculation type" in the hledger manual. data BalanceCalculation = CalcChange -- ^ Sum of posting amounts in the period. | CalcBudget -- ^ Sum of posting amounts and the goal for the period. | CalcValueChange -- ^ Change from previous period's historical end value to this period's historical end value. | CalcGain -- ^ Change from previous period's gain, i.e. valuation minus cost basis. deriving (Eq, Show) instance Default BalanceCalculation where def = CalcChange -- | How to accumulate calculated values across periods (columns) in a balance report. -- "Balance report types -> Accumulation type" in the hledger manual. data BalanceAccumulation = PerPeriod -- ^ No accumulation. Eg, shows the change of balance in each period. | Cumulative -- ^ Accumulate changes across periods, starting from zero at report start. | Historical -- ^ Accumulate changes across periods, including any from before report start. -- Eg, shows the historical end balance of each period. deriving (Eq,Show) instance Default BalanceAccumulation where def = PerPeriod -- | Should accounts be displayed: in the command's default style, hierarchically, or as a flat list ? data AccountListMode = ALFlat | ALTree deriving (Eq, Show) instance Default AccountListMode where def = ALFlat data Layout = LayoutWide (Maybe Int) | LayoutTall | LayoutBare | LayoutTidy deriving (Eq, Show) -- | Standard options for customising report filtering and output. -- Most of these correspond to standard hledger command-line options -- or query arguments, but not all. Some are used only by certain -- commands, as noted below. data ReportOpts = ReportOpts { -- for most reports: period_ :: Period ,interval_ :: Interval ,statuses_ :: [Status] -- ^ Zero, one, or two statuses to be matched ,conversionop_ :: Maybe ConversionOp -- ^ Which operation should we apply to conversion transactions? ,value_ :: Maybe ValuationType -- ^ What value should amounts be converted to ? ,infer_prices_ :: Bool -- ^ Infer market prices from transactions ? ,depth_ :: Maybe Int ,date2_ :: Bool ,empty_ :: Bool ,no_elide_ :: Bool ,real_ :: Bool ,format_ :: StringFormat ,pretty_ :: Bool ,querystring_ :: [T.Text] -- ,average_ :: Bool -- for posting reports (register) ,related_ :: Bool -- for account transactions reports (aregister) ,txn_dates_ :: Bool -- for balance reports (bal, bs, cf, is) ,balancecalc_ :: BalanceCalculation -- ^ What to calculate in balance report cells ,balanceaccum_ :: BalanceAccumulation -- ^ How to accumulate balance report values over time ,budgetpat_ :: Maybe T.Text -- ^ A case-insensitive description substring -- to select periodic transactions for budget reports. -- (Not a regexp, nor a full hledger query, for now.) ,accountlistmode_ :: AccountListMode ,drop_ :: Int ,declared_ :: Bool -- ^ Include accounts declared but not yet posted to ? ,row_total_ :: Bool ,no_total_ :: Bool ,show_costs_ :: Bool -- ^ Show costs for reports which normally don't show them ? ,sort_amount_ :: Bool ,percent_ :: Bool ,invert_ :: Bool -- ^ Flip all amount signs in reports ? ,normalbalance_ :: Maybe NormalSign -- ^ This can be set when running balance reports on a set of accounts -- with the same normal balance type (eg all assets, or all incomes). -- - It helps --sort-amount know how to sort negative numbers -- (eg in the income section of an income statement) -- - It helps compound balance report commands (is, bs etc.) do -- sign normalisation, converting normally negative subreports to -- normally positive for a more conventional display. ,color_ :: Bool -- ^ Whether to use ANSI color codes in text output. -- Influenced by the --color/colour flag (cf CliOptions), -- whether stdout is an interactive terminal, and the value of -- TERM and existence of NO_COLOR environment variables. ,transpose_ :: Bool ,layout_ :: Layout } deriving (Show) instance Default ReportOpts where def = defreportopts defreportopts :: ReportOpts defreportopts = ReportOpts { period_ = PeriodAll , interval_ = NoInterval , statuses_ = [] , conversionop_ = Nothing , value_ = Nothing , infer_prices_ = False , depth_ = Nothing , date2_ = False , empty_ = False , no_elide_ = False , real_ = False , format_ = def , pretty_ = False , querystring_ = [] , average_ = False , related_ = False , txn_dates_ = False , balancecalc_ = def , balanceaccum_ = def , budgetpat_ = Nothing , accountlistmode_ = ALFlat , drop_ = 0 , declared_ = False , row_total_ = False , no_total_ = False , show_costs_ = False , sort_amount_ = False , percent_ = False , invert_ = False , normalbalance_ = Nothing , color_ = False , transpose_ = False , layout_ = LayoutWide Nothing } -- | Generate a ReportOpts from raw command-line input, given a day. -- This will fail with a usage error if it is passed -- - an invalid --format argument, -- - an invalid --value argument, -- - if --valuechange is called with a valuation type other than -V/--value=end. -- - an invalid --pretty argument, rawOptsToReportOpts :: Day -> RawOpts -> ReportOpts rawOptsToReportOpts d rawopts = let formatstring = T.pack <$> maybestringopt "format" rawopts querystring = map T.pack $ listofstringopt "args" rawopts -- doesn't handle an arg like "" right pretty = fromMaybe False $ alwaysneveropt "pretty" rawopts format = case parseStringFormat <$> formatstring of Nothing -> defaultBalanceLineFormat Just (Right x) -> x Just (Left err) -> usageError $ "could not parse format option: " ++ err in defreportopts {period_ = periodFromRawOpts d rawopts ,interval_ = intervalFromRawOpts rawopts ,statuses_ = statusesFromRawOpts rawopts ,conversionop_ = conversionOpFromRawOpts rawopts ,value_ = valuationTypeFromRawOpts rawopts ,infer_prices_ = boolopt "infer-market-prices" rawopts ,depth_ = maybeposintopt "depth" rawopts ,date2_ = boolopt "date2" rawopts ,empty_ = boolopt "empty" rawopts ,no_elide_ = boolopt "no-elide" rawopts ,real_ = boolopt "real" rawopts ,format_ = format ,querystring_ = querystring ,average_ = boolopt "average" rawopts ,related_ = boolopt "related" rawopts ,txn_dates_ = boolopt "txn-dates" rawopts ,balancecalc_ = balancecalcopt rawopts ,balanceaccum_ = balanceaccumopt rawopts ,budgetpat_ = maybebudgetpatternopt rawopts ,accountlistmode_ = accountlistmodeopt rawopts ,drop_ = posintopt "drop" rawopts ,declared_ = boolopt "declared" rawopts ,row_total_ = boolopt "row-total" rawopts ,no_total_ = boolopt "no-total" rawopts ,show_costs_ = boolopt "show-costs" rawopts ,sort_amount_ = boolopt "sort-amount" rawopts ,percent_ = boolopt "percent" rawopts ,invert_ = boolopt "invert" rawopts ,pretty_ = pretty ,color_ = useColorOnStdout -- a lower-level helper ,transpose_ = boolopt "transpose" rawopts ,layout_ = layoutopt rawopts } -- | A fully-determined set of report parameters -- (report options with all partial values made total, eg the begin and end -- dates are known, avoiding date/regex errors; plus the reporting date), -- and the query successfully calculated from them. -- -- If you change the report options or date in one of these, you should -- use `reportOptsToSpec` to regenerate the whole thing, avoiding inconsistency. -- data ReportSpec = ReportSpec { _rsReportOpts :: ReportOpts -- ^ The underlying ReportOpts used to generate this ReportSpec , _rsDay :: Day -- ^ The Day this ReportSpec is generated for , _rsQuery :: Query -- ^ The generated Query for the given day , _rsQueryOpts :: [QueryOpt] -- ^ A list of QueryOpts for the given day } deriving (Show) instance Default ReportSpec where def = defreportspec defreportspec :: ReportSpec defreportspec = ReportSpec { _rsReportOpts = def , _rsDay = nulldate , _rsQuery = Any , _rsQueryOpts = [] } -- | Set the default ConversionOp. setDefaultConversionOp :: ConversionOp -> ReportSpec -> ReportSpec setDefaultConversionOp defop rspec@ReportSpec{_rsReportOpts=ropts} = rspec{_rsReportOpts=ropts{conversionop_=conversionop_ ropts <|> Just defop}} accountlistmodeopt :: RawOpts -> AccountListMode accountlistmodeopt = fromMaybe ALFlat . choiceopt parse where parse = \case "tree" -> Just ALTree "flat" -> Just ALFlat _ -> Nothing -- Get the argument of the --budget option if any, or the empty string. maybebudgetpatternopt :: RawOpts -> Maybe T.Text maybebudgetpatternopt = fmap T.pack . maybestringopt "budget" balancecalcopt :: RawOpts -> BalanceCalculation balancecalcopt = fromMaybe CalcChange . choiceopt parse where parse = \case "sum" -> Just CalcChange "valuechange" -> Just CalcValueChange "gain" -> Just CalcGain "budget" -> Just CalcBudget _ -> Nothing balanceaccumopt :: RawOpts -> BalanceAccumulation balanceaccumopt = fromMaybe PerPeriod . balanceAccumulationOverride alwaysneveropt :: String -> RawOpts -> Maybe Bool alwaysneveropt opt rawopts = case maybestringopt opt rawopts of Just "always" -> Just True Just "yes" -> Just True Just "y" -> Just True Just "never" -> Just False Just "no" -> Just False Just "n" -> Just False Just _ -> usageError "--pretty's argument should be \"yes\" or \"no\" (or y, n, always, never)" _ -> Nothing balanceAccumulationOverride :: RawOpts -> Maybe BalanceAccumulation balanceAccumulationOverride rawopts = choiceopt parse rawopts <|> reportbal where parse = \case "historical" -> Just Historical "cumulative" -> Just Cumulative "change" -> Just PerPeriod _ -> Nothing reportbal = case balancecalcopt rawopts of CalcValueChange -> Just PerPeriod _ -> Nothing layoutopt :: RawOpts -> Layout layoutopt rawopts = fromMaybe (LayoutWide Nothing) $ layout <|> column where layout = parse <$> maybestringopt "layout" rawopts column = LayoutBare <$ guard (boolopt "commodity-column" rawopts) parse opt = maybe err snd $ guard (not $ null s) *> find (isPrefixOf s . fst) checkNames where checkNames = [ ("wide", LayoutWide w) , ("tall", LayoutTall) , ("bare", LayoutBare) , ("tidy", LayoutTidy) ] -- For `--layout=elided,n`, elide to the given width (s,n) = break (==',') $ map toLower opt w = case drop 1 n of "" -> Nothing c | Just w' <- readMay c -> Just w' _ -> usageError "width in --layout=wide,WIDTH must be an integer" err = usageError "--layout's argument should be \"wide[,WIDTH]\", \"tall\", \"bare\", or \"tidy\"" -- Get the period specified by any -b/--begin, -e/--end and/or -p/--period -- options appearing in the command line. -- Its bounds are the rightmost begin date specified by a -b or -p, and -- the rightmost end date specified by a -e or -p. Cf #1011. -- Today's date is provided to help interpret any relative dates. periodFromRawOpts :: Day -> RawOpts -> Period periodFromRawOpts d rawopts = case (mlastb, mlaste) of (Nothing, Nothing) -> PeriodAll (Just b, Nothing) -> PeriodFrom b (Nothing, Just e) -> PeriodTo e (Just b, Just e) -> simplifyPeriod $ PeriodBetween b e where mlastb = case beginDatesFromRawOpts d rawopts of [] -> Nothing bs -> Just $ last bs mlaste = case endDatesFromRawOpts d rawopts of [] -> Nothing es -> Just $ last es -- Get all begin dates specified by -b/--begin or -p/--period options, in order, -- using the given date to interpret relative date expressions. beginDatesFromRawOpts :: Day -> RawOpts -> [Day] beginDatesFromRawOpts d = collectopts (begindatefromrawopt d) where begindatefromrawopt d' (n,v) | n == "begin" = either (\e -> usageError $ "could not parse "++n++" date: "++customErrorBundlePretty e) Just $ fixSmartDateStrEither' d' (T.pack v) | n == "period" = case either (\e -> usageError $ "could not parse period option: "++customErrorBundlePretty e) id $ parsePeriodExpr d' (stripquotes $ T.pack v) of (_, DateSpan (Just b) _) -> Just b _ -> Nothing | otherwise = Nothing -- Get all end dates specified by -e/--end or -p/--period options, in order, -- using the given date to interpret relative date expressions. endDatesFromRawOpts :: Day -> RawOpts -> [Day] endDatesFromRawOpts d = collectopts (enddatefromrawopt d) where enddatefromrawopt d' (n,v) | n == "end" = either (\e -> usageError $ "could not parse "++n++" date: "++customErrorBundlePretty e) Just $ fixSmartDateStrEither' d' (T.pack v) | n == "period" = case either (\e -> usageError $ "could not parse period option: "++customErrorBundlePretty e) id $ parsePeriodExpr d' (stripquotes $ T.pack v) of (_, DateSpan _ (Just e)) -> Just e _ -> Nothing | otherwise = Nothing -- | Get the report interval, if any, specified by the last of -p/--period, -- -D/--daily, -W/--weekly, -M/--monthly etc. options. -- An interval from --period counts only if it is explicitly defined. intervalFromRawOpts :: RawOpts -> Interval intervalFromRawOpts = lastDef NoInterval . collectopts intervalfromrawopt where intervalfromrawopt (n,v) | n == "period" = either (\e -> usageError $ "could not parse period option: "++customErrorBundlePretty e) extractIntervalOrNothing $ parsePeriodExpr (error' "intervalFromRawOpts: did not expect to need today's date here") -- PARTIAL: should not happen; we are just getting the interval, which does not use the reference date (stripquotes $ T.pack v) | n == "daily" = Just $ Days 1 | n == "weekly" = Just $ Weeks 1 | n == "monthly" = Just $ Months 1 | n == "quarterly" = Just $ Quarters 1 | n == "yearly" = Just $ Years 1 | otherwise = Nothing -- | Extract the interval from the parsed -p/--period expression. -- Return Nothing if an interval is not explicitly defined. extractIntervalOrNothing :: (Interval, DateSpan) -> Maybe Interval extractIntervalOrNothing (NoInterval, _) = Nothing extractIntervalOrNothing (interval, _) = Just interval -- | Get any statuses to be matched, as specified by -U/--unmarked, -- -P/--pending, -C/--cleared flags. -UPC is equivalent to no flags, -- so this returns a list of 0-2 unique statuses. statusesFromRawOpts :: RawOpts -> [Status] statusesFromRawOpts = simplifyStatuses . collectopts statusfromrawopt where statusfromrawopt (n,_) | n == "unmarked" = Just Unmarked | n == "pending" = Just Pending | n == "cleared" = Just Cleared | otherwise = Nothing -- | Reduce a list of statuses to just one of each status, -- and if all statuses are present return the empty list. simplifyStatuses l | length l' >= numstatuses = [] | otherwise = l' where l' = nubSort l numstatuses = length [minBound .. maxBound :: Status] -- | Add/remove this status from the status list. Used by hledger-ui. reportOptsToggleStatus s ropts@ReportOpts{statuses_=ss} | s `elem` ss = ropts{statuses_=filter (/= s) ss} | otherwise = ropts{statuses_=simplifyStatuses (s:ss)} -- | Parse the type of valuation to be performed, if any, specified by -V, -- -X/--exchange, or --value flags. If there's more than one valuation type, -- the rightmost flag wins. This will fail with a usage error if an invalid -- argument is passed to --value, or if --valuechange is called with a -- valuation type other than -V/--value=end. valuationTypeFromRawOpts :: RawOpts -> Maybe ValuationType valuationTypeFromRawOpts rawopts = case (balancecalcopt rawopts, directval) of (CalcValueChange, Nothing ) -> Just $ AtEnd Nothing -- If no valuation requested for valuechange, use AtEnd (CalcValueChange, Just (AtEnd _)) -> directval -- If AtEnd valuation requested, use it (CalcValueChange, _ ) -> usageError "--valuechange only produces sensible results with --value=end" (CalcGain, Nothing ) -> Just $ AtEnd Nothing -- If no valuation requested for gain, use AtEnd (_, _ ) -> directval -- Otherwise, use requested valuation where directval = lastMay $ collectopts valuationfromrawopt rawopts valuationfromrawopt (n,v) -- option name, value | n == "V" = Just $ AtEnd Nothing | n == "X" = Just $ AtEnd (Just $ T.pack v) | n == "value" = valueopt v | otherwise = Nothing valueopt v | t `elem` ["cost","c"] = AtEnd . Just <$> mc -- keep supporting --value=cost,COMM for now | t `elem` ["then" ,"t"] = Just $ AtThen mc | t `elem` ["end" ,"e"] = Just $ AtEnd mc | t `elem` ["now" ,"n"] = Just $ AtNow mc | otherwise = case parsedateM t of Just d -> Just $ AtDate d mc Nothing -> usageError $ "could not parse \""++t++"\" as valuation type, should be: then|end|now|t|e|n|YYYY-MM-DD" where -- parse --value's value: TYPE[,COMM] (t,c') = break (==',') v mc = case drop 1 c' of "" -> Nothing c -> Just $ T.pack c -- | Parse the type of costing to be performed, if any, specified by -B/--cost -- or --value flags. If there's more than one costing type, the rightmost flag -- wins. This will fail with a usage error if an invalid argument is passed to -- --cost or if a costing type is requested with --gain. conversionOpFromRawOpts :: RawOpts -> Maybe ConversionOp conversionOpFromRawOpts rawopts | isJust costFlag && balancecalcopt rawopts == CalcGain = usageError "--gain cannot be combined with --cost" | otherwise = costFlag where costFlag = lastMay $ collectopts conversionopfromrawopt rawopts conversionopfromrawopt (n,v) -- option name, value | n == "B" = Just ToCost | n == "value", takeWhile (/=',') v `elem` ["cost", "c"] = Just ToCost -- keep supporting --value=cost for now | otherwise = Nothing -- | Select the Transaction date accessor based on --date2. transactionDateFn :: ReportOpts -> (Transaction -> Day) transactionDateFn ReportOpts{..} = if date2_ then transactionDate2 else tdate -- | Select the Posting date accessor based on --date2. postingDateFn :: ReportOpts -> (Posting -> Day) postingDateFn ReportOpts{..} = if date2_ then postingDate2 else postingDate -- | Report which date we will report on based on --date2. whichDate :: ReportOpts -> WhichDate whichDate ReportOpts{..} = if date2_ then SecondaryDate else PrimaryDate -- | Legacy-compatible convenience aliases for accountlistmode_. tree_ :: ReportOpts -> Bool tree_ ReportOpts{accountlistmode_ = ALTree} = True tree_ ReportOpts{accountlistmode_ = ALFlat} = False flat_ :: ReportOpts -> Bool flat_ = not . tree_ -- depthFromOpts :: ReportOpts -> Int -- depthFromOpts opts = min (fromMaybe 99999 $ depth_ opts) (queryDepth $ queryFromOpts nulldate opts) -- | Convert a 'Journal''s amounts to cost and/or to value (see -- 'journalApplyValuationFromOpts'), and filter by the 'ReportSpec' 'Query'. -- -- We make sure to first filter by amt: and cur: terms, then value the -- 'Journal', then filter by the remaining terms. journalValueAndFilterPostings :: ReportSpec -> Journal -> Journal journalValueAndFilterPostings rspec j = journalValueAndFilterPostingsWith rspec j priceoracle where priceoracle = journalPriceOracle (infer_prices_ $ _rsReportOpts rspec) j -- | Like 'journalValueAndFilterPostings', but takes a 'PriceOracle' as an argument. journalValueAndFilterPostingsWith :: ReportSpec -> Journal -> PriceOracle -> Journal journalValueAndFilterPostingsWith rspec@ReportSpec{_rsQuery=q, _rsReportOpts=ropts} j = -- Filter by the remainder of the query filterJournal reportq -- Apply valuation and costing . journalApplyValuationFromOptsWith rspec -- Filter by amount and currency, so it matches pre-valuation/costing (if queryIsNull amtsymq then j else filterJournalAmounts amtsymq j) where -- with -r, replace each posting with its sibling postings filterJournal = if related_ ropts then filterJournalRelatedPostings else filterJournalPostings amtsymq = dbg3 "amtsymq" $ filterQuery queryIsAmtOrSym q reportq = dbg3 "reportq" $ filterQuery (not . queryIsAmtOrSym) q queryIsAmtOrSym = liftA2 (||) queryIsAmt queryIsSym -- | Convert this journal's postings' amounts to cost and/or to value, if specified -- by options (-B/--cost/-V/-X/--value etc.). Strip prices if not needed. This -- should be the main stop for performing costing and valuation. The exception is -- whenever you need to perform valuation _after_ summing up amounts, as in a -- historical balance report with --value=end. valuationAfterSum will check for this -- condition. journalApplyValuationFromOpts :: ReportSpec -> Journal -> Journal journalApplyValuationFromOpts rspec j = journalApplyValuationFromOptsWith rspec j priceoracle where priceoracle = journalPriceOracle (infer_prices_ $ _rsReportOpts rspec) j -- | Like journalApplyValuationFromOpts, but takes PriceOracle as an argument. journalApplyValuationFromOptsWith :: ReportSpec -> Journal -> PriceOracle -> Journal journalApplyValuationFromOptsWith rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle = case balancecalc_ ropts of CalcGain -> journalMapPostings (\p -> postingTransformAmount (gain p) p) j _ -> journalMapPostings (\p -> postingTransformAmount (valuation p) p) $ costing j where valuation p = maybe id (mixedAmountApplyValuation priceoracle styles (postingperiodend p) (_rsDay rspec) (postingDate p)) (value_ ropts) gain p = maybe id (mixedAmountApplyGain priceoracle styles (postingperiodend p) (_rsDay rspec) (postingDate p)) (value_ ropts) costing = journalToCost (fromMaybe NoConversionOp $ conversionop_ ropts) -- Find the end of the period containing this posting postingperiodend = addDays (-1) . fromMaybe err . mPeriodEnd . postingDateOrDate2 (whichDate ropts) mPeriodEnd = case interval_ ropts of NoInterval -> const . spanEnd . fst $ reportSpan j rspec _ -> spanEnd <=< latestSpanContaining (historical : spans) historical = DateSpan Nothing $ spanStart =<< headMay spans spans = snd $ reportSpanBothDates j rspec styles = journalCommodityStyles j err = error "journalApplyValuationFromOpts: expected all spans to have an end date" -- | Select the Account valuation functions required for performing valuation after summing -- amounts. Used in MultiBalanceReport to value historical and similar reports. mixedAmountApplyValuationAfterSumFromOptsWith :: ReportOpts -> Journal -> PriceOracle -> (DateSpan -> MixedAmount -> MixedAmount) mixedAmountApplyValuationAfterSumFromOptsWith ropts j priceoracle = case valuationAfterSum ropts of Just mc -> case balancecalc_ ropts of CalcGain -> gain mc _ -> \spn -> valuation mc spn . costing Nothing -> const id where valuation mc spn = mixedAmountValueAtDate priceoracle styles mc (maybe err (addDays (-1)) $ spanEnd spn) gain mc spn = mixedAmountGainAtDate priceoracle styles mc (maybe err (addDays (-1)) $ spanEnd spn) costing = case fromMaybe NoConversionOp $ conversionop_ ropts of NoConversionOp -> id ToCost -> styleMixedAmount styles . mixedAmountCost styles = journalCommodityStyles j err = error "mixedAmountApplyValuationAfterSumFromOptsWith: expected all spans to have an end date" -- | If the ReportOpts specify that we are performing valuation after summing amounts, -- return Just of the commodity symbol we're converting to, Just Nothing for the default, -- and otherwise return Nothing. -- Used for example with historical reports with --value=end. valuationAfterSum :: ReportOpts -> Maybe (Maybe CommoditySymbol) valuationAfterSum ropts = case value_ ropts of Just (AtEnd mc) | valueAfterSum -> Just mc _ -> Nothing where valueAfterSum = balancecalc_ ropts == CalcValueChange || balancecalc_ ropts == CalcGain || balanceaccum_ ropts /= PerPeriod -- | Convert report options to a query, ignoring any non-flag command line arguments. queryFromFlags :: ReportOpts -> Query queryFromFlags ReportOpts{..} = simplifyQuery $ And flagsq where flagsq = consIf Real real_ . consJust Depth depth_ $ [ (if date2_ then Date2 else Date) $ periodAsDateSpan period_ , Or $ map StatusQ statuses_ ] consIf f b = if b then (f True:) else id consJust f = maybe id ((:) . f) -- Report dates. -- | The effective report span is the start and end dates specified by -- options or queries, or otherwise the earliest and latest transaction or -- posting dates in the journal. If no dates are specified by options/queries -- and the journal is empty, returns the null date span. -- Also return the intervals if they are requested. reportSpan :: Journal -> ReportSpec -> (DateSpan, [DateSpan]) reportSpan = reportSpanHelper False -- | Like reportSpan, but uses both primary and secondary dates when calculating -- the span. reportSpanBothDates :: Journal -> ReportSpec -> (DateSpan, [DateSpan]) reportSpanBothDates = reportSpanHelper True -- | A helper for reportSpan, which takes a Bool indicating whether to use both -- primary and secondary dates. reportSpanHelper :: Bool -> Journal -> ReportSpec -> (DateSpan, [DateSpan]) reportSpanHelper bothdates j ReportSpec{_rsQuery=query, _rsReportOpts=ropts} = (reportspan, intervalspans) where -- The date span specified by -b/-e/-p options and query args if any. requestedspan = dbg3 "requestedspan" $ if bothdates then queryDateSpan' query else queryDateSpan (date2_ ropts) query -- If we are requesting period-end valuation, the journal date span should -- include price directives after the last transaction journalspan = dbg3 "journalspan" $ if bothdates then journalDateSpanBothDates j else journalDateSpan (date2_ ropts) j pricespan = dbg3 "pricespan" . DateSpan Nothing $ case value_ ropts of Just (AtEnd _) -> fmap (addDays 1) . maximumMay . map pddate $ jpricedirectives j _ -> Nothing -- If the requested span is open-ended, close it using the journal's start and end dates. -- This can still be the null (open) span if the journal is empty. requestedspan' = dbg3 "requestedspan'" $ requestedspan `spanDefaultsFrom` (journalspan `spanUnion` pricespan) -- The list of interval spans enclosing the requested span. -- This list can be empty if the journal was empty, -- or if hledger-ui has added its special date:-tomorrow to the query -- and all txns are in the future. intervalspans = dbg3 "intervalspans" $ splitSpan (interval_ ropts) requestedspan' -- The requested span enlarged to enclose a whole number of intervals. -- This can be the null span if there were no intervals. reportspan = dbg3 "reportspan" $ DateSpan (spanStart =<< headMay intervalspans) (spanEnd =<< lastMay intervalspans) reportStartDate :: Journal -> ReportSpec -> Maybe Day reportStartDate j = spanStart . fst . reportSpan j reportEndDate :: Journal -> ReportSpec -> Maybe Day reportEndDate j = spanEnd . fst . reportSpan j -- Some pure alternatives to the above. XXX review/clean up -- Get the report's start date. -- If no report period is specified, will be Nothing. reportPeriodStart :: ReportSpec -> Maybe Day reportPeriodStart = queryStartDate False . _rsQuery -- Get the report's start date, or if no report period is specified, -- the journal's start date (the earliest posting date). If there's no -- report period and nothing in the journal, will be Nothing. reportPeriodOrJournalStart :: ReportSpec -> Journal -> Maybe Day reportPeriodOrJournalStart rspec j = reportPeriodStart rspec <|> journalStartDate False j -- Get the last day of the overall report period. -- This the inclusive end date (one day before the -- more commonly used, exclusive, report end date). -- If no report period is specified, will be Nothing. reportPeriodLastDay :: ReportSpec -> Maybe Day reportPeriodLastDay = fmap (addDays (-1)) . queryEndDate False . _rsQuery -- Get the last day of the overall report period, or if no report -- period is specified, the last day of the journal (ie the latest -- posting date). If we're doing period-end valuation, include price -- directive dates. If there's no report period and nothing in the -- journal, will be Nothing. reportPeriodOrJournalLastDay :: ReportSpec -> Journal -> Maybe Day reportPeriodOrJournalLastDay rspec j = reportPeriodLastDay rspec <|> journalOrPriceEnd where journalOrPriceEnd = case value_ $ _rsReportOpts rspec of Just (AtEnd _) -> max (journalLastDay False j) lastPriceDirective _ -> journalLastDay False j lastPriceDirective = fmap (addDays 1) . maximumMay . map pddate $ jpricedirectives j -- | Make a name for the given period in a multiperiod report, given -- the type of balance being reported and the full set of report -- periods. This will be used as a column heading (or row heading, in -- a register summary report). We try to pick a useful name as follows: -- -- - ending-balance reports: the period's end date -- -- - balance change reports where the periods are months and all in the same year: -- the short month name in the current locale -- -- - all other balance change reports: a description of the datespan, -- abbreviated to compact form if possible (see showDateSpan). reportPeriodName :: BalanceAccumulation -> [DateSpan] -> DateSpan -> T.Text reportPeriodName balanceaccumulation spans = case balanceaccumulation of PerPeriod -> if multiyear then showDateSpan else showDateSpanMonthAbbrev where multiyear = (>1) $ length $ nubSort $ map spanStartYear spans _ -> maybe "" (showDate . prevday) . spanEnd -- lenses -- Reportable functors are so that we can create special lenses which can fail -- and report on their failure. class Functor f => Reportable f e where report :: a -> f (Either e a) -> f a instance Reportable (Const r) e where report _ (Const x) = Const x instance Reportable Identity e where report a (Identity i) = Identity $ fromRight a i instance Reportable Maybe e where report _ = (eitherToMaybe =<<) instance (e ~ a) => Reportable (Either a) e where report _ = join -- | Apply a function over a lens, but report on failure. overEither :: ((a -> Either e b) -> s -> Either e t) -> (a -> b) -> s -> Either e t overEither l f = l (pure . f) -- | Set a field using a lens, but report on failure. setEither :: ((a -> Either e b) -> s -> Either e t) -> b -> s -> Either e t setEither l = overEither l . const type ReportableLens' s a = forall f. Reportable f String => (a -> f a) -> s -> f s -- | Lenses for ReportOpts. -- Implement HasReportOptsNoUpdate, the basic lenses for ReportOpts. makeHledgerClassyLenses ''ReportOpts makeHledgerClassyLenses ''ReportSpec -- | Special lenses for ReportOpts which also update the Query and QueryOpts in ReportSpec. -- Note that these are not true lenses, as they have a further restriction on -- the functor. This will work as a normal lens for all common uses, but since they -- don't obey the lens laws for some fancy cases, they may fail in some exotic circumstances. -- -- Note that setEither/overEither should only be necessary with -- querystring and reportOpts: the other lenses should never fail. -- -- === Examples: -- >>> import Lens.Micro (set) -- >>> _rsQuery <$> setEither querystring ["assets"] defreportspec -- Right (Acct (RegexpCI "assets")) -- >>> _rsQuery <$> setEither querystring ["(assets"] defreportspec -- Left "This regular expression is malformed... -- >>> _rsQuery $ set querystring ["assets"] defreportspec -- Acct (RegexpCI "assets") -- >>> _rsQuery $ set querystring ["(assets"] defreportspec -- *** Exception: Error: Updating ReportSpec failed: try using overEither instead of over or setEither instead of set -- >>> _rsQuery $ set period (MonthPeriod 2021 08) defreportspec -- Date DateSpan 2021-08 class HasReportOptsNoUpdate a => HasReportOpts a where reportOpts :: ReportableLens' a ReportOpts reportOpts = reportOptsNoUpdate {-# INLINE reportOpts #-} -- XXX these names are a bit clashy period :: ReportableLens' a Period period = reportOpts.periodNoUpdate {-# INLINE period #-} statuses :: ReportableLens' a [Status] statuses = reportOpts.statusesNoUpdate {-# INLINE statuses #-} depth :: ReportableLens' a (Maybe Int) depth = reportOpts.depthNoUpdate {-# INLINE depth #-} date2 :: ReportableLens' a Bool date2 = reportOpts.date2NoUpdate {-# INLINE date2 #-} real :: ReportableLens' a Bool real = reportOpts.realNoUpdate {-# INLINE real #-} querystring :: ReportableLens' a [T.Text] querystring = reportOpts.querystringNoUpdate {-# INLINE querystring #-} instance HasReportOpts ReportOpts instance HasReportOptsNoUpdate ReportSpec where reportOptsNoUpdate = rsReportOpts instance HasReportOpts ReportSpec where reportOpts f rspec = report (error' "Updating ReportSpec failed: try using overEither instead of over or setEither instead of set") $ -- PARTIAL: reportOptsToSpec (_rsDay rspec) <$> f (_rsReportOpts rspec) {-# INLINE reportOpts #-} -- | Generate a ReportSpec from a set of ReportOpts on a given day. reportOptsToSpec :: Day -> ReportOpts -> Either String ReportSpec reportOptsToSpec day ropts = do (argsquery, queryopts) <- parseQueryList day $ querystring_ ropts return ReportSpec { _rsReportOpts = ropts , _rsDay = day , _rsQuery = simplifyQuery $ And [queryFromFlags ropts, argsquery] , _rsQueryOpts = queryopts } -- | Update the ReportOpts and the fields derived from it in a ReportSpec, -- or return an error message if there is a problem such as missing or -- unparseable options data. This is the safe way to change a ReportSpec, -- ensuring that all fields (_rsQuery, _rsReportOpts, querystring_, etc.) are in sync. updateReportSpec :: ReportOpts -> ReportSpec -> Either String ReportSpec updateReportSpec = setEither reportOpts -- | Like updateReportSpec, but takes a ReportOpts-modifying function. updateReportSpecWith :: (ReportOpts -> ReportOpts) -> ReportSpec -> Either String ReportSpec updateReportSpecWith = overEither reportOpts -- | Generate a ReportSpec from RawOpts and a provided day, or return an error -- string if there are regular expression errors. rawOptsToReportSpec :: Day -> RawOpts -> Either String ReportSpec rawOptsToReportSpec day = reportOptsToSpec day . rawOptsToReportOpts day