packages feed

exchangealgebra-0.5.0.0: src/ExchangeAlgebra/TrialBalance/Validation.hs

{-# LANGUAGE FlexibleContexts #-}
{-# OPTIONS_GHC -Wincomplete-patterns -Werror=incomplete-patterns #-}

{- |
Module      : ExchangeAlgebra.TrialBalance.Validation
Description : Explicit findings and policy gate between trial balances and reporting.

An exactly balanced trial balance can still be unsuitable for financial
statement preparation. This module detects reciprocal-account mismatches,
temporary-account residuals, closing-device residuals, abnormal-side balances,
and missing classification evidence independently of the double-entry balance
check.

Detection and acceptance are deliberately separate. 'trialBalanceFindings'
always reports the facts it can observe; 'validateTrialBalance' applies a
t'TrialBalancePolicy' and hides the v'ValidatedTrialBalance' constructor. No
finding performs a reclassification automatically. A caller must record and
apply a transfer, then validate the resulting trial balance again.

Validation uses exact equality. Prefer an exact value type such as
@MoneyDecimal@ for deterministic accounting gates.
-}
module ExchangeAlgebra.TrialBalance.Validation
    ( TrialBalanceStage(..)
    , ReciprocalPolicy(..)
    , TemporaryBalancePolicy(..)
    , TrialBalancePolicy(..)
    , strictTrialBalancePolicy
    , standaloneTrialBalancePolicy
    , AccountBalance(..)
    , ReclassificationRule(..)
    , TrialBalanceInput(..)
    , TBFinding(..)
    , trialBalanceFindings
    , findingBlocksPresentation
    , ValidatedTrialBalance
    , validateTrialBalance
    , validatedTrialBalance
    , validatedFindings
    , validatedPolicy
    , validatedStage
    , validatedMaturityRequiredTitles
    ) where

import           Data.List.NonEmpty (NonEmpty(..))
import qualified Data.List.NonEmpty as NE
import qualified Data.Map.Strict as M
import           Data.Map.Strict (Map)
import qualified Data.Set as S
import           Data.Set (Set)
import           Data.Text (Text)
import qualified Data.Text as T

import           ExchangeAlgebra.Algebra
                     ( Alg
                     , Exchange(decL, decR)
                     , HatVal(..)
                     , Redundant(norm)
                     , bases
                     , foldEntries
                     )
import           ExchangeAlgebra.Algebra.Base
                     ( AccountDivision(..)
                     , AccountRole(..)
                     , AccountSemantics(asemDivisionSemantics,
                                        asemHomeSideSemantics, asemRoles)
                     , AccountTitles(..)
                     , DivisionSemantics(..)
                     , Hat(..)
                     , HatBase((:<))
                     , HomeSideSemantics(..)
                     , Side(..)
                     , accountSemantics
                     , concreteAccountTitles
                     )
import           ExchangeAlgebra.TrialBalance.Balance
                     ( AccountBalance(..)
                     , accountBalances
                     , balanceFor
                     , balanceSide
                     )

-- | Processing point of the supplied trial balance.
data TrialBalanceStage
  = BeforeClosing
  | AfterClosing
  deriving (Show, Eq)

-- | Whether reciprocal accounts must tie in this validation context.
data ReciprocalPolicy
  = RequireReciprocalMatch
  | PermitStandaloneReciprocalBalance
  deriving (Show, Eq)

-- | Whether an explained temporary balance may pass the validation gate.
data TemporaryBalancePolicy
  = RequireTemporaryAccountsCleared
  | PermitExplainedTemporaryBalances
  deriving (Show, Eq)

-- | Policy applied after the complete finding list has been constructed.
data TrialBalancePolicy = TrialBalancePolicy
    { _reciprocalPolicy       :: ReciprocalPolicy
    , _temporaryBalancePolicy :: TemporaryBalancePolicy
    }
    deriving (Show, Eq)

-- | Combined/reporting default: reciprocal accounts must tie and temporary
-- accounts must be cleared.
strictTrialBalancePolicy :: TrialBalancePolicy
strictTrialBalancePolicy = TrialBalancePolicy
    RequireReciprocalMatch RequireTemporaryAccountsCleared

-- | Standalone default: a branch or head-office control balance may remain,
-- while an explained temporary balance is retained as an auditable finding.
standaloneTrialBalancePolicy :: TrialBalancePolicy
standaloneTrialBalancePolicy = TrialBalancePolicy
    PermitStandaloneReciprocalBalance PermitExplainedTemporaryBalances

-- | A rule identifies a condition and possible target titles. It supplies an
-- instruction, not an automatic mutation.
data ReclassificationRule
  = SideReclassificationRule
        AccountTitles
        Side
        (NonEmpty AccountTitles)
  | MaturityEvidenceRequired AccountTitles
  deriving (Show, Eq)

-- | Unvalidated trial-balance input and its external evidence.
data TrialBalanceInput v = TrialBalanceInput
    { _trialBalanceElement         :: Alg v (HatBase AccountTitles)
    , _trialBalanceStage           :: TrialBalanceStage
    , _temporaryBalanceExplanations :: Map AccountTitles Text
    , _reclassificationRules       :: [ReclassificationRule]
    , _maturityEvidenceTitles      :: Set AccountTitles
    }
    deriving (Show)

-- | Facts detected before financial-statement presentation.
data TBFinding v
  = InvalidTrialBalanceValue v
  | WildcardTrialBalanceAccount
  | WildcardTrialBalanceSide
  | UnbalancedTrialBalance v v
  | ReciprocalMismatch
        (AccountBalance v)
        (AccountBalance v)
  | StandaloneReciprocalBalance AccountTitles (AccountBalance v)
  | UnresolvedTemporaryBalance AccountTitles (AccountBalance v)
  | ExplainedTemporaryBalance AccountTitles (AccountBalance v) Text
  | BlankTemporaryExplanation AccountTitles (AccountBalance v)
  | ClosingDeviceResidual AccountTitles (AccountBalance v)
  | DerivedCoordinateResidual AccountTitles (AccountBalance v)
  | UnclosedNominalBalance AccountTitles (AccountBalance v)
  | UnexplainedAbnormalBalance
        AccountTitles Side (AccountBalance v)
  | AbnormalBalanceWithReclassificationRule
        AccountTitles (AccountBalance v) AccountTitles
  | AmbiguousReclassification
        AccountTitles (AccountBalance v) (NonEmpty AccountTitles)
  | MissingMaturityEvidence AccountTitles
  | InapplicableReclassificationRule ReclassificationRule
  deriving (Show, Eq)

-- | Detect every applicable finding. Structural errors suppress any operation
-- that would call the partial legacy @whichSide@ function on wildcard bases.
trialBalanceFindings :: HatVal v => TrialBalanceInput v -> [TBFinding v]
trialBalanceFindings input
    | not (null structuralFindings) = structuralFindings
    | otherwise = balanceFindings
        ++ reciprocalFindings balances
        ++ temporaryFindings input balances
        ++ closingFindings input balances
        ++ abnormalFindings input balances
        ++ maturityFindings input balances
        ++ ruleConfigurationFindings input
  where
    alg = _trialBalanceElement input
    structuralFindings =
        [ InvalidTrialBalanceValue value
        | value <- valuesOf alg
        , isErrorValue value
        ]
        ++ [ WildcardTrialBalanceAccount
           | _ :< AccountTitle <- bases alg
           ]
        ++ [ WildcardTrialBalanceSide
           | HatNot :< _ <- bases alg
           ]
    debit = norm (decL alg)
    credit = norm (decR alg)
    balanceFindings =
        [ UnbalancedTrialBalance debit credit | debit /= credit ]
    balances = accountBalances alg

-- | Decide whether one finding blocks presentation under a chosen policy.
findingBlocksPresentation :: TrialBalancePolicy -> TBFinding v -> Bool
findingBlocksPresentation policy finding = case finding of
    ExplainedTemporaryBalance _ _ _ ->
        _temporaryBalancePolicy policy == RequireTemporaryAccountsCleared
    StandaloneReciprocalBalance _ _ ->
        _reciprocalPolicy policy == RequireReciprocalMatch
    ReciprocalMismatch _ _ -> True
    InvalidTrialBalanceValue _ -> True
    WildcardTrialBalanceAccount -> True
    WildcardTrialBalanceSide -> True
    UnbalancedTrialBalance _ _ -> True
    UnresolvedTemporaryBalance _ _ -> True
    BlankTemporaryExplanation _ _ -> True
    ClosingDeviceResidual _ _ -> True
    DerivedCoordinateResidual _ _ -> True
    UnclosedNominalBalance _ _ -> True
    UnexplainedAbnormalBalance _ _ _ -> True
    AbnormalBalanceWithReclassificationRule _ _ _ -> True
    AmbiguousReclassification _ _ _ -> True
    MissingMaturityEvidence _ -> True
    InapplicableReclassificationRule _ -> True

-- | Trial balance accepted by a stated policy. The constructor is hidden.
data ValidatedTrialBalance v = ValidatedTrialBalance
    (Alg v (HatBase AccountTitles))
    [TBFinding v]
    TrialBalancePolicy
    TrialBalanceStage
    (Set AccountTitles)

-- | Apply a policy to the complete finding list.
validateTrialBalance
    :: HatVal v
    => TrialBalancePolicy
    -> TrialBalanceInput v
    -> Either (NonEmpty (TBFinding v)) (ValidatedTrialBalance v)
validateTrialBalance policy input =
    case filter (findingBlocksPresentation policy) findings of
        [] -> Right (ValidatedTrialBalance
            (_trialBalanceElement input) findings policy
            (_trialBalanceStage input) (maturityRequiredTitles input))
        blocker : blockers -> Left (blocker :| blockers)
  where
    findings = trialBalanceFindings input

-- | Recover the accepted algebra element.
validatedTrialBalance
    :: ValidatedTrialBalance v -> Alg v (HatBase AccountTitles)
validatedTrialBalance (ValidatedTrialBalance alg _ _ _ _) = alg

-- | Recover both blocking-policy-independent facts and permitted warnings.
validatedFindings :: ValidatedTrialBalance v -> [TBFinding v]
validatedFindings (ValidatedTrialBalance _ findings _ _ _) = findings

-- | Recover the policy that admitted this trial balance.
validatedPolicy :: ValidatedTrialBalance v -> TrialBalancePolicy
validatedPolicy (ValidatedTrialBalance _ _ policy _ _) = policy

-- | Recover the processing stage at which the trial balance was admitted.
validatedStage :: ValidatedTrialBalance v -> TrialBalanceStage
validatedStage (ValidatedTrialBalance _ _ _ stage _) = stage

-- | Maturity-allocation obligations that crossed the validation boundary.
validatedMaturityRequiredTitles
    :: ValidatedTrialBalance v -> Set AccountTitles
validatedMaturityRequiredTitles (ValidatedTrialBalance _ _ _ _ titles) = titles

maturityRequiredTitles :: TrialBalanceInput v -> Set AccountTitles
maturityRequiredTitles input = S.fromList
    [ title
    | MaturityEvidenceRequired title <- _reclassificationRules input
    ]

valuesOf :: HatVal v => Alg v (HatBase AccountTitles) -> [v]
valuesOf = foldEntries (\values value _ -> value : values) []

reciprocalFindings
    :: (Eq v)
    => Map AccountTitles (AccountBalance v)
    -> [TBFinding v]
reciprocalFindings balances =
    case (branch, headOffice) of
        (NoBalance, NoBalance) -> []
        (NoBalance, balance) ->
            [StandaloneReciprocalBalance HeadOfficeCurrentAccount balance]
        (balance, NoBalance) ->
            [StandaloneReciprocalBalance BranchCurrentAccount balance]
        _ | reciprocalBalancesMatch branch headOffice -> []
          | otherwise -> [ReciprocalMismatch branch headOffice]
  where
    branch = balanceFor BranchCurrentAccount balances
    headOffice = balanceFor HeadOfficeCurrentAccount balances

reciprocalBalancesMatch :: Eq v => AccountBalance v -> AccountBalance v -> Bool
reciprocalBalancesMatch NoBalance NoBalance = True
reciprocalBalancesMatch (DebitBalance left) (CreditBalance right) = left == right
reciprocalBalancesMatch (CreditBalance left) (DebitBalance right) = left == right
reciprocalBalancesMatch _ _ = False

temporaryFindings
    :: TrialBalanceInput v
    -> Map AccountTitles (AccountBalance v)
    -> [TBFinding v]
temporaryFindings input balances = concatMap finding temporaryTitles
  where
    temporaryTitles = case _trialBalanceStage input of
        BeforeClosing -> titlesWithRole SuspenseOrClearingAccount
        AfterClosing -> filter (/= CashOverShort)
            (titlesWithRole SuspenseOrClearingAccount)
    finding title = case balanceFor title balances of
        NoBalance -> []
        balance -> case M.lookup title (_temporaryBalanceExplanations input) of
            Nothing -> [UnresolvedTemporaryBalance title balance]
            Just explanation
                | T.null (T.strip explanation) ->
                    [BlankTemporaryExplanation title balance]
                | otherwise ->
                    [ExplainedTemporaryBalance title balance explanation]

closingFindings
    :: TrialBalanceInput v
    -> Map AccountTitles (AccountBalance v)
    -> [TBFinding v]
closingFindings input balances = case _trialBalanceStage input of
    BeforeClosing -> []
    AfterClosing ->
        concatMap residual (CashOverShort : titlesWithRole ClosingDevice)
        ++ concatMap derivedResidual
            (titlesWithRole PeriodResult ++ titlesWithRole ReportingSubtotal)
        ++ [ UnclosedNominalBalance title balance
           | (title, balance) <- M.toList balances
           , hasBalance balance
           , isNominal title
           ]
  where
    residual title = case balanceFor title balances of
        NoBalance -> []
        balance -> [ClosingDeviceResidual title balance]
    derivedResidual title = case balanceFor title balances of
        NoBalance -> []
        balance -> [DerivedCoordinateResidual title balance]
    isNominal title = case accountSemantics title of
        Just semantics -> case asemDivisionSemantics semantics of
            StatementDivision Cost -> True
            StatementDivision Revenue -> True
            _ -> False
        Nothing -> False
    hasBalance NoBalance = False
    hasBalance _ = True

abnormalFindings
    :: TrialBalanceInput v
    -> Map AccountTitles (AccountBalance v)
    -> [TBFinding v]
abnormalFindings input balances = concatMap finding (M.toList balances)
  where
    finding (_, NoBalance) = []
    finding (title, balance) = case expectedSide title of
        Nothing -> []
        Just expected
            | balanceSide balance == expected -> []
            | otherwise -> classifyRule title expected balance
    classifyRule title expected balance = case ruleTargets title (balanceSide balance)
            (_reclassificationRules input) of
        [] -> [UnexplainedAbnormalBalance
            title expected balance]
        [target] -> [AbnormalBalanceWithReclassificationRule
            title balance target]
        target : targets -> [AmbiguousReclassification
            title balance (target :| targets)]

maturityFindings
    :: Eq v
    => TrialBalanceInput v
    -> Map AccountTitles (AccountBalance v)
    -> [TBFinding v]
maturityFindings input balances =
    [ MissingMaturityEvidence title
    | MaturityEvidenceRequired title <- _reclassificationRules input
    , balanceFor title balances /= NoBalance
    , S.notMember title (_maturityEvidenceTitles input)
    ]

expectedSide :: AccountTitles -> Maybe Side
expectedSide title = do
    semantics <- accountSemantics title
    if OrdinaryAccount `elem` asemRoles semantics
        then case asemHomeSideSemantics semantics of
            FixedHomeSide side -> Just side
            ContextDependentHomeSide -> Nothing
            NoFixedHomeSide -> Nothing
            NoPostingSide -> Nothing
        else Nothing

ruleTargets
    :: AccountTitles
    -> Side
    -> [ReclassificationRule]
    -> [AccountTitles]
ruleTargets title side = deduplicate S.empty . concatMap targets
  where
    deduplicate _ [] = []
    deduplicate seen (candidate : candidates)
        | candidate `S.member` seen = deduplicate seen candidates
        | otherwise = candidate
            : deduplicate (S.insert candidate seen) candidates
    targets (SideReclassificationRule source trigger candidates)
        | source == title && trigger == side = NE.toList candidates
        | otherwise = []
    targets (MaturityEvidenceRequired _) = []

ruleConfigurationFindings :: TrialBalanceInput v -> [TBFinding v]
ruleConfigurationFindings input =
    [ InapplicableReclassificationRule rule
    | rule <- _reclassificationRules input
    , not (applicable rule)
    ]
  where
    applicable (MaturityEvidenceRequired title) = title /= AccountTitle
    applicable (SideReclassificationRule source trigger candidates) =
        source /= AccountTitle
        && trigger /= Side
        && expectedSide source /= Nothing
        && expectedSide source /= Just trigger
        && all (targetAccepts trigger) (NE.toList candidates)
    targetAccepts trigger target =
        target /= AccountTitle && expectedSide target == Just trigger

titlesWithRole :: AccountRole -> [AccountTitles]
titlesWithRole role =
    [ title
    | title <- concreteAccountTitles
    , Just semantics <- [accountSemantics title]
    , role `elem` asemRoles semantics
    ]