{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE LambdaCase, OverloadedStrings #-}
module Model
( Step(..)
, MaybeStep(..)
, MatchAlgo(..)
, nextStep
, undo
, context
, suggest
, setCurrentComment
, getCurrentComment
, setTransactionComment
, getTransactionComment
, accountsByFrequency
, isDuplicateTransaction
) where
import Data.Function
import Data.List
import qualified Data.HashMap.Lazy as HM
import Data.Maybe
import Data.Monoid
import Data.Ord (Down(..))
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Ext hiding (parseTime)
import qualified Hledger as HL
import Data.Foldable
import Control.Applicative
import AmountParser
import DateParser
type Comment = Text
type Duplicate = Bool
data Step = DateQuestion Comment
| DescriptionQuestion Day Comment
| AccountQuestion HL.Transaction Comment
| AmountQuestion HL.AccountName HL.Transaction Comment
| FinalQuestion HL.Transaction Duplicate
deriving (Eq, Show)
data MaybeStep = Finished HL.Transaction
| Step Step
deriving (Eq, Show)
data MatchAlgo = Fuzzy | Substrings
deriving (Eq, Show)
nextStep :: HL.Journal -> DateFormat -> Either Text Text -> Step -> IO (Either Text MaybeStep)
nextStep journal dateFormat entryText current = case current of
DateQuestion comment ->
fmap (Step . flip DescriptionQuestion comment)
<$> either (parseDateWithToday dateFormat) parseHLDateWithToday entryText
DescriptionQuestion day comment -> return $ Right $ Step $
AccountQuestion HL.nulltransaction { HL.tdate = day
, HL.tdescription = (fromEither entryText)
, HL.tcomment = comment
}
""
AccountQuestion trans comment
| T.null (fromEither entryText) && transactionBalanced trans
-> return $ Right $ Step $ FinalQuestion trans (isDuplicateTransaction journal trans)
| T.null (fromEither entryText)
-> return $ Left $ "Transaction not balanced! Please balance your transaction before adding it to the journal."
| otherwise -> return $ Right $ Step $
AmountQuestion (fromEither entryText) trans comment
AmountQuestion name trans comment -> case parseAmount journal (fromEither entryText) of
Left err -> return $ Left (T.pack err)
Right amount -> return $ Right $ Step $
let newPosting = post' name amount comment
in AccountQuestion (addPosting newPosting trans) ""
FinalQuestion trans _
| fromEither entryText == "y" -> return $ Right $ Finished trans
| otherwise -> return $ Right $ Step $ AccountQuestion trans ""
undo :: Step -> Either Text Step
undo current = case current of
DateQuestion _ -> Left "Already at oldest step in current transaction"
DescriptionQuestion _ comment -> return (DateQuestion comment)
AccountQuestion trans _ -> return $ case HL.tpostings trans of
[] -> DescriptionQuestion (HL.tdate trans) (HL.tcomment trans)
ps -> AmountQuestion (HL.paccount (last ps)) trans { HL.tpostings = init ps } (HL.pcomment (last ps))
AmountQuestion _ trans comment -> Right $ AccountQuestion trans comment
FinalQuestion trans _ -> undo (AccountQuestion trans "")
context :: HL.Journal -> MatchAlgo -> DateFormat -> Text -> Step -> IO [Text]
context _ _ dateFormat entryText (DateQuestion _) = parseDateWithToday dateFormat entryText >>= \case
Left _ -> return []
Right date -> return [T.pack $ HL.showDate date]
context j matchAlgo _ entryText (DescriptionQuestion _ _) = return $
let descs = HL.journalDescriptions j
in sortBy (descUses j) $ filter (matches matchAlgo entryText) descs
context j matchAlgo _ entryText (AccountQuestion _ _) = return $
let names = accountsByFrequency j
in filter (matches matchAlgo entryText) names
context journal _ _ entryText (AmountQuestion _ _ _) = return $
maybeToList $ T.pack . HL.showMixedAmount <$> trySumAmount journal entryText
context _ _ _ _ (FinalQuestion _ _) = return []
suggest :: HL.Journal -> DateFormat -> Step -> IO (Maybe Text)
suggest _ dateFormat (DateQuestion _) =
Just . printDate dateFormat <$> getLocalDay
suggest _ _ (DescriptionQuestion _ _) = return Nothing
suggest journal _ (AccountQuestion trans _) = return $
if numPostings trans /= 0 && transactionBalanced trans
then Nothing
else HL.paccount <$> (suggestAccountPosting journal trans)
suggest journal _ (AmountQuestion account trans _) = return $ fmap (T.pack . HL.showMixedAmount) $ do
case findLastSimilar journal trans of
Nothing
| null (HL.tpostings trans)
-> Nothing
| otherwise
-> Just $ negativeAmountSum trans
Just last
| transactionBalanced trans || (trans `isSubsetTransaction` last)
-> HL.pamount <$> (findPostingByAcc account last)
| otherwise
-> Just $ negativeAmountSum trans
suggest _ _ (FinalQuestion _ _) = return $ Just "y"
getCurrentComment :: Step -> Comment
getCurrentComment step = case step of
DateQuestion c -> c
DescriptionQuestion _ c -> c
AccountQuestion _ c -> c
AmountQuestion _ _ c -> c
FinalQuestion trans _ -> HL.tcomment trans
setCurrentComment :: Comment -> Step -> Step
setCurrentComment comment step = case step of
DateQuestion _ -> DateQuestion comment
DescriptionQuestion date _ -> DescriptionQuestion date comment
AccountQuestion trans _ -> AccountQuestion trans comment
AmountQuestion trans name _ -> AmountQuestion trans name comment
FinalQuestion trans duplicate -> FinalQuestion trans { HL.tcomment = comment } duplicate
getTransactionComment :: Step -> Comment
getTransactionComment step = case step of
DateQuestion c -> c
DescriptionQuestion _ c -> c
AccountQuestion trans _ -> HL.tcomment trans
AmountQuestion _ trans _ -> HL.tcomment trans
FinalQuestion trans _ -> HL.tcomment trans
setTransactionComment :: Comment -> Step -> Step
setTransactionComment comment step = case step of
DateQuestion _ -> DateQuestion comment
DescriptionQuestion date _ -> DescriptionQuestion date comment
AccountQuestion trans comment' ->
AccountQuestion (trans { HL.tcomment = comment }) comment'
AmountQuestion name trans comment' ->
AmountQuestion name (trans { HL.tcomment = comment }) comment'
FinalQuestion trans duplicate -> FinalQuestion trans { HL.tcomment = comment } duplicate
matches :: MatchAlgo -> Text -> Text -> Bool
matches algo a b
| T.null a = False
| otherwise = matches' (T.toCaseFold a) (T.toCaseFold b)
where
matches' a' b'
| algo == Fuzzy && T.any (== ':') b' = all (`fuzzyMatch` (T.splitOn ":" b')) (T.words a')
| otherwise = all (`T.isInfixOf` b') (T.words a')
fuzzyMatch :: Text -> [Text] -> Bool
fuzzyMatch _ [] = False
fuzzyMatch query (part : partsRest) = case (T.uncons query) of
Nothing -> True
Just (c, queryRest)
| c == ':' -> fuzzyMatch queryRest partsRest
| otherwise -> fuzzyMatch query partsRest || case (T.uncons part) of
Nothing -> False
Just (c2, partRest)
| c == c2 -> fuzzyMatch queryRest (partRest : partsRest)
| otherwise -> False
post' :: HL.AccountName -> HL.MixedAmount -> Comment -> HL.Posting
post' account amount comment = HL.nullposting
{ HL.paccount = account
, HL.pamount = amount
, HL.pcomment = comment
}
addPosting :: HL.Posting -> HL.Transaction -> HL.Transaction
addPosting p t = t { HL.tpostings = (HL.tpostings t) ++ [p] }
trySumAmount :: HL.Journal -> Text -> Maybe HL.MixedAmount
trySumAmount ctx = either (const Nothing) Just . parseAmount ctx
suggestNextPosting :: HL.Transaction -> HL.Transaction -> Maybe HL.Posting
suggestNextPosting current reference =
let unusedPostings = filter (`notContainedIn` curPostings) refPostings
in listToMaybe unusedPostings
where [refPostings, curPostings] = map HL.tpostings [reference, current]
notContainedIn p = not . any (((==) `on` HL.paccount) p)
suggestCorrespondingPosting :: HL.Transaction -> HL.Transaction -> Maybe HL.Posting
suggestCorrespondingPosting current reference =
let postingsEntered = length curPostings in
if postingsEntered < (length refPostings) then
Just (refPostings !! postingsEntered)
else
suggestNextPosting current reference
where [refPostings, curPostings] = map HL.tpostings [reference, current]
findLastSimilar :: HL.Journal -> HL.Transaction -> Maybe HL.Transaction
findLastSimilar journal desc =
maximumBy (compare `on` HL.tdate) <$>
listToMaybe' (filter (((==) `on` HL.tdescription) desc) $ HL.jtxns journal)
suggestAccountPosting :: HL.Journal -> HL.Transaction -> Maybe HL.Posting
suggestAccountPosting journal trans =
case findLastSimilar journal trans of
Just t -> suggestNextPosting trans t
Nothing -> (last <$> listToMaybe' (HL.jtxns journal)) >>= (suggestCorrespondingPosting trans)
findPostingByAcc :: HL.AccountName -> HL.Transaction -> Maybe HL.Posting
findPostingByAcc account = find ((==account) . HL.paccount) . HL.tpostings
isSubsetTransaction :: HL.Transaction -> HL.Transaction -> Bool
isSubsetTransaction current origin =
let
origPostings = HL.tpostings origin
currPostings = HL.tpostings current
in
null (deleteFirstsBy cmpPosting currPostings origPostings)
where
cmpPosting a b = HL.paccount a == HL.paccount b
&& HL.pamount a == HL.pamount b
listToMaybe' :: [a] -> Maybe [a]
listToMaybe' [] = Nothing
listToMaybe' ls = Just ls
numPostings :: HL.Transaction -> Int
numPostings = length . HL.tpostings
transactionBalanced :: HL.Transaction -> Bool
transactionBalanced trans =
let (rsum, _, _) = HL.transactionPostingBalances trans
in HL.isZeroMixedAmount rsum
negativeAmountSum :: HL.Transaction -> HL.MixedAmount
negativeAmountSum trans =
let (rsum, _, _) = HL.transactionPostingBalances trans
in HL.divideMixedAmount (-1) rsum
descUses :: HL.Journal -> Text -> Text -> Ordering
descUses journal = compare `on` (Down . flip HM.lookup usesMap)
where usesMap = foldr (count . HL.tdescription) HM.empty $
HL.jtxns journal
count :: Text -> HM.HashMap Text (Sum Int) -> HM.HashMap Text (Sum Int)
count = HM.alter (<> Just 1)
accountsByFrequency :: HL.Journal -> [HL.AccountName]
accountsByFrequency journal =
let
usedAccounts = map HL.paccount (HL.journalPostings journal)
frequencyMap :: HM.HashMap HL.AccountName Int = foldr insertOrPlusOne HM.empty usedAccounts
mapWithSubaccounts = foldr insertIfNotPresent frequencyMap (subaccounts frequencyMap)
declaredAccounts = HL.expandAccountNames (HL.journalAccountNamesDeclared journal)
mapWithDeclared = foldr insertIfNotPresent mapWithSubaccounts declaredAccounts
in
map fst (sortBy (compare `on` (Down . snd)) (HM.toList mapWithDeclared))
where
insertOrPlusOne = HM.alter (Just . maybe 1 (+1))
insertIfNotPresent account = HM.insertWith (flip const) account 0
subaccounts m = HL.expandAccountNames (HM.keys m)
isDuplicateTransaction :: HL.Journal -> HL.Transaction -> Bool
isDuplicateTransaction journal trans = any ((==EQ) . cmpTransaction trans) (HL.jtxns journal)
where
transactionAttributes =
[ cmp HL.tdate, cmp HL.tdate2, cmp HL.tdescription, cmp HL.tstatus
, cmp HL.tcode, cmpPostings `on` HL.tpostings
]
postingAttributes =
[ cmp HL.pdate, cmp HL.pdate2, cmp HL.pstatus, cmp HL.paccount
, cmpMixedAmount `on` HL.pamount, cmpPType `on` HL.ptype
, (fmap fold . liftA2 cmpBalanceAssertion) `on` HL.pbalanceassertion
]
amountAttributes =
[ cmp HL.acommodity, cmp HL.aprice, cmp HL.aquantity ]
cmpTransaction :: HL.Transaction -> HL.Transaction -> Ordering
cmpTransaction = lexical transactionAttributes
cmpPostings :: [HL.Posting] -> [HL.Posting] -> Ordering
cmpPostings ps1 ps2 =
mconcat (zipWith (lexical postingAttributes) (sortPostings ps1) (sortPostings ps2))
cmpPType :: HL.PostingType -> HL.PostingType -> Ordering
cmpPType = compare `on` pTypeToInt
where
pTypeToInt :: HL.PostingType -> Int
pTypeToInt HL.RegularPosting = 0
pTypeToInt HL.VirtualPosting = 1
pTypeToInt HL.BalancedVirtualPosting = 2
cmpAmount :: HL.Amount -> HL.Amount -> Ordering
cmpAmount = lexical amountAttributes
cmpMixedAmount :: HL.MixedAmount -> HL.MixedAmount -> Ordering
cmpMixedAmount (HL.Mixed as1) (HL.Mixed as2) =
let
sortedAs1 = sortBy cmpAmount as1
sortedAs2 = sortBy cmpAmount as2
in
mconcat $
compare (length as1) (length as2) : zipWith cmpAmount sortedAs1 sortedAs2
cmpBalanceAssertion :: HL.BalanceAssertion -> HL.BalanceAssertion -> Ordering
cmpBalanceAssertion = lexical [cmp HL.baamount, cmp HL.baexact]
sortPostings :: [HL.Posting] -> [HL.Posting]
sortPostings = sortBy (lexical postingAttributes)
cmp :: Ord b => (a -> b) -> a -> a -> Ordering
cmp f = compare `on` f
lexical :: [a -> b -> Ordering] -> a -> b -> Ordering
lexical = fold
fromEither :: Either a a -> a
fromEither = either id id