From fee951c05c081bcadcc49f90d1064b121a09ea97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 13 Feb 2024 10:52:55 +0100 Subject: [PATCH 1/7] Move past donations to partial --- .../contributions/donations/_list.html.erb | 25 +++++++++++++ .../contributions/donations/index.html.erb | 35 ++++--------------- 2 files changed, 31 insertions(+), 29 deletions(-) create mode 100644 app/views/contributions/donations/_list.html.erb diff --git a/app/views/contributions/donations/_list.html.erb b/app/views/contributions/donations/_list.html.erb new file mode 100644 index 0000000..8932dd3 --- /dev/null +++ b/app/views/contributions/donations/_list.html.erb @@ -0,0 +1,25 @@ + diff --git a/app/views/contributions/donations/index.html.erb b/app/views/contributions/donations/index.html.erb index e885055..2398fac 100644 --- a/app/views/contributions/donations/index.html.erb +++ b/app/views/contributions/donations/index.html.erb @@ -2,36 +2,13 @@ <%= render MainWithTabnavComponent.new(tabnav_partial: "shared/tabnav_contributions") do %>
+

+ Your financial contributions to the development and upkeep of Kosmos + software and services. +

<% if @donations.any? %> -

- Your financial contributions to the development and upkeep of Kosmos - software and services. -

- + <%= render partial: "contributions/donations/list", + locals: { donations: @donations } %> <% else %>

