Skip to content

Commit ab88615

Browse files
committed
dev!: balance: Use DayPartition for multibalance reports.
This allows us to guarantee that the report periods are well-formed and don't contain errors (e.g. empty spans, spans not contiguous, spans not a partition). Note the underlying representation is now for disjoint spans, whereas previously the end date of a span was equal to the start date of the next span, and then was adjusted backwards one day when needed.
1 parent e8672b3 commit ab88615

File tree

12 files changed

+172
-70
lines changed

12 files changed

+172
-70
lines changed

hledger-lib/Hledger/Data.hs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,20 @@ functionality. This package re-exports all the Hledger.Data.* modules
1010

1111
module Hledger.Data (
1212
module Hledger.Data.Account,
13-
module Hledger.Data.BalanceData,
14-
module Hledger.Data.PeriodData,
1513
module Hledger.Data.AccountName,
1614
module Hledger.Data.Amount,
15+
module Hledger.Data.BalanceData,
1716
module Hledger.Data.Balancing,
1817
module Hledger.Data.Currency,
1918
module Hledger.Data.Dates,
19+
module Hledger.Data.DayPartition,
2020
module Hledger.Data.Errors,
2121
module Hledger.Data.Journal,
2222
module Hledger.Data.JournalChecks,
2323
module Hledger.Data.Json,
2424
module Hledger.Data.Ledger,
2525
module Hledger.Data.Period,
26+
module Hledger.Data.PeriodData,
2627
module Hledger.Data.PeriodicTransaction,
2728
module Hledger.Data.Posting,
2829
module Hledger.Data.RawOptions,
@@ -39,18 +40,19 @@ where
3940
import Test.Tasty (testGroup)
4041
import Hledger.Data.Account
4142
import Hledger.Data.BalanceData
42-
import Hledger.Data.PeriodData
4343
import Hledger.Data.AccountName
4444
import Hledger.Data.Amount
4545
import Hledger.Data.Balancing
4646
import Hledger.Data.Currency
4747
import Hledger.Data.Dates
48+
import Hledger.Data.DayPartition
4849
import Hledger.Data.Errors
4950
import Hledger.Data.Journal
5051
import Hledger.Data.JournalChecks
5152
import Hledger.Data.Json
5253
import Hledger.Data.Ledger
5354
import Hledger.Data.Period
55+
import Hledger.Data.PeriodData
5456
import Hledger.Data.PeriodicTransaction
5557
import Hledger.Data.Posting
5658
import Hledger.Data.RawOptions
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
{-|
2+
A partition of time into contiguous spans, for defining reporting periods.
3+
-}
4+
module Hledger.Data.DayPartition
5+
( DayPartition
6+
, boundariesToDayPartition
7+
, boundariesToMaybeDayPartition
8+
9+
, lookupDayPartition
10+
, unionDayPartitions
11+
12+
, dayPartitionToNonEmpty
13+
, dayPartitionToList
14+
, dayPartitionToPeriodData
15+
, dayPartitionToDateSpans
16+
, maybeDayPartitionToDateSpans
17+
, dateSpansToDayPartition
18+
) where
19+
20+
import qualified Data.IntMap.Strict as IM
21+
import Data.List.NonEmpty (NonEmpty(..))
22+
import qualified Data.List.NonEmpty as NE
23+
import Data.Time (Day, addDays)
24+
25+
import Hledger.Data.Dates
26+
import Hledger.Data.PeriodData
27+
import Hledger.Data.Types
28+
import Hledger.Utils
29+
30+
31+
-- | A partition of time into contiguous spans, along with a historical period
32+
-- before any of the spans.
33+
--
34+
-- This is a newtype wrapper around 'PeriodData Day', where the start dates are
35+
-- the keys and the end dates are the values. Spans are stored in inclusive format
36+
-- [start, end]. Note that this differs from 'DateSpan' which uses [start, end)
37+
-- format.
38+
--
39+
-- The constructor is not exported so that we can ensure the spans are valid
40+
-- partitions of time.
41+
newtype DayPartition = DayPartition { dayPartitionToPeriodData :: PeriodData Day } deriving (Eq, Ord, Show)
42+
43+
-- Developer's note. All constructors must guarantee that:
44+
-- 1. The value stored in pdperiods has at least one key.
45+
-- 2. The value stored in pdpre equals one day before the smallest key in pdperiods.
46+
-- 3. The value stored in each entry of pdperiods equals one day before the
47+
-- next largest key, except for the value associated to the largest key.
48+
isValidDayPartition :: DayPartition -> Bool
49+
isValidDayPartition (DayPartition pd) = case ds of
50+
[] -> False -- Must be at least one key in pdperiods
51+
xs -> and $ zipWith isContiguous ((nulldate, h) : xs) xs
52+
where
53+
(h, ds) = periodDataToList pd
54+
isContiguous (_, e) (s, _) = addDays 1 e == s
55+
56+
57+
-- | Construct a 'DayPartition' from a non-empty list of boundary days.
58+
boundariesToDayPartition :: NonEmpty Day -> DayPartition
59+
boundariesToDayPartition xs =
60+
DayPartition $ periodDataFromList (addDays (-1) b) $ zip (b:bs) (map (addDays (-1)) bs)
61+
where (b:|bs) = NE.nub $ NE.sort xs
62+
63+
-- | Construct a 'DayPartition' from a list of boundary days, returning
64+
-- 'Nothing' for the empty list.
65+
boundariesToMaybeDayPartition :: [Day] -> Maybe DayPartition
66+
boundariesToMaybeDayPartition = fmap boundariesToDayPartition . NE.nonEmpty
67+
68+
69+
-- | Find the span of a 'DayPartition' which contains a given day.
70+
lookupDayPartition :: Day -> DayPartition -> (Maybe Day, Day)
71+
lookupDayPartition d (DayPartition xs) = lookupPeriodDataOrHistorical d xs
72+
73+
-- | Return the union of two 'DayPartition's if they are consistent, or 'Nothing' otherwise.
74+
unionDayPartitions :: DayPartition -> DayPartition -> Maybe DayPartition
75+
unionDayPartitions (DayPartition (PeriodData h as)) (DayPartition (PeriodData h' as')) =
76+
if equalIntersection as as' && isValidDayPartition union then Just union else Nothing
77+
where
78+
union = DayPartition . PeriodData (min h h') $ as <> as'
79+
equalIntersection x y = and $ IM.intersectionWith (==) x y
80+
81+
82+
-- | Convert 'DayPartition' to a non-empty list of start and end dates for the periods.
83+
--
84+
-- Note that the end date of each period will be one day before the start date
85+
-- of the next period.
86+
dayPartitionToNonEmpty :: DayPartition -> NonEmpty (Day, Day)
87+
dayPartitionToNonEmpty (DayPartition xs) = NE.fromList . snd $ periodDataToList xs -- Constructors guarantee this is non-empty
88+
89+
-- | Convert 'DayPartition' to a list of start and end dates for the periods.
90+
--
91+
-- Note that the end date of each period will be one day before the start date
92+
-- of the next period.
93+
dayPartitionToList :: DayPartition -> [(Day, Day)]
94+
dayPartitionToList = NE.toList . dayPartitionToNonEmpty
95+
96+
-- | Convert 'DayPartition' to a list of 'DateSpan's.
97+
--
98+
-- Note that the end date of each period will be equal to the start date of
99+
-- the next period.
100+
dayPartitionToDateSpans :: DayPartition -> [DateSpan]
101+
dayPartitionToDateSpans = map toDateSpan . dayPartitionToList
102+
where
103+
toDateSpan (s, e) = DateSpan (toEFDay s) (toEFDay $ addDays 1 e)
104+
toEFDay = Just . Exact
105+
106+
-- Convert a periodic report 'Maybe DayPartition' to a list of 'DateSpans',
107+
-- replacing the empty case with an appropriate placeholder.
108+
--
109+
-- Note that the end date of each period will be equal to the start date of
110+
-- the next period.
111+
maybeDayPartitionToDateSpans :: Maybe DayPartition -> [DateSpan]
112+
maybeDayPartitionToDateSpans = maybe [DateSpan Nothing Nothing] dayPartitionToDateSpans
113+
114+
-- | Convert a list of 'DateSpan's to a 'DayPartition', or 'Nothing' if it is not well-formed.
115+
--
116+
-- Warning: This can construct ill-formed 'DayPartitions' and can raise errors.
117+
-- It will be eliminated later.
118+
-- PARTIAL:
119+
dateSpansToDayPartition :: [DateSpan] -> Maybe DayPartition
120+
-- Handle the cases of partitions which would arise from journals with no transactions
121+
dateSpansToDayPartition [] = Nothing
122+
dateSpansToDayPartition [DateSpan Nothing Nothing] = Nothing
123+
dateSpansToDayPartition [DateSpan Nothing (Just _)] = Nothing
124+
dateSpansToDayPartition [DateSpan (Just _) Nothing] = Nothing
125+
-- Handle properly defined reports
126+
dateSpansToDayPartition (x:xs) = Just . DayPartition $
127+
periodDataFromList (addDays (-1) . fst $ boundaries x) (map boundaries (x:xs))
128+
where
129+
boundaries spn = makeJust (spanStart spn, addDays (-1) <$> spanEnd spn)
130+
makeJust (Just a, Just b) = (a, b)
131+
makeJust ab = error' $ "dateSpansToDayPartition: expected all spans to have start and end dates, but one has " ++ show ab

hledger-lib/Hledger/Data/PeriodData.hs

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,6 @@ module Hledger.Data.PeriodData
1818
, mergePeriodData
1919
, padPeriodData
2020

21-
, periodDataToDateSpans
22-
, maybePeriodDataToDateSpans
23-
, dateSpansToPeriodData
24-
2521
, tests_PeriodData
2622
) where
2723

@@ -38,7 +34,6 @@ import Data.List (foldl')
3834
import Data.Time (Day(..), fromGregorian)
3935

4036
import Hledger.Data.Amount
41-
import Hledger.Data.Dates
4237
import Hledger.Data.Types
4338
import Hledger.Utils
4439

@@ -127,31 +122,6 @@ padPeriodData :: a -> PeriodData b -> PeriodData a -> PeriodData a
127122
padPeriodData x pad bal = bal{pdperiods = pdperiods bal <> (x <$ pdperiods pad)}
128123

129124

130-
-- | Convert 'PeriodData Day' to a list of 'DateSpan's.
131-
periodDataToDateSpans :: PeriodData Day -> [DateSpan]
132-
periodDataToDateSpans = map (\(s, e) -> DateSpan (toEFDay s) (toEFDay e)) . snd . periodDataToList
133-
where toEFDay = Just . Exact
134-
135-
-- Convert a periodic report 'Maybe (PeriodData Day)' to a list of 'DateSpans',
136-
-- replacing the empty case with an appropriate placeholder.
137-
maybePeriodDataToDateSpans :: Maybe (PeriodData Day) -> [DateSpan]
138-
maybePeriodDataToDateSpans = maybe [DateSpan Nothing Nothing] periodDataToDateSpans
139-
140-
-- | Convert a list of 'DateSpan's to a 'PeriodData Day', or 'Nothing' if it is not well-formed.
141-
-- PARTIAL:
142-
dateSpansToPeriodData :: [DateSpan] -> Maybe (PeriodData Day)
143-
-- Handle the cases of partitions which would arise from journals with no transactions
144-
dateSpansToPeriodData [] = Nothing
145-
dateSpansToPeriodData [DateSpan Nothing Nothing] = Nothing
146-
dateSpansToPeriodData [DateSpan Nothing (Just _)] = Nothing
147-
dateSpansToPeriodData [DateSpan (Just _) Nothing] = Nothing
148-
-- Handle properly defined reports
149-
dateSpansToPeriodData (x:xs) = Just $ periodDataFromList (fst $ boundaries x) (map boundaries (x:xs))
150-
where
151-
boundaries spn = makeJust (spanStart spn, spanEnd spn)
152-
makeJust (Just a, Just b) = (a, b)
153-
makeJust ab = error' $ "dateSpansToPeriodData: expected all spans to have start and end dates, but one has " ++ show ab
154-
155125
intToDay = ModifiedJulianDay . toInteger
156126
dayToInt = fromInteger . toModifiedJulianDay
157127

hledger-lib/Hledger/Data/Types.hs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -750,13 +750,16 @@ data Account a = Account {
750750
,adata :: PeriodData a -- ^ associated data per report period
751751
} deriving (Generic, Functor)
752752

753-
-- | Data values for zero or more report periods, and for the pre-report period.
754-
-- Report periods are assumed to be contiguous, and represented only by start dates
755-
-- (as keys of an IntMap). XXX how does that work, again ?
753+
-- | A general container for storing data values associated to zero or more
754+
-- report periods, and for the pre-report period. Report periods are assumed to
755+
-- be contiguous, and represented only by start dates.
756+
--
757+
-- Data is stored in an 'IntMap' for efficiency, where Days are stored as as
758+
-- Int representing the underlying modified Julian date.
756759
data PeriodData a = PeriodData {
757760
pdpre :: a -- ^ data from the pre-report period (e.g. historical balances)
758761
,pdperiods :: IM.IntMap a -- ^ data for the periods
759-
} deriving (Eq, Functor, Generic)
762+
} deriving (Eq, Ord, Functor, Generic)
760763

761764
-- | Data that's useful in "balance" reports:
762765
-- subaccount-exclusive and -inclusive amounts,

hledger-lib/Hledger/Reports/BudgetReport.hs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import Data.Ord (comparing)
2424
import Data.Set qualified as S
2525
import Data.Text qualified as T
2626
import Data.These (These(..), these)
27-
import Data.Time (Day)
2827
import Safe (minimumDef)
2928

3029
import Hledger.Data
@@ -84,12 +83,13 @@ budgetReport rspec bopts reportspan j = dbg4 "sortedbudgetreport" budgetreport
8483

8584
(_, actualspans) = dbg5 "actualspans" $ reportSpan actualj rspec
8685
(_, budgetspans) = dbg5 "budgetspans" $ reportSpan budgetj rspec
87-
allspans = case interval_ ropts of
86+
allspans = dbg5 "allspans" $ case (interval_ ropts, budgetspans) of
8887
-- If no interval is specified:
8988
-- budgetgoalreport's span might be shorter actualreport's due to periodic txns;
9089
-- it should be safe to replace it with the latter, so they combine well.
91-
NoInterval -> actualspans
92-
_ -> maybe id (padPeriodData nulldate) budgetspans <$> actualspans
90+
(NoInterval, _) -> actualspans
91+
(_, Nothing) -> actualspans
92+
(_, Just bspan) -> unionDayPartitions bspan =<< actualspans
9393

9494
actualps = dbg5 "actualps" $ getPostings rspec actualj priceoracle reportspan
9595
budgetps = dbg5 "budgetps" $ getPostings rspec budgetj priceoracle reportspan
@@ -107,7 +107,7 @@ budgetReport rspec bopts reportspan j = dbg4 "sortedbudgetreport" budgetreport
107107
-- | Lay out a set of postings grouped by date span into a regular matrix with rows
108108
-- given by AccountName and columns by DateSpan, then generate a MultiBalanceReport
109109
-- from the columns.
110-
generateBudgetReport :: ReportOpts -> Maybe (PeriodData Day) -> Account (These BalanceData BalanceData) -> BudgetReport
110+
generateBudgetReport :: ReportOpts -> Maybe DayPartition -> Account (These BalanceData BalanceData) -> BudgetReport
111111
generateBudgetReport = generatePeriodicReport makeBudgetReportRow treeActualBalance flatActualBalance
112112
where
113113
treeActualBalance = these bdincludingsubs (const nullmixedamt) (const . bdincludingsubs)

hledger-lib/Hledger/Reports/MultiBalanceReport.hs

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import Data.Maybe (fromMaybe, isJust)
4747
import Data.Ord (Down(..))
4848
import Data.Semigroup (sconcat)
4949
import Data.These (these)
50-
import Data.Time.Calendar (Day(..), addDays, fromGregorian)
50+
import Data.Time.Calendar (Day(..), fromGregorian)
5151
import Data.Traversable (mapAccumL)
5252

5353
import Hledger.Data
@@ -162,7 +162,7 @@ compoundBalanceReportWith rspec' j priceoracle subreportspecs = cbr
162162
subreportTotal (_, sr, increasestotal) =
163163
(if increasestotal then id else fmap maNegate) $ prTotals sr
164164

165-
cbr = CompoundPeriodicReport "" (maybePeriodDataToDateSpans colspans) subreports overalltotals
165+
cbr = CompoundPeriodicReport "" (maybeDayPartitionToDateSpans colspans) subreports overalltotals
166166

167167

168168
-- | Remove any date queries and insert queries from the report span.
@@ -216,7 +216,7 @@ getPostings rspec@ReportSpec{_rsQuery=query, _rsReportOpts=ropts} j priceoracle
216216

217217
-- | Generate the 'Account' for the requested multi-balance report from a list
218218
-- of 'Posting's.
219-
generateMultiBalanceAccount :: ReportSpec -> Journal -> PriceOracle -> Maybe (PeriodData Day) -> [Posting] -> Account BalanceData
219+
generateMultiBalanceAccount :: ReportSpec -> Journal -> PriceOracle -> Maybe DayPartition -> [Posting] -> Account BalanceData
220220
generateMultiBalanceAccount rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle colspans =
221221
-- Add declared accounts if called with --declared and --empty
222222
(if (declared_ ropts && empty_ ropts) then addDeclaredAccounts rspec j else id)
@@ -262,7 +262,7 @@ addDeclaredAccounts rspec j acct =
262262
-- | Gather the account balance changes into a regular matrix, then
263263
-- accumulate and value amounts, as specified by the report options.
264264
-- Makes sure all report columns have an entry.
265-
calculateReportAccount :: ReportSpec -> Journal -> PriceOracle -> Maybe (PeriodData Day) -> [Posting] -> Account BalanceData
265+
calculateReportAccount :: ReportSpec -> Journal -> PriceOracle -> Maybe DayPartition -> [Posting] -> Account BalanceData
266266
calculateReportAccount _ _ _ Nothing _ =
267267
accountFromBalances "root" $ periodDataFromList mempty [(nulldate, mempty)]
268268
calculateReportAccount rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle (Just colspans) ps =
@@ -292,29 +292,24 @@ calculateReportAccount rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle (Just
292292
avalue = periodDataValuation ropts j priceoracle colspans
293293

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

298-
getIntervalStartDate p = fst <$> lookupPeriodData (getPostingDate p) colspans
298+
getIntervalStartDate p = fst $ lookupDayPartition (getPostingDate p) colspans
299299
getPostingDate = postingDateOrDate2 (whichDate (_rsReportOpts rspec))
300300

301301
-- | The valuation function to use for the chosen report options.
302-
-- This can call error in various situations.
303-
periodDataValuation :: ReportOpts -> Journal -> PriceOracle -> PeriodData Day
302+
periodDataValuation :: ReportOpts -> Journal -> PriceOracle -> DayPartition
304303
-> PeriodData BalanceData -> PeriodData BalanceData
305-
periodDataValuation ropts j priceoracle periodEnds =
306-
opPeriodData valueBalanceData balanceDataPeriodEnds
304+
periodDataValuation ropts j priceoracle colspans =
305+
opPeriodData valueBalanceData (dayPartitionToPeriodData colspans)
307306
where
308307
valueBalanceData :: Day -> BalanceData -> BalanceData
309308
valueBalanceData d = mapBalanceData (valueMixedAmount d)
310309

311310
valueMixedAmount :: Day -> MixedAmount -> MixedAmount
312311
valueMixedAmount = mixedAmountApplyValuationAfterSumFromOptsWith ropts j priceoracle
313312

314-
-- The end date of a period is one before the beginning of the next period
315-
balanceDataPeriodEnds :: PeriodData Day
316-
balanceDataPeriodEnds = dbg5 "balanceDataPeriodEnds" $ addDays (-1) <$> periodEnds
317-
318313
-- | Mark which nodes of an 'Account' are boring, and so should be omitted from reports.
319314
markAccountBoring :: ReportSpec -> Account BalanceData -> Account BalanceData
320315
markAccountBoring ReportSpec{_rsQuery=query,_rsReportOpts=ropts}
@@ -367,7 +362,7 @@ markAccountBoring ReportSpec{_rsQuery=query,_rsReportOpts=ropts}
367362
-- | Build a report row.
368363
--
369364
-- Calculate the column totals. These are always the sum of column amounts.
370-
generateMultiBalanceReport :: ReportOpts -> Maybe (PeriodData Day) -> Account BalanceData -> MultiBalanceReport
365+
generateMultiBalanceReport :: ReportOpts -> Maybe DayPartition -> Account BalanceData -> MultiBalanceReport
371366
generateMultiBalanceReport ropts colspans =
372367
reportPercent ropts . generatePeriodicReport makeMultiBalanceReportRow bdincludingsubs id ropts colspans
373368

@@ -377,9 +372,9 @@ generateMultiBalanceReport ropts colspans =
377372
generatePeriodicReport :: Show c =>
378373
(forall a. ReportOpts -> (BalanceData -> MixedAmount) -> a -> Account b -> PeriodicReportRow a c)
379374
-> (b -> MixedAmount) -> (c -> MixedAmount)
380-
-> ReportOpts -> Maybe (PeriodData Day) -> Account b -> PeriodicReport DisplayName c
375+
-> ReportOpts -> Maybe DayPartition -> Account b -> PeriodicReport DisplayName c
381376
generatePeriodicReport makeRow treeAmt flatAmt ropts colspans acct =
382-
PeriodicReport (maybePeriodDataToDateSpans colspans) (buildAndSort acct) totalsrow
377+
PeriodicReport (maybeDayPartitionToDateSpans colspans) (buildAndSort acct) totalsrow
383378
where
384379
-- Build report rows and sort them
385380
buildAndSort = dbg5 "generatePeriodicReport buildAndSort" . case accountlistmode_ ropts of

hledger-lib/Hledger/Reports/PostingsReport.hs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,12 @@ mkpostingsReportItem showdate showdesc wd mperiod p b =
209209
-- | Convert a list of postings into summary postings, one per interval,
210210
-- aggregated to the specified depth if any.
211211
-- Each summary posting will have a non-Nothing interval end date.
212-
summarisePostingsByInterval :: WhichDate -> Maybe Int -> Bool -> Maybe (PeriodData Day) -> [Posting] -> [SummaryPosting]
212+
summarisePostingsByInterval :: WhichDate -> Maybe Int -> Bool -> Maybe DayPartition -> [Posting] -> [SummaryPosting]
213213
summarisePostingsByInterval wd mdepth showempty colspans =
214214
concatMap (\(s,ps) -> summarisePostingsInDateSpan s wd mdepth showempty ps)
215215
-- Group postings into their columns. We try to be efficient, since
216216
-- there can possibly be a very large number of intervals (cf #1683)
217-
. groupByDateSpan showempty (postingDateOrDate2 wd) (maybePeriodDataToDateSpans colspans)
217+
. groupByDateSpan showempty (postingDateOrDate2 wd) (maybeDayPartitionToDateSpans colspans)
218218

219219
-- | Given a date span (representing a report interval) and a list of
220220
-- postings within it, aggregate the postings into one summary posting per

0 commit comments

Comments
 (0)