pwncash/lib/Internal/Utils.hs

1067 lines
35 KiB
Haskell

module Internal.Utils
( compareDate
, expandDatePat
, askDays
, fromWeekday
, inDaySpan
, fromGregorian'
, resolveDaySpan
, resolveDaySpan_
, intersectDaySpan
, throwAppError
, liftInner
, liftExceptT
, liftExcept
, liftIOExcept
, liftIOExceptT
, combineError
, combineError_
, combineError3
, combineErrors
, mapErrors
, combineErrorM
, combineErrorM3
, combineErrorIO2
, combineErrorIO3
, combineErrorIOM2
, combineErrorIOM3
, collectErrorsIO
, mapErrorsIO
, mapErrorsPooledIO
, showError
, lookupErr
, uncurry3
, dateMatches
, valMatches
, lookupAccount
, lookupAccountKey
, lookupAccountType
, lookupCurrency
, lookupCurrencyKey
, lookupCurrencyPrec
, lookupTag
, mapAdd_
, groupKey
, groupWith
, balanceTxs
, expandTransfers
, expandTransfer
, entryPair
, singleQuote
, keyVals
, realFracToDecimalP
, roundToP
, compileRegex
, matchMaybe
, matchGroupsMaybe
)
where
import Control.Monad.Error.Class
import Control.Monad.Except
import Data.Decimal
import Data.Time.Format.ISO8601
import qualified Database.Esqueleto.Experimental as E
import GHC.Real
import Internal.Types.Main
import RIO
import qualified RIO.List as L
import qualified RIO.Map as M
import qualified RIO.NonEmpty as NE
import RIO.State
import qualified RIO.Text as T
import RIO.Time
import qualified RIO.Vector as V
import Text.Regex.TDFA hiding (matchAll)
import Text.Regex.TDFA.Text
--------------------------------------------------------------------------------
-- intervals
expandDatePat :: DaySpan -> DatePat -> AppExcept [Day]
expandDatePat b (Cron cp) = expandCronPat b cp
expandDatePat i (Mod mp) = return $ expandModPat mp i
expandModPat :: ModPat -> DaySpan -> [Day]
expandModPat ModPat {mpStart = s, mpBy = b, mpUnit = u, mpRepeats = r} bs =
takeWhile (<= upper) $
(`addFun` start) . (* b')
<$> maybe id (take . fromIntegral) r [0 ..]
where
(lower, upper) = fromDaySpan bs
start = maybe lower fromGregorian' s
b' = fromIntegral b
addFun = case u of
Day -> addDays
Week -> addDays . (* 7)
Month -> addGregorianMonthsClip
Year -> addGregorianYearsClip
expandCronPat :: DaySpan -> CronPat -> AppExcept [Day]
expandCronPat b CronPat {cpYear, cpMonth, cpDay, cpWeekly} =
combineError3 yRes mRes dRes $ \ys ms ds ->
filter validWeekday $
mapMaybe (uncurry3 toDay) $
takeWhile (\((y, _), m, d) -> (y, m, d) <= (yb1, mb1, db1)) $
dropWhile (\((y, _), m, d) -> (y, m, d) < (yb0, mb0, db0)) $
[(y, m, d) | y <- (\y -> (y, isLeapYear y)) <$> ys, m <- ms, d <- ds]
where
yRes = case cpYear of
Nothing -> return [yb0 .. yb1]
Just pat -> do
ys <- expandMDYPat (fromIntegral yb0) (fromIntegral yb1) pat
return $ dropWhile (< yb0) $ fromIntegral <$> ys
mRes = expandMD 12 cpMonth
dRes = expandMD 31 cpDay
(s, e) = fromDaySpan b
(yb0, mb0, db0) = toGregorian s
(yb1, mb1, db1) = toGregorian $ addDays (-1) e
expandMD lim =
fmap (fromIntegral <$>)
. maybe (return [1 .. lim]) (expandMDYPat 1 lim)
expandW (OnDay x) = [fromEnum x]
expandW (OnDays xs) = fromEnum <$> xs
ws = maybe [] expandW cpWeekly
validWeekday = if null ws then const True else \day -> dayToWeekday day `elem` ws
toDay (y, leap) m d
| m == 2 && (not leap && d > 28 || leap && d > 29) = Nothing
| m `elem` [4, 6, 9, 11] && d > 30 = Nothing
| otherwise = Just $ fromGregorian y m d
expandMDYPat :: Natural -> Natural -> MDYPat -> AppExcept [Natural]
expandMDYPat lower upper (Single x) = return [x | lower <= x && x <= upper]
expandMDYPat lower upper (Multi xs) = return $ dropWhile (<= lower) $ takeWhile (<= upper) xs
expandMDYPat lower upper (After x) = return [max lower x .. upper]
expandMDYPat lower upper (Before x) = return [lower .. min upper x]
expandMDYPat lower upper (Between x y) = return [max lower x .. min upper y]
expandMDYPat lower upper (Repeat RepeatPat {rpStart = s, rpBy = b, rpRepeats = r})
| b < 1 = throwAppError $ DatePatternError s b r ZeroLength
| otherwise = do
k <- limit r
return $ dropWhile (<= lower) $ takeWhile (<= k) [s + i * b | i <- [0 ..]]
where
limit Nothing = return upper
limit (Just n)
-- this guard not only produces the error for the user but also protects
-- from an underflow below it
| n < 1 = throwAppError $ DatePatternError s b r ZeroRepeats
| otherwise = return $ min (s + b * (n - 1)) upper
dayToWeekday :: Day -> Int
dayToWeekday (ModifiedJulianDay d) = mod (fromIntegral d + 2) 7
askDays
:: (MonadFinance m, MonadAppError m)
=> DatePat
-> Maybe Interval
-> m [Day]
askDays dp i = do
globalSpan <- asks (unBSpan . tsBudgetScope)
case i of
Just i' -> do
localSpan <- liftExcept $ resolveDaySpan i'
maybe (return []) expand $ intersectDaySpan globalSpan localSpan
Nothing -> expand globalSpan
where
expand = liftExcept . (`expandDatePat` dp)
--------------------------------------------------------------------------------
-- dates
-- | Lame weekday converter since day of weeks aren't in dhall (yet)
fromWeekday :: Weekday -> DayOfWeek
fromWeekday Mon = Monday
fromWeekday Tue = Tuesday
fromWeekday Wed = Wednesday
fromWeekday Thu = Thursday
fromWeekday Fri = Friday
fromWeekday Sat = Saturday
fromWeekday Sun = Sunday
gregTup :: Gregorian -> (Integer, Int, Int)
gregTup Gregorian {gYear, gMonth, gDay} =
( fromIntegral gYear
, fromIntegral gMonth
, fromIntegral gDay
)
gregMTup :: GregorianM -> (Integer, Int)
gregMTup GregorianM {gmYear, gmMonth} =
( fromIntegral gmYear
, fromIntegral gmMonth
)
data YMD_ = Y_ !Integer | YM_ !Integer !Int | YMD_ !Integer !Int !Int
fromYMDMatcher :: YMDMatcher -> YMD_
fromYMDMatcher m = case m of
Y y -> Y_ $ fromIntegral y
YM g -> uncurry YM_ $ gregMTup g
YMD g -> uncurry3 YMD_ $ gregTup g
compareDate :: DateMatcher -> Day -> Ordering
compareDate (On md) x =
case fromYMDMatcher md of
Y_ y' -> compare y y'
YM_ y' m' -> compare (y, m) (y', m')
YMD_ y' m' d' -> compare (y, m, d) (y', m', d')
where
(y, m, d) = toGregorian x
compareDate (In md offset) x = do
case fromYMDMatcher md of
Y_ y' -> compareRange y' y
YM_ y' m' -> compareRange (toMonth y' m') $ toMonth y m
YMD_ y' m' d' ->
let s = toModifiedJulianDay $ fromGregorian y' m' d'
in compareRange s $ toModifiedJulianDay x
where
(y, m, _) = toGregorian x
compareRange start z
| z < start = LT
| otherwise = if (start + fromIntegral offset - 1) < z then GT else EQ
toMonth year month = (year * 12) + fromIntegral month
fromGregorian' :: Gregorian -> Day
fromGregorian' = uncurry3 fromGregorian . gregTup
inDaySpan :: DaySpan -> Day -> Bool
inDaySpan bs = withinDays (fromDaySpan bs)
withinDays :: (Day, Day) -> Day -> Bool
withinDays (d0, d1) x = d0 <= x && x < d1
resolveDaySpan :: Interval -> AppExcept DaySpan
resolveDaySpan i@Interval {intStart = s} =
resolveDaySpan_ (s {gYear = gYear s + 50}) i
intersectDaySpan :: DaySpan -> DaySpan -> Maybe DaySpan
intersectDaySpan a b =
if b' > a' then Nothing else Just $ toDaySpan (a', b')
where
(a0, a1) = fromDaySpan a
(b0, b1) = fromDaySpan b
a' = max a0 a1
b' = min b0 b1
resolveDaySpan_ :: Gregorian -> Interval -> AppExcept DaySpan
resolveDaySpan_ def Interval {intStart = s, intEnd = e} =
-- TODO the default isn't checked here :/
case fromGregorian' <$> e of
Nothing -> return $ toDaySpan_ $ fromGregorian' def
Just e_
| s_ < e_ -> return $ toDaySpan_ e_
| otherwise -> throwAppError $ DaySpanError s e
where
s_ = fromGregorian' s
toDaySpan_ end = toDaySpan (s_, end)
fromDaySpan :: DaySpan -> (Day, Day)
fromDaySpan (d, n) = (d, addDays (fromIntegral n + 1) d)
-- ASSUME a < b
toDaySpan :: (Day, Day) -> DaySpan
toDaySpan (a, b) = (a, fromIntegral $ diffDays b a - 1)
--------------------------------------------------------------------------------
-- matching
valMatches :: ValMatcher -> Rational -> AppExcept Bool
valMatches ValMatcher {vmDen, vmSign, vmNum, vmPrec} x
| Just d_ <- vmDen, d_ >= p = throwAppError $ MatchValPrecisionError d_ p
| otherwise =
return $
checkMaybe (s ==) vmSign
&& checkMaybe (n ==) vmNum
&& checkMaybe ((d * fromIntegral p ==) . fromIntegral) vmDen
where
(n, d) = properFraction $ abs x
p = 10 ^ vmPrec
s = signum x >= 0
checkMaybe = maybe True
dateMatches :: DateMatcher -> Day -> Bool
dateMatches md = (EQ ==) . compareDate md
--------------------------------------------------------------------------------
-- error flow control
throwAppError :: MonadAppError m => AppError -> m a
throwAppError e = throwError $ AppException [e]
liftInner :: Monad m => ExceptT e Identity a -> ExceptT e m a
liftInner = mapExceptT (return . runIdentity)
liftExceptT :: MonadError e m => ExceptT e m a -> m a
liftExceptT x = runExceptT x >>= either throwError return
liftExcept :: MonadError e m => Except e a -> m a
liftExcept = either throwError return . runExcept
liftIOExceptT :: MonadIO m => AppExceptT m a -> m a
liftIOExceptT = fromEither <=< runExceptT
liftIOExcept :: MonadIO m => AppExcept a -> m a
liftIOExcept = fromEither . runExcept
combineError :: MonadAppError m => m a -> m b -> (a -> b -> c) -> m c
combineError a b f = combineErrorM a b (\x y -> pure $ f x y)
combineError_ :: MonadAppError m => m a -> m b -> m ()
combineError_ a b = do
_ <- catchError a $ \e ->
throwError =<< catchError (e <$ b) (return . (e <>))
_ <- b
return ()
combineErrorM :: MonadAppError m => m a -> m b -> (a -> b -> m c) -> m c
combineErrorM a b f = do
a' <- catchError a $ \e ->
throwError =<< catchError (e <$ b) (return . (e <>))
f a' =<< b
combineError3 :: MonadAppError m => m a -> m b -> m c -> (a -> b -> c -> d) -> m d
combineError3 a b c f =
combineError (combineError a b (,)) c $ \(x, y) z -> f x y z
combineErrorM3 :: MonadAppError m => m a -> m b -> m c -> (a -> b -> c -> m d) -> m d
combineErrorM3 a b c f = do
combineErrorM (combineErrorM a b (curry return)) c $ \(x, y) z -> f x y z
mapErrors :: (Traversable t, MonadAppError m) => (a -> m b) -> t a -> m (t b)
-- First, record number of each action. Then try each action. On first failure,
-- note it's position in the sequence, skip ahead to the untried actions,
-- collect failures and add to the first failure.
mapErrors f xs = mapM go $ enumTraversable xs
where
go (n, x) = catchError (f x) $ \e -> do
es <- fmap catMaybes $ mapM (err . f) $ drop (n + 1) $ toList xs
throwError $ foldr (<>) e es
err x = catchError (Nothing <$ x) (pure . Just)
combineErrors :: (Traversable t, MonadAppError m) => t (m a) -> m (t a)
combineErrors = mapErrors id
enumTraversable :: (Num n, Traversable t) => t a -> t (n, a)
enumTraversable = snd . L.mapAccumL go 0
where
go n x = (n + 1, (n, x))
combineErrorIO2 :: MonadUnliftIO m => m a -> m b -> (a -> b -> c) -> m c
combineErrorIO2 a b f = combineErrorIOM2 a b (\x y -> pure $ f x y)
combineErrorIO3 :: MonadUnliftIO m => m a -> m b -> m c -> (a -> b -> c -> d) -> m d
combineErrorIO3 a b c f = combineErrorIOM3 a b c (\x y z -> pure $ f x y z)
combineErrorIOM2 :: MonadUnliftIO m => m a -> m b -> (a -> b -> m c) -> m c
combineErrorIOM2 a b f = do
a' <- catch a $ \(AppException es) ->
(throwIO . AppException)
=<< catch (es <$ b) (\(AppException es') -> return (es' ++ es))
f a' =<< b
combineErrorIOM3 :: MonadUnliftIO m => m a -> m b -> m c -> (a -> b -> c -> m d) -> m d
combineErrorIOM3 a b c f =
combineErrorIOM2 (combineErrorIOM2 a b (curry return)) c $ \(x, y) z -> f x y z
mapErrorsPooledIO :: (Traversable t, MonadUnliftIO m) => Int -> (a -> m b) -> t a -> m (t b)
mapErrorsPooledIO t f xs = pooledMapConcurrentlyN t go $ enumTraversable xs
where
go (n, x) = catch (f x) $ \(AppException e) -> do
es <- fmap catMaybes $ mapM (err . f) $ drop (n + 1) $ toList xs
throwIO $ AppException $ foldr (<>) e es
err x = catch (Nothing <$ x) $ \(AppException es) -> pure $ Just es
mapErrorsIO :: (Traversable t, MonadUnliftIO m) => (a -> m b) -> t a -> m (t b)
mapErrorsIO f xs = mapM go $ enumTraversable xs
where
go (n, x) = catch (f x) $ \(AppException e) -> do
es <- fmap catMaybes $ mapM (err . f) $ drop (n + 1) $ toList xs
throwIO $ AppException $ foldr (<>) e es
err x = catch (Nothing <$ x) $ \(AppException es) -> pure $ Just es
collectErrorsIO :: (Traversable t, MonadUnliftIO m) => t (m a) -> m (t a)
collectErrorsIO = mapErrorsIO id
lookupErr :: (Ord k, Show k) => LookupSuberr -> k -> M.Map k v -> AppExcept v
lookupErr what k m = case M.lookup k m of
Just x -> return x
_ -> throwAppError $ LookupError what $ tshow k
--------------------------------------------------------------------------------
-- error display
showError :: AppError -> [T.Text]
showError other = case other of
(StatementError ts ms) -> (showTx <$> ts) ++ (showMatch <$> ms)
(DaySpanError a b) ->
[T.unwords ["Could not create bounds from", showGregorian_ a, "and", showGreg b]]
where
showGreg (Just g) = showGregorian_ g
showGreg Nothing = "Inf"
(AccountTypeError a ts) ->
[ T.unwords
[ "account type of key"
, singleQuote $ unAcntID a
, "is not one of:"
, ts_
]
]
where
ts_ = T.intercalate ", " $ NE.toList $ fmap atName ts
(DatePatternError s b r p) -> [T.unwords [msg, "in pattern: ", pat]]
where
pat =
keyVals $
[ (k, v)
| (k, Just v) <-
[ ("start", Just $ tshow s)
, ("by", Just $ tshow b)
, ("repeats", tshow <$> r)
]
]
msg = case p of
ZeroLength -> "Zero repeat length"
ZeroRepeats -> "Zero repeats"
(RegexError re) -> [T.append "could not make regex from pattern: " re]
(ConversionError x isDouble) ->
[ T.unwords
[ "Could not convert to"
, if isDouble then "double" else "rational"
, "number: "
, x
]
]
(StatementIOError msg) -> [T.append "IO Error: " msg]
(ParseError msg) -> [T.append "Parse Error: " msg]
(MatchValPrecisionError d p) ->
[T.unwords ["Match denominator", tshow d, "must be less than", tshow p]]
(LookupError t f) ->
[T.unwords ["Could not find field", f, "when resolving", what]]
where
what = case t of
EntryIDField st -> T.unwords ["entry", idName st, "ID"]
EntryValField -> "entry value"
MatchField mt -> T.unwords [matchName mt, "match"]
DBKey st -> T.unwords ["database", idName st, "ID key"]
-- TODO this should be its own function
idName AcntField = "account"
idName CurField = "currency"
idName TagField = "tag"
matchName MatchNumeric = "numeric"
matchName MatchText = "text"
(PeriodError start next) ->
[ T.unwords
[ "First pay period on "
, singleQuote $ tshow start
, "must start before first income payment on "
, singleQuote $ tshow next
]
]
(LinkError i m) ->
[ T.unwords
[ "entry index"
, singleQuote $ tshow i
, "out of range: max index is"
, singleQuote $ tshow m
]
]
(DBError d) -> case d of
DBShouldBeEmpty -> ["database has no rows in 'config_state' but has other data"]
DBMultiScope -> ["database has multiple rows in 'config_state'"]
DBUpdateUnbalanced -> ["update is missing debit or credit entries"]
DBLinkError k l ->
let k' = T.append "in entry key: " $ tshow $ E.fromSqlKey k
in case l of
DBLinkNoScale -> [T.append "no link scale" k']
DBLinkNoValue -> [T.append "no link value" k']
DBLinkInvalidValue v isfixed ->
[ T.unwords
[ if isfixed
then "fixed link should not have value"
else "untyped value is ambiguous"
, singleQuote $ tshow v
, k'
]
]
DBLinkInvalidBalance -> [T.append "no value given for balance link" k']
DBLinkInvalidPercent -> [T.append "no value given for percent link" k']
showGregorian_ :: Gregorian -> T.Text
showGregorian_ Gregorian {gYear, gMonth, gDay} = T.intercalate "-" $ tshow <$> [gYear, gMonth, gDay]
showTx :: TxRecord -> T.Text
showTx TxRecord {trDate = d, trAmount = v, trDesc = e, trFile = f} =
T.append "Unmatched transaction: " $
keyVals
[ ("path", T.pack f)
, ("date", T.pack $ iso8601Show d)
, ("value", tshow v)
, ("description", doubleQuote $ unTxDesc e)
]
showMatch :: StatementParserRe -> T.Text
showMatch StatementParser {spDate, spVal, spDesc, spOther, spTimes, spPriority} =
T.append "Unused match: " $ keyVals [(x, y) | (x, Just y) <- kvs]
where
kvs =
[ ("date", showDateMatcher <$> spDate)
, ("val", showValMatcher spVal)
, ("desc", fst <$> spDesc)
, ("other", others)
, ("counter", Just $ maybe "Inf" tshow spTimes)
, ("priority", Just $ tshow spPriority)
]
others = case spOther of
[] -> Nothing
xs -> Just $ singleQuote $ T.concat $ showMatchOther <$> xs
-- | Convert match date to text
-- Single date matches will just show the single date, and ranged matches will
-- show an interval like [YY-MM-DD, YY-MM-DD)
showDateMatcher :: DateMatcher -> T.Text
showDateMatcher md = case md of
(On x) -> showYMDMatcher x
(In start n) -> T.concat ["[", showYMDMatcher start, " ", showYMD_ end, ")"]
where
-- TODO not DRY (this shifting thing happens during the comparison
-- function (kinda)
end = case fromYMDMatcher start of
Y_ y -> Y_ $ y + fromIntegral n
YM_ y m ->
let (y_, m_) = divMod (m + fromIntegral n - 1) 12
in YM_ (y + fromIntegral y_) (m + m_ + 1)
YMD_ y m d ->
uncurry3 YMD_ $
toGregorian $
addDays (fromIntegral n) $
fromGregorian y m d
-- | convert YMD match to text
showYMDMatcher :: YMDMatcher -> T.Text
showYMDMatcher = showYMD_ . fromYMDMatcher
showYMD_ :: YMD_ -> T.Text
showYMD_ md =
T.intercalate "-" $ L.take 3 (fmap tshow digits ++ L.repeat "*")
where
digits = case md of
Y_ y -> [fromIntegral y]
YM_ y m -> [fromIntegral y, m]
YMD_ y m d -> [fromIntegral y, m, d]
showValMatcher :: ValMatcher -> Maybe T.Text
showValMatcher ValMatcher {vmSign = Nothing, vmNum = Nothing, vmDen = Nothing} = Nothing
showValMatcher ValMatcher {vmNum, vmDen, vmSign, vmPrec} =
Just $ singleQuote $ keyVals [(k, v) | (k, Just v) <- kvs]
where
kvs =
[ ("sign", (\s -> if s then "+" else "-") <$> vmSign)
, ("numerator", tshow <$> vmNum)
, ("denominator", tshow <$> vmDen)
, ("precision", Just $ tshow vmPrec)
]
showMatchOther :: FieldMatcherRe -> T.Text
showMatchOther (Desc (Field f (re, _))) =
T.unwords ["desc field", singleQuote f, "with re", singleQuote re]
showMatchOther (Val (Field f mv)) =
T.unwords
[ "val field"
, singleQuote f
, "with match value"
, singleQuote $ fromMaybe "*" $ showValMatcher mv
]
singleQuote :: T.Text -> T.Text
singleQuote t = T.concat ["'", t, "'"]
doubleQuote :: T.Text -> T.Text
doubleQuote t = T.concat ["'", t, "'"]
keyVal :: T.Text -> T.Text -> T.Text
keyVal a b = T.concat [a, "=", b]
keyVals :: [(T.Text, T.Text)] -> T.Text
keyVals = T.intercalate "; " . fmap (uncurry keyVal)
--------------------------------------------------------------------------------
-- random functions
groupKey :: Ord c => (a -> c) -> [(a, b)] -> [(a, NonEmpty b)]
groupKey f = fmap go . NE.groupAllWith (f . fst)
where
go xs@((c, _) :| _) = (c, fmap snd xs)
groupWith :: Ord b => (a -> b) -> [a] -> [(b, NonEmpty a)]
groupWith f = fmap go . NE.groupAllWith fst . fmap (\x -> (f x, x))
where
go xs@((c, _) :| _) = (c, fmap snd xs)
mapAdd_ :: (Ord k, Num v) => k -> v -> M.Map k v -> M.Map k v
mapAdd_ k v = M.alter (maybe (Just v) (Just . (+ v))) k
uncurry3 :: (a -> b -> c -> d) -> (a, b, c) -> d
uncurry3 f (a, b, c) = f a b c
lookupAccount :: (MonadAppError m, MonadFinance m) => AcntID -> m (AccountRId, AcntType)
lookupAccount = lookupFinance AcntField tsAccountMap
lookupAccountKey :: (MonadAppError m, MonadFinance m) => AcntID -> m AccountRId
lookupAccountKey = fmap fst . lookupAccount
lookupAccountType :: (MonadAppError m, MonadFinance m) => AcntID -> m AcntType
lookupAccountType = fmap snd . lookupAccount
lookupCurrency :: (MonadAppError m, MonadFinance m) => CurID -> m CurrencyPrec
lookupCurrency = lookupFinance CurField tsCurrencyMap
lookupCurrencyKey :: (MonadAppError m, MonadFinance m) => CurID -> m CurrencyRId
lookupCurrencyKey = fmap cpID . lookupCurrency
lookupCurrencyPrec :: (MonadAppError m, MonadFinance m) => CurID -> m Precision
lookupCurrencyPrec = fmap cpPrec . lookupCurrency
lookupTag :: (MonadAppError m, MonadFinance m) => TagID -> m TagRId
lookupTag = lookupFinance TagField tsTagMap
lookupFinance
:: (MonadAppError m, MonadFinance m, Ord k, Show k)
=> EntryIDType
-> (TxState -> M.Map k a)
-> k
-> m a
lookupFinance t f c = (liftExcept . lookupErr (DBKey t) c) =<< asks f
balanceTxs
:: (MonadAppError m, MonadFinance m)
=> [EntryCRU]
-> m ([UEBalanced], [InsertTx])
balanceTxs ebs =
first concat . partitionEithers . catMaybes
<$> evalStateT (mapErrors go $ L.sortOn binDate ebs) M.empty
where
go (ToUpdate utx) =
fmap (Just . Left) $
liftInnerS $
either rebalanceTotalEntrySet rebalanceFullEntrySet utx
go (ToRead ReadEntry {reCurrency, reAcnt, reValue}) = do
modify $ mapAdd_ (reAcnt, reCurrency) reValue
return Nothing
go (ToInsert Tx {txPrimary, txOther, txMeta}) = do
e <- either balancePrimaryEntrySet balanceSecondaryEntrySet txPrimary
let tot = sum $ fmap (eValue . ieEntry) $ NE.toList $ iesFromEntries e
es <- mapErrors (goOther tot) txOther
let tx = InsertTx {itxMeta = txMeta, itxEntrySets = e :| es}
return $ Just $ Right tx
where
goOther tot =
either
balanceSecondaryEntrySet
(balancePrimaryEntrySet . fromShadow tot)
fromShadow tot e@EntrySet {esTotalValue} = e {esTotalValue = tot *. esTotalValue}
-- NOTE this sorting thing is super wonky; I'm basically sorting three different
-- levels of the hierarchy directory and assuming there will be no overlaps.
-- First, sort at the transaction level by day, priority, and description as
-- tiebreaker. Anything that shares those three keys will have an unstable sort
-- order. Within the entrysets, use the index as it appears in the
-- configuration, and same with the entries. Since we assume no overlap, nothing
-- "bad" should happen if the levels above entries/entrysets sort on 'Nothing'
-- for the indices they don't have at their level.
binDate :: EntryCRU -> (TxSortKey, Maybe EntrySetIndex, Maybe EntryIndex)
binDate (ToRead ReadEntry {reSortKey, reESIndex, reIndex}) = (reSortKey, Just reESIndex, Just reIndex)
binDate (ToInsert Tx {txMeta = (TxMeta t p d _)}) = (TxSortKey t p d, Nothing, Nothing)
binDate (ToUpdate u) = either go go u
where
go UpdateEntrySet {utSortKey, utIndex} = (utSortKey, Just utIndex, Nothing)
type BCKey = CurrencyRId
type ABCKey = (AccountRId, BCKey)
type EntryBals = M.Map ABCKey Decimal
--------------------------------------------------------------------------------
-- rebalancing
-- TODO make sure new values are rounded properly here
rebalanceTotalEntrySet :: TotalUpdateEntrySet -> State EntryBals [UEBalanced]
rebalanceTotalEntrySet
UpdateEntrySet
{ utFrom0 = (f0@UpdateEntry {ueAcnt = f0Acnt}, f0links)
, utTo0
, utFromUnk
, utToUnk
, utFromRO
, utToRO
, utCurrency
, utTotalValue
} =
do
(fval, fs, tpairs) <- rebalanceDebit utCurrency utFromRO utFromUnk
let f0val = utTotalValue - fval
modify $ mapAdd_ (f0Acnt, utCurrency) f0val
let tsLinked = tpairs ++ (unlink f0val <$> f0links)
ts <- rebalanceCredit utCurrency utTotalValue utTo0 utToUnk utToRO tsLinked
return (f0 {ueValue = StaticValue f0val} : fs ++ ts)
rebalanceFullEntrySet :: FullUpdateEntrySet -> State EntryBals [UEBalanced]
rebalanceFullEntrySet
UpdateEntrySet
{ utFrom0
, utTo0
, utFromUnk
, utToUnk
, utFromRO
, utToRO
, utCurrency
} =
do
(ftot, fs, tpairs) <- rebalanceDebit utCurrency rs ls
ts <- rebalanceCredit utCurrency ftot utTo0 utToUnk utToRO tpairs
return (fs ++ ts)
where
(rs, ls) = case utFrom0 of
Left x -> (x : utFromRO, utFromUnk)
Right x -> (utFromRO, x : utFromUnk)
rebalanceDebit
:: BCKey
-> [UE_RO]
-> [(UEUnk, [UELink])]
-> State EntryBals (Decimal, [UEBalanced], [UEBalanced])
rebalanceDebit k ro linked = do
(tot, (tpairs, fs)) <-
fmap (second (partitionEithers . concat)) $
sumM goFrom $
L.sortOn idx $
(Left <$> ro) ++ (Right <$> linked)
return (tot, fs, tpairs)
where
idx = either ueIndex (ueIndex . fst)
goFrom (Left e) = (,[]) <$> updateFixed k e
goFrom (Right (e0, es)) = do
v <- updateUnknown k e0
let e0' = Right $ e0 {ueValue = StaticValue v}
let es' = Left . unlink v <$> es
return (v, e0' : es')
unlink :: Decimal -> UELink -> UEBalanced
unlink v e = e {ueValue = StaticValue $ (-v) *. unLinkScale (ueValue e)}
rebalanceCredit
:: BCKey
-> Decimal
-> UEBlank
-> [UEUnk]
-> [UE_RO]
-> [UEBalanced]
-> State EntryBals [UEBalanced]
rebalanceCredit k tot t0@UpdateEntry {ueAcnt = t0Acnt} us rs bs = do
(tval, ts) <-
fmap (second catMaybes) $
sumM goTo $
L.sortOn idx $
(UETLinked <$> bs)
++ (UETUnk <$> us)
++ (UETReadOnly <$> rs)
let t0val = -(tot + tval)
modify $ mapAdd_ (t0Acnt, k) t0val
return (t0 {ueValue = StaticValue t0val} : ts)
where
idx = projectUET ueIndex ueIndex ueIndex
goTo (UETReadOnly e) = (,Nothing) <$> updateFixed k e
goTo (UETLinked e) = (,Just e) <$> updateFixed k e
goTo (UETUnk e) = do
v <- updateUnknown k e
return (v, Just $ e {ueValue = StaticValue v})
data UpdateEntryType a b
= UETReadOnly UE_RO
| UETUnk a
| UETLinked b
projectUET :: (UE_RO -> c) -> (a -> c) -> (b -> c) -> UpdateEntryType a b -> c
projectUET f _ _ (UETReadOnly e) = f e
projectUET _ f _ (UETUnk e) = f e
projectUET _ _ f (UETLinked p) = f p
updateFixed :: BCKey -> UpdateEntry i StaticValue -> State EntryBals Decimal
updateFixed k e = do
let v = unStaticValue $ ueValue e
modify $ mapAdd_ (ueAcnt e, k) v
return v
updateUnknown :: BCKey -> UpdateEntry i EntryValueUnk -> State EntryBals Decimal
updateUnknown k e = do
let key = (ueAcnt e, k)
curBal <- gets (M.findWithDefault 0 key)
let v = case ueValue e of
EVPercent p -> curBal *. p
EVBalance p -> p - curBal
modify $ mapAdd_ key v
return v
--------------------------------------------------------------------------------
-- balancing
balancePrimaryEntrySet
:: (MonadAppError m, MonadFinance m)
=> PrimaryEntrySet
-> StateT EntryBals m InsertEntrySet
balancePrimaryEntrySet
EntrySet
{ esFrom = HalfEntrySet {hesPrimary = f0, hesOther = fs}
, esTo = HalfEntrySet {hesPrimary = t0, hesOther = ts}
, esCurrency
, esTotalValue
} =
do
let f0res = resolveAcntAndTags f0
let t0res = resolveAcntAndTags t0
let fsres = mapErrors resolveAcntAndTags fs
let tsres = mapErrors resolveAcntAndTags ts
let bc = esCurrency
combineErrorM (combineError f0res fsres (,)) (combineError t0res tsres (,)) $
\(f0', fs') (t0', ts') -> do
let balFrom = fmap liftInnerS . balanceDeferred
fs'' <- balanceTotalEntrySet balFrom bc esTotalValue f0' fs'
balanceFinal bc (-esTotalValue) fs'' t0' ts'
balanceSecondaryEntrySet
:: (MonadAppError m, MonadFinance m)
=> SecondayEntrySet
-> StateT EntryBals m InsertEntrySet
balanceSecondaryEntrySet
EntrySet
{ esFrom = HalfEntrySet {hesPrimary = f0, hesOther = fs}
, esTo = HalfEntrySet {hesPrimary = t0, hesOther = ts}
, esCurrency
} =
do
let fsRes = mapErrors resolveAcntAndTags (f0 :| fs)
let t0Res = resolveAcntAndTags t0
let tsRes = mapErrors resolveAcntAndTags ts
combineErrorM fsRes (combineError t0Res tsRes (,)) $ \fs' (t0', ts') -> do
fs'' <- mapErrors balFrom fs'
let tot = entrySum (NE.toList fs'')
balanceFinal bc (-tot) fs'' t0' ts'
where
entrySum = sum . fmap (eValue . ieEntry)
balFrom = balanceEntry (fmap liftInnerS . balanceDeferred) bc
bc = esCurrency
balanceFinal
:: (MonadAppError m)
=> BCKey
-> Decimal
-> NonEmpty InsertEntry
-> Entry AccountRId () TagRId
-> [Entry AccountRId EntryLink TagRId]
-> StateT EntryBals m InsertEntrySet
balanceFinal curID tot fs t0 ts = do
let fv = V.fromList $ NE.toList $ fmap (eValue . ieEntry) fs
let balTo = balanceLinked fv
ts' <- balanceTotalEntrySet balTo curID tot t0 ts
return $
InsertEntrySet
{ iesCurrency = curID
, iesFromEntries = fs
, iesToEntries = ts'
}
balanceTotalEntrySet
:: (MonadAppError m)
=> (ABCKey -> v -> StateT EntryBals m (Decimal, Maybe CachedEntry))
-> BCKey
-> Decimal
-> Entry AccountRId () TagRId
-> [Entry AccountRId v TagRId]
-> StateT EntryBals m (NonEmpty InsertEntry)
balanceTotalEntrySet f k tot e@Entry {eAcnt = acntID} es = do
es' <- mapErrors (balanceEntry f k) es
let e0val = tot - entrySum es'
-- TODO not dry
modify (mapAdd_ (acntID, k) e0val)
let e' =
InsertEntry
{ ieEntry = e {eValue = e0val, eAcnt = acntID}
, ieCached = Nothing
}
return $ e' :| es'
where
entrySum = sum . fmap (eValue . ieEntry)
liftInnerS :: Monad m => StateT e Identity a -> StateT e m a
liftInnerS = mapStateT (return . runIdentity)
balanceLinked
:: MonadAppError m
=> Vector Decimal
-> ABCKey
-> EntryLink
-> StateT EntryBals m (Decimal, Maybe CachedEntry)
balanceLinked from k lg = case lg of
(LinkIndex LinkedNumGetter {lngIndex, lngScale}) -> do
let i = fromIntegral lngIndex
upper = EntryIndex $ V.length from
res = fmap (go lngScale) $ from V.!? i
case res of
Just v -> return (v, Just $ CachedLink (EntryIndex $ fromIntegral lngIndex) (LinkScale lngScale))
Nothing -> throwAppError $ LinkError (EntryIndex i) upper
(LinkValue d) -> liftInnerS $ balanceDeferred k d
where
go s = negate . (*. s)
balanceDeferred :: ABCKey -> EntryValue -> State EntryBals (Decimal, Maybe CachedEntry)
balanceDeferred k e = do
newval <- findBalance k e
let d = case e of
EntryFixed _ -> Nothing
EntryBalance v -> Just $ CachedBalance v
EntryPercent v -> Just $ CachedPercent v
return (newval, d)
balanceEntry
:: (MonadAppError m)
=> (ABCKey -> v -> StateT EntryBals m (Decimal, Maybe CachedEntry))
-> BCKey
-> Entry AccountRId v TagRId
-> StateT EntryBals m InsertEntry
balanceEntry f k e@Entry {eValue, eAcnt = acntID} = do
(newVal, cached) <- f (acntID, k) eValue
modify (mapAdd_ (acntID, k) newVal)
return $
InsertEntry
{ ieEntry = e {eValue = newVal, eAcnt = acntID}
, ieCached = cached
}
resolveAcntAndTags
:: (MonadAppError m, MonadFinance m)
=> Entry AcntID v TagID
-> m (Entry AccountRId v TagRId)
resolveAcntAndTags e@Entry {eAcnt, eTags} = do
let acntRes = lookupAccountKey eAcnt
let tagRes = mapErrors lookupTag eTags
combineError acntRes tagRes $ \a ts -> e {eAcnt = a, eTags = ts}
findBalance :: ABCKey -> EntryValue -> State EntryBals Decimal
findBalance k e = do
curBal <- gets (M.findWithDefault 0 k)
return $ case e of
EntryBalance b -> b - curBal
EntryPercent p -> curBal *. p
EntryFixed v -> v
--------------------------------------------------------------------------------
-- transfers
expandTransfers
:: (MonadAppError m, MonadFinance m)
=> CommitR
-> DaySpan
-> [PairedTransfer]
-> m [Tx CommitR]
expandTransfers tc bounds = fmap concat . mapErrors (expandTransfer tc bounds)
expandTransfer
:: (MonadAppError m, MonadFinance m)
=> CommitR
-> DaySpan
-> PairedTransfer
-> m [Tx CommitR]
expandTransfer tc bounds Transfer {transAmounts, transTo, transCurrency, transFrom} = do
txs <- mapErrors go transAmounts
return $ concat txs
where
go
Amount
{ amtWhen = pat
, amtValue = TransferValue {tvVal = v, tvType = t}
, amtDesc = desc
, amtPriority = pri
} = do
cp <- lookupCurrency transCurrency
let v' = (-v)
let dec = realFracToDecimalP (cpPrec cp) v'
let v'' = case t of
TFixed -> EntryFixed dec
TPercent -> EntryPercent v'
TBalance -> EntryBalance dec
withDates bounds pat $ \day ->
return
Tx
{ txMeta = TxMeta day (fromIntegral pri) (TxDesc desc) tc
, txPrimary = Right $ entryPair transFrom transTo (cpID cp) desc () v''
, txOther = []
}
entryPair
:: TaggedAcnt
-> TaggedAcnt
-> CurrencyRId
-> T.Text
-> v0
-> v1
-> EntrySet v0 v1 v2 v3
entryPair (TaggedAcnt fa fts) (TaggedAcnt ta tts) curid com totval val1 =
EntrySet
{ esCurrency = curid
, esTotalValue = totval
, esFrom = halfEntry (AcntID fa) (TagID <$> fts) val1
, esTo = halfEntry (AcntID ta) (TagID <$> tts) ()
}
where
halfEntry :: AcntID -> [TagID] -> v -> HalfEntrySet v v0
halfEntry a ts v =
HalfEntrySet
{ hesPrimary = Entry {eAcnt = a, eValue = v, eComment = com, eTags = ts}
, hesOther = []
}
withDates
:: (MonadFinance m, MonadAppError m)
=> DaySpan
-> DatePat
-> (Day -> m a)
-> m [a]
withDates bounds dp f = do
days <- liftExcept $ expandDatePat bounds dp
combineErrors $ fmap f days
sumM :: (Monad m, Num s) => (a -> m (s, b)) -> [a] -> m (s, [b])
sumM f = mapAccumM (\s -> fmap (first (+ s)) . f) 0
mapAccumM :: (Monad m) => (s -> a -> m (s, b)) -> s -> [a] -> m (s, [b])
mapAccumM f s = foldM (\(s', ys) -> fmap (second (: ys)) . f s') (s, [])
realFracToDecimalP :: (Integral i, RealFrac r) => Precision -> r -> DecimalRaw i
realFracToDecimalP p = realFracToDecimal (unPrecision p)
roundToP :: Integral i => Precision -> DecimalRaw i -> DecimalRaw i
roundToP p = roundTo (unPrecision p)
compileRegex :: Bool -> T.Text -> AppExcept (Text, Regex)
compileRegex groups pat = case res of
Right re -> return (pat, re)
Left _ -> throwError $ AppException [RegexError pat]
where
res =
compile
(blankCompOpt {newSyntax = True})
(blankExecOpt {captureGroups = groups})
pat
matchMaybe :: T.Text -> Regex -> AppExcept Bool
matchMaybe q re = case execute re q of
Right res -> return $ isJust res
Left _ -> throwError $ AppException [RegexError "this should not happen"]
matchGroupsMaybe :: T.Text -> Regex -> [T.Text]
matchGroupsMaybe q re = case regexec re q of
Right Nothing -> []
Right (Just (_, _, _, xs)) -> xs
-- this should never fail as regexec always returns Right
Left _ -> []