From 01cd0f1f6e0f3f74963b739c256561d93922818c Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone <exarkun@twistedmatrix.com> Date: Fri, 23 Aug 2019 14:03:29 -0400 Subject: [PATCH] Hook the Stripe processor up to an in-memory database --- PaymentServer.cabal | 6 ++ src/PaymentServer/Persistence.hs | 78 ++++++++++++++++++++++++++ src/PaymentServer/Processors/Stripe.hs | 41 ++++++++++++-- test/SpecStripe.hs | 57 +++++++++++++++++-- test/Util/Gen.hs | 6 +- 5 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 src/PaymentServer/Persistence.hs diff --git a/PaymentServer.cabal b/PaymentServer.cabal index 6fb7773..6aa2bf4 100644 --- a/PaymentServer.cabal +++ b/PaymentServer.cabal @@ -16,6 +16,7 @@ cabal-version: >=1.10 library hs-source-dirs: src exposed-modules: PaymentServer.Processors.Stripe + , PaymentServer.Persistence build-depends: base >= 4.7 && < 5 , aeson , servant @@ -23,6 +24,8 @@ library , wai , warp , stripe-core + , text + , containers default-language: Haskell2010 executable PaymentServer-exe @@ -51,6 +54,7 @@ test-suite PaymentServer-test , time , QuickCheck , quickcheck-instances + , checkers , wai-extra , bytestring , text @@ -58,10 +62,12 @@ test-suite PaymentServer-test , 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/src/PaymentServer/Persistence.hs b/src/PaymentServer/Persistence.hs new file mode 100644 index 0000000..23b95dc --- /dev/null +++ b/src/PaymentServer/Persistence.hs @@ -0,0 +1,78 @@ +module PaymentServer.Persistence + ( Voucher + , VoucherDatabase(payForVoucher, redeemVoucher) + , memory + ) where + +import Data.Text + ( Text + ) +import qualified Data.Set as Set +import qualified Data.Map as Map +import Data.IORef + ( IORef + , newIORef + , modifyIORef + ) + +-- | 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 + +-- | 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. This +-- is primarily useful for testing. +data MemoryVoucherDatabase = + Memory + { paid :: IORef (Set.Set Voucher) + , redeemed :: IORef (Map.Map Voucher Fingerprint) + } + +instance VoucherDatabase MemoryVoucherDatabase where + payForVoucher Memory{ paid = paid, redeemed = redeemed } voucher = do + modifyIORef paid (Set.insert voucher) + return () + +-- | 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 index 94b10a3..3aa84a6 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -1,9 +1,11 @@ +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE TypeOperators #-} module PaymentServer.Processors.Stripe ( StripeAPI , stripeServer + , getVoucher ) where import Control.Monad.IO.Class @@ -33,8 +35,14 @@ import Web.Stripe.Event , EventData(ChargeEvent) ) import Web.Stripe.Types - ( Charge(Charge) + ( Charge(Charge, chargeMetaData) + , MetaData(MetaData) ) +import PaymentServer.Persistence + ( Voucher + , VoucherDatabase(payForVoucher) + ) + data Acknowledgement = Ok @@ -43,14 +51,35 @@ instance ToJSON Acknowledgement where type StripeAPI = "webhook" :> ReqBody '[JSON] Event :> Post '[JSON] Acknowledgement -stripeServer :: Server StripeAPI +-- | 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 :: Event -> Handler Acknowledgement +webhook :: VoucherDatabase d => d -> Event -> Handler Acknowledgement -- Process charge succeeded events -webhook Event{eventId=Just (EventId eventId), eventType=ChargeSucceededEvent, eventData=ChargeEvent{}} = do - return Ok +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 _ = return Ok +webhook d _ = + -- TODO: Record the eventId somewhere. + return Ok diff --git a/test/SpecStripe.hs b/test/SpecStripe.hs index 878fac5..8701540 100644 --- a/test/SpecStripe.hs +++ b/test/SpecStripe.hs @@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} -- -- Test suite for Stripe support in the payment server. @@ -6,8 +7,8 @@ module SpecStripe where -import Data.ByteString as BS -import Data.ByteString.Lazy as LazyBS +import qualified Data.ByteString as BS +import qualified Data.ByteString.Lazy as LazyBS import Data.Aeson ( encode ) @@ -28,13 +29,26 @@ import Test.Hspec.Wai , liftIO ) import Test.QuickCheck - ( generate + ( Property + , Gen + , arbitrary + , suchThat + , suchThatMap + , property + , generate + , forAll + , (===) + , (=/=) + ) +import Test.QuickCheck.Instances.Tuple + ( (>*<) ) import Util.WAI ( postJSON ) import Util.Gen ( chargeSucceededEvents + , hasVoucher ) import Util.JSON ( -- ToJSON instance for Event @@ -47,22 +61,33 @@ import Servant , Proxy(Proxy) , serve ) +import Web.Stripe.Types + ( MetaData(MetaData) + ) import PaymentServer.Processors.Stripe ( StripeAPI , stripeServer + , getVoucher + ) +import PaymentServer.Persistence + ( Voucher + , memory + ) +import Data.List.Index + ( insertAt ) stripeAPI :: Proxy StripeAPI stripeAPI = Proxy -app :: Application -app = serve stripeAPI stripeServer +app :: IO Application +app = memory >>= return . stripeServer >>= return . serve stripeAPI aChargeEvent :: IO LazyBS.ByteString aChargeEvent = encode <$> generate chargeSucceededEvents spec_webhook :: Spec -spec_webhook = with (return app) $ do +spec_webhook = with app $ do -- I would like to make these property tests but I can't figure out how to -- use QuickCheck (or Hedgehog) to write property tests for web code. @@ -81,3 +106,23 @@ spec_webhook = with (return app) $ do bodyMatcher :: [Network.HTTP.Types.Header] -> Body -> Maybe String bodyMatcher _ "{}" = Nothing bodyMatcher _ body = Just $ show body + +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) + +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 index 5bdeffd..5bdcd45 100644 --- a/test/Util/Gen.hs +++ b/test/Util/Gen.hs @@ -2,6 +2,8 @@ module Util.Gen ( chargeSucceededEvents + , posixTimes + , hasVoucher ) where import Data.Text @@ -144,5 +146,5 @@ chargeSucceededEvents = posixTimes :: Gen UTCTime posixTimes = (arbitrary :: Gen Integer) `suchThatMap` (Just . posixSecondsToUTCTime . fromIntegral . abs) --- dropFractionalSeconds :: UTCTime -> UTCTime --- dropFractionalSeconds (UTCTime day dayTime) = UTCTime day (round dayTime) +hasVoucher :: MetaData -> Bool +hasVoucher (MetaData items) = any (== "Voucher") . (map fst) $ items -- GitLab