packages feed

exchangealgebra-0.5.0.0: src/ExchangeAlgebra/Consolidation/Worksheet.hs

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

{- |
Module      : ExchangeAlgebra.Consolidation.Worksheet
Description : Validation boundary for consolidation worksheets.

A consolidation worksheet is not an entity's journal. Its adjustments are
therefore accepted as exchange-algebra elements, while this module checks that
each adjustment is independently balanced before any aggregation takes place.
This prevents two malformed adjustments from cancelling each other only at the
worksheet total.

The validated value retains source trial balances and adjustments separately.
'combinedWorksheet' is an explicit projection and uses redundant-algebra
addition, so same-base postings remain separate sequences until a caller asks
for normalization. The projection itself does not carry per-posting provenance;
use 'validatedSources' and 'validatedAdjustments' when that provenance is
required.

Validation uses exact equality, consistently with the checked-conversion
boundary. Prefer an exact value type such as @MoneyDecimal@ for deterministic
worksheet validation rather than a floating-point representation.
-}
module ExchangeAlgebra.Consolidation.Worksheet
    ( PeriodResult(..)
    , AccountBalance(..)
    , LinkField(..)
    , TrialBalanceSource(..)
    , WorksheetAdjustment(..)
    , WorksheetLinkage(..)
    , WorksheetInput(..)
    , WorksheetError(..)
    , ValidatedWorksheet
    , validateConsolidationWorksheet
    , validatedSources
    , validatedAdjustments
    , validatedLinkage
    , combinedWorksheet
    ) where

import           Data.List.NonEmpty (NonEmpty(..))
import qualified Data.List as L
import qualified Data.Map.Strict as M

import           ExchangeAlgebra.Algebra
                     ( Alg
                     , Exchange(decL, decR)
                     , HatVal(..)
                     , Redundant((.+), norm)
                     , bases
                     , vals
                     )
import           ExchangeAlgebra.Accounting.PostingPolicy
                     ( ProcessingContext(ConsolidationWorksheet)
                     , postingAllowedIn
                     , postingCapabilityFor
                     )
import           ExchangeAlgebra.Algebra.Base
                     ( AccountTitles(..)
                     , Hat(..)
                     , HatBase((:<))
                     , PostingCapability
                     )
import           ExchangeAlgebra.Reporting.Metric (PeriodResult(..))
import           ExchangeAlgebra.TrialBalance.Balance
                     ( AccountBalance(..)
                     , balanceAmount
                     , balancePair
                     )

-- | Named linkage fields, used when reporting an invalid non-negative amount.
data LinkField
  = ProfitOrLossNetIncome
  | ProfitOrLossNetIncomeAttributableToOwners
  | StatementOfChangesNetIncomeAttributableToOwners
  | OpeningRetainedEarnings
  | RetainedEarningsDividends
  | StatementOfChangesClosingRetainedEarnings
  | BalanceSheetRetainedEarnings
  | OpeningNonControllingInterests
  | NonControllingInterestsPeriodShare
  | NonControllingInterestsDividends
  | StatementOfChangesClosingNonControllingInterests
  | BalanceSheetNonControllingInterests
  deriving (Show, Eq, Ord)

-- | One entity trial balance and its provenance identifier. A source trial
-- balance is accepted as historical input and is therefore not restricted by
-- a processing-context posting-capability gate; it is still checked for
-- structural coordinates, valid values, and exact balance.
data TrialBalanceSource source v = TrialBalanceSource
    { _sourceId           :: source
    , _sourceTrialBalance :: Alg v (HatBase AccountTitles)
    }
    deriving (Show)

-- | One atomic worksheet adjustment. The algebra element need not originate
-- from a journal entry, but it must be independently balanced and use only
-- coordinates admitted by the consolidation processing context.
data WorksheetAdjustment source adjustment v = WorksheetAdjustment
    { _adjustmentId        :: adjustment
    , _adjustmentSourceIds :: NonEmpty source
    , _adjustmentElement   :: Alg v (HatBase AccountTitles)
    }
    deriving (Show)

