From f56a3b8b4af98ee120dba1c7da8687a13ea1e766 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:19:24 -0400 Subject: [PATCH 01/74] add pay gem --- Gemfile | 5 +++++ Gemfile.lock | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Gemfile b/Gemfile index fe4765703..155561046 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 d8119f31c..d242425cb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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.0) 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 @@ -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 @@ -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.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af 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 From 24506dbe2d677f9cc7766749346b4100366696e6 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:20:07 -0400 Subject: [PATCH 02/74] pay gem migrations --- .../20260328221935_create_pay_tables.pay.rb | 98 ++++++++ .../20260328221936_add_pay_sti_columns.pay.rb | 25 ++ ...0328221937_add_object_to_pay_models.pay.rb | 8 + db/schema.rb | 233 +++++++++++++----- 4 files changed, 299 insertions(+), 65 deletions(-) create mode 100644 db/migrate/20260328221935_create_pay_tables.pay.rb create mode 100644 db/migrate/20260328221936_add_pay_sti_columns.pay.rb create mode 100644 db/migrate/20260328221937_add_object_to_pay_models.pay.rb 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..2ec88a565 --- /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/schema.rb b/db/schema.rb index 3014b674b..76b682a61 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_03_28_221937) 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 @@ -210,7 +210,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 +279,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 +474,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 +496,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 +578,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 +601,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 +642,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 +652,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,6 +672,105 @@ 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.datetime "created_at", null: false @@ -789,7 +888,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" @@ -798,7 +897,7 @@ 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 +947,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 +1047,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 +1084,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 +1104,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 +1307,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 +1332,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 +1356,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 +1401,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" @@ -1378,6 +1477,10 @@ add_foreign_key "organizations", "locations" add_foreign_key "organizations", "organization_statuses" add_foreign_key "organizations", "windows_types" + 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 "payments", "events" add_foreign_key "people", "users", column: "created_by_id" add_foreign_key "people", "users", column: "updated_by_id" From c7d0360921c8197e594ae177cd5b939ae3ae17e5 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:13:30 -0400 Subject: [PATCH 03/74] add pay_customer to person --- app/models/person.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/models/person.rb b/app/models/person.rb index 556994930..346dbe9bf 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 @@ -227,4 +229,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 From aef5332bf8886f3eff2e4466e89dfcbae62ff478 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:28:28 -0400 Subject: [PATCH 04/74] create allocations migration --- db/migrate/20260329161720_create_allocations.rb | 12 ++++++++++++ db/schema.rb | 16 +++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260329161720_create_allocations.rb 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/schema.rb b/db/schema.rb index 76b682a61..6816f9af7 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_28_221937) do +ActiveRecord::Schema[8.1].define(version: 2026_03_29_161720) 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,20 @@ 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 "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 ["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" From e2fa10f0ebf869a7f6ee9842a90f048a92c64e7d Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:56:31 -0400 Subject: [PATCH 05/74] change payments to sti --- .../20260329175425_change_payments_to_sti.rb | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 db/migrate/20260329175425_change_payments_to_sti.rb 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..59e0c5dba --- /dev/null +++ b/db/migrate/20260329175425_change_payments_to_sti.rb @@ -0,0 +1,31 @@ +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 + + remove_index :payments, name: "index_payments_on_event_id" + remove_index :payments, name: "index_payments_on_payable_type_and_payable_id_and_status" + remove_index :payments, name: "index_payments_on_payable" + remove_index :payments, name: "index_payments_on_payable_type_and_payable_id" + remove_index :payments, name: "index_payments_on_payer" + remove_index :payments, name: "index_payments_on_payer_type_and_payer_id" + remove_index :payments, name: "index_payments_on_stripe_charge_id" + remove_index :payments, name: "index_payments_on_stripe_payment_intent_id" + + add_index :payments, :pay_charge_id + add_index :payments, [ :payer_type, :payer_id ] + end +end From ebab3fff7af61afa440d825cb407e1fbe1ab6fce Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:03:21 -0400 Subject: [PATCH 06/74] add refunds migration --- db/migrate/20260329180236_create_refunds.rb | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 db/migrate/20260329180236_create_refunds.rb diff --git a/db/migrate/20260329180236_create_refunds.rb b/db/migrate/20260329180236_create_refunds.rb new file mode 100644 index 000000000..4e40cdc65 --- /dev/null +++ b/db/migrate/20260329180236_create_refunds.rb @@ -0,0 +1,9 @@ +class CreateRefunds < ActiveRecord::Migration[8.1] + def change + create_table :refunds do |t| + t.references :refundable, polymorphic: true, null: false + t.integer :amount_cents, null: false + t.timestamps + end + end +end From 549f23483a7bf52b52ed475760ddb8638fbcb51e Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:18:13 -0400 Subject: [PATCH 07/74] fix migration --- .../20260329175425_change_payments_to_sti.rb | 12 ------- db/migrate/20260329180236_create_refunds.rb | 1 + db/schema.rb | 34 +++++++++---------- 3 files changed, 17 insertions(+), 30 deletions(-) diff --git a/db/migrate/20260329175425_change_payments_to_sti.rb b/db/migrate/20260329175425_change_payments_to_sti.rb index 59e0c5dba..53082e287 100644 --- a/db/migrate/20260329175425_change_payments_to_sti.rb +++ b/db/migrate/20260329175425_change_payments_to_sti.rb @@ -15,17 +15,5 @@ def change remove_column :payments, :failure_code remove_column :payments, :failure_message remove_column :payments, :status - - remove_index :payments, name: "index_payments_on_event_id" - remove_index :payments, name: "index_payments_on_payable_type_and_payable_id_and_status" - remove_index :payments, name: "index_payments_on_payable" - remove_index :payments, name: "index_payments_on_payable_type_and_payable_id" - remove_index :payments, name: "index_payments_on_payer" - remove_index :payments, name: "index_payments_on_payer_type_and_payer_id" - remove_index :payments, name: "index_payments_on_stripe_charge_id" - remove_index :payments, name: "index_payments_on_stripe_payment_intent_id" - - add_index :payments, :pay_charge_id - add_index :payments, [ :payer_type, :payer_id ] end end diff --git a/db/migrate/20260329180236_create_refunds.rb b/db/migrate/20260329180236_create_refunds.rb index 4e40cdc65..2f6520b2c 100644 --- a/db/migrate/20260329180236_create_refunds.rb +++ b/db/migrate/20260329180236_create_refunds.rb @@ -2,6 +2,7 @@ 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 diff --git a/db/schema.rb b/db/schema.rb index 6816f9af7..ea5c7b179 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_29_161720) do +ActiveRecord::Schema[8.1].define(version: 2026_03_29_180236) 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 @@ -787,29 +787,16 @@ create_table "payments", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.integer "amount_cents", 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| @@ -910,6 +897,18 @@ 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.bigint "recipient_id", null: false + t.string "recipient_type", null: false + t.bigint "refundable_id", null: false + t.string "refundable_type", null: false + 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" + end + create_table "report_form_field_answers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.text "answer", size: :long t.integer "answer_option_id" @@ -1495,7 +1494,6 @@ 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 "payments", "events" 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" From 6fa31f2c3e71dc580d11e675ab4e33fdb4861cd0 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:34:28 -0400 Subject: [PATCH 08/74] update models to use allocations --- app/models/allocation.rb | 5 +++ app/models/cash_payment.rb | 2 + app/models/check_payment.rb | 3 ++ app/models/event.rb | 1 - app/models/event_registration.rb | 1 - app/models/external_processor_payment.rb | 4 ++ app/models/payment.rb | 47 ++---------------------- app/models/refund.rb | 7 ++++ 8 files changed, 24 insertions(+), 46 deletions(-) create mode 100644 app/models/allocation.rb create mode 100644 app/models/cash_payment.rb create mode 100644 app/models/check_payment.rb create mode 100644 app/models/external_processor_payment.rb create mode 100644 app/models/refund.rb diff --git a/app/models/allocation.rb b/app/models/allocation.rb new file mode 100644 index 000000000..dcae79f8d --- /dev/null +++ b/app/models/allocation.rb @@ -0,0 +1,5 @@ +class Allocation < ApplicationRecord + belongs_to :source, polymorphic: true + belongs_to :allocatable, polymorphic: true + validates :amount, numericality: true +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/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..b04531015 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -5,7 +5,6 @@ class EventRegistration < ApplicationRecord 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 before_destroy :create_refund_payments diff --git a/app/models/external_processor_payment.rb b/app/models/external_processor_payment.rb new file mode 100644 index 000000000..ab8f15240 --- /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..3f353d56d 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -1,49 +1,8 @@ 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" - end end diff --git a/app/models/refund.rb b/app/models/refund.rb new file mode 100644 index 000000000..e37a3ac25 --- /dev/null +++ b/app/models/refund.rb @@ -0,0 +1,7 @@ +class Refund < ApplicationRecord + belongs_to :refundable, polymorphic: true + belongs_to :recipient, polymorphic: true + has_many :allocations, as: :source + + validates :amount_cents, numericality: true +end From c2dfc28a0182dc9ef1646b5f229949c386355978 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:16:31 -0400 Subject: [PATCH 09/74] add allocation index with cash and check payment form --- app/controllers/allocations_controller.rb | 13 +++ app/controllers/events_controller.rb | 2 +- app/controllers/payments_controller.rb | 84 +++++++++++++++++++ app/models/event_registration.rb | 19 +++-- app/policies/allocation_policy.rb | 2 + app/policies/payment_policy.rb | 2 + app/views/allocations/index.html.erb | 55 ++++++++++++ app/views/events/_manage_results.html.erb | 14 ++-- .../payments/_cash_payment_fields.html.erb | 1 + .../payments/_check_payment_fields.html.erb | 1 + app/views/payments/_form.html.erb | 42 ++++++++++ app/views/payments/index.html.erb | 34 ++++++++ app/views/payments/new.html.erb | 47 +++++++++++ app/views/payments/new.turbo_stream.erb | 1 + app/views/payments/show.html.erb | 40 +++++++++ config/routes.rb | 6 ++ 16 files changed, 345 insertions(+), 18 deletions(-) create mode 100644 app/controllers/allocations_controller.rb create mode 100644 app/controllers/payments_controller.rb create mode 100644 app/policies/allocation_policy.rb create mode 100644 app/policies/payment_policy.rb create mode 100644 app/views/allocations/index.html.erb create mode 100644 app/views/payments/_cash_payment_fields.html.erb create mode 100644 app/views/payments/_check_payment_fields.html.erb create mode 100644 app/views/payments/_form.html.erb create mode 100644 app/views/payments/index.html.erb create mode 100644 app/views/payments/new.html.erb create mode 100644 app/views/payments/new.turbo_stream.erb create mode 100644 app/views/payments/show.html.erb diff --git a/app/controllers/allocations_controller.rb b/app/controllers/allocations_controller.rb new file mode 100644 index 000000000..601a9bd36 --- /dev/null +++ b/app/controllers/allocations_controller.rb @@ -0,0 +1,13 @@ +class AllocationsController < ApplicationController + before_action :authenticate_user! + + def index + if params[:allocatable_sgid].present? + @allocatable = GlobalID::Locator.locate_signed(params[:allocatable_sgid]) + @allocations = @allocatable.allocations.includes(:source) + else + @allocations = Allocation.all + end + authorize! @allocations + end +end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index df771df8f..cec0bbc8d 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? diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb new file mode 100644 index 000000000..1b2cd2392 --- /dev/null +++ b/app/controllers/payments_controller.rb @@ -0,0 +1,84 @@ +class PaymentsController < ApplicationController + def index + authorize! + @payments = Payment.all + end + + def new + authorize! + payment_type = params[:type].presence || "CashPayment" + @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.html + format.turbo_stream + end + end + + def create + authorize! + payment_class = params[:payment][:type].safe_constantize || CashPayment + + allocatable = nil + if params[:payment][:allocatable_sgid].present? + allocatable = GlobalID::Locator.locate_signed(params[:payment][:allocatable_sgid]) + end + + payment_attrs = payment_params.except(:allocatable_sgid) + payment_attrs[:payer_type] = "Person" + payment_attrs[:payer_id] = params[:payment][:payer_id].presence || (allocatable.try(:registrant_id) if allocatable.is_a?(EventRegistration)) + + @payment = payment_class.new(payment_attrs) + + if @payment.save + if allocatable.present? + Allocation.create!( + source: @payment, + allocatable: allocatable, + amount: @payment.amount_cents + ) + end + + respond_to do |format| + format.turbo_stream { redirect_to allocations_path(allocatable_sgid: params[:payment][:allocatable_sgid]) } + format.html { redirect_to @payment, notice: "Payment was successfully created." } + end + else + @allocatable = allocatable + render :new, status: :unprocessable_content + end + end + + def show + @payment = Payment.find(params[:id]) + authorize! @payment + end + + def allocation_form + authorize! + payment_type = params[:type].presence || "CashPayment" + @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 + + render turbo_stream: turbo_stream.append("payment-form", partial: "payments/form") + end + + private + + def payment_params + params.require(:payment).permit(:type, :payer_id, :amount_cents, :currency, :check_number, :allocatable_sgid) + end +end diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index b04531015..5bf08b9b0 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -5,6 +5,7 @@ class EventRegistration < ApplicationRecord has_many :event_registration_organizations, dependent: :destroy has_many :notifications, as: :noticeable, dependent: :destroy has_many :organizations, through: :event_registration_organizations + has_many :allocations, as: :allocatable before_destroy :create_refund_payments @@ -88,18 +89,20 @@ def paid? # 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 + # if payments.loaded? + # payments.select(&:succeeded?).sum(&:amount_cents) + # else + # payments.successful.sum(:amount_cents) + # end + 0 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 + # return true if event.cost_cents.to_i <= 0 + # return true if scholarship_recipient? + # successful_payments_total_cents >= event.cost_cents.to_i + false end def scholarship? 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/views/allocations/index.html.erb b/app/views/allocations/index.html.erb new file mode 100644 index 000000000..dfc4d6a3b --- /dev/null +++ b/app/views/allocations/index.html.erb @@ -0,0 +1,55 @@ +
+
+ <% 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 %> +
+ <% 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 %> +
+ <% if @allocations.any? %> +
+ + + + + + + + + + <% @allocations.each do |allocation| %> + + + + + + <% end %> + +
TypeAmountDate
<%= allocation.source.class.name.underscore.titleize %> - <%= allocation.source&.payer.name || "Unknown" %>$<%= "%.2f" % (allocation.amount.to_d / 100) %><%= allocation.created_at.in_time_zone.strftime("%B %d, %Y at %l:%M %p") %>
+
+ <% else %> +

