From 2d0df99e34d447431454a13cd5492ba056b58827 Mon Sep 17 00:00:00 2001 From: Mike Shaw <44751023+mikeshaw-stripe@users.noreply.github.com> Date: Wed, 3 Apr 2019 12:29:17 +0100 Subject: [PATCH] Add Ruby Sinatra server (#54) * Adding a ruby backend * Updating README * Fix shipping_change endpoint. * Fixing nits, README and removing API Version hack * Load skus separately. * Update main README. --- README.md | 8 +- public/javascripts/store.js | 19 ++- server/ruby/GemFile | 8 ++ server/ruby/Gemfile.lock | 54 +++++++++ server/ruby/README.md | 64 ++++++++++ server/ruby/app.rb | 226 ++++++++++++++++++++++++++++++++++++ server/ruby/inventory.rb | 50 ++++++++ server/ruby/setup.rb | 84 ++++++++++++++ 8 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 server/ruby/GemFile create mode 100644 server/ruby/Gemfile.lock create mode 100644 server/ruby/README.md create mode 100644 server/ruby/app.rb create mode 100644 server/ruby/inventory.rb create mode 100644 server/ruby/setup.rb diff --git a/README.md b/README.md index a1d9a323..20641e0c 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,13 @@ The [Sources API](https://stripe.com/docs/sources) provides a single integration ## Getting Started with Node -There are a couple server implementations in the [`server`](/server) directory. Instructions for running the Node.js server in [`server/node`](/server/node) are below, but if you’re more comfortable with Python you can find a README explaining how to run a Flask server in the [`server/python`](/server/python) directory. Both servers have the same endpoints to handle requests from the frontend and interact with the [Stripe libraries](https://stripe.com/docs/libraries). +Instructions for running the Node.js server in [`server/node`](/server/node) are below. You can find alternative server implementations in the [`server`](/server) directory: + +- Node, Express: [`server/node`](/server/node) +- Python, Flask: [`server/python`](/server/python) +- Ruby, Sinatra: [`server/ruby`](/server/ruby) + +All servers have the same endpoints to handle requests from the frontend and interact with the [Stripe libraries](https://stripe.com/docs/libraries). ### Requirements diff --git a/public/javascripts/store.js b/public/javascripts/store.js index 54f98a7f..a548e93e 100644 --- a/public/javascripts/store.js +++ b/public/javascripts/store.js @@ -54,13 +54,30 @@ class Store { } } + // Retrieve a SKU for the Product where the API Version is newer and doesn't include them on v1/product + async loadSkus(product_id) { + try { + const response = await fetch(`/product/${product_id}/skus`); + const skus = await response.json(); + this.products[product_id].skus = skus; + } catch (err) { + return {error: err.message}; + } + } + // Load the product details. loadProducts() { if (!this.productsFetchPromise) { this.productsFetchPromise = new Promise(async resolve => { const productsResponse = await fetch('/products'); const products = (await productsResponse.json()).data; - products.forEach(product => (this.products[product.id] = product)); + // Check if we have SKUs on the product, otherwise load them separately. + for (const product of products) { + this.products[product.id] = product; + if (!product.skus) { + await this.loadSkus(product.id); + } + } resolve(); }); } diff --git a/server/ruby/GemFile b/server/ruby/GemFile new file mode 100644 index 00000000..0d567064 --- /dev/null +++ b/server/ruby/GemFile @@ -0,0 +1,8 @@ +source 'https://rubygems.org/' + +gem 'sinatra' +gem 'sinatra-reloader' +gem 'stripe' +gem 'dotenv' +gem 'json' +gem 'ruby-debug-ide' diff --git a/server/ruby/Gemfile.lock b/server/ruby/Gemfile.lock new file mode 100644 index 00000000..4ca9b117 --- /dev/null +++ b/server/ruby/Gemfile.lock @@ -0,0 +1,54 @@ +GEM + remote: https://rubygems.org/ + specs: + backports (3.12.0) + connection_pool (2.2.2) + dotenv (2.7.1) + faraday (0.15.4) + multipart-post (>= 1.2, < 3) + json (2.2.0) + multi_json (1.13.1) + multipart-post (2.0.0) + mustermann (1.0.3) + net-http-persistent (3.0.0) + connection_pool (~> 2.2) + rack (2.0.6) + rack-protection (2.0.5) + rack + rake (12.3.2) + ruby-debug-ide (0.6.1) + rake (>= 0.8.1) + sinatra (2.0.5) + mustermann (~> 1.0) + rack (~> 2.0) + rack-protection (= 2.0.5) + tilt (~> 2.0) + sinatra-contrib (2.0.5) + backports (>= 2.8.2) + multi_json + mustermann (~> 1.0) + rack-protection (= 2.0.5) + sinatra (= 2.0.5) + tilt (>= 1.3, < 3) + sinatra-reloader (1.0) + sinatra-contrib + sorbet (0.0.1.pre.prealpha) + stripe (4.9.0) + faraday (~> 0.13) + net-http-persistent (~> 3.0) + tilt (2.0.9) + +PLATFORMS + ruby + +DEPENDENCIES + dotenv + json + ruby-debug-ide + sinatra + sinatra-reloader + sorbet (~> 0.0.1.pre.prealpha) + stripe + +BUNDLED WITH + 2.0.1 diff --git a/server/ruby/README.md b/server/ruby/README.md new file mode 100644 index 00000000..9cebc584 --- /dev/null +++ b/server/ruby/README.md @@ -0,0 +1,64 @@ +# Stripe Payments Demo - Ruby Server + +This demo uses a simple [Sinatra](http://sinatrarb.com/) application as the server. + +## Payments Integration + +- [`app.rb`](app.rb) contains the routes that interface with Stripe to create charges and receive webhook events. +- [`setup.rb`](setup.rb) a simple setup script to make some fake Products and SKUs for our Stripe store. +- [`inventory.rb`](inventory.rb) a minimal wrapper over the Stripe Python SDK that handles creating/fetching products and caluclating payment amounts from SKUs. You can override this class with your own product and order management system code. + +## Requirements + +You’ll new the following: + +- [Ruby 2.X](https://www.ruby-lang.org/en/downloads/) +- Modern browser that supports ES6 (Chrome to see the Payment Request, and Safari to see Apple Pay). +- Stripe account to accept payments ([sign up](https://dashboard.stripe.com/register) for free!) + +## Getting Started + +Before getting started check that you have ruby installed + +``` +ruby --version +``` + +Copy the example environment variables file `.env.example` from the root of the repo into your own environment file called `.env`: + +``` +cp .env.example .env +``` + +User `bundler` to install the required gems by navigating to ./server/ruby and running: + +``` +bundle install +``` + +Run the Sinatra application + +``` +bundle exec ruby app.rb +``` + +You should now see it running on [`http://localhost:4567/`](http://localhost:4567/) + +### Testing Webhooks + +If you want to test [receiving webhooks](https://stripe.com/docs/webhooks), we recommend using ngrok to expose your local server. + +First [download ngrok](https://ngrok.com) and start your Sinatra application. + +[Run ngrok](https://ngrok.com/docs). Assuming your Sinatra application is running on the default port 4567, you can simply run ngrok in your Terminal in the directory where you downloaded ngrok: + +``` +ngrok http 4567 +``` + +ngrok will display a UI in your terminal telling you the new forwarding address for your Sinatra app. Use this URL as the URL to be called in your developer [webhooks panel.](https://dashboard.stripe.com/account/webhooks) + +Don't forget to append `/webhook` when you set up your Stripe webhook URL in the Dashboard. Example URL to be called: `https://75795038.ngrok.io/webhook`. + +## Credits +- Code: [Mike Shaw](https://www.linkedin.com/in/mandshaw/) \ No newline at end of file diff --git a/server/ruby/app.rb b/server/ruby/app.rb new file mode 100644 index 00000000..4e8dc6ff --- /dev/null +++ b/server/ruby/app.rb @@ -0,0 +1,226 @@ +require 'stripe' +require 'sinatra' +require 'sinatra/cookies' +# require 'sinatra/reloader' +require 'dotenv' +require 'json' +require_relative 'inventory' +require_relative 'setup' + +Dotenv.load(File.dirname(__FILE__) + '/../../.env') +Stripe.api_key = ENV['STRIPE_SECRET_KEY'] +Stripe.api_version = '2019-02-11' + +set :static, true +set :root, File.dirname(__FILE__) +set :public_folder, Dir.chdir(Dir.pwd + '/../../public') + + +get '/javascripts/:path' do + content_type 'text/javascript' + send_file "javascripts/#{params['path']}" +end + +get '/stylesheets/:path' do + content_type 'text/css' + send_file "stylesheets/#{params['path']}" +end + +get '/images/*.*' do |path, ext| + if ext == "svg" + content_type "image/#{ext}+xml" + else + content_type "image/#{ext}" + end + send_file "images/#{path}.#{ext}" +end + +get '/' do + # Route to the index page which will show our cart + content_type 'text/html' + send_file 'index.html' +end + +get '/config' do + # Route to return configurations details required by the frontend + content_type 'application/json' + { + 'stripePublishableKey': ENV['STRIPE_PUBLISHABLE_KEY'], + 'stripeCountry': ENV['STRIPE_ACCOUNT_COUNTRY'] || 'US', + 'country': 'US', + 'currency': 'eur', + 'paymentMethods': ENV['PAYMENT_METHODS'] ? ENV['PAYMENT_METHODS'].split(', ') : ['card'], + 'shippingOptions': [ + { + 'id': 'free', + 'label': 'Free Shipping', + 'detail': 'Delivery within 5 days', + 'amount': 0, + }, + { + 'id': 'express', + 'label': 'Express Shipping', + 'detail': 'Next day delivery', + 'amount': 500, + } + ] + }.to_json +end + +get '/products' do + content_type 'application/json' + products = Inventory.list_products + if Inventory.products_exist(products) + products.to_json + else + # Setup products + puts "Needs to setup products" + create_data + products = Inventory.list_products + products.to_json + end +end + +get '/product/:product_id/skus' do + content_type 'application/json' + skus = Inventory.list_skus(params['product_id']) + skus.to_json +end + +get '/products/:product_id' do + content_type 'application/json' + product = Inventory.retrieve_product(params['product_id']) + product.to_json +end + +post '/payment_intents' do + content_type 'application/json' + data = JSON.parse request.body.read + + payment_intent = Stripe::PaymentIntent.create( + amount: Inventory.calculate_payment_amount(data['items']), + currency: data['currency'], + payment_method_types: ENV['PAYMENT_METHODS'] ? ENV['PAYMENT_METHODS'].split(', ') : ['card'] + ) + + { + paymentIntent: payment_intent + }.to_json +end + +post '/payment_intents/:id/shipping_change' do + content_type 'application/json' + data = JSON.parse request.body.read + + amount = Inventory.calculate_payment_amount(data['items']) + amount += Inventory.get_shipping_cost(data['shippingOption']['id']) + + payment_intent = Stripe::PaymentIntent.update( + params['id'], + { + amount: amount + } + ) + + { + paymentIntent: payment_intent + }.to_json +end + +post '/webhook' do + # You can use webhooks to receive information about asynchronous payment events. + # For more about our webhook events check out https://stripe.com/docs/webhooks. + webhook_secret = ENV['STRIP_WEBHOOK_SECRET'] + request_data = JSON.parse request.body.read + + if webhook_secret + # Retrieve the event by verifying the signature using the raw body and secret if webhook signing is configured. + sig_header = request.env['HTTP_STRIPE_SIGNATURE'] + + begin + event = Stripe::Webhook.construct_event( + payload, sig_header, endpoint_secret + ) + rescue JSON::ParserError => e + # Invalid payload + status 400 + return + rescue Stripe::SignatureVerificationError => e + # Invalid signature + status 400 + return + end + # Get the type of webhook event sent - used to check the status of PaymentIntents. + event_type = event['type'] + else + data = request_data['data'] + event_type = request_data['type'] + end + + data_object = data['object'] + + # PaymentIntent Beta, see https://stripe.com/docs/payments/payment-intents + # Monitor payment_intent.succeeded & payment_intent.payment_failed events. + if data_object['object'] == 'payment_intent' + payment_intent = data_object + + if event_type == 'payment_intent.succeeded' + puts "🔔 Webhook received! Payment for PaymentIntent #{payment_intent['id']} succeeded" + elsif event_type == 'payment_intent.payment_failed' + puts "🔔 Webhook received! Payment on source #{payment_intent['last_payment_error']['source']['id']} for PaymentIntent #{payment_intent['id']} failed." + end + + # Monitor `source.chargeable` events. + elsif data_object['object'] == 'source' && data_object['status'] == 'chargeable' && data_object['metadata'].include?('paymentIntent') + source = data_object + puts "🔔 Webhook received! The source #{source['id']} is chargeable" + + # Find the corresponding PaymentIntent this Source is for by looking in its metadata. + payment_intent = Stripe::PaymentIntent.retrieve( + source['metadata']['paymentIntent'] + ) + + # Verify that this PaymentIntent actually needs to be paid. + if payment_intent['status'] != 'requires_payment_method' + status 403 + { + error: "PaymentIntent already has a status of #{payment_intent['status']}" + }.to_json + end + + # Confirm the PaymentIntent with the chargeable source. + payment_intent.confirm( + { + source: source['id'] + } + ) + + # Monitor `source.failed` and `source.canceled` events. + elsif data_object['object'] == 'source' && ['failed', 'canceled'].include?(data_object['status']) + # Cancel the PaymentIntent. + source = data_object + intent = Stripe::PaymentIntent.retrieve( + source['metadata']['paymentIntent'] + ) + intent.cancel + end + + content_type 'application/json' + { + status: 'success' + }.to_json + +end + +get '/payment_intents/:id/status' do + payment_intent = Stripe::PaymentIntent.retrieve( + params['id'] + ) + + content_type 'application/json' + { + paymentIntent: { + status: payment_intent['status'] + } + }.to_json +end \ No newline at end of file diff --git a/server/ruby/inventory.rb b/server/ruby/inventory.rb new file mode 100644 index 00000000..c07b6cec --- /dev/null +++ b/server/ruby/inventory.rb @@ -0,0 +1,50 @@ +require 'stripe' +require 'dotenv' + +Dotenv.load(File.dirname(__FILE__) + '/../../.env') + +Stripe.api_key = ENV['STRIPE_SECRET_KEY'] +Stripe.api_version = '2019-02-11' + +class Inventory + + def self.calculate_payment_amount(items) + total = 0 + items.each do |item| + sku = Stripe::SKU.retrieve(item['parent']) + total += sku.price * item['quantity'] + end + total + end + + def self.get_shipping_cost(id) + shipping_cost = { + free: 0, + express: 500, + } + shipping_cost[id.to_sym] + end + + def self.list_products + Stripe::Product.list(limit: 3) + end + + def self.list_skus(product_id) + Stripe::SKU.list( + limit: 1, + product: product_id + ) + end + + def self.retrieve_product(product_id) + Stripe::Product.retrieve(product_id) + end + + def self.products_exist(product_list) + valid_products = ['increment', 'shirt', 'pins'] + product_list_data = product_list['data'] + products_present = product_list_data.map {|product| product['id']} + + product_list_data.length == 3 && products_present & valid_products == products_present + end +end \ No newline at end of file diff --git a/server/ruby/setup.rb b/server/ruby/setup.rb new file mode 100644 index 00000000..d3f41fb4 --- /dev/null +++ b/server/ruby/setup.rb @@ -0,0 +1,84 @@ +require 'stripe' +require 'dotenv' + +Dotenv.load(File.dirname(__FILE__) + '/../../.env') + +Stripe.api_key = ENV['STRIPE_SECRET_KEY'] +Stripe.api_version = '2019-02-11' + +def create_data + begin + products = [ + { + id: 'increment', + type: 'good', + name: 'Increment Magazine', + attributes: ['issue'] + }, + { + id: 'pins', + type: 'good', + name: 'Stripe Pins', + attributes: ['set'] + }, + { + id: 'shirt', + type: 'good', + name: 'Stripe Shirt', + attributes: ['size', 'gender'] + } + ] + + products.each do |product| + Stripe::Product.create(product) + end + + skus = [ + { + id: 'increment-03', + product: 'increment', + attributes: { + issue: 'Issue #3 “Development”' + }, + price: 399, + currency: 'usd', + inventory: { + type: 'infinite' + } + }, + { + id: 'shirt-small-woman', + product: 'shirt', + attributes: { + size: 'Small Standard', + gender: 'Woman' + }, + price: 999, + currency: 'usd', + inventory: { + type: 'infinite' + } + }, + { + id: 'pins-collector', + product: 'pins', + attributes: { + set: 'Collector Set' + }, + price: 799, + currency: 'usd', + inventory: { + type: 'finite', + quantity: 500 + } + } + ] + + skus.each do |sku| + Stripe::SKU.create(sku) + end + + rescue Stripe::InvalidRequestError => e + puts "Products already exist, #{e}" + end +end \ No newline at end of file