-- | Cross-column equations for the statement of profit or loss (P/L),
-- statement of changes in equity (S/S), and balance sheet (B/S).
--
-- The P/L total is checked against its owners-of-parent and NCI attribution,
-- and the owners amount is then linked to S/S. Retained earnings and NCI
-- balances keep credit/debit position structural, so accumulated deficits do
-- not require negative scalar values.
--
-- These values are caller-supplied worksheet-column facts. This Land 2b type
-- validates their equations but cannot derive them from the unlabelled 'Alg'
-- alone; anchoring statement columns to a reporting projection is a later
-- reporting-layer responsibility. The roll-forwards model period result and
-- dividends only; worksheets with OCI, ownership changes, reserve transfers,
-- or other equity movements require a later reporting-layer extension.
data WorksheetLinkage v = WorksheetLinkage
    { _profitOrLossNetIncome                    :: PeriodResult v
    , _profitOrLossNetIncomeAttributableToOwners :: PeriodResult v
    , _statementOfChangesNetIncomeAttributableToOwners :: PeriodResult v
    , _openingRetainedEarnings                  :: AccountBalance v
    , _retainedEarningsDividends                :: v
    , _statementOfChangesClosingRetainedEarnings :: AccountBalance v
    , _balanceSheetRetainedEarnings             :: AccountBalance v
    , _openingNonControllingInterests           :: AccountBalance v
    , _nonControllingInterestsPeriodShare       :: PeriodResult v
    , _nonControllingInterestsDividends         :: v
    , _statementOfChangesClosingNonControllingInterests
        :: AccountBalance v
    , _balanceSheetNonControllingInterests      :: AccountBalance v
    }
    deriving (Show, Eq)

-- | Unvalidated consolidation worksheet input.
data WorksheetInput source adjustment v = WorksheetInput
    { _worksheetSources     :: NonEmpty (TrialBalanceSource source v)
    , _worksheetAdjustments :: [WorksheetAdjustment source adjustment v]
    , _worksheetLinkage     :: WorksheetLinkage v
    }
    deriving (Show)

-- | Every rejected invariant is tied to a source, adjustment, or linkage.
data WorksheetError source adjustment v
  = DuplicateSourceId source
  | DuplicateAdjustmentId adjustment
  | DuplicateAdjustmentSource adjustment source
  | UnknownAdjustmentSource adjustment source
  | InvalidSourceValue source v
  | WildcardSourceAccount source
  | WildcardSourceSide source
  | UnbalancedSourceTrialBalance source v v
  | EmptyAdjustment adjustment
  | InvalidAdjustmentValue adjustment v
  | WildcardAdjustmentAccount adjustment
  | WildcardAdjustmentSide adjustment
  | AdjustmentPostingNotAllowed adjustment AccountTitles PostingCapability
  | UnbalancedAdjustment adjustment v v
  | InvalidLinkAmount LinkField v
  | NetIncomeAttributionMismatch v v
  | OwnersPeriodResultLinkMismatch (PeriodResult v) (PeriodResult v)
  | RetainedEarningsRollForwardMismatch v v
  | BalanceSheetRetainedEarningsMismatch
        (AccountBalance v) (AccountBalance v)
  | NonControllingInterestsRollForwardMismatch v v
  | BalanceSheetNonControllingInterestsMismatch
        (AccountBalance v) (AccountBalance v)
  deriving (Show, Eq)

-- | A worksheet whose provenance, atomic balance, processing capability, and
-- cross-column linkage have all passed validation. The constructor is hidden.
data ValidatedWorksheet source adjustment v = ValidatedWorksheet
    (NonEmpty (TrialBalanceSource source v))
    [WorksheetAdjustment source adjustment v]
    (WorksheetLinkage v)

-- | Validate a consolidation worksheet without normalizing any algebra term.
validateConsolidationWorksheet
    :: (HatVal v, Ord source, Ord adjustment)
    => WorksheetInput source adjustment v
    -> Either (NonEmpty (WorksheetError source adjustment v))
              (ValidatedWorksheet source adjustment v)
