Let users donate sats via BTCPay Server #176

Merged
raucao merged 9 commits from feature/donations_btcpay into master 2024-03-13 16:31:54 +00:00
54 changed files with 1315 additions and 180 deletions

View File

@ -31,6 +31,7 @@
# Service Integrations # Service Integrations
# #
# BTCPAY_PUBLIC_URL='https://btcpay.example.com'
# BTCPAY_API_URL='http://localhost:23001/api/v1' # BTCPAY_API_URL='http://localhost:23001/api/v1'
# BTCPAY_STORE_ID='' # BTCPAY_STORE_ID=''
# BTCPAY_AUTH_TOKEN='' # BTCPAY_AUTH_TOKEN=''

View File

@ -2,6 +2,7 @@ PRIMARY_DOMAIN=kosmos.org
REDIS_URL='redis://localhost:6379/0' REDIS_URL='redis://localhost:6379/0'
BTCPAY_PUBLIC_URL='https://btcpay.example.com'
BTCPAY_API_URL='http://btcpay.example.com/api/v1' BTCPAY_API_URL='http://btcpay.example.com/api/v1'
BTCPAY_STORE_ID='123456' BTCPAY_STORE_ID='123456'

View File

@ -32,6 +32,11 @@
focus:ring-blue-400 focus:ring-opacity-75; 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 { .btn-red {
@apply bg-red-600 hover:bg-red-700 text-white @apply bg-red-600 hover:bg-red-700 text-white
focus:ring-red-500 focus:ring-opacity-75; focus:ring-red-500 focus:ring-opacity-75;

View File

@ -1,5 +1,5 @@
<main class="w-full max-w-6xl mx-auto pb-12 px-4 md:px-6 lg:px-8"> <main class="w-full max-w-6xl mx-auto pb-12 px-4 md:px-6 lg:px-8">
<div class="bg-white rounded-lg shadow"> <div class="md:min-h-[50vh] bg-white rounded-lg shadow">
<div class="px-6 sm:px-12 pt-2 sm:pt-4"> <div class="px-6 sm:px-12 pt-2 sm:pt-4">
<%= render partial: @tabnav_partial %> <%= render partial: @tabnav_partial %>
</div> </div>

View File

@ -12,7 +12,7 @@
<!-- Modal Container --> <!-- Modal Container -->
<div data-modal-target="container" <div data-modal-target="container"
class="max-h-screen w-auto max-w-lg relative class="relative m-4 max-h-screen w-auto max-w-full
hidden animate-scale-in fixed inset-0 overflow-y-auto flex items-center justify-center"> hidden animate-scale-in fixed inset-0 overflow-y-auto flex items-center justify-center">
<!-- Modal Card --> <!-- Modal Card -->
<div class="m-1 bg-white rounded shadow"> <div class="m-1 bg-white rounded shadow">

View File

@ -34,6 +34,8 @@ class NotificationComponent < ViewComponent::Base
'alert-octagon' 'alert-octagon'
when 'alert' when 'alert'
'alert-octagon' 'alert-octagon'
when 'warning'
'alert-octagon'
else else
'info' 'info'
end end

View File

@ -3,18 +3,16 @@ class Admin::DonationsController < Admin::BaseController
before_action :set_current_section, only: [:index, :show, :new, :edit] before_action :set_current_section, only: [:index, :show, :new, :edit]
# GET /donations # GET /donations
# GET /donations.json
def index def index
@pagy, @donations = pagy(Donation.all.order('created_at desc')) @pagy, @donations = pagy(Donation.completed.order('paid_at desc'))
@stats = { @stats = {
overall_sats: @donations.all.sum("amount_sats"), overall_sats: @donations.sum("amount_sats"),
donor_count: Donation.distinct.count(:user_id) donor_count: @donations.distinct.count(:user_id)
} }
end end
# GET /donations/1 # GET /donations/1
# GET /donations/1.json
def show def show
end end
@ -28,55 +26,42 @@ class Admin::DonationsController < Admin::BaseController
end end
# POST /donations # POST /donations
# POST /donations.json
def create def create
@donation = Donation.new(donation_params) @donation = Donation.new(donation_params)
respond_to do |format| if @donation.paid_at == nil
@donation.errors.add(:paid_at, message: "is required")
render :new, status: :unprocessable_entity and return
end
if @donation.save if @donation.save
format.html do
redirect_to admin_donation_url(@donation), flash: { redirect_to admin_donation_url(@donation), flash: {
success: 'Donation was successfully created.' success: 'Donation was successfully created.'
} }
end
format.json { render :show, status: :created, location: @donation }
else else
format.html { render :new, status: :unprocessable_entity } render :new, status: :unprocessable_entity
format.json { render json: @donation.errors, status: :unprocessable_entity }
end
end end
end end
# PATCH/PUT /donations/1 # PUT /donations/1
# PATCH/PUT /donations/1.json
def update def update
respond_to do |format|
if @donation.update(donation_params) if @donation.update(donation_params)
format.html do
redirect_to admin_donation_url(@donation), flash: { redirect_to admin_donation_url(@donation), flash: {
success: 'Donation was successfully updated.' success: 'Donation was successfully updated.'
} }
end
format.json { render :show, status: :ok, location: @donation }
else else
format.html { render :edit, status: :unprocessable_entity } render :edit, status: :unprocessable_entity
format.json { render json: @donation.errors, status: :unprocessable_entity }
end
end end
end end
# DELETE /donations/1 # DELETE /donations/1
# DELETE /donations/1.json
def destroy def destroy
@donation.destroy @donation.destroy
respond_to do |format|
format.html do redirect_to admin_donations_url, flash: { redirect_to admin_donations_url, flash: {
success: 'Donation was successfully destroyed.' success: 'Donation was successfully destroyed.'
} }
end end
format.json { head :no_content }
end
end
private private
# Use callbacks to share common setup or constraints between actions. # Use callbacks to share common setup or constraints between actions.
@ -86,7 +71,10 @@ class Admin::DonationsController < Admin::BaseController
# Only allow a list of trusted parameters through. # Only allow a list of trusted parameters through.
def donation_params def donation_params
params.require(:donation).permit(:user_id, :amount_sats, :amount_eur, :amount_usd, :public_name, :paid_at) params.require(:donation).permit(
:user_id, :donation_method,
:amount_sats, :fiat_amount, :fiat_currency,
:public_name, :paid_at)
end end
def set_current_section def set_current_section

View File

@ -41,4 +41,26 @@ class ApplicationController < ActionController::Base
def after_sign_in_path_for(user) def after_sign_in_path_for(user)
session[:user_return_to] || root_path session[:user_return_to] || root_path
end 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 end

View File

@ -1,10 +1,129 @@
class Contributions::DonationsController < ApplicationController class Contributions::DonationsController < ApplicationController
before_action :authenticate_user! include BtcpayHelper
# GET /donations before_action :authenticate_user!
# GET /donations.json before_action :set_donation_methods, only: [:index, :create]
before_action :require_donation_method_enabled, only: [:create]
before_action :validate_donation_params, only: [:create]
before_action :set_donation, only: [:confirm_btcpay]
# GET /contributions/donations
def index def index
@donations = current_user.donations.completed
@current_section = :contributions @current_section = :contributions
@donations_completed = current_user.donations.completed.order('paid_at desc')
@donations_pending = current_user.donations.processing.order('created_at desc')
if Setting.lndhub_enabled?
begin
lndhub_authenticate
lndhub_fetch_balance
rescue
@balance = 0
end
end
end
# POST /contributions/donations
def create
if params[:currency] == "sats"
fiat_amount = nil
fiat_currency = nil
raucao marked this conversation as resolved
Review

Isn't a amount_sats = params[:amount_sats] missing here?

I couldn't see the amount_sats being assigned anywhere other than to nil when a different currency is selected, but it is being used further down in this action.

Isn't a `amount_sats = params[:amount_sats]` missing here? I couldn't see the `amount_sats` being assigned anywhere other than to `nil` when a different currency is selected, but it is being used further down in this action.
Review

Good catch! Since I did actual donations using sats for the amount, it must have gotten lost for the commit somehow. Added a spec to catch that, since that spec was missing in the first place.

Good catch! Since I did actual donations using sats for the amount, it must have gotten lost for the commit somehow. Added a spec to catch that, since that spec was missing in the first place.
amount_sats = params[:amount]
else
fiat_amount = params[:amount].to_i
fiat_currency = params[:currency]
amount_sats = nil
end
@donation = current_user.donations.create!(
donation_method: params[:donation_method],
payment_method: nil,
paid_at: nil,
amount_sats: amount_sats,
fiat_amount: (fiat_amount.nil? ? nil : fiat_amount * 100), # store in cents
fiat_currency: fiat_currency,
public_name: params[:public_name]
)
case params[:donation_method]
when "btcpay"
res = BtcpayManager::CreateInvoice.call(
amount: fiat_amount || (amount_sats.to_f / 100000000),
currency: fiat_currency || "BTC",
redirect_url: confirm_btcpay_contributions_donation_url(@donation)
)
@donation.update! btcpay_invoice_id: res["id"]
redirect_to btcpay_checkout_url(res["id"]), allow_other_host: true
else
redirect_to contributions_donations_url, flash: {
error: "Donation method currently not available"
}
end
end
def confirm_btcpay
redirect_to contributions_donations_url and return if @donation.completed?
invoice = BtcpayManager::FetchInvoice.call(invoice_id: @donation.btcpay_invoice_id)
if @donation.amount_sats.present?
# TODO make default fiat currency configurable and/or determine from user's
# i18n browser settings
@donation.fiat_currency = "EUR"
exchange_rate = BtcpayManager::FetchExchangeRate.call(fiat_currency: @donation.fiat_currency)
@donation.fiat_amount = (((@donation.amount_sats.to_f / 100000000) * exchange_rate) * 100).to_i
else
amt_str = invoice["paymentMethods"].first["amount"]
@donation.amount_sats = amt_str.tr(".","").sub(/0*$/, "").to_i
end
case invoice["status"]
when "Settled"
@donation.paid_at = DateTime.now
@donation.payment_status = "settled"
@donation.save!
flash_message = { success: "Thank you!" }
when "Processing"
unless @donation.processing?
@donation.payment_status = "processing"
@donation.save!
flash_message = { success: "Thank you! We will send you an email when the payment is confirmed." }
BtcpayCheckDonationJob.set(wait: 20.seconds).perform_later(@donation)
end
when "Expired"
flash_message = { warning: "The payment request for this donation has expired" }
else
flash_message = { warning: "Could not determine status of payment" }
end
redirect_to contributions_donations_url, flash: flash_message
end
private
def set_donation
@donation = current_user.donations.find_by(id: params[:id])
http_status :not_found unless @donation.present?
end
def set_donation_methods
@donation_methods = []
@donation_methods.push :btcpay if Setting.btcpay_enabled?
@donation_methods.push :lndhub if Setting.lndhub_enabled?
@donation_methods.push :opencollective if Setting.opencollective_enabled?
end
def require_donation_method_enabled
http_status :forbidden unless @donation_methods.include?(
params[:donation_method].to_sym
)
end
def validate_donation_params
if !%w[EUR USD sats].include?(params[:currency]) || (params[:amount].to_i <= 0)
http_status :unprocessable_entity
end
end end
end end

View File

@ -2,10 +2,11 @@ require "rqrcode"
require "lnurl" require "lnurl"
class Services::LightningController < ApplicationController class Services::LightningController < ApplicationController
before_action :authenticate_user!
before_action :authenticate_with_lndhub
before_action :set_current_section 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 def index
@wallet_setup_url = "lndhub://#{current_user.ln_account}:#{current_user.ln_password}@#{ENV['LNDHUB_PUBLIC_URL']}" @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 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 def set_current_section
@current_section = :services @current_section = :services
end end
def fetch_balance def require_service_available
lndhub = Lndhub.new http_status :not_found unless Setting.lndhub_enabled?
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
end end
def fetch_transactions def fetch_transactions

View File

@ -1,10 +1,6 @@
module ApplicationHelper module ApplicationHelper
include Pagy::Frontend include Pagy::Frontend
def sats_to_btc(sats)
sats.to_f / 100000000
end
def main_nav_class(current_section, link_to_section) def main_nav_class(current_section, link_to_section)
if current_section == link_to_section if current_section == link_to_section
"bg-gray-900/50 text-white px-3 py-2 rounded-md font-medium text-base md:text-sm block md:inline-block" "bg-gray-900/50 text-white px-3 py-2 rounded-md font-medium text-base md:text-sm block md:inline-block"

View File

@ -0,0 +1,7 @@
module BtcpayHelper
def btcpay_checkout_url(invoice_id)
"#{Setting.btcpay_public_url}/i/#{invoice_id}"
end
end

View File

@ -0,0 +1,28 @@
class BtcpayCheckDonationJob < ApplicationJob
queue_as :default
def perform(donation)
return if donation.completed?
invoice = BtcpayManager::FetchInvoice.call(
invoice_id: donation.btcpay_invoice_id
)
case invoice["status"]
when "Settled"
donation.paid_at = DateTime.now
donation.payment_status = "settled"
donation.save!
NotificationMailer.with(user: donation.user)
.bitcoin_donation_confirmed
.deliver_later
when "Processing"
re_enqueue_job(donation)
end
end
def re_enqueue_job(donation)
self.class.set(wait: 20.seconds).perform_later(donation)
end
end

View File

@ -23,4 +23,11 @@ class NotificationMailer < ApplicationMailer
@subject = "New invitations added to your account" @subject = "New invitations added to your account"
mail to: @user.email, subject: @subject mail to: @user.email, subject: @subject
end end
def bitcoin_donation_confirmed
@user = params[:user]
@donation = params[:donation]
@subject = "Donation confirmed"
mail to: @user.email, subject: @subject
end
end end

View File

@ -4,12 +4,25 @@ class Donation < ApplicationRecord
# Validations # Validations
validates_presence_of :user validates_presence_of :user
validates_presence_of :amount_sats validates_presence_of :donation_method,
validates_presence_of :paid_at inclusion: { in: %w[ custom btcpay lndhub ] }
validates_presence_of :payment_status, allow_nil: true,
# Hooks inclusion: { in: %w[ processing settled ] }
# TODO before_create :store_fiat_value validates_presence_of :paid_at, allow_nil: true
validates_presence_of :amount_sats, allow_nil: true
validates_presence_of :fiat_amount, allow_nil: true
validates_presence_of :fiat_currency, allow_nil: true,
inclusion: { in: %w[ EUR USD ] }
#Scopes #Scopes
scope :completed, -> { where.not(paid_at: nil) } scope :processing, -> { where(payment_status: "processing") }
scope :completed, -> { where(payment_status: "settled") }
def processing?
payment_status == "processing"
end
def completed?
payment_status == "settled"
end
end end

View File

@ -51,6 +51,9 @@ class Setting < RailsSettings::Base
field :btcpay_enabled, type: :boolean, field :btcpay_enabled, type: :boolean,
default: ENV["BTCPAY_API_URL"].present? default: ENV["BTCPAY_API_URL"].present?
field :btcpay_public_url, type: :string,
default: ENV["BTCPAY_PUBLIC_URL"].presence
field :btcpay_store_id, type: :string, field :btcpay_store_id, type: :string,
default: ENV["BTCPAY_STORE_ID"].presence default: ENV["BTCPAY_STORE_ID"].presence
@ -157,7 +160,13 @@ class Setting < RailsSettings::Base
# Nostr # Nostr
# #
field :nostr_enabled, type: :boolean, default: true field :nostr_enabled, type: :boolean, default: false
#
# OpenCollective
#
field :opencollective_enabled, type: :boolean, default: true
# #
# RemoteStorage # RemoteStorage

View File

@ -0,0 +1,21 @@
module BtcpayManager
class CreateInvoice < BtcpayManagerService
def initialize(amount:, currency:, redirect_url:)
@amount = amount
@currency = currency
@redirect_url = redirect_url
end
def call
post "/invoices", {
amount: @amount.to_s,
currency: @currency,
checkout: {
redirectURL: @redirect_url,
redirectAutomatically: true,
requiresRefundEmail: false
}
}
end
end
end

View File

@ -0,0 +1,14 @@
module BtcpayManager
class FetchExchangeRate < BtcpayManagerService
def initialize(fiat_currency:)
@fiat_currency = fiat_currency
end
def call
pair_str = "BTC_#{@fiat_currency}"
res = get "rates", { currencyPair: pair_str }
pair = res.find{|p| p["currencyPair"] == pair_str }
rate = pair["rate"].to_f
end
end
end

View File

@ -0,0 +1,14 @@
module BtcpayManager
class FetchInvoice < BtcpayManagerService
def initialize(invoice_id:)
@invoice_id = invoice_id
end
def call
invoice = get "/invoices/#{@invoice_id}"
payment_methods = get "/invoices/#{@invoice_id}/payment-methods"
invoice["paymentMethods"] = payment_methods
invoice
end
end
end

View File

@ -1,7 +1,7 @@
module BtcpayManager module BtcpayManager
class FetchLightningWalletBalance < BtcpayManagerService class FetchLightningWalletBalance < BtcpayManagerService
def call 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 confirmed_balance: res["offchain"]["local"].to_i / 1000 # msats to sats

View File

@ -1,7 +1,7 @@
module BtcpayManager module BtcpayManager
class FetchOnchainWalletBalance < BtcpayManagerService class FetchOnchainWalletBalance < BtcpayManagerService
def call 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 balance: (res["balance"].to_f * 100000000).to_i, # BTC to sats

View File

@ -2,23 +2,35 @@
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/ # API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
# #
class BtcpayManagerService < ApplicationService 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 private
def get(endpoint) def base_url
res = Faraday.get("#{base_url}/#{endpoint}", {}, { @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", "Content-Type" => "application/json",
"Accept" => "application/json", "Accept" => "application/json",
"Authorization" => "token #{auth_token}" "Authorization" => "token #{auth_token}"
}) }
end
def endpoint_url(path)
"#{base_url}/#{path.gsub(/^\//, '')}"
end
def get(path, params = {})
res = Faraday.get endpoint_url(path), params, headers
JSON.parse(res.body)
end
def post(path, payload)
res = Faraday.post endpoint_url(path), payload.to_json, headers
JSON.parse(res.body) JSON.parse(res.body)
end end
end end

View File

@ -1,24 +1,20 @@
class Lndhub class Lndhub < ApplicationService
attr_accessor :auth_token attr_accessor :auth_token
def initialize def post(path, payload)
@base_url = ENV["LNDHUB_API_URL"]
end
def post(endpoint, payload)
headers = { "Content-Type" => "application/json" } headers = { "Content-Type" => "application/json" }
if auth_token if auth_token
headers.merge!({ "Authorization" => "Bearer #{auth_token}" }) headers.merge!({ "Authorization" => "Bearer #{auth_token}" })
end 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 log_error(res) if res.status != 200
JSON.parse(res.body) JSON.parse(res.body)
end end
def get(endpoint, auth_token) def get(path, auth_token)
res = Faraday.get("#{@base_url}/#{endpoint}", {}, { res = Faraday.get(endpoint_url(path), {}, {
"Content-Type" => "application/json", "Content-Type" => "application/json",
"Accept" => "application/json", "Accept" => "application/json",
"Authorization" => "Bearer #{auth_token}" "Authorization" => "Bearer #{auth_token}"
@ -42,7 +38,7 @@ class Lndhub
self.auth_token self.auth_token
end end
def balance(user_token=nil) def fetch_balance(user_token=nil)
get "balance", user_token || auth_token get "balance", user_token || auth_token
end end
@ -72,4 +68,14 @@ class Lndhub
Sentry.capture_message("Lndhub API request failed: #{res.body}") Sentry.capture_message("Lndhub API request failed: #{res.body}")
end end
end end
private
def base_url
@base_url ||= Setting.lndhub_api_url
end
def endpoint_url(path)
"#{base_url}/#{path.gsub(/^\//, '')}"
end
end end

View File

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

View File

@ -1,13 +1,13 @@
class LndhubV2 < Lndhub class LndhubV2 < Lndhub
def post(endpoint, payload, options={}) def post(path, payload, options={})
headers = { "Content-Type" => "application/json" } headers = { "Content-Type" => "application/json" }
if auth_token if auth_token
headers.merge!({ "Authorization" => "Bearer #{auth_token}" }) headers.merge!({ "Authorization" => "Bearer #{auth_token}" })
elsif options[:admin_token] elsif options[:admin_token]
headers.merge!({ "Authorization" => "Bearer #{options[:admin_token]}" }) headers.merge!({ "Authorization" => "Bearer #{options[:admin_token]}" })
end 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 log_error(res) if res.status != 200
JSON.parse(res.body) JSON.parse(res.body)

View File

@ -1,2 +0,0 @@
json.extract! donation, :id, :user_id, :amount_sats, :amount_eur, :amount_usd, :public_name, :created_at, :updated_at
json.url donation_url(donation, format: :json)

View File

@ -14,14 +14,24 @@
<%= form.label :user_id %> <%= form.label :user_id %>
<%= form.collection_select :user_id, User.where(ou: Setting.primary_domain).order(:cn), :id, :cn, {} %> <%= form.collection_select :user_id, User.where(ou: Setting.primary_domain).order(:cn), :id, :cn, {} %>
<%= form.label :donation_method, "Donation method" %>
<%= form.select :donation_method, options_for_select([
["Custom (manual)", "custom"],
["BTCPay", "btcpay"],
["LndHub account", "lndhub"],
["OpenCollective", "opencollective"]
], selected: (donation.donation_method || "custom")) %>
<%= form.label :amount_sats, "Amount BTC (sats)" %> <%= form.label :amount_sats, "Amount BTC (sats)" %>
<%= form.number_field :amount_sats %> <%= form.number_field :amount_sats %>
<%= form.label :amount_eur, "Amount EUR (cents)" %> <%= form.label :fiat_amount, "Fiat Amount (cents)" %>
<%= form.number_field :amount_eur %> <%= form.number_field :fiat_amount %>
<%= form.label :amount_usd, "Amount USD (cents)"%> <%= form.label :fiat_currency, "Fiat Currency" %>
<%= form.number_field :amount_usd %> <%= form.select :fiat_currency, options_for_select([
["EUR", "EUR"], ["USD", "USD"], ["sats", "sats"]
], selected: donation.fiat_currency) %>
<%= form.label :public_name %> <%= form.label :public_name %>
<%= form.text_field :public_name %> <%= form.text_field :public_name %>

View File

@ -25,9 +25,8 @@
<thead> <thead>
<tr> <tr>
<th>User</th> <th>User</th>
<th class="text-right">Amount BTC</th> <th class="text-right">Sats</th>
<th class="text-right">in EUR</th> <th class="text-right">Fiat Amount</th>
<th class="text-right">in USD</th>
<th class="pl-2">Public name</th> <th class="pl-2">Public name</th>
<th>Date</th> <th>Date</th>
<th></th> <th></th>
@ -37,9 +36,8 @@
<% @donations.each do |donation| %> <% @donations.each do |donation| %>
<tr> <tr>
<td><%= link_to donation.user.address, admin_user_path(donation.user.address), class: 'ks-text-link' %></td> <td><%= link_to donation.user.address, admin_user_path(donation.user.address), class: 'ks-text-link' %></td>
<td class="text-right"><%= sats_to_btc donation.amount_sats %></td> <td class="text-right"><% if donation.amount_sats.present? %><%= number_with_delimiter donation.amount_sats %><% end %></td>
<td class="text-right"><% if donation.amount_eur.present? %><%= number_to_currency donation.amount_eur / 100, unit: "" %><% end %></td> <td class="text-right"><% if donation.fiat_amount.present? %><%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %><% end %></td>
<td class="text-right"><% if donation.amount_usd.present? %><%= number_to_currency donation.amount_usd / 100, unit: "" %><% end %></td>
<td class="pl-2"><%= donation.public_name %></td> <td class="pl-2"><%= donation.public_name %></td>
<td><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : "" %></td> <td><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : "" %></td>
<td class="text-right"> <td class="text-right">

View File

@ -1 +0,0 @@
json.array! @donations, partial: "donations/donation", as: :donation

View File

@ -8,17 +8,17 @@
<th>User</th> <th>User</th>
<td><%= link_to @donation.user.address, admin_user_path(@donation.user.address), class: 'ks-text-link' %></td> <td><%= link_to @donation.user.address, admin_user_path(@donation.user.address), class: 'ks-text-link' %></td>
</tr> </tr>
<tr>
<th>Donation Method</th>
<td><%= @donation.donation_method %></td>
</tr>
<tr> <tr>
<th>Amount sats</th> <th>Amount sats</th>
<td><%= @donation.amount_sats %></td> <td><%= @donation.amount_sats %></td>
</tr> </tr>
<tr> <tr>
<th>Amount EUR</th> <th>Fiat amount</th>
<td><%= @donation.amount_eur %></td> <td><% if @donation.fiat_amount.present? %><%= number_to_currency @donation.fiat_amount.to_f / 100, unit: "" %> <%= @donation.fiat_currency %><% end %></td>
</tr>
<tr>
<th>Amount USD</th>
<td><%= @donation.amount_usd %></td>
</tr> </tr>
<tr> <tr>
<th>Public name</th> <th>Public name</th>
@ -26,7 +26,7 @@
</tr> </tr>
<tr> <tr>
<th>Date</th> <th>Date</th>
<td><%= @donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") %></td> <td><%= @donation.paid_at&.strftime("%Y-%m-%d (%H:%M UTC)") %></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -1 +0,0 @@
json.partial! "donations/donation", donation: @donation

View File

@ -0,0 +1,36 @@
<div class="rounded-lg p-6 bg-emerald-50 hover:bg-emerald-100 transition-colors">
<h3 class="mb-4 text-lg font-bold">Donate directly with Bitcoin</h3>
<p class="mb-6">
Open-source money for open-source services.
</p>
<div data-controller="modal" data-action="keydown.esc->modal#close">
<button class="btn-md btn-emerald w-full lg:w-1/2" data-action="click->modal#open">
Donate
</button>
<%= render ModalComponent.new(show_close_button: false) do %>
<div>
<h3>Your contribution</h3>
<%= form_with(url: contributions_donations_url, method: :post) do |f| %>
<%= f.hidden_field :donation_method, value: "btcpay" %>
<div class="mb-6 flex gap-2">
<%= f.number_field :amount, required: true %>
<%= f.select :currency, options_for_select([
["EUR", "EUR"], ["USD", "USD"], ["sats", "sats"]
], selected: "EUR"), class: "flex-none" %>
</div>
<%= render FormElements::FieldsetComponent.new(tag: "div", title: "Public name") do %>
<%= f.text_field :public_name, class: "w-full", placeholder: "Anonymous" %>
<% end %>
<p class="mt-12">
<%= f.submit 'Continue', data: { turbo: false },
class: "btn-md btn-blue w-full" %>
</p>
<% end %>
</div>
<% end %>
</div>
</div>

View File

@ -0,0 +1,37 @@
<ul class="list-none">
<% donations.each do |donation| %>
<li class="mb-8 grid gap-y-2 grid-cols-2 items-center">
<h3 class="mb-0">
<% if donation.completed? %>
<%= donation.paid_at.strftime("%B %d, %Y") %>
<% else %>
<%= donation.created_at.strftime("%B %d, %Y") %>
<% end %>
</h3>
<p class="row-span-2 font-mono text-right mb-0">
<span class="text-xl">
<%= number_with_delimiter donation.amount_sats %> sats
</span>
<br>
<span class="text-sm text-gray-500">
(~ <%= number_to_currency donation.fiat_amount.to_f / 100, unit: "" %> <%= donation.fiat_currency %>)
</span>
</p>
<p class="mb-0 text-gray-500">
<% if donation.processing? %>
Waiting for confirmations
<% if donation.donation_method == "btcpay" %>
<%= link_to "check status", btcpay_checkout_url(donation.btcpay_invoice_id),
class: "ml-2 btn-sm btn-gray" %>
<% end %>
<% else %>
<% if donation.public_name.present? %>
As: <%= donation.public_name %>
<% else %>
Anonymous
<% end %>
<% end %>
</p>
</li>
<% end %>
</ul>

View File

@ -0,0 +1,6 @@
<div class="rounded-lg p-6 bg-zinc-100 hover:bg-zinc-200 transition-colors">
<h3 class="mb-4 text-lg font-bold text-gray-500">Donate via OpenCollective</h3>
<p class="text-gray-600 text-gray-500">
Coming soon.
</p>
</div>

View File

@ -2,50 +2,39 @@
<%= render MainWithTabnavComponent.new(tabnav_partial: "shared/tabnav_contributions") do %> <%= render MainWithTabnavComponent.new(tabnav_partial: "shared/tabnav_contributions") do %>
<section> <section>
<% if @donations.any? %>
<p class="mb-12"> <p class="mb-12">
Your financial contributions to the development and upkeep of Kosmos Your financial contributions to the development and upkeep of Kosmos
software and services. software and services.
</p> </p>
<ul class="list-none"> </section>
<% @donations.each do |donation| %>
<li class="mb-8 grid gap-y-2 gap-x-8 grid-cols-2 items-center"> <section class="donation-methods">
<h3 class="mb-0"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<%= donation.paid_at.strftime("%B %d, %Y") %> <% if @donation_methods.include?(:btcpay) ||
</h3> @donation_methods.include?(:lndhub) %>
<p class="row-span-2 font-mono text-right mb-0"> <%= render partial: "contributions/donations/bitcoin", locals: {
<span class="text-xl"> donation_methods: @donation_methods, lndhub_balance: @balance
<%= number_with_delimiter donation.amount_sats %> sats } %>
</span>
<br>
<span class="text-sm text-gray-500">
(~ <%= number_to_currency donation.amount_eur / 100, unit: "" %> EUR)
</span>
</p>
<p class="mb-0">
<% if donation.public_name.present? %>
Public name: <%= donation.public_name %>
<% else %>
Anonymous
<% end %> <% end %>
</p> <% if @donation_methods.include?(:opencollective) %>
</li> <%= render partial: "contributions/donations/opencollective" %>
<% end %> <% end %>
</ul>
<% else %>
<div class="text-center">
<p class="mt-8 mb-12 inline-flex align-center items-center">
<%= image_tag("/img/illustrations/undraw_savings_re_eq4w.svg", class: 'h-48') %>
</p>
<h3>
No donations yet
</h3>
<p class="text-gray-500">
The donation process is not automated yet.<br>Please
<a href="https://wiki.kosmos.org/Main_Page#Community_.2F_Getting_in_touch_.2F_Getting_involved" class="ks-text-link" target="_blank">contact us</a>
if you'd like to contribute this way right now.
</p>
</div> </div>
<% end %> </section>
<% if @donations_pending.any? %>
<section class="donation-list">
<h2>Pending</h2>
<%= render partial: "contributions/donations/list",
locals: { donations: @donations_pending } %>
</section> </section>
<% end %> <% end %>
<% if @donations_completed.any? %>
<section class="donation-list">
<h2>Past contributions</h2>
<%= render partial: "contributions/donations/list",
locals: { donations: @donations_completed } %>
</section>
<% end %>
<% end %>

View File

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

View File

@ -0,0 +1,6 @@
<%= render HeaderCompactComponent.new(title: "422") %>
<%= render MainCompactComponent.new do %>
<h2>Unprocessable content</h2>
<p>The data provided was malformed. Please go back and try again.</p>
<% end %>

View File

@ -12,8 +12,12 @@ Rails.application.routes.draw do
namespace :contributions do namespace :contributions do
root to: 'donations#index' root to: 'donations#index'
resources :donations, only: ['index', 'create'] do
member do
get 'confirm_btcpay'
end
end
get 'projects', to: 'projects#index' get 'projects', to: 'projects#index'
resources :donations, only: ['index']
end end
resources :invitations, only: ['index', 'show', 'create', 'destroy'] resources :invitations, only: ['index', 'show', 'create', 'destroy']

View File

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

View File

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

View File

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

View File

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

32
spec/fixtures/btcpay/create_invoice.rb vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,4 @@
require 'rails_helper' require 'rails_helper'
describe ApplicationHelper do 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 end

View File

@ -0,0 +1,79 @@
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
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
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(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(only: described_class) { job }
end
end
end

View File

@ -0,0 +1,270 @@
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
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
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