Merge pull request #13883 from mkllnk/taler

Add Taler payment method
This commit is contained in:
Rachel Arnould
2026-02-03 12:30:00 +01:00
committed by GitHub
17 changed files with 666 additions and 5 deletions

View File

@@ -58,6 +58,7 @@ gem 'stringex', '~> 2.8.5', require: false
gem 'paypal-sdk-merchant', '1.117.2'
gem 'stripe', '~> 15'
gem "taler"
gem 'devise'
gem 'devise-encryptable'

View File

@@ -884,6 +884,7 @@ GEM
faraday (~> 2.0)
faraday-follow_redirects
sysexits (1.2.0)
taler (0.1.0)
temple (0.10.4)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
@@ -1100,6 +1101,7 @@ DEPENDENCIES
stimulus_reflex_testing!
stringex (~> 2.8.5)
stripe (~> 15)
taler
turbo-rails
turbo_power
undercover

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
module PaymentGateways
class TalerController < BaseController
include OrderCompletion
# The Taler merchant backend has taken the payment.
# Now we just need to confirm that and update our local database
# before finalising the order.
def confirm
payment = Spree::Payment.find(params[:payment_id])
@order = payment.order
process_payment_completion!
end
end
end

View File

@@ -14,6 +14,7 @@ module Spree
Spree::Gateway::PayPalExpress
Spree::Gateway::StripeSCA
Spree::PaymentMethod::Check
Spree::PaymentMethod::Taler
}.freeze
def create

View File

@@ -95,8 +95,8 @@ class Invoice
def display_line_item_tax_rate(item)
all_tax_adjustments.select { |a|
a.adjustable.type == 'Spree::LineItem' && a.adjustable.id == item.id
}.map(&:originator).map { |tr|
number_to_percentage(tr.amount * 100, precision: 1)
}.map(&:originator).map(&:amount).sort.map { |amount|
number_to_percentage(amount * 100, precision: 1)
}.join(", ")
end

View File

@@ -183,6 +183,7 @@ module Spree
options.merge!({ billing_address: order.bill_address.try(:active_merchant_hash),
shipping_address: order.ship_address.try(:active_merchant_hash) })
options.merge!(payment: self)
options
end

View File

@@ -0,0 +1,94 @@
# frozen_string_literal: true
require "taler"
module Spree
class PaymentMethod
# GNU Taler is a distributed, open source payment system.
# You need a hosted Taler backend server to process payments.
#
# For testing, you can use the official demo backend:
#
# - Merchant UX: https://backend.demo.taler.net
# - Username: sandbox
# - Password: sandbox
#
# Configure this payment method for testing with:
#
# - backend_url: https://backend.demo.taler.net/instances/sandbox
# - api_key: sandbox
class Taler < PaymentMethod
preference :backend_url, :string
preference :api_key, :password
# Name of the view to display during checkout
def method_type
"check" # empty view
end
def external_gateway?
true
end
# The backend provides this URL. It can look like this:
# https://backend.demo.taler.net/instances/blog/orders/2026..?token=S8Y..&session_id=b0b..
def external_payment_url(options)
order = options.fetch(:order)
payment = load_payment(order)
payment.source ||= self
payment.response_code ||= create_taler_order(payment)
payment.redirect_auth_url ||= fetch_order_url(payment)
payment.save! if payment.changed?
payment.redirect_auth_url
end
# Main method called by Spree::Payment::Processing during checkout
# when the user is redirected back to the app.
#
# The payment has already been made and we need to verify the success.
def purchase(_money, _source, gateway_options)
payment = gateway_options[:payment]
return unless payment.response_code
taler_order = client.fetch_order(payment.response_code)
status = taler_order["order_status"]
success = (status == "paid")
message = I18n.t(status, default: status, scope: "taler.order_status")
ActiveMerchant::Billing::Response.new(success, message)
end
private
def load_payment(order)
order.payments.checkout.where(payment_method: self).last
end
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}"
urls = Rails.application.routes.url_helpers
new_order = client.create_order(
taler_amount,
I18n.t("payment_method_taler.order_summary"),
urls.payment_gateways_confirm_taler_url(payment_id: payment.id),
)
new_order["order_id"]
end
def fetch_order_url(payment)
order = client.fetch_order(payment.response_code)
order["order_status_url"]
end
def client
@client ||= ::Taler::Client.new(preferred_backend_url, preferred_api_key)
end
end
end
end

View File

@@ -11,7 +11,7 @@ module PermittedAttributes
[:name, :description, :type, :active,
:environment, :display_on, :tag_list,
:preferred_enterprise_id, :preferred_server, :preferred_login, :preferred_password,
:calculator_type, :preferred_api_key,
:calculator_type, :preferred_api_key, :preferred_backend_url,
:preferred_signature, :preferred_solution, :preferred_landing_page, :preferred_logourl,
:preferred_test_mode, :calculator_type, { distributor_ids: [] },
{ calculator_attributes: PermittedAttributes::Calculator.attributes }]

View File

