module Internal.Budget (readBudget) where import Control.Monad.Except import Data.Foldable import Internal.Database import Internal.Types.Main import Internal.Utils import RIO hiding (to) import qualified RIO.List as L import qualified RIO.Map as M import qualified RIO.NonEmpty as NE import qualified RIO.Text as T import RIO.Time -- each budget (designated at the top level by a 'name') is processed in the -- following steps -- 1. expand all transactions given the desired date range and date patterns for -- each directive in the budget -- 2. sort all transactions by date -- 3. propagate all balances forward, and while doing so assign values to each -- transaction (some of which depend on the 'current' balance of the -- target account) -- 4. assign shadow transactions -- 5. insert all transactions readBudget :: (MonadInsertError m, MonadFinance m) => Budget -> m (Either CommitR [Tx TxCommit]) readBudget b@Budget { bgtLabel , bgtIncomes , bgtTransfers , bgtShadowTransfers , bgtPretax , bgtTax , bgtPosttax , bgtInterval } = eitherHash CTBudget b return $ \key -> do (intAllos, _) <- combineError intAlloRes acntRes (,) let res1 = mapErrors (readIncome key bgtLabel intAllos bgtInterval) bgtIncomes let res2 = expandTransfers key bgtLabel bgtInterval bgtTransfers txs <- combineError (concat <$> res1) res2 (++) shadow <- addShadowTransfers bgtShadowTransfers txs return $ txs ++ shadow where acntRes = mapErrors isNotIncomeAcnt alloAcnts intAlloRes = combineError3 pre_ tax_ post_ (,,) pre_ = sortAllos bgtPretax tax_ = sortAllos bgtTax post_ = sortAllos bgtPosttax sortAllos = liftExcept . mapErrors sortAllo alloAcnts = (alloAcnt <$> bgtPretax) ++ (alloAcnt <$> bgtTax) ++ (alloAcnt <$> bgtPosttax) entryPair :: (MonadInsertError m, MonadFinance m) => TaggedAcnt -> TaggedAcnt -> CurID -> T.Text -> Double -> m (EntrySet AcntID CurrencyPrec TagID Rational (EntryValue Rational)) entryPair = entryPair_ (fmap (EntryValue TFixed) . roundPrecisionCur) entryPair_ :: (MonadInsertError m, MonadFinance m) => (CurrencyPrec -> v -> v') -> TaggedAcnt -> TaggedAcnt -> CurID -> T.Text -> v -> m (EntrySet AcntID CurrencyPrec TagID Rational v') entryPair_ f from to curid com val = do cp <- lookupCurrency curid return $ pair cp from to (f cp val) where halfEntry :: a -> [t] -> HalfEntrySet a c t v halfEntry a ts = HalfEntrySet { hesPrimary = Entry {eAcnt = a, eValue = (), eComment = com, eTags = ts} , hesOther = [] } pair cp (TaggedAcnt fa fts) (TaggedAcnt ta tts) v = EntrySet { esCurrency = cp , esTotalValue = v , esFrom = halfEntry fa fts , esTo = halfEntry ta tts } sortAllo :: MultiAllocation v -> InsertExcept (DaySpanAllocation v) sortAllo a@Allocation {alloAmts = as} = do bs <- foldSpan [] $ L.sortOn amtWhen as return $ a {alloAmts = reverse bs} where foldSpan acc [] = return acc foldSpan acc (x : xs) = do let start = amtWhen x res <- case xs of [] -> resolveDaySpan start (y : _) -> resolveDaySpan_ (intStart $ amtWhen y) start foldSpan (x {amtWhen = res} : acc) xs -------------------------------------------------------------------------------- -- Income -- TODO this will scan the interval allocations fully each time -- iteration which is a total waste, but the fix requires turning this -- loop into a fold which I don't feel like doing now :( readIncome :: (MonadInsertError m, MonadFinance m) => CommitR -> T.Text -> IntAllocations -> Maybe Interval -> Income -> m [Tx TxCommit] readIncome key name (intPre, intTax, intPost) localInterval Income { incWhen , incCurrency , incFrom , incPretax , incPosttax , incTaxes , incToBal , incGross , incPayPeriod } = combineErrorM (combineError incRes nonIncRes (,)) (combineError precRes dayRes (,)) $ \_ (precision, days) -> do let gross = roundPrecision precision incGross concat <$> foldDays (allocate precision gross) start days where incRes = isIncomeAcnt $ taAcnt incFrom nonIncRes = mapErrors isNotIncomeAcnt $ taAcnt incToBal : (alloAcnt <$> incPretax) ++ (alloAcnt <$> incTaxes) ++ (alloAcnt <$> incPosttax) precRes = lookupCurrencyPrec incCurrency dayRes = askDays incWhen localInterval start = fromGregorian' $ pStart incPayPeriod pType' = pType incPayPeriod meta = BudgetCommit key name flatPre = concatMap flattenAllo incPretax flatTax = concatMap flattenAllo incTaxes flatPost = concatMap flattenAllo incPosttax sumAllos = sum . fmap faValue -- TODO ensure these are all the "correct" accounts allocate precision gross prevDay day = do scaler <- liftExcept $ periodScaler pType' prevDay day let (preDeductions, pre) = allocatePre precision gross $ flatPre ++ concatMap (selectAllos day) intPre let tax = allocateTax precision gross preDeductions scaler $ flatTax ++ concatMap (selectAllos day) intTax aftertaxGross = gross - sumAllos (tax ++ pre) let post = allocatePost precision aftertaxGross $ flatPost ++ concatMap (selectAllos day) intPost let balance = aftertaxGross - sumAllos post -- TODO double or rational here? primary <- entryPair incFrom incToBal incCurrency "balance after deductions" (fromRational balance) allos <- mapErrors (allo2Trans meta day incFrom) (pre ++ tax ++ post) let bal = Tx { txCommit = meta , txDate = day , txPrimary = primary , txOther = [] , txDescr = "balance after deductions" } if balance < 0 then throwError $ InsertException [IncomeError day name balance] else return (bal : allos) periodScaler :: PeriodType -> Day -> Day -> InsertExcept PeriodScaler periodScaler pt prev cur = return scale where n = fromIntegral $ workingDays wds prev cur wds = case pt of Hourly HourlyPeriod {hpWorkingDays} -> hpWorkingDays Daily ds -> ds scale precision x = case pt of Hourly HourlyPeriod {hpAnnualHours, hpDailyHours} -> fromRational (rnd $ x / fromIntegral hpAnnualHours) * fromIntegral hpDailyHours * n Daily _ -> x * n / 365.25 where rnd = roundPrecision precision -- ASSUME start < end workingDays :: [Weekday] -> Day -> Day -> Natural workingDays wds start end = fromIntegral $ daysFull + daysTail where interval = diffDays end start (nFull, nPart) = divMod interval 7 daysFull = fromIntegral (length wds') * nFull daysTail = fromIntegral $ length $ takeWhile (< nPart) wds' startDay = dayOfWeek start wds' = L.sort $ (\x -> diff (fromWeekday x) startDay) <$> L.nub wds diff a b = fromIntegral $ mod (fromEnum a - fromEnum b) 7 -- ASSUME days is a sorted list foldDays :: MonadInsertError m => (Day -> Day -> m a) -> Day -> [Day] -> m [a] foldDays f start days = case NE.nonEmpty days of Nothing -> return [] Just ds | any (start >) ds -> throwError $ InsertException [PeriodError start $ minimum ds] | otherwise -> combineErrors $ snd $ L.mapAccumL (\prevDay day -> (day, f prevDay day)) start days isIncomeAcnt :: (MonadInsertError m, MonadFinance m) => AcntID -> m () isIncomeAcnt = checkAcntType IncomeT isNotIncomeAcnt :: (MonadInsertError m, MonadFinance m) => AcntID -> m () isNotIncomeAcnt = checkAcntTypes (AssetT :| [EquityT, ExpenseT, LiabilityT]) checkAcntType :: (MonadInsertError m, MonadFinance m) => AcntType -> AcntID -> m () checkAcntType t = checkAcntTypes (t :| []) checkAcntTypes :: (MonadInsertError m, MonadFinance m) => NE.NonEmpty AcntType -> AcntID -> m () checkAcntTypes ts i = void $ go =<< lookupAccountType i where go t | t `L.elem` ts = return i | otherwise = throwError $ InsertException [AccountError i ts] flattenAllo :: SingleAllocation v -> [FlatAllocation v] flattenAllo Allocation {alloAmts, alloCur, alloTo} = fmap go alloAmts where go Amount {amtValue, amtDesc} = FlatAllocation { faCur = alloCur , faTo = alloTo , faValue = amtValue , faDesc = amtDesc } -- ASSUME allocations are sorted selectAllos :: Day -> DaySpanAllocation v -> [FlatAllocation v] selectAllos day Allocation {alloAmts, alloCur, alloTo} = go <$> filter ((`inDaySpan` day) . amtWhen) alloAmts where go Amount {amtValue, amtDesc} = FlatAllocation { faCur = alloCur , faTo = alloTo , faValue = amtValue , faDesc = amtDesc } allo2Trans :: (MonadInsertError m, MonadFinance m) => TxCommit -> Day -> TaggedAcnt -> FlatAllocation Rational -> m (Tx TxCommit) allo2Trans meta day from FlatAllocation {faValue, faTo, faDesc, faCur} = do -- TODO double here? p <- entryPair from faTo faCur faDesc (fromRational faValue) return Tx { txCommit = meta , txDate = day , txPrimary = p , txOther = [] , txDescr = faDesc } allocatePre :: Natural -> Rational -> [FlatAllocation PretaxValue] -> (M.Map T.Text Rational, [FlatAllocation Rational]) allocatePre precision gross = L.mapAccumR go M.empty where go m f@FlatAllocation {faValue} = let c = preCategory faValue p = preValue faValue v = if prePercent faValue then (roundPrecision 3 p / 100) * gross else roundPrecision precision p in (mapAdd_ c v m, f {faValue = v}) allocateTax :: Natural -> Rational -> M.Map T.Text Rational -> PeriodScaler -> [FlatAllocation TaxValue] -> [FlatAllocation Rational] allocateTax precision gross preDeds f = fmap (fmap go) where go TaxValue {tvCategories, tvMethod} = let agi = gross - sum (mapMaybe (`M.lookup` preDeds) tvCategories) in case tvMethod of TMPercent p -> roundPrecision precision $ fromRational $ roundPrecision 3 p / 100 * agi TMBracket TaxProgression {tpDeductible, tpBrackets} -> let taxDed = roundPrecision precision $ f precision tpDeductible in foldBracket f precision (agi - taxDed) tpBrackets -- | Compute effective tax percentage of a bracket -- The algorithm can be thought of in three phases: -- 1. Find the highest tax bracket by looping backward until the AGI is less -- than the bracket limit -- 2. Computing the tax in the top bracket by subtracting the AGI from the -- bracket limit and multiplying by the tax percentage. -- 3. Adding all lower brackets, which are just the limit of the bracket less -- the amount of the lower bracket times the percentage. -- -- In reality, this can all be done with one loop, but it isn't clear these -- three steps are implemented from this alone. foldBracket :: PeriodScaler -> Natural -> Rational -> [TaxBracket] -> Rational foldBracket f precision agi bs = fst $ foldr go (0, agi) $ L.sortOn tbLowerLimit bs where go TaxBracket {tbLowerLimit, tbPercent} a@(acc, remain) = let l = roundPrecision precision $ f precision tbLowerLimit p = roundPrecision 3 tbPercent / 100 in if remain >= l then (acc + p * (remain - l), l) else a allocatePost :: Natural -> Rational -> [FlatAllocation PosttaxValue] -> [FlatAllocation Rational] allocatePost precision aftertax = fmap (fmap go) where go PosttaxValue {postValue, postPercent} = let v = postValue in if postPercent then aftertax * roundPrecision 3 v / 100 else roundPrecision precision v -------------------------------------------------------------------------------- -- Standalone Transfer expandTransfers :: (MonadInsertError m, MonadFinance m) => CommitR -> T.Text -> Maybe Interval -> [BudgetTransfer] -> m [Tx TxCommit] expandTransfers key name localInterval ts = do txs <- fmap (L.sortOn txDate . concat) $ combineErrors $ fmap (expandTransfer key name) ts case localInterval of Nothing -> return txs Just i -> do bounds <- liftExcept $ resolveDaySpan i return $ filter (inDaySpan bounds . txDate) txs expandTransfer :: (MonadInsertError m, MonadFinance m) => CommitR -> T.Text -> BudgetTransfer -> m [Tx TxCommit] expandTransfer key name Transfer {transAmounts, transTo, transCurrency, transFrom} = concat <$> mapErrors go transAmounts where go Amount { amtWhen = pat , amtValue = TransferValue {tvVal = v, tvType = t} , amtDesc = desc } = withDates pat $ \day -> do let meta = BudgetCommit key name p <- entryPair_ (\_ x -> EntryValue t $ toRational x) transFrom transTo transCurrency desc v return Tx { txCommit = meta , txDate = day , txPrimary = p , txOther = [] , txDescr = desc } withDates :: (MonadFinance m, MonadInsertError m) => DatePat -> (Day -> m a) -> m [a] withDates dp f = do bounds <- askDBState kmBudgetInterval days <- liftExcept $ expandDatePat bounds dp combineErrors $ fmap f days -------------------------------------------------------------------------------- -- shadow transfers -- TODO this is going to be O(n*m), which might be a problem? addShadowTransfers :: (MonadInsertError m, MonadFinance m) => [ShadowTransfer] -> [Tx TxCommit] -> m [Tx TxCommit] addShadowTransfers ms = mapErrors go where go tx = do es <- catMaybes <$> mapErrors (fromShadow tx) ms return $ tx {txOther = es} fromShadow :: (MonadInsertError m, MonadFinance m) => Tx TxCommit -> ShadowTransfer -> m (Maybe (EntrySet AcntID CurrencyPrec TagID Rational (Either Double (EntryValue Rational)))) fromShadow tx ShadowTransfer {stFrom, stTo, stDesc, stRatio, stCurrency, stMatch} = do res <- liftExcept $ shadowMatches stMatch tx es <- entryPair_ (\_ v -> Left v) stFrom stTo stCurrency stDesc stRatio return $ if not res then Nothing else Just es shadowMatches :: TransferMatcher -> Tx TxCommit -> InsertExcept Bool shadowMatches TransferMatcher {tmFrom, tmTo, tmDate} Tx {txPrimary, txDate} = do -- NOTE this will only match against the primary entry set since those -- are what are guaranteed to exist from a transfer -- valRes <- valMatches tmVal $ esTotalValue $ txPrimary return $ memberMaybe (eAcnt $ hesPrimary $ esFrom txPrimary) tmFrom && memberMaybe (eAcnt $ hesPrimary $ esTo txPrimary) tmTo && maybe True (`dateMatches` txDate) tmDate where -- && valRes memberMaybe x AcntSet {asList, asInclude} = (if asInclude then id else not) $ x `elem` asList -------------------------------------------------------------------------------- -- random alloAcnt :: Allocation w v -> AcntID alloAcnt = taAcnt . alloTo data UnbalancedValue = UnbalancedValue { cvType :: !TransferType , cvValue :: !Rational } deriving (Show) type IntAllocations = ( [DaySpanAllocation PretaxValue] , [DaySpanAllocation TaxValue] , [DaySpanAllocation PosttaxValue] ) type DaySpanAllocation = Allocation DaySpan type PeriodScaler = Natural -> Double -> Double data FlatAllocation v = FlatAllocation { faValue :: !v , faDesc :: !T.Text , faTo :: !TaggedAcnt , faCur :: !CurID } deriving (Functor, Show)