module Internal.Utils ( compareDate , expandDatePat , askDays , fromWeekday , inDaySpan , fmtRational , fromGregorian' , resolveDaySpan , resolveDaySpan_ , intersectDaySpan , liftInner , liftExceptT , liftExcept , liftIOExcept , liftIOExceptT , combineError , combineError_ , combineError3 , combineErrors , mapErrors , combineErrorM , combineErrorM3 , combineErrorIO2 , combineErrorIO3 , combineErrorIOM2 , combineErrorIOM3 , collectErrorsIO , mapErrorsIO , showError , acntPath2Text , showT , lookupErr , gregorians , uncurry3 , fstOf3 , sndOf3 , thdOf3 , xGregToDay , dateMatches , valMatches , roundPrecision , roundPrecisionCur , lookupAccount , lookupAccountKey , lookupAccountSign , lookupAccountType , lookupCurrency , lookupCurrencyKey , lookupCurrencyPrec , lookupTag , mapAdd_ , groupKey , groupWith , balanceTxs , expandTransfers , expandTransfer , entryPair , singleQuote , keyVals ) where import Control.Monad.Error.Class import Control.Monad.Except import Data.Time.Format.ISO8601 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 -------------------------------------------------------------------------------- -- intervals expandDatePat :: DaySpan -> DatePat -> InsertExcept [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 -> InsertExcept [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 -> InsertExcept [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 = throwError $ InsertException [PatternError 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 = throwError $ InsertException [PatternError 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, MonadInsertError m) => DatePat -> Maybe Interval -> m [Day] askDays dp i = do globalSpan <- askDBState kmBudgetInterval 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 -- | find the next date -- this is meant to go in a very tight loop and be very fast (hence no -- complex date functions, most of which heavily use 'mod' and friends) nextXGreg :: XGregorian -> XGregorian nextXGreg XGregorian {xgYear = y, xgMonth = m, xgDay = d, xgDayOfWeek = w} | m == 12 && d == 31 = XGregorian (y + 1) 1 1 w_ | (m == 2 && (not leap && d == 28 || (leap && d == 29))) || (m `elem` [4, 6, 9, 11] && d == 30) || (d == 31) = XGregorian y (m + 1) 1 w_ | otherwise = XGregorian y m (d + 1) w_ where -- don't use DayOfWeek from Data.Time since this uses mod (which uses a -- division opcode) and thus will be slower than just checking for equality -- and adding w_ = if w == 6 then 0 else w + 1 leap = isLeapYear $ fromIntegral y gregorians :: Day -> [XGregorian] gregorians x = L.iterate nextXGreg $ XGregorian (fromIntegral y) m d w where (y, m, d) = toGregorian x w = fromEnum $ dayOfWeek x xGregToDay :: XGregorian -> Day xGregToDay XGregorian {xgYear = y, xgMonth = m, xgDay = d} = fromGregorian (fromIntegral y) m d 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 -> InsertExcept 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 -> InsertExcept 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 -> throwError $ InsertException [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 -> InsertExcept Bool valMatches ValMatcher {vmDen, vmSign, vmNum, vmPrec} x | Just d_ <- vmDen, d_ >= p = throwError $ InsertException [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 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 => InsertExceptT m a -> m a liftIOExceptT = fromEither <=< runExceptT liftIOExcept :: MonadIO m => InsertExcept a -> m a liftIOExcept = fromEither . runExcept combineError :: MonadError InsertException m => m a -> m b -> (a -> b -> c) -> m c combineError a b f = combineErrorM a b (\x y -> pure $ f x y) combineError_ :: MonadError InsertException m => m a -> m b -> m () combineError_ a b = do _ <- catchError a $ \e -> throwError =<< catchError (e <$ b) (return . (e <>)) _ <- b return () combineErrorM :: MonadError InsertException 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 :: MonadError InsertException 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 :: MonadError InsertException 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, MonadError InsertException 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, MonadError InsertException 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 $ \(InsertException es) -> (throwIO . InsertException) =<< catch (es <$ b) (\(InsertException 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 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) $ \(InsertException e) -> do es <- fmap catMaybes $ mapM (err . f) $ drop (n + 1) $ toList xs throwIO $ InsertException $ foldr (<>) e es err x = catch (Nothing <$ x) $ \(InsertException 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 -> InsertExcept v lookupErr what k m = case M.lookup k m of Just x -> return x _ -> throwError $ InsertException [LookupError what $ showT k] fmtRational :: Natural -> Rational -> T.Text fmtRational precision x = T.concat [s, txt n', ".", pad 2 "0" $ txt d'] where s = if x >= 0 then "" else "-" x'@(n :% d) = abs x p = 10 ^ precision n' = div n d d' = (\(a :% b) -> div a b) ((x' - fromIntegral n') * p) txt = T.pack . show pad i c z = T.append (T.replicate (i - T.length z) c) z roundPrecision :: Natural -> Double -> Rational roundPrecision n = (% p) . round . (* fromIntegral p) . toRational where p = 10 ^ n roundPrecisionCur :: CurrencyPrec -> Double -> Rational roundPrecisionCur (CurrencyPrec _ n) = roundPrecision n acntPath2Text :: AcntPath -> T.Text acntPath2Text (AcntPath t cs) = T.intercalate "/" (atName t : cs) -------------------------------------------------------------------------------- -- error display showError :: InsertError -> [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" (AccountError a ts) -> [ T.unwords [ "account type of key" , singleQuote a , "is not one of:" , ts_ ] ] where ts_ = T.intercalate ", " $ NE.toList $ fmap atName ts (PatternError s b r p) -> [T.unwords [msg, "in pattern: ", pat]] where pat = keyVals $ [ (k, v) | (k, Just v) <- [ ("start", Just $ showT s) , ("by", Just $ showT b) , ("repeats", showT <$> 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) -> [T.append "Could not convert to rational number: " x] (InsertIOError msg) -> [T.append "IO Error: " msg] (ParseError msg) -> [T.append "Parse Error: " msg] (MatchValPrecisionError d p) -> [T.unwords ["Match denominator", showT d, "must be less than", showT 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" (IncomeError day name balance) -> [ T.unwords [ "Income allocations for budget" , singleQuote name , "exceed total on day" , showT day , "where balance is" , showT (fromRational balance :: Double) ] ] (PeriodError start next) -> [ T.unwords [ "First pay period on " , singleQuote $ showT start , "must start before first income payment on " , singleQuote $ showT next ] ] (IndexError Entry {eValue = LinkedNumGetter {lngIndex}, eAcnt} day) -> [ T.unwords [ "No credit entry for index" , singleQuote $ showT lngIndex , "for entry with account" , singleQuote eAcnt , "on" , showT day ] ] (RoundError cur) -> [ T.unwords [ "Could not look up precision for currency" , singleQuote cur ] ] showGregorian_ :: Gregorian -> T.Text showGregorian_ Gregorian {gYear, gMonth, gDay} = T.intercalate "-" $ showT <$> [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", showT (fromRational v :: Float)) , ("description", doubleQuote e) ] showMatch :: MatchRe -> 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" showT spTimes) , ("priority", Just $ showT 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 showT 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", showT <$> vmNum) , ("denominator", showT <$> vmDen) , ("precision", Just $ showT 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) showT :: Show a => a -> T.Text showT = T.pack . show -------------------------------------------------------------------------------- -- 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, [a])] groupWith f = fmap go . NE.groupAllWith fst . fmap (\x -> (f x, x)) where go xs@((c, _) :| _) = (c, NE.toList $ 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 fstOf3 :: (a, b, c) -> a fstOf3 (a, _, _) = a sndOf3 :: (a, b, c) -> b sndOf3 (_, b, _) = b thdOf3 :: (a, b, c) -> c thdOf3 (_, _, c) = c lookupAccount :: (MonadInsertError m, MonadFinance m) => AcntID -> m (AccountRId, AcntSign, AcntType) lookupAccount = lookupFinance AcntField kmAccount lookupAccountKey :: (MonadInsertError m, MonadFinance m) => AcntID -> m AccountRId lookupAccountKey = fmap fstOf3 . lookupAccount lookupAccountSign :: (MonadInsertError m, MonadFinance m) => AcntID -> m AcntSign lookupAccountSign = fmap sndOf3 . lookupAccount lookupAccountType :: (MonadInsertError m, MonadFinance m) => AcntID -> m AcntType lookupAccountType = fmap thdOf3 . lookupAccount lookupCurrency :: (MonadInsertError m, MonadFinance m) => CurID -> m CurrencyPrec lookupCurrency = lookupFinance CurField kmCurrency lookupCurrencyKey :: (MonadInsertError m, MonadFinance m) => AcntID -> m CurrencyRId lookupCurrencyKey = fmap cpID . lookupCurrency lookupCurrencyPrec :: (MonadInsertError m, MonadFinance m) => AcntID -> m Natural lookupCurrencyPrec = fmap cpPrec . lookupCurrency lookupTag :: (MonadInsertError m, MonadFinance m) => TagID -> m TagRId lookupTag = lookupFinance TagField kmTag lookupFinance :: (MonadInsertError m, MonadFinance m) => EntryIDType -> (DBState -> M.Map T.Text a) -> T.Text -> m a lookupFinance t f c = (liftExcept . lookupErr (DBKey t) c) =<< askDBState f balanceTxs :: (MonadInsertError m, MonadFinance m) => [EntryBin] -> 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, reBudget}) = do modify $ mapAdd_ (reAcnt, (reCurrency, reBudget)) reValue return Nothing go (ToInsert Tx {txPrimary, txOther, txDescr, txCommit, txDate, txBudget}) = do e <- either (balancePrimaryEntrySet txBudget) (balanceSecondaryEntrySet txBudget) txPrimary let tot = sum $ fmap (eValue . ieEntry) $ NE.toList $ iesFromEntries e es <- mapErrors (either (balanceSecondaryEntrySet txBudget) (balancePrimaryEntrySet txBudget . fromShadow tot)) txOther let tx = -- TODO this is lame InsertTx { itxDescr = txDescr , itxDate = txDate , itxEntrySets = e :| es , itxCommit = txCommit , itxBudget = txBudget } return $ Just $ Right tx fromShadow tot e@EntrySet {esTotalValue} = e {esTotalValue = tot * toRational esTotalValue} binDate :: EntryBin -> Day binDate (ToUpdate (Right UpdateEntrySet {utDate})) = utDate binDate (ToUpdate (Left UpdateEntrySet {utDate})) = utDate binDate (ToRead ReadEntry {reDate}) = reDate binDate (ToInsert Tx {txDate}) = txDate type BCKey = (CurrencyRId, Text) type ABCKey = (AccountRId, BCKey) type EntryBals = M.Map ABCKey Rational -------------------------------------------------------------------------------- -- 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 , utBudget } = do (fval, fs, tpairs) <- rebalanceDebit bc utFromRO utFromUnk let f0val = utTotalValue - fval modify $ mapAdd_ (f0Acnt, bc) f0val let tsLinked = tpairs ++ (unlink f0val <$> f0links) ts <- rebalanceCredit bc utTotalValue utTo0 utToUnk utToRO tsLinked return (f0 {ueValue = StaticValue f0val} : fs ++ ts) where bc = (utCurrency, utBudget) rebalanceFullEntrySet :: FullUpdateEntrySet -> State EntryBals [UEBalanced] rebalanceFullEntrySet UpdateEntrySet { utFrom0 , utTo0 , utFromUnk , utToUnk , utFromRO , utToRO , utCurrency , utBudget } = do (ftot, fs, tpairs) <- rebalanceDebit bc rs ls ts <- rebalanceCredit bc ftot utTo0 utToUnk utToRO tpairs return (fs ++ ts) where (rs, ls) = case utFrom0 of Left x -> (x : utFromRO, utFromUnk) Right x -> (utFromRO, x : utFromUnk) bc = (utCurrency, utBudget) rebalanceDebit :: BCKey -> [UE_RO] -> [(UEUnk, [UELink])] -> State EntryBals (Rational, [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 :: Rational -> UELink -> UEBalanced unlink v e = e {ueValue = StaticValue $ (-v) * unLinkScale (ueValue e)} rebalanceCredit :: BCKey -> Rational -> UEBlank -> [UEUnk] -> [UE_RO] -> [UEBalanced] -> State EntryBals [UEBalanced] rebalanceCredit k tot t0 us rs bs = do (tval, ts) <- fmap (second catMaybes) $ sumM goTo $ L.sortOn idx $ (UETLinked <$> bs) ++ (UETUnk <$> us) ++ (UETReadOnly <$> rs) return (t0 {ueValue = StaticValue (-(tot + tval))} : 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 Rational updateFixed k e = do let v = unStaticValue $ ueValue e modify $ mapAdd_ (ueAcnt e, k) v return v updateUnknown :: BCKey -> UpdateEntry i EntryValueUnk -> State EntryBals Rational updateUnknown k e = do let key = (ueAcnt e, k) curBal <- gets (M.findWithDefault 0 key) let v = case ueValue e of EVPercent p -> p * curBal EVBalance p -> p - curBal modify $ mapAdd_ key v return v -------------------------------------------------------------------------------- -- balancing balancePrimaryEntrySet :: (MonadInsertError m, MonadFinance m) => T.Text -> PrimaryEntrySet -> StateT EntryBals m InsertEntrySet balancePrimaryEntrySet budgetName EntrySet { esFrom = HalfEntrySet {hesPrimary = f0, hesOther = fs} , esTo = HalfEntrySet {hesPrimary = t0, hesOther = ts} , esCurrency = CurrencyPrec {cpID = curID, cpPrec = precision} , esTotalValue } = do let f0res = resolveAcntAndTags f0 let t0res = resolveAcntAndTags t0 let fsres = mapErrors resolveAcntAndTags fs let tsres = mapErrors resolveAcntAndTags ts let bc = (curID, budgetName) combineErrorM (combineError f0res fsres (,)) (combineError t0res tsres (,)) $ \(f0', fs') (t0', ts') -> do let balFrom = fmap liftInnerS . balanceDeferred fs'' <- doEntries balFrom bc esTotalValue f0' fs' balanceFinal bc (-esTotalValue) precision fs'' t0' ts' balanceSecondaryEntrySet :: (MonadInsertError m, MonadFinance m) => T.Text -> SecondayEntrySet -> StateT EntryBals m InsertEntrySet balanceSecondaryEntrySet budgetName EntrySet { esFrom = HalfEntrySet {hesPrimary = f0, hesOther = fs} , esTo = HalfEntrySet {hesPrimary = t0, hesOther = ts} , esCurrency = CurrencyPrec {cpID = curID, cpPrec = precision} } = 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) precision fs'' t0' ts' where entrySum = sum . fmap (eValue . ieEntry) balFrom = balanceEntry (fmap liftInnerS . balanceDeferred) bc bc = (curID, budgetName) balanceFinal :: (MonadInsertError m) => BCKey -> Rational -> Natural -> NonEmpty InsertEntry -> Entry (AccountRId, AcntSign) () TagRId -> [Entry (AccountRId, AcntSign) (LinkDeferred Rational) TagRId] -> StateT EntryBals m InsertEntrySet balanceFinal k@(curID, _) tot precision fs t0 ts = do let fv = V.fromList $ NE.toList $ fmap (eValue . ieEntry) fs let balTo = balanceLinked fv precision ts' <- doEntries balTo k tot t0 ts return $ InsertEntrySet { iesCurrency = curID , iesFromEntries = fs , iesToEntries = ts' } doEntries :: (MonadInsertError m) => (ABCKey -> v -> StateT EntryBals m (Rational, Maybe DBDeferred)) -> BCKey -> Rational -> Entry (AccountRId, AcntSign) () TagRId -> [Entry (AccountRId, AcntSign) v TagRId] -> StateT EntryBals m (NonEmpty InsertEntry) doEntries f k tot e@Entry {eAcnt = (acntID, sign)} es = do es' <- mapErrors (balanceEntry f k) es let e0val = tot - entrySum es' -- TODO not dry let s = fromIntegral $ sign2Int sign -- NOTE hack modify (mapAdd_ (acntID, k) e0val) let e' = InsertEntry { ieEntry = e {eValue = s * e0val, eAcnt = acntID} , ieDeferred = 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 :: MonadInsertError m => Vector Rational -> Natural -> ABCKey -> LinkDeferred Rational -> StateT EntryBals m (Rational, Maybe DBDeferred) balanceLinked from precision k lg = case lg of (LinkIndex LinkedNumGetter {lngIndex, lngScale}) -> do let res = fmap (go lngScale) $ from V.!? fromIntegral lngIndex case res of Just v -> return (v, Just $ EntryLinked lngIndex $ toRational lngScale) -- TODO this error would be much more informative if I had access to the -- file from which it came Nothing -> throwError undefined (LinkDeferred d) -> liftInnerS $ balanceDeferred k d where go s = negate . roundPrecision precision . (* s) . fromRational balanceDeferred :: ABCKey -> EntryValue Rational -> State EntryBals (Rational, Maybe DBDeferred) balanceDeferred k (EntryValue t v) = do newval <- findBalance k t v let d = case t of TFixed -> Nothing TBalance -> Just $ EntryBalance v TPercent -> Just $ EntryPercent v return (newval, d) balanceEntry :: (MonadInsertError m) => (ABCKey -> v -> StateT EntryBals m (Rational, Maybe DBDeferred)) -> BCKey -> Entry (AccountRId, AcntSign) v TagRId -> StateT EntryBals m InsertEntry balanceEntry f k e@Entry {eValue, eAcnt = (acntID, sign)} = do let s = fromIntegral $ sign2Int sign (newVal, deferred) <- f (acntID, k) eValue modify (mapAdd_ (acntID, k) newVal) return $ InsertEntry { ieEntry = e {eValue = s * newVal, eAcnt = acntID} , ieDeferred = deferred } resolveAcntAndTags :: (MonadInsertError m, MonadFinance m) => Entry AcntID v TagID -> m (Entry (AccountRId, AcntSign) v TagRId) resolveAcntAndTags e@Entry {eAcnt, eTags} = do let acntRes = lookupAccount eAcnt let tagRes = mapErrors lookupTag eTags combineError acntRes tagRes $ \(acntID, sign, _) tags -> e {eAcnt = (acntID, sign), eTags = tags} findBalance :: ABCKey -> TransferType -> Rational -> State EntryBals Rational findBalance k t v = do curBal <- gets (M.findWithDefault 0 k) return $ case t of TBalance -> v - curBal TPercent -> v * curBal TFixed -> v -------------------------------------------------------------------------------- -- transfers expandTransfers :: (MonadInsertError m, MonadFinance m) => CommitR -> T.Text -> DaySpan -> [PairedTransfer] -> m [Tx CommitR] expandTransfers tc name bounds = fmap concat . mapErrors (expandTransfer tc name bounds) expandTransfer :: (MonadInsertError m, MonadFinance m) => CommitR -> T.Text -> DaySpan -> PairedTransfer -> m [Tx CommitR] expandTransfer tc name 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 } = withDates bounds pat $ \day -> do p <- entryPair transFrom transTo transCurrency desc () (EntryValue t (toRational (-v))) return Tx { txCommit = tc , txDate = day , txPrimary = Right p , txOther = [] , txDescr = desc , txBudget = name } entryPair :: (MonadInsertError m, MonadFinance m) => TaggedAcnt -> TaggedAcnt -> CurID -> T.Text -> v0 -> v1 -> m (EntrySet v0 v1 v2 v3) entryPair (TaggedAcnt fa fts) (TaggedAcnt ta tts) curid com totval val1 = do cp <- lookupCurrency curid return $ EntrySet { esCurrency = cp , esTotalValue = totval , esFrom = halfEntry fa fts val1 , esTo = halfEntry ta 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, MonadInsertError 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, [])