{-# LANGUAGE GADTs #-} {-# LANGUAGE MultiWayIf #-} {-# LANGUAGE TemplateHaskell #-} module Web.PayPal.Adaptive.Core where import Prelude hiding (mapM) import Control.Applicative import Control.Exception import Control.Lens hiding ((.=)) import Control.Monad ((>=>)) import Data.Aeson import Data.Aeson.TH hiding (Options) import Data.Aeson.Types hiding (Options) import Data.ByteString.Lazy (ByteString) import Data.Char import Data.Default import Data.Maybe import Data.Monoid import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding import Data.Text.Read import Data.Traversable import qualified Data.Vector as V import Network.Wreq -------------------------------------------------- -- Client -------------------------------------------------- data Client = Client { _clAppId :: Text , _clUserId :: Text , _clEnv :: Env , _clPassword :: Text , _clSig :: Text -- | Not needed for authentication, but deposits are sent -- to here and withdrawals are sent from here. Must be the -- email associated with your PayPal app. , _clAccountEmail :: Text } deriving (Eq, Show) data Env = Sandbox | Production deriving (Eq, Show, Read) -------------------------------------------------- -- API class -------------------------------------------------- class AdaptiveAPI a where toPayPal :: Client -> a -> IO (Either AdaptiveErr PayResp) -- | Exported by Web.PayPal.Adaptive.Internal ppPost :: (ToJSON a) => Client -> Text -> a -> IO (Either AdaptiveErr (Response ByteString)) ppPost c endpoint p = catch (return . Right =<< runPost) handler where runPost :: IO (Response ByteString) runPost = postWith opts (clBaseUrl <> T.unpack endpoint) (toJSON p) clBaseUrl :: String clBaseUrl = case _clEnv c of Sandbox -> "https://svcs.sandbox.paypal.com/AdaptivePayments/" Production -> "https://svcs.paypal.com/AdaptivePayments/" handler :: SomeException -> IO (Either AdaptiveErr (Response ByteString)) handler e = return . Left . AeConnectionErr . T.pack . show $ e opts :: Options opts = defaults & header "X-PAYPAL-SECURITY-USERID" .~ [ encodeUtf8 (_clUserId c) ] & header "X-PAYPAL-SECURITY-PASSWORD" .~ [ encodeUtf8 (_clPassword c) ] & header "X-PAYPAL-SECURITY-SIGNATURE" .~ [ encodeUtf8 (_clSig c) ] & header "X-PAYPAL-APPLICATION-ID" .~ [ encodeUtf8 (_clAppId c) ] & header "X-PAYPAL-REQUEST-DATA-FORMAT" .~ [ "JSON" ] & header "X-PAYPAL-RESPONSE-DATA-FORMAT" .~ [ "JSON" ] -- | Exported by Web.PayPal.Adaptive.Internal ppDecode :: (FromJSON a) => Response ByteString -> Either AdaptiveErr a ppDecode r = let b = r ^. responseBody in case eitherDecode b of Right d -> Right d Left e -> -- If the response isn't a PayResp, it might be a PayPal error message. case decode b of Just (AeErrCodes codes) -> Left $ codeErr codes _ -> Left $ AeDecodeFailed b (T.pack e) where codeErr :: [Int] -> AdaptiveErr codeErr cs = if | elem 520003 cs -> AeInvalidCredentials | elem 589039 cs -> AeNoSuchEmail | otherwise -> AeErrCodes cs -------------------------------------------------- -- Errors -------------------------------------------------- -- | Text is an error message in all the below types. -- -- AeShouldNotHappen is meant to cover PayPal responses to API requests -- that this library doesn't support sending in the first place. -- TODO: is this actually how we're using it? data AdaptiveErr = AeConnectionErr Text | AeDecodeFailed ByteString Text -- | AeInvalidCredentials just a wrapper around an AeErrCodes Int. | AeInvalidCredentials -- | AeNoSuchEmail is just a wrapper around an AeErrCodes Int. -- It's raised when the Deposit sender email or the Withdrawal -- account email doesn't exist. | AeNoSuchEmail | AeErrCodes [Int] | AePending PayResp | AeRefused PayResp | AeShouldNotHappen PayResp Text deriving (Eq, Show) -- Error responses are what's returned if, e.g., your password is incorrect. -- -- Here's an example error response. Only the parts we parse are included. -- -- { -- "error": [ -- { -- "errorId": "111111", -- } -- ] -- } instance FromJSON AdaptiveErr where parseJSON = withObject "error response" $ \o -> AeErrCodes . V.toList <$> (mapM parseErrId =<< o .: "error") -- | Exported by Web.PayPal.Adaptive.Internal parseErrId :: Value -> Parser Int parseErrId = withObject "error array item" $ \o -> do a <- o .: "errorId" case decimal a of Left _ -> fail "Could not parse error response code to Int" Right (b,_) -> return b -------------------------------------------------- -- Payment Utils -------------------------------------------------- -- | Exported by Web.PayPal.Adaptive.Internal -- -- "en_US" is hardcoded because it's the only supported error language. requestEnvelope :: Value requestEnvelope = object ["errorLanguage" .= ("en_US" :: Text)] -- | Exported by Web.PayPal.Adaptive.Internal payExecStatusCompleted :: PayResp -> Either AdaptiveErr () payExecStatusCompleted a = case _prPayExecStatus a of PeCompleted -> Right () PeProcessing -> Left $ AePending a PePending -> Left $ AePending a PeError -> Left $ AeRefused a _ -> Left $ AeShouldNotHappen a "unsupported PayExecStatus" -- | Exported by Web.PayPal.Adaptive.Internal -- -- TODO: this makes the assumption that an empty PayInfo list means -- the transaction is pending. Is this always right? getPayInfo :: PayResp -> Either AdaptiveErr PayInfo getPayInfo a = case _prPayInfo a of [] -> Left $ AePending a [info] -> Right info _ -> Left $ AeShouldNotHappen a "more than one PayInfo in reponse" -- | Exported by Web.PayPal.Adaptive.Internal transactionStatusCompleted :: PayResp -> PayInfo -> Either AdaptiveErr () transactionStatusCompleted a info = case _piTransactionStatus info of Just TsCompleted -> Right () Just TsPending -> Left $ AePending a Just TsProcessing -> Left $ AePending a Just TsDenied -> Left $ AeRefused a Just TsFailed -> Left $ AeRefused a _ -> Left $ AeShouldNotHappen a "unsupported TransactionStatus" -- | Exported by Web.PayPal.Adaptive.Internal errorFieldNothing :: PayResp -> Either AdaptiveErr () errorFieldNothing a | _prPayError a == Nothing = Right () | otherwise = Left $ AeShouldNotHappen a "PayResp error field is Just in otherwise correct payment" -- | Exported by Web.PayPal.Adaptive.Internal checkPayKey :: PayResp -> Either AdaptiveErr () checkPayKey a | T.null . _unPayKey . _prPayKey $ a = Left $ AeShouldNotHappen a "Length zero PayKey in otherwise correct deposit resp" | otherwise = Right () -- | Exported by Web.PayPal.Adaptive.Internal payExecStatusCreated :: PayResp -> Either AdaptiveErr () payExecStatusCreated a = case _prPayExecStatus a of PeCreated -> Right () PeProcessing -> Left $ AePending a PePending -> Left $ AePending a PeError -> Left $ AeRefused a _ -> Left $ AeShouldNotHappen a "unsupported PayExecStatus" -- | Exported by Web.PayPal.Adaptive.Internal noPayInfo :: PayResp -> Either AdaptiveErr () noPayInfo a = case _prPayInfo a of [] -> Right () _ -> Left $ AeShouldNotHappen a "At least one PayInfo present in otherwise successful deposit resp" -------------------------------------------------- -- Withdrawals -------------------------------------------------- -- | Send a single payment from your account. -- -- "Pay" operation docs: -- -- -- This is a "Simple Payment" which means there's a single sender -- and receiver (same as Deposit). -- -- It uses the "Implicit" payment approval type, meaning the application -- making the API call must also be the sender of the payment. data Withdrawal = Withdrawal { _wdAmount :: Money , _wdReceiverEmail :: Text , _wdReturnUrl :: Text , _wdCancelUrl :: Text } deriving (Eq, Show) instance Default Withdrawal where def = Withdrawal { _wdAmount = mempty , _wdReceiverEmail = mempty , _wdReturnUrl = "https://example.com/" , _wdCancelUrl = "https://example.com/cancel" } -- | Exported by Web.PayPal.Adaptive.Internal data SerializeWithdrawal = SerializeWithdrawal Withdrawal Text instance ToJSON SerializeWithdrawal where toJSON (SerializeWithdrawal p accountEmail) = object [ "actionType" .= ("PAY" :: Text) , "currencyCode" .= (m2Currency . _wdAmount) p , "receiverList" .= object [ "receiver" .= [ object ["amount" .= m2PayPal (_wdAmount p) , "email" .= _wdReceiverEmail p ] ]] , "returnUrl" .= _wdReturnUrl p , "cancelUrl" .= _wdCancelUrl p , "requestEnvelope" .= requestEnvelope , "senderEmail" .= accountEmail ] -- | Sending a payment to a PayPal account that doesn't exist yet -- returns a PayResp with an empty list of PayInfos. We return this -- as Left 'AePending'. instance AdaptiveAPI Withdrawal where toPayPal c p = (>>= ppDecode >=> ensureSucceeded) <$> ppPost c "Pay" serializeWithdrawal where serializeWithdrawal :: SerializeWithdrawal serializeWithdrawal = SerializeWithdrawal p (_clAccountEmail c) ensureSucceeded :: PayResp -> Either AdaptiveErr PayResp ensureSucceeded a = do payExecStatusCompleted a info <- getPayInfo a transactionStatusCompleted a info errorFieldNothing a checkPayKey a Right a -------------------------------------------------- -- Deposits -------------------------------------------------- -- | Start the process of someone paying you. You have to redirect the payer's -- browser to PayPal to finish it. -- -- "Pay" operation docs: -- -- -- This is a "Simple Payment" which means there's a single sender -- and receiver (same as Withdrawal). -- -- It uses the "Explicit" payment approval type, meaning the payer must go to -- PayPal in their browser to approve the payment. Here's an example payment -- approval URL with a PayKey of "foo": -- -- https://www.paypal.com/cgi-bin/webscr?cmd=_ap-payment&paykey=foo data Deposit = Deposit { _dpAmount :: Money , _dpSenderEmail :: Text , _dpReturnUrl :: Text , _dpCancelUrl :: Text } deriving (Eq, Show) instance Default Deposit where def = Deposit { _dpAmount = mempty , _dpSenderEmail = mempty , _dpReturnUrl = "https://example.com/" , _dpCancelUrl = "https://example.com/cancel" } -- | Exported by Web.PayPal.Adaptive.Internal data SerializeDeposit = SerializeDeposit Deposit Text instance ToJSON SerializeDeposit where toJSON (SerializeDeposit p accountEmail) = object [ "actionType" .= ("PAY" :: Text) , "currencyCode" .= (m2Currency . _dpAmount) p , "receiverList" .= object [ "receiver" .= [ object ["amount" .= m2PayPal (_dpAmount p) , "email" .= accountEmail ] ]] , "returnUrl" .= _dpReturnUrl p , "cancelUrl" .= _dpCancelUrl p , "requestEnvelope" .= requestEnvelope , "senderEmail" .= _dpSenderEmail p ] instance AdaptiveAPI Deposit where toPayPal c p = (>>= ppDecode >=> ensureSucceeded) <$> ppPost c "Pay" serializeDeposit where serializeDeposit :: SerializeDeposit serializeDeposit = SerializeDeposit p (_clAccountEmail c) ensureSucceeded :: PayResp -> Either AdaptiveErr PayResp ensureSucceeded a = do payExecStatusCreated a noPayInfo a errorFieldNothing a checkPayKey a Right a approvalUrl :: Client -> PayKey -> Text approvalUrl c k = case _clEnv c of Sandbox -> "https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_ap-payment&paykey=" <> _unPayKey k Production -> "https://www.paypal.com/cgi-bin/webscr?cmd=_ap-payment&paykey=" <> _unPayKey k -------------------------------------------------- -- Lookup Payment -------------------------------------------------- -- | Look up information about a payment. -- -- data LookupPayment a where LookupTrID :: TransactionId -> LookupPayment TransactionId LookupPayKey :: PayKey -> LookupPayment PayKey instance ToJSON (LookupPayment a) where toJSON (LookupTrID (TransactionId p)) = object [ "transactionId" .= p , "requestEnvelope" .= requestEnvelope ] toJSON (LookupPayKey (PayKey p)) = object [ "payKey" .= p , "requestEnvelope" .= requestEnvelope ] instance AdaptiveAPI (LookupPayment a) where toPayPal c p = (>>= ppDecode) <$> ppPost c "PaymentDetails" p -------------------------------------------------- -- Payment Responses -------------------------------------------------- data PayResp = PayResp { _prPayError :: Maybe Text , _prPayExecStatus :: PayExecStatus , _prPayKey :: PayKey , _prPayInfo :: [PayInfo] } deriving (Eq, Show) instance FromJSON PayResp where parseJSON = withObject "PayResp" $ \o -> do e <- o .:? "payErrorList" k <- o .: "payKey" s <- o .:? "paymentExecStatus" s' <- o .:? "status" status <- maybe (fail "no paymentExecStatus or status") return (if isJust s then s else s') a <- o .:? "paymentInfoList" case a of Nothing -> return $ PayResp e status k mempty Just v -> do infos <- v .:? "paymentInfo" .!= mempty return $ PayResp e status k infos -- This refers to the processing of this request. To make sure a payment -- has actually gone through, check 'TransactionStatus' instead. -- -- PayPal returns this using the JSON object key "paymentExecStatus" -- in responses to Withdrawal and Deposit and "status" in responses to -- LookupPayment. data PayExecStatus = PeCreated | PeCompleted | PeIncomplete | PeError | PeReversalError | PeProcessing | PePending deriving (Eq, Show, Read) -- | Expires after three hours. -- -- newtype PayKey = PayKey { _unPayKey :: Text } deriving (Eq, Show) instance FromJSON PayKey where parseJSON = withText "PayKey" $ return . PayKey instance ToJSON PayKey where toJSON (PayKey a) = toJSON a -- | Right toPayPal responses return a PayInfo along with a -- _piTransactionStatus Just, with some exceptions. -- -- 1. Withdrawals to nonexistent accounts return no PayInfos. -- -- 2. Lookups on such transactions return one PayInfo with a -- _piTransactionStatus of Nothing. -- -- 3. Deposits don't return PayInfos. -- -- 4. Lookups on such transactions return one PayInfo with a -- _piTransactionStatus of Nothing. -- -- NOTE: We use the same newtype for both _piSenderTransactionId and -- _piTransactionStatus. They will have different values, but either -- can be used to look up a payment. data PayInfo = PayInfo { _piReceiver :: Receiver , _piSenderTransactionId :: Maybe TransactionId , _piTransactionStatus :: Maybe TransactionStatus , _piTransactionId :: Maybe TransactionId } deriving (Eq, Show) data Receiver = Receiver -- Haven't converted this to Money yet because I'm not sure how -- to determine its currency. { _reAmount :: Text , _reEmail :: Text , _reAccountId :: Text } deriving (Eq, Show) newtype TransactionId = TransactionId { _unTransactionId :: Text } deriving (Eq, Show) instance FromJSON TransactionId where parseJSON = withText "TransactionId" $ return . TransactionId instance ToJSON TransactionId where toJSON (TransactionId a) = toJSON a data TransactionStatus = TsCompleted | TsPending | TsCreated | TsPartiallyRefunded | TsDenied | TsProcessing | TsReversed | TsRefunded | TsFailed deriving (Eq, Show, Read) -------------------------------------------------- -- Money -------------------------------------------------- data Money = USD { _usdCents :: Int } deriving (Eq, Show) -- NOTE: Once we add more currencies this instance will violate the -- Monoid laws. instance Monoid Money where mempty = USD 0 mappend (USD c1) (USD c2) = USD $ c1 + c2 m2Currency :: Money -> Text m2Currency (USD _) = "USD" m2PayPal :: Money -> String m2PayPal (USD c) = let s = show (abs c) a = case length s of 0 -> "0.00" 1 -> "0.0" <> s 2 -> "0." <> s _ -> tailInsert 2 '.' s in if c < 0 then '-':a else a where tailInsert :: Int -> a -> [a] -> [a] tailInsert i x xs = let (ys, zs) = splitAt i (reverse xs) in reverse (ys <> pure x <> zs) $(deriveFromJSON defaultOptions { fieldLabelModifier = (\(x:xs) -> toLower x:xs) . drop 3 } ''PayInfo) $(deriveFromJSON defaultOptions { constructorTagModifier = map toUpper . drop 2 } ''PayExecStatus) $(deriveFromJSON defaultOptions { fieldLabelModifier = (\(x:xs) -> toLower x:xs) . drop 3 } ''Receiver) $(deriveFromJSON defaultOptions { constructorTagModifier = map toUpper . camelTo '_' . drop 2 } ''TransactionStatus) $(makePrisms ''AdaptiveErr) $(makeLenses ''Client) $(makeLenses ''Deposit) $(makePrisms ''Env) $(makePrisms ''PayExecStatus) $(makeLenses ''PayInfo) $(makeLenses ''PayResp) $(makeLenses ''Receiver) $(makeLenses ''Withdrawal) $(makePrisms ''TransactionId) $(makePrisms ''TransactionStatus)