diff --git a/PaymentServer.cabal b/PaymentServer.cabal index 89f755458e41e87cfe74c2ed2247fff9871f7fd6..4bba8eea5a2d1bc944ac946fb030c539a3bc978e 100644 --- a/PaymentServer.cabal +++ b/PaymentServer.cabal @@ -15,12 +15,21 @@ cabal-version: >=1.10 library hs-source-dirs: src - exposed-modules: Lib + exposed-modules: PaymentServer.Processors.Stripe + , PaymentServer.Persistence + , PaymentServer.Server + , PaymentServer.Main build-depends: base >= 4.7 && < 5 , aeson + , servant , servant-server , wai + , wai-extra + , data-default , warp + , stripe-core + , text + , containers default-language: Haskell2010 executable PaymentServer-exe @@ -34,13 +43,37 @@ executable PaymentServer-exe test-suite PaymentServer-test type: exitcode-stdio-1.0 hs-source-dirs: test - main-is: Spec.hs + main-is: Driver.hs + other-modules: SpecStripe + , SpecPersistence + , Util.WAI + , Util.Gen + , Util.JSON build-depends: base , PaymentServer , hspec , hspec-wai , hspec-wai-json + , hspec-expectations , aeson + , stripe-core + , time + , QuickCheck + , quickcheck-instances + , checkers + , wai-extra + , bytestring + , text + , http-types + , tasty + , tasty-th + , tasty-discover + , tasty-quickcheck + , tasty-hspec + , servant-server + , containers + , unordered-containers + , ilist ghc-options: -threaded -rtsopts -with-rtsopts=-N default-language: Haskell2010 diff --git a/README.rst b/README.rst index ef238ee4c5c59a41cd391d0f5be41a5a8dde7363..211cf19bfb7a568f1ad99094911e50072c0a8bb2 100644 --- a/README.rst +++ b/README.rst @@ -1,2 +1,45 @@ PaymentServer ============= + +PaymentServer maintains the state of a voucher database with respect to payments. +It receives payment notifications from payment processors and notes this in the database. + +Currently, Stripe is supported. + +Building +-------- + +Build using Stack:: + + $ stack build + +Testing +------- + +You can run the PaymentServer automated test suite using stack:: + + $ stack test + +You may also want to perform manual integration testing against Stripe. +First, run the server:: + + $ stack run + +Then configure Stripe with a `webhook`_ pointing at the server and receiving the *charge.successful* event. +Configure Stripe with ``http://<youraddress>:8081/v1/stripe/webhook``. + +Then create a testing charge using Stripe:: + + $ STRIPE_SECRET_KEY=sk_test_... + $ curl \ + https://api.stripe.com/v1/charges \ + -u ${STRIPE_SECRET_KEY}: \ + -d amount=999 \ + -d currency=usd \ + -d source=tok_visa \ + -d 'metadata[Voucher]=abcdefghijk' + +This results in Stripe making a callback to the PaymentServer with the charge details. +The PaymentServer marks the voucher as paid in its database. + +.. _webhook: https://stripe.com/docs/webhooks/setup#configure-webhook-settings diff --git a/app/Main.hs b/app/Main.hs index f66a415f02080ad1b552d1fb655750fe9ed0b03a..f0c5b6052d5c360e5f5bbaaec48fee9dc48001ef 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -1,6 +1,7 @@ -module Main where +module Main + ( main + ) where -import Lib - -main :: IO () -main = startApp +import PaymentServer.Main + ( main -- re-export + ) diff --git a/src/Lib.hs b/src/Lib.hs deleted file mode 100644 index 3844204aadfc686548ee94661bb696ead5d67e35..0000000000000000000000000000000000000000 --- a/src/Lib.hs +++ /dev/null @@ -1,40 +0,0 @@ -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE TemplateHaskell #-} -{-# LANGUAGE TypeOperators #-} -module Lib - ( startApp - , app - ) where - -import Data.Aeson -import Data.Aeson.TH -import Network.Wai -import Network.Wai.Handler.Warp -import Servant - -data User = User - { userId :: Int - , userFirstName :: String - , userLastName :: String - } deriving (Eq, Show) - -$(deriveJSON defaultOptions ''User) - -type API = "users" :> Get '[JSON] [User] - -startApp :: IO () -startApp = run 8080 app - -app :: Application -app = serve api server - -api :: Proxy API -api = Proxy - -server :: Server API -server = return users - -users :: [User] -users = [ User 1 "Isaac" "Newton" - , User 2 "Albert" "Einstein" - ] diff --git a/src/PaymentServer/Main.hs b/src/PaymentServer/Main.hs new file mode 100644 index 0000000000000000000000000000000000000000..adbc78b7754d89481363380c483f17064f42c9fe --- /dev/null +++ b/src/PaymentServer/Main.hs @@ -0,0 +1,32 @@ +-- | This module implements the main entrypoint to the PaymentServer. +module PaymentServer.Main + ( main + ) where + +import Data.Default + ( def + ) +import Network.Wai.Handler.Warp + ( run + ) +import Network.Wai.Middleware.RequestLogger + ( OutputFormat(Detailed, CustomOutputFormatWithDetails) + , outputFormat + , mkRequestLogger + ) +import Network.Wai.Middleware.RequestLogger.JSON + ( formatAsJSON + ) +import PaymentServer.Persistence + ( memory + ) +import PaymentServer.Server + ( paymentServerApp + ) + +main :: IO () +main = do + db <- memory + let app = paymentServerApp db + logger <- mkRequestLogger $ def { outputFormat = Detailed True} + run 8081 $ logger app diff --git a/src/PaymentServer/Persistence.hs b/src/PaymentServer/Persistence.hs new file mode 100644 index 0000000000000000000000000000000000000000..eea14b43cf92b45c2977e1e6801306aa4dd194db --- /dev/null +++ b/src/PaymentServer/Persistence.hs @@ -0,0 +1,104 @@ +module PaymentServer.Persistence + ( Voucher + , Fingerprint + , RedeemError(NotPaid, AlreadyRedeemed) + , VoucherDatabase(payForVoucher, redeemVoucher) + , memory + ) where + +import Control.Monad + ( liftM + ) +import Data.Text + ( Text + ) +import qualified Data.Set as Set +import qualified Data.Map as Map +import Data.IORef + ( IORef + , newIORef + , modifyIORef + , readIORef + ) + +-- | A voucher is a unique identifier which can be associated with a payment. +-- A paid voucher can be redeemed for ZKAPs which can themselves be exchanged +-- for service elsewhere with better privacy-preserving properties than the +-- voucher itself. +type Voucher = Text + +-- | Reasons that a voucher cannot be redeemed. +data RedeemError = + -- | The voucher has not been paid for. + NotPaid + -- | The voucher has already been redeemed. + | AlreadyRedeemed + deriving (Show, Eq) + +-- | A fingerprint cryptographically identifies a redemption of a voucher. +-- When a voucher is redeemed, a number of random tokens are received +-- alongside it. These tokens are signed to create ZKAPs to return to the +-- redeemer. To support fault tolerance (particularly network fault +-- tolerance) it is allowed to redeem a voucher more than once *so long as* +-- the same tokens are received with each attempt. The tokens are +-- cryptographically hashed to produce a fingerprint that can be persisted +-- along with the voucher state and checked on possibly-duplicate redemption +-- to support this case. +type Fingerprint = Text + +-- | A VoucherDatabase provides persistence for state related to vouchers. +class VoucherDatabase d where + -- | Change the state of the given voucher to indicate that it has been paid. + payForVoucher + :: d -- ^ The database in which to record the change + -> Voucher -- ^ A voucher which should be considered paid + -> IO () + + -- | Attempt to redeem a voucher. If it has not been redeemed before or it + -- has been redeemed with the same fingerprint, the redemption succeeds. + -- Otherwise, it fails. + redeemVoucher + :: d -- ^ The database + -> Voucher -- ^ A voucher to consider for redemption + -> Fingerprint -- ^ The retry-enabling fingerprint for this redemption + -> IO (Either RedeemError ()) -- ^ Left indicating the redemption is not allowed or Right indicating it is. + +-- | MemoryVoucherDatabase is a voucher database that only persists state +-- in-memory. The state does not outlive the process which creates it (nor +-- even the MemoryVoucherDatabase value). This is primarily useful for +-- testing. +data MemoryVoucherDatabase = + Memory { + -- | A set of vouchers which have been paid for. + paid :: IORef (Set.Set Voucher) + -- | A mapping from redeemed vouchers to fingerprints associated with the + -- redemption. + , redeemed :: IORef (Map.Map Voucher Fingerprint) + } + +instance VoucherDatabase MemoryVoucherDatabase where + payForVoucher Memory{ paid = paid, redeemed = redeemed } voucher = do + modifyIORef paid (Set.insert voucher) + return () + + redeemVoucher Memory{ paid = paid, redeemed = redeemed } voucher fingerprint = do + unpaid <- Set.notMember voucher <$> readIORef paid + existingFingerprint <- Map.lookup voucher <$> readIORef redeemed + case (unpaid, existingFingerprint) of + (True, _) -> + return $ Left NotPaid + (False, Nothing) -> do + modifyIORef redeemed (Map.insert voucher fingerprint) + return $ Right () + (False, Just fingerprint') -> + if fingerprint == fingerprint' then + return $ Right () + else + return $ Left AlreadyRedeemed + +-- | Create a new, empty MemoryVoucherDatabase. +memory :: IO MemoryVoucherDatabase +memory = do + paid <- newIORef mempty + redeemed <- newIORef mempty + return $ Memory paid redeemed diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs new file mode 100644 index 0000000000000000000000000000000000000000..3aa84a6eea156bc01e442cacaf67170f8a46da0a --- /dev/null +++ b/src/PaymentServer/Processors/Stripe.hs @@ -0,0 +1,85 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TypeOperators #-} + +module PaymentServer.Processors.Stripe + ( StripeAPI + , stripeServer + , getVoucher + ) where + +import Control.Monad.IO.Class + ( liftIO + ) +import Text.Printf + ( printf + ) +import Data.Aeson + ( ToJSON(toJSON) + , object + ) +import Servant + ( Server + , Handler + ) +import Servant.API + ( ReqBody + , JSON + , Post + , (:>) + ) +import Web.Stripe.Event + ( Event(Event, eventId, eventCreated, eventLiveMode, eventType, eventData, eventObject, eventPendingWebHooks, eventRequest) + , EventId(EventId) + , EventType(ChargeSucceededEvent) + , EventData(ChargeEvent) + ) +import Web.Stripe.Types + ( Charge(Charge, chargeMetaData) + , MetaData(MetaData) + ) +import PaymentServer.Persistence + ( Voucher + , VoucherDatabase(payForVoucher) + ) + + +data Acknowledgement = Ok + +instance ToJSON Acknowledgement where + toJSON Ok = object [] + +type StripeAPI = "webhook" :> ReqBody '[JSON] Event :> Post '[JSON] Acknowledgement + +-- | getVoucher finds the metadata item with the key `"Voucher"` and returns +-- the corresponding value, or Nothing. +getVoucher :: MetaData -> Maybe Voucher +getVoucher (MetaData []) = Nothing +getVoucher (MetaData (("Voucher", value):xs)) = Just value +getVoucher (MetaData (x:xs)) = getVoucher (MetaData xs) + +stripeServer :: VoucherDatabase d => d -> Server StripeAPI +stripeServer = webhook + +webhook :: VoucherDatabase d => d -> Event -> Handler Acknowledgement + +-- Process charge succeeded events +webhook d Event{eventId=Just (EventId eventId), eventType=ChargeSucceededEvent, eventData=(ChargeEvent charge)} = + case getVoucher $ chargeMetaData charge of + Nothing -> + -- TODO: Record the eventId somewhere. In all cases where we don't + -- associate the value of the charge with something in our system, we + -- probably need enough information to issue a refund. We're early + -- enough in the system here that refunds are possible and not even + -- particularly difficult. + return Ok + Just v -> do + -- TODO: What if it is a duplicate payment? payForVoucher should be + -- able to indicate error I guess. + () <- liftIO $ payForVoucher d v + return Ok + +-- Disregard anything else - but return success so that Stripe doesn't retry. +webhook d _ = + -- TODO: Record the eventId somewhere. + return Ok diff --git a/src/PaymentServer/Server.hs b/src/PaymentServer/Server.hs new file mode 100644 index 0000000000000000000000000000000000000000..f4ecaf3eb1f62a307e9a8dbc9651b05b6447855e --- /dev/null +++ b/src/PaymentServer/Server.hs @@ -0,0 +1,39 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TypeOperators #-} + +-- | This module exposes a Servant-based Network.Wai server for payment +-- interactions. +module PaymentServer.Server + ( paymentServerApp + ) where + +import Servant + ( Proxy(Proxy) + , Server + , Application + , serve + , (:>) + ) +import PaymentServer.Processors.Stripe + ( StripeAPI + , stripeServer + ) +import PaymentServer.Persistence + ( VoucherDatabase + ) + +-- | This is the complete type of the server API. +type PaymentServerAPI = "v1" :> "stripe" :> StripeAPI + +-- | Create a server which uses the given database. +paymentServer :: VoucherDatabase d => d -> Server PaymentServerAPI +paymentServer = stripeServer + +paymentServerAPI :: Proxy PaymentServerAPI +paymentServerAPI = Proxy + +-- | Create a Servant Application which serves the payment server API using +-- the given database. +paymentServerApp :: VoucherDatabase d => d -> Application +paymentServerApp = (serve paymentServerAPI) . paymentServer diff --git a/stack.yaml b/stack.yaml index 9879a51389f8ae856386953ccd3db2a4ea146b24..75399a2e58530adf91ef7986d22afc5aff66d297 100644 --- a/stack.yaml +++ b/stack.yaml @@ -37,7 +37,8 @@ packages: # Dependency packages to be pulled from upstream that are not in the resolver # using the same syntax as the packages field. # (e.g., acme-missiles-0.3) -# extra-deps: [] +extra-deps: + - "stripe-core-2.5.0" # Override default flag values for local packages and extra-deps # flags: {} diff --git a/test/Driver.hs b/test/Driver.hs new file mode 100644 index 0000000000000000000000000000000000000000..f2287e7136655bb1b55f1262258bcbd21bb32cb8 --- /dev/null +++ b/test/Driver.hs @@ -0,0 +1,8 @@ +{-# OPTIONS_GHC -F -pgmF tasty-discover #-} + +-- This is a module where we can hang the above preprocessor definition to +-- direct tasty-discover to find our test suite spread across the rest of the +-- modules rooted in this directory. +-- +-- See the test-suite definition in PaymentServer.cabal +-- See also https://git.coop/decentral1se/tasty-discover diff --git a/test/Spec.hs b/test/Spec.hs deleted file mode 100644 index 8aefe968b9a875f97786a3ab87cdf4bd7dbd20b6..0000000000000000000000000000000000000000 --- a/test/Spec.hs +++ /dev/null @@ -1,20 +0,0 @@ -{-# LANGUAGE QuasiQuotes #-} -{-# LANGUAGE OverloadedStrings #-} -module Main (main) where - -import Lib (app) -import Test.Hspec -import Test.Hspec.Wai -import Test.Hspec.Wai.JSON - -main :: IO () -main = hspec spec - -spec :: Spec -spec = with (return app) $ do - describe "GET /users" $ do - it "responds with 200" $ do - get "/users" `shouldRespondWith` 200 - it "responds with [User]" $ do - let users = "[{\"userId\":1,\"userFirstName\":\"Isaac\",\"userLastName\":\"Newton\"},{\"userId\":2,\"userFirstName\":\"Albert\",\"userLastName\":\"Einstein\"}]" - get "/users" `shouldRespondWith` users diff --git a/test/SpecPersistence.hs b/test/SpecPersistence.hs new file mode 100644 index 0000000000000000000000000000000000000000..db6d8a5be3f33853454c11bb95f3439b71766128 --- /dev/null +++ b/test/SpecPersistence.hs @@ -0,0 +1,86 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +-- +-- Test suite related to the persistence system. +-- + +module SpecPersistence where + +import Control.Monad.IO.Class + ( liftIO + ) +import Test.Hspec + ( Spec + , describe + , it + ) +import Test.Hspec.Expectations + ( shouldReturn + ) +import Test.QuickCheck + ( Property + , property + , (==>) + ) +import Test.QuickCheck.Monadic + ( monadicIO + , run + , assert + , pre + ) +import Test.QuickCheck.Instances.Text + ( + ) +import PaymentServer.Persistence + ( RedeemError(NotPaid, AlreadyRedeemed) + , Voucher + , Fingerprint + , VoucherDatabase(payForVoucher, redeemVoucher) + , memory + ) + +-- | A voucher which has not been paid for cannot be redeemed. +unpaidVoucherNotRedeemable :: VoucherDatabase d => IO d -> Property +unpaidVoucherNotRedeemable getDB = property $ \voucher fingerprint -> do + db <- liftIO getDB + redeemVoucher db voucher fingerprint `shouldReturn` Left NotPaid + +-- | A voucher which is paid for can be redeemed with any fingerprint. +paidVoucherRedeemable :: VoucherDatabase d => IO d -> Property +paidVoucherRedeemable getDB = property $ \voucher fingerprint -> do + db <- liftIO getDB + () <- payForVoucher db voucher + redeemVoucher db voucher fingerprint `shouldReturn` Right () + +-- | A voucher which is paid for can be redeemed more than once as long as the +-- same fingerprint is used each time. +paidVoucherMultiRedeemable :: VoucherDatabase d => IO d -> Property +paidVoucherMultiRedeemable getDB = property $ \voucher fingerprint -> do + db <- liftIO getDB + () <- payForVoucher db voucher + let redeem = redeemVoucher db voucher fingerprint + redeem + redeem `shouldReturn` Right () + +-- | A voucher which is paid for can not be redeemed a second time with a +-- different fingerprint than was used on the first attempt. +paidVoucherMismatchFingerprint :: VoucherDatabase d => IO d -> Property +paidVoucherMismatchFingerprint getDB = property $ \voucher fingerprint fingerprint' -> + fingerprint /= fingerprint' ==> do + db <- liftIO getDB + () <- payForVoucher db voucher + let redeem = redeemVoucher db voucher + redeem fingerprint + redeem fingerprint' `shouldReturn` Left AlreadyRedeemed + +makeSpec :: VoucherDatabase d => IO d -> Spec +makeSpec getDB = + describe "voucher interactions" $ + do + it "denies redemption of a not-paid-for voucher" $ unpaidVoucherNotRedeemable getDB + it "allows redemption of paid-for vouchers" $ paidVoucherRedeemable getDB + it "allows multiple redemption as long as the same fingerprint is used" $ paidVoucherMultiRedeemable getDB + it "denies a subsequent redemption with a different fingerprint" $ paidVoucherMismatchFingerprint getDB + +spec_memory = makeSpec memory diff --git a/test/SpecStripe.hs b/test/SpecStripe.hs new file mode 100644 index 0000000000000000000000000000000000000000..23ad2932d4368416880b5383a23f9d947017eea8 --- /dev/null +++ b/test/SpecStripe.hs @@ -0,0 +1,123 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +-- +-- Test suite for Stripe support in the payment server. +-- + +module SpecStripe where + +import qualified Data.ByteString as BS +import qualified Data.ByteString.Lazy as LazyBS +import Data.Aeson + ( encode + ) +import Test.Hspec + ( Spec + , describe + , it + ) +import Test.Hspec.Wai + ( WaiSession + , WaiExpectation + , MatchBody(MatchBody) + , ResponseMatcher(matchBody) + , Body + , with + , post + , shouldRespondWith + , liftIO + ) +import Test.Hspec.Wai.QuickCheck + ( property + ) +import Test.QuickCheck + ( Property + , Gen + , arbitrary + , generate + , forAll + , (===) + , (=/=) + ) +import Util.WAI + ( postJSON + ) +import Util.Gen + ( ChargeEvents(GoodChargeEvent, BadChargeEvent) + , chargeSucceededEvents + , metaDatasWithVoucher + , metaDatasWithoutVoucher + ) +import Util.JSON + ( -- ToJSON instance for Event + ) +import Network.HTTP.Types + ( Header + ) +import Servant + ( Application + , Proxy(Proxy) + , serve + ) +import Web.Stripe.Types + ( MetaData(MetaData) + ) +import PaymentServer.Processors.Stripe + ( StripeAPI + , stripeServer + , getVoucher + ) +import PaymentServer.Persistence + ( Voucher + , memory + ) + +stripeAPI :: Proxy StripeAPI +stripeAPI = Proxy + +app :: IO Application +app = serve stripeAPI . stripeServer <$> memory + +spec_webhook :: Spec +spec_webhook = with app $ do + describe "error behavior of POST /webhook" $ do + it "responds to non-JSON Content-Type with 415 (Unsupported Media Type)" $ + post "/webhook" "xxx" `shouldRespondWith` 415 + + it "responds to JSON non-Event body with 400 (Invalid Request)" $ + postJSON "/webhook" "{}" `shouldRespondWith` 400 + + -- I would like to make most or all of these into property tests. *This* + -- test shows how you can do it. Yay. The main thing (for me, anyway) to + -- remember is to use `property` from Test.Hspec.Wai.QuickCheck and not from + -- `Test.QuickCheck`. :/ Unsure whether I love the apparent Haskell + -- convention of giving the same name to *similar* functions. + describe "success behavior of POST /webhook" $ + it "responds to a JSON Event body with 200 (OK)" $ + let + test e = + postJSON "/webhook" (encode e) `shouldRespondWith` 200 { matchBody = MatchBody bodyMatcher } + -- For now these are the same. Maybe they always will be? The HTTP + -- behavior is the same though the backend behavior may differ. Note + -- that a "test_" prefix would cause tasty-discover to find this and try + -- to call it - but it can't since it's not a top-level, let alone + -- exported. + xtest_postWithEventBody (GoodChargeEvent e) = test e + xtest_postWithEventBody (BadChargeEvent e) = test e + in + property xtest_postWithEventBody + + + +bodyMatcher :: [Network.HTTP.Types.Header] -> Body -> Maybe String +bodyMatcher _ "{}" = Nothing +bodyMatcher _ body = Just $ show body + +prop_getVoucherFindsVoucher :: Property +prop_getVoucherFindsVoucher = forAll metaDatasWithVoucher $ \x -> + getVoucher x =/= Nothing + +prop_getVoucherWithoutVoucher :: Property +prop_getVoucherWithoutVoucher = forAll metaDatasWithoutVoucher $ \x -> + getVoucher x === Nothing diff --git a/test/Util/Gen.hs b/test/Util/Gen.hs new file mode 100644 index 0000000000000000000000000000000000000000..ffba41237a591d05a7834df9663564f46d7b5d2b --- /dev/null +++ b/test/Util/Gen.hs @@ -0,0 +1,187 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Util.Gen + ( chargeSucceededEvents + , metaDatasWithoutVoucher + , metaDatasWithVoucher + , posixTimes + , hasVoucher + , ChargeEvents(GoodChargeEvent, BadChargeEvent) + ) where + +import Data.Text + ( Text + ) +import Data.List.Index + ( insertAt + ) +import Data.Time.Clock + ( UTCTime(UTCTime) + ) +import Data.Time.Clock.POSIX + ( POSIXTime + , posixSecondsToUTCTime + ) +import Web.Stripe.Types + ( Charge(Charge) + , StatementDescription(StatementDescription) + , MetaData(MetaData) + , Description(Description) + , CustomerId(CustomerId) + , InvoiceId(InvoiceId) + , ChargeId(ChargeId) + , Expandable(Id) + , Currency(USD, UnknownCurrency) + , Amount(Amount) + , StripeList(StripeList, list, totalCount, hasMore) + ) +import Web.Stripe.Event + ( Event(Event, eventId, eventCreated, eventLiveMode, eventType, eventData, eventObject, eventPendingWebHooks, eventRequest) + , EventType(ChargeSucceededEvent) + , EventData(ChargeEvent) + , EventId(EventId) + ) +import Test.QuickCheck + ( Gen + , Arbitrary + , arbitrary + , Positive(Positive) + , oneof + , suchThat + , suchThatMap + ) +import Test.QuickCheck.Instances.Tuple + ( (>*<) + ) +import Test.QuickCheck.Instances.Time + ( -- Get the `Gen UTCTime` instance + ) +import Test.QuickCheck.Instances.Text + ( -- Get the `Gen Text` instance + ) +import PaymentServer.Persistence + ( Voucher + ) + +instance Arbitrary Charge where + arbitrary = charges True + +instance Semigroup (StripeList a) where + -- Very weak implementation + x <> y = StripeList (list x <> list y) "" "" Nothing (hasMore x || hasMore y) + +instance Monoid (StripeList a) where + mempty = StripeList [] "" "" (Just 0) False + +instance Arbitrary Amount where + arbitrary = Amount <$> arbitrary + + +instance Arbitrary EventId where + arbitrary = EventId <$> arbitrary + + +instance Arbitrary StatementDescription where + arbitrary = StatementDescription <$> arbitrary + +metaDatasWithoutVoucher = (arbitrary :: Gen MetaData) `suchThat` (not . hasVoucher) +-- Just filtering out random metadatas that don't have a voucher makes for an +-- incredibly inefficient generator. So start without a voucher and then add +-- one. +metaDatasWithVoucher = ((arbitrary :: Gen Voucher) >*< metaDatasWithoutVoucher) `suchThatMap` (Just. uncurry addVoucher) + +addVoucher :: Voucher -> MetaData -> MetaData +addVoucher voucher (MetaData []) = MetaData [("Voucher", voucher)] +addVoucher voucher (MetaData items) = + MetaData (insertAt (1234567 `mod` length items) ("Voucher", voucher) items) + +instance Arbitrary MetaData where + arbitrary = MetaData <$> arbitrary + +instance Arbitrary Description where + arbitrary = Description <$> arbitrary + + +instance Arbitrary InvoiceId where + arbitrary = InvoiceId <$> arbitrary + + +instance Arbitrary ChargeId where + arbitrary = ChargeId <$> arbitrary + + +instance Arbitrary CustomerId where + arbitrary = CustomerId <$> arbitrary + + +instance Arbitrary a => Arbitrary (Expandable a) where + arbitrary = Id <$> arbitrary + +chargeSucceededEvents :: Bool -> Gen Event +chargeSucceededEvents withVoucher = + Event + <$> arbitrary -- eventId + <*> posixTimes -- eventCreated + <*> arbitrary -- eventLiveMode + <*> return ChargeSucceededEvent -- eventType + <*> chargeEvents withVoucher -- eventData + <*> return "event" -- eventObject + <*> arbitrary -- eventPendingWebHooks + <*> arbitrary -- eventRequest + + +chargeEvents :: Bool -> Gen EventData +chargeEvents withVoucher = + ChargeEvent <$> charges withVoucher + +charges :: Bool -> Gen Charge +charges withVoucher = + Charge + <$> arbitrary -- chargeId :: ChargeId + <*> return "charge" -- chargeObject :: Text + <*> posixTimes -- chargeCreated :: UTCTime + <*> arbitrary -- chargeLiveMode :: Bool + <*> arbitrary -- chargePaid :: Bool + <*> arbitrary -- chargeAmount :: Amount + <*> oneof + [ return UnknownCurrency + , return USD + ] -- chargeCurrency :: Currency + <*> return False -- chargeRefunded :: Bool + <*> return Nothing -- chargeCreditCard :: Maybe Card + <*> arbitrary -- chargeCaptured :: Bool + <*> return mempty -- chargeRefunds :: StripeList Refund + <*> return Nothing -- chargeBalanceTransaction :: Maybe (Expandable TransactionId) + <*> return Nothing -- chargeFailureMessage :: Maybe Text + <*> return Nothing -- chargeFailureCode :: Maybe Text + <*> return 0 -- chargeAmountRefunded :: Int + <*> arbitrary -- chargeCustomerId :: Maybe (Expandable CustomerId) + <*> return Nothing -- chargeInvoice :: Maybe (Expandable InvoiceId) + <*> arbitrary -- chargeDescription :: Maybe Description + <*> return Nothing -- chargeDispute :: Maybe Dispute + <*> ( + if withVoucher then + metaDatasWithVoucher + else + metaDatasWithoutVoucher + ) -- chargeMetaData :: MetaData + <*> arbitrary -- chargeStatementDescription :: Maybe StatementDescription + <*> arbitrary -- chargeReceiptEmail :: Maybe Text + <*> arbitrary -- chargeReceiptNumber :: Maybe Text + +data ChargeEvents + = GoodChargeEvent Event + | BadChargeEvent Event + deriving (Show, Eq) + +instance Arbitrary ChargeEvents where + arbitrary = oneof + [ chargeSucceededEvents True `suchThatMap` (Just . GoodChargeEvent) + , chargeSucceededEvents False `suchThatMap` (Just . BadChargeEvent) + ] + +posixTimes :: Gen UTCTime +posixTimes = (arbitrary :: Gen Integer) `suchThatMap` (Just . posixSecondsToUTCTime . fromIntegral . abs) + +hasVoucher :: MetaData -> Bool +hasVoucher (MetaData items) = elem "Voucher" . map fst $ items diff --git a/test/Util/JSON.hs b/test/Util/JSON.hs new file mode 100644 index 0000000000000000000000000000000000000000..f43be9f362f703a9cc376cfa3fb8f72dd6dce601 --- /dev/null +++ b/test/Util/JSON.hs @@ -0,0 +1,175 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Util.JSON where + +import Data.Time.Clock.POSIX + ( utcTimeToPOSIXSeconds + ) +import Data.HashMap.Lazy + ( fromList + ) +import qualified Data.Sequence as Seq +import Web.Stripe.Types + ( Charge(Charge) + , ChargeId(ChargeId) + , InvoiceId(InvoiceId) + , Amount(Amount) + , Currency(USD, UnknownCurrency) + , MetaData(MetaData) + , StripeList(StripeList) + , Refund(Refund) + , RefundId(RefundId) + , TransactionId(TransactionId) + , Expandable(Id) + ) +import Web.Stripe.Event + ( Event(Event) + , EventData(ChargeEvent) + , EventType(ChargeSucceededEvent) + , EventId(EventId) + ) +import Data.Aeson + ( ToJSON(toJSON) + , Value(String, Number, Object, Array) + , object + , (.=) + ) +import Data.Aeson.Types + ( listValue + ) + +instance ToJSON Event where + toJSON (Event + eventId + eventCreated + eventLiveMode + eventType + eventData + eventObject + eventPendingWebHooks + eventRequest) + = object + [ "id" .= eventId + , "object" .= eventObject + , "api_version" .= String "2018-05-21" + , "created" .= utcTimeToPOSIXSeconds eventCreated + , "data" .= object [ "object" .= eventData ] + , "type" .= eventType + , "livemode" .= eventLiveMode + , "pending_webhooks" .= eventPendingWebHooks + , "request" .= eventRequest + ] + +instance ToJSON a => ToJSON (Expandable a) where + toJSON (Id eId) = toJSON eId + +instance ToJSON EventId where + toJSON (EventId eId) = String eId + +instance ToJSON InvoiceId where + toJSON (InvoiceId iId) = String iId + +instance ToJSON ChargeId where + toJSON (ChargeId cId) = String cId + +instance ToJSON RefundId where + toJSON (RefundId rId) = String rId + +instance ToJSON TransactionId where + toJSON (TransactionId tId) = String tId + +instance ToJSON Currency where + toJSON USD = "USD" + toJSON UnknownCurrency = "???" + +instance ToJSON EventType where + toJSON ChargeSucceededEvent = "charge.succeeded" + +instance ToJSON EventData where + toJSON (ChargeEvent charge) = toJSON charge + +instance ToJSON Amount where + toJSON (Amount a) = Number $ fromIntegral a + +instance ToJSON MetaData where + toJSON (MetaData items) = (Object . fromList . map (\(k, v) -> (k, String v))) items + +instance ToJSON Refund where + toJSON (Refund + refundId + refundAmount + refundCurrency + refundCreated + refundObject + refundCharge + refundBalanceTransaction + refundMetaData + ) + = object + [ "id" .= refundId + , "amount" .= refundAmount + , "currency" .= refundCurrency + , "created" .= utcTimeToPOSIXSeconds refundCreated + , "object" .= refundObject + , "charge" .= refundCharge + , "balance_transaction" .= refundBalanceTransaction + , "metadata" .= refundMetaData + ] + +instance (ToJSON a) => ToJSON (StripeList a) where + toJSON (StripeList elements stripeUrl obj totalCount hasMore) + = object + [ "data" .= toJSON elements + , "url" .= stripeUrl + , "object" .= obj + , "has_more" .= hasMore + ] + +instance ToJSON Charge where + toJSON (Charge + chargeId + chargeObject + chargeCreated + chargeLiveMode + chargePaid + chargeAmount + chargeCurrency + chargeRefunded + chargeCreditChard + chargeCaptured + chargeRefunds + chargeBalanceTransaction + chargeFailureMessage + chargeFailureCode + chargeAmountRefunded + chargeCustomerId + chargeInvoice + chargeDescription + chargeDispute + chargeMetaData + chargeStatementDescription + chargeReceiptEmail + chargeNumber + ) + = object + [ "id" .= chargeId + , "object" .= chargeObject + , "amount" .= chargeAmount + , "amount_refunded" .= chargeAmountRefunded + -- , "balance_transaction" .= chargeBalanceTransaction + , "captured" .= chargeCaptured + , "created" .= utcTimeToPOSIXSeconds chargeCreated + , "currency" .= chargeCurrency + -- , "customer" .= chargeCustomerId + -- , "description" .= chargeDescription + -- , "dispute" .= chargeDispute + -- , "failure_code" .= chargeFailureCode + -- , "failure_message" .= chargeFailureMessage + -- , "invoice" .= chargeInvoice + , "livemode" .= chargeLiveMode + , "metadata" .= chargeMetaData + , "paid" .= chargePaid + , "receipt_email" .= chargeReceiptEmail + , "refunded" .= chargeRefunded + , "refunds" .= chargeRefunds + ] diff --git a/test/Util/WAI.hs b/test/Util/WAI.hs new file mode 100644 index 0000000000000000000000000000000000000000..53d29ff74af1c937a477b89390868a23d7090d3d --- /dev/null +++ b/test/Util/WAI.hs @@ -0,0 +1,24 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Util.WAI + ( postJSON + ) where + +import Data.ByteString.Lazy as LazyBS +import Data.ByteString as BS +import Test.Hspec.Wai + ( WaiSession + , request + ) +import Network.HTTP.Types.Method + ( methodPost + ) +import Network.Wai.Test + ( SResponse + ) + +-- Post some JSON to a path. +-- Return a function from path to a response +postJSON :: BS.ByteString -> (LazyBS.ByteString -> WaiSession SResponse) +postJSON path = + request methodPost path [("Content-Type", "application/json")]