validateConsolidationWorksheet input =
    case errors of
        []     -> Right (ValidatedWorksheet sources adjustments linkage)
        e : es -> Left (e :| es)
  where
    sources = _worksheetSources input
    adjustments = _worksheetAdjustments input
    linkage = _worksheetLinkage input
    errors = duplicateIdErrors sources adjustments
        ++ concatMap validateSource (toListNE sources)
        ++ concatMap (validateAdjustment (sourceIds sources)) adjustments
        ++ validateWorksheetLinkage linkage

-- | Recover the source trial balances with their provenance intact.
validatedSources
    :: ValidatedWorksheet source adjustment v
    -> NonEmpty (TrialBalanceSource source v)
validatedSources (ValidatedWorksheet sources _ _) = sources

-- | Recover the atomic adjustments with their provenance intact.
validatedAdjustments
    :: ValidatedWorksheet source adjustment v
    -> [WorksheetAdjustment source adjustment v]
validatedAdjustments (ValidatedWorksheet _ adjustments _) = adjustments

-- | Recover the validated cross-column linkage values.
validatedLinkage
    :: ValidatedWorksheet source adjustment v
    -> WorksheetLinkage v
validatedLinkage (ValidatedWorksheet _ _ linkage) = linkage

-- | Explicitly combine source trial balances and atomic adjustments.
-- Redundant-algebra addition preserves same-base posting sequences. This is a
-- calculation projection; inspect the validated source and adjustment lists
-- when provenance is required.
combinedWorksheet
    :: (HatVal v)
    => ValidatedWorksheet source adjustment v
    -> Alg v (HatBase AccountTitles)
combinedWorksheet (ValidatedWorksheet sources adjustments _) =
    L.foldl' (.+) mempty
        (map _sourceTrialBalance (toListNE sources)
         ++ map _adjustmentElement adjustments)

duplicateIdErrors
    :: (Ord source, Ord adjustment)
    => NonEmpty (TrialBalanceSource source v)
    -> [WorksheetAdjustment source adjustment v]
    -> [WorksheetError source adjustment v]
duplicateIdErrors sources adjustments =
    [ DuplicateSourceId sourceId
    | (sourceId, count) <- M.toList sourceCounts
    , count > 1
    ]
    ++ [ DuplicateAdjustmentId adjustmentId
       | (adjustmentId, count) <- M.toList adjustmentCounts
       , count > 1
       ]
  where
    sourceCounts = M.fromListWith (+)
        [ (_sourceId source, 1 :: Int) | source <- toListNE sources ]
    adjustmentCounts = M.fromListWith (+)
        [ (_adjustmentId adjustment, 1 :: Int) | adjustment <- adjustments ]

sourceIds :: Ord source
          => NonEmpty (TrialBalanceSource source v)
          -> M.Map source ()
sourceIds = M.fromList . map (\source -> (_sourceId source, ())) . toListNE

validateSource
    :: HatVal v
    => TrialBalanceSource source v
    -> [WorksheetError source adjustment v]
validateSource source = structuralErrors ++ balanceErrors
  where
    sourceId = _sourceId source
    alg = _sourceTrialBalance source
    structuralErrors =
        [ InvalidSourceValue sourceId value
        | value <- vals alg
        , isErrorValue value
        ]
        ++ [ WildcardSourceAccount sourceId
           | _ :< AccountTitle <- bases alg
           ]
        ++ [ WildcardSourceSide sourceId
           | HatNot :< _ <- bases alg
           ]
    balanceErrors
        | not (null structuralErrors) = []
        | debit /= credit = [UnbalancedSourceTrialBalance sourceId debit credit]
        | otherwise = []
      where
        (debit, credit) = sideTotals alg

validateAdjustment
    :: (HatVal v, Ord source)
    => M.Map source ()
    -> WorksheetAdjustment source adjustment v
    -> [WorksheetError source adjustment v]
