File: MultiBalanceReport.hs

package info (click to toggle)
haskell-hledger-lib 1.50.2-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 1,516 kB
  • sloc: haskell: 16,433; makefile: 7
file content (560 lines) | stat: -rw-r--r-- 28,316 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
{-# LANGUAGE CPP                 #-}
{-# LANGUAGE FlexibleInstances   #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE NamedFieldPuns      #-}
{-# LANGUAGE RankNTypes          #-}
{-# LANGUAGE RecordWildCards     #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections       #-}
{-|

Multi-column balance reports, used by the balance command.

-}

module Hledger.Reports.MultiBalanceReport (
  MultiBalanceReport,
  MultiBalanceReportRow,

  multiBalanceReport,
  multiBalanceReportWith,

  compoundBalanceReport,
  compoundBalanceReportWith,

  -- * Helper functions
  makeReportQuery,
  getPostings,
  generateMultiBalanceAccount,
  generatePeriodicReport,
  makePeriodicReportRow,

  -- -- * Tests
  tests_MultiBalanceReport
)
where

#if !MIN_VERSION_base(4,18,0)
import Control.Applicative (liftA2)
#endif
import Control.Monad (guard)
import Data.Foldable (toList)
import Data.List (sortOn)
import Data.List.NonEmpty (NonEmpty((:|)))
import qualified Data.HashSet as HS
import qualified Data.IntMap.Strict as IM
import Data.Maybe (fromMaybe, isJust)
import Data.Ord (Down(..))
import Data.Semigroup (sconcat)
import Data.These (these)
import Data.Time.Calendar (Day(..), addDays, fromGregorian)
import Data.Traversable (mapAccumL)

import Hledger.Data
import Hledger.Query
import Hledger.Utils
import Hledger.Reports.ReportOptions
import Hledger.Reports.ReportTypes


-- | A multi balance report is a kind of periodic report, where the amounts
-- correspond to balance changes or ending balances in a given period. It has:
--
-- 1. a list of each column's period (date span)
--
-- 2. a list of rows, each containing:
--
--   * the full account name, display name, and display depth
--
--   * A list of amounts, one for each column.
--
--   * the total of the row's amounts for a periodic report
--
--   * the average of the row's amounts
--
-- 3. the column totals, and the overall grand total (or zero for
-- cumulative/historical reports) and grand average.

type MultiBalanceReport    = PeriodicReport    DisplayName MixedAmount
type MultiBalanceReportRow = PeriodicReportRow DisplayName MixedAmount


-- | Generate a multicolumn balance report for the matched accounts,
-- showing the change of balance, accumulated balance, or historical balance
-- in each of the specified periods. If the normalbalance_ option is set, it
-- adjusts the sorting and sign of amounts (see ReportOpts and
-- CompoundBalanceCommand). hledger's most powerful and useful report, used
-- by the balance command (in multiperiod mode) and (via compoundBalanceReport)
-- by the bs/cf/is commands.
multiBalanceReport :: ReportSpec -> Journal -> MultiBalanceReport
multiBalanceReport rspec j = multiBalanceReportWith rspec j (journalPriceOracle infer j)
  where infer = infer_prices_ $ _rsReportOpts rspec

-- | A helper for multiBalanceReport. This one takes some extra arguments,
-- a 'PriceOracle' to be used for looking up market prices, and a set of
-- 'AccountName's which should not be elided. Commands which run multiple
-- reports (bs etc.) can generate the price oracle just once for efficiency,
-- passing it to each report by calling this function directly.
multiBalanceReportWith :: ReportSpec -> Journal -> PriceOracle -> MultiBalanceReport
multiBalanceReportWith rspec' j priceoracle = report
  where
    -- Queries, report/column dates.
    (reportspan, colspans) = dbg5 "multiBalanceReportWith reportSpan" $ reportSpan j rspec'
    rspec = dbg3 "multiBalanceReportWith rspec" $ makeReportQuery rspec' reportspan
    -- force evaluation order to show price lookup after date spans in debug output (XXX not working)
    -- priceoracle = reportspan `seq` priceoracle0

    -- Get postings
    ps = dbg5 "multiBalanceReportWith ps" $ getPostings rspec j priceoracle reportspan

    -- Process changes into normal, cumulative, or historical amounts, plus value them and mark which are uninteresting
    acct = dbg5 "multiBalanceReportWith acct" $ generateMultiBalanceAccount rspec j priceoracle colspans ps

    -- Generate and postprocess the report, negating balances and taking percentages if needed
    report = dbg4 "multiBalanceReportWith report" $ generateMultiBalanceReport (_rsReportOpts rspec) colspans acct

-- | Generate a compound balance report from a list of CBCSubreportSpec. This
-- shares postings between the subreports.
compoundBalanceReport :: ReportSpec -> Journal -> [CBCSubreportSpec a]
                      -> CompoundPeriodicReport a MixedAmount
compoundBalanceReport rspec j = compoundBalanceReportWith rspec j (journalPriceOracle infer j)
  where infer = infer_prices_ $ _rsReportOpts rspec

-- | A helper for compoundBalanceReport, similar to multiBalanceReportWith.
compoundBalanceReportWith :: ReportSpec -> Journal -> PriceOracle
                          -> [CBCSubreportSpec a]
                          -> CompoundPeriodicReport a MixedAmount
compoundBalanceReportWith rspec' j priceoracle subreportspecs = cbr
  where
    -- Queries, report/column dates.
    (reportspan, colspans) = dbg5 "compoundBalanceReportWith reportSpan" $ reportSpan j rspec'
    rspec = dbg3 "compoundBalanceReportWith rspec" $ makeReportQuery rspec' reportspan

    -- Get postings
    ps = dbg5 "compoundBalanceReportWith ps" $ getPostings rspec j priceoracle reportspan

    subreports = map generateSubreport subreportspecs
      where
        generateSubreport CBCSubreportSpec{..} =
            ( cbcsubreporttitle
            -- Postprocess the report, negating balances and taking percentages if needed
            , cbcsubreporttransform $ generateMultiBalanceReport ropts colspans acct
            , cbcsubreportincreasestotal
            )
          where
            ropts = cbcsubreportoptions $ _rsReportOpts rspec
            -- Add a restriction to this subreport to the report query.
            -- XXX in non-thorough way, consider updateReportSpec ?
            rspecsub = rspec{_rsReportOpts=ropts, _rsQuery=And [cbcsubreportquery, _rsQuery rspec]}
            -- Match and postings for the subreport
            subreportps = filter (matchesPostingExtra (journalAccountType j) cbcsubreportquery) ps
            -- Account representing this subreport
            acct = generateMultiBalanceAccount rspecsub j priceoracle colspans subreportps

    -- Sum the subreport totals by column. Handle these cases:
    -- - no subreports
    -- - empty subreports, having no subtotals (#588)
    -- - subreports with a shorter subtotals row than the others
    overalltotals = case subreports of
        []     -> PeriodicReportRow () [] nullmixedamt nullmixedamt
        (r:rs) -> sconcat $ fmap subreportTotal (r:|rs)
      where
        subreportTotal (_, sr, increasestotal) =
            (if increasestotal then id else fmap maNegate) $ prTotals sr

    cbr = CompoundPeriodicReport "" (maybePeriodDataToDateSpans colspans) subreports overalltotals


-- | Remove any date queries and insert queries from the report span.
-- The user's query expanded to the report span
-- if there is one (otherwise any date queries are left as-is, which
-- handles the hledger-ui+future txns case above).
makeReportQuery :: ReportSpec -> DateSpan -> ReportSpec
makeReportQuery rspec reportspan
    | reportspan == nulldatespan = rspec
    | otherwise = rspec{_rsQuery=query}
  where
    query            = simplifyQuery $ And [dateless $ _rsQuery rspec, reportspandatesq]
    reportspandatesq = dbg3 "makeReportQuery reportspandatesq" $ dateqcons reportspan
    dateless         = dbg3 "makeReportQuery dateless" . filterQuery (not . queryIsDateOrDate2)
    dateqcons        = if date2_ (_rsReportOpts rspec) then Date2 else Date

-- | Gather postings matching the query within the report period.
getPostings :: ReportSpec -> Journal -> PriceOracle -> DateSpan -> [Posting]
getPostings rspec@ReportSpec{_rsQuery=query, _rsReportOpts=ropts} j priceoracle reportspan =
    setPostingsCount
    . journalPostings
    $ journalValueAndFilterPostingsWith rspec' j priceoracle
  where
    -- If doing --count, set all posting amounts to "1".
    setPostingsCount = case balancecalc_ ropts of
        CalcPostingsCount -> map (postingTransformAmount (const $ mixed [num 1]))
        _                 -> id

    rspec' = rspec{_rsQuery=fullreportq,_rsReportOpts=ropts'}
    -- If we're re-valuing every period, we need to have the unvalued start
    -- balance, so we can do it ourselves later.
    ropts' = if isJust (valuationAfterSum ropts)
        then ropts{period_=dateSpanAsPeriod fullreportspan, value_=Nothing, conversionop_=Just NoConversionOp}  -- If we're valuing after the sum, don't do it now
        else ropts{period_=dateSpanAsPeriod fullreportspan}

    -- q projected back before the report start date.
    -- When there's no report start date, in case there are future txns (the hledger-ui case above),
    -- we use emptydatespan to make sure they aren't counted as starting balance.
    fullreportq = dbg3 "getPostings fullreportq" $ And [datelessq, fullreportspanq]
    datelessq   = dbg3 "getPostings datelessq" $ filterQuery (not . queryIsDateOrDate2) depthlessq

    -- The user's query with no depth limit, and expanded to the report span
    -- if there is one (otherwise any date queries are left as-is, which
    -- handles the hledger-ui+future txns case above).
    depthlessq = dbg3 "getPostings depthlessq" $ filterQuery (not . queryIsDepth) query

    fullreportspan  = if requiresHistorical ropts then DateSpan Nothing (Exact <$> spanEnd reportspan) else reportspan
    fullreportspanq = (if date2_ ropts then Date2 else Date) $ case fullreportspan of
        DateSpan Nothing Nothing -> emptydatespan
        a -> a

-- | Generate the 'Account' for the requested multi-balance report from a list
-- of 'Posting's.
generateMultiBalanceAccount :: ReportSpec -> Journal -> PriceOracle -> Maybe (PeriodData Day) -> [Posting] -> Account BalanceData
generateMultiBalanceAccount rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle colspans =
    -- Add declared accounts if called with --declared and --empty
    (if (declared_ ropts && empty_ ropts) then addDeclaredAccounts rspec j else id)
    -- Negate amounts if applicable
    . (if invert_ ropts then fmap (mapBalanceData maNegate) else id)
    -- Mark which accounts are boring and which are interesting
    . markAccountBoring rspec
    -- Set account declaration info (for sorting purposes)
    . mapAccounts (accountSetDeclarationInfo j)
    -- Process changes into normal, cumulative, or historical amounts, plus value them
    . calculateReportAccount rspec j priceoracle colspans
    -- Clip account names
    . map clipPosting
  where
    -- Clip postings to the requested depth according to the query
    clipPosting p = p{paccount = clipOrEllipsifyAccountName depthSpec $ paccount p}
    depthSpec = dbg3 "generateMultiBalanceAccount depthSpec"
              . queryDepth . filterQuery queryIsDepth $ _rsQuery rspec

-- | Add declared accounts to the account tree.
addDeclaredAccounts :: Monoid a => ReportSpec -> Journal -> Account a -> Account a
addDeclaredAccounts rspec j acct =
    these id id const <$> mergeAccounts acct declaredTree
  where
    declaredTree =
        mapAccounts (\a -> a{aboring = not $ aname a `HS.member` HS.fromList declaredAccounts}) $
          accountTreeFromBalanceAndNames "root" (mempty <$ adata acct) declaredAccounts

    -- With --declared, add the query-matching declared accounts (as dummy postings
    -- so they are processed like the rest).
    declaredAccounts =
      map (clipOrEllipsifyAccountName depthSpec) .
      filter (matchesAccountExtra (journalAccountType j) (journalAccountTags j) accttypetagsq) $
      journalAccountNamesDeclared j

    accttypetagsq  = dbg3 "addDeclaredAccounts accttypetagsq" .
      filterQueryOrNotQuery (\q -> queryIsAcct q || queryIsType q || queryIsTag q) $
      _rsQuery rspec

    depthSpec = queryDepth . filterQuery queryIsDepth $ _rsQuery rspec


-- | Gather the account balance changes into a regular matrix, then
-- accumulate and value amounts, as specified by the report options.
-- Makes sure all report columns have an entry.
calculateReportAccount :: ReportSpec -> Journal -> PriceOracle -> Maybe (PeriodData Day) -> [Posting] -> Account BalanceData
calculateReportAccount _ _ _ Nothing _ =
    accountFromBalances "root" $ periodDataFromList mempty [(nulldate, mempty)]
calculateReportAccount rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle (Just colspans) ps =
    mapPeriodData rowbals changesAcct
  where
    -- The valued row amounts to be displayed: per-period changes,
    -- zero-based cumulative totals, or
    -- starting-balance-based historical balances.
    rowbals :: PeriodData BalanceData -> PeriodData BalanceData
    rowbals unvaluedChanges = case balanceaccum_ ropts of
        PerPeriod  -> changes
        Cumulative -> cumulative
        Historical -> historical
      where
        -- changes to report on: usually just the valued changes themselves, but use the
        -- differences in the valued historical amount for CalcValueChange and CalcGain.
        changes = case balancecalc_ ropts of
            CalcChange        -> avalue unvaluedChanges
            CalcBudget        -> avalue unvaluedChanges
            CalcValueChange   -> periodChanges historical
            CalcGain          -> periodChanges historical
            CalcPostingsCount -> avalue unvaluedChanges
        -- the historical balance is the valued cumulative sum of all unvalued changes
        historical = avalue $ cumulativeSum unvaluedChanges
        -- since this is a cumulative sum of valued amounts, it should not be valued again
        cumulative = cumulativeSum changes{pdpre = mempty}
        avalue = periodDataValuation ropts j priceoracle colspans

    changesAcct = dbg5With (\x -> "calculateReportAccount changesAcct\n" ++ showAccounts x) .
        mapPeriodData (padPeriodData mempty colspans) $
        accountFromPostings getIntervalStartDate ps

    getIntervalStartDate p = fst <$> lookupPeriodData (getPostingDate p) colspans
    getPostingDate = postingDateOrDate2 (whichDate (_rsReportOpts rspec))

-- | The valuation function to use for the chosen report options.
-- This can call error in various situations.
periodDataValuation :: ReportOpts -> Journal -> PriceOracle -> PeriodData Day
                    -> PeriodData BalanceData -> PeriodData BalanceData
periodDataValuation ropts j priceoracle periodEnds =
    opPeriodData valueBalanceData balanceDataPeriodEnds
  where
    valueBalanceData :: Day -> BalanceData -> BalanceData
    valueBalanceData d = mapBalanceData (valueMixedAmount d)

    valueMixedAmount :: Day -> MixedAmount -> MixedAmount
    valueMixedAmount = mixedAmountApplyValuationAfterSumFromOptsWith ropts j priceoracle

    -- The end date of a period is one before the beginning of the next period
    balanceDataPeriodEnds :: PeriodData Day
    balanceDataPeriodEnds = dbg5 "balanceDataPeriodEnds" $ addDays (-1) <$> periodEnds

-- | Mark which nodes of an 'Account' are boring, and so should be omitted from reports.
markAccountBoring :: ReportSpec -> Account BalanceData -> Account BalanceData
markAccountBoring ReportSpec{_rsQuery=query,_rsReportOpts=ropts}
    -- If depth 0, all accounts except the top-level account are boring
    | qdepthIsZero = markBoring False . mapAccounts (markBoring True)
    -- Otherwise the top level account is boring, and subaccounts are boring if
    -- they are both boring in and of themselves and are boring parents
    | otherwise    = markBoring True . mapAccounts (markBoringBy (liftA2 (&&) isBoring isBoringParent))
  where
    -- Accounts boring on their own
    isBoring :: Account BalanceData -> Bool
    isBoring acct = tooDeep || allZeros
      where
        tooDeep = d > qdepth                                       -- Throw out anything too deep
        allZeros = isZeroRow balance amts && not keepEmptyAccount  -- Throw away everything with a zero balance in the row, unless..
        keepEmptyAccount = empty_ ropts && keepWhenEmpty acct      -- We are keeping empty rows and this row meets the criteria

        amts = pdperiods $ adata acct
        d = accountNameLevel $ aname acct

        qdepth = fromMaybe maxBound . getAccountNameClippedDepth depthspec $ aname acct
        balance = maybeStripPrices . case accountlistmode_ ropts of
            ALTree | d == qdepth -> bdincludingsubs
            _                    -> bdexcludingsubs

    -- Accounts which don't have enough interesting subaccounts
    isBoringParent :: Account a -> Bool
    isBoringParent acct = case accountlistmode_ ropts of
        ALTree -> notEnoughSubs || droppedAccount
        ALFlat -> True
      where
        notEnoughSubs = length interestingSubs < minimumSubs
        droppedAccount = accountNameLevel (aname acct) <= drop_ ropts
        interestingSubs = filter (anyAccounts (not . aboring)) $ asubs acct
        minimumSubs = if no_elide_ ropts then 1 else 2

    isZeroRow balance = all (mixedAmountLooksZero . balance)
    keepWhenEmpty = case accountlistmode_ ropts of
        ALFlat -> any ((0<) . bdnumpostings) . pdperiods . adata  -- Keep all accounts that have postings in flat mode
        ALTree -> null . asubs                                    -- Keep only empty leaves in tree mode
    maybeStripPrices = if conversionop_ ropts == Just NoConversionOp then id else mixedAmountStripCosts

    qdepthIsZero = depthspec == DepthSpec (Just 0) []
    depthspec = queryDepth query

    markBoring   v a = a{aboring = v}
    markBoringBy f a = a{aboring = f a}


-- | Build a report row.
--
-- Calculate the column totals. These are always the sum of column amounts.
generateMultiBalanceReport :: ReportOpts -> Maybe (PeriodData Day) -> Account BalanceData -> MultiBalanceReport
generateMultiBalanceReport ropts colspans =
    reportPercent ropts . generatePeriodicReport makeMultiBalanceReportRow bdincludingsubs id ropts colspans

-- | Lay out a set of postings grouped by date span into a regular matrix with rows
-- given by AccountName and columns by DateSpan, then generate a MultiBalanceReport
-- from the columns.
generatePeriodicReport :: Show c =>
    (forall a. ReportOpts -> (BalanceData -> MixedAmount) -> a -> Account b -> PeriodicReportRow a c)
    -> (b -> MixedAmount) -> (c -> MixedAmount)
    -> ReportOpts -> Maybe (PeriodData Day) -> Account b -> PeriodicReport DisplayName c
generatePeriodicReport makeRow treeAmt flatAmt ropts colspans acct =
    PeriodicReport (maybePeriodDataToDateSpans colspans) (buildAndSort acct) totalsrow
  where
    -- Build report rows and sort them
    buildAndSort = dbg5 "generatePeriodicReport buildAndSort" . case accountlistmode_ ropts of
        ALTree | sort_amount_ ropts -> buildRows . sortTreeByAmount
        ALFlat | sort_amount_ ropts -> sortFlatByAmount . buildRows
        _                           -> buildRows . sortAccountTreeByDeclaration

    buildRows = buildReportRows makeRow ropts

    -- Calculate column totals from the inclusive balances of the root account
    totalsrow = dbg5 "generatePeriodicReport totalsrow" $ makeRow ropts bdincludingsubs () acct

    sortTreeByAmount = case fromMaybe NormallyPositive $ normalbalance_ ropts of
        NormallyPositive -> sortAccountTreeOn (\r -> (Down $ amt r, aname r))
        NormallyNegative -> sortAccountTreeOn (\r -> (amt r, aname r))
      where
        amt = mixedAmountStripCosts . sortKey . fmap treeAmt . pdperiods . adata
        sortKey = case balanceaccum_ ropts of
          PerPeriod -> maSum
          _         -> maybe nullmixedamt snd . IM.lookupMax

    sortFlatByAmount = case fromMaybe NormallyPositive $ normalbalance_ ropts of
        NormallyPositive -> sortOn (\r -> (Down $ amt r, prrFullName r))
        NormallyNegative -> sortOn (\r -> (amt r, prrFullName r))
      where amt = mixedAmountStripCosts . flatAmt . prrTotal

-- | Build the report rows.
-- One row per account, with account name info, row amounts, row total and row average.
-- Rows are sorted according to the order in the 'Account' tree.
buildReportRows :: forall b c.
                (ReportOpts -> (BalanceData -> MixedAmount) -> DisplayName -> Account b -> PeriodicReportRow DisplayName c)
                -> ReportOpts -> Account b -> [PeriodicReportRow DisplayName c]
buildReportRows makeRow ropts = mkRows True (-drop_ ropts) 0
  where
    -- Build the row for an account at a given depth with some number of boring parents
    mkRows :: Bool -> Int -> Int -> Account b -> [PeriodicReportRow DisplayName c]
    mkRows isRoot d boringParents acct
        -- Account is boring and has no interesting children at any depth, so we stop
        | allBoring acct                 = []
        -- Account is a boring root account, and should be bypassed entirely
        | aboring acct && isRoot         = buildSubrows d 0
        -- Account is boring and has been dropped, so should be skipped and move up the hierarchy
        | aboring acct && d < 0          = buildSubrows (d + 1) 0
        -- Account is boring, and we can omit boring parents, so we should omit but keep track
        | aboring acct && canOmitParents = buildSubrows d (boringParents + 1)
        -- Account is not boring or otherwise should be displayed.
        | otherwise = makeRow ropts balance displayname acct : buildSubrows (d + 1) 0
      where
        displayname = displayedName d boringParents $ aname acct
        buildSubrows i b = concatMap (mkRows False i b) $ asubs acct

    canOmitParents = flat_ ropts || not (no_elide_ ropts)
    allBoring a = aboring a && all allBoring (asubs a)
    balance = case accountlistmode_ ropts of
        ALTree -> bdincludingsubs
        ALFlat -> bdexcludingsubs

    displayedName d boringParents name
        | d == 0 && name == "root" = DisplayName "..." "..." 0
        | otherwise = case accountlistmode_ ropts of
            ALTree -> DisplayName name leaf $ max 0 d
            ALFlat -> DisplayName name droppedName 0
      where
        leaf = accountNameFromComponents
               . reverse . take (boringParents + 1) . reverse
               $ accountNameComponents droppedName
        droppedName = accountNameDrop (drop_ ropts) name


-- | Build a report row.
--
-- Calculate the column totals. These are always the sum of column amounts.
makeMultiBalanceReportRow :: ReportOpts -> (BalanceData -> MixedAmount)
                          -> a -> Account BalanceData -> PeriodicReportRow a MixedAmount
makeMultiBalanceReportRow = makePeriodicReportRow nullmixedamt sumAndAverageMixedAmounts

-- | Build a report row.
--
-- Calculate the column totals. These are always the sum of column amounts.
makePeriodicReportRow :: c -> (IM.IntMap c -> (c, c))
                      -> ReportOpts -> (b -> c)
                      -> a -> Account b -> PeriodicReportRow a c
makePeriodicReportRow nullEntry totalAndAverage ropts balance name acct =
    PeriodicReportRow name (toList rowbals) rowtotal avg
  where
    rowbals = fmap balance . pdperiods $ adata acct
    (total, avg) = totalAndAverage rowbals
    -- Total for a cumulative/historical report is always the last column.
    rowtotal = case balanceaccum_ ropts of
        PerPeriod -> total
        _         -> maybe nullEntry snd $ IM.lookupMax rowbals

-- | Map the report rows to percentages if needed
reportPercent :: ReportOpts -> MultiBalanceReport -> MultiBalanceReport
reportPercent ropts report@(PeriodicReport spans rows totalrow)
  | percent_ ropts = PeriodicReport spans (map percentRow rows) (percentRow totalrow)
  | otherwise      = report
  where
    percentRow (PeriodicReportRow name rowvals rowtotal rowavg) =
      PeriodicReportRow name
        (zipWith perdivide rowvals $ prrAmounts totalrow)
        (perdivide rowtotal $ prrTotal totalrow)
        (perdivide rowavg $ prrAverage totalrow)

-- | A helper: what percentage is the second mixed amount of the first ?
-- Keeps the sign of the first amount.
-- Uses unifyMixedAmount to unify each argument and then divides them.
-- Both amounts should be in the same, single commodity.
-- This can call error if the arguments are not right.
perdivide :: MixedAmount -> MixedAmount -> MixedAmount
perdivide a b = fromMaybe (error' errmsg) $ do  -- PARTIAL:
    a' <- unifyMixedAmount a
    b' <- unifyMixedAmount b
    guard $ amountIsZero a' || amountIsZero b' || acommodity a' == acommodity b'
    return $ mixed [per $ if aquantity b' == 0 then 0 else aquantity a' / abs (aquantity b') * 100]
  where errmsg = "Cannot calculate percentages if accounts have different commodities (Hint: Try --cost, -V or similar flags.)"

-- | Calculate a cumulative sum from a list of period changes.
cumulativeSum :: Traversable t => t BalanceData -> t BalanceData
cumulativeSum = snd . mapAccumL (\prev new -> let z = prev <> new in (z, z)) mempty

-- | Extract period changes from a cumulative list.
periodChanges :: Traversable t => t BalanceData -> t BalanceData
periodChanges = snd . mapAccumL (\prev new -> (new, opBalanceData maMinus new prev)) mempty

-- tests

tests_MultiBalanceReport = testGroup "MultiBalanceReport" [

  let
    amt0 = Amount {acommodity="$", aquantity=0, acost=Nothing,
      astyle=AmountStyle {ascommodityside = L, ascommodityspaced = False, asdigitgroups = Nothing,
      asdecimalmark = Just '.', asprecision = Precision 2, asrounding = NoRounding}}
    (rspec,journal) `gives` r = do
      let rspec' = rspec{_rsQuery=And [queryFromFlags $ _rsReportOpts rspec, _rsQuery rspec]}
          (eitems, etotal) = r
          (PeriodicReport _ aitems atotal) = multiBalanceReport rspec' journal
          showw (PeriodicReportRow a lAmt amt amt')
              = (displayFull a, displayName a, displayIndent a, map showMixedAmountDebug lAmt, showMixedAmountDebug amt, showMixedAmountDebug amt')
      (map showw aitems) @?= (map showw eitems)
      showMixedAmountDebug (prrTotal atotal) @?= showMixedAmountDebug etotal -- we only check the sum of the totals
  in
   testGroup "multiBalanceReport" [
      testCase "null journal"  $
      (defreportspec, nulljournal) `gives` ([], nullmixedamt)

     ,testCase "with -H on a populated period"  $
      (defreportspec{_rsReportOpts=defreportopts{period_= PeriodBetween (fromGregorian 2008 1 1) (fromGregorian 2008 1 2), balanceaccum_=Historical}}, samplejournal) `gives`
       (
        [ PeriodicReportRow (flatDisplayName "assets:bank:checking") [mixedAmount $ usd 1]    (mixedAmount $ usd 1)    (mixedAmount amt0{aquantity=1})
        , PeriodicReportRow (flatDisplayName "income:salary")        [mixedAmount $ usd (-1)] (mixedAmount $ usd (-1)) (mixedAmount amt0{aquantity=(-1)})
        ],
        mixedAmount $ usd 0)

     -- ,testCase "a valid history on an empty period"  $
     --  (defreportopts{period_= PeriodBetween (fromGregorian 2008 1 2) (fromGregorian 2008 1 3), balanceaccum_=Historical}, samplejournal) `gives`
     --   (
     --    [
     --     ("assets:bank:checking","checking",3, [mamountp' "$1.00"], mamountp' "$1.00",mixedAmount amt0 {aquantity=1})
     --    ,("income:salary","salary",2, [mamountp' "$-1.00"], mamountp' "$-1.00",mixedAmount amt0 {aquantity=(-1)})
     --    ],
     --    mixedAmount usd0)

     -- ,testCase "a valid history on an empty period (more complex)"  $
     --  (defreportopts{period_= PeriodBetween (fromGregorian 2009 1 1) (fromGregorian 2009 1 2), balanceaccum_=Historical}, samplejournal) `gives`
     --   (
     --    [
     --    ("assets:bank:checking","checking",3, [mamountp' "$1.00"], mamountp' "$1.00",mixedAmount amt0 {aquantity=1})
     --    ,("assets:bank:saving","saving",3, [mamountp' "$1.00"], mamountp' "$1.00",mixedAmount amt0 {aquantity=1})
     --    ,("assets:cash","cash",2, [mamountp' "$-2.00"], mamountp' "$-2.00",mixedAmount amt0 {aquantity=(-2)})
     --    ,("expenses:food","food",2, [mamountp' "$1.00"], mamountp' "$1.00",mixedAmount amt0 {aquantity=(1)})
     --    ,("expenses:supplies","supplies",2, [mamountp' "$1.00"], mamountp' "$1.00",mixedAmount amt0 {aquantity=(1)})
     --    ,("income:gifts","gifts",2, [mamountp' "$-1.00"], mamountp' "$-1.00",mixedAmount amt0 {aquantity=(-1)})
     --    ,("income:salary","salary",2, [mamountp' "$-1.00"], mamountp' "$-1.00",mixedAmount amt0 {aquantity=(-1)})
     --    ],
     --    mixedAmount usd0)
    ]
 ]