diff --git a/.env.sample b/.env.sample
index 56ee0b026..67d940a53 100644
--- a/.env.sample
+++ b/.env.sample
@@ -31,3 +31,9 @@ RAILS_SERVE_STATIC_FILES=true
ORGANIZATION_NAME=A Window Between Worlds
REPLY_TO_EMAIL=umberto.user@example.com
BLAZER_DATABASE_URL=db_url # Optional if you want to use a different db for Blazer
+
+# Pay Gem
+STRIPE_PUBLIC_KEY=test
+STRIPE_PRIVATE_KEY=test
+STRIPE_WEBHOOK_RECEIVE_TEST_EVENTS=true
+# STRIPE_SIGNING_SECRET=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e3dd3ed11..e57a1eeb1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -87,7 +87,7 @@ jobs:
CI: "true"
- name: Publish test results
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
if: always()
with:
name: rspec-results
diff --git a/Gemfile b/Gemfile
index af77ac2e1..7d07e983d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -66,6 +66,11 @@ gem "active_storage_validations", "~> 3.0"
gem "solid_cache"
+# Payments
+gem "pay", "~> 11.4"
+gem "stripe", "~> 18.0"
+gem "receipts", "~> 2.4"
+
group :development do
gem "rubocop-rails-omakase", require: false
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 2d204a934..5bd0ab93d 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -95,7 +95,7 @@ GEM
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
- addressable (2.8.8)
+ addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
ahoy_matey (5.4.1)
activesupport (>= 7.1)
@@ -315,7 +315,7 @@ GEM
multi_xml (0.8.1)
bigdecimal (>= 3.1, < 5)
mutex_m (0.3.0)
- net-imap (0.6.3)
+ net-imap (0.6.4)
date
net-protocol
net-pop (0.1.2)
@@ -325,21 +325,21 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
- nokogiri (1.19.2-aarch64-linux-gnu)
+ nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-aarch64-linux-musl)
+ nokogiri (1.19.3-aarch64-linux-musl)
racc (~> 1.4)
- nokogiri (1.19.2-arm-linux-gnu)
+ nokogiri (1.19.3-arm-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-arm-linux-musl)
+ nokogiri (1.19.3-arm-linux-musl)
racc (~> 1.4)
- nokogiri (1.19.2-arm64-darwin)
+ nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-darwin)
+ nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-linux-gnu)
+ nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-linux-musl)
+ nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4)
opentelemetry-api (1.7.0)
opentelemetry-common (0.23.0)
@@ -520,12 +520,20 @@ GEM
parser (3.3.10.1)
ast (~> 2.4.1)
racc
+ pay (11.4.3)
+ rails (>= 7.0.0)
+ pdf-core (0.9.0)
polyglot (0.3.5)
positioning (0.4.7)
activerecord (>= 6.1)
activesupport (>= 6.1)
pp (0.6.3)
prettyprint
+ prawn (2.4.0)
+ pdf-core (~> 0.9.0)
+ ttfunk (~> 1.7)
+ prawn-table (0.2.2)
+ prawn (>= 1.3.0, < 3.0.0)
premailer (1.27.0)
addressable
css_parser (>= 1.19.0)
@@ -553,12 +561,12 @@ GEM
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
- rack (3.2.5)
+ rack (3.2.6)
rack-mini-profiler (4.0.1)
rack (>= 1.2.0)
rack-proxy (0.7.7)
rack
- rack-session (2.1.1)
+ rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
@@ -604,6 +612,9 @@ GEM
erb
psych (>= 4.0.0)
tsort
+ receipts (2.4.0)
+ prawn (>= 1.3.0, < 3.0.0)
+ prawn-table (~> 0.2.1)
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
@@ -709,12 +720,14 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
+ stripe (18.4.2)
thor (1.5.0)
timeout (0.6.1)
treetop (1.6.18)
polyglot (~> 0.3)
trilogy (2.9.0)
tsort (0.2.0)
+ ttfunk (1.7.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
@@ -800,6 +813,7 @@ DEPENDENCIES
opentelemetry-instrumentation-all
opentelemetry-sdk
ostruct
+ pay (~> 11.4)
positioning (~> 0.4.7)
premailer-rails
pry-coolline
@@ -807,6 +821,7 @@ DEPENDENCIES
puma (~> 6.0)
rack-mini-profiler (~> 4.0)
rails (~> 8.1.0)
+ receipts (~> 2.4)
rspec-rails
rubocop-rails-omakase
search_cop
@@ -819,6 +834,7 @@ DEPENDENCIES
solid_queue (~> 1.3)
sprockets-rails (~> 3.2.2)
stimulus-rails (~> 1.3)
+ stripe (~> 18.0)
trilogy (= 2.9.0)
turbo-rails (~> 2.0)
uglifier
@@ -841,7 +857,7 @@ CHECKSUMS
activerecord (8.1.3) sha256=8003be7b2466ba0a2a670e603eeb0a61dd66058fccecfc49901e775260ac70ab
activestorage (8.1.3) sha256=0564ce9309143951a67615e1bb4e090ee54b8befed417133cae614479b46384d
activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e
- addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057
+ addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
ahoy_matey (5.4.1) sha256=b9ccac7f497889de6982f1503b41a7e275c5a6e4e12a6947b418c69c2c2119b1
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
@@ -943,19 +959,19 @@ CHECKSUMS
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
multi_xml (0.8.1) sha256=addba0290bac34e9088bfe73dc4878530297a82a7bbd66cb44dcd0a4b86edf5a
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
- net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad
+ net-imap (0.6.4) sha256=9a5598c67a3022c284d98430ef1d4948e7dbdb62596f61081ea8ca933270a02b
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
- nokogiri (1.19.2-aarch64-linux-gnu) sha256=c34d5c8208025587554608e98fd88ab125b29c80f9352b821964e9a5d5cfbd19
- nokogiri (1.19.2-aarch64-linux-musl) sha256=7f6b4b0202d507326841a4f790294bf75098aef50c7173443812e3ac5cb06515
- nokogiri (1.19.2-arm-linux-gnu) sha256=b7fa1139016f3dc850bda1260988f0d749934a939d04ef2da13bec060d7d5081
- nokogiri (1.19.2-arm-linux-musl) sha256=61114d44f6742ff72194a1b3020967201e2eb982814778d130f6471c11f9828c
- nokogiri (1.19.2-arm64-darwin) sha256=58d8ea2e31a967b843b70487a44c14c8ba1866daa1b9da9be9dbdf1b43dee205
- nokogiri (1.19.2-x86_64-darwin) sha256=7d9af11fda72dfaa2961d8c4d5380ca0b51bc389dc5f8d4b859b9644f195e7a4
- nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f
- nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8
+ nokogiri (1.19.3-aarch64-linux-gnu) sha256=46b89e5d7b9e844c2ee360794240c6ea2a4e6fa0c5892a4ed487db621224b639
+ nokogiri (1.19.3-aarch64-linux-musl) sha256=8392dfdcd21be7a94dbbe9ccc138dea01b97b24cb2dc02a114ca98bfb1d9a0b7
+ nokogiri (1.19.3-arm-linux-gnu) sha256=3919d5ffc334ad778a4a9eb88fda7dcb8b1fb58c8a52ac640c6dcd2f038e774f
+ nokogiri (1.19.3-arm-linux-musl) sha256=9ce1cb6346bb9c67b1550eb537aa183ead91e4b6eadb2f36ade02d8dd2a79fb6
+ nokogiri (1.19.3-arm64-darwin) sha256=71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42
+ nokogiri (1.19.3-x86_64-darwin) sha256=77f3fba57d46c53ab31e62fc6c28f705109d1bf6264356c76f132b2be5728d4d
+ nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976
+ nokogiri (1.19.3-x86_64-linux-musl) sha256=248c906d2166eca5efb56d52fdee5f9a1f51d69a72e2b64fdac647b4ce39ea3f
opentelemetry-api (1.7.0) sha256=ccfd264ea6f2db5bf4185e3c07a1297977b44a944e2ce65457c4fe63a697214f
opentelemetry-common (0.23.0) sha256=da721190479d57bae0ad2207468f47f3e2c3b9a91024b5bc32c9d280183eb32c
opentelemetry-exporter-otlp (0.31.1) sha256=5358be17d7849cbcc4f49e1fc24105edc780a6f96c8e57b64192ab9a8e47474a
@@ -1015,9 +1031,13 @@ CHECKSUMS
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
+ pay (11.4.3) sha256=f3d50b1a900ab0a7bfe9ba9fda631b2f5faf0c95236dd3d2afa9effc9bfb15e8
+ pdf-core (0.9.0) sha256=4f368b2f12b57ec979872d4bf4bd1a67e8648e0c81ab89801431d2fc89f4e0bb
polyglot (0.3.5) sha256=59d66ef5e3c166431c39cb8b7c1d02af419051352f27912f6a43981b3def16af
positioning (0.4.7) sha256=c9a8cfba8b99180bd2b05022cdbcc7b234ea8bb1aa9101010e06bdda9a7bb220
pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
+ prawn (2.4.0) sha256=82062744f7126c2d77501da253a154271790254dfa8c309b8e52e79bc5de2abd
+ prawn-table (0.2.2) sha256=336d46e39e003f77bf973337a958af6a68300b941c85cb22288872dc2b36addb
premailer (1.27.0) sha256=0fe2348cd82738855c482b31c915a06ecb1d3ad004578c19042905196ddbd1e7
premailer-rails (1.12.0) sha256=c13815d161b9bc7f7d3d81396b0bb0a61a90fa9bd89931548bf4e537c7710400
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
@@ -1030,10 +1050,10 @@ CHECKSUMS
puma (6.6.1) sha256=b9b56e4a4ea75d1bfa6d9e1972ee2c9f43d0883f011826d914e8e37b3694ea1e
raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
- rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
+ rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
rack-mini-profiler (4.0.1) sha256=485810c23211f908196c896ea10cad72ed68780ee2998bec1f1dfd7558263d78
rack-proxy (0.7.7) sha256=446a4b57001022145d5c3ba73b775f66a2260eaf7420c6907483141900395c8a
- rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
+ rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
rails (8.1.3) sha256=6d017ba5348c98fc909753a8169b21d44de14d2a0b92d140d1a966834c3c9cd3
@@ -1045,6 +1065,7 @@ CHECKSUMS
rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
+ receipts (2.4.0) sha256=64a7d9d95d223694106719dc10fb9291fa362e6bc684228d5cde4b772392fa74
regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb
@@ -1081,11 +1102,13 @@ CHECKSUMS
sprockets-rails (3.2.2) sha256=62862bce136e31d7497eededde5f7730d4096bc8ef33ef7037c41423ccf89557
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
+ stripe (18.4.2) sha256=fd08a73ab87fc0b0ad938ca71293e1051e593001b70de5e9fcf12d1097133831
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
treetop (1.6.18) sha256=a3043f32f1c652aa2abdf3a3848edb2d2f69897257af2675516ac61355c183da
trilogy (2.9.0) sha256=a2d63b663ba68a4758e15d1f9afb228f5d16efc7fe7cea68699e1c106ef6067f
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+ ttfunk (1.7.0) sha256=2370ba484b1891c70bdcafd3448cfd82a32dd794802d81d720a64c15d3ef2a96
turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
uglifier (4.2.1) sha256=75d42b81b10bfd21e7a427fabb1d49ff5ea7bda3c4a5039ddb2a78d194c6f5aa
diff --git a/app/controllers/allocations_controller.rb b/app/controllers/allocations_controller.rb
new file mode 100644
index 000000000..82a2f6aae
--- /dev/null
+++ b/app/controllers/allocations_controller.rb
@@ -0,0 +1,146 @@
+class AllocationsController < ApplicationController
+ before_action :authenticate_user!
+
+ def index
+ authorize!
+ if params[:allocatable_sgid].present?
+ @allocatable = GlobalID::Locator.locate_signed(params[:allocatable_sgid])
+ @allocations = @allocatable.allocations.includes(:source).order(created_at: :desc).paginate(page: params[:page], per_page: 10)
+ else
+ if turbo_frame_request?
+ @allocations = Allocation.search_by_params(params).includes(:source).order(created_at: :desc).paginate(page: params[:page], per_page: 10)
+ render :allocation_results
+ else
+ @allocations = Allocation.search_by_params(params).includes(:source).order(created_at: :desc).paginate(page: params[:page], per_page: 10)
+ end
+ end
+ end
+
+ def new
+ authorize!
+
+ if params[:source_sgid].present?
+ @source = GlobalID::Locator.locate_signed(params[:source_sgid])
+ end
+
+ @allocation = Allocation.new(source: @source)
+ end
+
+ def create
+ authorize!
+
+ amount_val = allocation_params[:amount_dollars].present? ? (allocation_params[:amount_dollars].to_d * 100).to_i : 0
+
+ @allocation = Allocation.new(
+ source_type: allocation_params[:source_type],
+ source_id: allocation_params[:source_id],
+ allocatable_type: allocation_params[:allocatable_type],
+ allocatable_id: allocation_params[:allocatable_id],
+ amount: amount_val
+ )
+
+ if @allocation.source_type && @allocation.source_id
+ @source = @allocation.source_type.constantize.find_by(id: @allocation.source_id)
+ end
+
+ if @allocation.allocatable_type == "EventRegistration" && @allocation.allocatable.present?
+ unless validate_event_registration_cost(amount_val)
+ flash.now[:error] = @allocation.errors.full_messages.join(", ")
+ respond_to do |format|
+ format.turbo_stream { render turbo_stream: turbo_stream.replace("flash_now", partial: "shared/flash_messages"), status: :unprocessable_content }
+ format.html { render :new, status: :unprocessable_content }
+ end
+ return
+ end
+ end
+
+ unless @source.present?
+ @allocation.errors.add(:base, "Source is required")
+ render :new, status: :unprocessable_content
+ return
+ end
+
+ @source.with_lock do
+ if @source.is_a?(Payment)
+ if @allocation.amount > @source.amount_cents_remaining
+ @allocation.errors.add(:base, "Cannot exceed remaining amount ($#{@source.remaining_dollars})")
+
+ flash.now[:error] = @allocation.errors.full_messages.join(", ")
+ respond_to do |format|
+ format.turbo_stream { render turbo_stream: turbo_stream.replace("flash_now", partial: "shared/flash_messages"), status: :unprocessable_content }
+ format.html { render :new, status: :unprocessable_content }
+ end
+ return
+ end
+
+ if @allocation.save
+ @source.update!(amount_cents_remaining: @source.amount_cents_remaining - amount_val)
+ flash[:notice] = "Allocation created. $#{'%.2f' % @source.remaining_dollars} remaining on payment."
+ redirect_to payment_path(@source)
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+ end
+ end
+
+ def revert
+ authorize!
+
+ @allocation = Allocation.find(params[:id])
+
+ if @allocation.reverted?
+ flash[:error] = "This allocation has already been reverted"
+ redirect_to payment_path(@allocation.source)
+ return
+ end
+
+ @revert = Allocation.new(
+ source: @allocation.source,
+ allocatable: @allocation.allocatable,
+ amount: -@allocation.amount
+ )
+
+ payment = @allocation.source
+
+ payment.with_lock do
+ ActiveRecord::Base.transaction do
+ if @revert.save
+ @allocation.update!(reverted_id: @revert.id)
+
+ payment.update!(amount_cents_remaining: payment.amount_cents_remaining + @allocation.amount)
+
+ redirect_to payment_path(payment), notice: "Allocation reverted"
+ else
+ flash[:error] = @revert.errors.full_messages.join(", ")
+ redirect_to payment_path(@allocation.source)
+ end
+ end
+ end
+ end
+
+ private
+
+ def validate_event_registration_cost(amount_val)
+ event_reg = @allocation.allocatable
+ event = event_reg.event
+ if event.cost_cents.blank?
+ @allocation.errors.add(:base, "Cannot allocate to a free event.")
+ return false
+ end
+ current_allocated = event_reg.allocations_sum || 0
+ new_total = current_allocated + amount_val
+
+ if new_total > event.cost_cents
+ remaining = [ event.cost_cents - current_allocated, 0 ].max
+ @allocation.errors.add(:base, "Cannot allocate more than remaining event cost. remaining: $#{'%.2f' % (remaining / 100.0)}")
+ return false
+ end
+
+ true
+ end
+
+ def allocation_params
+ params.expect(allocation: [ :source_type, :source_id, :allocatable_type, :allocatable_id, :amount_dollars ])
+ end
+end
diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb
index df771df8f..3593c4cb7 100644
--- a/app/controllers/events_controller.rb
+++ b/app/controllers/events_controller.rb
@@ -39,7 +39,7 @@ def manage
authorize! @event, to: :manage?
@event = @event.decorate
scope = @event.event_registrations
- .includes(:payments, :comments, :organizations, registrant: [ :user, :contact_methods, { avatar_attachment: :blob } ])
+ .includes(:comments, :organizations, registrant: [ :user, :contact_methods, { avatar_attachment: :blob } ])
.joins(:registrant)
scope = scope.keyword(params[:keyword]) if params[:keyword].present?
scope = scope.attendance_status(params[:attendance_status]) if params[:attendance_status].present?
@@ -202,7 +202,7 @@ def event_registration_csv_row(registration, cost_required)
.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }
.map(&:organization).compact.uniq
org_names = orgs.map(&:name).join("; ")
- total_cents = registration.successful_payments_total_cents
+ total_cents = registration.allocations_sum
payment_total = total_cents.positive? ? format("%.2f", total_cents / 100.0) : ""
payment_status = cost_required ? (registration.paid_in_full? ? "Paid in full" : "Not paid in full") : ""
[
diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb
index 0b76fd2a0..6c948f914 100644
--- a/app/controllers/notifications_controller.rb
+++ b/app/controllers/notifications_controller.rb
@@ -3,13 +3,18 @@ class NotificationsController < ApplicationController
def index
authorize!
- per_page = params[:number_of_items_per_page].presence || 25
- base_scope = authorized_scope(Notification.includes(:noticeable))
- filtered = base_scope.search_by_params(params.to_unsafe_h)
- @notifications = filtered.order(created_at: :desc)
- .paginate(page: params[:page], per_page: per_page)
- render turbo_frame_request? ? :notifications_results : :index
+ if turbo_frame_request?
+ per_page = params[:number_of_items_per_page].presence || 25
+ base_scope = authorized_scope(Notification.includes(:noticeable))
+ filtered = base_scope.search_by_params(params.to_unsafe_h)
+ @notifications = filtered.order(created_at: :desc)
+ .paginate(page: params[:page], per_page: per_page)
+
+ render :notifications_results
+ else
+ render :index
+ end
end
def show
diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb
new file mode 100644
index 000000000..d685596ba
--- /dev/null
+++ b/app/controllers/payments_controller.rb
@@ -0,0 +1,140 @@
+class PaymentsController < ApplicationController
+ def index
+ authorize!
+ per_page = params[:number_of_items_per_page].presence || 10
+
+ if turbo_frame_request?
+ @payments = Payment.search_by_params(params).order(created_at: :desc).paginate(page: params[:page], per_page: per_page)
+ render :payment_results
+ else
+ @payments = Payment.search_by_params(params).order(created_at: :desc).paginate(page: params[:page], per_page: per_page)
+ end
+ end
+
+ def new
+ authorize!
+ payment_type = params[:type]
+ @payment = payment_type.safe_constantize.new
+
+ if params[:allocatable_sgid].present?
+ @allocatable = GlobalID::Locator.locate_signed(params[:allocatable_sgid])
+ if @allocatable.is_a?(EventRegistration)
+ @payment.payer = @allocatable.registrant
+ end
+ end
+ end
+
+ def create
+ authorize!
+ payment_class = params[:payment][:type].presence&.safe_constantize || CashPayment
+
+ allocatable = locate_allocatable
+
+ begin
+ if allocatable.present?
+ payment_attrs = build_payment_attributes(payment_params, allocatable)
+ @payment = payment_class.new(payment_attrs)
+
+ process_allocation!(@payment, allocatable)
+
+ flash[:notice] = "Allocation created. $#{'%.2f' % @payment.reload.remaining_dollars} remaining on payment."
+ redirect_path = allocations_path(allocatable_sgid: allocatable.to_sgid.to_s)
+ else
+ @payment = payment_class.new(payment_params.except(:allocatable_sgid))
+ @payment.save!
+ redirect_path = payments_path
+ end
+
+ respond_to do |format|
+ format.turbo_stream { redirect_to redirect_path }
+ format.html { redirect_to redirect_path, notice: "Payment was successfully created." }
+ end
+
+ rescue ActiveRecord::RecordInvalid => e
+ flash[:error] = @payment.errors.full_messages.join(", ")
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def show
+ @payment = Payment.find(params[:id])
+ @allocations = @payment.allocations.order(created_at: :desc)
+ @refunds = @payment.refunds.order(created_at: :desc)
+ authorize! @payment, with: PaymentPolicy
+ end
+
+ def allocation_form
+ authorize!
+ payment_type = params[:type].presence
+ @payment = payment_type.safe_constantize.new
+
+ if params[:allocatable_sgid].present?
+ @allocatable = GlobalID::Locator.locate_signed(params[:allocatable_sgid])
+ if @allocatable.is_a?(EventRegistration)
+ @payment.payer = @allocatable.registrant
+ end
+ end
+
+ respond_to do |format|
+ format.turbo_stream
+ end
+ end
+
+ private
+
+ def payment_params
+ params.require(:payment).permit(:type, :payer_type, :payer_id, :amount_dollars, :currency, :check_number, :allocatable_sgid)
+ end
+
+ def locate_allocatable
+ return nil unless params[:payment][:allocatable_sgid].present?
+ GlobalID::Locator.locate_signed(params[:payment][:allocatable_sgid])
+ end
+
+ def build_payment_attributes(payment_params, allocatable)
+ attrs = payment_params.except(:allocatable_sgid)
+ attrs[:payer_type] = params[:payment][:payer_type].presence || "Person"
+ attrs[:payer_id] = params[:payment][:payer_id].presence || (allocatable.try(:registrant_id) if allocatable.is_a?(EventRegistration))
+ attrs
+ end
+
+ def process_allocation!(payment, allocatable)
+ Payment.transaction do
+ payment.save!
+
+ payment.with_lock do
+ allocation_amount = calculate_allocation_amount(payment, allocatable)
+ current_allocated = payment.allocations.sum(:amount)
+
+ if current_allocated + allocation_amount > payment.amount_cents
+ raise ActiveRecord::Rollback, "Cannot allocate more than payment amount"
+ end
+
+ Allocation.create!(
+ source: payment,
+ allocatable: allocatable,
+ amount: allocation_amount
+ )
+
+ payment.update!(amount_cents_remaining: payment.amount_cents - current_allocated - allocation_amount)
+ end
+ end
+ end
+
+ def calculate_allocation_amount(payment, allocatable)
+ payment_amount = payment.amount_cents
+
+ if allocatable.is_a?(EventRegistration)
+ event_cost = allocatable.event.cost_cents
+ already_allocated = allocatable.allocations_sum
+ remaining_needed = event_cost - already_allocated
+ [ payment_amount, remaining_needed ].min
+ else
+ payment_amount
+ end
+ end
+
+ def redirect_path_for(allocatable)
+ allocations_path(allocatable_sgid: allocatable.to_sgid.to_s)
+ end
+end
diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb
index c8befbfb9..3f20720f7 100644
--- a/app/controllers/people_controller.rb
+++ b/app/controllers/people_controller.rb
@@ -1,6 +1,6 @@
class PeopleController < ApplicationController
include AhoyTracking, TagAssignable
- before_action :set_person, only: %i[ show edit update destroy workshop_logs ]
+ before_action :set_person, only: %i[ show edit update destroy workshop_logs checkout ]
def index
authorize!
@@ -29,6 +29,12 @@ def show
@person = Person.includes(:avatar_attachment, :contact_methods, :user).find(params[:id]).decorate
track_view(@person)
+ if params[:checkout] == "success"
+ flash[:notice] = "Thank you for your donation!"
+ elsif params[:checkout] == "cancelled"
+ flash[:alert] = "Donation was cancelled."
+ end
+
# Handle paginated sections for Turbo Frame requests
if turbo_frame_request?
per_page = params[:section] == "stories" ? 8 : 9
@@ -83,6 +89,36 @@ def workshop_logs
end
end
+ def checkout
+ authorize! @person, with: PersonPolicy
+
+ amount = params[:amount].to_i
+ amount = (amount * 100).to_i # Convert dollars to cents
+ amount = 1000 if amount < 1000 # Minimum $10.00
+
+ @checkout_session = @person.payment_processor.checkout(
+ mode: "payment",
+ metadata: { person_id: @person.id },
+ payment_intent_data: {
+ metadata: { person_id: @person.id }
+ },
+ line_items: [ {
+ price_data: {
+ currency: "usd",
+ product_data: {
+ name: "Donation to A Window Between Worlds"
+ },
+ unit_amount: amount
+ },
+ quantity: 1
+ } ],
+ success_url: person_url(@person, checkout: "success"),
+ cancel_url: person_url(@person, checkout: "cancelled")
+ )
+
+ redirect_to @checkout_session.url, allow_other_host: true, status: :see_other
+ end
+
def new
set_user
@person = @user ? PersonFromUserService.new(user: @user).call : Person.new
diff --git a/app/controllers/refunds_controller.rb b/app/controllers/refunds_controller.rb
new file mode 100644
index 000000000..be01d10d5
--- /dev/null
+++ b/app/controllers/refunds_controller.rb
@@ -0,0 +1,49 @@
+class RefundsController < ApplicationController
+ before_action :authenticate_user!
+
+ def new
+ authorize!
+ if params[:payment_sgid].present?
+ @payment = GlobalID::Locator.locate_signed(params[:payment_sgid])
+ end
+ @refund = Refund.new(
+ refundable: @payment,
+ refundable_type: "Payment",
+ refundable_id: @payment&.id
+ )
+ end
+
+ def create
+ authorize!
+
+ @payment = Payment.find_by(id: params[:refund][:refundable_id])
+
+ amount_val = (params[:refund][:amount_dollars].to_d * 100).to_i if params[:refund][:amount_dollars].present?
+
+ remaining = @payment.amount_cents_remaining
+
+ if amount_val > remaining
+ flash[:error] = "Refund cannot exceed remaining amount (#{remaining})"
+ redirect_to new_refund_path(payment_sgid: @payment.to_sgid.to_s)
+ return
+ end
+
+ @refund = Refund.new(
+ refundable: @payment,
+ recipient_type: params[:refund][:recipient_type],
+ recipient_id: params[:refund][:recipient_id],
+ amount_cents: amount_val,
+ method: params[:refund][:method]
+ )
+
+ if @refund.save
+ @payment.with_lock do
+ @payment.update!(amount_cents_remaining: @payment.amount_cents_remaining - amount_val)
+ end
+ redirect_to payment_path(@payment), notice: "Refund created"
+ else
+ flash[:error] = @refund.errors.full_messages.join(", ")
+ render :new, status: :unprocessable_content
+ end
+ end
+end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index f181c56f6..bbe54dc05 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -38,7 +38,8 @@ def allowed_model(model_param)
"person" => Person,
"user" => User,
"workshop" => Workshop,
- "organization" => Organization
+ "organization" => Organization,
+ "event_registration" => EventRegistration
}[model_param]
end
diff --git a/app/frontend/javascript/controllers/collection_controller.js b/app/frontend/javascript/controllers/collection_controller.js
index b9f56ce46..2d5818a75 100644
--- a/app/frontend/javascript/controllers/collection_controller.js
+++ b/app/frontend/javascript/controllers/collection_controller.js
@@ -3,6 +3,8 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="collection"
export default class extends Controller {
static classes = ["unselected", "selected"];
+ static outlets = ["search-type-select"];
+
connect() {
this.element.addEventListener("change", (event) => {
const { type } = event.target;
@@ -21,7 +23,13 @@ export default class extends Controller {
}
});
this.element.addEventListener("input", (event) => {
- if (event.target.type === "text") {
+ // skip submit on tom-select keyboard input
+ const target = event.target;
+ if (target.type !== "text") return;
+
+ const isTomSelect = target.closest(".ts-control");
+
+ if (!isTomSelect) {
this.debouncedSubmit();
}
});
@@ -57,19 +65,30 @@ export default class extends Controller {
clearAndSubmit(event) {
event.preventDefault();
- this.element.querySelectorAll('input[type="text"], input[type="search"]').forEach(input => {
- input.value = '';
- });
- this.element.querySelectorAll('select').forEach(select => {
+ this.element
+ .querySelectorAll('input[type="text"], input[type="search"]')
+ .forEach((input) => {
+ input.value = "";
+ });
+ this.element.querySelectorAll("select").forEach((select) => {
select.selectedIndex = 0;
});
- this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(input => {
- if (input.checked) {
- this.toggleClass(input);
- }
- input.checked = false;
- });
+ this.element
+ .querySelectorAll('input[type="checkbox"], input[type="radio"]')
+ .forEach((input) => {
+ if (input.checked) {
+ this.toggleClass(input);
+ }
+ input.checked = false;
+ });
this.element.reset();
+
+ if (this.hasSearchTypeSelectOutlet) {
+ this.searchTypeSelectOutlets.forEach((controller) => {
+ controller.toggle({ target: { value: "" } });
+ });
+ }
+
this.submitForm();
}
diff --git a/app/frontend/javascript/controllers/currency_input_controller.js b/app/frontend/javascript/controllers/currency_input_controller.js
new file mode 100644
index 000000000..fc954c481
--- /dev/null
+++ b/app/frontend/javascript/controllers/currency_input_controller.js
@@ -0,0 +1,18 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ format() {
+ let value = this.element.value.replace(/[^\d]/g, "")
+
+ if (value === "") {
+ this.element.value = ""
+ return
+ }
+
+ value = value.padStart(2, "0")
+
+ const dollars = value.slice(0, -2).replace(/^0+/, "") || "0"
+ const cents = value.slice(-2)
+ this.element.value = `${dollars}.${cents}`
+ }
+}
diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js
index 2fdbf1a09..9ce5c16bc 100644
--- a/app/frontend/javascript/controllers/index.js
+++ b/app/frontend/javascript/controllers/index.js
@@ -57,6 +57,9 @@ application.register("paginated-fields", PaginatedFieldsController)
import PasswordToggleController from "./password_toggle_controller"
application.register("password-toggle", PasswordToggleController)
+import SearchTypeSelectController from "./search_type_select_controller"
+application.register("search-type-select", SearchTypeSelectController)
+
import PrefetchLazyController from "./prefetch_lazy_controller"
application.register("prefetch-lazy", PrefetchLazyController)
@@ -111,3 +114,6 @@ application.register("remote-select", RemoteSelectController)
import MixedChartController from "./mixed_chart_controller"
application.register("mixed-chart", MixedChartController)
+import CurrencyInputController from "./currency_input_controller"
+application.register("currency-input", CurrencyInputController)
+
diff --git a/app/frontend/javascript/controllers/search_type_select_controller.js b/app/frontend/javascript/controllers/search_type_select_controller.js
new file mode 100644
index 000000000..046c3d2a9
--- /dev/null
+++ b/app/frontend/javascript/controllers/search_type_select_controller.js
@@ -0,0 +1,26 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = ["template", "pickerContainer"];
+
+ connect() {
+ this._placeholder = this.pickerContainerTarget.innerHTML;
+ }
+
+ toggle(event) {
+ const selectedType = event.target.value;
+
+ this.pickerContainerTarget.innerHTML = "";
+
+ if (!selectedType) {
+ this.pickerContainerTarget.innerHTML = this._placeholder;
+ return;
+ }
+
+ const template = this.templateTargets.find(t => t.dataset.type === selectedType);
+ if (template) {
+ const cloned = template.content.cloneNode(true);
+ this.pickerContainerTarget.appendChild(cloned);
+ }
+ }
+}
diff --git a/app/helpers/admin_cards_helper.rb b/app/helpers/admin_cards_helper.rb
index 5856b9cf5..3ba78ff02 100644
--- a/app/helpers/admin_cards_helper.rb
+++ b/app/helpers/admin_cards_helper.rb
@@ -34,7 +34,9 @@ def user_content_cards
custom_card("Tags", tags_path, icon: "π·οΈ", color: :lime, intensity: 100),
model_card(:workshop_ideas, icon: "π‘", intensity: 100),
model_card(:workshop_variation_ideas, icon: "π", intensity: 100),
- model_card(:workshop_logs, icon: "π", intensity: 100)
+ model_card(:workshop_logs, icon: "π", intensity: 100),
+ model_card(:payments, icon: "π³"),
+ model_card(:allocations, icon: "π€")
]
end
diff --git a/app/models/allocation.rb b/app/models/allocation.rb
new file mode 100644
index 000000000..179010f90
--- /dev/null
+++ b/app/models/allocation.rb
@@ -0,0 +1,72 @@
+class Allocation < ApplicationRecord
+ belongs_to :source, polymorphic: true
+ belongs_to :allocatable, polymorphic: true
+ belongs_to :reverted, class_name: "Allocation", optional: true
+
+ has_one :revert_record, class_name: "Allocation", foreign_key: "reverted_id", inverse_of: :reverted
+
+ validates :amount, numericality: true
+ validates :allocatable_type, presence: true
+ validates :allocatable_id, presence: true
+
+ validate :reverted_requires_positive_amount, :negative_cannot_be_reverted
+
+ def reverted?
+ reverted_id.present?
+ end
+
+ def amount_dollars
+ amount.to_d / 100 if amount
+ end
+
+ def amount_dollars=(value)
+ self.amount = (value.to_d * 100).to_i if value.present?
+ end
+
+ scope :by_source_type, ->(types) {
+ return if types.blank?
+ types = types.split(",") if types.is_a?(String)
+ types = types.reject(&:blank?)
+ return unless types.present?
+ where("source_type = 'Payment' AND EXISTS (SELECT 1 FROM payments WHERE payments.id = allocations.source_id AND payments.type IN (?))", types)
+ }
+ scope :by_allocatable_type, ->(type) { where(allocatable_type: type) if type.present? }
+ scope :has_reverted, ->(value) {
+ case value
+ when "yes" then where("reverted_id IS NOT NULL OR amount < 0")
+ when "no" then where("reverted_id IS NULL AND amount >= 0")
+ end
+ }
+ scope :by_payer, ->(payer_type, payer_id) {
+ if payer_type.present? && payer_id.present?
+ where("source_type LIKE ? AND source_id IN (?)", "%Payment", Payment.where(payer_type: payer_type, payer_id: payer_id).select(:id))
+ end
+ }
+ scope :by_allocatable_id, ->(allocatable_type, allocatable_id) {
+ where(allocatable_type: allocatable_type, allocatable_id: allocatable_id) if allocatable_type.present? && allocatable_id.present?
+ }
+
+ def self.search_by_params(params)
+ results = all
+ results = results.by_source_type(params[:source_type]) if params[:source_type].present?
+ results = results.by_allocatable_type(params[:allocatable_type]) if params[:allocatable_type].present?
+ results = results.has_reverted(params[:has_reverted]) if params[:has_reverted].present? && params[:has_reverted] != "all"
+ results = results.by_payer(params[:payer_type], params[:payer_id]) if params[:payer_type].present? && params[:payer_id].present?
+ results = results.by_allocatable_id(params[:allocatable_type], params[:allocatable_id]) if params[:allocatable_type].present? && params[:allocatable_id].present?
+ results
+ end
+
+ private
+
+ def reverted_requires_positive_amount
+ if reverted_id.present? && amount.to_i < 0
+ errors.add(:reverted_id, "must be on a positive amount allocation")
+ end
+ end
+
+ def negative_cannot_be_reverted
+ if amount.to_i < 0 && reverted_id.present?
+ errors.add(:amount, "cannot be negative when reverting another allocation")
+ end
+ end
+end
diff --git a/app/models/cash_payment.rb b/app/models/cash_payment.rb
new file mode 100644
index 000000000..a226b6d78
--- /dev/null
+++ b/app/models/cash_payment.rb
@@ -0,0 +1,2 @@
+class CashPayment < Payment
+end
diff --git a/app/models/check_payment.rb b/app/models/check_payment.rb
new file mode 100644
index 000000000..d292fb9d8
--- /dev/null
+++ b/app/models/check_payment.rb
@@ -0,0 +1,3 @@
+class CheckPayment < Payment
+ validates :check_number, presence: true
+end
diff --git a/app/models/concerns/pay_charge_extensions.rb b/app/models/concerns/pay_charge_extensions.rb
new file mode 100644
index 000000000..41fe56b4b
--- /dev/null
+++ b/app/models/concerns/pay_charge_extensions.rb
@@ -0,0 +1,54 @@
+module PayChargeExtensions
+ extend ActiveSupport::Concern
+
+ included do
+ after_save_commit :create_external_processor_payment
+ after_save_commit :sync_refunds
+ end
+
+ private
+
+ def create_external_processor_payment
+ return if ExternalProcessorPayment.exists?(pay_charge_id: id)
+ return unless object["paid"] == true
+
+ person_id = metadata["person_id"]
+ return unless person_id
+
+ person = Person.find_by(id: person_id.to_i)
+ return unless person
+
+ ExternalProcessorPayment.create!(
+ payer: person,
+ amount_cents: amount,
+ amount_cents_remaining: amount,
+ currency: currency,
+ pay_charge_id: id
+ )
+ end
+
+ def sync_refunds
+ refunds_data = object["refunds"]["data"] || []
+ return if refunds_data.empty?
+
+ external_payment = ExternalProcessorPayment.find_by(pay_charge_id: id)
+ return unless external_payment
+
+ refunds_data.each do |stripe_refund|
+ next unless stripe_refund["status"] == "succeeded"
+ next if Refund.exists?(stripe_refund_id: stripe_refund["id"])
+
+ Refund.create!(
+ refundable: external_payment,
+ recipient: external_payment.payer,
+ amount_cents: stripe_refund["amount"],
+ method: "stripe",
+ stripe_refund_id: stripe_refund["id"]
+ )
+
+ external_payment.update!(
+ amount_cents_remaining: external_payment.amount_cents_remaining - stripe_refund["amount"]
+ )
+ end
+ end
+end
diff --git a/app/models/concerns/remote_searchable.rb b/app/models/concerns/remote_searchable.rb
index c7554e7de..898c30d83 100644
--- a/app/models/concerns/remote_searchable.rb
+++ b/app/models/concerns/remote_searchable.rb
@@ -2,31 +2,41 @@ module RemoteSearchable
extend ActiveSupport::Concern
class_methods do
- def remote_searchable_by(*columns)
+ def remote_searchable_by(*columns, **options)
@remote_search_columns = columns.map(&:to_s)
+ @remote_search_scope = options[:scope]
end
def remote_search_columns
@remote_search_columns || []
end
+ def remote_search_scope
+ @remote_search_scope
+ end
+
def remote_search(query)
return none if query.blank?
- raise "remote_searchable_by not defined for #{name}" if remote_search_columns.empty?
- words = query.split.flat_map { |w| w.split(/[\s\-]+/) }.reject(&:blank?)
- return none if words.blank?
+ if remote_search_scope
+ instance_exec(query, &remote_search_scope)
+ else
+ raise "remote_searchable_by not defined for #{name}" if remote_search_columns.empty?
- conditions = words.each_with_index.map do |word, i|
- bind_var = "pattern_#{i}".to_sym
- column_conditions = remote_search_columns.map { |column| "#{table_name}.#{column} LIKE :#{bind_var}" }
- "(#{column_conditions.join(' OR ')})"
- end
- bindings = words.each_with_index.each_with_object({}) do |(word, i), hash|
- hash["pattern_#{i}".to_sym] = "%#{word}%"
+ words = query.split.flat_map { |w| w.split(/[\s\-]+/) }.reject(&:blank?)
+ return none if words.blank?
+
+ conditions = words.each_with_index.map do |word, i|
+ bind_var = "pattern_#{i}".to_sym
+ column_conditions = remote_search_columns.map { |column| "#{table_name}.#{column} LIKE :#{bind_var}" }
+ "(#{column_conditions.join(' OR ')})"
+ end
+ bindings = words.each_with_index.each_with_object({}) do |(word, i), hash|
+ hash["pattern_#{i}".to_sym] = "%#{word}%"
+ end
+ where(conditions.join(" AND "), bindings)
+ .order(remote_search_columns.index_with { :asc })
end
- where(conditions.join(" AND "), bindings)
- .order(remote_search_columns.index_with { :asc })
end
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 8f67557f0..1dd3971b1 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -9,7 +9,6 @@ class Event < ApplicationRecord
belongs_to :location, optional: true
has_many :bookmarks, as: :bookmarkable, dependent: :destroy
has_many :event_registrations, dependent: :destroy
- has_many :payments
has_many :event_forms, dependent: :destroy
has_many :categorizable_items, dependent: :destroy, inverse_of: :categorizable, as: :categorizable
diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb
index 92dd5ca2a..3ed03cea2 100644
--- a/app/models/event_registration.rb
+++ b/app/models/event_registration.rb
@@ -1,11 +1,13 @@
class EventRegistration < ApplicationRecord
+ include RemoteSearchable
+
belongs_to :registrant, class_name: "Person"
belongs_to :event
has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy
has_many :event_registration_organizations, dependent: :destroy
has_many :notifications, as: :noticeable, dependent: :destroy
has_many :organizations, through: :event_registration_organizations
- has_many :payments, as: :payable
+ has_many :allocations, as: :allocatable
before_destroy :create_refund_payments
@@ -87,20 +89,14 @@ def paid?
paid_in_full?
end
- # Sum of successful payment amounts, using preloaded collection when available
- def successful_payments_total_cents
- if payments.loaded?
- payments.select(&:succeeded?).sum(&:amount_cents)
- else
- payments.successful.sum(:amount_cents)
- end
+ def allocations_sum
+ allocations.sum(:amount)
end
- # True if event is free, scholarship recipient, or total successful payments >= event.cost_cents
def paid_in_full?
return true if event.cost_cents.to_i <= 0
return true if scholarship_recipient?
- successful_payments_total_cents >= event.cost_cents.to_i
+ allocations_sum >= event.cost_cents.to_i
end
def scholarship?
@@ -128,6 +124,34 @@ def attendance_status_label
end
end
+ remote_searchable_by :registrant,
+ scope: ->(query) {
+ return none if query.blank?
+
+ words = query.split.flat_map { |w| w.split(/[\s\-]+/) }.reject(&:blank?)
+ return none if words.blank?
+
+ pattern = "%#{words.join('%')}%"
+ active
+ .joins(:registrant, :event)
+ .where(
+ "people.first_name LIKE :p
+ OR people.last_name LIKE :p
+ OR people.email LIKE :p
+ OR people.legal_first_name LIKE :p
+ OR people.email_2 LIKE :p
+ OR events.title LIKE :p",
+ p: pattern
+ )
+ }
+
+ def remote_search_label
+ {
+ id: id,
+ label: "#{registrant.full_name} - #{event.title} (##{id})"
+ }
+ end
+
private
def snapshot_registrant_organizations
diff --git a/app/models/external_processor_payment.rb b/app/models/external_processor_payment.rb
new file mode 100644
index 000000000..941765527
--- /dev/null
+++ b/app/models/external_processor_payment.rb
@@ -0,0 +1,4 @@
+class ExternalProcessorPayment < Payment
+ belongs_to :pay_charge, class_name: "Pay::Charge"
+ # validates :pay_charge_id, presence: true
+end
diff --git a/app/models/payment.rb b/app/models/payment.rb
index d741fcf81..bee7c61bf 100644
--- a/app/models/payment.rb
+++ b/app/models/payment.rb
@@ -1,49 +1,62 @@
class Payment < ApplicationRecord
- # --- Associations ---
- belongs_to :payer, polymorphic: true
- belongs_to :payable, polymorphic: true
- belongs_to :event, optional: true
+ has_many :allocations, as: :source
+ has_many :refunds, as: :refundable
+ belongs_to :payer, polymorphic: true
- # --- Callbacks ---
- attribute :currency, :string, default: "usd"
- attribute :status, :string, default: "pending"
-
- PAYMENT_TYPES = %w[ stripe scholarship check purchase_order other refund ].freeze
-
- # --- Validations ---
validates :amount_cents, numericality: true
validates :currency, presence: true
- validates :status, presence: true
- validates :payment_type, inclusion: { in: PAYMENT_TYPES }
- validates :stripe_payment_intent_id, presence: true, if: -> { payment_type == "stripe" }
-
- validates :stripe_payment_intent_id, uniqueness: true, allow_nil: true
- validates :stripe_charge_id, uniqueness: true, allow_nil: true
-
- STRIPE_PAYMENT_STATUSES = %w[
- pending
- requires_action
- processing
- succeeded
- failed
- canceled
- refunded
- partially_refunded
- ].freeze
-
- validates :status, inclusion: { in: STRIPE_PAYMENT_STATUSES }
-
- scope :for_payable, ->(payable) { where(payable: payable) }
- scope :successful, -> { where(status: "succeeded") }
- scope :pendingish, -> { where(status: %w[pending requires_action processing]) }
- scope :scholarships, -> { where(payment_type: "scholarship") }
- scope :refunds, -> { where(payment_type: "refund") }
-
- def succeeded?
- status == "succeeded"
- end
-
- def scholarship?
- payment_type == "scholarship"
+
+ before_validation :set_amount_cents_remaining, if: :new_record?
+
+ scope :by_type, ->(types) {
+ return if types.blank?
+ types = types.split(",") if types.is_a?(String)
+ types = types.reject(&:blank?)
+ where(type: types) if types.present?
+ }
+ scope :by_payer, ->(payer_type, payer_id) { where(payer_type: payer_type, payer_id: payer_id) if payer_type.present? && payer_id.present? }
+ scope :has_remaining, ->(value) {
+ case value
+ when "yes" then where("amount_cents_remaining > 0")
+ when "no" then where("amount_cents_remaining = 0")
+ end
+ }
+
+ def self.search_by_params(params)
+ results = all
+ results = results.by_type(params[:type]) if params[:type].present?
+ results = results.by_payer(params[:payer_type], params[:payer_id]) if params[:payer_type].present? && params[:payer_id].present?
+ results = results.has_remaining(params[:has_remaining]) if params[:has_remaining].present? && params[:has_remaining] != "all"
+ results
+ end
+
+ def amount_dollars
+ amount_cents.to_d / 100 if amount_cents
+ end
+
+ def amount_dollars=(value)
+ self.amount_cents = (value.to_d * 100).to_i if value.present?
+ end
+
+ def allocated_dollars
+ (amount_cents - (amount_cents_remaining || 0)).to_d / 100
+ end
+
+ def allocated_dollars=(value)
+ self.amount_cents_remaining = ((amount_cents || 0) - (value.to_d * 100).to_i) if value.present?
+ end
+
+ def remaining_dollars
+ amount_cents_remaining.to_d / 100 if amount_cents_remaining
+ end
+
+ def remaining_dollars=(value)
+ self.amount_cents_remaining = (value.to_d * 100).to_i if value.present?
+ end
+
+ private
+
+ def set_amount_cents_remaining
+ self.amount_cents_remaining = amount_cents if amount_cents_remaining.nil? && amount_cents.present?
end
end
diff --git a/app/models/person.rb b/app/models/person.rb
index 556994930..fd952a9fa 100644
--- a/app/models/person.rb
+++ b/app/models/person.rb
@@ -1,6 +1,8 @@
class Person < ApplicationRecord
include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable
+ pay_customer default_payment_processor: :stripe
+
belongs_to :created_by, class_name: "User", optional: true
belongs_to :updated_by, class_name: "User", optional: true
@@ -98,6 +100,11 @@ class Person < ApplicationRecord
.merge(Affiliation.active)
.distinct
}
+ scope :where_user_not_locked, -> {
+ left_joins(:user).where(users: { locked_at: nil }).or(
+ left_joins(:user).where(users: { id: nil })
+ )
+ }
scope :organization_name, ->(organization_name) {
return all if organization_name.blank?
left_joins(affiliations: :organization)
@@ -227,4 +234,17 @@ def unique_name_and_email_combination
errors.add(:base, "A person named #{first_name} #{last_name} with this email already exists")
end
end
+ ## Consider adding additional person info to be saved on stripes customer records
+ # def stripe_attributes(pay_customer)
+ # {
+ # address: {
+ # city: pay_customer.owner.city,
+ # country: pay_customer.owner.country
+ # },
+ # metadata: {
+ # pay_customer_id: pay_customer.id,
+ # user_id: id # or pay_customer.owner_id
+ # }
+ # }
+ # end
end
diff --git a/app/models/refund.rb b/app/models/refund.rb
new file mode 100644
index 000000000..fce590d62
--- /dev/null
+++ b/app/models/refund.rb
@@ -0,0 +1,19 @@
+class Refund < ApplicationRecord
+ METHODS = %w[check cash stripe].freeze
+
+ belongs_to :refundable, polymorphic: true
+ belongs_to :recipient, polymorphic: true
+ has_many :allocations, as: :source
+
+ validates :amount_cents, numericality: true
+ validates :method, inclusion: { in: METHODS }
+ validates :stripe_refund_id, uniqueness: true, allow_nil: true
+
+ def amount_dollars
+ amount_cents.to_d / 100 if amount_cents
+ end
+
+ def amount_dollars=(value)
+ self.amount_cents = (value.to_d * 100).to_i if value.present?
+ end
+end
diff --git a/app/policies/allocation_policy.rb b/app/policies/allocation_policy.rb
new file mode 100644
index 000000000..12388bedb
--- /dev/null
+++ b/app/policies/allocation_policy.rb
@@ -0,0 +1,2 @@
+class AllocationPolicy < ApplicationPolicy
+end
diff --git a/app/policies/payment_policy.rb b/app/policies/payment_policy.rb
new file mode 100644
index 000000000..baa8c5fb8
--- /dev/null
+++ b/app/policies/payment_policy.rb
@@ -0,0 +1,2 @@
+class PaymentPolicy < ApplicationPolicy
+end
diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb
index 199d254b0..1f12418ba 100644
--- a/app/policies/person_policy.rb
+++ b/app/policies/person_policy.rb
@@ -13,6 +13,10 @@ def workshop_logs?
admin? || owner?
end
+ def checkout?
+ admin?
+ end
+
def edit?
admin?
end
@@ -34,7 +38,7 @@ def search?
relation_scope do |relation|
next relation if admin?
- relation.searchable.with_active_affiliations
+ relation.searchable.with_active_affiliations.where_user_not_locked
end
private
diff --git a/app/policies/refund_policy.rb b/app/policies/refund_policy.rb
new file mode 100644
index 000000000..deaedefbc
--- /dev/null
+++ b/app/policies/refund_policy.rb
@@ -0,0 +1,2 @@
+class RefundPolicy < ApplicationPolicy
+end
diff --git a/app/views/allocations/_search_boxes.html.erb b/app/views/allocations/_search_boxes.html.erb
new file mode 100644
index 000000000..d1ee9aa7d
--- /dev/null
+++ b/app/views/allocations/_search_boxes.html.erb
@@ -0,0 +1,105 @@
+
+<% default_btn = "text-gray-600 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-800 shadow-sm" %>
+<% selected_btn = "text-white bg-blue-600 border-blue-600 hover:bg-blue-700 hover:text-white shadow-md ring-2 ring-blue-300" %>
+
+ <%= form_tag allocations_path, method: :get, class: "space-y-4",
+ data: {
+ controller: "collection",
+ collection_search_type_select_outlet: "[data-type-picker]",
+ turbo_frame: "allocation_results",
+ collection_unselected_class: default_btn,
+ collection_selected_class: selected_btn
+ }, autocomplete: "off" do %>
+
+
+
+ Source Type
+ <%= select_tag "source_type[]",
+ options_for_select({ "All" => "", "Cash" => "CashPayment", "Check" => "CheckPayment", "Stripe" => "ExternalProcessorPayment" }, Array(params[:source_type]).first),
+ class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500" %>
+
+
+ Reverted
+ <%= select_tag "has_reverted",
+ options_for_select({ "All" => "all", "Yes" => "yes", "No" => "no" }, params[:has_reverted] || "all"),
+ class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500" %>
+
+
+ <%= tag.div data: { controller: "search-type-select", type_picker: "" } do %>
+
+ Payer Type
+ <%= select_tag "payer_type",
+ options_for_select(["Person", "Organization"], params[:payer_type]),
+ include_blank: true,
+ class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500",
+ data: { action: "search-type-select#toggle" } %>
+
+
+
+ Payer
+ <%= select_tag "payer_id",
+ options_for_select(Person.where(id: params[:payer_id]).map { |p| [p.full_name, p.id] }),
+ include_blank: true,
+ class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500",
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "person"
+ },
+ prompt: "Search for a person" %>
+
+
+
+
+ Payer
+ <%= select_tag "payer_id",
+ options_for_select(Organization.where(id: params[:payer_id]).map { |o| [o.name, o.id] }),
+ include_blank: true,
+ class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500",
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "organization"
+ },
+ prompt: "Search for an organization" %>
+
+
+
+ <% end %>
+ <%= tag.div data: { controller: "search-type-select", type_picker: "" } do %>
+
+ Allocated To Type
+ <%= select_tag "allocatable_type",
+ options_for_select(["EventRegistration"], params[:allocatable_type]),
+ include_blank: true,
+ class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500",
+ data: { action: "search-type-select#toggle" } %>
+
+
+
+ Allocated To
+ <%= select_tag "allocatable_id",
+ options_for_select(EventRegistration.where(id: params[:allocatable_id]).map { |r| ["#{r.registrant.full_name} - #{r.event.title}", r.id] }),
+ include_blank: true,
+ class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500",
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "event_registration"
+ },
+ prompt: "Search for a registration" %>
+
+
+
+
+
Select Allocated To Type
+
+
+
+ <% end %>
+
+
<%= submit_tag "Search", class: "btn btn-primary" %><%= link_to "Clear filters", allocations_path, class: "btn btn-secondary", data: { action: "collection#clearAndSubmit" } %>
+ <% end %>
+
diff --git a/app/views/allocations/allocation_results.html.erb b/app/views/allocations/allocation_results.html.erb
new file mode 100644
index 000000000..3fbe65bb1
--- /dev/null
+++ b/app/views/allocations/allocation_results.html.erb
@@ -0,0 +1,56 @@
+<%= turbo_frame_tag :allocation_results do %>
+ <% if @allocations.any? %>
+
+
+
+
+ ID
+ Type
+ Source
+ Allocated To
+ Amount
+ Ref
+ Date
+
+
+
+ <% @allocations.each do |allocation| %>
+ <% row_class = if allocation.amount.to_i < 0
+ 'bg-red-50 hover:bg-red-100'
+ elsif allocation.reverted_id.present?
+ 'bg-gray-100 border border-gray-300 opacity-60 hover:bg-gray-200 hover:opacity-100'
+ else
+ 'hover:bg-gray-50'
+ end %>
+
+ <%= allocation.id %>
+ <%= allocation.source.class.name.underscore.titleize.gsub(" Payment", "").gsub("External Processor", "Stripe") %>
+ <%= allocation.source&.payer.name || "Unknown" %>
+
+ <% if allocation.allocatable_type == "EventRegistration" && allocation.allocatable.present? %>
+ <%= truncate("#{allocation.allocatable.registrant.full_name} - #{allocation.allocatable.event.title}", length: 40) %>
+ <% else %>
+ <%= allocation.allocatable_type.underscore.titleize %>
+ <% end %>
+
+ $<%= "%.2f" % allocation.amount_dollars %>
+
+ <% if allocation.amount.to_i < 0 %>
+ <%= Allocation.find_by(reverted_id: allocation.id)&.id || "-" %>
+ <% else %>
+ <%= allocation.reverted_id ? allocation.reverted_id : "-" %>
+ <% end %>
+
+ <%= allocation.created_at.strftime("%B %d, %Y") %>
+
+ <% end %>
+
+
+
+ <% else %>
+ There are no allocations that match your search. Please try again.
+ <% end %>
+ <% if @allocations.respond_to?(:total_pages) && @allocations.total_pages > 1 %>
+
+ <% end %>
+<% end %>
diff --git a/app/views/allocations/index.html.erb b/app/views/allocations/index.html.erb
new file mode 100644
index 000000000..be9d179ee
--- /dev/null
+++ b/app/views/allocations/index.html.erb
@@ -0,0 +1,132 @@
+
+
+ <% if @allocatable %>
+
+ <% if @allocatable.is_a?(EventRegistration) %>
+ Payments for
+ <%= @allocatable.registrant.full_name %>
+ -
+ <%= @allocatable.event.title %>
+ <% else %>
+ Allocations for
+ <%= @allocatable.model_name.human %>
+ <% end %>
+
+ <%= link_to "β Back", allocations_path(allocatable_sgid: params[:allocatable_sgid]), class: "btn btn-secondary" %>
+ <% else %>
+ All Allocations
+ <% end %>
+
+
+ <% unless params[:allocatable_sgid].present? %>
+ <%= render 'search_boxes' %>
+ <% result_src = allocations_path + "?" + request.query_string %>
+ <%= turbo_frame_tag :allocation_results, src: result_src, data: { turbo: "temporary" } do %>
+
+
+
+
+
+ <% 10.times do %>
+
+ <% end %>
+
+
+
+
+
+ <% end %>
+ <% else %>
+ <% if @allocatable.is_a?(EventRegistration) %>
+
+ <%= button_to "Add Cash Payment",
+ allocation_form_payments_path(type: "CashPayment", allocatable_sgid: params[:allocatable_sgid]),
+ class: "btn btn-secondary" %>
+
+ <%= button_to "Add Check Payment",
+ allocation_form_payments_path(type: "CheckPayment", allocatable_sgid: params[:allocatable_sgid]),
+ class: "btn btn-secondary" %>
+
+ <% end %>
+
+
+
+
+ ID
+ Type
+ Source
+
+ <% unless @allocatable %>
+ Allocated To
+ <% end %>
+
+ Amount
+ Ref
+ Date
+
+
+
+
+ <% @allocations.each do |allocation| %>
+ <% row_class = if allocation.amount.to_i < 0
+ 'bg-red-50 hover:bg-red-100'
+ elsif allocation.reverted_id.present?
+ 'bg-gray-100 border border-gray-300 opacity-60 hover:bg-gray-200 hover:opacity-100'
+ else
+ 'hover:bg-gray-50'
+ end %>
+
+
+ <%= allocation.id %>
+ <%= allocation.source.class.name.underscore.titleize.gsub(" Payment", "").gsub("External Processor", "Stripe") %>
+ <%= allocation.source&.payer.name || "Unknown" %>
+
+ <% unless @allocatable %>
+
+ <% if allocation.allocatable_type == "EventRegistration" && allocation.allocatable.present? %>
+ <%= truncate("#{allocation.allocatable.registrant.full_name} - #{allocation.allocatable.event.title}", length: 40) %>
+ <% else %>
+ <%= allocation.allocatable_type.underscore.titleize %>
+ <% end %>
+
+ <% end %>
+
+ $<%= "%.2f" % allocation.amount_dollars %>
+
+
+ <% if allocation.amount.to_i < 0 %>
+ <%= Allocation.find_by(reverted_id: allocation.id)&.id || "-" %>
+ <% else %>
+ <%= allocation.reverted_id ? allocation.reverted_id : "-" %>
+ <% end %>
+
+
+ <%= allocation.created_at.strftime("%B %d, %Y") %>
+
+ <% end %>
+
+
+
+ <% if @allocations.respond_to?(:total_pages) && @allocations.total_pages > 1 %>
+
+ <% end %>
+ <% end %>
+
diff --git a/app/views/allocations/new.html.erb b/app/views/allocations/new.html.erb
new file mode 100644
index 000000000..5873d70db
--- /dev/null
+++ b/app/views/allocations/new.html.erb
@@ -0,0 +1,52 @@
+
+
New Allocation
+ <%= simple_form_for @allocation, url: allocations_path do |f| %>
+ <%= f.hidden_field :source_type %>
+ <%= f.hidden_field :source_id %>
+ <% source = @source || (@allocation.source if @allocation.source.present?) %>
+ <% if source.present? && source.is_a?(Payment) %>
+
+
From Payment
+
$<%= "%.2f" % source.amount_dollars %>
+
Remaining: $<%= "%.2f" % source.remaining_dollars %>
+
+ <% else %>
+
+
Error: No payment source found
+
+ <% end %>
+ <%= f.input :allocatable_type, as: :select, collection: ["EventRegistration"],
+ include_blank: true,
+ required: true,
+ input_html: { data: { action: "search-type-select#toggle" } } %>
+
+ <%= f.input :allocatable_id,
+ collection: [],
+ include_blank: true,
+ input_html: {
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "event_registration"
+ }
+ },
+ prompt: "Search for a Person or Event",
+ label: "Allocatable",
+ required: true %>
+
+
+
+
Select Allocatable Type
+
+
+
+ <%= f.input :amount_dollars, label: "Amount ($)", input_html: { min: 0, step: 0.01, placeholder: "0.00", data: { controller: "currency-input", action: "currency-input#format" } } %>
+
+ <%= f.button :submit, "Create Allocation", class: "btn btn-primary" %>
+ <% if source.present? %>
+ <%= link_to "Cancel", payment_path(source), class: "btn btn-secondary ml-2" %>
+ <% else %>
+ <%= link_to "Cancel", payments_path, class: "btn btn-secondary ml-2" %>
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb
index 62d26ba33..7ea9d2334 100644
--- a/app/views/event_registrations/_form.html.erb
+++ b/app/views/event_registrations/_form.html.erb
@@ -1,7 +1,6 @@
<%= simple_form_for(event_registration, data: { turbo: false }) do |f| %>
<%= hidden_field_tag :return_to, params[:return_to] if params[:return_to].present? %>
<%= render 'shared/errors', resource: event_registration if event_registration.errors.any? %>
-
@@ -21,7 +20,6 @@
selected: f.object.event_id %>
<% end %>
-
<% if f.object.persisted? %>
Registrant
@@ -57,7 +55,6 @@
<% end %>
-
<% if f.object.persisted? %>
@@ -66,7 +63,6 @@
include_blank: false,
selected: f.object.status %>
-
>
Organizations represented by this registration
@@ -85,7 +81,6 @@
Click to toggle. Crossed-out organizations will be removed on save.
-
Payment status
@@ -94,7 +89,7 @@
<%= f.object.paid_in_full? ? "Paid in full" : "Not paid in full" %>
<% unless f.object.paid_in_full? %>
- <% paid_cents = f.object.payments.successful.sum(:amount_cents) %>
+ <% paid_cents = f.object.allocations_sum %>
<% due_cents = f.object.event.cost_cents - paid_cents %>
<% if paid_cents > 0 %>
@@ -106,7 +101,6 @@
Free event
<% end %>
-
Scholarship
@@ -117,21 +111,17 @@
<% end %>
-
<% if f.object.persisted? %>