mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-02-05 22:26:07 +00:00
1
Gemfile
1
Gemfile
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
16
app/controllers/payment_gateways/taler_controller.rb
Normal file
16
app/controllers/payment_gateways/taler_controller.rb
Normal 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
|
||||
@@ -14,6 +14,7 @@ module Spree
|
||||
Spree::Gateway::PayPalExpress
|
||||
Spree::Gateway::StripeSCA
|
||||
Spree::PaymentMethod::Check
|
||||
Spree::PaymentMethod::Taler
|
||||
}.freeze
|
||||
|
||||
def create
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
94
app/models/spree/payment_method/taler.rb
Normal file
94
app/models/spree/payment_method/taler.rb
Normal 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
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
209
spec/fixtures/vcr_cassettes/payment_gateways/taler/_id/completes_the_order.yml
vendored
Normal file
209
spec/fixtures/vcr_cassettes/payment_gateways/taler/_id/completes_the_order.yml
vendored
Normal file
File diff suppressed because one or more lines are too long
52
spec/models/spree/payment_method/taler_spec.rb
Normal file
52
spec/models/spree/payment_method/taler_spec.rb
Normal 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
|
||||
33
spec/requests/payment_gateways/taler_spec.rb
Normal file
33
spec/requests/payment_gateways/taler_spec.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user