diff --git a/app/controllers/api/payment/braintree_controller.rb b/app/controllers/api/payment/braintree_controller.rb index 6ec515c62..fbd2ca684 100644 --- a/app/controllers/api/payment/braintree_controller.rb +++ b/app/controllers/api/payment/braintree_controller.rb @@ -16,15 +16,22 @@ def webhook end def one_click - @result = client::OneClick.new(unsafe_params, cookies.signed[:payment_methods]).run - unless @result.success? - @errors = client::ErrorProcessing.new(@result, locale: locale).process - render status: :unprocessable_entity, errors: @errors - end + member = Member.find_by_email(params[:user][:email]) + @result = client::OneClick.new(unsafe_params, cookies.signed[:payment_methods], member).run + + render status: :unprocessable_entity, errors: oneclick_payment_errors unless @result.success? end private + def oneclick_payment_errors + if @result.class == Braintree::ErrorResult + client::ErrorProcessing.new(@result, locale: locale).process + else + @result.errors + end + end + def payment_options { nonce: unsafe_params[:payment_method_nonce], diff --git a/app/lib/payment_processor/braintree/one_click.rb b/app/lib/payment_processor/braintree/one_click.rb index 6188a2cd5..ac3b4e769 100644 --- a/app/lib/payment_processor/braintree/one_click.rb +++ b/app/lib/payment_processor/braintree/one_click.rb @@ -1,6 +1,28 @@ # frozen_string_literal: true module PaymentProcessor::Braintree + class DuplicateDonationError < StandardError + def message + I18n.t('fundraiser.oneclick.duplicate_donation') + end + end + + class DuplicateDonationResponse + attr_accessor :errors, :message, :params + attr_reader :immediate_redonation + + def initialize(errors: [], message: '', params: {}) + @errors = errors + @message = message + @params = params + @immediate_redonation = true + end + + def success? + @errors.empty? + end + end + class OneClick attr_reader :params, :payment_options @@ -11,6 +33,10 @@ def initialize(params, cookied_payment_methods, member = nil) end def run + # TODO: On the second attempt (if the member consents to duplicate donation), post with the same parameters + # but also params[:allow_duplicate] = true + return duplicate_donation_error_response if duplicate_donation && !payment_options.params[:allow_duplicate] + sale = make_payment if sale.success? action = create_action(extra_fields(sale)) @@ -20,6 +46,21 @@ def run sale end + def duplicate_donation + resources = (payment_options.recurring? ? 'subscriptions' : 'transactions') + # Check if there are any transactions/subscriptions for the customer, + # within 10 minutes, with the same amount + !@member.customer.send(resources) + .where('created_at > ? AND amount = ? AND page_id = ?', + 10.minutes.ago, payment_options.params[:payment][:amount], params[:page_id]) + .empty? + end + + def duplicate_donation_error_response + error = DuplicateDonationError.new + DuplicateDonationResponse.new(errors: [error], message: error.message, params: @params) + end + def extra_fields(sale) if payment_options.recurring? return { diff --git a/app/views/api/payment/braintree/one_click.json.jbuilder b/app/views/api/payment/braintree/one_click.json.jbuilder index 658871e13..983df4a77 100644 --- a/app/views/api/payment/braintree/one_click.json.jbuilder +++ b/app/views/api/payment/braintree/one_click.json.jbuilder @@ -5,4 +5,5 @@ unless @result.success? json.params @result.params json.errors @result.errors json.message @result.message + json.immediate_redonation @result.try(:immediate_redonation) end diff --git a/config/locales/member_facing.de.yml b/config/locales/member_facing.de.yml index 70c50de74..3db0f289c 100644 --- a/config/locales/member_facing.de.yml +++ b/config/locales/member_facing.de.yml @@ -94,6 +94,8 @@ de: credit_card_payment_method: "%{card_type} gültig bis %{last_four_digits}" paypal_payment_method: "Paypal (%{email})" new_payment_method: "Zahlungsmethode hinzufügen" + duplicate_donation: "Sie haben vor wenigen Minuten schon einmal gespendet. Sind Sie sicher, dass sie noch einmal spenden möchten? Dann klicken Sie bitte hier: %{link}. Wir möchten nur sicher gehen, dass Sie nicht versehentlich doppelt spenden. + Vielen Dank für Ihre Unterstützung!" debit: recurring: "Sie machen eine monatliche Spende von %{amount} an SumOfUs" one_time: "Sie spenden %{amount} an SumOfUs" diff --git a/config/locales/member_facing.en.yml b/config/locales/member_facing.en.yml index 8648afca5..400d39f07 100644 --- a/config/locales/member_facing.en.yml +++ b/config/locales/member_facing.en.yml @@ -101,6 +101,8 @@ en: credit_card_payment_method: "%{card_type} ending in %{last_four_digits}" paypal_payment_method: "Paypal (%{email})" new_payment_method: "Add payment method" + duplicate_donation: "You've just made a donation a few minutes ago. Are you sure, you want to donate again? If yes, please click here %{link}. We're doing this to make sure you don't accidentally donate twice. + Thanks so much for everything you do!" debit: recurring: "You are setting up a monthly donation of %{amount}" one_time: "You are donating %{amount}" diff --git a/config/locales/member_facing.es.yml b/config/locales/member_facing.es.yml index 7145c9a67..56f6040da 100644 --- a/config/locales/member_facing.es.yml +++ b/config/locales/member_facing.es.yml @@ -99,6 +99,8 @@ es: credit_card_payment_method: "%{card_type} terminada en %{last_four_digits}" paypal_payment_method: "Paypal (%{email})" new_payment_method: "Agregar método de pago" + duplicate_donation: "You've just made a donation a few minutes ago. Are you sure, you want to donate again? If yes, please click here %{link}. We're doing this to make sure you don't accidentally donate twice. + Thanks so much for everything you do!" debit: recurring: "Estás estableciendo una donación mensual de %{amount}" one_time: "Estás donando %{amount}" diff --git a/config/locales/member_facing.fr.yml b/config/locales/member_facing.fr.yml index 9f80e6320..0532fdd96 100644 --- a/config/locales/member_facing.fr.yml +++ b/config/locales/member_facing.fr.yml @@ -85,6 +85,8 @@ fr: credit_card_payment_method: "%{card_type} finissant par %{last_four_digits}" paypal_payment_method: "Paypal (%{email})" new_payment_method: "Ajouter un mode de paiement" + duplicate_donation: "Vous avez fait un don il y a quelques minutes. Êtes-vous sur de vouloir faire un nouveau don ? Si oui, veuillez cliquer ici : %{link}. Nous souhaitons simplement nous assurer que vous ne faites pas un double don par erreur. + Merci pour tout ce que vous faites !" debit: recurring: "Vous souhaitez faire un don mensuel de %{amount} à SumOfUs." one_time: "Vous souhaitez donner %{amount} à SumOfUs." diff --git a/spec/fixtures/vcr_cassettes/express_donation_duplicate_transactions.yml b/spec/fixtures/vcr_cassettes/express_donation_duplicate_transactions.yml new file mode 100644 index 000000000..b555fe790 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/express_donation_duplicate_transactions.yml @@ -0,0 +1,73 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.sandbox.braintreegateway.com/merchants//transactions + body: + encoding: UTF-8 + string: | + + + 6vg2vw + 2.0 + GBP + + true + + sale + + headers: + Accept-Encoding: + - gzip + Accept: + - application/xml + User-Agent: + - Braintree Ruby Gem 2.95.0 + X-Apiversion: + - '5' + Content-Type: + - application/xml + Authorization: + - Basic dGVzdF9wdWJsaWNfa2V5OnRlc3RfcHJpdmF0ZV9rZXk= + response: + status: + code: 401 + message: Unauthorized + headers: + Date: + - Tue, 16 Apr 2019 11:58:00 GMT + Content-Type: + - application/xml; charset=utf-8 + Transfer-Encoding: + - chunked + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + Www-Authenticate: + - Basic realm="Braintree API" + Vary: + - Accept-Encoding + Content-Encoding: + - gzip + Cache-Control: + - no-cache + X-Runtime: + - '0.021127' + X-Request-Id: + - 02-1555415879.889-182.65.195.137-38799225 + Content-Security-Policy: + - frame-ancestors 'self' + X-Broxyid: + - 02-1555415879.889-182.65.195.137-38799225 + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + body: + encoding: ASCII-8BIT + string: !binary |- + H4sIAEjDtVwAA/IICQlQcEoszky2UnBMTk4tLlZISc3LTE3R4wIAAAD//wMAaO1LWhsAAAA= + http_version: + recorded_at: Tue, 16 Apr 2019 11:58:00 GMT +recorded_with: VCR 3.0.3 diff --git a/spec/requests/api/braintree/braintree_spec.rb b/spec/requests/api/braintree/braintree_spec.rb index 3796f0c56..bdce067c7 100644 --- a/spec/requests/api/braintree/braintree_spec.rb +++ b/spec/requests/api/braintree/braintree_spec.rb @@ -16,7 +16,7 @@ allow(FundingCounter).to receive(:update) end - describe 'making multiple transactions on the same page' do + describe 'making multiple transactions on the same page with after 10 mins' do subject do body = { payment: { @@ -33,7 +33,9 @@ page_id: page.id } post api_payment_braintree_one_click_path(page.id), params: body - post api_payment_braintree_one_click_path(page.id), params: body + Timecop.freeze(Time.now + 11.minutes) do + post api_payment_braintree_one_click_path(page.id), params: body + end end it 'creates an action and a transaction for each payment' do @@ -47,6 +49,81 @@ end end + describe 'making multiple transactions on the same page and amount within 10 mins' do + subject do + body = { + payment: { + amount: 2.00, + payment_method_id: payment_method.id, + currency: 'GBP', + recurring: false + }, + user: { + form_id: form.id, + email: 'test@example.com', + name: 'John Doe' + }, + page_id: page.id + } + post api_payment_braintree_one_click_path(page.id), params: body + Timecop.freeze(Time.now + (1..9).to_a.sample.minutes) do + post api_payment_braintree_one_click_path(page.id), params: body + end + end + + it 'should not allow duplicate donation' do + VCR.use_cassette('express_donation_multiple_transactions') do + expect(Action.all.count).to eq 0 + expect(Payment::Braintree::Transaction.all.count).to eq 0 + subject + expect(Action.all.count).to eq 1 + expect(Payment::Braintree::Transaction.all.count).to eq 1 + + expect(response.status).to eq 422 + expect(json_hash['message']).to include( + "You've just made a donation a few minutes ago. Are you sure, you want to donate again?" + ) + end + end + end + + describe 'making duplicate donation for same page within 10 mins and duplicate attribute' do + subject do + body = { + payment: { + amount: 2.00, + payment_method_id: payment_method.id, + currency: 'GBP', + recurring: false + }, + user: { + form_id: form.id, + email: 'test@example.com', + name: 'John Doe' + }, + page_id: page.id + } + post api_payment_braintree_one_click_path(page.id), params: body + + Timecop.freeze(Time.now + (1..9).to_a.sample.minutes) do + body[:allow_duplicate] = true + post api_payment_braintree_one_click_path(page.id), params: body + end + end + + it 'should allow duplicate donation' do + VCR.use_cassette('express_donation_multiple_transactions') do + expect(Action.all.count).to eq 0 + expect(Payment::Braintree::Transaction.all.count).to eq 0 + subject + expect(Action.all.count).to eq 2 + expect(Payment::Braintree::Transaction.all.count).to eq 2 + + expect(response.status).to eq 200 + end + end + end + describe 'subscription' do before do VCR.use_cassette('braintree_express_donation_subscription') do