From 67abbb4123786898106d4c2d809f9d1385b7c958 Mon Sep 17 00:00:00 2001 From: Betsy Haibel Date: Wed, 20 Oct 2021 09:07:46 -0700 Subject: [PATCH] WIP -- stripe checkout & wechatpay Status: * relevant commands have been spiked out but still need to be tested * charges_controller has been spiked out to use the stripe checkout flow * TODO: wire stripe checkout succeeded/failed commands to a webhook controller (https://stripe.com/docs/payments/checkout/fulfill-orders) * TODO: write tests --- app/commands/money/charge_customer.rb | 132 ------------------ app/commands/money/start_stripe_checkout.rb | 126 +++++++++++++++++ app/commands/money/stripe_checkout_failed.rb | 53 +++++++ .../money/stripe_checkout_succeeded.rb | 83 +++++++++++ app/controllers/charges_controller.rb | 48 +++---- app/controllers/stripe_webhooks_controller.rb | 19 +++ db/seeds/dc/development.seeds.rb | 2 +- db/structure.sql | 6 +- lib/tasks/stripe.rake | 7 + yarn.lock | 10 +- 10 files changed, 316 insertions(+), 170 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/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..60439115 --- /dev/null +++ b/app/commands/money/start_stripe_checkout.rb @@ -0,0 +1,126 @@ +# 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, :failure_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 + @failure_url = failure_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.display_name, + }, + }], + payment_method_types: [ + 'card', + 'wechat_pay', + ], + mode: 'payment', + 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..10d90013 --- /dev/null +++ b/app/commands/money/stripe_checkout_failed.rb @@ -0,0 +1,53 @@ +# 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.transaction do + charge.state == ::Charge::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..8387a0e8 --- /dev/null +++ b/app/commands/money/stripe_checkout_succeeded.rb @@ -0,0 +1,83 @@ +# 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 + charge.state == ::Charge::SUCCESSFUL + charge.stripe_response = json_to_hash(stripe_checkout_session) + 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 + + 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 + raise "todo" + end + + def trigger_payment_mailer + if charge.reservation.instalment? + PaymentMailer.instalment( + user: current_user, + charge: charge, + outstanding_amount: (outstanding_amount - charge.amount).format(with_currency: true) + ).deliver_later + else + PaymentMailer.paid( + user: current_user, + charge: charge, + ).deliver_later + end + end +end diff --git a/app/controllers/charges_controller.rb b/app/controllers/charges_controller.rb index 76f8ce6d..091c6a8a 100644 --- a/app/controllers/charges_controller.rb +++ b/app/controllers/charges_controller.rb @@ -48,43 +48,39 @@ def create return end - service = Money::ChargeCustomer.new( - @reservation, - current_user, - params[:stripeToken], - outstanding_before_charge, + service = Money::StartStripeCheckout.new( + reservation:, user:, amount_owed:, success_url:, cancel_url:, charge_amount: nil + reservation: @reservation, + user: current_user, + amount_owed: outstanding_before_charge, charge_amount: charge_amount, + success_url: stripe_checkout_succcess_charges_url, + cancel_url: stripe_checkout_cancel_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, notice: message 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..b2649d38 --- /dev/null +++ b/app/controllers/stripe_webhooks_controller.rb @@ -0,0 +1,19 @@ +# 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 + # code goes here +end diff --git a/db/seeds/dc/development.seeds.rb b/db/seeds/dc/development.seeds.rb index eac54193..ee7ee5ad 100644 --- a/db/seeds/dc/development.seeds.rb +++ b/db/seeds/dc/development.seeds.rb @@ -17,7 +17,7 @@ presupport_open = "2004-09-06".to_time con_announced = "2019-08-25".to_time -price_change_1 = "2021-09-2 00:00:01 EDT".to_time +price_change_1 = "2021-11-2 00:00:01 EDT".to_time #clean up Membership.delete_all diff --git a/db/structure.sql b/db/structure.sql index 0d8808da..aeec2037 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -281,7 +281,8 @@ CREATE TABLE public.dc_contacts ( share_with_future_worldcons boolean DEFAULT true, show_in_listings boolean DEFAULT true, created_at timestamp without time zone NOT NULL, - updated_at timestamp without time zone NOT NULL + updated_at timestamp without time zone NOT NULL, + covid boolean ); @@ -1275,6 +1276,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20200717081753'), ('20200719215504'), ('20200720235919'), -('20200724003813'); +('20200724003813'), +('20210819040007'); diff --git a/lib/tasks/stripe.rake b/lib/tasks/stripe.rake index d2935a4e..a8d430bc 100644 --- a/lib/tasks/stripe.rake +++ b/lib/tasks/stripe.rake @@ -23,8 +23,15 @@ namespace :stripe do puts "Ended run, #{User.in_stripe.count}/#{User.count} users synced with stripe" end + + # TODO: Hard to tell if this was a one-time task for CoNZealand or a recurring maintenance bit. + # If the latter, this may need to be updated to be inclusive of charges made using the Stripe Checkout flow. + # For those, we store the *checkout session id* rather than the charge id. + # Checkout session ids are prefixed with `cs_` and so this code should silently ignore those. desc "Updates stripe descriptions to match local" task charges: "assert:setup" do + raise "I may have broken as part of a Stripe API migration! Please check comments in stripe.rake for more details." + # So initially stripe charges had the description "CoNZealand Payment" which is ugly. Lets fix that. Charge.stripe.joins(:user).find_each do |charge| next unless charge.stripe_id.starts_with?("ch_") diff --git a/yarn.lock b/yarn.lock index c4d13e22..4b66c6e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8142,15 +8142,7 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= -url-parse@^1.4.3, url-parse@^1.4.7: - version "1.5.1" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.1.tgz#d5fa9890af8a5e1f274a2c98376510f6425f6e3b" - integrity sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q== - dependencies: - querystringify "^2.1.1" - requires-port "^1.0.0" - -url-parse@^1.5.1: +url-parse@^1.4.3, url-parse@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.1.tgz#d5fa9890af8a5e1f274a2c98376510f6425f6e3b" integrity sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==