packages feed

exchangealgebra-0.5.0.0: src/ExchangeAlgebra/Reporting/Group.hs

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

{- |
Module      : ExchangeAlgebra.Reporting.Group
Description : Presentation groups and the contra netting policy (Definition 7, Land 3).

A /presentation group/ is a statement block made of one or more __gross__
account titles and the __contra__ (評価勘定) titles that are deducted from
them, rendered as @gross lines → deduction lines → net line@. It is the
mechanism the Definition 7 contra amendment left open: Land 2 fixed the
classification (contra assets are @Assets@ with @isContra = True@) but kept a
display-compatibility shim so that the contra accounts still /appeared/ in the
liability column; this module supplies the real deduction presentation and
lets that shim be removed.

This module is deliberately free of both the CSV writers and the validated
reporting pipeline: it is the __single implementation__ of grouping and
netting, and "ExchangeAlgebra.Write" ('ExchangeAlgebra.Write.bsRows',
'ExchangeAlgebra.Write.plRows') is its consumer. Placing it here rather than
inside "ExchangeAlgebra.Reporting.Presentation" is forced by the type of the
legacy writers: they are total, base-polymorphic functions
(@ExBaseClass b => Alg n b -> [[Text]]@), whereas @present@ is specialised to
@HatBase AccountTitles@, is gated by an opaque
@ValidatedTrialBalance@, and answers in @Either@. Routing @bsRows@ through
@present@ would therefore change it from a pure formatter into a partial,
validation-gated one; duplicating the netting rule in both places is exactly
what this module exists to prevent.

=== Value domain

Statement amounts are held as a @(debit gross, credit gross)@ pair and only
netted at the last moment, into a t'RelativeAmount' carrying a __non-negative__
magnitude plus a direction flag. Stored and rendered magnitudes therefore stay
in \(\mathbb{R}_0^+\), including for soft-invariant value types such as
@MoneyDecimal@. A minus sign exists only in rendered text, never in a stored
t'RelativeAmount'.

=== Block invariant

Within one block the displayed amounts sum to the net line:

> Σ (gross rows) + Σ (subgroup rows) + Σ (deduction rows) = net row

A deduction row is stated /relative to the group's normal side/, so an
ordinary contra balance is already negative there and the sum above is a plain
addition, not a subtraction. A contra that exceeds its gross members simply
makes the net row negative (that is the @contra > parent@ edge case); a group
whose gross members are absent entirely nets to the negated deduction (the
@parent absent@ edge case). Both are handled by the same arithmetic.
-}
module ExchangeAlgebra.Reporting.Group
    ( -- * Groups
      PresentationGroup(..)
    , PresentationGroupDef(..)
    , defaultPresentationGrouping
    , presentationGroupOf
    , lookupGroupDef
    , groupNormalSide
    , groupingForDivisions
      -- * Amounts
    , RelativeAmount(..)
    , relativeTo
    , addGross
      -- * Grouped rows
    , GroupRowKind(..)
    , GroupRow(..)
    , GroupedPresentation(..)
    , presentGroups
    ) where

import qualified Data.List as L
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           ExchangeAlgebra.Algebra (HatVal(zeroValue), nearlyEqScaled)
import           ExchangeAlgebra.Algebra.Base
                     ( AccountDivision(..)
                     , AccountTitles(..)
                     , Side(..)
                     , defaultSide
                     )

------------------------------------------------------------------
-- Groups
------------------------------------------------------------------

-- | Identity of a statement presentation block. These are reporting
-- identities, never posting coordinates: they name a group of account titles,
-- not an account.
data PresentationGroup
    = TradeReceivablesGroup   -- ^ 売上債権 − 貸倒引当金
    | DepreciableAssetsGroup  -- ^ 償却性有形固定資産 − 減価償却累計額
    | NetSalesGroup           -- ^ 総売上高 − 売上割戻
    | NetPurchasesGroup       -- ^ 総仕入高 − 仕入割戻
    | IncomeTaxesGroup        -- ^ 法人税等 − 還付法人税等
    deriving (Show, Eq, Ord, Enum, Bounded)

