Merge pull request #13962 from mkllnk/taler-credit

Credit customers via Taler
This commit is contained in:
Rachel Arnould
2026-03-25 15:24:06 +01:00
committed by GitHub
14 changed files with 187 additions and 105 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {},

View File

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