From ba5725789c3419b17d015558d35da7d8d853ce0b Mon Sep 17 00:00:00 2001 From: Jim Benton Date: Sun, 10 Aug 2025 08:22:48 -0500 Subject: [PATCH 1/9] Add gem --- Gemfile | 1 + Gemfile.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Gemfile b/Gemfile index 32d81a9ac..8a8723585 100644 --- a/Gemfile +++ b/Gemfile @@ -47,6 +47,7 @@ gem "square.rb", "43.0.1.20250716" gem "aws-sdk-s3", require: false gem "omniauth-google-oauth2" gem "omniauth-rails_csrf_protection" +gem "stripe" # Calendar syncing gem "googleauth" diff --git a/Gemfile.lock b/Gemfile.lock index f625a8c8b..a991fda4e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -571,6 +571,7 @@ GEM store_model (4.5.0) activerecord (>= 7.0) stringio (3.2.0) + stripe (15.4.0) sync (0.5.0) text (1.3.1) thor (1.5.0) @@ -674,6 +675,7 @@ DEPENDENCIES standard-rails stimulus-rails store_model + stripe timecop translation turbo-rails From 8e11986f234fb41b4a6054665d91bc78d7bb54bd Mon Sep 17 00:00:00 2001 From: Jim Benton Date: Fri, 29 Aug 2025 18:26:22 -0500 Subject: [PATCH 2/9] wip --- .env | 3 + .../organization_payments_controller.rb | 34 ++++++++++ app/lib/stripe_checkout.rb | 66 +++++++++++++++++++ .../organization_payments/new.html.erb | 2 + config/initializers/stripe.rb | 4 ++ config/routes.rb | 5 +- 6 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 app/controllers/account/organization_payments_controller.rb create mode 100644 app/lib/stripe_checkout.rb create mode 100644 app/views/account/organization_payments/new.html.erb create mode 100644 config/initializers/stripe.rb diff --git a/.env b/.env index 2087947da..21907f9eb 100644 --- a/.env +++ b/.env @@ -13,6 +13,9 @@ SQUARE_ACCESS_TOKEN=SQACCESSTOKEN1234 SQUARE_LOCATION_ID=SQLOCID1234 SQUARE_ENVIRONMENT=sandbox +# Stripe API is also used +STRIPE_API_KEY=STRIPEAPIKEY1234 + ## Google Calendar (see README for details) # # Path to Google service account credentials (used in prod) diff --git a/app/controllers/account/organization_payments_controller.rb b/app/controllers/account/organization_payments_controller.rb new file mode 100644 index 000000000..ec32f6c36 --- /dev/null +++ b/app/controllers/account/organization_payments_controller.rb @@ -0,0 +1,34 @@ +module Account + class OrganizationPaymentsController < BaseController + skip_before_action :callback + + def new + end + + def create + result = checkout.checkout_url(amount: Money.new(500), email: "test@esample.com", return_to: callback_account_organization_payments_url) + if result.success? + redirect_to result.value, status: :see_other, allow_other_host: true + else + errors = result.error + Rails.logger.error(errors) + flash[:error] = "There was a problem connecting to our payment processor." + redirect_to new_account_organization_payment_url, status: :see_other + end + end + + def callback + checkout.fetch_session(params[:session_id]) + end + + private + + # def form_params + # params.require(:membership_payment_form).permit(:amount_dollars) + # end + + def checkout + StripeCheckout.new(ENV.fetch("STRIPE_API_KEY"), environment: Rails.env) + end + end +end diff --git a/app/lib/stripe_checkout.rb b/app/lib/stripe_checkout.rb new file mode 100644 index 000000000..beeb66db5 --- /dev/null +++ b/app/lib/stripe_checkout.rb @@ -0,0 +1,66 @@ +class StripeCheckout + def initialize(api_key, environment: "production", now: Time.current) + @client = Stripe::StripeClient.new(api_key) + @now = now + end + + # TODO add user_id:, organization_id:, + def checkout_url(amount:, email:, return_to:) + session = @client.v1.checkout.sessions.create({ + customer_email: email, + line_items: [{ + # TODO set the org levels as products in stripe UI and pass one of those here + price_data: { + currency: "USD", + unit_amount: amount.cents, + product_data: { + name: "Annual Membership" + } + }, + quantity: 1 + }], + + mode: "payment", + currency: "USD", + customer_creation: "always", + + # Allow users to have their payment info prefilled for future purposes + saved_payment_method_options: {payment_method_save: "enabled"}, + + # Save payment info for circulate-created future charges + payment_intent_data: {setup_future_usage: "off_session"}, + + success_url: return_to + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url: return_to + }) + + Result.success(session.url) + rescue Stripe::InvalidRequestError => e + Result.failure(e) + end + + def fetch_session(session_id:) + session = @client.v1.checkout.sessions.retrieve(params[:session_id]) + customer = @client.v1.customers.retrieve(session.customer) + puts session + puts customer + binding.irb + + order = order_response.body.order + amount_money = order[:tenders][0][:amount_money] + + raise "non-USD currency is not supported" unless amount_money[:currency] == "USD" + + amount = Money.new(amount_money[:amount]) + + Result.success(amount) + rescue Stripe::InvalidRequestError => e + Result.failure(e) + end + + # private + + # def random_idempotency_key + # rand(1_000_000_000).to_s + # end +end diff --git a/app/views/account/organization_payments/new.html.erb b/app/views/account/organization_payments/new.html.erb new file mode 100644 index 000000000..d63d01f26 --- /dev/null +++ b/app/views/account/organization_payments/new.html.erb @@ -0,0 +1,2 @@ +

Test

+<%= button_to "Create Payment", account_organization_payments_path, method: "POST" %> \ No newline at end of file diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb new file mode 100644 index 000000000..a1cd178bf --- /dev/null +++ b/config/initializers/stripe.rb @@ -0,0 +1,4 @@ +require "stripe" + +Stripe.max_network_retries = 2 +Stripe.log_level = Stripe::LEVEL_INFO diff --git a/config/routes.rb b/config/routes.rb index b599a3256..f7f3c4f80 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,7 +39,7 @@ resources :for_later_list_items, path: :for_later, only: [:index, :create, :destroy] get "/", to: "home#index", as: "home" - if ENV["FEATURE_GROUP_LENDING"] == "on" + if FeatureFlags.group_lending_enabled? resources :reservations do scope module: "reservations" do resources :reservation_holds @@ -48,6 +48,9 @@ end end resources :item_pools + resources :organization_payments, only: [:new, :create] do + get :callback, on: :collection + end end end From 56efeb85435745cdb9e63629d31e71d055cb2bb4 Mon Sep 17 00:00:00 2001 From: Jim Benton Date: Wed, 3 Sep 2025 19:47:02 -0500 Subject: [PATCH 3/9] wip --- .env | 4 +- .../organization_payments_controller.rb | 7 ++-- app/controllers/stripe_controller.rb | 41 +++++++++++++++++++ app/lib/stripe_checkout.rb | 12 ++---- .../organization_payments/callback.html.erb | 2 + .../organization_payments/new.html.erb | 2 +- config/environments/development.rb | 1 + config/routes.rb | 1 + 8 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 app/controllers/stripe_controller.rb create mode 100644 app/views/account/organization_payments/callback.html.erb diff --git a/.env b/.env index 21907f9eb..02362aac7 100644 --- a/.env +++ b/.env @@ -13,8 +13,10 @@ SQUARE_ACCESS_TOKEN=SQACCESSTOKEN1234 SQUARE_LOCATION_ID=SQLOCID1234 SQUARE_ENVIRONMENT=sandbox -# Stripe API is also used +# Stripe API key STRIPE_API_KEY=STRIPEAPIKEY1234 +# Secret value used to sign webhook payloads +STRIPE_WEBHOOK_SIGNING_SECRET= ## Google Calendar (see README for details) # diff --git a/app/controllers/account/organization_payments_controller.rb b/app/controllers/account/organization_payments_controller.rb index ec32f6c36..6ac36f864 100644 --- a/app/controllers/account/organization_payments_controller.rb +++ b/app/controllers/account/organization_payments_controller.rb @@ -1,7 +1,5 @@ module Account class OrganizationPaymentsController < BaseController - skip_before_action :callback - def new end @@ -18,7 +16,10 @@ def create end def callback - checkout.fetch_session(params[:session_id]) + @result = checkout.fetch_session(session_id: params[:session_id]) + end + + def index end private diff --git a/app/controllers/stripe_controller.rb b/app/controllers/stripe_controller.rb new file mode 100644 index 000000000..88a6f3015 --- /dev/null +++ b/app/controllers/stripe_controller.rb @@ -0,0 +1,41 @@ +class StripeController < ApplicationController + skip_before_action :verify_authenticity_token + + def webhook + payload = request.body.read + event = nil + + begin + event = Stripe::Event.construct_from( + JSON.parse(payload, symbolize_names: true) + ) + rescue JSON::ParserError => e + # Invalid payload + render status: 400 + return + end + + # Retrieve the event by verifying the signature using the raw body and the endpoint secret + signing_secret = ENV["STRIPE_WEBHOOK_SIGNING_SECRET"] + signature = request.env["HTTP_STRIPE_SIGNATURE"] + begin + event = Stripe::Webhook.construct_event( + payload, signature, signing_secret + ) + rescue Stripe::SignatureVerificationError => e + Rails.logger.warn "⚠️ Webhook signature verification failed. #{e.message}" + render status: 400 + return + end + + # Handle the event + case event.type + when "checkout.session.completed" + session = event.data.object # contains a Stripe::PaymentIntent + puts session + else + puts "Unhandled event type: #{event.type}" + end + render json: {message: :success} + end +end diff --git a/app/lib/stripe_checkout.rb b/app/lib/stripe_checkout.rb index beeb66db5..737ba8bd5 100644 --- a/app/lib/stripe_checkout.rb +++ b/app/lib/stripe_checkout.rb @@ -40,18 +40,12 @@ def checkout_url(amount:, email:, return_to:) end def fetch_session(session_id:) - session = @client.v1.checkout.sessions.retrieve(params[:session_id]) + session = @client.v1.checkout.sessions.retrieve(session_id) customer = @client.v1.customers.retrieve(session.customer) - puts session - puts customer - binding.irb - order = order_response.body.order - amount_money = order[:tenders][0][:amount_money] + raise "non-USD currency is not supported" unless session.currency == "usd" - raise "non-USD currency is not supported" unless amount_money[:currency] == "USD" - - amount = Money.new(amount_money[:amount]) + amount = Money.new(session.amount_total) Result.success(amount) rescue Stripe::InvalidRequestError => e diff --git a/app/views/account/organization_payments/callback.html.erb b/app/views/account/organization_payments/callback.html.erb new file mode 100644 index 000000000..115990249 --- /dev/null +++ b/app/views/account/organization_payments/callback.html.erb @@ -0,0 +1,2 @@ +