-- | One block of a statement: the gross titles, the contra titles deducted
-- from them, and (optionally) the group this block rolls up into.
--
-- @pgGross@ and @pgDeductions@ are ordered; rows are emitted in that order so
-- that the output is deterministic. Every @pgDeductions@ member is expected to
-- be a registry contra account of the same division as @pgDivision@, and no
-- @pgGross@ member is; the test-suite pins both, together with the disjointness
-- of the membership sets and the fact that every registry contra account
-- belongs to exactly one group.
data PresentationGroupDef = PresentationGroupDef
    { pgKey        :: PresentationGroup
    , pgDivision   :: AccountDivision
      -- ^ Statement column the block belongs to. Fixes the group's normal
      -- side via 'groupNormalSide'.
    , pgLabel      :: Text
      -- ^ Label of the net line (and of the carried subtotal when this block
      -- rolls up into another one).
    , pgGross      :: [AccountTitles]
    , pgDeductions :: [AccountTitles]
    , pgParent     :: Maybe PresentationGroup
      -- ^ When set, this block's net is carried into the named block as a
      -- 'SubgroupRow' and only the outermost block contributes to the column
      -- total. The default grouping is flat; nesting exists so that a caller
      -- can state a multi-level statement without a second netting rule.
    }
    deriving (Show, Eq)

-- | Normal (deduction-free) side of a group, i.e. the side its net line is
-- stated against.
groupNormalSide :: PresentationGroupDef -> Side
groupNormalSide = defaultSide . pgDivision

-- | The grouping applied by 'ExchangeAlgebra.Write.bsRows' and
-- 'ExchangeAlgebra.Write.plRows' unless a caller supplies its own.
--
-- The membership lists are presentation policy, not algebra: they say which
-- gross titles a single undifferentiated valuation account is deducted from
-- under JGAAP's indirect method (間接控除法). The registry keeps exactly five
-- contra accounts, so there are exactly five blocks. All blocks are flat —
-- a nested default would assert a subtotal structure that the chart does not
-- yet carry.
defaultPresentationGrouping :: [PresentationGroupDef]
defaultPresentationGrouping =
    [ PresentationGroupDef
        { pgKey = TradeReceivablesGroup
        , pgDivision = Assets
        , pgLabel = "TradeReceivablesNet"
        , pgGross =
            [ NotesReceivable
            , AccountsReceivable
            , ElectronicallyRecordedReceivable
            , CreditCardReceivable
            ]
        , pgDeductions = [AllowanceForDoubtfulAccounts]
        , pgParent = Nothing
        }
    , PresentationGroupDef
        { pgKey = DepreciableAssetsGroup
        , pgDivision = Assets
        , pgLabel = "DepreciableAssetsNet"
        , pgGross =
            [ Building
            , Structures
            , Machinery
            , Vehicle
            , ToolsAndInstruments
            , Fixtures
            ]
        , pgDeductions = [AccumulatedDepreciation]
        , pgParent = Nothing
        }
    , PresentationGroupDef
        { pgKey = NetSalesGroup
        , pgDivision = Revenue
        , pgLabel = "NetSales"
        , pgGross = [Sales]
        , pgDeductions = [SalesRebates]
        , pgParent = Nothing
        }
    , PresentationGroupDef
        { pgKey = NetPurchasesGroup
        , pgDivision = Cost
        , pgLabel = "NetPurchases"
        , pgGross = [Purchases]
        , pgDeductions = [PurchaseRebates]
        , pgParent = Nothing
        }
    , PresentationGroupDef
        { pgKey = IncomeTaxesGroup
        , pgDivision = Cost
        , pgLabel = "IncomeTaxesNet"
        , pgGross = [CorporateIncomeTaxes, AdditionalIncomeTaxesForPriorPeriods]
        , pgDeductions = [RefundOfIncomeTaxes]
        , pgParent = Nothing
        }
    ]

-- | Block a title belongs to under 'defaultPresentationGrouping', if any.
presentationGroupOf :: AccountTitles -> Maybe PresentationGroup
presentationGroupOf title = fmap pgKey (L.find member defaultPresentationGrouping)
  where
    member def = title `elem` pgGross def || title `elem` pgDeductions def

