From c166441886e610ff6b655065de059d67ec421af1 Mon Sep 17 00:00:00 2001
From: Jean-Paul Calderone <exarkun@twistedmatrix.com>
Date: Tue, 4 May 2021 15:07:06 -0400
Subject: [PATCH] Insert the Stripe ChargeId in our local database

We can't control whether Stripe puts the voucher we supply in its metadata.
We can control whether we put the Stripe ChargeId Stripe supplies in our
database, though.
---
 PaymentServer.cabal                    |  1 +
 nix/PaymentServer.nix                  |  1 +
 src/PaymentServer/Persistence.hs       | 89 ++++++++++++++++----------
 src/PaymentServer/Processors/Stripe.hs | 28 +++-----
 test/Persistence.hs                    | 53 +++++++++++----
 5 files changed, 105 insertions(+), 67 deletions(-)

diff --git a/PaymentServer.cabal b/PaymentServer.cabal
index 134ce45..c80b595 100644
--- a/PaymentServer.cabal
+++ b/PaymentServer.cabal
@@ -86,6 +86,7 @@ test-suite PaymentServer-tests
                      , wai-extra
                      , servant-server
                      , prometheus-client
+                     , stripe-core
                      , PaymentServer
   default-language: Haskell2010
 
diff --git a/nix/PaymentServer.nix b/nix/PaymentServer.nix
index 861ed69..a8df27a 100644
--- a/nix/PaymentServer.nix
+++ b/nix/PaymentServer.nix
@@ -115,6 +115,7 @@ in { system, compiler, flags, pkgs, hsPkgs, pkgconfPkgs, ... }:
             (hsPkgs."wai-extra" or (buildDepError "wai-extra"))
             (hsPkgs."servant-server" or (buildDepError "servant-server"))
             (hsPkgs."prometheus-client" or (buildDepError "prometheus-client"))
+            (hsPkgs."stripe-core" or (buildDepError "stripe-core"))
             (hsPkgs."PaymentServer" or (buildDepError "PaymentServer"))
             ];
           };
diff --git a/src/PaymentServer/Persistence.hs b/src/PaymentServer/Persistence.hs
index 478821e..8ec3753 100644
--- a/src/PaymentServer/Persistence.hs
+++ b/src/PaymentServer/Persistence.hs
@@ -7,6 +7,7 @@ module PaymentServer.Persistence
   , Fingerprint
   , RedeemError(NotPaid, AlreadyRedeemed, DuplicateFingerprint, DatabaseUnavailable)
   , PaymentError(AlreadyPaid, PaymentFailed)
+  , ProcessorResult
   , VoucherDatabase(payForVoucher, redeemVoucher, redeemVoucherWithCounter)
   , VoucherDatabaseState(MemoryDB, SQLiteDB)
   , memory
@@ -50,6 +51,9 @@ import Data.Maybe
 import Web.Stripe.Error
   ( StripeError
   )
+import Web.Stripe.Types
+  ( ChargeId(ChargeId)
+  )
 
 -- | 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
@@ -111,19 +115,28 @@ type Fingerprint = Text
 -- allowed).
 type RedemptionKey = (Voucher, Integer)
 
+-- | The result of completing payment processing.  This is either an error
+-- indicating that the payment has *not* been completed (funds will not move)
+-- or a payment processor-specific identifier for the completed transaction
+-- (funds will move).
+type ProcessorResult = Either PaymentError ChargeId
+
 -- | 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 a          -- ^ An operation which completes the payment.  This is
-                     -- evaluated in the context of a database transaction so
-                     -- that if it fails the voucher is not marked as paid in
-                     -- the database but if it succeeds the database state is
-                     -- not confused by a competing transaction run around the
-                     -- same time.
-    -> IO a
+    :: d
+    -- ^ The database in which to record the change
+    -> Voucher
+    -- ^ A voucher which should be considered paid
+    -> IO ProcessorResult
+    -- ^ An operation which completes the payment.  This is evaluated in the
+    -- context of a database transaction so that if it fails the voucher is
+    -- not marked as paid in the database but if it succeeds the database
+    -- state is not confused by a competing transaction run around the same
+    -- time.
+    -> IO ProcessorResult
+    -- ^ The result of the attempt to complete payment processing.
 
   -- | Attempt to redeem a voucher.  If it has not been redeemed before or it
   -- has been redeemed with the same fingerprint, the redemption succeeds.
@@ -180,8 +193,12 @@ instance VoucherDatabase VoucherDatabaseState where
       else
       do
         result <- pay