validateAdjustment knownSources adjustment =
    provenanceErrors ++ structuralErrors ++ balanceErrors
  where
    adjustmentId = _adjustmentId adjustment
    alg = _adjustmentElement adjustment
    structuralErrors =
        [ EmptyAdjustment adjustmentId | null (vals alg) ]
        ++ [ InvalidAdjustmentValue adjustmentId value
           | value <- vals alg
           , isErrorValue value || not (value > 0)
           ]
        ++ [ WildcardAdjustmentAccount adjustmentId
           | _ :< AccountTitle <- bases alg
           ]
        ++ [ WildcardAdjustmentSide adjustmentId
           | HatNot :< _ <- bases alg
           ]
        ++ [ AdjustmentPostingNotAllowed adjustmentId account capability
           | _ :< account <- bases alg
           , account /= AccountTitle
           , let capability = postingCapabilityFor account
           , not (postingAllowedIn ConsolidationWorksheet capability)
           ]
    refs = toListNE (_adjustmentSourceIds adjustment)
    refCounts = M.fromListWith (+) [ (sourceId, 1 :: Int) | sourceId <- refs ]
    provenanceErrors =
        [ DuplicateAdjustmentSource adjustmentId sourceId
        | (sourceId, count) <- M.toList refCounts
        , count > 1
        ]
        ++ [ UnknownAdjustmentSource adjustmentId sourceId
           | sourceId <- M.keys refCounts
           , M.notMember sourceId knownSources
           ]
    balanceErrors
        | not (null structuralErrors) = []
        | debit /= credit = [UnbalancedAdjustment adjustmentId debit credit]
        | otherwise = []
      where
        (debit, credit) = sideTotals alg

validateWorksheetLinkage
    :: HatVal v
    => WorksheetLinkage v
    -> [WorksheetError source adjustment v]
validateWorksheetLinkage linkage
    | not (null invalidAmounts) = invalidAmounts
    | otherwise = attributionErrors
        ++ ownersLinkErrors
        ++ retainedEarningsErrors
        ++ balanceSheetErrors
        ++ nciErrors
  where
    invalidAmounts =
        [ InvalidLinkAmount field value
        | (field, value) <- linkageAmounts linkage
        , isErrorValue value
        ]

    totalResult = _profitOrLossNetIncome linkage
    ownersPlResult = _profitOrLossNetIncomeAttributableToOwners linkage
    ownersSsResult = _statementOfChangesNetIncomeAttributableToOwners linkage
    nciResult = _nonControllingInterestsPeriodShare linkage

    (attributionLeft, attributionRight) = attributionSides
        totalResult ownersPlResult nciResult
    attributionErrors =
        [ NetIncomeAttributionMismatch attributionLeft attributionRight
        | attributionLeft /= attributionRight
        ]

    ownersLinkErrors =
        [ OwnersPeriodResultLinkMismatch ownersPlResult ownersSsResult
        | not (periodResultEquivalent ownersPlResult ownersSsResult)
        ]

    (retainedLeft, retainedRight) = rollForwardSides
        (_openingRetainedEarnings linkage)
        ownersSsResult
        (_retainedEarningsDividends linkage)
        (_statementOfChangesClosingRetainedEarnings linkage)
    retainedEarningsErrors =
        [ RetainedEarningsRollForwardMismatch retainedLeft retainedRight
        | retainedLeft /= retainedRight
        ]

    ssClosing = _statementOfChangesClosingRetainedEarnings linkage
    bsClosing = _balanceSheetRetainedEarnings linkage
    balanceSheetErrors =
        [ BalanceSheetRetainedEarningsMismatch ssClosing bsClosing
        | not (balancePositionEquivalent ssClosing bsClosing)
        ]

    (nciLeft, nciRight) = rollForwardSides
        (_openingNonControllingInterests linkage)
        nciResult
        (_nonControllingInterestsDividends linkage)
        (_statementOfChangesClosingNonControllingInterests linkage)
    nciErrors =
        [ NonControllingInterestsRollForwardMismatch nciLeft nciRight
        | nciLeft /= nciRight
        ]
        ++ [ BalanceSheetNonControllingInterestsMismatch
                ssClosingNci bsNci
           | not (balancePositionEquivalent ssClosingNci bsNci)
           ]
    ssClosingNci =
        _statementOfChangesClosingNonControllingInterests linkage
    bsNci = _balanceSheetNonControllingInterests linkage