No allocations found.

+ <% end %> +
diff --git a/app/views/events/_manage_results.html.erb b/app/views/events/_manage_results.html.erb index 3b4c0707f..3f8fc26a6 100644 --- a/app/views/events/_manage_results.html.erb +++ b/app/views/events/_manage_results.html.erb @@ -97,18 +97,14 @@ <% if registration.scholarship_recipient? && registration.successful_payments_total_cents <= 0 %> - <% elsif registration.paid_in_full? %> - - - Paid - <% else %> <% paid_cents = registration.successful_payments_total_cents %> <% 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/payments/_cash_payment_fields.html.erb b/app/views/payments/_cash_payment_fields.html.erb new file mode 100644 index 000000000..213c0eeb7 --- /dev/null +++ b/app/views/payments/_cash_payment_fields.html.erb @@ -0,0 +1 @@ + diff --git a/app/views/payments/_check_payment_fields.html.erb b/app/views/payments/_check_payment_fields.html.erb new file mode 100644 index 000000000..3b72167ec --- /dev/null +++ b/app/views/payments/_check_payment_fields.html.erb @@ -0,0 +1 @@ +<%= f.input :check_number %> diff --git a/app/views/payments/_form.html.erb b/app/views/payments/_form.html.erb new file mode 100644 index 000000000..1cef1d081 --- /dev/null +++ b/app/views/payments/_form.html.erb @@ -0,0 +1,42 @@ +
+