-- | Look one block up in the grouping in force.
lookupGroupDef :: [PresentationGroupDef] -> PresentationGroup -> Maybe PresentationGroupDef
lookupGroupDef defs key = L.find ((== key) . pgKey) defs

-- | Restrict a grouping to the statement columns a writer renders. A balance
-- sheet must not net revenue accounts, and a profit and loss statement must
-- not net asset accounts.
groupingForDivisions :: [AccountDivision] -> [PresentationGroupDef] -> [PresentationGroupDef]
groupingForDivisions divisions = filter ((`elem` divisions) . pgDivision)

------------------------------------------------------------------
-- Amounts
------------------------------------------------------------------

-- | An amount stated relative to a group's normal side. @raMagnitude@ is
-- always non-negative; @raBelowZero@ says whether the amount sits on the
-- opposite side (a deduction, or a net that its deductions pushed past zero).
-- Callers render the direction — as a leading @-@, a triangle (△), or a move
-- to the other column — and never negate the value itself.
data RelativeAmount v = RelativeAmount
    { raBelowZero :: !Bool
    , raMagnitude :: !v
    }
    deriving (Show, Eq)

-- | Net a @(debit gross, credit gross)@ pair against a side. Uses the same
-- scale-aware near-equality as @ExchangeAlgebra.Write.netGross@\/@diffRL@, so
-- a balance that nets to zero within tolerance reports a zero magnitude
-- rather than floating-point dust.
relativeTo :: HatVal v => Side -> (v, v) -> RelativeAmount v
relativeTo side (debit, credit)
    | nearlyEqScaled debit credit = RelativeAmount False zeroValue
    | otherwise = case side of
        Credit -> orient credit debit
        -- 'defaultSide' only ever answers Debit or Credit, so the wildcard
        -- cannot arise from a group; orient it like Debit rather than
        -- inventing a third direction.
        Debit  -> orient debit credit
        Side   -> orient debit credit
  where
    orient toward away
        | toward >= away = RelativeAmount False (toward - away)
        | otherwise      = RelativeAmount True  (away - toward)

-- | Accumulate @(debit gross, credit gross)@ pairs.
addGross :: Num v => (v, v) -> (v, v) -> (v, v)
addGross (leftDebit, leftCredit) (rightDebit, rightCredit) =
    (leftDebit + rightDebit, leftCredit + rightCredit)

------------------------------------------------------------------
-- Grouped rows
------------------------------------------------------------------

-- | What one row of a block states.
data GroupRowKind
    = GrossRow AccountTitles
      -- ^ A constituent account, before deduction.
    | SubgroupRow PresentationGroup
      -- ^ The net of a nested block, carried into this one.
    | DeductionRow AccountTitles
      -- ^ A contra account. Its amount is already stated relative to the
      -- group's normal side, so it is normally 'raBelowZero'.
    | NetRow PresentationGroup
      -- ^ The block's net. Equals the sum of the rows above it.
    deriving (Show, Eq)

data GroupRow v = GroupRow
    { grKind   :: GroupRowKind
    , grAmount :: RelativeAmount v
    }
    deriving (Show, Eq)

-- | Result of applying a grouping to a set of per-title balances.
data GroupedPresentation v = GroupedPresentation
    { gpBlocks     :: [(PresentationGroupDef, [GroupRow v])]
      -- ^ Blocks in render order: nested blocks precede the block they roll
      -- up into, roots follow the order of the grouping list.
    , gpRootTotals :: Map AccountDivision (v, v)
      -- ^ Per-division @(debit, credit)@ contribution of the __outermost__
      -- blocks only. A caller adds this to the ungrouped part of the column;
      -- adding nested blocks as well would double count.
    , gpConsumed   :: Set AccountTitles
      -- ^ Titles the blocks have taken over. The caller must not render these
      -- again through its ordinary per-entry path.
    }
    deriving (Show, Eq)

