Add ability to "void" a customer credit payment

Voiding the payment will refund the credit used to the customer.
This commit is contained in:
Gaetan Craig-Riou
2026-02-20 11:23:47 +11:00
parent 5e4cd4d51d
commit d5bd8fa086
10 changed files with 229 additions and 11 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
//--------------------------------------------------------------

View File

@@ -30,7 +30,8 @@ button[class*="icon-"] {
}
.icon-cancel:before,
.icon-void:before {
.icon-void:before,
.icon-internal_void:before {
@extend .icon-remove, :before;
}

View File

@@ -86,7 +86,8 @@ table {
}
}
.icon-trash:hover,
.icon-void:hover {
.icon-void:hover,
.icon-internal_void:hover {
background-color: $color-error;
color: $white;
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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) }