New Payment

+ + <%= simple_form_for @payment, as: :payment, url: payments_path do |f| %> + <%= f.input :type, as: :hidden %> + <%= hidden_field_tag "payment[allocatable_sgid]", params[:allocatable_sgid] %> + <%= f.input :currency, as: :hidden, input_html: { value: "usd" } %> + <%= f.input :payer_id, as: :hidden %> + +
+ <%= label_tag :payer_search, "Payer", class: "block text-sm font-medium text-gray-700 mb-1" %> + + <%= select_tag :payer_search, + options_for_select([ [@payment.payer&.full_name, @payment.payer_id] ].compact), + include_blank: true, + data: { + controller: "remote-select", + remote_select_model_value: "person" + }, + class: "w-full" %> +
+ + <%= f.input :amount_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> + <%= render partial: "payments/#{f.object.type.underscore}_fields", locals: { f: f } if f.object.type.present? %> + +
<%= 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/index.html.erb b/app/views/payments/index.html.erb new file mode 100644 index 000000000..b7e7d4ee0 --- /dev/null +++ b/app/views/payments/index.html.erb @@ -0,0 +1,34 @@ +
+
+

Payments

+ <%= link_to "New Payment", new_payment_path, class: "btn btn-primary" %> +
+ +
+ + + + + + + + + + + + + + <% @payments.each do |payment| %> + + + + + + + + + <% end %> + +
TypePayerAmountCheck #CreatedActions
<%= payment.type %><%= payment.payer.name %>$<%= "%.2f" % (payment.amount_cents / 100.0) %><%= payment.check_number || "—" %><%= payment.created_at.strftime("%B %d, %Y") %><%= link_to "View", payment_path(payment), class: "text-primary hover:text-primary-dark" %>
+
+
diff --git a/app/views/payments/new.html.erb b/app/views/payments/new.html.erb new file mode 100644 index 000000000..5ced04458 --- /dev/null +++ b/app/views/payments/new.html.erb @@ -0,0 +1,47 @@ +
+

New Payment

+ <%= simple_form_for @payment, url: payments_path do |f| %> + <%= f.input :type, + collection: ["CashPayment", "CheckPayment", "ExternalProcessorPayment"], + prompt: "Select payment type" %> +
+ <%= label_tag :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :payer_type, + options_for_select(["Person", "User"]), + include_blank: true, + class: "w-full" %> +
+
+ <%= label_tag :payer_search, "Search Payer", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :payer_search, nil, + data: { + controller: "remote-select", + remote_select_model_value: "person" + }, + include_blank: true, + class: "w-full" %> + <%= f.input :payer_id, as: :hidden %> +
+ <%= f.input :amount_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> + <%= f.input :currency, as: :hidden, input_html: { value: "usd" } %> + <%= f.input :check_number %> + <%= f.input :pay_charge_id, label: "Pay Charge ID" %> +
+ <%= 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/new.turbo_stream.erb b/app/views/payments/new.turbo_stream.erb new file mode 100644 index 000000000..dfc4b2b90 --- /dev/null +++ b/app/views/payments/new.turbo_stream.erb @@ -0,0 +1 @@ +<%= turbo_stream.append "payment-form", partial: "payments/form" %> diff --git a/app/views/payments/show.html.erb b/app/views/payments/show.html.erb new file mode 100644 index 000000000..9a0ec019f --- /dev/null +++ b/app/views/payments/show.html.erb @@ -0,0 +1,40 @@ +
+
+

Payment

+ <%= link_to "Back", payments_path, class: "btn btn-secondary" %> +
+
+
+
Type
+
<%= @payment.type %>
+
+
+
Payer
+
<%= @payment.payer %>
+
+
+
Amount
+
$<%= "%.2f" % (@payment.amount_cents / 100.0) %>
+
+
+
Currency
+
<%= @payment.currency %>
+
+ <% 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") %>
+
+
+
diff --git a/config/routes.rb b/config/routes.rb index fb2639a53..f03dc9383 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -134,6 +134,12 @@ 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 ] resources :organization_statuses resources :affiliations resources :quotes From 84683be41be3219672742233e0437a4afbc9fd58 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:45:09 -0400 Subject: [PATCH 10/74] create allocation form --- app/controllers/payments_controller.rb | 4 +- app/views/payments/_allocation_form.html.erb | 27 ++++++++ .../payments/_cash_payment_fields.html.erb | 1 - .../payments/_check_payment_fields.html.erb | 1 - app/views/payments/_form.html.erb | 64 ++++++++----------- app/views/payments/new.html.erb | 48 +------------- 6 files changed, 58 insertions(+), 87 deletions(-) create mode 100644 app/views/payments/_allocation_form.html.erb delete mode 100644 app/views/payments/_cash_payment_fields.html.erb delete mode 100644 app/views/payments/_check_payment_fields.html.erb diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 1b2cd2392..1efd57219 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -24,7 +24,7 @@ def new def create authorize! - payment_class = params[:payment][:type].safe_constantize || CashPayment + payment_class = params[:payment][:type].presence&.safe_constantize || CashPayment allocatable = nil if params[:payment][:allocatable_sgid].present? @@ -73,7 +73,7 @@ def allocation_form end end - render turbo_stream: turbo_stream.append("payment-form", partial: "payments/form") + render turbo_stream: turbo_stream.append("payment-form", partial: "payments/allocation_form") end private diff --git a/app/views/payments/_allocation_form.html.erb b/app/views/payments/_allocation_form.html.erb new file mode 100644 index 000000000..e7ec8a068 --- /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_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> + <%= 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/_cash_payment_fields.html.erb b/app/views/payments/_cash_payment_fields.html.erb deleted file mode 100644 index 213c0eeb7..000000000 --- a/app/views/payments/_cash_payment_fields.html.erb +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/views/payments/_check_payment_fields.html.erb b/app/views/payments/_check_payment_fields.html.erb deleted file mode 100644 index 3b72167ec..000000000 --- a/app/views/payments/_check_payment_fields.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= f.input :check_number %> diff --git a/app/views/payments/_form.html.erb b/app/views/payments/_form.html.erb index 1cef1d081..429984225 100644 --- a/app/views/payments/_form.html.erb +++ b/app/views/payments/_form.html.erb @@ -1,42 +1,34 @@ -
-

New Payment

- +
+

New Payment

<%= simple_form_for @payment, as: :payment, url: payments_path do |f| %> - <%= f.input :type, as: :hidden %> - <%= hidden_field_tag "payment[allocatable_sgid]", params[:allocatable_sgid] %> - <%= f.input :currency, as: :hidden, input_html: { value: "usd" } %> - <%= f.input :payer_id, as: :hidden %> - + <%= f.input :type, + collection: ["CashPayment", "CheckPayment", "ExternalProcessorPayment"], + prompt: "Select payment type" %>
- <%= label_tag :payer_search, "Payer", class: "block text-sm font-medium text-gray-700 mb-1" %> - - <%= select_tag :payer_search, - options_for_select([ [@payment.payer&.full_name, @payment.payer_id] ].compact), - include_blank: true, - data: { - controller: "remote-select", - remote_select_model_value: "person" - }, - class: "w-full" %> + <%= label_tag :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :payer_type, + options_for_select(["Person", "Organization"]), + include_blank: true, + class: "w-full" %>
- + <%= 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_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> - <%= render partial: "payments/#{f.object.type.underscore}_fields", locals: { f: f } if f.object.type.present? %> - -
<%= f.button :submit, "Create Payment", class: "btn btn-primary" %><%= link_to "Cancel", allocations_path(allocatable_sgid: params[:allocatable_sgid]), class: "btn btn-secondary" %>
+ <%= f.input :currency, as: :hidden, input_html: { value: "usd" } %> + <%= f.input :check_number %> + <%= f.input :pay_charge_id, label: "Pay Charge ID" %> +
+ <%= 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/new.html.erb b/app/views/payments/new.html.erb index 5ced04458..e0f80e774 100644 --- a/app/views/payments/new.html.erb +++ b/app/views/payments/new.html.erb @@ -1,47 +1 @@ -
-

