ttc-0.1.0.0: examples/CreditCard.hs
------------------------------------------------------------------------------
-- |
-- Module : CreditCard
-- Description : credit card data types
-- Copyright : Copyright (c) 2019 Travis Cardwell
-- License : MIT
--
-- This module defines the data types for a credit card, with 'TTC.Render' and
-- 'TTC.Parse' instances, as an example of TTC usage.
--
-- The data types in this module all have 'THS.Lift' instances, to demonstrate
-- how to use 'TTC.valid' in `examples/valid.hs`. All of the 'THS.Lift'
-- instances are derived, using the `DeriveLift` extension.
--
-- The constructors and accessors of composite data types 'CreditCard' and
-- 'ExpirationDate' are exported, but the constructors for 'Name', 'Number',
-- 'Year', 'Month', and 'SecurityCode' are not. The 'TTC.Parse' instances
-- serve as "smart constructors," ensuring that all values are valid.
------------------------------------------------------------------------------
{-# LANGUAGE DeriveLift #-}
module CreditCard
( -- * CreditCard
CreditCard(..)
-- ** Name
, Name
-- ** Number
, Number
-- ** ExpirationDate
, ExpirationDate(..)
, toDay
, Year
, Month
-- ** SecurityCode
, SecurityCode
) where
-- http://hackage.haskell.org/package/base
import Control.Monad (unless, when)
import Data.Char (digitToInt, isDigit, isSpace, toUpper)
import Data.List (dropWhileEnd, intersperse)
import Text.Read (readMaybe)
-- https://hackage.haskell.org/package/template-haskell
import qualified Language.Haskell.TH.Syntax as THS
-- http://hackage.haskell.org/package/time
import qualified Data.Time.Calendar as Calendar
-- (ttc)
import qualified Data.TTC as TTC
------------------------------------------------------------------------------
-- | A credit card has a name, number, expiration date, and security code.
data CreditCard
= CreditCard
{ name :: !Name
, number :: !Number
, expirationDate :: !ExpirationDate
, securityCode :: !SecurityCode
}
deriving Show
------------------------------------------------------------------------------
-- | After any leading and trailing whitespace is stripped and all lowercase
-- characters are converted to uppercase, a name must meet the following
-- constraints:
--
-- * Only characters between `0x20` (space) and `0x5F` (underscore) are
-- allowed.
-- * The name must be between 1 and 26 characters in length.
--
-- Reference:
--
-- * https://stackoverflow.com/questions/2004532
newtype Name = Name String
deriving (Eq, Ord, Show, THS.Lift)
instance TTC.Parse Name where
parse = TTC.asS $ \s -> do
let name' = map toUpper $ strip s
invChars = filter ((||) <$> (< ' ') <*> (> '_')) name'
unless (null invChars) . Left $
"name has invalid character(s): " <> intersperse ',' invChars
when (null name') $ Left "name is empty"
when (length name' > 26) $ Left "name has more than 26 characters"
pure $ Name name'
instance TTC.Render Name where
render (Name name') = TTC.convert name'
------------------------------------------------------------------------------
-- | After any space and dash characters are removed, a number must meet the
-- following constraints:
--
-- * Only ASCII digits are allowed.
-- * The number must be between 8 and 19 characters in length.
-- * The number must have a valid checksum.
--
-- Reference:
--
-- * https://en.wikipedia.org/wiki/Payment_card_number
-- * https://en.wikipedia.org/wiki/Luhn_algorithm
-- * http://rosettacode.org/wiki/Luhn_test_of_credit_card_numbers#Haskell
newtype Number = Number String
deriving (Eq, Ord, Show, THS.Lift)
instance TTC.Parse Number where
parse = TTC.asS $ \s -> do
let number' = filter ((&&) <$> (/= ' ') <*> (/= '-')) s
invChars = filter (not . isDigit) number'
len = length number'
unless (null invChars) . Left $
"number has invalid character(s): " <> intersperse ',' invChars
unless (len >= 8) $ Left "number has fewer than 8 characters"
unless (len <= 19) $ Left "number has more than 19 characters"
unless (luhn number') $ Left "number checksum is invalid"
pure $ Number number'
instance TTC.Render Number where
render (Number number') = TTC.convert number'
luhn :: String -> Bool
luhn
= (== 0)
. (`mod` 10)
. sum
. map (uncurry (+) . (`divMod` 10))
. zipWith (*) (cycle [1, 2])
. map digitToInt
. reverse
------------------------------------------------------------------------------
-- | When parsed from a single string, an expiration date must be in `YYYY-MM`
-- format.
data ExpirationDate
= ExpirationDate
{ year :: !Year
, month :: !Month
}
deriving (Eq, Ord, Show, THS.Lift)
instance TTC.Parse ExpirationDate where
parse = TTC.asS $ \s -> case break (== '-') (strip s) of
(year', '-':month') ->
ExpirationDate <$> TTC.parse year' <*> TTC.parse month'
_ -> Left "expiration date not in YYYY-MM format"
instance TTC.Render ExpirationDate where
render (ExpirationDate year' month') =
TTC.fromS $ TTC.render year' <> "-" <> TTC.render month'
toDay
:: ExpirationDate
-> Calendar.Day
toDay (ExpirationDate (Year year') (Month month')) =
let yearZ = fromIntegral year'
day = Calendar.gregorianMonthLength yearZ month'
in Calendar.fromGregorian yearZ month' day
------------------------------------------------------------------------------
-- | A year must be in `YYYY` format, between 1900 and 9999.
newtype Year = Year Int
deriving (Eq, Ord, Show, THS.Lift)
instance TTC.Parse Year where
parse = TTC.asS $ \s -> do
year' <- maybe (Left "year is not in YYYY format") pure $ readMaybe s
unless (year' >= 1900) $ Left "year is before 1900"
unless (year' <= 9999) $ Left "year is after 9999"
pure $ Year year'
instance TTC.Render Year where
render (Year year') = TTC.convert $ show year'
------------------------------------------------------------------------------
-- | A month must be in `MM` format, between 1 (January) and 12 (December).
newtype Month = Month Int
deriving (Eq, Ord, Show, THS.Lift)
instance TTC.Parse Month where
parse = TTC.asS $ \s -> do
month' <- maybe (Left "month is not in MM format") pure $ readMaybe s
unless (month' >= 1 && month' <= 12) $ Left "month is not in 1-12 range"
pure $ Month month'
instance TTC.Render Month where
render (Month month')
| month' < 10 = TTC.convert $ '0' : show month'
| otherwise = TTC.convert $ show month'
------------------------------------------------------------------------------
-- | After any leading and trailing whitespace is stripped, a security code
-- must meet the following constraints:
--
-- * Only ASCII digits are allowed.
-- * The number must be 3 or 4 characters in length.
--
-- Reference:
--
-- * https://en.wikipedia.org/wiki/Card_security_code
newtype SecurityCode = SecurityCode String
deriving (Eq, Ord, Show, THS.Lift)
instance TTC.Parse SecurityCode where
parse = TTC.asS $ \s -> do
let securityCode' = strip s
invChars = filter (not . isDigit) securityCode'
len = length securityCode'
unless (null invChars) . Left $
"security code has invalid character(s): " <> intersperse ',' invChars
unless (len >= 3) $ Left "security code has fewer than 3 characters"
unless (len <= 4) $ Left "security code has more than 4 characters"
pure $ SecurityCode securityCode'
instance TTC.Render SecurityCode where
render (SecurityCode securityCode') = TTC.convert securityCode'
------------------------------------------------------------------------------
strip :: String -> String
strip = dropWhile isSpace . dropWhileEnd isSpace