Payment complete

+<%= @result.value.format %> \ No newline at end of file diff --git a/app/views/account/organization_payments/new.html.erb b/app/views/account/organization_payments/new.html.erb index d63d01f26..c0ec56bc1 100644 --- a/app/views/account/organization_payments/new.html.erb +++ b/app/views/account/organization_payments/new.html.erb @@ -1,2 +1,2 @@

Test

-<%= button_to "Create Payment", account_organization_payments_path, method: "POST" %> \ No newline at end of file +<%= button_to "Create Payment", account_organization_payments_path, method: "POST", form: { data: {turbo: false}} %> \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index dd9a79007..a142b6453 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -70,6 +70,7 @@ config.file_watcher = ActiveSupport::EventedFileUpdateChecker config.hosts << ".circulate.local" + config.hosts << "bd157cebdf96.ngrok-free.app" if ENV.fetch("DOCKER", "") == "true" Socket.ip_address_list.each do |addrinfo| diff --git a/config/routes.rb b/config/routes.rb index f7f3c4f80..98434f638 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -239,6 +239,7 @@ resources :documents, only: :show post "/twilio/callback", to: "twilio#callback" + post "/stripe/webhook", to: "stripe#webhook" # Mount dashboards for admins authenticate :user, ->(user) { user.admin? } do From 41d21ebe667f7be9626725d0a8a8fa8c985b300c Mon Sep 17 00:00:00 2001 From: Jim Benton Date: Tue, 30 Sep 2025 20:08:44 -0500 Subject: [PATCH 4/9] Sketch out integration --- .env | 2 + .../organization_payments_controller.rb | 35 -------- .../account/payment_methods_controller.rb | 32 ++++++++ app/javascript/controllers/index.js | 47 ++++++----- .../controllers/stripe_controller.js | 59 ++++++++++++++ app/lib/feature_flags.rb | 4 + app/lib/stripe_checkout.rb | 81 +++++++++---------- app/models/payment_method.rb | 15 ++++ app/models/user.rb | 3 + .../organization_payments/callback.html.erb | 2 - .../organization_payments/new.html.erb | 2 - .../account/payment_methods/index.html.erb | 32 ++++++++ .../account/payment_methods/new.html.erb | 30 +++++++ config/routes.rb | 4 +- ...4011345_add_stripe_customer_id_to_users.rb | 6 ++ .../20250918011950_create_payment_methods.rb | 17 ++++ db/schema.rb | 15 ++++ test/factories/payment_methods.rb | 5 ++ test/models/payment_method_test.rb | 12 +++ 19 files changed, 298 insertions(+), 105 deletions(-) delete mode 100644 app/controllers/account/organization_payments_controller.rb create mode 100644 app/controllers/account/payment_methods_controller.rb create mode 100644 app/javascript/controllers/stripe_controller.js create mode 100644 app/models/payment_method.rb delete mode 100644 app/views/account/organization_payments/callback.html.erb delete mode 100644 app/views/account/organization_payments/new.html.erb create mode 100644 app/views/account/payment_methods/index.html.erb create mode 100644 app/views/account/payment_methods/new.html.erb create mode 100644 db/migrate/20250904011345_add_stripe_customer_id_to_users.rb create mode 100644 db/migrate/20250918011950_create_payment_methods.rb create mode 100644 test/factories/payment_methods.rb create mode 100644 test/models/payment_method_test.rb diff --git a/.env b/.env index 02362aac7..b07f61f88 100644 --- a/.env +++ b/.env @@ -15,6 +15,7 @@ SQUARE_ENVIRONMENT=sandbox # Stripe API key STRIPE_API_KEY=STRIPEAPIKEY1234 +STRIPE_PUBLISHABLE_KEY=STRIPEPUBLISHABLEKEY1234 # Secret value used to sign webhook payloads STRIPE_WEBHOOK_SIGNING_SECRET= @@ -52,6 +53,7 @@ FEATURE_MAINTENANCE_WORKFLOW=on FEATURE_GROUP_LENDING=on FEATURE_SMS_REMINDERS=on FEATURE_NEW_APPOINTMENTS_PAGE=on +FEATURE_STRIPE_PAYMENTS=on # Twilio is used for SMS notifications TWILIO_ACCOUNT_SID= diff --git a/app/controllers/account/organization_payments_controller.rb b/app/controllers/account/organization_payments_controller.rb deleted file mode 100644 index 6ac36f864..000000000 --- a/app/controllers/account/organization_payments_controller.rb +++ /dev/null @@ -1,35 +0,0 @@ -module Account - class OrganizationPaymentsController < BaseController - def new - end - - def create - result = checkout.checkout_url(amount: Money.new(500), email: "test@esample.com", return_to: callback_account_organization_payments_url) - if result.success? - redirect_to result.value, status: :see_other, allow_other_host: true - else - errors = result.error - Rails.logger.error(errors) - flash[:error] = "There was a problem connecting to our payment processor." - redirect_to new_account_organization_payment_url, status: :see_other - end - end - - def callback - @result = checkout.fetch_session(session_id: params[:session_id]) - end - - def index - end - - private - - # def form_params - # params.require(:membership_payment_form).permit(:amount_dollars) - # end - - def checkout - StripeCheckout.new(ENV.fetch("STRIPE_API_KEY"), environment: Rails.env) - end - end -end diff --git a/app/controllers/account/payment_methods_controller.rb b/app/controllers/account/payment_methods_controller.rb new file mode 100644 index 000000000..9ebc4e77e --- /dev/null +++ b/app/controllers/account/payment_methods_controller.rb @@ -0,0 +1,32 @@ +module Account + class PaymentMethodsController < BaseController + def new + @result = checkout.prepare_to_collect_payment_info(current_user) + unless @result.success? + Rails.logger.error(result.error) + flash[:error] = "There was a problem connecting to our payment processor." + redirect_to_back_or_default + end + end + + def index + checkout.sync_payment_methods(current_user) + @payment_methods = current_user.payment_methods.active + end + + def destroy + @payment_method = current_user.payment_methods.find(params[:id]) + result = checkout.delete_payment_method(@payment_method) + unless result.success? + flash[:error] = result.error + end + redirect_to account_payment_methods_path, status: :see_other + end + + private + + def checkout + StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) + end + end +end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index bf8ae1df3..515da4ec9 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -5,64 +5,67 @@ import { application } from './application' import AlertController from './alert_controller' +application.register('alert', AlertController) import AppointmentDateController from './appointment_date_controller' +application.register('appointment-date', AppointmentDateController) import AutocompleteController from './autocomplete_controller' +application.register('autocomplete', AutocompleteController) import CollapseController from './collapse_controller' +application.register('collapse', CollapseController) import ConditionalFieldController from './conditional_field_controller' +application.register('conditional-field', ConditionalFieldController) import ConfirmItemAccessoriesController from './confirm_item_accessories_controller' +application.register( + 'confirm-item-accessories', + ConfirmItemAccessoriesController +) import DynamicFieldsController from './dynamic_fields_controller' +application.register('dynamic-fields', DynamicFieldsController) import EmailSettingsEditorController from './email_settings_editor_controller' +application.register('email-settings-editor', EmailSettingsEditorController) import FindToolController from './find_tool_controller' +application.register('find-tool', FindToolController) import HoldOrderController from './hold_order_controller' +application.register('hold-order', HoldOrderController) import ImageEditorController from './image_editor_controller' +application.register('image-editor', ImageEditorController) import ItemFilterController from './item_filter_controller' +application.register('item-filter', ItemFilterController) import ModalController from './modal_controller' +application.register('modal', ModalController) import MultiSelectController from './multi_select_controller' +application.register('multi-select', MultiSelectController) import NotesController from './notes_controller' +application.register('notes', NotesController) import ReservationDatesController from './reservation_dates_controller' +application.register('reservation-dates', ReservationDatesController) import SidebarController from './sidebar_controller' +application.register('sidebar', SidebarController) + +import StripeController from './stripe_controller' +application.register('stripe', StripeController) import TagEditorController from './tag_editor_controller' +application.register('tag-editor', TagEditorController) import ToggleController from './toggle_controller' +application.register('toggle', ToggleController) import TreeNavController from './tree_nav_controller' -application.register('alert', AlertController) -application.register('appointment-date', AppointmentDateController) -application.register('autocomplete', AutocompleteController) -application.register('collapse', CollapseController) -application.register('conditional-field', ConditionalFieldController) -application.register( - 'confirm-item-accessories', - ConfirmItemAccessoriesController -) -application.register('dynamic-fields', DynamicFieldsController) -application.register('email-settings-editor', EmailSettingsEditorController) -application.register('find-tool', FindToolController) -application.register('hold-order', HoldOrderController) -application.register('image-editor', ImageEditorController) -application.register('item-filter', ItemFilterController) -application.register('modal', ModalController) -application.register('multi-select', MultiSelectController) -application.register('notes', NotesController) -application.register('reservation-dates', ReservationDatesController) -application.register('sidebar', SidebarController) -application.register('tag-editor', TagEditorController) -application.register('toggle', ToggleController) application.register('tree-nav', TreeNavController) diff --git a/app/javascript/controllers/stripe_controller.js b/app/javascript/controllers/stripe_controller.js new file mode 100644 index 000000000..e31255876 --- /dev/null +++ b/app/javascript/controllers/stripe_controller.js @@ -0,0 +1,59 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + static targets = ['elements', 'errors'] + static values = { + intentSecret: String, + returnUrl: String, + } + + connect() { + this.clientKey = document + .querySelector('meta[name=stripe-client-key') + .getAttribute('content') + this.client = Stripe(this.clientKey) + + const options = { + clientSecret: this.intentSecretValue, + // Fully customizable with appearance API. + appearance: { + /*...*/ + }, + } + + // Set up Stripe.js and Elements using the SetupIntent's client secret + this.elements = this.client.elements(options) + + // Create and mount the Payment Element + const paymentElementOptions = { layout: 'accordion' } + const paymentElement = this.elements.create( + 'payment', + paymentElementOptions + ) + paymentElement.mount(this.elementsTarget) + } + + async submit(event) { + event.preventDefault() + + const { error } = await this.client.confirmSetup({ + elements: this.elements, + confirmParams: { + return_url: this.returnUrlValue, + }, + }) + + if (error) { + // This point will only be reached if there is an immediate error when + // confirming the payment. Show error to your customer (for example, payment + // details incomplete) + this.errorsTarget.textContent = error.message + } else { + // Your customer will be redirected to your `return_url`. For some payment + // methods like iDEAL, your customer will be redirected to an intermediate + // site first to authorize the payment, then redirected to the `return_url`. + } + } + + disconnect() {} +} diff --git a/app/lib/feature_flags.rb b/app/lib/feature_flags.rb index 1c05219ff..59e2b6c05 100644 --- a/app/lib/feature_flags.rb +++ b/app/lib/feature_flags.rb @@ -18,4 +18,8 @@ def self.group_lending_enabled? def self.for_later_lists_enabled? ENV["FOR_LATER_LISTS"] == "on" end + + def self.stripe_payments_enabled? + ENV["FEATURE_STRIPE_PAYMENTS"] == "on" + end end diff --git a/app/lib/stripe_checkout.rb b/app/lib/stripe_checkout.rb index 737ba8bd5..99b8aaedf 100644 --- a/app/lib/stripe_checkout.rb +++ b/app/lib/stripe_checkout.rb @@ -1,60 +1,57 @@ class StripeCheckout - def initialize(api_key, environment: "production", now: Time.current) + def initialize(api_key, now: Time.current) @client = Stripe::StripeClient.new(api_key) @now = now end - # TODO add user_id:, organization_id:, - def checkout_url(amount:, email:, return_to:) - session = @client.v1.checkout.sessions.create({ - customer_email: email, - line_items: [{ - # TODO set the org levels as products in stripe UI and pass one of those here - price_data: { - currency: "USD", - unit_amount: amount.cents, - product_data: { - name: "Annual Membership" - } - }, - quantity: 1 - }], - - mode: "payment", - currency: "USD", - customer_creation: "always", + def ensure_customer_exists(user) + if user.stripe_customer_id.blank? + customer = @client.v1.customers.create({metadata: {circulate_id: user.id}}) + user.update!(stripe_customer_id: customer.id) + end + end - # Allow users to have their payment info prefilled for future purposes - saved_payment_method_options: {payment_method_save: "enabled"}, + def sync_payment_methods(user) + list_payment_methods(user).value.each do |pm| + next unless pm.card + + payment_method = user.payment_methods.find_or_initialize_by(stripe_id: pm.id) + payment_method.update!( + display_brand: pm.card.display_brand, + last_four: pm.card.last4, + expire_month: pm.card.exp_month, + expire_year: pm.card.exp_year + ) + end + end - # Save payment info for circulate-created future charges - payment_intent_data: {setup_future_usage: "off_session"}, + def delete_payment_method(payment_method) + response = @client.v1.payment_methods.detach(payment_method.stripe_id) + payment_method.detach! + Result.success(response) + rescue Stripe::InvalidRequestError => e + Result.failure(e) + end - success_url: return_to + "?session_id={CHECKOUT_SESSION_ID}", - cancel_url: return_to + def list_payment_methods(user) + payment_methods = @client.v1.payment_methods.list({ + customer: user.stripe_customer_id, + type: "card" }) - - Result.success(session.url) + Result.success(payment_methods) rescue Stripe::InvalidRequestError => e Result.failure(e) end - def fetch_session(session_id:) - session = @client.v1.checkout.sessions.retrieve(session_id) - customer = @client.v1.customers.retrieve(session.customer) - - raise "non-USD currency is not supported" unless session.currency == "usd" - - amount = Money.new(session.amount_total) + def prepare_to_collect_payment_info(user) + ensure_customer_exists(user) + setup_intent = @client.v1.setup_intents.create({ + customer: user.stripe_customer_id, + payment_method_types: ["card"] + }) - Result.success(amount) + Result.success(setup_intent.client_secret) rescue Stripe::InvalidRequestError => e Result.failure(e) end - - # private - - # def random_idempotency_key - # rand(1_000_000_000).to_s - # end end diff --git a/app/models/payment_method.rb b/app/models/payment_method.rb new file mode 100644 index 000000000..396a8ca1e --- /dev/null +++ b/app/models/payment_method.rb @@ -0,0 +1,15 @@ +class PaymentMethod < ApplicationRecord + belongs_to :user + + enum :status, { + active: "active", + expired: "expired", + detached: "detached" + } + + scope :active, -> { where(status: "active") } + + def detach! + update!(status: self.class.statuses[:detached]) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 003d48e96..b2f464407 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,9 @@ class User < ApplicationRecord } has_one :member + has_many :organization_members, dependent: :destroy + has_many :organizations, through: :organization_members + has_many :payment_methods # Adapted from https://github.com/heartcombo/devise/blob/main/lib/devise/models/validatable.rb # so we can scope email uniqueness to library_id diff --git a/app/views/account/organization_payments/callback.html.erb b/app/views/account/organization_payments/callback.html.erb deleted file mode 100644 index 115990249..000000000 --- a/app/views/account/organization_payments/callback.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -

