diff --git a/app/controllers/spree/admin/payments_controller.rb b/app/controllers/spree/admin/payments_controller.rb index b95070ccf2..7649a5af41 100644 --- a/app/controllers/spree/admin/payments_controller.rb +++ b/app/controllers/spree/admin/payments_controller.rb @@ -52,7 +52,7 @@ module Spree # (we can't use respond_override because Spree no longer uses respond_with) def fire event = params[:e] - return unless event && @payment.payment_source + return unless event # capture_and_complete_order will complete the order, so we want to try to redeem VINE # voucher first and exit if it fails diff --git a/app/mailers/payment_mailer.rb b/app/mailers/payment_mailer.rb index b90d4c2191..0968224341 100644 --- a/app/mailers/payment_mailer.rb +++ b/app/mailers/payment_mailer.rb @@ -22,10 +22,10 @@ class PaymentMailer < ApplicationMailer end end - def refund_available(payment, taler_order_status_url) + def refund_available(amount, payment, taler_order_status_url) @order = payment.order @shop = @order.distributor.name - @amount = payment.display_amount + @amount = amount @taler_order_status_url = taler_order_status_url I18n.with_locale valid_locale(@order.user) do diff --git a/app/models/spree/credit_card.rb b/app/models/spree/credit_card.rb index 47fa1b8fca..5a6d8dfddc 100644 --- a/app/models/spree/credit_card.rb +++ b/app/models/spree/credit_card.rb @@ -63,35 +63,6 @@ module Spree "XXXX-XXXX-XXXX-#{last_digits}" end - def actions - %w{capture_and_complete_order void credit resend_authorization_email} - end - - def can_resend_authorization_email?(payment) - payment.requires_authorization? - end - - # Indicates whether its possible to capture the payment - def can_capture_and_complete_order?(payment) - return false if payment.requires_authorization? - - payment.pending? || payment.checkout? - end - - # Indicates whether its possible to void the payment. - def can_void?(payment) - !payment.void? - end - - # Indicates whether its possible to credit the payment. Note that most gateways require that the - # payment be settled first which generally happens within 12-24 hours of the transaction. - def can_credit?(payment) - return false unless payment.completed? - return false unless payment.order.payment_state == 'credit_owed' - - payment.credit_allowed.positive? - end - # Allows us to use a gateway_payment_profile_id to store Stripe Tokens def has_payment_profile? gateway_customer_profile_id.present? || gateway_payment_profile_id.present? diff --git a/app/models/spree/gateway.rb b/app/models/spree/gateway.rb index 08f6d20e27..e08a4e0ca0 100644 --- a/app/models/spree/gateway.rb +++ b/app/models/spree/gateway.rb @@ -13,6 +13,35 @@ module Spree preference :server, :string, default: 'live' preference :test_mode, :boolean, default: false + def actions + %w{capture_and_complete_order void credit resend_authorization_email} + end + + # Indicates whether its possible to capture the payment + def can_capture_and_complete_order?(payment) + return false if payment.requires_authorization? + + payment.pending? || payment.checkout? + end + + # Indicates whether its possible to void the payment. + def can_void?(payment) + !payment.void? + end + + # Indicates whether its possible to credit the payment. Note that most gateways require that the + # payment be settled first which generally happens within 12-24 hours of the transaction. + def can_credit?(payment) + return false unless payment.completed? + return false unless payment.order.payment_state == 'credit_owed' + + payment.credit_allowed.positive? + end + + def can_resend_authorization_email?(payment) + payment.requires_authorization? + end + def payment_source_class CreditCard end diff --git a/app/models/spree/payment.rb b/app/models/spree/payment.rb index ae224f44d0..7953d71a3c 100644 --- a/app/models/spree/payment.rb +++ b/app/models/spree/payment.rb @@ -152,11 +152,10 @@ module Spree end def actions - return [] unless payment_source.respond_to?(:actions) + return [] unless payment_method.respond_to?(:actions) - payment_source.actions.select do |action| - !payment_source.respond_to?("can_#{action}?") || - payment_source.__send__("can_#{action}?", self) + payment_method.actions.select do |action| + payment_method.__send__("can_#{action}?", self) end end @@ -166,11 +165,6 @@ module Spree PaymentMailer.authorize_payment(self).deliver_later end - def payment_source - res = source.is_a?(Payment) ? source.source : source - res || payment_method - end - def ensure_correct_adjustment revoke_adjustment_eligibility if ['failed', 'invalid', 'void'].include?(state) return if adjustment.try(:finalized?) diff --git a/app/models/spree/payment_method/taler.rb b/app/models/spree/payment_method/taler.rb index 479f06811e..f991f289ef 100644 --- a/app/models/spree/payment_method/taler.rb +++ b/app/models/spree/payment_method/taler.rb @@ -22,11 +22,20 @@ module Spree preference :api_key, :password def actions - %w{void} + %w[credit void] end def can_void?(payment) - payment.state == "completed" + # The source can be another payment. Then this is an offset payment + # like a credit record. We can't void a refund. + payment.source == self && payment.state == "completed" + end + + def can_credit?(payment) + return false unless payment.completed? + return false unless payment.order.payment_state == 'credit_owed' + + payment.credit_allowed.positive? end # Name of the view to display during checkout @@ -68,6 +77,23 @@ module Spree ActiveMerchant::Billing::Response.new(success, message) end + def credit(money, response_code, gateway_options) + amount = money / 100 # called with cents + payment = gateway_options[:payment] + taler_order = taler_order(id: response_code) + status = taler_order.fetch("order_status") + + raise "Unsupported action" if status != "paid" + + taler_amount = "KUDOS:#{amount}" + taler_order.refund(refund: taler_amount, reason: "credit") + + spree_money = Spree::Money.new(amount, currency: payment.currency).to_s + PaymentMailer.refund_available(spree_money, payment, taler_order.status_url).deliver_later + + ActiveMerchant::Billing::Response.new(true, "Refund initiated") + end + def void(response_code, gateway_options) payment = gateway_options[:payment] taler_order = taler_order(id: response_code) @@ -82,7 +108,8 @@ module Spree amount = taler_order.fetch("contract_terms")["amount"] taler_order.refund(refund: amount, reason: "void") - PaymentMailer.refund_available(payment, taler_order.status_url).deliver_later + spree_money = payment.money.to_s + PaymentMailer.refund_available(spree_money, payment, taler_order.status_url).deliver_later ActiveMerchant::Billing::Response.new(true, "Refund initiated") end diff --git a/app/services/checkout/params.rb b/app/services/checkout/params.rb index a14a04656f..3175e76a6a 100644 --- a/app/services/checkout/params.rb +++ b/app/services/checkout/params.rb @@ -14,7 +14,6 @@ module Checkout apply_strong_parameters set_pickup_address set_address_details - set_payment_amount set_existing_card @order_params @@ -58,12 +57,6 @@ module Checkout end end - def set_payment_amount - return unless @order_params[:payments_attributes] - - @order_params[:payments_attributes].first[:amount] = order.outstanding_balance.amount - end - def set_existing_card return unless existing_card_selected? diff --git a/spec/mailers/payment_mailer_spec.rb b/spec/mailers/payment_mailer_spec.rb index dcce998d6e..f5cdae6c21 100644 --- a/spec/mailers/payment_mailer_spec.rb +++ b/spec/mailers/payment_mailer_spec.rb @@ -56,7 +56,7 @@ RSpec.describe PaymentMailer do payment = build(:payment) payment.order.distributor = build(:enterprise, name: "Carrot Castle") link = "https://taler.example.com/order/1" - mail = PaymentMailer.refund_available(payment, link) + mail = PaymentMailer.refund_available(payment.money.to_s, payment, link) expect(mail.subject).to eq "Refund from Carrot Castle" expect(mail.body).to include "Your payment of $45.75 to Carrot Castle is being refunded." diff --git a/spec/models/spree/credit_card_spec.rb b/spec/models/spree/credit_card_spec.rb index 29f1fdf753..7a7db93886 100644 --- a/spec/models/spree/credit_card_spec.rb +++ b/spec/models/spree/credit_card_spec.rb @@ -21,53 +21,6 @@ RSpec.describe Spree::CreditCard do let(:credit_card) { described_class.new } - context "#can_capture?" do - it "should be true if payment is pending" do - payment = build_stubbed(:payment, created_at: Time.zone.now) - allow(payment).to receive(:pending?) { true } - expect(credit_card.can_capture_and_complete_order?(payment)).to be_truthy - end - - it "should be true if payment is checkout" do - payment = build_stubbed(:payment, created_at: Time.zone.now) - allow(payment).to receive_messages pending?: false, - checkout?: true - expect(credit_card.can_capture_and_complete_order?(payment)).to be_truthy - end - end - - context "#can_void?" do - it "should be true if payment is not void" do - payment = build_stubbed(:payment) - allow(payment).to receive(:void?) { false } - expect(credit_card.can_void?(payment)).to be_truthy - end - end - - context "#can_credit?" do - it "should be false if payment is not completed" do - payment = build_stubbed(:payment) - allow(payment).to receive(:completed?) { false } - expect(credit_card.can_credit?(payment)).to be_falsy - end - - it "should be false when order payment_state is not 'credit_owed'" do - payment = build_stubbed(:payment, - order: create(:order, payment_state: 'paid')) - allow(payment).to receive(:completed?) { true } - expect(credit_card.can_credit?(payment)).to be_falsy - end - - it "should be false when credit_allowed is zero" do - payment = build_stubbed(:payment, - order: create(:order, payment_state: 'credit_owed')) - allow(payment).to receive_messages completed?: true, - credit_allowed: 0 - - expect(credit_card.can_credit?(payment)).to be_falsy - end - end - context "#valid?" do it "should validate presence of number" do credit_card.attributes = valid_credit_card_attributes.except(:number) diff --git a/spec/models/spree/gateway_spec.rb b/spec/models/spree/gateway_spec.rb index f6e975ba38..3ff9dc0741 100644 --- a/spec/models/spree/gateway_spec.rb +++ b/spec/models/spree/gateway_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true RSpec.describe Spree::Gateway do + subject(:gateway) { test_gateway.new } let(:test_gateway) do Class.new(Spree::Gateway) do def provider_class @@ -15,13 +16,58 @@ RSpec.describe Spree::Gateway do it "passes through all arguments on a method_missing call" do expect(Rails.env).to receive(:local?).and_return(false) - gateway = test_gateway.new expect(gateway.provider).to receive(:imaginary_method).with('foo') gateway.imaginary_method('foo') end it "raises an error in test env" do - gateway = test_gateway.new expect { gateway.imaginary_method('foo') }.to raise_error StandardError end + + describe "#can_capture?" do + it "should be true if payment is pending" do + payment = build_stubbed(:payment, created_at: Time.zone.now) + allow(payment).to receive(:pending?) { true } + expect(gateway.can_capture_and_complete_order?(payment)).to be_truthy + end + + it "should be true if payment is checkout" do + payment = build_stubbed(:payment, created_at: Time.zone.now) + allow(payment).to receive_messages pending?: false, + checkout?: true + expect(gateway.can_capture_and_complete_order?(payment)).to be_truthy + end + end + + describe "#can_void?" do + it "should be true if payment is not void" do + payment = build_stubbed(:payment) + allow(payment).to receive(:void?) { false } + expect(gateway.can_void?(payment)).to be_truthy + end + end + + describe "#can_credit?" do + it "should be false if payment is not completed" do + payment = build_stubbed(:payment) + allow(payment).to receive(:completed?) { false } + expect(gateway.can_credit?(payment)).to be_falsy + end + + it "should be false when order payment_state is not 'credit_owed'" do + payment = build_stubbed(:payment, + order: create(:order, payment_state: 'paid')) + allow(payment).to receive(:completed?) { true } + expect(gateway.can_credit?(payment)).to be_falsy + end + + it "should be false when credit_allowed is zero" do + payment = build_stubbed(:payment, + order: create(:order, payment_state: 'credit_owed')) + allow(payment).to receive_messages completed?: true, + credit_allowed: 0 + + expect(gateway.can_credit?(payment)).to be_falsy + end + end end diff --git a/spec/models/spree/payment_method/taler_spec.rb b/spec/models/spree/payment_method/taler_spec.rb index 81f50b1a81..888a2ad410 100644 --- a/spec/models/spree/payment_method/taler_spec.rb +++ b/spec/models/spree/payment_method/taler_spec.rb @@ -56,6 +56,46 @@ RSpec.describe Spree::PaymentMethod::Taler do end end + describe "#credit" do + let(:order_endpoint) { "#{backend_url}/private/orders/taler-order-8" } + let(:refund_endpoint) { "#{order_endpoint}/refund" } + let(:taler_refund_uri) { + "taler://refund/backend.demo.taler.net/instances/sandbox/taler-order-8/" + } + + it "starts the refund process" do + order_status = { order_status: "paid" } + stub_request(:get, order_endpoint).to_return(body: order_status.to_json) + stub_request(:post, refund_endpoint).to_return(body: { taler_refund_uri: }.to_json) + + order = create(:completed_order_with_totals) + order.payments.create( + amount: order.total, state: :completed, + payment_method: taler, + response_code: "taler-order-8", + ) + expect { + response = taler.credit(100, "taler-order-8", { payment: order.payments[0] }) + expect(response.success?).to eq true + }.to enqueue_mail(PaymentMailer, :refund_available) + end + + it "raises an error if payment hasn't been taken yet" do + order_status = { order_status: "claimed" } + stub_request(:get, order_endpoint).to_return(body: order_status.to_json) + + order = create(:completed_order_with_totals) + order.payments.create( + amount: order.total, state: :completed, + payment_method: taler, + response_code: "taler-order-8", + ) + expect { + taler.credit(100, "taler-order-8", { payment: order.payments[0] }) + }.to raise_error StandardError, "Unsupported action" + end + end + describe "#void" do let(:order_endpoint) { "#{backend_url}/private/orders/taler-order-8" } let(:refund_endpoint) { "#{order_endpoint}/refund" } diff --git a/spec/models/spree/payment_spec.rb b/spec/models/spree/payment_spec.rb index cc6e8b029c..a59243096a 100644 --- a/spec/models/spree/payment_spec.rb +++ b/spec/models/spree/payment_spec.rb @@ -855,7 +855,8 @@ RSpec.describe Spree::Payment do describe "available actions" do context "for most gateways" do - let(:payment) { build_stubbed(:payment, source: build_stubbed(:credit_card)) } + let(:payment) { build_stubbed(:payment, payment_method:) } + let(:payment_method) { Spree::Gateway::StripeSCA.new } it "can capture and void" do expect(payment.actions).to match_array %w(capture_and_complete_order void) diff --git a/spec/requests/spree/admin/payments_spec.rb b/spec/requests/spree/admin/payments_spec.rb index c5c3b7035a..c4b7dbc7a2 100644 --- a/spec/requests/spree/admin/payments_spec.rb +++ b/spec/requests/spree/admin/payments_spec.rb @@ -157,8 +157,6 @@ RSpec.describe Spree::Admin::PaymentsController do context "with no payment source" do it "redirect to payments page" do - allow(payment).to receive(:payment_source).and_return(nil) - put( "/admin/orders/#{order.number}/payments/#{order.payments.first.id}/fire?e=void", params: {}, diff --git a/spec/system/admin/payments_taler_spec.rb b/spec/system/admin/payments_taler_spec.rb index 2d08f419f5..38a5da9792 100644 --- a/spec/system/admin/payments_taler_spec.rb +++ b/spec/system/admin/payments_taler_spec.rb @@ -25,7 +25,7 @@ RSpec.describe "Admin -> Order -> Payments" do login_as distributor.owner end - it "allows to refund a Taler payment" do + it "allows to void a Taler payment" do order_status = { order_status: "paid", contract_terms: { @@ -51,4 +51,34 @@ RSpec.describe "Admin -> Order -> Payments" do expect(page).not_to have_link "Void" end end + + it "allows to credit a Taler payment" do + order_status = { + order_status: "paid", + contract_terms: { + amount: "KUDOS:2", + } + } + order_endpoint = "https://taler.example.com/private/orders/taler-id-1" + refund_endpoint = "https://taler.example.com/private/orders/taler-id-1/refund" + stub_request(:get, order_endpoint).to_return(body: order_status.to_json) + stub_request(:post, refund_endpoint).to_return(body: "{}") + + visit spree.admin_order_payments_path(order.number) + + within row_containing("Taler") do + expect(page).to have_text "COMPLETED" + expect(page).to have_link "Credit" + + click_link class: "icon-credit" + + expect(page).to have_text "COMPLETED" + expect(page).not_to have_link "Credit" + end + + # Our payment system creates a new payment to show the credit. + within row_containing("$-9.75") do + expect(page).not_to have_link "Void" + end + end end