diff --git a/app/controllers/spree/admin/payments_controller.rb b/app/controllers/spree/admin/payments_controller.rb index 7fbc23f352..5c73ae1e0c 100644 --- a/app/controllers/spree/admin/payments_controller.rb +++ b/app/controllers/spree/admin/payments_controller.rb @@ -185,7 +185,7 @@ module Spree end def allowed_events - %w{capture void_transaction credit refund resend_authorization_email + %w{capture void_transaction credit refund internal_void resend_authorization_email capture_and_complete_order} end diff --git a/app/models/spree/payment/processing.rb b/app/models/spree/payment/processing.rb index 61ef7f408b..3a2ec14f26 100644 --- a/app/models/spree/payment/processing.rb +++ b/app/models/spree/payment/processing.rb @@ -144,6 +144,24 @@ module Spree end end + def internal_void! + return true if void? + + options = { customer_id: order.customer_id, payment_id: id, order_number: order.number } + response = payment_method.void( + (amount * 100).round, + nil, + options + ) + record_response(response) + + if response.success? + void + else + gateway_error(response) + end + end + def partial_credit(amount) return if amount > credit_allowed diff --git a/app/models/spree/payment_method/customer_credit.rb b/app/models/spree/payment_method/customer_credit.rb index 95a00fcccf..364aceded6 100644 --- a/app/models/spree/payment_method/customer_credit.rb +++ b/app/models/spree/payment_method/customer_credit.rb @@ -3,6 +3,16 @@ module Spree class PaymentMethod class CustomerCredit < Spree::PaymentMethod + def actions + %w{internal_void} + end + + # We should only void complete payment, otherwise we will be refunding credit that was + # not used in the first place. + def can_internal_void?(payment) + payment.state == "completed" + end + # Main method called by Spree::Payment::Processing during checkout # - amount is in cents # - options: { @@ -39,6 +49,38 @@ module Spree ActiveMerchant::Billing::Response.new(true, message) end + # Main method called by Spree::Payment::Processing for void + # - amount is in cents + # - options: { + # customer_id:, payment_id:, order_number: + # } + def void(amount, _source, options) + calculated_amount = amount / 100.00 + + customer = Customer.find_by(id: options[:customer_id]) + return error_response("customer_not_found") if customer.nil? + return error_response("missing_payment") if options[:payment_id].nil? + return error_response("credit_payment_method_missing") if payment_method.nil? + + customer.with_lock do + description = I18n.t( + "order_void_description", + scope: "credit_payment_method", + order_number: options[:order_number] + ) + customer.customer_account_transactions.create( + amount: calculated_amount, + currency:, + payment_method:, + payment_id: options[:payment_id], + description: + ) + end + + message = I18n.t("void_success", scope: "credit_payment_method") + ActiveMerchant::Billing::Response.new(true, message) + end + def method_type "check" # empty view end diff --git a/app/webpacker/css/admin/components/actions.scss b/app/webpacker/css/admin/components/actions.scss index c766a284ac..0a63ad0325 100644 --- a/app/webpacker/css/admin/components/actions.scss +++ b/app/webpacker/css/admin/components/actions.scss @@ -8,7 +8,8 @@ table tbody tr { } &.action-remove td, - &.action-void td { + &.action-void td, + &.action-internal_void td { text-decoration: line-through; &.actions { diff --git a/app/webpacker/css/admin/globals/variables.scss b/app/webpacker/css/admin/globals/variables.scss index efacef9a09..00987c53ad 100644 --- a/app/webpacker/css/admin/globals/variables.scss +++ b/app/webpacker/css/admin/globals/variables.scss @@ -156,9 +156,9 @@ $states-bg-colors: $color-ste-completed-bg, $color-ste-complete-bg, $states-text-colors: $color-ste-completed-text, $color-ste-complete-text, $color-ste-sold-text, $color-ste-pending-text, $color-ste-awaiting_return-text, $color-ste-returned-text, $color-ste-credit_owed-text, $color-ste-paid-text, $color-ste-shipped-text, $color-ste-balance_due-text, $color-ste-backorder-text, $color-ste-checkout-text, $color-ste-cart-text, $color-ste-address-text, $color-ste-delivery-text, $color-ste-payment-text, $color-ste-confirmation-text, $color-ste-canceled-text, $color-ste-ready-text, $color-ste-void-text, $color-ste-requires_authorization-text, $color-ste-active-text, $color-ste-inactive-text !default; // Available actions -$actions: edit, clone, remove, void, capture, save, cancel, mail !default; -$actions-bg-colors: $color-action-edit-bg, $color-action-clone-bg, $color-action-remove-bg, $color-action-void-bg, $color-action-capture-bg, $color-action-save-bg, $color-action-cancel-bg, $color-action-mail-bg !default; -$actions-brd-colors: $color-action-edit-brd, $color-action-clone-brd, $color-action-remove-brd, $color-action-void-brd, $color-action-capture-brd, $color-action-save-brd, $color-action-cancel-brd, $color-action-mail-brd !default; +$actions: edit, clone, remove, void, internal_void, capture, save, cancel, mail !default; +$actions-bg-colors: $color-action-edit-bg, $color-action-clone-bg, $color-action-remove-bg, $color-action-void-bg, $color-action-void-bg, $color-action-capture-bg, $color-action-save-bg, $color-action-cancel-bg, $color-action-mail-bg !default; +$actions-brd-colors: $color-action-edit-brd, $color-action-clone-brd, $color-action-remove-brd, $color-action-void-brd, $color-action-void-brd, $color-action-capture-brd, $color-action-save-brd, $color-action-cancel-brd, $color-action-mail-brd !default; // Sizes //-------------------------------------------------------------- diff --git a/app/webpacker/css/admin/shared/icons.scss b/app/webpacker/css/admin/shared/icons.scss index 13c3c516f3..cbbdfd1956 100644 --- a/app/webpacker/css/admin/shared/icons.scss +++ b/app/webpacker/css/admin/shared/icons.scss @@ -30,7 +30,8 @@ button[class*="icon-"] { } .icon-cancel:before, -.icon-void:before { +.icon-void:before, +.icon-internal_void:before { @extend .icon-remove, :before; } diff --git a/app/webpacker/css/admin/shared/tables.scss b/app/webpacker/css/admin/shared/tables.scss index 0511d0a9f4..fb86d32126 100644 --- a/app/webpacker/css/admin/shared/tables.scss +++ b/app/webpacker/css/admin/shared/tables.scss @@ -86,7 +86,8 @@ table { } } .icon-trash:hover, - .icon-void:hover { + .icon-void:hover, + .icon-internal_void:hover { background-color: $color-error; color: $white; } diff --git a/config/locales/en.yml b/config/locales/en.yml index 74ca7579a6..12e4bf20ad 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5198,7 +5198,9 @@ en: name: Customer credit description: Allow customer to pay with credit success: Payment with credit was sucessful + void_success: Credit void was sucessful order_payment_description: "Payment for order: %{order_number}" + order_void_description: "Refund for order: %{order_number}" errors: customer_not_found: Customer not found missing_payment: Missing payment diff --git a/spec/models/spree/payment_method/customer_credit_spec.rb b/spec/models/spree/payment_method/customer_credit_spec.rb index b7ce76adab..ac0fd3a8c8 100644 --- a/spec/models/spree/payment_method/customer_credit_spec.rb +++ b/spec/models/spree/payment_method/customer_credit_spec.rb @@ -8,9 +8,7 @@ RSpec.describe Spree::PaymentMethod::CustomerCredit do describe "#purchase" do let(:response) { subject.purchase(amount, nil, options) } - let!(:credit_payment_method) { - create(:customer_credit_payment_method) - } + let!(:credit_payment_method) { create(:customer_credit_payment_method) } let(:amount) { 1000 } # in cents let(:options) { { @@ -27,6 +25,7 @@ RSpec.describe Spree::PaymentMethod::CustomerCredit do expect(response).to be_a(ActiveMerchant::Billing::Response) expect(response.success?).to be(true) + expect(response.message).to eq("Payment with credit was sucessful") end it "debits the payment from customer the account transaction" do @@ -93,7 +92,84 @@ RSpec.describe Spree::PaymentMethod::CustomerCredit do let!(:credit_payment_method) { nil } around do |example| - # Customer is needed to create a purchase and a customer which is linked to an enterprise. + # Customer is needed to create a purchase and a customer is linked to an enterprise. + # That means FactoryBot will create an enterprise, so we disable the after create callback + # so that credit payment methods are not created. + Enterprise.skip_callback(:create, :after, :add_credit_payment_method) + example.run + Enterprise.set_callback(:create, :after, :add_credit_payment_method) + end + + it "returns an error" do + expect(response.success?).to be(false) + expect(response.message).to eq("Credit payment method is missing") + end + end + end + + describe "#void" do + let(:response) { subject.void(amount, nil, options) } + let(:amount) { 1500 } # in cents + let(:options) { + { + customer_id: customer.id, + payment_id: payment.id, + order_number: "R023075164" + } + } + let(:customer) { create(:customer) } + let!(:payment) { create(:payment, payment_method: credit_payment_method) } + let!(:credit_payment_method) { create(:customer_credit_payment_method) } + + it "returns a success response" do + expect(response).to be_a(ActiveMerchant::Billing::Response) + expect(response.success?).to be(true) + expect(response.message).to eq("Credit void was sucessful") + end + + it "credits the payment to customer the account transaction" do + expect(response.success?).to be(true) + + transaction = customer.customer_account_transactions.last + expect(transaction.amount).to eq(15.00) + expect(transaction.payment_method).to be_a(Spree::PaymentMethod::CustomerCredit) + expect(transaction.payment).to eq(payment) + expect(transaction.description).to eq("Refund for order: R023075164") + end + + context "when customer doesn't exist" do + let(:customer) { nil } + let(:options) { + { + customer_id: -1, + payment_id: payment.id + } + } + + it "returns an error" do + expect(response.success?).to be(false) + expect(response.message).to eq("Customer not found") + end + end + + context "when payment is missing" do + let(:options) { + { + customer_id: customer.id, + } + } + + it "returns an error" do + expect(response.success?).to be(false) + expect(response.message).to eq("Missing payment") + end + end + + context "when credit payment method is not configured" do + let!(:credit_payment_method) { nil } + + around do |example| + # Customer is needed to create a purchase and a customer is linked to an enterprise. # That means FactoryBot will create an enterprise, so we disable the after create callback # so that credit payment methods are not created. Enterprise.skip_callback(:create, :after, :add_credit_payment_method) diff --git a/spec/models/spree/payment_spec.rb b/spec/models/spree/payment_spec.rb index 89e47bb333..f7b2bb1a3e 100644 --- a/spec/models/spree/payment_spec.rb +++ b/spec/models/spree/payment_spec.rb @@ -943,6 +943,83 @@ RSpec.describe Spree::Payment do end end + describe "internal_void!" do + let(:order) { create(:order, customer:) } + let(:customer) { create(:customer) } + let(:payment_method) { + create(:customer_credit_payment_method, distributors: [create(:distributor_enterprise)]) + } + let(:success_response) do + instance_double( + ActiveMerchant::Billing::Response, + success?: true, + authorization: nil, + ) + end + let(:options) { + { customer_id: customer.id, payment_id: payment.id, order_number: payment.order.number } + } + + before do + allow(payment_method).to receive(:void).and_return(success_response) + end + + it "calls void on the internal payment method" do + expect(payment_method).to receive(:void).with( + amount_in_cents, nil, options + ).and_return(success_response) + + payment.internal_void! + end + + it "logs the response" do + expect(payment).to receive(:record_response) + + payment.internal_void! + end + + context "when successful" do + before do + allow(payment.payment_method).to receive(:void).with( + amount_in_cents, nil, options + ).and_return(success_response) + end + + it "voids the payment" do + allow(payment).to receive(:record_response) + + expect { payment.internal_void! }.to change { payment.state }.to("void") + end + end + + context "when unsuccessful" do + before do + allow(payment_method).to receive(:void).and_return(failed_response) + end + + it "does not create void payment" do + # Instanciate payment so our count expectation works as expected + payment + expect { payment.internal_void! } + .to raise_error(Spree::Core::GatewayError) + .and change { Spree::Payment.count }.by(0) + end + + it "raises an error" do + expect { payment.internal_void! }.to raise_error(Spree::Core::GatewayError) + end + end + + context "when payment already voided" do + it "does nothing" do + payment.void! + expect(payment_method).not_to receive(:void) + + payment.internal_void! + end + end + end + describe "applying transaction fees" do let!(:order) { create(:order) } let!(:line_item) { create(:line_item, order:, quantity: 3, price: 5.00) }