Set member status to sustainer upon payment
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing

Introduces a state machine for the payment status as well.

refs #213
This commit is contained in:
Râu Cao 2025-05-27 16:39:03 +04:00
parent 463bf34cdf
commit e48132cf5f
Signed by: raucao
GPG Key ID: 37036C356E56CC51
10 changed files with 73 additions and 24 deletions

View File

@ -32,6 +32,7 @@ gem 'devise_ldap_authenticatable'
gem 'net-ldap' gem 'net-ldap'
# Utilities # Utilities
gem 'aasm'
gem "image_processing", "~> 1.12.2" gem "image_processing", "~> 1.12.2"
gem "rqrcode", "~> 2.0" gem "rqrcode", "~> 2.0"
gem 'rails-settings-cached', '~> 2.8.3' gem 'rails-settings-cached', '~> 2.8.3'

View File

@ -1,6 +1,8 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
aasm (5.5.0)
concurrent-ruby (~> 1.0)
actioncable (8.0.2) actioncable (8.0.2)
actionpack (= 8.0.2) actionpack (= 8.0.2)
activesupport (= 8.0.2) activesupport (= 8.0.2)
@ -526,6 +528,7 @@ PLATFORMS
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
aasm
aws-sdk-s3 aws-sdk-s3
bcrypt (~> 3.1) bcrypt (~> 3.1)
capybara capybara

View File

@ -11,7 +11,7 @@ class Contributions::DonationsController < ApplicationController
def index def index
@current_section = :contributions @current_section = :contributions
@donations_completed = current_user.donations.completed.order('paid_at desc') @donations_completed = current_user.donations.completed.order('paid_at desc')
@donations_pending = current_user.donations.processing.order('created_at desc') @donations_processing = current_user.donations.processing.order('created_at desc')
if Setting.lndhub_enabled? if Setting.lndhub_enabled?
begin begin
@ -81,14 +81,11 @@ class Contributions::DonationsController < ApplicationController
case invoice["status"] case invoice["status"]
when "Settled" when "Settled"
@donation.paid_at = DateTime.now @donation.complete!
@donation.payment_status = "settled"
@donation.save!
flash_message = { success: "Thank you!" } flash_message = { success: "Thank you!" }
when "Processing" when "Processing"
unless @donation.processing? unless @donation.processing?
@donation.payment_status = "processing" @donation.start_processing!
@donation.save!
flash_message = { success: "Thank you! We will send you an email when the payment is confirmed." } flash_message = { success: "Thank you! We will send you an email when the payment is confirmed." }
BtcpayCheckDonationJob.set(wait: 20.seconds).perform_later(@donation) BtcpayCheckDonationJob.set(wait: 20.seconds).perform_later(@donation)
end end

View File

@ -10,9 +10,7 @@ class BtcpayCheckDonationJob < ApplicationJob
case invoice["status"] case invoice["status"]
when "Settled" when "Settled"
donation.paid_at = DateTime.now donation.complete!
donation.payment_status = "settled"
donation.save!
NotificationMailer.with(user: donation.user) NotificationMailer.with(user: donation.user)
.bitcoin_donation_confirmed .bitcoin_donation_confirmed

View File

@ -1,23 +1,42 @@
class Donation < ApplicationRecord class Donation < ApplicationRecord
# Relations include AASM
belongs_to :user belongs_to :user
# Validations
validates_presence_of :user validates_presence_of :user
validates_presence_of :donation_method, validates_presence_of :donation_method,
inclusion: { in: %w[ custom btcpay lndhub ] } inclusion: { in: %w[ custom btcpay lndhub ] }
validates_presence_of :payment_status, allow_nil: true, validates_presence_of :payment_status, allow_nil: true,
inclusion: { in: %w[ processing settled ] } inclusion: { in: %w[ pending processing settled ] }
validates_presence_of :paid_at, allow_nil: true validates_presence_of :paid_at, allow_nil: true
validates_presence_of :amount_sats, allow_nil: true validates_presence_of :amount_sats, allow_nil: true
validates_presence_of :fiat_amount, allow_nil: true validates_presence_of :fiat_amount, allow_nil: true
validates_presence_of :fiat_currency, allow_nil: true, validates_presence_of :fiat_currency, allow_nil: true,
inclusion: { in: %w[ EUR USD ] } inclusion: { in: %w[ EUR USD ] }
#Scopes scope :pending, -> { where(payment_status: "pending") }
scope :processing, -> { where(payment_status: "processing") } scope :processing, -> { where(payment_status: "processing") }
scope :completed, -> { where(payment_status: "settled") } scope :completed, -> { where(payment_status: "settled") }
aasm column: :payment_status do
state :pending, initial: true
state :processing
state :settled
event :start_processing do
transitions from: :pending, to: :processing
end
event :complete do
transitions from: :processing, to: :settled, after: [:set_paid_at, :set_sustainer_status]
transitions from: :pending, to: :settled, after: [:set_paid_at, :set_sustainer_status]
end
end
def pending?
payment_status == "pending"
end
def processing? def processing?
payment_status == "processing" payment_status == "processing"
end end
@ -25,4 +44,17 @@ class Donation < ApplicationRecord
def completed? def completed?
payment_status == "settled" payment_status == "settled"
end end
private
def set_paid_at
update paid_at: DateTime.now if paid_at.nil?
end
def set_sustainer_status
user.add_member_status :sustainer
rescue => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.error("Failed to set memberStatus: #{e.message}")
end
end end

