packages feed

exchangealgebra-0.5.0.0: src/ExchangeAlgebra/Convert.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
-- The alias table intentionally references the deprecated 'Commutation'
-- constructor so that its legacy Japanese label ("通信費") is recognised and
-- flagged as ambiguous against 'CommunicationExpenses'; silence the warning.
{-# OPTIONS_GHC -Wno-deprecations #-}

{- |
Module      : ExchangeAlgebra.Convert
Description : Pure conversion between external (side, account-name, amount) data and
              exchange-algebra terms. No serialization dependency (Text only).

This module is the dependency-free core of the input-conversion layer: it turns
externally supplied postings---a debit\/credit t'Side', an account name as 'Text',
and an amount---into exchange-algebra 'Alg' terms, and back. The format glue
(JSON\/XML, i.e. @aeson@) deliberately lives outside the published library
(see @examples\/audit-eval\/runner@), so the algebra core stays serialization-free.

The mapping from a debit\/credit side to the @Hat@\/@Not@ marker is derived from
the library's own 'whichSide', so the credit\/debit semantics are never duplicated.
Unknown or wildcard account names are rejected (a /correct-by-construction/ guard
against hallucinated accounts). Account names that are genuinely ambiguous (one
Japanese label shared by several constructors, e.g. @準備預金@ for both the asset
and liability side of reserve deposits) are also rejected, with the candidate
accounts reported, so callers must disambiguate by canonical name.

A fixed-schema CSV reader (@side,account,amount@, optional @note@ column) is
provided in "ExchangeAlgebra.Convert.Csv".
-}
module ExchangeAlgebra.Convert
    ( ConvError(..)
      -- $concreteAccountTitles
    , concreteAccountTitles
    , normalizeTitle
    , parseAccountTitle
    , parseSide
    , markerForSide
    , postingFromSide
    , journalFromSides
    ) where

import           Data.Char (isAlphaNum, isSpace)
import qualified Data.List as L
import           Data.Text (Text)
import qualified Data.Text as T

import           ExchangeAlgebra.Algebra         ( Alg, HatVal, Redundant((.+)), (.@) )
import           ExchangeAlgebra.Algebra.Base    ( AccountTitles(..)
                                                 , HatBase(..)
                                                 , Hat(..)
                                                 , Side(..)
                                                 , accountAliases
                                                 , whichSide )
import           ExchangeAlgebra.Algebra.Base.Account.Registry ( concreteAccountTitles )

-- $setup
-- The examples use 'Text' literals, so enable @OverloadedStrings@ in doctest.
-- >>> :set -XOverloadedStrings

-- | Conversion errors. Kept structural so callers can report precisely.
data ConvError = UnknownAccount Text                    -- ^ name matched no concrete account title
               | AmbiguousAccount Text [AccountTitles]  -- ^ name matched several accounts (disambiguate by canonical name)
               | UnknownSide    Text                    -- ^ side string was not debit\/credit
               | MalformedCsv   Text                    -- ^ CSV header\/row was structurally invalid (see "ExchangeAlgebra.Convert.Csv")
               | BadAmount      Text                    -- ^ amount field was not a valid non-negative number
    deriving (Eq, Show)

-- $concreteAccountTitles
-- 'concreteAccountTitles' lists all concrete account titles, /excluding/ the
-- wildcard 'AccountTitle', in stable Enum order. It is re-exported from
-- "ExchangeAlgebra.Algebra.Base.Account.Registry", which is the single
-- definition; external input must never resolve to the wildcard, so callers
-- should use this safe range rather than @[minBound .. maxBound]@.
--
-- >>> take 1 concreteAccountTitles
-- [Cash]
-- >>> Cash `elem` concreteAccountTitles && Sales `elem` concreteAccountTitles
-- True
-- >>> AccountsPayable `elem` concreteAccountTitles
-- True
-- >>> AccountTitle `elem` concreteAccountTitles
-- False

-- | Account-name lookup table: every key (canonical constructor names and the
-- Japanese\/abbreviation aliases below) is run through 'normalizeTitle', so matching is
-- case-, whitespace-, and symbol-insensitive. Keys are tagged so that a single
-- alias shared by several accounts is reported as 'AmbiguousAccount' rather than
-- silently resolving to whichever entry happens to come first.
--
-- The canonical English names (derived from 'show' over 'concreteAccountTitles')
-- are always unique, so they are never ambiguous and always resolve.
accountTable :: [(Text, [AccountTitles])]
accountTable = collapse [ (normalizeTitle k, a) | (k, a) <- entries ]
  where
    -- Group entries by normalised key, accumulating every account that key maps
    -- to (so genuine collisions become ambiguous, not first-wins).
    collapse kvs =
        [ (k, L.nub as)
        | k <- L.nub (L.map fst kvs)
        , let as = [ a | (k', a) <- kvs, k' == k ] ]

    entries :: [(Text, AccountTitles)]
    entries =  canonical ++ aliases

    -- Canonical: the constructor name. Always unique.
    canonical = [ (T.pack (show a), a) | a <- concreteAccountTitles ]

    -- Aliases: Japanese labels (from the Element.hs translation Haddock, which is
    -- the canonical bilingual reference) plus a few English abbreviations.
    -- Genuinely shared labels are listed for each account; lookup then returns
    -- 'AmbiguousAccount' for those, forcing the caller to use a canonical name.
    aliases =
        [ (alias, title)
        | title <- aliasTitleOrder
        , alias <- accountAliases title
        ]

    -- Preserve the historical candidate order for the one shared label whose
    -- alias table order differs from Enum order: "通信費" reports the modern
    -- title before the deprecated one.
    aliasTitleOrder = CommunicationExpenses
                    : L.delete CommunicationExpenses concreteAccountTitles

-- | Normalise an account name for matching: case-fold, drop punctuation\/symbols
-- (keep only alphanumerics and spaces), and collapse internal whitespace to
-- single spaces (also trimming). So @\"A\/R\"@ and @\"ar\"@ coincide, and
-- @\"Accounts  Receivable\"@ matches @\"accounts receivable\"@. CJK characters
-- are alphanumeric (Unicode @Lo@), so Japanese labels survive unchanged.
--
-- (Renamed from @norm@: that name collides with the core value-domain
-- homomorphism 'ExchangeAlgebra.Algebra.norm', which is an entirely unrelated
-- operation — the two must not be confusable in downstream imports.)
--
-- >>> normalizeTitle "  Accounts   Receivable "
-- "accounts receivable"
-- >>> normalizeTitle "A/R"
-- "ar"
normalizeTitle :: Text -> Text
normalizeTitle = T.unwords . T.words . T.filter (\c -> isAlphaNum c || isSpace c) . T.toLower

-- | Parse an account name into a concrete 'AccountTitles'. Unknown names and the
-- wildcard are rejected; ambiguous Japanese labels (shared by several accounts)
-- are rejected with the candidates reported.
--
-- >>> parseAccountTitle "Cash"
-- Right Cash
-- >>> parseAccountTitle "  accounts receivable "
-- Right AccountsReceivable
-- >>> parseAccountTitle "Accounts   Receivable"
-- Right AccountsReceivable
-- >>> parseAccountTitle "A/R"
-- Right AccountsReceivable
-- >>> parseAccountTitle "売掛金"
-- Right AccountsReceivable
--
-- A label shared by two accounts (here the asset and liability side of reserve
-- deposits) is rejected as ambiguous, listing the candidates so the caller can
-- pick a canonical name:
--
-- >>> case parseAccountTitle "準備預金" of { Left (AmbiguousAccount _ as) -> as; _ -> [] }
-- [ReserveDepositReceivable,ReserveDepositPayable]
-- >>> parseAccountTitle "Goodwill_X"
-- Left (UnknownAccount "Goodwill_X")
-- >>> parseAccountTitle "AccountTitle"
-- Left (UnknownAccount "AccountTitle")
parseAccountTitle :: Text -> Either ConvError AccountTitles
parseAccountTitle t =
    case lookup (normalizeTitle t) accountTable of
        Just [a] -> Right a
        Just as  -> Left (AmbiguousAccount t as)
        Nothing  -> Left (UnknownAccount t)

-- | Parse a side string (@"debit"@\/@"credit"@, case-insensitive) into t'Side'.
--
-- >>> parseSide "debit"
-- Right Debit
-- >>> parseSide "CREDIT"
-- Right Credit
-- >>> parseSide "left"
-- Left (UnknownSide "left")
parseSide :: Text -> Either ConvError Side
parseSide t = case normalizeTitle t of
    "debit"  -> Right Debit
    "credit" -> Right Credit
    _        -> Left (UnknownSide t)

-- | The @Hat@\/@Not@ marker that places @account@ on the requested debit\/credit
-- @side@. Derived from the library's 'whichSide' so the credit\/debit rule is not
-- duplicated: an account sits on its \"natural\" side under 'Not'; if the
-- requested side differs, the v'Hat' (reversal) marker is used.
--
-- >>> markerForSide Debit Cash
-- Not
-- >>> markerForSide Credit Cash
-- Hat
-- >>> markerForSide Credit Sales
-- Not
-- >>> markerForSide Debit Sales
-- Hat
markerForSide :: Side -> AccountTitles -> Hat
markerForSide side account
    | whichSide (Not :< account) == side = Not
    | otherwise                          = Hat

-- | Build a single posting term: @amount .\@ (marker :< account)@, with the
-- marker chosen so the posting lands on @side@. Built through the smart
-- constructor '(.@)' so the non-negative\/non-error value invariant is enforced.
--
-- >>> postingFromSide Debit Cash (1000 :: Double)
-- 1000.00:@Not:<Cash
-- >>> postingFromSide Credit Sales (1000 :: Double)
-- 1000.00:@Not:<Sales
postingFromSide :: (HatVal v)
                => Side -> AccountTitles -> v -> Alg v (HatBase AccountTitles)
postingFromSide side account amount =
    amount .@ (markerForSide side account :< account)

-- | Fold a list of @(side, account, amount)@ postings into one algebra term.
-- Balance (debit == credit) is /not/ enforced here; check it with the library's
-- balance functions after conversion.
--
-- >>> journalFromSides [(Debit, Cash, 1000), (Credit, Sales, 1000)] :: Alg Double (HatBase AccountTitles)
-- 1000.00:@Not:<Cash .+ 1000.00:@Not:<Sales
journalFromSides :: (HatVal v)
                 => [(Side, AccountTitles, v)] -> Alg v (HatBase AccountTitles)
journalFromSides = foldr (\(s, a, v) acc -> postingFromSide s a v .+ acc) mempty