New Payment

- <%= simple_form_for @payment, url: payments_path do |f| %> - <%= f.input :type, - collection: ["CashPayment", "CheckPayment", "ExternalProcessorPayment"], - prompt: "Select payment type" %> -
- <%= label_tag :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :payer_type, - options_for_select(["Person", "User"]), - include_blank: true, - class: "w-full" %> -
-
- <%= label_tag :payer_search, "Search Payer", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :payer_search, nil, - data: { - controller: "remote-select", - remote_select_model_value: "person" - }, - include_blank: true, - class: "w-full" %> - <%= f.input :payer_id, as: :hidden %> -
- <%= f.input :amount_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> - <%= f.input :currency, as: :hidden, input_html: { value: "usd" } %> - <%= f.input :check_number %> - <%= f.input :pay_charge_id, label: "Pay Charge ID" %> -
- <%= f.button :submit, "Create Payment", class: "btn btn-primary" %> - <%= link_to "Cancel", payments_path, class: "btn btn-secondary ml-2" %> -
- <% end %> -
- +<%= render "form" %> From 27a72e931c48e2b0e06226a3cabcec6bfb2de2fe Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:56:42 -0400 Subject: [PATCH 11/74] us turbo stream replace --- app/controllers/payments_controller.rb | 6 ++++-- app/views/allocations/index.html.erb | 7 +++++-- app/views/payments/_form.html.erb | 19 +++++++++---------- .../payments/allocation_form.turbo_stream.erb | 1 + 4 files changed, 19 insertions(+), 14 deletions(-) create mode 100644 app/views/payments/allocation_form.turbo_stream.erb diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 1efd57219..7f2da21aa 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -6,7 +6,7 @@ def index def new authorize! - payment_type = params[:type].presence || "CashPayment" + payment_type = params[:type] @payment = payment_type.safe_constantize.new if params[:allocatable_sgid].present? @@ -73,7 +73,9 @@ def allocation_form end end - render turbo_stream: turbo_stream.append("payment-form", partial: "payments/allocation_form") + respond_to do |format| + format.turbo_stream + end end private diff --git a/app/views/allocations/index.html.erb b/app/views/allocations/index.html.erb index dfc4d6a3b..7bf9fb38d 100644 --- a/app/views/allocations/index.html.erb +++ b/app/views/allocations/index.html.erb @@ -17,17 +17,19 @@

All Allocations

<% end %>
+ <% 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 %> -
+ <% if @allocations.any? %>
@@ -38,6 +40,7 @@ + <% @allocations.each do |allocation| %> diff --git a/app/views/payments/_form.html.erb b/app/views/payments/_form.html.erb index 429984225..50ae3cd89 100644 --- a/app/views/payments/_form.html.erb +++ b/app/views/payments/_form.html.erb @@ -1,16 +1,16 @@ -
+

New Payment

+ <%= simple_form_for @payment, as: :payment, url: payments_path do |f| %> <%= f.input :type, collection: ["CashPayment", "CheckPayment", "ExternalProcessorPayment"], prompt: "Select payment type" %> -
- <%= label_tag :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :payer_type, + +
<%= label_tag :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %><%= select_tag :payer_type, options_for_select(["Person", "Organization"]), include_blank: true, - class: "w-full" %> -
+ class: "w-full" %>
+ <%= f.input :payer_id, collection: @payment.payer.present? ? [[@payment.payer.full_name, @payment.payer.id]] : [], include_blank: true, @@ -22,13 +22,12 @@ }, prompt: "Search for a payer", label: "Payer" %> + <%= f.input :amount_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> <%= f.input :currency, as: :hidden, input_html: { value: "usd" } %> <%= f.input :check_number %> <%= f.input :pay_charge_id, label: "Pay Charge ID" %> -
- <%= f.button :submit, "Create Payment", class: "btn btn-primary" %> - <%= link_to "Cancel", payments_path, class: "btn btn-secondary ml-2" %> -
+ +
<%= 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/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" %> From 0c0d2243cc40ca163214b51feba9c4a845030dd8 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:49:57 -0400 Subject: [PATCH 12/74] change payment totals to allocations_sum --- app/controllers/events_controller.rb | 2 +- app/models/event_registration.rb | 18 +- app/views/event_registrations/_form.html.erb | 13 +- .../event_registrations/_ticket.html.erb | 252 ++++++++---------- app/views/events/_manage_results.html.erb | 4 +- app/views/events/preview_reminder.html.erb | 7 +- 6 files changed, 125 insertions(+), 171 deletions(-) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index cec0bbc8d..3593c4cb7 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -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/models/event_registration.rb b/app/models/event_registration.rb index 5bf08b9b0..9f97d1b89 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -87,22 +87,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 - 0 + 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 - false + return true if event.cost_cents.to_i <= 0 + return true if scholarship_recipient? + allocations_sum >= event.cost_cents.to_i end def scholarship? 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 3f8fc26a6..9857855c0 100644 --- a/app/views/events/_manage_results.html.erb +++ b/app/views/events/_manage_results.html.erb @@ -95,10 +95,10 @@ <% if @event.cost_cents.to_i > 0 %>
Date
- <% if registration.scholarship_recipient? && registration.successful_payments_total_cents <= 0 %> + <% if registration.scholarship_recipient? && registration.allocations_sum <= 0 %> <% else %> - <% paid_cents = registration.successful_payments_total_cents %> + <% paid_cents = registration.allocations_sum %> <% due_cents = @event.cost_cents - paid_cents %> <% 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 %> 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 %> From 95c8fb125ee3148876e98c9b4b9896a0df7e1aa7 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:07:35 -0400 Subject: [PATCH 13/74] clean up new payment form --- app/controllers/payments_controller.rb | 8 ++++---- app/views/payments/_form.html.erb | 17 ++++++----------- app/views/payments/index.html.erb | 7 ++++--- app/views/payments/new.turbo_stream.erb | 1 - 4 files changed, 14 insertions(+), 19 deletions(-) delete mode 100644 app/views/payments/new.turbo_stream.erb diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 7f2da21aa..44961d313 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -16,10 +16,10 @@ def new end end - respond_to do |format| - format.html - format.turbo_stream - end + # respond_to do |format| + # format.html + # format.turbo_stream + # end end def create diff --git a/app/views/payments/_form.html.erb b/app/views/payments/_form.html.erb index 50ae3cd89..41de10e36 100644 --- a/app/views/payments/_form.html.erb +++ b/app/views/payments/_form.html.erb @@ -1,16 +1,12 @@
-

New Payment

- +

New <%= @payment.type.underscore.titleize %> Payment

<%= simple_form_for @payment, as: :payment, url: payments_path do |f| %> - <%= f.input :type, - collection: ["CashPayment", "CheckPayment", "ExternalProcessorPayment"], - prompt: "Select payment type" %> - + <%= f.input :type, as: :hidden %> +

Type: <%= @payment.type.underscore.titleize %>

<%= label_tag :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %><%= select_tag :payer_type, options_for_select(["Person", "Organization"]), include_blank: true, class: "w-full" %>
- <%= f.input :payer_id, collection: @payment.payer.present? ? [[@payment.payer.full_name, @payment.payer.id]] : [], include_blank: true, @@ -22,12 +18,11 @@ }, prompt: "Search for a payer", label: "Payer" %> - <%= f.input :amount_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> <%= f.input :currency, as: :hidden, input_html: { value: "usd" } %> - <%= f.input :check_number %> - <%= f.input :pay_charge_id, label: "Pay Charge ID" %> - + <% 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/index.html.erb b/app/views/payments/index.html.erb index b7e7d4ee0..26f913820 100644 --- a/app/views/payments/index.html.erb +++ b/app/views/payments/index.html.erb @@ -1,9 +1,11 @@

Payments

