Compare commits

...

18 Commits

Author SHA1 Message Date
Ahmed Ejaz
4a66984ec4 Merge pull request #14084 from chahmedejaz/bugfix/14081-delete-user
Fix authorization for removing enterprise managers for non-admins
2026-03-26 05:10:36 +05:00
Ahmed Ejaz
ac716150eb Merge pull request #14091 from chahmedejaz/bugfix/14015-products-search-timeout-on-subscriptions
Fix Products search timing out when creating a new subscription
2026-03-25 20:49:45 +05:00
Ahmed Ejaz
2fe28d1707 Merge pull request #14101 from mkllnk/taler-currency
Use real currency for Taler payments unless using demo backend
2026-03-25 20:44:44 +05:00
Rachel Arnould
dcf3ab74b8 Merge pull request #13962 from mkllnk/taler-credit
Credit customers via Taler
2026-03-25 15:24:06 +01:00
Maikel Linke
fe0c6a4deb Use real currency for Taler payments unless using demo backend. 2026-03-25 15:09:43 +11:00
Ahmed Ejaz
fc123b38b4 Add comment for outgroing exchange variants 2026-03-24 03:46:17 +05:00
Ahmed Ejaz
1ff665a33a Refactor permitted producer IDs and outgoing exchange variant IDs queries for improved performance 2026-03-24 02:24:40 +05:00
Ahmed Ejaz
715a8f421a 14081: fix permission issue for deleting manager 2026-03-21 03:38:38 +05:00
Maikel Linke
9961578fc1 Don't offer to void a refund 2026-03-16 12:56:38 +11:00
Maikel Linke
53c2ef53d5 Call #credit with right arguments 2026-03-16 12:56:22 +11:00
Maikel Linke
7619062ad2 Revert "Move payment action logic to payment"
This reverts commit fdd22bc097.

And it adds the now needed `can_credit?` method to Taler.
It's just a duplicate.
2026-03-11 11:32:08 +11:00
Maikel Linke
fb2dfed6bf Pass amount to payment mailer
We now include the refunded amount in the email to the customer. When
voiding a payment, it's the full amount of the payment. When giving back
credit then it's only the money that has been paid too much.
2026-03-11 10:58:30 +11:00
Maikel Linke
cf53ac1990 Add credit action to Taler 2026-03-11 10:58:30 +11:00
Maikel Linke
fdd22bc097 Move payment action logic to payment
At closer inspection, almost all logic around which payment actions to
display involves only the state of the payment. So I moved the logic
there.

We now have one list of all possible actions supported by the UX. Then
payment methods can declare a list of supported actions. If that's
conditional, they can implement the conditions themselves. The payment
model itself then still filters the actions based on its state.
2026-03-11 10:58:30 +11:00
Maikel Linke
956c4a27c2 Remove unnecessary guard clause
Previously, payment actions that were listed without an associated
`can_?` method were interpreted as supported. All payment methods are
implementing all `can_?` methods for listed actions though. And I think
that new payment methods should explicitely implement all `can_?`
methods instead of relying on this hidden logic.
2026-03-11 10:58:30 +11:00
Maikel Linke
2b32f6b909 Make payment method source of truth of supported actions 2026-03-11 10:58:30 +11:00
Maikel Linke
303b91af5e Move list of payment actions from card to gateway
We currently ask the credit card first which payment actions like "void"
it supports. But all the logic is not card specifc. It depends on the
payment method which actions it supports.

And instead of having two different classes potentially being the source
of truth for actions, I prefer leaving that responsibility with exactly
one class, the payment method.

I'll move the `can_?` methods next.
2026-03-11 10:58:30 +11:00
Maikel Linke
ce96b58800 Remove useless setting of payment amount
It gets overridden later anyway.
2026-03-11 10:58:25 +11:00
18 changed files with 273 additions and 114 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

@@ -197,6 +197,10 @@ module Spree
can [:admin, :index, :destroy], :oidc_setting
can [:admin, :create], Voucher
can [:admin, :destroy], EnterpriseRole do |enterprise_role|
enterprise_role.enterprise.owner_id == user.id
end
end
def add_product_management_abilities(user)

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

