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/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 diff --git a/src/PaymentServer/Processors/Stripe.hs b/src/PaymentServer/Processors/Stripe.hs index 07f2556..bb6c982 100644 --- a/src/PaymentServer/Processors/Stripe.hs +++ b/src/PaymentServer/Processors/Stripe.hs @@ -5,11 +5,13 @@ {-# LANGUAGE ScopedTypeVariables #-} module PaymentServer.Processors.Stripe - ( StripeAPI + ( ChargesAPI + , WebhookAPI , Charges(Charges) , Acknowledgement(Ok) , Failure(Failure) - , stripeServer + , chargeServer + , webhookServer , getVoucher , charge ) where @@ -29,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) @@ -43,6 +42,8 @@ import Network.HTTP.Types , status503 ) +import Data.ByteString (ByteString) + import Data.ByteString.UTF8 ( toString ) @@ -68,12 +69,18 @@ import Servant.API , Post , (:>) ) +import Web.Stripe.Event + ( Event(Event, eventId, eventType, eventData) + , EventId(EventId) + , EventType(ChargeSucceededEvent) + , EventData(ChargeEvent) + ) import Web.Stripe.Error ( StripeError(StripeError, errorType, errorMsg) , StripeErrorType(InvalidRequest, APIError, ConnectionFailure, CardError) ) import Web.Stripe.Types - ( Charge(Charge, chargeId) + ( Charge(Charge, chargeId, chargeMetaData) , MetaData(MetaData) , Currency(USD) ) @@ -106,7 +113,7 @@ instance ToJSON Acknowledgement where [ "success" .= True ] -type StripeAPI = 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. @@ -115,10 +122,39 @@ 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 + +--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 + 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 _ = + -- 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 +211,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 as a first step + result <- liftIO payForVoucher' case result of Left AlreadyPaid -> diff --git a/src/PaymentServer/Server.hs b/src/PaymentServer/Server.hs index 3c0c5ee..342888f 100644 --- a/src/PaymentServer/Server.hs +++ b/src/PaymentServer/Server.hs @@ -37,8 +37,10 @@ import Web.Stripe.Client ) import PaymentServer.Processors.Stripe - ( StripeAPI - , stripeServer + ( ChargesAPI + , WebhookAPI + , chargeServer + , webhookServer ) import PaymentServer.Redemption ( RedemptionConfig(RedemptionConfig) @@ -58,14 +60,16 @@ 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 :<|> MetricsAPI -- | Create a server which uses the given database. paymentServer :: VoucherDatabase d => StripeConfig -> RedemptionConfig -> d -> Server PaymentServerAPI paymentServer stripeConfig redemptionConfig database = - stripeServer stripeConfig database + chargeServer stripeConfig database + :<|> webhookServer stripeConfig database :<|> redemptionServer redemptionConfig database :<|> metricsServer diff --git a/stack.yaml b/stack.yaml index 45a5364..58510fc 100644 --- a/stack.yaml +++ b/stack.yaml @@ -38,10 +38,11 @@ packages: # using the same syntax as the packages field. # (e.g., acme-missiles-0.3) extra-deps: + - "network-3.1.2.7" - github: "PrivateStorageio/servant-prometheus" commit: "622eb77cb08c5f13729173b8feb123a6700ff91f" # https://input-output-hk.github.io/haskell.nix/tutorials/source-repository-hashes/#stack - # nix-sha256: 09dcwj5ac2x2ilb4a0rai3qhl875vvvjr12af5plpz86faga3lnz + # nix-sha256: 39KhnnIG/UtvcUqELPfe5SAK8YgqA0UWjaILporkrCU= # Override default flag values for local packages and extra-deps # flags: {}