-- | Apply a grouping to per-title @(debit gross, credit gross)@ totals.
--
-- A block is /active/ when one of its own contra titles carries non-zero gross
-- activity, or when a nested block is active: a group with nothing to deduct is
-- not a group, and leaving it inactive is what keeps output identical to the
-- pre-amendment behaviour for charts that contain no contra posting. Testing
-- gross activity rather than the net balance also keeps a fully offset contra
-- title inside its group instead of leaking one side into the ordinary rows.
--
-- Complexity: O(g · m + g²) for @g@ blocks of @m@ members (the grouping is a
-- fixed, small table).
presentGroups
    :: HatVal v
    => [PresentationGroupDef]
    -> Map AccountTitles (v, v)
    -> GroupedPresentation v
presentGroups defs balances = GroupedPresentation
    { gpBlocks = blocks
    , gpRootTotals = rootTotals
    , gpConsumed = consumed
    }
  where
    grossOf title = M.findWithDefault (zeroValue, zeroValue) title balances
    hasBalance title = not (uncurry nearlyEqScaled (grossOf title))
    hasActivity title = case M.lookup title balances of
        Nothing -> False
        Just (debit, credit) ->
            not (nearlyEqScaled debit zeroValue)
            || not (nearlyEqScaled credit zeroValue)

    memberTitles def = pgGross def ++ pgDeductions def

    -- Children of a block, in grouping order. 'pgParent' is followed with a
    -- visited set so that a malformed (cyclic) grouping is truncated instead
    -- of diverging.
    childrenOf visited key =
        [ def
        | def <- defs
        , pgParent def == Just key
        , not (S.member (pgKey def) visited)
        ]

    ownPair def = L.foldl' addGross (zeroValue, zeroValue)
        (map grossOf (memberTitles def))

    subtreePair visited def = L.foldl' addGross (ownPair def)
        (map (subtreePair visited') (activeChildrenOf visited' def))
      where
        visited' = S.insert (pgKey def) visited

    ownActive def = any hasActivity (pgDeductions def)

    isActive visited def = ownActive def
        || any (isActive visited') (childrenOf visited' (pgKey def))
      where
        visited' = S.insert (pgKey def) visited

    activeChildrenOf visited def =
        [ child
        | child <- childrenOf visited (pgKey def)
        , pgDivision child == pgDivision def
        , isActive visited child
        ]

    -- A block is a root when it has no usable parent, or when it is the stable
    -- representative of a malformed parent cycle. Cross-division parents are
    -- unusable because a carried subtotal and its root total must share a
    -- statement column.
    isRoot def = case pgParent def of
        Nothing -> True
        Just parent -> case lookupGroupDef defs parent of
            Nothing -> True
            Just parentDef
                | pgDivision parentDef /= pgDivision def -> True
                | otherwise -> case parentCycle def of
                    Nothing -> False
                    Just keys -> pgKey def == minimum keys

    parentCycle def = follow [] (pgKey def)
      where
        follow path key
            | key `elem` path = Just (dropWhile (/= key) path)
            | otherwise = case lookupGroupDef defs key >>= pgParent of
                Nothing -> Nothing
                Just parent -> follow (path ++ [key]) parent

    activeRoots = [def | def <- defs, isRoot def, isActive S.empty def]

    -- Depth-first: nested blocks are rendered before the block that carries
    -- their net, so a reader meets a subtotal only after its constituents.
    blocksOf visited def =
        concatMap (blocksOf visited') activeChildren
        ++ [(def, rowsOf visited' def activeChildren)]
      where
        visited' = S.insert (pgKey def) visited
        activeChildren = activeChildrenOf visited' def

    rowsOf visited def activeChildren =
        [ GroupRow (GrossRow title) (relative (grossOf title))
        | title <- pgGross def, hasBalance title ]
        ++
        [ GroupRow (SubgroupRow (pgKey child)) (relative (subtreePair visited child))
        | child <- activeChildren ]
        ++
        [ GroupRow (DeductionRow title) (relative (grossOf title))
        | title <- pgDeductions def, hasBalance title ]
        ++
        [ GroupRow (NetRow (pgKey def)) (relative (subtreePair visited def)) ]
      where
        relative = relativeTo (groupNormalSide def)

    blocks = concatMap (blocksOf S.empty) activeRoots

    rootTotals = M.fromListWith addGross
        [ (pgDivision def, subtreePair S.empty def) | def <- activeRoots ]

    consumed = S.fromList
        [ title | (def, _) <- blocks, title <- memberTitles def ]