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 %> +
+
+
+ + <%= 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" %> +
+
+ + <%= 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 %> +
+ + <%= 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" } %> +
+ + +
+
+ +
+
+
+ <% end %> + <%= tag.div data: { controller: "search-type-select", type_picker: "" } do %> +
+ + <%= 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" } %> +
+ +
+
+ +
+
+
+ <% 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? %> +
+ + + + + + + + + + + + + + <% @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 %> + + + + + + + + + + <% end %> + +
IDTypeSourceAllocated ToAmountRefDate
<%= 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") %>
+
+ <% 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 %> +
+ + + + + + + + <% unless @allocatable %> + + <% end %> + + + + + + + + + <% @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 %> + + + + + + + <% unless @allocatable %> + + <% end %> + + + + + + + + <% end %> + +
IDTypeSourceAllocated ToAmountRefDate
<%= 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") %>
+
+ <% 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 :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? %> @@ -57,7 +55,6 @@ <% end %>
- <% if f.object.persisted? %>
@@ -66,7 +63,6 @@ include_blank: false, selected: f.object.status %>
-
>
@@ -85,7 +81,6 @@

Click to toggle. Crossed-out organizations will be removed on save.

-
@@ -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? %>
Comments
-
<%= f.simple_fields_for :comments do |cf| %> <%= render "comments/comment_fields", f: cf %> <% end %> -
-
<% end %> -
<% if allowed_to?(:destroy?, f.object) %> <%= link_to "Delete", event_registration_path(@event_registration, return_to: params[:return_to].presence), class: "btn btn-danger-outline", diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb index bb81e7db8..c59c63f6d 100644 --- a/app/views/event_registrations/_ticket.html.erb +++ b/app/views/event_registrations/_ticket.html.erb @@ -1,164 +1,140 @@ -
- -
- - -
-
-
- <%= image_tag("logo.png", alt: "Organization logo", class: "h-8 w-auto") %> -
-
-

Event Registration

-
+ +
+ +
+ +
+
+
+ <%= image_tag("logo.png", alt: "Organization logo", class: "h-8 w-auto") %> +
+
+

Event Registration

- - - -
- - -
-
- <%= render "assets/display_image", +
+ +
+ +
+
+ <%= render "assets/display_image", resource: event_registration.event, width: 48, height: 32, variant: :index, link: event_path(event_registration.event, reg: event_registration.slug), file: event_registration.event.decorate.display_image %> -
- -
- <% if event_registration.event.respond_to?(:pre_title) && event_registration.event.pre_title.present? %> -

<%= event_registration.event.pre_title %>

- <% end %> -

- <%= link_to event_registration.event.title, event_path(event_registration.event, reg: event_registration.slug), class: "hover:underline" %> -

-
-
- - -
-
-
-
-
- - -
-

Registrant

-

- <% if user_signed_in? %> - <%= link_to event_registration.registrant.full_name, person_path(event_registration.registrant), class: "text-blue-700 hover:text-blue-900 underline" %> - <% else %> - <%= event_registration.registrant.full_name %> - <% end %> -

- - -
- -
- <%= event_registration.event.decorate.times(display_day: true, display_date: true, styled: true) %> -
- - <% if event_registration.event.decorate.labelled_cost.present? %> -
- <%= event_registration.event.decorate.labelled_cost %> -
+
+ <% if event_registration.event.respond_to?(:pre_title) && event_registration.event.pre_title.present? %> +

<%= event_registration.event.pre_title %>

<% end %> - - <% if event_registration.event.location.present? %> -
- <%= event_registration.event.location.name %> -
- <% end %> - - <% if event_registration.event.autoshow_videoconference_label && event_registration.event.videoconference_label.present? %> -
<%= event_registration.event.videoconference_label %>
+

+ <%= link_to event_registration.event.title, event_path(event_registration.event, reg: event_registration.slug), class: "hover:underline" %> +

+
+
+ +
+
+
+
+
+ +
+

Registrant

+

+ <% if user_signed_in? %> + <%= link_to event_registration.registrant.full_name, person_path(event_registration.registrant), class: "text-blue-700 hover:text-blue-900 underline" %> + <% else %> + <%= event_registration.registrant.full_name %> <% end %> - - <%= render "events/videoconference_link", event: event_registration.event.decorate, joinable: event_registration.joinable? %> - - +

+
+ +
+
+ <%= event_registration.event.decorate.times(display_day: true, display_date: true, styled: true) %>
- - <% if event_registration.event.cost_cents.to_i > 0 && !event_registration.paid? %> - <% paid_cents = event_registration.payments.successful.sum(:amount_cents) %> - <% due_cents = event_registration.event.cost_cents - paid_cents %> -
- - $<%= "%.2f" % (due_cents / 100.0) %> payment is due - - <% if paid_cents > 0 %> -

$<%= "%.2f" % (paid_cents / 100.0) %> paid

- <% end %> + <% if event_registration.event.decorate.labelled_cost.present? %> +
+ <%= event_registration.event.decorate.labelled_cost %>
<% end %> - - -
- - -
- <% if event_registration.checked_in? %> - - Checked In - - <% elsif event_registration.status == "cancelled" %> - - Registration cancelled - - <% if event_registration.event.registerable? %> -
- <%= button_to "Register again", registration_reactivate_path(event_registration.slug), - class: "text-sm text-gray-500 hover:text-blue-600 underline bg-transparent border-0 cursor-pointer p-0" %> -
- <% end %> - <% else %> - - Registered on <%= event_registration.created_at.strftime("%B %-d, %Y") %> - + <% if event_registration.event.location.present? %> +
+ <%= event_registration.event.location.name %> +
+ <% end %> + <% if event_registration.event.autoshow_videoconference_label && event_registration.event.videoconference_label.present? %> +
<%= event_registration.event.videoconference_label %>
+ <% end %> + <%= render "events/videoconference_link", event: event_registration.event.decorate, joinable: event_registration.joinable? %> +
+ <% if event_registration.event.cost_cents.to_i > 0 && !event_registration.paid? %> + <% paid_cents = event_registration.allocations_sum %> + <% due_cents = event_registration.event.cost_cents - paid_cents %> +
+ + $<%= "%.2f" % (due_cents / 100.0) %> payment is due + + <% if paid_cents > 0 %> +

$<%= "%.2f" % (paid_cents / 100.0) %> paid

<% end %> -
- - <% if event_registration.active? %> - -
-

- Add to Your Calendar -

-
- <%= event_registration.event.decorate.calendar_links %> + <% end %> + +
+ +
+ <% if event_registration.checked_in? %> + + Checked In + + <% elsif event_registration.status == "cancelled" %> + + Registration cancelled + + <% if event_registration.event.registerable? %> +
+ <%= button_to "Register again", registration_reactivate_path(event_registration.slug), + class: "text-sm text-gray-500 hover:text-blue-600 underline bg-transparent border-0 cursor-pointer p-0" %>
-
+ <% end %> + <% else %> + + Registered on <%= event_registration.created_at.strftime("%B %-d, %Y") %> + <% end %> - - <% if event_registration.active? %> -
- <% registration_form = event_registration.event.registration_form %> - <% if registration_form && registration_form.person_forms.exists?(person: event_registration.registrant) %> - <%= link_to "View registration details", +
+ <% if event_registration.active? %> + +
+

+ Add to Your Calendar +

+
+ <%= event_registration.event.decorate.calendar_links %> +
+
+ <% end %> + <% if event_registration.active? %> +
+ <% registration_form = event_registration.event.registration_form %> + <% if registration_form && registration_form.person_forms.exists?(person: event_registration.registrant) %> + <%= link_to "View registration details", event_public_registration_path(event_registration.event, reg: event_registration.slug), class: "text-xs text-gray-400 hover:text-blue-600 underline" %> - <% end %> - <%= button_to "Resend confirmation email", + <% end %> + <%= button_to "Resend confirmation email", registration_resend_confirmation_path(event_registration.slug), class: "text-xs text-gray-400 hover:text-blue-600 underline bg-transparent border-0 cursor-pointer p-0" %> - <%= button_to "Cancel registration", + <%= button_to "Cancel registration", registration_cancel_path(event_registration.slug), data: { turbo_confirm: "Are you sure you want to cancel your registration?" }, class: "text-xs text-gray-400 hover:text-red-600 underline bg-transparent border-0 cursor-pointer p-0" %> -
- <% end %> - -
- - +
+ <% end %>
-
+
diff --git a/app/views/events/_manage_results.html.erb b/app/views/events/_manage_results.html.erb index 3b4c0707f..9857855c0 100644 --- a/app/views/events/_manage_results.html.erb +++ b/app/views/events/_manage_results.html.erb @@ -95,20 +95,16 @@ <% if @event.cost_cents.to_i > 0 %> - <% if registration.scholarship_recipient? && registration.successful_payments_total_cents <= 0 %> + <% if registration.scholarship_recipient? && registration.allocations_sum <= 0 %> β€” - <% elsif registration.paid_in_full? %> - - - Paid - <% else %> - <% paid_cents = registration.successful_payments_total_cents %> + <% paid_cents = registration.allocations_sum %> <% due_cents = @event.cost_cents - paid_cents %> - 0 %>"> - - $<%= "%.2f" % (due_cents / 100.0) %> due - + <% is_paid = registration.paid_in_full? %> + <%= link_to allocations_path(allocatable_sgid: registration.to_sgid.to_s), class: "inline-flex items-center gap-1.5 rounded-full text-xs font-medium border px-5 py-0.5 #{is_paid ? 'bg-green-50 text-green-700 border-green-200' : 'bg-amber-50 text-amber-700 border-amber-200'} hover:opacity-80", title: (!is_paid && paid_cents > 0 ? "$%.2f paid" % (paid_cents / 100.0) : nil), data: { turbo_frame: "_top" } do %> + + <%= is_paid ? 'Paid' : "$%.2f due" % (due_cents / 100.0) %> + <% end %> <% end %> <% end %> diff --git a/app/views/events/preview_reminder.html.erb b/app/views/events/preview_reminder.html.erb index 624edc75c..af026e502 100644 --- a/app/views/events/preview_reminder.html.erb +++ b/app/views/events/preview_reminder.html.erb @@ -9,7 +9,6 @@ <%= @event.title %>

