7
7
8
8
-}
9
9
10
+ {-# LANGUAGE MultiWayIf #-}
10
11
{-# LANGUAGE NamedFieldPuns #-}
11
12
{-# LANGUAGE OverloadedStrings #-}
13
+ {-# LANGUAGE TupleSections #-}
12
14
13
15
module Hledger.Data.Transaction
14
16
( -- * Transaction
@@ -44,18 +46,23 @@ module Hledger.Data.Transaction
44
46
, showTransactionOneLineAmounts
45
47
, showTransactionLineFirstPart
46
48
, transactionFile
49
+ -- * transaction errors
50
+ , annotateErrorWithTransaction
47
51
-- * tests
48
52
, tests_Transaction
49
53
) where
50
54
51
- import Data.Bifunctor (second )
52
- import Data.Maybe (fromMaybe , mapMaybe )
55
+ import Control.Monad.Trans.State (StateT (.. ), evalStateT )
56
+ import Data.Bifunctor (first )
57
+ import Data.Foldable (foldrM )
58
+ import Data.Maybe (fromMaybe , isJust , mapMaybe )
59
+ import Data.Semigroup (Endo (.. ))
53
60
import Data.Text (Text )
61
+ import qualified Data.Map as M
54
62
import qualified Data.Text as T
55
63
import qualified Data.Text.Lazy as TL
56
64
import qualified Data.Text.Lazy.Builder as TB
57
65
import Data.Time.Calendar (Day , fromGregorian )
58
- import qualified Data.Map as M
59
66
60
67
import Hledger.Utils
61
68
import Hledger.Data.Types
@@ -219,34 +226,120 @@ transactionAddInferredEquityPostings :: AccountName -> Transaction -> Transactio
219
226
transactionAddInferredEquityPostings equityAcct t =
220
227
t{tpostings= concatMap (postingAddInferredEquityPostings equityAcct) $ tpostings t}
221
228
222
- -- | Add inferred transaction prices from equity postings. The transaction
223
- -- price will be added to the first posting whose amount is the negation of one
224
- -- of the (exactly) two conversion postings, if it exists.
225
- transactionAddPricesFromEquity :: M. Map AccountName AccountType -> Transaction -> Transaction
226
- transactionAddPricesFromEquity acctTypes t
227
- | [(n1, cp1), (n2, cp2)] <- conversionps -- Exactly two conversion postings with indices
228
- , Just ca1 <- maybePostingAmount cp1, Just ca2 <- maybePostingAmount cp2 -- Each conversion posting has exactly one amount
229
- , (np,pricep): _ <- mapMaybe (maybeAddPrice ca1 ca2) npostings -- Get the first posting which matches one of the conversion postings
230
- , let subPosting (n, p) = if n == np then pricep else if n == n1 then cp1 else if n == n2 then cp2 else p
231
- = t{tpostings = map subPosting npostings}
232
- | otherwise = t
229
+ -- | Add inferred transaction prices from equity postings. For every adjacent
230
+ -- pair of conversion postings, it will first search the postings with
231
+ -- transaction prices to see if any match. If so, it will tag it as matched.
232
+ -- If no postings with transaction prices match, it will then search the
233
+ -- postings without transaction prices, and will match the first such posting
234
+ -- which matches one of the conversion amounts. If it finds a match, it will
235
+ -- add a transaction price and then tag it.
236
+ type IdxPosting = (Int , Posting )
237
+ transactionAddPricesFromEquity :: M. Map AccountName AccountType -> Transaction -> Either String Transaction
238
+ transactionAddPricesFromEquity acctTypes t = first (annotateErrorWithTransaction t . T. unpack) $ do
239
+ (conversionPairs, stateps) <- partitionPs npostings
240
+ f <- transformIndexedPostingsF addPricesToPostings conversionPairs stateps
241
+ return t{tpostings = map (snd . f) npostings}
233
242
where
234
- maybeAddPrice a1 a2 (n,p)
235
- | Just a <- mpamt, amountMatches (- a1) a = Just (n, markPosting p{pamount = mixedAmount a{aprice = Just $ TotalPrice a2}})
236
- | Just a <- mpamt, amountMatches (- a2) a = Just (n, markPosting p{pamount = mixedAmount a{aprice = Just $ TotalPrice a1}})
237
- | otherwise = Nothing
238
- where
239
- mpamt = maybePostingAmount p
240
-
241
- conversionps = map (second (`postingAddTags` [(" _matched-conversion-posting" ," " )]))
242
- $ filter (\ (_,p) -> M. lookup (paccount p) acctTypes == Just Conversion ) npostings
243
- markPosting = (`postingAddTags` [(" _matched-transaction-price" ," " )])
243
+ -- Include indices for postings
244
244
npostings = zip [0 .. ] $ tpostings t
245
+ transformIndexedPostingsF f = evalStateT . fmap (appEndo . foldMap Endo ) . traverse f
246
+
247
+ -- Sort postings into pairs of conversion postings, transaction price postings, and other postings
248
+ partitionPs = fmap fst . foldrM select (([] , ([] , [] )), Nothing )
249
+ select np@ (_, p) ((cs, others@ (ps, os)), Nothing )
250
+ | isConversion p = Right ((cs, others), Just np)
251
+ | hasPrice p = Right ((cs, (np: ps, os)), Nothing )
252
+ | otherwise = Right ((cs, (ps, np: os)), Nothing )
253
+ select np@ (_, p) ((cs, others), Just last )
254
+ | isConversion p = Right (((last , np): cs, others), Nothing )
255
+ | otherwise = Left " Conversion postings must occur in adjacent pairs"
256
+
257
+ -- Given a pair of indexed conversion postings, and a state consisting of lists of
258
+ -- priced and unpriced non-conversion postings, create a function which adds transaction
259
+ -- prices to the posting which matches the conversion postings if necessary, and tags
260
+ -- the conversion and matched postings. Then update the state by removing the matched
261
+ -- postings. If there are no matching postings or too much ambiguity, return an error
262
+ -- string annotated with the conversion postings.
263
+ addPricesToPostings :: (IdxPosting , IdxPosting )
264
+ -> StateT ([IdxPosting ], [IdxPosting ]) (Either Text ) (IdxPosting -> IdxPosting )
265
+ addPricesToPostings ((n1, cp1), (n2, cp2)) = StateT $ \ (priceps, otherps) -> do
266
+ -- Get the two conversion posting amounts, if possible
267
+ ca1 <- postingAmountNoPrice cp1
268
+ ca2 <- postingAmountNoPrice cp2
269
+ let -- The function to add transaction prices and tag postings in the indexed list of postings
270
+ transformPostingF np pricep = \ (n, p) ->
271
+ (n, if | n == np -> pricep `postingAddTags` [(" _price-matched" ," " )]
272
+ | n == n1 || n == n2 -> p `postingAddTags` [(" _conversion-matched" ," " )]
273
+ | otherwise -> p)
274
+ -- All priced postings which match the conversion posting pair
275
+ matchingPricePs = mapMaybe (mapM $ pricedPostingIfMatchesBothAmounts ca1 ca2) priceps
276
+ -- All other postings which match at least one of the conversion posting pair
277
+ matchingOtherPs = mapMaybe (mapM $ addPriceIfMatchesOneAmount ca1 ca2) otherps
278
+
279
+ -- Annotate any errors with the conversion posting pair
280
+ first (annotateWithPostings [cp1, cp2]) $
281
+ if -- If a single transaction price posting matches the conversion postings,
282
+ -- delete it from the list of priced postings in the state, delete the
283
+ -- first matching unpriced posting from the list of non-priced postings
284
+ -- in the state, and return the transformation function with the new state.
285
+ | [(np, (pricep, _))] <- matchingPricePs
286
+ , Just newpriceps <- deleteIdx np priceps
287
+ -> Right (transformPostingF np pricep, (newpriceps, otherps))
288
+ -- If no transaction price postings match the conversion postings, but some
289
+ -- of the unpriced postings match, check that the first such posting has a
290
+ -- different amount from all the others, and if so add a transaction price to
291
+ -- it, then delete it from the list of non-priced postings in the state, and
292
+ -- return the transformation function with the new state.
293
+ | [] <- matchingPricePs
294
+ , (np, (pricep, amt)): nps <- matchingOtherPs
295
+ , not $ any (amountMatches amt . snd . snd ) nps
296
+ , Just newotherps <- deleteIdx np otherps
297
+ -> Right (transformPostingF np pricep, (priceps, newotherps))
298
+ -- Otherwise it's too ambiguous to make a guess, so return an error.
299
+ | otherwise -> Left " There is not a unique posting which matches the conversion posting pair:"
300
+
301
+ -- If a posting with transaction price matches both the conversion amounts, return it along
302
+ -- with the matching amount which must be present in another non-conversion posting.
303
+ pricedPostingIfMatchesBothAmounts :: Amount -> Amount -> Posting -> Maybe (Posting , Amount )
304
+ pricedPostingIfMatchesBothAmounts a1 a2 p = do
305
+ a@ Amount {aprice= Just _} <- postingSingleAmount p
306
+ if | amountMatches (- a1) a && amountMatches a2 (amountCost a) -> Just (p, - a2)
307
+ | amountMatches (- a2) a && amountMatches a1 (amountCost a) -> Just (p, - a1)
308
+ | otherwise -> Nothing
309
+
310
+ -- Add a transaction price to a posting if it matches (negative) one of the
311
+ -- supplied conversion amounts, adding the other amount as the price
312
+ addPriceIfMatchesOneAmount :: Amount -> Amount -> Posting -> Maybe (Posting , Amount )
313
+ addPriceIfMatchesOneAmount a1 a2 p = do
314
+ a <- postingSingleAmount p
315
+ let newp price = p{pamount = mixedAmount a{aprice = Just $ TotalPrice price}}
316
+ if | amountMatches (- a1) a -> Just (newp a2, a2)
317
+ | amountMatches (- a2) a -> Just (newp a1, a1)
318
+ | otherwise -> Nothing
319
+
320
+ hasPrice p = isJust $ aprice =<< postingSingleAmount p
321
+ postingAmountNoPrice p = case postingSingleAmount p of
322
+ Just a@ Amount {aprice= Nothing } -> Right a
323
+ _ -> Left $ annotateWithPostings [p] " The posting must only have a single amount with no transaction price"
324
+ postingSingleAmount p = case amountsRaw (pamount p) of
325
+ [a] -> Just a
326
+ _ -> Nothing
245
327
246
- maybePostingAmount p = case amountsRaw $ pamount p of
247
- [a@ Amount {aprice= Nothing }] -> Just a
248
- _ -> Nothing
249
328
amountMatches a b = acommodity a == acommodity b && aquantity a == aquantity b
329
+ isConversion p = M. lookup (paccount p) acctTypes == Just Conversion
330
+
331
+ -- Delete a posting from the indexed list of postings based on either its
332
+ -- index or its posting amount.
333
+ -- Note: traversing the whole list to delete a single match is generally not efficient,
334
+ -- but given that a transaction probably doesn't have more than four postings, it should
335
+ -- still be more efficient than using a Map or another data structure. Even monster
336
+ -- transactions with up to 10 postings, which are generally not a good
337
+ -- idea, are still too small for there to be an advantage.
338
+ deleteIdx n = deleteUniqueMatch ((n== ) . fst )
339
+ deleteUniqueMatch p (x: xs) | p x = if any p xs then Nothing else Just xs
340
+ | otherwise = (x: ) <$> deleteUniqueMatch p xs
341
+ deleteUniqueMatch _ [] = Nothing
342
+ annotateWithPostings xs str = T. unlines $ str : postingsAsLines False xs
250
343
251
344
-- | Apply some account aliases to all posting account names in the transaction, as described by accountNameApplyAliases.
252
345
-- This can fail due to a bad replacement pattern in a regular expression alias.
@@ -268,6 +361,13 @@ transactionMapPostingAmounts f = transactionMapPostings (postingTransformAmount
268
361
transactionFile :: Transaction -> FilePath
269
362
transactionFile Transaction {tsourcepos} = sourceName $ fst tsourcepos
270
363
364
+ -- Add transaction information to an error message.
365
+ annotateErrorWithTransaction :: Transaction -> String -> String
366
+ annotateErrorWithTransaction t s =
367
+ unlines [ sourcePosPairPretty $ tsourcepos t, s
368
+ , T. unpack . T. stripEnd $ showTransaction t
369
+ ]
370
+
271
371
-- tests
272
372
273
373
tests_Transaction :: TestTree
0 commit comments