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? %> + + + + + + + + + <% @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/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