fix: show historical balances even if report period is empty [#2403]

This adds a safer version of spanDefaultsFrom that won't create spans
that end before they start, and updates all reports to use it.

The only related change noticed so far is that close now gives an
error instead of a malformed entry, when there's no data to close.
[#2409]
This commit is contained in:
Simon Michael 2025-06-15 22:30:49 -10:00
parent ca631d9fca
commit afd18a10bf
7 changed files with 62 additions and 8 deletions

View File

@ -63,6 +63,7 @@ module Hledger.Data.Dates (
spanIntersect,
spansIntersect,
spanDefaultsFrom,
spanValidDefaultsFrom,
spanExtend,
spanUnion,
spansUnion,
@ -348,10 +349,43 @@ spanIntersect (DateSpan b1 e1) (DateSpan b2 e2) = DateSpan (laterDefinite b1 b2)
-- | Fill any unspecified dates in the first span with the dates from
-- the second one (if specified there). Sort of a one-way spanIntersect.
-- This one can create an invalid span that'll always be empty.
--
-- >>> :{
-- DateSpan (Just $ Exact $ fromGregorian 2024 1 1) Nothing
-- `spanDefaultsFrom`
-- DateSpan (Just $ Exact $ fromGregorian 2024 1 1) (Just $ Exact $ fromGregorian 2024 1 2)
-- :}
-- DateSpan 2024-01-01
--
-- >>> :{
-- DateSpan (Just $ Exact $ fromGregorian 2025 1 1) Nothing
-- `spanDefaultsFrom`
-- DateSpan (Just $ Exact $ fromGregorian 2024 1 1) (Just $ Exact $ fromGregorian 2024 1 2)
-- :}
-- DateSpan 2025-01-01..2024-01-01
--
spanDefaultsFrom :: DateSpan -> DateSpan -> DateSpan
spanDefaultsFrom (DateSpan a1 b1) (DateSpan a2 b2) = DateSpan a b
where a = if isJust a1 then a1 else a2
b = if isJust b1 then b1 else b2
-- | A smarter version of spanDefaultsFrom that avoids creating invalid
-- spans ending before they begin. Kept separate for now to reduce risk.
--
-- >>> :{
-- DateSpan (Just $ Exact $ fromGregorian 2025 1 1) Nothing
-- `spanValidDefaultsFrom`
-- DateSpan (Just $ Exact $ fromGregorian 2024 1 1) (Just $ Exact $ fromGregorian 2024 1 2)
-- :}
-- DateSpan 2025-01-01..
--
spanValidDefaultsFrom :: DateSpan -> DateSpan -> DateSpan
spanValidDefaultsFrom s1 s2 =
case s1 `spanDefaultsFrom` s2 of
DateSpan b e | b >= e -> s1
s -> s
-- | Calculate the union of two datespans.
-- If either span is open-ended, the union will be too.
--

View File

@ -215,7 +215,7 @@ runPeriodicTransaction verbosetags PeriodicTransaction{..} requestedspan =
alltxnspans = splitSpan adjust ptinterval span'
where
-- If the PT does not specify start or end dates, we take them from the requestedspan.
span' = ptspan `spanDefaultsFrom` requestedspan
span' = ptspan `spanValidDefaultsFrom` requestedspan
-- Unless the PT specified a start date explicitly, we will adjust the start date to the previous interval boundary.
adjust = isNothing $ spanStart span'

View File

@ -390,7 +390,7 @@ generatePeriodicReport makeRow treeAmt flatAmt ropts colspans acct =
PeriodicReport colspans (buildAndSort acct) totalsrow
where
-- Build report rows and sort them
buildAndSort = dbg5 "generatePeriodicReport buildAndSort" . case accountlistmode_ ropts of {}
buildAndSort = dbg5 "generatePeriodicReport buildAndSort" . case accountlistmode_ ropts of
ALTree | sort_amount_ ropts -> buildRows . sortTreeByAmount
ALFlat | sort_amount_ ropts -> sortFlatByAmount . buildRows
_ -> buildRows . sortAccountTreeByDeclaration

View File

@ -793,9 +793,8 @@ reportSpanHelper bothdates j ReportSpec{_rsQuery=query, _rsReportOpts=ropts} =
pricespan = dbg3 "pricespan" . DateSpan Nothing $ case value_ ropts of
Just (AtEnd _) -> fmap (Exact . 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 `spanExtend` pricespan)
-- If the requested span has open ends, fill those with the journal's start and/or end dates, if possible.
requestedspan' = dbg3 "requestedspan'" $ requestedspan `spanValidDefaultsFrom` (journalspan `spanExtend` 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

View File

@ -256,7 +256,7 @@ withJournalCached defaultJournalOverride cliopts cmd = do
-- could use 'forecastPeriod') or to the journal end date (as
-- forecast transactions are never generated before journal end
-- unless specifically requested).
Just forecastspan -> forecastspan `spanDefaultsFrom` reportspan_ iopts
Just forecastspan -> forecastspan `spanValidDefaultsFrom` reportspan_ iopts
-- Read stdin, or if we read it alread, use a cache
-- readStdin :: InputOpts -> ExceptT String IO Journal
readStdin = do

View File

@ -232,3 +232,23 @@ Balance changes in 2014-01-10..2014-03-09:
explicit report period || 10 0
------------------------++------------------------------------------------
|| 10 100
# ** 19. The historical balances are shown here even though the report period contains no postings. (#2403)
<
2024-01-01
(a) 1000
$ hledger -f - bal -NHY -b 2025
Ending balances (historical) in 2025:
|| 2025-12-31
===++============
a || 1000
# ** 20. Without -H, no balance changes are shown here, even with -E.
# Should it show zero for each of the pre-report-period accounts ?
$ hledger -f - bal -NY -b 2025
Balance changes in 2025:
|| 2025
==++======

View File

@ -235,9 +235,10 @@ $ hledger -f- close
>=
# ** 16. "override the closing date ... by specifying a report period, where last day of the report period will be the closing date"
# With no data to close in the period, this is currently giving an error. XXX
$ hledger -f- close -e 100000-01-01
> /99999-12-31 closing balances/
>=
>2 /Error: balanceDataPeriodEnds: expected initial span to have an end date/
>=1
# ** 17. close (and print) should add trailing decimal marks when needed to posting amounts and costs.
<