@@ -288,6 +288,9 @@ en:
success_code:
disconnected: "Stripe account disconnected."
taler:
order_status:
claimed: "The payment request expired. Please try again."
activemodel:
errors:
messages:
@@ -3502,6 +3505,8 @@ en:
payment_processing_failed: "Payment could not be processed, please check the details you entered"
payment_method_not_supported: "That payment method is unsupported. Please choose another one."
payment_updated: "Payment Updated"
payment_method_taler:
order_summary: "Open Food Network order"
cannot_perform_operation: "Could not update the payment"
action_required: "Action required"
tag_rules: "Tag Rules"
@@ -4344,6 +4349,10 @@ en:
thumbnail: "Thumbnail"
back_to_images_list: "Back To Images List"
# Attributes of the Taler payment gateway
backend_url: "Backend URL"
api_key: "API key"
# TODO: remove `email` key once we get to Spree 2.0
email: Email
# TODO: remove 'account_updated' key once we get to Spree 2.0
@@ -4664,10 +4673,9 @@ en:
providers:
provider: "Provider"
check: "Cash/EFT/etc. (payments for which automatic validation is not required)"
pin: "Pin Payments"
paypalexpress: "PayPal Express"
stripeconnect: "Stripe"
stripesca: "Stripe SCA"
taler: "Taler"
payments:
source_forms:
stripe:

View File

@@ -82,6 +82,8 @@ Openfoodnetwork::Application.routes.draw do
get "/stripe/confirm", to: "stripe#confirm", as: :confirm_stripe
get "/stripe/authorize/:order_number", to: "stripe#authorize", as: :authorize_stripe
get "/taler/:payment_id", to: "taler#confirm", as: :confirm_taler
end
get '/checkout', to: 'checkout#edit'

View File

@@ -19,6 +19,7 @@ RSpec.describe Spree::Admin::PaymentMethodsController do
expect(providers).to eq %w[
Spree::Gateway::PayPalExpress
Spree::PaymentMethod::Check
Spree::PaymentMethod::Taler
]
end

View File

@@ -0,0 +1,106 @@
---
http_interactions:
- request:
method: post
uri: https://backend.demo.taler.net/instances/sandbox/private/orders
body:
encoding: UTF-8
string: '{"order":{"amount":"KUDOS:5","summary":"OFN Order","fulfillment_url":"https://ofn.example.net"},"create_token":false}'
headers:
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept:
- application/json
User-Agent:
- Taler Ruby
Content-Type:
- application/json
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx/1.26.3
Date:
- Thu, 15 Jan 2026 04:18:27 GMT
Content-Type:
- application/json
Content-Length:
- '42'
Connection:
- keep-alive
Access-Control-Allow-Origin:
- "*"
Access-Control-Expose-Headers:
- "*"
Cache-Control:
- no-store
Via:
- 1.1 Caddy
Strict-Transport-Security:
- max-age=63072000; includeSubDomains; preload
body:
encoding: UTF-8
string: |-
{
"order_id": "2026.015-02T676V4VARMT"
}
recorded_at: Thu, 15 Jan 2026 04:18:28 GMT
- request:
method: get
uri: https://backend.demo.taler.net/instances/sandbox/private/orders/2026.015-02T676V4VARMT
body:
encoding: US-ASCII
string: ''
headers:
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept:
- application/json
User-Agent:
- Taler Ruby
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx/1.26.3
Date:
- Thu, 15 Jan 2026 04:18:29 GMT
Content-Type:
- application/json
Content-Length:
- '336'
Connection:
- keep-alive
Access-Control-Allow-Origin:
- "*"
Access-Control-Expose-Headers:
- "*"
Cache-Control:
- no-store
Via:
- 1.1 Caddy
Strict-Transport-Security:
- max-age=63072000; includeSubDomains; preload
body:
encoding: ASCII-8BIT
string: |-
{
"taler_pay_uri": "taler://pay/backend.demo.taler.net/instances/sandbox/2026.015-02T676V4VARMT/",
"order_status_url": "https://backend.demo.taler.net/instances/sandbox/orders/2026.015-02T676V4VARMT",
"order_status": "unpaid",
"total_amount": "KUDOS:5",
"summary": "OFN Order",
"creation_time": {
"t_s": 1768450707
}
}
recorded_at: Thu, 15 Jan 2026 04:18:29 GMT
recorded_with: VCR 6.3.1

View File

