diff --git a/.github/workflows/fly.yml b/.github/workflows/fly.yml index 767be06..915cafb 100644 --- a/.github/workflows/fly.yml +++ b/.github/workflows/fly.yml @@ -1,15 +1,18 @@ -name: Fly Deploy +name: Deploy to Fly.io + on: -push: -branches: - - main + push: + branches: + - main + env: -FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + jobs: -deploy: - name: Deploy app - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: superfly/flyctl-actions/setup-flyctl@master - - run: flyctl deploy --remote-only + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: flyctl deploy --remote-only diff --git a/.ruby-version b/.ruby-version index c0013a8..dcb7d80 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-2.7.3 +ruby-3.2 diff --git a/Gemfile b/Gemfile index 9cc39c9..45c4c0b 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source 'https://rubygems.org' -ruby '2.7.3' +ruby '3.2' gem 'rails', '~> 6.1.6' gem 'pg', '~>1.5.0' @@ -16,6 +16,7 @@ gem 'sass-rails', '~> 5.0' gem 'uglifier', '>= 1.3.0' gem 'bootstrap_form', "~>4.0.0" gem 'local_time', '~>2.1.0' +gem 'stripe', '~>10.1.0' # API gem 'jbuilder', '~> 2.6' @@ -37,7 +38,7 @@ gem 'bcrypt', '~> 3.1.12' gem 'strip_attributes', '~>1.8.0' # gem install nokogiri -v '1.8.4' -- --use-system-libraries --with-xml2-include=/usr/local/opt/libxml2/include/libxml2 -gem 'nokogiri', '~> 1.13.6' +gem 'nokogiri', '~> 1.15.4' gem 'pr_geohash', '~>1.0.0' diff --git a/Gemfile.lock b/Gemfile.lock index 929eeb9..9313b1e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -166,8 +166,8 @@ GEM net-protocol netrc (0.11.0) nio4r (2.5.9) - nokogiri (1.13.10) - mini_portile2 (~> 2.8.0) + nokogiri (1.15.4) + mini_portile2 (~> 2.8.2) racc (~> 1.4) pg (1.5.4) pr_geohash (1.0.0) @@ -277,6 +277,7 @@ GEM sprockets (>= 3.0.0) strip_attributes (1.8.1) activemodel (>= 3.0, < 7.0) + stripe (10.1.0) temple (0.10.3) thor (1.3.0) tilt (2.3.0) @@ -321,7 +322,7 @@ DEPENDENCIES listen (~> 3.0.5) local_time (~> 2.1.0) mailgun-ruby (~> 1.1.10) - nokogiri (~> 1.13.6) + nokogiri (~> 1.15.4) pg (~> 1.5.0) pr_geohash (~> 1.0.0) puma (~> 5.6.4) @@ -339,6 +340,7 @@ DEPENDENCIES spring spring-watcher-listen (~> 2.0.0) strip_attributes (~> 1.8.0) + stripe (~> 10.1.0) uglifier (>= 1.3.0) varint (~> 0.1.1) vcr (~> 4.0.0) @@ -346,7 +348,7 @@ DEPENDENCIES webmock (~> 3.13.0) RUBY VERSION - ruby 2.7.3p183 + ruby 3.2.0p0 BUNDLED WITH 2.1.4 diff --git a/Procfile.dev b/Procfile.dev index 93c3a38..dd9b2f6 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1 +1,2 @@ -web: ./bin/rails server \ No newline at end of file +web: bundle exec rails server -p 3000 +worker: bundle exec sidekiq -t 25 -C config/sidekiq.yml \ No newline at end of file diff --git a/app/controllers/api/v1/payment_intents_controller.rb b/app/controllers/api/v1/payment_intents_controller.rb new file mode 100644 index 0000000..467060f --- /dev/null +++ b/app/controllers/api/v1/payment_intents_controller.rb @@ -0,0 +1,19 @@ +class Api::V1::PaymentIntentsController < Api::V1::ApiController + skip_before_action :load_region + + def create + if params[:donation_frequency] == 'recurring' + @recurring_response = Donations::Recurring.new(params[:donation_amount_in_cents]).run + @error = @recurring_response.error + else + @intent, @error = Donations::OneTime.new(params[:donation_amount_in_cents]).run + end + + if @error + logger.error(@error.message) + head :internal_server_error + else + respond_to {|format| format.json} + end + end +end diff --git a/app/lib/donations/one_time.rb b/app/lib/donations/one_time.rb new file mode 100644 index 0000000..debd853 --- /dev/null +++ b/app/lib/donations/one_time.rb @@ -0,0 +1,28 @@ +module Donations + class OneTime + def initialize(donation_amount_in_cents) + @donation_amount_in_cents = donation_amount_in_cents + end + + def run + intent, error = [nil, nil] + + begin + intent = Stripe::PaymentIntent.create( + { + amount: @donation_amount_in_cents, + currency: 'usd', + # In the latest version of the API, specifying the + # `automatic_payment_methods` parameter is optional + # because Stripe enables its functionality by default. + automatic_payment_methods: { enabled: true }, + } + ) + rescue Stripe::StripeError => e + error = e + end + + [intent, error] + end + end +end \ No newline at end of file diff --git a/app/lib/donations/recurring.rb b/app/lib/donations/recurring.rb new file mode 100644 index 0000000..6c300da --- /dev/null +++ b/app/lib/donations/recurring.rb @@ -0,0 +1,57 @@ + +module Donations + class Recurring + def initialize(donation_amount_in_cents) + @donation_amount_in_cents = donation_amount_in_cents + end + + def run + recurring_response = RecurringResponse.new(@donation_amount_in_cents) + + begin + customer = Stripe::Customer.create() + recurring_response.customer_id = customer.id + + recurring_response.ephemeral_key = Stripe::EphemeralKey.create( + {customer: recurring_response.customer_id}, + {stripe_version: '2023-08-16'} + ) + recurring_response.subscription = create_subscription(recurring_response.customer_id) + rescue Stripe::StripeError => e + recurring_response.error = e + end + + recurring_response + end + + private + + def create_subscription(customer_id) + # Create a new price for the custom donation amount + price = Stripe::Price.create( + unit_amount: @donation_amount_in_cents, + currency: 'usd', + recurring: { interval: 'month' }, + product: $stripe_recurring_donation_product_id, + ) + + # Create or update the subscription with the new price + subscription = Stripe::Subscription.create( + customer: customer_id, + items: [{ price: price.id }], + payment_behavior: 'default_incomplete', + expand: ['latest_invoice.payment_intent'], + ) + + subscription + end + end + + class RecurringResponse + attr_accessor :donation_amount_in_cents, :subscription, :ephemeral_key, :error, :customer_id + + def initialize(donation_amount_in_cents) + @donation_amount_in_cents = donation_amount_in_cents + end + end +end \ No newline at end of file diff --git a/app/views/api/v1/payment_intents/create.json.jbuilder b/app/views/api/v1/payment_intents/create.json.jbuilder new file mode 100644 index 0000000..bc7ffda --- /dev/null +++ b/app/views/api/v1/payment_intents/create.json.jbuilder @@ -0,0 +1,9 @@ +if @recurring_response + json.client_secret(@recurring_response.subscription.latest_invoice.payment_intent.client_secret) + json.customer_id(@recurring_response.customer_id) + json.ephemeral_key(@recurring_response.ephemeral_key.secret) +else + json.client_secret(@intent.client_secret) +end + +json.id(SecureRandom.uuid) \ No newline at end of file diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 83af4c1..521fbc2 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -MJwZKzEdAdLIxRzkIFbpFmuq/5oDLNqTtY+tYmjCvqxSBiO2TDQirH7As1nZ/ZAMAvyKzMI8134OtGvdq+Lem03Eqo74qNfJlKb6GUIqXbPRx4UaLEmSVSRaKQt5Uk3GUT+4f/SGh9ytlKGbKXWffEZGBfygMYMb+Vm301GMbLLOwusfF1p24luvBZC3dhrpUbsdaBLpZ3yJ6lnpZ9aP+A36nwwnbaeSAomlzvgKLPLyOGpx7XwevkhWxCNVoNJSBJTPlEeXXYVwR+tO9N9GqTotKAzcgY2On/HGkO/CY2qaul9aVxPGbxeS68CtEA8n3dj6OXv4TtykMgWCo5PxIYZempadDfT56lIyhZ/l4huhihElUYHSsdD7ztBGFAzsmOC1ncS35foYHF+JOlWbcVdqz8XFaZ1f+lWTyWwtIPPw/QsFUmEU8UKtzhrXt4+kyKF/WDIENa5AMMzSp52fB5qI8FqYKq/rkA/tGw4p4d1q0THxTaGMzqiAtQjZ5H33fMIeu1gYQ/uxlQ5aOkUZNTU2PcDTM+Kz6ezKbpYRGgYmeXrAqkIJQ/Jth1uXXON2P+5OitgZc58oYis6JLJdVrrDoKuVhq1K4jujQopuLZMsaH/dbCbJwkniEs8SaA5/Iiz6lnS3sDZgo/ZYA9wjZw/rUrjsGtOc9w==--DkBThg2KX7o0BrCe--yvrCQA4Puf8wp4WLCcNpuQ== \ No newline at end of file +iK+va/I+Cg4UlUl1yXabH1CxYW4BuKuwDqBbtZEK0cAu9FuAik8b/sE+GsVcISaW6xjb19XeOWwgPZbx++Vfl8yNWRCpURkgSPyCmTa4Vqr8tif5jU0Osn9ttlLhrvtoqfDG0acQbbV0h8uiwkLTBGyULLzyQp/6ihdxNS79STAB7VEcBHlNHF/SqIMWPm+X6Y40bL8TtWDs8xXuF5qYnroTjffDqJBRngmPsSiFfddMw8jrZrsIgCYxwHeag/L2XKspeH9swMK4EyMmNs+TkxuY+i6TCQMDscIrgcg1nlHsV3nftHPSGW1sWVDdzhHkv/WJmC5DY483uGdnlrrmcMevIEDtz/lLySurKZjko51xcNxxJ4+tdTjg0wgY/134CqigEgbRpxNALUqLoPj8ABoAZO51njopDs9QAfvJD1fI3aZyZRcqEeRhKxWCSAGI6t4e2wSXL7K4F+aAO3fGYxrZt1Y71y9MGyQNdp3rSOsQChtdw1QV5BzUsCCdqFZzV6u+5gY0x2KfLnEu0WpYAEgvOBHCeWe7HOIXar5HTCP42jWg0PUxbLDpnKvLnl4HXcrlvFWo9OmBaw+1HUJcLdWqSEqVnh+l+T9/JQnXUVnpTz2Enus9JLntU8nISyOqHTOgGoSQqlXQijiB95SAf4Nu+gF9NKZiUkg/9wRr8w5zN6mgvSntH4vn2g+pWAuJEeD2Oop+j57j1atoXKFqIHf77FOSP9y0M5COw3IzOnRqZ/9hP4+FGwFB7xWqyd0VXHaIltzlIOJAC+Fm0q1X+P+pj7RM0PL+Vy/znG5OFiO+QhaIEfQfkuIQupqrRhiDUDoR2Yu4VUhALHQCF/9I4miP1K7nd8zTe2AKmspYUFdJ0nFzVPZ300jgex+B2irkFVwSiV1HkBE2gw3Ih26LdgtdrjMtmPJwK9Zgd3NxXE/WiT8ROD14CcZquJLCuGx+af8qfOr05j7CGHvob0xjqSiWEST59eaUWl+2JH7DMV73mCzzYub8+VVA9w==--XBBlGfnBwetha++2--tU5synzhrItLtVOfnR0mQw== \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index d1d408b..a110c5e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -3,6 +3,8 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. + config.hosts << "*.ngrok-free.app" + # In the development environment your application's code is reloaded any time # it changes. This slows down response time but is perfect for development # since you don't have to restart the web server when you make code changes. diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb new file mode 100644 index 0000000..d075ebb --- /dev/null +++ b/config/initializers/stripe.rb @@ -0,0 +1,7 @@ +if Rails.env.production? + Stripe.api_key = Rails.application.credentials.dig(:stripe_secret_key, :production) + $stripe_recurring_donation_product_id = "prod_OqlLl6mR66dLVQ" +else + Stripe.api_key = Rails.application.credentials.dig(:stripe_secret_key, :test) + $stripe_recurring_donation_product_id = "prod_P1xUtsgjEfkGgu" +end diff --git a/config/routes.rb b/config/routes.rb index 192b8ac..6fcaa6e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,9 @@ namespace :api do namespace :v1 do + # PaymentIntents (Stripe SDK) + resources :payment_intents, only: [:create] + resources :regions, only: [:index] do get 'vehicles', on: :member, defaults: {format: 'json'} diff --git a/spec/lib/donations/onetime_spec.rb b/spec/lib/donations/onetime_spec.rb new file mode 100644 index 0000000..e69de29 diff --git a/spec/lib/donations/recurring_spec.rb b/spec/lib/donations/recurring_spec.rb new file mode 100644 index 0000000..63d0a59 --- /dev/null +++ b/spec/lib/donations/recurring_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +describe "Donations::Recurring" do + describe '#initialize' do + it 'sets the donation amount' do + donation = Donations::Recurring.new(500) + expect(donation.instance_variable_get(:@donation_amount_in_cents)).to eq(500) + end + end + + describe '#run' do + before do + allow(Stripe::Customer).to receive(:create).and_return(double('Customer', id: 'cus_test')) + allow(Stripe::EphemeralKey).to receive(:create).and_return(double('EphemeralKey')) + allow_any_instance_of(Donations::Recurring).to receive(:create_subscription).and_return(double('Subscription')) + end + + it 'creates a new Stripe customer' do + donation = Donations::Recurring.new(500) + donation.run + expect(Stripe::Customer).to have_received(:create) + end + + it 'creates a new Stripe ephemeral key' do + donation = Donations::Recurring.new(500) + donation.run + expect(Stripe::EphemeralKey).to have_received(:create) + end + + it 'creates or finds a subscription' do + donation = Donations::Recurring.new(500) + donation.run + expect(donation).to have_received(:create_subscription) + end + end + + describe '#create_subscription' do + before do + allow(Stripe::Price).to receive(:create).and_return(double('Price', id: 'price_test')) + allow(Stripe::Subscription).to receive(:create).and_return(double('Subscription')) + end + + it 'creates a new Stripe price' do + donation = Donations::Recurring.new(500) + donation.send(:create_subscription, 'cus_test') + expect(Stripe::Price).to have_received(:create) + end + + it 'creates a new Stripe subscription' do + donation = Donations::Recurring.new(500) + donation.send(:create_subscription, 'cus_test') + expect(Stripe::Subscription).to have_received(:create) + end + end +end \ No newline at end of file diff --git a/spec/requests/api/v1/payment_intents_spec.rb b/spec/requests/api/v1/payment_intents_spec.rb new file mode 100644 index 0000000..bea52c9 --- /dev/null +++ b/spec/requests/api/v1/payment_intents_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +RSpec.describe "Api::V1::PaymentIntents", type: :request do + let(:donation_amount_in_cents) { 1000 } + + describe "POST /create" do + it "creates a new PaymentIntent" do + allow(Stripe::PaymentIntent).to receive(:create).and_return(OpenStruct.new(client_secret: "12435")) + post api_v1_payment_intents_path(format: "json"), params: { donation_amount_in_cents: donation_amount_in_cents } + expect(response).to be_successful + expect(response.body).to include("12435") + end + + context "when there is a Stripe error" do + before do + allow(Stripe::PaymentIntent).to receive(:create).and_raise(Stripe::StripeError.new) + end + + it "returns a 500 status" do + post api_v1_payment_intents_path, params: { donation_amount_in_cents: donation_amount_in_cents } + + expect(response).to have_http_status(:internal_server_error) + end + end + end +end \ No newline at end of file