linkageAmounts :: Num v => WorksheetLinkage v -> [(LinkField, v)]
linkageAmounts linkage =
    [ (ProfitOrLossNetIncome, periodAmount (_profitOrLossNetIncome linkage))
    , (ProfitOrLossNetIncomeAttributableToOwners,
        periodAmount (_profitOrLossNetIncomeAttributableToOwners linkage))
    , (StatementOfChangesNetIncomeAttributableToOwners,
        periodAmount (_statementOfChangesNetIncomeAttributableToOwners linkage))
    , (OpeningRetainedEarnings,
        balanceAmount (_openingRetainedEarnings linkage))
    , (RetainedEarningsDividends, _retainedEarningsDividends linkage)
    , (StatementOfChangesClosingRetainedEarnings,
        balanceAmount (_statementOfChangesClosingRetainedEarnings linkage))
    , (BalanceSheetRetainedEarnings,
        balanceAmount (_balanceSheetRetainedEarnings linkage))
    , (OpeningNonControllingInterests,
        balanceAmount (_openingNonControllingInterests linkage))
    , (NonControllingInterestsPeriodShare,
        periodAmount (_nonControllingInterestsPeriodShare linkage))
    , (NonControllingInterestsDividends,
        _nonControllingInterestsDividends linkage)
    , (StatementOfChangesClosingNonControllingInterests,
        balanceAmount
            (_statementOfChangesClosingNonControllingInterests linkage))
    , (BalanceSheetNonControllingInterests,
        balanceAmount (_balanceSheetNonControllingInterests linkage))
    ]

periodAmount :: Num v => PeriodResult v -> v
periodAmount (PeriodProfit value) = value
periodAmount (PeriodLoss value) = value
periodAmount PeriodBreakEven = 0

periodSides :: Num v => PeriodResult v -> (v, v)
periodSides (PeriodProfit value) = (value, 0)
periodSides (PeriodLoss value) = (0, value)
periodSides PeriodBreakEven = (0, 0)

-- Total result equals owners' attribution plus NCI attribution. Moving all
-- loss-side values across the equation avoids signed scalars.
attributionSides :: Num v
                 => PeriodResult v
                 -> PeriodResult v
                 -> PeriodResult v
                 -> (v, v)
attributionSides total owners nci =
    (totalProfit + ownersLoss + nciLoss,
     totalLoss + ownersProfit + nciProfit)
  where
    (totalProfit, totalLoss) = periodSides total
    (ownersProfit, ownersLoss) = periodSides owners
    (nciProfit, nciLoss) = periodSides nci

periodResultEquivalent :: (Eq v, Num v)
                       => PeriodResult v -> PeriodResult v -> Bool
periodResultEquivalent left right =
    leftProfit + rightLoss == leftLoss + rightProfit
  where
    (leftProfit, leftLoss) = periodSides left
    (rightProfit, rightLoss) = periodSides right

balancePositionEquivalent :: (Eq v, Num v)
                          => AccountBalance v -> AccountBalance v -> Bool
balancePositionEquivalent left right =
    leftCredit + rightDebit == leftDebit + rightCredit
  where
    (leftDebit, leftCredit) = balancePair left
    (rightDebit, rightCredit) = balancePair right

rollForwardSides :: Num v
                 => AccountBalance v
                 -> PeriodResult v
                 -> v
                 -> AccountBalance v
                 -> (v, v)
rollForwardSides opening result dividends closing = case result of
    PeriodProfit amount ->
        (openingCredit + amount + closingDebit,
         openingDebit + dividends + closingCredit)
    PeriodLoss amount ->
        (openingCredit + closingDebit,
         openingDebit + amount + dividends + closingCredit)
    PeriodBreakEven ->
        (openingCredit + closingDebit,
         openingDebit + dividends + closingCredit)
  where
    (openingDebit, openingCredit) = balancePair opening
    (closingDebit, closingCredit) = balancePair closing

sideTotals :: HatVal v => Alg v (HatBase AccountTitles) -> (v, v)
sideTotals alg = (norm (decL alg), norm (decR alg))

toListNE :: NonEmpty a -> [a]
toListNE (x :| xs) = x : xs