View File

@ -22,11 +22,11 @@
</div> </div>
</section> </section>
<% if @donations_pending.any? %> <% if @donations_processing.any? %>
<section class="donation-list"> <section class="donation-list">
<h2>Pending</h2> <h2>Pending</h2>
<%= render partial: "contributions/donations/list", <%= render partial: "contributions/donations/list",
locals: { donations: @donations_pending } %> locals: { donations: @donations_processing } %>
</section> </section>
<% end %> <% end %>

View File

@ -0,0 +1,6 @@
class UpdatePaymentStatusToPending < ActiveRecord::Migration[8.0]
def change
Donation.where(payment_status: nil).update_all(payment_status: "pending")
Donation.where.not(payment_status: %w[pending processing settled]).update_all(payment_status: "pending")
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_05_17_105755) do ActiveRecord::Schema[8.0].define(version: 2025_05_27_113805) do
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false

View File

@ -8,16 +8,18 @@ RSpec.describe BtcpayCheckDonationJob, type: :job do
user.donations.create!( user.donations.create!(
donation_method: "btcpay", donation_method: "btcpay",
btcpay_invoice_id: "K4e31MhbLKmr3D7qoNYRd3", btcpay_invoice_id: "K4e31MhbLKmr3D7qoNYRd3",
paid_at: nil, payment_status: "processing", paid_at: nil,
fiat_amount: 120, fiat_currency: "USD" payment_status: "processing",
fiat_amount: 120,
fiat_currency: "USD"
) )
end end
before do before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({ allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil, uid: user.cn, ou: user.ou, mail: user.email, admin: nil, display_name: nil
display_name: nil
}) })
allow_any_instance_of(User).to receive(:add_member_status)
end end
after(:each) do after(:each) do
@ -68,12 +70,17 @@ RSpec.describe BtcpayCheckDonationJob, type: :job do
job = enqueued_jobs.select { |j| j['job_class'] == "ActionMailer::MailDeliveryJob" }.first job = enqueued_jobs.select { |j| j['job_class'] == "ActionMailer::MailDeliveryJob" }.first
expect(job['arguments'][0]).to eq('NotificationMailer') expect(job['arguments'][0]).to eq('NotificationMailer')
expect(job['arguments'][1]).to eq('bitcoin_donation_confirmed') expect(job['arguments'][1]).to eq('bitcoin_donation_confirmed')
expect(job['arguments'][3]['params']['user']['_aj_globalid']).to eq('gid://akkounts/User/1') expect(job['arguments'][3]['params']['user']['_aj_globalid']).to eq(user.to_global_id.to_s)
end end
it "does not enqueue itself again" do it "does not enqueue itself again" do
expect_any_instance_of(described_class).not_to receive(:re_enqueue_job) expect_any_instance_of(described_class).not_to receive(:re_enqueue_job)
perform_enqueued_jobs(only: described_class) { job } perform_enqueued_jobs(only: described_class) { job }
end end
it "updates the user's member status" do
expect_any_instance_of(User).to receive(:add_member_status).with(:sustainer)
perform_enqueued_jobs(only: described_class) { job }
end
end end
end end

View File

@ -177,7 +177,7 @@ RSpec.describe "Donations", type: :request do
.to_return(status: 200, headers: {}, body: invoice) .to_return(status: 200, headers: {}, body: invoice)
stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/MCkDbf2cUgBuuisUCgnRnb/payment-methods") stub_request(:get, "http://btcpay.example.com/api/v1/stores/123456/invoices/MCkDbf2cUgBuuisUCgnRnb/payment-methods")
.to_return(status: 200, headers: {}, body: payments) .to_return(status: 200, headers: {}, body: payments)
allow(user).to receive(:add_member_status).with(:sustainer).and_return(["sustainer"])
get confirm_btcpay_contributions_donation_path(subject) get confirm_btcpay_contributions_donation_path(subject)
end end
@ -185,11 +185,16 @@ RSpec.describe "Donations", type: :request do
subject.reload subject.reload
expect(subject.paid_at).not_to be_nil expect(subject.paid_at).not_to be_nil
expect(subject.amount_sats).to eq(2061) expect(subject.amount_sats).to eq(2061)
expect(subject.payment_status).to eq("settled")
end end
it "redirects to the donations index" do it "redirects to the donations index" do
expect(response).to redirect_to(contributions_donations_url) expect(response).to redirect_to(contributions_donations_url)
end end
it "updates the user's member status" do
expect(user).to have_received(:add_member_status).with(:sustainer)
end
end end
describe "amount in sats" do describe "amount in sats" do