@@ -1,8 +1,10 @@
1{-# LANGUAGE CPP #-}
1{-# LANGUAGE CPP #-}
2−{-# LANGUAGE DerivingStrategies #-}
3{-# LANGUAGE OverloadedStrings #-}
2{-# LANGUAGE OverloadedStrings #-}
4{-# LANGUAGE StandaloneDeriving #-}
3{-# LANGUAGE StandaloneDeriving #-}
4+{-# LANGUAGE DerivingVia #-}
5{-# LANGUAGE TemplateHaskell #-}
5{-# LANGUAGE TemplateHaskell #-}
6+{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
7+{-# HLINT ignore "Use ||" #-}
7module Data.CreditCard
9module Data.CreditCard
8 ( -- * Types
10 ( -- * Types
@@ -52,9 +54,10 @@ module Data.CreditCard
53import Control.Lens hiding (elements)
55import Control.Lens hiding (elements)
55−import Control.Monad (guard, (<=<))
57+import Control.Monad (guard, (<=<), replicateM)
56import Data.Aeson as Aeson
58import Data.Aeson as Aeson
57import Data.Aeson.Inflections (defaultFieldLabelModifier)
59import Data.Aeson.Inflections (defaultFieldLabelModifier)
60+import Data.Binary (Binary)
58import Data.Char (isDigit)
61import Data.Char (isDigit)
59import Data.Digit
62import Data.Digit
60import Data.Either (isRight)
63import Data.Either (isRight)
@@ -63,21 +66,20 @@ import Data.Ix
63import Data.List as L
66import Data.List as L
64import Data.List.NonEmpty (NonEmpty)
67import Data.List.NonEmpty (NonEmpty)
65import qualified Data.List.NonEmpty as NE
68import qualified Data.List.NonEmpty as NE
66−import Data.OpenApi as OpenApi
67− ( ToSchema(..), SchemaOptions(..), defaultSchemaOptions
68− , genericDeclareNamedSchema)
70import Data.Semigroup
69import Data.Semigroup
71import Data.Text (Text)
70import Data.Text (Text)
72import qualified Data.Text as T
71import qualified Data.Text as T
73import Data.Time
72import Data.Time
74−#ifndef ghcjs_HOST_OS
73+#ifdef BACKEND
75+import Data.OpenApi as OpenApi
76+ ( ToSchema(..), SchemaOptions(..), defaultSchemaOptions
77+ , genericDeclareNamedSchema)
75#ifdef USE_POSTGRES
78#ifdef USE_POSTGRES
76import Database.PostgreSQL.Simple.FromField
79import Database.PostgreSQL.Simple.FromField
77import Database.PostgreSQL.Simple.ToField
80import Database.PostgreSQL.Simple.ToField
80−import Formatting as F
81import GHC.Generics (Generic)
83import GHC.Generics (Generic)
82import Numeric.Natural
84import Numeric.Natural
83import Test.QuickCheck.Arbitrary
85import Test.QuickCheck.Arbitrary
@@ -91,7 +93,11 @@ import qualified Data.Store.Internal
91newtype ZipCode = ZipCode
93newtype ZipCode = ZipCode
92 { unZipCode :: Text }
94 { unZipCode :: Text }
93 deriving stock (Show, Eq, Generic)
95 deriving stock (Show, Eq, Generic)
94 deriving newtype (ToJSON, FromJSON, ToSchema)
97 deriving newtype (ToJSON, FromJSON, ToSchema)
99+ deriving newtype (ToJSON, FromJSON)
95#ifdef USE_STORE
101#ifdef USE_STORE
96 deriving anyclass Data.Store.Store
102 deriving anyclass Data.Store.Store
@@ -108,17 +114,12 @@ data CreditCardDate = CreditCardDate
108 deriving anyclass Data.Store.Store
114 deriving anyclass Data.Store.Store
111−instance ToSchema CreditCardDate where
112− declareNamedSchema _ = declareNamedSchema (Proxy @Text)
114makeLenses ''CreditCardDate
117makeLenses ''CreditCardDate
116formatExpiryDate :: CreditCardDate -> Text
119formatExpiryDate :: CreditCardDate -> Text
117−formatExpiryDate ccd =
120+formatExpiryDate ccd = s2 (ccd ^. ccdMonth) <> s2 (ccd ^. ccdYear - 2000)
119− (F.left 2 '0' F.% F.left 2 '0')
122+ s2 = T.justifyRight 2 '0' . T.pack . show
120− (F.sformat F.int $ ccd ^. ccdMonth)
121− (F.sformat F.int $ ccd ^. ccdYear - 2000)
123-- NOTE: This function is supposed to be called 'parseExpiryDate',
124-- NOTE: This function is supposed to be called 'parseExpiryDate',
124-- but we already have a function with the same name in b2b/api
125-- but we already have a function with the same name in b2b/api
@@ -166,10 +167,6 @@ newtype CreditCardNumber = CreditCardNumber
166 { unCreditCardNumber :: NonEmpty Digit }
167 { unCreditCardNumber :: NonEmpty Digit }
167 deriving (Eq, Generic)
168 deriving (Eq, Generic)
170−instance ToSchema CreditCardNumber where
171− declareNamedSchema _ = declareNamedSchema (Proxy @Text)
173makePrisms ''CreditCardNumber
170makePrisms ''CreditCardNumber
175renderCardNumber :: CreditCardNumber -> Text
172renderCardNumber :: CreditCardNumber -> Text
@@ -212,7 +209,7 @@ instance Arbitrary CreditCardNumber where
212newtype CreditCardPAN = CreditCardPAN
209newtype CreditCardPAN = CreditCardPAN
213 { unCreditCardPAN :: Text
210 { unCreditCardPAN :: Text
214 } deriving (Eq, Ord)
211 } deriving (Eq, Ord)
215− deriving newtype (Show)
212+ deriving newtype (Show, FromJSON, ToJSON)
217panTrailingDigits :: CreditCardPAN -> Text
214panTrailingDigits :: CreditCardPAN -> Text
218panTrailingDigits (CreditCardPAN txt) = T.takeEnd 4 txt
215panTrailingDigits (CreditCardPAN txt) = T.takeEnd 4 txt
@@ -228,12 +225,6 @@ panToBogusNumber (CreditCardPAN txt) = do
228 let middle = NE.fromList $ replicate (T.length txt - 8) D0
225 let middle = NE.fromList $ replicate (T.length txt - 8) D0
229 return $ CreditCardNumber $ sconcat $ NE.fromList [leading, middle, trailing]
226 return $ CreditCardNumber $ sconcat $ NE.fromList [leading, middle, trailing]
231−#ifndef ghcjs_HOST_OS
233−deriving instance FromField CreditCardPAN
234−deriving instance ToField CreditCardPAN
238mkCreditCardPan :: CreditCardNumber -> CreditCardPAN
229mkCreditCardPan :: CreditCardNumber -> CreditCardPAN
239mkCreditCardPan n = CreditCardPAN $ firstDigits <> stars <> lastDigits
230mkCreditCardPan n = CreditCardPAN $ firstDigits <> stars <> lastDigits
@@ -246,14 +237,18 @@ mkCreditCardPan n = CreditCardPAN $ firstDigits <> stars <> lastDigits
246newtype CreditCardSecurity = CreditCardSecurity
237newtype CreditCardSecurity = CreditCardSecurity
247 { unCCSecurity :: Text }
238 { unCCSecurity :: Text }
248 deriving (Eq, Ord, Generic)
239 deriving (Eq, Ord, Generic)
249 deriving newtype (ToJSON, FromJSON, ToSchema)
241 deriving newtype (ToJSON, FromJSON, ToSchema)
243+ deriving newtype (ToJSON, FromJSON)
250#ifdef USE_STORE
245#ifdef USE_STORE
251 deriving anyclass Data.Store.Store
246 deriving anyclass Data.Store.Store
254instance Arbitrary CreditCardSecurity where
249instance Arbitrary CreditCardSecurity where
255 arbitrary = CreditCardSecurity . T.pack . concatMap (show :: Int -> String)
250 arbitrary = CreditCardSecurity . T.pack . concatMap (show :: Int -> String)
256− <$> sequence (replicate 3 (choose (0, 9)))
251+ <$> replicateM 3 (choose (0, 9))
258-- | Show instance for 'CreditCardSecurity' doesn't actually show
253-- | Show instance for 'CreditCardSecurity' doesn't actually show
259-- anything. It replaces all characters with '*'.
254-- anything. It replaces all characters with '*'.
@@ -269,27 +264,19 @@ parseCreditCardSecurity txt
269 | isValid = pure $ CreditCardSecurity txt
264 | isValid = pure $ CreditCardSecurity txt
270 | otherwise = Nothing
265 | otherwise = Nothing
273− [ (3, 4) `inRange` T.length txt
267+ isValid = (3, 4) `inRange` T.length txt && T.all isDigit txt
274− , T.all isDigit txt ]
276data CreditCard = CreditCard
269data CreditCard = CreditCard
277− { _ccNumber :: !(CreditCardNumber)
270+ { _ccNumber :: !CreditCardNumber
278− , _ccHolderName :: !(Text)
271+ , _ccHolderName :: !Text
279 , _ccSecurity :: !(Maybe CreditCardSecurity)
272 , _ccSecurity :: !(Maybe CreditCardSecurity)
280− , _ccExpirationDate :: !(CreditCardDate)
273+ , _ccExpirationDate :: !CreditCardDate
281 , _ccZipCode :: !(Maybe ZipCode)
274 , _ccZipCode :: !(Maybe ZipCode)
282 } deriving (Eq, Generic, Show)
275 } deriving (Eq, Generic, Show)
283#ifdef USE_STORE
276#ifdef USE_STORE
284 deriving anyclass Data.Store.Store
277 deriving anyclass Data.Store.Store
287−instance ToSchema CreditCard where
288− declareNamedSchema p = genericDeclareNamedSchema opts p
290− opts = defaultSchemaOptions
291− { OpenApi.fieldLabelModifier = defaultFieldLabelModifier }
293instance FromJSON CreditCard where
280instance FromJSON CreditCard where
294 parseJSON =
281 parseJSON =
295 genericParseJSON defaultOptions
282 genericParseJSON defaultOptions
@@ -317,10 +304,23 @@ data CreditCardType
317 | DinersClub
304 | DinersClub
319 | UnionPay
306 | UnionPay
320− deriving (Eq, Show)
307+ deriving (Eq, Show, Generic, Enum, Bounded)
309+instance Binary CreditCardType
322makePrisms ''CreditCardType
311makePrisms ''CreditCardType
313+instance Arbitrary CreditCardType where
314+ arbitrary = elements [minBound..maxBound]
316+instance FromJSON CreditCardType where
318+ genericParseJSON defaultOptions
320+instance ToJSON CreditCardType where
322+ genericToJSON defaultOptions
324data CreditCardError
324data CreditCardError
325 = UnknownCardNumber
325 = UnknownCardNumber
326 | InvalidCardNumber
326 | InvalidCardNumber
@@ -335,8 +335,8 @@ getDigits = F.toList . fmap digitToNum . unDigits . natToDigits
335getLuhnDigit :: [Digit] -> Digit
335getLuhnDigit :: [Digit] -> Digit
336getLuhnDigit n = digitFromNumMod10 (total * 9)
336getLuhnDigit n = digitFromNumMod10 (total * 9)
338− total = sum $ L.map multEven $ L.zip [0..] $ L.reverse n
338+ total = sum $ L.zipWith multEven [0..] $ L.reverse n
339− multEven (i :: Int, digitToNum -> a)
339+ multEven (i :: Int) (digitToNum -> a)
340 | even i = sum $ getDigits (a * 2)
340 | even i = sum $ getDigits (a * 2)
341 | otherwise = a
341 | otherwise = a
@@ -358,35 +358,86 @@ guessCreditCardType n
358 | otherwise = Nothing
358 | otherwise = Nothing
360 isVisa = (4 == firstDigits 1) && (digitsLength `elem` [13,16,19])
360 isVisa = (4 == firstDigits 1) && (digitsLength `elem` [13,16,19])
361− isMasterCard = (inRange (51, 55) (firstDigits 2)) && (digitsLength == 16)
362+ [ inRange (51, 55) (firstDigits 2) && (digitsLength == 16)
363+ , inRange (2221, 2720) (firstDigits 4) && (digitsLength == 16)]
362 isAmEx = (firstDigits 2 `elem` [34, 37]) && (digitsLength == 15)
364 isAmEx = (firstDigits 2 `elem` [34, 37]) && (digitsLength == 15)
363− isUnionPay = (firstDigits 2 `elem` [60, 62]) && (digitsLength `elem` [16,17,18,19])
365+ isUnionPay = (firstDigits 2 == 62) && (digitsLength `elem` [16,17,18,19])
364− isDiscoverCard =
366+ isDiscoverCard = or
367+ [ firstDigits 4 == 6011
365− (firstDigits 4 == 6011 || inRange (644, 649) (firstDigits 3)
368+ , inRange (644, 649) (firstDigits 3)
366− || inRange (622126, 622925) (firstDigits 6) || firstDigits 2 == 65)
369+ , inRange (622126, 622925) (firstDigits 6)
370+ , firstDigits 2 == 65]
367 && (digitsLength `elem` [16, 19])
371 && (digitsLength `elem` [16, 19])
368− isDinersClub =
372+ isDinersClub = or
369− ((inRange (300, 305) (firstDigits 3) || firstDigits 3 == 309
373+ [ inRange (300, 305) (firstDigits 3) && digitsLength == 14
374+ , firstDigits 3 == 309 && digitsLength == 14
375+ , firstDigits 2 == 36 && digitsLength == 14
370− || firstDigits 2 == 36 || inRange (38, 39) (firstDigits 2))
376+ , inRange (38, 39) (firstDigits 2) && digitsLength == 14
371− && digitsLength == 14)
372− || (firstDigits 2 `elem` [54, 55] && digitsLength == 16)
377+ , firstDigits 2 `elem` [54, 55] && digitsLength == 16]
373− isJCB =
378+ isJCB = or
374− (inRange (3528, 3589) (firstDigits 4) && digitsLength `elem` [15, 16])
379+ [ inRange (3528, 3589) (firstDigits 4) && digitsLength `elem` [15, 16]
375− || (firstDigits 4 `elem` [1800, 2131] && digitsLength == 15)
380+ , firstDigits 4 `elem` [1800, 2131] && digitsLength == 15
376− || (firstDigits 6 == 357266 && digitsLength == 19)
381+ , firstDigits 6 == 357266 && digitsLength == 19]
377 digitsLength = L.length $ n ^. _CreditCardNumber
382 digitsLength = L.length $ n ^. _CreditCardNumber
378 firstDigits x = poly10 @Integer $ L.take x
383 firstDigits x = poly10 @Integer $ L.take x
379 $ n ^.. _CreditCardNumber . folded . to digitToNum
384 $ n ^.. _CreditCardNumber . folded . to digitToNum
381creditCardType :: CreditCardNumber -> Either CreditCardError CreditCardType
386creditCardType :: CreditCardNumber -> Either CreditCardError CreditCardType
382−creditCardType n = case checkLuhnDigit n of
387+creditCardType n
383− True -> case guessCreditCardType n of
388+ | checkLuhnDigit n = case guessCreditCardType n of
384 Nothing -> Left UnknownCardNumber
389 Nothing -> Left UnknownCardNumber
385 Just t -> Right t
390 Just t -> Right t
386− False -> Left InvalidCardNumber
391+ | otherwise = Left InvalidCardNumber
388isValidCCNumber :: CreditCardNumber -> Bool
393isValidCCNumber :: CreditCardNumber -> Bool
389isValidCCNumber = isRight . creditCardType
394isValidCCNumber = isRight . creditCardType
391guessPANCardType :: CreditCardPAN -> Maybe CreditCardType
396guessPANCardType :: CreditCardPAN -> Maybe CreditCardType
392guessPANCardType = guessCreditCardType <=< panToBogusNumber
397guessPANCardType = guessCreditCardType <=< panToBogusNumber
401+deriving instance FromField CreditCardPAN
402+deriving instance ToField CreditCardPAN
403+instance ToField CreditCardType where
404+ toField cardType = toField $ case cardType of
406+ MasterCard -> "MasterCard"
407+ AmericanExpress-> "AmericanExpress"
408+ DiscoverCard -> "DiscoverCard"
409+ DinersClub -> "DinersClub"
411+ UnionPay -> "UnionPay" :: Text
413+instance FromField CreditCardType where
414+ fromField f mbs = do
415+ txt <- fromField f mbs
416+ case T.unpack txt of
417+ "Visa" -> return Visa
418+ "MasterCard" -> return MasterCard
419+ "AmericanExpress"-> return AmericanExpress
420+ "DiscoverCard" -> return DiscoverCard
421+ "DinersClub" -> return DinersClub
423+ "UnionPay" -> return UnionPay
424+ _ -> returnError ConversionFailed f ("Invalid credit card type: " ++ T.unpack txt)
429+instance ToSchema CreditCardDate where
430+ declareNamedSchema _ = declareNamedSchema (Proxy @Text)
432+instance ToSchema CreditCardNumber where
433+ declareNamedSchema _ = declareNamedSchema (Proxy @Text)
435+instance ToSchema CreditCard where
436+ declareNamedSchema = genericDeclareNamedSchema opts
438+ opts = defaultSchemaOptions
439+ { OpenApi.fieldLabelModifier = defaultFieldLabelModifier }
441+instance ToSchema CreditCardType where
442+ declareNamedSchema = genericDeclareNamedSchema defaultSchemaOptions