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
#
# BTCPAY_PUBLIC_URL='https://btcpay.example.com'
# BTCPAY_API_URL='http://localhost:23001/api/v1'
# BTCPAY_STORE_ID=''
# BTCPAY_AUTH_TOKEN=''

View File

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

View File

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

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">
<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">
<%= render partial: @tabnav_partial %>
</div>

View File

@ -12,7 +12,7 @@
<!-- Modal 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">
<!-- Modal Card -->
<div class="m-1 bg-white rounded shadow">

View File

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

View File

@ -3,18 +3,16 @@ class Admin::DonationsController < Admin::BaseController
before_action :set_current_section, only: [:index, :show, :new, :edit]
# GET /donations
# GET /donations.json
def index
@pagy, @donations = pagy(Donation.all.order('created_at desc'))
@pagy, @donations = pagy(Donation.completed.order('paid_at desc'))
@stats = {
overall_sats: @donations.all.sum("amount_sats"),
donor_count: Donation.distinct.count(:user_id)
overall_sats: @donations.sum("amount_sats"),
donor_count: @donations.distinct.count(:user_id)
}
end
# GET /donations/1
# GET /donations/1.json
def show
end
@ -28,55 +26,42 @@ class Admin::DonationsController < Admin::BaseController
end
# POST /donations
# POST /donations.json
def create
@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
format.html do
redirect_to admin_donation_url(@donation), flash: {
success: 'Donation was successfully created.'
}
end
format.json { render :show, status: :created, location: @donation }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @donation.errors, status: :unprocessable_entity }
end
render :new, status: :unprocessable_entity
end
end
# PATCH/PUT /donations/1
# PATCH/PUT /donations/1.json
# PUT /donations/1
def update
respond_to do |format|
if @donation.update(donation_params)
format.html do
redirect_to admin_donation_url(@donation), flash: {
success: 'Donation was successfully updated.'
}
end
format.json { render :show, status: :ok, location: @donation }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @donation.errors, status: :unprocessable_entity }
end
render :edit, status: :unprocessable_entity
end
end
# DELETE /donations/1
# DELETE /donations/1.json
def 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.'
}
end
format.json { head :no_content }
end
end
private
# 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.
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
def set_current_section

View File

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

View File

@ -1,10 +1,129 @@
class Contributions::DonationsController < ApplicationController
before_action :authenticate_user!
include BtcpayHelper
# GET /donations
# GET /donations.json
before_action :authenticate_user!
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
@donations = current_user.donations.completed
@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

View File

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

View File

@ -1,10 +1,6 @@
module ApplicationHelper
include Pagy::Frontend
def sats_to_btc(sats)
sats.to_f / 100000000
end
def main_nav_class(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"

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

View File

@ -4,12 +4,25 @@ class Donation < ApplicationRecord
# Validations
validates_presence_of :user
validates_presence_of :amount_sats
validates_presence_of :paid_at
# Hooks
# TODO before_create :store_fiat_value
validates_presence_of :donation_method,
inclusion: { in: %w[ custom btcpay lndhub ] }
validates_presence_of :payment_status, allow_nil: true,
inclusion: { in: %w[ processing settled ] }
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
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

View File

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

View File

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

View File

@ -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, 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)
end
end

View File

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

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

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.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.number_field :amount_sats %>
<%= form.label :amount_eur, "Amount EUR (cents)" %>
<%= form.number_field :amount_eur %>
<%= form.label :fiat_amount, "Fiat Amount (cents)" %>
<%= form.number_field :fiat_amount %>
<%= form.label :amount_usd, "Amount USD (cents)"%>
<%= form.number_field :amount_usd %>
<%= form.label :fiat_currency, "Fiat Currency" %>
<%= form.select :fiat_currency, options_for_select([
["EUR", "EUR"], ["USD", "USD"], ["sats", "sats"]
], selected: donation.fiat_currency) %>
<%= form.label :public_name %>
<%= form.text_field :public_name %>

View File

@ -25,9 +25,8 @@
<thead>
<tr>
<th>User</th>
<th class="text-right">Amount BTC</th>
<th class="text-right">in EUR</th>
<th class="text-right">in USD</th>
<th class="text-right">Sats</th>
<th class="text-right">Fiat Amount</th>
<th class="pl-2">Public name</th>
<th>Date</th>
<th></th>
@ -37,9 +36,8 @@
<% @donations.each do |donation| %>
<tr>
<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_eur.present? %><%= number_to_currency donation.amount_eur / 100, unit: "" %><% end %></td>
<td class="text-right"><% if donation.amount_usd.present? %><%= number_to_currency donation.amount_usd / 100, unit: "" %><% end %></td>
<td class="text-right"><% if donation.amount_sats.present? %><%= number_with_delimiter donation.amount_sats %><% 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="pl-2"><%= donation.public_name %></td>
<td><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : "" %></td>
<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>
<td><%= link_to @donation.user.address, admin_user_path(@donation.user.address), class: 'ks-text-link' %></td>
</tr>
<tr>
<th>Donation Method</th>
<td><%= @donation.donation_method %></td>
</tr>
<tr>
<th>Amount sats</th>
<td><%= @donation.amount_sats %></td>
</tr>
<tr>
<th>Amount EUR</th>
<td><%= @donation.amount_eur %></td>
</tr>
<tr>
<th>Amount USD</th>
<td><%= @donation.amount_usd %></td>
<th>Fiat amount</th>
<td><% if @donation.fiat_amount.present? %><%= number_to_currency @donation.fiat_amount.to_f / 100, unit: "" %> <%= @donation.fiat_currency %><% end %></td>
</tr>
<tr>
<th>Public name</th>
@ -26,7 +26,7 @@
</tr>
<tr>
<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>
</tbody>
</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 %>
<section>
<% if @donations.any? %>
<p class="mb-12">
Your financial contributions to the development and upkeep of Kosmos
software and services.
</p>
<ul class="list-none">
<% @donations.each do |donation| %>
<li class="mb-8 grid gap-y-2 gap-x-8 grid-cols-2 items-center">
<h3 class="mb-0">
<%= donation.paid_at.strftime("%B %d, %Y") %>
</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.amount_eur / 100, unit: "" %> EUR)
</span>
</p>
<p class="mb-0">
<% if donation.public_name.present? %>
Public name: <%= donation.public_name %>
<% else %>
Anonymous
<% end %>
</p>
</li>
<% 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>
<% end %>
</section>
<section class="donation-methods">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<% 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 %>
</div>
</section>
<% if @donations_pending.any? %>
<section class="donation-list">
<h2>Pending</h2>
<%= render partial: "contributions/donations/list",
locals: { donations: @donations_pending } %>
</section>
<% 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
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']

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

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