exchangealgebra-0.6.0.0: src/ExchangeAlgebra/Accounting/Closing.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}
{- |
Module : ExchangeAlgebra.Accounting.Closing
Description : Additional closing entries and ordered settlement pairs.
This Accounting layer selects closing accounts and constructs additional
entries using generic Algebra operations. Start with 'closingEntries' for
an existing ledger; use 'settleEntries' for signed nets already computed
by the caller. Both preserve the non-account coordinates of each source.
Import this module qualified alongside "ExchangeAlgebra.Algebra.Core":
> import qualified ExchangeAlgebra.Accounting.Closing as Closing
> import ExchangeAlgebra.Algebra.Core ((.+))
'closingEntries' returns only additional entries. For @Right entries =
Closing.closingEntries ledger@, add them with @ledger .+ entries@.
Supply only postings through the closing date, including earlier closing
entries. For a journal, first use @toAlg@ from "ExchangeAlgebra.Journal.Core",
then attach the closing note to the returned entries with @(.|)@.
The posting pairs implement Definition 9 and retain the original audit trail.
== Laws
The observation @obs@ is the map from complete bases (including Hat\/Not) to
summed values after @bar@; it does not observe the singleton versus composite
constructor or sequence order. Relative tolerance means a per-base difference
of at most @1e-9 * max (abs x) (abs y)@, treating absent bases as zero.
=== L2: closing
* Subject: 'closingEntries'.
* Preconditions: valid Hat\/Not postings; the ledger includes only entries
through the closing date and the call returns @Right entries@.
* Relation: adding the entries closes each eligible complete base exactly.
Each reversal has an equal retained-earnings posting on the side selected
by 'closingSide'; non-closing bases and non-account coordinates are retained.
* Observation: exact net per complete base, including 'RetainedEarnings'.
* Tolerance: none; the 'HatVal' accumulator and 'splitAccum' preserve exact nets.
* Instances: 'Double' and @MoneyDecimal@ with 'ExBaseClass' bases.
=== L3: balance
* Subject: generated transfer and closing entries.
* Preconditions: valid postings, concrete accounts, @Relabel@ rules whose
actual target and source have equal 'whichSide'; finite totals. The
transfer or closing operation being observed returns @Right entries@.
* Relation: debit total equals credit total. Closing entries also satisfy
this relation, because 'closingSide' follows the account's PIMO direction.
* Observation: @norm (decL entries)@ and @norm (decR entries)@.
* Tolerance: relative @1e-9@ for 'Double'; exact for @MoneyDecimal@.
* Instances: 'Double' and @MoneyDecimal@ with 'ExBaseClass' bases.
A relabel from @Not :< Cash@ to @Not :< Sales@ violates L3's side
precondition: its cancellation and destination are both credits.
=== L5: non-negativity
* Subject: generated transfer and closing entries.
* Preconditions: non-negative valid input values; the transfer or closing
operation being observed returns @Right entries@.
* Relation: every generated value is greater than or equal to zero.
* Observation: posting values, before any @bar@.
* Tolerance: none.
* Instances: all lawful 'HatVal' and 'HatBaseClass' instances (closing also
requires 'ExBaseClass'). Hat reversal is never numeric negation.
-}
module ExchangeAlgebra.Accounting.Closing ( ClosingSide(..)
, closingSide
, closingEntries
, SettleRule
, retainedEarningsRule
, SettlementBatch
, SignedNet
, SettleError(..)
, settleEntries
, settlementSteps
) where
import Data.Binary (Binary(..))
import Data.Hashable (Hashable)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (mapMaybe)
import GHC.Generics (Generic)
import ExchangeAlgebra.Algebra.Core ( Alg(..)
, Redundant((.+))
, (.@)
, foldEntries
)
import ExchangeAlgebra.Algebra.Base.Representation (HatBaseClass(..), Hat(..))
import ExchangeAlgebra.Algebra.Value.Class (HatVal(..), isErrorValue)
import ExchangeAlgebra.Accounting.Exchange (ExBaseClass(..))
import ExchangeAlgebra.Accounting.Account ( AccountTitles(..)
, accountSpec
, asClosing
, ClosingRule(..)
, classifyAccountDivision
, classifyAccountContra
, pimoFromDivision
, pimoFlip
, PIMO(..)
)
import ExchangeAlgebra.Algebra.Transfer.Representation (TransferApplyError(..))
-- * Closing entries
-- | The retained-earnings side selected by a closing account's PIMO direction.
data ClosingSide
= ClosingKeep -- ^ IN: retain Hat/Not (revenue or contra cost).
| ClosingFlip -- ^ OUT: reverse Hat/Not (cost or contra revenue).
deriving (Eq, Ord, Show, Enum, Bounded, Generic)
instance Binary ClosingSide
instance Hashable ClosingSide
-- | Select the retained-earnings direction for an account eligible for closing.
-- Registry NoClose accounts, including NetIncome and NetLoss, are excluded.
closingSide :: AccountTitles -> Maybe ClosingSide
closingSide title = case accountSpec title of
Nothing -> Nothing
Just spec -> case asClosing spec of
NoClose -> Nothing
CloseByDivision -> case direction of
IN -> Just ClosingKeep
OUT -> Just ClosingFlip
_ -> Nothing
where
direction
| classifyAccountContra title = pimoFlip ordinaryDirection
| otherwise = ordinaryDirection
ordinaryDirection = pimoFromDivision (classifyAccountDivision title)
-- | Generate closing entries that close each eligible base exactly.
-- Supply only postings through the closing date. For each base whose account
-- has a 'closingSide', the Hat and Not values are accumulated without
-- rounding ('HatVal' accumulator); the exact net (Not minus Hat) decides the
-- winning side. A zero net generates nothing. Otherwise 'splitAccum' splits
-- the exact magnitude into finite positive values @d1 >= d2 >= ... >= dk@
-- whose exact sum is the net, and each @di@ becomes one pair: the reversal of
-- the base and the posting to 'RetainedEarnings'. @k = 1@ whenever the net is
-- representable in the value type. The pairs are not compressed back into one
-- pair, so the exact net of every closed base after the entries is zero.
-- This folds the source sequences (audit detail) per base, but preserves
-- separate target postings from distinct source accounts. It does not call
-- @bar@. All non-account axes are retained, and no note is attached.
--
-- 'NonFiniteBalance' is returned when an eligible base has a posting value
-- for which 'valueError' is 'Just' (non-finite or negative), or when its net
-- cannot be split ('splitAccum' returns 'Nothing': the rounded net is not
-- finite). Side sums beyond the value type's range succeed when the exact
-- net is in range. The reported base is the first in ascending 'Ord' order
-- after normalizing Hat/Not to Not. No partial result is returned.
--
-- Laws (exact arithmetic; observed with 'exactAccum'): for every closed base
-- the exact net of @x .+ c@ is zero, where @Right c = closingEntries x@;
-- the entries of @c@ have equal exact debit and credit sums; and
-- @closingEntries (x .+ c) == Right Zero@.
-- Balance collection is O(s log b), for s postings and b eligible bases.
closingEntries :: forall v b. (HatVal v, ExBaseClass b)
=> Alg v b -> Either (TransferApplyError v b) (Alg v b)
closingEntries = Map.foldlWithKey' close (Right Zero) . foldEntries collect Map.empty
where
collect :: Map b (Bool, Accum v, Accum v) -> v -> b
-> Map b (Bool, Accum v, Accum v)
collect balances value source = case (hat source, closingSide (getAccountTitle source)) of
(Hat, Just _) -> Map.insertWith addTotals (toNot source)
(isErrorValue value, addAccum value emptyAccum, emptyAccum) balances
(Not, Just _) -> Map.insertWith addTotals source
(isErrorValue value, emptyAccum, addAccum value emptyAccum) balances
_ -> balances
addTotals :: (Bool, Accum v, Accum v) -> (Bool, Accum v, Accum v)
-> (Bool, Accum v, Accum v)
addTotals (invalid, hatValue, notValue) (failed, hatTotal, notTotal) =
(invalid || failed, mergeAccum hatValue hatTotal, mergeAccum notValue notTotal)
close :: Either (TransferApplyError v b) (Alg v b)
-> b
-> (Bool, Accum v, Accum v)
-> Either (TransferApplyError v b) (Alg v b)
close result source (invalid, hatTotal, notTotal) = do
entries <- result
additions <- netEntries source invalid (mergeAccum notTotal (negateAccum hatTotal))
pure (entries .+ additions)
netEntries :: b -> Bool -> Accum v -> Either (TransferApplyError v b) (Alg v b)
netEntries source invalid amount
| invalid = Left (NonFiniteBalance source)
| otherwise = case signAccum amount of
Just EQ -> Right Zero
Just LT -> splitPairs source (toHat source) (negateAccum amount)
Just GT -> splitPairs source source amount
Nothing -> Left (NonFiniteBalance source)
splitPairs :: b -> b -> Accum v -> Either (TransferApplyError v b) (Alg v b)
splitPairs source balanceBase amount = case splitAccum amount of
Nothing -> Left (NonFiniteBalance source)
Just values -> case closingSide (getAccountTitle balanceBase) of
Nothing -> Right Zero
Just side -> Right $ mconcat
[ closingPairBy (targetSide side) RetainedEarnings value balanceBase
| value <- values ]
targetSide :: ClosingSide -> b -> b
targetSide side = case side of
ClosingKeep -> id
ClosingFlip -> revHat
-- | A closing rule containing only its destination account title.
-- The private constructor restricts destinations to accounts compatible with
-- the closing directions.
newtype SettleRule = SettleRule AccountTitles
-- | Close eligible accounts into 'RetainedEarnings', preserving all other axes.
retainedEarningsRule :: SettleRule
retainedEarningsRule = SettleRule RetainedEarnings
-- | Settlement pairs in strictly ascending source-base order.
-- A model returns only @Posting@ built with @entry@ and 'Monoid'. Settlement
-- magnitudes can exceed the @Posted@ bound, so this type has no conversion to
-- @Posting@ and no 'Semigroup' or 'Monoid' instance.
--
-- Record each pair separately in source-base order. Combining all pairs first
-- can change the order of additions to a shared destination. For example,
-- sequential increments @T, 1, -T@ with @T = 2^53@ give 0 in 'Double', whereas
-- adding @T, -T, 1@ gives 1.
newtype SettlementBatch b = SettlementBatch [(BasePart b, Alg Double b)]
-- | A signed Not-minus-Hat net. This is not a non-negative posting magnitude.
-- As a type synonym, it does not enforce finiteness or any numeric range.
type SignedNet = Double
-- | The first non-finite input net, identified by its complete base coordinates.
data SettleError b = NonFiniteNet (BasePart b)
deriving instance Eq (BasePart b) => Eq (SettleError b)
deriving instance Show (BasePart b) => Show (SettleError b)
-- | Construct one reversal and destination pair per eligible source base.
-- Input values are signed Not-minus-Hat nets. Zero nets, accounts without a
-- closing side, and destination bases produce no pair. Every pair has two
-- finite, non-negative magnitudes equal to the absolute input net; the source
-- reversal cancels that net exactly. Other base axes are preserved.
--
-- A non-finite input, including at an excluded key, returns 'Left' with the
-- first key in ascending order. Finite magnitudes above
-- 'ExchangeAlgebra.Algebra.Value.postingUpperBound' are accepted, without passing
-- through 'ExchangeAlgebra.Algebra.Posting.posted'. No implicit @bar@ or @compress@ is
-- applied. Complexity: O(b) for b input bases.
-- Law: subject: each generated settlement pair; preconditions: all nets finite.
-- Relation: each reversal cancels its source net. For each destination base,
-- its increment is the sum of @direction * net@ over the source bases, where
-- @direction@ is +1 for 'ClosingKeep' and -1 for 'ClosingFlip'.
-- Observation: sums of @decL@ and @decR@ lifted to Rational for each pair.
-- Tolerance: exact. Instances: 'ExBaseClass' bases with Double posting values.
settleEntries :: forall b. ExBaseClass b
=> SettleRule
-> Map (BasePart b) SignedNet
-> Either (SettleError b) (SettlementBatch b)
settleEntries (SettleRule destination) amounts
= case mapMaybe nonFinite (Map.toAscList amounts) of
first : _ -> Left (NonFiniteNet first)
[] -> Right (SettlementBatch (mapMaybe close (Map.toAscList amounts)))
where
finite amount = not (isNaN amount || isInfinite amount)
nonFinite (coordinates, amount)
| finite amount = Nothing
| otherwise = Just coordinates
close (coordinates, amount)
| amount == 0 = Nothing
| coordinates == base (setAccountTitle source destination) = Nothing
| otherwise = case closingSide (getAccountTitle source) of
Nothing -> Nothing
Just side -> Just
(coordinates, closingPairBy (targetSide side) destination (abs amount) source)
where
source = merge (sourceSide amount) coordinates :: b
sourceSide amount
| amount < 0 = Hat
| otherwise = Not
targetSide side = case side of
ClosingKeep -> id
ClosingFlip -> revHat
-- | Read the pairs in strictly ascending source-base order, without constraints
-- on the base type. Record each pair before proceeding to the next source.
-- Complexity: O(1) to expose the list; O(b) to consume b pairs.
settlementSteps :: SettlementBatch b -> [(BasePart b, Alg Double b)]
settlementSteps (SettlementBatch steps) = steps
-- | Reverse a closing balance and post it to the given destination account.
-- The first argument transforms the source to the target side: @id@ retains
-- Hat/Not, and 'revHat' reverses it. The caller must classify the source,
-- and the value must satisfy the non-negative,
-- finite posting contract of '.@'.
closingPairBy :: (HatVal v, ExBaseClass b)
=> (b -> b)
-> AccountTitles
-> v
-> b
-> Alg v b
closingPairBy targetSide targetAccount value source
= (value .@ revHat source)
.+ (value .@ setAccountTitle (targetSide source) targetAccount)