- <%= link_to "New Payment", new_payment_path, class: "btn btn-primary" %> +
+ <%= 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" %> +
-
@@ -16,7 +18,6 @@ - <% @payments.each do |payment| %> diff --git a/app/views/payments/new.turbo_stream.erb b/app/views/payments/new.turbo_stream.erb deleted file mode 100644 index dfc4b2b90..000000000 --- a/app/views/payments/new.turbo_stream.erb +++ /dev/null @@ -1 +0,0 @@ -<%= turbo_stream.append "payment-form", partial: "payments/form" %> From e8ee56bf22a5a6272faed4b5d521e05beae8826a Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:10:43 -0400 Subject: [PATCH 14/74] add stimulus selection for payer typer --- app/frontend/javascript/controllers/index.js | 3 ++ .../controllers/payer_select_controller.js | 20 ++++++++ app/views/payments/_form.html.erb | 51 +++++++++++++------ 3 files changed, 58 insertions(+), 16 deletions(-) create mode 100644 app/frontend/javascript/controllers/payer_select_controller.js diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 2fdbf1a09..9fdfb34ba 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 PayerSelectController from "./payer_select_controller" +application.register("payer-select", PayerSelectController) + import PrefetchLazyController from "./prefetch_lazy_controller" application.register("prefetch-lazy", PrefetchLazyController) diff --git a/app/frontend/javascript/controllers/payer_select_controller.js b/app/frontend/javascript/controllers/payer_select_controller.js new file mode 100644 index 000000000..cc8dd2be8 --- /dev/null +++ b/app/frontend/javascript/controllers/payer_select_controller.js @@ -0,0 +1,20 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["personField", "organizationField"] + + toggle(event) { + const payerType = event.target.value + + if (payerType === "Person") { + this.personFieldTarget.classList.remove("hidden") + this.organizationFieldTarget.classList.add("hidden") + } else if (payerType === "Organization") { + this.personFieldTarget.classList.add("hidden") + this.organizationFieldTarget.classList.remove("hidden") + } else { + this.personFieldTarget.classList.add("hidden") + this.organizationFieldTarget.classList.add("hidden") + } + } +} diff --git a/app/views/payments/_form.html.erb b/app/views/payments/_form.html.erb index 41de10e36..371b123e1 100644 --- a/app/views/payments/_form.html.erb +++ b/app/views/payments/_form.html.erb @@ -1,23 +1,42 @@ -
+

New <%= @payment.type.underscore.titleize %> Payment

<%= simple_form_for @payment, as: :payment, url: payments_path do |f| %> <%= f.input :type, as: :hidden %>

Type: <%= @payment.type.underscore.titleize %>

-
<%= label_tag :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %><%= select_tag :payer_type, - options_for_select(["Person", "Organization"]), - include_blank: true, - class: "w-full" %>
- <%= 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" %> +
+ <%= label_tag :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :payer_type, + options_for_select(["Person", "Organization"]), + include_blank: true, + class: "w-full", + data: { action: "payer-select#toggle" } %> +
+ + <%= f.input :amount_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> <%= f.input :currency, as: :hidden, input_html: { value: "usd" } %> <% if @payment.type == "CheckPayment" %> From 4388f423e983b8a6923d0ba201c98ecfcd64e3c5 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:30:41 -0400 Subject: [PATCH 15/74] fix toggle of payer type search --- app/controllers/payments_controller.rb | 8 +++--- .../controllers/payer_select_controller.js | 21 +++++++------- app/views/payments/_form.html.erb | 28 +++++++++++-------- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 44961d313..7c04e7420 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -32,7 +32,7 @@ def create end payment_attrs = payment_params.except(:allocatable_sgid) - payment_attrs[:payer_type] = "Person" + payment_attrs[:payer_type] = params[:payment][:payer_type].presence || "Person" payment_attrs[:payer_id] = params[:payment][:payer_id].presence || (allocatable.try(:registrant_id) if allocatable.is_a?(EventRegistration)) @payment = payment_class.new(payment_attrs) @@ -47,8 +47,8 @@ def create end respond_to do |format| - format.turbo_stream { redirect_to allocations_path(allocatable_sgid: params[:payment][:allocatable_sgid]) } - format.html { redirect_to @payment, notice: "Payment was successfully created." } + format.turbo_stream { redirect_to payments_path } + format.html { redirect_to payments_path, notice: "Payment was successfully created." } end else @allocatable = allocatable @@ -81,6 +81,6 @@ def allocation_form private def payment_params - params.require(:payment).permit(:type, :payer_id, :amount_cents, :currency, :check_number, :allocatable_sgid) + params.require(:payment).permit(:type, :payer_type, :payer_id, :amount_cents, :currency, :check_number, :allocatable_sgid) end end diff --git a/app/frontend/javascript/controllers/payer_select_controller.js b/app/frontend/javascript/controllers/payer_select_controller.js index cc8dd2be8..5d00cf3a0 100644 --- a/app/frontend/javascript/controllers/payer_select_controller.js +++ b/app/frontend/javascript/controllers/payer_select_controller.js @@ -1,20 +1,19 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = ["personField", "organizationField"] + static targets = ["person", "organization", "payerContainer"]; toggle(event) { - const payerType = event.target.value - + const payerType = event.target.value; + + this.payerContainerTarget.innerHTML = ""; + if (payerType === "Person") { - this.personFieldTarget.classList.remove("hidden") - this.organizationFieldTarget.classList.add("hidden") + const template = this.personTarget.content.cloneNode(true); + this.payerContainerTarget.appendChild(template); } else if (payerType === "Organization") { - this.personFieldTarget.classList.add("hidden") - this.organizationFieldTarget.classList.remove("hidden") - } else { - this.personFieldTarget.classList.add("hidden") - this.organizationFieldTarget.classList.add("hidden") + const template = this.organizationTarget.content.cloneNode(true); + this.payerContainerTarget.appendChild(template); } } } diff --git a/app/views/payments/_form.html.erb b/app/views/payments/_form.html.erb index 371b123e1..ea62c6b0f 100644 --- a/app/views/payments/_form.html.erb +++ b/app/views/payments/_form.html.erb @@ -1,17 +1,18 @@

New <%= @payment.type.underscore.titleize %> Payment

+ <%= simple_form_for @payment, as: :payment, url: payments_path do |f| %> <%= f.input :type, as: :hidden %> +

Type: <%= @payment.type.underscore.titleize %>

-
- <%= label_tag :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :payer_type, + +
<%= label_tag :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %><%= select_tag :payer_type, options_for_select(["Person", "Organization"]), include_blank: true, class: "w-full", - data: { action: "payer-select#toggle" } %> -
- + + + + + +
<%= f.input :amount_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> <%= 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 %>
From 1386148f1c9c0a79e139fd0c53a605c8b711ce98 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:45:20 -0400 Subject: [PATCH 16/74] fix form style --- app/views/payments/_form.html.erb | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/views/payments/_form.html.erb b/app/views/payments/_form.html.erb index ea62c6b0f..329596480 100644 --- a/app/views/payments/_form.html.erb +++ b/app/views/payments/_form.html.erb @@ -1,16 +1,19 @@
-

New <%= @payment.type.underscore.titleize %> Payment

+

New <%= @payment.type.underscore.titleize %>

<%= simple_form_for @payment, as: :payment, url: payments_path do |f| %> <%= f.input :type, as: :hidden %> -

Type: <%= @payment.type.underscore.titleize %>