@@ -0,0 +1,106 @@
---
http_interactions:
- request:
method: post
uri: https://backend.demo.taler.net/instances/sandbox/private/orders
body:
encoding: UTF-8
string: '{"order":{"amount":"KUDOS:10.0","summary":"Open Food Network order","fulfillment_url":"http://test.host/payment_gateways/taler/61"},"create_token":false}'
headers:
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept:
- application/json
User-Agent:
- Taler Ruby
Content-Type:
- application/json
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx/1.26.3
Date:
- Thu, 22 Jan 2026 04:43:32 GMT
Content-Type:
- application/json
Content-Length:
- '42'
Connection:
- keep-alive
Access-Control-Allow-Origin:
- "*"
Access-Control-Expose-Headers:
- "*"
Cache-Control:
- no-store
Via:
- 1.1 Caddy
Strict-Transport-Security:
- max-age=63072000; includeSubDomains; preload
body:
encoding: UTF-8
string: |-
{
"order_id": "2026.022-0284X4GE8WKMJ"
}
recorded_at: Thu, 22 Jan 2026 04:43:33 GMT
- request:
method: get
uri: https://backend.demo.taler.net/instances/sandbox/private/orders/2026.022-0284X4GE8WKMJ
body:
encoding: US-ASCII
string: ''
headers:
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept:
- application/json
User-Agent:
- Taler Ruby
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx/1.26.3
Date:
- Thu, 22 Jan 2026 04:43:34 GMT
Content-Type:
- application/json
Content-Length:
- '351'
Connection:
- keep-alive
Access-Control-Allow-Origin:
- "*"
Access-Control-Expose-Headers:
- "*"
Cache-Control:
- no-store
Via:
- 1.1 Caddy
Strict-Transport-Security:
- max-age=63072000; includeSubDomains; preload
body:
encoding: ASCII-8BIT
string: |-
{
"taler_pay_uri": "taler://pay/backend.demo.taler.net/instances/sandbox/2026.022-0284X4GE8WKMJ/",
"order_status_url": "https://backend.demo.taler.net/instances/sandbox/orders/2026.022-0284X4GE8WKMJ",
"order_status": "unpaid",
"total_amount": "KUDOS:10",
"summary": "Open Food Network order",
"creation_time": {
"t_s": 1769057012
}
}
recorded_at: Thu, 22 Jan 2026 04:43:34 GMT
recorded_with: VCR 6.3.1

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,52 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Spree::PaymentMethod::Taler do
subject(:taler) {
Spree::PaymentMethod::Taler.new(
preferred_backend_url: backend_url,
preferred_api_key: "sandbox",
)
}
let(:backend_url) { "https://backend.demo.taler.net/instances/sandbox" }
describe "#external_payment_url", vcr: true do
it "retrieves a URL to pay at and stores it on the payment record" do
order = create(:order_ready_for_confirmation, payment_method: taler)
url = subject.external_payment_url(order:)
expect(url).to start_with backend_url
payment = order.payments.last.reload
expect(payment.response_code).to match "2026.022-0284X4GE8WKMJ"
expect(payment.redirect_auth_url).to eq url
end
end
describe "#purchase" do
let(:money) { 100 }
let(:source) { taler }
let(:payment) { build(:payment, response_code: "taler-order-7") }
let(:order_url) { "#{backend_url}/private/orders/taler-order-7" }
it "returns an ActiveMerchant response" do
order_status = "paid"
stub_request(:get, order_url).to_return(body: { order_status: }.to_json)
response = taler.purchase(nil, nil, payment:)
expect(response.success?).to eq true
expect(response.message).to eq "paid"
end
it "translates error messages" do
order_status = "claimed"
stub_request(:get, order_url).to_return(body: { order_status: }.to_json)
response = taler.purchase(nil, nil, payment:)
expect(response.success?).to eq false
expect(response.message).to eq "The payment request expired. Please try again."
end
end
end

View File

@@ -0,0 +1,33 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe "/payment_gateways/taler/:id" do
it "completes the order", :vcr do
shop = create(:distributor_enterprise)
taler = Spree::PaymentMethod::Taler.create!(
name: "Taler",
environment: "test",
distributors: [shop],
preferred_backend_url: "https://backend.demo.taler.net/instances/sandbox",
preferred_api_key: "sandbox",
)
order = create(:order_ready_for_confirmation, payment_method: taler)
payment = Spree::Payment.last
payment.update!(
source: taler,
payment_method: taler,
# This is a Taler order id of a paid order on the test backend.
# It may be gone when you try to re-record this test.
# To create a new order, you need user interaction with a wallet.
response_code: "2026.020-03R3ETNZZ0DVA",
redirect_auth_url: "https://merchant.backend.where-we-paid.com",
)
get payment_gateways_confirm_taler_path(payment_id: payment.id)
expect(response).to redirect_to(order_path(order, order_token: order.token))
payment.reload
expect(payment.state).to eq "completed"
end
end

View File

@@ -340,6 +340,35 @@ RSpec.describe "As a consumer, I want to checkout my order" do
it_behaves_like "different payment methods", "Stripe SCA"
end
context "Taler" do
let!(:taler) do
Spree::PaymentMethod::Taler.create!(
name: "Taler",
environment: "test",
distributors: [distributor]
)
end
before do
# Shortcut the user interaction and go straight to our
# confirmation action.
taler_order_id = { "order_id" => "taler-order:123" }
expect_any_instance_of(Taler::Client)
.to receive(:create_order).and_return(taler_order_id)
# And fake the payment status to avoid user interaction.
allow_any_instance_of(Taler::Client)
.to receive(:fetch_order) do
payment = Spree::Payment.last
url = payment_gateways_confirm_taler_path(payment_id: payment.id)
{ "order_status_url" => url, "order_status" => "paid" }
end
end
it_behaves_like "different payment methods", "Taler"
end
end
end