packages feed

paynow-zw-0.1.0.0: src/Network/Payments/Paynow.hs

{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE FlexibleContexts #-}

module Network.Payments.Paynow
  ( 
    newPaynowClient,
    defaultProdConfig,
    PaynowConfig (..),
    PaynowClient (..),
    PaynowTransaction (..),
    Checkout (..),
    PaynowTXResult (..),
    PaymentMethod (..),
    TxData (..),
    PaynowError(..),
    PaynowTXPayload (..),
    Status(..),
    BillingAddress (..),
    Card (..),
    PhoneNumber,
    Email,
    Amount,
    Ref,
    PaynowRef,
    PaynowIntegrationID,
    PaynowIntegrationKey,
    PaynowResultURL,
    PaynowReturnURL,
    Hash,
    RedirectURL,
    PollURL,
    VMC (..),
    serverResponseToResult,
    sha1Hex,
  )
where

import UnliftIO.Exception
import UnliftIO
import qualified Crypto.Hash as C
import qualified Data.ByteString.Lazy.Char8 as BS
import qualified Data.Map as M
import Data.List (sort)
import Data.Maybe (catMaybes, fromMaybe, isJust)
import Data.Text hiding (zip, filter, foldr)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Network.HTTP.Base (urlDecode, urlEncodeVars)
import Network.HTTP.Types.Header (hContentType)
import Network.HTTP.Simple

prodPaynowEndpoint :: Text
prodPaynowEndpoint = "https://www.paynow.co.zw/interface/"


-- | Additional data to be sent with the transaction
data TxData = TxData
  { tAdditionalInfo :: Maybe Text,
    tResultURL :: Maybe PaynowResultURL,
    tReturnURL :: Maybe PaynowReturnURL
  }

instance ToKeyMap (Maybe TxData) where
  toKeyMap Nothing = []
  toKeyMap (Just (TxData {..})) = foldr 
    (\(k, v) accum -> if (isJust v) then (k, fromMaybe "" v) : accum else accum )
    []
    [("additionalinfo", tAdditionalInfo), ("returnurl", tReturnURL), ("resulturl", tResultURL)]

class ToKeyMap a where
  toKeyMap :: a -> [(Text, Text)]


-- Type aliases used to make documentation more readable
type PhoneNumber = Text
type Email = Text
type Amount = Double
type PaynowRef = Text
type Instructions = Text

data Status = Created | Error | Cancelled | Paid | Okay | Refunded | Failed | OtherStatus Text deriving (Show, Eq)
type PollURL = Text
type RedirectURL = Text
type Hash = Text
type Ref = Text

type PaynowIntegrationID = Text
type PaynowIntegrationKey = Text
type PaynowResultURL = Text
type PaynowReturnURL = Text


-- | Card Details
data Card = Card {cardNumber :: !Text, cardCVV :: !Text, cardExpiry :: !Text, cardName :: !Text} deriving (Show)

-- | ToKeyMap typeclass
instance ToKeyMap Card where
  toKeyMap Card {..} = [("cardnumber", cardNumber), ("cardname", cardName), ("cardcvv", cardCVV), ("cardexpiry", cardExpiry)]

-- | Billing Address
data BillingAddress = BillingAddress {baAddressLine1 :: !Text, baAddressLine2 :: !Text, baCity :: !Text, baCountry :: !Text, baProvince :: Maybe Text} deriving (Show)

instance ToKeyMap BillingAddress where
  toKeyMap BillingAddress {..} =
    [("billingline1", baAddressLine1), ("billingline2", baAddressLine2), ("billingcity", baCity), ("billingprovince", fromMaybe "" baProvince), ("billingcountry", baCountry)]

-- | Visa Mastercard Details
data VMC = VMC Card BillingAddress deriving (Show)

instance ToKeyMap VMC where
  toKeyMap (VMC card address) = (toKeyMap card) <> (toKeyMap address)

-- | Ecocash, OneMoney, VisaMastercard payment methods
data PaymentMethod = Ecocash PhoneNumber | OneMoney PhoneNumber | VisaMastercard VMC deriving (Show)

instance ToKeyMap PaymentMethod where
  toKeyMap (Ecocash phone) = [("phone", phone), ("method", "ecocash")]
  toKeyMap (OneMoney phone) = [("phone", phone), ("method", "onemoney")]
  toKeyMap (VisaMastercard vmcd) = toKeyMap vmcd

-- | Checkout type
data Checkout = ExpressCheckout PaymentMethod | ClassicCheckout deriving (Show)

instance ToKeyMap Checkout where
  toKeyMap ClassicCheckout = []
  toKeyMap (ExpressCheckout method) = toKeyMap method

data PaynowError = ServerError | InternalError | UnHandledException SomeException | OtherError Text deriving (Show)

-- |
-- | PaynowTXResult is the result of a transaction
-- | 
data PaynowTXResult = 
  InitExpressPaymentSuccess Status PaynowRef Instructions PollURL  Hash | 
  InitClassicCheckoutSuccess Status RedirectURL PollURL Hash  |
  PollPaymentSuccess Status PaynowRef Ref Amount PollURL Hash  |
  VerifySignatureSuccess deriving (Show, Eq)

-- |
--  PaynowTransaction is the type of transaction to be performed.
--
--  InitPayment: Initialize a payment
--  PollPayment: Poll for the status of a payment
--  VerifyStatusUpdate: Verify the authenticity of a status update
data PaynowTransaction = InitPayment Ref Amount Email Checkout (Maybe TxData) | PollPayment PollURL | VerifyStatusUpdate [(Text, Text)]

statusMessage :: [(Text, Text)]
statusMessage = [("status", "Message")]

-- | Ordering of keys for hashing and form body
txOrdering :: [Text]
txOrdering = ["resulturl", "returnurl", "reference", "amount", "id", "additionalinfo", "authemail", "phone", "method", "status", "hash"]

orderTXKeyMap :: [(Text, Text)] -> [(Text, Text)]
orderTXKeyMap l =
  catMaybes $ (\k -> (k,) <$> M.lookup k m) <$> txOrdering
  where
    m :: M.Map Text Text
    m = M.fromList l

instance ToKeyMap PaynowTransaction where
  toKeyMap (InitPayment ref amount email checkout mTxData) =
    orderTXKeyMap $ statusMessage <> [("reference", ref), ("amount", T.pack $ show amount), ("authemail", email)] <> (toKeyMap checkout) <> (toKeyMap mTxData)
  toKeyMap (PollPayment _) = statusMessage
  toKeyMap _ = statusMessage

class PaynowTXPayload a where
  urlParamsWithHash :: a -> PaynowConfig -> (Hash, Text)
  hash :: a -> PaynowConfig -> Text

urlEncodeT :: [(Text, Text)] -> Text
urlEncodeT tple = T.pack $ urlEncodeVars $ (\(k, v) -> (T.unpack k, T.unpack v)) <$> tple

sha1Hex :: Text -> Hash
sha1Hex t =
  T.toUpper $ T.decodeUtf8 $ (C.digestToHexByteString (C.hash (T.encodeUtf8 t) :: C.Digest C.SHA512))

instance PaynowTXPayload PaynowTransaction where
  hash tx config =
    sha1Hex $ (intercalate "" ((snd) <$> (orderTXKeyMap $ toKeyMap config <> toKeyMap tx))) <> (pncIntegrationKey config)

  urlParamsWithHash tx config =
    (hashed, urlEncodeT $ orderTXKeyMap ((toKeyMap config <> toKeyMap tx <> [("hash", hashed)])))
    where
      hashed :: Text
      hashed = hash tx config

-- |
--  PaynowConfig is the configuration for the Paynow API.
--  For more details, see https://www.paynow.co.zw/docs/api/
--
--  pncEndpoint: The endpoint to use for the API
--  pncIntegrationID: The integration ID for the API
--  pncIntegrationKey: The integration key for the API
--  pncResultURL: The URL to redirect to after a transaction is completed
--  pncReturnURL: The URL to redirect to after a transaction is completed
data PaynowConfig = PaynowConfig
  { pncEndpoint :: !Text,
    pncIntegrationID :: !PaynowIntegrationID,
    pncIntegrationKey :: !PaynowIntegrationKey,
    pncResultURL :: !PaynowResultURL,
    pncReturnURL :: !PaynowReturnURL
  }
  deriving (Show)

checkoutEndpoint :: PaynowConfig -> Checkout -> Text
checkoutEndpoint PaynowConfig {..} ClassicCheckout =
  pncEndpoint <> "initiatetransaction"
checkoutEndpoint PaynowConfig {..} (ExpressCheckout _) =
  pncEndpoint <> "remotetransaction"

instance ToKeyMap PaynowConfig where
  toKeyMap PaynowConfig {..} =
    [("id", pncIntegrationID), ("resulturl", pncResultURL), ("returnurl", pncReturnURL)]

-- |
-- | A PaynowClient is a client for the Paynow API. It contains the following functions:
-- | - newExpressCheckout: Create a new express checkout transaction (mobile or visa mastercard)
-- | - newClassicCheckout: Create a new classic checkout transaction (user will be redirected to url to complete payment)
-- | - newPollPayment: Create a new poll payment transaction (poll for the status of a payment)
-- | - newVerifyStatusUpdate: Create a new verify status update transaction (verify the authenticity of a status update)
-- | - processTx: Process a transaction, sends an HTTP request to the Paynow API
-- | - txHash: Get the hash of a transaction
data PaynowClient m = PaynowClient
  { newExpressCheckout :: PaymentMethod -> Ref -> Amount -> Email -> Maybe TxData -> m PaynowTransaction,
    newClassicCheckout :: Ref -> Amount -> Email -> Maybe TxData -> m PaynowTransaction,
    newPollPayment :: PollURL -> m PaynowTransaction,
    newVerifyStatusUpdate :: [(Text, Text)] -> m PaynowTransaction,
    processTx :: PaynowTransaction -> m (Either PaynowError PaynowTXResult),
    txHash :: PaynowTransaction -> m Text
  }

-- | The default configuration for the Paynow production API
defaultProdConfig :: PaynowIntegrationID -> PaynowIntegrationKey -> PaynowResultURL -> PaynowReturnURL -> PaynowConfig
defaultProdConfig apiID apiSecret = PaynowConfig prodPaynowEndpoint apiID apiSecret



-- |
--  Create a new PaynowClient with the given configuration.
-- @
-- client <- newPaynowClient config
-- payment <- newExpressCheckout (Ecocash "0779800700") "45" 37.50 Nothing
-- result <- processTx payment
-- case result of
--    PaynowError e => //handle err
--    PaynowTxResult r => //handle result
-- @
newPaynowClient :: forall m. (MonadUnliftIO m) => PaynowConfig -> (PaynowClient m)
newPaynowClient config =
  PaynowClient {..}
  where
    newExpressCheckout method ref amount email mTXData =
      pure $ InitPayment ref amount email (ExpressCheckout method) mTXData

    newClassicCheckout ref amount email mTXData =
      pure $ InitPayment ref amount email ClassicCheckout mTXData

    newPollPayment = pure . PollPayment

    newVerifyStatusUpdate = pure . VerifyStatusUpdate

    txHash = pure . fst . (`urlParamsWithHash` config)

    processTx tx = processTxHandler `handleAny` case tx of
      InitPayment _ _ _ checkout _ -> do
        r <- pure $ parseRequest_ $ T.unpack $ "POST " <> (checkoutEndpoint config checkout)
        let req = addRequestHeader hContentType "application/x-www-form-urlencoded" $  setRequestBodyLBS (BS.pack $ T.unpack $ snd $ urlParamsWithHash tx config) r
        response <- httpLBS req
        let body = T.pack . BS.unpack $ getResponseBody response
        case getResponseStatusCode response of
          200 -> pure $ serverResponseToResult body
          _ -> pure $ Left $ ServerError
      PollPayment url -> do
        req <- pure $ parseRequest_ $ T.unpack $ "GET " <> url
        response <- httpLBS req
        case getResponseStatusCode response of
          200 -> 
            pure $ serverResponseToResult (T.pack . BS.unpack $ getResponseBody response)
          _ -> pure $ Left $ ServerError
      VerifyStatusUpdate _ -> 
        pure $ Left $ OtherError "not yet implemented"

    
    processTxHandler :: (MonadUnliftIO m) => SomeException -> m (Either PaynowError PaynowTXResult)
    processTxHandler ex = pure $ Left $ UnHandledException ex


serverResponseToResult :: Text -> Either PaynowError PaynowTXResult
serverResponseToResult resp = case ( sort $ M.keys kvp) of
--  InitExpressPaymentSuccess Status PaynowRef Instructions PollURL  Hash  
  ["hash", "instructions", "paynowreference", "pollurl", "status"] -> 
    Right $ InitExpressPaymentSuccess (ls "status") (l "paynowreference") (l "instructions") (l "pollurl") (l "hash")
--  InitClassicCheckoutSuccess Status RedirectURL PollURL Hash  
  ["browserurl", "hash", "pollurl", "status"] -> 
    Right $ InitClassicCheckoutSuccess (ls "status") (l "browserurl") (l "pollurl") (l "hash")
--  PollPaymentSuccess Status PaynowRef Ref Amount PollURL Hash  
  ["amount", "hash", "paynowreference", "pollurl", "reference", "status"] -> 
    Right $ PollPaymentSuccess (ls "status") (l "paynowreference") (l "reference") (la "amount") (l "pollurl") (l "hash")
  _ -> Left $ OtherError resp
  
  where
    l :: Text -> Text
    l k = fromMaybe "" $ M.lookup k kvp

    la :: Text -> Amount
    la k =  (read . T.unpack . fromMaybe "0" $ M.lookup k kvp) :: Amount

    ls :: Text -> Status
    ls t = case (T.toLower $ l t) of
      "paid" -> Paid
      "created" -> Created
      "ok" -> Okay
      "cancelled" -> Cancelled
      "failed" -> Failed
      "error" -> Error
      "refunded" -> Refunded
      other -> OtherStatus other
    

    kvp :: M.Map Text Text
    kvp = M.fromList $ (\t -> explode $ splitOn "=" t) <$> splitOn "&" resp

    explode :: [Text] -> (Text, Text)
    explode [] = ("", "")
    explode [k] = (k, "")
    explode [k, v] = (k, T.pack . urlDecode $ T.unpack v)
    explode (k : vs) = (k, T.pack . urlDecode $ T.unpack $ T.unwords vs)