Add ability to use payment with credit

Currently it works like any other payment method you can select on
checkout. It will eventually be added automatically to the order, when a
customer has credit available.
This commit is contained in:
Gaetan Craig-Riou
2026-02-08 14:37:24 +11:00
parent 7ab33d86f1
commit 29a8a6641c
6 changed files with 319 additions and 2 deletions

View File

@@ -4,6 +4,8 @@ module Spree
class Payment < ApplicationRecord
module Processing
def process!
return internal_purchase! if payment_method.internal?
return unless validate!
purchase!
@@ -20,6 +22,17 @@ module Spree
end
end
def internal_purchase!
started_processing!
options = { customer_id: order.customer_id, payment_id: id, order_number: order.number }
response = payment_method.purchase(
(amount * 100).round,
nil,
options
)
handle_response(response, :complete, :failure)
end
def authorize!(return_url = nil)
started_processing!
gateway_action(source, :authorize, :pend, return_url:)
@@ -248,6 +261,7 @@ module Spree
end
logger.error(Spree.t(:gateway_error))
logger.error(" #{error.to_yaml}")
# TODO why is this not captured ?
raise Core::GatewayError, text
end

View File

@@ -3,9 +3,82 @@
module Spree
class PaymentMethod
class CustomerCredit < Spree::PaymentMethod
def name
try_translating(super)
end
def description
try_translating(super)
end
# Main method called by Spree::Payment::Processing during checkout
# - amount is in cents
# - options: {
# customer_id:, payment_id:, order_number:
# }
def purchase(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?
payment_method = Spree::PaymentMethod.find_by(name: "credit_payment_method.name")
return error_response("credit_payment_method_missing") if payment_method.nil?
available_credit = customer.customer_account_transactions.last&.balance
return error_response("no_credit_available") if available_credit.nil?
return error_response("not_enough_credit_available") if calculated_amount > available_credit
customer.with_lock do
description = I18n.t(
"order_payment_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("success", scope: "credit_payment_method")
ActiveMerchant::Billing::Response.new(true, message)
end
def method_type
"check" # empty view
end
def source_required?
false
end
def internal?
true
end
private
def error_response(translation_key)
message = I18n.t(translation_key, scope: "credit_payment_method.errors")
ActiveMerchant::Billing::Response.new(false, message)
end
def currency
CurrentConfig.get(:currency)
end
def try_translating(value)
I18n.t!(value)
rescue I18n::MissingTranslationData
value
end
end
end
end

View File

@@ -5192,5 +5192,16 @@ en:
api_payment_method:
name: API customer credit
description: Used to credit customer via customer account transactions endpoint
credit_payment_method:
name: Customer credit
description: Allow customer to pay with credit
success: Payment with credit was sucessful
order_payment_description: "Payment for order: %{order_number}"
errors:
customer_not_found: Customer not found
missing_payment: Missing payment
credit_payment_method_missing: Credit payment method is missing
no_credit_available: No credit available
not_enough_credit_available: Not enough credit available
customer_account_transaction:
account_creation: Account creation

View File

@@ -29,4 +29,12 @@ FactoryBot.define do
distributor { FactoryBot.create(:distributor_enterprise) }
payment_method { FactoryBot.create(:payment_method) }
end
factory :customer_credit_payment_method, class: Spree::PaymentMethod::CustomerCredit do
name { "credit_payment_method.name" }
description { "credit_payment_method.description" }
environment { 'test' }
distributors { [Enterprise.is_distributor.first || FactoryBot.create(:distributor_enterprise)] }
end
end

View File

@@ -0,0 +1,140 @@
# frozen_string_literal: true
require "spec_helper"
RSpec.describe Spree::PaymentMethod::CustomerCredit do
subject { build(:customer_credit_payment_method) }
describe "#name" do
subject { build(:customer_credit_payment_method, name:) }
let(:name) { "credit_payment_method.name" }
it "translate the name" do
expect(subject.name).to eq("Customer credit")
end
context "when not a tranlatable string" do
let(:name) { "customer credit payment" }
it "falls back to no translation" do
expect(subject.name).to eq("customer credit payment")
end
end
end
describe "#description" do
subject { build(:customer_credit_payment_method, description:) }
let(:description) { "credit_payment_method.description" }
it "translate the name" do
expect(subject.description).to eq("Allow customer to pay with credit")
end
context "when not a tranlatable string" do
let(:description) { "Payment method to allow customer to pay with credit" }
it "falls back to no translation" do
expect(subject.description).to eq("Payment method to allow customer to pay with credit")
end
end
end
describe "#purchase" do
let(:response) { subject.purchase(amount, nil, options) }
let!(:payment_method) {
create(:payment_method, name: CustomerAccountTransaction::DEFAULT_PAYMENT_METHOD_NAME)
}
let!(:credit_payment_method) {
create(:customer_credit_payment_method)
}
let(:amount) { 1000 } # 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) }
it "returns a success response" do
create(:customer_account_transaction, amount: 25.00, customer:)
expect(response).to be_a(ActiveMerchant::Billing::Response)
expect(response.success?).to be(true)
end
it "debits the payment from customer the account transaction" do
create(:customer_account_transaction, amount: 25.00, customer:)
expect(response.success?).to be(true)
transaction = customer.customer_account_transactions.last
expect(transaction.amount).to eq(-10.00)
expect(transaction.payment_method).to be_a(Spree::PaymentMethod::CustomerCredit)
expect(transaction.payment).to eq(payment)
expect(transaction.description).to eq("Payment for order: R023075164")
end
context "when not enough credit is available" do
let!(:customer_credit) { create(:customer_account_transaction, amount: 5.00, customer:) }
it "returns an error" do
expect(response.success?).to be(false)
expect(response.message).to eq("Not enough credit available")
end
it "doesn't debit the customer account transaction" do
expect(CustomerAccountTransaction.where(customer: customer).last).to eq(customer_credit)
end
end
context "when no credit available" do
it "returns an error" do
expect(response.success?).to be(false)
expect(response.message).to eq("No credit available")
end
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 }
it "returns an error" do
expect(response.success?).to be(false)
expect(response.message).to eq("Credit payment method is missing")
end
end
end
end

View File

@@ -107,7 +107,7 @@ RSpec.describe Spree::Payment do
allow(payment).to receive(:create_payment_profile).and_return(true)
end
context "#process!" do
describe "#process!" do
it "should call purchase!" do
payment = build_stubbed(:payment, payment_method:)
expect(payment).to receive(:purchase!)
@@ -141,6 +141,18 @@ RSpec.describe Spree::Payment do
payment.process!
end
end
context "with an internal payment method" do
let(:payment_method) {
create(:customer_credit_payment_method, distributors: [create(:distributor_enterprise)])
}
it "calls internal_purchase!" do
payment = build_stubbed(:payment, payment_method:)
expect(payment).to receive(:internal_purchase!)
payment.process!
end
end
end
context "#process_offline when payment is already authorized" do
@@ -236,7 +248,7 @@ RSpec.describe Spree::Payment do
end
end
context "purchase" do
describe "#purchase!" do
before do
allow(payment_method).to receive(:purchase).and_return(success_response)
end
@@ -288,6 +300,65 @@ RSpec.describe Spree::Payment do
end
end
describe "internal_purchase!" 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(:purchase).and_return(success_response)
end
it "calls purchase on the internal payment method" do
expect(payment_method).to receive(:purchase).with(
amount_in_cents, nil, options
).and_return(success_response)
payment.internal_purchase!
end
it "logs the response" do
expect(payment).to receive(:record_response)
payment.internal_purchase!
end
context "when successful" do
before do
expect(payment.payment_method).to receive(:purchase).with(
amount_in_cents, nil, options
).and_return(success_response)
end
it "makes payment complete" do
expect(payment).to receive(:complete!)
payment.internal_purchase!
end
end
context "when unsuccessful" do
it "makes payment failed" do
allow(payment_method).to receive(:purchase).and_return(failed_response)
expect(payment).to receive(:failure)
expect(payment).not_to receive(:pend)
expect { payment.internal_purchase! }.to raise_error(Spree::Core::GatewayError)
end
end
end
context "#capture" do
before do
allow(payment).to receive(:complete).and_return(true)