@@ -1,81 +1,218 @@
1+{-# LANGUAGE DataKinds #-}
2+{-# LANGUAGE OverloadedStrings #-}
3+{-# LANGUAGE UndecidableInstances #-}
1−module Web.Connexpay.Payments
4+module Web.Connexpay.Payments ( CreditCard(..)
9− , AuthResponse(..)
5+ , AuthResponse(..)
10− , TransactionStatus(..)
11− , authorisePayment
6+ , authorisePayment
12− , VoidRequest(..)
7+ , VoidRequest(..)
13− , voidPayment
8+ , voidPayment
14− , CaptureResponse(..)
9+ , CaptureResponse(..)
16− , capturePayment
10+ , capturePayment
17− , cancelPayment
11+ , cancelPayment
18− , returnPayment
12+ , returnPayment
15+import Control.Monad (when,void)
16+import Control.Monad.Except (throwError)
17+import Control.Monad.Reader (asks)
18+import Control.Monad.Writer.Strict
21import Data.Aeson
19import Data.Aeson
20+import Data.Aeson.KeyMap qualified as KeyMap
21+import Data.Aeson.Types (Pair, typeMismatch)
22+import Data.ByteString.Lazy qualified as ByteString
23+import Data.Int (Int32)
26+import Data.Text (Text)
27+import Data.Text qualified as Text
28+import Data.Text.Encoding qualified as Text
29+import GHC.TypeError as TypeError
30+import Network.HTTP.Req
23−import Web.Connexpay.Http
32+import Web.Connexpay.Data
24−import Web.Connexpay.Payments.Types
25import Web.Connexpay.Types
33import Web.Connexpay.Types
34+import Web.Connexpay.Utils
37+-- No 'Show' instance should be made for this type
38+-- in order to avoid sensitive data leaks.
39+data CreditCard = CreditCard { number :: Text -- ^ Credit card number, as 'Text'.
40+ , cardholder :: Maybe Text -- ^ Cardholder name, optional.
41+ , expiration :: (Word, Word) -- ^ Expiration date (month,year).
42+ , cvv :: Maybe Text -- ^ CVC/CVV code.
45+type ShowError = TypeError.Text "CreditCard must not be shown in order to avoid leaking sensitive data"
47+instance TypeError ShowError => Show CreditCard where
48+ show = error "UNREACHABLE"
50+instance ToJSON CreditCard where
53+ $ do tell ["CardNumber" .= cc.number]
54+ whenJust cc.cardholder $ \name ->
55+ tell ["CardHolderName" .= name]
56+ whenJust cc.cvv $ \cvv ->
58+ let expDate = padDate (tshow (snd cc.expiration)) <> padDate (tshow (fst cc.expiration))
59+ tell ["ExpirationDate" .= expDate]
61+padDate :: Text -> Text
62+padDate t | Text.length t == 1 = "0" <> t
65+sendRequest' :: HttpResponse resp => Proxy resp -> Text -> [Pair] -> ConnexpayM resp
66+sendRequest' resp endpoint body =
67+ do mtok <- bearerToken
70+ Nothing -> throwError (ConnectionError $ TokenError "No authentication token available. Check connection parameters?")
72+ tls <- asks (.useTLS)
73+ let auth = header "Authorization" ("Bearer " <> Text.encodeUtf8 tok)
74+ url s = s host /: "api" /: "v1" /: endpoint
75+ obj <- object <$> addGuid body
76+ let jbody = ReqBodyJson obj
78+ then reqCb POST (url https) jbody resp auth (logRequest obj)
79+ else reqCb POST (url http) jbody resp auth (logRequest obj)
84+ do guid <- asks (.deviceGuid)
85+ return (b <> [ "DeviceGuid" .= show guid ])
87+ do log_ <- asks (.logAction)
88+ -- Remove card info from logs
91+ | Just c <- KeyMap.lookup "Card" obj -> Object (KeyMap.insert "Card" (replaceCard c) obj)
93+ msg = Text.unlines [ "Connexpay request:"
95+ , Text.decodeUtf8 (ByteString.toStrict $ encode v')
97+ _ <- liftIO (log_ msg)
99+ replaceCard (Object c) =
101+ $ KeyMap.insert "CardNumber" (String "<REDACTED>")
102+ $ KeyMap.insert "Cvv2" (String "<REDACTED>")
106+sendRequestJson :: FromJSON a => Text -> [Pair] -> ConnexpayM (JsonResponse a)
107+sendRequestJson = sendRequest' jsonResponse
109+sendRequest_ :: Text -> [Pair] -> ConnexpayM ()
110+sendRequest_ ep = void . sendRequest' ignoreResponse ep
112+data AuthResponse = AuthResponse { paymentGuid :: AuthOnlyGuid
113+ , status :: TransactionStatus
114+ , processorStatusCode :: Maybe Text
115+ , processorMessage :: Maybe Text
116+ , addressVerificationCode :: Maybe Text
117+ , cvvVerificationCode :: Maybe Text
120+instance FromJSON AuthResponse where
121+ parseJSON (Object o) = AuthResponse <$> o .: "guid"
123+ <*> o .:? "processorStatusCode"
124+ <*> o .:? "processorResponseMessage"
125+ <*> o .:? "addressVerificationCode"
126+ <*> o .:? "cvvVerificationCode"
127+ parseJSON v = typeMismatch "AuthReponse" v
28-- | Authorise a credit card payment.
129-- | Authorise a credit card payment.
29−authorisePayment
130+authorisePayment :: CreditCard -- ^ Credit card details (see 'CreditCard')
30− :: Connexpay -> Env -> AuthRequest -> IO (Response AuthError AuthorizeResult)
131+ -> Money USD -- ^ Amount to charge, USD
31−authorisePayment connexpay env raw =
132+ -> Maybe Text -- ^ Invoice description
32− bimapResponse (guessErrorType guessAuthError) postProcessAuthResponse <$>
133+ -> Maybe Text -- ^ Merchant description that will appear in a customer's statement.
33− doRequest connexpay env "authonlys" RequestBody
134+ -> ConnexpayM AuthResponse
34− { raw
135+authorisePayment cc amt invoice vendor =
35− , logMasker = maskAuthRequest
136+ do resp <- sendRequestJson "authonlys" body
36− }
137+ let rbody = responseBody resp
138+ -- Special case for Connexpay local transaction
139+ -- This status means that the transaction was registered,
140+ -- but Connexpay stopped its processing and it won't be
141+ -- moved any further.
142+ -- Also, when I asked Ken from Connexpay about this,
143+ -- he told me he had never seen this status before.
144+ when (rbody.status == TransactionCreatedLocal) $
145+ throwError (PaymentFailure LocalTransaction)
147+ where body = execWriter $
148+ do tell [ "Card" .= cc ]
149+ tell [ "Amount" .= getAmount amt ]
150+ whenJust invoice $ \i ->
151+ tell [ "OrderNumber" .= i ]
152+ whenJust vendor $ \v ->
153+ tell [ "StatementDescription" .= v ]
154+ -- We are supposed to pass RiskData, but it still
155+ -- can be an empty object. Consider population this
156+ -- should the need arise.
157+ tell [ "RiskData" .= KeyMap.empty @() ]
159+data VoidRequest = VoidAuthorized AuthOnlyGuid | VoidCaptured SaleGuid (Maybe (Money USD))
38-- | Void payment
161-- | Void payment
39−voidPayment :: Connexpay -> Env -> VoidRequest -> IO (Response () ())
162+voidPayment :: VoidRequest
40−voidPayment connexpay env raw = doRequest_ connexpay env "void" RequestBody
163+ -> ConnexpayM ()
41− { raw
164+voidPayment (VoidAuthorized pid) = sendRequest_ "void" body
42− , logMasker = id
165+ where body = [ "AuthOnlyGuid" .= show pid ]
43− }
166+voidPayment (VoidCaptured pid amt) = sendRequest_ "void" body
167+ where body = execWriter $
168+ do tell [ "SaleGuid" .= show pid ]
169+ whenJust amt $ \m ->
170+ tell [ "Amount" .= getAmount m ]
172+-- | Internal data type for Capture requests.
173+data CPTransaction = CPTransaction { expectedPayments :: Int32 }
175+instance ToJSON CPTransaction where
176+ toJSON t = object [ "ExpectedPayments" .= t.expectedPayments ]
178+-- | Response for the payment capture request
179+data CaptureResponse = CaptureResponse { captureGuid :: CaptureGuid
180+ , saleGuid :: SaleGuid
181+ , saleStatus :: TransactionStatus
184+instance FromJSON CaptureResponse where
185+ parseJSON (Object o) =
186+ do cguid <- o .: "guid"
188+ saleGuid <- sale .: "guid"
189+ status <- sale .: "status"
190+ pure (CaptureResponse cguid saleGuid status)
191+ parseJSON v = typeMismatch "CaptureResponse" v
45-- | Capture payment, previously authorised through 'authorisePayment'.
193-- | Capture payment, previously authorised through 'authorisePayment'.
46−capturePayment :: Connexpay
48− -> AuthOnlyGuid -- ^ Sales GUID, obtained from 'authorisePayment'.
194+capturePayment :: SaleGuid -- ^ Sales GUID, obtained from 'authorisePayment'.
49− -> IO (Response () CaptureResponse)
195+ -> ConnexpayM CaptureResponse
50−capturePayment connexpay env pid = doRequest connexpay env "Captures" RequestBody
196+capturePayment pid =
51− { raw = object
197+ do resp <- sendRequestJson "Captures" body
198+ let rbody = responseBody resp
52− [ "AuthOnlyGuid" .= pid
200+ where body = [ "AuthOnlyGuid" .= show pid
53− , "ConnexPayTransaction" .= CPTransaction 1
201+ , "ConnexPayTransaction" .= CPTransaction 1 ]
58-- | Cancel voided or captured payment.
203-- | Cancel voided or captured payment.
59-- In case of an authorised-only payment, voiding is performed.
204-- In case of an authorised-only payment, voiding is performed.
60-- Otherwise, a payment goes through a refund process.
205-- Otherwise, a payment goes through a refund process.
61−cancelPayment :: Connexpay
63− -> SaleGuid -- ^ Sales GUID, obtained from 'capturePayment'.
206+cancelPayment :: SaleGuid -- ^ Sales GUID, obtained from 'capturePayment'.
64− -> IO (Response () ())
207+ -> ConnexpayM ()
65−cancelPayment connexpay env pid = doRequest_ connexpay env "cancel" RequestBody
208+cancelPayment pid = sendRequest_ "cancel" body
66− { raw = object [ "SaleGuid" .= pid ]
209+ where body = [ "SaleGuid" .= show pid ]
70−returnPayment :: Connexpay
72− -> SaleGuid -- ^ Sales GUID, obtained from 'capturePayment'.
211+returnPayment :: SaleGuid -- ^ Sales GUID, obtained from 'capturePayment'.
73− -> USD
212+ -> Maybe (Money USD)
74− -> IO (Response () ())
213+ -> ConnexpayM ()
75−returnPayment connexpay env pid amt = doRequest_ connexpay env "returns" RequestBody
214+returnPayment pid amt = sendRequest_ "returns" body
76− { raw = object
215+ where body = execWriter $
77− [ "SaleGuid" .= pid
216+ do tell [ "SaleGuid" .= show pid ]
217+ whenJust amt $ \m ->
78− , "Amount" .= amt
218+ tell [ "Amount" .= getAmount m ]