Payment complete

-<%= @result.value.format %> \ No newline at end of file diff --git a/app/views/account/organization_payments/new.html.erb b/app/views/account/organization_payments/new.html.erb deleted file mode 100644 index c0ec56bc1..000000000 --- a/app/views/account/organization_payments/new.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -

Test

-<%= button_to "Create Payment", account_organization_payments_path, method: "POST", form: { data: {turbo: false}} %> \ No newline at end of file diff --git a/app/views/account/payment_methods/index.html.erb b/app/views/account/payment_methods/index.html.erb new file mode 100644 index 000000000..edf4d0023 --- /dev/null +++ b/app/views/account/payment_methods/index.html.erb @@ -0,0 +1,32 @@ +<% if @payment_methods.any? %> + + + + + + + + + <% @payment_methods.each do |pm| %> + + + + + + + <% end %> + +
Card typeLast 4Expires
<%= feather_icon "credit-card" %> <%= pm.display_brand %><%= pm.last_four %><%= pm.expire_month %>/<%= pm.expire_year %><%= button_to "Delete", account_payment_method_path(pm.id), method: :delete, class: "btn btn-sm" %>
+

+ <%= link_to new_account_payment_method_path, class: "btn btn-primary", data: {turbo: false} do %> + <%= feather_icon "credit-card" %> Add new payment method + <% end %> +

+ +<% else %> + <%= empty_state "You have no payment methods" do %> + <%= link_to new_account_payment_method_path, class: "btn btn-primary", data: {turbo: false} do %> + <%= feather_icon "credit-card" %> Add new payment method + <% end %> + <% end %> +<% end %> diff --git a/app/views/account/payment_methods/new.html.erb b/app/views/account/payment_methods/new.html.erb new file mode 100644 index 000000000..785869fa0 --- /dev/null +++ b/app/views/account/payment_methods/new.html.erb @@ -0,0 +1,30 @@ +<%= content_for :head do %> + +"> +<% end %> + +
+
+ <%= section_header "New Payment Method" %> +

We won't charge your card now. This payment information will be saved for future use.

