From fdcacdcd8ed3ded1ab358720f7b6c707dbb6e35b Mon Sep 17 00:00:00 2001 From: Betsy Haibel Date: Wed, 20 Oct 2021 09:07:46 -0700 Subject: [PATCH] Stripe checkout & WeChat Pay This patch introduces a new required environment variable. It also requires production to have Stripe webhooks configured against the `checkout.session.completed` and `checkout.session.expired` events. That configuration should be completed prior to deploy. For production, webhooks can be configured using: https://dashboard.stripe.com/webhooks The webhook will need to point to https://PRODUCTION_HOST/stripe_webhook Webhook events are opt-in, so make sure to include both `checkout.session.completed` and `checkout.session.expired`. After configuring the webhook, you'll want to shove its signing secret into the STRIPE_WEBHOOK_ENDPOINT_SECRET environment variable. For local testing, you can test Stripe webhooks using the Stripe CLI and the `stripe listen` command. --- README.md | 4 + app/commands/money/charge_customer.rb | 132 ----------------- app/commands/money/start_stripe_checkout.rb | 134 ++++++++++++++++++ app/commands/money/stripe_checkout_failed.rb | 54 +++++++ .../money/stripe_checkout_succeeded.rb | 91 ++++++++++++ app/controllers/charges_controller.rb | 47 +++--- app/controllers/stripe_webhooks_controller.rb | 88 ++++++++++++ app/javascript/sprinkles/index.js | 50 ------- app/views/charges/new.html.erb | 4 +- config/routes.rb | 7 +- db/seeds/dc/development.seeds.rb | 2 +- db/structure.sql | 6 +- lib/tasks/stripe.rake | 7 + yarn.lock | 10 +- 14 files changed, 412 insertions(+), 224 deletions(-) delete mode 100644 app/commands/money/charge_customer.rb create mode 100644 app/commands/money/start_stripe_checkout.rb create mode 100644 app/commands/money/stripe_checkout_failed.rb create mode 100644 app/commands/money/stripe_checkout_succeeded.rb create mode 100644 app/controllers/stripe_webhooks_controller.rb diff --git a/README.md b/README.md index d5973594..c3e4062b 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,10 @@ HOSTNAME=localhost:3000 # Generate them here https://dashboard.stripe.com/account/apikeys STRIPE_PUBLIC_KEY=pk_test_zq022DcopypastatXAVMaOJT STRIPE_PRIVATE_KEY=sk_test_35SiP3qovcopypastaLguIyY +# Stripe webhook secret +# Use the Stripe CLI and `stripe listen --forward-to localhost:3000/stripe_webhook` to configure this in development. +# For production configuration, go to https://dashboard.stripe.com/webhooks +STRIPE_WEBHOOK_ENDPOINT_SECRET=whsec_HcopypastaS7IH3D779S # https://stripe.com/docs/currencies STRIPE_CURRENCY=NZD diff --git a/app/commands/money/charge_customer.rb b/app/commands/money/charge_customer.rb deleted file mode 100644 index 13390d4c..00000000 --- a/app/commands/money/charge_customer.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true - -# Copyright 2019 Matthew B. Gray -# Copyright 2019 AJ Esler -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Money::ChargeCustomer creates a charge record against a User for the Stripe integrations -# Truthy returns mean the charge succeeded, otherwise check #errors for failure details. -class Money::ChargeCustomer - attr_reader :reservation, :user, :token, :charge_amount, :charge, :amount_owed - - def initialize(reservation, user, token, amount_owed, charge_amount: nil) - @reservation = reservation - @user = user - @token = token - @charge_amount = charge_amount || amount_owed - @amount_owed = amount_owed - end - - def call - @charge = ::Charge.stripe.pending.create!( - user: user, - reservation: reservation, - stripe_id: token, - amount: charge_amount, - comment: "Pending stripe payment" - ) - - check_charge_amount - setup_stripe_customer unless errors.any? - create_stripe_charge unless errors.any? - - if errors.any? - @charge.state = ::Charge::STATE_FAILED - @charge.comment = error_message - elsif !@stripe_charge[:paid] - @charge.state = ::Charge::STATE_FAILED - else - @charge.state = ::Charge::STATE_SUCCESSFUL - end - - if @stripe_charge.present? - @charge.stripe_id = @stripe_charge[:id] - @charge.amount_cents = @stripe_charge[:amount] - @charge.comment = @stripe_charge[:description] - @charge.stripe_response = json_to_hash(@stripe_charge.to_json) - end - - reservation.transaction do - @charge.comment = ChargeDescription.new(@charge).for_users - @charge.save! - if fully_paid? - reservation.update!(state: Reservation::PAID) - else - reservation.update!(state: Reservation::INSTALMENT) - end - end - - @charge.successful? - end - - def error_message - errors.to_sentence - end - - def errors - @errors ||= [] - end - - private - - def check_charge_amount - if !charge_amount.present? - errors << "charge amount is missing" - end - if charge_amount <= 0 - errors << "amount must be more than 0 cents" - end - if charge_amount > amount_owed - errors << "refusing to overpay for reservation" - end - end - - def setup_stripe_customer - if !user.stripe_id.present? - stripe_customer = Stripe::Customer.create(email: user.email) - user.update!(stripe_id: stripe_customer.id) - end - card_response = Stripe::Customer.create_source(user.stripe_id, source: token) - @card_id = card_response.id - rescue Stripe::StripeError => e - errors << e.message.to_s - @charge.stripe_response = json_to_hash(e.response) - @charge.comment = "Failed to setup customer - #{e.message}" - end - - def create_stripe_charge - # Note, stripe does everything in cents - @stripe_charge = Stripe::Charge.create( - description: ChargeDescription.new(@charge).for_accounts, - currency: $currency, - customer: user.stripe_id, - source: @card_id, - amount: charge_amount.cents, - ) - rescue Stripe::StripeError => e - errors << e.message - @charge.stripe_response = json_to_hash(e.response) - @charge.comment = "failed to create Stripe::Charge - #{e.message}" - end - - def json_to_hash(obj) - JSON.parse(obj.to_json) - rescue - {} - end - - def fully_paid? - @charge.successful? && (amount_owed - charge_amount) <= 0 - end -end diff --git a/app/commands/money/start_stripe_checkout.rb b/app/commands/money/start_stripe_checkout.rb new file mode 100644 index 00000000..919835bc --- /dev/null +++ b/app/commands/money/start_stripe_checkout.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +# Copyright 2019 Matthew B. Gray +# Copyright 2019 AJ Esler +# Copyright 2021 DisCon III +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Money::StartStripeCheckout creates a pending charge record against a User for the Stripe integrations +# and sets them up to check out with Stripe. +# Truthy returns mean the user can be safely sent on to the checkout flow, otherwise check #errors for failure details. +class Money::StartStripeCheckout + attr_reader :reservation, :user, :charge_amount, :charge, :amount_owed, :success_url, :cancel_url + + def initialize(reservation:, user:, amount_owed:, success_url:, cancel_url:, charge_amount: nil) + @reservation = reservation + @user = user + @charge_amount = charge_amount || amount_owed + @amount_owed = amount_owed + @success_url = success_url + @cancel_url = cancel_url + end + + def call + setup_stripe_customer + + check_charge_amount unless errors.any? + create_checkout_session unless errors.any? + + charge_state_params = if errors.any? + { + state: ::Charge::STATE_FAILED, + comment: error_message, + } + else + { + state: ::Charge::STATE_PENDING, + comment: "Pending stripe payment", + } + end + + @charge = ::Charge.stripe.create!({ + user: user, + reservation: reservation, + stripe_id: @checkout_session.id, + amount: charge_amount, + }.merge(charge_state_params)) + + checkout_url.present? + end + + def error_message + errors.to_sentence + end + + def errors + @errors ||= [] + end + + def checkout_url + @checkout_session['url'] + end + + private + + def check_charge_amount + if !charge_amount.present? + errors << "charge amount is missing" + end + if charge_amount <= 0 + errors << "amount must be more than 0 cents" + end + if charge_amount > amount_owed + errors << "refusing to overpay for reservation" + end + end + + def create_checkout_session + @checkout_session = Stripe::Checkout::Session.create({ + line_items: [ + { + currency: ENV.fetch('STRIPE_CURRENCY'), + amount: charge_amount.cents, + name: reservation.membership.to_s, + quantity: 1, + }, + ], + payment_method_types: [ + 'card', + 'wechat_pay', + ], + payment_method_options: { + wechat_pay: { + client: 'web', + } + }, + mode: 'payment', + customer: user.stripe_id, + success_url: success_url, + cancel_url: cancel_url, + }) + end + + def setup_stripe_customer + if !user.stripe_id.present? + stripe_customer = Stripe::Customer.create(email: user.email) + user.update!(stripe_id: stripe_customer.id) + end + rescue Stripe::StripeError => e + errors << e.message.to_s + @charge.stripe_response = json_to_hash(e.response) + @charge.comment = "Failed to setup customer - #{e.message}" + end + + def json_to_hash(obj) + JSON.parse(obj.to_json) + rescue + {} + end + + def fully_paid? + @charge.successful? && (amount_owed - charge_amount) <= 0 + end +end diff --git a/app/commands/money/stripe_checkout_failed.rb b/app/commands/money/stripe_checkout_failed.rb new file mode 100644 index 00000000..ef81d735 --- /dev/null +++ b/app/commands/money/stripe_checkout_failed.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Copyright 2021 DisCon III +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Money::StripeCheckoutFailed updates the Charge record associated with that checkout session to indicate that the payment failed. +# Truthy returns mean that everything updated correctly, otherwise check #errors for failure details. +class Money::StripeCheckoutFailed + attr_reader :charge, :stripe_checkout_session + + def initialize(charge:, stripe_checkout_session:) + @charge = charge + @stripe_checkout_session = stripe_checkout_session + end + + def call + reservation = charge.reservation + reservation.transaction do + charge.state = ::Charge::STATE_FAILED + charge.stripe_response = json_to_hash(stripe_checkout_session) + charge.comment = "Stripe checkout failed." + charge.save! + end + + return charge + end + + def error_message + errors.to_sentence + end + + def errors + @errors ||= [] + end + + private + + def json_to_hash(obj) + JSON.parse(obj.to_json) + rescue + {} + end +end diff --git a/app/commands/money/stripe_checkout_succeeded.rb b/app/commands/money/stripe_checkout_succeeded.rb new file mode 100644 index 00000000..11969053 --- /dev/null +++ b/app/commands/money/stripe_checkout_succeeded.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +# Copyright 2021 DisCon III +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Money::StripeCheckoutSucceeded updates the Charge record associated with that checkout session to indicate that the payment succeeded. +# Truthy returns mean that everything updated correctly, otherwise check #errors for failure details. +class Money::StripeCheckoutSucceeded + attr_reader :charge, :stripe_checkout_session + + def initialize(charge:, stripe_checkout_session:) + @charge = charge + @stripe_checkout_session = stripe_checkout_session + end + + def call + reservation.transaction do + # this is a little hacky, but it makes sure that ChargeDescription is set correctly + # otherwise the payment history on the membership looks strange + charge.state = ::Charge::STATE_SUCCESSFUL + charge.update!( + state: ::Charge::STATE_SUCCESSFUL, + stripe_response: json_to_hash(stripe_checkout_session), + comment: ChargeDescription.new(charge).for_users, + ) + if fully_paid? + reservation.update!(state: Reservation::PAID) + else + reservation.update!(state: Reservation::INSTALMENT) + end + end + + trigger_payment_mailer + + return charge + end + + def error_message + errors.to_sentence + end + + def errors + @errors ||= [] + end + + private + + def json_to_hash(obj) + JSON.parse(obj.to_json) + rescue + {} + end + + def fully_paid? + reservation.charges.successful.sum(&:amount_cents) >= reservation.membership.price_cents + end + + def outstanding_amount + Money.new(reservation.membership.price_cents - reservation.charges.successful.sum(&:amount_cents), ENV.fetch("STRIPE_CURRENCY")) + end + + def trigger_payment_mailer + if charge.reservation.instalment? + PaymentMailer.instalment( + user: reservation.user, + charge: charge, + outstanding_amount: outstanding_amount.format(with_currency: true) + ).deliver_later + else + PaymentMailer.paid( + user: reservation.user, + charge: charge, + ).deliver_later + end + end + + def reservation + charge.reservation + end +end diff --git a/app/controllers/charges_controller.rb b/app/controllers/charges_controller.rb index 76f8ce6d..e9641ed4 100644 --- a/app/controllers/charges_controller.rb +++ b/app/controllers/charges_controller.rb @@ -48,43 +48,38 @@ def create return end - service = Money::ChargeCustomer.new( - @reservation, - current_user, - params[:stripeToken], - outstanding_before_charge, + service = Money::StartStripeCheckout.new( + reservation: @reservation, + user: current_user, + amount_owed: outstanding_before_charge, charge_amount: charge_amount, + success_url: stripe_checkout_success_reservation_charges_url, + cancel_url: stripe_checkout_cancel_reservation_charges_url, ) - charge_successful = service.call - if !charge_successful + checkout_started = service.call + if !checkout_started flash[:error] = service.error_message redirect_to new_reservation_charge_path return end - trigger_payment_mailer(service.charge, outstanding_before_charge, charge_amount) - - message = "Thank you for your #{charge_amount.format} payment" - (message += ". Your #{@reservation.membership} membership has been paid for.") if @reservation.paid? - - redirect_to reservations_path, notice: message + redirect_to service.checkout_url end - private - - def trigger_payment_mailer(charge, outstanding_before_charge, charge_amount) - if charge.reservation.instalment? - PaymentMailer.instalment( - user: current_user, - charge: charge, - outstanding_amount: (outstanding_before_charge - charge_amount).format(with_currency: true) - ).deliver_later + def stripe_checkout_success + message = "Thank you for your payment" + if @reservation.paid? + message += ". Your #{@reservation.membership} membership has been paid for." else - PaymentMailer.paid( - user: current_user, - charge: charge, - ).deliver_later + message += ". It may take up to an hour for your payment to be processed. Please contact support if you experience issues." end + + redirect_to reservations_path, notice: message + end + + def stripe_checkout_cancel + redirect_to new_reservation_charge_path(@reservation) end + end diff --git a/app/controllers/stripe_webhooks_controller.rb b/app/controllers/stripe_webhooks_controller.rb new file mode 100644 index 00000000..b2f20ea2 --- /dev/null +++ b/app/controllers/stripe_webhooks_controller.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +# Copyright 2021 DisCon III +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +class StripeWebhooksController < ActionController::Base + # stripe webhooks don't have access to the Rails CSRF token, so we + # instead use Stripe::Webhook.construct_event to verify authenticity + skip_forgery_protection + + def receive + event = nil + + # Verify webhook signature and extract the event + # See https://stripe.com/docs/webhooks/signatures for more information. + begin + sig_header = request.env['HTTP_STRIPE_SIGNATURE'] + payload = request.body.read + event = Stripe::Webhook.construct_event(payload, sig_header, endpoint_secret) + rescue JSON::ParserError => e + # Invalid payload + head :bad_request + rescue Stripe::SignatureVerificationError => e + # Invalid signature + head :bad_request + end + + case + when event['type'] == 'checkout.session.completed' + checkout_session = event['data']['object'] + checkout_session_completed(checkout_session) + when event['type'] == 'checkout.session.expired' + checkout_session = event['data']['object'] + checkout_session_expired(checkout_session) + else + # silently ignore + head :ok + end + end + + private + def endpoint_secret + ENV['STRIPE_WEBHOOK_ENDPOINT_SECRET'] || raise("must have STRIPE_WEBHOOK_ENDPOINT_SECRET configured for payments") + end + + def checkout_session_completed(stripe_checkout_session) + charge = Charge.find_by!(stripe_id: stripe_checkout_session['id']) + + service = Money::StripeCheckoutSucceeded.new( + charge: charge, + stripe_checkout_session: stripe_checkout_session, + ) + + after_success_actions_completed = service.call + if after_success_actions_completed + head :ok + else + head :unprocessable_entity + end + end + + def checkout_session_expired(stripe_checkout_session) + charge = Charge.find_by!(stripe_id: stripe_checkout_session['id']) + + service = Money::StripeCheckoutFailed.new( + charge: charge, + stripe_checkout_session: stripe_checkout_session, + ) + + after_expire_actions_completed = service.call + if after_expire_actions_completed + head :ok + else + head :unprocessable_entity + end + end +end diff --git a/app/javascript/sprinkles/index.js b/app/javascript/sprinkles/index.js index 1ec4ce98..4d9a428e 100644 --- a/app/javascript/sprinkles/index.js +++ b/app/javascript/sprinkles/index.js @@ -41,56 +41,6 @@ $(document).ready(() => { }); }); -$(document).ready(() => { - // Stripe form setup for accepting payments form the charges endpoints - const $form = $('#charge-form'); - if ($form.length === 0) { - return; - } - - const config = $form.data('stripe'); - // n.b. Stripe is setup in a