hledger-interest 1.7.0 → 2.0.0
raw patch · 134 files changed
+3618/−729 lines, 134 filesdep +bytestringdep +megaparsecdep +tastydep ~basedep ~hledger-lib
Dependencies added: bytestring, megaparsec, tasty, tasty-golden, tasty-hunit, typed-process
Dependency ranges changed: base, hledger-lib
Files
- CHANGELOG.md +81/−7
- Hledger/Interest.hs +0/−148
- Hledger/Interest/DayCountConvention.hs +0/−53
- Hledger/Interest/Rate.hs +0/−171
- Main.hs +0/−125
- README.md +284/−157
- hledger-interest.cabal +115/−68
- src/Hledger/Interest.hs +186/−0
- src/Hledger/Interest/DayCountConvention.hs +53/−0
- src/Hledger/Interest/Rate.hs +43/−0
- src/Hledger/Interest/ScheduleFile.hs +75/−0
- src/hledger-interest.hs +152/−0
- tests/HistoricalRates.hs +37/−0
- tests/calculation-tests.hs +314/−0
- tests/fixtures/after-coverage.journal +9/−0
- tests/fixtures/assertions-invalid.journal +10/−0
- tests/fixtures/assertions-valid.journal +10/−0
- tests/fixtures/before-coverage.journal +9/−0
- tests/fixtures/bgb288-start.journal +9/−0
- tests/fixtures/bgb288.rates +57/−0
- tests/fixtures/boundaries.journal +37/−0
- tests/fixtures/comments-only.rates +8/−0
- tests/fixtures/costed-loan.journal +5/−0
- tests/fixtures/cutoff.journal +17/−0
- tests/fixtures/db24-start.journal +9/−0
- tests/fixtures/effective-dates.journal +17/−0
- tests/fixtures/empty.rates +0/−0
- tests/fixtures/historical.journal +17/−0
- tests/fixtures/ing-diba-start.journal +9/−0
- tests/fixtures/leap-crossing.journal +9/−0
- tests/fixtures/literal-account.journal +17/−0
- tests/fixtures/multi-currency.journal +14/−0
- tests/fixtures/multiple-postings.journal +12/−0
- tests/fixtures/negative.rates +3/−0
- tests/fixtures/overpayment.journal +10/−0
- tests/fixtures/positive.rates +3/−0
- tests/fixtures/repayment-and-new-loan.journal +14/−0
- tests/fixtures/review-30-360-monthly.journal +54/−0
- tests/fixtures/review-costed-repayment.journal +9/−0
- tests/fixtures/review-posting-date-cutoff.journal +14/−0
- tests/fixtures/review-posting-date-earlier.journal +9/−0
- tests/fixtures/review-posting-date-later.journal +5/−0
- tests/fixtures/review-posting-date-year-end.journal +5/−0
- tests/fixtures/review-posting-date2.journal +5/−0
- tests/fixtures/schedule-bad-second-line.rates +4/−0
- tests/fixtures/schedule-crlf.rates +5/−0
- tests/fixtures/schedule-double-percent.rates +3/−0
- tests/fixtures/schedule-extra-field.rates +3/−0
- tests/fixtures/schedule-indented-spaces.rates +3/−0
- tests/fixtures/schedule-indented-tab.rates +3/−0
- tests/fixtures/schedule-inline-hash.rates +3/−0
- tests/fixtures/schedule-inline-star.rates +3/−0
- tests/fixtures/schedule-invalid-date.rates +3/−0
- tests/fixtures/schedule-invalid-rate.rates +3/−0
- tests/fixtures/schedule-missing-rate.rates +3/−0
- tests/fixtures/schedule-mixed-date-separators.rates +3/−0
- tests/fixtures/schedule-no-newline.rates +5/−0
- tests/fixtures/schedule.rates +18/−0
- tests/fixtures/split-loan.journal +6/−0
- tests/fixtures/split-payment.journal +6/−0
- tests/fixtures/unicode.journal +9/−0
- tests/fixtures/year-end-transaction.journal +13/−0
- tests/fixtures/yearless.rates +4/−0
- tests/golden-tests.hs +409/−0
- tests/golden/annual-30-360.golden +28/−0
- tests/golden/annual-30E-360.golden +28/−0
- tests/golden/annual-30E-360isda.golden +28/−0
- tests/golden/annual-act.golden +28/−0
- tests/golden/annual-separate-account.golden +28/−0
- tests/golden/annual-verbose.golden +60/−0
- tests/golden/bgb288-after-last-rate.golden +4/−0
- tests/golden/bgb288-before-first-rate.golden +4/−0
- tests/golden/bgb288-first-period.golden +4/−0
- tests/golden/bgb288.golden +32/−0
- tests/golden/constant-30-360.golden +28/−0
- tests/golden/constant-30E-360.golden +28/−0
- tests/golden/constant-30E-360isda.golden +28/−0
- tests/golden/constant-act.golden +28/−0
- tests/golden/costed-loan.golden +4/−0
- tests/golden/db24-after-last-rate.golden +4/−0
- tests/golden/db24-before-first-rate.golden +4/−0
- tests/golden/db24-first-period.golden +4/−0
- tests/golden/db24.golden +16/−0
- tests/golden/effective-dates.golden +12/−0
- tests/golden/help.golden +35/−0
- tests/golden/ing-diba-after-last-rate.golden +4/−0
- tests/golden/ing-diba-before-first-rate.golden +4/−0
- tests/golden/ing-diba-first-period.golden +4/−0
- tests/golden/ing-diba.golden +20/−0
- tests/golden/literal-account.golden +12/−0
- tests/golden/multi-currency-transactions.golden +12/−0
- tests/golden/multi-currency.golden +12/−0
- tests/golden/multiple-files.golden +8/−0
- tests/golden/multiple-postings-transactions.golden +8/−0
- tests/golden/multiple-postings.golden +8/−0
- tests/golden/negative-rate.golden +28/−0
- tests/golden/no-year-end-leap-30-360.golden +4/−0
- tests/golden/no-year-end-leap-30E-360.golden +4/−0
- tests/golden/no-year-end-leap-30E-360isda.golden +4/−0
- tests/golden/no-year-end-leap-act.golden +4/−0
- tests/golden/overpayment-transactions.golden +8/−0
- tests/golden/overpayment.golden +8/−0
- tests/golden/rate-change-at-year-end.golden +8/−0
- tests/golden/repayment-and-new-loan-transactions.golden +8/−0
- tests/golden/repayment-and-new-loan.golden +8/−0
- tests/golden/schedule-30-360.golden +40/−0
- tests/golden/schedule-30E-360.golden +40/−0
- tests/golden/schedule-30E-360isda.golden +40/−0
- tests/golden/schedule-act.golden +40/−0
- tests/golden/schedule-no-year-end-30-360.golden +36/−0
- tests/golden/schedule-no-year-end-30E-360.golden +36/−0
- tests/golden/schedule-no-year-end-30E-360isda.golden +36/−0
- tests/golden/schedule-no-year-end-act.golden +36/−0
- tests/golden/schedule-transactions-only-30-360.golden +24/−0
- tests/golden/schedule-transactions-only-30E-360.golden +24/−0
- tests/golden/schedule-transactions-only-30E-360isda.golden +24/−0
- tests/golden/schedule-transactions-only-act.golden +24/−0
- tests/golden/transaction-at-year-end.golden +8/−0
- tests/golden/unicode.golden +12/−0
- tests/golden/until-after-last-transaction.golden +12/−0
- tests/golden/until-default-30-360.golden +28/−0
- tests/golden/until-default-30E-360.golden +28/−0
- tests/golden/until-default-30E-360isda.golden +28/−0
- tests/golden/until-default-act.golden +28/−0
- tests/golden/until-effective-dates.golden +20/−0
- tests/golden/until-rate-changes-30-360.golden +24/−0
- tests/golden/until-rate-changes-30E-360.golden +24/−0
- tests/golden/until-rate-changes-30E-360isda.golden +24/−0
- tests/golden/until-rate-changes-act.golden +24/−0
- tests/golden/until-transactions-30-360.golden +16/−0
- tests/golden/until-transactions-30E-360.golden +16/−0
- tests/golden/until-transactions-30E-360isda.golden +16/−0
- tests/golden/until-transactions-act.golden +16/−0
- tests/golden/zero-rate.golden +28/−0
CHANGELOG.md view
@@ -1,5 +1,80 @@ # Revision history for hledger-interest +## 2.0.0 (2026-11-11)++### Incompatible changes++- The built-in `--bgb288`, `--ing-diba`, and `--db24` modes have been+ removed. hledger-interest no longer contains bank-specific or statutory+ rate tables; supply the rates with `--schedule` or `--schedule-file`+ together with an explicit day-count flag. Unlike the former modes,+ schedules earn no interest before their first entry and keep the last+ rate indefinitely. The historical tables survive as regression tests,+ which also show how to reproduce the former modes' output.+- The interest account is matched literally by its full name. Punctuation+ is no longer interpreted as a regular expression, subaccounts are no+ longer included, and an account without matching transactions is an+ error.+- Rate selection is independent of posting frequency. Every rate option,+ including `--constant`, now posts interest at year ends by default;+ `--constant` and `--annual` are equivalent.+- Interest is no longer computed backwards: balance changes are processed+ in effective-date order, and overlapping periods are rejected instead of+ being counted twice.++### Changes that affect computed interest++- `--30-360`, `--30E-360`, and `--30E-360isda` miscounted periods that+ start on a day adjusted by the convention: a year of monthly payments on+ the 30th accrued 365, 367, or 358 days instead of 360. Periods are now+ counted from the last accounted day, so consecutive periods add up to+ the convention's count over the whole span. `--act` is unaffected+ ([#29](https://github.com/peti/hledger-interest/issues/29)).+- Posting-level dates are honored. A posting's own date, or its secondary+ date, determines when it changes the balance, taking precedence over the+ transaction's dates+ ([#31](https://github.com/peti/hledger-interest/issues/31)).+- Secondary transaction dates are respected when ordering transactions.+- Transaction costs (`@` and `@@`) no longer leak into the principal,+ the descriptions, or the generated postings.+- `--act` splits calculations at calendar-year boundaries in every posting+ mode, so each year uses its correct 365/366-day denominator. These+ internal splits do not compound interest unless it is actually posted.+- No rate is looked up while the balance is zero, e.g. before the first+ loan.++### New features++- `--until=YYYY-MM-DD` computes and posts interest through an inclusive+ cutoff date and ignores later transactions, adapting Bas van Dijk's+ contribution in [#19](https://github.com/peti/hledger-interest/pull/19).+ `--today` is now an alias for `--until=today`; the last cutoff option+ wins.+- `--schedule-file=PATH` reads dated annualized rates from a text file.+ Rates may be given as fractions or percentages, and the file accepts+ hledger date and comment syntax. Syntax errors report the file, line,+ and column.+- `--schedule` specifies annualized rates and their effective dates;+ `--annual-schedule` remains as an alias.+- `--post-on-rate-changes` posts interest at transactions and rate-period+ ends, but not at year ends. `--post-on-transactions` takes precedence+ when both flags are given.++### Other changes++- Invalid rate arguments and a missing account argument are reported+ before the journal is read, and computation errors are shown without+ exception noise.+- The `--verbose` and `--quiet` help texts describe the output accurately:+ verbose mode prints the matching input transactions, not the entire+ journal.+- The README documents the rounding and precision policy: interest accrues+ unrounded, and only its display is rounded.+- A new `calculations` test suite checks interest amounts and posting+ dates against hand-derived values, and the golden-file `regression`+ suite covers all day-count conventions with all posting modes.+- Require hledger-lib >= 1.32.+ ## 1.7.0 (2026-11-11) ### Changes that affect computed interest@@ -12,8 +87,8 @@ stray entry in the table of base rates shadowed all later rates. The table has been fixed, checked against the rates published by the Deutsche Bundesbank, and extended up to 2026-12-31. Dates after the- end of the table now produce a clear error message instead of a- crash.+ end of the table now fail with an explicit message requesting a+ rate-table update. - `--annual-schedule` used the rate in effect at the start of a period until the next transaction or year end, ignoring any rate changes in between, and a new rate took effect only on the day after its start@@ -33,11 +108,10 @@ ### New features -- The new `--post-on-transactions` flag posts interest only when the- balance changes (and on the final day with `--today`), rather than at- the end of every year and rate period. Interest is still computed- separately for every year and rate, but no longer compounds at the end- of each year+- The new `--post-on-transactions` flag posts interest only on transaction+ dates (and on the final day with `--today`). Year and rate boundaries+ can still split the calculation, but do not themselves trigger posting+ or compounding ([#16](https://github.com/peti/hledger-interest/issues/16), [#26](https://github.com/peti/hledger-interest/issues/26)). - Without `--file`, hledger-interest now reads `$LEDGER_FILE` or
− Hledger/Interest.hs
@@ -1,148 +0,0 @@-module Hledger.Interest- ( Computer, runComputer- , Config(..)- , InterestState(..), nullInterestState- , processTransaction, computeInterest- , module Hledger.Interest.DayCountConvention- , module Hledger.Interest.Rate- , module Hledger.Data- )- where--import Hledger.Data-import Hledger.Interest.DayCountConvention-import Hledger.Interest.Rate--import Control.Monad-import Control.Monad.RWS-import Data.Decimal-import Data.Maybe-import qualified Data.Text as T-import Data.Time.Calendar-import Data.Time.Calendar.OrdinalDate--type Computer = RWS Config [Transaction] InterestState--runComputer :: Config -> Computer () -> [Transaction]-runComputer cfg f = ts- where ((),_,ts) = runRWS f cfg nullInterestState--data Config = Config- { interestAccount :: AccountName- , sourceAccount :: AccountName- , targetAccount :: AccountName- , dayCountConvention :: DayCountConvention- , interestRate :: Rate- , postOnTransactions :: Bool- -- ^ Post accrued interest only on transaction dates rather than at the- -- end of every rate period (e.g. at every year end with 'perAnno').- }--data InterestState = InterestState- { balancedUntil :: Day- , balance :: MixedAmount- , accruedDays :: Integer -- ^ interest accrued but not posted yet- , accruedInterest :: MixedAmount- }--nullInterestState :: InterestState-nullInterestState = InterestState- { balancedUntil = nulldate- , balance = nullmixedamt- , accruedDays = 0- , accruedInterest = nullmixedamt- }--processTransaction :: Transaction -> Computer ()-processTransaction ts = do- let day = fromMaybe (tdate ts) (tdate2 ts)- computeInterest day- interestAcc <- asks interestAccount- let posts = [ p | p <- tpostings ts, interestAcc == paccount p ]- forM_ posts $ \p -> do- bal <- gets balance- modify (\st -> st { balance = bal + pamount p })---- | Compute and post all interest up to and including the given day.-computeInterest :: Day -> Computer ()-computeInterest day = accrueInterest day >> postInterest day Nothing--accrueInterest :: Day -> Computer ()-accrueInterest day = do- from <- gets balancedUntil- bal <- gets balance- rate <- asks interestRate- let (endOfPeriod,ratePerAnno) = rate from- to = min day endOfPeriod- newFrom = succ to- modify (\st -> st { balancedUntil = newFrom })- when (to >= from && not (mixedAmountIsZero bal)) $ do- diff <- asks dayCountConvention- yearDays <- daysInYear to- let days = (from `diff` to) + 1- interest = mixed [ a { aquantity = (aquantity a * ratePerAnno) / fromInteger yearDays * fromInteger days } | a <- amounts bal ]- modify (\st -> st { accruedDays = accruedDays st + days- , accruedInterest = accruedInterest st + interest- })- onTransactionsOnly <- asks postOnTransactions- unless onTransactionsOnly (postInterest to (Just ratePerAnno))- when (newFrom <= day) (accrueInterest day)---- | Post all accrued interest as one transaction on the given day. The--- description mentions the rate only if one is given, i.e. if the interest--- was accrued over a single rate period.-postInterest :: Day -> Maybe Decimal -> Computer ()-postInterest day ratePerAnno = do- st <- get- when (accruedDays st > 0) $ do- put st { accruedDays = 0, accruedInterest = nullmixedamt }- t <- mkTrans day (accruedDays st) (accruedInterest st) ratePerAnno- tell [t]- processTransaction t--daysInYear :: Day -> Computer Integer-daysInYear now = asks dayCountConvention >>= \diff -> return (day1 `diff` day2)- where day1 = fromGregorian (fst (toOrdinalDate now)) 1 1- day2 = fromGregorian (succ (fst (toOrdinalDate now))) 1 1---- | Create the transaction for the given interest, which was accrued over--- the given number of days.-mkTrans :: Day -> Integer -> MixedAmount -> Maybe Decimal -> Computer Transaction-mkTrans day days interest ratePerAnno = do- bal <- gets balance- srcAcc <- asks sourceAccount- targetAcc <- asks targetAccount- let t = nulltransaction- { tdate = day- , tdescription = T.pack $ maybe "" (\r -> showPercent r ++ " ") ratePerAnno ++ "interest for " ++ showMixedAmountOneLine bal ++ " over " ++ show days ++ " days"- , tpostings = [pTarget,pSource]- }- -- balance might have been an exact amount, in which case its precision might- -- be set to 0 decimal places. We ensure that interest is displayed with at least- -- two decimal points- pTarget = nullposting- { paccount = targetAcc- , pamount = mixed [ setMinPrecision 2 a | a <- amounts interest ]- , ptype = RegularPosting- , ptransaction = Just t- }- pSource = nullposting- { paccount = srcAcc- , pamount = negate (pamount pTarget)- , ptype = RegularPosting- , ptransaction = Just t- }- return t- where- -- Copy of amountSetPrecisionMin (with amountDisplayPrecision inlined) from- -- hledger-lib >= 1.32, which older hledger-lib versions (still allowed by our- -- lower bound) do not provide- setMinPrecision minp a = amountSetPrecision (Precision $ max minp displayPrecision) a- where- displayPrecision =- case asprecision $ astyle a of- Precision n -> n- NaturalPrecision -> decimalPlaces (normalizeDecimal (aquantity a))--showPercent :: Decimal -> String-showPercent r = shows (r * 100) "%"
− Hledger/Interest/DayCountConvention.hs
@@ -1,53 +0,0 @@-module Hledger.Interest.DayCountConvention- ( DayCountConvention- , diffAct- , diff30_360- , diff30E_360- , diff30E_360isda- )- where--import Control.Exception ( assert )-import Data.Time.Calendar--type DayCountConvention = Day -> Day -> Integer--diffAct :: DayCountConvention-diffAct date1 date2 = assert (date1 <= date2) $ fromInteger (date2 `diffDays` date1)--mkDiff30_360 :: (Integer,Int,Int) -> (Integer,Int,Int) -> Integer-mkDiff30_360 (y1,m1,d1) (y2,m2,d2) = 360*(y2-y1) + 30*toInteger (m2-m1) + toInteger (d2-d1)---- The un-corrected naked formular.--diff30_360 :: DayCountConvention-diff30_360 date1 date2 = assert (date1 <= date2) $ mkDiff30_360 (toGregorian date1) (toGregorian date2)---- No month has more than 30 days, but February may have 28 or 29; i.e.--- there are 32 days between 2003-02-28 and 2003-03-31. Commonly known--- as "Deutsche Zinsmethode 30 / 360".--diff30E_360 :: DayCountConvention-diff30E_360 date1 date2 = assert (date1 <= date2) $ mkDiff30_360 (y1, m1, min 30 d1) (y2, m2, min 30 d2)- where- (y1,m1,d1) = toGregorian date1- (y2,m2,d2) = toGregorian date2---- | This variant additionally normalizes end-of-months to 30, i.e. there--- are 30 days between 2003-02-28 and 2003-03-31. (Strict 30E/360 ISDA--- exempts a February month-end that is the maturity date; we have no--- notion of maturity, so that exception is not applied.)------ >>> diff30E_360isda (fromGregorian 2003 2 28) (fromGregorian 2003 3 31)--- 30--- >>> diff30E_360isda (fromGregorian 2023 1 31) (fromGregorian 2023 2 15)--- 15--- >>> diff30E_360isda (fromGregorian 2023 1 15) (fromGregorian 2023 2 28)--- 45-diff30E_360isda :: DayCountConvention-diff30E_360isda date1 date2 = assert (date1 <= date2) $ mkDiff30_360 (y1, m1, d1') (y2, m2, d2')- where- (y1,m1,d1) = toGregorian date1- (y2,m2,d2) = toGregorian date2- d1' = if d1 == gregorianMonthLength y1 m1 then 30 else d1- d2' = if d2 == gregorianMonthLength y2 m2 then 30 else d2
− Hledger/Interest/Rate.hs
@@ -1,171 +0,0 @@-module Hledger.Interest.Rate ( Rate, perAnno, perAnnoSchedule, constant, bgb288, ingDiba, db24 ) where--import Data.Time.Calendar-import Data.Time.Calendar.OrdinalDate-import Data.Decimal-import Data.List (sortOn, find)-import Data.Maybe (fromMaybe)--type Rate = Day -> (Day,Decimal)--constant :: Decimal -> Rate-constant rate _ = (day 999999 12 31, rate)--perAnno :: Decimal -> Rate-perAnno rate date = (yearEnd date, rate)--perAnnoSchedule :: [(Day,Decimal)] -> Rate-perAnnoSchedule schedule date = (effectiveUntil, effectiveRate)- where- effectiveUntil =- -- make sure that end date we report does not cross the year boundary- min (yearEnd date) endOfPeriod- (endOfPeriod, effectiveRate) =- case find (\(fromDate, toDate, _) -> fromDate<=date && date<=toDate) fromToSchedule of- -- date precedes the schedule: no interest until the first rate takes effect- Nothing -> (beforeSchedule, 0)- Just (_,toDate,rate) -> (toDate, rate)- beforeSchedule =- case sortedSchedule of- (firstDate, _):_ -> pred firstDate- [] -> yearEnd date- fromToSchedule =- -- Convert the list of (startDate,rate) to (startDate,endDate,rate) intervals, where- -- the end date of the interval is the day before the start of the next interval,- -- and the last interval goes to infinity- zipWith- (\(from, rate) (to, _) -> (from, pred to, rate))- sortedSchedule- (drop 1 sortedSchedule ++ [(day 999999 1 1,0)])- sortedSchedule = sortOn fst schedule--day :: Integer -> Int -> Int -> Day-day = fromGregorian--yearEnd :: Day -> Day-yearEnd date = day (fst (toOrdinalDate date)) 12 31---- | Interest according to German BGB §288: the base rate published by the--- Deutsche Bundesbank (BGB §247) plus 5 percentage points.------ >>> bgb288 (day 2011 08 15)--- (2011-12-31,0.0537)--- >>> bgb288 (day 2018 07 01)--- (2018-12-31,0.0412)--- >>> bgb288 (day 2023 06 01)--- (2023-06-30,0.0662)--- >>> bgb288 (day 2026 07 01)--- (2026-12-31,0.0652)-bgb288 :: Rate-bgb288 = basiszins (5/100)--basiszins :: Decimal -> Rate-basiszins r date =- case tableRate basiszinsPeriods date of- Just (to, p) -> (to, r + p)- Nothing -> error ("hledger-interest: the BGB base rate after " ++ show lastEnd ++- " is not known yet; please update basiszinsTable in Hledger/Interest/Rate.hs")- where- lastEnd = case reverse basiszinsPeriods of- (_,to,_) : _ -> to- [] -> error "basiszinsTable is empty"---- | Find the period of a (from, to, rate) table that has not ended yet at--- the given date, and return its end date and rate.-tableRate :: [(Day, Day, Decimal)] -> Day -> Maybe (Day, Decimal)-tableRate table date =- case dropWhile (\(_,to,_) -> to < date) table of- (_,to,p) : _ -> Just (to, p)- [] -> Nothing---- | Like 'tableRate', but fail with an error message if the table does not--- cover the given date.-tableRate' :: String -> [(Day, Day, Decimal)] -> Rate-tableRate' name table date =- fromMaybe (error ("hledger-interest: no " ++ name ++ " interest rate is known for " ++ show date))- (tableRate table date)---- | Turn the start dates of 'basiszinsTable' into (from, to, rate) periods.--- Each period ends the day before the next one starts; the last one is--- valid for six months.-basiszinsPeriods :: [(Day, Day, Decimal)]-basiszinsPeriods = zipWith (\(from, p) to -> (from, to, p)) basiszinsTable ends- where- starts = map fst basiszinsTable- ends = map pred (drop 1 starts ++ map (addGregorianMonthsClip 6) (take 1 (reverse starts)))---- | Basiszinssatz according to BGB §247, keyed by the date it takes effect.--- Source: https://www.bundesbank.de/de/bundesbank/organisation/agb-und-regelungen/basiszinssatz-607820-basiszinsTable :: [(Day, Decimal)]-basiszinsTable =- [ (day 2002 01 01, 257 / 10000)- , (day 2002 07 01, 247 / 10000)- , (day 2003 01 01, 197 / 10000)- , (day 2003 07 01, 122 / 10000)- , (day 2004 01 01, 114 / 10000)- , (day 2004 07 01, 113 / 10000)- , (day 2005 01 01, 121 / 10000)- , (day 2005 07 01, 117 / 10000)- , (day 2006 01 01, 137 / 10000)- , (day 2006 07 01, 195 / 10000)- , (day 2007 01 01, 270 / 10000)- , (day 2007 07 01, 319 / 10000)- , (day 2008 01 01, 332 / 10000)- , (day 2008 07 01, 319 / 10000)- , (day 2009 01 01, 162 / 10000)- , (day 2009 07 01, 12 / 10000)- , (day 2010 01 01, 12 / 10000)- , (day 2010 07 01, 12 / 10000)- , (day 2011 01 01, 12 / 10000)- , (day 2011 07 01, 37 / 10000)- , (day 2012 01 01, 12 / 10000)- , (day 2012 07 01, 12 / 10000)- , (day 2013 01 01, (-13) / 10000)- , (day 2013 07 01, (-38) / 10000)- , (day 2014 01 01, (-63) / 10000)- , (day 2014 07 01, (-73) / 10000)- , (day 2015 01 01, (-83) / 10000)- , (day 2015 07 01, (-83) / 10000)- , (day 2016 01 01, (-83) / 10000)- , (day 2016 07 01, (-88) / 10000)- , (day 2017 01 01, (-88) / 10000)- , (day 2017 07 01, (-88) / 10000)- , (day 2018 01 01, (-88) / 10000)- , (day 2018 07 01, (-88) / 10000)- , (day 2019 01 01, (-88) / 10000)- , (day 2019 07 01, (-88) / 10000)- , (day 2020 01 01, (-88) / 10000)- , (day 2020 07 01, (-88) / 10000)- , (day 2021 01 01, (-88) / 10000)- , (day 2021 07 01, (-88) / 10000)- , (day 2022 01 01, (-88) / 10000)- , (day 2022 07 01, (-88) / 10000)- , (day 2023 01 01, 162 / 10000)- , (day 2023 07 01, 312 / 10000)- , (day 2024 01 01, 362 / 10000)- , (day 2024 07 01, 337 / 10000)- , (day 2025 01 01, 227 / 10000)- , (day 2025 07 01, 127 / 10000)- , (day 2026 01 01, 127 / 10000)- , (day 2026 07 01, 152 / 10000)- ]--ingDiba :: Rate-ingDiba = tableRate' "ING-DiBa" ingDibaTable--ingDibaTable :: [(Day, Day, Decimal)]-ingDibaTable =- [ (day 2009 01 01, day 2009 12 31, 150 / 10000)- , (day 2010 01 01, day 2010 12 31, 150 / 10000)- , (day 2011 01 01, day 2011 07 14, 150 / 10000)- , (day 2011 07 15, day 2999 12 31, 175 / 10000)- ]--db24 :: Rate-db24 = tableRate' "Deutsche Bank 24" db24Table--db24Table :: [(Day, Day, Decimal)]-db24Table =- [ (day 2000 10 06, day 2010 09 15, 638 / 10000)- , (day 2010 09 16, day 2999 12 31, 415 / 10000)- ]
− Main.hs
@@ -1,125 +0,0 @@-module Main ( main ) where--import Hledger.Interest-import Hledger.Query-import Hledger.Read-import Hledger.Utils ( toRegex' )--import Control.Exception ( ErrorCall(..), bracket, handle )-import Control.Monad-import Data.List ( sortOn )-import Data.Maybe-import qualified Data.Text as T-import Data.Version-import System.Console.GetOpt-import System.Environment-import System.Exit-import System.IO--import Paths_hledger_interest ( version )--data Options = Options- { optVerbose :: Bool- , optShowVersion :: Bool- , optShowHelp :: Bool- , optInput :: [FilePath]- , optSourceAcc :: String- , optTargetAcc :: String- , optDCC :: Maybe DayCountConvention- , optRate :: Maybe Rate- , optBalanceToday :: Bool- , optPostOnTxns :: Bool- , optIgnoreAssertions :: Bool- }--defaultOptions :: Options-defaultOptions = Options- { optVerbose = True- , optShowVersion = False- , optShowHelp = False- , optInput = []- , optSourceAcc = ""- , optTargetAcc = ""- , optDCC = Nothing- , optRate = Nothing- , optBalanceToday = False- , optPostOnTxns = False- , optIgnoreAssertions = False- }--options :: [OptDescr (Options -> Options)]-options =- [ Option ['h'] ["help"] (NoArg (\o -> o { optShowHelp = True })) "print this message and exit"- , Option ['V'] ["version"] (NoArg (\o -> o { optShowVersion = True })) "show version number and exit"- , Option ['v'] ["verbose"] (NoArg (\o -> o { optVerbose = True })) "echo input ledger to stdout (default)"- , Option ['q'] ["quiet"] (NoArg (\o -> o { optVerbose = False })) "don't echo input ledger to stdout"- , Option [] ["today"] (NoArg (\o -> o { optBalanceToday = True })) "compute interest up until today"- , Option [] ["post-on-transactions"] (NoArg (\o -> o { optPostOnTxns = True })) "post interest only when the balance changes\n(and with --today), not at the end of\nevery year or rate period"- , Option ['f'] ["file"] (ReqArg (\f o -> o { optInput = f : optInput o }) "FILE") "input ledger file (pass '-' for stdin)\ndefault: $LEDGER_FILE or ~/.hledger.journal"- , Option ['s'] ["source"] (ReqArg (\a o -> o { optSourceAcc = a }) "ACCOUNT") "interest source account"- , Option ['t'] ["target"] (ReqArg (\a o -> o { optTargetAcc = a }) "ACCOUNT") "interest target account"- , Option ['I'] ["ignore-assertions"] (NoArg (\o -> o { optIgnoreAssertions = True })) "ignore any failing balance assertions"- , Option [] ["act"] (NoArg (\o -> o { optDCC = Just diffAct })) "use 'act/act' day counting convention\n(Actual/Actual ISDA; see README)"- , Option [] ["30-360"] (NoArg (\o -> o { optDCC = Just diff30_360 })) "use '30/360' day counting convention"- , Option [] ["30E-360"] (NoArg (\o -> o { optDCC = Just diff30E_360 })) "use '30E/360' day counting convention"- , Option [] ["30E-360isda"] (NoArg (\o -> o { optDCC = Just diff30E_360isda })) "use '30E/360isda' day counting convention"- , Option [] ["constant"] (ReqArg (\r o -> o { optRate = Just (constant (read r)) }) "RATE") "constant interest rate"- , Option [] ["annual"] (ReqArg (\r o -> o { optRate = Just (perAnno (read r)) }) "RATE") "annual interest rate"- , Option [] ["annual-schedule"] (ReqArg (\r o -> o { optRate = Just (perAnnoSchedule (read r)) }) "SCHEDULE") "schedule of annual interest rates.\nsyntax: '[(Date1,Rate1),(Date2,Rate2),...]'"- , Option [] ["bgb288"] (NoArg (\o -> o { optRate = Just bgb288, optDCC = Just diffAct })) "compute interest according to German BGB288"- , Option [] ["db24"] (NoArg (\o -> o { optRate = Just db24, optDCC = Just diff30E_360 })) "HACK: Deutsche Bank 24"- , Option [] ["ing-diba"] (NoArg (\o -> o { optRate = Just ingDiba, optDCC = Just diffAct })) "HACK: compute interest according for Ing-Diba Tagesgeld account"- ]--usageMessage :: String-usageMessage = usageInfo header options- where header = "Usage: hledger-interest [OPTION...] ACCOUNT"--commandLineError :: String -> IO a-commandLineError err = do hPutStrLn stderr (err ++ usageMessage)- exitFailure--parseOpts :: [String] -> IO (Options, [String])-parseOpts argv =- case getOpt Permute options argv of- (o,n,[] ) -> return (foldl (flip id) defaultOptions o, n)- (_,_,errs) -> commandLineError (concat errs)--main :: IO ()-main = bracket (return ()) (\() -> hFlush stdout >> hFlush stderr) $ \() -> do- (opts, args) <- getArgs >>= parseOpts- when (optShowVersion opts) (putStrLn (showVersion version) >> exitSuccess)- when (optShowHelp opts) (putStr usageMessage >> exitSuccess)- when (null (optSourceAcc opts)) (commandLineError "required --source option is missing\n")- when (null (optTargetAcc opts)) (commandLineError "required --target option is missing\n")- when (isNothing (optDCC opts)) (commandLineError "no day counting convention specified\n")- when (isNothing (optRate opts)) (commandLineError "no interest rate specified\n")- let ledgerInputOptions = definputopts { balancingopts_ = (balancingopts_ definputopts) { ignore_assertions_ = optIgnoreAssertions opts } }- inputFiles <- case optInput opts of- [] -> pure <$> defaultJournalPath- fs -> return (reverse fs)- jnl' <- handle (\(ErrorCall msg) -> hPutStrLn stderr msg >> exitFailure) $- runExceptT (readJournalFiles ledgerInputOptions inputFiles) >>= either fail return- interestAcc <- case args of- [] -> commandLineError "required argument ACCOUNT is missing\n"- [acc] -> return $ T.pack acc- _ -> commandLineError "only one interest ACCOUNT may be specified\n"- let jnl = filterJournalTransactions (Acct (toRegex' interestAcc)) jnl'- ts = sortOn tdate (jtxns jnl)- cfg = Config- { interestAccount = interestAcc- , sourceAccount = T.pack (optSourceAcc opts)- , targetAccount = T.pack (optTargetAcc opts)- , dayCountConvention = fromJust (optDCC opts)- , interestRate = fromJust (optRate opts)- , postOnTransactions = optPostOnTxns opts- }- thisDay <- getCurrentDay- let finalize- | optBalanceToday opts = computeInterest thisDay- | otherwise = return ()- ts' = runComputer cfg (mapM_ processTransaction ts >> finalize)- result- | optVerbose opts = ts' ++ ts- | otherwise = ts'- mapM_ (putStr . T.unpack . showTransaction) (sortOn tdate result)
README.md view
@@ -11,227 +11,354 @@ compute interest for a given ledger account. Using command line flags, the program can be configured to use various [day counting conventions](http://en.wikipedia.org/wiki/Day_count_convention), such as-"act/act", "30/360", "30E/360", and "30/360isda". Furthermore, it-supports several of different interest schemes, i.e. annual interest-with a fixed rate and the scheme mandated by the German law [§ 288 BGB-Verzugszinsen](http://de.wikipedia.org/wiki/Verzugszinssatz). Extending-support for other schemes is fairly easy, but currently requires hacking-the source code.+"act/act", "30/360", "30E/360", and "30E/360isda". It supports+fixed annualized rates and rates that change on specified dates. Rates+are expressed as fractions: `0.05` means 5% per year. -An overview over the available run-time options can be displayed by-running "`hledger-interest --help`":+Display the available command-line options with `hledger-interest --help`: Usage: hledger-interest [OPTION...] ACCOUNT- -v --verbose echo input ledger to stdout (default)- -q --quiet don't echo input ledger to stdout- --today compute interest up until today- --post-on-transactions post interest only when the balance changes- (and with --today), not at the end of- every year or rate period+ Default: post at transactions, rate-period ends, and year ends.+ -h --help print this message and exit+ -V --version show version number and exit+ -v --verbose print matching input transactions and generated interest (default)+ -q --quiet print generated interest only+ --today alias for --until=today+ --until=DATE compute interest through DATE (YYYY-MM-DD or today);+ exclude transactions with later effective dates+ --post-on-transactions post only at transactions (and at --until/--today);+ skip rate-period and automatic year-end postings;+ overrides --post-on-rate-changes+ --post-on-rate-changes post at transactions and rate-period ends;+ skip automatic year-end postings;+ --post-on-transactions takes precedence;+ --until/--today also post on the final day -f FILE --file=FILE input ledger file (pass '-' for stdin) default: $LEDGER_FILE or ~/.hledger.journal -s ACCOUNT --source=ACCOUNT interest source account -t ACCOUNT --target=ACCOUNT interest target account+ -I --ignore-assertions ignore any failing balance assertions --act use 'act/act' day counting convention (Actual/Actual ISDA; see README) --30-360 use '30/360' day counting convention --30E-360 use '30E/360' day counting convention --30E-360isda use '30E/360isda' day counting convention- --constant=RATE constant interest rate- --annual=RATE annual interest rate- --annual-schedule=SCHEDULE schedule of annual interest rates.+ --constant=RATE fixed annual interest rate+ --annual=RATE alias for --constant+ --schedule=SCHEDULE annualized rates and their effective dates;+ rates are fractions: 0.05 means 5% per year syntax: '[(Date1,Rate1),(Date2,Rate2),...]'- --bgb288 compute interest according to German BGB288+ --schedule-file=PATH read a schedule from a text file;+ one date and rate per line, starting in column 1;+ rates: 0.05 or 5%; hledger comments allowed+ --annual-schedule=SCHEDULE alias for --schedule When run, hledger-interest reads the [ledger file](http://hledger.org/MANUAL.html#file-format) designated by the-`--file` flag and filters all transactions that change the account-specified on the command line. All other accounts will be ignored. Every-time a transaction modifies the given account's balance -- thereby-changing the amount of money that earns interest --, hledger-interest-transfers the interest that accrued so far. Interest will be debited-from the account designed by the `--source` flag and credited to the-account designed by the `--target` flag.+`--file` flag and selects the postings to the account specified on the+command line. The account name must be given in full and is matched+literally, including punctuation; selecting a parent account does not+include its subaccounts. An account with no matching postings produces an+error. +Postings change the balance in effective-date order. The effective date of+a posting is the first one present of: the posting's secondary date, the+transaction's secondary date, the posting's own date, and the transaction's+date. Interest accrued through that date is posted before the posting+changes the balance. Transaction costs (`@` and `@@`) are ignored; interest+accrues on the posted amount only.++Generated interest is booked to the account designated by `--target`, with+the opposite amount booked to the account designated by `--source`. The+amounts' signs depend on the balance and rate. With `--verbose`, the+default, the output also contains every input transaction that touches the+selected account, unchanged and including its other postings.+ ## Day counting conventions For every period, hledger-interest computes interest = balance * rate * days / days-in-year -where `days` is the length of the period, both ends included, and-`days-in-year` is the length of the calendar year in which the period-ends. Both are counted with the selected convention:+where `days` is the length of the period, counted from the last day+already accounted for (for example, the loan date) to the posting date,+and `days-in-year` is the length of the calendar year in which the period+ends. Both are counted with the selected convention. For every convention,+the day counts of consecutive periods add up to the count over the whole+span: -- `--act` counts actual calendar days, so a year has 365 or 366 days.- `--annual`, `--annual-schedule`, and `--bgb288` never let a period- cross a year boundary, which makes this the [Actual/Actual- ISDA](https://en.wikipedia.org/wiki/Day_count_convention#Actual/Actual_ISDA)- method. It is also the reason why these rates post interest on- December 31st of every year (see below). With `--constant`, a period can span- several years and is divided by the length of the year it ends in, so- the result is only an approximation of Actual/Actual ISDA in that case.-- `--30-360`, `--30E-360`, and `--30E-360isda` count every month as 30- days and every year as 360 days. They differ in how they treat the- 31st and the end of February; see the [Wikipedia- article](https://en.wikipedia.org/wiki/Day_count_convention#30/360_methods)- for details. `--30E-360isda` does not apply the February exception- for the maturity date, because hledger-interest has no notion of- maturity.+- `--act` counts actual calendar days. Calculation splits at calendar-year+ boundaries, using 365 or 366 days for each year. This is the+ [Actual/Actual ISDA](https://en.wikipedia.org/wiki/Day_count_convention#Actual/Actual_ISDA)+ method for every rate option. Internal calculation splits do not cause+ compounding unless interest is actually posted.+- `--30-360` uses 30-day month offsets and a 360-day year, without+ adjusting day numbers at month ends.+- `--30E-360` also uses a 360-day year, but replaces the 31st with the+ 30th when counting days. February retains its actual last day.+- `--30E-360isda` additionally treats the last day of February as the+ 30th. It does not apply the February exception for the maturity date,+ because hledger-interest has no notion of maturity. -By default, hledger-interest posts the interest at the end of every-period: on every transaction date, but also on December 31st and on-every date at which the rate changes. If the interest is credited to the-interest-bearing account itself, it compounds from then on. Many loans-and savings accounts compound less often, though, e.g. only when a-payment is made. With `--post-on-transactions`, interest is still-computed separately for every year and rate, but posted only on-transaction dates (and on the final day with `--today`). Since such a-posting may cover several rates, its description does not mention a-rate, e.g. for a rate change from 5% to 7% on 2021-12-20:+Rate selection and posting frequency are independent. By default, interest+is posted on transaction dates, at the end of each rate period, and on+December 31st. Posting interest to the interest-bearing account compounds+it from the following day. +| Posting option | Transactions | Rate-period ends | Year ends |+|---|---|---|---|+| Default | Yes | Yes | Yes |+| `--post-on-rate-changes` | Yes | Yes | No |+| `--post-on-transactions` | Yes | No | No |++`--post-on-rate-changes` posts at transactions and rate-period ends, without+an additional automatic year-end posting. A transaction on December 31st+or a new rate taking effect on January 1st still causes a+posting on December 31st. `--post-on-transactions` takes precedence if both+flags are supplied, regardless of their order. All modes also post on the+final day when `--until` or `--today` is used.++With `--act`, calculations still split internally at year boundaries to+apply the correct 365/366-day denominator, even when year-end posting is+suppressed. Fixed 360-day conventions need no internal year split unless+a year-end posting is requested.++With `--post-on-transactions`, a posting may combine several rates, so its+description omits the rate, for example:+ 2022-01-10 interest for -9841.10 USD over 31 days Liabilities:Loan -53.65 USD Expenses:Interest 53.65 USD +## Rounding and precision++Interest is calculated using decimal arithmetic at its available precision.+Amounts are not explicitly rounded to cents when they accrue or are posted.+If interest is posted to the interest-bearing account, the full computed+quantity becomes part of the principal for subsequent calculations.+Deferred postings sum the unrounded accrued amounts before displaying them.++Display precision is separate from calculation precision. Generated postings+show at least two decimal places, retaining higher display precision from+the input. Formatting and display rounding follow `hledger-lib`'s rendering+rules; they do not change the quantity used internally. For example, an+interest amount of EUR 0.004 can be displayed as EUR 0.00 while EUR 0.004+is still added to the internal principal when compounding.++Consequently, later displayed balances and interest can differ slightly+from calculations made using only the printed amounts. Reading the output+journal back in also uses those printed amounts, not the undisplayed+fractions. The tool does not implement a policy of rounding each interest+posting to the currency's smallest unit before compounding.++## Calculation cutoff++Use `--until=YYYY-MM-DD` to compute interest through a specific date,+including that day. For example, `--until=2024-01-10` posts any remaining+interest on January 10th, even if there is no transaction on that date.+Dates may omit leading zeros in the month and day.++`--today` is an alias for `--until=today`, using the current local date.+Both options use one cutoff setting: if they are repeated or combined,+the last one wins. Without either option, computation stops at the last+matching transaction.++Postings with effective dates after the cutoff are ignored, and+transactions dated after the cutoff are not echoed. A secondary date takes+precedence over the primary date, so a transaction with a later primary+date is still included if its secondary date is on or before the cutoff.+Postings on the cutoff date are processed normally. A cutoff before the+first matching posting produces no output.+ ## Examples -Suppose that you've loaned 1000 Euro from your bank at an annual-interest rate of 5%, and that you would like to see how interest-develops over time. Then you would create a ledger file, say-`loan.ledger`, that looks something like this:+Suppose you borrowed EUR 1000 from your bank at 5% per year and made three+repayments. Store the following journal in `loan.ledger`: - 2010/09/26 Loan- Assets:Bank EUR 1000.00+ 2010-09-26 Loan+ Assets:Bank EUR 1000.00 Liabilities:Loan -Now, `ledger-interest` is run to determine the interest up until today:+ 2010-12-11 Payment+ Assets:Bank EUR -150.00+ Liabilities:Loan - $ hledger-interest -f loan.ledger --act --annual=0.05 --today -s Expenses:Interest -t Liabilities:Loan:Interest Liabilities:Loan- 2010/09/26 Loan- Assets:Bank EUR 1000.00+ 2011-03-07 Payment+ Assets:Bank EUR -300.00 Liabilities:Loan - 2010/12/31 5.00% interest for EUR -1000.00 over 96 days- Liabilities:Loan:Interest EUR -13.15- Expenses:Interest+ 2011-08-21 Payment+ Assets:Bank EUR -150.00+ Liabilities:Loan - 2011/08/22 5.00% interest for EUR -1000.00 over 234 days- Liabilities:Loan:Interest EUR -32.05- Expenses:Interest+To post interest to a separate account, run: -Note a separate credit account for the interest was chosen:-`Liabilities:Loan:Interest`. Consequently, interest accrued in one-interest period does *not* earn interest in the following periods. If-interest is credited to the main account instead, that behavior changes:+ $ hledger-interest -f loan.ledger --quiet --act --annual=0.05 -s Expenses:Interest -t Liabilities:Loan:Interest Liabilities:Loan+ 2010-12-11 5% interest for EUR -1000.00 over 76 days+ Liabilities:Loan:Interest EUR -10.41+ Expenses:Interest EUR 10.41 - $ hledger-interest -f loan.ledger --act --annual=0.05 --today -s Expenses:Interest -t Liabilities:Loan Liabilities:Loan- 2010/09/26 Loan- Assets:Bank EUR 1000.00- Liabilities:Loan+ 2010-12-31 5% interest for EUR -850.00 over 20 days+ Liabilities:Loan:Interest EUR -2.33+ Expenses:Interest EUR 2.33 - 2010/12/31 5.00% interest for EUR -1000.00 over 96 days- Liabilities:Loan EUR -13.15- Expenses:Interest+ 2011-03-07 5% interest for EUR -850.00 over 66 days+ Liabilities:Loan:Interest EUR -7.68+ Expenses:Interest EUR 7.68 - 2011/08/22 5.00% interest for EUR -1013.15 over 234 days- Liabilities:Loan EUR -32.48- Expenses:Interest+ 2011-08-21 5% interest for EUR -550.00 over 167 days+ Liabilities:Loan:Interest EUR -12.58+ Expenses:Interest EUR 12.58 -Of course, loans are supposed to be paid back, and these payments change-the amount of interest accrued. Suppose that `load.ledger` would be-extended by the following transactions:+The target account, `Liabilities:Loan:Interest`, differs from the selected+account, `Liabilities:Loan`, so the posted interest does not itself earn+interest. Selecting the parent account does not include its subaccounts.+The `--quiet` flag prints only generated interest postings. - 2010/12/11 Payment- Assets:Bank EUR -150.00- Liabilities:Loan+To compound interest, use the loan account itself as the target: - 2011/03/07 Payment- Assets:Bank EUR -300.00- Liabilities:Loan+ $ hledger-interest -f loan.ledger --quiet --act --annual=0.05 -s Expenses:Interest -t Liabilities:Loan Liabilities:Loan+ 2010-12-11 5% interest for EUR -1000.00 over 76 days+ Liabilities:Loan EUR -10.41+ Expenses:Interest EUR 10.41 - 2011/08/21 Payment- Assets:Bank EUR -150.00- Liabilities:Loan+ 2010-12-31 5% interest for EUR -860.41 over 20 days+ Liabilities:Loan EUR -2.36+ Expenses:Interest EUR 2.36 -Then interest would develop as follows:+ 2011-03-07 5% interest for EUR -862.77 over 66 days+ Liabilities:Loan EUR -7.80+ Expenses:Interest EUR 7.80 - $ hledger-interest -f loan.ledger --act --annual=0.05 -s Expenses:Interest -t Liabilities:Loan Liabilities:Loan- 2010/09/26 Loan- Assets:Bank EUR 1000.00- Liabilities:Loan+ 2011-08-21 5% interest for EUR -570.57 over 167 days+ Liabilities:Loan EUR -13.05+ Expenses:Interest EUR 13.05 - 2010/12/11 5.00% interest for EUR -1000.00 over 76 days- Liabilities:Loan EUR -10.41- Expenses:Interest+Interest posted on December 11th and at year end now becomes part of the+principal for subsequent calculations. Add `--today` to either command to+extend the calculation beyond the final repayment through the current date;+that additional output depends on when the command runs.+Use `--until=2011-08-22` instead for a fixed, reproducible cutoff. - 2010/12/11 Payment- Assets:Bank EUR -150.00- Liabilities:Loan+## Variable annualized rates - 2010/12/31 5.00% interest for EUR -860.41 over 20 days- Liabilities:Loan EUR -2.36- Expenses:Interest+Use `--schedule` to supply annualized rates and their effective dates,+together with an explicit day-count convention. For example: - 2011/03/07 5.00% interest for EUR -862.77 over 66 days- Liabilities:Loan EUR -7.80- Expenses:Interest+```sh+hledger-interest -f loan.ledger --act \+ '--schedule=[(2010-09-26,0.05),(2011-01-01,0.07)]' \+ --source=Expenses:Interest --target=Liabilities:Loan Liabilities:Loan+``` - 2011/03/07 Payment- Assets:Bank EUR -300.00- Liabilities:Loan+For the journal above, this applies 5% per year from September 26, 2010,+and 7% per year from January 1, 2011. Rates can change on any date. Dates+before the first entry earn zero interest, and the last rate continues+indefinitely. By default, interest is posted at year ends, at the end of+each rate period (the day before the next rate takes effect), and on+transaction dates. Use `--post-on-rate-changes` or+`--post-on-transactions` to control posting as explained above.+`--annual-schedule` remains an alias for `--schedule`. Likewise,+`--annual` is an alias for `--constant`; both specify a fixed annualized+rate. The executable contains no bank-specific or statutory rate tables;+supply the appropriate schedule yourself. - 2011/08/21 5.00% interest for EUR -570.57 over 167 days- Liabilities:Loan EUR -13.05- Expenses:Interest+### Schedule files - 2011/08/21 Payment- Assets:Bank EUR -150.00- Liabilities:Loan+Use `--schedule-file=PATH` to read the rates from a plain text file instead+of specifying a list on the command line. For example, `rates.txt` could+contain: -Last but not least, there is a special case known as "Verzugszinsen" in-German law, which applies when someone is supposed to pay a bill, but-fails to do so on time. For every day past the deadline, interest-accrues according to terms specified in [§ 247-BGB](http://www.gesetze-im-internet.de/bgb/__247.html). The command line-flag `--bgb288` enables this scheme in `hledger-interest`.+```text+# Annualized rates and their effective dates+2010-09-26 5%+2011/1/1 0.07 ; equivalent to 7% -Let's assume that customer ACME is supposed to pay 35 Euro by-2010/09/15, but the money actually arrives almost half a year late:+comment+This block is ignored.+end comment+``` - 2010/09/15 Services rendered to Customer ACME- ACME 1 hour @ EUR 35.00- Receivable:ACME+Run it with the loan journal above: - 2011/03/17 ACME- ACME EUR 35.00- Receivable:ACME+```sh+hledger-interest -f loan.ledger --act --schedule-file=rates.txt \+ --source=Expenses:Interest --target=Liabilities:Loan Liabilities:Loan+``` -According to German law, you are entitled to the following interest:+Each entry consists of a hledger date and an annualized rate, separated by+spaces or tabs. A fraction such as `0.15` and a percentage such as `15%`+are equivalent. Signed rates are supported, and whitespace before `%` or+at the end of a line is ignored. Empty and whitespace-only lines are ignored.+An entry must begin in column 1: ` 2026-01-01 0.05` is a syntax error. - $ hledger-interest -f acme.ledger --quiet --bgb288 -s Income:Interest -t Receivable:ACME:Interest Receivable:ACME- 2010/12/31 5.12% interest for EUR 35.00 over 107 days- Receivable:ACME:Interest EUR 0.53- Income:Interest+Dates accept `YYYY-MM-DD`, `YYYY/MM/DD`, or `YYYY.MM.DD`, with optional+leading zeros. As in hledger journals, dates without a year use the+current local year. Specify the year explicitly for reproducible schedules. - 2011/03/17 5.12% interest for EUR 35.00 over 76 days- Receivable:ACME:Interest EUR 0.37- Income:Interest+Comments follow hledger syntax: lines beginning with `#`, `;`, or `*`+(optionally indented) are ignored. A semicolon starts an inline comment;+`#` and `*` do not. A `comment` line starts a block that ends at an+`end comment` line or the end of the file. Both block delimiters start+in column 1. Windows line endings and a missing final newline are accepted. -So, if you're smart, then you'll book the payment so that the accrued-interest is paid *first*:+File schedules use the same sorting, posting, and rate rules as `--schedule`.+An empty or comments-only file specifies zero interest. When rate options+are repeated or combined, the last one wins; overridden files are not read.+Read and syntax errors identify the schedule file, with line and column+numbers for syntax errors. - 2011/03/17 ACME- ACME EUR 35.00- Receivable:ACME:Interest EUR -0.90- Receivable:ACME+## Regression tests -This gives the following transaction history for the ACME account:+Run all tests with: - $ hledger-interest -f acme.ledger --bgb288 -s Income:Interest -t Receivable:ACME:Interest Receivable:ACME |- hledger -f - reg Receivable:ACME- 2010/09/15 Services rendered .. Receivable:ACME EUR 35.00 EUR 35.00- 2010/12/31 5.12% interest for.. Re:ACME:Interest EUR 0.53 EUR 35.53- 2011/03/17 5.12% interest for.. Re:ACME:Interest EUR 0.37 EUR 35.90- 2011/03/17 ACME Re:ACME:Interest EUR -0.90 EUR 35.00- Receivable:ACME EUR -34.10 EUR 0.90+```sh+cabal test all --test-show-details=direct+```++The `calculations` suite (`tests/calculation-tests.hs`) checks the+computation engine directly against hand-derived rational values,+independently of rendered output. It covers day counts, repayment timing,+leap-year denominators, compounding, deferred posting, rate changes,+signs, balance transitions, and the rounding policy described above.+Comparisons use a tolerance of `10^-30`, which accommodates finite decimal+representations of recurring fractions while remaining far below any+rounding to cents.++The `regression` suite (`tests/golden-tests.hs`) runs the executable on+the journals in `tests/fixtures/` and compares its stdout byte for byte+with `tests/golden/*.golden`. Successful commands must produce no stderr+output; error cases check the exit status, empty stdout, and the+diagnostic. The cases cover:++- all four day-count conventions with constant, annual, and scheduled+ rates, in all three posting modes, given on the command line and in+ schedule files, with and without `--until`;+- leap days, month and year ends, rate changes, same-day payments,+ compounding versus separate interest accounts, zero and negative rates;+- secondary and posting-level dates, transaction costs, literal account+ names, Unicode, and verbose output;+- multiple currencies, full repayment followed by a new loan,+ overpayments, and several postings to the selected account;+- stdin, multiple journals, balance assertions, and invalid arguments;+- historical rate schedules: BGB288 rates in `tests/fixtures/bgb288.rates`,+ DB24 and ING-DiBa rates in `tests/HistoricalRates.hs`. The DB24 case+ reproduces the output of the former built-in mode exactly.++Golden cases use explicit years and avoid `--today`, so their output does+not depend on the clock.++To add a case, add its argument list to `tests/golden-tests.hs` and, if+needed, a fixture journal. Missing baselines fail the suite, even with+`--accept`, so create an empty one first (for example,+`touch tests/golden/new-case.golden`). Then record the expected output:++```sh+cabal test regression --test-options=--accept+git diff -- tests/golden+```++Review new and changed golden files before committing them. They capture+current behavior, including formatting supplied by `hledger-lib`, rather+than proving the calculations correct, so dependency upgrades may require+reviewed baseline updates.
hledger-interest.cabal view
@@ -1,5 +1,5 @@ Name: hledger-interest-Version: 1.7.0+Version: 2.0.0 Synopsis: computes interest for a given account License: BSD3 License-file: LICENSE@@ -8,8 +8,11 @@ Homepage: https://github.com/peti/hledger-interest Category: Finance Build-type: Simple-Cabal-version: >= 1.10-Extra-source-files: README.md+Cabal-version: 2.0+Extra-source-files: tests/fixtures/*.journal+ tests/fixtures/*.rates+ tests/golden/*.golden+Extra-doc-files: README.md CHANGELOG.md Stability: stable tested-with: GHC == 9.6.7, GHC == 9.8.4, GHC == 9.10.3,@@ -20,84 +23,102 @@ Michael's hledger library. Its purpose is to compute interest for a given ledger account. Using command line flags, the program can be configured to use various schemes for day-counting, such as act\/act,- 30\/360, 30E\/360, and 30\/360isda. Furthermore, it supports a (small)- number of interest schemes, i.e. annual interest with a fixed rate and- the scheme mandated by the German BGB288 (Basiszins fuer- Verbrauchergeschaefte). Extending support for other schemes is fairly- easy, but currently requires changes to the source code.+ 30\/360, 30E\/360, and 30E\/360isda. It supports constant rates,+ annual interest with a fixed rate, and schedules of annual rates+ supplied on the command line or read from a text file. .- As an example, consider the following loan, stored in a file called- @test.ledger@:+ As an example, consider the loan in the regression fixture+ @tests/fixtures/boundaries.journal@: .- > 2008/09/26 Loan+ > 2023-01-31 Loan > Assets:Bank EUR 10000.00- > Liabilities:Bank+ > Liabilities:Loan >- > 2008/11/27 Payment- > Assets:Bank EUR -3771.12- > Liabilities:Bank+ > 2023-02-28 Payment+ > Assets:Bank EUR -100.00+ > Liabilities:Loan >- > 2009/05/03 Payment- > Assets:Bank EUR -1200.00- > Liabilities:Bank+ > 2023-03-31 Payment+ > Assets:Bank EUR -100.00+ > Liabilities:Loan >- > 2010/12/10 Payment- > Assets:Bank EUR -3700.00- > Liabilities:Bank- .- Suppose that loan earns 5% interest per year, and payments amortize- interest before amortizing the principal claim, then the resulting- ledger would look like this:- .- > $ hledger-interest --file=test.ledger --source=Expenses:Interest --target=Liabilities:Bank --30-360 --annual=0.05 Liabilities:Bank- > 2008-09-26 Loan- > Assets:Bank EUR 10000.00- > Liabilities:Bank EUR -10000.00+ > 2023-07-01 Payment on rate change+ > Assets:Bank EUR -100.00+ > Liabilities:Loan >- > 2008-11-27 5% interest for EUR -10000.00 over 61 days- > Liabilities:Bank EUR -84.72- > Expenses:Interest EUR 84.72+ > 2023-07-01 Second payment on same day+ > Assets:Bank EUR -50.00+ > Liabilities:Loan >- > 2008-11-27 Payment- > Assets:Bank EUR -3771.12- > Liabilities:Bank EUR 3771.12+ > 2024-01-15 Payment after year end+ > Assets:Bank EUR -100.00+ > Liabilities:Loan >- > 2008-12-31 5% interest for EUR -6313.60 over 34 days- > Liabilities:Bank EUR -29.81- > Expenses:Interest EUR 29.81+ > 2024-02-29 Leap day payment+ > Assets:Bank EUR -100.00+ > Liabilities:Loan >- > 2009-05-03 5% interest for EUR -6343.42 over 123 days- > Liabilities:Bank EUR -108.37- > Expenses:Interest EUR 108.37+ > 2024-03-31 Payment+ > Assets:Bank EUR -100.00+ > Liabilities:Loan >- > 2009-05-03 Payment- > Assets:Bank EUR -1200.00- > Liabilities:Bank EUR 1200.00+ > 2024-04-01 Unrelated transaction+ > Assets:Bank EUR -10.00+ > Expenses:Other+ .+ With 5% annual interest and actual-day counting, crediting interest+ to the loan account compounds it. The following command prints only+ the generated interest postings; its output is checked by the+ @annual-act@ golden test:+ .+ > $ hledger-interest --file=tests/fixtures/boundaries.journal --source=Expenses:Interest --target=Liabilities:Loan --act --annual=0.05 --quiet Liabilities:Loan+ > 2023-02-28 5% interest for EUR -10000.00 over 28 days+ > Liabilities:Loan EUR -38.36+ > Expenses:Interest EUR 38.36 >- > 2009-12-31 5% interest for EUR -5251.78 over 238 days- > Liabilities:Bank EUR -173.60- > Expenses:Interest EUR 173.60+ > 2023-03-31 5% interest for EUR -9938.36 over 31 days+ > Liabilities:Loan EUR -42.20+ > Expenses:Interest EUR 42.20 >- > 2010-12-10 5% interest for EUR -5425.38 over 340 days- > Liabilities:Bank EUR -256.20- > Expenses:Interest EUR 256.20+ > 2023-07-01 5% interest for EUR -9880.56 over 92 days+ > Liabilities:Loan EUR -124.52+ > Expenses:Interest EUR 124.52 >- > 2010-12-10 Payment- > Assets:Bank EUR -3700.00- > Liabilities:Bank EUR 3700.00+ > 2023-12-31 5% interest for EUR -9855.08 over 183 days+ > Liabilities:Loan EUR -247.05+ > Expenses:Interest EUR 247.05+ >+ > 2024-01-15 5% interest for EUR -10102.13 over 15 days+ > Liabilities:Loan EUR -20.70+ > Expenses:Interest EUR 20.70+ >+ > 2024-02-29 5% interest for EUR -10022.84 over 45 days+ > Liabilities:Loan EUR -61.62+ > Expenses:Interest EUR 61.62+ >+ > 2024-03-31 5% interest for EUR -9984.45 over 31 days+ > Liabilities:Loan EUR -42.28+ > Expenses:Interest EUR 42.28 . Running the utility with @--help@ gives a brief overview over the available options: . > Usage: hledger-interest [OPTION...] ACCOUNT+ > Default: post at transactions, rate-period ends, and year ends. > -h --help print this message and exit > -V --version show version number and exit- > -v --verbose echo input ledger to stdout (default)- > -q --quiet don't echo input ledger to stdout- > --today compute interest up until today- > --post-on-transactions post interest only when the balance changes- > (and with --today), not at the end of- > every year or rate period+ > -v --verbose print matching input transactions and generated interest (default)+ > -q --quiet print generated interest only+ > --today alias for --until=today+ > --until=DATE compute interest through DATE (YYYY-MM-DD or today);+ > exclude transactions with later effective dates+ > --post-on-transactions post only at transactions (and at --until/--today);+ > skip rate-period and automatic year-end postings;+ > overrides --post-on-rate-changes+ > --post-on-rate-changes post at transactions and rate-period ends;+ > skip automatic year-end postings;+ > --post-on-transactions takes precedence;+ > --until/--today also post on the final day > -f FILE --file=FILE input ledger file (pass '-' for stdin) > default: $LEDGER_FILE or ~/.hledger.journal > -s ACCOUNT --source=ACCOUNT interest source account@@ -108,13 +129,15 @@ > --30-360 use '30/360' day counting convention > --30E-360 use '30E/360' day counting convention > --30E-360isda use '30E/360isda' day counting convention- > --constant=RATE constant interest rate- > --annual=RATE annual interest rate- > --annual-schedule=SCHEDULE schedule of annual interest rates.+ > --constant=RATE fixed annual interest rate+ > --annual=RATE alias for --constant+ > --schedule=SCHEDULE annualized rates and their effective dates;+ > rates are fractions: 0.05 means 5% per year > syntax: '[(Date1,Rate1),(Date2,Rate2),...]'- > --bgb288 compute interest according to German BGB288- > --db24 HACK: Deutsche Bank 24- > --ing-diba HACK: compute interest according for Ing-Diba Tagesgeld account+ > --schedule-file=PATH read a schedule from a text file;+ > one date and rate per line, starting in column 1;+ > rates: 0.05 or 5%; hledger comments allowed+ > --annual-schedule=SCHEDULE alias for --schedule Source-Repository head Type: git@@ -122,9 +145,33 @@ Executable hledger-interest default-language: Haskell2010- Main-is: Main.hs- Build-depends: base >= 3 && < 5, hledger-lib >= 1.26 && < 1.53, time, mtl, Decimal, text+ hs-source-dirs: src+ Main-is: hledger-interest.hs+ Build-depends: base >= 4.9 && < 5, hledger-lib >= 1.32 && < 1.53, time, mtl, Decimal, text, megaparsec >= 7 other-modules: Hledger.Interest Hledger.Interest.DayCountConvention Hledger.Interest.Rate+ Hledger.Interest.ScheduleFile Paths_hledger_interest+ autogen-modules: Paths_hledger_interest++Test-suite regression+ type: exitcode-stdio-1.0+ default-language: Haskell2010+ hs-source-dirs: tests+ main-is: golden-tests.hs+ other-modules: HistoricalRates+ build-depends: base >= 4.9 && < 5, bytestring, typed-process, Decimal, time, tasty,+ tasty-golden >= 2.3.6, tasty-hunit+ build-tool-depends: hledger-interest:hledger-interest++Test-suite calculations+ type: exitcode-stdio-1.0+ default-language: Haskell2010+ hs-source-dirs: tests src+ main-is: calculation-tests.hs+ other-modules: Hledger.Interest+ Hledger.Interest.DayCountConvention+ Hledger.Interest.Rate+ build-depends: base >= 4.9 && < 5, hledger-lib >= 1.32 && < 1.53,+ time, mtl, Decimal, text, tasty, tasty-hunit
+ src/Hledger/Interest.hs view
@@ -0,0 +1,186 @@+module Hledger.Interest+ ( Computer, runComputer+ , Config(..), PostingMode(..)+ , InterestState(..), nullInterestState+ , processTransaction, processTransactionsUntil, computeInterest+ , module Hledger.Interest.DayCountConvention+ , module Hledger.Interest.Rate+ , module Hledger.Data+ )+ where++import Hledger.Data+import Hledger.Interest.DayCountConvention+import Hledger.Interest.Rate++import Control.Monad+import Control.Monad.RWS+import Data.Decimal+import Data.List (sortOn)+import Data.Maybe (fromMaybe)+import qualified Data.Text as T+import Data.Time.Calendar+import Data.Time.Calendar.OrdinalDate++type Computer = RWS Config [Transaction] InterestState++runComputer :: Config -> Computer () -> [Transaction]+runComputer cfg f = ts+ where ((),_,ts) = runRWS f cfg nullInterestState++-- | Increasingly restrictive posting policies. This ordering lets the CLI+-- combine flags with 'max', so transaction-only posting always takes precedence.+data PostingMode = PostAtAllBoundaries | PostOnRateChanges | PostOnTransactions+ deriving (Eq, Ord, Show)++data Config = Config+ { interestAccount :: AccountName+ , sourceAccount :: AccountName+ , targetAccount :: AccountName+ , dayCountConvention :: DayCountConvention+ , interestRate :: Rate+ , postingMode :: PostingMode+ -- ^ Select automatic posting boundaries. Transactions and an explicit+ -- cutoff always post; actual-day calculations split at year ends in all modes.+ }++data InterestState = InterestState+ { balancedUntil :: Day+ , balance :: MixedAmount+ , accruedDays :: Integer -- ^ interest accrued but not posted yet+ , accruedInterest :: MixedAmount+ }++nullInterestState :: InterestState+nullInterestState = InterestState+ { balancedUntil = nulldate+ , balance = nullmixedamt+ , accruedDays = 0+ , accruedInterest = nullmixedamt+ }++processTransaction :: Transaction -> Computer ()+processTransaction ts = processTransactionsUntil Nothing [ts]++-- | Apply matching postings in posting-date order, optionally ignoring+-- changes after the cutoff date.+processTransactionsUntil :: Maybe Day -> [Transaction] -> Computer ()+processTransactionsUntil cutoff ts = do+ interestAcc <- asks interestAccount+ let changes = sortOn fst+ [ (date, p) | t <- ts, p <- tpostings t, interestAcc == paccount p+ , let date = balanceChangeDate t p+ , maybe True (date <=) cutoff ]+ forM_ changes $ \(date, p) -> do+ let principal = mapMixedAmount (\a -> a { acost = Nothing, acostbasis = Nothing }) (pamount p)+ computeInterest date+ modify (\st -> st { balance = balance st + principal })++-- | The effective date of a posting within the given transaction, with the+-- same precedence as hledger's 'postingDate2': posting date2, transaction+-- date2, posting date, transaction date. Taking the transaction explicitly+-- also works for postings whose 'ptransaction' link has not been set.+balanceChangeDate :: Transaction -> Posting -> Day+balanceChangeDate t p = fromMaybe (tdate t) (msum [pdate2 p, tdate2 t, pdate p])++-- | Compute and post all interest up to and including the given day.+computeInterest :: Day -> Computer ()+computeInterest day = accrueInterest day >> postInterest day Nothing++accrueInterest :: Day -> Computer ()+accrueInterest day = do+ from <- gets balancedUntil+ bal <- gets balance+ when (day < pred from) $+ error "hledger-interest: cannot compute interest backwards; transactions must be ordered by effective date"+ -- A second transaction on the same day must not rewind the accrual cursor.+ when (day >= from) $+ if mixedAmountIsZero bal+ -- No rate is needed before the first loan or while its balance is zero.+ then modify (\st -> st { balancedUntil = succ day })+ else do+ rate <- asks interestRate+ mode <- asks postingMode+ yearDays <- daysInYear from+ let (endOfPeriod,ratePerAnno) = rate from+ endOfYear = yearEnd from+ -- Fixed 360-day conventions need no internal year split. Avoiding+ -- one also preserves their day counts across December 31st.+ yearLimit+ | mode == PostAtAllBoundaries || yearDays /= 360 = endOfYear+ | otherwise = day+ to = minimum [day, endOfPeriod, yearLimit]+ newFrom = succ to+ when (to < from) $+ error "hledger-interest: interest rate period ends before its start"+ modify (\st -> st { balancedUntil = newFrom })+ diff <- asks dayCountConvention+ -- Count from the last accounted day, not the first accrual day: the+ -- 30/360 conventions adjust some dates, so only this form makes the+ -- counts of consecutive periods add up to the count over the span.+ let days = pred from `diff` to+ interest = mapMixedAmount (\a -> a { aquantity = (aquantity a * ratePerAnno) / fromInteger yearDays * fromInteger days }) bal+ modify (\st -> st { accruedDays = accruedDays st + days+ , accruedInterest = accruedInterest st + interest+ })+ -- Internal calendar-year splits do not change the principal unless+ -- the posting policy calls for a posting at that boundary.+ let shouldPost = case mode of+ PostAtAllBoundaries -> True+ PostOnRateChanges -> to == day || to == endOfPeriod+ PostOnTransactions -> False+ when shouldPost (postInterest to (Just ratePerAnno))+ when (newFrom <= day) (accrueInterest day)++-- | Post all accrued interest as one transaction on the given day. The+-- description mentions the rate only if one is given, i.e. if the interest+-- was accrued over a single rate period.+postInterest :: Day -> Maybe Decimal -> Computer ()+postInterest day ratePerAnno = do+ st <- get+ when (accruedDays st > 0) $ do+ put st { accruedDays = 0, accruedInterest = nullmixedamt }+ t <- mkTrans day (accruedDays st) (accruedInterest st) ratePerAnno+ tell [t]+ processTransaction t++daysInYear :: Day -> Computer Integer+daysInYear now = asks dayCountConvention >>= \diff -> return (day1 `diff` day2)+ where day1 = fromGregorian (fst (toOrdinalDate now)) 1 1+ day2 = fromGregorian (succ (fst (toOrdinalDate now))) 1 1++yearEnd :: Day -> Day+yearEnd date = fromGregorian (fst (toOrdinalDate date)) 12 31++-- | Create the transaction for the given interest, which was accrued over+-- the given number of days.+mkTrans :: Day -> Integer -> MixedAmount -> Maybe Decimal -> Computer Transaction+mkTrans day days interest ratePerAnno = do+ bal <- gets balance+ srcAcc <- asks sourceAccount+ targetAcc <- asks targetAccount+ let t = nulltransaction+ { tdate = day+ , tdescription = T.pack $ maybe "" (\r -> showPercent r ++ " ") ratePerAnno ++ "interest for " ++ showMixedAmountOneLine bal ++ " over " ++ show days ++ " days"+ , tpostings = [pTarget,pSource]+ }+ -- balance might have been an exact amount, in which case its precision might+ -- be set to 0 decimal places. We ensure that interest is displayed with at least+ -- two decimal places. This changes rendering only: the full computed+ -- quantity is posted and, when applicable, compounded (see README).+ pTarget = nullposting+ { paccount = targetAcc+ , pamount = mapMixedAmount (amountSetPrecisionMin 2) interest+ , ptype = RegularPosting+ , ptransaction = Just t+ }+ pSource = nullposting+ { paccount = srcAcc+ , pamount = negate (pamount pTarget)+ , ptype = RegularPosting+ , ptransaction = Just t+ }+ return t++showPercent :: Decimal -> String+showPercent r = shows (r * 100) "%"
+ src/Hledger/Interest/DayCountConvention.hs view
@@ -0,0 +1,53 @@+module Hledger.Interest.DayCountConvention+ ( DayCountConvention+ , diffAct+ , diff30_360+ , diff30E_360+ , diff30E_360isda+ )+ where++import Control.Exception ( assert )+import Data.Time.Calendar++type DayCountConvention = Day -> Day -> Integer++diffAct :: DayCountConvention+diffAct date1 date2 = assert (date1 <= date2) $ date2 `diffDays` date1++mkDiff30_360 :: (Integer,Int,Int) -> (Integer,Int,Int) -> Integer+mkDiff30_360 (y1,m1,d1) (y2,m2,d2) = 360*(y2-y1) + 30*toInteger (m2-m1) + toInteger (d2-d1)++-- The un-corrected naked formular.++diff30_360 :: DayCountConvention+diff30_360 date1 date2 = assert (date1 <= date2) $ mkDiff30_360 (toGregorian date1) (toGregorian date2)++-- No month has more than 30 days, but February may have 28 or 29; i.e.+-- there are 32 days between 2003-02-28 and 2003-03-31. Commonly known+-- as "Deutsche Zinsmethode 30 / 360".++diff30E_360 :: DayCountConvention+diff30E_360 date1 date2 = assert (date1 <= date2) $ mkDiff30_360 (y1, m1, min 30 d1) (y2, m2, min 30 d2)+ where+ (y1,m1,d1) = toGregorian date1+ (y2,m2,d2) = toGregorian date2++-- | This variant additionally normalizes end-of-months to 30, i.e. there+-- are 30 days between 2003-02-28 and 2003-03-31. (Strict 30E/360 ISDA+-- exempts a February month-end that is the maturity date; we have no+-- notion of maturity, so that exception is not applied.)+--+-- >>> diff30E_360isda (fromGregorian 2003 2 28) (fromGregorian 2003 3 31)+-- 30+-- >>> diff30E_360isda (fromGregorian 2023 1 31) (fromGregorian 2023 2 15)+-- 15+-- >>> diff30E_360isda (fromGregorian 2023 1 15) (fromGregorian 2023 2 28)+-- 45+diff30E_360isda :: DayCountConvention+diff30E_360isda date1 date2 = assert (date1 <= date2) $ mkDiff30_360 (y1, m1, d1') (y2, m2, d2')+ where+ (y1,m1,d1) = toGregorian date1+ (y2,m2,d2) = toGregorian date2+ d1' = if d1 == gregorianMonthLength y1 m1 then 30 else d1+ d2' = if d2 == gregorianMonthLength y2 m2 then 30 else d2
+ src/Hledger/Interest/Rate.hs view
@@ -0,0 +1,43 @@+module Hledger.Interest.Rate ( Rate, constant, scheduled, perAnno, perAnnoSchedule ) where++import Data.Time.Calendar+import Data.Decimal+import Data.List (sortOn, find)++type Rate = Day -> (Day,Decimal)++constant :: Decimal -> Rate+constant rate _ = (day 999999 12 31, rate)++-- | Compatibility aliases; posting boundaries are independent of the rate.+perAnno :: Decimal -> Rate+perAnno = constant++perAnnoSchedule :: [(Day,Decimal)] -> Rate+perAnnoSchedule = scheduled++-- | Rate-change boundaries only; calendar-year handling belongs to the computer.+scheduled :: [(Day,Decimal)] -> Rate+scheduled schedule date = (endOfPeriod, effectiveRate)+ where+ (endOfPeriod, effectiveRate) =+ case find (\(fromDate, toDate, _) -> fromDate<=date && date<=toDate) fromToSchedule of+ -- date precedes the schedule: no interest until the first rate takes effect+ Nothing -> (beforeSchedule, 0)+ Just (_,toDate,rate) -> (toDate, rate)+ beforeSchedule =+ case sortedSchedule of+ (firstDate, _):_ -> pred firstDate+ [] -> day 999999 12 31+ fromToSchedule =+ -- Convert the list of (startDate,rate) to (startDate,endDate,rate) intervals, where+ -- the end date of the interval is the day before the start of the next interval,+ -- and the last interval goes to infinity+ zipWith+ (\(from, rate) (to, _) -> (from, pred to, rate))+ sortedSchedule+ (drop 1 sortedSchedule ++ [(day 999999 1 1,0)])+ sortedSchedule = sortOn fst schedule++day :: Integer -> Int -> Int -> Day+day = fromGregorian
+ src/Hledger/Interest/ScheduleFile.hs view
@@ -0,0 +1,75 @@+{-# LANGUAGE OverloadedStrings #-}++module Hledger.Interest.ScheduleFile (readScheduleFile) where++import Control.Monad (void, when)+import Control.Monad.State.Strict (evalStateT, lift)+import Data.Char (isSpace)+import Data.Decimal (Decimal)+import Data.Functor.Identity (Identity)+import Data.Maybe (catMaybes, isJust)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import Data.Time.Calendar (Day, toGregorian)+import Hledger.Data (JournalParser, getCurrentDay, nulljournal)+import Hledger.Read.Common+ ( datep, emptyorcommentlinep, multilinecommentp+ , numberp, setYear+ )+import Hledger.Utils (TextParser)+import System.IO.Error (ioeGetErrorString, tryIOError)+import Text.Megaparsec+ ( (<|>), anySingle, eof, errorBundlePretty, getInput, lookAhead, manyTill, optional+ , runParser, satisfy, skipMany, skipSome, takeWhileP, (<?>)+ )+import Text.Megaparsec.Char (char, newline)++readScheduleFile :: FilePath -> IO (Either String [(Day, Decimal)])+readScheduleFile path = do+ contents <- tryIOError (TIO.readFile path)+ today <- getCurrentDay+ let (year, _, _) = toGregorian today+ parser = evalStateT (setYear year >> schedulep) nulljournal+ -- Like journal input, accept CRLF and a missing final newline. A final+ -- newline also lets hledger's comment parsers handle comments at EOF.+ normalise text = T.replace "\r\n" "\n" text <> "\n"+ pure $ case contents of+ Left err -> Left (path ++ ": " ++ ioeGetErrorString err)+ Right text -> case runParser parser path (normalise text) of+ Left err -> Left (errorBundlePretty err)+ Right schedule -> Right schedule++schedulep :: JournalParser Identity [(Day, Decimal)]+schedulep = catMaybes <$> manyTill itemp eof+ where+ itemp :: JournalParser Identity (Maybe (Day, Decimal))+ itemp = do+ input <- lift getInput+ -- Classify ignored lines without speculative consumption of indentation,+ -- so malformed entries are diagnosed at their beginning, in column 1.+ case T.uncons (T.dropWhile isHorizontalSpace input) of+ Just (c, _) | c `elem` ['\n', '#', ';', '*'] ->+ Nothing <$ lift emptyorcommentlinep+ _ -> Nothing <$ lift multilinecommentp <|> Just <$> entryp++entryp :: JournalParser Identity (Day, Decimal)+entryp = do+ first <- lift (lookAhead anySingle)+ when (isSpace first) $ fail "schedule entries must begin in column 1"+ date <- datep+ rate <- lift $ do+ skipSome horizontalSpace <?> "whitespace between date and rate"+ (quantity, _, _, _) <- numberp Nothing+ skipMany horizontalSpace+ percent <- optional (char '%')+ skipMany horizontalSpace+ void $ optional (char ';' *> takeWhileP Nothing (/= '\n'))+ void newline+ pure $ if isJust percent then quantity / 100 else quantity+ pure (date, rate)+ where+ horizontalSpace :: TextParser Identity Char+ horizontalSpace = satisfy isHorizontalSpace++isHorizontalSpace :: Char -> Bool+isHorizontalSpace c = isSpace c && c /= '\n'
+ src/hledger-interest.hs view
@@ -0,0 +1,152 @@+module Main ( main ) where++import Hledger.Interest+import Hledger.Interest.ScheduleFile (readScheduleFile)+import Hledger.Read++import Control.Exception ( ErrorCall(..), finally, handle )+import Control.Monad+import Data.List ( sortOn )+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import Data.Time (Day, defaultTimeLocale, parseTimeM)+import Data.Version+import System.Console.GetOpt+import System.Environment+import System.Exit+import System.IO+import Text.Read (readEither)++import Paths_hledger_interest ( version )++data RateInput = RateValue Rate | RateFile FilePath++data Options = Options+ { optVerbose :: Bool+ , optShowVersion :: Bool+ , optShowHelp :: Bool+ , optInput :: [FilePath]+ , optSourceAcc :: String+ , optTargetAcc :: String+ , optDCC :: Maybe DayCountConvention+ , optRate :: Either String RateInput+ , optBalanceUntil :: Maybe String+ , optPostingMode :: PostingMode+ , optIgnoreAssertions :: Bool+ }++defaultOptions :: Options+defaultOptions = Options+ { optVerbose = True+ , optShowVersion = False+ , optShowHelp = False+ , optInput = []+ , optSourceAcc = ""+ , optTargetAcc = ""+ , optDCC = Nothing+ , optRate = Left "no interest rate specified\n"+ , optBalanceUntil = Nothing+ , optPostingMode = PostAtAllBoundaries+ , optIgnoreAssertions = False+ }++options :: [OptDescr (Options -> Options)]+options =+ [ Option ['h'] ["help"] (NoArg (\o -> o { optShowHelp = True })) "print this message and exit"+ , Option ['V'] ["version"] (NoArg (\o -> o { optShowVersion = True })) "show version number and exit"+ , Option ['v'] ["verbose"] (NoArg (\o -> o { optVerbose = True })) "print matching input transactions and generated interest (default)"+ , Option ['q'] ["quiet"] (NoArg (\o -> o { optVerbose = False })) "print generated interest only"+ , Option [] ["today"] (NoArg (\o -> o { optBalanceUntil = Just "today" })) "alias for --until=today"+ , Option [] ["until"] (ReqArg (\d o -> o { optBalanceUntil = Just d }) "DATE") "compute interest through DATE (YYYY-MM-DD or today);\nexclude transactions with later effective dates"+ , Option [] ["post-on-transactions"] (NoArg (\o -> o { optPostingMode = PostOnTransactions })) "post only at transactions (and at --until/--today);\nskip rate-period and automatic year-end postings;\noverrides --post-on-rate-changes"+ , Option [] ["post-on-rate-changes"] (NoArg (\o -> o { optPostingMode = max PostOnRateChanges (optPostingMode o) })) "post at transactions and rate-period ends;\nskip automatic year-end postings;\n--post-on-transactions takes precedence;\n--until/--today also post on the final day"+ , Option ['f'] ["file"] (ReqArg (\f o -> o { optInput = f : optInput o }) "FILE") "input ledger file (pass '-' for stdin)\ndefault: $LEDGER_FILE or ~/.hledger.journal"+ , Option ['s'] ["source"] (ReqArg (\a o -> o { optSourceAcc = a }) "ACCOUNT") "interest source account"+ , Option ['t'] ["target"] (ReqArg (\a o -> o { optTargetAcc = a }) "ACCOUNT") "interest target account"+ , Option ['I'] ["ignore-assertions"] (NoArg (\o -> o { optIgnoreAssertions = True })) "ignore any failing balance assertions"+ , Option [] ["act"] (NoArg (\o -> o { optDCC = Just diffAct })) "use 'act/act' day counting convention\n(Actual/Actual ISDA; see README)"+ , Option [] ["30-360"] (NoArg (\o -> o { optDCC = Just diff30_360 })) "use '30/360' day counting convention"+ , Option [] ["30E-360"] (NoArg (\o -> o { optDCC = Just diff30E_360 })) "use '30E/360' day counting convention"+ , Option [] ["30E-360isda"] (NoArg (\o -> o { optDCC = Just diff30E_360isda })) "use '30E/360isda' day counting convention"+ , Option [] ["constant"] (ReqArg (setRate "--constant" constant) "RATE") "fixed annual interest rate"+ , Option [] ["annual"] (ReqArg (setRate "--annual" perAnno) "RATE") "alias for --constant"+ , Option [] ["schedule"] (ReqArg (setRate "--schedule" scheduled) "SCHEDULE") "annualized rates and their effective dates;\nrates are fractions: 0.05 means 5% per year\nsyntax: '[(Date1,Rate1),(Date2,Rate2),...]'"+ , Option [] ["schedule-file"] (ReqArg (\path o -> o { optRate = Right (RateFile path) }) "PATH") "read a schedule from a text file;\none date and rate per line, starting in column 1;\nrates: 0.05 or 5%; hledger comments allowed"+ , Option [] ["annual-schedule"] (ReqArg (setRate "--annual-schedule" scheduled) "SCHEDULE") "alias for --schedule"+ ]++setRate :: Read a => String -> (a -> Rate) -> String -> Options -> Options+setRate flag makeRate value opts = opts { optRate = parsed }+ where+ parsed = case readEither value of+ Left _ -> Left ("invalid value for " ++ flag ++ ": " ++ value ++ "\n")+ Right r -> Right (RateValue (makeRate r))++parseUntil :: String -> IO Day+parseUntil "today" = getCurrentDay+parseUntil date =+ maybe (commandLineError ("invalid value for --until: " ++ date +++ "\nexpected YYYY-MM-DD or today\n"))+ pure (parseTimeM True defaultTimeLocale "%Y-%-m-%-d" date)++usageMessage :: String+usageMessage = usageInfo header options+ where header = "Usage: hledger-interest [OPTION...] ACCOUNT\nDefault: post at transactions, rate-period ends, and year ends."++commandLineError :: String -> IO a+commandLineError err = do hPutStrLn stderr (err ++ usageMessage)+ exitFailure++parseOpts :: [String] -> IO (Options, [String])+parseOpts argv =+ case getOpt Permute options argv of+ (o,n,[] ) -> return (foldl (flip id) defaultOptions o, n)+ (_,_,errs) -> commandLineError (concat errs)++main :: IO ()+main = handle (\(ErrorCall msg) -> hPutStrLn stderr msg >> exitFailure) $+ run `finally` (hFlush stdout >> hFlush stderr)++run :: IO ()+run = do+ (opts, args) <- getArgs >>= parseOpts+ when (optShowVersion opts) (putStrLn (showVersion version) >> exitSuccess)+ when (optShowHelp opts) (putStr usageMessage >> exitSuccess)+ when (null (optSourceAcc opts)) (commandLineError "required --source option is missing\n")+ when (null (optTargetAcc opts)) (commandLineError "required --target option is missing\n")+ dcc <- maybe (commandLineError "no day counting convention specified\n") pure (optDCC opts)+ rateInput <- either commandLineError pure (optRate opts)+ cutoff <- traverse parseUntil (optBalanceUntil opts)+ interestAcc <- case args of+ [] -> commandLineError "required argument ACCOUNT is missing\n"+ [acc] -> return $ T.pack acc+ _ -> commandLineError "only one interest ACCOUNT may be specified\n"+ rate <- case rateInput of+ RateValue r -> pure r+ RateFile path -> readScheduleFile path >>=+ either (\msg -> hPutStrLn stderr msg >> exitFailure) (pure . scheduled)+ let ledgerInputOptions = definputopts { balancingopts_ = (balancingopts_ definputopts) { ignore_assertions_ = optIgnoreAssertions opts } }+ inputFiles <- case optInput opts of+ [] -> pure <$> defaultJournalPath+ fs -> return (reverse fs)+ jnl <- runExceptT (readJournalFiles ledgerInputOptions inputFiles) >>=+ either (\msg -> hPutStrLn stderr msg >> exitFailure) pure+ let matching = filter (any ((== interestAcc) . paccount) . tpostings) (jtxns jnl)+ ts = sortOn transactionDate2 $+ filter (\t -> maybe True (transactionDate2 t <=) cutoff) matching+ cfg = Config+ { interestAccount = interestAcc+ , sourceAccount = T.pack (optSourceAcc opts)+ , targetAccount = T.pack (optTargetAcc opts)+ , dayCountConvention = dcc+ , interestRate = rate+ , postingMode = optPostingMode opts+ }+ when (null matching) $ commandLineError ("no transactions found for account " ++ T.unpack interestAcc ++ "\n")+ let ts' = runComputer cfg $ do+ processTransactionsUntil cutoff (jtxns jnl)+ mapM_ computeInterest cutoff+ result+ | optVerbose opts = ts' ++ ts+ | otherwise = ts'+ mapM_ (TIO.putStr . showTransaction) (sortOn transactionDate2 result)
+ tests/HistoricalRates.hs view
@@ -0,0 +1,37 @@+module HistoricalRates (historicalModels) where++import Data.Decimal (Decimal)+import Data.Time.Calendar (Day, fromGregorian)++-- Historical snapshots from the removed built-in modes. Keep these data in+-- tests only: the executable receives ordinary annual schedules and explicit+-- day-count flags. Schedules have zero interest before the first entry and+-- keep the final rate indefinitely; they do not enforce the old coverage limits.+historicalModels :: [(String, [String])]+historicalModels =+ [ ("bgb288", ["--act", "--schedule-file=tests/fixtures/bgb288.rates"])+ , ("ing-diba", ["--act", scheduleArgument ingDibaSchedule])+ , ("db24", ["--30E-360", "--post-on-rate-changes", scheduleArgument db24Schedule])+ ]++scheduleArgument :: [(Day, Decimal)] -> String+scheduleArgument schedule = "--schedule=" ++ show schedule++day :: Integer -> Int -> Int -> Day+day = fromGregorian++-- Preserve the original annual boundaries, including unchanged rates.+ingDibaSchedule :: [(Day, Decimal)]+ingDibaSchedule =+ [ (day 2009 01 01, 150 / 10000)+ , (day 2010 01 01, 150 / 10000)+ , (day 2011 01 01, 150 / 10000)+ , (day 2011 07 15, 175 / 10000)+ ]++-- Suppressing year-end postings reproduces the original DB24 compounding.+db24Schedule :: [(Day, Decimal)]+db24Schedule =+ [ (day 2000 10 06, 638 / 10000)+ , (day 2010 09 16, 415 / 10000)+ ]
+ tests/calculation-tests.hs view
@@ -0,0 +1,314 @@+{-# LANGUAGE OverloadedStrings #-}++module Main (main) where++import Control.Monad (zipWithM_)+import Data.Decimal (Decimal)+import Data.Ratio ((%))+import qualified Data.Text as T+import Data.Time.Calendar (Day, fromGregorian)+import Hledger.Interest+import Test.Tasty (TestTree, defaultMain, testGroup)+import Test.Tasty.HUnit (Assertion, assertBool, testCase, (@?=))++main :: IO ()+main = defaultMain $ testGroup "Independent calculations"+ [ dayCountTests+ , balanceTests+ , testGroup "Accrual"+ [ testCase "repayment changes the next period, not the payment day" $ do+ -- 3650 at 10% earns 1 per day in 2023. After repaying 1000,+ -- ten more days earn 2650 * 10% * 10 / 365 = 530/73.+ let cfg = baseConfig+ assertInterest cfg (compute cfg [(day 2023 1 1, 3650), (day 2023 1 11, -1000)] (day 2023 1 21))+ [(day 2023 1 11, 10), (day 2023 1 21, 530 % 73)]+ , testCase "same-day repayments do not accrue another day" $ do+ -- Both repayments leave 2000; the next ten days earn 400/73.+ let cfg = baseConfig+ assertInterest cfg (compute cfg+ [(day 2023 1 1, 3650), (day 2023 1 11, -1000), (day 2023 1 11, -650)] (day 2023 1 21))+ [(day 2023 1 11, 10), (day 2023 1 21, 400 % 73)]+ , testCase "February 29th is included with a 366-day denominator" $ do+ -- The loan day is excluded; February 29th and March 1st each+ -- earn 3660 * 10% / 366 = 1.+ let cfg = baseConfig+ assertInterest cfg (compute cfg [(day 2024 2 28, 3660)] (day 2024 3 1))+ [(day 2024 3 1, 2)]+ , testCase "year-end postings compound only in the principal account" $ do+ -- December 31st earns 1. The next two days earn+ -- 3651 * 10% * 2 / 366 = 1217/610 after compounding.+ let cfg = baseConfig { targetAccount = "Assets:Loan" }+ assertInterest cfg (compute cfg [(day 2023 12 30, 3650)] (day 2024 1 2))+ [(day 2023 12 31, 1), (day 2024 1 2, 1217 % 610)]+ , testCase "a separate interest account does not compound" $ do+ -- The same two leap-year days use the original principal:+ -- 3650 * 10% * 2 / 366 = 365/183.+ let cfg = baseConfig+ assertInterest cfg (compute cfg [(day 2023 12 30, 3650)] (day 2024 1 2))+ [(day 2023 12 31, 1), (day 2024 1 2, 365 % 183)]+ ]+ , testGroup "Internal year splits"+ [ testCase name $ do+ -- No intermediate posting: 1 non-leap-year day plus 2 leap-year+ -- days earn 1 + 365/183 = 548/183 on unchanged principal.+ let cfg = baseConfig { targetAccount = "Assets:Loan"+ , postingMode = mode }+ assertInterest cfg (compute cfg [(day 2023 12 30, 3650)] (day 2024 1 2))+ [(day 2024 1 2, 548 % 183)]+ | (name, mode) <-+ [("rate-change posting", PostOnRateChanges), ("transaction-only posting", PostOnTransactions)]+ ]+ , testGroup "Integrated day counts"+ [ testCase name $ do+ -- Accrual starts February 28th, after the February 27th loan.+ -- At 3600 and 10%, every synthetic 360-day-convention day earns 1.+ -- Actual/Actual uses 32 actual days: 360 * 32 / 365 = 2304/73.+ let cfg = baseConfig { dayCountConvention = convention }+ assertInterest cfg (compute cfg [(day 2023 2 27, 3600)] (day 2023 3 31))+ [(day 2023 3 31, expected)]+ | (name, convention, expected) <-+ [("actual", diffAct, 2304 % 73), ("raw 30/360", diff30_360, 34),+ ("30E/360", diff30E_360, 33), ("30E/360 ISDA", diff30E_360isda, 33)]+ ]+ , testGroup "Periods start after an adjusted day"+ [ testCase name $ do+ -- Accrual starts January 31st, which 30E/360 counts as the 30th,+ -- so the period must be counted from the loan day (issue #29).+ -- Actual/Actual uses 29 actual days: 360 * 29 / 365 = 2088/73.+ let cfg = baseConfig { dayCountConvention = convention }+ assertInterest cfg (compute cfg [(day 2023 1 30, 3600)] (day 2023 2 28))+ [(day 2023 2 28, expected)]+ | (name, convention, expected) <-+ [("actual", diffAct, 2088 % 73), ("raw 30/360", diff30_360, 28),+ ("30E/360", diff30E_360, 28), ("30E/360 ISDA", diff30E_360isda, 30)]+ ]+ , testGroup "Monthly periods add up to a 360-day year"+ [ testCase name $ do+ -- Payments on the 30th (February 28th) split a year into twelve+ -- periods; at 1 per synthetic day they must earn exactly 360.+ let cfg = baseConfig { dayCountConvention = convention }+ payments = [(day 2023 m (if m == 2 then 28 else 30), 0) | m <- [1 .. 12]]+ txns = compute cfg ((day 2022 12 30, 3600) : payments) (day 2023 12 30)+ sum [aquantity a | t <- txns, p <- tpostings t, paccount p == targetAccount cfg,+ a <- amounts (pamount p)] @?= 360+ | (name, convention) <-+ [("raw 30/360", diff30_360), ("30E/360", diff30E_360), ("30E/360 ISDA", diff30E_360isda)]+ ]+ , testGroup "Rate changes"+ [ testCase "new rate applies on its effective date" $ do+ -- January 2nd-10th: 9 days at 1/day; January 11th-20th:+ -- 10 days at 2/day. A separate account keeps principal at 3600.+ let cfg = rateChangeConfig+ assertInterest cfg (compute cfg [(day 2023 1 1, 3600)] (day 2023 1 20))+ [(day 2023 1 10, 9), (day 2023 1 20, 20)]+ , testCase "posted interest compounds at a rate change" $ do+ -- The second period uses 3609: 3609 * 20% * 10 / 360 = 20.05.+ let cfg = rateChangeConfig { targetAccount = "Assets:Loan" }+ assertInterest cfg (compute cfg [(day 2023 1 1, 3600)] (day 2023 1 20))+ [(day 2023 1 10, 9), (day 2023 1 20, 20.05)]+ , testCase "deferred interest combines rates without compounding" $ do+ let cfg = rateChangeConfig { targetAccount = "Assets:Loan", postingMode = PostOnTransactions }+ assertInterest cfg (compute cfg [(day 2023 1 1, 3600)] (day 2023 1 20))+ [(day 2023 1 20, 29)]+ , testCase "dates before the first rate earn zero" $ do+ let cfg = baseConfig { dayCountConvention = diff30E_360+ , interestRate = scheduled [(day 2023 1 11, 0.1)] }+ assertInterest cfg (compute cfg [(day 2023 1 1, 3600)] (day 2023 1 20))+ [(day 2023 1 10, 0), (day 2023 1 20, 10)]+ ]+ , testGroup "Balance-change dates (review regressions, #31)"+ [ testCase "postings without a transaction link use the transaction date" $ do+ -- Library callers may build transactions from bare postings+ -- (ptransaction = Nothing). Ten days on 3650 earn 10, then ten+ -- days on 1825 earn 5; nothing may be dated before the loan.+ let loan = principalChange (day 2023 1 1) 3650+ repayment = principalChange (day 2023 1 11) (-1825)+ txns = runComputer baseConfig $ do+ processTransaction loan+ processTransaction repayment+ computeInterest (day 2023 1 21)+ assertInterest baseConfig txns [(day 2023 1 11, 10), (day 2023 1 21, 5)]+ , testCase "a linked posting's own date takes precedence" $ do+ -- The loan transaction is dated January 1st, its posting January+ -- 11th: 3650 * 10% * 21 / 365 = 21 through February 1st.+ let loan = txnTieKnot (principalChange (day 2023 1 1) 3650)+ { tpostings = [ (mkPosting "Assets:Loan" 3650) { pdate = Just (day 2023 1 11) }+ , mkPosting "Equity:Funding" (-3650) ] }+ txns = runComputer baseConfig $ do+ processTransaction loan+ computeInterest (day 2023 2 1)+ assertInterest baseConfig txns [(day 2023 2 1, 21)]+ , testCase "an unlinked posting's own date takes precedence" $ do+ let loan = (principalChange (day 2023 1 1) 3650)+ { tpostings = [ (mkPosting "Assets:Loan" 3650) { pdate = Just (day 2023 1 11) }+ , mkPosting "Equity:Funding" (-3650) ] }+ txns = runComputer baseConfig $ do+ processTransaction loan+ computeInterest (day 2023 2 1)+ assertInterest baseConfig txns [(day 2023 2 1, 21)]+ ]+ , testGroup "Signs"+ [ testCase name $ do+ -- Ten 360-day-convention days on +/-3600 at +/-10%.+ let cfg = baseConfig { dayCountConvention = diff30E_360, interestRate = constant rate }+ assertInterest cfg (compute cfg [(day 2023 1 1, principal)] (day 2023 1 11))+ [(day 2023 1 11, expected)]+ | (name, principal, rate, expected) <-+ [("negative principal", -3600, 0.1, -10),+ ("negative rate", 3600, -0.1, -10),+ ("negative principal and rate", -3600, -0.1, 10)]+ ]+ , testGroup "Rounding policy"+ [ testCase "sub-cent quantities are posted and compounded unrounded" $ do+ -- 100 * 1.44% / 360 = 0.004. After posting it, the next day+ -- earns 100.004 * 1.44% / 360 = 0.00400016, not 0.004.+ let cfg = baseConfig { dayCountConvention = diff30E_360+ , interestRate = constant 0.0144+ , targetAccount = "Assets:Loan" }+ txns = compute cfg [(day 2023 1 1, 100), (day 2023 1 2, 0)] (day 2023 1 3)+ assertInterest cfg txns [(day 2023 1 2, 0.004), (day 2023 1 3, 0.00400016)]+ assertBool "Both sub-cent postings should display zero to two decimal places"+ (all (T.isInfixOf "EUR 0.00\n" . showTransaction) txns)+ , testCase "deferred accrual is summed before display rounding" $ do+ -- Repeat the rate on January 3rd to force two calculation segments.+ -- Each earns 0.004 on unchanged principal; their sum is 0.008,+ -- which displays as 0.01 rather than two separately rounded zeros.+ let cfg = baseConfig { dayCountConvention = diff30E_360+ , interestRate = scheduled [(day 2023 1 1, 0.0144), (day 2023 1 3, 0.0144)]+ , targetAccount = "Assets:Loan", postingMode = PostOnTransactions }+ txns = compute cfg [(day 2023 1 1, 100)] (day 2023 1 3)+ assertInterest cfg txns [(day 2023 1 3, 0.008)]+ assertBool "The combined posting should display EUR 0.01"+ (any (T.isInfixOf "EUR 0.01\n" . showTransaction) txns)+ ]+ ]++dayCountTests :: TestTree+dayCountTests = testGroup "Day-count definitions"+ [ testCase (name ++ ": " ++ label) $ convention from to @?= expected+ | (label, from, to, counts) <-+ [("non-leap February end", day 2023 2 28, day 2023 3 31, [31,33,32,30]),+ ("leap February end", day 2024 2 29, day 2024 3 31, [31,32,31,30]),+ ("start on the 31st", day 2023 1 31, day 2023 2 15, [15,14,15,15]),+ ("end in February", day 2023 1 15, day 2023 2 28, [44,43,43,45]),+ ("cross December 31st", day 2023 12 31, day 2024 1 1, [1,0,1,1])]+ , ((name, convention), expected) <- zip conventions counts+ ]+ where+ conventions = [("actual", diffAct), ("raw 30/360", diff30_360),+ ("30E/360", diff30E_360), ("30E/360 ISDA", diff30E_360isda)]++baseConfig :: Config+baseConfig = Config+ { interestAccount = "Assets:Loan"+ , sourceAccount = "Income:Interest"+ , targetAccount = "Assets:Interest"+ , dayCountConvention = diffAct+ , interestRate = constant 0.1+ , postingMode = PostAtAllBoundaries+ }++rateChangeConfig :: Config+rateChangeConfig = baseConfig+ { dayCountConvention = diff30E_360+ , interestRate = scheduled [(day 2023 1 1, 0.1), (day 2023 1 11, 0.2)]+ }++day :: Integer -> Int -> Int -> Day+day = fromGregorian++compute :: Config -> [(Day, Decimal)] -> Day -> [Transaction]+compute cfg changes cutoff = runComputer cfg $ do+ mapM_ (processTransaction . uncurry principalChange) changes+ computeInterest cutoff++principalChange :: Day -> Decimal -> Transaction+principalChange date quantity = nulltransaction+ { tdate = date+ , tpostings = [mkPosting "Assets:Loan" quantity, mkPosting "Equity:Funding" (negate quantity)]+ }++mkPosting :: AccountName -> Decimal -> Posting+mkPosting = currencyPosting "EUR"++currencyPosting :: CommoditySymbol -> AccountName -> Decimal -> Posting+currencyPosting currency account q = nullposting+ { paccount = account+ , pamount = mixed [nullamt { acommodity = currency, aquantity = q+ , astyle = amountstyle { asprecision = Precision 2, ascommodityspaced = True } }]+ }++-- Expected values are hand-derived rational numbers, not output captured from+-- the implementation. Compare internal quantities before any display rounding.+assertInterest :: Config -> [Transaction] -> [(Day, Rational)] -> Assertion+assertInterest cfg txns expected = do+ let actual = [(tdate t, aquantity a) | t <- txns, p <- tpostings t,+ paccount p == targetAccount cfg, a <- amounts (pamount p)]+ map fst actual @?= map fst expected+ zipWithM_ assertQuantity (map snd expected) (map snd actual)+ where+ -- Decimal division is finite precision. This tolerance is far below a+ -- monetary rounding unit, while allowing recurring rational fractions.+ assertQuantity rational quantity = assertBool+ ("Expected " ++ show rational ++ "; got " ++ show quantity)+ (abs (toRational quantity - rational) < 1 % (10 ^ (30 :: Int)))++balanceTests :: TestTree+balanceTests = testGroup "Balance transitions"+ [ testGroup name+ [ testCase "full repayment and a new loan" $ do+ -- First ten days earn 10. The balance is then zero across a rate+ -- change. Ten synthetic days on the new 7200 at 20% earn 40.+ let cfg = plain { interestRate = scheduled [(day 2023 1 1, 0.1), (day 2023 1 16, 0.2)] }+ assertInterest cfg (compute cfg+ [(day 2023 1 1, 3600), (day 2023 1 11, -3600), (day 2023 1 21, 7200)] cutoff)+ [(day 2023 1 11, 10), (cutoff, 40)]+ , testCase "repayment clears compounded interest too" $ do+ let cfg = compound { interestRate = scheduled [(day 2023 1 1, 0.1), (day 2023 1 16, 0.2)] }+ assertInterest cfg (compute cfg+ [(day 2023 1 1, 3600), (day 2023 1 11, -3610), (day 2023 1 21, 7200)] cutoff)+ [(day 2023 1 11, 10), (cutoff, 40)]+ , testCase "overpayment reverses interest" $+ assertInterest plain (compute plain+ [(day 2023 1 1, 3600), (day 2023 1 11, -7200)] cutoff)+ [(day 2023 1 11, 10), (cutoff, -20)]+ , testCase "overpayment retains unrounded compounded balance" $+ -- After posting 10 and repaying 7200, -3590 earns -3590/180.+ assertInterest compound (compute compound+ [(day 2023 1 1, 3600), (day 2023 1 11, -7200)] cutoff)+ [(day 2023 1 11, 10), (cutoff, (-359) % 18)]+ , testCase "all selected-account postings contribute" $ do+ -- Net opening principal 1800 earns 5; the net repayment of 900+ -- leaves 900, earning another 5 over twenty synthetic days.+ let balanceChange date quantities = nulltransaction+ { tdate = date, tpostings = map (mkPosting "Assets:Loan") quantities +++ [mkPosting "Equity:Funding" (negate (sum quantities))] }+ txns = runComputer plain $ do+ processTransaction (balanceChange (day 2023 1 1) [3600, -1800])+ processTransaction (balanceChange (day 2023 1 11) [-400, -500])+ computeInterest cutoff+ assertInterest plain txns [(day 2023 1 11, 5), (cutoff, 5)]+ , testCase "currencies accrue independently" $ do+ -- EUR: ten days on 3600, then twenty on 2400 = 10 + 40/3.+ -- USD: ten days on 7200, then twenty on 3600 = 20 + 20.+ let balanceChange date euroAmount dollarAmount = nulltransaction+ { tdate = date, tpostings =+ [currencyPosting currency account quantity+ | (currency, q) <- [("EUR", euroAmount), ("USD", dollarAmount)]+ , (account, quantity) <- [("Assets:Loan", q), ("Equity:Funding", negate q)]] }+ txns = runComputer plain $ do+ processTransaction (balanceChange (day 2023 1 1) 3600 7200)+ processTransaction (balanceChange (day 2023 1 11) (-1200) (-3600))+ computeInterest cutoff+ currencyInterest currency =+ [t { tpostings = [p { pamount = mixed (filter ((== currency) . acommodity) (amounts (pamount p))) }+ | p <- tpostings t] } | t <- txns]+ assertInterest plain (currencyInterest "EUR") [(day 2023 1 11, 10), (cutoff, 40 % 3)]+ assertInterest plain (currencyInterest "USD") [(day 2023 1 11, 20), (cutoff, 20)]+ ]+ | (name, mode) <- [("default", PostAtAllBoundaries), ("rate changes", PostOnRateChanges),+ ("transactions", PostOnTransactions)]+ , let plain = baseConfig { dayCountConvention = diff30E_360, postingMode = mode }+ compound = plain { targetAccount = "Assets:Loan" }+ ]+ where cutoff = day 2023 2 1
+ tests/fixtures/after-coverage.journal view
@@ -0,0 +1,9 @@+# Verify historical schedules keep their final rate beyond the old coverage limits.+# A loan and repayment in year 3000 exercise that rate without intervening years.+3000-01-01 Loan after the former built-in coverage limits+ Assets:Bank EUR 10000.00+ Liabilities:Loan++3000-02-01 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan
+ tests/fixtures/assertions-invalid.journal view
@@ -0,0 +1,10 @@+# Verify that journal balance assertions are rejected unless explicitly ignored.+# The opening assertion is deliberately off by one; the repayment leaves EUR 2700.++2023-01-01 Loan+ Assets:Loan EUR 3600.00 = EUR 3601.00+ Equity:Funding++2023-01-11 Repayment+ Assets:Loan EUR -900.00 = EUR 2700.00+ Equity:Funding
+ tests/fixtures/assertions-valid.journal view
@@ -0,0 +1,10 @@+# Verify that journal balance assertions are checked successfully.+# The opening assertion is correct; the repayment leaves EUR 2700.++2023-01-01 Loan+ Assets:Loan EUR 3600.00 = EUR 3600.00+ Equity:Funding++2023-01-11 Repayment+ Assets:Loan EUR -900.00 = EUR 2700.00+ Equity:Funding
+ tests/fixtures/before-coverage.journal view
@@ -0,0 +1,9 @@+# Verify schedules charge zero interest before their first effective rate.+# Both transactions precede the first entry in every historical schedule.+2000-01-01 Loan+ Assets:Bank EUR 10000.00+ Liabilities:Loan++2000-02-01 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan
+ tests/fixtures/bgb288-start.journal view
@@ -0,0 +1,9 @@+# Verify the first BGB288 rate takes effect on its exact start date.+# A loan just before 2002-01-01 earns interest on January 1st and 2nd only.+2001-12-31 Loan+ Assets:Bank EUR 10000.00+ Liabilities:Loan++2002-01-02 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan
+ tests/fixtures/bgb288.rates view
@@ -0,0 +1,57 @@+# Reproduce historical BGB288 output using --schedule-file instead of Haskell data.+# Retain every half-year boundary and the combined base rate plus five points.+# BGB288 historical regression snapshot, effective dates and annualized rates.+# Rates already include the BGB247 base rate plus 5 percentage points.+# Source: https://www.bundesbank.de/de/bundesbank/organisation/agb-und-regelungen/basiszinssatz-607820+# This fixed fixture is not a maintained rate feed.++2002-01-01 7.57%+2002-07-01 7.47%+2003-01-01 6.97%+2003-07-01 6.22%+2004-01-01 6.14%+2004-07-01 6.13%+2005-01-01 6.21%+2005-07-01 6.17%+2006-01-01 6.37%+2006-07-01 6.95%+2007-01-01 7.70%+2007-07-01 8.19%+2008-01-01 8.32%+2008-07-01 8.19%+2009-01-01 6.62%+2009-07-01 5.12%+2010-01-01 5.12%+2010-07-01 5.12%+2011-01-01 5.12%+2011-07-01 5.37%+2012-01-01 5.12%+2012-07-01 5.12%+2013-01-01 4.87%+2013-07-01 4.62%+2014-01-01 4.37%+2014-07-01 4.27%+2015-01-01 4.17%+2015-07-01 4.17%+2016-01-01 4.17%+2016-07-01 4.12%+2017-01-01 4.12%+2017-07-01 4.12%+2018-01-01 4.12%+2018-07-01 4.12%+2019-01-01 4.12%+2019-07-01 4.12%+2020-01-01 4.12%+2020-07-01 4.12%+2021-01-01 4.12%+2021-07-01 4.12%+2022-01-01 4.12%+2022-07-01 4.12%+2023-01-01 6.62%+2023-07-01 8.12%+2024-01-01 8.62%+2024-07-01 8.37%+2025-01-01 7.27%+2025-07-01 6.27%+2026-01-01 6.27%+2026-07-01 6.52%
+ tests/fixtures/boundaries.journal view
@@ -0,0 +1,37 @@+# Compare rate models, day counts, posting modes, and compounding on one journal.+# Month/year ends, a leap day, rate-change dates, same-day payments, and an unrelated account expose boundary and filtering errors.+2023-01-31 Loan+ Assets:Bank EUR 10000.00+ Liabilities:Loan++2023-02-28 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan++2023-03-31 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan++2023-07-01 Payment on rate change+ Assets:Bank EUR -100.00+ Liabilities:Loan++2023-07-01 Second payment on same day+ Assets:Bank EUR -50.00+ Liabilities:Loan++2024-01-15 Payment after year end+ Assets:Bank EUR -100.00+ Liabilities:Loan++2024-02-29 Leap day payment+ Assets:Bank EUR -100.00+ Liabilities:Loan++2024-03-31 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan++2024-04-01 Unrelated transaction+ Assets:Bank EUR -10.00+ Expenses:Other
+ tests/fixtures/comments-only.rates view
@@ -0,0 +1,8 @@+# Verify a schedule with comments and whitespace but no active entries earns zero.+# Mix indented comment markers with a block containing an invalid entry through EOF.+# no active entries+ ; another comment+ * org comment+ +comment+2023-01-01 invalid-but-commented
+ tests/fixtures/costed-loan.journal view
@@ -0,0 +1,5 @@+# Ensure transaction valuation costs do not carry into generated interest.+# The EUR principal has a USD unit cost, while accrued interest should remain EUR.+2023-01-01 Loan+ Assets:Loan EUR 3650.00 @ USD 1+ Equity:Funding EUR -3650.00 @ USD 1
+ tests/fixtures/cutoff.journal view
@@ -0,0 +1,17 @@+# Verify an inclusive effective-date cutoff excludes later transactions from output.+# Reverse primary/secondary date order and repay on the cutoff to expose filtering errors.+2023-12-30 Loan+ Assets:Bank EUR 1000000.00+ Liabilities:Loan++2024-01-01 Payment on cutoff+ Assets:Bank EUR -100.00+ Liabilities:Loan++2024-01-02=2023-12-31 Payment effective before cutoff+ Assets:Bank EUR -50.00+ Liabilities:Loan++2023-12-31=2024-01-02 Payment effective after cutoff+ Assets:Bank EUR -100000.00+ Liabilities:Loan
+ tests/fixtures/db24-start.journal view
@@ -0,0 +1,9 @@+# Verify the first DB24 rate takes effect on its exact start date.+# A loan just before 2000-10-06 earns interest on October 6th and 7th only.+2000-10-05 Loan+ Assets:Bank EUR 10000.00+ Liabilities:Loan++2000-10-07 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan
+ tests/fixtures/effective-dates.journal view
@@ -0,0 +1,17 @@+# Verify transactions are processed by effective date without overlapping accrual.+# A secondary date reverses the primary-date ordering of two repayments.+2023-01-01 Loan+ Assets:Bank EUR 10000.00+ Liabilities:Loan++2023-02-01=2023-03-01 Later value date+ Assets:Bank EUR -100.00+ Liabilities:Loan++2023-02-15 Earlier value date+ Assets:Bank EUR -100.00+ Liabilities:Loan++2023-04-01 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan
+ tests/fixtures/empty.rates view
+ tests/fixtures/historical.journal view
@@ -0,0 +1,17 @@+# Exercise historical DB24 and ING-DiBa rate changes and their compounding.+# Repayments bracket the 2010-09-16 and 2011-07-15 rate changes and cross year end.+2010-09-01 Loan+ Assets:Bank EUR 10000.00+ Liabilities:Loan++2010-10-01 Payment after DB24 rate change+ Assets:Bank EUR -100.00+ Liabilities:Loan++2011-07-01 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan++2011-08-01 Payment after ING rate change+ Assets:Bank EUR -100.00+ Liabilities:Loan
+ tests/fixtures/ing-diba-start.journal view
@@ -0,0 +1,9 @@+# Verify the first ING-DiBa rate takes effect on its exact start date.+# A loan just before 2009-01-01 earns interest on January 1st and 2nd only.+2008-12-31 Loan+ Assets:Bank EUR 10000.00+ Liabilities:Loan++2009-01-02 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan
+ tests/fixtures/leap-crossing.journal view
@@ -0,0 +1,9 @@+# Verify year-end posting controls and correct denominators across a leap year.+# A large loan spanning one day of 2023 and two of 2024 makes rounding-sensitive errors visible.+2023-12-30 Loan+ Assets:Bank EUR 1000000.00+ Liabilities:Loan++2024-01-02 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan
+ tests/fixtures/literal-account.journal view
@@ -0,0 +1,17 @@+# Verify account selection treats punctuation literally and excludes other accounts.+# Loan(1) is accompanied by a regex lookalike and a subaccount that must not affect it.+2023-01-01 Loan+ Assets:Bank EUR 10000.00+ Liabilities:Loan(1)++2023-01-15 Different account+ Assets:Bank EUR 1000.00+ Liabilities:Loan1++2023-01-20 Subaccount+ Assets:Bank EUR 1000.00+ Liabilities:Loan(1):Other++2023-02-01 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan(1)
+ tests/fixtures/multi-currency.journal view
@@ -0,0 +1,14 @@+# Keep currencies separate through a repayment.+# Different EUR and USD balances expose accidental currency aggregation.++2023-01-01 Loan+ Assets:Loan EUR 3600.00+ Assets:Loan USD 7200.00+ Equity:Funding EUR -3600.00+ Equity:Funding USD -7200.00++2023-01-11 Repayment+ Assets:Loan EUR -1200.00+ Assets:Loan USD -3600.00+ Equity:Funding EUR 1200.00+ Equity:Funding USD 3600.00
+ tests/fixtures/multiple-postings.journal view
@@ -0,0 +1,12 @@+# Sum every posting to the selected account.+# Mixed signs and repeated repayments expose first-posting-only handling.++2023-01-01 Loan+ Assets:Loan EUR 3600.00+ Assets:Loan EUR -1800.00+ Equity:Funding++2023-01-11 Repayment+ Assets:Loan EUR -400.00+ Assets:Loan EUR -500.00+ Equity:Funding
+ tests/fixtures/negative.rates view
@@ -0,0 +1,3 @@+# Verify a signed percentage parses as the equivalent negative fractional rate.+# One -1% entry predates the journal and must match the -0.01 golden output.+2020-01-01 -1%
+ tests/fixtures/overpayment.journal view
@@ -0,0 +1,10 @@+# Accrue negative interest after an overpayment.+# Repaying twice the principal reverses the balance sign.++2023-01-01 Loan+ Assets:Loan EUR 3600.00+ Equity:Funding++2023-01-11 Overpayment+ Assets:Loan EUR -7200.00+ Equity:Funding
+ tests/fixtures/positive.rates view
@@ -0,0 +1,3 @@+# Verify an explicit plus sign and percentage parse as a positive fractional rate.+# One +5% entry predates the journal and must match the 0.05 golden output.+2020-01-01 +5%
+ tests/fixtures/repayment-and-new-loan.journal view
@@ -0,0 +1,14 @@+# Resume accrual after full repayment and a dormant period.+# A rate change while the balance is zero must not create interest.++2023-01-01 Loan+ Assets:Loan EUR 3600.00+ Equity:Funding++2023-01-11 Full repayment+ Assets:Loan EUR -3600.00+ Equity:Funding++2023-01-21 New loan+ Assets:Loan EUR 7200.00+ Equity:Funding
+ tests/fixtures/review-30-360-monthly.journal view
@@ -0,0 +1,54 @@+# Issue #29: twelve monthly periods ending on the 30th (February 28th) must+# add up to exactly 360 days under every 30/360 convention. At 3600 and 10%+# every synthetic day earns 1.00. Zero payments only mark period ends.+2022-12-30 Loan+ Assets:Bank EUR 3600.00+ Liabilities:Loan++2023-01-30 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan++2023-02-28 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan++2023-03-30 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan++2023-04-30 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan++2023-05-30 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan++2023-06-30 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan++2023-07-30 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan++2023-08-30 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan++2023-09-30 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan++2023-10-30 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan++2023-11-30 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan++2023-12-30 Statement+ Assets:Bank EUR 0.00+ Liabilities:Loan
+ tests/fixtures/review-costed-repayment.journal view
@@ -0,0 +1,9 @@+# Costs on the loan and on the repayment (unit and total cost) must not+# reach the accrued principal or the generated interest postings.+2023-01-01 Loan+ Assets:Bank USD 3650.00+ Liabilities:Loan EUR -3650.00 @ USD 1.00++2023-01-11 Repayment+ Assets:Bank USD -2007.50+ Liabilities:Loan EUR 1825.00 @@ USD 2007.50
+ tests/fixtures/review-posting-date-cutoff.journal view
@@ -0,0 +1,14 @@+# Issue #31 with --until=2023-01-31: the cutoff applies to posting dates.+# Repayment A is entered before the cutoff but dated after it (excluded);+# repayment B is entered after the cutoff but dated before it (included).+2023-01-01 Loan+ Assets:Bank EUR 3650.00+ Liabilities:Loan++2023-01-05 Repayment A+ Assets:Bank EUR -1825.00+ Liabilities:Loan ; date:2023-02-10++2023-02-05 Repayment B+ Assets:Bank EUR -730.00+ Liabilities:Loan ; date:2023-01-21
+ tests/fixtures/review-posting-date-earlier.journal view
@@ -0,0 +1,9 @@+# Issue #31: a repayment recorded on 2023-02-01 carries a posting date of+# 2023-01-11, so the principal halves on 2023-01-11.+2023-01-01 Loan+ Assets:Bank EUR 3650.00+ Liabilities:Loan++2023-02-01 Repayment recorded late+ Assets:Bank EUR -1825.00+ Liabilities:Loan ; date:2023-01-11
+ tests/fixtures/review-posting-date-later.journal view
@@ -0,0 +1,5 @@+# Issue #31: the loan posting is dated ten days after its transaction, so+# interest accrues from 2023-01-11 (as in hledger register), not 2023-01-01.+2023-01-01 Loan+ Assets:Bank EUR 3650.00+ Liabilities:Loan ; date:2023-01-11
+ tests/fixtures/review-posting-date-year-end.journal view
@@ -0,0 +1,5 @@+# Issue #31: the loan posting is dated in the next year, so nothing accrues+# or posts at the 2023 year end.+2023-12-01 Loan+ Assets:Bank EUR 3660.00+ Liabilities:Loan ; date:2024-01-01
+ tests/fixtures/review-posting-date2.journal view
@@ -0,0 +1,5 @@+# Issue #31: hledger-interest uses secondary dates, so a posting's date2 tag+# dates the balance change, like `hledger register --date2`.+2023-01-01 Loan+ Assets:Bank EUR 3650.00+ Liabilities:Loan ; date2:2023-01-11
+ tests/fixtures/schedule-bad-second-line.rates view
@@ -0,0 +1,4 @@+# Verify a malformed later entry is rejected and reports its actual line number.+# Follow one valid entry with an entry indented by three spaces.+2026-01-01 0.05+ 2026-02-01 0.07
+ tests/fixtures/schedule-crlf.rates view
@@ -0,0 +1,5 @@+# Verify Windows CRLF input produces the same output as the inline schedule. +# Use the three reference rates, mixing fractions and percentages; preserve CRLF. +2023-02-15 0.05 +2023-07-01 7% +2024-02-29 0.03
+ tests/fixtures/schedule-double-percent.rates view
@@ -0,0 +1,3 @@+# Verify the parser rejects a duplicated percentage suffix.+# The date and numeric value are valid, but 5%% leaves an extra percent sign.+2026-01-01 5%%
+ tests/fixtures/schedule-extra-field.rates view
@@ -0,0 +1,3 @@+# Verify an entry cannot contain an unexpected third field.+# Append an uncommented word after an otherwise valid date and fractional rate.+2026-01-01 0.05 extra
+ tests/fixtures/schedule-indented-spaces.rates view
@@ -0,0 +1,3 @@+# Verify schedule entries must start in column 1 even when whitespace is ignored elsewhere.+# Prepend three spaces to an otherwise valid date and rate.+ 2026-01-01 0.05
+ tests/fixtures/schedule-indented-tab.rates view
@@ -0,0 +1,3 @@+# Verify a tab cannot indent a schedule entry.+# Prepend a literal tab to an otherwise valid date and rate.+ 2026-01-01 0.05
+ tests/fixtures/schedule-inline-hash.rates view
@@ -0,0 +1,3 @@+# Verify hash comments are allowed only on comment lines, not after an entry.+# Append a hash and text after a valid rate instead of using a semicolon.+2026-01-01 0.05 # not an inline comment
+ tests/fixtures/schedule-inline-star.rates view
@@ -0,0 +1,3 @@+# Verify star comments are allowed only on comment lines, not after an entry.+# Append a star and text after a valid rate instead of using a semicolon.+2026-01-01 0.05 * not an inline comment
+ tests/fixtures/schedule-invalid-date.rates view
@@ -0,0 +1,3 @@+# Verify calendar dates are validated rather than clipped or normalised.+# Use February 30th with an otherwise valid fractional rate.+2026-02-30 0.05
+ tests/fixtures/schedule-invalid-rate.rates view
@@ -0,0 +1,3 @@+# Verify a schedule rate must be numeric.+# Put an unparseable word in the rate field after a valid date.+2026-01-01 bananas
+ tests/fixtures/schedule-missing-rate.rates view
@@ -0,0 +1,3 @@+# Verify every schedule entry requires a rate on the same line.+# Provide only a valid date followed by a newline.+2026-01-01
+ tests/fixtures/schedule-mixed-date-separators.rates view
@@ -0,0 +1,3 @@+# Verify hledger dates require consistent separators within a date.+# Mix a dash and a slash in an otherwise valid year-month-day value.+2026-01/01 0.05
+ tests/fixtures/schedule-no-newline.rates view
@@ -0,0 +1,5 @@+# Verify the last schedule entry is accepted without a final newline.+# Use the reference schedule and finish with an inline comment; keep EOF unterminated.+2023-02-15 0.05+2023-07-01 7%+2024-02-29 0.03 ; no final newline
+ tests/fixtures/schedule.rates view
@@ -0,0 +1,18 @@+# Verify file input matches inline-schedule goldens for all day counts and posting modes.+# Unsorted mixed date/rate formats and each comment form exercise parsing and sorting.+# Historical rates — unsorted rates exercise sorting as well as parsing.+ ; Indented comment lines are allowed.+ # A tab-indented comment is also allowed.+* An org-style comment line++comment+This block is ignored, even if its contents look like invalid entries.+ 2023-01-01 not-a-rate+end comment++2024.2.29 3 % ; Percentage with whitespace before the percent sign+2023/7/1 0.07+2023-02-15 5%; Inline semicolon comments need no separating space.+ ; A following, indented comment+comment+The final block comment intentionally runs through EOF.
+ tests/fixtures/split-loan.journal view
@@ -0,0 +1,6 @@+# Supply the opening balance for multi-file and stdin tests.+# The repayment lives in split-payment.journal to test merged input ordering.++2023-01-01 Loan+ Assets:Loan EUR 3600.00+ Equity:Funding
+ tests/fixtures/split-payment.journal view
@@ -0,0 +1,6 @@+# Supply the later repayment for multi-file and stdin tests.+# Reading this file first must still process the opening loan first.++2023-01-11 Repayment+ Assets:Loan EUR -900.00+ Equity:Funding
+ tests/fixtures/unicode.journal view
@@ -0,0 +1,9 @@+# Verify Unicode survives journal parsing, generated descriptions, and byte comparisons.+# Use accented account/transaction names and the euro currency symbol.+2023-01-01 Prêt en euros+ Assets:Bank €10000.00+ Liabilities:Prêt++2023-02-01 Remboursement+ Assets:Bank €-100.00+ Liabilities:Prêt
+ tests/fixtures/year-end-transaction.journal view
@@ -0,0 +1,13 @@+# Verify suppressing automatic year-end postings still posts on a December 31st transaction.+# Place a repayment at year end between the initial loan and a January repayment.+2023-12-30 Loan+ Assets:Bank EUR 10000.00+ Liabilities:Loan++2023-12-31 Payment at year end+ Assets:Bank EUR -100.00+ Liabilities:Loan++2024-01-02 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan
+ tests/fixtures/yearless.rates view
@@ -0,0 +1,4 @@+# Verify yearless hledger dates use the current local year.+# Use slash and dot month/day forms and compare with an explicit-year inline schedule.+2/15 5%+7.1 0.07
+ tests/golden-tests.hs view
@@ -0,0 +1,409 @@+module Main (main) where++import HistoricalRates (historicalModels)++import Control.Monad (forM_, (>=>))++import qualified Data.ByteString.Lazy as B+import qualified Data.ByteString.Lazy.Char8 as BC+import Data.List (isInfixOf)+import Data.Time.Calendar (toGregorian)+import Data.Time.LocalTime (getZonedTime, localDay, zonedTimeToLocalTime)+import System.Exit (ExitCode(..))+import System.Process.Typed (proc, readProcess, setStdin, closed, byteStringInput)+import Test.Tasty (defaultMain, localOption, testGroup, TestTree)+import Test.Tasty.Golden (goldenVsString, NoCreateFile(..))+import Test.Tasty.HUnit (testCase, assertBool, (@?=))++main :: IO ()+main = defaultMain $ testGroup "CLI regression"+ [ localOption (NoCreateFile True) $ testGroup "golden"+ ([ goldenCase name fixture "Liabilities:Loan" target args+ | (name, fixture, target, args) <- cases+ ] +++ [ goldenCase "costed-loan" "costed-loan" "Assets:Loan" "Assets:Loan"+ ["--annual=0.1", "--act", "--quiet", "--until=2023-01-11"]+ , goldenCase "effective-dates" "effective-dates" "Liabilities:Loan"+ "Liabilities:Interest" ["--annual=0.05", "--act", "--quiet"]+ , goldenCase "literal-account" "literal-account" "Liabilities:Loan(1)"+ "Liabilities:Loan(1)" ["--annual=0.05", "--act"]+ , goldenCase "unicode" "unicode" "Liabilities:Prêt"+ "Liabilities:Prêt" ["--annual=0.05", "--act"]+ ] +++ [ goldenCase (model ++ "-first-period") (model ++ "-start")+ "Liabilities:Loan" "Liabilities:Interest" (args ++ ["--quiet"])+ | (model, args) <- historicalModels+ ] +++ [ goldenCase (model ++ "-before-first-rate") "before-coverage"+ "Liabilities:Loan" "Liabilities:Interest" (args ++ ["--quiet"])+ | (model, args) <- historicalModels+ ] +++ [ goldenCase (model ++ "-after-last-rate") "after-coverage"+ "Liabilities:Loan" "Liabilities:Interest" (args ++ ["--quiet"])+ | (model, args) <- historicalModels+ ] +++ [ goldenOutput ("schedule-file-" ++ mode ++ "-" ++ convention)+ (baseline ++ convention)+ (fileArguments "schedule" (flag : postingFlags))+ | (mode, postingFlags, baseline) <-+ [("default", [], "schedule-"),+ ("rate-changes", ["--post-on-rate-changes"], "schedule-no-year-end-"),+ ("transactions", ["--post-on-transactions"], "schedule-transactions-only-")]+ , (convention, flag) <- dayCounts+ ] +++ [ goldenOutput ("schedule-file-" ++ fixture) baseline (fileArguments fixture ["--act"])+ | (fixture, baseline) <-+ [("schedule-crlf", "schedule-act"), ("schedule-no-newline", "schedule-act"),+ ("empty", "zero-rate"), ("comments-only", "zero-rate"),+ ("negative", "negative-rate"), ("positive", "annual-act")]+ ])+ , balanceAndInputTests+ , reviewRegressionTests+ , testGroup "schedule file errors"+ ([ failurePrefix fixture (fileArguments ("schedule-" ++ fixture) ["--act"])+ ("tests/fixtures/schedule-" ++ fixture ++ ".rates:" ++ line ++ ":" ++ column)+ | (fixture, line, column) <-+ [("indented-spaces", "3", "1:"), ("indented-tab", "3", "1:"),+ ("invalid-date", "3", ""), ("mixed-date-separators", "3", ""),+ ("invalid-rate", "3", ""), ("missing-rate", "3", ""),+ ("extra-field", "3", ""), ("inline-hash", "3", ""),+ ("inline-star", "3", ""), ("double-percent", "3", ""),+ ("bad-second-line", "4", "1:")]+ ] +++ [ failurePrefix "missing-file" (fileArguments "missing" ["--act"])+ "tests/fixtures/missing.rates: "+ ])+ , testGroup "schedule file options"+ [ testCase "last-rate-option-wins" $ do+ let invoke opts = successfulOutput $ arguments "boundaries" "Liabilities:Loan"+ "Liabilities:Loan" (["--act", "--quiet"] ++ opts)+ file name = "--schedule-file=tests/fixtures/" ++ name ++ ".rates"+ fixed <- invoke ["--annual=0.05"]+ scheduled <- invoke [schedule]+ zero <- invoke ["--schedule=[]"]+ invoke [file "missing", "--annual=0.05"] >>= (@?= fixed)+ invoke ["--annual=0.9", file "schedule"] >>= (@?= scheduled)+ invoke [file "schedule", "--schedule=[]"] >>= (@?= zero)+ invoke ["--schedule=bogus", file "schedule"] >>= (@?= scheduled)+ invoke [file "schedule", file "empty"] >>= (@?= zero)+ , testCase "yearless-dates-use-current-year" $ do+ (year, _, _) <- toGregorian . localDay . zonedTimeToLocalTime <$> getZonedTime+ let inline = "--schedule=[(" ++ show year ++ "-02-15,0.05),(" ++ show year ++ "-07-01,0.07)]"+ expected <- successfulOutput (arguments "boundaries" "Liabilities:Loan"+ "Liabilities:Loan" [inline, "--act", "--quiet"])+ successfulOutput (fileArguments "yearless" ["--act"]) >>= (@?= expected)+ ]+ , testGroup "errors"+ ([ failureCase ("removed-" ++ model) ["--" ++ model]+ ("unrecognized option `--" ++ model ++ "'")+ | (model, _) <- historicalModels+ ] +++ [ failureCase ("invalid-" ++ flag)+ (arguments "boundaries" "Liabilities:Loan" "Liabilities:Interest"+ ["--act", "--" ++ flag ++ "=bogus"])+ ("invalid value for --" ++ flag ++ ": bogus")+ | flag <- ["constant", "annual", "schedule", "annual-schedule"]+ ] +++ [ failureCase "abbreviated-account"+ (arguments "boundaries" "Loan" "Liabilities:Interest" ["--annual=0.05", "--act"])+ "no transactions found for account Loan"+ , failureCase "missing-account-before-reading-journal"+ ["--file=tests/fixtures/does-not-exist.journal", "--source=Expenses:Interest",+ "--target=Liabilities:Interest", "--act", "--annual=0.05"]+ "required argument ACCOUNT is missing"+ , failureCase "invalid-rate-before-reading-journal"+ ["--file=tests/fixtures/does-not-exist.journal", "--source=Expenses:Interest",+ "--target=Liabilities:Interest", "--act", "--annual=bogus", "Liabilities:Loan"]+ "invalid value for --annual: bogus"+ ])+ , testGroup "rate aliases"+ [ testCase (mode ++ "-" ++ convention) $ do+ let invoke rate = successfulOutput $ arguments "boundaries" "Liabilities:Loan"+ "Liabilities:Loan" ([rate, flag, "--quiet"] ++ postingFlags)+ rates = "[(2023-01-01,0.05)]"+ expected <- invoke ("--schedule=" ++ rates)+ forM_ ["--annual-schedule=" ++ rates, "--constant=0.05", "--annual=0.05"]+ (invoke >=> (@?= expected))+ | (mode, postingFlags) <- postingModes+ , (convention, flag) <- dayCounts+ ]+ , testGroup "posting flag precedence"+ [ testCase convention $ do+ let invoke flags = successfulOutput $ arguments "boundaries" "Liabilities:Loan"+ "Liabilities:Loan" ([schedule, flag, "--quiet"] ++ flags)+ expected <- invoke ["--post-on-transactions"]+ invoke ["--post-on-transactions", "--post-on-rate-changes"] >>= (@?= expected)+ invoke ["--post-on-rate-changes", "--post-on-transactions"] >>= (@?= expected)+ | (convention, flag) <- dayCounts+ ]+ , testGroup "cutoff options"+ [ testCase "last-cutoff-option-wins-and-today-is-an-alias" $ do+ let invoke flags = successfulOutput $ arguments "boundaries" "Liabilities:Loan"+ "Liabilities:Loan" ([schedule, "--act", "--quiet"] ++ flags)+ cutoff <- invoke ["--until=2024-01-10"]+ invoke ["--today", "--until=2024-01-10"] >>= (@?= cutoff)+ invoke ["--until=bad", "--until=2024-01-10"] >>= (@?= cutoff)+ full <- invoke []+ invoke ["--until=2024-01-10", "--until=2024-03-31"] >>= (@?= full)+ today <- invoke ["--until=today"]+ invoke ["--today"] >>= (@?= today)+ invoke ["--until=2024-01-10", "--today"] >>= (@?= today)+ , testCase "unpadded-date" $ do+ let invoke date = successfulOutput $ arguments "leap-crossing" "Liabilities:Loan"+ "Liabilities:Loan" ["--annual=0.05", "--act", "--quiet", "--until=" ++ date]+ expected <- invoke "2024-01-05"+ invoke "2024-1-5" >>= (@?= expected)+ , testCase "cutoff-before-first-transaction" $+ forM_ postingModes $ \(_, flags) -> do+ out <- successfulOutput $ arguments "boundaries" "Liabilities:Loan"+ "Liabilities:Loan" ([schedule, "--act", "--until=2023-01-30"] ++ flags)+ out @?= B.empty+ ]+ , testGroup "cutoff errors"+ ([ failureCase ("invalid-" ++ date)+ (arguments "boundaries" "Liabilities:Loan" "Liabilities:Loan"+ ["--annual=0.05", "--act", "--until=" ++ date])+ ("invalid value for --until: " ++ date)+ | date <- ["bad", "2023-02-29", "2024-13-01", "2024/01/10", "2024-01-10junk", ""]+ ] +++ [ failureCase "invalid-cutoff-before-reading-journal"+ ["--file=tests/fixtures/does-not-exist.journal", "--source=Expenses:Interest",+ "--target=Liabilities:Loan", "--annual=0.05", "--act", "--until=bad", "Liabilities:Loan"]+ "invalid value for --until: bad"+ ])+ ]++postingModes :: [(String, [String])]+postingModes = [("default", []), ("rate-changes", ["--post-on-rate-changes"]),+ ("transactions", ["--post-on-transactions"])]++dayCounts :: [(String, String)]+dayCounts = [("act", "--act"), ("30-360", "--30-360"),+ ("30E-360", "--30E-360"), ("30E-360isda", "--30E-360isda")]++schedule :: String+schedule = "--schedule=[(2023-02-15,0.05),(2023-07-01,0.07),(2024-02-29,0.03)]"++-- Capture bytes directly, preserving Unicode and avoiding locale-dependent decoding.+runCLI :: [String] -> IO (ExitCode, B.ByteString, B.ByteString)+runCLI = readProcess . setStdin closed . proc "hledger-interest"++arguments :: String -> String -> String -> [String] -> [String]+arguments fixture account target args =+ ["--file=tests/fixtures/" ++ fixture ++ ".journal",+ "--source=Expenses:Interest", "--target=" ++ target] ++ args ++ [account]++goldenCase :: String -> String -> String -> String -> [String] -> TestTree+goldenCase name fixture account target args =+ goldenOutput name name (arguments fixture account target args)++goldenOutput :: String -> String -> [String] -> TestTree+goldenOutput name baseline args =+ goldenVsString name ("tests/golden/" ++ baseline ++ ".golden") (successfulOutput args)++fileArguments :: String -> [String] -> [String]+fileArguments fixture flags = arguments "boundaries" "Liabilities:Loan" "Liabilities:Loan"+ (["--schedule-file=tests/fixtures/" ++ fixture ++ ".rates", "--quiet"] ++ flags)++successfulOutput :: [String] -> IO B.ByteString+successfulOutput args = do+ (status, out, err) <- runCLI args+ status @?= ExitSuccess+ err @?= B.empty+ pure out++failureCase :: String -> [String] -> String -> TestTree+failureCase name args message = failurePrefix name args (message ++ "\n")++failurePrefix :: String -> [String] -> String -> TestTree+failurePrefix name args message = testCase name $ do+ (status, out, err) <- runCLI args+ status @?= ExitFailure 1+ out @?= B.empty+ assertBool ("Expected error: " ++ message ++ "\nActual stderr: " ++ BC.unpack err)+ (BC.pack message `B.isPrefixOf` err)++cases :: [(String, String, String, [String])]+cases =+ [ (model ++ "-" ++ convention, "boundaries", principal, [rate, flag, "--quiet"])+ | (model, rate) <- [("constant", "--constant=0.05"), ("annual", "--annual=0.05"),+ ("schedule", schedule)]+ , (convention, flag) <- dayCounts+ ] +++ [ ("schedule-no-year-end-" ++ convention, "boundaries", principal,+ [schedule, flag, "--quiet", "--post-on-rate-changes"])+ | (convention, flag) <- dayCounts+ ] +++ [ ("no-year-end-leap-" ++ convention, "leap-crossing", principal,+ ["--annual=0.05", flag, "--quiet", "--post-on-rate-changes"])+ | (convention, flag) <- dayCounts+ ] +++ [ ("schedule-transactions-only-" ++ convention, "boundaries", principal,+ [schedule, flag, "--quiet", "--post-on-transactions"])+ | (convention, flag) <- dayCounts+ ] +++ [ ("until-" ++ mode ++ "-" ++ convention, "boundaries", principal,+ [schedule, flag, "--quiet", "--until=2024-01-10"] ++ postingFlags)+ | (mode, postingFlags) <- postingModes+ , (convention, flag) <- dayCounts+ ] +++ [ ("until-after-last-transaction", "leap-crossing", principal,+ ["--annual=0.05", "--act", "--quiet", "--until=2024-01-05"])+ , ("until-effective-dates", "cutoff", principal,+ ["--annual=0.05", "--act", "--until=2024-01-01"])+ ] +++ [ ("help", "boundaries", principal, ["--help"])+ , ("rate-change-at-year-end", "leap-crossing", principal,+ ["--schedule=[(2023-01-01,0.05),(2024-01-01,0.07)]", "--act", "--quiet", "--post-on-rate-changes"])+ , ("transaction-at-year-end", "year-end-transaction", principal,+ ["--annual=0.05", "--act", "--quiet", "--post-on-rate-changes"])+ , ("annual-separate-account", "boundaries", "Liabilities:Interest",+ ["--annual=0.05", "--act", "--quiet"])+ , ("annual-verbose", "boundaries", principal, ["--annual=0.05", "--act"])+ , ("zero-rate", "boundaries", principal, ["--annual=0", "--act", "--quiet"])+ , ("negative-rate", "boundaries", principal, ["--annual=-0.01", "--act", "--quiet"])+ ] +++ [ (model, if model == "bgb288" then "boundaries" else "historical", principal,+ args ++ ["--quiet"])+ | (model, args) <- historicalModels+ ]+ where+ principal = "Liabilities:Loan"++-- The same balance cases run under every posting policy. Their dates avoid+-- calendar-year boundaries; the dormant case changes rate while fully repaid.+balanceAndInputTests :: TestTree+balanceAndInputTests = localOption (NoCreateFile True) $ testGroup "Balances and journal input"+ ([ goldenOutput (fixture ++ "-" ++ mode)+ (fixture ++ if mode == "transactions" then "-transactions" else "")+ (assetArguments [fixture] (rate : flags))+ | fixture <- ["multi-currency", "repayment-and-new-loan", "overpayment", "multiple-postings"]+ , let rate = if fixture == "repayment-and-new-loan"+ then "--schedule=[(2023-01-01,0.1),(2023-01-16,0.2)]"+ else "--annual=0.1"+ , (mode, flags) <- postingModes+ ] +++ [ goldenOutput "multiple-files" "multiple-files" (assetArguments ["split-loan", "split-payment"] [])+ , goldenOutput "multiple-files-reversed" "multiple-files" (assetArguments ["split-payment", "split-loan"] [])+ , goldenOutput "valid-balance-assertions" "multiple-files" (assetArguments ["assertions-valid"] [])+ ] +++ [ goldenOutput flag "multiple-files" (assetArguments ["assertions-invalid"] [flag])+ | flag <- ["--ignore-assertions", "-I"]+ ] +++ [ testCase "invalid-balance-assertion" $ do+ (status, out, err) <- runCLI (assetArguments ["assertions-invalid"] [])+ status @?= ExitFailure 1+ out @?= B.empty+ assertBool (BC.unpack err) ("Balance assertion failed" `isInfixOf` BC.unpack err)+ , goldenVsString "stdin" "tests/golden/multiple-files.golden" $ do+ input <- B.concat <$> mapM (B.readFile . fixturePath) ["split-loan", "split-payment"]+ (status, out, err) <- readProcess $ setStdin (byteStringInput input) $+ proc "hledger-interest" ("--file=-" : assetArguments [] [])+ status @?= ExitSuccess+ err @?= B.empty+ pure out+ ])++fixturePath :: String -> FilePath+fixturePath fixture = "tests/fixtures/" ++ fixture ++ ".journal"++assetArguments :: [String] -> [String] -> [String]+assetArguments fixtures flags = map (("--file=" ++) . fixturePath) fixtures +++ ["--source=Income:Interest", "--target=Assets:Interest", "--annual=0.1",+ "--30E-360", "--quiet", "--until=2023-02-01"] ++ flags ++ ["Assets:Loan"]++-- Regression tests for the findings of the November 2026 code review. All+-- expectations are calculated by hand; they are not recorded program output.+reviewRegressionTests :: TestTree+reviewRegressionTests = testGroup "review regressions"+ [ testGroup "30/360 monthly periods add up to 360 days (#29)"+ [ testCase convention $ do+ out <- successfulOutput $ reviewArguments "review-30-360-monthly" "Liabilities:Interest"+ [flag, "--annual=0.1", "--post-on-transactions"]+ let expected = zipWith (\date n -> (date, n, "EUR -" ++ show n ++ ".00")) monthEnds days+ interestSummary out @?= expected+ sum days @?= (360 :: Int)+ | (convention, flag, days) <-+ -- 30/360 counts 2023-01-30 -> 2023-02-28 as 28 and 2023-02-28 -> 2023-03-30+ -- as 32; 30E/360 agrees. 30E/360isda treats February 28th as the 30th.+ [ ("30-360", "--30-360", [30, 28, 32] ++ replicate 9 30)+ , ("30E-360", "--30E-360", [30, 28, 32] ++ replicate 9 30)+ , ("30E-360isda", "--30E-360isda", replicate 12 30) ]+ ]+ , testGroup "posting-level dates (#31)"+ [ testCase "posting dated after its transaction" $ do+ -- 3650 * 10% * 21 / 365 = 21.00 for 2023-01-11 -> 2023-02-01.+ out <- successfulOutput $ reviewArguments "review-posting-date-later" "Liabilities:Interest"+ ["--act", "--annual=0.1", "--until=2023-02-01"]+ interestSummary out @?= [("2023-02-01", 21, "EUR -21.00")]+ , testCase "posting dated before its transaction" $ do+ -- 10 days on 3650 = 10.00, then 21 days on 1825 = 10.50.+ out <- successfulOutput $ reviewArguments "review-posting-date-earlier" "Liabilities:Interest"+ ["--act", "--annual=0.1", "--until=2023-02-01"]+ interestSummary out @?= [("2023-01-11", 10, "EUR -10.00"), ("2023-02-01", 21, "EUR -10.50")]+ , testCase "cutoff applies to posting dates" $ do+ -- Only repayment B (dated 2023-01-21) counts: 20 days on 3650 = 20.00,+ -- then 10 days on 2920 = 8.00.+ out <- successfulOutput $ reviewArguments "review-posting-date-cutoff" "Liabilities:Interest"+ ["--act", "--annual=0.1", "--until=2023-01-31"]+ interestSummary out @?= [("2023-01-21", 20, "EUR -20.00"), ("2023-01-31", 10, "EUR -8.00")]+ , testCase "posting dated across a year end" $ do+ -- No 2023 year-end posting; 3660 * 10% * 30 / 366 = 30.00 in 2024.+ out <- successfulOutput $ reviewArguments "review-posting-date-year-end" "Liabilities:Interest"+ ["--act", "--annual=0.1", "--until=2024-01-31"]+ interestSummary out @?= [("2024-01-31", 30, "EUR -30.00")]+ , testCase "posting date2 tag" $ do+ out <- successfulOutput $ reviewArguments "review-posting-date2" "Liabilities:Interest"+ ["--act", "--annual=0.1", "--until=2023-02-01"]+ interestSummary out @?= [("2023-02-01", 21, "EUR -21.00")]+ ]+ , testGroup "transaction costs"+ [ testCase "costs do not reach principal or interest postings" $ do+ -- 10 days on 3650 = 10.00, then 21 days on 1825 = 10.50.+ out <- successfulOutput $ reviewArguments "review-costed-repayment" "Liabilities:Interest"+ ["--act", "--annual=0.1", "--until=2023-02-01"]+ interestSummary out @?= [("2023-01-11", 10, "EUR -10.00"), ("2023-02-01", 21, "EUR -10.50")]+ assertBool ("Generated interest mentions a cost:\n" ++ BC.unpack out) ('@' `BC.notElem` out)+ assertBool ("Unexpected descriptions:\n" ++ BC.unpack out) $+ all (`isInfixOf` BC.unpack out) ["for EUR -3650.00 over", "for EUR -1825.00 over"]+ , testCase "compounded interest stays cost-free" $ do+ -- After compounding 10.00, 21 days on 1835 = 10.5575, shown as 10.56.+ out <- successfulOutput $ reviewArguments "review-costed-repayment" "Liabilities:Loan"+ ["--act", "--annual=0.1", "--until=2023-02-01"]+ interestSummary out @?= [("2023-01-11", 10, "EUR -10.00"), ("2023-02-01", 21, "EUR -10.56")]+ assertBool ("Generated interest mentions a cost:\n" ++ BC.unpack out) ('@' `BC.notElem` out)+ , testCase "verbose output keeps the original costs" $ do+ out <- successfulOutput $ filter (/= "--quiet") $ reviewArguments "review-costed-repayment"+ "Liabilities:Interest" ["--act", "--annual=0.1", "--until=2023-02-01"]+ assertBool ("Input transactions lost their costs:\n" ++ BC.unpack out) $+ all (`isInfixOf` BC.unpack out) ["@ USD 1.00", "@@ USD 2007.50"]+ ]+ , testCase "verbose output echoes matching transactions only" $ do+ let invoke flags = successfulOutput $ arguments "boundaries" "Liabilities:Loan"+ "Liabilities:Interest" (["--annual=0.05", "--act"] ++ flags)+ verbose <- BC.unpack <$> invoke []+ quiet <- BC.unpack <$> invoke ["--quiet"]+ assertBool "--verbose echoed a transaction without the interest account"+ (not ("Unrelated transaction" `isInfixOf` verbose))+ assertBool "--verbose omitted a matching transaction"+ (all (`isInfixOf` verbose) ["Loan", "Payment after year end", "Leap day payment"])+ assertBool "--quiet printed an input transaction" (not ("Payment" `isInfixOf` quiet))+ ]+ where+ monthEnds = [ "2023-" ++ pad m ++ "-" ++ (if m == 2 then "28" else "30") | m <- [1 .. 12 :: Int] ]+ pad m = (if m < 10 then "0" else "") ++ show m++reviewArguments :: String -> String -> [String] -> [String]+reviewArguments fixture target flags = arguments fixture "Liabilities:Loan" target ("--quiet" : flags)++-- | Extract (date, days, first posting amount) from each generated interest+-- transaction, independent of column alignment.+interestSummary :: B.ByteString -> [(String, Int, String)]+interestSummary = go . lines . BC.unpack+ where+ go (header : posting : rest)+ | " interest for " `isInfixOf` header+ , [n, "days"] <- drop (length (words header) - 2) (words header) =+ (takeWhile (/= ' ') header, read n, unwords (drop 1 (words posting))) : go rest+ go (_ : rest) = go rest+ go [] = []
+ tests/golden/annual-30-360.golden view
@@ -0,0 +1,28 @@+2023-02-28 5% interest for EUR -10000.00 over 27 days+ Liabilities:Loan EUR -37.50+ Expenses:Interest EUR 37.50++2023-03-31 5% interest for EUR -9937.50 over 33 days+ Liabilities:Loan EUR -45.55+ Expenses:Interest EUR 45.55++2023-07-01 5% interest for EUR -9883.05 over 90 days+ Liabilities:Loan EUR -123.54+ Expenses:Interest EUR 123.54++2023-12-31 5% interest for EUR -9856.58 over 180 days+ Liabilities:Loan EUR -246.41+ Expenses:Interest EUR 246.41++2024-01-15 5% interest for EUR -10103.00 over 14 days+ Liabilities:Loan EUR -19.64+ Expenses:Interest EUR 19.64++2024-02-29 5% interest for EUR -10022.64 over 44 days+ Liabilities:Loan EUR -61.25+ Expenses:Interest EUR 61.25++2024-03-31 5% interest for EUR -9983.89 over 32 days+ Liabilities:Loan EUR -44.37+ Expenses:Interest EUR 44.37+
+ tests/golden/annual-30E-360.golden view
@@ -0,0 +1,28 @@+2023-02-28 5% interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR -38.89+ Expenses:Interest EUR 38.89++2023-03-31 5% interest for EUR -9938.89 over 32 days+ Liabilities:Loan EUR -44.17+ Expenses:Interest EUR 44.17++2023-07-01 5% interest for EUR -9883.06 over 91 days+ Liabilities:Loan EUR -124.91+ Expenses:Interest EUR 124.91++2023-12-31 5% interest for EUR -9857.97 over 179 days+ Liabilities:Loan EUR -245.08+ Expenses:Interest EUR 245.08++2024-01-15 5% interest for EUR -10103.05 over 15 days+ Liabilities:Loan EUR -21.05+ Expenses:Interest EUR 21.05++2024-02-29 5% interest for EUR -10024.10 over 44 days+ Liabilities:Loan EUR -61.26+ Expenses:Interest EUR 61.26++2024-03-31 5% interest for EUR -9985.36 over 31 days+ Liabilities:Loan EUR -42.99+ Expenses:Interest EUR 42.99+
+ tests/golden/annual-30E-360isda.golden view
@@ -0,0 +1,28 @@+2023-02-28 5% interest for EUR -10000.00 over 30 days+ Liabilities:Loan EUR -41.67+ Expenses:Interest EUR 41.67++2023-03-31 5% interest for EUR -9941.67 over 30 days+ Liabilities:Loan EUR -41.42+ Expenses:Interest EUR 41.42++2023-07-01 5% interest for EUR -9883.09 over 91 days+ Liabilities:Loan EUR -124.91+ Expenses:Interest EUR 124.91++2023-12-31 5% interest for EUR -9858.00 over 179 days+ Liabilities:Loan EUR -245.08+ Expenses:Interest EUR 245.08++2024-01-15 5% interest for EUR -10103.08 over 15 days+ Liabilities:Loan EUR -21.05+ Expenses:Interest EUR 21.05++2024-02-29 5% interest for EUR -10024.13 over 45 days+ Liabilities:Loan EUR -62.65+ Expenses:Interest EUR 62.65++2024-03-31 5% interest for EUR -9986.78 over 30 days+ Liabilities:Loan EUR -41.61+ Expenses:Interest EUR 41.61+
+ tests/golden/annual-act.golden view
@@ -0,0 +1,28 @@+2023-02-28 5% interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR -38.36+ Expenses:Interest EUR 38.36++2023-03-31 5% interest for EUR -9938.36 over 31 days+ Liabilities:Loan EUR -42.20+ Expenses:Interest EUR 42.20++2023-07-01 5% interest for EUR -9880.56 over 92 days+ Liabilities:Loan EUR -124.52+ Expenses:Interest EUR 124.52++2023-12-31 5% interest for EUR -9855.08 over 183 days+ Liabilities:Loan EUR -247.05+ Expenses:Interest EUR 247.05++2024-01-15 5% interest for EUR -10102.13 over 15 days+ Liabilities:Loan EUR -20.70+ Expenses:Interest EUR 20.70++2024-02-29 5% interest for EUR -10022.84 over 45 days+ Liabilities:Loan EUR -61.62+ Expenses:Interest EUR 61.62++2024-03-31 5% interest for EUR -9984.45 over 31 days+ Liabilities:Loan EUR -42.28+ Expenses:Interest EUR 42.28+
+ tests/golden/annual-separate-account.golden view
@@ -0,0 +1,28 @@+2023-02-28 5% interest for EUR -10000.00 over 28 days+ Liabilities:Interest EUR -38.36+ Expenses:Interest EUR 38.36++2023-03-31 5% interest for EUR -9900.00 over 31 days+ Liabilities:Interest EUR -42.04+ Expenses:Interest EUR 42.04++2023-07-01 5% interest for EUR -9800.00 over 92 days+ Liabilities:Interest EUR -123.51+ Expenses:Interest EUR 123.51++2023-12-31 5% interest for EUR -9650.00 over 183 days+ Liabilities:Interest EUR -241.91+ Expenses:Interest EUR 241.91++2024-01-15 5% interest for EUR -9650.00 over 15 days+ Liabilities:Interest EUR -19.77+ Expenses:Interest EUR 19.77++2024-02-29 5% interest for EUR -9550.00 over 45 days+ Liabilities:Interest EUR -58.71+ Expenses:Interest EUR 58.71++2024-03-31 5% interest for EUR -9450.00 over 31 days+ Liabilities:Interest EUR -40.02+ Expenses:Interest EUR 40.02+
+ tests/golden/annual-verbose.golden view
@@ -0,0 +1,60 @@+2023-01-31 Loan+ Assets:Bank EUR 10000.00+ Liabilities:Loan EUR -10000.00++2023-02-28 5% interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR -38.36+ Expenses:Interest EUR 38.36++2023-02-28 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan EUR 100.00++2023-03-31 5% interest for EUR -9938.36 over 31 days+ Liabilities:Loan EUR -42.20+ Expenses:Interest EUR 42.20++2023-03-31 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan EUR 100.00++2023-07-01 5% interest for EUR -9880.56 over 92 days+ Liabilities:Loan EUR -124.52+ Expenses:Interest EUR 124.52++2023-07-01 Payment on rate change+ Assets:Bank EUR -100.00+ Liabilities:Loan EUR 100.00++2023-07-01 Second payment on same day+ Assets:Bank EUR -50.00+ Liabilities:Loan EUR 50.00++2023-12-31 5% interest for EUR -9855.08 over 183 days+ Liabilities:Loan EUR -247.05+ Expenses:Interest EUR 247.05++2024-01-15 5% interest for EUR -10102.13 over 15 days+ Liabilities:Loan EUR -20.70+ Expenses:Interest EUR 20.70++2024-01-15 Payment after year end+ Assets:Bank EUR -100.00+ Liabilities:Loan EUR 100.00++2024-02-29 5% interest for EUR -10022.84 over 45 days+ Liabilities:Loan EUR -61.62+ Expenses:Interest EUR 61.62++2024-02-29 Leap day payment+ Assets:Bank EUR -100.00+ Liabilities:Loan EUR 100.00++2024-03-31 5% interest for EUR -9984.45 over 31 days+ Liabilities:Loan EUR -42.28+ Expenses:Interest EUR 42.28++2024-03-31 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan EUR 100.00+
+ tests/golden/bgb288-after-last-rate.golden view
@@ -0,0 +1,4 @@+3000-02-01 6.52% interest for EUR -10000.00 over 31 days+ Liabilities:Interest EUR -55.38+ Expenses:Interest EUR 55.38+
+ tests/golden/bgb288-before-first-rate.golden view
@@ -0,0 +1,4 @@+2000-02-01 0% interest for EUR -10000.00 over 31 days+ Liabilities:Interest EUR 0.00+ Expenses:Interest EUR 0.00+
+ tests/golden/bgb288-first-period.golden view
@@ -0,0 +1,4 @@+2002-01-02 7.57% interest for EUR -10000.00 over 2 days+ Liabilities:Interest EUR -4.15+ Expenses:Interest EUR 4.15+
+ tests/golden/bgb288.golden view
@@ -0,0 +1,32 @@+2023-02-28 6.62% interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR -50.78+ Expenses:Interest EUR 50.78++2023-03-31 6.62% interest for EUR -9950.78 over 31 days+ Liabilities:Loan EUR -55.95+ Expenses:Interest EUR 55.95++2023-06-30 6.62% interest for EUR -9906.73 over 91 days+ Liabilities:Loan EUR -163.51+ Expenses:Interest EUR 163.51++2023-07-01 8.12% interest for EUR -10070.24 over 1 days+ Liabilities:Loan EUR -2.24+ Expenses:Interest EUR 2.24++2023-12-31 8.12% interest for EUR -9922.48 over 183 days+ Liabilities:Loan EUR -403.96+ Expenses:Interest EUR 403.96++2024-01-15 8.62% interest for EUR -10326.44 over 15 days+ Liabilities:Loan EUR -36.48+ Expenses:Interest EUR 36.48++2024-02-29 8.62% interest for EUR -10262.92 over 45 days+ Liabilities:Loan EUR -108.77+ Expenses:Interest EUR 108.77++2024-03-31 8.62% interest for EUR -10271.69 over 31 days+ Liabilities:Loan EUR -74.99+ Expenses:Interest EUR 74.99+
+ tests/golden/constant-30-360.golden view
@@ -0,0 +1,28 @@+2023-02-28 5% interest for EUR -10000.00 over 27 days+ Liabilities:Loan EUR -37.50+ Expenses:Interest EUR 37.50++2023-03-31 5% interest for EUR -9937.50 over 33 days+ Liabilities:Loan EUR -45.55+ Expenses:Interest EUR 45.55++2023-07-01 5% interest for EUR -9883.05 over 90 days+ Liabilities:Loan EUR -123.54+ Expenses:Interest EUR 123.54++2023-12-31 5% interest for EUR -9856.58 over 180 days+ Liabilities:Loan EUR -246.41+ Expenses:Interest EUR 246.41++2024-01-15 5% interest for EUR -10103.00 over 14 days+ Liabilities:Loan EUR -19.64+ Expenses:Interest EUR 19.64++2024-02-29 5% interest for EUR -10022.64 over 44 days+ Liabilities:Loan EUR -61.25+ Expenses:Interest EUR 61.25++2024-03-31 5% interest for EUR -9983.89 over 32 days+ Liabilities:Loan EUR -44.37+ Expenses:Interest EUR 44.37+
+ tests/golden/constant-30E-360.golden view
@@ -0,0 +1,28 @@+2023-02-28 5% interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR -38.89+ Expenses:Interest EUR 38.89++2023-03-31 5% interest for EUR -9938.89 over 32 days+ Liabilities:Loan EUR -44.17+ Expenses:Interest EUR 44.17++2023-07-01 5% interest for EUR -9883.06 over 91 days+ Liabilities:Loan EUR -124.91+ Expenses:Interest EUR 124.91++2023-12-31 5% interest for EUR -9857.97 over 179 days+ Liabilities:Loan EUR -245.08+ Expenses:Interest EUR 245.08++2024-01-15 5% interest for EUR -10103.05 over 15 days+ Liabilities:Loan EUR -21.05+ Expenses:Interest EUR 21.05++2024-02-29 5% interest for EUR -10024.10 over 44 days+ Liabilities:Loan EUR -61.26+ Expenses:Interest EUR 61.26++2024-03-31 5% interest for EUR -9985.36 over 31 days+ Liabilities:Loan EUR -42.99+ Expenses:Interest EUR 42.99+
+ tests/golden/constant-30E-360isda.golden view
@@ -0,0 +1,28 @@+2023-02-28 5% interest for EUR -10000.00 over 30 days+ Liabilities:Loan EUR -41.67+ Expenses:Interest EUR 41.67++2023-03-31 5% interest for EUR -9941.67 over 30 days+ Liabilities:Loan EUR -41.42+ Expenses:Interest EUR 41.42++2023-07-01 5% interest for EUR -9883.09 over 91 days+ Liabilities:Loan EUR -124.91+ Expenses:Interest EUR 124.91++2023-12-31 5% interest for EUR -9858.00 over 179 days+ Liabilities:Loan EUR -245.08+ Expenses:Interest EUR 245.08++2024-01-15 5% interest for EUR -10103.08 over 15 days+ Liabilities:Loan EUR -21.05+ Expenses:Interest EUR 21.05++2024-02-29 5% interest for EUR -10024.13 over 45 days+ Liabilities:Loan EUR -62.65+ Expenses:Interest EUR 62.65++2024-03-31 5% interest for EUR -9986.78 over 30 days+ Liabilities:Loan EUR -41.61+ Expenses:Interest EUR 41.61+
+ tests/golden/constant-act.golden view
@@ -0,0 +1,28 @@+2023-02-28 5% interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR -38.36+ Expenses:Interest EUR 38.36++2023-03-31 5% interest for EUR -9938.36 over 31 days+ Liabilities:Loan EUR -42.20+ Expenses:Interest EUR 42.20++2023-07-01 5% interest for EUR -9880.56 over 92 days+ Liabilities:Loan EUR -124.52+ Expenses:Interest EUR 124.52++2023-12-31 5% interest for EUR -9855.08 over 183 days+ Liabilities:Loan EUR -247.05+ Expenses:Interest EUR 247.05++2024-01-15 5% interest for EUR -10102.13 over 15 days+ Liabilities:Loan EUR -20.70+ Expenses:Interest EUR 20.70++2024-02-29 5% interest for EUR -10022.84 over 45 days+ Liabilities:Loan EUR -61.62+ Expenses:Interest EUR 61.62++2024-03-31 5% interest for EUR -9984.45 over 31 days+ Liabilities:Loan EUR -42.28+ Expenses:Interest EUR 42.28+
+ tests/golden/costed-loan.golden view
@@ -0,0 +1,4 @@+2023-01-11 10% interest for EUR 3650.00 over 10 days+ Assets:Loan EUR 10.00+ Expenses:Interest EUR -10.00+
+ tests/golden/db24-after-last-rate.golden view
@@ -0,0 +1,4 @@+3000-02-01 4.15% interest for EUR -10000.00 over 30 days+ Liabilities:Interest EUR -34.58+ Expenses:Interest EUR 34.58+
+ tests/golden/db24-before-first-rate.golden view
@@ -0,0 +1,4 @@+2000-02-01 0% interest for EUR -10000.00 over 30 days+ Liabilities:Interest EUR 0.00+ Expenses:Interest EUR 0.00+
+ tests/golden/db24-first-period.golden view
@@ -0,0 +1,4 @@+2000-10-07 6.38% interest for EUR -10000.00 over 2 days+ Liabilities:Interest EUR -3.54+ Expenses:Interest EUR 3.54+
+ tests/golden/db24.golden view
@@ -0,0 +1,16 @@+2010-09-15 6.38% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -24.81+ Expenses:Interest EUR 24.81++2010-10-01 4.15% interest for EUR -10024.81 over 16 days+ Liabilities:Loan EUR -18.49+ Expenses:Interest EUR 18.49++2011-07-01 4.15% interest for EUR -9943.30 over 270 days+ Liabilities:Loan EUR -309.49+ Expenses:Interest EUR 309.49++2011-08-01 4.15% interest for EUR -10152.79 over 30 days+ Liabilities:Loan EUR -35.11+ Expenses:Interest EUR 35.11+
+ tests/golden/effective-dates.golden view
@@ -0,0 +1,12 @@+2023-02-15 5% interest for EUR -10000.00 over 45 days+ Liabilities:Interest EUR -61.64+ Expenses:Interest EUR 61.64++2023-03-01 5% interest for EUR -9900.00 over 14 days+ Liabilities:Interest EUR -18.99+ Expenses:Interest EUR 18.99++2023-04-01 5% interest for EUR -9800.00 over 31 days+ Liabilities:Interest EUR -41.62+ Expenses:Interest EUR 41.62+
+ tests/golden/help.golden view
@@ -0,0 +1,35 @@+Usage: hledger-interest [OPTION...] ACCOUNT+Default: post at transactions, rate-period ends, and year ends.+ -h --help print this message and exit+ -V --version show version number and exit+ -v --verbose print matching input transactions and generated interest (default)+ -q --quiet print generated interest only+ --today alias for --until=today+ --until=DATE compute interest through DATE (YYYY-MM-DD or today);+ exclude transactions with later effective dates+ --post-on-transactions post only at transactions (and at --until/--today);+ skip rate-period and automatic year-end postings;+ overrides --post-on-rate-changes+ --post-on-rate-changes post at transactions and rate-period ends;+ skip automatic year-end postings;+ --post-on-transactions takes precedence;+ --until/--today also post on the final day+ -f FILE --file=FILE input ledger file (pass '-' for stdin)+ default: $LEDGER_FILE or ~/.hledger.journal+ -s ACCOUNT --source=ACCOUNT interest source account+ -t ACCOUNT --target=ACCOUNT interest target account+ -I --ignore-assertions ignore any failing balance assertions+ --act use 'act/act' day counting convention+ (Actual/Actual ISDA; see README)+ --30-360 use '30/360' day counting convention+ --30E-360 use '30E/360' day counting convention+ --30E-360isda use '30E/360isda' day counting convention+ --constant=RATE fixed annual interest rate+ --annual=RATE alias for --constant+ --schedule=SCHEDULE annualized rates and their effective dates;+ rates are fractions: 0.05 means 5% per year+ syntax: '[(Date1,Rate1),(Date2,Rate2),...]'+ --schedule-file=PATH read a schedule from a text file;+ one date and rate per line, starting in column 1;+ rates: 0.05 or 5%; hledger comments allowed+ --annual-schedule=SCHEDULE alias for --schedule
+ tests/golden/ing-diba-after-last-rate.golden view
@@ -0,0 +1,4 @@+3000-02-01 1.75% interest for EUR -10000.00 over 31 days+ Liabilities:Interest EUR -14.86+ Expenses:Interest EUR 14.86+
+ tests/golden/ing-diba-before-first-rate.golden view
@@ -0,0 +1,4 @@+2000-02-01 0% interest for EUR -10000.00 over 31 days+ Liabilities:Interest EUR 0.00+ Expenses:Interest EUR 0.00+
+ tests/golden/ing-diba-first-period.golden view
@@ -0,0 +1,4 @@+2009-01-02 1.5% interest for EUR -10000.00 over 2 days+ Liabilities:Interest EUR -0.82+ Expenses:Interest EUR 0.82+
+ tests/golden/ing-diba.golden view
@@ -0,0 +1,20 @@+2010-10-01 1.5% interest for EUR -10000.00 over 30 days+ Liabilities:Loan EUR -12.33+ Expenses:Interest EUR 12.33++2010-12-31 1.5% interest for EUR -9912.33 over 91 days+ Liabilities:Loan EUR -37.07+ Expenses:Interest EUR 37.07++2011-07-01 1.5% interest for EUR -9949.40 over 182 days+ Liabilities:Loan EUR -74.42+ Expenses:Interest EUR 74.42++2011-07-14 1.5% interest for EUR -9923.81 over 13 days+ Liabilities:Loan EUR -5.30+ Expenses:Interest EUR 5.30++2011-08-01 1.75% interest for EUR -9929.12 over 18 days+ Liabilities:Loan EUR -8.57+ Expenses:Interest EUR 8.57+
+ tests/golden/literal-account.golden view
@@ -0,0 +1,12 @@+2023-01-01 Loan+ Assets:Bank EUR 10000.00+ Liabilities:Loan(1) EUR -10000.00++2023-02-01 5% interest for EUR -10000.00 over 31 days+ Liabilities:Loan(1) EUR -42.47+ Expenses:Interest EUR 42.47++2023-02-01 Payment+ Assets:Bank EUR -100.00+ Liabilities:Loan(1) EUR 100.00+
+ tests/golden/multi-currency-transactions.golden view
@@ -0,0 +1,12 @@+2023-01-11 interest for EUR 3600.00, USD 7200.00 over 10 days+ Assets:Interest EUR 10.00+ Assets:Interest USD 20.00+ Income:Interest EUR -10.00+ Income:Interest USD -20.00++2023-02-01 interest for EUR 2400.00, USD 3600.00 over 20 days+ Assets:Interest EUR 13.33+ Assets:Interest USD 20.00+ Income:Interest EUR -13.33+ Income:Interest USD -20.00+
+ tests/golden/multi-currency.golden view
@@ -0,0 +1,12 @@+2023-01-11 10% interest for EUR 3600.00, USD 7200.00 over 10 days+ Assets:Interest EUR 10.00+ Assets:Interest USD 20.00+ Income:Interest EUR -10.00+ Income:Interest USD -20.00++2023-02-01 10% interest for EUR 2400.00, USD 3600.00 over 20 days+ Assets:Interest EUR 13.33+ Assets:Interest USD 20.00+ Income:Interest EUR -13.33+ Income:Interest USD -20.00+
+ tests/golden/multiple-files.golden view
@@ -0,0 +1,8 @@+2023-01-11 10% interest for EUR 3600.00 over 10 days+ Assets:Interest EUR 10.00+ Income:Interest EUR -10.00++2023-02-01 10% interest for EUR 2700.00 over 20 days+ Assets:Interest EUR 15.00+ Income:Interest EUR -15.00+
+ tests/golden/multiple-postings-transactions.golden view
@@ -0,0 +1,8 @@+2023-01-11 interest for EUR 1800.00 over 10 days+ Assets:Interest EUR 5.00+ Income:Interest EUR -5.00++2023-02-01 interest for EUR 900.00 over 20 days+ Assets:Interest EUR 5.00+ Income:Interest EUR -5.00+
+ tests/golden/multiple-postings.golden view
@@ -0,0 +1,8 @@+2023-01-11 10% interest for EUR 1800.00 over 10 days+ Assets:Interest EUR 5.00+ Income:Interest EUR -5.00++2023-02-01 10% interest for EUR 900.00 over 20 days+ Assets:Interest EUR 5.00+ Income:Interest EUR -5.00+
+ tests/golden/negative-rate.golden view
@@ -0,0 +1,28 @@+2023-02-28 -1% interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR 7.67+ Expenses:Interest EUR -7.67++2023-03-31 -1% interest for EUR -9892.33 over 31 days+ Liabilities:Loan EUR 8.40+ Expenses:Interest EUR -8.40++2023-07-01 -1% interest for EUR -9783.93 over 92 days+ Liabilities:Loan EUR 24.66+ Expenses:Interest EUR -24.66++2023-12-31 -1% interest for EUR -9609.27 over 183 days+ Liabilities:Loan EUR 48.18+ Expenses:Interest EUR -48.18++2024-01-15 -1% interest for EUR -9561.09 over 15 days+ Liabilities:Loan EUR 3.92+ Expenses:Interest EUR -3.92++2024-02-29 -1% interest for EUR -9457.17 over 45 days+ Liabilities:Loan EUR 11.63+ Expenses:Interest EUR -11.63++2024-03-31 -1% interest for EUR -9345.54 over 31 days+ Liabilities:Loan EUR 7.92+ Expenses:Interest EUR -7.92+
+ tests/golden/no-year-end-leap-30-360.golden view
@@ -0,0 +1,4 @@+2024-01-02 5% interest for EUR -1000000.00 over 2 days+ Liabilities:Loan EUR -277.78+ Expenses:Interest EUR 277.78+
+ tests/golden/no-year-end-leap-30E-360.golden view
@@ -0,0 +1,4 @@+2024-01-02 5% interest for EUR -1000000.00 over 2 days+ Liabilities:Loan EUR -277.78+ Expenses:Interest EUR 277.78+
+ tests/golden/no-year-end-leap-30E-360isda.golden view
@@ -0,0 +1,4 @@+2024-01-02 5% interest for EUR -1000000.00 over 2 days+ Liabilities:Loan EUR -277.78+ Expenses:Interest EUR 277.78+
+ tests/golden/no-year-end-leap-act.golden view
@@ -0,0 +1,4 @@+2024-01-02 5% interest for EUR -1000000.00 over 3 days+ Liabilities:Loan EUR -410.21+ Expenses:Interest EUR 410.21+
+ tests/golden/overpayment-transactions.golden view
@@ -0,0 +1,8 @@+2023-01-11 interest for EUR 3600.00 over 10 days+ Assets:Interest EUR 10.00+ Income:Interest EUR -10.00++2023-02-01 interest for EUR -3600.00 over 20 days+ Assets:Interest EUR -20.00+ Income:Interest EUR 20.00+
+ tests/golden/overpayment.golden view
@@ -0,0 +1,8 @@+2023-01-11 10% interest for EUR 3600.00 over 10 days+ Assets:Interest EUR 10.00+ Income:Interest EUR -10.00++2023-02-01 10% interest for EUR -3600.00 over 20 days+ Assets:Interest EUR -20.00+ Income:Interest EUR 20.00+
+ tests/golden/rate-change-at-year-end.golden view
@@ -0,0 +1,8 @@+2023-12-31 5% interest for EUR -1000000.00 over 1 days+ Liabilities:Loan EUR -136.99+ Expenses:Interest EUR 136.99++2024-01-02 7% interest for EUR -1000136.99 over 2 days+ Liabilities:Loan EUR -382.57+ Expenses:Interest EUR 382.57+
+ tests/golden/repayment-and-new-loan-transactions.golden view
@@ -0,0 +1,8 @@+2023-01-11 interest for EUR 3600.00 over 10 days+ Assets:Interest EUR 10.00+ Income:Interest EUR -10.00++2023-02-01 interest for EUR 7200.00 over 10 days+ Assets:Interest EUR 40.00+ Income:Interest EUR -40.00+
+ tests/golden/repayment-and-new-loan.golden view
@@ -0,0 +1,8 @@+2023-01-11 10% interest for EUR 3600.00 over 10 days+ Assets:Interest EUR 10.00+ Income:Interest EUR -10.00++2023-02-01 20% interest for EUR 7200.00 over 10 days+ Assets:Interest EUR 40.00+ Income:Interest EUR -40.00+
+ tests/golden/schedule-30-360.golden view
@@ -0,0 +1,40 @@+2023-02-14 0% interest for EUR -10000.00 over 13 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 5% interest for EUR -9919.44 over 33 days+ Liabilities:Loan EUR -45.46+ Expenses:Interest EUR 45.46++2023-06-30 5% interest for EUR -9864.91 over 89 days+ Liabilities:Loan EUR -121.94+ Expenses:Interest EUR 121.94++2023-07-01 7% interest for EUR -9986.85 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2023-12-31 7% interest for EUR -9838.79 over 180 days+ Liabilities:Loan EUR -344.36+ Expenses:Interest EUR 344.36++2024-01-15 7% interest for EUR -10183.15 over 14 days+ Liabilities:Loan EUR -27.72+ Expenses:Interest EUR 27.72++2024-02-28 7% interest for EUR -10110.87 over 43 days+ Liabilities:Loan EUR -84.54+ Expenses:Interest EUR 84.54++2024-02-29 3% interest for EUR -10195.41 over 1 days+ Liabilities:Loan EUR -0.85+ Expenses:Interest EUR 0.85++2024-03-31 3% interest for EUR -10096.26 over 32 days+ Liabilities:Loan EUR -26.92+ Expenses:Interest EUR 26.92+
+ tests/golden/schedule-30E-360.golden view
@@ -0,0 +1,40 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 5% interest for EUR -9919.44 over 32 days+ Liabilities:Loan EUR -44.09+ Expenses:Interest EUR 44.09++2023-06-30 5% interest for EUR -9863.53 over 90 days+ Liabilities:Loan EUR -123.29+ Expenses:Interest EUR 123.29++2023-07-01 7% interest for EUR -9986.83 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2023-12-31 7% interest for EUR -9838.77 over 179 days+ Liabilities:Loan EUR -342.44+ Expenses:Interest EUR 342.44++2024-01-15 7% interest for EUR -10181.21 over 15 days+ Liabilities:Loan EUR -29.70+ Expenses:Interest EUR 29.70++2024-02-28 7% interest for EUR -10110.91 over 43 days+ Liabilities:Loan EUR -84.54+ Expenses:Interest EUR 84.54++2024-02-29 3% interest for EUR -10195.44 over 1 days+ Liabilities:Loan EUR -0.85+ Expenses:Interest EUR 0.85++2024-03-31 3% interest for EUR -10096.29 over 31 days+ Liabilities:Loan EUR -26.08+ Expenses:Interest EUR 26.08+
+ tests/golden/schedule-30E-360isda.golden view
@@ -0,0 +1,40 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 16 days+ Liabilities:Loan EUR -22.22+ Expenses:Interest EUR 22.22++2023-03-31 5% interest for EUR -9922.22 over 30 days+ Liabilities:Loan EUR -41.34+ Expenses:Interest EUR 41.34++2023-06-30 5% interest for EUR -9863.56 over 90 days+ Liabilities:Loan EUR -123.29+ Expenses:Interest EUR 123.29++2023-07-01 7% interest for EUR -9986.86 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2023-12-31 7% interest for EUR -9838.80 over 179 days+ Liabilities:Loan EUR -342.44+ Expenses:Interest EUR 342.44++2024-01-15 7% interest for EUR -10181.25 over 15 days+ Liabilities:Loan EUR -29.70+ Expenses:Interest EUR 29.70++2024-02-28 7% interest for EUR -10110.94 over 43 days+ Liabilities:Loan EUR -84.54+ Expenses:Interest EUR 84.54++2024-02-29 3% interest for EUR -10195.48 over 2 days+ Liabilities:Loan EUR -1.70+ Expenses:Interest EUR 1.70++2024-03-31 3% interest for EUR -10097.18 over 30 days+ Liabilities:Loan EUR -25.24+ Expenses:Interest EUR 25.24+
+ tests/golden/schedule-act.golden view
@@ -0,0 +1,40 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.18+ Expenses:Interest EUR 19.18++2023-03-31 5% interest for EUR -9919.18 over 31 days+ Liabilities:Loan EUR -42.12+ Expenses:Interest EUR 42.12++2023-06-30 5% interest for EUR -9861.30 over 91 days+ Liabilities:Loan EUR -122.93+ Expenses:Interest EUR 122.93++2023-07-01 7% interest for EUR -9984.23 over 1 days+ Liabilities:Loan EUR -1.91+ Expenses:Interest EUR 1.91++2023-12-31 7% interest for EUR -9836.14 over 183 days+ Liabilities:Loan EUR -345.21+ Expenses:Interest EUR 345.21++2024-01-15 7% interest for EUR -10181.35 over 15 days+ Liabilities:Loan EUR -29.21+ Expenses:Interest EUR 29.21++2024-02-28 7% interest for EUR -10110.56 over 44 days+ Liabilities:Loan EUR -85.08+ Expenses:Interest EUR 85.08++2024-02-29 3% interest for EUR -10195.64 over 1 days+ Liabilities:Loan EUR -0.84+ Expenses:Interest EUR 0.84++2024-03-31 3% interest for EUR -10096.48 over 31 days+ Liabilities:Loan EUR -25.65+ Expenses:Interest EUR 25.65+
+ tests/golden/schedule-no-year-end-30-360.golden view
@@ -0,0 +1,36 @@+2023-02-14 0% interest for EUR -10000.00 over 13 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 5% interest for EUR -9919.44 over 33 days+ Liabilities:Loan EUR -45.46+ Expenses:Interest EUR 45.46++2023-06-30 5% interest for EUR -9864.91 over 89 days+ Liabilities:Loan EUR -121.94+ Expenses:Interest EUR 121.94++2023-07-01 7% interest for EUR -9986.85 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2024-01-15 7% interest for EUR -9838.79 over 194 days+ Liabilities:Loan EUR -371.14+ Expenses:Interest EUR 371.14++2024-02-28 7% interest for EUR -10109.93 over 43 days+ Liabilities:Loan EUR -84.53+ Expenses:Interest EUR 84.53++2024-02-29 3% interest for EUR -10194.46 over 1 days+ Liabilities:Loan EUR -0.85+ Expenses:Interest EUR 0.85++2024-03-31 3% interest for EUR -10095.31 over 32 days+ Liabilities:Loan EUR -26.92+ Expenses:Interest EUR 26.92+
+ tests/golden/schedule-no-year-end-30E-360.golden view
@@ -0,0 +1,36 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 5% interest for EUR -9919.44 over 32 days+ Liabilities:Loan EUR -44.09+ Expenses:Interest EUR 44.09++2023-06-30 5% interest for EUR -9863.53 over 90 days+ Liabilities:Loan EUR -123.29+ Expenses:Interest EUR 123.29++2023-07-01 7% interest for EUR -9986.83 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2024-01-15 7% interest for EUR -9838.77 over 194 days+ Liabilities:Loan EUR -371.14+ Expenses:Interest EUR 371.14++2024-02-28 7% interest for EUR -10109.91 over 43 days+ Liabilities:Loan EUR -84.53+ Expenses:Interest EUR 84.53++2024-02-29 3% interest for EUR -10194.44 over 1 days+ Liabilities:Loan EUR -0.85+ Expenses:Interest EUR 0.85++2024-03-31 3% interest for EUR -10095.29 over 31 days+ Liabilities:Loan EUR -26.08+ Expenses:Interest EUR 26.08+
+ tests/golden/schedule-no-year-end-30E-360isda.golden view
@@ -0,0 +1,36 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 16 days+ Liabilities:Loan EUR -22.22+ Expenses:Interest EUR 22.22++2023-03-31 5% interest for EUR -9922.22 over 30 days+ Liabilities:Loan EUR -41.34+ Expenses:Interest EUR 41.34++2023-06-30 5% interest for EUR -9863.56 over 90 days+ Liabilities:Loan EUR -123.29+ Expenses:Interest EUR 123.29++2023-07-01 7% interest for EUR -9986.86 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2024-01-15 7% interest for EUR -9838.80 over 194 days+ Liabilities:Loan EUR -371.14+ Expenses:Interest EUR 371.14++2024-02-28 7% interest for EUR -10109.94 over 43 days+ Liabilities:Loan EUR -84.53+ Expenses:Interest EUR 84.53++2024-02-29 3% interest for EUR -10194.47 over 2 days+ Liabilities:Loan EUR -1.70+ Expenses:Interest EUR 1.70++2024-03-31 3% interest for EUR -10096.17 over 30 days+ Liabilities:Loan EUR -25.24+ Expenses:Interest EUR 25.24+
+ tests/golden/schedule-no-year-end-act.golden view
@@ -0,0 +1,36 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.18+ Expenses:Interest EUR 19.18++2023-03-31 5% interest for EUR -9919.18 over 31 days+ Liabilities:Loan EUR -42.12+ Expenses:Interest EUR 42.12++2023-06-30 5% interest for EUR -9861.30 over 91 days+ Liabilities:Loan EUR -122.93+ Expenses:Interest EUR 122.93++2023-07-01 7% interest for EUR -9984.23 over 1 days+ Liabilities:Loan EUR -1.91+ Expenses:Interest EUR 1.91++2024-01-15 7% interest for EUR -9836.14 over 198 days+ Liabilities:Loan EUR -373.43+ Expenses:Interest EUR 373.43++2024-02-28 7% interest for EUR -10109.57 over 44 days+ Liabilities:Loan EUR -85.08+ Expenses:Interest EUR 85.08++2024-02-29 3% interest for EUR -10194.65 over 1 days+ Liabilities:Loan EUR -0.84+ Expenses:Interest EUR 0.84++2024-03-31 3% interest for EUR -10095.48 over 31 days+ Liabilities:Loan EUR -25.65+ Expenses:Interest EUR 25.65+
+ tests/golden/schedule-transactions-only-30-360.golden view
@@ -0,0 +1,24 @@+2023-02-28 interest for EUR -10000.00 over 27 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 interest for EUR -9919.44 over 33 days+ Liabilities:Loan EUR -45.46+ Expenses:Interest EUR 45.46++2023-07-01 interest for EUR -9864.91 over 90 days+ Liabilities:Loan EUR -123.86+ Expenses:Interest EUR 123.86++2024-01-15 interest for EUR -9838.77 over 194 days+ Liabilities:Loan EUR -371.14+ Expenses:Interest EUR 371.14++2024-02-29 interest for EUR -10109.91 over 44 days+ Liabilities:Loan EUR -85.37+ Expenses:Interest EUR 85.37++2024-03-31 interest for EUR -10095.28 over 32 days+ Liabilities:Loan EUR -26.92+ Expenses:Interest EUR 26.92+
+ tests/golden/schedule-transactions-only-30E-360.golden view
@@ -0,0 +1,24 @@+2023-02-28 interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 interest for EUR -9919.44 over 32 days+ Liabilities:Loan EUR -44.09+ Expenses:Interest EUR 44.09++2023-07-01 interest for EUR -9863.53 over 91 days+ Liabilities:Loan EUR -125.21+ Expenses:Interest EUR 125.21++2024-01-15 interest for EUR -9838.74 over 194 days+ Liabilities:Loan EUR -371.14+ Expenses:Interest EUR 371.14++2024-02-29 interest for EUR -10109.88 over 44 days+ Liabilities:Loan EUR -85.37+ Expenses:Interest EUR 85.37++2024-03-31 interest for EUR -10095.25 over 31 days+ Liabilities:Loan EUR -26.08+ Expenses:Interest EUR 26.08+
+ tests/golden/schedule-transactions-only-30E-360isda.golden view
@@ -0,0 +1,24 @@+2023-02-28 interest for EUR -10000.00 over 30 days+ Liabilities:Loan EUR -22.22+ Expenses:Interest EUR 22.22++2023-03-31 interest for EUR -9922.22 over 30 days+ Liabilities:Loan EUR -41.34+ Expenses:Interest EUR 41.34++2023-07-01 interest for EUR -9863.56 over 91 days+ Liabilities:Loan EUR -125.21+ Expenses:Interest EUR 125.21++2024-01-15 interest for EUR -9838.78 over 194 days+ Liabilities:Loan EUR -371.14+ Expenses:Interest EUR 371.14++2024-02-29 interest for EUR -10109.92 over 45 days+ Liabilities:Loan EUR -86.22+ Expenses:Interest EUR 86.22++2024-03-31 interest for EUR -10096.13 over 30 days+ Liabilities:Loan EUR -25.24+ Expenses:Interest EUR 25.24+
+ tests/golden/schedule-transactions-only-act.golden view
@@ -0,0 +1,24 @@+2023-02-28 interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR -19.18+ Expenses:Interest EUR 19.18++2023-03-31 interest for EUR -9919.18 over 31 days+ Liabilities:Loan EUR -42.12+ Expenses:Interest EUR 42.12++2023-07-01 interest for EUR -9861.30 over 92 days+ Liabilities:Loan EUR -124.82+ Expenses:Interest EUR 124.82++2024-01-15 interest for EUR -9836.12 over 198 days+ Liabilities:Loan EUR -373.43+ Expenses:Interest EUR 373.43++2024-02-29 interest for EUR -10109.55 over 45 days+ Liabilities:Loan EUR -85.90+ Expenses:Interest EUR 85.90++2024-03-31 interest for EUR -10095.45 over 31 days+ Liabilities:Loan EUR -25.65+ Expenses:Interest EUR 25.65+
+ tests/golden/transaction-at-year-end.golden view
@@ -0,0 +1,8 @@+2023-12-31 5% interest for EUR -10000.00 over 1 days+ Liabilities:Loan EUR -1.37+ Expenses:Interest EUR 1.37++2024-01-02 5% interest for EUR -9901.37 over 2 days+ Liabilities:Loan EUR -2.71+ Expenses:Interest EUR 2.71+
+ tests/golden/unicode.golden view
@@ -0,0 +1,12 @@+2023-01-01 Prêt en euros+ Assets:Bank €10000.00+ Liabilities:Prêt €-10000.00++2023-02-01 5% interest for €-10000.00 over 31 days+ Liabilities:Prêt €-42.47+ Expenses:Interest €42.47++2023-02-01 Remboursement+ Assets:Bank €-100.00+ Liabilities:Prêt €100.00+
+ tests/golden/until-after-last-transaction.golden view
@@ -0,0 +1,12 @@+2023-12-31 5% interest for EUR -1000000.00 over 1 days+ Liabilities:Loan EUR -136.99+ Expenses:Interest EUR 136.99++2024-01-02 5% interest for EUR -1000136.99 over 2 days+ Liabilities:Loan EUR -273.26+ Expenses:Interest EUR 273.26++2024-01-05 5% interest for EUR -1000310.25 over 3 days+ Liabilities:Loan EUR -409.96+ Expenses:Interest EUR 409.96+
+ tests/golden/until-default-30-360.golden view
@@ -0,0 +1,28 @@+2023-02-14 0% interest for EUR -10000.00 over 13 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 5% interest for EUR -9919.44 over 33 days+ Liabilities:Loan EUR -45.46+ Expenses:Interest EUR 45.46++2023-06-30 5% interest for EUR -9864.91 over 89 days+ Liabilities:Loan EUR -121.94+ Expenses:Interest EUR 121.94++2023-07-01 7% interest for EUR -9986.85 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2023-12-31 7% interest for EUR -9838.79 over 180 days+ Liabilities:Loan EUR -344.36+ Expenses:Interest EUR 344.36++2024-01-10 7% interest for EUR -10183.15 over 9 days+ Liabilities:Loan EUR -17.82+ Expenses:Interest EUR 17.82+
+ tests/golden/until-default-30E-360.golden view
@@ -0,0 +1,28 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 5% interest for EUR -9919.44 over 32 days+ Liabilities:Loan EUR -44.09+ Expenses:Interest EUR 44.09++2023-06-30 5% interest for EUR -9863.53 over 90 days+ Liabilities:Loan EUR -123.29+ Expenses:Interest EUR 123.29++2023-07-01 7% interest for EUR -9986.83 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2023-12-31 7% interest for EUR -9838.77 over 179 days+ Liabilities:Loan EUR -342.44+ Expenses:Interest EUR 342.44++2024-01-10 7% interest for EUR -10181.21 over 10 days+ Liabilities:Loan EUR -19.80+ Expenses:Interest EUR 19.80+
+ tests/golden/until-default-30E-360isda.golden view
@@ -0,0 +1,28 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 16 days+ Liabilities:Loan EUR -22.22+ Expenses:Interest EUR 22.22++2023-03-31 5% interest for EUR -9922.22 over 30 days+ Liabilities:Loan EUR -41.34+ Expenses:Interest EUR 41.34++2023-06-30 5% interest for EUR -9863.56 over 90 days+ Liabilities:Loan EUR -123.29+ Expenses:Interest EUR 123.29++2023-07-01 7% interest for EUR -9986.86 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2023-12-31 7% interest for EUR -9838.80 over 179 days+ Liabilities:Loan EUR -342.44+ Expenses:Interest EUR 342.44++2024-01-10 7% interest for EUR -10181.25 over 10 days+ Liabilities:Loan EUR -19.80+ Expenses:Interest EUR 19.80+
+ tests/golden/until-default-act.golden view
@@ -0,0 +1,28 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.18+ Expenses:Interest EUR 19.18++2023-03-31 5% interest for EUR -9919.18 over 31 days+ Liabilities:Loan EUR -42.12+ Expenses:Interest EUR 42.12++2023-06-30 5% interest for EUR -9861.30 over 91 days+ Liabilities:Loan EUR -122.93+ Expenses:Interest EUR 122.93++2023-07-01 7% interest for EUR -9984.23 over 1 days+ Liabilities:Loan EUR -1.91+ Expenses:Interest EUR 1.91++2023-12-31 7% interest for EUR -9836.14 over 183 days+ Liabilities:Loan EUR -345.21+ Expenses:Interest EUR 345.21++2024-01-10 7% interest for EUR -10181.35 over 10 days+ Liabilities:Loan EUR -19.47+ Expenses:Interest EUR 19.47+
+ tests/golden/until-effective-dates.golden view
@@ -0,0 +1,20 @@+2023-12-30 Loan+ Assets:Bank EUR 1000000.00+ Liabilities:Loan EUR -1000000.00++2023-12-31 5% interest for EUR -1000000.00 over 1 days+ Liabilities:Loan EUR -136.99+ Expenses:Interest EUR 136.99++2024-01-02=2023-12-31 Payment effective before cutoff+ Assets:Bank EUR -50.00+ Liabilities:Loan EUR 50.00++2024-01-01 5% interest for EUR -1000086.99 over 1 days+ Liabilities:Loan EUR -136.62+ Expenses:Interest EUR 136.62++2024-01-01 Payment on cutoff+ Assets:Bank EUR -100.00+ Liabilities:Loan EUR 100.00+
+ tests/golden/until-rate-changes-30-360.golden view
@@ -0,0 +1,24 @@+2023-02-14 0% interest for EUR -10000.00 over 13 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 5% interest for EUR -9919.44 over 33 days+ Liabilities:Loan EUR -45.46+ Expenses:Interest EUR 45.46++2023-06-30 5% interest for EUR -9864.91 over 89 days+ Liabilities:Loan EUR -121.94+ Expenses:Interest EUR 121.94++2023-07-01 7% interest for EUR -9986.85 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2024-01-10 7% interest for EUR -9838.79 over 189 days+ Liabilities:Loan EUR -361.58+ Expenses:Interest EUR 361.58+
+ tests/golden/until-rate-changes-30E-360.golden view
@@ -0,0 +1,24 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 5% interest for EUR -9919.44 over 32 days+ Liabilities:Loan EUR -44.09+ Expenses:Interest EUR 44.09++2023-06-30 5% interest for EUR -9863.53 over 90 days+ Liabilities:Loan EUR -123.29+ Expenses:Interest EUR 123.29++2023-07-01 7% interest for EUR -9986.83 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2024-01-10 7% interest for EUR -9838.77 over 189 days+ Liabilities:Loan EUR -361.57+ Expenses:Interest EUR 361.57+
+ tests/golden/until-rate-changes-30E-360isda.golden view
@@ -0,0 +1,24 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 16 days+ Liabilities:Loan EUR -22.22+ Expenses:Interest EUR 22.22++2023-03-31 5% interest for EUR -9922.22 over 30 days+ Liabilities:Loan EUR -41.34+ Expenses:Interest EUR 41.34++2023-06-30 5% interest for EUR -9863.56 over 90 days+ Liabilities:Loan EUR -123.29+ Expenses:Interest EUR 123.29++2023-07-01 7% interest for EUR -9986.86 over 1 days+ Liabilities:Loan EUR -1.94+ Expenses:Interest EUR 1.94++2024-01-10 7% interest for EUR -9838.80 over 189 days+ Liabilities:Loan EUR -361.58+ Expenses:Interest EUR 361.58+
+ tests/golden/until-rate-changes-act.golden view
@@ -0,0 +1,24 @@+2023-02-14 0% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-02-28 5% interest for EUR -10000.00 over 14 days+ Liabilities:Loan EUR -19.18+ Expenses:Interest EUR 19.18++2023-03-31 5% interest for EUR -9919.18 over 31 days+ Liabilities:Loan EUR -42.12+ Expenses:Interest EUR 42.12++2023-06-30 5% interest for EUR -9861.30 over 91 days+ Liabilities:Loan EUR -122.93+ Expenses:Interest EUR 122.93++2023-07-01 7% interest for EUR -9984.23 over 1 days+ Liabilities:Loan EUR -1.91+ Expenses:Interest EUR 1.91++2024-01-10 7% interest for EUR -9836.14 over 193 days+ Liabilities:Loan EUR -364.02+ Expenses:Interest EUR 364.02+
+ tests/golden/until-transactions-30-360.golden view
@@ -0,0 +1,16 @@+2023-02-28 interest for EUR -10000.00 over 27 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 interest for EUR -9919.44 over 33 days+ Liabilities:Loan EUR -45.46+ Expenses:Interest EUR 45.46++2023-07-01 interest for EUR -9864.91 over 90 days+ Liabilities:Loan EUR -123.86+ Expenses:Interest EUR 123.86++2024-01-10 interest for EUR -9838.77 over 189 days+ Liabilities:Loan EUR -361.57+ Expenses:Interest EUR 361.57+
+ tests/golden/until-transactions-30E-360.golden view
@@ -0,0 +1,16 @@+2023-02-28 interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR -19.44+ Expenses:Interest EUR 19.44++2023-03-31 interest for EUR -9919.44 over 32 days+ Liabilities:Loan EUR -44.09+ Expenses:Interest EUR 44.09++2023-07-01 interest for EUR -9863.53 over 91 days+ Liabilities:Loan EUR -125.21+ Expenses:Interest EUR 125.21++2024-01-10 interest for EUR -9838.74 over 189 days+ Liabilities:Loan EUR -361.57+ Expenses:Interest EUR 361.57+
+ tests/golden/until-transactions-30E-360isda.golden view
@@ -0,0 +1,16 @@+2023-02-28 interest for EUR -10000.00 over 30 days+ Liabilities:Loan EUR -22.22+ Expenses:Interest EUR 22.22++2023-03-31 interest for EUR -9922.22 over 30 days+ Liabilities:Loan EUR -41.34+ Expenses:Interest EUR 41.34++2023-07-01 interest for EUR -9863.56 over 91 days+ Liabilities:Loan EUR -125.21+ Expenses:Interest EUR 125.21++2024-01-10 interest for EUR -9838.78 over 189 days+ Liabilities:Loan EUR -361.58+ Expenses:Interest EUR 361.58+
+ tests/golden/until-transactions-act.golden view
@@ -0,0 +1,16 @@+2023-02-28 interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR -19.18+ Expenses:Interest EUR 19.18++2023-03-31 interest for EUR -9919.18 over 31 days+ Liabilities:Loan EUR -42.12+ Expenses:Interest EUR 42.12++2023-07-01 interest for EUR -9861.30 over 92 days+ Liabilities:Loan EUR -124.82+ Expenses:Interest EUR 124.82++2024-01-10 interest for EUR -9836.12 over 193 days+ Liabilities:Loan EUR -364.02+ Expenses:Interest EUR 364.02+
+ tests/golden/zero-rate.golden view
@@ -0,0 +1,28 @@+2023-02-28 0% interest for EUR -10000.00 over 28 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-03-31 0% interest for EUR -9900.00 over 31 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-07-01 0% interest for EUR -9800.00 over 92 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2023-12-31 0% interest for EUR -9650.00 over 183 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2024-01-15 0% interest for EUR -9650.00 over 15 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2024-02-29 0% interest for EUR -9550.00 over 45 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00++2024-03-31 0% interest for EUR -9450.00 over 31 days+ Liabilities:Loan EUR 0.00+ Expenses:Interest EUR 0.00+