{-|
A 'Journal' is a parsed ledger file, containing 'Transaction's.
It can be filtered and massaged in various ways, then \"crunched\"
to form a 'Ledger'.
-}moduleLedger.JournalwhereimportqualifiedData.MapasMapimportData.Map(findWithDefault,(!))importSystem.Time(ClockTime(TOD))importLedger.UtilsimportLedger.TypesimportLedger.AccountNameimportLedger.AmountimportLedger.Transaction(ledgerTransactionWithDate)importLedger.PostingimportLedger.TimeLoginstanceShowJournalwhereshowj=printf"Journal with %d transactions, %d accounts: %s"(length(jtxnsj)+length(jmodifiertxnsj)+length(jperiodictxnsj))(lengthaccounts)(showaccounts)-- ++ (show $ journalTransactions l)whereaccounts=flatten$journalAccountNameTreejnulljournal::Journalnulljournal=Journal{jmodifiertxns=[],jperiodictxns=[],jtxns=[],open_timelog_entries=[],historical_prices=[],final_comment_lines=[],filepath="",filereadtime=TOD00,jtext=""}addTransaction::Transaction->Journal->JournaladdTransactiontl0=l0{jtxns=t:jtxnsl0}addModifierTransaction::ModifierTransaction->Journal->JournaladdModifierTransactionmtl0=l0{jmodifiertxns=mt:jmodifiertxnsl0}addPeriodicTransaction::PeriodicTransaction->Journal->JournaladdPeriodicTransactionptl0=l0{jperiodictxns=pt:jperiodictxnsl0}addHistoricalPrice::HistoricalPrice->Journal->JournaladdHistoricalPricehl0=l0{historical_prices=h:historical_pricesl0}addTimeLogEntry::TimeLogEntry->Journal->JournaladdTimeLogEntrytlel0=l0{open_timelog_entries=tle:open_timelog_entriesl0}journalPostings::Journal->[Posting]journalPostings=concatMaptpostings.jtxnsjournalAccountNamesUsed::Journal->[AccountName]journalAccountNamesUsed=accountNamesFromPostings.journalPostingsjournalAccountNames::Journal->[AccountName]journalAccountNames=sort.expandAccountNames.journalAccountNamesUsedjournalAccountNameTree::Journal->TreeAccountNamejournalAccountNameTree=accountNameTreeFrom.journalAccountNames-- Various kinds of filtering on journals. We do it differently depending-- on the command.-- | Keep only transactions we are interested in, as described by-- the filter specification. May also massage the data a little.filterJournalTransactions::FilterSpec->Journal->JournalfilterJournalTransactionsFilterSpec{datespan=datespan,cleared=cleared-- ,real=real-- ,empty=empty-- ,costbasis=_,acctpats=apats,descpats=dpats,whichdate=whichdate,depth=depth}=filterJournalTransactionsByClearedStatuscleared.filterJournalPostingsByDepthdepth.filterJournalTransactionsByAccountapats.filterJournalTransactionsByDescriptiondpats.filterJournalTransactionsByDatedatespan.journalSelectingDatewhichdate-- | Keep only postings we are interested in, as described by-- the filter specification. May also massage the data a little.-- This can leave unbalanced transactions.filterJournalPostings::FilterSpec->Journal->JournalfilterJournalPostingsFilterSpec{datespan=datespan,cleared=cleared,real=real,empty=empty-- ,costbasis=costbasis,acctpats=apats,descpats=dpats,whichdate=whichdate,depth=depth}=filterJournalPostingsByRealnessreal.filterJournalPostingsByClearedStatuscleared.filterJournalPostingsByEmptyempty.filterJournalPostingsByDepthdepth.filterJournalPostingsByAccountapats.filterJournalTransactionsByDescriptiondpats.filterJournalTransactionsByDatedatespan.journalSelectingDatewhichdate-- | Keep only ledger transactions whose description matches the description patterns.filterJournalTransactionsByDescription::[String]->Journal->JournalfilterJournalTransactionsByDescriptionpatsj@Journal{jtxns=ts}=j{jtxns=filtermatchdescts}wherematchdesc=matchpatspats.tdescription-- | Keep only ledger transactions which fall between begin and end dates.-- We include transactions on the begin date and exclude transactions on the end-- date, like ledger. An empty date string means no restriction.filterJournalTransactionsByDate::DateSpan->Journal->JournalfilterJournalTransactionsByDate(DateSpanbeginend)j@Journal{jtxns=ts}=j{jtxns=filtermatchts}wherematcht=maybeTrue(tdatet>=)begin&&maybeTrue(tdatet<)end-- | Keep only ledger transactions which have the requested-- cleared/uncleared status, if there is one.filterJournalTransactionsByClearedStatus::MaybeBool->Journal->JournalfilterJournalTransactionsByClearedStatusNothingj=jfilterJournalTransactionsByClearedStatus(Justval)j@Journal{jtxns=ts}=j{jtxns=filtermatchts}wherematch=(==val).tstatus-- | Keep only postings which have the requested cleared/uncleared status,-- if there is one.filterJournalPostingsByClearedStatus::MaybeBool->Journal->JournalfilterJournalPostingsByClearedStatusNothingj=jfilterJournalPostingsByClearedStatus(Justc)j@Journal{jtxns=ts}=j{jtxns=mapfilterpostingsts}wherefilterpostingst@Transaction{tpostings=ps}=t{tpostings=filter((==c).postingCleared)ps}-- | Strip out any virtual postings, if the flag is true, otherwise do-- no filtering.filterJournalPostingsByRealness::Bool->Journal->JournalfilterJournalPostingsByRealnessFalsel=lfilterJournalPostingsByRealnessTruej@Journal{jtxns=ts}=j{jtxns=mapfilterpostingsts}wherefilterpostingst@Transaction{tpostings=ps}=t{tpostings=filterisRealps}-- | Strip out any postings with zero amount, unless the flag is true.filterJournalPostingsByEmpty::Bool->Journal->JournalfilterJournalPostingsByEmptyTruel=lfilterJournalPostingsByEmptyFalsej@Journal{jtxns=ts}=j{jtxns=mapfilterpostingsts}wherefilterpostingst@Transaction{tpostings=ps}=t{tpostings=filter(not.isEmptyPosting)ps}-- | Keep only transactions which affect accounts deeper than the specified depth.filterJournalTransactionsByDepth::MaybeInt->Journal->JournalfilterJournalTransactionsByDepthNothingj=jfilterJournalTransactionsByDepth(Justd)j@Journal{jtxns=ts}=j{jtxns=(filter(any((<=d+1).accountNameLevel.paccount).tpostings)ts)}-- | Strip out any postings to accounts deeper than the specified depth-- (and any ledger transactions which have no postings as a result).filterJournalPostingsByDepth::MaybeInt->Journal->JournalfilterJournalPostingsByDepthNothingj=jfilterJournalPostingsByDepth(Justd)j@Journal{jtxns=ts}=j{jtxns=filter(not.null.tpostings)$mapfiltertxnsts}wherefiltertxnst@Transaction{tpostings=ps}=t{tpostings=filter((<=d).accountNameLevel.paccount)ps}-- | Keep only transactions which affect accounts matched by the account patterns.filterJournalTransactionsByAccount::[String]->Journal->JournalfilterJournalTransactionsByAccountapatsj@Journal{jtxns=ts}=j{jtxns=filtermatchts}wherematch=any(matchpatsapats.paccount).tpostings-- | Keep only postings which affect accounts matched by the account patterns.-- This can leave transactions unbalanced.filterJournalPostingsByAccount::[String]->Journal->JournalfilterJournalPostingsByAccountapatsj@Journal{jtxns=ts}=j{jtxns=mapfilterpostingsts}wherefilterpostingst@Transaction{tpostings=ps}=t{tpostings=filter(matchpatsapats.paccount)ps}-- | Convert this journal's transactions' primary date to either the-- actual or effective date.journalSelectingDate::WhichDate->Journal->JournaljournalSelectingDateActualDatej=jjournalSelectingDateEffectiveDatej=j{jtxns=map(ledgerTransactionWithDateEffectiveDate)$jtxnsj}-- | Convert all the journal's amounts to their canonical display settings.-- Ie, in each commodity, amounts will use the display settings of the first-- amount detected, and the greatest precision of the amounts detected.-- Also, missing unit prices are added if known from the price history.-- Also, amounts are converted to cost basis if that flag is active.-- XXX refactorcanonicaliseAmounts::Bool->Journal->JournalcanonicaliseAmountscostbasisj@Journal{jtxns=ts}=j{jtxns=mapfixledgertransactionts}wherefixledgertransaction(Transactiondedscdecotspr)=Transactiondedscdeco(mapfixrawpostingts)prwherefixrawposting(Postingsacacttxn)=Postingsac(fixmixedamounta)cttxnfixmixedamount(Mixedas)=Mixed$mapfixamountasfixamount=(ifcostbasisthencostOfAmountelseid).fixprice.fixcommodityfixcommoditya=a{commodity=c}wherec=canonicalcommoditymap!symbol(commoditya)canonicalcommoditymap=Map.fromList[(s,firstc{precision=maxp})|s<-commoditysymbols,letcs=commoditymap!s,letfirstc=headcs,letmaxp=maximum$mapprecisioncs]commoditymap=Map.fromList[(s,commoditieswithsymbols)|s<-commoditysymbols]commoditieswithsymbols=filter((s==).symbol)commoditiescommoditysymbols=nub$mapsymbolcommoditiescommodities=mapcommodity(concatMap(amounts.pamount)(journalPostingsj)++concatMap(amounts.hamount)(historical_pricesj))fixprice::Amount->Amountfixpricea@Amount{price=Just_}=afixpricea@Amount{commodity=c}=a{price=journalHistoricalPriceForjdc}-- | Get the price for a commodity on the specified day from the price database, if known.-- Does only one lookup step, ie will not look up the price of a price.journalHistoricalPriceFor::Journal->Day->Commodity->MaybeMixedAmountjournalHistoricalPriceForjdCommodity{symbol=s}=doletps=reverse$filter((<=d).hdate)$filter((s==).hsymbol)$sortBy(comparinghdate)$historical_pricesjcasepsof(HistoricalPrice{hamount=a}:_)->Just$canonicaliseCommoditiesa_->NothingwherecanonicaliseCommodities(Mixedas)=Mixed$mapcanonicaliseCommodityaswherecanonicaliseCommoditya@Amount{commodity=Commodity{symbol=s}}=a{commodity=findWithDefault(error"programmer error: canonicaliseCommodity failed")scanonicalcommoditymap}-- | Get just the amounts from a ledger, in the order parsed.journalAmounts::Journal->[MixedAmount]journalAmounts=mappamount.journalPostings-- | Get just the ammount commodities from a ledger, in the order parsed.journalCommodities::Journal->[Commodity]journalCommodities=mapcommodity.concatMapamounts.journalAmounts-- | Get just the amount precisions from a ledger, in the order parsed.journalPrecisions::Journal->[Int]journalPrecisions=mapprecision.journalCommodities-- | Close any open timelog sessions using the provided current time.journalConvertTimeLog::LocalTime->Journal->JournaljournalConvertTimeLogtl0=l0{jtxns=convertedTimeLog++jtxnsl0,open_timelog_entries=[]}whereconvertedTimeLog=entriesFromTimeLogEntriest$open_timelog_entriesl0-- | The (fully specified) date span containing all the raw ledger's transactions,-- or DateSpan Nothing Nothing if there are none.journalDateSpan::Journal->DateSpanjournalDateSpanj|nullts=DateSpanNothingNothing|otherwise=DateSpan(Just$tdate$headts)(Just$addDays1$tdate$lastts)wherets=sortBy(comparingtdate)$jtxnsj-- | Check if a set of ledger account/description patterns matches the-- given account name or entry description. Patterns are case-insensitive-- regular expression strings; those beginning with - are anti-patterns.matchpats::[String]->String->Boolmatchpatspatsstr=(nullpositives||anymatchpositives)&&(nullnegatives||not(anymatchnegatives))where(negatives,positives)=partitionisnegativepatpatsmatch""=Truematchpat=containsRegex(abspatpat)strnegateprefix="not:"isnegativepat=(negateprefix`isPrefixOf`)abspatpat=ifisnegativepatpatthendrop(lengthnegateprefix)patelsepat-- | Calculate the account tree and account balances from a journal's-- postings, and return the results for efficient lookup.crunchJournal::Journal->(TreeAccountName,Map.MapAccountNameAccount)crunchJournalj=(ant,amap)where(ant,psof,_,inclbalof)=(groupPostings.journalPostings)jamap=Map.fromList[(a,acctinfoa)|a<-flattenant]acctinfoa=Accounta(psofa)(inclbalofa)-- | Given a list of postings, return an account name tree and three query-- functions that fetch postings, balance, and subaccount-including-- balance by account name. This factors out common logic from-- cacheLedger and summarisePostingsInDateSpan.groupPostings::[Posting]->(TreeAccountName,(AccountName->[Posting]),(AccountName->MixedAmount),(AccountName->MixedAmount))groupPostingsps=(ant,psof,exclbalof,inclbalof)whereanames=sort$nub$mappaccountpsant=accountNameTreeFrom$expandAccountNamesanamesallanames=flattenantpmap=Map.union(postingsByAccountps)(Map.fromList[(a,[])|a<-allanames])psof=(pmap!)balmap=Map.fromList$flatten$calculateBalancesantpsofexclbalof=fst.(balmap!)inclbalof=snd.(balmap!)-- | Add subaccount-excluding and subaccount-including balances to a tree-- of account names somewhat efficiently, given a function that looks up-- transactions by account name.calculateBalances::TreeAccountName->(AccountName->[Posting])->Tree(AccountName,(MixedAmount,MixedAmount))calculateBalancesantpsof=addbalancesantwhereaddbalances(Nodeasubs)=Node(a,(bal,bal+subsbal))subs'wherebal=sumPostings$psofasubsbal=sum$map(snd.snd.root)subs'subs'=mapaddbalancessubs-- | Convert a list of postings to a map from account name to that-- account's postings.postingsByAccount::[Posting]->Map.MapAccountName[Posting]postingsByAccountps=m'wheresortedps=sortBy(comparingpaccount)psgroupedps=groupBy(\p1p2->paccountp1==paccountp2)sortedpsm'=Map.fromList[(paccount$headg,g)|g<-groupedps]