+ <%= form_with id: "payment-form", + builder: SpectreFormBuilder, + data: { + controller: "stripe", + stripe_intent_secret_value: @result.value, + stripe_return_url_value: account_payment_methods_url, + action: "submit->stripe#submit" + } do |f| %> +
+ +
+ <%= f.actions do %> + <%= f.submit "Save", class: "btn btn-primary" %> + <%= link_to "Cancel", account_payment_methods_path, class: "btn btn-block mt-2" %> + <% end %> +
+ +
+ <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 98434f638..90c43fe03 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -48,8 +48,8 @@ end end resources :item_pools - resources :organization_payments, only: [:new, :create] do - get :callback, on: :collection + if FeatureFlags.stripe_payments_enabled? + resources :payment_methods, only: [:index, :new, :create, :destroy] end end end diff --git a/db/migrate/20250904011345_add_stripe_customer_id_to_users.rb b/db/migrate/20250904011345_add_stripe_customer_id_to_users.rb new file mode 100644 index 000000000..f51683db3 --- /dev/null +++ b/db/migrate/20250904011345_add_stripe_customer_id_to_users.rb @@ -0,0 +1,6 @@ +class AddStripeCustomerIdToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :stripe_customer_id, :string + add_index :users, :stripe_customer_id + end +end diff --git a/db/migrate/20250918011950_create_payment_methods.rb b/db/migrate/20250918011950_create_payment_methods.rb new file mode 100644 index 000000000..cd17686fc --- /dev/null +++ b/db/migrate/20250918011950_create_payment_methods.rb @@ -0,0 +1,17 @@ +class CreatePaymentMethods < ActiveRecord::Migration[8.0] + def change + create_enum :payment_method_status, [:active, :expired, :detached] + create_table :payment_methods do |t| + t.belongs_to :user + t.string :stripe_id + t.string :display_brand + t.string :last_four + t.integer :expire_month + t.integer :expire_year + t.enum :status, enum_type: :payment_method_status, default: "active", null: false + t.timestamps + + t.index :stripe_id, unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c767bdca0..1c46d4ab8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -25,6 +25,7 @@ create_enum "item_retired_reason", ["not_returned", "broken", "upgraded", "used_up"] create_enum "item_status", ["pending", "active", "maintenance", "retired", "missing"] create_enum "membership_type", ["initial", "renewal"] + create_enum "organization_member_role", ["admin", "member"] create_enum "payment_method_status", ["active", "expired", "detached"] create_enum "power_source", ["solar", "gas", "air", "electric (corded)", "electric (battery)"] create_enum "renewal_request_status", ["requested", "approved", "rejected"] @@ -671,6 +672,20 @@ t.index ["user_id"], name: "index_payment_methods_on_user_id" end + create_table "payment_methods", force: :cascade do |t| + t.bigint "user_id" + t.string "stripe_id" + t.string "display_brand" + t.string "last_four" + t.integer "expire_month" + t.integer "expire_year" + t.enum "status", default: "active", null: false, enum_type: "payment_method_status" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["stripe_id"], name: "index_payment_methods_on_stripe_id", unique: true + t.index ["user_id"], name: "index_payment_methods_on_user_id" + end + create_table "pending_reservation_items", force: :cascade do |t| t.bigint "reservable_item_id", null: false t.bigint "reservation_id", null: false diff --git a/test/factories/payment_methods.rb b/test/factories/payment_methods.rb new file mode 100644 index 000000000..6b86d7b96 --- /dev/null +++ b/test/factories/payment_methods.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :payment_method do + association :user + end +end diff --git a/test/models/payment_method_test.rb b/test/models/payment_method_test.rb new file mode 100644 index 000000000..0cd146630 --- /dev/null +++ b/test/models/payment_method_test.rb @@ -0,0 +1,12 @@ +require "test_helper" + +class PaymentMethodTest < ActiveSupport::TestCase + test "detach from a user" do + payment_method = create(:payment_method) + assert_equal PaymentMethod.statuses[:active], payment_method.status + + payment_method.detach! + assert_equal PaymentMethod.statuses[:detached], payment_method.status + refute PaymentMethod.active.find_by(id: payment_method.id) + end +end From f79c0e4263c4cc35d6d39f11bf5c2208fadea276 Mon Sep 17 00:00:00 2001 From: Michael Crismali Date: Fri, 13 Feb 2026 16:18:13 -0600 Subject: [PATCH 5/9] Rearrange migrations after rebase --- app/models/user.rb | 2 -- ...904011345_add_stripe_customer_id_to_users.rb | 6 ------ ..._users_and_create_custom_payment_methods.rb} | 5 ++++- db/schema.rb | 17 +---------------- 4 files changed, 5 insertions(+), 25 deletions(-) delete mode 100644 db/migrate/20250904011345_add_stripe_customer_id_to_users.rb rename db/migrate/{20250918011950_create_payment_methods.rb => 20260213221407_add_stripe_customer_id_to_users_and_create_custom_payment_methods.rb} (70%) diff --git a/app/models/user.rb b/app/models/user.rb index b2f464407..b2898a65b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,8 +18,6 @@ class User < ApplicationRecord } has_one :member - has_many :organization_members, dependent: :destroy - has_many :organizations, through: :organization_members has_many :payment_methods # Adapted from https://github.com/heartcombo/devise/blob/main/lib/devise/models/validatable.rb diff --git a/db/migrate/20250904011345_add_stripe_customer_id_to_users.rb b/db/migrate/20250904011345_add_stripe_customer_id_to_users.rb deleted file mode 100644 index f51683db3..000000000 --- a/db/migrate/20250904011345_add_stripe_customer_id_to_users.rb +++ /dev/null @@ -1,6 +0,0 @@ -class AddStripeCustomerIdToUsers < ActiveRecord::Migration[8.0] - def change - add_column :users, :stripe_customer_id, :string - add_index :users, :stripe_customer_id - end -end diff --git a/db/migrate/20250918011950_create_payment_methods.rb b/db/migrate/20260213221407_add_stripe_customer_id_to_users_and_create_custom_payment_methods.rb similarity index 70% rename from db/migrate/20250918011950_create_payment_methods.rb rename to db/migrate/20260213221407_add_stripe_customer_id_to_users_and_create_custom_payment_methods.rb index cd17686fc..d2e4ae20f 100644 --- a/db/migrate/20250918011950_create_payment_methods.rb +++ b/db/migrate/20260213221407_add_stripe_customer_id_to_users_and_create_custom_payment_methods.rb @@ -1,5 +1,8 @@ -class CreatePaymentMethods < ActiveRecord::Migration[8.0] +class AddStripeCustomerIdToUsersAndCreateCustomPaymentMethods < ActiveRecord::Migration[8.0] def change + add_column :users, :stripe_customer_id, :string + add_index :users, :stripe_customer_id + create_enum :payment_method_status, [:active, :expired, :detached] create_table :payment_methods do |t| t.belongs_to :user diff --git a/db/schema.rb b/db/schema.rb index 1c46d4ab8..17f5e4892 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.0].define(version: 2026_01_07_012305) do +ActiveRecord::Schema[8.0].define(version: 2026_02_13_221407) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -25,7 +25,6 @@ create_enum "item_retired_reason", ["not_returned", "broken", "upgraded", "used_up"] create_enum "item_status", ["pending", "active", "maintenance", "retired", "missing"] create_enum "membership_type", ["initial", "renewal"] - create_enum "organization_member_role", ["admin", "member"] create_enum "payment_method_status", ["active", "expired", "detached"] create_enum "power_source", ["solar", "gas", "air", "electric (corded)", "electric (battery)"] create_enum "renewal_request_status", ["requested", "approved", "rejected"] @@ -672,20 +671,6 @@ t.index ["user_id"], name: "index_payment_methods_on_user_id" end - create_table "payment_methods", force: :cascade do |t| - t.bigint "user_id" - t.string "stripe_id" - t.string "display_brand" - t.string "last_four" - t.integer "expire_month" - t.integer "expire_year" - t.enum "status", default: "active", null: false, enum_type: "payment_method_status" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["stripe_id"], name: "index_payment_methods_on_stripe_id", unique: true - t.index ["user_id"], name: "index_payment_methods_on_user_id" - end - create_table "pending_reservation_items", force: :cascade do |t| t.bigint "reservable_item_id", null: false t.bigint "reservation_id", null: false From fd6516ab8855501f5383fb17e48b694dd78c478b Mon Sep 17 00:00:00 2001 From: Michael Crismali Date: Sun, 1 Mar 2026 11:36:15 -0600 Subject: [PATCH 6/9] Made skipping csrf protection narrower for the github security bot --- app/controllers/stripe_controller.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controllers/stripe_controller.rb b/app/controllers/stripe_controller.rb index 88a6f3015..2af187f73 100644 --- a/app/controllers/stripe_controller.rb +++ b/app/controllers/stripe_controller.rb @@ -1,5 +1,5 @@ class StripeController < ApplicationController - skip_before_action :verify_authenticity_token + skip_before_action :verify_authenticity_token, only: %i[webhook] def webhook payload = request.body.read @@ -9,7 +9,7 @@ def webhook event = Stripe::Event.construct_from( JSON.parse(payload, symbolize_names: true) ) - rescue JSON::ParserError => e + rescue JSON::ParserError # Invalid payload render status: 400 return @@ -32,9 +32,9 @@ def webhook case event.type when "checkout.session.completed" session = event.data.object # contains a Stripe::PaymentIntent - puts session + Rails.logger.info session else - puts "Unhandled event type: #{event.type}" + Rails.logger.info "Unhandled event type: #{event.type}" end render json: {message: :success} end From a5b0f1286f3304ba0320933d0e266b74a3f634f1 Mon Sep 17 00:00:00 2001 From: Michael Crismali Date: Sun, 1 Mar 2026 12:07:01 -0600 Subject: [PATCH 7/9] Added basic VCR/webmock setup and some tests for StripeCheckout --- Gemfile | 2 + Gemfile.lock | 12 ++ app/lib/stripe_checkout.rb | 7 +- test/lib/stripe_checkout_test.rb | 34 +++++ test/test_helper.rb | 7 ++ test/vcr_cassettes/create_stripe_customer.yml | 116 ++++++++++++++++++ 6 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 test/lib/stripe_checkout_test.rb create mode 100644 test/vcr_cassettes/create_stripe_customer.yml diff --git a/Gemfile b/Gemfile index 8a8723585..23596060a 100644 --- a/Gemfile +++ b/Gemfile @@ -92,6 +92,8 @@ group :test do gem "capybara-playwright-driver" gem "rails-controller-testing" gem "timecop" + gem "vcr" + gem "webmock" end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem diff --git a/Gemfile.lock b/Gemfile.lock index a991fda4e..be9ef973a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -173,6 +173,9 @@ GEM chronic (0.10.2) concurrent-ruby (1.3.6) connection_pool (3.0.2) + crack (1.0.1) + bigdecimal + rexml crass (1.0.6) cssbundling-rails (1.4.3) railties (>= 6.0.0) @@ -264,6 +267,7 @@ GEM multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) + hashdiff (1.2.1) hashie (5.1.0) logger http (5.3.1) @@ -495,6 +499,7 @@ GEM railties (>= 7.0) reverse_markdown (3.0.2) nokogiri + rexml (3.4.4) rubocop (1.84.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -594,6 +599,7 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) + vcr (6.4.0) version_gem (1.1.9) warden (1.2.9) rack (>= 2.0.9) @@ -602,6 +608,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.26.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket-driver (0.7.7) base64 websocket-extensions (>= 0.1.0) @@ -680,7 +690,9 @@ DEPENDENCIES translation turbo-rails twilio-ruby (~> 7.10) + vcr web-console (>= 3.3.0) + webmock RUBY VERSION ruby 3.4.5p51 diff --git a/app/lib/stripe_checkout.rb b/app/lib/stripe_checkout.rb index 99b8aaedf..c3924d36d 100644 --- a/app/lib/stripe_checkout.rb +++ b/app/lib/stripe_checkout.rb @@ -1,7 +1,8 @@ class StripeCheckout - def initialize(api_key, now: Time.current) - @client = Stripe::StripeClient.new(api_key) - @now = now + attr_accessor :client + + def initialize(api_key) + self.client = Stripe::StripeClient.new(api_key) end def ensure_customer_exists(user) diff --git a/test/lib/stripe_checkout_test.rb b/test/lib/stripe_checkout_test.rb new file mode 100644 index 000000000..102a0d824 --- /dev/null +++ b/test/lib/stripe_checkout_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class StripeCheckoutTest < ActiveSupport::TestCase + test "#initialize creates a stripe client" do + api_key = SecureRandom.hex(10) + stripe_checkout = StripeCheckout.new(api_key) + + assert stripe_checkout.client + assert_equal Stripe::StripeClient, stripe_checkout.client.class + end + + test "#ensure_customer_exists creates a stripe customer and saves its id to the given user when the user lacks a stripe id" do + stripe_checkout = StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) + user = create(:user, stripe_customer_id: nil) + + VCR.use_cassette("create_stripe_customer") do + stripe_checkout.ensure_customer_exists(user) + end + + user.reload + + assert user.stripe_customer_id + end + + test "#ensure_customer_exists does nothing when the user already has a stripe id" do + stripe_checkout = StripeCheckout.new(SecureRandom.hex(10)) + user = create(:user, stripe_customer_id: SecureRandom.hex(10)) + original_stripe_customer_id = user.stripe_customer_id + + stripe_checkout.ensure_customer_exists(user) + + assert_equal original_stripe_customer_id, user.reload.stripe_customer_id + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 4989c3c2a..354e29838 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,6 +3,7 @@ require "rails/test_help" require "spy/integration" require "minitest/mock" +require "vcr" require "helpers/return_values" require "helpers/ensure_request_tenant" @@ -12,6 +13,12 @@ # otherwise, the plugin isn't loaded until later. require "minitest/tags_plugin" +VCR.configure do |config| + config.cassette_library_dir = "test/vcr_cassettes" + config.hook_into :webmock + config.filter_sensitive_data("") { ENV.fetch("STRIPE_API_KEY") } +end + class ActiveSupport::TestCase # Run tests in parallel with specified workers parallelize(workers: :number_of_processors) diff --git a/test/vcr_cassettes/create_stripe_customer.yml b/test/vcr_cassettes/create_stripe_customer.yml new file mode 100644 index 000000000..2e661269e --- /dev/null +++ b/test/vcr_cassettes/create_stripe_customer.yml @@ -0,0 +1,116 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.stripe.com/v1/customers + body: + encoding: UTF-8 + string: metadata[circulate_id]=25 + headers: + User-Agent: + - Stripe/v1 RubyBindings/15.4.0 + Authorization: + - Bearer + Idempotency-Key: + - b4586ddd-70d8-42d8-b7f7-daefa9bada62 + Stripe-Version: + - 2025-07-30.basil + X-Stripe-Client-User-Agent: + - '{"bindings_version":"15.4.0","lang":"ruby","lang_version":"3.4.5 p51 (2025-07-16)","platform":"arm64-darwin24","engine":"ruby","publisher":"stripe","uname":"Darwin + Michaels-Mac-mini.local 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:49:24 + PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T8132 arm64","hostname":"Michaels-Mac-mini.local"}' + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Sun, 01 Mar 2026 18:05:54 GMT + Content-Type: + - application/json + Content-Length: + - '670' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, HEAD, PUT, PATCH, POST, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, + X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Content-Security-Policy: + - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none'; + img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src + 'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=UoIE6vSXHfZCLrDQogGpAQUbSLFIgiT4Wh6YV5q_D9O09Cg9cZstzH7oTqk1Bh5tZYtKJaYjc4nnLlNQ + Idempotency-Key: + - b4586ddd-70d8-42d8-b7f7-daefa9bada62 + Original-Request: + - req_qx5bUyCRkqAg5F + Request-Id: + - req_qx5bUyCRkqAg5F + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - 2025-07-30.basil + Vary: + - Origin + X-Stripe-Priority-Routing-Enabled: + - 'true' + X-Stripe-Routing-Context-Priority-Tier: + - api-testmode + X-Wc: + - ABGHIJ + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "cus_U4N7YsOE2tKLa1", + "object": "customer", + "address": null, + "balance": 0, + "created": 1772388354, + "currency": null, + "customer_account": null, + "default_source": null, + "delinquent": false, + "description": null, + "discount": null, + "email": null, + "invoice_prefix": "XKDOZUVK", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": { + "circulate_id": "25" + }, + "name": null, + "next_invoice_sequence": 1, + "phone": null, + "preferred_locales": [], + "shipping": null, + "tax_exempt": "none", + "test_clock": null + } + recorded_at: Sun, 01 Mar 2026 18:05:54 GMT +recorded_with: VCR 6.4.0 From c7ac9d32d53771480ba9db2622014e36d9a14ed6 Mon Sep 17 00:00:00 2001 From: Michael Crismali Date: Sun, 1 Mar 2026 12:35:54 -0600 Subject: [PATCH 8/9] Added tests for StripeCheckout --- app/lib/stripe_checkout.rb | 8 +- test/lib/stripe_checkout_test.rb | 133 +++++++++++++++++ test/vcr_cassettes/create_intent.yml | 122 ++++++++++++++++ test/vcr_cassettes/create_intent_failure.yml | 92 ++++++++++++ .../delete_stripe_payment_method.yml | 138 ++++++++++++++++++ .../delete_stripe_payment_method_failure.yml | 94 ++++++++++++ .../list_stripe_payment_methods.yml | 135 +++++++++++++++++ .../list_stripe_payment_methods_failure.yml | 92 ++++++++++++ 8 files changed, 810 insertions(+), 4 deletions(-) create mode 100644 test/vcr_cassettes/create_intent.yml create mode 100644 test/vcr_cassettes/create_intent_failure.yml create mode 100644 test/vcr_cassettes/delete_stripe_payment_method.yml create mode 100644 test/vcr_cassettes/delete_stripe_payment_method_failure.yml create mode 100644 test/vcr_cassettes/list_stripe_payment_methods.yml create mode 100644 test/vcr_cassettes/list_stripe_payment_methods_failure.yml diff --git a/app/lib/stripe_checkout.rb b/app/lib/stripe_checkout.rb index c3924d36d..aa4d48fe3 100644 --- a/app/lib/stripe_checkout.rb +++ b/app/lib/stripe_checkout.rb @@ -7,7 +7,7 @@ def initialize(api_key) def ensure_customer_exists(user) if user.stripe_customer_id.blank? - customer = @client.v1.customers.create({metadata: {circulate_id: user.id}}) + customer = client.v1.customers.create({metadata: {circulate_id: user.id}}) user.update!(stripe_customer_id: customer.id) end end @@ -27,7 +27,7 @@ def sync_payment_methods(user) end def delete_payment_method(payment_method) - response = @client.v1.payment_methods.detach(payment_method.stripe_id) + response = client.v1.payment_methods.detach(payment_method.stripe_id) payment_method.detach! Result.success(response) rescue Stripe::InvalidRequestError => e @@ -35,7 +35,7 @@ def delete_payment_method(payment_method) end def list_payment_methods(user) - payment_methods = @client.v1.payment_methods.list({ + payment_methods = client.v1.payment_methods.list({ customer: user.stripe_customer_id, type: "card" }) @@ -46,7 +46,7 @@ def list_payment_methods(user) def prepare_to_collect_payment_info(user) ensure_customer_exists(user) - setup_intent = @client.v1.setup_intents.create({ + setup_intent = client.v1.setup_intents.create({ customer: user.stripe_customer_id, payment_method_types: ["card"] }) diff --git a/test/lib/stripe_checkout_test.rb b/test/lib/stripe_checkout_test.rb index 102a0d824..ba297dd2b 100644 --- a/test/lib/stripe_checkout_test.rb +++ b/test/lib/stripe_checkout_test.rb @@ -31,4 +31,137 @@ class StripeCheckoutTest < ActiveSupport::TestCase assert_equal original_stripe_customer_id, user.reload.stripe_customer_id end + + test "#sync_payment_methods creates payment method records based on stripe's payment methods" do + stripe_checkout = StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) + user = create(:user, stripe_customer_id: nil) + + assert_equal [], user.payment_methods + + VCR.use_cassette("create_stripe_customer") do + stripe_checkout.ensure_customer_exists(user) + end + + VCR.use_cassette("list_stripe_payment_methods") do + stripe_checkout.sync_payment_methods(user) + end + + user.reload + assert_equal 1, user.payment_methods.count + + payment_method = user.payment_methods.first! + + assert payment_method.display_brand + assert payment_method.last_four + assert payment_method.expire_month + assert payment_method.expire_year + end + + test "#sync_payment_methods updates payment method records based on stripe's payment methods" do + stripe_checkout = StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) + user = create(:user, stripe_customer_id: nil) + payment_method = create(:payment_method, user:, stripe_id: "pm_1T6Ea8PnUSFzhT0d7yCb8feV") + original_display_brand = payment_method.display_brand + original_last_four = payment_method.last_four + original_expire_month = payment_method.expire_month + original_expire_year = payment_method.expire_year + + assert_equal 1, user.payment_methods.count + + VCR.use_cassette("create_stripe_customer") do + stripe_checkout.ensure_customer_exists(user) + end + + VCR.use_cassette("list_stripe_payment_methods") do + stripe_checkout.sync_payment_methods(user) + end + + assert_equal 1, user.reload.payment_methods.count + + payment_method.reload + refute_equal original_display_brand, payment_method.display_brand + refute_equal original_last_four, payment_method.last_four + refute_equal original_expire_month, payment_method.expire_month + refute_equal original_expire_year, payment_method.expire_year + end + + test "#list_payment_methods returns the payment methods in stripe" do + stripe_checkout = StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) + user = create(:user, stripe_customer_id: nil) + + VCR.use_cassette("create_stripe_customer") do + stripe_checkout.ensure_customer_exists(user) + end + + VCR.use_cassette("list_stripe_payment_methods") do + result = stripe_checkout.list_payment_methods(user) + assert result.success? + assert_equal "visa", result.value.first.card.brand + end + end + + test "#list_payment_methods returns a failure when something goes wrong" do + stripe_checkout = StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) + user = create(:user, stripe_customer_id: SecureRandom.hex(10)) + + VCR.use_cassette("list_stripe_payment_methods_failure") do + result = stripe_checkout.prepare_to_collect_payment_info(user) + assert result.failure? + end + end + + test "#prepare_to_collect_payment_info returns the created intent's client secret" do + stripe_checkout = StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) + user = create(:user, stripe_customer_id: nil) + + VCR.use_cassette("create_stripe_customer") do + stripe_checkout.ensure_customer_exists(user) + end + + VCR.use_cassette("create_intent") do + result = stripe_checkout.prepare_to_collect_payment_info(user) + assert result.success? + assert result.value + end + end + + test "#prepare_to_collect_payment_info returns a failure when something goes wrong" do + stripe_checkout = StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) + user = create(:user, stripe_customer_id: SecureRandom.hex(10)) + + VCR.use_cassette("create_intent_failure") do + result = stripe_checkout.prepare_to_collect_payment_info(user) + assert result.failure? + end + end + + test "#delete_payment_method deletes the payment method from stripe" do + stripe_checkout = StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) + user = create(:user, stripe_customer_id: nil) + payment_method = create(:payment_method, user:, stripe_id: "pm_1T6Ea8PnUSFzhT0d7yCb8feV") + + VCR.use_cassette("create_stripe_customer") do + stripe_checkout.ensure_customer_exists(user) + end + + VCR.use_cassette("delete_stripe_payment_method") do + result = stripe_checkout.delete_payment_method(payment_method) + assert result.success? + end + end + + test "#delete_payment_method returns a failure when something goes wrong" do + stripe_checkout = StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) + user = create(:user, stripe_customer_id: nil) + payment_method = create(:payment_method, user:, stripe_id: "does not exist") + + VCR.use_cassette("create_stripe_customer") do + stripe_checkout.ensure_customer_exists(user) + end + + VCR.use_cassette("delete_stripe_payment_method_failure") do + result = stripe_checkout.delete_payment_method(payment_method) + assert result.failure? + end + end end diff --git a/test/vcr_cassettes/create_intent.yml b/test/vcr_cassettes/create_intent.yml new file mode 100644 index 000000000..d6c1b4ccf --- /dev/null +++ b/test/vcr_cassettes/create_intent.yml @@ -0,0 +1,122 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.stripe.com/v1/setup_intents + body: + encoding: UTF-8 + string: customer=cus_U4N7YsOE2tKLa1&payment_method_types[0]=card + headers: + User-Agent: + - Stripe/v1 RubyBindings/15.4.0 + Authorization: + - Bearer + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_qx5bUyCRkqAg5F","request_duration_ms":7}}' + Idempotency-Key: + - 43fe8d38-4d9c-42fb-b2c1-ce27f0943431 + Stripe-Version: + - 2025-07-30.basil + X-Stripe-Client-User-Agent: + - '{"bindings_version":"15.4.0","lang":"ruby","lang_version":"3.4.5 p51 (2025-07-16)","platform":"arm64-darwin24","engine":"ruby","publisher":"stripe","uname":"Darwin + Michaels-Mac-mini.local 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:49:24 + PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T8132 arm64","hostname":"Michaels-Mac-mini.local"}' + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Sun, 01 Mar 2026 18:13:27 GMT + Content-Type: + - application/json + Content-Length: + - '958' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, HEAD, PUT, PATCH, POST, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, + X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Content-Security-Policy: + - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none'; + img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src + 'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=ZX552u9Q8yTj2Qagr0MNqEj7QQyrop_f-WvPOAMz7L10goGOg30kmvH6CGJVDzOFo6qfCQhVQpdThwvi + Idempotency-Key: + - 43fe8d38-4d9c-42fb-b2c1-ce27f0943431 + Original-Request: + - req_5iBdBUzc8PFn6z + Request-Id: + - req_5iBdBUzc8PFn6z + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - 2025-07-30.basil + Vary: + - Origin + X-Stripe-Priority-Routing-Enabled: + - 'true' + X-Stripe-Routing-Context-Priority-Tier: + - api-testmode + X-Wc: + - ABGHIJ + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "seti_1T6EUdPnUSFzhT0dKduJvPnF", + "object": "setup_intent", + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1T6EUdPnUSFzhT0dKduJvPnF_secret_U4NFpaVlL6iJaONotJh0UvwZvz5p1Rf", + "created": 1772388807, + "customer": "cus_U4N7YsOE2tKLa1", + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": null, + "livemode": false, + "mandate": null, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "requires_payment_method", + "usage": "off_session" + } + recorded_at: Sun, 01 Mar 2026 18:13:27 GMT +recorded_with: VCR 6.4.0 diff --git a/test/vcr_cassettes/create_intent_failure.yml b/test/vcr_cassettes/create_intent_failure.yml new file mode 100644 index 000000000..6bdc2dd5a --- /dev/null +++ b/test/vcr_cassettes/create_intent_failure.yml @@ -0,0 +1,92 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.stripe.com/v1/setup_intents + body: + encoding: UTF-8 + string: customer=e67d4accfa0b00fc30d9&payment_method_types[0]=card + headers: + User-Agent: + - Stripe/v1 RubyBindings/15.4.0 + Authorization: + - Bearer + Idempotency-Key: + - dd77389c-c5a7-48dd-a679-974989f9f8d7 + Stripe-Version: + - 2025-07-30.basil + X-Stripe-Client-User-Agent: + - '{"bindings_version":"15.4.0","lang":"ruby","lang_version":"3.4.5 p51 (2025-07-16)","platform":"arm64-darwin24","engine":"ruby","publisher":"stripe","uname":"Darwin + Michaels-Mac-mini.local 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:49:24 + PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T8132 arm64","hostname":"Michaels-Mac-mini.local"}' + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 400 + message: Bad Request + headers: + Server: + - nginx + Date: + - Sun, 01 Mar 2026 18:14:58 GMT + Content-Type: + - application/json + Content-Length: + - '367' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, HEAD, PUT, PATCH, POST, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, + X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Content-Security-Policy: + - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none'; + img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src + 'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=b4n8TColFBUVVZk2pvCsmHadPqa8d3IF_kCRW8yC9lDzIkHqSn6JjL0PTrbHPh_0uzSwgMWRdCO5IHKr + Idempotency-Key: + - dd77389c-c5a7-48dd-a679-974989f9f8d7 + Original-Request: + - req_E4pjjC2GEl0Yns + Request-Id: + - req_E4pjjC2GEl0Yns + Stripe-Version: + - 2025-07-30.basil + Vary: + - Origin + X-Stripe-Priority-Routing-Enabled: + - 'true' + X-Stripe-Routing-Context-Priority-Tier: + - api-testmode + X-Wc: + - ABGHIJ + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: | + { + "error": { + "code": "resource_missing", + "doc_url": "https://stripe.com/docs/error-codes/resource-missing", + "message": "No such customer: 'e67d4accfa0b00fc30d9'", + "param": "customer", + "request_log_url": "https://dashboard.stripe.com/acct_1T6DoHPnUSFzhT0d/test/workbench/logs?object=req_E4pjjC2GEl0Yns", + "type": "invalid_request_error" + } + } + recorded_at: Sun, 01 Mar 2026 18:14:58 GMT +recorded_with: VCR 6.4.0 diff --git a/test/vcr_cassettes/delete_stripe_payment_method.yml b/test/vcr_cassettes/delete_stripe_payment_method.yml new file mode 100644 index 000000000..ae346a3f5 --- /dev/null +++ b/test/vcr_cassettes/delete_stripe_payment_method.yml @@ -0,0 +1,138 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.stripe.com/v1/payment_methods/pm_1T6Ea8PnUSFzhT0d7yCb8feV/detach + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - Stripe/v1 RubyBindings/15.4.0 + Authorization: + - Bearer + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_qx5bUyCRkqAg5F","request_duration_ms":7}}' + Idempotency-Key: + - 4ed6a5f5-1be4-456a-96dc-a5e5cab3440d + Stripe-Version: + - 2025-07-30.basil + X-Stripe-Client-User-Agent: + - '{"bindings_version":"15.4.0","lang":"ruby","lang_version":"3.4.5 p51 (2025-07-16)","platform":"arm64-darwin24","engine":"ruby","publisher":"stripe","uname":"Darwin + Michaels-Mac-mini.local 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:49:24 + PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T8132 arm64","hostname":"Michaels-Mac-mini.local"}' + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Sun, 01 Mar 2026 18:25:01 GMT + Content-Type: + - application/json + Content-Length: + - '1082' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, HEAD, PUT, PATCH, POST, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, + X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Content-Security-Policy: + - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none'; + img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src + 'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=Yw1ZX2qEdk-I6GJ2tHsyqc_2pUB0VDrI0opwva9rTU8cxjVaULRMRPx2_Z9K-U1F1UzD52Q38_9GxshO + Idempotency-Key: + - 4ed6a5f5-1be4-456a-96dc-a5e5cab3440d + Original-Request: + - req_VihViKy5saHYPR + Request-Id: + - req_VihViKy5saHYPR + Stripe-Should-Retry: + - 'false' + Stripe-Version: + - 2025-07-30.basil + Vary: + - Origin + X-Stripe-Priority-Routing-Enabled: + - 'true' + X-Stripe-Routing-Context-Priority-Tier: + - api-testmode + X-Wc: + - ABGHIJ + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "id": "pm_1T6Ea8PnUSFzhT0d7yCb8feV", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": "US", + "line1": null, + "line2": null, + "postal_code": "60612", + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": "pass", + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 6, + "exp_year": 2028, + "fingerprint": "D78TFXGvWpkLdP3B", + "funding": "credit", + "generated_from": null, + "last4": "1111", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1772389148, + "customer": null, + "customer_account": null, + "livemode": false, + "metadata": {}, + "type": "card" + } + recorded_at: Sun, 01 Mar 2026 18:25:02 GMT +recorded_with: VCR 6.4.0 diff --git a/test/vcr_cassettes/delete_stripe_payment_method_failure.yml b/test/vcr_cassettes/delete_stripe_payment_method_failure.yml new file mode 100644 index 000000000..15dadc160 --- /dev/null +++ b/test/vcr_cassettes/delete_stripe_payment_method_failure.yml @@ -0,0 +1,94 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.stripe.com/v1/payment_methods/does+not+exist/detach + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - Stripe/v1 RubyBindings/15.4.0 + Authorization: + - Bearer + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_qx5bUyCRkqAg5F","request_duration_ms":0}}' + Idempotency-Key: + - d5702f8f-02e8-4934-861e-61ca9bc97482 + Stripe-Version: + - 2025-07-30.basil + X-Stripe-Client-User-Agent: + - '{"bindings_version":"15.4.0","lang":"ruby","lang_version":"3.4.5 p51 (2025-07-16)","platform":"arm64-darwin24","engine":"ruby","publisher":"stripe","uname":"Darwin + Michaels-Mac-mini.local 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:49:24 + PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T8132 arm64","hostname":"Michaels-Mac-mini.local"}' + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 404 + message: Not Found + headers: + Server: + - nginx + Date: + - Sun, 01 Mar 2026 18:27:17 GMT + Content-Type: + - application/json + Content-Length: + - '372' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, HEAD, PUT, PATCH, POST, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, + X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Content-Security-Policy: + - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none'; + img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src + 'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=8Z7MjpoOtCZXVXpLD13P2MRgLN0L_L_n7AzkAi_KE45ayQtlKjSGU1n1bxaozt71bC4gUUs2-MJCLAoy + Idempotency-Key: + - d5702f8f-02e8-4934-861e-61ca9bc97482 + Original-Request: + - req_bJaVbfCwSbzH5Z + Request-Id: + - req_bJaVbfCwSbzH5Z + Stripe-Version: + - 2025-07-30.basil + Vary: + - Origin + X-Stripe-Priority-Routing-Enabled: + - 'true' + X-Stripe-Routing-Context-Priority-Tier: + - api-testmode + X-Wc: + - ABGHIJ + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: | + { + "error": { + "code": "resource_missing", + "doc_url": "https://stripe.com/docs/error-codes/resource-missing", + "message": "No such PaymentMethod: 'does not exist'", + "param": "payment_method", + "request_log_url": "https://dashboard.stripe.com/acct_1T6DoHPnUSFzhT0d/test/workbench/logs?object=req_bJaVbfCwSbzH5Z", + "type": "invalid_request_error" + } + } + recorded_at: Sun, 01 Mar 2026 18:27:17 GMT +recorded_with: VCR 6.4.0 diff --git a/test/vcr_cassettes/list_stripe_payment_methods.yml b/test/vcr_cassettes/list_stripe_payment_methods.yml new file mode 100644 index 000000000..c6ee5aa73 --- /dev/null +++ b/test/vcr_cassettes/list_stripe_payment_methods.yml @@ -0,0 +1,135 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.stripe.com/v1/payment_methods?customer=cus_U4N7YsOE2tKLa1&type=card + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - Stripe/v1 RubyBindings/15.4.0 + Authorization: + - Bearer + X-Stripe-Client-Telemetry: + - '{"last_request_metrics":{"request_id":"req_qx5bUyCRkqAg5F","request_duration_ms":0}}' + Stripe-Version: + - 2025-07-30.basil + X-Stripe-Client-User-Agent: + - '{"bindings_version":"15.4.0","lang":"ruby","lang_version":"3.4.5 p51 (2025-07-16)","platform":"arm64-darwin24","engine":"ruby","publisher":"stripe","uname":"Darwin + Michaels-Mac-mini.local 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:49:24 + PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T8132 arm64","hostname":"Michaels-Mac-mini.local"}' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Sun, 01 Mar 2026 18:17:41 GMT + Content-Type: + - application/json + Content-Length: + - '89' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, HEAD, PUT, PATCH, POST, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, + X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Content-Security-Policy: + - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none'; + img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src + 'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=n5t-RE_IyCr2cum1A5nlIHQyPM7nvPnXdP6kGFlWm2ROstyebRVLEdn4VgDQyUT3Z6am7g-zMUbC-heK + Request-Id: + - req_1LeT82LouwwYAA + Stripe-Version: + - 2025-07-30.basil + Vary: + - Origin + X-Stripe-Priority-Routing-Enabled: + - 'true' + X-Stripe-Routing-Context-Priority-Tier: + - api-testmode + X-Wc: + - ABGHIJ + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: |- + { + "object": "list", + "data": [ + { + "id": "pm_1T6Ea8PnUSFzhT0d7yCb8feV", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": "US", + "line1": null, + "line2": null, + "postal_code": "60612", + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": "pass", + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 6, + "exp_year": 2028, + "fingerprint": "D78TFXGvWpkLdP3B", + "funding": "credit", + "generated_from": null, + "last4": "1111", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1772389148, + "customer": "cus_U4NKMxlVHVQRt9", + "customer_account": null, + "livemode": false, + "metadata": {}, + "type": "card" + } + ], + "has_more": false, + "url": "/v1/payment_methods" + } + recorded_at: Sun, 01 Mar 2026 18:17:41 GMT +recorded_with: VCR 6.4.0 diff --git a/test/vcr_cassettes/list_stripe_payment_methods_failure.yml b/test/vcr_cassettes/list_stripe_payment_methods_failure.yml new file mode 100644 index 000000000..e57bee492 --- /dev/null +++ b/test/vcr_cassettes/list_stripe_payment_methods_failure.yml @@ -0,0 +1,92 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.stripe.com/v1/setup_intents + body: + encoding: UTF-8 + string: customer=2d63fce41cd3470ba66a&payment_method_types[0]=card + headers: + User-Agent: + - Stripe/v1 RubyBindings/15.4.0 + Authorization: + - Bearer + Idempotency-Key: + - 15d400af-6269-4a5a-9d68-bb8c8752295b + Stripe-Version: + - 2025-07-30.basil + X-Stripe-Client-User-Agent: + - '{"bindings_version":"15.4.0","lang":"ruby","lang_version":"3.4.5 p51 (2025-07-16)","platform":"arm64-darwin24","engine":"ruby","publisher":"stripe","uname":"Darwin + Michaels-Mac-mini.local 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:49:24 + PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T8132 arm64","hostname":"Michaels-Mac-mini.local"}' + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 400 + message: Bad Request + headers: + Server: + - nginx + Date: + - Sun, 01 Mar 2026 18:22:06 GMT + Content-Type: + - application/json + Content-Length: + - '367' + Connection: + - keep-alive + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, HEAD, PUT, PATCH, POST, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, + X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Content-Security-Policy: + - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none'; + img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src + 'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=pCUimM3uZ1dnuOWOoRZngWVFBD13jmP2zaRLo07t8CKjaLdS4rPDmXJVDXcpONZFHVkEMihXxhOo3GEE + Idempotency-Key: + - 15d400af-6269-4a5a-9d68-bb8c8752295b + Original-Request: + - req_9qNt3Bw4CMnZ7P + Request-Id: + - req_9qNt3Bw4CMnZ7P + Stripe-Version: + - 2025-07-30.basil + Vary: + - Origin + X-Stripe-Priority-Routing-Enabled: + - 'true' + X-Stripe-Routing-Context-Priority-Tier: + - api-testmode + X-Wc: + - ABGHIJ + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + body: + encoding: UTF-8 + string: | + { + "error": { + "code": "resource_missing", + "doc_url": "https://stripe.com/docs/error-codes/resource-missing", + "message": "No such customer: '2d63fce41cd3470ba66a'", + "param": "customer", + "request_log_url": "https://dashboard.stripe.com/acct_1T6DoHPnUSFzhT0d/test/workbench/logs?object=req_9qNt3Bw4CMnZ7P", + "type": "invalid_request_error" + } + } + recorded_at: Sun, 01 Mar 2026 18:22:06 GMT +recorded_with: VCR 6.4.0 From ffad075b7e346bfcf4413acd03df6407fd869feb Mon Sep 17 00:00:00 2001 From: Michael Crismali Date: Sun, 1 Mar 2026 16:10:29 -0600 Subject: [PATCH 9/9] WIP: system tests for payment methods, non-nullable colums --- .../account/payment_methods_controller.rb | 6 +- app/lib/stripe_checkout.rb | 4 ++ .../account/payment_methods/index.html.erb | 2 +- ...users_and_create_custom_payment_methods.rb | 12 ++-- db/schema.rb | 12 ++-- test/factories/payment_methods.rb | 5 ++ test/lib/stripe_checkout_test.rb | 7 +++ test/models/payment_method_test.rb | 13 ++++ test/system/account/payment_methods_test.rb | 62 +++++++++++++++++++ test/test_helper.rb | 1 + 10 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 test/system/account/payment_methods_test.rb diff --git a/app/controllers/account/payment_methods_controller.rb b/app/controllers/account/payment_methods_controller.rb index 9ebc4e77e..18cc27f96 100644 --- a/app/controllers/account/payment_methods_controller.rb +++ b/app/controllers/account/payment_methods_controller.rb @@ -17,7 +17,9 @@ def index def destroy @payment_method = current_user.payment_methods.find(params[:id]) result = checkout.delete_payment_method(@payment_method) - unless result.success? + if result.success? + flash[:success] = "Successfully deleted payment method" + else flash[:error] = result.error end redirect_to account_payment_methods_path, status: :see_other @@ -26,7 +28,7 @@ def destroy private def checkout - StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) + StripeCheckout.build end end end diff --git a/app/lib/stripe_checkout.rb b/app/lib/stripe_checkout.rb index aa4d48fe3..f6a56f417 100644 --- a/app/lib/stripe_checkout.rb +++ b/app/lib/stripe_checkout.rb @@ -1,6 +1,10 @@ class StripeCheckout attr_accessor :client + def self.build + new(ENV.fetch("STRIPE_API_KEY")) + end + def initialize(api_key) self.client = Stripe::StripeClient.new(api_key) end diff --git a/app/views/account/payment_methods/index.html.erb b/app/views/account/payment_methods/index.html.erb index edf4d0023..9b1dfabc0 100644 --- a/app/views/account/payment_methods/index.html.erb +++ b/app/views/account/payment_methods/index.html.erb @@ -8,7 +8,7 @@ <% @payment_methods.each do |pm| %> - + <%= feather_icon "credit-card" %> <%= pm.display_brand %> <%= pm.last_four %> <%= pm.expire_month %>/<%= pm.expire_year %> diff --git a/db/migrate/20260213221407_add_stripe_customer_id_to_users_and_create_custom_payment_methods.rb b/db/migrate/20260213221407_add_stripe_customer_id_to_users_and_create_custom_payment_methods.rb index d2e4ae20f..fe3159fcb 100644 --- a/db/migrate/20260213221407_add_stripe_customer_id_to_users_and_create_custom_payment_methods.rb +++ b/db/migrate/20260213221407_add_stripe_customer_id_to_users_and_create_custom_payment_methods.rb @@ -5,12 +5,12 @@ def change create_enum :payment_method_status, [:active, :expired, :detached] create_table :payment_methods do |t| - t.belongs_to :user - t.string :stripe_id - t.string :display_brand - t.string :last_four - t.integer :expire_month - t.integer :expire_year + t.belongs_to :user, null: false + t.string :stripe_id, null: false + t.string :display_brand, null: false + t.string :last_four, null: false + t.integer :expire_month, null: false + t.integer :expire_year, null: false t.enum :status, enum_type: :payment_method_status, default: "active", null: false t.timestamps diff --git a/db/schema.rb b/db/schema.rb index 17f5e4892..7cb9b0ade 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -658,12 +658,12 @@ end create_table "payment_methods", force: :cascade do |t| - t.bigint "user_id" - t.string "stripe_id" - t.string "display_brand" - t.string "last_four" - t.integer "expire_month" - t.integer "expire_year" + t.bigint "user_id", null: false + t.string "stripe_id", null: false + t.string "display_brand", null: false + t.string "last_four", null: false + t.integer "expire_month", null: false + t.integer "expire_year", null: false t.enum "status", default: "active", null: false, enum_type: "payment_method_status" t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/test/factories/payment_methods.rb b/test/factories/payment_methods.rb index 6b86d7b96..c9593c4e8 100644 --- a/test/factories/payment_methods.rb +++ b/test/factories/payment_methods.rb @@ -1,5 +1,10 @@ FactoryBot.define do factory :payment_method do + stripe_id { SecureRandom.hex(10) } + display_brand { %w[visa mastercard discover].sample } + last_four { rand(1000..9999).to_s } + expire_month { rand(1..12) } + expire_year { 5.times.map { |n| n.years.from_now.year }.sample } association :user end end diff --git a/test/lib/stripe_checkout_test.rb b/test/lib/stripe_checkout_test.rb index ba297dd2b..cff07dfca 100644 --- a/test/lib/stripe_checkout_test.rb +++ b/test/lib/stripe_checkout_test.rb @@ -9,6 +9,13 @@ class StripeCheckoutTest < ActiveSupport::TestCase assert_equal Stripe::StripeClient, stripe_checkout.client.class end + test ".build creates a stripe client without needing to pass the API key" do + stripe_checkout = StripeCheckout.build + + assert stripe_checkout.client + assert_equal Stripe::StripeClient, stripe_checkout.client.class + end + test "#ensure_customer_exists creates a stripe customer and saves its id to the given user when the user lacks a stripe id" do stripe_checkout = StripeCheckout.new(ENV.fetch("STRIPE_API_KEY")) user = create(:user, stripe_customer_id: nil) diff --git a/test/models/payment_method_test.rb b/test/models/payment_method_test.rb index 0cd146630..680d35884 100644 --- a/test/models/payment_method_test.rb +++ b/test/models/payment_method_test.rb @@ -1,6 +1,19 @@ require "test_helper" class PaymentMethodTest < ActiveSupport::TestCase + [ + :payment_method, + [:payment_method, :active], + [:payment_method, :expired], + [:payment_method, :detached] + ].each do |factory_name| + test "#{Array(factory_name).join(", ")} is a valid factory/trait" do + payment_method = build(*factory_name) + payment_method.valid? + assert_equal({}, payment_method.errors.messages) + end + end + test "detach from a user" do payment_method = create(:payment_method) assert_equal PaymentMethod.statuses[:active], payment_method.status diff --git a/test/system/account/payment_methods_test.rb b/test/system/account/payment_methods_test.rb new file mode 100644 index 000000000..47e7faa1b --- /dev/null +++ b/test/system/account/payment_methods_test.rb @@ -0,0 +1,62 @@ +require "application_system_test_case" + +module Account + class PaymentMethodsTest < ApplicationSystemTestCase + setup do + @member = create(:verified_member_with_membership) + + login_as @member.user + end + + test "viewing payment methods" do + mock_stripe_checkout = Minitest::Mock.new + mock_stripe_checkout.expect :sync_payment_methods, nil, [@member.user] + + ignored_payment_method = create(:payment_method, :active) + active_payment_method = create(:payment_method, :active, user: @member.user) + expired_payment_method = create(:payment_method, :expired, user: @member.user) + detached_payment_method = create(:payment_method, :detached, user: @member.user) + + StripeCheckout.stub :build, mock_stripe_checkout do + visit account_payment_methods_url + + assert_text active_payment_method.last_four + end + + refute_text ignored_payment_method.last_four + refute_text expired_payment_method.last_four + refute_text detached_payment_method.last_four + end + + test "creating a payment method" do + skip + end + + test "deleting a payment method" do + payment_method_to_keep = create(:payment_method, :active, user: @member.user) + payment_method_to_delete = create(:payment_method, :active, user: @member.user) + + mock_stripe_checkout = Minitest::Mock.new + mock_stripe_checkout.expect :sync_payment_methods, nil, [@member.user] + mock_stripe_checkout.expect :sync_payment_methods, nil, [@member.user] + mock_stripe_checkout.expect :delete_payment_method, Result.success(nil) do |given_payment_method| + given_payment_method.detach! + end + + StripeCheckout.stub :build, mock_stripe_checkout do + visit account_payment_methods_url + + assert_text payment_method_to_keep.last_four + assert_text payment_method_to_delete.last_four + + within("#payment-method-#{payment_method_to_delete.id}") do + click_button "Delete" + end + + assert_text "Successfully deleted payment method" + assert_text payment_method_to_keep.last_four + refute_text payment_method_to_delete.last_four + end + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 354e29838..c61e9932f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -17,6 +17,7 @@ config.cassette_library_dir = "test/vcr_cassettes" config.hook_into :webmock config.filter_sensitive_data("") { ENV.fetch("STRIPE_API_KEY") } + config.allow_http_connections_when_no_cassette = true end class ActiveSupport::TestCase