{-# LANGUAGE OverloadedStrings, RecordWildCards, DeriveDataTypeable, FlexibleInstances #-}
{-|

An account-centric transactions report.

-}

module Hledger.Reports.AccountTransactionsReport (
  AccountTransactionsReport,
  AccountTransactionsReportItem,
  accountTransactionsReport,
  accountTransactionsReportItems,
  transactionRegisterDate,
  tests_AccountTransactionsReport
)
where

import Data.List
import Data.Ord
import Data.Maybe
import qualified Data.Text as T
import Data.Time.Calendar

import Hledger.Data
import Hledger.Query
import Hledger.Reports.ReportOptions
import Hledger.Utils


-- | An account transactions report represents transactions affecting
-- a particular account (or possibly several accounts, but we don't
-- use that). It is used eg by hledger-ui's and hledger-web's account
-- register view, where we want to show one row per transaction, in
-- the context of the current account. Report items consist of:
--
-- - the transaction, unmodified
--
-- - the transaction as seen in the context of the current account and query,
--   which means:
--
--   - the transaction date is set to the "transaction context date",
--     which can be different from the transaction's general date:
--     if postings to the current account (and matched by the report query)
--     have their own dates, it's the earliest of these dates.
--
--   - the transaction's postings are filtered, excluding any which are not
--     matched by the report query
--
-- - a text description of the other account(s) posted to/from
--
-- - a flag indicating whether there's more than one other account involved
--
-- - the total increase/decrease to the current account
--
-- - the report transactions' running total after this transaction;
--   or if historical balance is requested (-H), the historical running total.
--   The historical running total includes transactions from before the
--   report start date if one is specified, filtered by the report query.
--   The historical running total may or may not be the account's historical
--   running balance, depending on the report query.
--
-- Items are sorted by transaction register date (the earliest date the transaction
-- posts to the current account), most recent first.
-- Reporting intervals are currently ignored.
--
type AccountTransactionsReport =
  (String                          -- label for the balance column, eg "balance" or "total"
  ,[AccountTransactionsReportItem] -- line items, one per transaction
  )

type AccountTransactionsReportItem =
  (
   Transaction -- the transaction, unmodified
  ,Transaction -- the transaction, as seen from the current account
  ,Bool        -- is this a split (more than one posting to other accounts) ?
  ,String      -- a display string describing the other account(s), if any
  ,MixedAmount -- the amount posted to the current account(s) (or total amount posted)
  ,MixedAmount -- the register's running total or the current account(s)'s historical balance, after this transaction
  )

totallabel :: String
totallabel   = "Period Total"
balancelabel :: String
balancelabel = "Historical Total"

accountTransactionsReport :: ReportOpts -> Journal -> Query -> Query -> AccountTransactionsReport
accountTransactionsReport :: ReportOpts
-> Journal -> Query -> Query -> AccountTransactionsReport
accountTransactionsReport ropts :: ReportOpts
ropts j :: Journal
j reportq :: Query
reportq thisacctq :: Query
thisacctq = (String
label, [AccountTransactionsReportItem]
items)
  where
    -- a depth limit does not affect the account transactions report
    -- seems unnecessary for some reason XXX
    reportq' :: Query
reportq' = -- filterQuery (not . queryIsDepth)
               Query
reportq

    -- get all transactions
    ts1 :: [Transaction]
ts1 = Journal -> [Transaction]
jtxns Journal
j

    -- apply any cur:SYM filters in reportq'
    symq :: Query
symq  = (Query -> Bool) -> Query -> Query
filterQuery Query -> Bool
queryIsSym Query
reportq'
    ts2 :: [Transaction]
ts2 = (if Query -> Bool
queryIsNull Query
symq then [Transaction] -> [Transaction]
forall a. a -> a
id else (Transaction -> Transaction) -> [Transaction] -> [Transaction]
forall a b. (a -> b) -> [a] -> [b]
map (Query -> Transaction -> Transaction
filterTransactionAmounts Query
symq)) [Transaction]
ts1

    -- keep just the transactions affecting this account (via possibly realness or status-filtered postings)
    realq :: Query
realq = (Query -> Bool) -> Query -> Query
filterQuery Query -> Bool
queryIsReal Query
reportq'
    statusq :: Query
statusq = (Query -> Bool) -> Query -> Query
filterQuery Query -> Bool
queryIsStatus Query
reportq'
    ts3 :: [Transaction]
ts3 = (Transaction -> Bool) -> [Transaction] -> [Transaction]
forall a. (a -> Bool) -> [a] -> [a]
filter (Query -> Transaction -> Bool
matchesTransaction Query
thisacctq (Transaction -> Bool)
-> (Transaction -> Transaction) -> Transaction -> Bool
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Query -> Transaction -> Transaction
filterTransactionPostings ([Query] -> Query
And [Query
realq, Query
statusq])) [Transaction]
ts2

    -- maybe convert these transactions to cost or value
    prices :: PriceOracle
prices = Journal -> PriceOracle
journalPriceOracle Journal
j
    styles :: Map CommoditySymbol AmountStyle
styles = Journal -> Map CommoditySymbol AmountStyle
journalCommodityStyles Journal
j
    periodlast :: Day
periodlast =
      Day -> Maybe Day -> Day
forall a. a -> Maybe a -> a
fromMaybe (String -> Day
forall a. String -> a
error' "journalApplyValuation: expected a non-empty journal") (Maybe Day -> Day) -> Maybe Day -> Day
forall a b. (a -> b) -> a -> b
$ -- XXX shouldn't happen
      ReportOpts -> Journal -> Maybe Day
reportPeriodOrJournalLastDay ReportOpts
ropts Journal
j
    mreportlast :: Maybe Day
mreportlast = ReportOpts -> Maybe Day
reportPeriodLastDay ReportOpts
ropts
    today :: Day
today = Day -> Maybe Day -> Day
forall a. a -> Maybe a -> a
fromMaybe (String -> Day
forall a. String -> a
error' "journalApplyValuation: could not pick a valuation date, ReportOpts today_ is unset") (Maybe Day -> Day) -> Maybe Day -> Day
forall a b. (a -> b) -> a -> b
$ ReportOpts -> Maybe Day
today_ ReportOpts
ropts
    multiperiod :: Bool
multiperiod = ReportOpts -> Interval
interval_ ReportOpts
ropts Interval -> Interval -> Bool
forall a. Eq a => a -> a -> Bool
/= Interval
NoInterval
    tval :: Transaction -> Transaction
tval = case ReportOpts -> Maybe ValuationType
value_ ReportOpts
ropts of
             Just v :: ValuationType
v  -> \t :: Transaction
t -> PriceOracle
-> Map CommoditySymbol AmountStyle
-> Day
-> Maybe Day
-> Day
-> Bool
-> Transaction
-> ValuationType
-> Transaction
transactionApplyValuation PriceOracle
prices Map CommoditySymbol AmountStyle
styles Day
periodlast Maybe Day
mreportlast Day
today Bool
multiperiod Transaction
t ValuationType
v
             Nothing -> Transaction -> Transaction
forall a. a -> a
id
    ts4 :: [Transaction]
ts4 = (Transaction -> Transaction) -> [Transaction] -> [Transaction]
forall a b. (a -> b) -> [a] -> [b]
map Transaction -> Transaction
tval [Transaction]
ts3 

    -- sort by the transaction's register date, for accurate starting balance
    ts :: [Transaction]
ts = (Transaction -> Transaction -> Ordering)
-> [Transaction] -> [Transaction]
forall a. (a -> a -> Ordering) -> [a] -> [a]
sortBy ((Transaction -> Day) -> Transaction -> Transaction -> Ordering
forall a b. Ord a => (b -> a) -> b -> b -> Ordering
comparing (Query -> Query -> Transaction -> Day
transactionRegisterDate Query
reportq' Query
thisacctq)) [Transaction]
ts4

    (startbal :: MixedAmount
startbal,label :: String
label)
      | ReportOpts -> BalanceType
balancetype_ ReportOpts
ropts BalanceType -> BalanceType -> Bool
forall a. Eq a => a -> a -> Bool
== BalanceType
HistoricalBalance = ([Posting] -> MixedAmount
sumPostings [Posting]
priorps, String
balancelabel)
      | Bool
otherwise                              = (MixedAmount
nullmixedamt,        String
totallabel)
      where
        priorps :: [Posting]
priorps = String -> [Posting] -> [Posting]
forall a. Show a => String -> a -> a
dbg1 "priorps" ([Posting] -> [Posting]) -> [Posting] -> [Posting]
forall a b. (a -> b) -> a -> b
$
                  (Posting -> Bool) -> [Posting] -> [Posting]
forall a. (a -> Bool) -> [a] -> [a]
filter (Query -> Posting -> Bool
matchesPosting
                          (String -> Query -> Query
forall a. Show a => String -> a -> a
dbg1 "priorq" (Query -> Query) -> Query -> Query
forall a b. (a -> b) -> a -> b
$
                           [Query] -> Query
And [Query
thisacctq, Query
tostartdateq, Query
datelessreportq]))
                         ([Posting] -> [Posting]) -> [Posting] -> [Posting]
forall a b. (a -> b) -> a -> b
$ [Transaction] -> [Posting]
transactionsPostings [Transaction]
ts
        tostartdateq :: Query
tostartdateq =
          case Maybe Day
mstartdate of
            Just _  -> DateSpan -> Query
Date (Maybe Day -> Maybe Day -> DateSpan
DateSpan Maybe Day
forall a. Maybe a
Nothing Maybe Day
mstartdate)
            Nothing -> Query
None  -- no start date specified, there are no prior postings
        mstartdate :: Maybe Day
mstartdate = Bool -> Query -> Maybe Day
queryStartDate (ReportOpts -> Bool
date2_ ReportOpts
ropts) Query
reportq'
        datelessreportq :: Query
datelessreportq = (Query -> Bool) -> Query -> Query
filterQuery (Bool -> Bool
not (Bool -> Bool) -> (Query -> Bool) -> Query -> Bool
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Query -> Bool
queryIsDateOrDate2) Query
reportq'

    items :: [AccountTransactionsReportItem]
items = [AccountTransactionsReportItem] -> [AccountTransactionsReportItem]
forall a. [a] -> [a]
reverse ([AccountTransactionsReportItem]
 -> [AccountTransactionsReportItem])
-> [AccountTransactionsReportItem]
-> [AccountTransactionsReportItem]
forall a b. (a -> b) -> a -> b
$
            Query
-> Query
-> MixedAmount
-> (MixedAmount -> MixedAmount)
-> [Transaction]
-> [AccountTransactionsReportItem]
accountTransactionsReportItems Query
reportq' Query
thisacctq MixedAmount
startbal MixedAmount -> MixedAmount
forall a. Num a => a -> a
negate [Transaction]
ts

-- | Generate transactions report items from a list of transactions,
-- using the provided user-specified report query, a query specifying
-- which account to use as the focus, a starting balance, a sign-setting
-- function and a balance-summing function. Or with a None current account
-- query, this can also be used for the transactionsReport.
accountTransactionsReportItems :: Query -> Query -> MixedAmount -> (MixedAmount -> MixedAmount) -> [Transaction] -> [AccountTransactionsReportItem]
accountTransactionsReportItems :: Query
-> Query
-> MixedAmount
-> (MixedAmount -> MixedAmount)
-> [Transaction]
-> [AccountTransactionsReportItem]
accountTransactionsReportItems reportq :: Query
reportq thisacctq :: Query
thisacctq bal :: MixedAmount
bal signfn :: MixedAmount -> MixedAmount
signfn =
    [Maybe AccountTransactionsReportItem]
-> [AccountTransactionsReportItem]
forall a. [Maybe a] -> [a]
catMaybes ([Maybe AccountTransactionsReportItem]
 -> [AccountTransactionsReportItem])
-> ([Transaction] -> [Maybe AccountTransactionsReportItem])
-> [Transaction]
-> [AccountTransactionsReportItem]
forall b c a. (b -> c) -> (a -> b) -> a -> c
. (MixedAmount, [Maybe AccountTransactionsReportItem])
-> [Maybe AccountTransactionsReportItem]
forall a b. (a, b) -> b
snd ((MixedAmount, [Maybe AccountTransactionsReportItem])
 -> [Maybe AccountTransactionsReportItem])
-> ([Transaction]
    -> (MixedAmount, [Maybe AccountTransactionsReportItem]))
-> [Transaction]
-> [Maybe AccountTransactionsReportItem]
forall b c a. (b -> c) -> (a -> b) -> a -> c
.
    (MixedAmount
 -> Transaction
 -> (MixedAmount, Maybe AccountTransactionsReportItem))
-> MixedAmount
-> [Transaction]
-> (MixedAmount, [Maybe AccountTransactionsReportItem])
forall (t :: * -> *) a b c.
Traversable t =>
(a -> b -> (a, c)) -> a -> t b -> (a, t c)
mapAccumL (Query
-> Query
-> (MixedAmount -> MixedAmount)
-> MixedAmount
-> Transaction
-> (MixedAmount, Maybe AccountTransactionsReportItem)
accountTransactionsReportItem Query
reportq Query
thisacctq MixedAmount -> MixedAmount
signfn) MixedAmount
bal

accountTransactionsReportItem :: Query -> Query -> (MixedAmount -> MixedAmount) -> MixedAmount -> Transaction -> (MixedAmount, Maybe AccountTransactionsReportItem)
accountTransactionsReportItem :: Query
-> Query
-> (MixedAmount -> MixedAmount)
-> MixedAmount
-> Transaction
-> (MixedAmount, Maybe AccountTransactionsReportItem)
accountTransactionsReportItem reportq :: Query
reportq thisacctq :: Query
thisacctq signfn :: MixedAmount -> MixedAmount
signfn bal :: MixedAmount
bal torig :: Transaction
torig = (MixedAmount, Maybe AccountTransactionsReportItem)
balItem
    -- 201403: This is used for both accountTransactionsReport and transactionsReport, which makes it a bit overcomplicated
    -- 201407: I've lost my grip on this, let's just hope for the best
    -- 201606: we now calculate change and balance from filtered postings, check this still works well for all callers XXX
    where
      tfiltered :: Transaction
tfiltered@Transaction{tpostings :: Transaction -> [Posting]
tpostings=[Posting]
reportps} = Query -> Transaction -> Transaction
filterTransactionPostings Query
reportq Transaction
torig
      tacct :: Transaction
tacct = Transaction
tfiltered{tdate :: Day
tdate=Query -> Query -> Transaction -> Day
transactionRegisterDate Query
reportq Query
thisacctq Transaction
tfiltered}
      balItem :: (MixedAmount, Maybe AccountTransactionsReportItem)
balItem = case [Posting]
reportps of
           [] -> (MixedAmount
bal, Maybe AccountTransactionsReportItem
forall a. Maybe a
Nothing)  -- no matched postings in this transaction, skip it
           _  -> (MixedAmount
b, AccountTransactionsReportItem
-> Maybe AccountTransactionsReportItem
forall a. a -> Maybe a
Just (Transaction
torig, Transaction
tacct, Int
numotheraccts Int -> Int -> Bool
forall a. Ord a => a -> a -> Bool
> 1, String
otheracctstr, MixedAmount
a, MixedAmount
b))
                 where
                  (thisacctps :: [Posting]
thisacctps, otheracctps :: [Posting]
otheracctps) = (Posting -> Bool) -> [Posting] -> ([Posting], [Posting])
forall a. (a -> Bool) -> [a] -> ([a], [a])
partition (Query -> Posting -> Bool
matchesPosting Query
thisacctq) [Posting]
reportps
                  numotheraccts :: Int
numotheraccts = [CommoditySymbol] -> Int
forall (t :: * -> *) a. Foldable t => t a -> Int
length ([CommoditySymbol] -> Int) -> [CommoditySymbol] -> Int
forall a b. (a -> b) -> a -> b
$ [CommoditySymbol] -> [CommoditySymbol]
forall a. Eq a => [a] -> [a]
nub ([CommoditySymbol] -> [CommoditySymbol])
-> [CommoditySymbol] -> [CommoditySymbol]
forall a b. (a -> b) -> a -> b
$ (Posting -> CommoditySymbol) -> [Posting] -> [CommoditySymbol]
forall a b. (a -> b) -> [a] -> [b]
map Posting -> CommoditySymbol
paccount [Posting]
otheracctps
                  otheracctstr :: String
otheracctstr | Query
thisacctq Query -> Query -> Bool
forall a. Eq a => a -> a -> Bool
== Query
None  = [Posting] -> String
summarisePostingAccounts [Posting]
reportps     -- no current account ? summarise all matched postings
                               | Int
numotheraccts Int -> Int -> Bool
forall a. Eq a => a -> a -> Bool
== 0 = [Posting] -> String
summarisePostingAccounts [Posting]
thisacctps   -- only postings to current account ? summarise those
                               | Bool
otherwise          = [Posting] -> String
summarisePostingAccounts [Posting]
otheracctps  -- summarise matched postings to other account(s)
                  a :: MixedAmount
a = MixedAmount -> MixedAmount
signfn (MixedAmount -> MixedAmount) -> MixedAmount -> MixedAmount
forall a b. (a -> b) -> a -> b
$ MixedAmount -> MixedAmount
forall a. Num a => a -> a
negate (MixedAmount -> MixedAmount) -> MixedAmount -> MixedAmount
forall a b. (a -> b) -> a -> b
$ [MixedAmount] -> MixedAmount
forall (t :: * -> *) a. (Foldable t, Num a) => t a -> a
sum ([MixedAmount] -> MixedAmount) -> [MixedAmount] -> MixedAmount
forall a b. (a -> b) -> a -> b
$ (Posting -> MixedAmount) -> [Posting] -> [MixedAmount]
forall a b. (a -> b) -> [a] -> [b]
map Posting -> MixedAmount
pamount [Posting]
thisacctps
                  b :: MixedAmount
b = MixedAmount
bal MixedAmount -> MixedAmount -> MixedAmount
forall a. Num a => a -> a -> a
+ MixedAmount
a

-- | What is the transaction's date in the context of a particular account
-- (specified with a query) and report query, as in an account register ?
-- It's normally the transaction's general date, but if any posting(s)
-- matched by the report query and affecting the matched account(s) have
-- their own earlier dates, it's the earliest of these dates.
-- Secondary transaction/posting dates are ignored.
transactionRegisterDate :: Query -> Query -> Transaction -> Day
transactionRegisterDate :: Query -> Query -> Transaction -> Day
transactionRegisterDate reportq :: Query
reportq thisacctq :: Query
thisacctq t :: Transaction
t
  | [Posting] -> Bool
forall (t :: * -> *) a. Foldable t => t a -> Bool
null [Posting]
thisacctps = Transaction -> Day
tdate Transaction
t
  | Bool
otherwise       = [Day] -> Day
forall (t :: * -> *) a. (Foldable t, Ord a) => t a -> a
minimum ([Day] -> Day) -> [Day] -> Day
forall a b. (a -> b) -> a -> b
$ (Posting -> Day) -> [Posting] -> [Day]
forall a b. (a -> b) -> [a] -> [b]
map Posting -> Day
postingDate [Posting]
thisacctps
  where
    reportps :: [Posting]
reportps   = Transaction -> [Posting]
tpostings (Transaction -> [Posting]) -> Transaction -> [Posting]
forall a b. (a -> b) -> a -> b
$ Query -> Transaction -> Transaction
filterTransactionPostings Query
reportq Transaction
t
    thisacctps :: [Posting]
thisacctps = (Posting -> Bool) -> [Posting] -> [Posting]
forall a. (a -> Bool) -> [a] -> [a]
filter (Query -> Posting -> Bool
matchesPosting Query
thisacctq) [Posting]
reportps

-- -- | Generate a short readable summary of some postings, like
-- -- "from (negatives) to (positives)".
-- summarisePostings :: [Posting] -> String
-- summarisePostings ps =
--     case (summarisePostingAccounts froms, summarisePostingAccounts tos) of
--        ("",t) -> "to "++t
--        (f,"") -> "from "++f
--        (f,t)  -> "from "++f++" to "++t
--     where
--       (froms,tos) = partition (fromMaybe False . isNegativeMixedAmount . pamount) ps

-- | Generate a simplified summary of some postings' accounts.
-- To reduce noise, if there are both real and virtual postings, show only the real ones.
summarisePostingAccounts :: [Posting] -> String
summarisePostingAccounts :: [Posting] -> String
summarisePostingAccounts ps :: [Posting]
ps =
  (String -> [String] -> String
forall a. [a] -> [[a]] -> [a]
intercalate ", " ([String] -> String)
-> ([Posting] -> [String]) -> [Posting] -> String
forall b c a. (b -> c) -> (a -> b) -> a -> c
. (CommoditySymbol -> String) -> [CommoditySymbol] -> [String]
forall a b. (a -> b) -> [a] -> [b]
map (CommoditySymbol -> String
T.unpack (CommoditySymbol -> String)
-> (CommoditySymbol -> CommoditySymbol)
-> CommoditySymbol
-> String
forall b c a. (b -> c) -> (a -> b) -> a -> c
. CommoditySymbol -> CommoditySymbol
accountSummarisedName) ([CommoditySymbol] -> [String])
-> ([Posting] -> [CommoditySymbol]) -> [Posting] -> [String]
forall b c a. (b -> c) -> (a -> b) -> a -> c
. [CommoditySymbol] -> [CommoditySymbol]
forall a. Eq a => [a] -> [a]
nub ([CommoditySymbol] -> [CommoditySymbol])
-> ([Posting] -> [CommoditySymbol])
-> [Posting]
-> [CommoditySymbol]
forall b c a. (b -> c) -> (a -> b) -> a -> c
. (Posting -> CommoditySymbol) -> [Posting] -> [CommoditySymbol]
forall a b. (a -> b) -> [a] -> [b]
map Posting -> CommoditySymbol
paccount) [Posting]
displayps -- XXX pack
  where
    realps :: [Posting]
realps = (Posting -> Bool) -> [Posting] -> [Posting]
forall a. (a -> Bool) -> [a] -> [a]
filter Posting -> Bool
isReal [Posting]
ps
    displayps :: [Posting]
displayps | [Posting] -> Bool
forall (t :: * -> *) a. Foldable t => t a -> Bool
null [Posting]
realps = [Posting]
ps
              | Bool
otherwise   = [Posting]
realps

-- tests

tests_AccountTransactionsReport :: TestTree
tests_AccountTransactionsReport = String -> [TestTree] -> TestTree
tests "AccountTransactionsReport" [
 ]