From 9a95dd9d828c5cdff1a1bec3b7f4dc65215ea730 Mon Sep 17 00:00:00 2001 From: Andreas Wuerl Date: Thu, 15 Sep 2022 12:28:48 +0200 Subject: [PATCH 01/40] restore old webhook endpoint --- src/PaymentServer/Processors/Stripe.hs | 31 +++++++++++++++++++++++++- stack.yaml | 3 ++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 07f2556..bf5d3a6 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -106,7 +106,8 @@ instance ToJSON Acknowledgement where [ "success" .= True ] -type StripeAPI = ChargesAPI +type StripeAPI = WebhookAPI :<|> ChargesAPI +type WebhookAPI = "webhook" :> ReqBody '[JSON] Event :> Post '[JSON] Acknowledgement -- | getVoucher finds the metadata item with the key `"Voucher"` and returns -- the corresponding value, or Nothing. @@ -119,6 +120,31 @@ stripeServer :: VoucherDatabase d => StripeConfig -> d -> Server StripeAPI stripeServer stripeConfig d = withSuccessFailureMetrics chargeAttempts chargeSuccesses . charge stripeConfig d +--stripeServer :: VoucherDatabase d => StripeSecretKey -> d -> Server StripeAPI +--stripeServer key d = webhook d :<|> charge d key + +-- | Process charge succeeded events +webhook :: VoucherDatabase d => StripeConfig -> d -> Event -> Handler Acknowledgement +webhook stripeConfig 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 + -- | Browser facing API that takes token, voucher and a few other information -- and calls stripe charges API. If payment succeeds, then the voucher is stored -- in the voucher database. @@ -175,6 +201,9 @@ withSuccessFailureMetrics attemptCount successCount op = do -- and if the Charge is okay, then set the voucher as "paid" in the database. charge :: VoucherDatabase d => StripeConfig -> d -> Charges -> Handler Acknowledgement charge stripeConfig d (Charges token voucher 650 USD) = do + + # TODO verify the webhook request + result <- liftIO payForVoucher' case result of Left AlreadyPaid -> diff --git a/stack.yaml b/stack.yaml index d3514f5..400f37f 100644 --- a/stack.yaml +++ b/stack.yaml @@ -17,7 +17,8 @@ # # resolver: ./custom-snapshot.yaml # resolver: https://example.com/snapshots/2018-01-01.yaml -resolver: lts-14.2 +resolver: + url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/19/19.yaml # User packages to be built. # Various formats can be used as shown in the example below. From 26bd10f168ef440385f21b94789f37e296f2c2f5 Mon Sep 17 00:00:00 2001 From: Andreas Wuerl Date: Thu, 15 Sep 2022 14:53:24 +0200 Subject: [PATCH 02/40] update versions and add missing imports --- src/PaymentServer/Processors/Stripe.hs | 9 ++++++++- stack.yaml | 13 ++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index bf5d3a6..4fa45e4 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -67,6 +67,13 @@ import Servant.API , JSON , Post , (:>) + , (:<|>)((:<|>)) + ) +import Web.Stripe.Event + ( Event(Event, eventId, eventType, eventData) + , EventId(EventId) + , EventType(ChargeSucceededEvent) + , EventData(ChargeEvent) ) import Web.Stripe.Error ( StripeError(StripeError, errorType, errorMsg) @@ -202,7 +209,7 @@ withSuccessFailureMetrics attemptCount successCount op = do charge :: VoucherDatabase d => StripeConfig -> d -> Charges -> Handler Acknowledgement charge stripeConfig d (Charges token voucher 650 USD) = do - # TODO verify the webhook request + -- TODO verify the webhook request as a first step result <- liftIO payForVoucher' case result of diff --git a/stack.yaml b/stack.yaml index 400f37f..552d5c6 100644 --- a/stack.yaml +++ b/stack.yaml @@ -17,8 +17,7 @@ # # resolver: ./custom-snapshot.yaml # resolver: https://example.com/snapshots/2018-01-01.yaml -resolver: - url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/19/19.yaml +resolver: lts-18.28 # User packages to be built. # Various formats can be used as shown in the example below. @@ -39,11 +38,11 @@ packages: # using the same syntax as the packages field. # (e.g., acme-missiles-0.3) extra-deps: - - "stripe-core-2.5.0" - - "stripe-haskell-2.5.0" - - "stripe-http-client-2.5.0" - - github: "PrivateStorageio/servant-prometheus" - commit: "b9461cbf689b47506b2eee973136706092b74968" + - "stripe-core-2.6.2" + - "stripe-haskell-2.6.2" + - "stripe-http-client-2.6.2" + - github: "wuan/servant-prometheus" + commit: "3eaceb378edc1f81abb4d6fbdb92c6172a09813d" # https://input-output-hk.github.io/haskell.nix/tutorials/source-repository-hashes/#stack # nix-sha256: 1gfslw670ri119bnq3szc8b08n504f8cnzs5cgk5qvfwvfmsr1xh From 01fdeb141768ac0bd94ad3fbf305d1a7c92e380e Mon Sep 17 00:00:00 2001 From: Andreas Wuerl Date: Mon, 19 Sep 2022 11:11:21 +0200 Subject: [PATCH 03/40] reference updated version --- stack.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stack.yaml b/stack.yaml index 552d5c6..84f1bfe 100644 --- a/stack.yaml +++ b/stack.yaml @@ -41,8 +41,8 @@ extra-deps: - "stripe-core-2.6.2" - "stripe-haskell-2.6.2" - "stripe-http-client-2.6.2" - - github: "wuan/servant-prometheus" - commit: "3eaceb378edc1f81abb4d6fbdb92c6172a09813d" + - github: "PrivateStorageio/servant-prometheus" + commit: "6c8430b802303f9b8a3d11acb0c212b80444ad7c" # https://input-output-hk.github.io/haskell.nix/tutorials/source-repository-hashes/#stack # nix-sha256: 1gfslw670ri119bnq3szc8b08n504f8cnzs5cgk5qvfwvfmsr1xh From 185298e5d5988a05b4df2767d92780855dfd1d98 Mon Sep 17 00:00:00 2001 From: Andreas Wuerl Date: Tue, 20 Sep 2022 18:01:07 +0200 Subject: [PATCH 04/40] partial fix from meeting --- src/PaymentServer/Processors/Stripe.hs | 16 +++++++++------- src/PaymentServer/Server.hs | 4 ++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 4fa45e4..29fb5fa 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -6,10 +6,12 @@ module PaymentServer.Processors.Stripe ( StripeAPI + , WebhookAPI , Charges(Charges) , Acknowledgement(Ok) , Failure(Failure) , stripeServer + , webhookServer , getVoucher , charge ) where @@ -80,7 +82,7 @@ import Web.Stripe.Error , StripeErrorType(InvalidRequest, APIError, ConnectionFailure, CardError) ) import Web.Stripe.Types - ( Charge(Charge, chargeId) + ( Charge(Charge, chargeId, chargeMetaData) , MetaData(MetaData) , Currency(USD) ) @@ -113,8 +115,8 @@ instance ToJSON Acknowledgement where [ "success" .= True ] -type StripeAPI = WebhookAPI :<|> ChargesAPI -type WebhookAPI = "webhook" :> ReqBody '[JSON] Event :> Post '[JSON] Acknowledgement +type StripeAPI = ChargesAPI +type WebhookAPI = ReqBody '[JSON] Event :> Post '[JSON] Acknowledgement -- | getVoucher finds the metadata item with the key `"Voucher"` and returns -- the corresponding value, or Nothing. @@ -131,8 +133,8 @@ stripeServer stripeConfig d = --stripeServer key d = webhook d :<|> charge d key -- | Process charge succeeded events -webhook :: VoucherDatabase d => StripeConfig -> d -> Event -> Handler Acknowledgement -webhook stripeConfig d Event{eventId=Just (EventId eventId), eventType=ChargeSucceededEvent, eventData=(ChargeEvent charge)} = +webhookServer :: VoucherDatabase d => StripeConfig -> d -> Event -> Handler Acknowledgement +webhookServer stripeConfig 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 @@ -144,11 +146,11 @@ webhook stripeConfig d Event{eventId=Just (EventId eventId), eventType=ChargeSuc Just v -> do -- TODO: What if it is a duplicate payment? payForVoucher should be -- able to indicate error I guess. - () <- liftIO $ payForVoucher d v + _ <- liftIO $ payForVoucher d v (return $ Right $ chargeId charge) return Ok -- Disregard anything else - but return success so that Stripe doesn't retry. -webhook d _ = +webhook _ d _ = -- TODO: Record the eventId somewhere. return Ok diff --git a/src/PaymentServer/Server.hs b/src/PaymentServer/Server.hs index 3c0c5ee..8f0c1ae 100644 --- a/src/PaymentServer/Server.hs +++ b/src/PaymentServer/Server.hs @@ -38,7 +38,9 @@ import Web.Stripe.Client import PaymentServer.Processors.Stripe ( StripeAPI + , WebhookAPI , stripeServer + , webhookServer ) import PaymentServer.Redemption ( RedemptionConfig(RedemptionConfig) @@ -60,12 +62,14 @@ import PaymentServer.Persistence type PaymentServerAPI = "v1" :> "stripe" :> StripeAPI :<|> "v1" :> "redeem" :> RedemptionAPI + :<|> "v1" :> "webhook" :> WebhookAPI :<|> MetricsAPI -- | Create a server which uses the given database. paymentServer :: VoucherDatabase d => StripeConfig -> RedemptionConfig -> d -> Server PaymentServerAPI paymentServer stripeConfig redemptionConfig database = stripeServer stripeConfig database + :<|> webhookServer stripeConfig database :<|> redemptionServer redemptionConfig database :<|> metricsServer From d74b04968d1214ddac307244bbb0e8b8c70ded17 Mon Sep 17 00:00:00 2001 From: Andreas Wuerl Date: Tue, 20 Sep 2022 18:02:24 +0200 Subject: [PATCH 05/40] fixed last compile error --- src/PaymentServer/Server.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PaymentServer/Server.hs b/src/PaymentServer/Server.hs index 8f0c1ae..4029f74 100644 --- a/src/PaymentServer/Server.hs +++ b/src/PaymentServer/Server.hs @@ -69,8 +69,8 @@ type PaymentServerAPI paymentServer :: VoucherDatabase d => StripeConfig -> RedemptionConfig -> d -> Server PaymentServerAPI paymentServer stripeConfig redemptionConfig database = stripeServer stripeConfig database - :<|> webhookServer stripeConfig database :<|> redemptionServer redemptionConfig database + :<|> webhookServer stripeConfig database :<|> metricsServer paymentServerAPI :: Proxy PaymentServerAPI From 42d19c5bc6c329cf1107bdac5504be8a4e71670d Mon Sep 17 00:00:00 2001 From: Andreas Wuerl Date: Tue, 11 Oct 2022 17:03:09 +0200 Subject: [PATCH 06/40] import cleanup --- nix/materialized.paymentserver/.stack-to-nix.cache | 2 +- src/PaymentServer/Processors/Stripe.hs | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/nix/materialized.paymentserver/.stack-to-nix.cache b/nix/materialized.paymentserver/.stack-to-nix.cache index 36bb67c..c641303 100644 --- a/nix/materialized.paymentserver/.stack-to-nix.cache +++ b/nix/materialized.paymentserver/.stack-to-nix.cache @@ -1 +1 @@ -https://github.com/PrivateStorageio/servant-prometheus.git b9461cbf689b47506b2eee973136706092b74968 . 1gfslw670ri119bnq3szc8b08n504f8cnzs5cgk5qvfwvfmsr1xh servant-prometheus .stack-to-nix.cache.0 +https://github.com/PrivateStorageio/servant-prometheus.git 6c8430b802303f9b8a3d11acb0c212b80444ad7c . 1gfslw670ri119bnq3szc8b08n504f8cnzs5cgk5qvfwvfmsr1xh servant-prometheus .stack-to-nix.cache.0 diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 29fb5fa..294b385 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -31,12 +31,9 @@ import Control.Monad ) import Data.Text ( Text - , unpack , concat ) -import Text.Read - ( readMaybe - ) +import Text.Read() import Network.HTTP.Types ( Status(Status) @@ -69,7 +66,6 @@ import Servant.API , JSON , Post , (:>) - , (:<|>)((:<|>)) ) import Web.Stripe.Event ( Event(Event, eventId, eventType, eventData) From 1ca9928c93b1e9d2193fcaea11e4c76d9dd0fc29 Mon Sep 17 00:00:00 2001 From: Andreas Wuerl Date: Tue, 11 Oct 2022 18:24:52 +0200 Subject: [PATCH 07/40] update dependency --- nix/materialized.paymentserver/.stack-to-nix.cache | 2 +- stack.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nix/materialized.paymentserver/.stack-to-nix.cache b/nix/materialized.paymentserver/.stack-to-nix.cache index c641303..36bb67c 100644 --- a/nix/materialized.paymentserver/.stack-to-nix.cache +++ b/nix/materialized.paymentserver/.stack-to-nix.cache @@ -1 +1 @@ -https://github.com/PrivateStorageio/servant-prometheus.git 6c8430b802303f9b8a3d11acb0c212b80444ad7c . 1gfslw670ri119bnq3szc8b08n504f8cnzs5cgk5qvfwvfmsr1xh servant-prometheus .stack-to-nix.cache.0 +https://github.com/PrivateStorageio/servant-prometheus.git b9461cbf689b47506b2eee973136706092b74968 . 1gfslw670ri119bnq3szc8b08n504f8cnzs5cgk5qvfwvfmsr1xh servant-prometheus .stack-to-nix.cache.0 diff --git a/stack.yaml b/stack.yaml index 84f1bfe..63eb141 100644 --- a/stack.yaml +++ b/stack.yaml @@ -42,9 +42,9 @@ extra-deps: - "stripe-haskell-2.6.2" - "stripe-http-client-2.6.2" - github: "PrivateStorageio/servant-prometheus" - commit: "6c8430b802303f9b8a3d11acb0c212b80444ad7c" + commit: "622eb77cb08c5f13729173b8feb123a6700ff91f" # https://input-output-hk.github.io/haskell.nix/tutorials/source-repository-hashes/#stack - # nix-sha256: 1gfslw670ri119bnq3szc8b08n504f8cnzs5cgk5qvfwvfmsr1xh + # nix-sha256: 39KhnnIG/UtvcUqELPfe5SAK8YgqA0UWjaILporkrCU= # Override default flag values for local packages and extra-deps # flags: {} From dd25dc75a7d3ed1cd261d7d99fb7f37ff0ffe1cd Mon Sep 17 00:00:00 2001 From: Andreas Wuerl Date: Wed, 12 Oct 2022 08:20:14 +0200 Subject: [PATCH 08/40] upgrade network to avoid compile errors --- stack.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/stack.yaml b/stack.yaml index 193f5e4..58510fc 100644 --- a/stack.yaml +++ b/stack.yaml @@ -38,9 +38,7 @@ packages: # using the same syntax as the packages field. # (e.g., acme-missiles-0.3) extra-deps: - - "stripe-core-2.5.0" - - "stripe-haskell-2.5.0" - - "stripe-http-client-2.5.0" + - "network-3.1.2.7" - github: "PrivateStorageio/servant-prometheus" commit: "622eb77cb08c5f13729173b8feb123a6700ff91f" # https://input-output-hk.github.io/haskell.nix/tutorials/source-repository-hashes/#stack From 2bf07c9587f01c10f1d4ca6c766ec5a5824c16c4 Mon Sep 17 00:00:00 2001 From: Andreas Wuerl Date: Wed, 12 Oct 2022 15:21:35 +0200 Subject: [PATCH 09/40] update materialized payment server --- nix/materialized.paymentserver/.stack-to-nix.cache | 2 +- nix/materialized.paymentserver/.stack-to-nix.cache.0 | 2 +- nix/materialized.paymentserver/PaymentServer.nix | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/nix/materialized.paymentserver/.stack-to-nix.cache b/nix/materialized.paymentserver/.stack-to-nix.cache index 826f2b3..f0ac606 100644 --- a/nix/materialized.paymentserver/.stack-to-nix.cache +++ b/nix/materialized.paymentserver/.stack-to-nix.cache @@ -1 +1 @@ -https://github.com/PrivateStorageio/servant-prometheus.git 6c8430b802303f9b8a3d11acb0c212b80444ad7c . 09dcwj5ac2x2ilb4a0rai3qhl875vvvjr12af5plpz86faga3lnz servant-prometheus .stack-to-nix.cache.0 +https://github.com/PrivateStorageio/servant-prometheus.git 622eb77cb08c5f13729173b8feb123a6700ff91f . 09dcwj5ac2x2ilb4a0rai3qhl875vvvjr12af5plpz86faga3lnz servant-prometheus .stack-to-nix.cache.0 diff --git a/nix/materialized.paymentserver/.stack-to-nix.cache.0 b/nix/materialized.paymentserver/.stack-to-nix.cache.0 index 54253b1..086ae74 100644 --- a/nix/materialized.paymentserver/.stack-to-nix.cache.0 +++ b/nix/materialized.paymentserver/.stack-to-nix.cache.0 @@ -76,5 +76,5 @@ }; }; } // rec { - src = (pkgs.lib).mkDefault /nix/store/g62jlfm3vg9dld12fi12f5rkp9sjgbab-servant-prometheus-6c8430b; + src = (pkgs.lib).mkDefault /nix/store/r537z0w0swb6calr6vkk2ppnqd2bgv8z-servant-prometheus-622eb77; } diff --git a/nix/materialized.paymentserver/PaymentServer.nix b/nix/materialized.paymentserver/PaymentServer.nix index a5702ec..842157c 100644 --- a/nix/materialized.paymentserver/PaymentServer.nix +++ b/nix/materialized.paymentserver/PaymentServer.nix @@ -142,4 +142,6 @@ }; }; }; - } // rec { src = (pkgs.lib).mkDefault ./.; } \ No newline at end of file + } // rec { + src = (pkgs.lib).mkDefault ./.; + } \ No newline at end of file From 8f76ae702c290a78ed724bd75e5490c2cdffa64c Mon Sep 17 00:00:00 2001 From: Andreas Wuerl Date: Thu, 13 Oct 2022 15:57:08 +0200 Subject: [PATCH 10/40] move webhook endpoint to stripe path and add some example payloads --- misc/examples/events/charge.http | 10 + misc/examples/events/webhook.http | 292 +++++++++++++++++++++++++ src/PaymentServer/Processors/Stripe.hs | 19 +- src/PaymentServer/Server.hs | 12 +- 4 files changed, 320 insertions(+), 13 deletions(-) create mode 100644 misc/examples/events/charge.http create mode 100644 misc/examples/events/webhook.http diff --git a/misc/examples/events/charge.http b/misc/examples/events/charge.http new file mode 100644 index 0000000..7829a7a --- /dev/null +++ b/misc/examples/events/charge.http @@ -0,0 +1,10 @@ +### +POST http://localhost:8080/v1/stripe/charge +Content-Type: application/json + +{ + "token": "tok_visa", + "voucher": "abcg", + "amount": "650", + "currency": "USD" +} \ No newline at end of file diff --git a/misc/examples/events/webhook.http b/misc/examples/events/webhook.http new file mode 100644 index 0000000..a544d21 --- /dev/null +++ b/misc/examples/events/webhook.http @@ -0,0 +1,292 @@ +### +POST http://localhost:8080/v1/stripe/webhook +Content-Type: application/json + +{ + "id": "evt_3Ls83eLswFpehDNg0dmzogyf", + "object": "event", + "api_version": "2022-08-01", + "created": 1665593127, + "data": { + "object": { + "id": "ch_3Ls83eLswFpehDNg0WVw0vTa", + "object": "charge", + "amount": 250, + "amount_captured": 250, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3Ls83eLswFpehDNg0dFvPaKv", + "billing_details": { + "address": { + "city": null, + "country": "DE", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "a@b.d", + "name": "asdfasf", + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1665593127, + "currency": "usd", + "customer": null, + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 21, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3Ls83eLswFpehDNg0b2mAFUW", + "payment_method": "pm_1Ls83dLswFpehDNgpYAGL3j9", + "payment_method_details": { + "card": { + "brand": "mastercard", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 12, + "exp_year": 2023, + "fingerprint": "DoAWRfUcyOfJupbL", + "funding": "credit", + "installments": null, + "last4": "4444", + "mandate": null, + "network": "mastercard", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTGZORGFMc3dGcGVoRE5nKKjem5oGMgZo4m-xDMM6LBadftys-t7FIeo23hfQKTAtYI3zpLwmJb_3-A6VqCpIGjfmpkWUwCDQC38M", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3Ls83eLswFpehDNg0WVw0vTa/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_F8pjOORr12gJT9", + "idempotency_key": "8fdd25c9-cb73-4807-973f-f0b21d8bb7cc" + }, + "type": "charge.succeeded" +} + + +### +POST http://localhost:8080/v1/stripe/webhook +Content-Type: application/json + +{ + "id": "evt_3Ls83eLswFpehDNg0YbJevK2", + "object": "event", + "api_version": "2022-08-01", + "created": 1665593128, + "data": { + "object": { + "id": "pi_3Ls83eLswFpehDNg0b2mAFUW", + "object": "payment_intent", + "amount": 250, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 250, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3Ls83eLswFpehDNg0WVw0vTa", + "object": "charge", + "amount": 250, + "amount_captured": 250, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3Ls83eLswFpehDNg0dFvPaKv", + "billing_details": { + "address": { + "city": null, + "country": "DE", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "a@b.d", + "name": "asdfasf", + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1665593127, + "currency": "usd", + "customer": null, + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 21, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3Ls83eLswFpehDNg0b2mAFUW", + "payment_method": "pm_1Ls83dLswFpehDNgpYAGL3j9", + "payment_method_details": { + "card": { + "brand": "mastercard", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 12, + "exp_year": 2023, + "fingerprint": "DoAWRfUcyOfJupbL", + "funding": "credit", + "installments": null, + "last4": "4444", + "mandate": null, + "network": "mastercard", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTGZORGFMc3dGcGVoRE5nKKjem5oGMgbvNEiopYo6LBaa9sMFbxpWzGb2WX9aOuc4LWzXH4hUaNoIgvdvHlJv5zChsx0FH0MJcj6b", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3Ls83eLswFpehDNg0WVw0vTa/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_3Ls83eLswFpehDNg0b2mAFUW" + }, + "client_secret": "pi_3Ls83eLswFpehDNg0b2mAFUW_secret_72eKxCUCTrTLH0E3dSvrlwmgO", + "confirmation_method": "automatic", + "created": 1665593126, + "currency": "usd", + "customer": null, + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1Ls83dLswFpehDNgpYAGL3j9", + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_F8pjOORr12gJT9", + "idempotency_key": "8fdd25c9-cb73-4807-973f-f0b21d8bb7cc" + }, + "type": "payment_intent.succeeded" +} + + + diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 294b385..bb6c982 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -5,12 +5,12 @@ {-# LANGUAGE ScopedTypeVariables #-} module PaymentServer.Processors.Stripe - ( StripeAPI + ( ChargesAPI , WebhookAPI , Charges(Charges) , Acknowledgement(Ok) , Failure(Failure) - , stripeServer + , chargeServer , webhookServer , getVoucher , charge @@ -42,6 +42,8 @@ import Network.HTTP.Types , status503 ) +import Data.ByteString (ByteString) + import Data.ByteString.UTF8 ( toString ) @@ -111,8 +113,7 @@ instance ToJSON Acknowledgement where [ "success" .= True ] -type StripeAPI = ChargesAPI -type WebhookAPI = ReqBody '[JSON] Event :> Post '[JSON] Acknowledgement +type WebhookAPI = "webhook" :> ReqBody '[JSON] Event :> Post '[JSON] Acknowledgement -- | getVoucher finds the metadata item with the key `"Voucher"` and returns -- the corresponding value, or Nothing. @@ -121,14 +122,18 @@ getVoucher (MetaData []) = Nothing getVoucher (MetaData (("Voucher", value):xs)) = Just value getVoucher (MetaData (x:xs)) = getVoucher (MetaData xs) -stripeServer :: VoucherDatabase d => StripeConfig -> d -> Server StripeAPI -stripeServer stripeConfig d = +chargeServer :: VoucherDatabase d => StripeConfig -> d -> Server ChargesAPI +chargeServer stripeConfig d = withSuccessFailureMetrics chargeAttempts chargeSuccesses . charge stripeConfig d --stripeServer :: VoucherDatabase d => StripeSecretKey -> d -> Server StripeAPI --stripeServer key d = webhook d :<|> charge d key --- | Process charge succeeded events +--webhookServer :: VoucherDatabase d => StripeConfig -> d -> ByteString -> Handler Acknowledgement +--webhookServer stripeConfig d payload = + + +-- | Process charge succeeded webhookServer :: VoucherDatabase d => StripeConfig -> d -> Event -> Handler Acknowledgement webhookServer stripeConfig d Event{eventId=Just (EventId eventId), eventType=ChargeSucceededEvent, eventData=(ChargeEvent charge)} = case getVoucher $ chargeMetaData charge of diff --git a/src/PaymentServer/Server.hs b/src/PaymentServer/Server.hs index 4029f74..342888f 100644 --- a/src/PaymentServer/Server.hs +++ b/src/PaymentServer/Server.hs @@ -37,9 +37,9 @@ import Web.Stripe.Client ) import PaymentServer.Processors.Stripe - ( StripeAPI + ( ChargesAPI , WebhookAPI - , stripeServer + , chargeServer , webhookServer ) import PaymentServer.Redemption @@ -60,17 +60,17 @@ import PaymentServer.Persistence -- | This is the complete type of the server API. type PaymentServerAPI - = "v1" :> "stripe" :> StripeAPI + = "v1" :> "stripe" :> ChargesAPI + :<|> "v1" :> "stripe" :> WebhookAPI :<|> "v1" :> "redeem" :> RedemptionAPI - :<|> "v1" :> "webhook" :> WebhookAPI :<|> MetricsAPI -- | Create a server which uses the given database. paymentServer :: VoucherDatabase d => StripeConfig -> RedemptionConfig -> d -> Server PaymentServerAPI paymentServer stripeConfig redemptionConfig database = - stripeServer stripeConfig database - :<|> redemptionServer redemptionConfig database + chargeServer stripeConfig database :<|> webhookServer stripeConfig database + :<|> redemptionServer redemptionConfig database :<|> metricsServer paymentServerAPI :: Proxy PaymentServerAPI From cc40dadc3898be9827da646a5fa6fd4dd0f3414e Mon Sep 17 00:00:00 2001 From: Andreas Wuerl Date: Tue, 25 Oct 2022 18:08:59 +0200 Subject: [PATCH 11/40] accept stripe signature header and plain text to prepare validation --- src/PaymentServer/Processors/Stripe.hs | 42 +++++++++++++++----------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index bb6c982..6b04d06 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -34,6 +34,7 @@ import Data.Text , concat ) import Text.Read() +import Data.Maybe import Network.HTTP.Types ( Status(Status) @@ -64,8 +65,11 @@ import Servant , throwError ) import Servant.API - ( ReqBody + ( Header + , ReqBody , JSON + , OctetStream + , PlainText , Post , (:>) ) @@ -113,8 +117,6 @@ instance ToJSON Acknowledgement where [ "success" .= True ] -type WebhookAPI = "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 @@ -132,23 +134,27 @@ chargeServer stripeConfig d = --webhookServer :: VoucherDatabase d => StripeConfig -> d -> ByteString -> Handler Acknowledgement --webhookServer stripeConfig d payload = +--type WebhookAPI = "webhook" :> ReqBody '[JSON] Event :> Post '[JSON] Acknowledgement +type WebhookAPI = "webhook" :> Header "STRIPE_SIGNATURE" Text :> ReqBody '[OctetStream] ByteString :> Post '[PlainText] Text -- | Process charge succeeded -webhookServer :: VoucherDatabase d => StripeConfig -> d -> Event -> Handler Acknowledgement -webhookServer stripeConfig 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 $ Right $ chargeId charge) - return Ok +webhookServer :: VoucherDatabase d => StripeConfig -> d -> Maybe Text -> ByteString -> Handler Text +webhookServer stripeConfig d signatureHeader payload = + return "asdf" + +-- 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 $ Right $ chargeId charge) +-- return Ok -- Disregard anything else - but return success so that Stripe doesn't retry. webhook _ d _ = From a50b0ced75206c7642f9a01310b066a7ebd18132 Mon Sep 17 00:00:00 2001 From: Florian Sesser Date: Tue, 25 Oct 2022 16:52:14 +0000 Subject: [PATCH 12/40] packaging changes to get stripe-signature --- PaymentServer.cabal | 1 + nix/materialized.paymentserver/PaymentServer.nix | 5 ++--- nix/materialized.paymentserver/default.nix | 2 ++ stack.yaml | 2 ++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/PaymentServer.cabal b/PaymentServer.cabal index 1b29cf0..79a623a 100644 --- a/PaymentServer.cabal +++ b/PaymentServer.cabal @@ -39,6 +39,7 @@ library , warp-tls , stripe-haskell , stripe-core + , stripe-signature , text , containers , cryptonite diff --git a/nix/materialized.paymentserver/PaymentServer.nix b/nix/materialized.paymentserver/PaymentServer.nix index 842157c..535632d 100644 --- a/nix/materialized.paymentserver/PaymentServer.nix +++ b/nix/materialized.paymentserver/PaymentServer.nix @@ -49,6 +49,7 @@ (hsPkgs."warp-tls" or (errorHandler.buildDepError "warp-tls")) (hsPkgs."stripe-haskell" or (errorHandler.buildDepError "stripe-haskell")) (hsPkgs."stripe-core" or (errorHandler.buildDepError "stripe-core")) + (hsPkgs."stripe-signature" or (errorHandler.buildDepError "stripe-signature")) (hsPkgs."text" or (errorHandler.buildDepError "text")) (hsPkgs."containers" or (errorHandler.buildDepError "containers")) (hsPkgs."cryptonite" or (errorHandler.buildDepError "cryptonite")) @@ -142,6 +143,4 @@ }; }; }; - } // rec { - src = (pkgs.lib).mkDefault ./.; - } \ No newline at end of file + } // rec { src = (pkgs.lib).mkDefault ./.; } \ No newline at end of file diff --git a/nix/materialized.paymentserver/default.nix b/nix/materialized.paymentserver/default.nix index dcaf7f6..fd72226 100644 --- a/nix/materialized.paymentserver/default.nix +++ b/nix/materialized.paymentserver/default.nix @@ -2,6 +2,8 @@ extras = hackage: { packages = { + "network" = (((hackage.network)."3.1.2.7").revisions).default; + "stripe-signature" = (((hackage.stripe-signature)."1.0.0.14").revisions).default; PaymentServer = ./PaymentServer.nix; servant-prometheus = ./.stack-to-nix.cache.0; }; diff --git a/stack.yaml b/stack.yaml index 58510fc..8e01c7b 100644 --- a/stack.yaml +++ b/stack.yaml @@ -43,6 +43,8 @@ extra-deps: commit: "622eb77cb08c5f13729173b8feb123a6700ff91f" # https://input-output-hk.github.io/haskell.nix/tutorials/source-repository-hashes/#stack # nix-sha256: 39KhnnIG/UtvcUqELPfe5SAK8YgqA0UWjaILporkrCU= + - "stripe-signature-1.0.0.14" + # Override default flag values for local packages and extra-deps # flags: {} From ec6584b5893ffd5cfdf2d014cbaa3f1427288584 Mon Sep 17 00:00:00 2001 From: Florian Sesser Date: Tue, 25 Oct 2022 16:52:32 +0000 Subject: [PATCH 13/40] start writing a test for the webhook endpoint --- test/Stripe.hs | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/test/Stripe.hs b/test/Stripe.hs index eaa8350..a4a23f4 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -47,13 +47,21 @@ import Data.Aeson ( decode ) +import Web.Stripe.Client + ( StripeConfig(StripeConfig) + , StripeKey(StripeKey) + ) + import Web.Stripe.Types ( Currency(USD, AED) , ChargeId(ChargeId) ) - +import Network.HTTP.Types + ( status200 + ) import Network.Wai.Test ( SRequest(SRequest) + , SResponse(simpleStatus) , runSession , request , srequest @@ -78,7 +86,7 @@ import PaymentServer.Processors.Stripe , Acknowledgement(Ok) , Failure(Failure) , charge - + , webhookServer ) import PaymentServer.Issuer @@ -102,6 +110,7 @@ tests :: TestTree tests = testGroup "Stripe" [ chargeTests , corsTests + , webhookTests ] corsTests :: TestTree @@ -218,3 +227,34 @@ chargeTests = where token = "foo" voucher = "bar" + +-- TODO +-- Make "charge.succeeded" fail with a good error message +-- Make "charge.succeeded" pass +-- Prevent replay attacks? https://stripe.com/docs/webhooks/signatures#replay-attacks +-- Check network origin? https://stripe.com/docs/ips#webhook-notifications +-- Check the Stripe signature + + +webhookTests :: TestTree +webhookTests = + testGroup "The Stripe charge web hook" + [ testCase "charge.succeeded" $ do + db <- memory + let stripeKey = StripeKey "" + let stripeConfig = StripeConfig stripeKey Nothing + let origins = [] + let redemptionConfig = RedemptionConfig 16 1024 trivialIssue + let app = paymentServerApp origins stripeConfig redemptionConfig db + + response <- (flip runSession) app $ srequest theSRequest + assertEqual "The response is 200" status200 (simpleStatus response) + ] + where + path = "/v1/stripe/webhook" + theRequest = setPath defaultRequest + { requestMethod = "POST" + , requestHeaders = [("content-type", "application/json")] + } path + theSRequest = SRequest theRequest body + body = "{\"api_version\":\"2022-08-01\",\"created\":1665593127,\"data\":{\"object\":{\"amount\":250,\"amount_captured\":250,\"amount_refunded\":0,\"application\":null,\"application_fee\":null,\"application_fee_amount\":null,\"balance_transaction\":\"txn_3Ls83eLswFpehDNg0dFvPaKv\",\"billing_details\":{\"address\":{\"city\":null,\"country\":\"DE\",\"line1\":null,\"line2\":null,\"postal_code\":null,\"state\":null},\"email\":\"a@b.d\",\"name\":\"asdfasf\",\"phone\":null},\"calculated_statement_descriptor\":\"Stripe\",\"captured\":true,\"created\":1665593127,\"currency\":\"usd\",\"customer\":null,\"description\":null,\"destination\":null,\"dispute\":null,\"disputed\":false,\"failure_balance_transaction\":null,\"failure_code\":null,\"failure_message\":null,\"fraud_details\":{},\"id\":\"ch_3Ls83eLswFpehDNg0WVw0vTa\",\"invoice\":null,\"livemode\":false,\"metadata\":{},\"object\":\"charge\",\"on_behalf_of\":null,\"order\":null,\"outcome\":{\"network_status\":\"approved_by_network\",\"reason\":null,\"risk_level\":\"normal\",\"risk_score\":21,\"seller_message\":\"Payment complete.\",\"type\":\"authorized\"},\"paid\":true,\"payment_intent\":\"pi_3Ls83eLswFpehDNg0b2mAFUW\",\"payment_method\":\"pm_1Ls83dLswFpehDNgpYAGL3j9\",\"payment_method_details\":{\"card\":{\"brand\":\"mastercard\",\"checks\":{\"address_line1_check\":null,\"address_postal_code_check\":null,\"cvc_check\":\"pass\"},\"country\":\"US\",\"exp_month\":12,\"exp_year\":2023,\"fingerprint\":\"DoAWRfUcyOfJupbL\",\"funding\":\"credit\",\"installments\":null,\"last4\":\"4444\",\"mandate\":null,\"network\":\"mastercard\",\"three_d_secure\":null,\"wallet\":null},\"type\":\"card\"},\"receipt_email\":null,\"receipt_number\":null,\"receipt_url\":\"https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTGZORGFMc3dGcGVoRE5nKKjem5oGMgZo4m-xDMM6LBadftys-t7FIeo23hfQKTAtYI3zpLwmJb_3-A6VqCpIGjfmpkWUwCDQC38M\",\"refunded\":false,\"refunds\":{\"data\":[],\"has_more\":false,\"object\":\"list\",\"total_count\":0,\"url\":\"/v1/charges/ch_3Ls83eLswFpehDNg0WVw0vTa/refunds\"},\"review\":null,\"shipping\":null,\"source\":null,\"source_transfer\":null,\"statement_descriptor\":null,\"statement_descriptor_suffix\":null,\"status\":\"succeeded\",\"transfer_data\":null,\"transfer_group\":null}},\"id\":\"evt_3Ls83eLswFpehDNg0dmzogyf\",\"livemode\":false,\"object\":\"event\",\"pending_webhooks\":2,\"request\":{\"id\":\"req_F8pjOORr12gJT9\",\"idempotency_key\":\"8fdd25c9-cb73-4807-973f-f0b21d8bb7cc\"},\"type\":\"charge.succeeded\"}" From bb9a91e6dba3be2cbbd3f05e3022926024ca9276 Mon Sep 17 00:00:00 2001 From: Florian Sesser Date: Tue, 25 Oct 2022 16:53:07 +0000 Subject: [PATCH 14/40] the pretty version of the charge event json not used now but maybe nice for reference for further dev --- charge_succeeded.json | 110 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 charge_succeeded.json diff --git a/charge_succeeded.json b/charge_succeeded.json new file mode 100644 index 0000000..28dd7c7 --- /dev/null +++ b/charge_succeeded.json @@ -0,0 +1,110 @@ +{ + "id": "evt_3Ls83eLswFpehDNg0dmzogyf", + "object": "event", + "api_version": "2022-08-01", + "created": 1665593127, + "data": { + "object": { + "id": "ch_3Ls83eLswFpehDNg0WVw0vTa", + "object": "charge", + "amount": 250, + "amount_captured": 250, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3Ls83eLswFpehDNg0dFvPaKv", + "billing_details": { + "address": { + "city": null, + "country": "DE", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "a@b.d", + "name": "asdfasf", + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1665593127, + "currency": "usd", + "customer": null, + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 21, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3Ls83eLswFpehDNg0b2mAFUW", + "payment_method": "pm_1Ls83dLswFpehDNgpYAGL3j9", + "payment_method_details": { + "card": { + "brand": "mastercard", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 12, + "exp_year": 2023, + "fingerprint": "DoAWRfUcyOfJupbL", + "funding": "credit", + "installments": null, + "last4": "4444", + "mandate": null, + "network": "mastercard", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTGZORGFMc3dGcGVoRE5nKKjem5oGMgZo4m-xDMM6LBadftys-t7FIeo23hfQKTAtYI3zpLwmJb_3-A6VqCpIGjfmpkWUwCDQC38M", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3Ls83eLswFpehDNg0WVw0vTa/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_F8pjOORr12gJT9", + "idempotency_key": "8fdd25c9-cb73-4807-973f-f0b21d8bb7cc" + }, + "type": "charge.succeeded" +} From b0d4f8b3b79cd5e252624084ecef6d4fc88d3033 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 26 Oct 2022 11:58:03 -0400 Subject: [PATCH 15/40] Test that demonstrates a 200 response for an Event-having request body --- PaymentServer.cabal | 1 + .../PaymentServer.nix | 1 + src/PaymentServer/Processors/Stripe.hs | 27 ++++++++++++++----- test/Stripe.hs | 4 +-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/PaymentServer.cabal b/PaymentServer.cabal index 79a623a..f30ec14 100644 --- a/PaymentServer.cabal +++ b/PaymentServer.cabal @@ -31,6 +31,7 @@ library , servant , servant-server , http-types + , http-media , wai , wai-extra , wai-cors diff --git a/nix/materialized.paymentserver/PaymentServer.nix b/nix/materialized.paymentserver/PaymentServer.nix index 535632d..0b243f3 100644 --- a/nix/materialized.paymentserver/PaymentServer.nix +++ b/nix/materialized.paymentserver/PaymentServer.nix @@ -41,6 +41,7 @@ (hsPkgs."servant" or (errorHandler.buildDepError "servant")) (hsPkgs."servant-server" or (errorHandler.buildDepError "servant-server")) (hsPkgs."http-types" or (errorHandler.buildDepError "http-types")) + (hsPkgs."http-media" or (errorHandler.buildDepError "http-media")) (hsPkgs."wai" or (errorHandler.buildDepError "wai")) (hsPkgs."wai-extra" or (errorHandler.buildDepError "wai-extra")) (hsPkgs."wai-cors" or (errorHandler.buildDepError "wai-cors")) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 6b04d06..0f06bd1 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -3,6 +3,7 @@ {-# LANGUAGE TypeOperators #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE MultiParamTypeClasses #-} module PaymentServer.Processors.Stripe ( ChargesAPI @@ -20,6 +21,8 @@ import Prelude hiding ( concat ) +import Data.Data (Typeable) + import Control.Exception ( catch ) @@ -36,6 +39,7 @@ import Data.Text import Text.Read() import Data.Maybe +import qualified Network.HTTP.Media as M import Network.HTTP.Types ( Status(Status) , status400 @@ -45,6 +49,8 @@ import Network.HTTP.Types import Data.ByteString (ByteString) +import Data.ByteString.Lazy (toStrict) + import Data.ByteString.UTF8 ( toString ) @@ -71,6 +77,8 @@ import Servant.API , OctetStream , PlainText , Post + , Accept(contentType) + , MimeUnrender(mimeUnrender) , (:>) ) import Web.Stripe.Event @@ -109,6 +117,8 @@ import PaymentServer.Persistence , PaymentError(AlreadyPaid, PaymentFailed) , ProcessorResult ) +import Data.Data (Typeable) +import Servant.API.ContentTypes (AcceptHeader(AcceptHeader)) data Acknowledgement = Ok deriving (Eq, Show) @@ -128,20 +138,23 @@ chargeServer :: VoucherDatabase d => StripeConfig -> d -> Server ChargesAPI chargeServer stripeConfig d = withSuccessFailureMetrics chargeAttempts chargeSuccesses . charge stripeConfig d ---stripeServer :: VoucherDatabase d => StripeSecretKey -> d -> Server StripeAPI ---stripeServer key d = webhook d :<|> charge d key +data UnparsedJSON deriving Typeable ---webhookServer :: VoucherDatabase d => StripeConfig -> d -> ByteString -> Handler Acknowledgement ---webhookServer stripeConfig d payload = +instance Accept UnparsedJSON where + -- We could also require charset=utf-8 on this but we think Stripe doesn't + -- actually include that in its requests. + contentType _ = "application" M.// "json" ---type WebhookAPI = "webhook" :> ReqBody '[JSON] Event :> Post '[JSON] Acknowledgement -type WebhookAPI = "webhook" :> Header "STRIPE_SIGNATURE" Text :> ReqBody '[OctetStream] ByteString :> Post '[PlainText] Text +instance MimeUnrender UnparsedJSON ByteString where + mimeUnrender _ = Right . toStrict + +type WebhookAPI = "webhook" :> Header "STRIPE_SIGNATURE" Text :> ReqBody '[UnparsedJSON] ByteString :> Post '[PlainText] Text -- | Process charge succeeded webhookServer :: VoucherDatabase d => StripeConfig -> d -> Maybe Text -> ByteString -> Handler Text webhookServer stripeConfig d signatureHeader payload = return "asdf" - + -- case getVoucher $ chargeMetaData charge of -- Nothing -> -- -- TODO: Record the eventId somewhere. In all cases where we don't diff --git a/test/Stripe.hs b/test/Stripe.hs index a4a23f4..ec4bc77 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -51,7 +51,7 @@ import Web.Stripe.Client ( StripeConfig(StripeConfig) , StripeKey(StripeKey) ) - + import Web.Stripe.Types ( Currency(USD, AED) , ChargeId(ChargeId) @@ -254,7 +254,7 @@ webhookTests = path = "/v1/stripe/webhook" theRequest = setPath defaultRequest { requestMethod = "POST" - , requestHeaders = [("content-type", "application/json")] + , requestHeaders = [("content-type", "application/json; charset=utf-8")] } path theSRequest = SRequest theRequest body body = "{\"api_version\":\"2022-08-01\",\"created\":1665593127,\"data\":{\"object\":{\"amount\":250,\"amount_captured\":250,\"amount_refunded\":0,\"application\":null,\"application_fee\":null,\"application_fee_amount\":null,\"balance_transaction\":\"txn_3Ls83eLswFpehDNg0dFvPaKv\",\"billing_details\":{\"address\":{\"city\":null,\"country\":\"DE\",\"line1\":null,\"line2\":null,\"postal_code\":null,\"state\":null},\"email\":\"a@b.d\",\"name\":\"asdfasf\",\"phone\":null},\"calculated_statement_descriptor\":\"Stripe\",\"captured\":true,\"created\":1665593127,\"currency\":\"usd\",\"customer\":null,\"description\":null,\"destination\":null,\"dispute\":null,\"disputed\":false,\"failure_balance_transaction\":null,\"failure_code\":null,\"failure_message\":null,\"fraud_details\":{},\"id\":\"ch_3Ls83eLswFpehDNg0WVw0vTa\",\"invoice\":null,\"livemode\":false,\"metadata\":{},\"object\":\"charge\",\"on_behalf_of\":null,\"order\":null,\"outcome\":{\"network_status\":\"approved_by_network\",\"reason\":null,\"risk_level\":\"normal\",\"risk_score\":21,\"seller_message\":\"Payment complete.\",\"type\":\"authorized\"},\"paid\":true,\"payment_intent\":\"pi_3Ls83eLswFpehDNg0b2mAFUW\",\"payment_method\":\"pm_1Ls83dLswFpehDNgpYAGL3j9\",\"payment_method_details\":{\"card\":{\"brand\":\"mastercard\",\"checks\":{\"address_line1_check\":null,\"address_postal_code_check\":null,\"cvc_check\":\"pass\"},\"country\":\"US\",\"exp_month\":12,\"exp_year\":2023,\"fingerprint\":\"DoAWRfUcyOfJupbL\",\"funding\":\"credit\",\"installments\":null,\"last4\":\"4444\",\"mandate\":null,\"network\":\"mastercard\",\"three_d_secure\":null,\"wallet\":null},\"type\":\"card\"},\"receipt_email\":null,\"receipt_number\":null,\"receipt_url\":\"https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTGZORGFMc3dGcGVoRE5nKKjem5oGMgZo4m-xDMM6LBadftys-t7FIeo23hfQKTAtYI3zpLwmJb_3-A6VqCpIGjfmpkWUwCDQC38M\",\"refunded\":false,\"refunds\":{\"data\":[],\"has_more\":false,\"object\":\"list\",\"total_count\":0,\"url\":\"/v1/charges/ch_3Ls83eLswFpehDNg0WVw0vTa/refunds\"},\"review\":null,\"shipping\":null,\"source\":null,\"source_transfer\":null,\"statement_descriptor\":null,\"statement_descriptor_suffix\":null,\"status\":\"succeeded\",\"transfer_data\":null,\"transfer_group\":null}},\"id\":\"evt_3Ls83eLswFpehDNg0dmzogyf\",\"livemode\":false,\"object\":\"event\",\"pending_webhooks\":2,\"request\":{\"id\":\"req_F8pjOORr12gJT9\",\"idempotency_key\":\"8fdd25c9-cb73-4807-973f-f0b21d8bb7cc\"},\"type\":\"charge.succeeded\"}" From 7fd0a0b7e9ff1ad0a712a2a0b0dcda4f09666484 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 26 Oct 2022 12:34:11 -0400 Subject: [PATCH 16/40] exercise and implement some more cases of the webhook --- PaymentServer.cabal | 1 + .../PaymentServer.nix | 1 + src/PaymentServer/Processors/Stripe.hs | 28 ++++++++---- test/Stripe.hs | 43 +++++++++++++------ 4 files changed, 52 insertions(+), 21 deletions(-) diff --git a/PaymentServer.cabal b/PaymentServer.cabal index f30ec14..2baa895 100644 --- a/PaymentServer.cabal +++ b/PaymentServer.cabal @@ -38,6 +38,7 @@ library , data-default , warp , warp-tls + , stripe-concepts , stripe-haskell , stripe-core , stripe-signature diff --git a/nix/materialized.paymentserver/PaymentServer.nix b/nix/materialized.paymentserver/PaymentServer.nix index 0b243f3..8b04f0c 100644 --- a/nix/materialized.paymentserver/PaymentServer.nix +++ b/nix/materialized.paymentserver/PaymentServer.nix @@ -48,6 +48,7 @@ (hsPkgs."data-default" or (errorHandler.buildDepError "data-default")) (hsPkgs."warp" or (errorHandler.buildDepError "warp")) (hsPkgs."warp-tls" or (errorHandler.buildDepError "warp-tls")) + (hsPkgs."stripe-concepts" or (errorHandler.buildDepError "stripe-concepts")) (hsPkgs."stripe-haskell" or (errorHandler.buildDepError "stripe-haskell")) (hsPkgs."stripe-core" or (errorHandler.buildDepError "stripe-core")) (hsPkgs."stripe-signature" or (errorHandler.buildDepError "stripe-signature")) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 0f06bd1..02d6859 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -87,6 +87,9 @@ import Web.Stripe.Event , EventType(ChargeSucceededEvent) , EventData(ChargeEvent) ) + +import Stripe.Signature (parseSig, isSigValid) + import Web.Stripe.Error ( StripeError(StripeError, errorType, errorMsg) , StripeErrorType(InvalidRequest, APIError, ConnectionFailure, CardError) @@ -102,13 +105,19 @@ import Web.Stripe.Charge , TokenId(TokenId) ) import Web.Stripe.Client - ( StripeConfig + ( StripeConfig(StripeConfig, secretKey) + , StripeKey(StripeKey) ) import Web.Stripe ( stripe , (-&-) ) +import Stripe.Concepts + ( WebhookSecretKey(WebhookSecretKey) + ) + + import qualified Prometheus as P import PaymentServer.Persistence @@ -148,12 +157,18 @@ instance Accept UnparsedJSON where instance MimeUnrender UnparsedJSON ByteString where mimeUnrender _ = Right . toStrict -type WebhookAPI = "webhook" :> Header "STRIPE_SIGNATURE" Text :> ReqBody '[UnparsedJSON] ByteString :> Post '[PlainText] Text +type WebhookAPI = "webhook" :> Header "HTTP_STRIPE_SIGNATURE" Text :> ReqBody '[UnparsedJSON] ByteString :> Post '[PlainText] Text -- | Process charge succeeded webhookServer :: VoucherDatabase d => StripeConfig -> d -> Maybe Text -> ByteString -> Handler Text -webhookServer stripeConfig d signatureHeader payload = - return "asdf" +webhookServer _ _ Nothing _ = throwError $ jsonErr status400 "Bad Request" +webhookServer stripeConfig@StripeConfig { secretKey = (StripeKey stripeKey) } d (Just signatureText) payload = + case parseSig signatureText of + Nothing -> throwError $ jsonErr status400 "Bad Request" + Just sig -> + if isSigValid sig (WebhookSecretKey stripeKey) payload + then return "ok" + else throwError $ jsonErr status400 "Bad Request" -- case getVoucher $ chargeMetaData charge of -- Nothing -> @@ -169,11 +184,6 @@ webhookServer stripeConfig d signatureHeader payload = -- _ <- liftIO $ payForVoucher d v (return $ Right $ chargeId charge) -- return Ok --- Disregard anything else - but return success so that Stripe doesn't retry. -webhook _ d _ = - -- TODO: Record the eventId somewhere. - return Ok - -- | Browser facing API that takes token, voucher and a few other information -- and calls stripe charges API. If payment succeeds, then the voucher is stored -- in the voucher database. diff --git a/test/Stripe.hs b/test/Stripe.hs index ec4bc77..fe590ef 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -58,6 +58,7 @@ import Web.Stripe.Types ) import Network.HTTP.Types ( status200 + , status400 ) import Network.Wai.Test ( SRequest(SRequest) @@ -239,22 +240,40 @@ chargeTests = webhookTests :: TestTree webhookTests = testGroup "The Stripe charge web hook" - [ testCase "charge.succeeded" $ do + [ testCase "If the signature header is missing then the response is a Bad Request" $ do db <- memory - let stripeKey = StripeKey "" - let stripeConfig = StripeConfig stripeKey Nothing - let origins = [] - let redemptionConfig = RedemptionConfig 16 1024 trivialIssue - let app = paymentServerApp origins stripeConfig redemptionConfig db + + let + theRequest = setPath defaultRequest + { requestMethod = "POST" + , requestHeaders = [("content-type", "application/json; charset=utf-8")] + } path + theSRequest = SRequest theRequest body + app = paymentServerApp origins stripeConfig redemptionConfig db + + response <- (flip runSession) app $ srequest theSRequest + assertEqual "The response is 400" status400 (simpleStatus response) + + , testCase "If the signature header contains an invalid signature then the response is a Bad Request" $ do + db <- memory + let + app = paymentServerApp origins stripeConfig redemptionConfig db + theRequest = (flip setPath) path defaultRequest + { requestMethod = "POST" + , requestHeaders = [ ("content-type", "application/json; charset=utf-8") + , ("HTTP_STRIPE_SIGNATURE", "Do you like my signature?") + ] + } + theSRequest = SRequest theRequest body response <- (flip runSession) app $ srequest theSRequest - assertEqual "The response is 200" status200 (simpleStatus response) + assertEqual "The response is 400" status400 (simpleStatus response) + ] where + stripeKey = StripeKey "" + stripeConfig = StripeConfig stripeKey Nothing + origins = [] + redemptionConfig = RedemptionConfig 16 1024 trivialIssue path = "/v1/stripe/webhook" - theRequest = setPath defaultRequest - { requestMethod = "POST" - , requestHeaders = [("content-type", "application/json; charset=utf-8")] - } path - theSRequest = SRequest theRequest body body = "{\"api_version\":\"2022-08-01\",\"created\":1665593127,\"data\":{\"object\":{\"amount\":250,\"amount_captured\":250,\"amount_refunded\":0,\"application\":null,\"application_fee\":null,\"application_fee_amount\":null,\"balance_transaction\":\"txn_3Ls83eLswFpehDNg0dFvPaKv\",\"billing_details\":{\"address\":{\"city\":null,\"country\":\"DE\",\"line1\":null,\"line2\":null,\"postal_code\":null,\"state\":null},\"email\":\"a@b.d\",\"name\":\"asdfasf\",\"phone\":null},\"calculated_statement_descriptor\":\"Stripe\",\"captured\":true,\"created\":1665593127,\"currency\":\"usd\",\"customer\":null,\"description\":null,\"destination\":null,\"dispute\":null,\"disputed\":false,\"failure_balance_transaction\":null,\"failure_code\":null,\"failure_message\":null,\"fraud_details\":{},\"id\":\"ch_3Ls83eLswFpehDNg0WVw0vTa\",\"invoice\":null,\"livemode\":false,\"metadata\":{},\"object\":\"charge\",\"on_behalf_of\":null,\"order\":null,\"outcome\":{\"network_status\":\"approved_by_network\",\"reason\":null,\"risk_level\":\"normal\",\"risk_score\":21,\"seller_message\":\"Payment complete.\",\"type\":\"authorized\"},\"paid\":true,\"payment_intent\":\"pi_3Ls83eLswFpehDNg0b2mAFUW\",\"payment_method\":\"pm_1Ls83dLswFpehDNgpYAGL3j9\",\"payment_method_details\":{\"card\":{\"brand\":\"mastercard\",\"checks\":{\"address_line1_check\":null,\"address_postal_code_check\":null,\"cvc_check\":\"pass\"},\"country\":\"US\",\"exp_month\":12,\"exp_year\":2023,\"fingerprint\":\"DoAWRfUcyOfJupbL\",\"funding\":\"credit\",\"installments\":null,\"last4\":\"4444\",\"mandate\":null,\"network\":\"mastercard\",\"three_d_secure\":null,\"wallet\":null},\"type\":\"card\"},\"receipt_email\":null,\"receipt_number\":null,\"receipt_url\":\"https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTGZORGFMc3dGcGVoRE5nKKjem5oGMgZo4m-xDMM6LBadftys-t7FIeo23hfQKTAtYI3zpLwmJb_3-A6VqCpIGjfmpkWUwCDQC38M\",\"refunded\":false,\"refunds\":{\"data\":[],\"has_more\":false,\"object\":\"list\",\"total_count\":0,\"url\":\"/v1/charges/ch_3Ls83eLswFpehDNg0WVw0vTa/refunds\"},\"review\":null,\"shipping\":null,\"source\":null,\"source_transfer\":null,\"statement_descriptor\":null,\"statement_descriptor_suffix\":null,\"status\":\"succeeded\",\"transfer_data\":null,\"transfer_group\":null}},\"id\":\"evt_3Ls83eLswFpehDNg0dmzogyf\",\"livemode\":false,\"object\":\"event\",\"pending_webhooks\":2,\"request\":{\"id\":\"req_F8pjOORr12gJT9\",\"idempotency_key\":\"8fdd25c9-cb73-4807-973f-f0b21d8bb7cc\"},\"type\":\"charge.succeeded\"}" From 5e7813a0d4f45135abcb96c769f96bec5428a5fd Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 26 Oct 2022 16:15:09 -0400 Subject: [PATCH 17/40] fix parsing of Stripe Events in the happy-path --- PaymentServer.cabal | 3 + .../PaymentServer.nix | 3 + src/PaymentServer/Processors/Stripe.hs | 20 ++++-- stack.yaml | 6 +- test/Stripe.hs | 68 +++++++++++++++++-- 5 files changed, 90 insertions(+), 10 deletions(-) diff --git a/PaymentServer.cabal b/PaymentServer.cabal index 2baa895..97a7229 100644 --- a/PaymentServer.cabal +++ b/PaymentServer.cabal @@ -91,6 +91,9 @@ test-suite PaymentServer-tests build-depends: aeson , base , bytestring + , stripe-signature + , stripe-concepts + , base16-bytestring , text , transformers , raw-strings-qq diff --git a/nix/materialized.paymentserver/PaymentServer.nix b/nix/materialized.paymentserver/PaymentServer.nix index 8b04f0c..71002e0 100644 --- a/nix/materialized.paymentserver/PaymentServer.nix +++ b/nix/materialized.paymentserver/PaymentServer.nix @@ -113,6 +113,9 @@ (hsPkgs."aeson" or (errorHandler.buildDepError "aeson")) (hsPkgs."base" or (errorHandler.buildDepError "base")) (hsPkgs."bytestring" or (errorHandler.buildDepError "bytestring")) + (hsPkgs."stripe-signature" or (errorHandler.buildDepError "stripe-signature")) + (hsPkgs."stripe-concepts" or (errorHandler.buildDepError "stripe-concepts")) + (hsPkgs."base16-bytestring" or (errorHandler.buildDepError "base16-bytestring")) (hsPkgs."text" or (errorHandler.buildDepError "text")) (hsPkgs."transformers" or (errorHandler.buildDepError "transformers")) (hsPkgs."raw-strings-qq" or (errorHandler.buildDepError "raw-strings-qq")) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 02d6859..6df641f 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -17,6 +17,10 @@ module PaymentServer.Processors.Stripe , charge ) where +import Data.Aeson + ( eitherDecode + ) + import Prelude hiding ( concat ) @@ -35,6 +39,7 @@ import Control.Monad import Data.Text ( Text , concat + , pack ) import Text.Read() import Data.Maybe @@ -49,7 +54,7 @@ import Network.HTTP.Types import Data.ByteString (ByteString) -import Data.ByteString.Lazy (toStrict) +import Data.ByteString.Lazy (toStrict, fromStrict) import Data.ByteString.UTF8 ( toString @@ -161,14 +166,19 @@ type WebhookAPI = "webhook" :> Header "HTTP_STRIPE_SIGNATURE" Text :> ReqBody '[ -- | Process charge succeeded webhookServer :: VoucherDatabase d => StripeConfig -> d -> Maybe Text -> ByteString -> Handler Text -webhookServer _ _ Nothing _ = throwError $ jsonErr status400 "Bad Request" +webhookServer _ _ Nothing _ = throwError $ jsonErr status400 "missing signature" webhookServer stripeConfig@StripeConfig { secretKey = (StripeKey stripeKey) } d (Just signatureText) payload = case parseSig signatureText of - Nothing -> throwError $ jsonErr status400 "Bad Request" + Nothing -> throwError $ jsonErr status400 "malformed signature" Just sig -> if isSigValid sig (WebhookSecretKey stripeKey) payload - then return "ok" - else throwError $ jsonErr status400 "Bad Request" + then fundVoucher + else throwError $ jsonErr status400 "invalid signature" + where + fundVoucher = + case eitherDecode . fromStrict $ payload of + Left s -> throwError $ jsonErr status400 (pack s) + Right Event{eventId=Just (EventId eventId), eventType=ChargeSucceededEvent, eventData=(ChargeEvent charge)} -> return "Ok" -- case getVoucher $ chargeMetaData charge of -- Nothing -> diff --git a/stack.yaml b/stack.yaml index 8e01c7b..bacbd0c 100644 --- a/stack.yaml +++ b/stack.yaml @@ -44,7 +44,11 @@ extra-deps: # https://input-output-hk.github.io/haskell.nix/tutorials/source-repository-hashes/#stack # nix-sha256: 39KhnnIG/UtvcUqELPfe5SAK8YgqA0UWjaILporkrCU= - "stripe-signature-1.0.0.14" - + - github: "PrivateStorageio/stripe" + commit: "bbbe0d2" + subdirs: + - "stripe-core" + # Override default flag values for local packages and extra-deps # flags: {} diff --git a/test/Stripe.hs b/test/Stripe.hs index fe590ef..e7a866c 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -29,6 +29,7 @@ import Data.Text.Lazy , concat ) +import qualified Data.ByteString as BS import qualified Data.ByteString.Lazy as LBS import Control.Monad.IO.Class ( liftIO @@ -38,6 +39,17 @@ import Control.Monad.Trans.Except ( runExceptT ) +import Stripe.Concepts + ( WebhookSecretKey(WebhookSecretKey) + ) + +import Stripe.Signature + ( digest + , natBytes + ) + +import qualified Data.ByteString.Base16 as Base16 + import Servant.Server ( Handler(runHandler') , ServerError(ServerError) @@ -62,7 +74,7 @@ import Network.HTTP.Types ) import Network.Wai.Test ( SRequest(SRequest) - , SResponse(simpleStatus) + , SResponse(simpleStatus, simpleBody) , runSession , request , srequest @@ -240,7 +252,7 @@ chargeTests = webhookTests :: TestTree webhookTests = testGroup "The Stripe charge web hook" - [ testCase "If the signature header is missing then the response is a Bad Request" $ do + [ testCase "If the signature is missing then the response is Bad Request" $ do db <- memory let @@ -252,9 +264,10 @@ webhookTests = app = paymentServerApp origins stripeConfig redemptionConfig db response <- (flip runSession) app $ srequest theSRequest + assertEqual "The body reflects the error" (Just $ Failure "missing signature") (decode . simpleBody $ response) assertEqual "The response is 400" status400 (simpleStatus response) - , testCase "If the signature header contains an invalid signature then the response is a Bad Request" $ do + , testCase "If the signature is misformatted then the response is Bad Request" $ do db <- memory let app = paymentServerApp origins stripeConfig redemptionConfig db @@ -267,13 +280,60 @@ webhookTests = theSRequest = SRequest theRequest body response <- (flip runSession) app $ srequest theSRequest + assertEqual "The body reflects the error" (Just $ Failure "malformed signature") (decode . simpleBody $ response) assertEqual "The response is 400" status400 (simpleStatus response) + , testCase "If the signature is incorrect then the response is Bad Request" $ do + db <- memory + let + app = paymentServerApp origins stripeConfig redemptionConfig db + theRequest = (flip setPath) path defaultRequest + { requestMethod = "POST" + , requestHeaders = [ ("content-type", "application/json; charset=utf-8") + , ("HTTP_STRIPE_SIGNATURE", stripeSignature (WebhookSecretKey "key") timestamp "Some other body") + ] + } + theSRequest = SRequest theRequest body + + response <- (flip runSession) app $ srequest theSRequest + assertEqual "The body reflects the error" (Just $ Failure "invalid signature") (decode . simpleBody $ response) + assertEqual "The response is 400" status400 (simpleStatus response) + + , testCase "If the signature is correct then the response is OK" $ do + db <- memory + let + app = paymentServerApp origins stripeConfig redemptionConfig db + theRequest = (flip setPath) path defaultRequest + { requestMethod = "POST" + , requestHeaders = [ ("content-type", "application/json; charset=utf-8") + , ("HTTP_STRIPE_SIGNATURE", stripeSignature (WebhookSecretKey keyBytes) timestamp (LBS.toStrict body)) + ] + } + theSRequest = SRequest theRequest body + + response <- (flip runSession) app $ srequest theSRequest + assertEqual "The body reflects the error" "Ok" (simpleBody response) + assertEqual "The response is 200" status200 (simpleStatus response) ] where - stripeKey = StripeKey "" + stripeSignature key when what = BS.concat + [ "t=" + , natBytes when + , "," + , "v1=" + , encodeHex $ digest key when what + ] + + timestamp = 1234567890 + encodeHex = Base16.encode + + + keyBytes = "an extremely good key" + stripeKey = StripeKey keyBytes stripeConfig = StripeConfig stripeKey Nothing origins = [] redemptionConfig = RedemptionConfig 16 1024 trivialIssue path = "/v1/stripe/webhook" + + body :: LBS.ByteString body = "{\"api_version\":\"2022-08-01\",\"created\":1665593127,\"data\":{\"object\":{\"amount\":250,\"amount_captured\":250,\"amount_refunded\":0,\"application\":null,\"application_fee\":null,\"application_fee_amount\":null,\"balance_transaction\":\"txn_3Ls83eLswFpehDNg0dFvPaKv\",\"billing_details\":{\"address\":{\"city\":null,\"country\":\"DE\",\"line1\":null,\"line2\":null,\"postal_code\":null,\"state\":null},\"email\":\"a@b.d\",\"name\":\"asdfasf\",\"phone\":null},\"calculated_statement_descriptor\":\"Stripe\",\"captured\":true,\"created\":1665593127,\"currency\":\"usd\",\"customer\":null,\"description\":null,\"destination\":null,\"dispute\":null,\"disputed\":false,\"failure_balance_transaction\":null,\"failure_code\":null,\"failure_message\":null,\"fraud_details\":{},\"id\":\"ch_3Ls83eLswFpehDNg0WVw0vTa\",\"invoice\":null,\"livemode\":false,\"metadata\":{},\"object\":\"charge\",\"on_behalf_of\":null,\"order\":null,\"outcome\":{\"network_status\":\"approved_by_network\",\"reason\":null,\"risk_level\":\"normal\",\"risk_score\":21,\"seller_message\":\"Payment complete.\",\"type\":\"authorized\"},\"paid\":true,\"payment_intent\":\"pi_3Ls83eLswFpehDNg0b2mAFUW\",\"payment_method\":\"pm_1Ls83dLswFpehDNgpYAGL3j9\",\"payment_method_details\":{\"card\":{\"brand\":\"mastercard\",\"checks\":{\"address_line1_check\":null,\"address_postal_code_check\":null,\"cvc_check\":\"pass\"},\"country\":\"US\",\"exp_month\":12,\"exp_year\":2023,\"fingerprint\":\"DoAWRfUcyOfJupbL\",\"funding\":\"credit\",\"installments\":null,\"last4\":\"4444\",\"mandate\":null,\"network\":\"mastercard\",\"three_d_secure\":null,\"wallet\":null},\"type\":\"card\"},\"receipt_email\":null,\"receipt_number\":null,\"receipt_url\":\"https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTGZORGFMc3dGcGVoRE5nKKjem5oGMgZo4m-xDMM6LBadftys-t7FIeo23hfQKTAtYI3zpLwmJb_3-A6VqCpIGjfmpkWUwCDQC38M\",\"refunded\":false,\"refunds\":{\"data\":[],\"has_more\":false,\"object\":\"list\",\"total_count\":0,\"url\":\"/v1/charges/ch_3Ls83eLswFpehDNg0WVw0vTa/refunds\"},\"review\":null,\"shipping\":null,\"source\":null,\"source_transfer\":null,\"statement_descriptor\":null,\"statement_descriptor_suffix\":null,\"status\":\"succeeded\",\"transfer_data\":null,\"transfer_group\":null}},\"id\":\"evt_3Ls83eLswFpehDNg0dmzogyf\",\"livemode\":false,\"object\":\"event\",\"pending_webhooks\":2,\"request\":{\"id\":\"req_F8pjOORr12gJT9\",\"idempotency_key\":\"8fdd25c9-cb73-4807-973f-f0b21d8bb7cc\"},\"type\":\"charge.succeeded\"}" From 85359d82d2554ff389a1420802b5593de66415d5 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Thu, 27 Oct 2022 15:42:28 -0400 Subject: [PATCH 18/40] steps towards handling properly signed webhook requests --- PaymentServer.cabal | 2 +- .../.stack-to-nix.cache | 1 + nix/materialized.paymentserver/default.nix | 1 + src/PaymentServer/Processors/Stripe.hs | 54 +++++++++++-------- stack.yaml | 4 +- test/Stripe.hs | 29 ++++++++-- 6 files changed, 62 insertions(+), 29 deletions(-) diff --git a/PaymentServer.cabal b/PaymentServer.cabal index 97a7229..3f4c483 100644 --- a/PaymentServer.cabal +++ b/PaymentServer.cabal @@ -41,7 +41,7 @@ library , stripe-concepts , stripe-haskell , stripe-core - , stripe-signature + , stripe-signature , text , containers , cryptonite diff --git a/nix/materialized.paymentserver/.stack-to-nix.cache b/nix/materialized.paymentserver/.stack-to-nix.cache index f0ac606..c6ac914 100644 --- a/nix/materialized.paymentserver/.stack-to-nix.cache +++ b/nix/materialized.paymentserver/.stack-to-nix.cache @@ -1 +1,2 @@ https://github.com/PrivateStorageio/servant-prometheus.git 622eb77cb08c5f13729173b8feb123a6700ff91f . 09dcwj5ac2x2ilb4a0rai3qhl875vvvjr12af5plpz86faga3lnz servant-prometheus .stack-to-nix.cache.0 +https://github.com/PrivateStorageio/stripe.git bbbe0d2d5cce3e1f12fa9f333f994c779d9bc965 stripe-core 01362xs4gaxlyyzilsq7l3zkanvysi9zi0jqm0iyhwg6v4zs54c1 stripe-core .stack-to-nix.cache.1 diff --git a/nix/materialized.paymentserver/default.nix b/nix/materialized.paymentserver/default.nix index fd72226..dd74a3c 100644 --- a/nix/materialized.paymentserver/default.nix +++ b/nix/materialized.paymentserver/default.nix @@ -6,6 +6,7 @@ "stripe-signature" = (((hackage.stripe-signature)."1.0.0.14").revisions).default; PaymentServer = ./PaymentServer.nix; servant-prometheus = ./.stack-to-nix.cache.0; + stripe-core = ./.stack-to-nix.cache.1; }; }; resolver = "lts-18.28"; diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 6df641f..a021709 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -89,8 +89,8 @@ import Servant.API import Web.Stripe.Event ( Event(Event, eventId, eventType, eventData) , EventId(EventId) - , EventType(ChargeSucceededEvent) - , EventData(ChargeEvent) + , EventType(ChargeSucceededEvent, CheckoutSessionCompleted) + , EventData(ChargeEvent, CheckoutSessionEvent) ) import Stripe.Signature (parseSig, isSigValid) @@ -101,6 +101,7 @@ import Web.Stripe.Error ) import Web.Stripe.Types ( Charge(Charge, chargeId, chargeMetaData) + , CheckoutSession(checkoutSessionClientReferenceId) , MetaData(MetaData) , Currency(USD) ) @@ -143,10 +144,16 @@ instance ToJSON Acknowledgement where -- | 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) +getVoucher :: Event -> Maybe Voucher +getVoucher Event{eventData=(CheckoutSessionEvent checkoutSession)} = + checkoutSessionClientReferenceId checkoutSession +getVoucher Event{eventData=(ChargeEvent charge)} = + voucherFromMetadata . chargeMetaData $ charge + where + voucherFromMetadata (MetaData []) = Nothing + voucherFromMetadata (MetaData (("Voucher", value):xs)) = Just value + voucherFromMetadata (MetaData (x:xs)) = voucherFromMetadata (MetaData xs) +getVoucher _ = Nothing chargeServer :: VoucherDatabase d => StripeConfig -> d -> Server ChargesAPI chargeServer stripeConfig d = @@ -162,10 +169,10 @@ instance Accept UnparsedJSON where instance MimeUnrender UnparsedJSON ByteString where mimeUnrender _ = Right . toStrict -type WebhookAPI = "webhook" :> Header "HTTP_STRIPE_SIGNATURE" Text :> ReqBody '[UnparsedJSON] ByteString :> Post '[PlainText] Text +type WebhookAPI = "webhook" :> Header "Stripe-Signature" Text :> ReqBody '[UnparsedJSON] ByteString :> Post '[JSON] Acknowledgement -- | Process charge succeeded -webhookServer :: VoucherDatabase d => StripeConfig -> d -> Maybe Text -> ByteString -> Handler Text +webhookServer :: VoucherDatabase d => StripeConfig -> d -> Maybe Text -> ByteString -> Handler Acknowledgement webhookServer _ _ Nothing _ = throwError $ jsonErr status400 "missing signature" webhookServer stripeConfig@StripeConfig { secretKey = (StripeKey stripeKey) } d (Just signatureText) payload = case parseSig signatureText of @@ -178,21 +185,22 @@ webhookServer stripeConfig@StripeConfig { secretKey = (StripeKey stripeKey) } d fundVoucher = case eitherDecode . fromStrict $ payload of Left s -> throwError $ jsonErr status400 (pack s) - Right Event{eventId=Just (EventId eventId), eventType=ChargeSucceededEvent, eventData=(ChargeEvent charge)} -> return "Ok" - --- 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 $ Right $ chargeId charge) --- return Ok + Right event -> + case getVoucher event 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 . Right $ mempty + return Ok + Right _ -> + return Ok -- | Browser facing API that takes token, voucher and a few other information -- and calls stripe charges API. If payment succeeds, then the voucher is stored diff --git a/stack.yaml b/stack.yaml index bacbd0c..18b1bd4 100644 --- a/stack.yaml +++ b/stack.yaml @@ -44,10 +44,12 @@ extra-deps: # https://input-output-hk.github.io/haskell.nix/tutorials/source-repository-hashes/#stack # nix-sha256: 39KhnnIG/UtvcUqELPfe5SAK8YgqA0UWjaILporkrCU= - "stripe-signature-1.0.0.14" + # - "/home/exarkun/Work/PrivateStorage/stripe-core/stripe-core" - github: "PrivateStorageio/stripe" - commit: "bbbe0d2" + commit: "45bb1e68ce84f3154b84d5a1343ec1a9d51c6b2d" subdirs: - "stripe-core" + # nix-sha256: FDtO2pm9jBw8NzwuGEki6XuUnC5+yEOQdpfcHFjRzwg= # Override default flag values for local packages and extra-deps diff --git a/test/Stripe.hs b/test/Stripe.hs index e7a866c..6c5027f 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -57,6 +57,7 @@ import Servant.Server import Data.Aeson ( decode + , encode ) import Web.Stripe.Client @@ -274,7 +275,7 @@ webhookTests = theRequest = (flip setPath) path defaultRequest { requestMethod = "POST" , requestHeaders = [ ("content-type", "application/json; charset=utf-8") - , ("HTTP_STRIPE_SIGNATURE", "Do you like my signature?") + , ("Stripe-Signature", "Do you like my signature?") ] } theSRequest = SRequest theRequest body @@ -290,7 +291,7 @@ webhookTests = theRequest = (flip setPath) path defaultRequest { requestMethod = "POST" , requestHeaders = [ ("content-type", "application/json; charset=utf-8") - , ("HTTP_STRIPE_SIGNATURE", stripeSignature (WebhookSecretKey "key") timestamp "Some other body") + , ("Stripe-Signature", stripeSignature (WebhookSecretKey "key") timestamp "Some other body") ] } theSRequest = SRequest theRequest body @@ -299,6 +300,25 @@ webhookTests = assertEqual "The body reflects the error" (Just $ Failure "invalid signature") (decode . simpleBody $ response) assertEqual "The response is 400" status400 (simpleStatus response) + , testCase "If the signature is correct and the body is not JSON then the response is Bad Request" $ do + db <- memory + let + nonJSONBody = "Some other body" + app = paymentServerApp origins stripeConfig redemptionConfig db + theRequest = (flip setPath) path defaultRequest + { requestMethod = "POST" + , requestHeaders = [ ("content-type", "application/json; charset=utf-8") + , ("Stripe-Signature", stripeSignature (WebhookSecretKey keyBytes) timestamp nonJSONBody) + ] + } + theSRequest = SRequest theRequest (LBS.fromStrict nonJSONBody) + + response <- (flip runSession) app $ srequest theSRequest + + case decode . simpleBody $ response of + Just (Failure _) -> assertEqual "The response is 400" status400 (simpleStatus response) + Nothing -> fail "response body parse failed" + , testCase "If the signature is correct then the response is OK" $ do db <- memory let @@ -306,14 +326,15 @@ webhookTests = theRequest = (flip setPath) path defaultRequest { requestMethod = "POST" , requestHeaders = [ ("content-type", "application/json; charset=utf-8") - , ("HTTP_STRIPE_SIGNATURE", stripeSignature (WebhookSecretKey keyBytes) timestamp (LBS.toStrict body)) + , ("Stripe-Signature", stripeSignature (WebhookSecretKey keyBytes) timestamp (LBS.toStrict body)) ] } theSRequest = SRequest theRequest body response <- (flip runSession) app $ srequest theSRequest - assertEqual "The body reflects the error" "Ok" (simpleBody response) + assertEqual "The body reflects success" (encode Ok) (simpleBody response) assertEqual "The response is 200" status200 (simpleStatus response) + assertEqual "The voucher is recorded as paid in the database" ] where stripeSignature key when what = BS.concat From b23be3de75f5a72f5b8c202e46fad01b162ce36f Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Thu, 27 Oct 2022 16:33:18 -0400 Subject: [PATCH 19/40] be explicit in what we are importing --- test/FakeStripe.hs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/FakeStripe.hs b/test/FakeStripe.hs index dde808a..9c13561 100644 --- a/test/FakeStripe.hs +++ b/test/FakeStripe.hs @@ -11,6 +11,8 @@ module FakeStripe ) where import Text.RawString.QQ + ( r + ) import Data.ByteString.Lazy ( ByteString From 35bf5b1b2e4ba7912ab0a0e312eebfa1b95733da Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 09:42:43 -0400 Subject: [PATCH 20/40] Be sure we can handle the other events Stripe might deliver to us --- .../.stack-to-nix.cache | 2 +- stack.yaml | 5 +- test/Stripe.hs | 502 +++++++++++++++++- 3 files changed, 490 insertions(+), 19 deletions(-) diff --git a/nix/materialized.paymentserver/.stack-to-nix.cache b/nix/materialized.paymentserver/.stack-to-nix.cache index c6ac914..90b74b0 100644 --- a/nix/materialized.paymentserver/.stack-to-nix.cache +++ b/nix/materialized.paymentserver/.stack-to-nix.cache @@ -1,2 +1,2 @@ https://github.com/PrivateStorageio/servant-prometheus.git 622eb77cb08c5f13729173b8feb123a6700ff91f . 09dcwj5ac2x2ilb4a0rai3qhl875vvvjr12af5plpz86faga3lnz servant-prometheus .stack-to-nix.cache.0 -https://github.com/PrivateStorageio/stripe.git bbbe0d2d5cce3e1f12fa9f333f994c779d9bc965 stripe-core 01362xs4gaxlyyzilsq7l3zkanvysi9zi0jqm0iyhwg6v4zs54c1 stripe-core .stack-to-nix.cache.1 +https://github.com/PrivateStorageio/stripe.git f5e03955832b95c26504b3e44bd5fe018e8b27d8 stripe-core 1kmgh2937n1m10n8q5xw5ysgvhd7yq502qan210ykiz5xgkm5can stripe-core .stack-to-nix.cache.1 diff --git a/stack.yaml b/stack.yaml index 18b1bd4..fe9368b 100644 --- a/stack.yaml +++ b/stack.yaml @@ -44,12 +44,11 @@ extra-deps: # https://input-output-hk.github.io/haskell.nix/tutorials/source-repository-hashes/#stack # nix-sha256: 39KhnnIG/UtvcUqELPfe5SAK8YgqA0UWjaILporkrCU= - "stripe-signature-1.0.0.14" - # - "/home/exarkun/Work/PrivateStorage/stripe-core/stripe-core" - github: "PrivateStorageio/stripe" - commit: "45bb1e68ce84f3154b84d5a1343ec1a9d51c6b2d" + commit: "6c340eea6dc23c4245762b937b9701a40761a5c9" subdirs: - "stripe-core" - # nix-sha256: FDtO2pm9jBw8NzwuGEki6XuUnC5+yEOQdpfcHFjRzwg= + # nix-sha256: uVB4vVHgDxyCJpzURl0MPPvwOAaIyi07vNGJXFkLHK4= # Override default flag values for local packages and extra-deps diff --git a/test/Stripe.hs b/test/Stripe.hs index 6c5027f..99a99cf 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} -- | Tests related to PaymentServer.Processors.Stripe. @@ -10,6 +11,10 @@ import Prelude hiding ( concat ) +import Text.RawString.QQ + ( r + ) + import Test.Tasty ( TestTree , testGroup @@ -91,8 +96,10 @@ import Network.Wai import PaymentServer.Persistence ( Voucher + , RedeemError(NotPaid) , memory , payForVoucher + , redeemVoucher ) import PaymentServer.Processors.Stripe @@ -243,11 +250,8 @@ chargeTests = voucher = "bar" -- TODO --- Make "charge.succeeded" fail with a good error message --- Make "charge.succeeded" pass -- Prevent replay attacks? https://stripe.com/docs/webhooks/signatures#replay-attacks -- Check network origin? https://stripe.com/docs/ips#webhook-notifications --- Check the Stripe signature webhookTests :: TestTree @@ -257,16 +261,17 @@ webhookTests = db <- memory let - theRequest = setPath defaultRequest + theRequest = (flip setPath) path defaultRequest { requestMethod = "POST" , requestHeaders = [("content-type", "application/json; charset=utf-8")] - } path - theSRequest = SRequest theRequest body + } + theSRequest = SRequest theRequest checkoutSessionCompleted app = paymentServerApp origins stripeConfig redemptionConfig db response <- (flip runSession) app $ srequest theSRequest assertEqual "The body reflects the error" (Just $ Failure "missing signature") (decode . simpleBody $ response) assertEqual "The response is 400" status400 (simpleStatus response) + assertNotRedeemable db voucher fingerprint , testCase "If the signature is misformatted then the response is Bad Request" $ do db <- memory @@ -278,13 +283,14 @@ webhookTests = , ("Stripe-Signature", "Do you like my signature?") ] } - theSRequest = SRequest theRequest body + theSRequest = SRequest theRequest checkoutSessionCompleted response <- (flip runSession) app $ srequest theSRequest assertEqual "The body reflects the error" (Just $ Failure "malformed signature") (decode . simpleBody $ response) assertEqual "The response is 400" status400 (simpleStatus response) + assertNotRedeemable db voucher fingerprint - , testCase "If the signature is incorrect then the response is Bad Request" $ do + , testCase "If the signature is incorrect then no attempt is made to parse the request body and the response is Bad Request" $ do db <- memory let app = paymentServerApp origins stripeConfig redemptionConfig db @@ -294,11 +300,12 @@ webhookTests = , ("Stripe-Signature", stripeSignature (WebhookSecretKey "key") timestamp "Some other body") ] } - theSRequest = SRequest theRequest body + theSRequest = SRequest theRequest checkoutSessionCompleted response <- (flip runSession) app $ srequest theSRequest assertEqual "The body reflects the error" (Just $ Failure "invalid signature") (decode . simpleBody $ response) assertEqual "The response is 400" status400 (simpleStatus response) + assertNotRedeemable db voucher fingerprint , testCase "If the signature is correct and the body is not JSON then the response is Bad Request" $ do db <- memory @@ -320,6 +327,29 @@ webhookTests = Nothing -> fail "response body parse failed" , testCase "If the signature is correct then the response is OK" $ do + db <- assertOkResponse checkoutSessionCompleted + -- It has been paid so we should be allowed to redeem it. + assertRedeemable db voucher fingerprint + + , testCase "The response to a charge.succeeded is OK" $ do + db <- assertOkResponse chargeSucceeded + -- It is only redeemable after checkout.session.completed. + assertNotRedeemable db voucher fingerprint + + , testCase "The response to a payment_intent.created is OK" $ do + db <- assertOkResponse paymentIntentCreated + -- It is only redeemable after checkout.session.completed. + assertNotRedeemable db voucher fingerprint + + , testCase "The response to a customer.created is OK" $ do + db <- assertOkResponse customerCreated + -- It is only redeemable after checkout.session.completed. + assertNotRedeemable db voucher fingerprint + ] + where + -- Assert that the response to a correctly signed applicaton/json request + -- with the given body is 200 OK. + assertOkResponse body = do db <- memory let app = paymentServerApp origins stripeConfig redemptionConfig db @@ -334,9 +364,27 @@ webhookTests = response <- (flip runSession) app $ srequest theSRequest assertEqual "The body reflects success" (encode Ok) (simpleBody response) assertEqual "The response is 200" status200 (simpleStatus response) - assertEqual "The voucher is recorded as paid in the database" - ] - where + return db + + -- Assert that the database allows us to redeem a voucher, demonstrating + -- that the voucher has persistent state consistent with payment having + -- been received. + assertRedeemable db voucher fingerprint = do + redeemed <- redeemVoucher db voucher fingerprint + assertEqual "The voucher is redeemable." (Right True) redeemed + + -- Assert the opposite of assertRedeemable + assertNotRedeemable db voucher fingerprint = do + redeemed <- redeemVoucher db voucher fingerprint + assertEqual "The unpaid voucher is not redeemable." (Left NotPaid) redeemed + + -- Arbitrary strings that don't matter apart from how they compare to + -- other values in the same range. Maybe Voucher and Fingerprint should + -- be newtype instead of type. Note that the voucher value does appear in + -- the checkoutSessionCompleted value below, though. + voucher = "abcdefghi" + fingerprint = "rstuvwxyz" + stripeSignature key when what = BS.concat [ "t=" , natBytes when @@ -348,7 +396,6 @@ webhookTests = timestamp = 1234567890 encodeHex = Base16.encode - keyBytes = "an extremely good key" stripeKey = StripeKey keyBytes stripeConfig = StripeConfig stripeKey Nothing @@ -356,5 +403,430 @@ webhookTests = redemptionConfig = RedemptionConfig 16 1024 trivialIssue path = "/v1/stripe/webhook" - body :: LBS.ByteString - body = "{\"api_version\":\"2022-08-01\",\"created\":1665593127,\"data\":{\"object\":{\"amount\":250,\"amount_captured\":250,\"amount_refunded\":0,\"application\":null,\"application_fee\":null,\"application_fee_amount\":null,\"balance_transaction\":\"txn_3Ls83eLswFpehDNg0dFvPaKv\",\"billing_details\":{\"address\":{\"city\":null,\"country\":\"DE\",\"line1\":null,\"line2\":null,\"postal_code\":null,\"state\":null},\"email\":\"a@b.d\",\"name\":\"asdfasf\",\"phone\":null},\"calculated_statement_descriptor\":\"Stripe\",\"captured\":true,\"created\":1665593127,\"currency\":\"usd\",\"customer\":null,\"description\":null,\"destination\":null,\"dispute\":null,\"disputed\":false,\"failure_balance_transaction\":null,\"failure_code\":null,\"failure_message\":null,\"fraud_details\":{},\"id\":\"ch_3Ls83eLswFpehDNg0WVw0vTa\",\"invoice\":null,\"livemode\":false,\"metadata\":{},\"object\":\"charge\",\"on_behalf_of\":null,\"order\":null,\"outcome\":{\"network_status\":\"approved_by_network\",\"reason\":null,\"risk_level\":\"normal\",\"risk_score\":21,\"seller_message\":\"Payment complete.\",\"type\":\"authorized\"},\"paid\":true,\"payment_intent\":\"pi_3Ls83eLswFpehDNg0b2mAFUW\",\"payment_method\":\"pm_1Ls83dLswFpehDNgpYAGL3j9\",\"payment_method_details\":{\"card\":{\"brand\":\"mastercard\",\"checks\":{\"address_line1_check\":null,\"address_postal_code_check\":null,\"cvc_check\":\"pass\"},\"country\":\"US\",\"exp_month\":12,\"exp_year\":2023,\"fingerprint\":\"DoAWRfUcyOfJupbL\",\"funding\":\"credit\",\"installments\":null,\"last4\":\"4444\",\"mandate\":null,\"network\":\"mastercard\",\"three_d_secure\":null,\"wallet\":null},\"type\":\"card\"},\"receipt_email\":null,\"receipt_number\":null,\"receipt_url\":\"https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTGZORGFMc3dGcGVoRE5nKKjem5oGMgZo4m-xDMM6LBadftys-t7FIeo23hfQKTAtYI3zpLwmJb_3-A6VqCpIGjfmpkWUwCDQC38M\",\"refunded\":false,\"refunds\":{\"data\":[],\"has_more\":false,\"object\":\"list\",\"total_count\":0,\"url\":\"/v1/charges/ch_3Ls83eLswFpehDNg0WVw0vTa/refunds\"},\"review\":null,\"shipping\":null,\"source\":null,\"source_transfer\":null,\"statement_descriptor\":null,\"statement_descriptor_suffix\":null,\"status\":\"succeeded\",\"transfer_data\":null,\"transfer_group\":null}},\"id\":\"evt_3Ls83eLswFpehDNg0dmzogyf\",\"livemode\":false,\"object\":\"event\",\"pending_webhooks\":2,\"request\":{\"id\":\"req_F8pjOORr12gJT9\",\"idempotency_key\":\"8fdd25c9-cb73-4807-973f-f0b21d8bb7cc\"},\"type\":\"charge.succeeded\"}" +chargeSucceeded :: LBS.ByteString +chargeSucceeded = [r| +{ + "id": "evt_3LxcbqBHXBAMm9bP1XpbOJrq", + "object": "event", + "api_version": "2019-11-05", + "created": 1666902207, + "data": { + "object": { + "id": "ch_3LxcbqBHXBAMm9bP1QIFhXee", + "object": "charge", + "amount": 100, + "amount_captured": 100, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3LxcbqBHXBAMm9bP1Q0skk4e", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": "PRIVATESTORAGE.IO", + "captured": true, + "created": 1666902206, + "currency": "usd", + "customer": null, + "description": "(created by Stripe CLI)", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": { + }, + "invoice": null, + "livemode": false, + "metadata": { + }, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 35, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": null, + "payment_method": "card_1LxcbqBHXBAMm9bPRIob1C1S", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 10, + "exp_year": 2023, + "fingerprint": "gLKhmoQYfsr1qGDi", + "funding": "credit", + "installments": null, + "last4": "4242", + "mandate": null, + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xRmhoeFRCSFhCQU1tOWJQKL_R65oGMgalIgGPgQc6LBaD9Kdq4Rg0Iz82re-NgTxpvigBVa_0K9HB7KHKy2v5eLI-3zt8J7kJZeRs", + "refunded": false, + "refunds": { + "object": "list", + "data": [ + + ], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3LxcbqBHXBAMm9bP1QIFhXee/refunds" + }, + "review": null, + "shipping": null, + "source": { + "id": "card_1LxcbqBHXBAMm9bPRIob1C1S", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": null, + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 10, + "exp_year": 2023, + "fingerprint": "gLKhmoQYfsr1qGDi", + "funding": "credit", + "last4": "4242", + "metadata": { + }, + "name": null, + "tokenization_method": null + }, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_u9SZdDchrHT1Iv", + "idempotency_key": "2591ca44-b3b5-463b-b3ad-128bf954acfb" + }, + "type": "charge.succeeded" +} +|] + +-- Note the client_reference_id contained within matches the voucher defined +-- above. +checkoutSessionCompleted :: LBS.ByteString +checkoutSessionCompleted = [r| +{ + "id": "evt_1LxcsdBHXBAMm9bPSq6UWAZe", + "object": "event", + "api_version": "2019-11-05", + "created": 1666903247, + "data": { + "object": { + "id": "cs_test_a1kWLWGoXZPa6ywyVnuib8DPA3BqXCWZX5UEjLfKh7gLjdZy2LD3F5mEp3", + "object": "checkout.session", + "after_expiration": null, + "allow_promotion_codes": null, + "amount_subtotal": 3000, + "amount_total": 3000, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_address_collection": null, + "cancel_url": "https://httpbin.org/post", + "client_reference_id": "abcdefghi", + "consent": null, + "consent_collection": null, + "created": 1666903243, + "currency": "usd", + "customer": "cus_Mh0u62xtelUehD", + "customer_creation": "always", + "customer_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "stripe@example.com", + "name": null, + "phone": null, + "tax_exempt": "none", + "tax_ids": [ + + ] + }, + "customer_email": null, + "display_items": [ + { + "amount": 1500, + "currency": "usd", + "custom": { + "description": "comfortable cotton t-shirt", + "images": null, + "name": "t-shirt" + }, + "quantity": 2, + "type": "custom" + } + ], + "expires_at": 1666989643, + "livemode": false, + "locale": null, + "metadata": { + }, + "mode": "payment", + "payment_intent": "pi_3LxcsZBHXBAMm9bP1daBGoPV", + "payment_link": null, + "payment_method_collection": "always", + "payment_method_options": { + }, + "payment_method_types": [ + "card" + ], + "payment_status": "paid", + "phone_number_collection": { + "enabled": false + }, + "recovered_from": null, + "setup_intent": null, + "shipping": null, + "shipping_address_collection": null, + "shipping_options": [ + + ], + "shipping_rate": null, + "status": "complete", + "submit_type": null, + "subscription": null, + "success_url": "https://httpbin.org/post", + "total_details": { + "amount_discount": 0, + "amount_shipping": 0, + "amount_tax": 0 + }, + "url": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "checkout.session.completed" +} +|] + +paymentIntentCreated :: LBS.ByteString +paymentIntentCreated = [r| +{ + "id": "evt_3LxcZvBHXBAMm9bP1vttzzH9", + "object": "event", + "api_version": "2019-11-05", + "created": 1666902087, + "data": { + "object": { + "id": "pi_3LxcZvBHXBAMm9bP1eIHeoyO", + "object": "payment_intent", + "amount": 3000, + "amount_capturable": 0, + "amount_details": { + "tip": { + } + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + + ], + "has_more": false, + "total_count": 0, + "url": "/v1/charges?payment_intent=pi_3LxcZvBHXBAMm9bP1eIHeoyO" + }, + "client_secret": "pi_3LxcZvBHXBAMm9bP1eIHeoyO_secret_diVUyvF9D65M4h8Azbr2j4kEA", + "confirmation_method": "automatic", + "created": 1666902087, + "currency": "usd", + "customer": null, + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": { + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": { + "address": { + "city": "townsville", + "country": "US", + "line1": "123 Street road", + "line2": null, + "postal_code": "11111", + "state": "CA" + }, + "carrier": null, + "name": "example username", + "phone": null, + "tracking_number": null + }, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_iopIfwbaJIDNrU", + "idempotency_key": "95faad4b-7cdc-4271-b9eb-c70eae570a33" + }, + "type": "payment_intent.created" +} +|] + + +customerCreated :: LBS.ByteString +customerCreated = [r| +{ + "id": "evt_1LxsEGBHXBAMm9bPNpMsfAwM", + "object": "event", + "api_version": "2019-11-05", + "created": 1666962248, + "data": { + "object": { + "id": "cus_MhGlMSuYwsznIR", + "object": "customer", + "address": null, + "balance": 0, + "created": 1666962248, + "currency": null, + "default_currency": null, + "default_source": null, + "delinquent": false, + "description": "(created by Stripe CLI)", + "discount": null, + "email": null, + "invoice_prefix": "4DEA2542", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": { + }, + "name": null, + "next_invoice_sequence": 1, + "phone": null, + "preferred_locales": [ + + ], + "shipping": null, + "sources": { + "object": "list", + "data": [ + + ], + "has_more": false, + "total_count": 0, + "url": "/v1/customers/cus_MhGlMSuYwsznIR/sources" + }, + "subscriptions": { + "object": "list", + "data": [ + + ], + "has_more": false, + "total_count": 0, + "url": "/v1/customers/cus_MhGlMSuYwsznIR/subscriptions" + }, + "tax_exempt": "none", + "tax_ids": { + "object": "list", + "data": [ + + ], + "has_more": false, + "total_count": 0, + "url": "/v1/customers/cus_MhGlMSuYwsznIR/tax_ids" + }, + "tax_info": null, + "tax_info_verification": null, + "test_clock": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_E1nCrCScXzp8ua", + "idempotency_key": "42b72b96-3fde-47a7-bf5d-02779bbbbd5d" + }, + "type": "customer.created" +} +|] From 944ae742c445bca50d89ae383f1ad3598ec45602 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 10:16:52 -0400 Subject: [PATCH 21/40] Thread the webhook secret key from CLI to webhook implementation This is a new required CLI argument so that we can validate signatures on webhooks. --- src/PaymentServer/Main.hs | 25 +++++++++++++++++++++++-- src/PaymentServer/Processors/Stripe.hs | 15 +++++++++++---- src/PaymentServer/Server.hs | 12 +++++++----- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/PaymentServer/Main.hs b/src/PaymentServer/Main.hs index e5d4ef7..22c73bc 100644 --- a/src/PaymentServer/Main.hs +++ b/src/PaymentServer/Main.hs @@ -55,6 +55,10 @@ import Network.Wai.Middleware.RequestLogger , mkRequestLogger ) +import Stripe.Concepts + ( WebhookSecretKey(WebhookSecretKey) + ) + import Web.Stripe.Client ( Protocol(HTTPS) , StripeConfig(StripeConfig) @@ -71,6 +75,10 @@ import PaymentServer.Issuer , trivialIssue , ristrettoIssue ) +import PaymentServer.Processors.Stripe + ( WebhookConfig(WebhookConfig) + ) + import PaymentServer.Server ( RedemptionConfig(RedemptionConfig) , paymentServerApp @@ -105,6 +113,7 @@ import System.Exit import Data.Semigroup ((<>)) import qualified Data.Text.IO as TIO import qualified Data.ByteString as B +import qualified Data.ByteString.Char8 as Char8 import qualified Data.ByteString.Lazy.UTF8 as LBS data IssuerFlavor = @@ -125,6 +134,7 @@ data ServerConfig = ServerConfig , databasePath :: Maybe Text , endpoint :: Endpoint , stripeKeyPath :: FilePath + , stripeWebhookKeyPath :: FilePath , stripeEndpointUrl :: ByteString , stripeEndpointProtocol :: Protocol , stripeEndpointPort :: Int @@ -218,6 +228,9 @@ sample = ServerConfig ( long "stripe-key-path" <> help "Path to Stripe Secret key" ) <*> option str + ( long "stripe-webhook-key-path" + <> help "Path to Stripe Webhook signing key" ) + <*> option str ( long "stripe-endpoint-domain" <> help "The domain name for the Stripe API HTTP endpoint." <> value "api.stripe.com" @@ -311,11 +324,18 @@ getApp config = , stripeEndpointPort } = do - key <- B.readFile stripeKeyPath + key <- Char8.strip <$> B.readFile stripeKeyPath return $ StripeConfig (StripeKey key) (Just $ Stripe.Endpoint stripeEndpointUrl stripeEndpointProtocol stripeEndpointPort) + + webhookConfig ServerConfig + { stripeWebhookKeyPath + } = + do + webhookKey <- Char8.strip <$> B.readFile stripeWebhookKeyPath + return $ WebhookConfig (WebhookSecretKey webhookKey) in do issuer <- getIssuer config case issuer of @@ -330,10 +350,11 @@ getApp config = Right getDB -> do db <- getDB stripeConfig' <- stripeConfig config + webhookConfig' <- webhookConfig config let origins = corsOrigins config redemptionConfig = getRedemptionConfig config issuer - app = paymentServerApp origins stripeConfig' redemptionConfig db + app = paymentServerApp origins stripeConfig' webhookConfig' redemptionConfig db metricsMiddleware <- makeMetricsMiddleware logger <- mkRequestLogger (def { outputFormat = Detailed True}) return . logger . metricsMiddleware $ app diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index e1662aa..1fbb627 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -8,6 +8,7 @@ module PaymentServer.Processors.Stripe ( ChargesAPI , WebhookAPI + , WebhookConfig(WebhookConfig) , Charges(Charges) , Acknowledgement(Ok) , Failure(Failure) @@ -121,7 +122,7 @@ import Web.Stripe ) import Stripe.Concepts - ( WebhookSecretKey(WebhookSecretKey) + ( WebhookSecretKey ) @@ -143,6 +144,12 @@ instance ToJSON Acknowledgement where [ "success" .= True ] +-- Represent configuration options for setting up the webhook endpoint for +-- receiving event notifications from Stripe. +data WebhookConfig = WebhookConfig + { webhookConfigKey :: WebhookSecretKey + } + -- | getVoucher finds the metadata item with the key `"Voucher"` and returns -- the corresponding value, or Nothing. getVoucher :: Event -> Maybe Voucher @@ -173,13 +180,13 @@ instance MimeUnrender UnparsedJSON ByteString where type WebhookAPI = "webhook" :> Header "Stripe-Signature" Text :> ReqBody '[UnparsedJSON] ByteString :> Post '[JSON] Acknowledgement -- | Process charge succeeded -webhookServer :: VoucherDatabase d => StripeConfig -> d -> Maybe Text -> ByteString -> Handler Acknowledgement +webhookServer :: VoucherDatabase d => WebhookConfig -> d -> Maybe Text -> ByteString -> Handler Acknowledgement webhookServer _ _ Nothing _ = throwError $ jsonErr status400 "missing signature" -webhookServer stripeConfig@StripeConfig { secretKey = (StripeKey stripeKey) } d (Just signatureText) payload = +webhookServer WebhookConfig { webhookConfigKey } d (Just signatureText) payload = case parseSig signatureText of Nothing -> throwError $ jsonErr status400 "malformed signature" Just sig -> - if isSigValid sig (WebhookSecretKey stripeKey) payload + if isSigValid sig webhookConfigKey payload then fundVoucher else throwError $ jsonErr status400 "invalid signature" where diff --git a/src/PaymentServer/Server.hs b/src/PaymentServer/Server.hs index 342888f..4115989 100644 --- a/src/PaymentServer/Server.hs +++ b/src/PaymentServer/Server.hs @@ -39,6 +39,7 @@ import Web.Stripe.Client import PaymentServer.Processors.Stripe ( ChargesAPI , WebhookAPI + , WebhookConfig , chargeServer , webhookServer ) @@ -66,10 +67,10 @@ type PaymentServerAPI :<|> MetricsAPI -- | Create a server which uses the given database. -paymentServer :: VoucherDatabase d => StripeConfig -> RedemptionConfig -> d -> Server PaymentServerAPI -paymentServer stripeConfig redemptionConfig database = +paymentServer :: VoucherDatabase d => StripeConfig -> WebhookConfig -> RedemptionConfig -> d -> Server PaymentServerAPI +paymentServer stripeConfig webhookConfig redemptionConfig database = chargeServer stripeConfig database - :<|> webhookServer stripeConfig database + :<|> webhookServer webhookConfig database :<|> redemptionServer redemptionConfig database :<|> metricsServer @@ -82,12 +83,13 @@ paymentServerApp :: VoucherDatabase d => [Origin] -- ^ A list of CORS Origins to accept. -> StripeConfig + -> WebhookConfig -> RedemptionConfig -> d -> Application -paymentServerApp corsOrigins stripeConfig redemptionConfig = +paymentServerApp corsOrigins stripeConfig webhookConfig redemptionConfig = let - app = serve paymentServerAPI . paymentServer stripeConfig redemptionConfig + app = serve paymentServerAPI . paymentServer stripeConfig webhookConfig redemptionConfig withCredentials = False corsResourcePolicy = simpleCorsResourcePolicy { corsOrigins = Just (corsOrigins, withCredentials) From 8d86a2e05b5cb8f854be7b14dd0792714c63238d Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 10:18:40 -0400 Subject: [PATCH 22/40] Fix the materialization --- .../.stack-to-nix.cache | 2 +- .../.stack-to-nix.cache.1 | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 nix/materialized.paymentserver/.stack-to-nix.cache.1 diff --git a/nix/materialized.paymentserver/.stack-to-nix.cache b/nix/materialized.paymentserver/.stack-to-nix.cache index 90b74b0..b3607b1 100644 --- a/nix/materialized.paymentserver/.stack-to-nix.cache +++ b/nix/materialized.paymentserver/.stack-to-nix.cache @@ -1,2 +1,2 @@ https://github.com/PrivateStorageio/servant-prometheus.git 622eb77cb08c5f13729173b8feb123a6700ff91f . 09dcwj5ac2x2ilb4a0rai3qhl875vvvjr12af5plpz86faga3lnz servant-prometheus .stack-to-nix.cache.0 -https://github.com/PrivateStorageio/stripe.git f5e03955832b95c26504b3e44bd5fe018e8b27d8 stripe-core 1kmgh2937n1m10n8q5xw5ysgvhd7yq502qan210ykiz5xgkm5can stripe-core .stack-to-nix.cache.1 +https://github.com/PrivateStorageio/stripe.git 6c340eea6dc23c4245762b937b9701a40761a5c9 stripe-core 1bhw1dcmr2fiphxjvjl80qwg1yrw1ifldm4w4s11q3z0a6yphl5r stripe-core .stack-to-nix.cache.1 diff --git a/nix/materialized.paymentserver/.stack-to-nix.cache.1 b/nix/materialized.paymentserver/.stack-to-nix.cache.1 new file mode 100644 index 0000000..b1ccce5 --- /dev/null +++ b/nix/materialized.paymentserver/.stack-to-nix.cache.1 @@ -0,0 +1,42 @@ +{ system + , compiler + , flags + , pkgs + , hsPkgs + , pkgconfPkgs + , errorHandler + , config + , ... }: + { + flags = {}; + package = { + specVersion = "1.10"; + identifier = { name = "stripe-core"; version = "2.6.2"; }; + license = "MIT"; + copyright = "Copyright (c) 2016 David M. Johnson, Jeremy Shaw"; + maintainer = "djohnson.m@gmail.com"; + author = "David Johnson, Jeremy Shaw"; + homepage = "https://github.com/dmjio/stripe-haskell"; + url = ""; + synopsis = "Stripe API for Haskell - Pure Core"; + description = "\n<>\n\n[Pure API Wrapper]\n`stripe-core` provides a complete binding to the Stripe API. `stripe-core` provides pure wrappers around all the Stripe API objects and methods. `stripe-core` is pure and is not tied to any particular HTTP client library. End users will typically install the `stripe-haskell` package which pulls in the `stripe-http-client` library to obtain a complete set of functionality."; + buildType = "Simple"; + }; + components = { + "library" = { + depends = [ + (hsPkgs."aeson" or (errorHandler.buildDepError "aeson")) + (hsPkgs."base" or (errorHandler.buildDepError "base")) + (hsPkgs."bytestring" or (errorHandler.buildDepError "bytestring")) + (hsPkgs."mtl" or (errorHandler.buildDepError "mtl")) + (hsPkgs."text" or (errorHandler.buildDepError "text")) + (hsPkgs."time" or (errorHandler.buildDepError "time")) + (hsPkgs."transformers" or (errorHandler.buildDepError "transformers")) + (hsPkgs."unordered-containers" or (errorHandler.buildDepError "unordered-containers")) + ]; + buildable = true; + }; + }; + } // rec { + src = (pkgs.lib).mkDefault /nix/store/5np9iplqv5nqcawrwlng38ak0l847nid-stripe-6c340ee/stripe-core; + } From 52335d5f987de99d192050e54aca3b6384afdedd Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 10:39:02 -0400 Subject: [PATCH 23/40] This is redundant with the other example --- charge_succeeded.json | 110 ------------------------------------------ 1 file changed, 110 deletions(-) delete mode 100644 charge_succeeded.json diff --git a/charge_succeeded.json b/charge_succeeded.json deleted file mode 100644 index 28dd7c7..0000000 --- a/charge_succeeded.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "id": "evt_3Ls83eLswFpehDNg0dmzogyf", - "object": "event", - "api_version": "2022-08-01", - "created": 1665593127, - "data": { - "object": { - "id": "ch_3Ls83eLswFpehDNg0WVw0vTa", - "object": "charge", - "amount": 250, - "amount_captured": 250, - "amount_refunded": 0, - "application": null, - "application_fee": null, - "application_fee_amount": null, - "balance_transaction": "txn_3Ls83eLswFpehDNg0dFvPaKv", - "billing_details": { - "address": { - "city": null, - "country": "DE", - "line1": null, - "line2": null, - "postal_code": null, - "state": null - }, - "email": "a@b.d", - "name": "asdfasf", - "phone": null - }, - "calculated_statement_descriptor": "Stripe", - "captured": true, - "created": 1665593127, - "currency": "usd", - "customer": null, - "description": null, - "destination": null, - "dispute": null, - "disputed": false, - "failure_balance_transaction": null, - "failure_code": null, - "failure_message": null, - "fraud_details": {}, - "invoice": null, - "livemode": false, - "metadata": {}, - "on_behalf_of": null, - "order": null, - "outcome": { - "network_status": "approved_by_network", - "reason": null, - "risk_level": "normal", - "risk_score": 21, - "seller_message": "Payment complete.", - "type": "authorized" - }, - "paid": true, - "payment_intent": "pi_3Ls83eLswFpehDNg0b2mAFUW", - "payment_method": "pm_1Ls83dLswFpehDNgpYAGL3j9", - "payment_method_details": { - "card": { - "brand": "mastercard", - "checks": { - "address_line1_check": null, - "address_postal_code_check": null, - "cvc_check": "pass" - }, - "country": "US", - "exp_month": 12, - "exp_year": 2023, - "fingerprint": "DoAWRfUcyOfJupbL", - "funding": "credit", - "installments": null, - "last4": "4444", - "mandate": null, - "network": "mastercard", - "three_d_secure": null, - "wallet": null - }, - "type": "card" - }, - "receipt_email": null, - "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTGZORGFMc3dGcGVoRE5nKKjem5oGMgZo4m-xDMM6LBadftys-t7FIeo23hfQKTAtYI3zpLwmJb_3-A6VqCpIGjfmpkWUwCDQC38M", - "refunded": false, - "refunds": { - "object": "list", - "data": [], - "has_more": false, - "total_count": 0, - "url": "/v1/charges/ch_3Ls83eLswFpehDNg0WVw0vTa/refunds" - }, - "review": null, - "shipping": null, - "source": null, - "source_transfer": null, - "statement_descriptor": null, - "statement_descriptor_suffix": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null - } - }, - "livemode": false, - "pending_webhooks": 2, - "request": { - "id": "req_F8pjOORr12gJT9", - "idempotency_key": "8fdd25c9-cb73-4807-973f-f0b21d8bb7cc" - }, - "type": "charge.succeeded" -} From 83e9d46cb1e733429187adb7b243ad7064224109 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 10:40:56 -0400 Subject: [PATCH 24/40] We don't need a charge anymore and we have a payment_intent in the test suite --- misc/examples/events/charge.http | 10 - misc/examples/events/webhook.http | 292 ------------------------------ 2 files changed, 302 deletions(-) delete mode 100644 misc/examples/events/charge.http delete mode 100644 misc/examples/events/webhook.http diff --git a/misc/examples/events/charge.http b/misc/examples/events/charge.http deleted file mode 100644 index 7829a7a..0000000 --- a/misc/examples/events/charge.http +++ /dev/null @@ -1,10 +0,0 @@ -### -POST http://localhost:8080/v1/stripe/charge -Content-Type: application/json - -{ - "token": "tok_visa", - "voucher": "abcg", - "amount": "650", - "currency": "USD" -} \ No newline at end of file diff --git a/misc/examples/events/webhook.http b/misc/examples/events/webhook.http deleted file mode 100644 index a544d21..0000000 --- a/misc/examples/events/webhook.http +++ /dev/null @@ -1,292 +0,0 @@ -### -POST http://localhost:8080/v1/stripe/webhook -Content-Type: application/json - -{ - "id": "evt_3Ls83eLswFpehDNg0dmzogyf", - "object": "event", - "api_version": "2022-08-01", - "created": 1665593127, - "data": { - "object": { - "id": "ch_3Ls83eLswFpehDNg0WVw0vTa", - "object": "charge", - "amount": 250, - "amount_captured": 250, - "amount_refunded": 0, - "application": null, - "application_fee": null, - "application_fee_amount": null, - "balance_transaction": "txn_3Ls83eLswFpehDNg0dFvPaKv", - "billing_details": { - "address": { - "city": null, - "country": "DE", - "line1": null, - "line2": null, - "postal_code": null, - "state": null - }, - "email": "a@b.d", - "name": "asdfasf", - "phone": null - }, - "calculated_statement_descriptor": "Stripe", - "captured": true, - "created": 1665593127, - "currency": "usd", - "customer": null, - "description": null, - "destination": null, - "dispute": null, - "disputed": false, - "failure_balance_transaction": null, - "failure_code": null, - "failure_message": null, - "fraud_details": {}, - "invoice": null, - "livemode": false, - "metadata": {}, - "on_behalf_of": null, - "order": null, - "outcome": { - "network_status": "approved_by_network", - "reason": null, - "risk_level": "normal", - "risk_score": 21, - "seller_message": "Payment complete.", - "type": "authorized" - }, - "paid": true, - "payment_intent": "pi_3Ls83eLswFpehDNg0b2mAFUW", - "payment_method": "pm_1Ls83dLswFpehDNgpYAGL3j9", - "payment_method_details": { - "card": { - "brand": "mastercard", - "checks": { - "address_line1_check": null, - "address_postal_code_check": null, - "cvc_check": "pass" - }, - "country": "US", - "exp_month": 12, - "exp_year": 2023, - "fingerprint": "DoAWRfUcyOfJupbL", - "funding": "credit", - "installments": null, - "last4": "4444", - "mandate": null, - "network": "mastercard", - "three_d_secure": null, - "wallet": null - }, - "type": "card" - }, - "receipt_email": null, - "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTGZORGFMc3dGcGVoRE5nKKjem5oGMgZo4m-xDMM6LBadftys-t7FIeo23hfQKTAtYI3zpLwmJb_3-A6VqCpIGjfmpkWUwCDQC38M", - "refunded": false, - "refunds": { - "object": "list", - "data": [], - "has_more": false, - "total_count": 0, - "url": "/v1/charges/ch_3Ls83eLswFpehDNg0WVw0vTa/refunds" - }, - "review": null, - "shipping": null, - "source": null, - "source_transfer": null, - "statement_descriptor": null, - "statement_descriptor_suffix": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null - } - }, - "livemode": false, - "pending_webhooks": 2, - "request": { - "id": "req_F8pjOORr12gJT9", - "idempotency_key": "8fdd25c9-cb73-4807-973f-f0b21d8bb7cc" - }, - "type": "charge.succeeded" -} - - -### -POST http://localhost:8080/v1/stripe/webhook -Content-Type: application/json - -{ - "id": "evt_3Ls83eLswFpehDNg0YbJevK2", - "object": "event", - "api_version": "2022-08-01", - "created": 1665593128, - "data": { - "object": { - "id": "pi_3Ls83eLswFpehDNg0b2mAFUW", - "object": "payment_intent", - "amount": 250, - "amount_capturable": 0, - "amount_details": { - "tip": {} - }, - "amount_received": 250, - "application": null, - "application_fee_amount": null, - "automatic_payment_methods": null, - "canceled_at": null, - "cancellation_reason": null, - "capture_method": "automatic", - "charges": { - "object": "list", - "data": [ - { - "id": "ch_3Ls83eLswFpehDNg0WVw0vTa", - "object": "charge", - "amount": 250, - "amount_captured": 250, - "amount_refunded": 0, - "application": null, - "application_fee": null, - "application_fee_amount": null, - "balance_transaction": "txn_3Ls83eLswFpehDNg0dFvPaKv", - "billing_details": { - "address": { - "city": null, - "country": "DE", - "line1": null, - "line2": null, - "postal_code": null, - "state": null - }, - "email": "a@b.d", - "name": "asdfasf", - "phone": null - }, - "calculated_statement_descriptor": "Stripe", - "captured": true, - "created": 1665593127, - "currency": "usd", - "customer": null, - "description": null, - "destination": null, - "dispute": null, - "disputed": false, - "failure_balance_transaction": null, - "failure_code": null, - "failure_message": null, - "fraud_details": {}, - "invoice": null, - "livemode": false, - "metadata": {}, - "on_behalf_of": null, - "order": null, - "outcome": { - "network_status": "approved_by_network", - "reason": null, - "risk_level": "normal", - "risk_score": 21, - "seller_message": "Payment complete.", - "type": "authorized" - }, - "paid": true, - "payment_intent": "pi_3Ls83eLswFpehDNg0b2mAFUW", - "payment_method": "pm_1Ls83dLswFpehDNgpYAGL3j9", - "payment_method_details": { - "card": { - "brand": "mastercard", - "checks": { - "address_line1_check": null, - "address_postal_code_check": null, - "cvc_check": "pass" - }, - "country": "US", - "exp_month": 12, - "exp_year": 2023, - "fingerprint": "DoAWRfUcyOfJupbL", - "funding": "credit", - "installments": null, - "last4": "4444", - "mandate": null, - "network": "mastercard", - "three_d_secure": null, - "wallet": null - }, - "type": "card" - }, - "receipt_email": null, - "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTGZORGFMc3dGcGVoRE5nKKjem5oGMgbvNEiopYo6LBaa9sMFbxpWzGb2WX9aOuc4LWzXH4hUaNoIgvdvHlJv5zChsx0FH0MJcj6b", - "refunded": false, - "refunds": { - "object": "list", - "data": [], - "has_more": false, - "total_count": 0, - "url": "/v1/charges/ch_3Ls83eLswFpehDNg0WVw0vTa/refunds" - }, - "review": null, - "shipping": null, - "source": null, - "source_transfer": null, - "statement_descriptor": null, - "statement_descriptor_suffix": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/charges?payment_intent=pi_3Ls83eLswFpehDNg0b2mAFUW" - }, - "client_secret": "pi_3Ls83eLswFpehDNg0b2mAFUW_secret_72eKxCUCTrTLH0E3dSvrlwmgO", - "confirmation_method": "automatic", - "created": 1665593126, - "currency": "usd", - "customer": null, - "description": null, - "invoice": null, - "last_payment_error": null, - "livemode": false, - "metadata": {}, - "next_action": null, - "on_behalf_of": null, - "payment_method": "pm_1Ls83dLswFpehDNgpYAGL3j9", - "payment_method_options": { - "card": { - "installments": null, - "mandate_options": null, - "network": null, - "request_three_d_secure": "automatic" - } - }, - "payment_method_types": [ - "card" - ], - "processing": null, - "receipt_email": null, - "review": null, - "setup_future_usage": null, - "shipping": null, - "source": null, - "statement_descriptor": null, - "statement_descriptor_suffix": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null - } - }, - "livemode": false, - "pending_webhooks": 2, - "request": { - "id": "req_F8pjOORr12gJT9", - "idempotency_key": "8fdd25c9-cb73-4807-973f-f0b21d8bb7cc" - }, - "type": "payment_intent.succeeded" -} - - - From 9a18d35256afb23016ab3eeff025a8e5cecc61d5 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 11:07:48 -0400 Subject: [PATCH 25/40] Fix the tests :/ --- test/FakeStripe.hs | 14 ++++++++++++-- test/Metrics.hs | 2 +- test/Redemption.hs | 4 ++-- test/Stripe.hs | 34 ++++++++++++++++++++-------------- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/test/FakeStripe.hs b/test/FakeStripe.hs index 9c13561..d48da37 100644 --- a/test/FakeStripe.hs +++ b/test/FakeStripe.hs @@ -52,6 +52,14 @@ import Web.Stripe.Types ( ChargeId(ChargeId) ) +import PaymentServer.Processors.Stripe + ( WebhookConfig(WebhookConfig) + ) + +import Stripe.Concepts + ( WebhookSecretKey(WebhookSecretKey) + ) + cardError :: ByteString cardError = [r| { @@ -184,9 +192,11 @@ chargeFailed stripeResponse req respond = -- Pass a Stripe-flavored configuration for a running Wai application to a -- function and evaluate the resulting IO action. -withFakeStripe :: IO Application -> (StripeConfig -> IO a) -> IO a +withFakeStripe :: IO Application -> (WebhookConfig -> StripeConfig -> IO a) -> IO a withFakeStripe app f = - testWithApplication app $ f . makeConfig + testWithApplication app $ f webhookConfig . makeConfig where makeConfig = StripeConfig stripeKey . Just . Endpoint "127.0.0.1" HTTP stripeKey = StripeKey "pk_test_aaaaaaaaaaaaaaaaaaaaaa" + webhookKey = WebhookSecretKey "whsec_bbbbbbbbbbbbbbbbbbbbbbb" + webhookConfig = WebhookConfig webhookKey diff --git a/test/Metrics.hs b/test/Metrics.hs index 67392b2..d232808 100644 --- a/test/Metrics.hs +++ b/test/Metrics.hs @@ -103,7 +103,7 @@ serverTests = testCase "metrics endpoint" $ let app :: Application - app = paymentServerApp mempty undefined undefined (undefined :: VoucherDatabaseState) + app = paymentServerApp mempty undefined undefined undefined (undefined :: VoucherDatabaseState) in flip runSession app $ do response <- readMetrics diff --git a/test/Redemption.hs b/test/Redemption.hs index 79d1f0b..f435b7a 100644 --- a/test/Redemption.hs +++ b/test/Redemption.hs @@ -129,13 +129,13 @@ redemptionTests = -- response with the given status. assertRedemptionStatus redemption expectedStatus = withFakeStripe (return chargeOkay) $ - \stripeConfig -> do + \webhookConfig stripeConfig -> do db <- memory payForVoucher db aVoucher (return $ Right $ ChargeId "xyz") -- It would be nice if we exercised `getApp` here instead of doing it -- all ourselves. - let app = paymentServerApp origins stripeConfig redemptionConfig db + let app = paymentServerApp origins stripeConfig webhookConfig redemptionConfig db flip runSession app $ do response <- request redemption diff --git a/test/Stripe.hs b/test/Stripe.hs index 99a99cf..6b5491a 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -106,6 +106,7 @@ import PaymentServer.Processors.Stripe ( Charges(Charges) , Acknowledgement(Ok) , Failure(Failure) + , WebhookConfig(WebhookConfig) , charge , webhookServer ) @@ -176,10 +177,10 @@ corsTests = assertCORSHeader' db stripeResponse method headers body = withFakeStripe (return stripeResponse) $ - \stripeConfig -> do + \webhookConfig stripeConfig -> do let origins = ["example.invalid"] let redemptionConfig = RedemptionConfig 16 1024 trivialIssue - let app = paymentServerApp origins stripeConfig redemptionConfig db + let app = paymentServerApp origins stripeConfig webhookConfig redemptionConfig db let path = "/v1/stripe/charge" let theRequest = setPath defaultRequest @@ -196,7 +197,7 @@ chargeTests :: TestTree chargeTests = testGroup "Charges" [ testCase "non-USD currency is rejected" $ - withFakeStripe (return chargeOkay) $ \stripeConfig -> do + withFakeStripe (return chargeOkay) $ \webhookConfig stripeConfig -> do let amount = 650 let currency = AED db <- memory @@ -206,7 +207,7 @@ chargeTests = assertEqual "The JSON body includes the reason" (Just $ Failure "Unsupported currency") (decode body) , testCase "incorrect USD amount is rejected" $ - withFakeStripe (return chargeOkay) $ \stripeConfig -> do + withFakeStripe (return chargeOkay) $ \webhookConfig stripeConfig -> do let amount = 649 let currency = USD db <- memory @@ -216,7 +217,7 @@ chargeTests = assertEqual "The JSON body includes the reason" (Just $ Failure "Incorrect charge amount") (decode body) , testCase "a Stripe charge failure is propagated" $ - withFakeStripe (return (chargeFailed cardError)) $ \stripeConfig -> do + withFakeStripe (return (chargeFailed cardError)) $ \webhookConfig stripeConfig -> do let amount = 650 let currency = USD db <- memory @@ -228,7 +229,7 @@ chargeTests = (Just $ Failure "Stripe charge didn't succeed: Your card is expired.") (decode body) , testCase "the HTTP error code is derived from the specific failure" $ - withFakeStripe (return (chargeFailed apiError)) $ \stripeConfig -> do + withFakeStripe (return (chargeFailed apiError)) $ \webhookConfig stripeConfig -> do let amount = 650 let currency = USD db <- memory @@ -238,7 +239,7 @@ chargeTests = assertEqual "The HTTP phrase matches the code" "Service Unavailable" phrase , testCase "currect USD amount is accepted" $ - withFakeStripe (return chargeOkay) $ \stripeConfig -> do + withFakeStripe (return chargeOkay) $ \webhookConfig stripeConfig -> do let amount = 650 let currency = USD db <- memory @@ -266,7 +267,7 @@ webhookTests = , requestHeaders = [("content-type", "application/json; charset=utf-8")] } theSRequest = SRequest theRequest checkoutSessionCompleted - app = paymentServerApp origins stripeConfig redemptionConfig db + app = makeApp db response <- (flip runSession) app $ srequest theSRequest assertEqual "The body reflects the error" (Just $ Failure "missing signature") (decode . simpleBody $ response) @@ -276,7 +277,7 @@ webhookTests = , testCase "If the signature is misformatted then the response is Bad Request" $ do db <- memory let - app = paymentServerApp origins stripeConfig redemptionConfig db + app = makeApp db theRequest = (flip setPath) path defaultRequest { requestMethod = "POST" , requestHeaders = [ ("content-type", "application/json; charset=utf-8") @@ -293,7 +294,7 @@ webhookTests = , testCase "If the signature is incorrect then no attempt is made to parse the request body and the response is Bad Request" $ do db <- memory let - app = paymentServerApp origins stripeConfig redemptionConfig db + app = makeApp db theRequest = (flip setPath) path defaultRequest { requestMethod = "POST" , requestHeaders = [ ("content-type", "application/json; charset=utf-8") @@ -311,11 +312,11 @@ webhookTests = db <- memory let nonJSONBody = "Some other body" - app = paymentServerApp origins stripeConfig redemptionConfig db + app = makeApp db theRequest = (flip setPath) path defaultRequest { requestMethod = "POST" , requestHeaders = [ ("content-type", "application/json; charset=utf-8") - , ("Stripe-Signature", stripeSignature (WebhookSecretKey keyBytes) timestamp nonJSONBody) + , ("Stripe-Signature", stripeSignature webhookSecret timestamp nonJSONBody) ] } theSRequest = SRequest theRequest (LBS.fromStrict nonJSONBody) @@ -352,11 +353,11 @@ webhookTests = assertOkResponse body = do db <- memory let - app = paymentServerApp origins stripeConfig redemptionConfig db + app = makeApp db theRequest = (flip setPath) path defaultRequest { requestMethod = "POST" , requestHeaders = [ ("content-type", "application/json; charset=utf-8") - , ("Stripe-Signature", stripeSignature (WebhookSecretKey keyBytes) timestamp (LBS.toStrict body)) + , ("Stripe-Signature", stripeSignature webhookSecret timestamp (LBS.toStrict body)) ] } theSRequest = SRequest theRequest body @@ -378,6 +379,8 @@ webhookTests = redeemed <- redeemVoucher db voucher fingerprint assertEqual "The unpaid voucher is not redeemable." (Left NotPaid) redeemed + makeApp = paymentServerApp origins stripeConfig webhookConfig redemptionConfig + -- Arbitrary strings that don't matter apart from how they compare to -- other values in the same range. Maybe Voucher and Fingerprint should -- be newtype instead of type. Note that the voucher value does appear in @@ -399,6 +402,9 @@ webhookTests = keyBytes = "an extremely good key" stripeKey = StripeKey keyBytes stripeConfig = StripeConfig stripeKey Nothing + webhookSecretBytes = "very secret bytes" + webhookSecret = WebhookSecretKey webhookSecretBytes + webhookConfig = WebhookConfig webhookSecret origins = [] redemptionConfig = RedemptionConfig 16 1024 trivialIssue path = "/v1/stripe/webhook" From 341416c127b4c080c52a266741ad0878cedf5da2 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 11:09:15 -0400 Subject: [PATCH 26/40] regenerate materialization to account for main merge changes --- nix/materialized.paymentserver/PaymentServer.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nix/materialized.paymentserver/PaymentServer.nix b/nix/materialized.paymentserver/PaymentServer.nix index 71002e0..d0cbb8e 100644 --- a/nix/materialized.paymentserver/PaymentServer.nix +++ b/nix/materialized.paymentserver/PaymentServer.nix @@ -59,6 +59,7 @@ (hsPkgs."retry" or (errorHandler.buildDepError "retry")) (hsPkgs."prometheus-client" or (errorHandler.buildDepError "prometheus-client")) (hsPkgs."servant-prometheus" or (errorHandler.buildDepError "servant-prometheus")) + (hsPkgs."mtl" or (errorHandler.buildDepError "mtl")) ]; pkgconfig = [ (pkgconfPkgs."libchallenge_bypass_ristretto_ffi" or (errorHandler.pkgConfDepError "libchallenge_bypass_ristretto_ffi")) From 6b74533f821a0a68ae0828df1a84603e732d7292 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 12:05:41 -0400 Subject: [PATCH 27/40] Some lint cleanup --- src/PaymentServer/Processors/Stripe.hs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 1fbb627..e3a3e25 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -18,16 +18,10 @@ module PaymentServer.Processors.Stripe , charge ) where -import Data.Aeson - ( eitherDecode - ) - import Prelude hiding ( concat ) -import Data.Data (Typeable) - import Control.Exception ( catch ) @@ -42,8 +36,6 @@ import Data.Text , concat , pack ) -import Text.Read() -import Data.Maybe import qualified Network.HTTP.Media as M import Network.HTTP.Types @@ -67,6 +59,7 @@ import Data.Aeson , Value(Object) , object , encode + , eitherDecode , (.:) , (.=) ) From f7be761382302b811cf7415e0d4af914a65aef55 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 12:05:44 -0400 Subject: [PATCH 28/40] note about timestamp checking --- src/PaymentServer/Processors/Stripe.hs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index e3a3e25..a1647ad 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -179,6 +179,9 @@ webhookServer WebhookConfig { webhookConfigKey } d (Just signatureText) payload case parseSig signatureText of Nothing -> throwError $ jsonErr status400 "malformed signature" Just sig -> + -- We check the signature but we don't otherwise interpret the timestamp + -- it carries. In the future perhaps we should. + -- https://github.com/PrivateStorageio/PaymentServer/issues/129 if isSigValid sig webhookConfigKey payload then fundVoucher else throwError $ jsonErr status400 "invalid signature" From ede0a1cd4dfcb7317fce486b0c81837576decadc Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 12:05:50 -0400 Subject: [PATCH 29/40] webhook is elsewhere now --- src/PaymentServer/Processors/Stripe.hs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index a1647ad..5584d28 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -262,9 +262,6 @@ withSuccessFailureMetrics attemptCount successCount op = do -- and if the Charge is okay, then set the voucher as "paid" in the database. charge :: VoucherDatabase d => StripeConfig -> d -> Charges -> Handler Acknowledgement charge stripeConfig d (Charges token voucher 650 USD) = do - - -- TODO verify the webhook request as a first step - result <- liftIO payForVoucher' case result of Left AlreadyPaid -> From 68aac17aad2795bf2f3f3a0e3f0f7e043374c2bc Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 12:05:54 -0400 Subject: [PATCH 30/40] whitespace --- src/PaymentServer/Processors/Stripe.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 5584d28..c171c4c 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -118,7 +118,6 @@ import Stripe.Concepts ( WebhookSecretKey ) - import qualified Prometheus as P import PaymentServer.Persistence From 529d29b677cdc4f6b5559f7f7ba2fa18658a774f Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 12:07:15 -0400 Subject: [PATCH 31/40] note about our stripe fork --- stack.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/stack.yaml b/stack.yaml index fe9368b..abdfe63 100644 --- a/stack.yaml +++ b/stack.yaml @@ -44,6 +44,9 @@ extra-deps: # https://input-output-hk.github.io/haskell.nix/tutorials/source-repository-hashes/#stack # nix-sha256: 39KhnnIG/UtvcUqELPfe5SAK8YgqA0UWjaILporkrCU= - "stripe-signature-1.0.0.14" + + # Our fork of the stripe library supports *just* enough of Stripe's newer + # API version to get some extra information our webhook needs. - github: "PrivateStorageio/stripe" commit: "6c340eea6dc23c4245762b937b9701a40761a5c9" subdirs: From f501f822886d3aee4b63ff1f67cb626eadb418aa Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 12:08:45 -0400 Subject: [PATCH 32/40] made a ticket for that one --- test/Stripe.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Stripe.hs b/test/Stripe.hs index 6b5491a..1437c3e 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -251,7 +251,6 @@ chargeTests = voucher = "bar" -- TODO --- Prevent replay attacks? https://stripe.com/docs/webhooks/signatures#replay-attacks -- Check network origin? https://stripe.com/docs/ips#webhook-notifications From 61f49093d0d7a00ad05cbd6466508d4784157f79 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 12:08:48 -0400 Subject: [PATCH 33/40] let's not even bother, source ip verification is amazingly weak form of protection --- test/Stripe.hs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/Stripe.hs b/test/Stripe.hs index 1437c3e..ba03e1d 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -250,10 +250,6 @@ chargeTests = token = "foo" voucher = "bar" --- TODO --- Check network origin? https://stripe.com/docs/ips#webhook-notifications - - webhookTests :: TestTree webhookTests = testGroup "The Stripe charge web hook" From 1ff1be918c5a10ce0ec8450c50dc82132b9041aa Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Oct 2022 12:21:06 -0400 Subject: [PATCH 34/40] Some minor test refactoring --- test/Stripe.hs | 48 ++++++++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/test/Stripe.hs b/test/Stripe.hs index ba03e1d..5b4473c 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -257,11 +257,7 @@ webhookTests = db <- memory let - theRequest = (flip setPath) path defaultRequest - { requestMethod = "POST" - , requestHeaders = [("content-type", "application/json; charset=utf-8")] - } - theSRequest = SRequest theRequest checkoutSessionCompleted + theSRequest = SRequest jsonRequest checkoutSessionCompleted app = makeApp db response <- (flip runSession) app $ srequest theSRequest @@ -273,12 +269,7 @@ webhookTests = db <- memory let app = makeApp db - theRequest = (flip setPath) path defaultRequest - { requestMethod = "POST" - , requestHeaders = [ ("content-type", "application/json; charset=utf-8") - , ("Stripe-Signature", "Do you like my signature?") - ] - } + theRequest = signedRequest "Do you like my signature?" theSRequest = SRequest theRequest checkoutSessionCompleted response <- (flip runSession) app $ srequest theSRequest @@ -290,12 +281,7 @@ webhookTests = db <- memory let app = makeApp db - theRequest = (flip setPath) path defaultRequest - { requestMethod = "POST" - , requestHeaders = [ ("content-type", "application/json; charset=utf-8") - , ("Stripe-Signature", stripeSignature (WebhookSecretKey "key") timestamp "Some other body") - ] - } + theRequest = signedRequest $ stripeSignature (WebhookSecretKey "key") timestamp "Some other body" theSRequest = SRequest theRequest checkoutSessionCompleted response <- (flip runSession) app $ srequest theSRequest @@ -308,19 +294,14 @@ webhookTests = let nonJSONBody = "Some other body" app = makeApp db - theRequest = (flip setPath) path defaultRequest - { requestMethod = "POST" - , requestHeaders = [ ("content-type", "application/json; charset=utf-8") - , ("Stripe-Signature", stripeSignature webhookSecret timestamp nonJSONBody) - ] - } + theRequest = signedRequest $ stripeSignature webhookSecret timestamp nonJSONBody theSRequest = SRequest theRequest (LBS.fromStrict nonJSONBody) response <- (flip runSession) app $ srequest theSRequest - case decode . simpleBody $ response of - Just (Failure _) -> assertEqual "The response is 400" status400 (simpleStatus response) - Nothing -> fail "response body parse failed" + -- It should fail but we don't really care what the message is. + let (Just (Failure _)) = decode . simpleBody $ response + assertEqual "The response is 400" status400 (simpleStatus response) , testCase "If the signature is correct then the response is OK" $ do db <- assertOkResponse checkoutSessionCompleted @@ -402,8 +383,23 @@ webhookTests = webhookConfig = WebhookConfig webhookSecret origins = [] redemptionConfig = RedemptionConfig 16 1024 trivialIssue + + -- The path at which our server exposes the Stripe webhook handler. path = "/v1/stripe/webhook" + -- Some request values useful for the various cases we want to test. + postRequest = (flip setPath) path defaultRequest + { requestMethod = "POST" + } + + jsonRequest = postRequest + { requestHeaders = [("content-type", "application/json; charset=utf-8")] + } + + signedRequest sig = jsonRequest + { requestHeaders = ("Stripe-Signature", sig):requestHeaders jsonRequest + } + chargeSucceeded :: LBS.ByteString chargeSucceeded = [r| { From 4684edf215e57ac5e65b666c14d7978b3fb8e132 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 31 Oct 2022 09:34:34 -0400 Subject: [PATCH 35/40] Attempt to clarify the motivation behind handling of certain cases --- test/Stripe.hs | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/test/Stripe.hs b/test/Stripe.hs index 5b4473c..e7c19ff 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -303,30 +303,34 @@ webhookTests = let (Just (Failure _)) = decode . simpleBody $ response assertEqual "The response is 400" status400 (simpleStatus response) - , testCase "If the signature is correct then the response is OK" $ do - db <- assertOkResponse checkoutSessionCompleted + , testCase "If the request body contains a checkout.session.completed event and the signature is correct then the voucher is marked as paid and the response is OK" $ do + db <- runRequest checkoutSessionCompleted >>= assertOkResponse -- It has been paid so we should be allowed to redeem it. assertRedeemable db voucher fingerprint , testCase "The response to a charge.succeeded is OK" $ do - db <- assertOkResponse chargeSucceeded - -- It is only redeemable after checkout.session.completed. + db <- runRequest chargeSucceeded >>= assertOkResponse + -- The charge.succeeded event does not carry the voucher value so it is + -- impossible for us to record the voucher as paid in response to this + -- event. Check that explicitly to confirm our reasoning... assertNotRedeemable db voucher fingerprint , testCase "The response to a payment_intent.created is OK" $ do - db <- assertOkResponse paymentIntentCreated - -- It is only redeemable after checkout.session.completed. + db <- runRequest paymentIntentCreated >>= assertOkResponse + -- A payment intent "acts as the single source of truth in the + -- lifecycle" (https://stripe.com/docs/payments/intents) of a payment + -- flow. The mere *creation* of one implies nothing about payment + -- having been received so the voucher should not have been marked as + -- paid in response to this event. assertNotRedeemable db voucher fingerprint , testCase "The response to a customer.created is OK" $ do - db <- assertOkResponse customerCreated + db <- runRequest customerCreated >>= assertOkResponse -- It is only redeemable after checkout.session.completed. assertNotRedeemable db voucher fingerprint ] where - -- Assert that the response to a correctly signed applicaton/json request - -- with the given body is 200 OK. - assertOkResponse body = do + runRequest body = do db <- memory let app = makeApp db @@ -339,10 +343,18 @@ webhookTests = theSRequest = SRequest theRequest body response <- (flip runSession) app $ srequest theSRequest + return (db, response) + + -- Assert that the response to a correctly signed applicaton/json request + -- with the given body is 200 OK. + assertOkResponse (db, response) = do assertEqual "The body reflects success" (encode Ok) (simpleBody response) - assertEqual "The response is 200" status200 (simpleStatus response) + assertResponse status200 (db, response) return db + assertResponse status (db, response) = + assertEqual ("The response is " ++ (show status)) status (simpleStatus response) + -- Assert that the database allows us to redeem a voucher, demonstrating -- that the voucher has persistent state consistent with payment having -- been received. From 5e374d711c63f3a2eea99becc638f38d882e2e8e Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 31 Oct 2022 12:44:42 -0400 Subject: [PATCH 36/40] add PaymentServer-complete-payment A helper for populating database state related to voucher/payment. --- PaymentServer.cabal | 18 +- complete-payment/Main.hs | 253 ++++++++++++++++++ .../PaymentServer.nix | 19 +- src/PaymentServer/Processors/Stripe.hs | 25 +- test/Stripe.hs | 17 +- 5 files changed, 308 insertions(+), 24 deletions(-) create mode 100644 complete-payment/Main.hs diff --git a/PaymentServer.cabal b/PaymentServer.cabal index 28b50f2..e72b908 100644 --- a/PaymentServer.cabal +++ b/PaymentServer.cabal @@ -28,6 +28,7 @@ library , aeson , bytestring , utf8-string + , base16-bytestring , servant , servant-server , http-types @@ -81,6 +82,22 @@ executable PaymentServer-get-public-key , PaymentServer default-language: Haskell2010 +executable PaymentServer-complete-payment + hs-source-dirs: complete-payment + main-is: Main.hs + ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wmissing-import-lists -Wunused-imports + build-depends: base + , time + , text + , bytestring + , optparse-applicative + , unix-compat + , http-client + , stripe-concepts + , raw-strings-qq + , PaymentServer + default-language: Haskell2010 + test-suite PaymentServer-tests type: exitcode-stdio-1.0 hs-source-dirs: test @@ -96,7 +113,6 @@ test-suite PaymentServer-tests , bytestring , stripe-signature , stripe-concepts - , base16-bytestring , text , transformers , raw-strings-qq diff --git a/complete-payment/Main.hs b/complete-payment/Main.hs new file mode 100644 index 0000000..b41a3a3 --- /dev/null +++ b/complete-payment/Main.hs @@ -0,0 +1,253 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module Main + ( main + ) where + +import Text.Printf + ( printf + ) + +import Data.Text + ( Text + , pack + , unpack + ) + +import GHC.Natural + ( naturalFromInteger + ) + +import Data.Text.Encoding + ( encodeUtf8 + ) + +import Data.ByteString + ( ByteString + , readFile + ) +import Data.ByteString.Char8 + ( strip + ) + +import Text.RawString.QQ + ( r + ) + +import Options.Applicative + ( Parser + , ParserInfo + , strOption + , option + , auto + , long + , help + , showDefault + , value + , info + , (<**>) + , helper + , fullDesc + , progDesc + , header + , execParser + ) + +import Network.HTTP.Client + ( Request(method, requestBody, requestHeaders, path) + , RequestBody(RequestBodyBS) + , parseRequest + , newManager + , defaultManagerSettings + , httpLbs + , responseStatus + , responseBody + ) + +import Data.Time.Clock.POSIX + ( getPOSIXTime + ) + +import Stripe.Concepts + ( WebhookSecretKey(WebhookSecretKey) + ) + +import PaymentServer.Processors.Stripe + ( stripeSignature + ) + +data Config = Config + { configServerURL :: Text + , configVoucher :: Text + , configWebhookSecretPath :: FilePath + } + +config :: Parser Config +config = Config + <$> strOption + ( long "server-url" + <> help "The root URL of the PaymentServer on which to complete the payment." + <> showDefault + <> value "http://localhost:8000/" + ) + <*> strOption + ( long "voucher" + <> help "The voucher for which to complete payment." + ) + <*> strOption + ( long "webhook-secret-path" + <> help "The path to a file containing the webhook secret to use to sign the request." + ) + +options :: ParserInfo Config +options = info (config <**> helper) + ( fullDesc + <> progDesc "" + <> header "" + ) + +-- Construct the request body for a `checkout.session.complete` event +-- containing the given voucher. +completePaymentBody :: Text -> ByteString +completePaymentBody = + encodeUtf8 . pack . printf template + where + template = [r| +{ + "id": "evt_1LxcsdBHXBAMm9bPSq6UWAZe", + "object": "event", + "api_version": "2019-11-05", + "created": 1666903247, + "data": { + "object": { + "id": "cs_test_a1kWLWGoXZPa6ywyVnuib8DPA3BqXCWZX5UEjLfKh7gLjdZy2LD3F5mEp3", + "object": "checkout.session", + "after_expiration": null, + "allow_promotion_codes": null, + "amount_subtotal": 3000, + "amount_total": 3000, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_address_collection": null, + "cancel_url": "https://httpbin.org/post", + "client_reference_id": "%s", + "consent": null, + "consent_collection": null, + "created": 1666903243, + "currency": "usd", + "customer": "cus_Mh0u62xtelUehD", + "customer_creation": "always", + "customer_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "stripe@example.com", + "name": null, + "phone": null, + "tax_exempt": "none", + "tax_ids": [ + + ] + }, + "customer_email": null, + "display_items": [ + { + "amount": 1500, + "currency": "usd", + "custom": { + "description": "comfortable cotton t-shirt", + "images": null, + "name": "t-shirt" + }, + "quantity": 2, + "type": "custom" + } + ], + "expires_at": 1666989643, + "livemode": false, + "locale": null, + "metadata": { + }, + "mode": "payment", + "payment_intent": "pi_3LxcsZBHXBAMm9bP1daBGoPV", + "payment_link": null, + "payment_method_collection": "always", + "payment_method_options": { + }, + "payment_method_types": [ + "card" + ], + "payment_status": "paid", + "phone_number_collection": { + "enabled": false + }, + "recovered_from": null, + "setup_intent": null, + "shipping": null, + "shipping_address_collection": null, + "shipping_options": [ + + ], + "shipping_rate": null, + "status": "complete", + "submit_type": null, + "subscription": null, + "success_url": "https://httpbin.org/post", + "total_details": { + "amount_discount": 0, + "amount_shipping": 0, + "amount_tax": 0 + }, + "url": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "checkout.session.completed" +} +|] + + +main :: IO () +main = do + Config + { configServerURL + , configVoucher + , configWebhookSecretPath + } <- execParser options + + let body = completePaymentBody configVoucher + webhookSecret <- WebhookSecretKey . strip <$> Data.ByteString.readFile configWebhookSecretPath + now <- naturalFromInteger . truncate <$> getPOSIXTime + + req <- parseRequest . unpack $ configServerURL + let req' = req + { method = "POST" + , path = "/v1/stripe/webhook" + , requestBody = RequestBodyBS body + , requestHeaders = + [ ( "Stripe-Signature" + , stripeSignature webhookSecret now body + ) + , ( "Content-Type" + , "application/json; charset=utf-8" + ) + ] + } + + manager <- newManager defaultManagerSettings + response <- httpLbs req' manager + print ((responseStatus response), (responseBody response)) diff --git a/nix/materialized.paymentserver/PaymentServer.nix b/nix/materialized.paymentserver/PaymentServer.nix index 85bebd9..b884df1 100644 --- a/nix/materialized.paymentserver/PaymentServer.nix +++ b/nix/materialized.paymentserver/PaymentServer.nix @@ -38,6 +38,7 @@ (hsPkgs."aeson" or (errorHandler.buildDepError "aeson")) (hsPkgs."bytestring" or (errorHandler.buildDepError "bytestring")) (hsPkgs."utf8-string" or (errorHandler.buildDepError "utf8-string")) + (hsPkgs."base16-bytestring" or (errorHandler.buildDepError "base16-bytestring")) (hsPkgs."servant" or (errorHandler.buildDepError "servant")) (hsPkgs."servant-server" or (errorHandler.buildDepError "servant-server")) (hsPkgs."http-types" or (errorHandler.buildDepError "http-types")) @@ -108,6 +109,23 @@ hsSourceDirs = [ "get-public-key" ]; mainPath = [ "Main.hs" ]; }; + "PaymentServer-complete-payment" = { + depends = [ + (hsPkgs."base" or (errorHandler.buildDepError "base")) + (hsPkgs."time" or (errorHandler.buildDepError "time")) + (hsPkgs."text" or (errorHandler.buildDepError "text")) + (hsPkgs."bytestring" or (errorHandler.buildDepError "bytestring")) + (hsPkgs."optparse-applicative" or (errorHandler.buildDepError "optparse-applicative")) + (hsPkgs."unix-compat" or (errorHandler.buildDepError "unix-compat")) + (hsPkgs."http-client" or (errorHandler.buildDepError "http-client")) + (hsPkgs."stripe-concepts" or (errorHandler.buildDepError "stripe-concepts")) + (hsPkgs."raw-strings-qq" or (errorHandler.buildDepError "raw-strings-qq")) + (hsPkgs."PaymentServer" or (errorHandler.buildDepError "PaymentServer")) + ]; + buildable = true; + hsSourceDirs = [ "complete-payment" ]; + mainPath = [ "Main.hs" ]; + }; }; tests = { "PaymentServer-tests" = { @@ -117,7 +135,6 @@ (hsPkgs."bytestring" or (errorHandler.buildDepError "bytestring")) (hsPkgs."stripe-signature" or (errorHandler.buildDepError "stripe-signature")) (hsPkgs."stripe-concepts" or (errorHandler.buildDepError "stripe-concepts")) - (hsPkgs."base16-bytestring" or (errorHandler.buildDepError "base16-bytestring")) (hsPkgs."text" or (errorHandler.buildDepError "text")) (hsPkgs."transformers" or (errorHandler.buildDepError "transformers")) (hsPkgs."raw-strings-qq" or (errorHandler.buildDepError "raw-strings-qq")) diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index c171c4c..fd8476d 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -16,10 +16,11 @@ module PaymentServer.Processors.Stripe , webhookServer , getVoucher , charge + , stripeSignature ) where -import Prelude hiding - ( concat +import GHC.Natural + ( Natural ) import Control.Exception @@ -45,7 +46,7 @@ import Network.HTTP.Types , status503 ) -import Data.ByteString (ByteString) +import Data.ByteString (ByteString, concat) import Data.ByteString.Lazy (toStrict, fromStrict) @@ -53,6 +54,8 @@ import Data.ByteString.UTF8 ( toString ) +import qualified Data.ByteString.Base16 as Base16 + import Data.Aeson ( ToJSON(toJSON) , FromJSON(parseJSON) @@ -87,7 +90,7 @@ import Web.Stripe.Event , EventData(ChargeEvent, CheckoutSessionEvent) ) -import Stripe.Signature (parseSig, isSigValid) +import Stripe.Signature (digest, natBytes, parseSig, isSigValid) import Web.Stripe.Error ( StripeError(StripeError, errorType, errorMsg) @@ -142,7 +145,17 @@ data WebhookConfig = WebhookConfig { webhookConfigKey :: WebhookSecretKey } --- | getVoucher finds the metadata item with the key `"Voucher"` and returns +-- Create the value for the `Stripe-Signature` header item in a webhook request. +stripeSignature :: WebhookSecretKey -> Natural -> ByteString -> ByteString +stripeSignature key when what = Data.ByteString.concat + [ "t=" + , natBytes when + , "," + , "v1=" + , Base16.encode $ digest key when what + ] + +-- getVoucher finds the metadata item with the key `"Voucher"` and returns -- the corresponding value, or Nothing. getVoucher :: Event -> Maybe Voucher getVoucher Event{eventData=(CheckoutSessionEvent checkoutSession)} = @@ -269,7 +282,7 @@ charge stripeConfig d (Charges token voucher 650 USD) = do Left (PaymentFailed (StripeError { errorType = errorType, errorMsg = msg })) -> do liftIO $ print "Stripe createCharge failed:" liftIO $ print msg - let err = errorForStripe errorType ( concat [ "Stripe charge didn't succeed: ", msg ]) + let err = errorForStripe errorType ( Data.Text.concat [ "Stripe charge didn't succeed: ", msg ]) throwError err Right _ -> return Ok diff --git a/test/Stripe.hs b/test/Stripe.hs index e7c19ff..d8eec89 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -48,13 +48,6 @@ import Stripe.Concepts ( WebhookSecretKey(WebhookSecretKey) ) -import Stripe.Signature - ( digest - , natBytes - ) - -import qualified Data.ByteString.Base16 as Base16 - import Servant.Server ( Handler(runHandler') , ServerError(ServerError) @@ -109,6 +102,7 @@ import PaymentServer.Processors.Stripe , WebhookConfig(WebhookConfig) , charge , webhookServer + , stripeSignature ) import PaymentServer.Issuer @@ -376,16 +370,7 @@ webhookTests = voucher = "abcdefghi" fingerprint = "rstuvwxyz" - stripeSignature key when what = BS.concat - [ "t=" - , natBytes when - , "," - , "v1=" - , encodeHex $ digest key when what - ] - timestamp = 1234567890 - encodeHex = Base16.encode keyBytes = "an extremely good key" stripeKey = StripeKey keyBytes From 9ad1adc83648f54b71b380036f2a5c25c348f831 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 31 Oct 2022 12:54:18 -0400 Subject: [PATCH 37/40] Update the dev docs a little bit --- README.rst | 24 ++++++++++++++++-------- src/PaymentServer/Processors/Stripe.hs | 8 ++++---- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index 03d02ad..930aee6 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,11 @@ Get all the build dependencies with nix:: $ nix-shell PrivateStorageio/shell.nix # Might be needed depending on your system, see #88 $ nix-shell PaymentServer/shell.nix -Build using Stack:: +Build using Nix:: + + $ nix-build nix/ -A PaymentServer.components.exes.PaymentServer-exe -o exe + +Or using Stack:: $ stack build @@ -24,15 +28,19 @@ Testing You can perform manual integration testing against Stripe. First, run the server:: - $ stack run + $ ./exe/bin/PaymentServer-exe [arguments] -Then create a testing charge:: +Or with stack:: - $ curl \ - http://:8081/v1/stripe/charge \ - -X POST \ - -H 'content-type: application/json' \ - --data '{ "token":"tok_visa", "voucher":"abcdefg", "amount":"650", "currency":"USD" }' + $ stack run -- [arguments] + +Then report that payment has been received for a given voucher: + + $ stack run -- \ + PaymentServer-complete-payment \ + --voucher abcdefg \ + --server-url http://localhost:8081/ \ + --webhook-secret-path ../stripe.webhook-secret The PaymentServer marks the voucher as paid in its database. Then redeem the vouncher for tokens:: diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index fd8476d..bf4ea67 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -86,7 +86,7 @@ import Servant.API import Web.Stripe.Event ( Event(Event, eventId, eventType, eventData) , EventId(EventId) - , EventType(ChargeSucceededEvent, CheckoutSessionCompleted) + , EventType(ChargeSucceededEvent, CheckoutSessionCompleted, PaymentIntentCreated) , EventData(ChargeEvent, CheckoutSessionEvent) ) @@ -201,7 +201,7 @@ webhookServer WebhookConfig { webhookConfigKey } d (Just signatureText) payload fundVoucher = case eitherDecode . fromStrict $ payload of Left s -> throwError $ jsonErr status400 (pack s) - Right event -> + Right event@Event { eventType = CheckoutSessionCompleted } -> case getVoucher event of Nothing -> -- TODO: Record the eventId somewhere. In all cases where we don't @@ -215,8 +215,8 @@ webhookServer WebhookConfig { webhookConfigKey } d (Just signatureText) payload -- should be able to indicate error I guess. _ <- liftIO . payForVoucher d v . return . Right $ () return Ok - Right _ -> - return Ok + Right event@Event { eventType } -> + throwError . jsonErr status400 . pack $ "unsupported event type " ++ show eventType -- | Browser facing API that takes token, voucher and a few other information -- and calls stripe charges API. If payment succeeds, then the voucher is stored From 24a8209b0f58e73566914b384f6f5b39cbaf5910 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 31 Oct 2022 12:54:34 -0400 Subject: [PATCH 38/40] Make non-checkout.session.completed events errors --- test/Stripe.hs | 348 +++++-------------------------------------------- 1 file changed, 29 insertions(+), 319 deletions(-) diff --git a/test/Stripe.hs b/test/Stripe.hs index d8eec89..a1b8aa6 100644 --- a/test/Stripe.hs +++ b/test/Stripe.hs @@ -302,26 +302,9 @@ webhookTests = -- It has been paid so we should be allowed to redeem it. assertRedeemable db voucher fingerprint - , testCase "The response to a charge.succeeded is OK" $ do - db <- runRequest chargeSucceeded >>= assertOkResponse - -- The charge.succeeded event does not carry the voucher value so it is - -- impossible for us to record the voucher as paid in response to this - -- event. Check that explicitly to confirm our reasoning... - assertNotRedeemable db voucher fingerprint - - , testCase "The response to a payment_intent.created is OK" $ do - db <- runRequest paymentIntentCreated >>= assertOkResponse - -- A payment intent "acts as the single source of truth in the - -- lifecycle" (https://stripe.com/docs/payments/intents) of a payment - -- flow. The mere *creation* of one implies nothing about payment - -- having been received so the voucher should not have been marked as - -- paid in response to this event. - assertNotRedeemable db voucher fingerprint + , testCase "The response to any other event is Bad Request" $ + runRequest productCreated >>= assertResponse status400 - , testCase "The response to a customer.created is OK" $ do - db <- runRequest customerCreated >>= assertOkResponse - -- It is only redeemable after checkout.session.completed. - assertNotRedeemable db voucher fingerprint ] where runRequest body = do @@ -397,149 +380,6 @@ webhookTests = { requestHeaders = ("Stripe-Signature", sig):requestHeaders jsonRequest } -chargeSucceeded :: LBS.ByteString -chargeSucceeded = [r| -{ - "id": "evt_3LxcbqBHXBAMm9bP1XpbOJrq", - "object": "event", - "api_version": "2019-11-05", - "created": 1666902207, - "data": { - "object": { - "id": "ch_3LxcbqBHXBAMm9bP1QIFhXee", - "object": "charge", - "amount": 100, - "amount_captured": 100, - "amount_refunded": 0, - "application": null, - "application_fee": null, - "application_fee_amount": null, - "balance_transaction": "txn_3LxcbqBHXBAMm9bP1Q0skk4e", - "billing_details": { - "address": { - "city": null, - "country": null, - "line1": null, - "line2": null, - "postal_code": null, - "state": null - }, - "email": null, - "name": null, - "phone": null - }, - "calculated_statement_descriptor": "PRIVATESTORAGE.IO", - "captured": true, - "created": 1666902206, - "currency": "usd", - "customer": null, - "description": "(created by Stripe CLI)", - "destination": null, - "dispute": null, - "disputed": false, - "failure_balance_transaction": null, - "failure_code": null, - "failure_message": null, - "fraud_details": { - }, - "invoice": null, - "livemode": false, - "metadata": { - }, - "on_behalf_of": null, - "order": null, - "outcome": { - "network_status": "approved_by_network", - "reason": null, - "risk_level": "normal", - "risk_score": 35, - "seller_message": "Payment complete.", - "type": "authorized" - }, - "paid": true, - "payment_intent": null, - "payment_method": "card_1LxcbqBHXBAMm9bPRIob1C1S", - "payment_method_details": { - "card": { - "brand": "visa", - "checks": { - "address_line1_check": null, - "address_postal_code_check": null, - "cvc_check": null - }, - "country": "US", - "exp_month": 10, - "exp_year": 2023, - "fingerprint": "gLKhmoQYfsr1qGDi", - "funding": "credit", - "installments": null, - "last4": "4242", - "mandate": null, - "network": "visa", - "three_d_secure": null, - "wallet": null - }, - "type": "card" - }, - "receipt_email": null, - "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xRmhoeFRCSFhCQU1tOWJQKL_R65oGMgalIgGPgQc6LBaD9Kdq4Rg0Iz82re-NgTxpvigBVa_0K9HB7KHKy2v5eLI-3zt8J7kJZeRs", - "refunded": false, - "refunds": { - "object": "list", - "data": [ - - ], - "has_more": false, - "total_count": 0, - "url": "/v1/charges/ch_3LxcbqBHXBAMm9bP1QIFhXee/refunds" - }, - "review": null, - "shipping": null, - "source": { - "id": "card_1LxcbqBHXBAMm9bPRIob1C1S", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": null, - "address_zip_check": null, - "brand": "Visa", - "country": "US", - "customer": null, - "cvc_check": null, - "dynamic_last4": null, - "exp_month": 10, - "exp_year": 2023, - "fingerprint": "gLKhmoQYfsr1qGDi", - "funding": "credit", - "last4": "4242", - "metadata": { - }, - "name": null, - "tokenization_method": null - }, - "source_transfer": null, - "statement_descriptor": null, - "statement_descriptor_suffix": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null - } - }, - "livemode": false, - "pending_webhooks": 2, - "request": { - "id": "req_u9SZdDchrHT1Iv", - "idempotency_key": "2591ca44-b3b5-463b-b3ad-128bf954acfb" - }, - "type": "charge.succeeded" -} -|] - -- Note the client_reference_id contained within matches the voucher defined -- above. checkoutSessionCompleted :: LBS.ByteString @@ -649,178 +489,48 @@ checkoutSessionCompleted = [r| } |] -paymentIntentCreated :: LBS.ByteString -paymentIntentCreated = [r| -{ - "id": "evt_3LxcZvBHXBAMm9bP1vttzzH9", - "object": "event", - "api_version": "2019-11-05", - "created": 1666902087, - "data": { - "object": { - "id": "pi_3LxcZvBHXBAMm9bP1eIHeoyO", - "object": "payment_intent", - "amount": 3000, - "amount_capturable": 0, - "amount_details": { - "tip": { - } - }, - "amount_received": 0, - "application": null, - "application_fee_amount": null, - "automatic_payment_methods": null, - "canceled_at": null, - "cancellation_reason": null, - "capture_method": "automatic", - "charges": { - "object": "list", - "data": [ - - ], - "has_more": false, - "total_count": 0, - "url": "/v1/charges?payment_intent=pi_3LxcZvBHXBAMm9bP1eIHeoyO" - }, - "client_secret": "pi_3LxcZvBHXBAMm9bP1eIHeoyO_secret_diVUyvF9D65M4h8Azbr2j4kEA", - "confirmation_method": "automatic", - "created": 1666902087, - "currency": "usd", - "customer": null, - "description": null, - "invoice": null, - "last_payment_error": null, - "livemode": false, - "metadata": { - }, - "next_action": null, - "on_behalf_of": null, - "payment_method": null, - "payment_method_options": { - "card": { - "installments": null, - "mandate_options": null, - "network": null, - "request_three_d_secure": "automatic" - } - }, - "payment_method_types": [ - "card" - ], - "processing": null, - "receipt_email": null, - "review": null, - "setup_future_usage": null, - "shipping": { - "address": { - "city": "townsville", - "country": "US", - "line1": "123 Street road", - "line2": null, - "postal_code": "11111", - "state": "CA" - }, - "carrier": null, - "name": "example username", - "phone": null, - "tracking_number": null - }, - "source": null, - "statement_descriptor": null, - "statement_descriptor_suffix": null, - "status": "requires_payment_method", - "transfer_data": null, - "transfer_group": null - } - }, - "livemode": false, - "pending_webhooks": 2, - "request": { - "id": "req_iopIfwbaJIDNrU", - "idempotency_key": "95faad4b-7cdc-4271-b9eb-c70eae570a33" - }, - "type": "payment_intent.created" -} -|] - -customerCreated :: LBS.ByteString -customerCreated = [r| +productCreated :: LBS.ByteString +productCreated = [r| { - "id": "evt_1LxsEGBHXBAMm9bPNpMsfAwM", + "id": "evt_1LyxesBHXBAMm9bPqekQW4Yj", "object": "event", "api_version": "2019-11-05", - "created": 1666962248, + "created": 1667221446, "data": { "object": { - "id": "cus_MhGlMSuYwsznIR", - "object": "customer", - "address": null, - "balance": 0, - "created": 1666962248, - "currency": null, - "default_currency": null, - "default_source": null, - "delinquent": false, + "id": "prod_MiOR6hX1zcaGfJ", + "object": "product", + "active": true, + "attributes": [ + + ], + "created": 1667221445, + "default_price": null, "description": "(created by Stripe CLI)", - "discount": null, - "email": null, - "invoice_prefix": "4DEA2542", - "invoice_settings": { - "custom_fields": null, - "default_payment_method": null, - "footer": null, - "rendering_options": null - }, - "livemode": false, - "metadata": { - }, - "name": null, - "next_invoice_sequence": 1, - "phone": null, - "preferred_locales": [ + "images": [ ], - "shipping": null, - "sources": { - "object": "list", - "data": [ - - ], - "has_more": false, - "total_count": 0, - "url": "/v1/customers/cus_MhGlMSuYwsznIR/sources" - }, - "subscriptions": { - "object": "list", - "data": [ - - ], - "has_more": false, - "total_count": 0, - "url": "/v1/customers/cus_MhGlMSuYwsznIR/subscriptions" - }, - "tax_exempt": "none", - "tax_ids": { - "object": "list", - "data": [ - - ], - "has_more": false, - "total_count": 0, - "url": "/v1/customers/cus_MhGlMSuYwsznIR/tax_ids" + "livemode": false, + "metadata": { }, - "tax_info": null, - "tax_info_verification": null, - "test_clock": null + "name": "myproduct", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "type": "service", + "unit_label": null, + "updated": 1667221446, + "url": null } }, "livemode": false, "pending_webhooks": 2, "request": { - "id": "req_E1nCrCScXzp8ua", - "idempotency_key": "42b72b96-3fde-47a7-bf5d-02779bbbbd5d" + "id": "req_kvFraITogK8pZB", + "idempotency_key": "74150cd6-6ac5-4144-859f-4e6774adb09d" }, - "type": "customer.created" + "type": "product.created" } |] From 1b5e83dc4ffec6eafcb4efd2a1077609e56ab7fa Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 31 Oct 2022 12:55:11 -0400 Subject: [PATCH 39/40] Build more pieces on CI --- .circleci/config.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 38c6cfd..9af1876 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -83,7 +83,10 @@ jobs: --option extra-trusted-public-keys "${TRUSTED_PUBLIC_KEYS}" \ -j 4 \ ./nix/ \ - -A PaymentServer.components.exes."PaymentServer-exe" + -A PaymentServer.components.exes."PaymentServer-exe" \ + -A PaymentServer.components.exes."PaymentServer-generate-key" \ + -A PaymentServer.components.exes."PaymentServer-get-public-key" \ + -A PaymentServer.components.exes."PaymentServer-complete-payment" - run: name: "Building Tests" From 11df85fd7e58cb0d5bc67029d4173c8ca5446669 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 31 Oct 2022 13:03:53 -0400 Subject: [PATCH 40/40] document the stripe interaction a bit --- README.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.rst b/README.rst index 930aee6..8c9ddab 100644 --- a/README.rst +++ b/README.rst @@ -50,3 +50,21 @@ Then redeem the vouncher for tokens:: -X POST \ -H 'content-type: application/json' \ --data '{ "redeemVoucher": "abcdefg", "redeemTokens":[]}' + +Stripe Integration +------------------ + +PaymentServer listens for Stripe events at a "webhook" endpoint. +The endpoint is at ``/v1/stripe/webhook``. +It handles only ``checkout.session.completed`` events. +These events must include a voucher in the ``client_reference_id`` field. +A voucher so referenced will be marked as paid when this event is processed. + +The webhook must be correctly configured in the associated Stripe account. +One way to configure it is with a request like:: + + curl \ + https://api.stripe.com/v1/webhook_endpoints \ + -u sk_test_yourkey: \ + -d url="https://serveraddress/v1/stripe/webhook" \ + -d "enabled_events[]"="checkout.session.completed"