lib: clarify transaction balancing & balance assertion checking
This commit is contained in:
		
							parent
							
								
									cf52eb1e42
								
							
						
					
					
						commit
						3b47b58aec
					
				| @ -76,14 +76,13 @@ module Hledger.Data.Journal ( | |||||||
| ) | ) | ||||||
| where | where | ||||||
| import Control.Applicative (Const(..)) | import Control.Applicative (Const(..)) | ||||||
| import Control.Arrow |  | ||||||
| import Control.Monad | import Control.Monad | ||||||
| import Control.Monad.Except | import Control.Monad.Except | ||||||
| import qualified Control.Monad.Reader as R | import Control.Monad.Reader as R | ||||||
| import Control.Monad.ST | import Control.Monad.ST | ||||||
| import Data.Array.ST | import Data.Array.ST | ||||||
| import Data.Functor.Identity (Identity(..)) | import Data.Functor.Identity (Identity(..)) | ||||||
| import qualified Data.HashTable.ST.Cuckoo as HT | import qualified Data.HashTable.ST.Cuckoo as H | ||||||
| import Data.List | import Data.List | ||||||
| import Data.List.Extra (groupSort) | import Data.List.Extra (groupSort) | ||||||
| import Data.Maybe | import Data.Maybe | ||||||
| @ -563,51 +562,223 @@ journalUntieTransactions t@Transaction{tpostings=ps} = t{tpostings=map (\p -> p{ | |||||||
| journalModifyTransactions :: Journal -> Journal | journalModifyTransactions :: Journal -> Journal | ||||||
| journalModifyTransactions j = j{ jtxns = modifyTransactions (jtxnmodifiers j) (jtxns j) } | journalModifyTransactions j = j{ jtxns = modifyTransactions (jtxnmodifiers j) (jtxns j) } | ||||||
| 
 | 
 | ||||||
| -- | Check any balance assertions in the journal and return an error | -- | Check any balance assertions in the journal and return an error message | ||||||
| -- message if any of them fail. | -- if any of them fail (or if the transaction balancing they require fails). | ||||||
| journalCheckBalanceAssertions :: Journal -> Either String Journal | journalCheckBalanceAssertions :: Journal -> Maybe String | ||||||
| journalCheckBalanceAssertions j = | journalCheckBalanceAssertions = either Just (const Nothing) . journalBalanceTransactions True | ||||||
|   runST $ journalBalanceTransactionsST  |  | ||||||
|     True  |  | ||||||
|     j  |  | ||||||
|     (return ()) |  | ||||||
|     (\_ _ -> return ())  |  | ||||||
|     (const $ return j) |  | ||||||
| 
 | 
 | ||||||
| -- | Check a posting's balance assertion and return an error if it | -- | Infer any missing amounts (to satisfy balance assignments and | ||||||
| -- fails. | -- to balance transactions) and check that all transactions balance  | ||||||
|  | -- and (optional) all balance assertions pass. Or return an error message | ||||||
|  | -- (just the first error encountered). | ||||||
|  | -- | ||||||
|  | -- Assumes journalInferCommodityStyles has been called, since those affect transaction balancing. | ||||||
|  | -- | ||||||
|  | -- This does multiple things because amount inferring, balance assignments,  | ||||||
|  | -- balance assertions and posting dates are interdependent. | ||||||
|  | --  | ||||||
|  | -- Overview, 20190216: | ||||||
|  | -- @ | ||||||
|  | -- ****** parseAndFinaliseJournal['] [[Cli/Utils.hs]], journalAddForecast [[Common.hs]], budgetJournal [[BudgetReport.hs]], tests [[BalanceReport.hs]] | ||||||
|  | -- ******* journalBalanceTransactions | ||||||
|  | -- ******** runST | ||||||
|  | -- ********* runExceptT | ||||||
|  | -- ********** runReaderT | ||||||
|  | -- *********** balanceNoAssignmentTransactionB | ||||||
|  | -- ************ balanceTransactionB [[Transaction.hs]] | ||||||
|  | -- ************* balanceTransactionHelper | ||||||
|  | -- ************** inferBalancingAmount | ||||||
|  | -- *********** balanceAssignmentTransactionAndOrCheckAssertionsB | ||||||
|  | -- ************ addAmountAndCheckBalanceAssertionB | ||||||
|  | -- ************* addToBalanceB | ||||||
|  | -- ************ inferFromAssignmentB | ||||||
|  | -- ************ balanceTransactionB [[Transaction.hs]] | ||||||
|  | -- ************* balanceTransactionHelper | ||||||
|  | -- ************ addToBalanceB | ||||||
|  | -- ****** uiCheckBalanceAssertions d ui@UIState{aopts=UIOpts{cliopts_=copts}, ajournal=j} [[ErrorScreen.hs]] | ||||||
|  | -- ******* journalCheckBalanceAssertions | ||||||
|  | -- ******** journalBalanceTransactions | ||||||
|  | -- ****** transactionWizard, postingsBalanced [[Add.hs]], tests [[Transaction.hs]] | ||||||
|  | -- ******* balanceTransaction | ||||||
|  | -- @ | ||||||
|  | journalBalanceTransactions :: Bool -> Journal -> Either String Journal | ||||||
|  | journalBalanceTransactions assrt j' = | ||||||
|  |   let | ||||||
|  |     -- ensure transactions are numbered, so we can store them by number  | ||||||
|  |     j@Journal{jtxns=ts} = journalNumberTransactions j' | ||||||
|  |     styles = journalCommodityStyles j | ||||||
|  |     -- balance assignments will not be allowed on these | ||||||
|  |     txnmodifieraccts = S.fromList $ map paccount $ concatMap tmpostingrules $ jtxnmodifiers j  | ||||||
|  |   in  | ||||||
|  |     runST $ do  | ||||||
|  |       bals <- H.newSized (length $ journalAccountNamesUsed j) | ||||||
|  |       txns <- newListArray (1, genericLength ts) ts | ||||||
|  |       runExceptT $ do | ||||||
|  |         flip runReaderT (BalancingState styles txnmodifieraccts assrt bals txns) $ do | ||||||
|  |           -- Fill in missing posting amounts, check transactions are balanced,  | ||||||
|  |           -- and check balance assertions. This is done in two passes: | ||||||
|  |           -- 1. Balance the transactions which don't have balance assignments, | ||||||
|  |           -- and collect their postings, plus the still-unbalanced transactions, in date order. | ||||||
|  |           sortedpsandts <- sortOn (either postingDate tdate) . concat <$> | ||||||
|  |                            mapM' balanceNoAssignmentTransactionB (jtxns j) | ||||||
|  |           -- 2. Step through these, keeping running account balances,  | ||||||
|  |           -- performing balance assignments in and balancing the remaining transactions, | ||||||
|  |           -- and checking balance assertions. This last could be a separate pass | ||||||
|  |           -- but perhaps it's more efficient to do all at once. | ||||||
|  |           void $ mapM' balanceAssignmentTransactionAndOrCheckAssertionsB sortedpsandts | ||||||
|  |         ts' <- lift $ getElems txns | ||||||
|  |         return j{jtxns=ts'}  | ||||||
|  | 
 | ||||||
|  | -- | If this transaction has no balance assignments, balance and store it | ||||||
|  | -- and return its postings. If it can't be balanced, an error will be thrown. | ||||||
|  | -- | ||||||
|  | -- It it has balance assignments, return it unchanged. If any posting has both  | ||||||
|  | -- a balance assignment and a custom date, an error will be thrown. | ||||||
|  | -- | ||||||
|  | balanceNoAssignmentTransactionB :: Transaction -> Balancing s [Either Posting Transaction] | ||||||
|  | balanceNoAssignmentTransactionB t | ||||||
|  |   | null (assignmentPostings t) = do | ||||||
|  |     styles <- R.reader bsStyles | ||||||
|  |     t' <- lift $ ExceptT $ return $ balanceTransaction (Just styles) t | ||||||
|  |     storeTransactionB t' | ||||||
|  |     return [Left $ removePrices p | p <- tpostings t'] | ||||||
|  | 
 | ||||||
|  |   | otherwise = do | ||||||
|  |     when (any (isJust . pdate) $ tpostings t) $  -- XXX check more carefully that date and assignment are on same posting ? | ||||||
|  |       throwError $ | ||||||
|  |       unlines $ | ||||||
|  |       [ "postings may not have both a custom date and a balance assignment." | ||||||
|  |       , "Write the posting amount explicitly, or remove the posting date:\n" | ||||||
|  |       , showTransaction t | ||||||
|  |       ] | ||||||
|  |     return [Right $ t {tpostings = removePrices <$> tpostings t}] | ||||||
|  | 
 | ||||||
|  | -- | This function is called in turn on each item in a date-ordered sequence  | ||||||
|  | -- of postings (from already-balanced transactions) or transactions   | ||||||
|  | -- (not yet balanced, because containing balance assignments). | ||||||
|  | -- It applies balance assignments and balances the unbalanced transactions,  | ||||||
|  | -- and checks any balance assertion(s). | ||||||
|  | -- | ||||||
|  | -- For a posting: update the account's running balance, and  | ||||||
|  | -- check the balance assertion if any. | ||||||
|  | -- | ||||||
|  | -- For a transaction: for each posting,  | ||||||
|  | --  | ||||||
|  | -- - if it has a missing amount and a balance assignment, infer the amount  | ||||||
|  | -- | ||||||
|  | -- - update the account's running balance | ||||||
|  | -- | ||||||
|  | -- - check the balance assertion if any | ||||||
|  | -- | ||||||
|  | -- Then balance the transaction, so that any remaining missing amount is inferred.  | ||||||
|  | -- And if that happened, also update *that* account's running balance.  XXX and check the assertion ?  | ||||||
|  | -- And store the transaction. | ||||||
|  | -- | ||||||
|  | -- Will throw an error if a transaction can't be balanced, | ||||||
|  | -- or if an illegal balance assignment is found (cf checkIllegalBalanceAssignment).  | ||||||
|  | -- | ||||||
|  | balanceAssignmentTransactionAndOrCheckAssertionsB :: Either Posting Transaction -> Balancing s () | ||||||
|  | balanceAssignmentTransactionAndOrCheckAssertionsB (Left p) = do | ||||||
|  |   checkIllegalBalanceAssignmentB p | ||||||
|  |   void $ addAmountAndCheckBalanceAssertionB return p | ||||||
|  | balanceAssignmentTransactionAndOrCheckAssertionsB (Right t@Transaction{tpostings=ps}) = do | ||||||
|  |   mapM_ checkIllegalBalanceAssignmentB ps | ||||||
|  |   ps' <- forM ps $ addAmountAndCheckBalanceAssertionB inferFromAssignmentB | ||||||
|  |   styles <- R.reader bsStyles | ||||||
|  |   storeTransactionB =<<  | ||||||
|  |     balanceTransactionB (fmap void . addToBalanceB) (Just styles) t{tpostings=ps'} | ||||||
|  | 
 | ||||||
|  | -- | Throw an error if this posting is trying to do a balance assignment and | ||||||
|  | -- the account does not allow balance assignments (because it is referenced | ||||||
|  | -- by a transaction modifier). | ||||||
|  | checkIllegalBalanceAssignmentB :: Posting -> Balancing s () | ||||||
|  | checkIllegalBalanceAssignmentB p = do | ||||||
|  |   unassignable <- R.asks bsUnassignable | ||||||
|  |   when (isAssignment p && paccount p `S.member` unassignable) $ | ||||||
|  |     throwError $ | ||||||
|  |     unlines $ | ||||||
|  |     [ "cannot assign amount to account " | ||||||
|  |     , "" | ||||||
|  |     , "    " ++ T.unpack (paccount p) | ||||||
|  |     , "" | ||||||
|  |     , "because it is also included in transaction modifiers." | ||||||
|  |     ] | ||||||
|  | 
 | ||||||
|  | -- | If this posting has a missing amount and a balance assignment, use | ||||||
|  | -- the running account balance to infer the amount required to satisfy | ||||||
|  | -- the assignment. | ||||||
|  | inferFromAssignmentB :: Posting -> Balancing s Posting | ||||||
|  | inferFromAssignmentB p@Posting{paccount=acc} = | ||||||
|  |   case pbalanceassertion p of | ||||||
|  |     Nothing -> return p | ||||||
|  |     Just ba | batotal ba -> do | ||||||
|  |       diff <- setAccountRunningBalance acc $ Mixed [baamount ba] | ||||||
|  |       return $ setPostingAmount diff p | ||||||
|  |     Just ba -> do | ||||||
|  |       oldbal <- fromMaybe 0 <$> liftB (\bs -> H.lookup (bsBalances bs) acc) | ||||||
|  |       let amt    = baamount ba | ||||||
|  |           newbal = filterMixedAmount ((/=acommodity amt).acommodity) oldbal + Mixed [amt] | ||||||
|  |       diff <- setAccountRunningBalance acc newbal | ||||||
|  |       return $ setPostingAmount diff p | ||||||
|  |   where | ||||||
|  |     setPostingAmount a p = p{pamount=a, porigin=Just $ originalPosting p} | ||||||
|  |     -- | Set the account's running balance, and return the difference from the old.  | ||||||
|  |     setAccountRunningBalance :: AccountName -> MixedAmount -> Balancing s MixedAmount | ||||||
|  |     setAccountRunningBalance acc amt = liftB $ \BalancingState{bsBalances=bals} -> do | ||||||
|  |       old <- fromMaybe 0 <$> H.lookup bals acc | ||||||
|  |       H.insert bals acc amt | ||||||
|  |       return $ amt - old | ||||||
|  | 
 | ||||||
|  | -- | Adds a posting's amount to the posting's account's running balance, and | ||||||
|  | -- checks the posting's balance assertion if any. Or if the posting has no | ||||||
|  | -- amount, runs the supplied fallback action. | ||||||
|  | addAmountAndCheckBalanceAssertionB ::  | ||||||
|  |      (Posting -> Balancing s Posting) -- ^ fallback action | ||||||
|  |   -> Posting | ||||||
|  |   -> Balancing s Posting | ||||||
|  | addAmountAndCheckBalanceAssertionB _ p | hasAmount p = do | ||||||
|  |   newAmt <- addToBalanceB (paccount p) (pamount p) | ||||||
|  |   assrt <- R.reader bsAssrt | ||||||
|  |   lift $ when assrt $ ExceptT $ return $ checkBalanceAssertion p newAmt | ||||||
|  |   return p | ||||||
|  | addAmountAndCheckBalanceAssertionB fallback p = fallback p | ||||||
|  | 
 | ||||||
|  | -- | Check a posting's balance assertion against the given actual balance, and | ||||||
|  | -- return an error if the assertion is not satisfied. | ||||||
|  | -- If the assertion is partial, unasserted commodities in the actual balance | ||||||
|  | -- are ignored; if it is total, they will cause the assertion to fail. | ||||||
| checkBalanceAssertion :: Posting -> MixedAmount -> Either String () | checkBalanceAssertion :: Posting -> MixedAmount -> Either String () | ||||||
| checkBalanceAssertion p@Posting{pbalanceassertion=Just (BalanceAssertion{baamount,baexact})} actualbal = | checkBalanceAssertion p@Posting{pbalanceassertion=Just (BalanceAssertion{baamount,batotal})} actualbal = | ||||||
|   foldl' f (Right ()) assertedamts |   foldl' f (Right ()) assertedamts | ||||||
|     where |     where | ||||||
|       f (Right _) assertedamt = checkBalanceAssertionCommodity p assertedamt actualbal |       f (Right _) assertedamt = checkBalanceAssertionOneCommodity p assertedamt actualbal | ||||||
|       f err _                 = err |       f err _                 = err | ||||||
|       assertedamts = baamount : otheramts |       assertedamts = baamount : otheramts | ||||||
|         where |         where | ||||||
|           assertedcomm = acommodity baamount |           assertedcomm = acommodity baamount | ||||||
|           otheramts | baexact   = map (\a -> a{ aquantity = 0 }) $ amounts $ filterMixedAmount (\a -> acommodity a /= assertedcomm) actualbal |           otheramts | batotal   = map (\a -> a{ aquantity = 0 }) $ amounts $ filterMixedAmount (\a -> acommodity a /= assertedcomm) actualbal | ||||||
|                     | otherwise = [] |                     | otherwise = [] | ||||||
| checkBalanceAssertion _ _ = Right () | checkBalanceAssertion _ _ = Right () | ||||||
| 
 | 
 | ||||||
| -- | Are the asserted balance and the actual balance | -- | Does this (single commodity) expected balance match the amount of that | ||||||
| -- exactly equal (disregarding display precision) ? | -- commodity in the given (multicommodity) actual balance ? If not, returns a | ||||||
| -- The posting is used for creating an error message. | -- balance assertion failure message based on the provided posting.  To match, | ||||||
| checkBalanceAssertionCommodity :: Posting -> Amount -> MixedAmount -> Either String () | -- the amounts must be exactly equal (display precision is ignored here). | ||||||
| checkBalanceAssertionCommodity p assertedamt actualbal | checkBalanceAssertionOneCommodity :: Posting -> Amount -> MixedAmount -> Either String () | ||||||
|  | checkBalanceAssertionOneCommodity p assertedamt actualbal | ||||||
|   | pass      = Right () |   | pass      = Right () | ||||||
|   | otherwise = Left err |   | otherwise = Left errmsg | ||||||
|     where |     where | ||||||
|       assertedcomm = acommodity assertedamt |       assertedcomm = acommodity assertedamt | ||||||
|       actualbalincommodity = fromMaybe nullamt $ find ((== assertedcomm) . acommodity) (amounts actualbal) |       actualbalincommodity = fromMaybe nullamt $ find ((== assertedcomm) . acommodity) (amounts actualbal) | ||||||
|       pass = |       pass = | ||||||
|         aquantity |         aquantity | ||||||
|         -- traceWith (("asserted:"++).showAmountDebug) |           -- traceWith (("asserted:"++).showAmountDebug) | ||||||
|         assertedamt == |           assertedamt == | ||||||
|         aquantity |         aquantity | ||||||
|         -- traceWith (("actual:"++).showAmountDebug) |           -- traceWith (("actual:"++).showAmountDebug) | ||||||
|         actualbalincommodity |           actualbalincommodity | ||||||
|       diff = aquantity assertedamt - aquantity actualbalincommodity |       errmsg = printf (unlines | ||||||
|       err = printf (unlines |  | ||||||
|                     [ "balance assertion: %s", |                     [ "balance assertion: %s", | ||||||
|                       "\nassertion details:", |                       "\nassertion details:", | ||||||
|                       "date:       %s", |                       "date:       %s", | ||||||
| @ -635,208 +806,7 @@ checkBalanceAssertionCommodity p assertedamt actualbal | |||||||
|         -- (showAmount actualbalincommodity) |         -- (showAmount actualbalincommodity) | ||||||
|         (show $ aquantity assertedamt) |         (show $ aquantity assertedamt) | ||||||
|         -- (showAmount assertedamt) |         -- (showAmount assertedamt) | ||||||
|         (show diff) |         (show $ aquantity assertedamt - aquantity actualbalincommodity) | ||||||
| 
 |  | ||||||
| -- | Fill in any missing amounts and check that all journal transactions |  | ||||||
| -- balance and all balance assertions pass, or return an error message. |  | ||||||
| -- This is done after parsing all amounts and applying canonical |  | ||||||
| -- commodity styles, since balancing depends on display precision. |  | ||||||
| -- Reports only the first error encountered. |  | ||||||
| journalBalanceTransactions :: Bool -> Journal -> Either String Journal |  | ||||||
| journalBalanceTransactions assrt j = |  | ||||||
|   runST $ journalBalanceTransactionsST  |  | ||||||
|     assrt -- check balance assertions also ? |  | ||||||
|     (journalNumberTransactions j) -- journal to process |  | ||||||
|     (newArray_ (1, genericLength $ jtxns j) :: forall s. ST s (STArray s Integer Transaction)) -- initialise state |  | ||||||
|     (\arr tx -> writeArray arr (tindex tx) tx)    -- update state |  | ||||||
|     (fmap (\txns -> j{ jtxns = txns}) . getElems) -- summarise state |  | ||||||
| 
 |  | ||||||
| -- | Helper used by 'journalBalanceTransactions' and 'journalCheckBalanceAssertions'. |  | ||||||
| -- Balances transactions, applies balance assignments, and checks balance assertions |  | ||||||
| -- at the same time. |  | ||||||
| journalBalanceTransactionsST :: |  | ||||||
|   Bool |  | ||||||
|   -> Journal |  | ||||||
|   -> ST s txns                        -- ^ initialise state |  | ||||||
|   -> (txns -> Transaction -> ST s ()) -- ^ update state |  | ||||||
|   -> (txns -> ST s a)                 -- ^ summarise state |  | ||||||
|   -> ST s (Either String a) |  | ||||||
| journalBalanceTransactionsST assrt j createStore storeIn extract = |  | ||||||
|   runExceptT $ do |  | ||||||
|     bals <- lift $ HT.newSized size |  | ||||||
|     txStore <- lift $ createStore |  | ||||||
|     let env = Env bals  |  | ||||||
|                   (storeIn txStore)  |  | ||||||
|                   assrt |  | ||||||
|                   (Just $ journalCommodityStyles j) |  | ||||||
|                   (getModifierAccountNames j) |  | ||||||
|     flip R.runReaderT env $ do |  | ||||||
|       dated <- fmap snd . sortOn fst . concat |  | ||||||
|                 <$> mapM' discriminateByDate (jtxns j) |  | ||||||
|       mapM' checkInferAndRegisterAmounts dated |  | ||||||
|     lift $ extract txStore |  | ||||||
|     where  |  | ||||||
|       size = genericLength $ journalPostings j |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| -- | Collect account names in account modifiers into a set |  | ||||||
| getModifierAccountNames :: Journal -> S.Set AccountName |  | ||||||
| getModifierAccountNames j = S.fromList $ |  | ||||||
|                             map paccount $ |  | ||||||
|                             concatMap tmpostingrules $ |  | ||||||
|                             jtxnmodifiers j |  | ||||||
| 
 |  | ||||||
| -- | Monad transformer stack with a reference to a mutable hashtable |  | ||||||
| -- of current account balances and a mutable array of finished |  | ||||||
| -- transactions in original parsing order. |  | ||||||
| type CurrentBalancesModifier s = R.ReaderT (Env s) (ExceptT String (ST s)) |  | ||||||
| 
 |  | ||||||
| -- | Environment for 'CurrentBalancesModifier' |  | ||||||
| data Env s = Env { eBalances     :: HT.HashTable s AccountName MixedAmount |  | ||||||
|                  , eStoreTx      :: Transaction -> ST s () |  | ||||||
|                  , eAssrt        :: Bool |  | ||||||
|                  , eStyles       :: Maybe (M.Map CommoditySymbol AmountStyle) |  | ||||||
|                  , eUnassignable :: S.Set AccountName |  | ||||||
|                  } |  | ||||||
| 
 |  | ||||||
| -- | This converts a transaction into a list of transactions or |  | ||||||
| -- postings whose dates have to be considered when checking  |  | ||||||
| -- balance assertions and handled by 'checkInferAndRegisterAmounts'. |  | ||||||
| -- |  | ||||||
| -- Transaction without balance assignments can be balanced and stored |  | ||||||
| -- immediately and their (possibly) dated postings are returned. |  | ||||||
| -- |  | ||||||
| -- Transaction with balance assignments are only supported if no |  | ||||||
| -- posting has a 'pdate' value. Supported transactions will be |  | ||||||
| -- returned unchanged and balanced and stored later in 'checkInferAndRegisterAmounts'. |  | ||||||
| discriminateByDate :: Transaction |  | ||||||
|   -> CurrentBalancesModifier s [(Day, Either Posting Transaction)] |  | ||||||
| discriminateByDate tx |  | ||||||
|   | null (assignmentPostings tx) = do |  | ||||||
|     styles <- R.reader $ eStyles |  | ||||||
|     balanced <- lift $ ExceptT $ return $ balanceTransaction styles tx |  | ||||||
|     storeTransaction balanced |  | ||||||
|     return $ fmap (postingDate &&& (Left . removePrices)) $ tpostings $ balanced |  | ||||||
|   | otherwise = do |  | ||||||
|     when (any (isJust . pdate) $ tpostings tx) $ |  | ||||||
|       throwError $ |  | ||||||
|       unlines $ |  | ||||||
|       [ "postings may not have both a custom date and a balance assignment." |  | ||||||
|       , "Write the posting amount explicitly, or remove the posting date:\n" |  | ||||||
|       , showTransaction tx |  | ||||||
|       ] |  | ||||||
|     return [(tdate tx, Right $ tx {tpostings = removePrices <$> tpostings tx})] |  | ||||||
| 
 |  | ||||||
| -- | Throw an error if a posting is in the unassignable set. |  | ||||||
| checkUnassignablePosting :: Posting -> CurrentBalancesModifier s () |  | ||||||
| checkUnassignablePosting p = do |  | ||||||
|   unassignable <- R.asks eUnassignable |  | ||||||
|   when (isAssignment p && paccount p `S.member` unassignable) $ |  | ||||||
|     throwError $ |  | ||||||
|     unlines $ |  | ||||||
|     [ "cannot assign amount to account " |  | ||||||
|     , "" |  | ||||||
|     , "    " ++ T.unpack (paccount p) |  | ||||||
|     , "" |  | ||||||
|     , "because it is also included in transaction modifiers." |  | ||||||
|     ] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| -- | This function takes an object describing changes to |  | ||||||
| -- account balances on a single day - either a single posting  |  | ||||||
| -- (from an already balanced transaction without assignments) |  | ||||||
| -- or a whole transaction with assignments (which is required to  |  | ||||||
| -- have no posting with pdate set). |  | ||||||
| -- |  | ||||||
| -- For a single posting, there is not much to do. Only add its amount |  | ||||||
| -- to its account and check the assertion, if there is one. This |  | ||||||
| -- functionality is provided by 'addAmountAndCheckBalance'. |  | ||||||
| -- |  | ||||||
| -- For a whole transaction, it loops over all postings, and performs |  | ||||||
| -- 'addAmountAndCheckBalance', if there is an amount. If there is no |  | ||||||
| -- amount, the amount is inferred by the assertion or left empty if |  | ||||||
| -- there is no assertion. Then, the transaction is balanced, the |  | ||||||
| -- inferred amount added to the balance (all in 'balanceTransactionUpdate')  |  | ||||||
| -- and the resulting transaction with no missing amounts is stored  |  | ||||||
| -- in the array, for later retrieval. |  | ||||||
| -- |  | ||||||
| -- Again in short: |  | ||||||
| -- |  | ||||||
| -- 'Left Posting': Check the balance assertion and update the |  | ||||||
| --  account balance. If the amount is empty do nothing.  this can be |  | ||||||
| --  the case e.g. for virtual postings |  | ||||||
| -- |  | ||||||
| -- 'Right Transaction': Loop over all postings, infer their amounts |  | ||||||
| -- and then balance and store the transaction. |  | ||||||
| checkInferAndRegisterAmounts :: Either Posting Transaction |  | ||||||
|                              -> CurrentBalancesModifier s () |  | ||||||
| checkInferAndRegisterAmounts (Left p) = do |  | ||||||
|   checkUnassignablePosting p |  | ||||||
|   void $ addAmountAndCheckBalance return p |  | ||||||
| checkInferAndRegisterAmounts (Right oldTx) = do |  | ||||||
|   let ps = tpostings oldTx |  | ||||||
|   mapM_ checkUnassignablePosting ps |  | ||||||
|   styles <- R.reader $ eStyles |  | ||||||
|   newPostings <- forM ps $ addAmountAndCheckBalance inferFromAssignment |  | ||||||
|   storeTransaction =<< balanceTransactionUpdate |  | ||||||
|     (fmap void . addToBalance) styles oldTx { tpostings = newPostings } |  | ||||||
|   where |  | ||||||
|     inferFromAssignment :: Posting -> CurrentBalancesModifier s Posting |  | ||||||
|     inferFromAssignment p = do |  | ||||||
|       let acc = paccount p |  | ||||||
|       case pbalanceassertion p of |  | ||||||
|         Just ba | baexact ba -> do |  | ||||||
|           diff <- setMixedBalance acc $ Mixed [baamount ba] |  | ||||||
|           fullPosting diff p |  | ||||||
|         Just ba -> do |  | ||||||
|           old <- liftModifier $ \Env{ eBalances = bals } -> HT.lookup bals acc |  | ||||||
|           let amt = baamount ba |  | ||||||
|               assertedcomm = acommodity amt |  | ||||||
|           diff <- setMixedBalance acc $ |  | ||||||
|             Mixed [amt] + filterMixedAmount (\a -> acommodity a /= assertedcomm) (fromMaybe nullmixedamt old) |  | ||||||
|           fullPosting diff p |  | ||||||
|         Nothing -> return p |  | ||||||
|     fullPosting amt p = return p |  | ||||||
|       { pamount = amt |  | ||||||
|       , porigin = Just $ originalPosting p |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
| -- | Adds a posting's amount to the posting's account balance and |  | ||||||
| -- checks a possible balance assertion. Or if there is no amount, |  | ||||||
| -- runs the supplied fallback action. |  | ||||||
| addAmountAndCheckBalance ::  |  | ||||||
|      (Posting -> CurrentBalancesModifier s Posting) -- ^ action if posting has no amount |  | ||||||
|   -> Posting |  | ||||||
|   -> CurrentBalancesModifier s Posting |  | ||||||
| addAmountAndCheckBalance _ p | hasAmount p = do |  | ||||||
|   newAmt <- addToBalance (paccount p) $ pamount p |  | ||||||
|   assrt <- R.reader eAssrt |  | ||||||
|   lift $ when assrt $ ExceptT $ return $ checkBalanceAssertion p newAmt |  | ||||||
|   return p |  | ||||||
| addAmountAndCheckBalance fallback p = fallback p |  | ||||||
| 
 |  | ||||||
| -- | Sets all commodities comprising an account's balance to the given |  | ||||||
| -- amounts and returns the difference from the previous balance. |  | ||||||
| setMixedBalance :: AccountName -> MixedAmount -> CurrentBalancesModifier s MixedAmount |  | ||||||
| setMixedBalance acc amt = liftModifier $ \Env{ eBalances = bals } -> do |  | ||||||
|   old <- HT.lookup bals acc |  | ||||||
|   HT.insert bals acc amt |  | ||||||
|   return $ maybe amt (amt -) old |  | ||||||
| 
 |  | ||||||
| -- | Adds an amount to an account's balance and returns the resulting balance. |  | ||||||
| addToBalance :: AccountName -> MixedAmount -> CurrentBalancesModifier s MixedAmount |  | ||||||
| addToBalance acc amt = liftModifier $ \Env{ eBalances = bals } -> do |  | ||||||
|   new <- maybe amt (+ amt) <$> HT.lookup bals acc |  | ||||||
|   HT.insert bals acc new |  | ||||||
|   return new |  | ||||||
| 
 |  | ||||||
| -- | Stores a transaction in the transaction array in original parsing order. |  | ||||||
| storeTransaction :: Transaction -> CurrentBalancesModifier s () |  | ||||||
| storeTransaction tx = liftModifier $ ($tx) . eStoreTx |  | ||||||
| 
 |  | ||||||
| -- | Helper function. |  | ||||||
| liftModifier :: (Env s -> ST s a) -> CurrentBalancesModifier s a |  | ||||||
| liftModifier f = R.ask >>= lift . lift . f |  | ||||||
| 
 | 
 | ||||||
| -- | Choose and apply a consistent display format to the posting | -- | Choose and apply a consistent display format to the posting | ||||||
| -- amounts in each commodity. Each commodity's format is specified by | -- amounts in each commodity. Each commodity's format is specified by | ||||||
|  | |||||||
| @ -104,7 +104,7 @@ nullsourcepos = JournalSourcePos "" (1,1) | |||||||
| nullassertion, assertion :: BalanceAssertion | nullassertion, assertion :: BalanceAssertion | ||||||
| nullassertion = BalanceAssertion | nullassertion = BalanceAssertion | ||||||
|                   {baamount=nullamt |                   {baamount=nullamt | ||||||
|                   ,baexact=False |                   ,batotal=False | ||||||
|                   ,baposition=nullsourcepos |                   ,baposition=nullsourcepos | ||||||
|                   } |                   } | ||||||
| assertion = nullassertion | assertion = nullassertion | ||||||
|  | |||||||
| @ -1,4 +1,3 @@ | |||||||
| {-# LANGUAGE FlexibleContexts #-} |  | ||||||
| {-| | {-| | ||||||
| 
 | 
 | ||||||
| A 'Transaction' represents a movement of some commodity(ies) between two | A 'Transaction' represents a movement of some commodity(ies) between two | ||||||
| @ -8,7 +7,11 @@ tags. | |||||||
| 
 | 
 | ||||||
| -} | -} | ||||||
| 
 | 
 | ||||||
| {-# LANGUAGE OverloadedStrings, LambdaCase #-} | {-# LANGUAGE FlexibleContexts #-} | ||||||
|  | {-# LANGUAGE LambdaCase #-} | ||||||
|  | {-# LANGUAGE OverloadedStrings #-} | ||||||
|  | {-# LANGUAGE Rank2Types #-} | ||||||
|  | {-# LANGUAGE RecordWildCards #-} | ||||||
| 
 | 
 | ||||||
| module Hledger.Data.Transaction ( | module Hledger.Data.Transaction ( | ||||||
|   -- * Transaction |   -- * Transaction | ||||||
| @ -24,13 +27,18 @@ module Hledger.Data.Transaction ( | |||||||
|   balancedVirtualPostings, |   balancedVirtualPostings, | ||||||
|   transactionsPostings, |   transactionsPostings, | ||||||
|   isTransactionBalanced, |   isTransactionBalanced, | ||||||
|  |   balanceTransaction, | ||||||
|  |   Balancing, | ||||||
|  |   BalancingState(..), | ||||||
|  |   addToBalanceB, | ||||||
|  |   storeTransactionB, | ||||||
|  |   liftB, | ||||||
|  |   balanceTransactionB, | ||||||
|   -- nonzerobalanceerror, |   -- nonzerobalanceerror, | ||||||
|   -- * date operations |   -- * date operations | ||||||
|   transactionDate2, |   transactionDate2, | ||||||
|   -- * arithmetic |   -- * arithmetic | ||||||
|   transactionPostingBalances, |   transactionPostingBalances, | ||||||
|   balanceTransaction, |  | ||||||
|   balanceTransactionUpdate, |  | ||||||
|   -- * rendering |   -- * rendering | ||||||
|   showTransaction, |   showTransaction, | ||||||
|   showTransactionUnelided, |   showTransactionUnelided, | ||||||
| @ -47,7 +55,12 @@ module Hledger.Data.Transaction ( | |||||||
| where | where | ||||||
| import Data.List | import Data.List | ||||||
| import Control.Monad.Except | import Control.Monad.Except | ||||||
| import Control.Monad.Identity | import Control.Monad.Reader (ReaderT, ask) | ||||||
|  | import Control.Monad.ST | ||||||
|  | import Data.Array.ST | ||||||
|  | import qualified Data.HashTable.ST.Cuckoo as HT | ||||||
|  | import qualified Data.Map as M | ||||||
|  | import qualified Data.Set as S | ||||||
| import Data.Maybe | import Data.Maybe | ||||||
| import Data.Text (Text) | import Data.Text (Text) | ||||||
| import qualified Data.Text as T | import qualified Data.Text as T | ||||||
| @ -324,34 +337,78 @@ isTransactionBalanced styles t = | |||||||
|       bvsum' = canonicalise $ costOfMixedAmount bvsum |       bvsum' = canonicalise $ costOfMixedAmount bvsum | ||||||
|       canonicalise = maybe id canonicaliseMixedAmount styles |       canonicalise = maybe id canonicaliseMixedAmount styles | ||||||
| 
 | 
 | ||||||
| -- | Ensure this transaction is balanced, possibly inferring a missing | -- | Monad used for statefully "balancing" a sequence of transactions. | ||||||
| -- amount or conversion price(s), or return an error message. | type Balancing s = ReaderT (BalancingState s) (ExceptT String (ST s)) | ||||||
| -- Balancing is affected by commodity display precisions, so those can | 
 | ||||||
| -- (optionally) be provided. | -- | The state used while balancing a sequence of transactions. | ||||||
| --  | data BalancingState s = BalancingState { | ||||||
| -- this fails for example, if there are several missing amounts |    -- read only | ||||||
| -- (possibly with balance assignments) |    bsStyles       :: M.Map CommoditySymbol AmountStyle       -- ^ commodity display styles | ||||||
| balanceTransaction :: Maybe (Map.Map CommoditySymbol AmountStyle) |   ,bsUnassignable :: S.Set AccountName                       -- ^ accounts in which balance assignments may not be used | ||||||
|                    -> Transaction -> Either String Transaction |   ,bsAssrt        :: Bool                                    -- ^ whether to check balance assertions | ||||||
| balanceTransaction stylemap = runIdentity . runExceptT |    -- mutable | ||||||
|   . balanceTransactionUpdate (\_ _ -> return ()) stylemap |   ,bsBalances     :: HT.HashTable s AccountName MixedAmount  -- ^ running account balances, initially empty | ||||||
|  |   ,bsTransactions :: STArray s Integer Transaction           -- ^ the transactions being balanced | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | -- | Lift a BalancingState mutator through the Except and Reader  | ||||||
|  | -- layers into the Balancing monad. | ||||||
|  | liftB :: (BalancingState s -> ST s a) -> Balancing s a | ||||||
|  | liftB f = ask >>= lift . lift . f | ||||||
|  | 
 | ||||||
|  | -- | Add this amount to this account's running balance,  | ||||||
|  | -- and return the new running balance. | ||||||
|  | addToBalanceB :: AccountName -> MixedAmount -> Balancing s MixedAmount | ||||||
|  | addToBalanceB acc amt = liftB $ \BalancingState{bsBalances=bals} -> do | ||||||
|  |   b <- maybe amt (+amt) <$> HT.lookup bals acc | ||||||
|  |   HT.insert bals acc b | ||||||
|  |   return b | ||||||
|  | 
 | ||||||
|  | -- | Update (overwrite) this transaction with a new one. | ||||||
|  | storeTransactionB :: Transaction -> Balancing s () | ||||||
|  | storeTransactionB t = liftB $ \bs -> | ||||||
|  |   void $ writeArray (bsTransactions bs) (tindex t) t | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| -- | More general version of 'balanceTransaction' that takes an update | -- | Balance this transaction, ensuring that its postings sum to 0, | ||||||
| -- function | -- by inferring a missing amount or conversion price(s) if needed.  | ||||||
| balanceTransactionUpdate :: MonadError String m | -- Or if balancing is not possible, because of unbalanced amounts or  | ||||||
|   => (AccountName -> MixedAmount -> m ()) | -- more than one missing amount, returns an error message. | ||||||
|      -- ^ update function | -- Whether postings "sum to 0" depends on commodity display precisions, | ||||||
|   -> Maybe (Map.Map CommoditySymbol AmountStyle) | -- so those can optionally be provided. | ||||||
|   -> Transaction -> m Transaction | balanceTransaction :: | ||||||
| balanceTransactionUpdate update mstyles t = |      Maybe (Map.Map CommoditySymbol AmountStyle)  -- ^ commodity display styles | ||||||
|   (finalize =<< inferBalancingAmount update (fromMaybe Map.empty mstyles) t) |   -> Transaction | ||||||
|     `catchError` (throwError . annotateErrorWithTxn t) |   -> Either String Transaction | ||||||
|  | balanceTransaction mstyles = fmap fst . balanceTransactionHelper mstyles  | ||||||
|  | 
 | ||||||
|  | -- | Like balanceTransaction, but when inferring amounts it will also | ||||||
|  | -- use the given state update function to update running account balances.  | ||||||
|  | -- Used when balancing a sequence of transactions (see journalBalanceTransactions). | ||||||
|  | balanceTransactionB :: | ||||||
|  |      (AccountName -> MixedAmount -> Balancing s ())  -- ^ function to update running balances | ||||||
|  |   -> Maybe (Map.Map CommoditySymbol AmountStyle)     -- ^ commodity display styles | ||||||
|  |   -> Transaction | ||||||
|  |   -> Balancing s Transaction | ||||||
|  | balanceTransactionB updatebalsfn mstyles t = do | ||||||
|  |   case balanceTransactionHelper mstyles t of | ||||||
|  |     Left err -> throwError err  | ||||||
|  |     Right (t', inferredacctsandamts) -> do | ||||||
|  |       mapM_ (uncurry updatebalsfn) inferredacctsandamts | ||||||
|  |       return t' | ||||||
|  | 
 | ||||||
|  | balanceTransactionHelper :: | ||||||
|  |      Maybe (Map.Map CommoditySymbol AmountStyle)  -- ^ commodity display styles | ||||||
|  |   -> Transaction | ||||||
|  |   -> Either String (Transaction, [(AccountName, MixedAmount)]) | ||||||
|  | balanceTransactionHelper mstyles t = do | ||||||
|  |   (t', inferredamtsandaccts) <-  | ||||||
|  |     inferBalancingAmount (fromMaybe Map.empty mstyles) $ inferBalancingPrices t  | ||||||
|  |   if isTransactionBalanced mstyles t' | ||||||
|  |   then Right (txnTieKnot t', inferredamtsandaccts) | ||||||
|  |   else Left $ annotateErrorWithTxn t' $ nonzerobalanceerror t' | ||||||
|  | 
 | ||||||
|   where |   where | ||||||
|     finalize t' = let t'' = inferBalancingPrices t' |  | ||||||
|                   in if isTransactionBalanced mstyles t'' |  | ||||||
|                      then return $ txnTieKnot t'' |  | ||||||
|                      else throwError $ nonzerobalanceerror t'' |  | ||||||
|     nonzerobalanceerror :: Transaction -> String |     nonzerobalanceerror :: Transaction -> String | ||||||
|     nonzerobalanceerror t = printf "could not balance this transaction (%s%s%s)" rmsg sep bvmsg |     nonzerobalanceerror t = printf "could not balance this transaction (%s%s%s)" rmsg sep bvmsg | ||||||
|         where |         where | ||||||
| @ -364,45 +421,52 @@ balanceTransactionUpdate update mstyles t = | |||||||
|                   ++ showMixedAmount (costOfMixedAmount bvsum) |                   ++ showMixedAmount (costOfMixedAmount bvsum) | ||||||
|           sep = if not (null rmsg) && not (null bvmsg) then "; " else "" :: String |           sep = if not (null rmsg) && not (null bvmsg) then "; " else "" :: String | ||||||
| 
 | 
 | ||||||
|     annotateErrorWithTxn t e = intercalate "\n" [showGenericSourcePos $ tsourcepos t, e, showTransactionUnelided t] | annotateErrorWithTxn :: Transaction -> String -> String | ||||||
|  | annotateErrorWithTxn t s = intercalate "\n" [showGenericSourcePos $ tsourcepos t, s, showTransactionUnelided t]     | ||||||
| 
 | 
 | ||||||
| -- | Infer up to one missing amount for this transactions's real postings, and | -- | Infer up to one missing amount for this transactions's real postings, and | ||||||
| -- likewise for its balanced virtual postings, if needed; or return an error | -- likewise for its balanced virtual postings, if needed; or return an error | ||||||
| -- message if we can't. | -- message if we can't. Returns the updated transaction and any inferred posting amounts, | ||||||
|  | -- with the corresponding accounts, in order). | ||||||
| -- | -- | ||||||
| -- We can infer a missing amount when there are multiple postings and exactly | -- We can infer a missing amount when there are multiple postings and exactly | ||||||
| -- one of them is amountless. If the amounts had price(s) the inferred amount | -- one of them is amountless. If the amounts had price(s) the inferred amount | ||||||
| -- have the same price(s), and will be converted to the price commodity. | -- have the same price(s), and will be converted to the price commodity. | ||||||
| inferBalancingAmount :: MonadError String m => | inferBalancingAmount ::  | ||||||
|                         (AccountName -> MixedAmount -> m ()) -- ^ update function |      Map.Map CommoditySymbol AmountStyle -- ^ commodity display styles | ||||||
|                      -> Map.Map CommoditySymbol AmountStyle  -- ^ standard amount styles |   -> Transaction | ||||||
|                      -> Transaction |   -> Either String (Transaction, [(AccountName, MixedAmount)]) | ||||||
|                      -> m Transaction | inferBalancingAmount styles t@Transaction{tpostings=ps} | ||||||
| inferBalancingAmount update styles t@Transaction{tpostings=ps} |  | ||||||
|   | length amountlessrealps > 1 |   | length amountlessrealps > 1 | ||||||
|       = throwError "could not balance this transaction - can't have more than one real posting with no amount (remember to put 2 or more spaces before amounts)" |       = Left $ annotateErrorWithTxn t "could not balance this transaction - can't have more than one real posting with no amount (remember to put 2 or more spaces before amounts)" | ||||||
|   | length amountlessbvps > 1 |   | length amountlessbvps > 1 | ||||||
|       = throwError "could not balance this transaction - can't have more than one balanced virtual posting with no amount (remember to put 2 or more spaces before amounts)" |       = Left $ annotateErrorWithTxn t "could not balance this transaction - can't have more than one balanced virtual posting with no amount (remember to put 2 or more spaces before amounts)" | ||||||
|   | otherwise |   | otherwise | ||||||
|       = do postings <- mapM inferamount ps |       = let psandinferredamts = map inferamount ps | ||||||
|            return t{tpostings=postings} |             inferredacctsandamts = [(paccount p, amt) | (p, Just amt) <- psandinferredamts] | ||||||
|  |         in Right (t{tpostings=map fst psandinferredamts}, inferredacctsandamts) | ||||||
|   where |   where | ||||||
|     (amountfulrealps, amountlessrealps) = partition hasAmount (realPostings t) |     (amountfulrealps, amountlessrealps) = partition hasAmount (realPostings t) | ||||||
|     realsum = sumStrict $ map pamount amountfulrealps |     realsum = sumStrict $ map pamount amountfulrealps | ||||||
|     (amountfulbvps, amountlessbvps) = partition hasAmount (balancedVirtualPostings t) |     (amountfulbvps, amountlessbvps) = partition hasAmount (balancedVirtualPostings t) | ||||||
|     bvsum = sumStrict $ map pamount amountfulbvps |     bvsum = sumStrict $ map pamount amountfulbvps | ||||||
|     inferamount p@Posting{ptype=RegularPosting} | 
 | ||||||
|      | not (hasAmount p) = updateAmount p realsum |     inferamount :: Posting -> (Posting, Maybe MixedAmount) | ||||||
|     inferamount p@Posting{ptype=BalancedVirtualPosting} |     inferamount p = | ||||||
|      | not (hasAmount p) = updateAmount p bvsum |       let | ||||||
|     inferamount p = return p |         minferredamt = case ptype p of | ||||||
|     updateAmount p amt =  |           RegularPosting         | not (hasAmount p) -> Just realsum  | ||||||
|       update (paccount p) amt' >> return p { pamount=amt', porigin=Just $ originalPosting p } |           BalancedVirtualPosting | not (hasAmount p) -> Just bvsum  | ||||||
|       where |           _                                          -> Nothing  | ||||||
|         -- Inferred amounts are converted to cost. |       in | ||||||
|         -- Also, ensure the new amount has the standard style for its commodity   |         case minferredamt of | ||||||
|         -- (the main amount styling pass happened before this balancing pass).    |           Nothing -> (p, Nothing) | ||||||
|         amt' = styleMixedAmount styles $ normaliseMixedAmount $ costOfMixedAmount (-amt) |           Just a  -> (p{pamount=a', porigin=Just $ originalPosting p}, Just a')  | ||||||
|  |             where | ||||||
|  |               -- Inferred amounts are converted to cost. | ||||||
|  |               -- Also ensure the new amount has the standard style for its commodity   | ||||||
|  |               -- (since the main amount styling pass happened before this balancing pass); | ||||||
|  |               a' = styleMixedAmount styles $ normaliseMixedAmount $ costOfMixedAmount (-a) | ||||||
| 
 | 
 | ||||||
| -- | Infer prices for this transaction's posting amounts, if needed to make | -- | Infer prices for this transaction's posting amounts, if needed to make | ||||||
| -- the postings balance, and if possible. This is done once for the real | -- the postings balance, and if possible. This is done once for the real | ||||||
| @ -627,17 +691,14 @@ tests_Transaction = | |||||||
|                in postingsAsLines False False t (tpostings t) `is` |                in postingsAsLines False False t (tpostings t) `is` | ||||||
|                   ["    a          $-0.01", "    b           $0.005", "    c           $0.005"] |                   ["    a          $-0.01", "    b           $0.005", "    c           $0.005"] | ||||||
|             ] |             ] | ||||||
|     , do let inferTransaction :: Transaction -> Either String Transaction |     , tests | ||||||
|              inferTransaction = runIdentity . runExceptT . inferBalancingAmount (\_ _ -> return ()) Map.empty |          "inferBalancingAmount" | ||||||
|          tests |          [ (fst <$> inferBalancingAmount Map.empty nulltransaction) `is` Right nulltransaction | ||||||
|            "inferBalancingAmount" |          , (fst <$> inferBalancingAmount Map.empty nulltransaction{tpostings = ["a" `post` usd (-5), "b" `post` missingamt]}) `is` | ||||||
|            [ inferTransaction nulltransaction `is` Right nulltransaction |            Right nulltransaction{tpostings = ["a" `post` usd (-5), "b" `post` usd 5]} | ||||||
|            , inferTransaction nulltransaction {tpostings = ["a" `post` usd (-5), "b" `post` missingamt]} `is` |          , (fst <$> inferBalancingAmount Map.empty nulltransaction{tpostings = ["a" `post` usd (-5), "b" `post` (eur 3 @@ usd 4), "c" `post` missingamt]}) `is` | ||||||
|              Right nulltransaction {tpostings = ["a" `post` usd (-5), "b" `post` usd 5]} |            Right nulltransaction{tpostings = ["a" `post` usd (-5), "b" `post` (eur 3 @@ usd 4), "c" `post` usd 1]} | ||||||
|            , inferTransaction |          ] | ||||||
|                nulltransaction {tpostings = ["a" `post` usd (-5), "b" `post` (eur 3 @@ usd 4), "c" `post` missingamt]} `is` |  | ||||||
|              Right nulltransaction {tpostings = ["a" `post` usd (-5), "b" `post` (eur 3 @@ usd 4), "c" `post` usd 1]} |  | ||||||
|            ] |  | ||||||
|     , tests |     , tests | ||||||
|         "showTransaction" |         "showTransaction" | ||||||
|         [ test "show a balanced transaction, eliding last amount" $ |         [ test "show a balanced transaction, eliding last amount" $ | ||||||
|  | |||||||
| @ -238,32 +238,47 @@ instance Show Status where -- custom show.. bad idea.. don't do it.. | |||||||
|   show Pending   = "!" |   show Pending   = "!" | ||||||
|   show Cleared   = "*" |   show Cleared   = "*" | ||||||
| 
 | 
 | ||||||
| -- | The amount to compare an account's balance to, to verify that the history | -- | A balance assertion is a declaration about an account's expected balance | ||||||
| -- leading to a given point is correct or to set the account to a known value. | -- at a certain point (posting date and parse order). They provide additional | ||||||
|  | -- error checking and readability to a journal file. | ||||||
| -- | -- | ||||||
| -- Different kinds of balance assertion (from #290): | -- The 'BalanceAssertion' type is also used to represent balance assignments, | ||||||
|  | -- which instruct hledger what an account's balance should become at a certain | ||||||
|  | -- point. | ||||||
| -- | -- | ||||||
| -- * simple assertions: single-commodity, non-total, subaccount-exclusive | -- Different kinds of balance assertions are discussed eg on #290. | ||||||
| --   assertions, as in Ledger (syntax: `=`). See definitions below. | -- Variables include: | ||||||
| -- | -- | ||||||
| -- * subaccount-inclusive assertions: asserting the balance of an account | -- - which postings are to be summed (real/virtual; unmarked/pending/cleared; this account/this account including subs) | ||||||
| --   including all its subaccounts' balances. Not implemented, proposed by #290. |  | ||||||
| -- | -- | ||||||
| -- * multicommodity assertions: writing multiple amounts separated by + to | -- - which commodities within the balance are to be checked | ||||||
| --   assert a multicommodity balance, in a single assertion. Not implemented, |  | ||||||
| --   proposed by #934.  In current hledger you can assert a multicommodity |  | ||||||
| --   balance by using multiple postings/assertions.  But in either case, the |  | ||||||
| --   balance might contain additional unasserted commodities. To disallow that |  | ||||||
| --   you need... |  | ||||||
| -- | -- | ||||||
| -- * total assertions: asserting that the balance is as written, with no extra | -- - whether to do a partial or a total check (disallowing other commodities) | ||||||
| --   commodities in the account. Added by #902, with syntax `==`. I sometimes | -- | ||||||
| --   wish this was the default behaviour, of `=`. | -- I suspect we want: | ||||||
|  | -- | ||||||
|  | -- 1. partial, subaccount-exclusive, Ledger-compatible assertions. Because | ||||||
|  | --    they're what we've always had, and removing them would break some | ||||||
|  | --    journals unnecessarily.  Implemented with = syntax. | ||||||
|  | -- | ||||||
|  | -- 2. total assertions. Because otherwise assertions are a bit leaky. | ||||||
|  | --    Implemented with == syntax. | ||||||
|  | -- | ||||||
|  | -- 3. subaccount-inclusive assertions. Because that's something folks need. | ||||||
|  | --    Not implemented. | ||||||
|  | -- | ||||||
|  | -- 4. flexible assertions allowing custom criteria (perhaps arbitrary | ||||||
|  | --    queries). Because power users have diverse needs and want to try out | ||||||
|  | --    different schemes (assert cleared balances, assert balance from real or | ||||||
|  | --    virtual postings, etc.). Not implemented. | ||||||
|  | -- | ||||||
|  | -- 5. multicommodity assertions, asserting the balance of multiple commodities | ||||||
|  | --    at once. Not implemented, requires #934. | ||||||
| -- | -- | ||||||
| data BalanceAssertion = BalanceAssertion { | data BalanceAssertion = BalanceAssertion { | ||||||
|       baamount   :: Amount,             -- ^ the expected balance of a single commodity |       baamount    :: Amount,             -- ^ the expected balance in a particular commodity | ||||||
|       baexact    :: Bool,               -- ^ whether the assertion is total, ie disallowing amounts in other commodities |       batotal     :: Bool,               -- ^ disallow additional non-asserted commodities ? | ||||||
|       baposition :: GenericSourcePos |       baposition  :: GenericSourcePos    -- ^ the assertion's file position, for error reporting | ||||||
|     } deriving (Eq,Typeable,Data,Generic,Show) |     } deriving (Eq,Typeable,Data,Generic,Show) | ||||||
| 
 | 
 | ||||||
| instance NFData BalanceAssertion | instance NFData BalanceAssertion | ||||||
|  | |||||||
| @ -728,14 +728,14 @@ balanceassertionp :: JournalParser m BalanceAssertion | |||||||
| balanceassertionp = do | balanceassertionp = do | ||||||
|   sourcepos <- genericSourcePos <$> lift getSourcePos |   sourcepos <- genericSourcePos <$> lift getSourcePos | ||||||
|   char '=' |   char '=' | ||||||
|   exact <- optional $ try $ char '=' |   istotal <- fmap isJust $ optional $ try $ char '=' | ||||||
|   lift (skipMany spacenonewline) |   lift (skipMany spacenonewline) | ||||||
|   -- this amount can have a price; balance assertions ignore it, |   -- this amount can have a price; balance assertions ignore it, | ||||||
|   -- but balance assignments will use it |   -- but balance assignments will use it | ||||||
|   a <- amountp <?> "amount (for a balance assertion or assignment)" |   a <- amountp <?> "amount (for a balance assertion or assignment)" | ||||||
|   return BalanceAssertion |   return BalanceAssertion | ||||||
|     { baamount = a |     { baamount   = a | ||||||
|     , baexact = isJust exact |     , batotal    = istotal | ||||||
|     , baposition = sourcepos |     , baposition = sourcepos | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -176,8 +176,8 @@ uiCheckBalanceAssertions d ui@UIState{aopts=UIOpts{cliopts_=copts}, ajournal=j} | |||||||
|   | ignore_assertions_ $ inputopts_ copts = ui |   | ignore_assertions_ $ inputopts_ copts = ui | ||||||
|   | otherwise = |   | otherwise = | ||||||
|     case journalCheckBalanceAssertions j of |     case journalCheckBalanceAssertions j of | ||||||
|       Right _  -> ui |       Nothing  -> ui | ||||||
|       Left err -> |       Just err -> | ||||||
|         case ui of |         case ui of | ||||||
|           UIState{aScreen=s@ErrorScreen{}} -> ui{aScreen=s{esError=err}} |           UIState{aScreen=s@ErrorScreen{}} -> ui{aScreen=s{esError=err}} | ||||||
|           _                                -> screenEnter d errorScreen{esError=err} ui |           _                                -> screenEnter d errorScreen{esError=err} ui | ||||||
|  | |||||||
| @ -63,10 +63,11 @@ $ hledger -f - print -x | |||||||
|   c |   c | ||||||
| 
 | 
 | ||||||
| $ hledger -f journal:- print | $ hledger -f journal:- print | ||||||
| >2 /\<4\>/ | >2 /could not balance this transaction - can't have more than one real posting with no amount/ | ||||||
| >=1 | >=1 | ||||||
| 
 | 
 | ||||||
| # 7. Two (or more) virtual postings with implicit amount cannot be balanced. | # 7. Two (or more) virtual postings with implicit amount cannot be balanced. | ||||||
|  | # (And the error message contains line numbers). | ||||||
| < | < | ||||||
| 2018/1/1 | 2018/1/1 | ||||||
|   [a]  1 |   [a]  1 | ||||||
| @ -74,5 +75,5 @@ $ hledger -f journal:- print | |||||||
|   [c] |   [c] | ||||||
| 
 | 
 | ||||||
| $ hledger -f journal:- print | $ hledger -f journal:- print | ||||||
| >2 /\<4\>/ | >2 /lines 1-4/ | ||||||
| >=1 | >=1 | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user