+
+ <%= f.label :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %> -
<%= label_tag :payer_type, "Payer Type", class: "block text-sm font-medium text-gray-700 mb-1" %><%= select_tag :payer_type, - options_for_select(["Person", "Organization"]), - include_blank: true, - class: "w-full", - data: { action: "payer-select#toggle" } %>
+ <%= 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: "payer-select#toggle" } %> +
+ label: false %>
<%= f.input :amount_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> From dc9620f7e4ef3de5a2bcd4f6886a12dcddcba27b Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:49:32 -0400 Subject: [PATCH 17/74] add dollars to UI --- app/controllers/payments_controller.rb | 2 +- app/models/payment.rb | 10 ++++++++++ app/views/payments/_form.html.erb | 10 +--------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 7c04e7420..53f8ec789 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -81,6 +81,6 @@ def allocation_form private def payment_params - params.require(:payment).permit(:type, :payer_type, :payer_id, :amount_cents, :currency, :check_number, :allocatable_sgid) + params.require(:payment).permit(:type, :payer_type, :payer_id, :amount_dollars, :amount_cents, :currency, :check_number, :allocatable_sgid) end end diff --git a/app/models/payment.rb b/app/models/payment.rb index 3f353d56d..2a08dfb6e 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -1,8 +1,18 @@ class Payment < ApplicationRecord + attr_accessor :amount_dollars + has_many :allocations, as: :source has_many :refunds, as: :refundable belongs_to :payer, polymorphic: true validates :amount_cents, numericality: true validates :currency, presence: 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/views/payments/_form.html.erb b/app/views/payments/_form.html.erb index 329596480..9c8995647 100644 --- a/app/views/payments/_form.html.erb +++ b/app/views/payments/_form.html.erb @@ -1,12 +1,9 @@

New <%= @payment.type.underscore.titleize %>

- <%= 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 }, @@ -14,7 +11,6 @@ focus:ring-blue-500 focus:border-blue-500", data: { action: "payer-select#toggle" } %>
- - -
- <%= f.input :amount_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> + <%= f.input :amount_dollars, label: "Amount ($)", input_html: { min: 0, step: 0.01, placeholder: "$0.00" } %> <%= 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 %>
From 7bf4311d50631944a40f24a1b67336dab572ce75 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:59:00 -0400 Subject: [PATCH 18/74] add dollars to allocations and add payment index pagination --- app/controllers/payments_controller.rb | 10 +++------- app/models/allocation.rb | 10 ++++++++++ app/views/allocations/index.html.erb | 6 +----- app/views/payments/_allocation_form.html.erb | 2 +- app/views/payments/index.html.erb | 3 ++- app/views/payments/show.html.erb | 2 +- 6 files changed, 18 insertions(+), 15 deletions(-) diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 53f8ec789..818b75a61 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -1,7 +1,8 @@ class PaymentsController < ApplicationController def index authorize! - @payments = Payment.all + per_page = params[:number_of_items_per_page].presence || 10 + @payments = Payment.order(created_at: :desc).paginate(page: params[:page], per_page: per_page) end def new @@ -15,11 +16,6 @@ def new @payment.payer = @allocatable.registrant end end - - # respond_to do |format| - # format.html - # format.turbo_stream - # end end def create @@ -81,6 +77,6 @@ def allocation_form private def payment_params - params.require(:payment).permit(:type, :payer_type, :payer_id, :amount_dollars, :amount_cents, :currency, :check_number, :allocatable_sgid) + params.require(:payment).permit(:type, :payer_type, :payer_id, :amount_dollars, :currency, :check_number, :allocatable_sgid) end end diff --git a/app/models/allocation.rb b/app/models/allocation.rb index dcae79f8d..397b185e1 100644 --- a/app/models/allocation.rb +++ b/app/models/allocation.rb @@ -1,5 +1,15 @@ class Allocation < ApplicationRecord + attr_accessor :amount_dollars + belongs_to :source, polymorphic: true belongs_to :allocatable, polymorphic: true validates :amount, numericality: true + + 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 end diff --git a/app/views/allocations/index.html.erb b/app/views/allocations/index.html.erb index 7bf9fb38d..ee5788963 100644 --- a/app/views/allocations/index.html.erb +++ b/app/views/allocations/index.html.erb @@ -17,19 +17,16 @@

All Allocations

<% end %>
- <% 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 %> - <% if @allocations.any? %>
Actions
@@ -40,12 +37,11 @@ - <% @allocations.each do |allocation| %> - + <% end %> diff --git a/app/views/payments/_allocation_form.html.erb b/app/views/payments/_allocation_form.html.erb index e7ec8a068..23bfe21fa 100644 --- a/app/views/payments/_allocation_form.html.erb +++ b/app/views/payments/_allocation_form.html.erb @@ -14,7 +14,7 @@ }, prompt: "Search for a payer", label: "Payer" %> - <%= f.input :amount_cents, label: "Amount (cents)", input_html: { min: 0, step: 1 } %> + <%= f.input :amount_dollars, label: "Amount ($)", input_html: { min: 0, step: 0.01, placeholder: "$0.00" } %> <%= f.input :currency, as: :hidden, input_html: { value: "usd" } %> <% if @payment.type == "CheckPayment" %> <%= f.input :check_number %> diff --git a/app/views/payments/index.html.erb b/app/views/payments/index.html.erb index 26f913820..417b6aac3 100644 --- a/app/views/payments/index.html.erb +++ b/app/views/payments/index.html.erb @@ -23,7 +23,7 @@ - + @@ -32,4 +32,5 @@
Date
<%= allocation.source.class.name.underscore.titleize %> - <%= allocation.source&.payer.name || "Unknown" %>$<%= "%.2f" % (allocation.amount.to_d / 100) %>$<%= "%.2f" % allocation.amount_dollars %> <%= allocation.created_at.in_time_zone.strftime("%B %d, %Y at %l:%M %p") %>
<%= payment.type %> <%= payment.payer.name %>$<%= "%.2f" % (payment.amount_cents / 100.0) %>$<%= "%.2f" % payment.amount_dollars %> <%= payment.check_number || "—" %> <%= payment.created_at.strftime("%B %d, %Y") %> <%= link_to "View", payment_path(payment), class: "text-primary hover:text-primary-dark" %>
+
diff --git a/app/views/payments/show.html.erb b/app/views/payments/show.html.erb index 9a0ec019f..287565048 100644 --- a/app/views/payments/show.html.erb +++ b/app/views/payments/show.html.erb @@ -14,7 +14,7 @@
Amount
-
$<%= "%.2f" % (@payment.amount_cents / 100.0) %>
+
$<%= "%.2f" % @payment.amount_dollars %>
Currency
From c733ac6385434c0616246f8411641e28e5a56014 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:14:02 -0400 Subject: [PATCH 19/74] titlize payment type --- app/controllers/payments_controller.rb | 2 +- app/views/payments/index.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 818b75a61..edf72f5fd 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -54,7 +54,7 @@ def create def show @payment = Payment.find(params[:id]) - authorize! @payment + authorize! @payment, with: PaymentPolicy end def allocation_form diff --git a/app/views/payments/index.html.erb b/app/views/payments/index.html.erb index 417b6aac3..953dd5e0c 100644 --- a/app/views/payments/index.html.erb +++ b/app/views/payments/index.html.erb @@ -21,7 +21,7 @@ <% @payments.each do |payment| %> - <%= payment.type %> + <%= payment.type.underscore.titleize %> <%= payment.payer.name %> $<%= "%.2f" % payment.amount_dollars %> <%= payment.check_number || "—" %> From 6aeb0b5fb5d033106e262a714aa89ca3adaa1fef Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:24:57 -0400 Subject: [PATCH 20/74] display amount input with decimal --- .../controllers/currency_input_controller.js | 18 ++++++++++++++++++ app/frontend/javascript/controllers/index.js | 3 +++ app/views/payments/_allocation_form.html.erb | 2 +- app/views/payments/_form.html.erb | 2 +- 4 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 app/frontend/javascript/controllers/currency_input_controller.js 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 9fdfb34ba..3cdea377c 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -114,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/views/payments/_allocation_form.html.erb b/app/views/payments/_allocation_form.html.erb index 23bfe21fa..90ac8ad67 100644 --- a/app/views/payments/_allocation_form.html.erb +++ b/app/views/payments/_allocation_form.html.erb @@ -14,7 +14,7 @@ }, prompt: "Search for a payer", label: "Payer" %> - <%= f.input :amount_dollars, label: "Amount ($)", input_html: { min: 0, step: 0.01, placeholder: "$0.00" } %> + <%= 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 %> diff --git a/app/views/payments/_form.html.erb b/app/views/payments/_form.html.erb index 9c8995647..acb331da2 100644 --- a/app/views/payments/_form.html.erb +++ b/app/views/payments/_form.html.erb @@ -36,7 +36,7 @@ prompt: "Search for an organization", label: false %>
- <%= f.input :amount_dollars, label: "Amount ($)", input_html: { min: 0, step: 0.01, placeholder: "$0.00" } %> + <%= 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 %> From 1e11b71c6b325b98df90d5377188c87ab889c0cc Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:51:43 -0400 Subject: [PATCH 21/74] add allocated amount to payment --- app/controllers/payments_controller.rb | 21 ++++++++++++++----- app/models/payment.rb | 18 +++++++++++++++- app/views/payments/index.html.erb | 12 +++++------ app/views/payments/show.html.erb | 13 +++++++++--- ...183730_add_allocated_amount_to_payments.rb | 5 +++++ db/schema.rb | 3 ++- 6 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 db/migrate/20260406183730_add_allocated_amount_to_payments.rb diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index edf72f5fd..2873b0962 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -35,11 +35,22 @@ def create if @payment.save if allocatable.present? - Allocation.create!( - source: @payment, - allocatable: allocatable, - amount: @payment.amount_cents - ) + Allocation.transaction do + @payment.with_lock do + new_allocation_amount = @payment.amount_cents + current_allocated = @payment.allocations.sum(:amount) + + if current_allocated + new_allocation_amount > @payment.amount_cents + raise ActiveRecord::Rollback, "Cannot allocate more than payment amount" + end + + Allocation.create!( + source: @payment, + allocatable: allocatable, + amount: @payment.amount_cents + ) + end + end end respond_to do |format| diff --git a/app/models/payment.rb b/app/models/payment.rb index 2a08dfb6e..cc9924ddd 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -1,5 +1,5 @@ class Payment < ApplicationRecord - attr_accessor :amount_dollars + attr_accessor :amount_dollars, :allocated_dollars has_many :allocations, as: :source has_many :refunds, as: :refundable @@ -15,4 +15,20 @@ def amount_dollars def amount_dollars=(value) self.amount_cents = (value.to_d * 100).to_i if value.present? end + + def allocated_dollars + allocated_amount_cents.to_d / 100 if allocated_amount_cents + end + + def allocated_dollars=(value) + self.allocated_amount_cents = (value.to_d * 100).to_i if value.present? + end + + def unallocated_amount_cents + amount_cents - (allocated_amount_cents || 0) + end + + def unallocated_dollars + unallocated_amount_cents.to_d / 100 + end end diff --git a/app/views/payments/index.html.erb b/app/views/payments/index.html.erb index 953dd5e0c..2dc0a5f30 100644 --- a/app/views/payments/index.html.erb +++ b/app/views/payments/index.html.erb @@ -1,11 +1,9 @@

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" %> -
+
<%= 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" %>
+
@@ -13,18 +11,19 @@ - + + <% @payments.each do |payment| %> - + @@ -32,5 +31,6 @@
Type Payer AmountCheck #Remaining Created Actions
<%= payment.type.underscore.titleize %> <%= payment.payer.name %> $<%= "%.2f" % payment.amount_dollars %><%= payment.check_number || "—" %>$<%= "%.2f" % payment.unallocated_dollars %> <%= payment.created_at.strftime("%B %d, %Y") %> <%= link_to "View", payment_path(payment), class: "text-primary hover:text-primary-dark" %>
+
diff --git a/app/views/payments/show.html.erb b/app/views/payments/show.html.erb index 287565048..a7adef9ed 100644 --- a/app/views/payments/show.html.erb +++ b/app/views/payments/show.html.erb @@ -3,35 +3,42 @@

