diff --git a/.env b/.env
index 2087947da..b07f61f88 100644
--- a/.env
+++ b/.env
@@ -13,6 +13,12 @@ SQUARE_ACCESS_TOKEN=SQACCESSTOKEN1234
SQUARE_LOCATION_ID=SQLOCID1234
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=
+
## Google Calendar (see README for details)
#
# Path to Google service account credentials (used in prod)
@@ -47,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/Gemfile b/Gemfile
index 32d81a9ac..23596060a 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"
@@ -91,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 f625a8c8b..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)
@@ -571,6 +576,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)
@@ -593,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)
@@ -601,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)
@@ -674,11 +685,14 @@ DEPENDENCIES
standard-rails
stimulus-rails
store_model
+ stripe
timecop
translation
turbo-rails
twilio-ruby (~> 7.10)
+ vcr
web-console (>= 3.3.0)
+ webmock
RUBY VERSION
ruby 3.4.5p51
diff --git a/app/controllers/account/payment_methods_controller.rb b/app/controllers/account/payment_methods_controller.rb
new file mode 100644
index 000000000..18cc27f96
--- /dev/null
+++ b/app/controllers/account/payment_methods_controller.rb
@@ -0,0 +1,34 @@
+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)
+ if result.success?
+ flash[:success] = "Successfully deleted payment method"
+ else
+ flash[:error] = result.error
+ end
+ redirect_to account_payment_methods_path, status: :see_other
+ end
+
+ private
+
+ def checkout
+ StripeCheckout.build
+ end
+ end
+end
diff --git a/app/controllers/stripe_controller.rb b/app/controllers/stripe_controller.rb
new file mode 100644
index 000000000..2af187f73
--- /dev/null
+++ b/app/controllers/stripe_controller.rb
@@ -0,0 +1,41 @@
+class StripeController < ApplicationController
+ skip_before_action :verify_authenticity_token, only: %i[webhook]
+
+ def webhook
+ payload = request.body.read
+ event = nil
+
+ begin
+ event = Stripe::Event.construct_from(
+ JSON.parse(payload, symbolize_names: true)
+ )
+ rescue JSON::ParserError
+ # 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
+ Rails.logger.info session
+ else
+ Rails.logger.info "Unhandled event type: #{event.type}"
+ end
+ render json: {message: :success}
+ 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
new file mode 100644
index 000000000..f6a56f417
--- /dev/null
+++ b/app/lib/stripe_checkout.rb
@@ -0,0 +1,62 @@
+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
+
+ 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
+
+ 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
+
+ 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
+
+ def list_payment_methods(user)
+ payment_methods = client.v1.payment_methods.list({
+ customer: user.stripe_customer_id,
+ type: "card"
+ })
+ Result.success(payment_methods)
+ rescue Stripe::InvalidRequestError => e
+ Result.failure(e)
+ end
+
+ 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(setup_intent.client_secret)
+ rescue Stripe::InvalidRequestError => e
+ Result.failure(e)
+ 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..b2898a65b 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -18,6 +18,7 @@ class User < ApplicationRecord
}
has_one :member
+ 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/payment_methods/index.html.erb b/app/views/account/payment_methods/index.html.erb
new file mode 100644
index 000000000..9b1dfabc0
--- /dev/null
+++ b/app/views/account/payment_methods/index.html.erb
@@ -0,0 +1,32 @@
+<% if @payment_methods.any? %>
+
+
+ | Card type |
+ Last 4 |
+ Expires |
+ |
+
+
+ <% @payment_methods.each do |pm| %>
+
+ | <%= 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" %> |
+
+ <% end %>
+
+
+
+ <%= 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/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/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..90c43fe03 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
+ if FeatureFlags.stripe_payments_enabled?
+ resources :payment_methods, only: [:index, :new, :create, :destroy]
+ end
end
end
@@ -236,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
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
new file mode 100644
index 000000000..fe3159fcb
--- /dev/null
+++ b/db/migrate/20260213221407_add_stripe_customer_id_to_users_and_create_custom_payment_methods.rb
@@ -0,0 +1,20 @@
+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, 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
+
+ t.index :stripe_id, unique: true
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c767bdca0..7cb9b0ade 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"
@@ -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
new file mode 100644
index 000000000..c9593c4e8
--- /dev/null
+++ b/test/factories/payment_methods.rb
@@ -0,0 +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
new file mode 100644
index 000000000..cff07dfca
--- /dev/null
+++ b/test/lib/stripe_checkout_test.rb
@@ -0,0 +1,174 @@
+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 ".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)
+
+ 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
+
+ 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/models/payment_method_test.rb b/test/models/payment_method_test.rb
new file mode 100644
index 000000000..680d35884
--- /dev/null
+++ b/test/models/payment_method_test.rb
@@ -0,0 +1,25 @@
+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
+
+ payment_method.detach!
+ assert_equal PaymentMethod.statuses[:detached], payment_method.status
+ refute PaymentMethod.active.find_by(id: payment_method.id)
+ end
+end
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 4989c3c2a..c61e9932f 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,13 @@
# 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") }
+ config.allow_http_connections_when_no_cassette = true
+end
+
class ActiveSupport::TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
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/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
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