-- 2.25.1 From 69b3afb8f7e29d2af8a05742e1ee20a85c563b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 14 Feb 2024 10:44:47 +0100 Subject: [PATCH 2/7] DRY up btcpay and lndhub services Removing initialize methods from the main/manager class also allows for different iniitalizers in specific task services --- .../fetch_lightning_wallet_balance.rb | 2 +- .../fetch_onchain_wallet_balance.rb | 2 +- app/services/btcpay_manager_service.rb | 34 +++++++++++++------ app/services/lndhub.rb | 26 ++++++++------ app/services/lndhub_v2.rb | 4 +-- 5 files changed, 43 insertions(+), 25 deletions(-) diff --git a/app/services/btcpay_manager/fetch_lightning_wallet_balance.rb b/app/services/btcpay_manager/fetch_lightning_wallet_balance.rb index 96bbf00..3685b58 100644 --- a/app/services/btcpay_manager/fetch_lightning_wallet_balance.rb +++ b/app/services/btcpay_manager/fetch_lightning_wallet_balance.rb @@ -1,7 +1,7 @@ module BtcpayManager class FetchLightningWalletBalance < BtcpayManagerService def call - res = get "stores/#{store_id}/lightning/BTC/balance" + res = get "/lightning/BTC/balance" { confirmed_balance: res["offchain"]["local"].to_i / 1000 # msats to sats diff --git a/app/services/btcpay_manager/fetch_onchain_wallet_balance.rb b/app/services/btcpay_manager/fetch_onchain_wallet_balance.rb index e28f197..fb1f0dc 100644 --- a/app/services/btcpay_manager/fetch_onchain_wallet_balance.rb +++ b/app/services/btcpay_manager/fetch_onchain_wallet_balance.rb @@ -1,7 +1,7 @@ module BtcpayManager class FetchOnchainWalletBalance < BtcpayManagerService def call - res = get "stores/#{store_id}/payment-methods/onchain/BTC/wallet" + res = get "/payment-methods/onchain/BTC/wallet" { balance: (res["balance"].to_f * 100000000).to_i, # BTC to sats diff --git a/app/services/btcpay_manager_service.rb b/app/services/btcpay_manager_service.rb index d8fbf09..b91e897 100644 --- a/app/services/btcpay_manager_service.rb +++ b/app/services/btcpay_manager_service.rb @@ -2,23 +2,35 @@ # API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/ # class BtcpayManagerService < ApplicationService - attr_reader :base_url, :store_id, :auth_token - - def initialize - @base_url = Setting.btcpay_api_url - @store_id = Setting.btcpay_store_id - @auth_token = Setting.btcpay_auth_token - end - private - def get(endpoint) - res = Faraday.get("#{base_url}/#{endpoint}", {}, { + def base_url + @base_url ||= "#{Setting.btcpay_api_url}/stores/#{Setting.btcpay_store_id}" + end + + def auth_token + @auth_token ||= Setting.btcpay_auth_token + end + + def headers + { "Content-Type" => "application/json", "Accept" => "application/json", "Authorization" => "token #{auth_token}" - }) + } + end + def endpoint_url(path) + "#{base_url}/#{path.gsub(/^\//, '')}" + end + + def get(path) + res = Faraday.get endpoint_url(path), {}, headers + JSON.parse(res.body) + end + + def post(path, payload) + res = Faraday.post endpoint_url(path), payload.to_json, headers JSON.parse(res.body) end end diff --git a/app/services/lndhub.rb b/app/services/lndhub.rb index 44b7880..8fd9673 100644 --- a/app/services/lndhub.rb +++ b/app/services/lndhub.rb @@ -1,24 +1,20 @@ -class Lndhub +class Lndhub < ApplicationService attr_accessor :auth_token - def initialize - @base_url = ENV["LNDHUB_API_URL"] - end - - def post(endpoint, payload) + def post(path, payload) headers = { "Content-Type" => "application/json" } if auth_token headers.merge!({ "Authorization" => "Bearer #{auth_token}" }) end - res = Faraday.post "#{@base_url}/#{endpoint}", payload.to_json, headers + res = Faraday.post endpoint_url(path), payload.to_json, headers log_error(res) if res.status != 200 JSON.parse(res.body) end - def get(endpoint, auth_token) - res = Faraday.get("#{@base_url}/#{endpoint}", {}, { + def get(path, auth_token) + res = Faraday.get(endpoint_url(path), {}, { "Content-Type" => "application/json", "Accept" => "application/json", "Authorization" => "Bearer #{auth_token}" @@ -42,7 +38,7 @@ class Lndhub self.auth_token end - def balance(user_token=nil) + def fetch_balance(user_token=nil) get "balance", user_token || auth_token end @@ -72,4 +68,14 @@ class Lndhub Sentry.capture_message("Lndhub API request failed: #{res.body}") end end + + private + + def base_url + @base_url ||= Setting.lndhub_api_url + end + + def endpoint_url(path) + "#{base_url}/#{path.gsub(/^\//, '')}" + end end diff --git a/app/services/lndhub_v2.rb b/app/services/lndhub_v2.rb index b816fcb..c1298d9 100644 --- a/app/services/lndhub_v2.rb +++ b/app/services/lndhub_v2.rb @@ -1,13 +1,13 @@ class LndhubV2 < Lndhub - def post(endpoint, payload, options={}) + def post(path, payload, options={}) headers = { "Content-Type" => "application/json" } if auth_token headers.merge!({ "Authorization" => "Bearer #{auth_token}" }) elsif options[:admin_token] headers.merge!({ "Authorization" => "Bearer #{options[:admin_token]}" }) end - res = Faraday.post "#{@base_url}/#{endpoint}", payload.to_json, headers + res = Faraday.post endpoint_url(path), payload.to_json, headers log_error(res) if res.status != 200 JSON.parse(res.body) -- 2.25.1 From 26d613bdca9043194a5508bf8bb776490ebfbd82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 14 Feb 2024 10:47:27 +0100 Subject: [PATCH 3/7] Allow other controllers to access lndhub user balance --- app/controllers/application_controller.rb | 22 +++++++++++++ .../services/lightning_controller.rb | 31 ++++--------------- .../lndhub_manager/fetch_user_balance.rb | 12 +++++++ 3 files changed, 40 insertions(+), 25 deletions(-) create mode 100644 app/services/lndhub_manager/fetch_user_balance.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ff94797..a0da71a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -41,4 +41,26 @@ class ApplicationController < ActionController::Base def after_sign_in_path_for(user) session[:user_return_to] || root_path end + + def lndhub_authenticate(options={}) + if session[:ln_auth_token].present? && !options[:force_reauth] + @ln_auth_token = session[:ln_auth_token] + else + lndhub = Lndhub.new + auth_token = lndhub.authenticate(current_user) + session[:ln_auth_token] = auth_token + @ln_auth_token = auth_token + end + rescue => e + Sentry.capture_exception(e) if Setting.sentry_enabled? + end + + def lndhub_fetch_balance + @balance = LndhubManager::FetchUserBalance.call(auth_token: @ln_auth_token) + rescue AuthError + lndhub_authenticate(force_reauth: true) + raise if @fetch_balance_retried + @fetch_balance_retried = true + lndhub_fetch_balance + end end diff --git a/app/controllers/services/lightning_controller.rb b/app/controllers/services/lightning_controller.rb index 1253ab0..c02b91f 100644 --- a/app/controllers/services/lightning_controller.rb +++ b/app/controllers/services/lightning_controller.rb @@ -2,10 +2,11 @@ require "rqrcode" require "lnurl" class Services::LightningController < ApplicationController - before_action :authenticate_user! - before_action :authenticate_with_lndhub before_action :set_current_section - before_action :fetch_balance + before_action :require_service_available + before_action :authenticate_user! + before_action :lndhub_authenticate + before_action :lndhub_fetch_balance def index @wallet_setup_url = "lndhub://#{current_user.ln_account}:#{current_user.ln_password}@#{ENV['LNDHUB_PUBLIC_URL']}" @@ -55,32 +56,12 @@ class Services::LightningController < ApplicationController private - def authenticate_with_lndhub(options={}) - if session[:ln_auth_token].present? && !options[:force_reauth] - @ln_auth_token = session[:ln_auth_token] - else - lndhub = Lndhub.new - auth_token = lndhub.authenticate(current_user) - session[:ln_auth_token] = auth_token - @ln_auth_token = auth_token - end - rescue => e - Sentry.capture_exception(e) if Setting.sentry_enabled? - end - def set_current_section @current_section = :services end - def fetch_balance - lndhub = Lndhub.new - data = lndhub.balance @ln_auth_token - @balance = data["BTC"]["AvailableBalance"] rescue nil - rescue AuthError - authenticate_with_lndhub(force_reauth: true) - raise if @fetch_balance_retried - @fetch_balance_retried = true - fetch_balance + def require_service_available + http_status :not_found unless Setting.lndhub_enabled? end def fetch_transactions diff --git a/app/services/lndhub_manager/fetch_user_balance.rb b/app/services/lndhub_manager/fetch_user_balance.rb new file mode 100644 index 0000000..1bc0353 --- /dev/null +++ b/app/services/lndhub_manager/fetch_user_balance.rb @@ -0,0 +1,12 @@ +module LndhubManager + class FetchUserBalance < Lndhub + def initialize(auth_token:) + @auth_token = auth_token + end + + def call + data = fetch_balance(auth_token) + data["BTC"]["AvailableBalance"] rescue nil + end + end +end -- 2.25.1 From 079ee8833c2e1adcdce6de707f37336339ae9eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 14 Feb 2024 11:09:03 +0100 Subject: [PATCH 4/7] Implement bitcoin donations via BTCPay --- .env.example | 1 + .env.test | 1 + app/assets/stylesheets/components/buttons.css | 5 + .../main_with_tabnav_component.html.erb | 2 +- app/components/modal_component.html.erb | 2 +- app/components/notification_component.rb | 2 + app/controllers/admin/donations_controller.rb | 70 +++--- .../contributions/donations_controller.rb | 126 +++++++++- app/helpers/application_helper.rb | 4 - app/helpers/btcpay_helper.rb | 7 + app/jobs/btcpay_check_donation_job.rb | 24 ++ app/models/donation.rb | 25 +- app/models/setting.rb | 11 +- app/services/btcpay_manager/create_invoice.rb | 21 ++ .../btcpay_manager/fetch_exchange_rate.rb | 14 ++ app/services/btcpay_manager/fetch_invoice.rb | 14 ++ app/services/btcpay_manager_service.rb | 4 +- .../admin/donations/_donation.json.jbuilder | 2 - app/views/admin/donations/_form.html.erb | 18 +- app/views/admin/donations/index.html.erb | 10 +- app/views/admin/donations/index.json.jbuilder | 1 - app/views/admin/donations/show.html.erb | 14 +- app/views/admin/donations/show.json.jbuilder | 1 - .../contributions/donations/_bitcoin.html.erb | 36 +++ .../contributions/donations/_list.html.erb | 24 +- .../donations/_opencollective.html.erb | 6 + .../contributions/donations/index.html.erb | 48 ++-- .../status_unprocessable_entity.html.erb | 6 + config/routes.rb | 6 +- ...58_change_donation_amounts_and_currency.rb | 9 + .../20240214121049_add_new_donation_fields.rb | 7 + ...6124640_add_payment_status_to_donations.rb | 8 + db/schema.rb | 10 +- spec/features/contributions/donations_spec.rb | 35 +++ spec/fixtures/btcpay/create_invoice.rb | 32 +++ .../btcpay/lightning_eur_settled_invoice.json | 41 +++ .../lightning_eur_settled_payments.json | 46 ++++ .../lightning_sats_settled_invoice.json | 41 +++ .../lightning_sats_settled_payments.json | 46 ++++ .../onchain_eur_processing_invoice.json | 42 ++++ .../onchain_eur_processing_payments.json | 46 ++++ .../btcpay/onchain_eur_settled_invoice.json | 41 +++ .../btcpay/onchain_eur_settled_payments.json | 46 ++++ spec/helpers/application_helper_spec.rb | 5 - spec/jobs/btcpay_check_donation_job_spec.rb | 63 +++++ spec/requests/contributions/donations_spec.rb | 233 ++++++++++++++++++ 46 files changed, 1142 insertions(+), 114 deletions(-) create mode 100644 app/helpers/btcpay_helper.rb create mode 100644 app/jobs/btcpay_check_donation_job.rb create mode 100644 app/services/btcpay_manager/create_invoice.rb create mode 100644 app/services/btcpay_manager/fetch_exchange_rate.rb create mode 100644 app/services/btcpay_manager/fetch_invoice.rb delete mode 100644 app/views/admin/donations/_donation.json.jbuilder delete mode 100644 app/views/admin/donations/index.json.jbuilder delete mode 100644 app/views/admin/donations/show.json.jbuilder create mode 100644 app/views/contributions/donations/_bitcoin.html.erb create mode 100644 app/views/contributions/donations/_opencollective.html.erb create mode 100644 app/views/shared/status_unprocessable_entity.html.erb create mode 100644 db/migrate/20240214115058_change_donation_amounts_and_currency.rb create mode 100644 db/migrate/20240214121049_add_new_donation_fields.rb create mode 100644 db/migrate/20240216124640_add_payment_status_to_donations.rb create mode 100644 spec/features/contributions/donations_spec.rb create mode 100644 spec/fixtures/btcpay/create_invoice.rb create mode 100644 spec/fixtures/btcpay/lightning_eur_settled_invoice.json create mode 100644 spec/fixtures/btcpay/lightning_eur_settled_payments.json create mode 100644 spec/fixtures/btcpay/lightning_sats_settled_invoice.json create mode 100644 spec/fixtures/btcpay/lightning_sats_settled_payments.json create mode 100644 spec/fixtures/btcpay/onchain_eur_processing_invoice.json create mode 100644 spec/fixtures/btcpay/onchain_eur_processing_payments.json create mode 100644 spec/fixtures/btcpay/onchain_eur_settled_invoice.json create mode 100644 spec/fixtures/btcpay/onchain_eur_settled_payments.json create mode 100644 spec/jobs/btcpay_check_donation_job_spec.rb create mode 100644 spec/requests/contributions/donations_spec.rb diff --git a/.env.example b/.env.example index 976aaeb..4245a30 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,7 @@ WEBHOOKS_ALLOWED_IPS='10.1.1.163' # Service Integrations # +BTCPAY_PUBLIC_URL='https://btcpay.example.com' BTCPAY_API_URL='http://localhost:23001/api/v1' BTCPAY_STORE_ID='' BTCPAY_AUTH_TOKEN='' diff --git a/.env.test b/.env.test index c032b0e..aadec95 100644 --- a/.env.test +++ b/.env.test @@ -2,6 +2,7 @@ PRIMARY_DOMAIN=kosmos.org REDIS_URL='redis://localhost:6379/0' +BTCPAY_PUBLIC_URL='https://btcpay.example.com' BTCPAY_API_URL='http://btcpay.example.com/api/v1' BTCPAY_STORE_ID='123456' diff --git a/app/assets/stylesheets/components/buttons.css b/app/assets/stylesheets/components/buttons.css index b879634..dde8f3c 100644 --- a/app/assets/stylesheets/components/buttons.css +++ b/app/assets/stylesheets/components/buttons.css @@ -32,6 +32,11 @@ focus:ring-blue-400 focus:ring-opacity-75; } + .btn-emerald { + @apply bg-emerald-500 hover:bg-emerald-600 text-white + focus:ring-emerald-400 focus:ring-opacity-75; + } + .btn-red { @apply bg-red-600 hover:bg-red-700 text-white focus:ring-red-500 focus:ring-opacity-75; diff --git a/app/components/main_with_tabnav_component.html.erb b/app/components/main_with_tabnav_component.html.erb index 6c578a9..60884a5 100644 --- a/app/components/main_with_tabnav_component.html.erb +++ b/app/components/main_with_tabnav_component.html.erb @@ -1,5 +1,5 @@

-
+
<%= render partial: @tabnav_partial %>
diff --git a/app/components/modal_component.html.erb b/app/components/modal_component.html.erb index f8d4baf..43f6d10 100644 --- a/app/components/modal_component.html.erb +++ b/app/components/modal_component.html.erb @@ -12,7 +12,7 @@
+ +
+
+ <% if @donation_methods.include?(:btcpay) || + @donation_methods.include?(:lndhub) %> + <%= render partial: "contributions/donations/bitcoin", locals: { + donation_methods: @donation_methods, lndhub_balance: @balance + } %> + <% end %> + <% if @donation_methods.include?(:opencollective) %> + <%= render partial: "contributions/donations/opencollective" %> + <% end %> +
+
+ + <% if @donations_pending.any? %> +
+

Pending

+ <%= render partial: "contributions/donations/list", + locals: { donations: @donations_pending } %> +
+ <% end %> + + <% if @donations_completed.any? %> +
+

Past contributions

+ <%= render partial: "contributions/donations/list", + locals: { donations: @donations_completed } %> +
+ <% end %> <% end %> diff --git a/app/views/shared/status_unprocessable_entity.html.erb b/app/views/shared/status_unprocessable_entity.html.erb new file mode 100644 index 0000000..47d5f6d --- /dev/null +++ b/app/views/shared/status_unprocessable_entity.html.erb @@ -0,0 +1,6 @@ +<%= render HeaderCompactComponent.new(title: "422") %> + +<%= render MainCompactComponent.new do %> +

Unprocessable content

+

The data provided was malformed. Please go back and try again.

+<% end %> diff --git a/config/routes.rb b/config/routes.rb index 39e69f2..4307f9e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,8 +12,12 @@ Rails.application.routes.draw do namespace :contributions do root to: 'donations#index' + resources :donations, only: ['index', 'create'] do + member do + get 'confirm_btcpay' + end + end get 'projects', to: 'projects#index' - resources :donations, only: ['index'] end resources :invitations, only: ['index', 'show', 'create', 'destroy'] diff --git a/db/migrate/20240214115058_change_donation_amounts_and_currency.rb b/db/migrate/20240214115058_change_donation_amounts_and_currency.rb new file mode 100644 index 0000000..9a14641 --- /dev/null +++ b/db/migrate/20240214115058_change_donation_amounts_and_currency.rb @@ -0,0 +1,9 @@ +class ChangeDonationAmountsAndCurrency < ActiveRecord::Migration[7.1] + def change + rename_column :donations, :amount_usd, :fiat_amount + add_column :donations, :fiat_currency, :string, default: "USD" + remove_column :donations, :amount_eur, :integer + + Donation.update_all(fiat_currency: 'USD') + end +end diff --git a/db/migrate/20240214121049_add_new_donation_fields.rb b/db/migrate/20240214121049_add_new_donation_fields.rb new file mode 100644 index 0000000..1c9873c --- /dev/null +++ b/db/migrate/20240214121049_add_new_donation_fields.rb @@ -0,0 +1,7 @@ +class AddNewDonationFields < ActiveRecord::Migration[7.1] + def change + add_column :donations, :donation_method, :string + add_column :donations, :payment_method, :string, default: nil + add_column :donations, :btcpay_invoice_id, :string, default: nil + end +end diff --git a/db/migrate/20240216124640_add_payment_status_to_donations.rb b/db/migrate/20240216124640_add_payment_status_to_donations.rb new file mode 100644 index 0000000..613179c --- /dev/null +++ b/db/migrate/20240216124640_add_payment_status_to_donations.rb @@ -0,0 +1,8 @@ +class AddPaymentStatusToDonations < ActiveRecord::Migration[7.1] + def change + add_column :donations, :payment_status, :string, default: nil + add_index :donations, :payment_status + + Donation.completed.update_all payment_status: "settled" + end +end diff --git a/db/schema.rb b/db/schema.rb index e558da8..dd267df 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_02_07_080515) do +ActiveRecord::Schema[7.1].define(version: 2024_02_16_124640) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -50,12 +50,16 @@ ActiveRecord::Schema[7.1].define(version: 2024_02_07_080515) do create_table "donations", force: :cascade do |t| t.integer "user_id" t.integer "amount_sats" - t.integer "amount_eur" - t.integer "amount_usd" + t.integer "fiat_amount" t.string "public_name" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "paid_at", precision: nil + t.string "fiat_currency", default: "USD" + t.string "donation_method" + t.string "payment_method" + t.string "btcpay_invoice_id" + t.string "payment_status" t.index ["user_id"], name: "index_donations_on_user_id" end diff --git a/spec/features/contributions/donations_spec.rb b/spec/features/contributions/donations_spec.rb new file mode 100644 index 0000000..1bbb475 --- /dev/null +++ b/spec/features/contributions/donations_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +RSpec.describe 'Donations page', type: :feature do + let(:user) { create :user } + + before do + login_as user, :scope => :user + end + + describe "Donation methods" do + scenario "Only BTCPay enabled" do + Setting.btcpay_enabled = true + Setting.lndhub_enabled = false + Setting.opencollective_enabled = false + visit contributions_donations_url + + within ".donation-methods" do + expect(page).to have_content("Bitcoin") + expect(page).not_to have_content("OpenCollective") + end + end + + scenario "Only OpenCollective enabled" do + Setting.btcpay_enabled = false + Setting.lndhub_enabled = false + Setting.opencollective_enabled = true + visit contributions_donations_url + + within ".donation-methods" do + expect(page).not_to have_content("Bitcoin") + expect(page).to have_content("OpenCollective") + end + end + end +end diff --git a/spec/fixtures/btcpay/create_invoice.rb b/spec/fixtures/btcpay/create_invoice.rb new file mode 100644 index 0000000..6b9563f --- /dev/null +++ b/spec/fixtures/btcpay/create_invoice.rb @@ -0,0 +1,32 @@ +{ + "id" => "Q9GBe143MXHkdpZeH4Ftx5", + "storeId" => "AxQQ6oH4YX7n5pH1JPBLu97QD6RTybtj8m2W8YzhYr6T", + "amount" => "1", + "checkoutLink" => "http://10.1.1.163:23001/i/Q9GBe143MXHkdpZeH4Ftx5", + "status" => "New", + "additionalStatus" => "None", + "monitoringExpiration" => 1707995026, + "expirationTime" => 1707908626, + "createdTime" => 1707907726, + "availableStatusesForManualMarking" =>["Settled", "Invalid"], + "archived" => false, + "type" => "Standard", + "currency" => "EUR", + "metadata" => {}, + "checkout" => { + "speedPolicy" => "MediumSpeed", + "paymentMethods" => ["BTC", "BTC-LightningNetwork"], + "defaultPaymentMethod" => "BTC-LightningNetwork", + "expirationMinutes" => 15, + "monitoringMinutes" => 1440, + "paymentTolerance" => 0.0, + "redirectURL" => "http://localhost:3000/contributions/donations", + "redirectAutomatically" => false, + "requiresRefundEmail" => false, + "defaultLanguage" => nil, + "checkoutType" => nil, + "lazyPaymentMethods" => nil}, + "receipt" => { + "enabled" => nil, "showQR" => nil, "showPayments" => nil + } +} diff --git a/spec/fixtures/btcpay/lightning_eur_settled_invoice.json b/spec/fixtures/btcpay/lightning_eur_settled_invoice.json new file mode 100644 index 0000000..533b7dc --- /dev/null +++ b/spec/fixtures/btcpay/lightning_eur_settled_invoice.json @@ -0,0 +1,41 @@ +{ + "id": "MCkDbf2cUgBuuisUCgnRnb", + "storeId": "AxQQ6oH4YX7n5pH1JPBLu97QD6RTybtj8m2W8YzhYr6T", + "amount": "1", + "checkoutLink": "http://10.1.1.163:23001/i/MCkDbf2cUgBuuisUCgnRnb", + "status": "Settled", + "additionalStatus": "None", + "monitoringExpiration": 1708169508, + "expirationTime": 1708083108, + "createdTime": 1708082208, + "availableStatusesForManualMarking": [ + + ], + "archived": false, + "type": "Standard", + "currency": "EUR", + "metadata": { + }, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC", + "BTC-LightningNetwork" + ], + "defaultPaymentMethod": "BTC-LightningNetwork", + "expirationMinutes": 15, + "monitoringMinutes": 1440, + "paymentTolerance": 0.0, + "redirectURL": "http://localhost:3000/contributions/donations/27/confirm_btcpay", + "redirectAutomatically": true, + "requiresRefundEmail": false, + "defaultLanguage": null, + "checkoutType": null, + "lazyPaymentMethods": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + } +} \ No newline at end of file diff --git a/spec/fixtures/btcpay/lightning_eur_settled_payments.json b/spec/fixtures/btcpay/lightning_eur_settled_payments.json new file mode 100644 index 0000000..483e05f --- /dev/null +++ b/spec/fixtures/btcpay/lightning_eur_settled_payments.json @@ -0,0 +1,46 @@ +[ + { + "activated": true, + "destination": "bc1qtvwjguv679lcch9a9zxzxcengq3t3zgd5zm0pd", + "paymentLink": "bitcoin:bc1qtvwjguv679lcch9a9zxzxcengq3t3zgd5zm0pd", + "rate": "48532.8", + "paymentMethodPaid": "0", + "totalPaid": "0.00002061", + "due": "0", + "amount": "0.00002061", + "networkFee": "0", + "payments": [ + + ], + "paymentMethod": "BTC", + "cryptoCode": "BTC", + "additionalData": { + } + }, + { + "activated": true, + "destination": "lnbc20610n1pju73pqpp5rrvhc34tzt3vz70r33c2n2qqtm6hxau2hyl9w23kvrx56vhsfh5sdphg3hkuct5d9hkugr5dus8g6r9yp9k7umddaejqcm0dacx2unpw35hvegcqzzsxqyz5vqsp56grmnp2ezj4n323squu2p2e09g5lgncsr7f34084p5dyjgmwcssq9qyyssqfrc9x37qcvvpsx8m4zvu9glvcfcmqzs9ttfsg30g2gjxfkylvp8rdud2yx8gshs2jv0rea0etjrcygrc0hp4vckgsfs4grsnl854ajgpurzzp4", + "paymentLink": "lightning:lnbc20610n1pju73pqpp5rrvhc34tzt3vz70r33c2n2qqtm6hxau2hyl9w23kvrx56vhsfh5sdphg3hkuct5d9hkugr5dus8g6r9yp9k7umddaejqcm0dacx2unpw35hvegcqzzsxqyz5vqsp56grmnp2ezj4n323squu2p2e09g5lgncsr7f34084p5dyjgmwcssq9qyyssqfrc9x37qcvvpsx8m4zvu9glvcfcmqzs9ttfsg30g2gjxfkylvp8rdud2yx8gshs2jv0rea0etjrcygrc0hp4vckgsfs4grsnl854ajgpurzzp4", + "rate": "48532.8", + "paymentMethodPaid": "0.00002061", + "totalPaid": "0.00002061", + "due": "0", + "amount": "0.00002061", + "networkFee": "0", + "payments": [ + { + "id": "18d97c46ab12e2c179e38c70a9a8005ef573778ab93e572a3660cd4d32f04de9", + "receivedDate": 1708082214, + "value": "0.00002061", + "fee": "0.0", + "status": "Settled", + "destination": "lnbc20610n1pju73pqpp5rrvhc34tzt3vz70r33c2n2qqtm6hxau2hyl9w23kvrx56vhsfh5sdphg3hkuct5d9hkugr5dus8g6r9yp9k7umddaejqcm0dacx2unpw35hvegcqzzsxqyz5vqsp56grmnp2ezj4n323squu2p2e09g5lgncsr7f34084p5dyjgmwcssq9qyyssqfrc9x37qcvvpsx8m4zvu9glvcfcmqzs9ttfsg30g2gjxfkylvp8rdud2yx8gshs2jv0rea0etjrcygrc0hp4vckgsfs4grsnl854ajgpurzzp4" + } + ], + "paymentMethod": "BTC-LightningNetwork", + "cryptoCode": "BTC", + "additionalData": { + "paymentHash": "18d97c46ab12e2c179e38c70a9a8005ef573778ab93e572a3660cd4d32f04de9" + } + } +] \ No newline at end of file diff --git a/spec/fixtures/btcpay/lightning_sats_settled_invoice.json b/spec/fixtures/btcpay/lightning_sats_settled_invoice.json new file mode 100644 index 0000000..36063c1 --- /dev/null +++ b/spec/fixtures/btcpay/lightning_sats_settled_invoice.json @@ -0,0 +1,41 @@ +{ + "id": "JxjfeJi1TtX8FcWSjEvGxg", + "storeId": "AxQQ6oH4YX7n5pH1JPBLu97QD6RTybtj8m2W8YzhYr6T", + "amount": "0.0001", + "checkoutLink": "http://10.1.1.163:23001/i/JxjfeJi1TtX8FcWSjEvGxg", + "status": "Settled", + "additionalStatus": "None", + "monitoringExpiration": 1708180292, + "expirationTime": 1708093892, + "createdTime": 1708092992, + "availableStatusesForManualMarking": [ + + ], + "archived": false, + "type": "Standard", + "currency": "BTC", + "metadata": { + }, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC", + "BTC-LightningNetwork" + ], + "defaultPaymentMethod": "BTC-LightningNetwork", + "expirationMinutes": 15, + "monitoringMinutes": 1440, + "paymentTolerance": 0.0, + "redirectURL": "http://localhost:3000/contributions/donations/32/confirm_btcpay", + "redirectAutomatically": true, + "requiresRefundEmail": false, + "defaultLanguage": null, + "checkoutType": null, + "lazyPaymentMethods": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + } +} \ No newline at end of file diff --git a/spec/fixtures/btcpay/lightning_sats_settled_payments.json b/spec/fixtures/btcpay/lightning_sats_settled_payments.json new file mode 100644 index 0000000..a51d272 --- /dev/null +++ b/spec/fixtures/btcpay/lightning_sats_settled_payments.json @@ -0,0 +1,46 @@ +[ + { + "activated": true, + "destination": "bc1q9fay59qdmtv46d5hpf62vt5eyd7ag98t4h0s3g", + "paymentLink": "bitcoin:bc1q9fay59qdmtv46d5hpf62vt5eyd7ag98t4h0s3g", + "rate": "1.0", + "paymentMethodPaid": "0", + "totalPaid": "0.0001", + "due": "0", + "amount": "0.0001", + "networkFee": "0", + "payments": [ + + ], + "paymentMethod": "BTC", + "cryptoCode": "BTC", + "additionalData": { + } + }, + { + "activated": true, + "destination": "lnbc100u1pju7mjqpp54yt6z4g4j294vta90yn35pwch76a4h47txx4m4njfdqmcsa4w50qdphg3hkuct5d9hkugr5dus8g6r9yp9k7umddaejqcm0dacx2unpw35hvegcqzzsxqyz5vqsp5gx3wee4c2gsl7lnmx4qkrha3mkxh926j5qpdqz7wyyya04wks7cq9qyyssq4ft7a69c93kr04hamp5ah958ay222dvdrzr3nl599nx0l3ejpqe4ktarkxdymsxgg6v3evat9e9u0fp2vg2r2z860fn0h04znq9c6psqh8s53q", + "paymentLink": "lightning:lnbc100u1pju7mjqpp54yt6z4g4j294vta90yn35pwch76a4h47txx4m4njfdqmcsa4w50qdphg3hkuct5d9hkugr5dus8g6r9yp9k7umddaejqcm0dacx2unpw35hvegcqzzsxqyz5vqsp5gx3wee4c2gsl7lnmx4qkrha3mkxh926j5qpdqz7wyyya04wks7cq9qyyssq4ft7a69c93kr04hamp5ah958ay222dvdrzr3nl599nx0l3ejpqe4ktarkxdymsxgg6v3evat9e9u0fp2vg2r2z860fn0h04znq9c6psqh8s53q", + "rate": "1.0", + "paymentMethodPaid": "0.0001", + "totalPaid": "0.0001", + "due": "0", + "amount": "0.0001", + "networkFee": "0", + "payments": [ + { + "id": "a917a15515928b562fa579271a05d8bfb5dadebe598d5dd6724b41bc43b5751e", + "receivedDate": 1708093015, + "value": "0.0001", + "fee": "0.0", + "status": "Settled", + "destination": "lnbc100u1pju7mjqpp54yt6z4g4j294vta90yn35pwch76a4h47txx4m4njfdqmcsa4w50qdphg3hkuct5d9hkugr5dus8g6r9yp9k7umddaejqcm0dacx2unpw35hvegcqzzsxqyz5vqsp5gx3wee4c2gsl7lnmx4qkrha3mkxh926j5qpdqz7wyyya04wks7cq9qyyssq4ft7a69c93kr04hamp5ah958ay222dvdrzr3nl599nx0l3ejpqe4ktarkxdymsxgg6v3evat9e9u0fp2vg2r2z860fn0h04znq9c6psqh8s53q" + } + ], + "paymentMethod": "BTC-LightningNetwork", + "cryptoCode": "BTC", + "additionalData": { + "paymentHash": "a917a15515928b562fa579271a05d8bfb5dadebe598d5dd6724b41bc43b5751e" + } + } +] \ No newline at end of file diff --git a/spec/fixtures/btcpay/onchain_eur_processing_invoice.json b/spec/fixtures/btcpay/onchain_eur_processing_invoice.json new file mode 100644 index 0000000..ad08b22 --- /dev/null +++ b/spec/fixtures/btcpay/onchain_eur_processing_invoice.json @@ -0,0 +1,42 @@ +{ + "id": "K4e31MhbLKmr3D7qoNYRd3", + "storeId": "AxQQ6oH4YX7n5pH1JPBLu97QD6RTybtj8m2W8YzhYr6T", + "amount": "100", + "checkoutLink": "http://10.1.1.163:23001/i/K4e31MhbLKmr3D7qoNYRd3", + "status": "Processing", + "additionalStatus": "None", + "monitoringExpiration": 1708173683, + "expirationTime": 1708087283, + "createdTime": 1708086383, + "availableStatusesForManualMarking": [ + "Settled", + "Invalid" + ], + "archived": false, + "type": "Standard", + "currency": "USD", + "metadata": { + }, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC", + "BTC-LightningNetwork" + ], + "defaultPaymentMethod": "BTC-LightningNetwork", + "expirationMinutes": 15, + "monitoringMinutes": 1440, + "paymentTolerance": 0.0, + "redirectURL": "http://localhost:3000/contributions/donations/28/confirm_btcpay", + "redirectAutomatically": true, + "requiresRefundEmail": false, + "defaultLanguage": null, + "checkoutType": null, + "lazyPaymentMethods": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + } +} \ No newline at end of file diff --git a/spec/fixtures/btcpay/onchain_eur_processing_payments.json b/spec/fixtures/btcpay/onchain_eur_processing_payments.json new file mode 100644 index 0000000..5074245 --- /dev/null +++ b/spec/fixtures/btcpay/onchain_eur_processing_payments.json @@ -0,0 +1,46 @@ +[ + { + "activated": true, + "destination": "bc1qqxm55h6yzych9kg6kquak4c73nyv352070tsmh", + "paymentLink": "bitcoin:bc1qqxm55h6yzych9kg6kquak4c73nyv352070tsmh", + "rate": "52259.2", + "paymentMethodPaid": "0.00191354", + "totalPaid": "0.00191354", + "due": "0", + "amount": "0.00191354", + "networkFee": "0", + "payments": [ + { + "id": "21da85563274d0c3975273c1a2a8551bddeebb68b8f8a3242f63dd4cc238b480-1", + "receivedDate": 1708086448, + "value": "0.00191354", + "fee": "0.0", + "status": "Processing", + "destination": "bc1qqxm55h6yzych9kg6kquak4c73nyv352070tsmh" + } + ], + "paymentMethod": "BTC", + "cryptoCode": "BTC", + "additionalData": { + } + }, + { + "activated": true, + "destination": "lnbc1913540n1pju74r0pp5vpnw6l84ytu5u5evehn00xwsrppg7w45cj4mrwjwng474w7x3ugqdphg3hkuct5d9hkugr5dus8g6r9yp9k7umddaejqcm0dacx2unpw35hvegcqzzsxqyz5vqsp5sscc8ufdw29lpv9z09c6edhzc2njg0lmspsk8sdunek7yrkeu3mq9qyyssqnzz9xrhpy7sej5k62vcjju253kxx87jveq7vusl2sgaeuyh48ph9ecuud6f329syuut3z8w544c6ynhtx4ratundzmp7fs6sdll8g0spurjhnx", + "paymentLink": "lightning:lnbc1913540n1pju74r0pp5vpnw6l84ytu5u5evehn00xwsrppg7w45cj4mrwjwng474w7x3ugqdphg3hkuct5d9hkugr5dus8g6r9yp9k7umddaejqcm0dacx2unpw35hvegcqzzsxqyz5vqsp5sscc8ufdw29lpv9z09c6edhzc2njg0lmspsk8sdunek7yrkeu3mq9qyyssqnzz9xrhpy7sej5k62vcjju253kxx87jveq7vusl2sgaeuyh48ph9ecuud6f329syuut3z8w544c6ynhtx4ratundzmp7fs6sdll8g0spurjhnx", + "rate": "52259.2", + "paymentMethodPaid": "0", + "totalPaid": "0.00191354", + "due": "0", + "amount": "0.00191354", + "networkFee": "0", + "payments": [ + + ], + "paymentMethod": "BTC-LightningNetwork", + "cryptoCode": "BTC", + "additionalData": { + "paymentHash": "6066ed7cf522f94e532ccde6f799d018428f3ab4c4abb1ba4e9a2beabbc68f10" + } + } +] \ No newline at end of file diff --git a/spec/fixtures/btcpay/onchain_eur_settled_invoice.json b/spec/fixtures/btcpay/onchain_eur_settled_invoice.json new file mode 100644 index 0000000..09db36c --- /dev/null +++ b/spec/fixtures/btcpay/onchain_eur_settled_invoice.json @@ -0,0 +1,41 @@ +{ + "id": "K4e31MhbLKmr3D7qoNYRd3", + "storeId": "AxQQ6oH4YX7n5pH1JPBLu97QD6RTybtj8m2W8YzhYr6T", + "amount": "100", + "checkoutLink": "http://10.1.1.163:23001/i/K4e31MhbLKmr3D7qoNYRd3", + "status": "Settled", + "additionalStatus": "None", + "monitoringExpiration": 1708173683, + "expirationTime": 1708087283, + "createdTime": 1708086383, + "availableStatusesForManualMarking": [ + + ], + "archived": false, + "type": "Standard", + "currency": "USD", + "metadata": { + }, + "checkout": { + "speedPolicy": "MediumSpeed", + "paymentMethods": [ + "BTC", + "BTC-LightningNetwork" + ], + "defaultPaymentMethod": "BTC-LightningNetwork", + "expirationMinutes": 15, + "monitoringMinutes": 1440, + "paymentTolerance": 0.0, + "redirectURL": "http://localhost:3000/contributions/donations/28/confirm_btcpay", + "redirectAutomatically": true, + "requiresRefundEmail": false, + "defaultLanguage": null, + "checkoutType": null, + "lazyPaymentMethods": null + }, + "receipt": { + "enabled": null, + "showQR": null, + "showPayments": null + } +} \ No newline at end of file diff --git a/spec/fixtures/btcpay/onchain_eur_settled_payments.json b/spec/fixtures/btcpay/onchain_eur_settled_payments.json new file mode 100644 index 0000000..aedc453 --- /dev/null +++ b/spec/fixtures/btcpay/onchain_eur_settled_payments.json @@ -0,0 +1,46 @@ +[ + { + "activated": true, + "destination": "bc1qqxm55h6yzych9kg6kquak4c73nyv352070tsmh", + "paymentLink": "bitcoin:bc1qqxm55h6yzych9kg6kquak4c73nyv352070tsmh", + "rate": "52259.2", + "paymentMethodPaid": "0.00191354", + "totalPaid": "0.00191354", + "due": "0", + "amount": "0.00191354", + "networkFee": "0", + "payments": [ + { + "id": "218652f351508c46cfd99de1c6cdc0dcb66bc1bbfaf38578235d080046a96305-1", + "receivedDate": 1708106396, + "value": "0.00191354", + "fee": "0.0", + "status": "Settled", + "destination": "bc1qqxm55h6yzych9kg6kquak4c73nyv352070tsmh" + } + ], + "paymentMethod": "BTC", + "cryptoCode": "BTC", + "additionalData": { + } + }, + { + "activated": true, + "destination": "lnbc1913540n1pju74r0pp5vpnw6l84ytu5u5evehn00xwsrppg7w45cj4mrwjwng474w7x3ugqdphg3hkuct5d9hkugr5dus8g6r9yp9k7umddaejqcm0dacx2unpw35hvegcqzzsxqyz5vqsp5sscc8ufdw29lpv9z09c6edhzc2njg0lmspsk8sdunek7yrkeu3mq9qyyssqnzz9xrhpy7sej5k62vcjju253kxx87jveq7vusl2sgaeuyh48ph9ecuud6f329syuut3z8w544c6ynhtx4ratundzmp7fs6sdll8g0spurjhnx", + "paymentLink": "lightning:lnbc1913540n1pju74r0pp5vpnw6l84ytu5u5evehn00xwsrppg7w45cj4mrwjwng474w7x3ugqdphg3hkuct5d9hkugr5dus8g6r9yp9k7umddaejqcm0dacx2unpw35hvegcqzzsxqyz5vqsp5sscc8ufdw29lpv9z09c6edhzc2njg0lmspsk8sdunek7yrkeu3mq9qyyssqnzz9xrhpy7sej5k62vcjju253kxx87jveq7vusl2sgaeuyh48ph9ecuud6f329syuut3z8w544c6ynhtx4ratundzmp7fs6sdll8g0spurjhnx", + "rate": "52259.2", + "paymentMethodPaid": "0", + "totalPaid": "0.00191354", + "due": "0", + "amount": "0.00191354", + "networkFee": "0", + "payments": [ + + ], + "paymentMethod": "BTC-LightningNetwork", + "cryptoCode": "BTC", + "additionalData": { + "paymentHash": "6066ed7cf522f94e532ccde6f799d018428f3ab4c4abb1ba4e9a2beabbc68f10" + } + } +] \ No newline at end of file diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 92f8de4..f9a8fb6 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -1,9 +1,4 @@ require 'rails_helper' describe ApplicationHelper do - describe "sats_to_btc" do - it "converts satoshis to BTC" do - expect(helper.sats_to_btc(120000000)).to eq(1.2) - end - end end diff --git a/spec/jobs/btcpay_check_donation_job_spec.rb b/spec/jobs/btcpay_check_donation_job_spec.rb new file mode 100644 index 0000000..9344c46 --- /dev/null +++ b/spec/jobs/btcpay_check_donation_job_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' +require 'webmock/rspec' + +RSpec.describe BtcpayCheckDonationJob, type: :job do + let(:user) { create :user, cn: 'jimmy', ou: 'kosmos.org' } + + let(:donation) do + user.donations.create!( + donation_method: "btcpay", btcpay_invoice_id: "K4e31MhbLKmr3D7qoNYRd3", + paid_at: nil, payment_status: "processing", + fiat_amount: 120, fiat_currency: "USD" + ) + end + + after(:each) do + clear_enqueued_jobs + clear_performed_jobs + end + + describe "invoice still processing" do + subject(:job) { described_class.perform_later(donation) } + + before do + invoice = File.read("#{Rails.root}/spec/fixtures/btcpay/onchain_eur_processing_invoice.json") + payments = File.read("#{Rails.root}/spec/fixtures/btcpay/onchain_eur_processing_payments.json") + stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/K4e31MhbLKmr3D7qoNYRd3") + .to_return(status: 200, headers: {}, body: invoice) + stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/K4e31MhbLKmr3D7qoNYRd3/payment-methods") + .to_return(status: 200, headers: {}, body: payments) + end + + it "enqueues itself to check again later" do + expect_any_instance_of(described_class).to receive(:re_enqueue_job).once + perform_enqueued_jobs { job } + end + end + + describe "invoice settled" do + subject(:job) { described_class.perform_later(donation) } + + before do + invoice = File.read("#{Rails.root}/spec/fixtures/btcpay/onchain_eur_settled_invoice.json") + payments = File.read("#{Rails.root}/spec/fixtures/btcpay/onchain_eur_settled_payments.json") + stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/K4e31MhbLKmr3D7qoNYRd3") + .to_return(status: 200, headers: {}, body: invoice) + stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/K4e31MhbLKmr3D7qoNYRd3/payment-methods") + .to_return(status: 200, headers: {}, body: payments) + end + + it "updates the donation record" do + perform_enqueued_jobs { job } + + donation.reload + expect(donation.paid_at).not_to be_nil + expect(donation.payment_status).to eq("settled") + end + + it "does not enqueue itself again" do + expect_any_instance_of(described_class).not_to receive(:re_enqueue_job) + perform_enqueued_jobs { job } + end + end +end diff --git a/spec/requests/contributions/donations_spec.rb b/spec/requests/contributions/donations_spec.rb new file mode 100644 index 0000000..dc9118f --- /dev/null +++ b/spec/requests/contributions/donations_spec.rb @@ -0,0 +1,233 @@ +require 'rails_helper' +require 'webmock/rspec' + +RSpec.describe "Donations", type: :request do + let(:user) { create :user, cn: 'jimmy', ou: 'kosmos.org' } + + before do + Warden.test_mode! + login_as user, scope: :user + end + + after { Warden.test_reset! } + + describe "#create" do + describe "with disabled methods" do + before do + Setting.btcpay_enabled = false + end + + it "returns a 403" do + post "/contributions/donations", params: { donation_method: "btcpay" } + expect(response).to have_http_status(:forbidden) + end + end + + describe "with fake methods" do + it "returns a 403" do + post "/contributions/donations", params: { donation_method: "remotestorage" } + expect(response).to have_http_status(:forbidden) + end + end + + describe "with invalid fiat currency" do + it "returns a 422" do + post "/contributions/donations", params: { + donation_method: "btcpay", amount: "10", currency: "GBP" + } + expect(response).to have_http_status(:unprocessable_entity) + end + end + + describe "with bad amount" do + it "returns a 422" do + post "/contributions/donations", params: { + donation_method: "btcpay", amount: "" + } + expect(response).to have_http_status(:unprocessable_entity) + end + end + + describe "with BTCPay" do + before { Setting.btcpay_enabled = true } + + describe "amount in EUR" do + before do + expect(BtcpayManager::CreateInvoice).to receive(:call) + .with(amount: 25, currency: "EUR", redirect_url: "http://www.example.com/contributions/donations/1/confirm_btcpay") + .and_return({ + "id" => "Q9GBe143HJIkdpZeH4Ftx5", + "amount" => "25", + "currency" => "EUR", + "checkoutLink" => "#{Setting.btcpay_api_url}/i/Q9GBe143HJIkdpZeH4Ftx5", + "expirationTime" => 1707908626, + "checkout" => { "redirectURL" => "http://www.example.com/contributions/donations/1/confirm_btcpay" } + }) + + post "/contributions/donations", params: { + donation_method: "btcpay", amount: "25", currency: "EUR", + public_name: "Mickey" + } + end + + it "creates a new donation record" do + expect(user.donations.count).to eq(1) + donation = user.donations.first + expect(donation.donation_method).to eq("btcpay") + expect(donation.payment_method).to be_nil + expect(donation.paid_at).to be_nil + expect(donation.public_name).to eq("Mickey") + expect(donation.amount_sats).to be_nil + expect(donation.fiat_amount).to eq(2500) + expect(donation.fiat_currency).to eq("EUR") + expect(donation.btcpay_invoice_id).to eq("Q9GBe143HJIkdpZeH4Ftx5") + end + + it "redirects to the BTCPay checkout page" do + expect(response).to redirect_to("https://btcpay.example.com/i/Q9GBe143HJIkdpZeH4Ftx5") + end + end + end + end + + describe "#confirm_btcpay" do + before { Setting.btcpay_enabled = true } + + describe "with donation of another user" do + let(:other_user) { create :user, id: 3, cn: "carl", ou: 'kosmos.org', email: "carl@example.com" } + + before do + @donation = other_user.donations.create!( + donation_method: "btcpay", btcpay_invoice_id: "123abc", + fiat_amount: 25, fiat_currency: "EUR", paid_at: nil + ) + get confirm_btcpay_contributions_donation_path(@donation.id) + end + + it "returns a 404" do + expect(response).to have_http_status(:not_found) + end + end + + describe "with confirmed donation" do + before do + @donation = user.donations.create!( + donation_method: "btcpay", btcpay_invoice_id: "123abc", + fiat_amount: 25, fiat_currency: "EUR", + paid_at: "2024-02-16", payment_status: "settled" + ) + get confirm_btcpay_contributions_donation_path(@donation.id) + end + + it "redirects to the donations index" do + expect(response).to redirect_to(contributions_donations_url) + end + end + + describe "settled via Lightning" do + describe "amount in EUR" do + subject do + user.donations.create!( + donation_method: "btcpay", btcpay_invoice_id: "MCkDbf2cUgBuuisUCgnRnb", + fiat_amount: 25, fiat_currency: "EUR", paid_at: nil + ) + end + + before do + invoice = File.read("#{Rails.root}/spec/fixtures/btcpay/lightning_eur_settled_invoice.json") + payments = File.read("#{Rails.root}/spec/fixtures/btcpay/lightning_eur_settled_payments.json") + stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/MCkDbf2cUgBuuisUCgnRnb") + .to_return(status: 200, headers: {}, body: invoice) + stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/MCkDbf2cUgBuuisUCgnRnb/payment-methods") + .to_return(status: 200, headers: {}, body: payments) + + get confirm_btcpay_contributions_donation_path(subject) + end + + it "updates the donation record" do + subject.reload + expect(subject.paid_at).not_to be_nil + expect(subject.amount_sats).to eq(2061) + end + + it "redirects to the donations index" do + expect(response).to redirect_to(contributions_donations_url) + end + end + + describe "amount in sats" do + subject do + user.donations.create!( + donation_method: "btcpay", btcpay_invoice_id: "JxjfeJi1TtX8FcWSjEvGxg", + amount_sats: 10000, fiat_amount: nil, fiat_currency: nil, paid_at: nil + ) + end + + before do + invoice = File.read("#{Rails.root}/spec/fixtures/btcpay/lightning_sats_settled_invoice.json") + payments = File.read("#{Rails.root}/spec/fixtures/btcpay/lightning_sats_settled_payments.json") + stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/JxjfeJi1TtX8FcWSjEvGxg") + .to_return(status: 200, headers: {}, body: invoice) + stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/JxjfeJi1TtX8FcWSjEvGxg/payment-methods") + .to_return(status: 200, headers: {}, body: payments) + + expect(BtcpayManager::FetchExchangeRate).to receive(:call) + .with(fiat_currency: "EUR").and_return(48532.00) + + get confirm_btcpay_contributions_donation_path(subject) + end + + it "updates the donation record" do + subject.reload + expect(subject.paid_at).not_to be_nil + expect(subject.amount_sats).to eq(10000) + expect(subject.fiat_amount).to eq(485) + expect(subject.fiat_currency).to eq("EUR") + end + + it "redirects to the donations index" do + expect(response).to redirect_to(contributions_donations_url) + end + end + end + + describe "on-chain" do + describe "waiting for confirmations" do + subject do + user.donations.create!( + donation_method: "btcpay", btcpay_invoice_id: "K4e31MhbLKmr3D7qoNYRd3", + fiat_amount: 120, fiat_currency: "USD", paid_at: nil + ) + end + + before do + invoice = File.read("#{Rails.root}/spec/fixtures/btcpay/onchain_eur_processing_invoice.json") + payments = File.read("#{Rails.root}/spec/fixtures/btcpay/onchain_eur_processing_payments.json") + stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/K4e31MhbLKmr3D7qoNYRd3") + .to_return(status: 200, headers: {}, body: invoice) + stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/K4e31MhbLKmr3D7qoNYRd3/payment-methods") + .to_return(status: 200, headers: {}, body: payments) + + get confirm_btcpay_contributions_donation_path(subject) + end + + it "updates the donation record" do + subject.reload + expect(subject.paid_at).to be_nil + expect(subject.amount_sats).to eq(191354) + expect(subject.payment_status).to eq("processing") + end + + it "enqueues a job to periodically check the invoice status" do + expect(enqueued_jobs.size).to eq(1) + expect(enqueued_jobs.first["job_class"]).to eq("BtcpayCheckDonationJob") + expect(enqueued_jobs.first['arguments'][0]["_aj_globalid"]).to eq("gid://akkounts/Donation/#{subject.id}") + end + + it "redirects to the donations index" do + expect(response).to redirect_to(contributions_donations_url) + end + end + end + end +end -- 2.25.1 From 54220019bb969f08c4d86541932a5d78648a9321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sat, 17 Feb 2024 14:17:41 +0100 Subject: [PATCH 5/7] Send email confirmation when BTC payment is confirmed --- app/jobs/btcpay_check_donation_job.rb | 10 +++++--- app/mailers/notification_mailer.rb | 7 ++++++ .../bitcoin_donation_confirmed.text.erb | 11 +++++++++ spec/jobs/btcpay_check_donation_job_spec.rb | 24 +++++++++++++++---- 4 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 app/views/notification_mailer/bitcoin_donation_confirmed.text.erb diff --git a/app/jobs/btcpay_check_donation_job.rb b/app/jobs/btcpay_check_donation_job.rb index 3d89028..3cfdbcb 100644 --- a/app/jobs/btcpay_check_donation_job.rb +++ b/app/jobs/btcpay_check_donation_job.rb @@ -4,15 +4,19 @@ class BtcpayCheckDonationJob < ApplicationJob def perform(donation) return if donation.completed? - invoice = BtcpayManager::FetchInvoice.call(invoice_id: donation.btcpay_invoice_id) + invoice = BtcpayManager::FetchInvoice.call( + invoice_id: donation.btcpay_invoice_id + ) case invoice["status"] when "Settled" - # TODO use time from actual payment confirmation donation.paid_at = DateTime.now donation.payment_status = "settled" donation.save! - # TODO send email + + NotificationMailer.with(user: donation.user) + .bitcoin_donation_confirmed + .deliver_later when "Processing" re_enqueue_job(donation) end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 004f94f..b4ec37d 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -23,4 +23,11 @@ class NotificationMailer < ApplicationMailer @subject = "New invitations added to your account" mail to: @user.email, subject: @subject end + + def bitcoin_donation_confirmed + @user = params[:user] + @donation = params[:donation] + @subject = "Donation confirmed" + mail to: @user.email, subject: @subject + end end diff --git a/app/views/notification_mailer/bitcoin_donation_confirmed.text.erb b/app/views/notification_mailer/bitcoin_donation_confirmed.text.erb new file mode 100644 index 0000000..3da1e04 --- /dev/null +++ b/app/views/notification_mailer/bitcoin_donation_confirmed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.display_name.presence || @user.cn %>, + +Your bitcoin donation has been confirmed successfully. <3 + +Thank you so much for helping us with keeping the lights on, as well as with continually improving our services for you! + +You can find all of your past financial contributions on this page: + +<%= contributions_donations_url %> + +Have a nice day! diff --git a/spec/jobs/btcpay_check_donation_job_spec.rb b/spec/jobs/btcpay_check_donation_job_spec.rb index 9344c46..71cd1df 100644 --- a/spec/jobs/btcpay_check_donation_job_spec.rb +++ b/spec/jobs/btcpay_check_donation_job_spec.rb @@ -6,12 +6,20 @@ RSpec.describe BtcpayCheckDonationJob, type: :job do let(:donation) do user.donations.create!( - donation_method: "btcpay", btcpay_invoice_id: "K4e31MhbLKmr3D7qoNYRd3", + donation_method: "btcpay", + btcpay_invoice_id: "K4e31MhbLKmr3D7qoNYRd3", paid_at: nil, payment_status: "processing", fiat_amount: 120, fiat_currency: "USD" ) end + before do + allow_any_instance_of(User).to receive(:ldap_entry).and_return({ + uid: user.cn, ou: user.ou, mail: user.email, admin: nil, + display_name: nil + }) + end + after(:each) do clear_enqueued_jobs clear_performed_jobs @@ -48,16 +56,24 @@ RSpec.describe BtcpayCheckDonationJob, type: :job do end it "updates the donation record" do - perform_enqueued_jobs { job } - + perform_enqueued_jobs(only: described_class) { job } donation.reload expect(donation.paid_at).not_to be_nil expect(donation.payment_status).to eq("settled") end + it "notifies the user via email" do + perform_enqueued_jobs(only: described_class) { job } + expect(enqueued_jobs.size).to eq(1) + job = enqueued_jobs.select{|j| j['job_class'] == "ActionMailer::MailDeliveryJob"}.first + expect(job['arguments'][0]).to eq('NotificationMailer') + expect(job['arguments'][1]).to eq('bitcoin_donation_confirmed') + expect(job['arguments'][3]['params']['user']['_aj_globalid']).to eq('gid://akkounts/User/1') + end + it "does not enqueue itself again" do expect_any_instance_of(described_class).not_to receive(:re_enqueue_job) - perform_enqueued_jobs { job } + perform_enqueued_jobs(only: described_class) { job } end end end -- 2.25.1 From da22a9d44852acc305c2806208a49721dceaf12a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 6 Mar 2024 11:20:28 +0100 Subject: [PATCH 6/7] Add spec for reported regression --- spec/requests/contributions/donations_spec.rb | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/spec/requests/contributions/donations_spec.rb b/spec/requests/contributions/donations_spec.rb index dc9118f..01397eb 100644 --- a/spec/requests/contributions/donations_spec.rb +++ b/spec/requests/contributions/donations_spec.rb @@ -87,6 +87,43 @@ RSpec.describe "Donations", type: :request do expect(response).to redirect_to("https://btcpay.example.com/i/Q9GBe143HJIkdpZeH4Ftx5") end end + + describe "amount in sats" do + before do + expect(BtcpayManager::CreateInvoice).to receive(:call) + .with(amount: 0.0001, currency: "BTC", redirect_url: "http://www.example.com/contributions/donations/1/confirm_btcpay") + .and_return({ + "id" => "Q9GBe143HJIkdpZeH4Ftx5", + "amount" => "0.0001", + "currency" => "BTC", + "checkoutLink" => "#{Setting.btcpay_api_url}/i/Q9GBe143HJIkdpZeH4Ftx5", + "expirationTime" => 1707908626, + "checkout" => { "redirectURL" => "http://www.example.com/contributions/donations/1/confirm_btcpay" } + }) + + post "/contributions/donations", params: { + donation_method: "btcpay", amount: "10000", currency: "sats", + public_name: "Garret Holmes" + } + end + + it "creates a new donation record" do + expect(user.donations.count).to eq(1) + donation = user.donations.first + expect(donation.donation_method).to eq("btcpay") + expect(donation.payment_method).to be_nil + expect(donation.paid_at).to be_nil + expect(donation.public_name).to eq("Garret Holmes") + expect(donation.amount_sats).to eq(10000) + expect(donation.fiat_amount).to be_nil + expect(donation.fiat_currency).to be_nil + expect(donation.btcpay_invoice_id).to eq("Q9GBe143HJIkdpZeH4Ftx5") + end + + it "redirects to the BTCPay checkout page" do + expect(response).to redirect_to("https://btcpay.example.com/i/Q9GBe143HJIkdpZeH4Ftx5") + end + end end end -- 2.25.1 From 7f2df3b025e808e487262320cdddf660928a1302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 6 Mar 2024 11:22:53 +0100 Subject: [PATCH 7/7] Fix donation record for amounts given in sats --- app/controllers/contributions/donations_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/contributions/donations_controller.rb b/app/controllers/contributions/donations_controller.rb index 404f203..b9f46c4 100644 --- a/app/controllers/contributions/donations_controller.rb +++ b/app/controllers/contributions/donations_controller.rb @@ -28,6 +28,7 @@ class Contributions::DonationsController < ApplicationController if params[:currency] == "sats" fiat_amount = nil fiat_currency = nil + amount_sats = params[:amount] else fiat_amount = params[:amount].to_i fiat_currency = params[:currency] -- 2.25.1