Payment

<%= link_to "Back", payments_path, class: "btn btn-secondary" %>
+
Type
<%= @payment.type %>
+
Payer
-
<%= @payment.payer %>
+
<%= @payment.payer.name %>
+
Amount
$<%= "%.2f" % @payment.amount_dollars %>
+
-
Currency
-
<%= @payment.currency %>
+
Amount available to allocate
+
$<%= "%.2f" % @payment.unallocated_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") %>
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/schema.rb b/db/schema.rb index ea5c7b179..166d61d7a 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_29_180236) do +ActiveRecord::Schema[8.1].define(version: 2026_04_06_183730) 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 @@ -786,6 +786,7 @@ end create_table "payments", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.integer "allocated_amount_cents", default: 0, null: false t.integer "amount_cents", null: false t.string "check_number" t.datetime "created_at", null: false From 191f1305418fd927e3849426f06388be696da1a8 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:31:28 -0400 Subject: [PATCH 22/74] handle allocation payment transaction --- app/controllers/payments_controller.rb | 99 ++++++++++++++++++-------- app/views/payments/show.html.erb | 38 ++++++++-- 2 files changed, 100 insertions(+), 37 deletions(-) diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 2873b0962..969a55cb0 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -22,43 +22,30 @@ def create authorize! payment_class = params[:payment][:type].presence&.safe_constantize || CashPayment - allocatable = nil - if params[:payment][:allocatable_sgid].present? - allocatable = GlobalID::Locator.locate_signed(params[:payment][:allocatable_sgid]) - end - - payment_attrs = payment_params.except(:allocatable_sgid) - payment_attrs[:payer_type] = params[:payment][:payer_type].presence || "Person" - payment_attrs[:payer_id] = params[:payment][:payer_id].presence || (allocatable.try(:registrant_id) if allocatable.is_a?(EventRegistration)) + allocatable = locate_allocatable - @payment = payment_class.new(payment_attrs) - - if @payment.save + begin if allocatable.present? - Allocation.transaction do - @payment.with_lock do - new_allocation_amount = @payment.amount_cents - current_allocated = @payment.allocations.sum(:amount) - - if current_allocated + new_allocation_amount > @payment.amount_cents - raise ActiveRecord::Rollback, "Cannot allocate more than payment amount" - end - - Allocation.create!( - source: @payment, - allocatable: allocatable, - amount: @payment.amount_cents - ) - end - end + payment_attrs = build_payment_attributes(payment_params, allocatable) + @payment = payment_class.new(payment_attrs) + + process_allocation!(@payment, allocatable) + 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 payments_path } - format.html { redirect_to payments_path, notice: "Payment was successfully created." } + format.turbo_stream { redirect_to redirect_path } + format.html { redirect_to redirect_path, notice: "Payment was successfully created." } end - else + + rescue ActiveRecord::RecordInvalid => e @allocatable = allocatable + @payment ||= payment_class.new(payment_attrs || {}) + @payment.errors.add(:base, e.message) render :new, status: :unprocessable_content end end @@ -90,4 +77,56 @@ def allocation_form 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!(allocated_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/views/payments/show.html.erb b/app/views/payments/show.html.erb index a7adef9ed..927d5619e 100644 --- a/app/views/payments/show.html.erb +++ b/app/views/payments/show.html.erb @@ -3,45 +3,69 @@

Payment

<%= link_to "Back", payments_path, class: "btn btn-secondary" %>
-
Type
<%= @payment.type %>
-
Payer
<%= @payment.payer.name %>
-
Amount
$<%= "%.2f" % @payment.amount_dollars %>
-
Amount available to allocate
$<%= "%.2f" % @payment.unallocated_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 @payment.allocations.any? %> +
+

Allocations

+
+ + + + + + + + + + <% @payment.allocations.each do |allocation| %> + + + + + + <% end %> + +
Allocated ToAmountDate
+ <% 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") %>
+
+
+ <% end %> From 260dc5d81c8fe886b6bdf9acac6d740125112526 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:06:56 -0400 Subject: [PATCH 23/74] allocate payments manually --- app/controllers/allocations_controller.rb | 67 +++++++++++++++++++++++ app/views/allocations/new.html.erb | 36 ++++++++++++ app/views/payments/show.html.erb | 12 +++- 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 app/views/allocations/new.html.erb diff --git a/app/controllers/allocations_controller.rb b/app/controllers/allocations_controller.rb index 601a9bd36..3a632e61c 100644 --- a/app/controllers/allocations_controller.rb +++ b/app/controllers/allocations_controller.rb @@ -10,4 +10,71 @@ def index end authorize! @allocations end + + def new + authorize! + + if params[:source_sgid].present? + @source = GlobalID::Locator.locate_signed(params[:source_sgid]) + end + + @allocation = Allocation.new(source: @source) + @event_registrations = EventRegistration.all.order(created_at: :desc).limit(100) + 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 + ) + + # Locate the source + if @allocation.source_type && @allocation.source_id + @source = @allocation.source_type.constantize.find_by(id: @allocation.source_id) + end + + @event_registrations = EventRegistration.all.order(created_at: :desc).limit(100) + + # Ensure we have a valid source before proceeding + unless @source.present? + @allocation.errors.add(:base, "Source is required") + render :new, status: :unprocessable_content + return + end + + if @source.is_a?(Payment) + remaining = @source.unallocated_amount_cents + if @allocation.amount > remaining + @allocation.errors.add(:amount, "cannot exceed remaining unallocated amount (#{remaining})") + render :new, status: :unprocessable_content + return + end + end + + if @allocation.save + if @source.is_a?(Payment) + @source.with_lock do + current = @source.allocations.sum(:amount) + @source.update!(allocated_amount_cents: current) + end + end + redirect_to payment_path(@source), notice: "Allocation created" + else + Rails.logger.error "Allocation save failed: #{@allocation.errors.full_messages}" + render :new, status: :unprocessable_content + end + end + + private + + def allocation_params + params.require(:allocation).permit(:source_type, :source_id, :allocatable_type, :allocatable_id, :amount_dollars) + end end diff --git a/app/views/allocations/new.html.erb b/app/views/allocations/new.html.erb new file mode 100644 index 000000000..c36424fba --- /dev/null +++ b/app/views/allocations/new.html.erb @@ -0,0 +1,36 @@ +
+

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.unallocated_dollars %>