-        -- Only modify the paid set if the payment succeeds.
-        modifyIORef paidRef (Set.insert voucher)
+        case result of
+          Right chargeId ->
+            -- Only modify the paid set if the payment succeeds.
+            modifyIORef paidRef (Set.insert voucher)
+
+          Left _ -> return ()
         return result
 
   payForVoucher SQLiteDB{ connect = connect } voucher pay =
@@ -329,32 +346,34 @@ getVoucherFingerprint dbConn (voucher, counter) =
     listToMaybe <$> Sqlite.query dbConn sql ((voucher :: Text), (counter :: Integer))
 
 -- | Mark the given voucher as paid in the database.
-insertVoucher :: Sqlite.Connection -> Voucher -> IO a -> IO a
+insertVoucher :: Sqlite.Connection -> Voucher -> IO ProcessorResult -> IO ProcessorResult
 insertVoucher dbConn voucher pay =
-  -- Begin an immediate transaction so that it includes the IO.  The first
-  -- thing we do is execute our one and only statement so the transaction is
-  -- immediate anyway but it doesn't hurt to be explicit.
-  Sqlite.withImmediateTransaction dbConn $
+  -- Begin an immediate transaction so that it includes the IO.  The
+  -- transaction is immediate so that we can first check that the voucher is
+  -- unique and then proceed to do the IO without worrying that another
+  -- request will concurrently begin operating on the same voucher.
+  Sqlite.withExclusiveTransaction dbConn $
   do
-    -- Vouchers must be unique in this table.  This might fail if someone is
-    -- trying to double-pay for a voucher.  In this case, we won't ever
-    -- finalize the payment.
-    Sqlite.execute dbConn "INSERT INTO vouchers (name) VALUES (?)" (Sqlite.Only voucher)
-      `catch` handleConstraintError
-    -- If we managed to insert the voucher, try to finalize the payment.  If
-    -- this succeeds, the transaction is committed and we expect the payment
-    -- system to actually be moving money around.  If it fails, we expect the
-    -- payment system *not* to move money around and the voucher should not be
-    -- marked as paid.  The transaction will be rolled back so, indeed, it
-    -- won't be marked thus.
-    pay
-
-  where
-    handleConstraintError Sqlite.SQLError { Sqlite.sqlError = Sqlite.ErrorConstraint } =
-      throwIO AlreadyPaid
-    handleConstraintError e =
-      throwIO e
-
+    -- Vouchers must be unique in this table.  Check to see if this one
+    -- already exists.
+    rows <- Sqlite.query dbConn "SELECT 1 FROM vouchers WHERE name = ?" (Sqlite.Only voucher) :: IO [Sqlite.Only Int]
+    if length rows /= 0
+      then throwIO AlreadyPaid
+      else
+      do
+        -- If the voucher isn't present yet, try to finalize the payment.  If
+        -- this succeeds, the transaction is committed and we expect the
+        -- payment system to actually be moving money around.  If it fails, we
+        -- expect the payment system *not* to move money around and the
+        -- voucher should not be marked as paid.  The transaction will be
+        -- rolled back so, indeed, it won't be marked thus.
+        result <- pay
+        case result of
+          Right (ChargeId chargeId) -> do
+            Sqlite.execute dbConn "INSERT INTO vouchers (name, charge_id) VALUES (?, ?)" (voucher, chargeId)
+            return result
+          Left err ->
+            return result
 
 -- | Mark the given voucher as having been redeemed (with the given
 -- fingerprint) in the database.
diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs
index c279b4b..1696eaf 100644
--- a/src/PaymentServer/Processors/Stripe.hs
+++ b/src/PaymentServer/Processors/Stripe.hs
@@ -1,6 +1,7 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE DataKinds #-}
 {-# LANGUAGE TypeOperators #-}
+{-# LANGUAGE NamedFieldPuns #-}
 
 module PaymentServer.Processors.Stripe
   ( StripeAPI
@@ -47,7 +48,7 @@ import Web.Stripe.Error
   , StripeErrorType(InvalidRequest, APIError, ConnectionFailure, CardError)
   )
 import Web.Stripe.Types
-  ( Charge(Charge, chargeMetaData)
+  ( Charge(Charge, chargeId)
   , MetaData(MetaData)
   , Currency
   )
@@ -70,6 +71,7 @@ import PaymentServer.Persistence
   ( Voucher
   , VoucherDatabase(payForVoucher)
   , PaymentError(AlreadyPaid, PaymentFailed)
+  , ProcessorResult
   )
 
 data Acknowledgement = Ok
@@ -149,7 +151,7 @@ withSuccessFailureMetrics attemptCount successCount op = do
 charge :: VoucherDatabase d => StripeConfig -> d -> Charges -> Handler Acknowledgement
 charge stripeConfig d (Charges token voucher amount currency) = do
   currency' <- getCurrency currency
-  result <- liftIO ((payForVoucher d voucher (completeStripeCharge currency')) :: IO (Either PaymentError Charge))
+  result <- liftIO ((payForVoucher d voucher (completeStripeCharge currency')) :: IO ProcessorResult)
   case result of
     Left AlreadyPaid ->
       throwError voucherAlreadyPaid
@@ -157,8 +159,7 @@ charge stripeConfig d (Charges token voucher amount currency) = do
       liftIO $ print "Stripe createCharge failed:"
       liftIO $ print msg
       throwError . errorForStripeType $ errorType
-    Right Charge { chargeMetaData = metadata } ->
-      checkVoucherMetadata metadata
+    Right chargeId -> return Ok
     where
       getCurrency :: Text -> Handler Currency
       getCurrency maybeCurrency =
@@ -167,30 +168,20 @@ charge stripeConfig d (Charges token voucher amount currency) = do
           Nothing -> throwError unsupportedCurrency
 
       tokenId = TokenId token
-      completeStripeCharge :: Currency -> IO (Either PaymentError Charge)
+      completeStripeCharge :: Currency -> IO ProcessorResult
       completeStripeCharge currency' = do
-        result <- (stripe stripeConfig charge) :: IO (Either StripeError Charge)
+        result <- stripe stripeConfig charge
         case result of
           Left any ->
             return . Left $ PaymentFailed any
-          Right any ->
-            return . Right $ any
+          Right (Charge { chargeId }) ->
+            return . Right $ chargeId
           where
           charge =
             createCharge (Amount amount) currency'
             -&- tokenId
             -&- MetaData [("Voucher", voucher)]
 
-      checkVoucherMetadata :: MetaData -> Handler Acknowledgement
-      checkVoucherMetadata metadata =
-        -- verify that we are getting the same metadata that we sent.
-        case metadata of
-          MetaData [("Voucher", v)] ->
-            if v == voucher
-            then return Ok
-            else throwError voucherCodeMismatch
-          _ -> throwError voucherCodeNotFound
-
       -- "Invalid request errors arise when your request has invalid parameters."
       errorForStripeType InvalidRequest    = internalServerError
 
@@ -211,7 +202,6 @@ charge stripeConfig d (Charges token voucher amount currency) = do
 
       serviceUnavailable  = jsonErr 503 "Service temporarily unavailable"
       internalServerError = jsonErr 500 "Internal server error"
-      voucherCodeMismatch = jsonErr 500 "Voucher code mismatch"
       unsupportedCurrency = jsonErr 400 "Invalid currency specified"
       voucherCodeNotFound = jsonErr 400 "Voucher code not found"
       stripeChargeFailed  = jsonErr 400 "Stripe charge didn't succeed"
diff --git a/test/Persistence.hs b/test/Persistence.hs
index f5af378..ba91eec 100644
--- a/test/Persistence.hs
+++ b/test/Persistence.hs
@@ -38,13 +38,22 @@ import System.Directory
 
 import qualified Database.SQLite.Simple as Sqlite
 
+import Web.Stripe.Types
+  ( ChargeId(ChargeId)
+  )
+import Web.Stripe.Error
+  ( StripeErrorType(CardError)
+  , StripeError(StripeError)
+  )
+
 import PaymentServer.Persistence
   ( Voucher
   , Fingerprint
   , RedeemError(NotPaid, AlreadyRedeemed, DuplicateFingerprint, DatabaseUnavailable)
-  , PaymentError(AlreadyPaid)
+  , PaymentError(AlreadyPaid, PaymentFailed)
   , VoucherDatabase(payForVoucher, redeemVoucher, redeemVoucherWithCounter)
   , VoucherDatabaseState(SQLiteDB)
+  , ProcessorResult
   , memory
   , sqlite
   , upgradeSchema
@@ -70,14 +79,24 @@ anotherVoucher = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
 fingerprint = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
 anotherFingerprint = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
 
+aChargeId :: ChargeId
+aChargeId = ChargeId "abc"
+
 -- Mock a successful payment.
-paySuccessfully :: IO ()
-paySuccessfully = return ()
+paySuccessfully :: IO ProcessorResult
+paySuccessfully = return . Right $ aChargeId
 
 -- Mock a failed payment.
-failPayment :: IO ()
+failPayment :: IO ProcessorResult
 failPayment = throwIO ArbitraryException
 
+-- Mock a payment that fails at the processor rather than with an IO
+-- exception.
+aStripeError :: StripeError
+aStripeError = StripeError CardError "Card rejected because reasons" Nothing Nothing Nothing
+failPaymentProcessing :: IO ProcessorResult
+failPaymentProcessing = return $ Left $ PaymentFailed aStripeError
+
 -- | Create a group of tests related to voucher payment and redemption.
 makeVoucherPaymentTests
   :: VoucherDatabase d
@@ -96,13 +115,13 @@ makeVoucherPaymentTests label makeDatabase =
   , testCase "paid for" $ do
       connect <- makeDatabase
       conn <- connect
-      () <- payForVoucher conn voucher paySuccessfully
+      Right _ <- payForVoucher conn voucher paySuccessfully
       result <- redeemVoucher conn voucher fingerprint
       assertEqual "redeeming paid voucher" (Right True) result
   , testCase "allowed double redemption" $ do
       connect <- makeDatabase
       conn <- connect
-      () <- payForVoucher conn voucher paySuccessfully
+      Right _ <- payForVoucher conn voucher paySuccessfully
       let redeem = redeemVoucher conn voucher fingerprint
       first <- redeem
       second <- redeem
@@ -111,7 +130,7 @@ makeVoucherPaymentTests label makeDatabase =
   , testCase "disallowed double redemption" $ do
       connect <- makeDatabase
       conn <- connect
-      () <- payForVoucher conn voucher paySuccessfully
+      Right _ <- payForVoucher conn voucher paySuccessfully
       let redeem = redeemVoucher conn voucher
       first <- redeem fingerprint
       second <- redeem (Text.cons 'a' $ Text.tail fingerprint)
@@ -120,7 +139,7 @@ makeVoucherPaymentTests label makeDatabase =
   , testCase "allowed redemption varying by counter" $ do
       connect <- makeDatabase
       conn <- connect
-      () <- payForVoucher conn voucher paySuccessfully
+      Right _ <- payForVoucher conn voucher paySuccessfully
       let redeem = redeemVoucherWithCounter conn voucher
       first <- redeem fingerprint 0
       second <- redeem anotherFingerprint 1
@@ -129,12 +148,20 @@ makeVoucherPaymentTests label makeDatabase =
   , testCase "disallowed redemption varying by counter but not fingerprint" $ do
       connect <- makeDatabase
       conn <- connect
-      () <- payForVoucher conn voucher paySuccessfully
+      Right _ <- payForVoucher conn voucher paySuccessfully
       let redeem = redeemVoucherWithCounter conn voucher
       first <- redeem fingerprint 0
       second <- redeem fingerprint 1
       assertEqual "redeemed with counter 0" (Right True) first
       assertEqual "redeemed with counter 1" (Left DuplicateFingerprint) second
+  , testCase "pay with processor error" $ do
+      connect <- makeDatabase
+      conn <- connect
+      actual <- payForVoucher conn voucher failPaymentProcessing
+      let expected = Left $ PaymentFailed aStripeError
+      assertEqual "failing payment processing for a voucher" expected actual
+      result <- redeemVoucher conn voucher fingerprint
+      assertEqual "redeeming voucher with failed payment" (Left NotPaid) result
   , testCase "pay with exception" $ do
       connect <- makeDatabase
       conn <- connect
@@ -146,7 +173,7 @@ makeVoucherPaymentTests label makeDatabase =
       connect <- makeDatabase
       conn <- connect
       let pay = payForVoucher conn voucher paySuccessfully
-      () <- pay
+      Right _ <- pay
       payResult <- try pay
       assertEqual "double-paying for a voucher" (Left AlreadyPaid) payResult
       redeemResult <- redeemVoucher conn voucher fingerprint
@@ -163,15 +190,15 @@ makeVoucherPaymentTests label makeDatabase =
         withAsync anotherPayment $ \p2 -> do
           waitBoth p1 p2
 
-      assertEqual "Both payments should succeed" ((), ()) result
+      assertEqual "Both payments should succeed" (Right aChargeId, Right aChargeId) result
   , testCase "concurrent redemption" $ do
       connect <- makeDatabase
       connA <- connect
       connB <- connect
       -- It doesn't matter which connection pays for the vouchers.  They
       -- payments are concurrent and the connections are to the same database.
-      () <- payForVoucher connA voucher paySuccessfully
-      () <- payForVoucher connA anotherVoucher paySuccessfully
+      Right _ <- payForVoucher connA voucher paySuccessfully
+      Right _ <- payForVoucher connA anotherVoucher paySuccessfully
 
       -- It does matter which connection is used to redeem the voucher.  A
       -- connection can only do one thing at a time.
-- 
GitLab