diff --git a/.ruby-version b/.ruby-version index 338a5b5..a603bb5 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.6 +2.7.5 diff --git a/web.rb b/web.rb index b3930a4..7e836da 100644 --- a/web.rb +++ b/web.rb @@ -1,435 +1,408 @@ +# server.rb +# +# Use this sample code to handle webhook events in your integration. +# +# 1) Paste this code into a new file (server.rb) +# +# 2) Install dependencies +# gem install sinatra +# gem install stripe +# +# 3) Run the server on http://localhost:4242 +# ruby server.rb + +require 'json' require 'sinatra' require 'stripe' -require 'dotenv' -require 'json' -require 'encrypted_cookie' - -$stdout.sync = true # Get puts to show up in heroku logs - -Dotenv.load -Stripe.api_key = ENV['STRIPE_TEST_SECRET_KEY'] - -use Rack::Session::EncryptedCookie, - :secret => 'replace_me_with_a_real_secret_key' # Actually use something secret here! -def log_info(message) - puts "\n" + message + "\n\n" - return message -end +# This is your Stripe CLI webhook secret for testing your endpoint locally. +endpoint_secret = 'whsec_48912a4b5e88a0592369c5bfbdeb0da054f4c83c8693cdd2ffe747f08f484aeb' -get '/' do - status 200 - return log_info("Great, your backend is set up. Now you can configure the Stripe example apps to point here.") -end +set :port, 4242 -post '/ephemeral_keys' do - authenticate! - begin - key = Stripe::EphemeralKey.create( - {customer: @customer.id}, - {stripe_version: params["api_version"]} - ) - rescue Stripe::StripeError => e - status 402 - return log_info("Error creating ephemeral key: #{e.message}") - end +post '/webhook' do + payload = request.body.read + sig_header = request.env['HTTP_STRIPE_SIGNATURE'] + event = nil - content_type :json - status 200 - key.to_json -end - -def authenticate! - # This code simulates "loading the Stripe customer for your current session". - # Your own logic will likely look very different. - return @customer if @customer - if session.has_key?(:customer_id) - customer_id = session[:customer_id] begin - @customer = Stripe::Customer.retrieve(customer_id) - rescue Stripe::InvalidRequestError - end - else - default_customer_id = ENV['DEFAULT_CUSTOMER_ID'] - if default_customer_id - @customer = Stripe::Customer.retrieve(default_customer_id) - else - begin - @customer = create_customer() - - if (Stripe.api_key.start_with?('sk_test_')) - # only attach test cards in testmode - attach_customer_test_cards() - end - rescue Stripe::InvalidRequestError - end - end - session[:customer_id] = @customer.id - end - @customer -end - -def create_customer - Stripe::Customer.create( - :description => 'mobile SDK example customer', - :metadata => { - # Add our application's customer id for this Customer, so it'll be easier to look up - :my_customer_id => '72F8C533-FCD5-47A6-A45B-3956CA8C792D', - }, - ) -end - -def attach_customer_test_cards - # Attach some test cards to the customer for testing convenience. - # See https://stripe.com/docs/payments/3d-secure#three-ds-cards - # and https://stripe.com/docs/mobile/android/authentication#testing - ['4000000000003220', '4000000000003063', '4000000000003238', '4000000000003246', '4000000000003253', '4242424242424242'].each { |cc_number| - payment_method = Stripe::PaymentMethod.create({ - type: 'card', - card: { - number: cc_number, - exp_month: 8, - exp_year: 2022, - cvc: '123', - }, - }) - - Stripe::PaymentMethod.attach( - payment_method.id, - { - customer: @customer.id, - } - ) - } -end - -# This endpoint responds to webhooks sent by Stripe. To use it, you'll need -# to add its URL (https://{your-app-name}.herokuapp.com/stripe-webhook) -# in the webhook settings section of the Dashboard. -# https://dashboard.stripe.com/account/webhooks -# See https://stripe.com/docs/webhooks -post '/stripe-webhook' do - # Retrieving the event from Stripe guarantees its authenticity - payload = request.body.read - event = nil - - begin - event = Stripe::Event.construct_from( - JSON.parse(payload, symbolize_names: true) - ) - rescue JSON::ParserError => e - # Invalid payload - status 400 - return - end - - # Handle the event - case event.type - when 'source.chargeable' - # For sources that require additional user action from your customer - # (e.g. authorizing the payment with their bank), you should use webhooks - # to capture a PaymentIntent after the source becomes chargeable. - # For more information, see https://stripe.com/docs/sources#best-practices - source = event.data.object # contains a Stripe::Source - WEBHOOK_CHARGE_CREATION_TYPES = ['bancontact', 'giropay', 'ideal', 'sofort', 'three_d_secure', 'wechat'] - if WEBHOOK_CHARGE_CREATION_TYPES.include?(source.type) - begin - payment_intent = Stripe::PaymentIntent.create( - :amount => source.amount, - :currency => source.currency, - :source => source.id, - :payment_method_types => [source.type], - :description => "PaymentIntent for Source webhook", - :confirm => true, - :capture_method => ENV['CAPTURE_METHOD'] == "manual" ? "manual" : "automatic", + event = Stripe::Webhook.construct_event( + payload, sig_header, endpoint_secret ) - rescue Stripe::StripeError => e + rescue JSON::ParserError => e + # Invalid payload + status 400 + return + rescue Stripe::SignatureVerificationError => e + # Invalid signature status 400 - return log_info("Webhook: Error creating PaymentIntent: #{e.message}") - end - return log_info("Webhook: Created PaymentIntent for source: #{payment_intent.id}") + return end - when 'payment_intent.succeeded' - payment_intent = event.data.object # contains a Stripe::PaymentIntent - log_info("Webhook: PaymentIntent succeeded #{payment_intent.id}") - # Fulfill the customer's purchase, send an email, etc. - # When creating the PaymentIntent, consider storing any order - # information (e.g. order number) as metadata so that you can retrieve it - # here and use it to complete your customer's purchase. - when 'payment_intent.amount_capturable_updated' - # Capture the payment, then fulfill the customer's purchase like above. - payment_intent = event.data.object # contains a Stripe::PaymentIntent - log_info("Webhook: PaymentIntent succeeded #{payment_intent.id}") - else - # Unexpected event type - status 400 - return - end - status 200 -end - -# ==== SetupIntent -# See https://stripe.com/docs/payments/cards/saving-cards-without-payment - -# This endpoint is used by the mobile example apps to create a SetupIntent. -# https://stripe.com/docs/api/setup_intents/create -# A real implementation would include controls to prevent misuse -post '/create_setup_intent' do - payload = params - if request.content_type != nil and request.content_type.include? 'application/json' and params.empty? - payload = Sinatra::IndifferentHash[JSON.parse(request.body.read)] - end - begin - setup_intent = Stripe::SetupIntent.create({ - payment_method: payload[:payment_method], - return_url: payload[:return_url], - confirm: payload[:payment_method] != nil, - customer: payload[:customer_id], - use_stripe_sdk: payload[:payment_method] != nil ? true : nil, - payment_method_types: payment_methods_for_country(payload[:country]), - }) - rescue Stripe::StripeError => e - status 402 - return log_info("Error creating SetupIntent: #{e.message}") - end - - log_info("SetupIntent successfully created: #{setup_intent.id}") - status 200 - return { - :intent => setup_intent.id, - :secret => setup_intent.client_secret, - :status => setup_intent.status - }.to_json -end - -# ==== PaymentIntent Automatic Confirmation -# See https://stripe.com/docs/payments/payment-intents/ios - -# This endpoint is used by the mobile example apps to create a PaymentIntent -# https://stripe.com/docs/api/payment_intents/create -# A real implementation would include controls to prevent misuse -post '/create_payment_intent' do - authenticate! - payload = params - if request.content_type != nil and request.content_type.include? 'application/json' and params.empty? - payload = Sinatra::IndifferentHash[JSON.parse(request.body.read)] - end - - supported_payment_methods = payload[:supported_payment_methods] ? payload[:supported_payment_methods].split(",") : nil - - # Calculate how much to charge the customer - amount = calculate_price(payload[:products], payload[:shipping]) - - begin - payment_intent = Stripe::PaymentIntent.create( - :amount => amount, - :currency => currency_for_country(payload[:country]), - :customer => payload[:customer_id] || @customer.id, - :description => "Example PaymentIntent", - :capture_method => ENV['CAPTURE_METHOD'] == "manual" ? "manual" : "automatic", - payment_method_types: supported_payment_methods ? supported_payment_methods : payment_methods_for_country(payload[:country]), - :metadata => { - :order_id => '5278735C-1F40-407D-933A-286E463E72D8', - }.merge(payload[:metadata] || {}), - ) - rescue Stripe::StripeError => e - status 402 - return log_info("Error creating PaymentIntent: #{e.message}") - end - - log_info("PaymentIntent successfully created: #{payment_intent.id}") - status 200 - return { - :intent => payment_intent.id, - :secret => payment_intent.client_secret, - :status => payment_intent.status - }.to_json -end - -# ===== PaymentIntent Manual Confirmation -# See https://stripe.com/docs/payments/payment-intents/ios-manual - -# This endpoint is used by the mobile example apps to create and confirm a PaymentIntent -# using manual confirmation. -# https://stripe.com/docs/api/payment_intents/create -# https://stripe.com/docs/api/payment_intents/confirm -# A real implementation would include controls to prevent misuse -post '/confirm_payment_intent' do - authenticate! - payload = params - if request.content_type.include? 'application/json' and params.empty? - payload = Sinatra::IndifferentHash[JSON.parse(request.body.read)] - end - - begin - if payload[:payment_intent_id] - # Confirm the PaymentIntent - payment_intent = Stripe::PaymentIntent.confirm(payload[:payment_intent_id], {:use_stripe_sdk => true}) - elsif payload[:payment_method_id] - # Calculate how much to charge the customer - amount = calculate_price(payload[:products], payload[:shipping]) - - # Create and confirm the PaymentIntent - payment_intent = Stripe::PaymentIntent.create( - :amount => amount, - :currency => currency_for_country(payload[:country]), - :customer => payload[:customer_id] || @customer.id, - :source => payload[:source], - :payment_method => payload[:payment_method_id], - :payment_method_types => payment_methods_for_country(payload[:country]), - :description => "Example PaymentIntent", - :shipping => payload[:shipping], - :return_url => payload[:return_url], - :confirm => true, - :confirmation_method => "manual", - # Set use_stripe_sdk for mobile apps using Stripe iOS SDK v16.0.0+ or Stripe Android SDK v10.0.0+ - # Do not set this on apps using Stripe SDK versions below this. - :use_stripe_sdk => true, - :capture_method => ENV['CAPTURE_METHOD'] == "manual" ? "manual" : "automatic", - :metadata => { - :order_id => '5278735C-1F40-407D-933A-286E463E72D8', - }.merge(payload[:metadata] || {}), - ) + # Handle the event + case event.type + when 'account.updated' + account = event.data.object + when 'account.external_account.created' + external_account = event.data.object + when 'account.external_account.deleted' + external_account = event.data.object + when 'account.external_account.updated' + external_account = event.data.object + when 'balance.available' + balance = event.data.object + when 'billing_portal.configuration.created' + configuration = event.data.object + when 'billing_portal.configuration.updated' + configuration = event.data.object + when 'capability.updated' + capability = event.data.object + when 'charge.captured' + charge = event.data.object + when 'charge.expired' + charge = event.data.object + when 'charge.failed' + charge = event.data.object + when 'charge.pending' + charge = event.data.object + when 'charge.refunded' + charge = event.data.object + when 'charge.succeeded' + charge = event.data.object + when 'charge.updated' + charge = event.data.object + when 'charge.dispute.closed' + dispute = event.data.object + when 'charge.dispute.created' + dispute = event.data.object + when 'charge.dispute.funds_reinstated' + dispute = event.data.object + when 'charge.dispute.funds_withdrawn' + dispute = event.data.object + when 'charge.dispute.updated' + dispute = event.data.object + when 'charge.refund.updated' + refund = event.data.object + when 'checkout.session.async_payment_failed' + session = event.data.object + when 'checkout.session.async_payment_succeeded' + session = event.data.object + when 'checkout.session.completed' + session = event.data.object + when 'checkout.session.expired' + session = event.data.object + when 'coupon.created' + coupon = event.data.object + when 'coupon.deleted' + coupon = event.data.object + when 'coupon.updated' + coupon = event.data.object + when 'credit_note.created' + credit_note = event.data.object + when 'credit_note.updated' + credit_note = event.data.object + when 'credit_note.voided' + credit_note = event.data.object + when 'customer.created' + customer = event.data.object + when 'customer.deleted' + customer = event.data.object + when 'customer.updated' + customer = event.data.object + when 'customer.discount.created' + discount = event.data.object + when 'customer.discount.deleted' + discount = event.data.object + when 'customer.discount.updated' + discount = event.data.object + when 'customer.source.created' + source = event.data.object + when 'customer.source.deleted' + source = event.data.object + when 'customer.source.expiring' + source = event.data.object + when 'customer.source.updated' + source = event.data.object + when 'customer.subscription.created' + subscription = event.data.object + when 'customer.subscription.deleted' + subscription = event.data.object + when 'customer.subscription.pending_update_applied' + subscription = event.data.object + when 'customer.subscription.pending_update_expired' + subscription = event.data.object + when 'customer.subscription.trial_will_end' + subscription = event.data.object + when 'customer.subscription.updated' + subscription = event.data.object + when 'customer.tax_id.created' + tax_id = event.data.object + when 'customer.tax_id.deleted' + tax_id = event.data.object + when 'customer.tax_id.updated' + tax_id = event.data.object + when 'file.created' + file = event.data.object + when 'identity.verification_session.canceled' + verification_session = event.data.object + when 'identity.verification_session.created' + verification_session = event.data.object + when 'identity.verification_session.processing' + verification_session = event.data.object + when 'identity.verification_session.requires_input' + verification_session = event.data.object + when 'identity.verification_session.verified' + verification_session = event.data.object + when 'invoice.created' + invoice = event.data.object + when 'invoice.deleted' + invoice = event.data.object + when 'invoice.finalization_failed' + invoice = event.data.object + when 'invoice.finalized' + invoice = event.data.object + when 'invoice.marked_uncollectible' + invoice = event.data.object + when 'invoice.paid' + invoice = event.data.object + when 'invoice.payment_action_required' + invoice = event.data.object + when 'invoice.payment_failed' + invoice = event.data.object + when 'invoice.payment_succeeded' + invoice = event.data.object + when 'invoice.sent' + invoice = event.data.object + when 'invoice.upcoming' + invoice = event.data.object + when 'invoice.updated' + invoice = event.data.object + when 'invoice.voided' + invoice = event.data.object + when 'invoiceitem.created' + invoiceitem = event.data.object + when 'invoiceitem.deleted' + invoiceitem = event.data.object + when 'invoiceitem.updated' + invoiceitem = event.data.object + when 'issuing_authorization.created' + issuing_authorization = event.data.object + when 'issuing_authorization.updated' + issuing_authorization = event.data.object + when 'issuing_card.created' + issuing_card = event.data.object + when 'issuing_card.updated' + issuing_card = event.data.object + when 'issuing_cardholder.created' + issuing_cardholder = event.data.object + when 'issuing_cardholder.updated' + issuing_cardholder = event.data.object + when 'issuing_dispute.closed' + issuing_dispute = event.data.object + when 'issuing_dispute.created' + issuing_dispute = event.data.object + when 'issuing_dispute.funds_reinstated' + issuing_dispute = event.data.object + when 'issuing_dispute.submitted' + issuing_dispute = event.data.object + when 'issuing_dispute.updated' + issuing_dispute = event.data.object + when 'issuing_transaction.created' + issuing_transaction = event.data.object + when 'issuing_transaction.updated' + issuing_transaction = event.data.object + when 'mandate.updated' + mandate = event.data.object + when 'order.created' + order = event.data.object + when 'order.payment_failed' + order = event.data.object + when 'order.payment_succeeded' + order = event.data.object + when 'order.updated' + order = event.data.object + when 'order_return.created' + order_return = event.data.object + when 'payment_intent.amount_capturable_updated' + payment_intent = event.data.object + when 'payment_intent.canceled' + payment_intent = event.data.object + when 'payment_intent.created' + payment_intent = event.data.object + when 'payment_intent.partially_funded' + payment_intent = event.data.object + when 'payment_intent.payment_failed' + payment_intent = event.data.object + when 'payment_intent.processing' + payment_intent = event.data.object + when 'payment_intent.requires_action' + payment_intent = event.data.object + when 'payment_intent.succeeded' + payment_intent = event.data.object + when 'payment_link.created' + payment_link = event.data.object + when 'payment_link.updated' + payment_link = event.data.object + when 'payment_method.attached' + payment_method = event.data.object + when 'payment_method.automatically_updated' + payment_method = event.data.object + when 'payment_method.detached' + payment_method = event.data.object + when 'payment_method.updated' + payment_method = event.data.object + when 'payout.canceled' + payout = event.data.object + when 'payout.created' + payout = event.data.object + when 'payout.failed' + payout = event.data.object + when 'payout.paid' + payout = event.data.object + when 'payout.updated' + payout = event.data.object + when 'person.created' + person = event.data.object + when 'person.deleted' + person = event.data.object + when 'person.updated' + person = event.data.object + when 'plan.created' + plan = event.data.object + when 'plan.deleted' + plan = event.data.object + when 'plan.updated' + plan = event.data.object + when 'price.created' + price = event.data.object + when 'price.deleted' + price = event.data.object + when 'price.updated' + price = event.data.object + when 'product.created' + product = event.data.object + when 'product.deleted' + product = event.data.object + when 'product.updated' + product = event.data.object + when 'promotion_code.created' + promotion_code = event.data.object + when 'promotion_code.updated' + promotion_code = event.data.object + when 'quote.accepted' + quote = event.data.object + when 'quote.canceled' + quote = event.data.object + when 'quote.created' + quote = event.data.object + when 'quote.finalized' + quote = event.data.object + when 'radar.early_fraud_warning.created' + early_fraud_warning = event.data.object + when 'radar.early_fraud_warning.updated' + early_fraud_warning = event.data.object + when 'recipient.created' + recipient = event.data.object + when 'recipient.deleted' + recipient = event.data.object + when 'recipient.updated' + recipient = event.data.object + when 'reporting.report_run.failed' + report_run = event.data.object + when 'reporting.report_run.succeeded' + report_run = event.data.object + when 'review.closed' + review = event.data.object + when 'review.opened' + review = event.data.object + when 'setup_intent.canceled' + setup_intent = event.data.object + when 'setup_intent.created' + setup_intent = event.data.object + when 'setup_intent.requires_action' + setup_intent = event.data.object + when 'setup_intent.setup_failed' + setup_intent = event.data.object + when 'setup_intent.succeeded' + setup_intent = event.data.object + when 'sigma.scheduled_query_run.created' + scheduled_query_run = event.data.object + when 'sku.created' + sku = event.data.object + when 'sku.deleted' + sku = event.data.object + when 'sku.updated' + sku = event.data.object + when 'source.canceled' + source = event.data.object + when 'source.chargeable' + source = event.data.object + when 'source.failed' + source = event.data.object + when 'source.mandate_notification' + source = event.data.object + when 'source.refund_attributes_required' + source = event.data.object + when 'source.transaction.created' + transaction = event.data.object + when 'source.transaction.updated' + transaction = event.data.object + when 'subscription_schedule.aborted' + subscription_schedule = event.data.object + when 'subscription_schedule.canceled' + subscription_schedule = event.data.object + when 'subscription_schedule.completed' + subscription_schedule = event.data.object + when 'subscription_schedule.created' + subscription_schedule = event.data.object + when 'subscription_schedule.expiring' + subscription_schedule = event.data.object + when 'subscription_schedule.released' + subscription_schedule = event.data.object + when 'subscription_schedule.updated' + subscription_schedule = event.data.object + when 'tax_rate.created' + tax_rate = event.data.object + when 'tax_rate.updated' + tax_rate = event.data.object + when 'terminal.reader.action_failed' + reader = event.data.object + when 'terminal.reader.action_succeeded' + reader = event.data.object + when 'test_helpers.test_clock.advancing' + test_clock = event.data.object + when 'test_helpers.test_clock.created' + test_clock = event.data.object + when 'test_helpers.test_clock.deleted' + test_clock = event.data.object + when 'test_helpers.test_clock.internal_failure' + test_clock = event.data.object + when 'test_helpers.test_clock.ready' + test_clock = event.data.object + when 'topup.canceled' + topup = event.data.object + when 'topup.created' + topup = event.data.object + when 'topup.failed' + topup = event.data.object + when 'topup.reversed' + topup = event.data.object + when 'topup.succeeded' + topup = event.data.object + when 'transfer.created' + transfer = event.data.object + when 'transfer.failed' + transfer = event.data.object + when 'transfer.paid' + transfer = event.data.object + when 'transfer.reversed' + transfer = event.data.object + when 'transfer.updated' + transfer = event.data.object + # ... handle other event types else - status 400 - return log_info("Error: Missing params. Pass payment_intent_id to confirm or payment_method to create") - end - rescue Stripe::StripeError => e - status 402 - return log_info("Error: #{e.message}") - end - - return generate_payment_response(payment_intent) -end - -def generate_payment_response(payment_intent) - # Note that if your API version is before 2019-02-11, 'requires_action' - # appears as 'requires_source_action'. - if payment_intent.status == 'requires_action' - # Tell the client to handle the action - status 200 - return { - requires_action: true, - secret: payment_intent.client_secret - }.to_json - elsif payment_intent.status == 'succeeded' or - (payment_intent.status == 'requires_capture' and ENV['CAPTURE_METHOD'] == "manual") - # The payment didn’t need any additional actions and is completed! - # Handle post-payment fulfillment - status 200 - return { - :success => true - }.to_json - else - # Invalid status - status 500 - return "Invalid PaymentIntent status" - end -end - -# ===== Helpers - -# Our example apps sell emoji apparel; this hash lets us calculate the total amount to charge. -EMOJI_STORE = { - "👕" => 2000, - "👖" => 4000, - "👗" => 3000, - "👞" => 700, - "👟" => 600, - "👠" => 1000, - "👡" => 2000, - "👢" => 2500, - "👒" => 800, - "👙" => 3000, - "💄" => 2000, - "🎩" => 5000, - "👛" => 5500, - "👜" => 6000, - "🕶" => 2000, - "👚" => 2500, -} - -def price_lookup(product) - price = EMOJI_STORE[product] - raise "Can't find price for %s (%s)" % [product, product.ord.to_s(16)] if price.nil? - return price -end - -def calculate_price(products, shipping) - amount = 1099 # Default amount. - - if products - amount = products.reduce(0) { | sum, product | sum + price_lookup(product) } - end - - if shipping - case shipping - when "fedex" - amount = amount + 599 - when "fedex_world" - amount = amount + 2099 - when "ups_worldwide" - amount = amount + 1099 + puts "Unhandled event type: #{event.type}" end - end - - return amount -end - -def currency_for_country(country) - # Determine currency to use. Generally a store would charge different prices for - # different countries, but for the sake of simplicity we'll charge X of the local currency. - case country - when 'us' - 'usd' - when 'mx' - 'mxn' - when 'my' - 'myr' - when 'at', 'be', 'de', 'es', 'it', 'nl', 'pl' - 'eur' - when 'au' - 'aud' - when 'gb' - 'gbp' - when 'in' - 'inr' - else - 'usd' - end -end - -def payment_methods_for_country(country) - case country - when 'us' - %w[card] - when 'mx' - %w[card oxxo] - when 'my' - %w[card fpx grabpay] - when 'nl' - %w[card ideal sepa_debit sofort] - when 'au' - %w[card au_becs_debit] - when 'gb' - %w[card paypal bacs_debit] - when 'es', 'it' - %w[card paypal sofort] - when 'pl' - %w[card paypal p24] - when 'be' - %w[card paypal sofort bancontact] - when 'de' - %w[card paypal sofort giropay] - when 'at' - %w[card paypal sofort eps] - when 'sg' - %w[card alipay grabpay] - when 'in' - %w[card upi netbanking] - else - %w[card] - end -end + status 200 +end \ No newline at end of file