+
+ <% else %> +
+

Error: No payment source found

+
+ <% end %> +
+ <%= label_tag "allocation[allocatable_id]", "Allocate To", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag "allocation[allocatable_id]", + options_from_collection_for_select(@event_registrations, :id, :id), + include_blank: "Select Event Registration", + class: "w-full" %> + <%= hidden_field_tag "allocation[allocatable_type]", "EventRegistration" %> +
+ <%= 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/payments/show.html.erb b/app/views/payments/show.html.erb index 927d5619e..748349af6 100644 --- a/app/views/payments/show.html.erb +++ b/app/views/payments/show.html.erb @@ -1,7 +1,12 @@

Payment

- <%= link_to "Back", payments_path, class: "btn btn-secondary" %> +
+ <% if @payment.unallocated_amount_cents > 0 %> + <%= link_to "Allocate", new_allocation_path(source_sgid: @payment.to_sgid.to_s), class: "btn btn-primary" %> + <% end %> + <%= link_to "Back", payments_path, class: "btn btn-secondary" %> +
@@ -37,7 +42,8 @@
<%= @payment.created_at.strftime("%B %d, %Y at %I:%M %p") %>
- <% if @payment.allocations.any? %> + <% allocations = @payment.allocations.to_a %> + <% if allocations.any? %>

Allocations

@@ -50,7 +56,7 @@ - <% @payment.allocations.each do |allocation| %> + <% allocations.each do |allocation| %> <% if allocation.allocatable_type == "EventRegistration" %> From 287eaf2f0890469a915d0bb5ec77d3ce7a77fc5a Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:33:14 -0400 Subject: [PATCH 24/74] add refunds to payments --- app/controllers/refunds_controller.rb | 47 ++++++++++++++++ app/frontend/javascript/controllers/index.js | 4 +- .../controllers/payer_select_controller.js | 19 ------- .../search_type_select_controller.js | 19 +++++++ app/models/refund.rb | 10 ++++ app/policies/refund_policy.rb | 2 + app/views/payments/_form.html.erb | 10 ++-- app/views/payments/show.html.erb | 1 + app/views/refunds/new.html.erb | 55 +++++++++++++++++++ config/routes.rb | 1 + 10 files changed, 142 insertions(+), 26 deletions(-) create mode 100644 app/controllers/refunds_controller.rb delete mode 100644 app/frontend/javascript/controllers/payer_select_controller.js create mode 100644 app/frontend/javascript/controllers/search_type_select_controller.js create mode 100644 app/policies/refund_policy.rb create mode 100644 app/views/refunds/new.html.erb diff --git a/app/controllers/refunds_controller.rb b/app/controllers/refunds_controller.rb new file mode 100644 index 000000000..0ada06093 --- /dev/null +++ b/app/controllers/refunds_controller.rb @@ -0,0 +1,47 @@ +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.unallocated_amount_cents + + if amount_val > remaining + flash[:error] = "Refund cannot exceed unallocated 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 + ) + + if @refund.save + @payment.with_lock do + @payment.update!(allocated_amount_cents: @payment.allocated_amount_cents + amount_val) + end + redirect_to payment_path(@payment), notice: "Refund created" + else + render :new, status: :unprocessable_content + end + end +end diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 3cdea377c..9ce5c16bc 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -57,8 +57,8 @@ application.register("paginated-fields", PaginatedFieldsController) import PasswordToggleController from "./password_toggle_controller" application.register("password-toggle", PasswordToggleController) -import PayerSelectController from "./payer_select_controller" -application.register("payer-select", PayerSelectController) +import SearchTypeSelectController from "./search_type_select_controller" +application.register("search-type-select", SearchTypeSelectController) import PrefetchLazyController from "./prefetch_lazy_controller" application.register("prefetch-lazy", PrefetchLazyController) diff --git a/app/frontend/javascript/controllers/payer_select_controller.js b/app/frontend/javascript/controllers/payer_select_controller.js deleted file mode 100644 index 5d00cf3a0..000000000 --- a/app/frontend/javascript/controllers/payer_select_controller.js +++ /dev/null @@ -1,19 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -export default class extends Controller { - static targets = ["person", "organization", "payerContainer"]; - - toggle(event) { - const payerType = event.target.value; - - this.payerContainerTarget.innerHTML = ""; - - if (payerType === "Person") { - const template = this.personTarget.content.cloneNode(true); - this.payerContainerTarget.appendChild(template); - } else if (payerType === "Organization") { - const template = this.organizationTarget.content.cloneNode(true); - this.payerContainerTarget.appendChild(template); - } - } -} 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..17d60a4af --- /dev/null +++ b/app/frontend/javascript/controllers/search_type_select_controller.js @@ -0,0 +1,19 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["person", "organization", "pickerContainer"]; + + toggle(event) { + const selectedType = event.target.value; + + this.pickerContainerTarget.innerHTML = ""; + + if (selectedType === "Person") { + const template = this.personTarget.content.cloneNode(true); + this.pickerContainerTarget.appendChild(template); + } else if (selectedType === "Organization") { + const template = this.organizationTarget.content.cloneNode(true); + this.pickerContainerTarget.appendChild(template); + } + } +} diff --git a/app/models/refund.rb b/app/models/refund.rb index e37a3ac25..61d6dd5c3 100644 --- a/app/models/refund.rb +++ b/app/models/refund.rb @@ -1,7 +1,17 @@ class Refund < ApplicationRecord + attr_accessor :amount_dollars + belongs_to :refundable, polymorphic: true belongs_to :recipient, polymorphic: true has_many :allocations, as: :source validates :amount_cents, numericality: 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/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/payments/_form.html.erb b/app/views/payments/_form.html.erb index acb331da2..0bb91d4fb 100644 --- a/app/views/payments/_form.html.erb +++ b/app/views/payments/_form.html.erb @@ -1,4 +1,4 @@ -
+

New <%= @payment.type.underscore.titleize %>

<%= simple_form_for @payment, as: :payment, url: payments_path do |f| %> <%= f.input :type, as: :hidden %> @@ -9,9 +9,9 @@ { 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: "payer-select#toggle" } %> + data: { action: "search-type-select#toggle" } %>
-