- <% if @event_registrations.empty? %>

There are no registrants with an email address to send a reminder to.

<%= link_to "Back to manage", manage_event_path(@event), class: "btn btn-secondary-outline" %> @@ -42,12 +41,12 @@ <%= person.preferred_email %> <% if @event.object.cost_cents.to_i > 0 %> - <% if reg.scholarship_recipient? && reg.successful_payments_total_cents <= 0 %> + <% if reg.scholarship_recipient? && reg.allocations_sum <= 0 %> β€” <% elsif reg.paid_in_full? %> Paid <% else %> - <% due_cents = @event.object.cost_cents - reg.successful_payments_total_cents %> + <% due_cents = @event.object.cost_cents - reg.allocations_sum %> $<%= "%.2f" % (due_cents / 100.0) %> due <% end %> <% else %> @@ -59,7 +58,6 @@
- <% if @reminder_preview_html.present? %>

Preview (sample registrant)

@@ -70,7 +68,6 @@
<% end %> - <%= f.submit "Send reminder", class: "btn btn-primary", data: { turbo_confirm: "Send reminder emails to the selected registrant(s)?" } %> <% end %> <% end %> diff --git a/app/views/notifications/_index.html.erb b/app/views/notifications/_index.html.erb index ad0e3e9bd..22898cca7 100644 --- a/app/views/notifications/_index.html.erb +++ b/app/views/notifications/_index.html.erb @@ -44,6 +44,7 @@ <% if notification.noticeable.present? %> <%= link_to notification.noticeable.class.name, polymorphic_path(notification.noticeable), + data: { turbo_frame: "_top" }, class: "btn btn-secondary-outline" %> <% else %> β€” @@ -54,6 +55,7 @@ <%= link_to "View", notification_path(notification), + data: { turbo_frame: "_top" }, class: "btn btn-secondary-outline" %> diff --git a/app/views/notifications/_notifications_results.html.erb b/app/views/notifications/_notifications_results.html.erb deleted file mode 100644 index 5638b31c2..000000000 --- a/app/views/notifications/_notifications_results.html.erb +++ /dev/null @@ -1,21 +0,0 @@ -<%= turbo_stream.replace("notifications_count", partial: "notifications_count") %> -<% if @notifications.any? %> -
- <%= render "index" %> -
- -
- -
-<% else %> -
-

No notifications yet

- <% if allowed_to?(:new?, Notification) %> -
- <%= link_to "Create a notification", - new_notification_path, - class: "btn btn-primary" %> -
- <% end %> -
-<% end %> diff --git a/app/views/notifications/_results_skeleton.html.erb b/app/views/notifications/_results_skeleton.html.erb new file mode 100644 index 000000000..3f95119aa --- /dev/null +++ b/app/views/notifications/_results_skeleton.html.erb @@ -0,0 +1,24 @@ +
+ + + + + + + + + + + + <% 5.times do %> + + + + + + + + <% end %> + +
+
diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb index 9bde34ce3..25317c709 100644 --- a/app/views/notifications/index.html.erb +++ b/app/views/notifications/index.html.erb @@ -11,14 +11,7 @@ <% result_src = notifications_path + "?" + request.query_string %> <%= turbo_frame_tag "notifications_results", src: result_src do %> -
- <%= render "index" %> -
- - - + <%= render "results_skeleton" %> <% end %>
diff --git a/app/views/payments/_allocation_form.html.erb b/app/views/payments/_allocation_form.html.erb new file mode 100644 index 000000000..90ac8ad67 --- /dev/null +++ b/app/views/payments/_allocation_form.html.erb @@ -0,0 +1,27 @@ +
+

New Payment

+ <%= simple_form_for @payment, as: :payment, url: payments_path do |f| %> + <%= hidden_field_tag "payment[allocatable_sgid]", params[:allocatable_sgid] %> + <%= f.input :type, as: :hidden, input_html: { value: @payment.type } %> + <%= f.input :payer_id, + collection: @payment.payer.present? ? [[@payment.payer.full_name, @payment.payer.id]] : [], + include_blank: true, + input_html: { + data: { + controller: "remote-select", + remote_select_model_value: "person" + } + }, + prompt: "Search for a payer", + label: "Payer" %> + <%= 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.input :currency, as: :hidden, input_html: { value: "usd" } %> + <% if @payment.type == "CheckPayment" %> + <%= f.input :check_number %> + <% end %> +
+ <%= f.button :submit, "Create Payment", class: "btn btn-primary" %> + <%= link_to "Cancel", allocations_path(allocatable_sgid: params[:allocatable_sgid]), class: "btn btn-secondary" %> +
+ <% end %> +
diff --git a/app/views/payments/_form.html.erb b/app/views/payments/_form.html.erb new file mode 100644 index 000000000..d6996a1f6 --- /dev/null +++ b/app/views/payments/_form.html.erb @@ -0,0 +1,53 @@ +
+

New <%= @payment.type.underscore.titleize.gsub(" Payment", "").gsub("External Processor", "Stripe") %>

