420 lines
13 KiB
Haskell
420 lines
13 KiB
Haskell
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
|
|
|
|
readBudget
|
|
:: (MonadInsertError m, MonadFinance m)
|
|
=> Budget
|
|
-> m (Either CommitR [Tx CommitR])
|
|
readBudget
|
|
b@Budget
|
|
{ bgtLabel
|
|
, bgtIncomes
|
|
, bgtTransfers
|
|
, bgtShadowTransfers
|
|
, bgtPretax
|
|
, bgtTax
|
|
, bgtPosttax
|
|
, bgtInterval
|
|
} =
|
|
eitherHash CTBudget b return $ \key -> do
|
|
spanRes <- getSpan
|
|
case spanRes of
|
|
Nothing -> return []
|
|
Just budgetSpan -> do
|
|
(intAllos, _) <- combineError intAlloRes acntRes (,)
|
|
let res1 = mapErrors (readIncome key bgtLabel intAllos budgetSpan) bgtIncomes
|
|
let res2 = expandTransfers key bgtLabel budgetSpan 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)
|
|
getSpan = do
|
|
globalSpan <- askDBState kmBudgetInterval
|
|
case bgtInterval of
|
|
Nothing -> return $ Just globalSpan
|
|
Just bi -> do
|
|
localSpan <- liftExcept $ resolveDaySpan bi
|
|
return $ intersectDaySpan globalSpan localSpan
|
|
|
|
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
|
|
-> DaySpan
|
|
-> Income
|
|
-> m [Tx CommitR]
|
|
readIncome
|
|
key
|
|
name
|
|
(intPre, intTax, intPost)
|
|
ds
|
|
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 = liftExcept $ expandDatePat ds incWhen
|
|
start = fromGregorian' $ pStart incPayPeriod
|
|
pType' = pType incPayPeriod
|
|
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)
|
|
()
|
|
-- TODO make this into one large tx?
|
|
allos <- mapErrors (allo2Trans key name day incFrom) (pre ++ tax ++ post)
|
|
let bal =
|
|
Tx
|
|
{ txCommit = key
|
|
, txDate = day
|
|
, txPrimary = Left primary
|
|
, txOther = []
|
|
, txDescr = "balance after deductions"
|
|
, txBudget = name
|
|
}
|
|
-- TODO use real name here
|
|
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)
|
|
=> CommitR
|
|
-> T.Text
|
|
-> Day
|
|
-> TaggedAcnt
|
|
-> FlatAllocation Rational
|
|
-> m (Tx CommitR)
|
|
allo2Trans meta name 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 = Left p
|
|
, txOther = []
|
|
, txDescr = faDesc
|
|
, txBudget = name
|
|
}
|
|
|
|
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
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- shadow transfers
|
|
|
|
-- TODO this is going to be O(n*m), which might be a problem?
|
|
addShadowTransfers
|
|
:: (MonadInsertError m, MonadFinance m)
|
|
=> [ShadowTransfer]
|
|
-> [Tx CommitR]
|
|
-> m [Tx CommitR]
|
|
addShadowTransfers ms = mapErrors go
|
|
where
|
|
go tx = do
|
|
es <- catMaybes <$> mapErrors (fromShadow tx) ms
|
|
return $ tx {txOther = Right <$> es}
|
|
|
|
fromShadow
|
|
:: (MonadInsertError m, MonadFinance m)
|
|
=> Tx CommitR
|
|
-> ShadowTransfer
|
|
-> m (Maybe ShadowEntrySet)
|
|
fromShadow tx ShadowTransfer {stFrom, stTo, stDesc, stRatio, stCurrency, stMatch} = do
|
|
res <- liftExcept $ shadowMatches stMatch tx
|
|
es <- entryPair stFrom stTo stCurrency stDesc stRatio ()
|
|
return $ if not res then Nothing else Just es
|
|
|
|
shadowMatches :: TransferMatcher -> Tx CommitR -> InsertExcept Bool
|
|
shadowMatches TransferMatcher {tmFrom, tmTo, tmDate, tmVal} 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 <- case txPrimary of
|
|
Left es -> valMatches tmVal $ esTotalValue es
|
|
Right _ -> return True
|
|
return $
|
|
memberMaybe fa tmFrom
|
|
&& memberMaybe ta tmTo
|
|
&& maybe True (`dateMatches` txDate) tmDate
|
|
&& valRes
|
|
where
|
|
fa = either getAcntFrom getAcntFrom txPrimary
|
|
ta = either getAcntTo getAcntTo txPrimary
|
|
getAcntFrom = getAcnt esFrom
|
|
getAcntTo = getAcnt esTo
|
|
getAcnt f = eAcnt . hesPrimary . f
|
|
memberMaybe x AcntSet {asList, asInclude} =
|
|
(if asInclude then id else not) $ x `elem` asList
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- random
|
|
|
|
alloAcnt :: Allocation w v -> AcntID
|
|
alloAcnt = taAcnt . alloTo
|
|
|
|
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)
|