packages feed

haspara-0.0.0.0: src/Haspara/Internal/Money.hs

{-# LANGUAGE DataKinds      #-}
{-# LANGUAGE KindSignatures #-}

module Haspara.Internal.Money where

import           Control.Applicative       ((<|>))
import           Data.Aeson                ((.:), (.=))
import qualified Data.Aeson                as Aeson
import           Data.Scientific           (Scientific)
import           GHC.TypeLits              (KnownNat, Nat)
import           Haspara.Internal.Currency (Currency, baseCurrency, quoteCurrency)
import           Haspara.Internal.Date     (Date)
import           Haspara.Internal.FXQuote  (FXQuote(fxQuotePair, fxQuoteRate))
import           Haspara.Internal.Quantity (Quantity, quantity, times)
import           Refined                   (unrefine)


data Money (s :: Nat) =
    MoneySome Date Currency (Quantity s)
  | MoneyZero
  | MoneyFail String
  deriving (Eq, Ord, Show)


-- | 'Aeson.FromJSON' instance for 'Money'.
--
-- >>> Aeson.decode "{\"qty\":42,\"ccy\":\"USD\",\"date\":\"2021-01-01\"}" :: Maybe (Money 2)
-- Just (MoneySome 2021-01-01 USD 42.00)
-- >>> Aeson.decode "0" :: Maybe (Money 2)
-- Just MoneyZero
-- >>> Aeson.decode "{\"error\": \"oops\"}" :: Maybe (Money 2)
-- Just (MoneyFail "oops")
instance (KnownNat s) => Aeson.FromJSON (Money s) where
  parseJSON (Aeson.Number 0) = pure MoneyZero
  parseJSON (Aeson.Object obj) = parseSome obj <|> parseFail obj
    where
      parseSome o = MoneySome
        <$> o .: "date"
        <*> o .: "ccy"
        <*> o .: "qty"
      parseFail o = MoneyFail <$> o .: "error"
  parseJSON x = fail ("Not a monetary value: " <> show x)



-- | 'Aeson.ToJSON' instance for 'Money'.
--
-- >>> Aeson.encode (MoneySome (read "2021-01-01") ("USD" :: Currency) (42 :: Quantity 0))
-- "{\"qty\":42,\"ccy\":\"USD\",\"date\":\"2021-01-01\"}"
-- >>> Aeson.encode (MoneyZero :: Money 2)
-- "0"
-- >>> Aeson.encode (MoneyFail "oops" :: Money 2)
-- "{\"error\":\"oops\"}"
instance (KnownNat s) => Aeson.ToJSON (Money s) where
  toJSON (MoneySome d c q) = Aeson.object [ "date" .= d, "ccy" .= c, "qty" .= q ]
  toJSON MoneyZero         = Aeson.Number 0
  toJSON (MoneyFail s)     = Aeson.object ["error" .= s]


mkMoney :: KnownNat s => Date -> Currency -> Quantity s -> Money s
mkMoney = MoneySome


mkMoneyFromScientific :: KnownNat s => Date -> Currency -> Scientific -> Money s
mkMoneyFromScientific d c s = mkMoney d c (quantity s)


moneyDate :: KnownNat s => Money s -> Maybe Date
moneyDate (MoneySome d _ _) = Just d
moneyDate MoneyZero         = Nothing
moneyDate (MoneyFail _)     = Nothing


moneyCurrency :: KnownNat s => Money s -> Maybe Currency
moneyCurrency (MoneySome _ c _) = Just c
moneyCurrency MoneyZero         = Nothing
moneyCurrency (MoneyFail _)     = Nothing


moneyQuantity :: KnownNat s => Money s -> Maybe (Quantity s)
moneyQuantity (MoneySome _ _ q) = Just q
moneyQuantity MoneyZero         = Nothing
moneyQuantity (MoneyFail _)     = Nothing


-- | Converts the given 'Money' value to another given currency with the given
-- rate.
--
-- >>> import Haspara
-- >>> let eur = either error id $ currency "EUR"
-- >>> let usd = either error id $ currency "USD"
-- >>> let date = read "2021-01-01" :: Date
-- >>> let eurmoney = mkMoney date eur (quantity 0.42 :: Quantity 2) :: Money 2
-- >>> convert eurmoney eur (quantity 1 :: Quantity 4)
-- MoneySome 2021-01-01 EUR 0.42
-- >>> convert eurmoney usd (quantity 1 :: Quantity 4)
-- MoneySome 2021-01-01 USD 0.42
-- >>> convert eurmoney usd (quantity 1.1516 :: Quantity 4)
-- MoneySome 2021-01-01 USD 0.48
convert :: (KnownNat s, KnownNat k) => Money s -> Currency -> Quantity k -> Money s
convert MoneyZero _ _                    = MoneyZero
convert x@(MoneyFail _) _ _              = x
convert x@(MoneySome d cbase q) cquot rate
  | cbase == cquot && rate == 1 = x
  | cbase == cquot && rate /= 1 = MoneyFail $ "Attempting to convert from same currency with rate != 1: " <> show x <> " to " <> show cquot <> " with " <> show rate
  | otherwise                   = MoneySome d cquot (times q rate)


-- | Converts the given 'Money' value to another currency with the given
-- 'FXQuote'.
convertWithQuote :: (KnownNat s, KnownNat k) => Money s -> FXQuote k -> Money s
convertWithQuote MoneyZero _                    = MoneyZero
convertWithQuote x@(MoneyFail _) _              = x
convertWithQuote x@(MoneySome _ cbase _) quote
  | cbase /= baseCurrency (fxQuotePair quote) = MoneyFail $ "Attempting to convert with incompatible base currency: " <> show x <> " with " <> show quote
  | otherwise = convert x (quoteCurrency (fxQuotePair quote)) (unrefine $ fxQuoteRate quote)