{-|
A 'Journal' is a set of 'Transaction's and related data, usually parsed
from a hledger/ledger journal file or timelog. This is the primary hledger
data object.
-}moduleHledger.Data.JournalwhereimportqualifiedData.MapasMapimportData.Map(findWithDefault,(!))importSystem.Time(ClockTime(TOD))importHledger.Data.UtilsimportHledger.Data.TypesimportHledger.Data.AccountNameimportHledger.Data.AmountimportHledger.Data.Commodity(canonicaliseCommodities)importHledger.Data.Dates(nulldatespan)importHledger.Data.Transaction(journalTransactionWithDate)importHledger.Data.PostingimportHledger.Data.TimeLoginstanceShowJournalwhereshowj=printf"Journal %s with %d transactions, %d accounts: %s"(filepathj)(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=""}nullfilterspec=FilterSpec{datespan=nulldatespan,cleared=Nothing,real=False,empty=False,costbasis=False,acctpats=[],descpats=[],whichdate=ActualDate,depth=Nothing}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 transactions whose description matches the description patterns.filterJournalTransactionsByDescription::[String]->Journal->JournalfilterJournalTransactionsByDescriptionpatsj@Journal{jtxns=ts}=j{jtxns=filtermatchdescts}wherematchdesc=matchpatspats.tdescription-- | Keep only 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 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 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.-- More precisely: each positive account pattern excludes transactions-- which do not contain a posting to a matched account, and each negative-- account pattern excludes transactions containing a posting to a matched-- account.filterJournalTransactionsByAccount::[String]->Journal->JournalfilterJournalTransactionsByAccountapatsj@Journal{jtxns=ts}=j{jtxns=filtertmatchts}wheretmatcht=(nullpositives||anypositivepmatchps)&&(nullnegatives||not(anynegativepmatchps))whereps=tpostingstpositivepmatchp=any(`amatch`a)positiveswherea=paccountpnegativepmatchp=any(`amatch`a)negativeswherea=paccountpamatchpata=containsRegex(abspatpat)a(negatives,positives)=partitionisnegativepatapats-- | 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(journalTransactionWithDateEffectiveDate)$jtxnsj}-- | Do post-parse processing on a journal, to make it ready for use.journalFinalise::ClockTime->LocalTime->FilePath->String->Journal->JournaljournalFinalisetclocktlocalpathtxtj=journalCanonicaliseAmounts$journalApplyHistoricalPrices$journalCloseTimeLogEntriestlocalj{filepath=path,filereadtime=tclock,jtext=txt}-- | Convert all the journal's amounts to their canonical display-- settings. Ie, all amounts in a given commodity will use (a) the-- display settings of the first, and (b) the greatest precision, of the-- amounts in that commodity. Prices are canonicalised as well, so consider-- calling journalApplyHistoricalPrices before this.journalCanonicaliseAmounts::Journal->JournaljournalCanonicaliseAmountsj@Journal{jtxns=ts}=j{jtxns=mapfixtransactionts}wherefixtransactiont@Transaction{tpostings=ps}=t{tpostings=mapfixpostingps}fixpostingp@Posting{pamount=a}=p{pamount=fixmixedamounta}fixmixedamount(Mixedas)=Mixed$mapfixamountasfixamounta@Amount{commodity=c,price=p}=a{commodity=fixcommodityc,price=maybeNothing(Just.fixmixedamount)p}fixcommodityc@Commodity{symbol=s}=findWithDefaultcscanonicalcommoditymapcanonicalcommoditymap=journalCanonicalCommoditiesj-- | Apply this journal's historical price records to unpriced amounts where possible.journalApplyHistoricalPrices::Journal->JournaljournalApplyHistoricalPricesj@Journal{jtxns=ts}=j{jtxns=mapfixtransactionts}wherefixtransactiont@Transaction{tdate=d,tpostings=ps}=t{tpostings=mapfixpostingps}wherefixpostingp@Posting{pamount=a}=p{pamount=fixmixedamounta}fixmixedamount(Mixedas)=Mixed$mapfixamountasfixamount=fixpricefixpricea@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}:_)->Justa_->Nothing-- | Close any open timelog sessions in this journal using the provided current time.journalCloseTimeLogEntries::LocalTime->Journal->JournaljournalCloseTimeLogEntriesnowj@Journal{jtxns=ts,open_timelog_entries=es}=j{jtxns=ts++(timeLogEntriesToTransactionsnowes),open_timelog_entries=[]}-- | Convert all this journal's amounts to cost by applying their prices, if any.journalConvertAmountsToCost::Journal->JournaljournalConvertAmountsToCostj@Journal{jtxns=ts}=j{jtxns=mapfixtransactionts}wherefixtransactiont@Transaction{tpostings=ps}=t{tpostings=mapfixpostingps}fixpostingp@Posting{pamount=a}=p{pamount=fixmixedamounta}fixmixedamount(Mixedas)=Mixed$mapfixamountasfixamount=costOfAmount-- | Get this journal's unique, display-preference-canonicalised commodities, by symbol.journalCanonicalCommodities::Journal->Map.MapStringCommodityjournalCanonicalCommoditiesj=canonicaliseCommodities$journalAmountAndPriceCommoditiesj-- | Get all this journal's amounts' commodities, in the order parsed.journalAmountCommodities::Journal->[Commodity]journalAmountCommodities=mapcommodity.concatMapamounts.journalAmounts-- | Get all this journal's amount and price commodities, in the order parsed.journalAmountAndPriceCommodities::Journal->[Commodity]journalAmountAndPriceCommodities=concatMapamountCommodities.concatMapamounts.journalAmounts-- | Get this amount's commodity and any commodities referenced in its price.amountCommodities::Amount->[Commodity]amountCommoditiesAmount{commodity=c,price=Nothing}=[c]amountCommoditiesAmount{commodity=c,price=Justma}=c:(concatMapamountCommodities$amountsma)-- | Get all this journal's amounts, in the order parsed.journalAmounts::Journal->[MixedAmount]journalAmounts=mappamount.journalPostings-- | The (fully specified) date span containing this journal'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 hledger account/description filter patterns matches the-- given account name or entry description. Patterns are case-insensitive-- regular expressions. Prefixed with not:, they become 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 all account balances from a journal's-- postings, returning the results for efficient lookup.journalAccountInfo::Journal->(TreeAccountName,Map.MapAccountNameAccount)journalAccountInfoj=(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, subaccount-excluding-balance and-- subaccount-including-balance by account name.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]