+ <%= simple_form_for @payment, as: :payment, url: payments_path do |f| %> + <%= f.input :type, as: :hidden %> +
+ <%= f.label :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= f.select :payer_type, + options_for_select(["Person", "Organization"]), + { 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" } %> +
+ + +
+
+ +
+
+
+ <%= 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.input :currency, as: :hidden, input_html: { value: "usd" } %> + <% if @payment.type == "CheckPayment" %> + <%= f.input :check_number %> + <% end %> +
<%= f.button :submit, "Create Payment", class: "btn btn-primary" %><%= link_to "Cancel", payments_path, class: "btn btn-secondary ml-2" %>
+ <% end %> +
diff --git a/app/views/payments/_search_boxes.html.erb b/app/views/payments/_search_boxes.html.erb new file mode 100644 index 000000000..573c7cbb2 --- /dev/null +++ b/app/views/payments/_search_boxes.html.erb @@ -0,0 +1,84 @@ + +<% 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 payments_path, method: :get, class: "space-y-4", + data: { + controller: "collection search-type-select", + collection_search_type_select_outlet: "[data-type-picker]", + turbo_frame: "payment_results", + collection_unselected_class: default_btn, + collection_selected_class: selected_btn, + type_picker: "" + }, autocomplete: "off" do %> +
+
+ + + <%= select_tag "type[]", + options_for_select({ "All" => "", "Cash" => "CashPayment", "Check" => "CheckPayment", "Stripe" => "ExternalProcessorPayment" }, Array(params[: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" %> +
+ +
+ + + <%= select_tag "has_remaining", + options_for_select({ "All" => "all", "Yes" => "yes", "No" => "no" }, params[:has_remaining] || "all"), + class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500" %> +
+ +
+ + + <%= 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" } %> +
+ + + + + +
+
+ +
+
+
+ +
<%= submit_tag "Search", class: "btn btn-primary" %><%= link_to "Clear filters", payments_path, class: "btn btn-secondary", data: { action: "collection#clearAndSubmit" } %>
+
+ <% end %> +
diff --git a/app/views/payments/allocation_form.turbo_stream.erb b/app/views/payments/allocation_form.turbo_stream.erb new file mode 100644 index 000000000..46942f6af --- /dev/null +++ b/app/views/payments/allocation_form.turbo_stream.erb @@ -0,0 +1 @@ +<%= turbo_stream.replace "payment-form", partial: "payments/allocation_form" %> diff --git a/app/views/payments/index.html.erb b/app/views/payments/index.html.erb new file mode 100644 index 000000000..8cb7411f0 --- /dev/null +++ b/app/views/payments/index.html.erb @@ -0,0 +1,43 @@ +
+
+

Payments

+
<%= link_to "Add Cash Payment", new_payment_path(type: "CashPayment"), class: "btn btn-secondary" %><%= link_to "Add Check Payment", new_payment_path(type: "CheckPayment"), class: "btn btn-secondary" %>
+
+ + <%= render 'search_boxes' %> + <% result_src = payments_path + "?" + request.query_string %> + + <%= turbo_frame_tag :payment_results, src: result_src, data: { turbo: "temporary" } do %> +
+
+
+
+
+
+
+
+
+
+
+ + <% 10.times do %> +
+
+
+
+
+
+
+
+ <% end %> +
+
+ +
+
+
+
+
+
+ <% end %> +
diff --git a/app/views/payments/new.html.erb b/app/views/payments/new.html.erb new file mode 100644 index 000000000..e0f80e774 --- /dev/null +++ b/app/views/payments/new.html.erb @@ -0,0 +1 @@ +<%= render "form" %> diff --git a/app/views/payments/payment_results.html.erb b/app/views/payments/payment_results.html.erb new file mode 100644 index 000000000..fbca0bc0f --- /dev/null +++ b/app/views/payments/payment_results.html.erb @@ -0,0 +1,33 @@ +<%= turbo_frame_tag :payment_results do %> + <% if @payments.any? %> +
+ + + + + + + + + + + + + <% @payments.each do |payment| %> + + + + + + + + + <% end %> + +
TypePayerAmountUnallocatedCreatedActions
<%= payment.type.underscore.titleize.gsub(" Payment", "").gsub("External Processor", "Stripe") %><%= payment.payer.name %>$<%= "%.2f" % payment.amount_dollars %>$<%= "%.2f" % payment.remaining_dollars %><%= payment.created_at.strftime("%B %d, %Y at %I:%M %p") %><%= link_to "View", payment_path(payment), class: "text-primary hover:text-primary-dark", data: { turbo_frame: "_top"} %>
+
+ <% else %> +

There are no payments that match your search. Please try again.

+ <% end %> + +<% end %> diff --git a/app/views/payments/show.html.erb b/app/views/payments/show.html.erb new file mode 100644 index 000000000..76d3bb541 --- /dev/null +++ b/app/views/payments/show.html.erb @@ -0,0 +1,123 @@ +
+
+

Payment

+
+ <% if @payment.amount_cents_remaining > 0 %> + <%= link_to "Allocate", new_allocation_path(source_sgid: @payment.to_sgid.to_s), class: "btn btn-primary" %> + <%= link_to "Refund", new_refund_path(payment_sgid: @payment.to_sgid.to_s), class: "btn btn-warning" %> + <% end %> + <%= link_to "Back", payments_path, class: "btn btn-secondary" %> +
+
+
+
+
Type
+
<%= @payment.type.underscore.titleize.gsub(" Payment", "").gsub("External Processor", "Stripe") %>
+
+
+
Payer
+
<%= @payment.payer.name %>
+
+
+
Amount
+
$<%= "%.2f" % @payment.amount_dollars %>
+
+
+
Amount remaining to allocate
+
$<%= "%.2f" % @payment.remaining_dollars %>
+
+ <% if @payment.check_number.present? %> +
+
Check Number
+
<%= @payment.check_number %>
+
+ <% end %> + <% if @payment.pay_charge_id.present? %> +
+
Pay Charge ID
+
<%= @payment.pay_charge_id %>
+
+ <% end %> +
+
Created
+
<%= @payment.created_at.strftime("%B %d, %Y at %I:%M %p") %>
+
+
+ <% if @allocations.any? %> +
+

Allocations

+
+ + + + + + + + + + + <% @allocations.each do |allocation| %> + <% row_class = if allocation.amount.to_i < 0 + 'bg-red-50' + elsif allocation.reverted_id.present? + 'bg-gray-100 border border-gray-300 opacity-60' + end %> + + + + + + + <% end %> + +
Allocated ToAmountDate/TimeActions
+ <% if allocation.allocatable_type == "EventRegistration" %> + <%= allocation.allocatable.registrant.full_name %> + - + <%= allocation.allocatable.event.title %> + <% else %> + <%= allocation.allocatable_type.underscore.titleize %> + <% end %> + $<%= "%.2f" % (allocation.amount.to_d / 100) %><%= allocation.created_at.strftime("%B %d, %Y at %I:%M %p") %> + <% unless allocation.reverted? || allocation.amount.to_i < 0 %> + <%= button_to "Revert", revert_allocation_path(allocation), method: :post, class: "cursor-pointer text-danger hover:text-danger-dark", data: { turbo_confirm: "Are you sure you want to revert this allocation? The funds will be returned to the payment." } %> + <% end %> +
+
+
+ <% end %> + <% if @refunds.any? %> +
+

Refunds

+
+ + + + + + + + + + + <% @refunds.each do |refund| %> + + + + + + + <% end %> + +
RecipientMethodAmountDate/Time
+ <% if refund.recipient.present? %> + <%= refund.recipient.name %> + <% else %> + <%= refund.recipient_type.underscore.titleize %> + <% end %> + <%= refund.method.titleize %>$<%= "%.2f" % refund.amount_dollars %><%= refund.created_at.strftime("%B %d, %Y at %I:%M %p") %>
+
+
+ <% end %> +
diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index 542243706..8a262c93b 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -1,276 +1,280 @@ <% content_for(:page_bg_class, "admin-or-owner-or-authsearchable") %>
- -
-
- - - - Individual Profile -
-
-
- -
- <% unless @person.user %> -
- No user! - <%= link_to "Create user", + +
+
+ + + + Individual Profile +
+
+
+ +
+ <% unless @person.user %> +
+ No user! + <%= link_to "Create user", new_user_path(person_id: @person.id), class: "btn btn-warning px-2 py-1" %> -
- <% end %> - <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> - <% if allowed_to?(:index?, Person) %> - <%= link_to "People", people_path, class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> - <% end %> - <% if allowed_to?(:edit?, @person) %> - <%= link_to "Edit", edit_person_path(@person), +
+ <% end %> + <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% if allowed_to?(:index?, Person) %> + <%= link_to "People", people_path, class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% end %> + <% if allowed_to?(:edit?, @person) %> + <%= link_to "Edit", edit_person_path(@person), class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> - <% end %> - - <%= render "bookmarks/editable_bookmark_button", resource: @person.object %> - -
- -
-
- <% if @person.avatar&.attached? %> - <%= image_tag @person.avatar.variant(:thumbnail), + <% end %> + <% if allowed_to?(:manage?, Person) && show_feature?(:stripe_payments) %> + <%= link_to "Collect Donation", + checkout_person_path(@person), + class: "admin-only bg-green-100 text-sm text-green-700 hover:text-green-900 px-2 py-1", + data: { turbo: false } %> + <% end %> + + <%= render "bookmarks/editable_bookmark_button", resource: @person.object %> + +
+ +
+
+ <% if @person.avatar&.attached? %> + <%= image_tag @person.avatar.variant(:thumbnail), id: "person_#{@person.id}_avatar_image", class: "w-28 h-28 rounded-full object-cover border-2 border-gray-200 shadow" %> - <% else %> - <%= image_tag "missing.png", + <% else %> + <%= image_tag "missing.png", id: "person_#{@person.id}_avatar_image", class: "w-28 h-28 rounded-full object-cover border-2 border-dashed border-gray-300" %> + <% end %> +
+
+

+ <%= @person.name %> + <% if @person.pronouns.present? && @person.profile_show_pronouns? %> + (<%= @person.pronouns %>) <% end %> -

-
-

- <%= @person.name %> - <% if @person.pronouns.present? && @person.profile_show_pronouns? %> - (<%= @person.pronouns %>) - <% end %> -

- <% if @person.profile_show_member_since? %> - <% if @person.facilitator_since_date.present? %> -

- Facilitator since - <%= @person.facilitator_since_date.strftime("%B %Y") %> -

- <% elsif @person.affiliated_since_date.present? %> -

- Affiliated since - <%= @person.affiliated_since_date.strftime("%B %Y") %> -

- <% end %> + + <% if @person.profile_show_member_since? %> + <% if @person.facilitator_since_date.present? %> +

+ Facilitator since + <%= @person.facilitator_since_date.strftime("%B %Y") %> +

+ <% elsif @person.affiliated_since_date.present? %> +

+ Affiliated since + <%= @person.affiliated_since_date.strftime("%B %Y") %> +

<% end %> - -
- <% email = @person.user&.email || @person.email %> - <% if email.present? %> - <% if @person.profile_show_email? %> - - πŸ“§ <%= mail_to email, email, class: "text-blue-600 hover:underline" %> - - <% elsif allowed_to?(:manage?, Person) %> - - πŸ“§ <%= mail_to email, email, class: "text-blue-600 hover:underline" %> - - <% end %> - <% end %> - <% phone = @person.phone_number || @person.user&.phone %> - <% if @person.profile_show_phone? && phone.present? %> + <% end %> + +
+ <% email = @person.user&.email || @person.email %> + <% if email.present? %> + <% if @person.profile_show_email? %> - πŸ“ž <%= link_to phone, "tel:#{phone}", class: "text-blue-600 hover:underline" %> + πŸ“§ <%= mail_to email, email, class: "text-blue-600 hover:underline" %> + + <% elsif allowed_to?(:manage?, Person) %> + + πŸ“§ <%= mail_to email, email, class: "text-blue-600 hover:underline" %> <% end %> - <% if @person.profile_show_social_media? %> - <%= render "people/social_media_buttons", person: @person %> + <% end %> + <% phone = @person.phone_number || @person.user&.phone %> + <% if @person.profile_show_phone? && phone.present? %> + + πŸ“ž <%= link_to phone, "tel:#{phone}", class: "text-blue-600 hover:underline" %> + + <% end %> + <% if @person.profile_show_social_media? %> + <%= render "people/social_media_buttons", person: @person %> + <% end %> +
+ + <% if @person.badges.any? %> +
+ <% @person.badges.each do |badge| %> + + <%= badge[:label] %> + <% end %>
- - <% if @person.badges.any? %> -
- <% @person.badges.each do |badge| %> - - <%= badge[:label] %> - + <% end %> +
+
+
+
+ +
+
+ + <% if @person.profile_show_affiliations? %> +
+

Affiliations

+ <%= turbo_frame_tag "person_affiliations_section", src: person_path(@person, section: "affiliations"), loading: :lazy do %> +

Loading affiliations...

<% end %>
<% end %> -
-
-
-
- -
-
- - <% if @person.profile_show_affiliations? %> -
-

Affiliations

- <%= turbo_frame_tag "person_affiliations_section", src: person_path(@person, section: "affiliations"), loading: :lazy do %> -

Loading affiliations...

- <% end %> -
- <% end %> - - <% if @person.profile_show_sectors? %> -
-

Sectors

- <% if @person.sectorable_items.any? %> -
- <% @person + + <% if @person.profile_show_sectors? %> +
+

Sectors

+ <% if @person.sectorable_items.any? %> +
+ <% @person .sectorable_items .includes(:sector) .order("sectors.name") .each do |si| %> - <%= render "sectors/tagging_label", + <%= render "sectors/tagging_label", sector: si.sector, display_leader: true, is_leader: si.is_leader %> - <% end %> -
- <% else %> -

None selected.

- <% end %> -
- <% end %> -
+ <% end %> +
+ <% else %> +

None selected.

+ <% end %> +
+ <% end %>
- - <% if @person.profile_show_bio? && @person.bio.present? %> -
border <%= DomainTheme.border_class_for(:person_bio) %> rounded-xl p-4 sm:p-5"> -
-

Bio

-
- <%= sanitize(@person.bio, tags: %w[p br strong em a ul ol li b i], attributes: %w[href]) %> -
+
+

Bio

+
+ <%= sanitize(@person.bio, tags: %w[p br strong em a ul ol li b i], attributes: %w[href]) %>
- <% end %> - - - <% if @person.profile_show_workshops? || +
+ <% end %> + + <% if @person.profile_show_workshops? || @person.profile_show_workshop_variations? || @person.profile_show_stories? %> -
- -
-

- Published content -

-
- -
- - <% if @person.profile_show_workshops? %> -
-

Workshops authored

- <%= turbo_frame_tag "person_workshops_section", src: person_path(@person, section: "workshops"), loading: :lazy do %> -

Loading workshops...

- <% end %> -
- <% end %> - - <% if @person.profile_show_workshop_variations? %> -
-

Workshop variations authored

- <%= turbo_frame_tag "person_workshop_variations_section", src: person_path(@person, section: "workshop_variations"), loading: :lazy do %> -

Loading workshop variations...

- <% end %> -
+
+ +
+

+ Published content +

+
+ +
+ + <% if @person.profile_show_workshops? %> +
+

Workshops authored

+ <%= turbo_frame_tag "person_workshops_section", src: person_path(@person, section: "workshops"), loading: :lazy do %> +

Loading workshops...

<% end %> - - <% if @person.profile_show_stories? %> -
-

Stories authored/featured

- <%= turbo_frame_tag "person_stories_section", src: person_path(@person, section: "stories"), loading: :lazy do %> -

Loading stories...

- <% end %> -
+
+ <% end %> + + <% if @person.profile_show_workshop_variations? %> +
+

Workshop variations authored

+ <%= turbo_frame_tag "person_workshop_variations_section", src: person_path(@person, section: "workshop_variations"), loading: :lazy do %> +

Loading workshop variations...

<% end %>
-
- <% end %> - - <% if @person.profile_show_events_registered? %> -
- -
-

- Participation history -

+ <% end %> + + <% if @person.profile_show_stories? %> +
+

Stories authored/featured

+ <%= turbo_frame_tag "person_stories_section", src: person_path(@person, section: "stories"), loading: :lazy do %> +

Loading stories...

+ <% end %>
- -
- <% if @person.profile_show_events_registered? %> -
-

Events registered

- <%= turbo_frame_tag "person_events_section", src: person_path(@person, section: "events"), loading: :lazy do %> -

Loading events...

- <% end %> -
+ <% end %> +
+
+ <% end %> + + <% if @person.profile_show_events_registered? %> +
+ +
+

+ Participation history +

+
+ +
+ <% if @person.profile_show_events_registered? %> +
+

Events registered

+ <%= turbo_frame_tag "person_events_section", src: person_path(@person, section: "events"), loading: :lazy do %> +

Loading events...

<% end %>
-
- <% end %> - - <% if allowed_to?(:show?, @person) %> - + <% end %> +
+ <% end %> +
+
diff --git a/app/views/refunds/new.html.erb b/app/views/refunds/new.html.erb new file mode 100644 index 000000000..27aa33b03 --- /dev/null +++ b/app/views/refunds/new.html.erb @@ -0,0 +1,70 @@ +
+

New Refund

+ <%= simple_form_for @refund, url: refunds_path do |f| %> + <%= f.hidden_field :refundable_type %> + <%= f.hidden_field :refundable_id %> + <% if @payment.present? %> +
+

For Payment

+

$<%= "%.2f" % @payment.amount_dollars %>

+

Remaining: $<%= "%.2f" % @payment.remaining_dollars %>

+
+ <% else %> +
+

Error: No payment found

+
+ <% end %> + <%= f.input :recipient_type, as: :select, collection: ["Person", "Organization"], include_blank: true, input_html: { data: { action: "search-type-select#toggle" } } %> + + +
+
+ +
+
+
+
+ Method +
+ <%= f.collection_radio_buttons :method, [["Check", "check"], ["Cash", "cash"]], :last, :first, checked: "check" do |b| %> + + <% end %> +
+
+ <%= 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 Refund", class: "btn btn-primary" %> + <% if @payment.present? %> + <%= link_to "Cancel", payment_path(@payment), class: "btn btn-secondary ml-2" %> + <% else %> + <%= link_to "Cancel", payments_path, class: "btn btn-secondary ml-2" %> + <% end %> +
+ <% end %> +
diff --git a/config/initializers/pay.rb b/config/initializers/pay.rb new file mode 100644 index 000000000..60c685b1b --- /dev/null +++ b/config/initializers/pay.rb @@ -0,0 +1,3 @@ +Rails.application.config.to_prepare do + Pay::Charge.include PayChargeExtensions +end diff --git a/config/routes.rb b/config/routes.rb index fb2639a53..de9e802ee 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -116,6 +116,7 @@ end member do get :workshop_logs + get :checkout end resources :comments, only: [ :index, :create ] end @@ -134,6 +135,16 @@ end resources :comments, only: [ :index, :create ] end + resources :payments, only: [ :new, :create, :show, :index ] do + collection do + post :allocation_form + end + end + resources :allocations, only: [ :new, :create, :index ] do + post :revert, on: :member + end + + resources :refunds, only: [ :new, :create ] resources :organization_statuses resources :affiliations resources :quotes diff --git a/db/migrate/20260328221935_create_pay_tables.pay.rb b/db/migrate/20260328221935_create_pay_tables.pay.rb new file mode 100644 index 000000000..1ec5d50f9 --- /dev/null +++ b/db/migrate/20260328221935_create_pay_tables.pay.rb @@ -0,0 +1,98 @@ +# This migration comes from pay (originally 1) +class CreatePayTables < ActiveRecord::Migration[6.0] + def change + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :pay_customers, id: primary_key_type do |t| + t.belongs_to :owner, polymorphic: true, index: false, type: foreign_key_type + t.string :processor, null: false + t.string :processor_id + t.boolean :default + t.public_send Pay::Adapter.json_column_type, :data + t.string :stripe_account + t.datetime :deleted_at + t.timestamps + end + add_index :pay_customers, [ :owner_type, :owner_id, :deleted_at ], name: :pay_customer_owner_index, unique: true + add_index :pay_customers, [ :processor, :processor_id ], unique: true + + create_table :pay_merchants, id: primary_key_type do |t| + t.belongs_to :owner, polymorphic: true, index: false, type: foreign_key_type + t.string :processor, null: false + t.string :processor_id + t.boolean :default + t.public_send Pay::Adapter.json_column_type, :data + t.timestamps + end + add_index :pay_merchants, [ :owner_type, :owner_id, :processor ] + + create_table :pay_payment_methods, id: primary_key_type do |t| + t.belongs_to :customer, foreign_key: { to_table: :pay_customers }, null: false, index: false, type: foreign_key_type + t.string :processor_id, null: false + t.boolean :default + t.string :type + t.public_send Pay::Adapter.json_column_type, :data + t.string :stripe_account + t.timestamps + end + add_index :pay_payment_methods, [ :customer_id, :processor_id ], unique: true + + create_table :pay_subscriptions, id: primary_key_type do |t| + t.belongs_to :customer, foreign_key: { to_table: :pay_customers }, null: false, index: false, type: foreign_key_type + t.string :name, null: false + t.string :processor_id, null: false + t.string :processor_plan, null: false + t.integer :quantity, default: 1, null: false + t.string :status, null: false + t.datetime :current_period_start + t.datetime :current_period_end + t.datetime :trial_ends_at + t.datetime :ends_at + t.boolean :metered + t.string :pause_behavior + t.datetime :pause_starts_at + t.datetime :pause_resumes_at + t.decimal :application_fee_percent, precision: 8, scale: 2 + t.public_send Pay::Adapter.json_column_type, :metadata + t.public_send Pay::Adapter.json_column_type, :data + t.string :stripe_account + t.string :payment_method_id + t.timestamps + end + add_index :pay_subscriptions, [ :customer_id, :processor_id ], unique: true + add_index :pay_subscriptions, [ :metered ] + add_index :pay_subscriptions, [ :pause_starts_at ] + + create_table :pay_charges, id: primary_key_type do |t| + t.belongs_to :customer, foreign_key: { to_table: :pay_customers }, null: false, index: false, type: foreign_key_type + t.belongs_to :subscription, foreign_key: { to_table: :pay_subscriptions }, null: true, type: foreign_key_type + t.string :processor_id, null: false + t.integer :amount, null: false + t.string :currency + t.integer :application_fee_amount + t.integer :amount_refunded + t.public_send Pay::Adapter.json_column_type, :metadata + t.public_send Pay::Adapter.json_column_type, :data + t.string :stripe_account + t.timestamps + end + add_index :pay_charges, [ :customer_id, :processor_id ], unique: true + + create_table :pay_webhooks, id: primary_key_type do |t| + t.string :processor + t.string :event_type + t.public_send Pay::Adapter.json_column_type, :event + t.timestamps + end + end + + private + + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [ primary_key_type, foreign_key_type ] + end +end diff --git a/db/migrate/20260328221936_add_pay_sti_columns.pay.rb b/db/migrate/20260328221936_add_pay_sti_columns.pay.rb new file mode 100644 index 000000000..f439c600b --- /dev/null +++ b/db/migrate/20260328221936_add_pay_sti_columns.pay.rb @@ -0,0 +1,25 @@ +# This migration comes from pay (originally 2) +class AddPayStiColumns < ActiveRecord::Migration[6.0] + def change + add_column :pay_customers, :type, :string + add_column :pay_charges, :type, :string + add_column :pay_subscriptions, :type, :string + + rename_column :pay_payment_methods, :type, :payment_method_type + add_column :pay_payment_methods, :type, :string + + add_column :pay_merchants, :type, :string + + Pay::Customer.find_each do |pay_customer| + pay_customer.update(type: "Pay::#{pay_customer.processor.classify}::Customer") + + pay_customer.charges.update_all(type: "Pay::#{pay_customer.processor.classify}::Charge") + pay_customer.subscriptions.update_all(type: "Pay::#{pay_customer.processor.classify}::Subscription") + pay_customer.payment_methods.update_all(type: "Pay::#{pay_customer.processor.classify}::PaymentMethod") + end + + Pay::Merchant.find_each do |pay_merchant| + pay_merchant.update(type: "Pay::#{pay_merchant.processor.classify}::Merchant") + end + end +end diff --git a/db/migrate/20260328221937_add_object_to_pay_models.pay.rb b/db/migrate/20260328221937_add_object_to_pay_models.pay.rb new file mode 100644 index 000000000..e68f58fcd --- /dev/null +++ b/db/migrate/20260328221937_add_object_to_pay_models.pay.rb @@ -0,0 +1,8 @@ +# This migration comes from pay (originally 20250415151129) +class AddObjectToPayModels < ActiveRecord::Migration[6.0] + def change + add_column :pay_charges, :object, Pay::Adapter.json_column_type + add_column :pay_customers, :object, Pay::Adapter.json_column_type + add_column :pay_subscriptions, :object, Pay::Adapter.json_column_type + end +end diff --git a/db/migrate/20260329161720_create_allocations.rb b/db/migrate/20260329161720_create_allocations.rb new file mode 100644 index 000000000..7643e3360 --- /dev/null +++ b/db/migrate/20260329161720_create_allocations.rb @@ -0,0 +1,12 @@ +class CreateAllocations < ActiveRecord::Migration[8.1] + def change + create_table :allocations do |t| + t.references :source, polymorphic: true, null: false + t.references :allocatable, polymorphic: true, null: false + t.integer :amount, null: false, default: 0 + t.timestamps + end + add_index :allocations, [ :source_type, :source_id ] + add_index :allocations, [ :allocatable_type, :allocatable_id ] + end +end diff --git a/db/migrate/20260329175425_change_payments_to_sti.rb b/db/migrate/20260329175425_change_payments_to_sti.rb new file mode 100644 index 000000000..53082e287 --- /dev/null +++ b/db/migrate/20260329175425_change_payments_to_sti.rb @@ -0,0 +1,19 @@ +class ChangePaymentsToSti < ActiveRecord::Migration[8.1] + def change + add_column :payments, :type, :string, null: false + + add_column :payments, :pay_charge_id, :bigint + add_column :payments, :check_number, :string + + remove_column :payments, :event_id + remove_column :payments, :payable_type + remove_column :payments, :payable_id + remove_column :payments, :payment_type + remove_column :payments, :stripe_payment_intent_id + remove_column :payments, :stripe_charge_id + remove_column :payments, :stripe_metadata + remove_column :payments, :failure_code + remove_column :payments, :failure_message + remove_column :payments, :status + end +end diff --git a/db/migrate/20260329180236_create_refunds.rb b/db/migrate/20260329180236_create_refunds.rb new file mode 100644 index 000000000..2f6520b2c --- /dev/null +++ b/db/migrate/20260329180236_create_refunds.rb @@ -0,0 +1,10 @@ +class CreateRefunds < ActiveRecord::Migration[8.1] + def change + create_table :refunds do |t| + t.references :refundable, polymorphic: true, null: false + t.references :recipient, polymorphic: true, null: false + t.integer :amount_cents, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20260406183730_add_allocated_amount_to_payments.rb b/db/migrate/20260406183730_add_allocated_amount_to_payments.rb new file mode 100644 index 000000000..f8b90f76d --- /dev/null +++ b/db/migrate/20260406183730_add_allocated_amount_to_payments.rb @@ -0,0 +1,5 @@ +class AddAllocatedAmountToPayments < ActiveRecord::Migration[8.1] + def change + add_column :payments, :allocated_amount_cents, :integer, null: false, default: 0 + end +end diff --git a/db/migrate/20260406205610_add_method_to_refunds.rb b/db/migrate/20260406205610_add_method_to_refunds.rb new file mode 100644 index 000000000..d6fed7edc --- /dev/null +++ b/db/migrate/20260406205610_add_method_to_refunds.rb @@ -0,0 +1,5 @@ +class AddMethodToRefunds < ActiveRecord::Migration[8.1] + def change + add_column :refunds, :method, :string, null: false + end +end diff --git a/db/migrate/20260406232219_add_reverted_id_to_allocations.rb b/db/migrate/20260406232219_add_reverted_id_to_allocations.rb new file mode 100644 index 000000000..011c2ea7a --- /dev/null +++ b/db/migrate/20260406232219_add_reverted_id_to_allocations.rb @@ -0,0 +1,6 @@ +class AddRevertedIdToAllocations < ActiveRecord::Migration[8.1] + def change + add_column :allocations, :reverted_id, :bigint + add_foreign_key :allocations, :allocations, column: :reverted_id, primary_key: :id, validate: false + end +end diff --git a/db/migrate/20260407135003_rename_allocated_amount_cents_to_amount_cents_remaining_on_payments.rb b/db/migrate/20260407135003_rename_allocated_amount_cents_to_amount_cents_remaining_on_payments.rb new file mode 100644 index 000000000..ac5a3811c --- /dev/null +++ b/db/migrate/20260407135003_rename_allocated_amount_cents_to_amount_cents_remaining_on_payments.rb @@ -0,0 +1,5 @@ +class RenameAllocatedAmountCentsToAmountCentsRemainingOnPayments < ActiveRecord::Migration[8.1] + def change + rename_column :payments, :allocated_amount_cents, :amount_cents_remaining + end +end diff --git a/db/migrate/20260407231104_add_stripe_refund_id_to_refunds.rb b/db/migrate/20260407231104_add_stripe_refund_id_to_refunds.rb new file mode 100644 index 000000000..67d38ba73 --- /dev/null +++ b/db/migrate/20260407231104_add_stripe_refund_id_to_refunds.rb @@ -0,0 +1,6 @@ +class AddStripeRefundIdToRefunds < ActiveRecord::Migration[8.1] + def change + add_column :refunds, :stripe_refund_id, :string + add_index :refunds, :stripe_refund_id + end +end diff --git a/db/migrate/20260408163911_remove_default_from_amount_cents_remaining_on_payments.rb b/db/migrate/20260408163911_remove_default_from_amount_cents_remaining_on_payments.rb new file mode 100644 index 000000000..1c004da56 --- /dev/null +++ b/db/migrate/20260408163911_remove_default_from_amount_cents_remaining_on_payments.rb @@ -0,0 +1,5 @@ +class RemoveDefaultFromAmountCentsRemainingOnPayments < ActiveRecord::Migration[8.1] + def change + change_column_default :payments, :amount_cents_remaining, from: 0, to: nil + end +end diff --git a/db/schema.rb b/db/schema.rb index 3014b674b..3a8cf7228 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_12_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_04_08_163911) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -178,6 +178,22 @@ t.index ["visitor_token", "started_at"], name: "index_ahoy_visits_on_visitor_token_and_started_at" end + create_table "allocations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "allocatable_id", null: false + t.string "allocatable_type", null: false + t.integer "amount", default: 0, null: false + t.datetime "created_at", null: false + t.bigint "reverted_id" + t.bigint "source_id", null: false + t.string "source_type", null: false + t.datetime "updated_at", null: false + t.index ["allocatable_type", "allocatable_id"], name: "index_allocations_on_allocatable" + t.index ["allocatable_type", "allocatable_id"], name: "index_allocations_on_allocatable_type_and_allocatable_id" + t.index ["reverted_id"], name: "fk_rails_4e7a74eb48" + t.index ["source_type", "source_id"], name: "index_allocations_on_source" + t.index ["source_type", "source_id"], name: "index_allocations_on_source_type_and_source_id" + end + create_table "answer_options", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.string "name" @@ -210,7 +226,7 @@ end create_table "banners", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.text "content" + t.text "content", size: :medium t.datetime "created_at", precision: nil, null: false t.integer "created_by_id" t.boolean "published", default: false, null: false @@ -279,7 +295,7 @@ end create_table "bookmark_annotations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.text "annotation", size: :medium + t.text "annotation", size: :long t.integer "bookmark_id" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false @@ -474,7 +490,7 @@ end create_table "faqs", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.text "answer", size: :medium + t.text "answer", size: :long t.datetime "created_at", precision: nil, null: false t.boolean "inactive" t.integer "position", null: false @@ -496,7 +512,7 @@ create_table "form_builders", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false - t.text "description", size: :medium + t.text "description", size: :long t.string "name" t.integer "owner_type" t.datetime "updated_at", precision: nil, null: false @@ -578,14 +594,14 @@ create_table "monthly_reports", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "best_call_time" t.boolean "call_requested" - t.text "comments", size: :medium + t.text "comments", size: :long t.datetime "created_at", precision: nil, null: false - t.text "goals", size: :medium - t.text "goals_reached", size: :medium + t.text "goals", size: :long + t.text "goals_reached", size: :long t.boolean "mail_evaluations" t.string "month" - t.text "most_challenging", size: :medium - t.text "most_effective", size: :medium + t.text "most_challenging", size: :long + t.text "most_effective", size: :long t.string "name" t.string "num_new_participants" t.string "num_ongoing_participants" @@ -601,9 +617,9 @@ create_table "notifications", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.datetime "delivered_at" - t.text "email_body_html" - t.text "email_body_text" - t.text "email_subject" + t.text "email_body_html", size: :medium + t.text "email_body_text", size: :medium + t.text "email_subject", size: :medium t.datetime "error_at" t.string "error_class" t.text "error_message" @@ -642,7 +658,7 @@ t.string "agency_type" t.string "agency_type_other" t.datetime "created_at", precision: nil, null: false - t.text "description", size: :medium + t.text "description", size: :long t.string "email" t.date "end_date" t.string "filemaker_code" @@ -652,7 +668,7 @@ t.integer "location_id" t.string "mission_vision_values" t.string "name" - t.text "notes", size: :medium + t.text "notes", size: :long t.integer "organization_status_id" t.boolean "profile_show_description", default: true, null: false t.boolean "profile_show_email", default: true, null: false @@ -672,31 +688,118 @@ t.index ["windows_type_id"], name: "index_organizations_on_windows_type_id" end + create_table "pay_charges", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.integer "amount", null: false + t.integer "amount_refunded" + t.integer "application_fee_amount" + t.datetime "created_at", null: false + t.string "currency" + t.bigint "customer_id", null: false + t.json "data" + t.json "metadata" + t.json "object" + t.string "processor_id", null: false + t.string "stripe_account" + t.bigint "subscription_id" + t.string "type" + t.datetime "updated_at", null: false + t.index ["customer_id", "processor_id"], name: "index_pay_charges_on_customer_id_and_processor_id", unique: true + t.index ["subscription_id"], name: "index_pay_charges_on_subscription_id" + end + + create_table "pay_customers", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.json "data" + t.boolean "default" + t.datetime "deleted_at", precision: nil + t.json "object" + t.bigint "owner_id" + t.string "owner_type" + t.string "processor", null: false + t.string "processor_id" + t.string "stripe_account" + t.string "type" + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id", "deleted_at"], name: "pay_customer_owner_index", unique: true + t.index ["processor", "processor_id"], name: "index_pay_customers_on_processor_and_processor_id", unique: true + end + + create_table "pay_merchants", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.json "data" + t.boolean "default" + t.bigint "owner_id" + t.string "owner_type" + t.string "processor", null: false + t.string "processor_id" + t.string "type" + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id", "processor"], name: "index_pay_merchants_on_owner_type_and_owner_id_and_processor" + end + + create_table "pay_payment_methods", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "customer_id", null: false + t.json "data" + t.boolean "default" + t.string "payment_method_type" + t.string "processor_id", null: false + t.string "stripe_account" + t.string "type" + t.datetime "updated_at", null: false + t.index ["customer_id", "processor_id"], name: "index_pay_payment_methods_on_customer_id_and_processor_id", unique: true + end + + create_table "pay_subscriptions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.decimal "application_fee_percent", precision: 8, scale: 2 + t.datetime "created_at", null: false + t.datetime "current_period_end", precision: nil + t.datetime "current_period_start", precision: nil + t.bigint "customer_id", null: false + t.json "data" + t.datetime "ends_at", precision: nil + t.json "metadata" + t.boolean "metered" + t.string "name", null: false + t.json "object" + t.string "pause_behavior" + t.datetime "pause_resumes_at", precision: nil + t.datetime "pause_starts_at", precision: nil + t.string "payment_method_id" + t.string "processor_id", null: false + t.string "processor_plan", null: false + t.integer "quantity", default: 1, null: false + t.string "status", null: false + t.string "stripe_account" + t.datetime "trial_ends_at", precision: nil + t.string "type" + t.datetime "updated_at", null: false + t.index ["customer_id", "processor_id"], name: "index_pay_subscriptions_on_customer_id_and_processor_id", unique: true + t.index ["metered"], name: "index_pay_subscriptions_on_metered" + t.index ["pause_starts_at"], name: "index_pay_subscriptions_on_pause_starts_at" + end + + create_table "pay_webhooks", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.json "event" + t.string "event_type" + t.string "processor" + t.datetime "updated_at", null: false + end + create_table "payments", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.integer "amount_cents", null: false + t.integer "amount_cents_remaining", null: false + t.string "check_number" t.datetime "created_at", null: false t.string "currency", default: "usd", null: false - t.bigint "event_id" - t.string "failure_code" - t.string "failure_message" - t.bigint "payable_id", null: false - t.string "payable_type", null: false + t.bigint "pay_charge_id" t.bigint "payer_id", null: false t.string "payer_type", null: false - t.string "payment_type", default: "stripe", null: false - t.string "status", null: false - t.string "stripe_charge_id" - t.json "stripe_metadata" - t.string "stripe_payment_intent_id" + t.string "type", null: false t.datetime "updated_at", null: false - t.index ["event_id"], name: "index_payments_on_event_id" - t.index ["payable_type", "payable_id", "status"], name: "index_payments_on_payable_type_and_payable_id_and_status" - t.index ["payable_type", "payable_id"], name: "index_payments_on_payable" - t.index ["payable_type", "payable_id"], name: "index_payments_on_payable_type_and_payable_id" t.index ["payer_type", "payer_id"], name: "index_payments_on_payer" t.index ["payer_type", "payer_id"], name: "index_payments_on_payer_type_and_payer_id" - t.index ["stripe_charge_id"], name: "index_payments_on_stripe_charge_id" - t.index ["stripe_payment_intent_id"], name: "index_payments_on_stripe_payment_intent_id", unique: true end create_table "people", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| @@ -789,7 +892,7 @@ t.boolean "legacy", default: false t.integer "legacy_id" t.boolean "published", default: false, null: false - t.text "quote", size: :medium + t.text "quote", size: :long t.string "speaker_name" t.datetime "updated_at", precision: nil, null: false t.integer "workshop_id" @@ -797,8 +900,23 @@ t.index ["workshop_id"], name: "index_quotes_on_workshop_id" end + create_table "refunds", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.integer "amount_cents", null: false + t.datetime "created_at", null: false + t.string "method", null: false + t.bigint "recipient_id", null: false + t.string "recipient_type", null: false + t.bigint "refundable_id", null: false + t.string "refundable_type", null: false + t.string "stripe_refund_id" + t.datetime "updated_at", null: false + t.index ["recipient_type", "recipient_id"], name: "index_refunds_on_recipient" + t.index ["refundable_type", "refundable_id"], name: "index_refunds_on_refundable" + t.index ["stripe_refund_id"], name: "index_refunds_on_stripe_refund_id" + end + create_table "report_form_field_answers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.text "answer", size: :medium + t.text "answer", size: :long t.integer "answer_option_id" t.datetime "created_at", precision: nil t.integer "form_field_id" @@ -848,7 +966,7 @@ create_table "resources", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "agency" t.string "author" - t.text "body", size: :medium + t.text "body", size: :long t.datetime "created_at", precision: nil, null: false t.integer "created_by_id" t.boolean "featured", default: false @@ -948,7 +1066,7 @@ create_table "user_form_form_fields", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.integer "form_field_id" - t.text "text", size: :medium + t.text "text", size: :long t.datetime "updated_at", precision: nil, null: false t.integer "user_form_id" t.index ["form_field_id"], name: "index_user_form_form_fields_on_form_field_id" @@ -985,7 +1103,7 @@ t.date "birthday" t.string "city" t.string "city2" - t.text "comment", size: :medium + t.text "comment", size: :long t.datetime "confirmation_sent_at" t.string "confirmation_token" t.datetime "confirmed_at" @@ -1005,7 +1123,7 @@ t.boolean "legacy", default: false t.integer "legacy_id" t.datetime "locked_at" - t.text "notes", size: :medium + t.text "notes", size: :long t.bigint "person_id" t.string "phone" t.string "phone2" @@ -1208,7 +1326,7 @@ create_table "workshop_variations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "author_credit_preference" - t.text "body", size: :medium + t.text "body", size: :long t.datetime "created_at", precision: nil, null: false t.integer "created_by_id" t.boolean "inactive", default: true @@ -1233,22 +1351,22 @@ end create_table "workshops", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.text "age_range", size: :medium - t.text "age_range_spanish", size: :medium + t.text "age_range", size: :long + t.text "age_range_spanish", size: :long t.string "author_credit_preference" t.string "author_location" - t.text "closing", size: :medium - t.text "closing_spanish", size: :medium + t.text "closing", size: :long + t.text "closing_spanish", size: :long t.datetime "created_at", precision: nil, null: false t.integer "created_by_id" - t.text "creation", size: :medium - t.text "creation_spanish", size: :medium - t.text "demonstration", size: :medium - t.text "demonstration_spanish", size: :medium - t.text "description", size: :medium - t.text "description_spanish", size: :medium - t.text "extra_field" - t.text "extra_field_spanish" + t.text "creation", size: :long + t.text "creation_spanish", size: :long + t.text "demonstration", size: :long + t.text "demonstration_spanish", size: :long + t.text "description", size: :long + t.text "description_spanish", size: :long + t.text "extra_field", size: :medium + t.text "extra_field_spanish", size: :medium t.boolean "featured", default: false t.string "filemaker_code" t.string "full_name" @@ -1257,40 +1375,40 @@ t.integer "header_file_size" t.datetime "header_updated_at", precision: nil t.boolean "inactive", default: true - t.text "instructions", size: :medium - t.text "instructions_spanish", size: :medium - t.text "introduction", size: :medium - t.text "introduction_spanish", size: :medium + t.text "instructions", size: :long + t.text "instructions_spanish", size: :long + t.text "introduction", size: :long + t.text "introduction_spanish", size: :long t.integer "led_count", default: 0 t.boolean "legacy", default: false t.integer "legacy_id" - t.text "materials", size: :medium - t.text "materials_spanish", size: :medium + t.text "materials", size: :long + t.text "materials_spanish", size: :long t.string "misc1" - t.text "misc1_spanish", size: :medium + t.text "misc1_spanish", size: :long t.string "misc2" - t.text "misc2_spanish", size: :medium - t.text "misc_instructions", size: :medium - t.text "misc_instructions_spanish", size: :medium + t.text "misc2_spanish", size: :long + t.text "misc_instructions", size: :long + t.text "misc_instructions_spanish", size: :long t.integer "month" - t.text "notes", size: :medium - t.text "notes_spanish", size: :medium - t.text "objective", size: :medium - t.text "objective_spanish", size: :medium - t.text "opening_circle", size: :medium - t.text "opening_circle_spanish", size: :medium - t.text "optional_materials", size: :medium - t.text "optional_materials_spanish", size: :medium + t.text "notes", size: :long + t.text "notes_spanish", size: :long + t.text "objective", size: :long + t.text "objective_spanish", size: :long + t.text "opening_circle", size: :long + t.text "opening_circle_spanish", size: :long + t.text "optional_materials", size: :long + t.text "optional_materials_spanish", size: :long t.string "photo_caption" - t.text "project", size: :medium - t.text "project_spanish", size: :medium + t.text "project", size: :long + t.text "project_spanish", size: :long t.string "pub_issue" t.boolean "publicly_featured", default: false, null: false t.boolean "publicly_visible", default: false, null: false t.boolean "published", default: false, null: false t.boolean "searchable", default: false - t.text "setup", size: :medium - t.text "setup_spanish", size: :medium + t.text "setup", size: :long + t.text "setup_spanish", size: :long t.string "thumbnail_content_type" t.string "thumbnail_file_name" t.integer "thumbnail_file_size" @@ -1302,17 +1420,17 @@ t.integer "time_opening" t.integer "time_opening_circle" t.integer "time_warm_up" - t.text "timeframe", size: :medium - t.text "timeframe_spanish", size: :medium - t.text "timestamps", size: :medium - t.text "tips", size: :medium - t.text "tips_spanish", size: :medium + t.text "timeframe", size: :long + t.text "timeframe_spanish", size: :long + t.text "timestamps", size: :long + t.text "tips", size: :long + t.text "tips_spanish", size: :long t.string "title" t.datetime "updated_at", precision: nil, null: false - t.text "visualization", size: :medium - t.text "visualization_spanish", size: :medium - t.text "warm_up", size: :medium - t.text "warm_up_spanish", size: :medium + t.text "visualization", size: :long + t.text "visualization_spanish", size: :long + t.text "warm_up", size: :long + t.text "warm_up_spanish", size: :long t.integer "windows_type_id" t.bigint "workshop_idea_id" t.integer "year" @@ -1337,6 +1455,7 @@ add_foreign_key "affiliations", "people" add_foreign_key "affiliations", "users" add_foreign_key "age_ranges", "windows_types" + add_foreign_key "allocations", "allocations", column: "reverted_id" add_foreign_key "banners", "users", column: "created_by_id" add_foreign_key "banners", "users", column: "updated_by_id" add_foreign_key "blazer_audits", "blazer_queries", column: "query_id" @@ -1378,7 +1497,10 @@ add_foreign_key "organizations", "locations" add_foreign_key "organizations", "organization_statuses" add_foreign_key "organizations", "windows_types" - add_foreign_key "payments", "events" + add_foreign_key "pay_charges", "pay_customers", column: "customer_id" + add_foreign_key "pay_charges", "pay_subscriptions", column: "subscription_id" + add_foreign_key "pay_payment_methods", "pay_customers", column: "customer_id" + add_foreign_key "pay_subscriptions", "pay_customers", column: "customer_id" add_foreign_key "people", "users", column: "created_by_id" add_foreign_key "people", "users", column: "updated_by_id" add_foreign_key "person_form_form_fields", "form_fields" diff --git a/db/seeds.rb b/db/seeds.rb index 56758799a..df1eeaf91 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,6 +1,10 @@ # Disable email delivery during seeding ActionMailer::Base.perform_deliveries = false +def seed(file) + require_relative "seeds/#{file}" +end + puts "Creating Users…" # Helper: case-insensitive find-or-create by name @@ -481,3 +485,5 @@ def find_or_create_by_name!(klass, name, **attrs, &block) unless Form.standalone.exists?(name: ScholarshipApplicationFormBuilder::FORM_NAME) ScholarshipApplicationFormBuilder.build_standalone! end + +seed "payments" diff --git a/db/seeds/payments.rb b/db/seeds/payments.rb new file mode 100644 index 000000000..ab4ceba7c --- /dev/null +++ b/db/seeds/payments.rb @@ -0,0 +1,174 @@ + # Payment seeds - can be run independently + # Usage: "rails runner db/seeds/payments.rb" + + puts "Seeding Payments, Allocations, and Refunds..." + + payment_ids_start = Payment.maximum(:id) || 0 + allocation_ids_start = Allocation.maximum(:id) || 0 + refund_ids_start = Refund.maximum(:id) || 0 + event_reg_ids_start = EventRegistration.maximum(:id) || 0 + + Refund.where("id > ?", refund_ids_start).delete_all + Allocation.where("id > ?", allocation_ids_start).delete_all + Payment.where("id > ?", payment_ids_start).delete_all + EventRegistration.where("id > ?", event_reg_ids_start).delete_all + + event_cost_cents = 150000 + + bob = Person.find_or_create_by!(email: "bob.payment@seed.example.com") do |p| + p.first_name = "Bob" + p.last_name = "Barker" + end + + alice = Person.find_or_create_by!(email: "alice.payment@seed.example.com") do |p| + p.first_name = "Alice" + p.last_name = "Test" + end + + charlie = Person.find_or_create_by!(email: "charlie.payment@seed.example.com") do |p| + p.first_name = "Charlie" + p.last_name = "Test" + end + + diana = Person.find_or_create_by!(email: "diana.payment@seed.example.com") do |p| + p.first_name = "Diana" + p.last_name = "Test" + end + + eve = Person.find_or_create_by!(email: "eve.payment@seed.example.com") do |p| + p.first_name = "Eve" + p.last_name = "Test" + end + + frank = Person.find_or_create_by!(email: "frank.payment@seed.example.com") do |p| + p.first_name = "Frank" + p.last_name = "Test" + end + + gary = Person.find_or_create_by!(email: "gary.payment@seed.example.com") do |p| + p.first_name = "Gary" + p.last_name = "Test" + end + + holly = Person.find_or_create_by!(email: "holly.payment@seed.example.com") do |p| + p.first_name = "Holly" + p.last_name = "Test" + end + + iris = Person.find_or_create_by!(email: "iris.payment@seed.example.com") do |p| + p.first_name = "Iris" + p.last_name = "Test" + end + + event = Event.find_or_create_by!(title: "Payment Test Workshop") do |e| + e.start_date = 1.month.from_now.to_date + e.end_date = 1.month.from_now.to_date + 2.days + e.published = true + e.cost_cents = event_cost_cents + end + event.update!(start_date: 1.month.from_now.to_date, end_date: 1.month.from_now.to_date + 2.days, published: true, cost_cents: event_cost_cents) + + reg_bob = EventRegistration.find_or_create_by!(registrant: bob, event: event) + reg_alice = EventRegistration.find_or_create_by!(registrant: alice, event: event) + reg_charlie = EventRegistration.find_or_create_by!(registrant: charlie, event: event) + reg_diana = EventRegistration.find_or_create_by!(registrant: diana, event: event) + reg_eve = EventRegistration.find_or_create_by!(registrant: eve, event: event) + reg_frank = EventRegistration.find_or_create_by!(registrant: frank, event: event) + reg_gary = EventRegistration.find_or_create_by!(registrant: gary, event: event) + reg_iris = EventRegistration.find_or_create_by!(registrant: iris, event: event) + + puts " Payment made but allocation reverted)" + payment1 = CashPayment.find_or_create_by!( + payer: bob, + amount_cents: event_cost_cents, + amount_cents_remaining: event_cost_cents + ) do |p| + p.created_at = 5.days.ago + end + original_allocation1 = Allocation.create!( + source: payment1, + allocatable: reg_bob, + amount: event_cost_cents, + created_at: 5.days.ago + ) + reversal_allocation1 = Allocation.create!( + source: payment1, + allocatable: reg_bob, + amount: -event_cost_cents, + created_at: 4.days.ago + ) + original_allocation1.update!(reverted_id: reversal_allocation1.id) + + puts " Overpayment with full allocation (Alice pays $6000, covers 4 people)" + payment2 = CashPayment.find_or_create_by!( + payer: alice, + amount_cents: 600000, + amount_cents_remaining: 0 + ) do |p| + p.created_at = 4.days.ago + end + Allocation.find_or_create_by!(source: payment2, allocatable: reg_alice, amount: event_cost_cents) { |a| a.created_at = 4.days.ago } + Allocation.find_or_create_by!(source: payment2, allocatable: reg_charlie, amount: event_cost_cents) { |a| a.created_at = 4.days.ago } + Allocation.find_or_create_by!(source: payment2, allocatable: reg_diana, amount: event_cost_cents) { |a| a.created_at = 4.days.ago } + Allocation.find_or_create_by!(source: payment2, allocatable: reg_eve, amount: event_cost_cents) { |a| a.created_at = 4.days.ago } + + puts " Payment with remaining available ($2000 payment, $1500 allocated, $500 remaining)" + payment3 = CashPayment.find_or_create_by!( + payer: frank, + amount_cents: 200000, + amount_cents_remaining: 50000 + ) do |p| + p.created_at = 3.days.ago + end + Allocation.find_or_create_by!( + source: payment3, + allocatable: reg_frank, + amount: 150000 + ) do |a| + a.created_at = 3.days.ago + end + + puts " Full refund ($1500 payment, fully allocated, fully refunded)" + payment4 = CashPayment.find_or_create_by!( + payer: gary, + amount_cents: event_cost_cents, + amount_cents_remaining: 0 + ) do |p| + p.created_at = 2.days.ago + end + Allocation.find_or_create_by!(source: payment4, allocatable: reg_gary, amount: event_cost_cents) { |a| a.created_at = 2.days.ago } + Refund.find_or_create_by!( + refundable: payment4, + recipient: gary, + amount_cents: event_cost_cents, + method: "check" + ) do |r| + r.created_at = 1.day.ago + end + + puts " Creating Scenario 8: Payment with no allocations ($10000, full amount remaining)" + CashPayment.find_or_create_by!( + payer: holly, + amount_cents: 1000000, + amount_cents_remaining: 1000000 + ) do |p| + p.created_at = 7.days.ago + end + + puts " Partial payment" + payment9 = CashPayment.find_or_create_by!( + payer: iris, + amount_cents: 100000, + amount_cents_remaining: 0 + ) do |p| + p.created_at = 3.days.ago + end + Allocation.find_or_create_by!( + source: payment9, + allocatable: reg_iris, + amount: 100000 + ) do |a| + a.created_at = 3.days.ago + end + + puts " Payment seeds complete!" diff --git a/spec/policies/person_policy_spec.rb b/spec/policies/person_policy_spec.rb index f95fcf550..77aedbf97 100644 --- a/spec/policies/person_policy_spec.rb +++ b/spec/policies/person_policy_spec.rb @@ -124,11 +124,13 @@ def policy_for(record: nil, user:) context "with regular user" do let(:policy) { policy_for(record: Person, user: regular_user) } - it "filters to searchable people with active affiliations" do + it "filters to searchable people with active affiliations and unlocked users" do scope = policy.apply_scope(Person.all, type: :active_record_relation) - expect(scope.to_sql).to include('`people`.`profile_is_searchable` = TRUE') - expect(scope.to_sql).to include('INNER JOIN `affiliations`') - expect(scope.to_sql).to include('`affiliations`.`inactive` = FALSE') + sql = scope.to_sql + expect(sql).to include('`people`.`profile_is_searchable` = TRUE') + expect(sql).to include('INNER JOIN `affiliations`') + expect(sql).to include('`affiliations`.`inactive` = FALSE') + expect(sql).to include('`users`.`locked_at` IS NULL') end end end diff --git a/spec/requests/notifications_spec.rb b/spec/requests/notifications_spec.rb index 7cfdc557f..89629af49 100644 --- a/spec/requests/notifications_spec.rb +++ b/spec/requests/notifications_spec.rb @@ -8,22 +8,25 @@ describe "GET /notifications" do before { sign_in admin } + let(:turbo_headers) { { "Turbo-Frame" => "notifications_results" } } let!(:story_notification) { create(:notification, noticeable: create(:story_idea), email_subject: "New story idea") } let!(:user_notification) { create(:notification, noticeable: create(:user), email_subject: "Welcome") } - it "preserves the email_topic filter selection" do - get notifications_path, params: { email_topic: "User: confirm new email" } - expect(response.body).to include('selected="selected"') - expect(response.body).to include("User: confirm new email") + it "filters by email_topic" do + matching = create(:notification, email_subject: "Confirm your new email address") + get notifications_path, params: { email_topic: "User: confirm new email" }, headers: turbo_headers + expect(response.body).to include(matching.recipient_email) + expect(response.body).not_to include(story_notification.recipient_email) end - it "preserves the record_type filter selection" do - get notifications_path, params: { record_type: "StoryIdea" } - expect(response.body).to include('selected="selected"') + it "filters by record_type" do + get notifications_path, params: { record_type: "StoryIdea" }, headers: turbo_headers + expect(response.body).to include(story_notification.recipient_email) + expect(response.body).not_to include(user_notification.recipient_email) end it "wraps results in a turbo frame" do - get notifications_path + get notifications_path, headers: turbo_headers expect(response.body).to include('id="notifications_results"') end end