@@ -18,15 +18,27 @@ module Spree
# - backend_url: https://backend.demo.taler.net/instances/sandbox
# - api_key: sandbox
class Taler < PaymentMethod
# Demo backend instances will use the KUDOS currency.
DEMO_PREFIX = "https://backend.demo.taler.net/instances"
preference :backend_url, :string
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 +80,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 +111,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
@@ -96,7 +126,7 @@ module Spree
def create_taler_order(payment)
# We are ignoring currency for now so that we can test with the
# current demo backend only working with the KUDOS currency.
taler_amount = "KUDOS:#{payment.amount}"
taler_amount = "#{currency(payment)}:#{payment.amount}"
urls = Rails.application.routes.url_helpers
fulfillment_url = urls.payment_gateways_confirm_taler_url(payment_id: payment.id)
taler_order.create(
@@ -113,6 +143,12 @@ module Spree
id:,
)
end
def currency(payment)
return "KUDOS" if preferred_backend_url.starts_with?(DEMO_PREFIX)
payment.order.currency
end
end
end
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

@@ -30,17 +30,19 @@ module OrderManagement
other_permitted_producer_ids = EnterpriseRelationship.joins(:parent)
.permitting(distributor.id).with_permission(:add_to_order_cycle)
.merge(Enterprise.is_primary_producer)
.pluck(:parent_id)
.select(:parent_id)
# Append to the potentially gigantic array instead of using union, which creates a new array
# The db IN statement won't care if there's a duplicate.
other_permitted_producer_ids << distributor.id
Enterprise.where(id: distributor.id)
.select(:id)
.or(Enterprise.where(id: other_permitted_producer_ids))
end
def self.outgoing_exchange_variant_ids(distributor)
ExchangeVariant.select("DISTINCT exchange_variants.variant_id").joins(:exchange)
# DISTINCT is not required here since this subquery is used within an IN clause,
# where duplicate values do not impact the result.
ExchangeVariant.joins(:exchange)
.where(exchanges: { incoming: false, receiver_id: distributor.id })
.pluck(:variant_id)
.select(:variant_id)
end
end
end

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

@@ -12,8 +12,8 @@ RSpec.describe Spree::PaymentMethod::Taler do
let(:backend_url) { "https://backend.demo.taler.net/instances/sandbox" }
let(:token_url) { "#{backend_url}/private/token" }
describe "#external_payment_url", vcr: true do
it "creates an order reference and retrieves a URL to pay at" do
describe "#external_payment_url" do
it "creates an order reference and retrieves a URL to pay at", vcr: true do
order = create(:order_ready_for_confirmation, payment_method: taler)
url = subject.external_payment_url(order:)
@@ -23,6 +23,26 @@ RSpec.describe Spree::PaymentMethod::Taler do
payment = order.payments.last.reload
expect(payment.response_code).to match "20...[0-9A-Z-]{17}$"
end
it "creates the Taler order with the right currency" do
order = create(:order_ready_for_confirmation, payment_method: taler)
backend_url = "https://taler.example.com"
token_url = "https://taler.example.com/private/token"
order_url = "https://taler.example.com/private/orders"
taler = Spree::PaymentMethod::Taler.new(
preferred_backend_url: "https://taler.example.com",
preferred_api_key: "sandbox",
)
stub_request(:post, token_url).to_return(body: { token: "1234" }.to_json)
stub_request(:post, order_url)
.with(body: /"amount":"AUD:10.0"/)
.to_return(body: { order_id: "one" }.to_json)
url = taler.external_payment_url(order:)
expect(url).to eq "#{backend_url}/orders/one"
end
end
describe "#purchase" do
@@ -56,6 +76,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

@@ -885,6 +885,47 @@ RSpec.describe '
end
end
end
describe "removing enterprise managers" do
let(:existing_user) { create(:user) }
before do
distributor1.users << existing_user
login_as logged_in_user
visit edit_admin_enterprise_path(distributor1)
scroll_to(:bottom)
within ".side_menu" do
find(:link, "Users").trigger("click")
end
end
context "as the enterprise owner" do
let(:logged_in_user) { distributor1.owner }
it 'removes the manager as enterprise owner' do
expect(page).to have_content existing_user.email
within "#manager-#{existing_user.id}" do
accept_confirm do
page.find("a.icon-trash").click
end
end
expect(page).not_to have_content existing_user.email
end
end
context "as the enterprise manager" do
let(:logged_in_user) { existing_user }
it "is unable delete any other manager" do
expect(page).to have_content existing_user.email
within('.edit_enterprise') do
expect(page).not_to have_selector('a.icon-trash')
end
end
end
end
end
context "changing package" do

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

View File

@@ -370,6 +370,7 @@ RSpec.describe "As a consumer, I want to checkout my order" do
Spree::PaymentMethod::Taler.create!(
name: "Taler",
environment: "test",
preferred_backend_url: "https://taler.example.com/",
distributors: [distributor]
)
end