├── test
├── dummy
│ ├── log
│ │ └── .keep
│ ├── lib
│ │ └── assets
│ │ │ └── .keep
│ ├── public
│ │ ├── favicon.ico
│ │ ├── apple-touch-icon.png
│ │ ├── apple-touch-icon-precomposed.png
│ │ ├── 500.html
│ │ ├── 422.html
│ │ └── 404.html
│ ├── app
│ │ ├── assets
│ │ │ ├── images
│ │ │ │ └── .keep
│ │ │ ├── config
│ │ │ │ └── manifest.js
│ │ │ └── stylesheets
│ │ │ │ └── application.css
│ │ ├── models
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ ├── account.rb
│ │ │ ├── application_record.rb
│ │ │ ├── team.rb
│ │ │ └── user.rb
│ │ ├── controllers
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ ├── main_controller.rb
│ │ │ ├── paddle
│ │ │ │ ├── payment_methods_controller.rb
│ │ │ │ ├── charges_controller.rb
│ │ │ │ └── subscriptions_controller.rb
│ │ │ ├── application_controller.rb
│ │ │ ├── payment_methods_controller.rb
│ │ │ ├── braintree
│ │ │ │ ├── payment_methods_controller.rb
│ │ │ │ ├── charges_controller.rb
│ │ │ │ └── subscriptions_controller.rb
│ │ │ └── stripe
│ │ │ │ ├── payment_methods_controller.rb
│ │ │ │ ├── charges
│ │ │ │ └── imports_controller.rb
│ │ │ │ ├── checkouts_controller.rb
│ │ │ │ ├── charges_controller.rb
│ │ │ │ └── subscriptions_controller.rb
│ │ ├── views
│ │ │ ├── layouts
│ │ │ │ ├── mailer.text.erb
│ │ │ │ └── mailer.html.erb
│ │ │ ├── paddle
│ │ │ │ ├── charges
│ │ │ │ │ ├── show.html.erb
│ │ │ │ │ ├── index.html.erb
│ │ │ │ │ └── new.html.erb
│ │ │ │ ├── payment_methods
│ │ │ │ │ └── edit.html.erb
│ │ │ │ └── subscriptions
│ │ │ │ │ ├── index.html.erb
│ │ │ │ │ ├── show.html.erb
│ │ │ │ │ └── new.html.erb
│ │ │ ├── stripe
│ │ │ │ ├── charges
│ │ │ │ │ ├── show.html.erb
│ │ │ │ │ ├── imports
│ │ │ │ │ │ └── new.html.erb
│ │ │ │ │ ├── index.html.erb
│ │ │ │ │ └── new.html.erb
│ │ │ │ ├── subscriptions
│ │ │ │ │ ├── index.html.erb
│ │ │ │ │ ├── show.html.erb
│ │ │ │ │ └── new.html.erb
│ │ │ │ ├── checkouts
│ │ │ │ │ └── show.html.erb
│ │ │ │ └── payment_methods
│ │ │ │ │ └── edit.html.erb
│ │ │ ├── braintree
│ │ │ │ ├── charges
│ │ │ │ │ ├── show.html.erb
│ │ │ │ │ ├── index.html.erb
│ │ │ │ │ └── new.html.erb
│ │ │ │ ├── subscriptions
│ │ │ │ │ ├── index.html.erb
│ │ │ │ │ ├── show.html.erb
│ │ │ │ │ └── new.html.erb
│ │ │ │ └── payment_methods
│ │ │ │ │ └── edit.html.erb
│ │ │ ├── main
│ │ │ │ └── show.html.erb
│ │ │ └── payment_methods
│ │ │ │ └── show.html.erb
│ │ ├── helpers
│ │ │ ├── application_helper.rb
│ │ │ └── current_helper.rb
│ │ ├── jobs
│ │ │ └── application_job.rb
│ │ ├── webhooks
│ │ │ └── charge_succeeded.rb
│ │ ├── channels
│ │ │ └── application_cable
│ │ │ │ ├── channel.rb
│ │ │ │ └── connection.rb
│ │ ├── mailers
│ │ │ └── application_mailer.rb
│ │ └── javascript
│ │ │ ├── processors
│ │ │ └── paddle.js
│ │ │ ├── controllers
│ │ │ ├── application.js
│ │ │ ├── index.js
│ │ │ └── braintree_controller.js
│ │ │ └── application.js
│ ├── vendor
│ │ └── javascript
│ │ │ └── .keep
│ ├── .browserslistrc
│ ├── config
│ │ ├── database.yml
│ │ ├── spring.rb
│ │ ├── environment.rb
│ │ ├── cable.yml
│ │ ├── initializers
│ │ │ ├── session_store.rb
│ │ │ ├── mime_types.rb
│ │ │ ├── application_controller_renderer.rb
│ │ │ ├── filter_parameter_logging.rb
│ │ │ ├── cookies_serializer.rb
│ │ │ ├── backtrace_silencers.rb
│ │ │ ├── wrap_parameters.rb
│ │ │ ├── assets.rb
│ │ │ ├── pay.rb
│ │ │ └── inflections.rb
│ │ ├── boot.rb
│ │ ├── importmap.rb
│ │ ├── locales
│ │ │ └── en.yml
│ │ ├── application.rb
│ │ ├── secrets.yml
│ │ ├── routes.rb
│ │ └── storage.yml
│ ├── pay_test.db
│ ├── bin
│ │ ├── rake
│ │ ├── importmap
│ │ ├── rails
│ │ ├── webpack
│ │ ├── webpack-dev-server
│ │ └── setup
│ ├── config.ru
│ ├── Rakefile
│ └── db
│ │ └── migrate
│ │ └── 20170205000000_create_users.rb
├── fixtures
│ ├── accounts.yml
│ ├── teams.yml
│ ├── pay
│ │ ├── merchants.yml
│ │ ├── charges.yml
│ │ ├── payment_methods.yml
│ │ └── customers.yml
│ └── users.yml
├── models
│ └── pay
│ │ ├── payment_method_test.rb
│ │ ├── merchant_test.rb
│ │ ├── customer_test.rb
│ │ └── webhook_test.rb
├── pay
│ ├── payment_test.rb
│ ├── stripe
│ │ ├── webhooks
│ │ │ ├── subscription_deleted_test.rb
│ │ │ ├── subscription_created_test.rb
│ │ │ ├── payment_method_detached_test.rb
│ │ │ ├── account_updated_test.rb
│ │ │ ├── charge_succeeded_test.rb
│ │ │ ├── charge_refunded_test.rb
│ │ │ ├── payment_method_attached_test.rb
│ │ │ ├── payment_action_required_test.rb
│ │ │ ├── customer_updated_test.rb
│ │ │ ├── payment_failed_test.rb
│ │ │ ├── payment_method_updated_test.rb
│ │ │ ├── customer_deleted_test.rb
│ │ │ ├── subscription_renewing_test.rb
│ │ │ └── checkout_session_completed_test.rb
│ │ ├── error_test.rb
│ │ └── checkout_test.rb
│ ├── paddle
│ │ ├── webhooks
│ │ │ ├── signature_verifier_test.rb
│ │ │ ├── subscription_payment_refunded_test.rb
│ │ │ ├── subscription_created_test.rb
│ │ │ └── subscription_cancelled_test.rb
│ │ ├── error_test.rb
│ │ └── charge_test.rb
│ ├── braintree
│ │ ├── webhooks
│ │ │ ├── subscription_canceled_test.rb
│ │ │ ├── subscription_trial_ended_test.rb
│ │ │ └── subscription_charged_successfully_test.rb
│ │ ├── error_test.rb
│ │ └── charge_test.rb
│ ├── fake_processor
│ │ └── charge_test.rb
│ ├── adapter_test.rb
│ ├── stripe_test.rb
│ ├── billable
│ │ └── sync_customer_test.rb
│ └── currency_test.rb
├── support
│ ├── braintree.rb
│ ├── fixtures
│ │ ├── braintree
│ │ │ └── subscription_trial_ended.json
│ │ ├── paddle
│ │ │ ├── verification
│ │ │ │ └── paddle_public_key.pem
│ │ │ ├── subscription_cancelled.json
│ │ │ ├── subscription_created.json
│ │ │ └── subscription_payment_refunded.json
│ │ └── stripe
│ │ │ ├── customer.deleted.json
│ │ │ ├── customer.updated.json
│ │ │ ├── payment_method.attached.json
│ │ │ ├── payment_method.detached.json
│ │ │ └── payment_method.updated.json
│ └── vcr.rb
├── jobs
│ └── pay
│ │ └── customer_sync_job_test.rb
├── routes
│ └── webhooks_test.rb
├── controllers
│ └── pay
│ │ └── webhooks
│ │ ├── paddle_controller_test.rb
│ │ ├── braintree_controller_test.rb
│ │ └── stripe_controller_test.rb
└── mailers
│ └── previews
│ └── pay
│ └── user_mailer_preview.rb
├── app
├── assets
│ ├── images
│ │ └── pay
│ │ │ └── .keep
│ ├── config
│ │ └── pay_manifest.js
│ ├── javascripts
│ │ └── pay
│ │ │ └── application.js
│ └── stylesheets
│ │ └── pay
│ │ └── application.css
├── helpers
│ └── pay
│ │ └── application_helper.rb
├── jobs
│ └── pay
│ │ ├── application_job.rb
│ │ └── customer_sync_job.rb
├── controllers
│ └── pay
│ │ ├── application_controller.rb
│ │ ├── payments_controller.rb
│ │ └── webhooks
│ │ ├── braintree_controller.rb
│ │ ├── paddle_controller.rb
│ │ └── stripe_controller.rb
├── models
│ └── pay
│ │ ├── application_record.rb
│ │ ├── merchant.rb
│ │ ├── webhook.rb
│ │ └── payment_method.rb
├── mailers
│ └── pay
│ │ ├── application_mailer.rb
│ │ └── user_mailer.rb
└── views
│ ├── pay
│ ├── user_mailer
│ │ ├── payment_failed.html.erb
│ │ ├── subscription_trial_ended.html.erb
│ │ ├── subscription_trial_will_end.html.erb
│ │ ├── payment_action_required.html.erb
│ │ ├── subscription_renewing.html.erb
│ │ ├── receipt.html.erb
│ │ └── refund.html.erb
│ └── stripe
│ │ └── _checkout_button.html.erb
│ └── layouts
│ └── pay
│ └── application.html.erb
├── lib
├── pay
│ ├── paddle
│ │ ├── response.rb
│ │ ├── error.rb
│ │ ├── webhooks
│ │ │ ├── subscription_created.rb
│ │ │ ├── subscription_payment_refunded.rb
│ │ │ ├── subscription_cancelled.rb
│ │ │ └── subscription_updated.rb
│ │ └── charge.rb
│ ├── version.rb
│ ├── fake_processor
│ │ ├── error.rb
│ │ ├── charge.rb
│ │ ├── payment_method.rb
│ │ └── merchant.rb
│ ├── stripe
│ │ ├── error.rb
│ │ └── webhooks
│ │ │ ├── checkout_session_async_payment_succeeded.rb
│ │ │ ├── subscription_created.rb
│ │ │ ├── subscription_updated.rb
│ │ │ ├── payment_method_detached.rb
│ │ │ ├── payment_method_attached.rb
│ │ │ ├── subscription_deleted.rb
│ │ │ ├── account_updated.rb
│ │ │ ├── charge_refunded.rb
│ │ │ ├── charge_succeeded.rb
│ │ │ ├── payment_method_updated.rb
│ │ │ ├── payment_intent_succeeded.rb
│ │ │ ├── payment_failed.rb
│ │ │ ├── customer_deleted.rb
│ │ │ ├── customer_updated.rb
│ │ │ ├── payment_action_required.rb
│ │ │ ├── subscription_trial_will_end.rb
│ │ │ ├── subscription_renewing.rb
│ │ │ └── checkout_session_completed.rb
│ ├── webhooks
│ │ ├── process_job.rb
│ │ └── delegator.rb
│ ├── braintree
│ │ ├── authorization_error.rb
│ │ ├── webhooks
│ │ │ ├── subscription_canceled.rb
│ │ │ ├── subscription_trial_ended.rb
│ │ │ ├── subscription_expired.rb
│ │ │ ├── subscription_went_past_due.rb
│ │ │ ├── subscription_went_active.rb
│ │ │ ├── subscription_charged_successfully.rb
│ │ │ └── subscription_charged_unsuccessfully.rb
│ │ ├── error.rb
│ │ ├── payment_method.rb
│ │ └── charge.rb
│ ├── webhooks.rb
│ ├── nano_id.rb
│ ├── fake_processor.rb
│ ├── adapter.rb
│ ├── errors.rb
│ ├── billable
│ │ └── sync_customer.rb
│ ├── payment.rb
│ └── engine.rb
├── generators
│ └── pay
│ │ ├── views_generator.rb
│ │ └── email_views_generator.rb
└── tasks
│ └── pay.rake
├── .tool-versions
├── docs
├── paddle
│ ├── 3_webhooks.md
│ └── 2_javascript.md
├── braintree
│ ├── 2_webhooks.md
│ └── 1_overview.md
├── images
│ └── logo.png
├── 8_customizing_models.md
├── marketplaces
│ └── braintree.md
├── stripe
│ ├── 2_credentials.md
│ ├── 5_webhooks.md
│ ├── 6_metered_billing.md
│ └── 7_stripe_tax.md
└── 9_testing.md
├── .standard.yml
├── .overcommit.yml
├── Appraisals
├── bin
├── test_databases
└── rails
├── config
└── routes.rb
├── .rubocop.yml
├── Rakefile
├── pay.gemspec
├── gemfiles
├── rails_6_1.gemfile
├── rails_7.gemfile
└── rails_main.gemfile
├── MIT-LICENSE
└── Gemfile
/test/dummy/log/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/assets/images/pay/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/pay/paddle/response.rb:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/lib/assets/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | ruby 3.2.2
2 |
--------------------------------------------------------------------------------
/test/dummy/public/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/app/assets/images/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/app/models/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/vendor/javascript/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/dummy/.browserslistrc:
--------------------------------------------------------------------------------
1 | defaults
2 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/paddle/3_webhooks.md:
--------------------------------------------------------------------------------
1 | # Paddle Webhooks
2 |
--------------------------------------------------------------------------------
/docs/braintree/2_webhooks.md:
--------------------------------------------------------------------------------
1 | # Braintree Webhooks
2 |
--------------------------------------------------------------------------------
/test/dummy/public/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/braintree/1_overview.md:
--------------------------------------------------------------------------------
1 | # Using Pay with Braintree
2 |
--------------------------------------------------------------------------------
/test/dummy/app/views/layouts/mailer.text.erb:
--------------------------------------------------------------------------------
1 | <%= yield %>
2 |
--------------------------------------------------------------------------------
/test/fixtures/accounts.yml:
--------------------------------------------------------------------------------
1 | one:
2 | email: one@team.org
3 |
--------------------------------------------------------------------------------
/lib/pay/version.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | VERSION = "6.6.1"
3 | end
4 |
--------------------------------------------------------------------------------
/test/dummy/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | module ApplicationHelper
2 | end
3 |
--------------------------------------------------------------------------------
/test/dummy/app/jobs/application_job.rb:
--------------------------------------------------------------------------------
1 | class ApplicationJob < ActiveJob::Base
2 | end
3 |
--------------------------------------------------------------------------------
/test/dummy/app/models/account.rb:
--------------------------------------------------------------------------------
1 | class Account < ApplicationRecord
2 | pay_merchant
3 | end
4 |
--------------------------------------------------------------------------------
/test/dummy/config/database.yml:
--------------------------------------------------------------------------------
1 | test:
2 | adapter: sqlite3
3 | database: db/test.sqlite3
4 |
--------------------------------------------------------------------------------
/.standard.yml:
--------------------------------------------------------------------------------
1 | ignore:
2 | - 'test/dummy/**/*'
3 | - 'lib/generators/active_record/templates/*'
4 |
--------------------------------------------------------------------------------
/app/helpers/pay/application_helper.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module ApplicationHelper
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/docs/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/melody413/Payment-with-Ruby/HEAD/docs/images/logo.png
--------------------------------------------------------------------------------
/test/dummy/pay_test.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/melody413/Payment-with-Ruby/HEAD/test/dummy/pay_test.db
--------------------------------------------------------------------------------
/test/fixtures/teams.yml:
--------------------------------------------------------------------------------
1 | one:
2 | email: one@team.org
3 | name: "Team One"
4 | owner: none (User)
5 |
--------------------------------------------------------------------------------
/app/jobs/pay/application_job.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | class ApplicationJob < ActiveJob::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/app/assets/config/pay_manifest.js:
--------------------------------------------------------------------------------
1 | //= link_directory ../javascripts/pay .js
2 | //= link_directory ../stylesheets/pay .css
3 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/main_controller.rb:
--------------------------------------------------------------------------------
1 | class MainController < ApplicationController
2 | def show
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require_relative '../config/boot'
3 | require 'rake'
4 | Rake.application.run
5 |
--------------------------------------------------------------------------------
/test/dummy/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | class ApplicationRecord < ActiveRecord::Base
2 | self.abstract_class = true
3 | end
4 |
--------------------------------------------------------------------------------
/test/dummy/bin/importmap:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require_relative "../config/application"
4 | require "importmap/commands"
5 |
--------------------------------------------------------------------------------
/lib/pay/fake_processor/error.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module FakeProcessor
3 | class Error < Pay::Error
4 | end
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/test/dummy/app/webhooks/charge_succeeded.rb:
--------------------------------------------------------------------------------
1 | class ChargeSucceeded
2 | def call(event)
3 | Rails.logger.debug event
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/test/dummy/app/channels/application_cable/channel.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Channel < ActionCable::Channel::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/app/channels/application_cable/connection.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Connection < ActionCable::Connection::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/lib/pay/paddle/error.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Paddle
3 | class Error < Pay::Error
4 | delegate :message, to: :cause
5 | end
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/pay/stripe/error.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | class Error < Pay::Error
4 | delegate :message, to: :cause
5 | end
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/paddle/payment_methods_controller.rb:
--------------------------------------------------------------------------------
1 | class Paddle::PaymentMethodsController < ApplicationController
2 | def edit
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/app/mailers/application_mailer.rb:
--------------------------------------------------------------------------------
1 | class ApplicationMailer < ActionMailer::Base
2 | default from: "from@example.com"
3 | layout "mailer"
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/config/spring.rb:
--------------------------------------------------------------------------------
1 | %w[
2 | .ruby-version
3 | .rbenv-vars
4 | tmp/restart.txt
5 | tmp/caching-dev.txt
6 | ].each { |path| Spring.watch(path) }
7 |
--------------------------------------------------------------------------------
/.overcommit.yml:
--------------------------------------------------------------------------------
1 | PreCommit:
2 | StandardRB:
3 | enabled: true
4 | required: true
5 | command: ['bundle', 'exec', 'standardrb']
6 | flags: ['--fix']
7 |
--------------------------------------------------------------------------------
/test/dummy/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | APP_PATH = File.expand_path('../config/application', __dir__)
3 | require_relative '../config/boot'
4 | require 'rails/commands'
5 |
--------------------------------------------------------------------------------
/app/controllers/pay/application_controller.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | class ApplicationController < ActionController::Base
3 | protect_from_forgery with: :exception
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/test/dummy/app/javascript/processors/paddle.js:
--------------------------------------------------------------------------------
1 | document.addEventListener("turbo:load", () => {
2 | Paddle.Environment.set('sandbox');
3 | Paddle.Setup({ vendor: 924 });
4 | })
5 |
--------------------------------------------------------------------------------
/test/dummy/config/environment.rb:
--------------------------------------------------------------------------------
1 | # Load the Rails application.
2 | require_relative "application"
3 |
4 | # Initialize the Rails application.
5 | Rails.application.initialize!
6 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | class ApplicationController < ActionController::Base
2 | include CurrentHelper
3 | protect_from_forgery with: :exception
4 | end
5 |
--------------------------------------------------------------------------------
/test/dummy/config/cable.yml:
--------------------------------------------------------------------------------
1 | development:
2 | adapter: async
3 |
4 | test:
5 | adapter: async
6 |
7 | production:
8 | adapter: redis
9 | url: redis://localhost:6379/1
10 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/session_store.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | Rails.application.config.session_store :cookie_store, key: "_dummy_session"
4 |
--------------------------------------------------------------------------------
/test/models/pay/payment_method_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::PaymentMethodTest < ActiveSupport::TestCase
4 | # test "the truth" do
5 | # assert true
6 | # end
7 | end
8 |
--------------------------------------------------------------------------------
/test/dummy/app/assets/config/manifest.js:
--------------------------------------------------------------------------------
1 | //= link_tree ../images
2 | //= link_directory ../stylesheets .css
3 | //= link_tree ../../javascript .js
4 | //= link_tree ../../../vendor/javascript .js
5 |
--------------------------------------------------------------------------------
/test/dummy/config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require_relative "config/environment"
4 |
5 | run Rails.application
6 | Rails.application.load_server
7 |
--------------------------------------------------------------------------------
/app/models/pay/application_record.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | class ApplicationRecord < Pay.model_parent_class.constantize
3 | self.abstract_class = true
4 | self.table_name_prefix = "pay_"
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new mime types for use in respond_to blocks:
4 | # Mime::Type.register "text/richtext", :rtf
5 |
--------------------------------------------------------------------------------
/test/dummy/app/models/team.rb:
--------------------------------------------------------------------------------
1 | class Team < ApplicationRecord
2 | pay_customer
3 | pay_merchant
4 |
5 | belongs_to :owner, polymorphic: true
6 |
7 | def email
8 | owner.email
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/app/mailers/pay/application_mailer.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | class ApplicationMailer < ActionMailer::Base
3 | default from: Pay.support_email || ApplicationMailer.default_params[:from]
4 | layout "mailer"
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/lib/pay/webhooks/process_job.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Webhooks
3 | class ProcessJob < ApplicationJob
4 | def perform(pay_webhook)
5 | pay_webhook.process!
6 | end
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/payment_methods_controller.rb:
--------------------------------------------------------------------------------
1 | class PaymentMethodsController < ApplicationController
2 | def show
3 | @payment_method = current_user.payment_processor&.default_payment_method
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/checkout_session_async_payment_succeeded.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class CheckoutSessionAsyncPaymentSucceeded < CheckoutSessionCompleted
5 | end
6 | end
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/application_controller_renderer.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # ApplicationController.renderer.defaults.merge!(
4 | # http_host: 'example.org',
5 | # https: false
6 | # )
7 |
--------------------------------------------------------------------------------
/Appraisals:
--------------------------------------------------------------------------------
1 | appraise "rails-6-1" do
2 | gem "rails", "~> 6.1.0"
3 | end
4 |
5 | appraise "rails-7" do
6 | gem "rails", "~> 7.0.0"
7 | end
8 |
9 | appraise "rails-main" do
10 | gem "rails", github: "rails/rails", branch: "main"
11 | end
12 |
--------------------------------------------------------------------------------
/lib/pay/braintree/authorization_error.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Braintree
3 | class AuthorizationError < Braintree::Error
4 | def message
5 | I18n.t("pay.errors.braintree.authorization")
6 | end
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test/dummy/app/views/paddle/charges/show.html.erb:
--------------------------------------------------------------------------------
1 |
Paddle Charge
2 |
3 | <%= @charge.pretty_inspect %>
4 |
5 | Actions
6 | <%= button_to_to "Refund", refund_paddle_charge_path(@charge), method: :patch, class: "d-block" %>
7 |
--------------------------------------------------------------------------------
/test/dummy/app/views/stripe/charges/show.html.erb:
--------------------------------------------------------------------------------
1 | Stripe Charge
2 |
3 | <%= @charge.pretty_inspect %>
4 |
5 | Actions
6 | <%= button_to "Refund", refund_stripe_charge_path(@charge), method: :patch, class: "d-block" %>
7 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/filter_parameter_logging.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Configure sensitive parameters which will be filtered from the log file.
4 | Rails.application.config.filter_parameters += [:password]
5 |
--------------------------------------------------------------------------------
/test/dummy/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require_relative "config/application"
5 |
6 | Rails.application.load_tasks
7 |
--------------------------------------------------------------------------------
/test/dummy/app/views/braintree/charges/show.html.erb:
--------------------------------------------------------------------------------
1 | Braintree Charge
2 |
3 | <%= @charge.pretty_inspect %>
4 |
5 | Actions
6 | <%= button_to "Refund", refund_braintree_charge_path(@charge), method: :patch, class: "d-block" %>
7 |
--------------------------------------------------------------------------------
/test/dummy/config/boot.rb:
--------------------------------------------------------------------------------
1 | # Set up gems listed in the Gemfile.
2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__)
3 | ENV["RAILS_ENV"] ||= "test"
4 |
5 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
6 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__)
7 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/cookies_serializer.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Specify a serializer for the signed and encrypted cookie jars.
4 | # Valid options are :json, :marshal, and :hybrid.
5 | Rails.application.config.action_dispatch.cookies_serializer = :json
6 |
--------------------------------------------------------------------------------
/bin/test_databases:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | echo "Testing Pay against SQLite3"
4 | rails test
5 |
6 | echo "Testing Pay against PostgreSQL"
7 | DATABASE_URL=postgres://127.0.0.1/pay_test rails test
8 |
9 | echo "Testing Pay against MySQL"
10 | DATABASE_URL=mysql2://root:@127.0.0.1/pay_test rails test
11 |
--------------------------------------------------------------------------------
/test/dummy/app/javascript/controllers/application.js:
--------------------------------------------------------------------------------
1 | import { Application } from "@hotwired/stimulus"
2 |
3 | const application = Application.start()
4 |
5 | // Configure Stimulus development experience
6 | application.debug = false
7 | window.Stimulus = application
8 |
9 | export { application }
10 |
11 |
--------------------------------------------------------------------------------
/lib/pay/paddle/webhooks/subscription_created.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Paddle
3 | module Webhooks
4 | class SubscriptionCreated
5 | def call(event)
6 | Pay::Paddle::Subscription.sync(event.subscription_id, object: event)
7 | end
8 | end
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/test/pay/payment_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Payment::Test < ActiveSupport::TestCase
4 | test "amount_with_currency" do
5 | fake_payment_intent = OpenStruct.new(amount: 12_34, currency: "usd")
6 | assert_equal "$12.34", Pay::Payment.new(fake_payment_intent).amount_with_currency
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/test/dummy/app/views/layouts/mailer.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 | <%= yield %>
12 |
13 |
14 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/subscription_created.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class SubscriptionCreated
5 | def call(event)
6 | Pay::Stripe::Subscription.sync(event.data.object.id, stripe_account: event.try(:account))
7 | end
8 | end
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/subscription_updated.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class SubscriptionUpdated
5 | def call(event)
6 | Pay::Stripe::Subscription.sync(event.data.object.id, stripe_account: event.try(:account))
7 | end
8 | end
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/app/jobs/pay/customer_sync_job.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | class CustomerSyncJob < ApplicationJob
3 | def perform(pay_customer_id)
4 | Pay::Customer.find(pay_customer_id).update_customer!
5 | rescue ActiveRecord::RecordNotFound
6 | Rails.logger.info "Couldn't find a Pay::Customer with ID = #{pay_customer_id}"
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/generators/pay/views_generator.rb:
--------------------------------------------------------------------------------
1 | require "rails/generators"
2 |
3 | module Pay
4 | module Generators
5 | class ViewsGenerator < Rails::Generators::Base
6 | source_root File.expand_path("../../../..", __FILE__)
7 |
8 | def copy_views
9 | directory "app/views/pay", "app/views/pay"
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/payment_method_detached.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class PaymentMethodDetached
5 | def call(event)
6 | object = event.data.object
7 | Pay::PaymentMethod.find_by_processor_and_id(:stripe, object.id)&.destroy
8 | end
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/payment_method_attached.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class PaymentMethodAttached
5 | def call(event)
6 | object = event.data.object
7 | Pay::Stripe::PaymentMethod.sync(object.id, stripe_account: event.try(:account))
8 | end
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/pay/webhooks.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Webhooks
3 | autoload :Delegator, "pay/webhooks/delegator"
4 | autoload :ProcessJob, "pay/webhooks/process_job"
5 |
6 | class << self
7 | delegate :configure, :instrument, to: :delegator
8 |
9 | def delegator
10 | @delegator ||= Delegator.new
11 | end
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/dummy/app/helpers/current_helper.rb:
--------------------------------------------------------------------------------
1 | module CurrentHelper
2 | def current_user
3 | @current_user ||= User.first || User.create!(email: "test@user.com", first_name: "Test", last_name: "User")
4 | end
5 |
6 | def current_team
7 | @current_user ||= Team.first || Team.create!(name: "Test Team", email: "test@team.com", owner: current_user)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test/models/pay/merchant_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::MerchantTest < ActiveSupport::TestCase
4 | test "should return stripe connect onboarding status" do
5 | merchant = Pay::Merchant.new
6 | refute merchant.onboarding_complete?
7 |
8 | merchant.onboarding_complete = true
9 | assert merchant.onboarding_complete?
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/braintree/payment_methods_controller.rb:
--------------------------------------------------------------------------------
1 | class Braintree::PaymentMethodsController < ApplicationController
2 | def edit
3 | end
4 |
5 | def update
6 | current_user.set_payment_processor params[:processor]
7 | current_user.payment_processor.update_payment_method(params[:card_token])
8 | redirect_to payment_method_path
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/app/views/pay/user_mailer/payment_failed.html.erb:
--------------------------------------------------------------------------------
1 | Your payment was declined
2 | We were unable to charge your payment method for your <%= Pay.application_name %> subscription. Please update your billing information.
3 | <%= link_to "Update billing information", root_url %>
4 | Let us know if you have any questions.
5 | — The <%= Pay.application_name %> Team
6 |
--------------------------------------------------------------------------------
/test/dummy/app/views/main/show.html.erb:
--------------------------------------------------------------------------------
1 | Pay Examples
2 |
3 | Braintree
4 | <%= link_to "Subscriptions", braintree_subscriptions_path %>
5 | <%= link_to "Charges", braintree_charges_path %>
6 |
7 | Stripe
8 | <%= link_to "Subscriptions", stripe_subscriptions_path %>
9 | <%= link_to "Charges", stripe_charges_path %>
10 |
--------------------------------------------------------------------------------
/test/support/braintree.rb:
--------------------------------------------------------------------------------
1 | # Braintree configuration
2 | Pay.braintree_gateway = Braintree::Gateway.new(
3 | environment: :sandbox,
4 | merchant_id: "zyfwpztymjqdcc5g",
5 | public_key: "5r59rrxhn89npc9n",
6 | private_key: "00f0df79303e1270881e5feda7788927"
7 | )
8 |
9 | logger = Logger.new("/dev/null")
10 | logger.level = Logger::INFO
11 | Pay.braintree_gateway.config.logger = logger
12 |
--------------------------------------------------------------------------------
/lib/generators/pay/email_views_generator.rb:
--------------------------------------------------------------------------------
1 | require "rails/generators"
2 |
3 | module Pay
4 | module Generators
5 | class EmailViewsGenerator < Rails::Generators::Base
6 | source_root File.expand_path("../../../..", __FILE__)
7 |
8 | def copy_views
9 | directory "app/views/pay/user_mailer", "app/views/pay/user_mailer"
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Pay::Engine.routes.draw do
4 | resources :payments, only: [:show], module: :pay
5 | post "webhooks/stripe", to: "pay/webhooks/stripe#create" if Pay::Stripe.enabled?
6 | post "webhooks/braintree", to: "pay/webhooks/braintree#create" if Pay::Braintree.enabled?
7 | post "webhooks/paddle", to: "pay/webhooks/paddle#create" if Pay::Paddle.enabled?
8 | end
9 |
--------------------------------------------------------------------------------
/lib/pay/nano_id.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module NanoId
3 | # Generates unique IDs - faster than UUID
4 | ALPHABET = "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".freeze
5 | ALPHABET_SIZE = ALPHABET.size
6 |
7 | def self.generate(size: 21)
8 | id = ""
9 | size.times { id << ALPHABET[(Random.rand * ALPHABET_SIZE).floor] }
10 | id
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/test/fixtures/pay/merchants.yml:
--------------------------------------------------------------------------------
1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
2 |
3 | one:
4 | owner: one
5 | owner_type: Owner
6 | processor: MyString
7 | processor_id: MyString
8 | default: false
9 | data:
10 |
11 | two:
12 | owner: two
13 | owner_type: Owner
14 | processor: MyString
15 | processor_id: MyString
16 | default: false
17 | data:
18 |
--------------------------------------------------------------------------------
/app/views/pay/user_mailer/subscription_trial_ended.html.erb:
--------------------------------------------------------------------------------
1 | Your <%= Pay.application_name %> trial has ended
2 | This is just a friendly reminder that your <%= link_to Pay.application_name, root_url %> trial has ended.
3 | You may <%= link_to "manage your subscription", root_url %> via your account. If you have any questions, please hit reply and let us know.
4 | — The <%= Pay.application_name %> Team
5 |
--------------------------------------------------------------------------------
/test/dummy/app/views/paddle/charges/index.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
Paddle Charges
3 |
4 |
5 | <%= link_to "New Paddle Charge", new_paddle_charge_path, class: "btn btn-primary" %>
6 |
7 |
8 |
9 | <% @charges.each do |charge| %>
10 |
11 | <%= link_to "Pay::Charge #{charge.id}", paddle_charge_path(charge) %>
12 |
13 | <% end %>
14 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/subscription_deleted_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::SubscriptionDeletedTest < ActiveSupport::TestCase
4 | setup do
5 | @event = stripe_event("subscription.deleted")
6 | end
7 |
8 | test "syncs subscription" do
9 | Pay::Stripe::Subscription.expects(:sync)
10 | Pay::Stripe::Webhooks::SubscriptionDeleted.new.call(@event)
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/subscription_created_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::SubscriptionCreatedTest < ActiveSupport::TestCase
4 | setup do
5 | @event = stripe_event("subscription.created")
6 | end
7 |
8 | test "subscription is created" do
9 | Pay::Stripe::Subscription.expects(:sync)
10 | Pay::Stripe::Webhooks::SubscriptionCreated.new.call(@event)
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/dummy/app/views/paddle/payment_methods/edit.html.erb:
--------------------------------------------------------------------------------
1 |
14 |
15 |
--------------------------------------------------------------------------------
/lib/pay/fake_processor.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module FakeProcessor
3 | autoload :Billable, "pay/fake_processor/billable"
4 | autoload :Charge, "pay/fake_processor/charge"
5 | autoload :Error, "pay/fake_processor/error"
6 | autoload :PaymentMethod, "pay/fake_processor/payment_method"
7 | autoload :Subscription, "pay/fake_processor/subscription"
8 | autoload :Merchant, "pay/fake_processor/merchant"
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/subscription_deleted.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class SubscriptionDeleted
5 | def call(event)
6 | # Canceled subscriptions are still accessible via the API, so we can sync their details
7 | Pay::Stripe::Subscription.sync(event.data.object.id, stripe_account: event.try(:account))
8 | end
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/dummy/app/views/braintree/charges/index.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
Braintree Charges
3 |
4 |
5 | <%= link_to "New Braintree Charge", new_braintree_charge_path, class: "btn btn-primary" %>
6 |
7 |
8 |
9 | <% @charges.each do |charge| %>
10 |
11 | <%= link_to "Pay::Charge #{charge.id}", braintree_charge_path(charge) %>
12 |
13 | <% end %>
14 |
--------------------------------------------------------------------------------
/test/dummy/app/views/stripe/charges/imports/new.html.erb:
--------------------------------------------------------------------------------
1 | Import Stripe Charge
2 |
3 | <%= form_with url: stripe_charges_import_path do |form| %>
4 |
5 | <%= form.label :id, "Charge or PaymentIntent ID" %>
6 | <%= form.text_field :id, class: "form-control", placeholder: "ch_xxxxxx or pi_xxxxxx" %>
7 |
8 |
9 | <%= form.submit "Import", class: "btn btn-primary" %>
10 | <% end %>
11 |
--------------------------------------------------------------------------------
/app/views/pay/user_mailer/subscription_trial_will_end.html.erb:
--------------------------------------------------------------------------------
1 | Your <%= Pay.application_name %> trial is ending soon
2 | This is just a friendly reminder that your <%= link_to Pay.application_name, root_url %> trial will be ending soon.
3 | You may <%= link_to "manage your subscription", root_url %> via your account. If you have any questions, please hit reply and let us know.
4 | — The <%= Pay.application_name %> Team
5 |
--------------------------------------------------------------------------------
/lib/pay/braintree/webhooks/subscription_canceled.rb:
--------------------------------------------------------------------------------
1 | # A subscription is canceled.
2 |
3 | module Pay
4 | module Braintree
5 | module Webhooks
6 | class SubscriptionCanceled
7 | def call(event)
8 | subscription = event.subscription
9 | return if subscription.nil?
10 |
11 | Pay::Braintree::Subscription.sync(subscription.id)
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/app/views/pay/user_mailer/payment_action_required.html.erb:
--------------------------------------------------------------------------------
1 | Extra confirmation is needed to process your payment
2 | Your <%= Pay.application_name %> subscription requires confirmation to process your payment to continue access.
3 | <%= link_to "Confirm your payment", pay.payment_url(params[:payment_intent_id]) %>
4 | If you have any questions, please hit reply and let us know.
5 | — The <%= Pay.application_name %> Team
6 |
--------------------------------------------------------------------------------
/test/dummy/app/javascript/application.js:
--------------------------------------------------------------------------------
1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
2 | import "@hotwired/turbo-rails"
3 | import "controllers"
4 | import "./processors/paddle"
5 | import "./processors/stripe"
6 |
7 | // Bootstrap
8 | document.addEventListener("turbo:load", function() {
9 | $('[data-toggle="tooltip"]').tooltip()
10 | $('[data-toggle="popover"]').popover()
11 | })
12 |
--------------------------------------------------------------------------------
/lib/pay/braintree/webhooks/subscription_trial_ended.rb:
--------------------------------------------------------------------------------
1 | # A subscription's trial period ends.
2 |
3 | module Pay
4 | module Braintree
5 | module Webhooks
6 | class SubscriptionTrialEnded
7 | def call(event)
8 | subscription = event.subscription
9 | return if subscription.nil?
10 |
11 | Pay::Braintree::Subscription.sync(subscription.id)
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/fixtures/pay/charges.yml:
--------------------------------------------------------------------------------
1 | stripe:
2 | customer: stripe
3 | processor_id: ch_1234
4 | amount: 1500
5 | subscription: stripe
6 | data:
7 | payment_method_type: card
8 | brand: Visa
9 | last4: 4242
10 |
11 | braintree:
12 | customer: braintree
13 | processor_id: 1234
14 | amount: 1500
15 | subscription: braintree
16 | data:
17 | payment_method_type: paypal
18 | brand: PayPal
19 | email: test@example.org
20 |
--------------------------------------------------------------------------------
/test/pay/paddle/webhooks/signature_verifier_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Paddle::Webhooks::SignatureVerifierTest < ActiveSupport::TestCase
4 | setup do
5 | @data = JSON.parse(File.read("test/support/fixtures/paddle/subscription_created.json"))
6 | end
7 |
8 | test "webhook signature is verified correctly" do
9 | verifier = Pay::Paddle::Webhooks::SignatureVerifier.new(@data)
10 | assert verifier.verify
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/stripe/payment_methods_controller.rb:
--------------------------------------------------------------------------------
1 | module Stripe
2 | class PaymentMethodsController < ApplicationController
3 | def edit
4 | @setup_intent = ::Stripe::SetupIntent.create
5 | end
6 |
7 | def update
8 | current_user.set_payment_processor params[:processor]
9 | current_user.payment_processor.update_payment_method(params[:card_token])
10 | redirect_to payment_method_path
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/backtrace_silencers.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
5 |
6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
7 | # Rails.backtrace_cleaner.remove_silencers!
8 |
--------------------------------------------------------------------------------
/test/pay/braintree/webhooks/subscription_canceled_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Braintree::Webhooks::SubscriptionCanceledTest < ActiveSupport::TestCase
4 | setup do
5 | @event = braintree_event "subscription_cancelled"
6 | end
7 |
8 | test "braintree syncs subscription on cancelled webhook" do
9 | Pay::Braintree::Subscription.expects(:sync)
10 | Pay::Braintree::Webhooks::SubscriptionCanceled.new.call(@event)
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/account_updated.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class AccountUpdated
5 | def call(event)
6 | object = event.data.object
7 |
8 | merchant = Pay::Merchant.find_by(processor: :stripe, processor_id: object.id)
9 | return unless merchant
10 |
11 | merchant.update(onboarding_complete: object.charges_enabled)
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/dummy/app/views/paddle/subscriptions/index.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
Paddle Subscriptions
3 |
4 | <%= link_to "New Paddle Subscription", new_paddle_subscription_path, class: "btn btn-primary" %>
5 |
6 |
7 |
8 | <% @subscriptions.each do |subscription| %>
9 |
10 | <%= link_to "Pay::Subscription #{subscription.id}", paddle_subscription_path(subscription) %>
11 |
12 | <% end %>
13 |
--------------------------------------------------------------------------------
/test/dummy/app/views/stripe/subscriptions/index.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
Stripe Subscriptions
3 |
4 | <%= link_to "New Stripe Subscription", new_stripe_subscription_path, class: "btn btn-primary" %>
5 |
6 |
7 |
8 | <% @subscriptions.each do |subscription| %>
9 |
10 | <%= link_to "Pay::Subscription #{subscription.id}", stripe_subscription_path(subscription) %>
11 |
12 | <% end %>
13 |
--------------------------------------------------------------------------------
/test/pay/braintree/webhooks/subscription_trial_ended_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Braintree::Webhooks::SubscriptionTrialEndedTest < ActiveSupport::TestCase
4 | setup do
5 | @event = braintree_event "subscription_trial_ended"
6 | end
7 |
8 | test "braintree syncs subscription on trial ended webhook" do
9 | Pay::Braintree::Subscription.expects(:sync)
10 | Pay::Braintree::Webhooks::SubscriptionCanceled.new.call(@event)
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | AllCops:
2 | Exclude:
3 | - db/migrate/*
4 | - lib/pay/version.rb
5 | - test/dummy/**/*
6 | - test/models/subscription_test.rb
7 | - test/pay/billable_test.rb
8 | - test/test_helper.rb
9 |
10 | Documentation:
11 | Enabled: false
12 |
13 | ClassAndModuleChildren:
14 | Enabled: false
15 |
16 | ClassVars:
17 | Enabled: false
18 |
19 | SpecialGlobalVars:
20 | Enabled: false
21 |
22 | AmbiguousBlockAssociation:
23 | Enabled: false
24 |
--------------------------------------------------------------------------------
/lib/pay/braintree/webhooks/subscription_expired.rb:
--------------------------------------------------------------------------------
1 | # A subscription reaches the specified number of billing cycles and expires.
2 |
3 | module Pay
4 | module Braintree
5 | module Webhooks
6 | class SubscriptionExpired
7 | def call(event)
8 | subscription = event.subscription
9 | return if subscription.nil?
10 |
11 | Pay::Braintree::Subscription.sync(subscription.id)
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/dummy/app/views/braintree/subscriptions/index.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
Braintree Subscriptions
3 |
4 | <%= link_to "New Braintree Subscription", new_braintree_subscription_path, class: "btn btn-primary" %>
5 |
6 |
7 |
8 | <% @subscriptions.each do |subscription| %>
9 |
10 | <%= link_to "Pay::Subscription #{subscription.id}", braintree_subscription_path(subscription) %>
11 |
12 | <% end %>
13 |
--------------------------------------------------------------------------------
/test/dummy/config/importmap.rb:
--------------------------------------------------------------------------------
1 | # Pin npm packages by running ./bin/importmap
2 |
3 | pin "application", preload: true
4 | pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
5 | pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
6 | pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
7 | pin_all_from "app/javascript/controllers", under: "controllers"
8 | pin "@rails/ujs", to: "https://ga.jspm.io/npm:@rails/ujs@7.0.3-1/lib/assets/compiled/rails-ujs.js"
9 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/payment_method_detached_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::PaymentMethodDetachedTest < ActiveSupport::TestCase
4 | setup do
5 | @event = stripe_event("payment_method.detached")
6 | end
7 |
8 | test "payment_method.detached removes payment method from database" do
9 | assert_difference "Pay::PaymentMethod.count", -1 do
10 | Pay::Stripe::Webhooks::PaymentMethodUpdated.new.call(@event)
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/app/views/pay/user_mailer/subscription_renewing.html.erb:
--------------------------------------------------------------------------------
1 | Your upcoming <%= Pay.application_name %> subscription renewal
2 | This is a friendly reminder that your <%= link_to Pay.application_name, root_url %> subscription will renew automatically on <%= l params[:date].to_date, format: :long %>.
3 | You may <%= link_to "manage your subscription", root_url %> via your account. If you have any questions, please hit reply and let us know.
4 | — The <%= Pay.application_name %> Team
5 |
--------------------------------------------------------------------------------
/lib/pay/fake_processor/charge.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module FakeProcessor
3 | class Charge
4 | attr_reader :pay_charge
5 |
6 | delegate :processor_id, :owner, to: :pay_charge
7 |
8 | def initialize(pay_charge)
9 | @pay_charge = pay_charge
10 | end
11 |
12 | def charge
13 | pay_charge
14 | end
15 |
16 | def refund!(amount_to_refund)
17 | pay_charge.update(amount_refunded: amount_to_refund)
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/test/dummy/bin/webpack:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
4 | ENV["NODE_ENV"] ||= "development"
5 |
6 | require "pathname"
7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
8 | Pathname.new(__FILE__).realpath)
9 |
10 | require "bundler/setup"
11 |
12 | require "webpacker"
13 | require "webpacker/webpack_runner"
14 |
15 | APP_ROOT = File.expand_path("..", __dir__)
16 | Dir.chdir(APP_ROOT) do
17 | Webpacker::WebpackRunner.run(ARGV)
18 | end
19 |
--------------------------------------------------------------------------------
/test/dummy/app/views/stripe/charges/index.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
Stripe Charges
3 |
4 |
5 | <%= link_to "New Stripe Charge", new_stripe_charge_path, class: "btn btn-primary" %>
6 | <%= link_to "Import Stripe Charge", new_stripe_charges_import_path, class: "btn btn-primary" %>
7 |
8 |
9 |
10 | <% @charges.each do |charge| %>
11 |
12 | <%= link_to "Pay::Charge #{charge.id}", stripe_charge_path(charge) %>
13 |
14 | <% end %>
15 |
--------------------------------------------------------------------------------
/test/dummy/bin/webpack-dev-server:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development"
4 | ENV["NODE_ENV"] ||= "development"
5 |
6 | require "pathname"
7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
8 | Pathname.new(__FILE__).realpath)
9 |
10 | require "bundler/setup"
11 |
12 | require "webpacker"
13 | require "webpacker/dev_server_runner"
14 |
15 | APP_ROOT = File.expand_path("..", __dir__)
16 | Dir.chdir(APP_ROOT) do
17 | Webpacker::DevServerRunner.run(ARGV)
18 | end
19 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/charge_refunded.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class ChargeRefunded
5 | def call(event)
6 | pay_charge = Pay::Stripe::Charge.sync(event.data.object.id, stripe_account: event.try(:account))
7 |
8 | if pay_charge && Pay.send_email?(:refund, pay_charge)
9 | Pay.mailer.with(pay_customer: pay_charge.customer, pay_charge: pay_charge).refund.deliver_later
10 | end
11 | end
12 | end
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/test/jobs/pay/customer_sync_job_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | module Pay
4 | class CustomerSyncJobTest < ActiveJob::TestCase
5 | test "sync customer with stripe" do
6 | ::Stripe::Customer.expects(:update)
7 | Pay::CustomerSyncJob.perform_now(pay_customers(:stripe).id)
8 | end
9 |
10 | test "sync customer with braintree" do
11 | ::Braintree::CustomerGateway.any_instance.expects(:update)
12 | Pay::CustomerSyncJob.perform_now(pay_customers(:braintree).id)
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/controllers/pay/payments_controller.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | class PaymentsController < ApplicationController
3 | layout "pay/application"
4 |
5 | before_action :set_redirect_to
6 |
7 | def show
8 | @payment = Payment.from_id(params[:id])
9 | end
10 |
11 | private
12 |
13 | # Ensure the back parameter is a valid path
14 | # This safely handles XSS or external redirects
15 | def set_redirect_to
16 | @redirect_to = URI.parse(params[:back].to_s).path || root_path
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/charge_succeeded.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class ChargeSucceeded
5 | def call(event)
6 | pay_charge = Pay::Stripe::Charge.sync(event.data.object.id, stripe_account: event.try(:account))
7 |
8 | if pay_charge && Pay.send_email?(:receipt, pay_charge)
9 | Pay.mailer.with(pay_customer: pay_charge.customer, pay_charge: pay_charge).receipt.deliver_later
10 | end
11 | end
12 | end
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/test/pay/paddle/error_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Paddle::ErrorTest < ActiveSupport::TestCase
4 | test "re-raised paddle exceptions keep the same message" do
5 | exception = assert_raises {
6 | begin
7 | raise ::PaddlePay::ConnectionError, "The connection failed"
8 | rescue
9 | raise ::Pay::Paddle::Error
10 | end
11 | }
12 |
13 | assert_equal "The connection failed", exception.message
14 | assert_equal ::PaddlePay::ConnectionError, exception.cause.class
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/pay/fake_processor/payment_method.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module FakeProcessor
3 | class PaymentMethod
4 | attr_reader :pay_payment_method
5 |
6 | delegate :customer, :processor_id, to: :pay_payment_method
7 |
8 | def initialize(pay_payment_method)
9 | @pay_payment_method = pay_payment_method
10 | end
11 |
12 | # Sets payment method as default on Stripe
13 | def make_default!
14 | end
15 |
16 | # Remove payment method
17 | def detach
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/test/dummy/app/views/stripe/subscriptions/show.html.erb:
--------------------------------------------------------------------------------
1 | Stripe Subscription
2 |
3 | <%= @subscription.pretty_inspect %>
4 |
5 | Actions
6 | <%= button_to "Resume", resume_stripe_subscription_path(@subscription), method: :patch, class: "d-block" if @subscription.on_grace_period?%>
7 | <%= button_to "Cancel at period end", cancel_stripe_subscription_path(@subscription), method: :patch, class: "d-block" %>
8 | <%= button_to "Cancel immediately", stripe_subscription_path(@subscription), method: :delete, class: "d-block" %>
9 |
--------------------------------------------------------------------------------
/lib/pay/adapter.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Adapter
3 | extend ActiveSupport::Concern
4 |
5 | def self.current_adapter
6 | if ActiveRecord::Base.respond_to?(:connection_db_config)
7 | ActiveRecord::Base.connection_db_config.adapter
8 | else
9 | ActiveRecord::Base.connection_config[:adapter]
10 | end
11 | end
12 |
13 | def self.json_column_type
14 | case current_adapter
15 | when "postgresql"
16 | :jsonb
17 | else
18 | :json
19 | end
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/test/dummy/app/views/braintree/subscriptions/show.html.erb:
--------------------------------------------------------------------------------
1 | Braintree Subscription
2 |
3 | <%= @subscription.pretty_inspect %>
4 |
5 | Actions
6 | <%= button_to "Resume", resume_braintree_subscription_path(@subscription), method: :patch, class: "d-block" if @subscription.on_grace_period? %>
7 | <%= button_to "Cancel at period end", cancel_braintree_subscription_path(@subscription), method: :patch, class: "d-block" %>
8 | <%= button_to "Cancel immediately", braintree_subscription_path(@subscription), method: :delete, class: "d-block" %>
9 |
--------------------------------------------------------------------------------
/test/dummy/app/views/paddle/subscriptions/show.html.erb:
--------------------------------------------------------------------------------
1 | Braintree Subscription
2 |
3 | <%= @subscription.pretty_inspect %>
4 |
5 | Actions
6 | <%= button_to "Resume", resume_braintree_subscription_path(@subscription), method: :patch, class: "d-block" if @subscription.on_grace_period? %>
7 | <%= button_to "Cancel at period end", cancel_braintree_subscription_path(@subscription), method: :patch, class: "d-block" %>
8 | <%= button_to "Cancel immediately", braintree_subscription_path(@subscription), method: :delete, class: "d-block" %>
9 |
--------------------------------------------------------------------------------
/test/fixtures/pay/payment_methods.yml:
--------------------------------------------------------------------------------
1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
2 |
3 | one:
4 | customer: stripe
5 | processor_id: pm_1000
6 | default: true
7 | type: card
8 | data:
9 | brand: "Visa"
10 | last4: "4242"
11 | exp_month: "01"
12 | exp_year: "2021"
13 |
14 | two:
15 | customer: stripe
16 | processor_id: pm_1001
17 | default: true
18 | type: card
19 | data:
20 |
21 | paddle:
22 | customer: paddle
23 | processor_id: 1000
24 | default: true
25 | type: card
26 | data:
27 |
--------------------------------------------------------------------------------
/lib/pay/errors.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | # https://avdi.codes/exception-causes-in-ruby-2-1/
3 | class Error < StandardError
4 | end
5 |
6 | class PaymentError < StandardError
7 | attr_reader :payment
8 |
9 | def initialize(payment)
10 | @payment = payment
11 | end
12 | end
13 |
14 | class ActionRequired < PaymentError
15 | def message
16 | I18n.t("pay.errors.action_required")
17 | end
18 | end
19 |
20 | class InvalidPaymentMethod < PaymentError
21 | def message
22 | I18n.t("pay.errors.invalid_payment")
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/wrap_parameters.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # This file contains settings for ActionController::ParamsWrapper which
4 | # is enabled by default.
5 |
6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
7 | ActiveSupport.on_load(:action_controller) do
8 | wrap_parameters format: [:json]
9 | end
10 |
11 | # To enable root element in JSON for ActiveRecord objects.
12 | # ActiveSupport.on_load(:active_record) do
13 | # self.include_root_in_json = true
14 | # end
15 |
--------------------------------------------------------------------------------
/test/dummy/app/views/paddle/subscriptions/new.html.erb:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/account_updated_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::AccountUpdatedTest < ActiveSupport::TestCase
4 | setup do
5 | @event = stripe_event("account.updated")
6 | end
7 |
8 | test "an account is authorized" do
9 | account = Account.create!
10 | account.set_merchant_processor :stripe
11 | account.merchant_processor.update processor_id: @event.data.data.object.id
12 |
13 | Pay::Stripe::Webhooks::AccountUpdated.new.call(@event.data)
14 |
15 | assert account.merchant_processor.reload.onboarding_complete
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/dummy/app/views/paddle/charges/new.html.erb:
--------------------------------------------------------------------------------
1 |
20 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/assets.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Version of your assets, change this if you want to expire all your assets.
4 | Rails.application.config.assets.version = "1.0" if Rails.application.config.respond_to? :assets
5 |
6 | # Add additional assets to the asset load path
7 | # Rails.application.config.assets.paths << Emoji.images_path
8 |
9 | # Precompile additional assets.
10 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
11 | # Rails.application.config.assets.precompile += %w( search.js )
12 |
--------------------------------------------------------------------------------
/test/dummy/db/migrate/20170205000000_create_users.rb:
--------------------------------------------------------------------------------
1 | class CreateUsers < ActiveRecord::Migration[6.0]
2 | def change
3 | create_table :users do |t|
4 | t.string :email
5 | t.string :first_name
6 | t.string :last_name
7 | t.text :extra_billing_info
8 | end
9 |
10 | create_table :teams do |t|
11 | t.string :email
12 | t.string :name
13 | t.references :owner, polymorphic: true
14 | end
15 |
16 | create_table :accounts do |t|
17 | t.string :email
18 | t.string :merchant_processor
19 | t.string :pay_data
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/charge_succeeded_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::ChargeSucceededTest < ActiveSupport::TestCase
4 | setup do
5 | @event = stripe_event("charge.succeeded")
6 | end
7 |
8 | test "a charge is created" do
9 | pay_customers(:stripe).update(processor_id: @event.data.object.customer)
10 |
11 | ::Stripe::Charge.expects(:retrieve).returns(@event.data.object)
12 |
13 | # Make sure enqueues the receipt email
14 | assert_enqueued_jobs 1 do
15 | Pay::Stripe::Webhooks::ChargeSucceeded.new.call(@event)
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/payment_method_updated.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class PaymentMethodUpdated
5 | def call(event)
6 | object = event.data.object
7 | if object.customer
8 | Pay::Stripe::PaymentMethod.sync(object.id, stripe_account: event.try(:account))
9 | else
10 | # If customer was removed, we should delete the payment method if it exists
11 | Pay::PaymentMethod.find_by_processor_and_id(:stripe, object.id)&.destroy
12 | end
13 | end
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/dummy/app/views/stripe/checkouts/show.html.erb:
--------------------------------------------------------------------------------
1 | Stripe Checkout
2 |
3 |
4 | <%= render partial: "pay/stripe/checkout_button", locals: { session: @payment, title: "Payment" } %>
5 |
6 |
7 |
8 | <%= render partial: "pay/stripe/checkout_button", locals: { session: @subscription, title: "Subscription" } %>
9 |
10 |
11 |
12 | <%= render partial: "pay/stripe/checkout_button", locals: { session: @setup, title: "Setup" } %>
13 |
14 |
15 |
16 | <%= link_to "Customer Billing Portal", @portal.url %>
17 |
18 |
--------------------------------------------------------------------------------
/test/pay/stripe/error_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::ErrorTest < ActiveSupport::TestCase
4 | setup do
5 | @pay_customer = pay_customers(:stripe)
6 | @pay_customer.update(processor_id: nil)
7 | @pay_customer.payment_methods.destroy_all
8 | end
9 |
10 | test "re-raised stripe exceptions keep the same message" do
11 | exception = assert_raises(Pay::Stripe::Error) { @pay_customer.charge(0) }
12 | assert_equal "This value must be greater than or equal to 1.", exception.message
13 | assert_equal ::Stripe::InvalidRequestError, exception.cause.class
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/pay/braintree/error.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Braintree
3 | class Error < Pay::Error
4 | # For any manually raised Braintree error results (for failure responses)
5 | # we can raise this exception manually but treat it as if we wrapped an exception
6 |
7 | attr_reader :result
8 |
9 | def initialize(result)
10 | if result.is_a?(::Braintree::ErrorResult)
11 | super(result.message)
12 | @result = result
13 | else
14 | super
15 | end
16 | end
17 |
18 | def cause
19 | super || result
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/charge_refunded_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::ChargeRefundedTest < ActiveSupport::TestCase
4 | setup do
5 | @event = stripe_event("charge.refunded")
6 | pay_charges(:stripe).update(id: @event.data.object.id)
7 | pay_customers(:stripe).update(processor_id: @event.data.object.customer)
8 | end
9 |
10 | test "a charge is updated with refunded amount" do
11 | ::Stripe::Charge.expects(:retrieve).returns(@event.data.object)
12 |
13 | assert_enqueued_jobs 1 do
14 | Pay::Stripe::Webhooks::ChargeRefunded.new.call(@event)
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # This command will automatically be run when you run "rails" with Rails gems
3 | # installed from the root of your application.
4 |
5 | ENV["RAILS_ENV"] ||= "test"
6 |
7 | ENGINE_ROOT = File.expand_path('..', __dir__)
8 | ENGINE_PATH = File.expand_path('../lib/pay/engine', __dir__)
9 | APP_PATH = File.expand_path('../test/dummy/config/application', __dir__)
10 |
11 | # Set up gems listed in the Gemfile.
12 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
13 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
14 |
15 | require 'rails/all'
16 | require 'rails/engine/commands'
17 |
--------------------------------------------------------------------------------
/test/routes/webhooks_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class WebhookRoutesTest < ActionDispatch::IntegrationTest
6 | test "stripe webhook routes get mounted correctly" do
7 | post "/pay/webhooks/stripe", as: :json
8 | assert_response :bad_request
9 | end
10 |
11 | test "braintree webhook routes get mounted correctly" do
12 | post "/pay/webhooks/braintree", as: :json
13 | assert_response :bad_request
14 | end
15 |
16 | test "paddle webhook routes get mounted correctly" do
17 | post "/pay/webhooks/paddle", as: :json
18 | assert_response :bad_request
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/test/pay/fake_processor/charge_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::FakeProcessor::Charge::Test < ActiveSupport::TestCase
4 | setup do
5 | @pay_customer = pay_customers(:fake)
6 | @charge = @pay_customer.charge(10_00)
7 | end
8 |
9 | test "fake processor charge" do
10 | assert_equal @charge, @charge.processor_charge
11 | assert_equal "card", @charge.payment_method_type
12 | assert_equal "Fake", @charge.brand
13 | end
14 |
15 | test "fake processor refund" do
16 | assert_nil @charge.amount_refunded
17 | @charge.refund!
18 | assert_equal 10_00, @charge.reload.amount_refunded
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/pay/braintree/webhooks/subscription_went_past_due.rb:
--------------------------------------------------------------------------------
1 | # A subscription has moved from the Active status to the Past Due status. This will only be triggered when the initial transaction in a billing cycle is declined. Once the status moves to past due, it will not be triggered again in that billing cycle.
2 |
3 | module Pay
4 | module Braintree
5 | module Webhooks
6 | class SubscriptionWentPastDue
7 | def call(event)
8 | subscription = event.subscription
9 | return if subscription.nil?
10 |
11 | Pay::Braintree::Subscription.sync(subscription.id)
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/dummy/app/javascript/controllers/index.js:
--------------------------------------------------------------------------------
1 | // Import and register all your controllers from the importmap under controllers/*
2 |
3 | import { application } from "controllers/application"
4 |
5 | // Eager load all controllers defined in the import map under controllers/**/*_controller
6 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
7 | eagerLoadControllersFrom("controllers", application)
8 |
9 | // Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
10 | // import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
11 | // lazyLoadControllersFrom("controllers", application)
12 |
--------------------------------------------------------------------------------
/test/dummy/app/models/user.rb:
--------------------------------------------------------------------------------
1 | class User < ApplicationRecord
2 | pay_customer
3 | # pay_customer stripe_attributes: :stripe_attributes
4 | # pay_customer stripe_attributes: ->(pay_customer) { { metadata: { user_id: pay_customer.owner_id } } }
5 |
6 | def stripe_attributes(pay_customer)
7 | {
8 | description: "description",
9 | address: { # Used for tax calculations
10 | country: "us",
11 | postal_code: "90210"
12 | },
13 | metadata: {
14 | user_id: id # or pay_customer.owner_id
15 | }
16 | }
17 | end
18 |
19 | def braintree_attributes(pay_customer)
20 | { company: "Company" }
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/pay/paddle/webhooks/subscription_payment_refunded.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Paddle
3 | module Webhooks
4 | class SubscriptionPaymentRefunded
5 | def call(event)
6 | pay_charge = Pay::Charge.find_by_processor_and_id(:paddle, event.subscription_payment_id)
7 | return unless pay_charge.present?
8 |
9 | pay_charge.update!(amount_refunded: (event.gross_refund.to_f * 100).to_i)
10 |
11 | if Pay.send_email?(:refund, pay_charge)
12 | Pay.mailer.with(pay_customer: pay_charge.customer, pay_charge: pay_charge).refund.deliver_later
13 | end
14 | end
15 | end
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/stripe/charges/imports_controller.rb:
--------------------------------------------------------------------------------
1 | module Stripe
2 | class Charges::ImportsController < ApplicationController
3 | def new
4 | end
5 |
6 | def create
7 | object = find_stripe_object
8 | charge = Pay::Stripe::Webhooks::ChargeSucceeded.new.create_charge(User.first, object)
9 | redirect_to stripe_charge_path(charge)
10 | end
11 |
12 | private
13 |
14 | def find_stripe_object
15 | case params[:id]
16 | when /^ch_/
17 | Stripe::Charge.retrieve(params[:id])
18 | when /^pi_/
19 | Stripe::PaymentIntent.retrieve(params[:id]).charges.first
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/pay/braintree/webhooks/subscription_went_active.rb:
--------------------------------------------------------------------------------
1 | # A subscription's first authorized transaction is created, or a successful transaction moves a subscription from the Past Due status to the Active status. Subscriptions with trial periods will not trigger this notification when they move from the trial period into the first billing cycle.
2 |
3 | module Pay
4 | module Braintree
5 | module Webhooks
6 | class SubscriptionWentActive
7 | def call(event)
8 | subscription = event.subscription
9 | return if subscription.nil?
10 |
11 | Pay::Braintree::Subscription.sync(subscription.id)
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/stripe/checkouts_controller.rb:
--------------------------------------------------------------------------------
1 | module Stripe
2 | class CheckoutsController < ApplicationController
3 | def show
4 | current_user.set_payment_processor :stripe
5 | current_user.payment_processor.customer
6 |
7 | @payment = current_user.payment_processor.checkout(mode: "payment", line_items: "price_1ILVZaKXBGcbgpbZQ26kgXWG")
8 | @subscription = current_user.payment_processor.checkout(mode: "subscription", line_items: "default")
9 | @setup = current_user.payment_processor.checkout(mode: "setup")
10 | @portal = current_user.payment_processor.billing_portal(return_url: "http://localhost:3000/stripe/checkout")
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/pay.rb:
--------------------------------------------------------------------------------
1 | ENV["BRAINTREE_PUBLIC_KEY"] ||= "5r59rrxhn89npc9n"
2 | ENV["BRAINTREE_PRIVATE_KEY"] ||= "00f0df79303e1270881e5feda7788927"
3 | ENV["BRAINTREE_MERCHANT_ID"] ||= "zyfwpztymjqdcc5g"
4 | ENV["BRAINTREE_ENVIRONMENT"] ||= "sandbox"
5 |
6 | Pay.setup do |config|
7 | # For use in the receipt/refund/renewal mailers
8 | config.business_name = "Test Business"
9 | config.business_address = "1600 Pennsylvania Avenue NW\nWashington, DC 20500"
10 | config.application_name = "My App"
11 | config.support_email = "support@example.org"
12 | end
13 |
14 | ActiveSupport.on_load(:pay) do
15 | Pay::Webhooks.delegator.subscribe "stripe.charge.succeeded", ChargeSucceeded.new
16 | end
17 |
--------------------------------------------------------------------------------
/app/views/layouts/pay/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Payment Confirmation - <%= Pay.business_name %>
7 | <%#= stylesheet_link_tag "pay/application", media: "all" %>
8 | <%#= javascript_include_tag "pay/application" %>
9 | <%= csrf_meta_tags %>
10 |
11 |
12 |
13 |
14 |
15 |
16 | <%= yield %>
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/payment_method_attached_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::PaymentMethodAttachedTest < ActiveSupport::TestCase
4 | setup do
5 | @event = stripe_event("payment_method.attached")
6 | end
7 |
8 | test "payment_method.attached removes payment method from database" do
9 | ::Stripe::PaymentMethod.expects(:retrieve).returns(@event.data.object)
10 | ::Stripe::Customer.expects(:retrieve).returns ::Stripe::Customer.construct_from(id: @event.data.object.customer, invoice_settings: {default_payment_method: nil})
11 |
12 | assert_difference "Pay::PaymentMethod.count", 1 do
13 | Pay::Stripe::Webhooks::PaymentMethodAttached.new.call(@event)
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/dummy/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new inflection rules using the following format. Inflections
4 | # are locale specific, and you may define rules for as many different
5 | # locales as you wish. All of these examples are active by default:
6 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
7 | # inflect.plural /^(ox)$/i, '\1en'
8 | # inflect.singular /^(ox)en/i, '\1'
9 | # inflect.irregular 'person', 'people'
10 | # inflect.uncountable %w( fish sheep )
11 | # end
12 |
13 | # These inflection rules are supported but not enabled by default:
14 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
15 | # inflect.acronym 'RESTful'
16 | # end
17 |
--------------------------------------------------------------------------------
/app/models/pay/merchant.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | class Merchant < Pay::ApplicationRecord
3 | belongs_to :owner, polymorphic: true
4 |
5 | validates :processor, presence: true
6 |
7 | store_accessor :data, :onboarding_complete
8 |
9 | delegate_missing_to :pay_processor
10 |
11 | def self.pay_processor_for(name)
12 | "Pay::#{name.to_s.classify}::Merchant".constantize
13 | end
14 |
15 | def pay_processor
16 | return if processor.blank?
17 | @pay_processor ||= self.class.pay_processor_for(processor).new(self)
18 | end
19 |
20 | def onboarding_complete?
21 | ActiveModel::Type::Boolean.new.cast(
22 | (data.presence || {})["onboarding_complete"]
23 | )
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/test/dummy/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Files in the config/locales directory are used for internationalization
2 | # and are automatically loaded by Rails. If you want to use locales other
3 | # than English, add the necessary files in this directory.
4 | #
5 | # To use the locales, use `I18n.t`:
6 | #
7 | # I18n.t 'hello'
8 | #
9 | # In views, this is aliased to just `t`:
10 | #
11 | # <%= t('hello') %>
12 | #
13 | # To use a different locale, set it with `I18n.locale`:
14 | #
15 | # I18n.locale = :es
16 | #
17 | # This would use the information in config/locales/es.yml.
18 | #
19 | # To learn more, please read the Rails Internationalization guide
20 | # available at http://guides.rubyonrails.org/i18n.html.
21 |
22 | en:
23 | hello: "Hello world"
24 |
--------------------------------------------------------------------------------
/app/assets/javascripts/pay/application.js:
--------------------------------------------------------------------------------
1 | // This is a manifest file that'll be compiled into application.js, which will include all the files
2 | // listed below.
3 | //
4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path.
6 | //
7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8 | // compiled file. JavaScript code in this file should be added after the last require_* statement.
9 | //
10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
11 | // about supported directives.
12 | //
13 | //= require_tree .
14 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/payment_intent_succeeded.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class PaymentIntentSucceeded
5 | # This webhook does NOT send notifications because stripe sends both
6 | # `charge.succeeded` and `payment_intent.succeeded` events.
7 | #
8 | # We use `charge.succeeded` as the single place to send notifications
9 |
10 | def call(event)
11 | object = event.data.object
12 | payment_intent = ::Stripe::PaymentIntent.retrieve({id: object.id}, {stripe_account: event.try(:account)}.compact)
13 | Pay::Stripe::Charge.sync(payment_intent.latest_charge, stripe_account: event.try(:account))
14 | end
15 | end
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/test/pay/adapter_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Adapter::Test < ActiveSupport::TestCase
4 | test "current_adapter returns adapter as string" do
5 | assert_includes %w[postgresql mysql2 sqlite3], Pay::Adapter.current_adapter
6 | end
7 |
8 | test "jsonb for postgres" do
9 | Pay::Adapter.stub(:current_adapter, "postgresql") do
10 | assert_equal :jsonb, Pay::Adapter.json_column_type
11 | end
12 | end
13 |
14 | test "json for other databases" do
15 | Pay::Adapter.stub(:current_adapter, "mysql2") do
16 | assert_equal :json, Pay::Adapter.json_column_type
17 | end
18 |
19 | Pay::Adapter.stub(:current_adapter, "sqlite3") do
20 | assert_equal :json, Pay::Adapter.json_column_type
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/payment_failed.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class PaymentFailed
5 | def call(event)
6 | # Event is of type "invoice" see:
7 | # https://stripe.com/docs/api/invoices/object
8 |
9 | object = event.data.object
10 |
11 | pay_subscription = Pay::Subscription.find_by_processor_and_id(:stripe, object.subscription)
12 | return if pay_subscription.nil?
13 |
14 | if Pay.send_email?(:payment_failed, pay_subscription)
15 | Pay.mailer.with(
16 | pay_customer: pay_subscription.customer,
17 | stripe_invoice: object
18 | ).payment_failed.deliver_now
19 | end
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | begin
2 | require "bundler/setup"
3 | rescue LoadError
4 | puts "You must `gem install bundler` and `bundle install` to run rake tasks"
5 | end
6 |
7 | require "bundler/gem_tasks"
8 |
9 | require "rdoc/task"
10 |
11 | RDoc::Task.new(:rdoc) do |rdoc|
12 | rdoc.rdoc_dir = "rdoc"
13 | rdoc.title = "Pay"
14 | rdoc.options << "--line-numbers"
15 | rdoc.rdoc_files.include("README.md")
16 | rdoc.rdoc_files.include("lib/**/*.rb")
17 | end
18 |
19 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
20 | load "rails/tasks/engine.rake"
21 |
22 | load "rails/tasks/statistics.rake"
23 |
24 | require "rake/testtask"
25 |
26 | Rake::TestTask.new(:test) do |t|
27 | t.libs << "test"
28 | t.pattern = "test/**/*_test.rb"
29 | t.verbose = false
30 | end
31 |
32 | task default: :test
33 |
--------------------------------------------------------------------------------
/test/support/fixtures/braintree/subscription_trial_ended.json:
--------------------------------------------------------------------------------
1 | {
2 | "bt_signature": "5r59rrxhn89npc9n|87c3e15e45acfb164613a0d910c6b8c276a5eb2f",
3 | "bt_payload": "ICAgICAgICA8bm90aWZpY2F0aW9uPgogICAgICAgICAgPHRpbWVzdGFtcCB0\neXBlPSJkYXRldGltZSI+MjAyMS0wOC0yNVQxNzozNzo1OFo8L3RpbWVzdGFt\ncD4KICAgICAgICAgIDxraW5kPnN1YnNjcmlwdGlvbl90cmlhbF9lbmRlZDwv\na2luZD4KICAgICAgICAgIAogICAgICAgICAgPHN1YmplY3Q+CiAgICAgICAg\nICAgICAgICAgICAgPHN1YnNjcmlwdGlvbj4KICAgICAgICAgIDxpZD5hYmNk\nPC9pZD4KICAgICAgICAgIDx0cmFuc2FjdGlvbnMgdHlwZT0iYXJyYXkiPgog\nICAgICAgICAgPC90cmFuc2FjdGlvbnM+CiAgICAgICAgICA8YWRkX29ucyB0\neXBlPSJhcnJheSI+CiAgICAgICAgICA8L2FkZF9vbnM+CiAgICAgICAgICA8\nZGlzY291bnRzIHR5cGU9ImFycmF5Ij4KICAgICAgICAgIDwvZGlzY291bnRz\nPgogICAgICAgIDwvc3Vic2NyaXB0aW9uPgoKICAgICAgICAgIDwvc3ViamVj\ndD4KICAgICAgICA8L25vdGlmaWNhdGlvbj4K\n"
4 | }
5 |
--------------------------------------------------------------------------------
/lib/pay/fake_processor/merchant.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module FakeProcessor
3 | class Merchant
4 | attr_reader :pay_merchant
5 | delegate :processor_id, to: :pay_merchant
6 |
7 | def initialize(pay_merchant)
8 | @pay_merchant = pay_merchant
9 | end
10 |
11 | def create_account(**options)
12 | fake_account = Struct.new(:id).new("fake_account_id")
13 | pay_merchant.update(processor_id: fake_account.id)
14 | fake_account
15 | end
16 |
17 | def account_link(refresh_url:, return_url:, type: "account_onboarding", **options)
18 | Struct.new(:url).new("/fake_processor/account_link")
19 | end
20 |
21 | def login_link(**options)
22 | Struct.new(:url).new("/fake_processor/login_link")
23 | end
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/pay/application.css:
--------------------------------------------------------------------------------
1 | /*
2 | * This is a manifest file that'll be compiled into application.css, which will include all the files
3 | * listed below.
4 | *
5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7 | *
8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10 | * files in this directory. Styles in this file should be added after the last require_* statement.
11 | * It is generally better to create a new file per style scope.
12 | *
13 | *= require_tree .
14 | *= require_self
15 | */
16 |
--------------------------------------------------------------------------------
/pay.gemspec:
--------------------------------------------------------------------------------
1 | $:.push File.expand_path("../lib", __FILE__)
2 |
3 | # Maintain your gem's version:
4 | require "pay/version"
5 |
6 | # Describe your gem and declare its dependencies:
7 | Gem::Specification.new do |s|
8 | s.name = "pay"
9 | s.version = Pay::VERSION
10 | s.authors = ["Jason Charnes", "Chris Oliver", "Collin Jilbert"]
11 | s.email = ["jason@thecharnes.com", "excid3@gmail.com", "cjilbert504@gmail.com"]
12 | s.homepage = "https://github.com/pay-rails/pay"
13 | s.summary = "Payments engine for Ruby on Rails"
14 | s.description = "Stripe, Paddle, and Braintree payments for Ruby on Rails apps"
15 | s.license = "MIT"
16 |
17 | s.files = Dir[
18 | "{app,config,db,lib}/**/*",
19 | "MIT-LICENSE",
20 | "Rakefile",
21 | "README.md"
22 | ]
23 |
24 | s.add_dependency "rails", ">= 6.0.0"
25 | end
26 |
--------------------------------------------------------------------------------
/test/dummy/app/assets/stylesheets/application.css:
--------------------------------------------------------------------------------
1 | /*
2 | * This is a manifest file that'll be compiled into application.css, which will include all the files
3 | * listed below.
4 | *
5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7 | *
8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10 | * files in this directory. Styles in this file should be added after the last require_* statement.
11 | * It is generally better to create a new file per style scope.
12 | *
13 | *= require_tree .
14 | *= require_self
15 | */
16 |
--------------------------------------------------------------------------------
/gemfiles/rails_6_1.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "byebug"
6 | gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
7 | gem "overcommit"
8 | gem "standard"
9 | gem "mocha"
10 | gem "vcr"
11 | gem "webmock"
12 | gem "braintree", ">= 2.92.0"
13 | gem "stripe", "~> 8.0"
14 | gem "paddle_pay", "~> 0.2"
15 | gem "receipts"
16 | gem "prawn", git: "https://github.com/prawnpdf/prawn.git"
17 | gem "pg"
18 | gem "mysql2"
19 | gem "sqlite3", "~> 1.6.0.rc2"
20 | gem "puma"
21 | gem "web-console", group: :development
22 | gem "sprockets-rails"
23 | gem "importmap-rails"
24 | gem "turbo-rails"
25 | gem "stimulus-rails"
26 | gem "net-imap", require: false
27 | gem "net-pop", require: false
28 | gem "net-smtp", require: false
29 | gem "rails", "~> 6.1.0"
30 |
31 | gemspec path: "../"
32 |
--------------------------------------------------------------------------------
/gemfiles/rails_7.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "byebug"
6 | gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
7 | gem "overcommit"
8 | gem "standard"
9 | gem "mocha"
10 | gem "vcr"
11 | gem "webmock"
12 | gem "braintree", ">= 2.92.0"
13 | gem "stripe", "~> 8.0"
14 | gem "paddle_pay", "~> 0.2"
15 | gem "receipts"
16 | gem "prawn", git: "https://github.com/prawnpdf/prawn.git"
17 | gem "pg"
18 | gem "mysql2"
19 | gem "sqlite3", "~> 1.6.0.rc2"
20 | gem "puma"
21 | gem "web-console", group: :development
22 | gem "sprockets-rails"
23 | gem "importmap-rails"
24 | gem "turbo-rails"
25 | gem "stimulus-rails"
26 | gem "net-imap", require: false
27 | gem "net-pop", require: false
28 | gem "net-smtp", require: false
29 | gem "rails", "~> 7.0.0"
30 |
31 | gemspec path: "../"
32 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/customer_deleted.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class CustomerDeleted
5 | def call(event)
6 | object = event.data.object
7 | pay_customer = Pay::Customer.find_by(processor: :stripe, processor_id: object.id)
8 |
9 | # Skip processing if this customer is not in the database
10 | return unless pay_customer
11 |
12 | # Mark all subscriptions as canceled
13 | pay_customer.subscriptions.active.update_all(ends_at: Time.current, status: "canceled")
14 |
15 | # Remove all payment methods
16 | pay_customer.payment_methods.destroy_all
17 |
18 | # Mark customer as deleted
19 | pay_customer.update!(default: false, deleted_at: Time.current)
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/customer_updated.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class CustomerUpdated
5 | def call(event)
6 | object = event.data.object
7 | pay_customer = Pay::Customer.find_by(processor: :stripe, processor_id: object.id)
8 |
9 | # Couldn't find user, we can skip
10 | return unless pay_customer.present?
11 |
12 | # Sync default card
13 | if (payment_method_id = pay_customer.customer.invoice_settings.default_payment_method)
14 | Pay::Stripe::PaymentMethod.sync(payment_method_id, stripe_account: event.try(:account))
15 |
16 | else
17 | # No default payment method set
18 | pay_customer.payment_methods.update_all(default: false)
19 | end
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/gemfiles/rails_main.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "byebug"
6 | gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
7 | gem "overcommit"
8 | gem "standard"
9 | gem "mocha"
10 | gem "vcr"
11 | gem "webmock"
12 | gem "braintree", ">= 2.92.0"
13 | gem "stripe", "~> 8.0"
14 | gem "paddle_pay", "~> 0.2"
15 | gem "receipts"
16 | gem "prawn", git: "https://github.com/prawnpdf/prawn.git"
17 | gem "pg"
18 | gem "mysql2"
19 | gem "sqlite3", "~> 1.6.0.rc2"
20 | gem "puma"
21 | gem "web-console", group: :development
22 | gem "sprockets-rails"
23 | gem "importmap-rails"
24 | gem "turbo-rails"
25 | gem "stimulus-rails"
26 | gem "net-imap", require: false
27 | gem "net-pop", require: false
28 | gem "net-smtp", require: false
29 | gem "rails", branch: "main", git: "https://github.com/rails/rails.git"
30 |
31 | gemspec path: "../"
32 |
--------------------------------------------------------------------------------
/test/support/fixtures/paddle/verification/paddle_public_key.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN PUBLIC KEY-----
2 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3CGT82ixrOTpCjAyY9pI
3 | GthIx4HzZtnO519VECl7wouV4nSD/09YDkyeujygarlYuKjILra4/yuwHoQGD/En
4 | EdADYfNgBNYMuiUhsiXArPQRuqRKSi3xboZEkeDLaeABtxr/5VWyHFtxbSe8FMa6
5 | st0Lm6zrOauYkYRbWF+8+6pp4CVNsFSBjP8PLum0zN9Uh44DyFI6qlJ3xA5Uxcr/
6 | Ew/Fp3eDPloqqa2MNCZnkJwft5rLVz/B5YWf5jY515OKl+OU/t+FCtxKpjj74ug7
7 | 9Vk5cZsA3044VClsusI3qj/iRXPWtBNKUpj0tknT5Q9J94twv3kfa+XMu61uOw3k
8 | Vjhf62W6CYt5jtQdk/HgxzMP64ouvf0zVDKHoJtprU524IXOYxK7tiDXuktpVAw3
9 | SnNh/bpN0qD2D7TGVzHeajgcppxjBYJc6OzXHToiyIiLGqZdP0ewzfOIKGkW9tOi
10 | Y/1kSzlkJrQ6sfM5aKg6SSsos0+TBY9t7/UZFVKKe7J4ijSRmsFd+DVmc2KHHReK
11 | rd1tEBa2GQeJU9F0iM5EVcYnXeXzHRNha54snsp5ZYXSAmsYZCAutdh+gWI2Alni
12 | P97ZBsCbv7RHLQOY60CXkKnILEPhu4u5Kp7P1Ez1deGf/mcnZz21RCdC7PhqPXtm
13 | WtpkNrCK6BeRMj0XZG7oB3MCAwEAAQ==
14 | -----END PUBLIC KEY-----
--------------------------------------------------------------------------------
/app/views/pay/user_mailer/receipt.html.erb:
--------------------------------------------------------------------------------
1 | We received payment for your <%= link_to Pay.application_name, root_url %> subscription. Thanks for your business!
2 |
3 | Questions? Please reply to this email.
4 |
5 | ------------------------------------
6 | RECEIPT - SUBSCRIPTION
7 |
8 |
9 | <%= Pay.application_name %>
10 | Amount: <%= params[:pay_charge].amount_with_currency %>
11 |
12 | Charged to: <%= params[:pay_charge].charged_to %>
13 | Transaction ID: <%= params[:pay_charge].id %>
14 | Date: <%= l params[:pay_charge].created_at %>
15 | <% if params[:pay_charge].customer.owner.try(:extra_billing_info?) %>
16 | <%= params[:pay_charge].customer.owner.extra_billing_info %>
17 | <% end %>
18 |
19 |
20 | <%= simple_format [Pay.business_name, Pay.business_address].join("\n") %>
21 | ------------------------------------
22 |
--------------------------------------------------------------------------------
/app/views/pay/stripe/_checkout_button.html.erb:
--------------------------------------------------------------------------------
1 | <%= button_tag title,
2 | id: "checkout-#{session.id}",
3 | class: local_assigns[:class],
4 | style: (local_assigns[:class] || local_assigns[:style]) ? local_assigns[:style] : 'background-color:#6772E5;color:#FFF;padding:8px 12px;border:0;border-radius:4px;font-size:1em'
5 | %>
6 | <%= tag.div id: "error-for-#{session.id}" %>
7 |
8 |
22 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/payment_action_required.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class PaymentActionRequired
5 | def call(event)
6 | # Event is of type "invoice" see:
7 | # https://stripe.com/docs/api/invoices/object
8 |
9 | object = event.data.object
10 |
11 | pay_subscription = Pay::Subscription.find_by_processor_and_id(:stripe, object.subscription)
12 | return if pay_subscription.nil?
13 |
14 | if Pay.send_email?(:payment_action_required, pay_subscription)
15 | Pay.mailer.with(
16 | pay_customer: pay_subscription.customer,
17 | payment_intent_id: event.data.object.payment_intent,
18 | pay_subscription: pay_subscription
19 | ).payment_action_required.deliver_later
20 | end
21 | end
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/app/mailers/pay/user_mailer.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | class UserMailer < Pay.parent_mailer.constantize
3 | def receipt
4 | if params[:pay_charge].respond_to? :receipt
5 | attachments[params[:pay_charge].filename] = params[:pay_charge].receipt
6 | end
7 |
8 | mail mail_arguments
9 | end
10 |
11 | def refund
12 | mail mail_arguments
13 | end
14 |
15 | def subscription_renewing
16 | mail mail_arguments
17 | end
18 |
19 | def payment_action_required
20 | mail mail_arguments
21 | end
22 |
23 | def subscription_trial_will_end
24 | mail mail_arguments
25 | end
26 |
27 | def subscription_trial_ended
28 | mail mail_arguments
29 | end
30 |
31 | def payment_failed
32 | mail mail_arguments
33 | end
34 |
35 | private
36 |
37 | def mail_arguments
38 | instance_exec(&Pay.mail_arguments)
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/app/views/pay/user_mailer/refund.html.erb:
--------------------------------------------------------------------------------
1 | We have processed your <%= link_to Pay.application_name, root_url %> refund.
2 | Please allow up to 7 business days for your refund to appear in your account
3 |
4 | Questions? Please reply to this email.
5 |
6 | ------------------------------------
7 | RECEIPT - REFUND
8 |
9 | <%= Pay.application_name %>
10 | Amount: <%= params[:pay_charge].amount_refunded_with_currency %>
11 |
12 | Refunded to: <%= params[:pay_charge].charged_to %>
13 | Transaction ID: <%= params[:pay_charge].id %>
14 | Date: <%= l params[:pay_charge].created_at %>
15 | <% if params[:pay_charge].customer.owner.try(:extra_billing_info?) %>
16 | <%= params[:pay_charge].customer.owner.extra_billing_info %>
17 | <% end %>
18 |
19 |
20 | <%= simple_format [Pay.business_name, Pay.business_address].join("\n") %>
21 | ------------------------------------
22 |
--------------------------------------------------------------------------------
/docs/8_customizing_models.md:
--------------------------------------------------------------------------------
1 | # Customizing Pay Models
2 |
3 | Want to add functionality to a Pay model? You can define a concern and simply include it in the model when Rails loads the code.
4 |
5 | First, you'll need to create a concern with the functionality you'd like to add.
6 |
7 | ```ruby
8 | # app/models/concerns/charge_extensions.rb
9 | module ChargeExtensions
10 | extend ActiveSupport::Concern
11 |
12 | included do
13 | belongs_to :order
14 | after_create :fulfill_order
15 | end
16 |
17 | def fulfill_order
18 | order.fulfill!
19 | end
20 | end
21 | ```
22 |
23 | Then you can tell Rails to include the concern whenever it loads the application.
24 |
25 | ```ruby
26 | # config/initializers/pay.rb
27 |
28 | # Re-include the ChargeExtensions every time Rails reloads
29 | Rails.application.config.to_prepare do
30 | Pay::Charge.include ChargeExtensions
31 | end
32 | ```
33 |
34 | ## Next
35 |
36 | See [Testing](9_testing.md)
37 |
--------------------------------------------------------------------------------
/lib/pay/billable/sync_customer.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Billable
3 | module SyncCustomer
4 | # Syncs customer details to the payment processor.
5 | # This way they're kept in sync and email notifications are
6 | # always sent to the correct email address after an update.
7 |
8 | extend ActiveSupport::Concern
9 |
10 | included do
11 | after_update_commit :enqeue_customer_sync_job, if: :pay_should_sync_customer?
12 | end
13 |
14 | def pay_should_sync_customer?
15 | try(:saved_change_to_email?)
16 | end
17 |
18 | private
19 |
20 | def enqeue_customer_sync_job
21 | if pay_should_sync_customer?
22 | # Queue job to update each payment processor for this customer
23 | pay_customers.pluck(:id).each do |pay_customer_id|
24 | CustomerSyncJob.perform_later(pay_customer_id)
25 | end
26 | end
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/test/support/fixtures/stripe/customer.deleted.json:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "id": "cus_EGxrcooteA3WAb",
4 | "object": "customer",
5 | "account_balance": 0,
6 | "created": 1546497808,
7 | "currency": "usd",
8 | "default_source": null,
9 | "delinquent": false,
10 | "description": null,
11 | "discount": null,
12 | "email": null,
13 | "invoice_prefix": "93B9993",
14 | "livemode": false,
15 | "metadata": {},
16 | "shipping": null,
17 | "sources": {
18 | "object": "list",
19 | "data": [],
20 | "has_more": false,
21 | "total_count": 0,
22 | "url": "/v1/customers/cus_EGxrcooteA3WAb/sources"
23 | },
24 | "subscriptions": {
25 | "object": "list",
26 | "data": [],
27 | "has_more": false,
28 | "total_count": 0,
29 | "url": "/v1/customers/cus_EGxrcooteA3WAb/subscriptions"
30 | },
31 | "tax_info": null,
32 | "tax_info_verification": null
33 | }
34 | }
--------------------------------------------------------------------------------
/docs/marketplaces/braintree.md:
--------------------------------------------------------------------------------
1 | # Braintree Marketplace Payments
2 |
3 | [Braintree Marketplace Overview](https://developers.braintreepayments.com/guides/braintree-marketplace/overview)
4 |
5 | **Work In Progress**
6 |
7 | Braintree marketplace payments are unfinished and may not work completely.
8 |
9 | ## Usage
10 |
11 | To add Merchant functionality to a model, configure the model:
12 |
13 | ```ruby
14 | class User
15 | pay_merchant
16 | end
17 | ```
18 |
19 | ### Assigning a merchant to a customer
20 |
21 | Payments for the billable will be processed through the sub-merchant account.
22 |
23 | ```ruby
24 | @user.set_merchant_processor :braintree, processor_id: "provider_sub_merchant_account"
25 | ```
26 |
27 | ### Creating a marketplace transaction
28 |
29 | ```ruby
30 | @user.payment_processor.charge(10_00, service_fee_amount: "1.00")
31 | ```
32 |
33 | Pay will store the `service_fee_amount` for transactions in the `application_fee_amount` field on `Pay::Charge`.
34 |
--------------------------------------------------------------------------------
/test/pay/braintree/error_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Braintree::ErrorTest < ActiveSupport::TestCase
4 | test "raising braintree failures keep the same message" do
5 | pay_customer = pay_customers(:braintree)
6 | pay_customer.update(processor_id: nil)
7 | exception = assert_raises(Pay::Braintree::Error) { pay_customer.charge(0) }
8 | assert_match "Amount must be greater than zero.", exception.to_s
9 | assert_equal ::Braintree::ErrorResult, exception.cause.class
10 | end
11 |
12 | test "re-raising braintree exceptions keep the same message" do
13 | exception = assert_raises(Pay::Braintree::Error) {
14 | begin
15 | raise ::Braintree::AuthorizationError, "Oh no!"
16 | rescue ::Braintree::AuthorizationError => e
17 | raise Pay::Braintree::Error, e
18 | end
19 | }
20 | assert_match "Oh no!", exception.to_s
21 | assert_equal ::Braintree::AuthorizationError, exception.cause.class
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/subscription_trial_will_end.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class SubscriptionTrialWillEnd
5 | def call(event)
6 | object = event.data.object
7 |
8 | pay_subscription = Pay::Subscription.find_by_processor_and_id(:stripe, object.id)
9 | return if pay_subscription.nil?
10 |
11 | pay_subscription.sync!
12 |
13 | pay_user_mailer = Pay.mailer.with(pay_customer: pay_subscription.customer, pay_subscription: pay_subscription)
14 |
15 | if Pay.send_email?(:subscription_trial_will_end, pay_subscription) && pay_subscription.on_trial?
16 | pay_user_mailer.subscription_trial_will_end.deliver_later
17 | elsif Pay.send_email?(:subscription_trial_ended, pay_subscription) && pay_subscription.trial_ended?
18 | pay_user_mailer.subscription_trial_ended.deliver_later
19 | end
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/docs/stripe/2_credentials.md:
--------------------------------------------------------------------------------
1 | # Stripe Credentials
2 |
3 | To use Stripe with Pay, you'll need to add your API keys and Signing Secret(s) to your Rails app. See [Configuring Pay](/docs/2_configuration.md#credentials) for instructions on adding credentials or ENV Vars.
4 |
5 | ### API keys
6 |
7 | You can find your Stripe private (secret) and pubilc (publishable) keys in the [Stripe Dashboard](https://dashboard.stripe.com/test/apikeys).
8 |
9 | ### Signing secrets
10 |
11 | Webhooks use signing secrets to verify the webhook was sent by Stripe. You can find these on your Stripe Dashboard or the Stripe CLI.
12 |
13 | #### Dashboard
14 |
15 | The [Webhooks](https://dashboard.stripe.com/test/webhooks/) page on Stripe contains all the defined endpoints and their signing secrets.
16 |
17 | #### Stripe CLI (Development)
18 |
19 | View the webhook signing secret used by the Stripe CLI by running:
20 |
21 | ```sh
22 | stripe listen --print-secret
23 | ```
24 |
25 | ## Next
26 |
27 | See [JavaScript](3_javascript.md)
28 |
--------------------------------------------------------------------------------
/test/dummy/config/application.rb:
--------------------------------------------------------------------------------
1 | require_relative "boot"
2 |
3 | require "rails/all"
4 |
5 | Bundler.require(*Rails.groups)
6 | require "pay"
7 |
8 | module Dummy
9 | class Application < Rails::Application
10 | # Settings in config/environments/* take precedence over those specified here.
11 | # Application configuration should go into files in config/initializers
12 | # -- all .rb files in that directory are automatically loaded.
13 |
14 | config.active_job.queue_adapter = :test
15 | config.action_mailer.default_url_options = {host: "localhost", port: 3000}
16 |
17 | # Remove warnings
18 | config.active_record.legacy_connection_handling = false if Rails.gem_version >= Gem::Version.new("6.1") && Rails.gem_version < Gem::Version.new("7.1.0.alpha")
19 |
20 | # Set the ActionMailer preview path to the gem test directory
21 | config.action_mailer.show_previews = true
22 | config.action_mailer.preview_path = Rails.root.join("../../test/mailers/previews")
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/test/controllers/pay/webhooks/paddle_controller_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | module Pay
4 | class PaddleControllerTest < ActionDispatch::IntegrationTest
5 | include Engine.routes.url_helpers
6 |
7 | setup do
8 | @routes = Engine.routes
9 | end
10 |
11 | test "should handle post requests" do
12 | post webhooks_paddle_path
13 | assert_response :bad_request
14 | end
15 |
16 | test "should parse a paddle webhook" do
17 | user = User.create!
18 | params = fake_event "paddle/subscription_created"
19 |
20 | GlobalID::Locator.expects(:locate_signed).returns(user)
21 |
22 | assert_difference("Pay::Webhook.count") do
23 | assert_enqueued_with(job: Pay::Webhooks::ProcessJob) do
24 | post webhooks_paddle_path, params: params
25 | assert_response :success
26 | end
27 | end
28 |
29 | assert_difference("user.subscriptions.count") do
30 | perform_enqueued_jobs
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/test/pay/braintree/webhooks/subscription_charged_successfully_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Braintree::Webhooks::SubscriptionChargedSuccessfullyTest < ActiveSupport::TestCase
4 | setup do
5 | @event = braintree_event "subscription_charged_successfully"
6 | end
7 |
8 | test "it sets ends_at on the subscription" do
9 | pay_customer = pay_customers(:braintree)
10 | pay_customer.update(processor_id: @event.subscription.transactions.first.customer_details.id)
11 |
12 | pay_subscription = pay_customer.subscriptions.create!(
13 | processor_id: @event.subscription.id,
14 | name: "default",
15 | processor_plan: "some-plan",
16 | status: "active"
17 | )
18 |
19 | assert_difference "pay_customer.charges.count" do
20 | Pay::Braintree::Webhooks::SubscriptionChargedSuccessfully.new.call(@event)
21 | end
22 |
23 | assert_equal pay_subscription, Pay::Charge.find_by!(processor_id: @event.subscription.transactions.first.id).subscription
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/docs/stripe/5_webhooks.md:
--------------------------------------------------------------------------------
1 | # Stripe Webhooks
2 |
3 | ### Development
4 |
5 | You can use the [Stripe CLI](https://stripe.com/docs/stripe-cli) to test and forward webhooks in development.
6 |
7 | ```bash
8 | stripe listen --forward-to localhost:3000/pay/webhooks/stripe
9 | ```
10 |
11 | ### Events
12 |
13 | Pay requires the following webhooks to properly sync charges and subscriptions as they happen.
14 |
15 | ```ruby
16 | charge.succeeded
17 | charge.refunded
18 |
19 | payment_intent.succeeded
20 |
21 | invoice.upcoming
22 | invoice.payment_action_required
23 |
24 | customer.subscription.created
25 | customer.subscription.updated
26 | customer.subscription.deleted
27 | customer.updated
28 | customer.deleted
29 |
30 | payment_method.attached
31 | payment_method.updated
32 | payment_method.automatically_updated
33 | payment_method.detached
34 |
35 | account.updated
36 |
37 | checkout.session.completed
38 | checkout.session.async_payment_succeeded
39 | ```
40 |
41 | ## Next
42 |
43 | See [Metered Billing](6_metered_billing.md)
44 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/payment_action_required_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::PaymentActionRequiredTest < ActiveSupport::TestCase
4 | setup do
5 | @event = stripe_event("invoice.payment_action_required")
6 |
7 | # Create user and subscription
8 | @pay_customer = pay_customers(:stripe)
9 | @pay_customer.update(processor_id: @event.data.object.customer)
10 | @subscription = @pay_customer.subscriptions.create!(
11 | processor_id: @event.data.object.subscription,
12 | name: "default",
13 | processor_plan: "some-plan",
14 | status: "requires_action"
15 | )
16 | end
17 |
18 | test "it sends an email" do
19 | assert_enqueued_jobs 1 do
20 | Pay::Stripe::Webhooks::PaymentActionRequired.new.call(@event)
21 | end
22 | end
23 |
24 | test "ignores if subscription doesn't exist" do
25 | @subscription.destroy!
26 | assert_no_enqueued_jobs do
27 | Pay::Stripe::Webhooks::PaymentActionRequired.new.call(@event)
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/customer_updated_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::CustomerUpdatedTest < ActiveSupport::TestCase
4 | setup do
5 | @pay_customer = pay_customers(:stripe)
6 | end
7 |
8 | test "removes default payment method if default payment method set to null" do
9 | event = stripe_event("customer.updated")
10 | Pay::Stripe::Billable.any_instance.expects(:customer).returns(OpenStruct.new(invoice_settings: OpenStruct.new(default_payment_method: nil)))
11 | assert_not_nil @pay_customer.default_payment_method
12 | Pay::Stripe::Webhooks::CustomerUpdated.new.call(event)
13 | @pay_customer.reload
14 | assert_nil @pay_customer.default_payment_method
15 | end
16 |
17 | test "stripe is not called if user can't be found" do
18 | event = stripe_event("customer.updated", overrides: {"object" => {"id" => "missing"}})
19 | Pay::Stripe::Billable.any_instance.expects(:sync_payment_method_from_stripe).never
20 | Pay::Stripe::Webhooks::CustomerUpdated.new.call(event)
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/payment_failed_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::PaymentFailedTest < ActiveSupport::TestCase
4 | setup do
5 | @payment_failed_event = stripe_event("invoice.payment_failed")
6 | @pay_customer = pay_customers(:stripe)
7 | @pay_customer.update(processor_id: @payment_failed_event.data.object.customer)
8 | end
9 |
10 | test "customer should receive payment failed email if setting is enabled" do
11 | Pay.emails.stub(:payment_failed, true) do
12 | create_subscription(processor_id: @payment_failed_event.data.object.subscription)
13 | mail = Pay::Stripe::Webhooks::PaymentFailed.new.call(@payment_failed_event)
14 |
15 | assert_equal I18n.t("pay.user_mailer.payment_failed.subject", application: Pay.application_name), mail.subject
16 | end
17 | end
18 |
19 | private
20 |
21 | def create_subscription(processor_id:)
22 | @pay_customer.subscriptions.create!(processor_id: processor_id, name: "default", processor_plan: "some-plan", status: "active")
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/test/dummy/config/secrets.yml:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Your secret key is used for verifying the integrity of signed cookies.
4 | # If you change this key, all old signed cookies will become invalid!
5 |
6 | # Make sure the secret is at least 30 characters and all random,
7 | # no regular words or you'll be exposed to dictionary attacks.
8 | # You can use `rails secret` to generate a secure secret key.
9 |
10 | # Make sure the secrets in this file are kept private
11 | # if you're sharing your code publicly.
12 |
13 | development:
14 | secret_key_base: 1ca8a0578a7f730e9b976b2d4caaaf4a739bb5e391de413127bf748c9ffe8abc9784eaae87eb7fbf88543bc3ee67c74576e1763f75b6a6c6bc2071f834782652
15 |
16 | test:
17 | secret_key_base: ae102b18ad3fcf12beea3626f5dc743633e494919238f2153fcd5720e7625b62006350d5e11cafb0f3383417d9823eb2c8e2133cdddbd7f4b032e0412105d299
18 |
19 | # Do not keep production secrets in the repository,
20 | # instead read values from the environment.
21 | production:
22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>
23 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/paddle/charges_controller.rb:
--------------------------------------------------------------------------------
1 | class Paddle::ChargesController < ApplicationController
2 | before_action :set_charge, only: [:show, :refund]
3 |
4 | def index
5 | @charges = Pay::Charge.joins(:customer).where(pay_customers: {processor: :paddle}).order(created_at: :desc)
6 | end
7 |
8 | def show
9 | end
10 |
11 | def new
12 | end
13 |
14 | def create
15 | current_user.set_payment_processor params[:processor]
16 | current_user.payment_processor.payment_method_token = params[:card_token]
17 | charge = current_user.payment_processor.charge(params[:amount])
18 | redirect_to paddle_charge_path(charge)
19 | rescue Pay::Error => e
20 | flash[:alert] = e.message
21 | render :new, status: :unprocessable_entity
22 | end
23 |
24 | def refund
25 | @charge.refund!
26 | rescue Pay::Error => e
27 | flash[:alert] = e.message
28 | ensure
29 | redirect_to paddle_charge_path(@charge)
30 | end
31 |
32 | private
33 |
34 | def set_charge
35 | @charge = Pay::Charge.find(params[:id])
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/test/fixtures/users.yml:
--------------------------------------------------------------------------------
1 | # User with no payment processors
2 | none:
3 | email: none@example.org
4 | first_name: None
5 | last_name: User
6 |
7 | # User with stripe payment processor
8 | stripe:
9 | email: stripe@example.org
10 | first_name: Stripe
11 | last_name: User
12 |
13 | # User with braintree payment processor
14 | braintree:
15 | email: braintree@example.org
16 | first_name: Braintree
17 | last_name: User
18 |
19 | # User with paddle payment processor
20 | paddle:
21 | email: paddle@example.org
22 | first_name: Paddle
23 | last_name: User
24 |
25 | # User with fake_processor payment processor
26 | fake:
27 | email: fake@example.org
28 | first_name: Fake
29 | last_name: User
30 |
31 | # User with multiple payment processors
32 | multiple:
33 | email: multiple@example.org
34 | first_name: Multiple
35 | last_name: User
36 |
37 | deleted_customer:
38 | email: deleted@example.org
39 | first_name: Deleted
40 | last_name: User
41 |
42 | pending_customer:
43 | email: pending@example.org
44 | first_name: Pending
45 | last_name: User
46 |
--------------------------------------------------------------------------------
/test/support/fixtures/stripe/customer.updated.json:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "id": "cus_1234",
4 | "object": "customer",
5 | "account_balance": 0,
6 | "created": 1546497808,
7 | "currency": "usd",
8 | "default_source": null,
9 | "delinquent": false,
10 | "description": null,
11 | "discount": null,
12 | "email": null,
13 | "invoice_prefix": "93B9993",
14 | "livemode": false,
15 | "metadata": {},
16 | "shipping": null,
17 | "sources": {
18 | "object": "list",
19 | "data": [],
20 | "has_more": false,
21 | "total_count": 0,
22 | "url": "/v1/customers/cus_EGxrcooteA3WAb/sources"
23 | },
24 | "subscriptions": {
25 | "object": "list",
26 | "data": [],
27 | "has_more": false,
28 | "total_count": 0,
29 | "url": "/v1/customers/cus_EGxrcooteA3WAb/subscriptions"
30 | },
31 | "tax_info": null,
32 | "tax_info_verification": null
33 | },
34 | "previous_attributes": {
35 | "invoice_settings": {
36 | "default_payment_method": "pm_1000"
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/lib/pay/paddle/webhooks/subscription_cancelled.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Paddle
3 | module Webhooks
4 | class SubscriptionCancelled
5 | def call(event)
6 | pay_subscription = Pay::Subscription.find_by_processor_and_id(:paddle, event.subscription_id)
7 |
8 | # We couldn't find the subscription for some reason, maybe it's from another service
9 | return if pay_subscription.nil?
10 |
11 | # User canceled subscriptions have an ends_at
12 | # Automatically cancelled subscriptions need this value set
13 | ends_at = Time.zone.parse(event.cancellation_effective_date)
14 | pay_subscription.update!(
15 | status: :canceled,
16 | trial_ends_at: (ends_at if pay_subscription.trial_ends_at?),
17 | ends_at: ends_at
18 | )
19 |
20 | # Paddle doesn't allow reusing customers, so we should remove their payment methods
21 | Pay::PaymentMethod.where(customer_id: pay_subscription.customer_id).destroy_all
22 | end
23 | end
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/braintree/charges_controller.rb:
--------------------------------------------------------------------------------
1 | class Braintree::ChargesController < ApplicationController
2 | before_action :set_charge, only: [:show, :refund]
3 |
4 | def index
5 | @charges = Pay::Charge.joins(:customer).where(pay_customers: {processor: :braintree}).order(created_at: :desc)
6 | end
7 |
8 | def show
9 | end
10 |
11 | def new
12 | end
13 |
14 | def create
15 | current_user.set_payment_processor params[:processor]
16 | current_user.payment_processor.payment_method_token = params[:card_token]
17 | charge = current_user.payment_processor.charge(params[:amount])
18 | redirect_to braintree_charge_path(charge)
19 | rescue Pay::Error => e
20 | flash[:alert] = e.message
21 | render :new, status: :unprocessable_entity
22 | end
23 |
24 | def refund
25 | @charge.refund!
26 | rescue Pay::Error => e
27 | flash[:alert] = e.message
28 | ensure
29 | redirect_to braintree_charge_path(@charge)
30 | end
31 |
32 | private
33 |
34 | def set_charge
35 | @charge = Pay::Charge.find(params[:id])
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/payment_method_updated_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::PaymentMethodUpdatedTest < ActiveSupport::TestCase
4 | setup do
5 | @event = stripe_event("payment_method.updated")
6 | end
7 |
8 | test "updates payment method in database" do
9 | payment_method = pay_payment_methods(:one)
10 |
11 | # Spoof Stripe PaymentMethod lookup
12 | fake_payment_method = OpenStruct.new(id: payment_method.processor_id, customer: "cus_1234", type: "card", card: OpenStruct.new(brand: "Visa", last4: "4242", exp_month: "01", exp_year: "2034"))
13 | ::Stripe::PaymentMethod.expects(:retrieve).returns(fake_payment_method)
14 |
15 | fake_customer = OpenStruct.new(invoice_settings: OpenStruct.new(default_payment_method: nil))
16 | ::Stripe::Customer.expects(:retrieve).returns(fake_customer)
17 |
18 | assert_equal payment_method.exp_year, payment_method.exp_year
19 | Pay::Stripe::Webhooks::PaymentMethodUpdated.new.call(@event)
20 |
21 | payment_method.reload
22 | assert_equal "2034", payment_method.exp_year
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/test/pay/paddle/webhooks/subscription_payment_refunded_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Paddle::Webhooks::SubscriptionPaymentRefundedTest < ActiveSupport::TestCase
4 | setup do
5 | @data = OpenStruct.new JSON.parse(File.read("test/support/fixtures/paddle/subscription_payment_refunded.json"))
6 | @pay_customer = pay_customers(:paddle)
7 | @pay_customer.update(processor_id: @data.user_id)
8 | end
9 |
10 | test "a charge is updated with refunded amount" do
11 | charge = @pay_customer.charges.create!(processor_id: @data.subscription_payment_id, amount: 16)
12 | Pay::Paddle::Webhooks::SubscriptionPaymentRefunded.new.call(@data)
13 | assert_equal (@data.gross_refund.to_f * 100).to_i, charge.reload.amount_refunded
14 | end
15 |
16 | test "a charge isn't updated with the refunded amount if a corresponding charge can't be found (obviously)" do
17 | charge = @pay_customer.charges.create!(processor_id: "does-not-exist", amount: 16)
18 | Pay::Paddle::Webhooks::SubscriptionPaymentRefunded.new.call(@data)
19 | assert_nil charge.reload.amount_refunded
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/app/controllers/pay/webhooks/braintree_controller.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Webhooks
3 | class BraintreeController < Pay::ApplicationController
4 | if Rails.application.config.action_controller.default_protect_from_forgery
5 | skip_before_action :verify_authenticity_token
6 | end
7 |
8 | def create
9 | queue_event(verified_event)
10 | head :ok
11 | rescue ::Braintree::InvalidSignature
12 | head :bad_request
13 | end
14 |
15 | private
16 |
17 | def queue_event(event)
18 | return unless Pay::Webhooks.delegator.listening?("braintree.#{event.kind}")
19 |
20 | record = Pay::Webhook.create!(
21 | processor: :braintree,
22 | event_type: event.kind,
23 | event: {bt_signature: params[:bt_signature], bt_payload: params[:bt_payload]}
24 | )
25 | Pay::Webhooks::ProcessJob.perform_later(record)
26 | end
27 |
28 | def verified_event
29 | Pay.braintree_gateway.webhook_notification.parse(params[:bt_signature], params[:bt_payload])
30 | end
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/test/dummy/app/views/braintree/payment_methods/edit.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= form_with url: braintree_payment_method_path,
4 | method: :patch,
5 | data: {
6 | controller: "braintree",
7 | target: "braintree.form",
8 | braintree_env: Pay.braintree_gateway.config.environment,
9 | braintree_client_token: Pay.braintree_gateway.client_token.generate
10 | } do |form| %>
11 |
12 | <%= tag.div nil, data: { target: "braintree.dropin" } %>
13 |
14 |
15 | <%= form.button "Save Payment Method", class: "btn btn-primary", data: { action: "click->braintree#submit", } %>
16 |
17 | <% end %>
18 |
19 |
20 |
21 |
22 |
Test cards
23 |
24 | 4111111111111111
25 | Visa
26 |
27 |
28 | <%= link_to "All Test Cards", "https://developers.braintreepayments.com/reference/general/testing/ruby", target: :_blank, class: "btn btn-outline-dark btn-sm mt-3" %>
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/MIT-LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2019 Jason Charnes
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/test/models/pay/customer_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::CustomerTest < ActiveSupport::TestCase
4 | test "active customers" do
5 | results = Pay::Customer.active
6 | assert_includes results, pay_customers(:stripe)
7 | refute_includes results, pay_customers(:deleted)
8 | end
9 |
10 | test "deleted customers" do
11 | assert_includes Pay::Customer.deleted, pay_customers(:deleted)
12 | end
13 |
14 | test "active?" do
15 | assert pay_customers(:stripe).active?
16 | end
17 |
18 | test "deleted?" do
19 | assert pay_customers(:deleted).deleted?
20 | end
21 |
22 | test "update_customer!" do
23 | assert pay_customers(:fake).respond_to?(:update_customer!)
24 | end
25 |
26 | test "update_customer! with a promotion code" do
27 | pay_customer = pay_customers(:fake)
28 | assert pay_customer.update_customer!(promotion_code: "promo_xxx123")
29 | end
30 |
31 | test "not_fake scope" do
32 | assert_not_includes Pay::Customer.not_fake_processor, pay_customers(:fake)
33 | assert_includes Pay::Customer.not_fake_processor, pay_customers(:stripe)
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/test/dummy/app/views/payment_methods/show.html.erb:
--------------------------------------------------------------------------------
1 | Payment Method
2 |
3 |
4 |
Processor
5 | <%= current_user.payment_processor.processor&.titleize || "None"%>
6 |
7 |
8 |
Payment Method Details
9 | <% case @payment_method&.type %>
10 | <% when "paypal", "PayPal" %>
11 |
<%= @payment_method.brand.titleize %> (<%= @payment_method.email %>)
12 | <% when "card" %>
13 |
<%= @payment_method.brand.titleize %> ending in <%= @payment_method.last4 %>
14 |
Expires <%= @payment_method.exp_month %> / <%= @payment_method.exp_year %>
15 | <% when nil %>
16 | No card on file.
17 | <% else %>
18 |
<%= @payment_method.data %>
19 | <% end %>
20 |
21 |
22 |
23 |
Update Payment Method
24 | <%= link_to "Stripe", edit_stripe_payment_method_path, class: "d-block" %>
25 | <%= link_to "Braintree", edit_braintree_payment_method_path, class: "d-block" %>
26 | <%= link_to "Paddle", edit_paddle_payment_method_path, class: "d-block" %>
27 |
28 |
--------------------------------------------------------------------------------
/lib/pay/braintree/webhooks/subscription_charged_successfully.rb:
--------------------------------------------------------------------------------
1 | # A subscription successfully moves to the next billing cycle. This will also occur when either a new transaction is created mid-cycle due to proration on an upgrade or a billing cycle is skipped due to the presence of a negative balance that covers the cost of the subscription.
2 |
3 | module Pay
4 | module Braintree
5 | module Webhooks
6 | class SubscriptionChargedSuccessfully
7 | def call(event)
8 | subscription = event.subscription
9 | return if subscription.nil?
10 |
11 | pay_subscription = Pay::Subscription.find_by_processor_and_id(:braintree, subscription.id)
12 | return unless pay_subscription.present?
13 |
14 | charge = subscription.transactions.first
15 | pay_charge = Pay::Braintree::Charge.sync(charge.id, object: charge)
16 |
17 | if pay_charge && Pay.send_email?(:receipt, pay_charge)
18 | Pay.mailer.with(pay_customer: pay_subscription.customer, pay_charge: pay_charge).receipt.deliver_later
19 | end
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/pay/braintree/webhooks/subscription_charged_unsuccessfully.rb:
--------------------------------------------------------------------------------
1 | # A subscription successfully moves to the next billing cycle. This will also occur when either a new transaction is created mid-cycle due to proration on an upgrade or a billing cycle is skipped due to the presence of a negative balance that covers the cost of the subscription.
2 |
3 | module Pay
4 | module Braintree
5 | module Webhooks
6 | class SubscriptionChargedUnsuccessfully
7 | def call(event)
8 | subscription = event.subscription
9 | return if subscription.nil?
10 |
11 | pay_subscription = Pay::Subscription.find_by_processor_and_id(:braintree, subscription.id)
12 | return unless pay_subscription.present?
13 |
14 | # pay_customer = pay_subscription.customer
15 | # pay_charge = Pay::Braintree::Billable.new(pay_customer).save_transaction(subscription.transactions.first)
16 |
17 | # if Pay.send_emails
18 | # Pay.mailer.with(pay_customer: pay_charge.customer, charge: pay_charge).receipt.deliver_later
19 | # end
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/test/dummy/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require 'fileutils'
3 |
4 | # path to your application root.
5 | APP_ROOT = File.expand_path('..', __dir__)
6 |
7 | def system!(*args)
8 | system(*args) || abort("\n== Command #{args} failed ==")
9 | end
10 |
11 | FileUtils.chdir APP_ROOT do
12 | # This script is a way to setup or update your development environment automatically.
13 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome.
14 | # Add necessary setup steps to this file.
15 |
16 | puts '== Installing dependencies =='
17 | system! 'gem install bundler --conservative'
18 | system('bundle check') || system!('bundle install')
19 |
20 | # puts "\n== Copying sample files =="
21 | # unless File.exist?('config/database.yml')
22 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml'
23 | # end
24 |
25 | puts "\n== Preparing database =="
26 | system! 'bin/rails db:prepare'
27 |
28 | puts "\n== Removing old logs and tempfiles =="
29 | system! 'bin/rails log:clear tmp:clear'
30 |
31 | puts "\n== Restarting application server =="
32 | system! 'bin/rails restart'
33 | end
34 |
--------------------------------------------------------------------------------
/docs/paddle/2_javascript.md:
--------------------------------------------------------------------------------
1 | # Paddle Javascript
2 |
3 | ### Update Payment Details
4 |
5 | https://developer.paddle.com/guides/how-tos/subscriptions/update-payment-details
6 |
7 | ##### Inline
8 |
9 | ```html
10 |
Update Payment Information
14 | ```
15 |
16 | ```javascript
17 | Paddle.Checkout.open({
18 | override: 'https://checkout.paddle.com/subscription/update...',
19 | success: 'https://example.com/subscription/update/success'
20 | });
21 | ```
22 |
23 | ##### Overlay
24 |
25 | ```javascript
26 | Paddle.Checkout.open({
27 | override: 'https://checkout.paddle.com/subscription/update...',
28 | method: 'inline',
29 | frameTarget: 'checkout-container', // The className of your checkout
30 | frameInitialHeight: 416,
31 | frameStyle: 'width:100%; min-width:312px; background-color: transparent; border: none;', // Please ensure the minimum width is kept at or above 312px.
32 | success: 'https://example.com/subscription/update/success'
33 | });
34 | ```
35 |
36 |
--------------------------------------------------------------------------------
/test/support/vcr.rb:
--------------------------------------------------------------------------------
1 | require "vcr"
2 |
3 | unless ENV["SKIP_VCR"]
4 | require "webmock/minitest"
5 |
6 | VCR.configure do |c|
7 | c.cassette_library_dir = "test/vcr_cassettes"
8 | c.hook_into :webmock
9 | c.allow_http_connections_when_no_cassette = true
10 | c.filter_sensitive_data("
") { ENV["PADDLE_VENDOR_ID"] }
11 | c.filter_sensitive_data("") { ENV["PADDLE_VENDOR_AUTH_CODE"] }
12 | c.filter_sensitive_data("") { Pay::Stripe.private_key }
13 | c.filter_sensitive_data("") { Pay::Braintree.private_key }
14 | c.filter_sensitive_data("") { Pay::Paddle.vendor_auth_code }
15 | end
16 |
17 | class ActiveSupport::TestCase
18 | setup do
19 | # Test filenames are case sensitive in CI
20 | VCR.insert_cassette name, allow_unused_http_interactions: false, record_on_error: false
21 | end
22 |
23 | teardown do
24 | cassette = VCR.current_cassette
25 | VCR.eject_cassette
26 | rescue VCR::Errors::UnusedHTTPInteractionError
27 | puts
28 | puts "Unused HTTP requests in cassette: #{cassette.file}"
29 | raise
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/test/models/pay/webhook_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Webhook::Test < ActiveSupport::TestCase
4 | test "rehydrates a Paddle event" do
5 | pay_webhook = Pay::Webhook.create processor: :paddle, event_type: :example, event: fake_event("paddle/subscription_payment_succeeded")
6 | event = pay_webhook.rehydrated_event
7 | assert_equal OpenStruct, event.class
8 | assert_equal "visa", event.payment_method.card_type
9 | end
10 |
11 | test "rehydrates a Stripe event" do
12 | pay_webhook = Pay::Webhook.create processor: :stripe, event_type: :example, event: fake_event("stripe/customer.updated")
13 | event = pay_webhook.rehydrated_event
14 | assert_equal ::Stripe::Event, event.class
15 | assert_equal "pm_1000", event.previous_attributes.invoice_settings.default_payment_method
16 | end
17 |
18 | test "rehydrates a Braintree event" do
19 | pay_webhook = Pay::Webhook.create processor: :braintree, event_type: :example, event: fake_event("braintree/subscription_charged_successfully")
20 | event = pay_webhook.rehydrated_event
21 | assert_equal ::Braintree::WebhookNotification, event.class
22 | assert_equal "f6rnpm", event.subscription.id
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/tasks/pay.rake:
--------------------------------------------------------------------------------
1 | namespace :pay do
2 | namespace :payment_methods do
3 | desc "Sync default payment methods for Pay::Customers"
4 | task sync_default: :environment do
5 | Pay::Customer.find_each do |pay_customer|
6 | sync_default_payment_method(pay_customer)
7 | end
8 | end
9 | end
10 | end
11 |
12 | def sync_default_payment_method(pay_customer, retries: 2)
13 | try = 0
14 | begin
15 | puts "Syncing Pay::Customer ##{pay_customer.id} attempt #{try + 1}: #{pay_customer.processor.titleize} #{pay_customer.processor_id}"
16 | case pay_customer.processor
17 | when "braintree"
18 | payment_method = pay_customer.customer.payment_methods.find(&:default?)
19 | Pay::Braintree::PaymentMethod.sync(payment_method.token, object: payment_method) if payment_method
20 | when "stripe"
21 | payment_method_id = pay_customer.customer.invoice_settings.default_payment_method
22 | Pay::Stripe::PaymentMethod.sync(payment_method_id) if payment_method_id
23 | when "paddle"
24 | Pay::Paddle::PaymentMethod.sync(pay_customer: pay_customer)
25 | end
26 | rescue
27 | sleep 0.5
28 | try += 1
29 | (try <= retries) ? retry : raise
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/app/controllers/pay/webhooks/paddle_controller.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Webhooks
3 | class PaddleController < Pay::ApplicationController
4 | if Rails.application.config.action_controller.default_protect_from_forgery
5 | skip_before_action :verify_authenticity_token
6 | end
7 |
8 | def create
9 | queue_event(verified_event)
10 | head :ok
11 | rescue Pay::Paddle::Error
12 | head :bad_request
13 | end
14 |
15 | private
16 |
17 | def queue_event(event)
18 | return unless Pay::Webhooks.delegator.listening?("paddle.#{params[:alert_name]}")
19 |
20 | record = Pay::Webhook.create!(processor: :paddle, event_type: params[:alert_name], event: event)
21 | Pay::Webhooks::ProcessJob.perform_later(record)
22 | end
23 |
24 | def verified_event
25 | event = verify_params.as_json
26 | verifier = Pay::Paddle::Webhooks::SignatureVerifier.new(event)
27 | return event if verifier.verify
28 | raise Pay::Paddle::Error, "Unable to verify Paddle webhook event"
29 | end
30 |
31 | def verify_params
32 | params.except(:action, :controller).permit!
33 | end
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/app/models/pay/webhook.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | class Webhook < Pay::ApplicationRecord
3 | validates :processor, presence: true
4 | validates :event_type, presence: true
5 | validates :event, presence: true
6 |
7 | def process!
8 | Pay::Webhooks.instrument type: "#{processor}.#{event_type}", event: rehydrated_event
9 |
10 | # Remove after successfully processing
11 | destroy
12 | end
13 |
14 | # Events have already been verified by the webhook, so we just store the raw data
15 | # Then we can rehydrate as webhook objects for each payment processor
16 | def rehydrated_event
17 | case processor
18 | when "braintree"
19 | Pay.braintree_gateway.webhook_notification.parse(event["bt_signature"], event["bt_payload"])
20 | when "paddle"
21 | to_recursive_ostruct(event)
22 | when "stripe"
23 | ::Stripe::Event.construct_from(event)
24 | else
25 | event
26 | end
27 | end
28 |
29 | def to_recursive_ostruct(hash)
30 | result = hash.each_with_object({}) do |(key, val), memo|
31 | memo[key] = val.is_a?(Hash) ? to_recursive_ostruct(val) : val
32 | end
33 | OpenStruct.new(result)
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/stripe/charges_controller.rb:
--------------------------------------------------------------------------------
1 | module Stripe
2 | class ChargesController < ApplicationController
3 | before_action :set_charge, only: [:show, :refund]
4 |
5 | def index
6 | @charges = Pay::Charge.joins(:customer).where(pay_customers: {processor: :stripe}).order(created_at: :desc)
7 | end
8 |
9 | def show
10 | end
11 |
12 | def new
13 | end
14 |
15 | def create
16 | current_user.set_payment_processor params[:processor]
17 | current_user.payment_processor.payment_method_token = params[:card_token]
18 | charge = current_user.payment_processor.charge(params[:amount])
19 | redirect_to stripe_charge_path(charge)
20 | rescue Pay::ActionRequired => e
21 | redirect_to pay.payment_path(e.payment.id)
22 | rescue Pay::Error => e
23 | flash[:alert] = e.message
24 | render :new, status: :unprocessable_entity
25 | end
26 |
27 | def refund
28 | @charge.refund!
29 | rescue Pay::Error => e
30 | flash[:alert] = e.message
31 | ensure
32 | redirect_to stripe_charge_path(@charge)
33 | end
34 |
35 | private
36 |
37 | def set_charge
38 | @charge = Pay::Charge.find(params[:id])
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/test/controllers/pay/webhooks/braintree_controller_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | module Pay
4 | class BraintreeWebhooksControllerTest < ActionDispatch::IntegrationTest
5 | include Engine.routes.url_helpers
6 |
7 | setup do
8 | @routes = Engine.routes
9 | end
10 |
11 | test "should handle post requests" do
12 | post webhooks_braintree_path
13 | assert_response :bad_request
14 | end
15 |
16 | test "should parse a braintree webhook" do
17 | params = fake_event "braintree/subscription_charged_successfully"
18 |
19 | pay_customer = pay_customers(:braintree)
20 | pay_customer.update(processor_id: "108696401")
21 | pay_customer.subscriptions.create!(
22 | processor_id: "f6rnpm",
23 | processor_plan: "default",
24 | name: "default",
25 | status: "active"
26 | )
27 |
28 | assert_difference("Pay::Webhook.count") do
29 | assert_enqueued_with(job: Pay::Webhooks::ProcessJob) do
30 | post webhooks_braintree_path, params: params
31 | assert_response :success
32 | end
33 | end
34 |
35 | assert_difference("pay_customer.charges.count") do
36 | perform_enqueued_jobs
37 | end
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/customer_deleted_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::CustomerDeletedTest < ActiveSupport::TestCase
4 | setup do
5 | @event = stripe_event("customer.deleted")
6 | end
7 |
8 | test "stripe customer delete marks pay customer deleted" do
9 | pay_customer = pay_customers(:stripe)
10 | pay_customer.update!(processor_id: @event.data.object.id)
11 | pay_customer.payment_methods.create!(processor_id: "pm_fake")
12 | pay_subscription = pay_customer.subscriptions.create!(
13 | processor_id: "sub_someid",
14 | name: "default",
15 | processor_plan: "some-plan",
16 | trial_ends_at: 3.days.from_now,
17 | status: "active"
18 | )
19 |
20 | Pay::Stripe::Webhooks::CustomerDeleted.new.call(@event)
21 |
22 | pay_customer.reload
23 | pay_subscription.reload
24 |
25 | refute pay_customer.default?
26 | assert pay_customer.deleted_at?
27 | assert_empty pay_customer.payment_methods
28 | assert pay_subscription.canceled?
29 | end
30 |
31 | test "stripe customer deleted webhook does nothing if customer not in database" do
32 | assert_nothing_raised do
33 | Pay::Stripe::Webhooks::CustomerDeleted.new.call(@event)
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/test/dummy/app/views/braintree/charges/new.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= form_with url: braintree_charges_path,
4 | method: :post,
5 | data: {
6 | controller: "braintree",
7 | target: "braintree.form",
8 | braintree_env: Pay.braintree_gateway.config.environment,
9 | braintree_client_token: Pay.braintree_gateway.client_token.generate
10 | } do |form| %>
11 |
12 |
13 | <%= form.label :amount, "Amount in cents" %>
14 | <%= form.text_field :amount, value: 1500, class: "form-control" %>
15 |
16 |
17 | <%= tag.div nil, data: { target: "braintree.dropin" } %>
18 |
19 |
20 | <%= form.button "Checkout", class: "btn btn-primary", data: { action: "click->braintree#submit", } %>
21 |
22 | <% end %>
23 |
24 |
25 |
26 |
27 |
Test cards
28 |
29 | 4111111111111111
30 | Visa
31 |
32 |
33 | <%= link_to "All Test Cards", "https://developers.braintreepayments.com/reference/general/testing/ruby", target: :_blank, class: "btn btn-outline-dark btn-sm mt-3" %>
34 |
35 |
36 |
--------------------------------------------------------------------------------
/test/dummy/app/views/braintree/subscriptions/new.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= form_with url: braintree_subscriptions_path,
4 | method: :post,
5 | data: {
6 | controller: "braintree",
7 | target: "braintree.form",
8 | braintree_env: Pay.braintree_gateway.config.environment,
9 | braintree_client_token: Pay.braintree_gateway.client_token.generate
10 | } do |form| %>
11 |
12 |
13 | <%= form.label :plan_id, "Plan ID" %>
14 | <%= form.text_field :plan_id, value: "default", class: "form-control" %>
15 |
16 |
17 | <%= tag.div nil, data: { target: "braintree.dropin" } %>
18 |
19 |
20 | <%= form.button "Checkout", class: "btn btn-primary", data: { action: "click->braintree#submit", } %>
21 |
22 | <% end %>
23 |
24 |
25 |
26 |
27 |
Test cards
28 |
29 | 4111111111111111
30 | Visa
31 |
32 |
33 | <%= link_to "All Test Cards", "https://developers.braintreepayments.com/reference/general/testing/ruby", target: :_blank, class: "btn btn-outline-dark btn-sm mt-3" %>
34 |
35 |
36 |
--------------------------------------------------------------------------------
/lib/pay/paddle/charge.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Paddle
3 | class Charge
4 | attr_reader :pay_charge
5 |
6 | delegate :processor_id, :customer, to: :pay_charge
7 |
8 | def initialize(pay_charge)
9 | @pay_charge = pay_charge
10 | end
11 |
12 | def charge
13 | return unless customer.subscription
14 | payments = PaddlePay::Subscription::Payment.list({subscription_id: customer.subscription.processor_id})
15 | charges = payments.select { |p| p[:id].to_s == processor_id }
16 | charges.try(:first)
17 | rescue ::PaddlePay::PaddlePayError => e
18 | raise Pay::Paddle::Error, e
19 | end
20 |
21 | def refund!(amount_to_refund)
22 | return unless customer.subscription
23 | payments = PaddlePay::Subscription::Payment.list({subscription_id: customer.subscription.processor_id, is_paid: 1})
24 | if payments.count > 0
25 | PaddlePay::Subscription::Payment.refund(payments.last[:id], {amount: amount_to_refund})
26 | pay_charge.update(amount_refunded: amount_to_refund)
27 | else
28 | raise Error, "Payment not found"
29 | end
30 | rescue ::PaddlePay::PaddlePayError => e
31 | raise Pay::Paddle::Error, e
32 | end
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/test/dummy/config/routes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literals: true
2 |
3 | Rails.application.routes.draw do
4 | resource :payment_method
5 |
6 | namespace :braintree do
7 | resource :payment_method, namespace: :braintree
8 | resources :subscriptions do
9 | member do
10 | patch :cancel
11 | patch :resume
12 | end
13 | end
14 | resources :charges do
15 | member do
16 | patch :refund
17 | end
18 | end
19 | end
20 |
21 | namespace :paddle do
22 | resource :payment_method, namespace: :paddle
23 | resources :subscriptions do
24 | member do
25 | patch :cancel
26 | patch :resume
27 | end
28 | end
29 | resources :charges do
30 | member do
31 | patch :refund
32 | end
33 | end
34 | end
35 |
36 | namespace :stripe do
37 | resource :payment_method, namespace: :stripe
38 | resources :subscriptions do
39 | member do
40 | patch :cancel
41 | patch :resume
42 | end
43 | end
44 | resources :charges do
45 | member do
46 | patch :refund
47 | end
48 | end
49 | namespace :charges do
50 | resource :import
51 | end
52 | resource :checkout, namespace: :stripe
53 | end
54 |
55 | root to: "main#show"
56 | end
57 |
--------------------------------------------------------------------------------
/test/dummy/config/storage.yml:
--------------------------------------------------------------------------------
1 | test:
2 | service: Disk
3 | root: <%= Rails.root.join("tmp/storage") %>
4 |
5 | local:
6 | service: Disk
7 | root: <%= Rails.root.join("storage") %>
8 |
9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
10 | # amazon:
11 | # service: S3
12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
14 | # region: us-east-1
15 | # bucket: your_own_bucket-<%= Rails.env %>
16 |
17 | # Remember not to checkin your GCS keyfile to a repository
18 | # google:
19 | # service: GCS
20 | # project: your_project
21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
22 | # bucket: your_own_bucket-<%= Rails.env %>
23 |
24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
25 | # microsoft:
26 | # service: AzureStorage
27 | # storage_account_name: your_account_name
28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
29 | # container: your_container_name-<%= Rails.env %>
30 |
31 | # mirror:
32 | # service: Mirror
33 | # primary: local
34 | # mirrors: [ amazon, google, microsoft ]
35 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/subscription_renewing.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class SubscriptionRenewing
5 | # Handles `invoice.upcoming` webhook from Stripe
6 | # Occurs X number of days before a subscription is scheduled to create an invoice that is automatically charged—where X is determined by your subscriptions settings. Note: The received Invoice object will not have an invoice ID.
7 |
8 | def call(event)
9 | # Event is of type "invoice" see:
10 | # https://stripe.com/docs/api/invoices/object
11 | pay_subscription = Pay::Subscription.find_by_processor_and_id(:stripe, event.data.object.subscription)
12 | return unless pay_subscription
13 |
14 | # Stripe subscription items all have the same interval
15 | price = event.data.object.lines.data.first.price
16 |
17 | if Pay.send_email?(:subscription_renewing, pay_subscription, price)
18 | Pay.mailer.with(
19 | pay_customer: pay_subscription.customer,
20 | pay_subscription: pay_subscription,
21 | date: Time.zone.at(event.data.object.next_payment_attempt)
22 | ).subscription_renewing.deliver_later
23 | end
24 | end
25 | end
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/test/support/fixtures/stripe/payment_method.attached.json:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "id": "card_1JN6Z1KXBGcbgpbZ2AZJOYEZ",
4 | "object": "payment_method",
5 | "billing_details": {
6 | "address": {
7 | "city": null,
8 | "country": null,
9 | "line1": null,
10 | "line2": null,
11 | "postal_code": null,
12 | "state": null
13 | },
14 | "email": null,
15 | "name": null,
16 | "phone": null
17 | },
18 | "card": {
19 | "brand": "visa",
20 | "checks": {
21 | "address_line1_check": null,
22 | "address_postal_code_check": null,
23 | "cvc_check": "unchecked"
24 | },
25 | "country": "US",
26 | "exp_month": 4,
27 | "exp_year": 2024,
28 | "fingerprint": "w4XDzQOFakih5EZM",
29 | "funding": "credit",
30 | "generated_from": null,
31 | "last4": "4242",
32 | "networks": {
33 | "available": [
34 | "visa"
35 | ],
36 | "preferred": null
37 | },
38 | "three_d_secure_usage": {
39 | "supported": true
40 | },
41 | "wallet": null
42 | },
43 | "created": 1628646543,
44 | "customer": "cus_1234",
45 | "livemode": false,
46 | "metadata": {
47 | },
48 | "type": "card"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/test/fixtures/pay/customers.yml:
--------------------------------------------------------------------------------
1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
2 |
3 | stripe:
4 | owner: stripe (User)
5 | processor: stripe
6 | processor_id: cus_1234
7 | default: true
8 |
9 | braintree:
10 | owner: braintree (User)
11 | processor: braintree
12 | processor_id: bt_1234
13 | default: true
14 |
15 | paddle:
16 | owner: paddle (User)
17 | processor: paddle
18 | processor_id: 17368056
19 | default: true
20 |
21 | fake:
22 | owner: fake (User)
23 | processor: fake_processor
24 | processor_id: fake_1234
25 | default: true
26 |
27 | multiple_first:
28 | owner: multiple (User)
29 | processor: stripe
30 | processor_id: cus_1235
31 | default: true
32 |
33 | multiple_second:
34 | owner: multiple (User)
35 | processor: braintree
36 | processor_id: bt_1235
37 | default: true
38 |
39 | deleted:
40 | owner: deleted_customer (User)
41 | processor: stripe
42 | processor_id: 9999
43 | default: false
44 | deleted_at: <%= Time.current %>
45 |
46 | deleted2:
47 | owner: deleted_customer (User)
48 | processor: stripe
49 | processor_id: 9998
50 | default: false
51 | deleted_at: <%= Time.current %>
52 |
53 | pending_stripe:
54 | owner: pending (User)
55 | processor: stripe
56 | processor_id:
57 | default: true
58 |
--------------------------------------------------------------------------------
/lib/pay/stripe/webhooks/checkout_session_completed.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Stripe
3 | module Webhooks
4 | class CheckoutSessionCompleted
5 | def call(event)
6 | locate_owner(event.data.object)
7 |
8 | # By the time CheckoutSessionCompleted is fired, we probably missed the original events
9 | # Instead, we can sync the payment intent or subscription during this event to ensure they're in the database
10 |
11 | if (payment_intent_id = event.data.object.payment_intent)
12 | payment_intent = ::Stripe::PaymentIntent.retrieve({id: payment_intent_id}, {stripe_account: event.try(:account)}.compact)
13 | Pay::Stripe::Charge.sync(payment_intent.latest_charge, stripe_account: event.try(:account))
14 | end
15 |
16 | if (subscription_id = event.data.object.subscription)
17 | Pay::Stripe::Subscription.sync(subscription_id, stripe_account: event.try(:account))
18 | end
19 | end
20 |
21 | def locate_owner(object)
22 | return if object.client_reference_id.nil?
23 |
24 | owner = Pay::Stripe.find_by_client_reference_id(object.client_reference_id)
25 | owner&.add_payment_processor(:stripe, processor_id: object.customer)
26 | end
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/test/support/fixtures/stripe/payment_method.detached.json:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "id": "pm_1000",
4 | "object": "payment_method",
5 | "billing_details": {
6 | "address": {
7 | "city": null,
8 | "country": null,
9 | "line1": null,
10 | "line2": null,
11 | "postal_code": "12345",
12 | "state": null
13 | },
14 | "email": null,
15 | "name": "User Two",
16 | "phone": null
17 | },
18 | "card": {
19 | "brand": "visa",
20 | "checks": {
21 | "address_line1_check": null,
22 | "address_postal_code_check": "pass",
23 | "cvc_check": "pass"
24 | },
25 | "country": "DE",
26 | "exp_month": 12,
27 | "exp_year": 2034,
28 | "fingerprint": "qu2JxQg97yWGBVrd",
29 | "funding": "credit",
30 | "generated_from": null,
31 | "last4": "3184",
32 | "networks": {
33 | "available": [
34 | "visa"
35 | ],
36 | "preferred": null
37 | },
38 | "three_d_secure_usage": {
39 | "supported": true
40 | },
41 | "wallet": null
42 | },
43 | "created": 1628643304,
44 | "customer": null,
45 | "livemode": false,
46 | "metadata": {
47 | },
48 | "type": "card"
49 | },
50 | "previous_attributes": {
51 | "customer": "cus_1234"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/docs/9_testing.md:
--------------------------------------------------------------------------------
1 | # Testing Pay
2 |
3 | Pay comes with a fake payment processor to make testing easy. It can also be used in production to give free access to friends, testers, etc.
4 |
5 | ### Using the Fake Processor
6 |
7 | To protect from abuse, the `allow_fake` option must be set to `true` in order to use the Fake Processor.
8 |
9 | ```ruby
10 | @user.set_payment_processor :fake_processor, allow_fake: true
11 | ```
12 |
13 | You can then make charges and subscriptions like normal. These will be generated with random unique IDs just like a real payment processor.
14 |
15 | ```ruby
16 | pay_charge = @user.payment_processor.charge(19_00)
17 | pay_subscription = @user.payment_processor.subscribe(plan: "fake")
18 | ```
19 |
20 | ### Test Examples
21 |
22 | You'll want to test the various situations like subscriptions on trial, active, canceled on grace period, canceled permanently, etc.
23 |
24 | Fake processor charges and subscriptions will automatically assign these fields to the database for easy testing of different situations:
25 |
26 | ```ruby
27 | # Canceled subscription
28 | @user.payment_processor.subscribe(plan: "fake", ends_at: 1.week.ago)
29 |
30 | # On Trial
31 | @user.payment_processor.subscribe(plan: "fake", trial_ends_at: 1.week.from_now)
32 |
33 | # Expired Trial
34 | @user.payment_processor.subscribe(plan: "fake", trial_ends_at: 1.week.ago)
35 | ```
36 |
--------------------------------------------------------------------------------
/lib/pay/payment.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | class Payment
3 | attr_reader :intent
4 |
5 | delegate :id, :amount, :client_secret, :currency, :customer, :status, :confirm, to: :intent
6 |
7 | def self.from_id(id)
8 | intent = id.start_with?("seti_") ? ::Stripe::SetupIntent.retrieve(id) : ::Stripe::PaymentIntent.retrieve(id)
9 | new(intent)
10 | end
11 |
12 | def initialize(intent)
13 | @intent = intent
14 | end
15 |
16 | def requires_payment_method?
17 | status == "requires_payment_method"
18 | end
19 |
20 | def requires_action?
21 | status == "requires_action"
22 | end
23 |
24 | def canceled?
25 | status == "canceled"
26 | end
27 |
28 | def cancelled?
29 | canceled?
30 | end
31 |
32 | def succeeded?
33 | status == "succeeded"
34 | end
35 |
36 | def payment_intent?
37 | intent.is_a?(::Stripe::PaymentIntent)
38 | end
39 |
40 | def setup_intent?
41 | intent.is_a?(::Stripe::SetupIntent)
42 | end
43 |
44 | def amount_with_currency
45 | Pay::Currency.format(amount, currency: currency)
46 | end
47 |
48 | def validate
49 | if requires_payment_method?
50 | raise Pay::InvalidPaymentMethod.new(self)
51 | elsif requires_action?
52 | raise Pay::ActionRequired.new(self)
53 | end
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/pay/braintree/payment_method.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Braintree
3 | class PaymentMethod
4 | attr_reader :pay_payment_method
5 |
6 | delegate :customer, :processor_id, to: :pay_payment_method
7 |
8 | def self.sync(id, object: nil, try: 0, retries: 1)
9 | object ||= Pay.braintree_gateway.payment_method.find(id)
10 |
11 | pay_customer = Pay::Customer.find_by(processor: :braintree, processor_id: object.customer_id)
12 | return unless pay_customer
13 |
14 | pay_customer.save_payment_method(object, default: object.default?)
15 | end
16 |
17 | def initialize(pay_payment_method)
18 | @pay_payment_method = pay_payment_method
19 | end
20 |
21 | # Sets payment method as default on Stripe
22 | def make_default!
23 | result = gateway.customer.update(customer.processor_id, default_payment_method_token: processor_id)
24 | raise Pay::Braintree::Error, result unless result.success?
25 | result.success?
26 | end
27 |
28 | # Remove payment method
29 | def detach
30 | result = gateway.payment_method.delete(processor_id)
31 | raise Pay::Braintree::Error, result unless result.success?
32 | result.success?
33 | end
34 |
35 | private
36 |
37 | def gateway
38 | Pay.braintree_gateway
39 | end
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/test/pay/stripe_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Test < ActiveSupport::TestCase
4 | test "finds API keys from env" do
5 | ENV["STRIPE_PUBLIC_KEY"] = "public"
6 | ENV["STRIPE_PRIVATE_KEY"] = "private"
7 | ENV["STRIPE_SIGNING_SECRET"] = "secret"
8 |
9 | assert_equal "public", Pay::Stripe.public_key
10 | assert_equal "private", Pay::Stripe.private_key
11 | assert_equal "secret", Pay::Stripe.signing_secret
12 | end
13 |
14 | test "can generate a client_reference_id for a model" do
15 | user = users(:none)
16 | assert_equal "User/#{user.id}", Pay::Stripe.to_client_reference_id(user)
17 | end
18 |
19 | test "raises an error for client_reference_id if the object does not use Pay" do
20 | assert_raises ArgumentError do
21 | Pay::Stripe.to_client_reference_id("not-a-user-instance")
22 | end
23 | end
24 |
25 | test "can find a record by client_reference_id" do
26 | user = users(:none)
27 | assert_equal user, Pay::Stripe.find_by_client_reference_id("User/#{user.id}")
28 | end
29 |
30 | test "returns nil if record not found by client_reference_id" do
31 | assert_nil Pay::Stripe.find_by_client_reference_id("User/9999")
32 | end
33 |
34 | test "returns nil if client_reference_id is not an allowed class" do
35 | assert_nil Pay::Stripe.find_by_client_reference_id("Secret::Agent::Man/9999")
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/test/support/fixtures/stripe/payment_method.updated.json:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "id": "pm_1000",
4 | "object": "payment_method",
5 | "billing_details": {
6 | "address": {
7 | "city": null,
8 | "country": null,
9 | "line1": null,
10 | "line2": null,
11 | "postal_code": "12345",
12 | "state": null
13 | },
14 | "email": null,
15 | "name": "User Two",
16 | "phone": null
17 | },
18 | "card": {
19 | "brand": "visa",
20 | "checks": {
21 | "address_line1_check": null,
22 | "address_postal_code_check": "pass",
23 | "cvc_check": "pass"
24 | },
25 | "country": "DE",
26 | "exp_month": 12,
27 | "exp_year": 2035,
28 | "fingerprint": "qu2JxQg97yWGBVrd",
29 | "funding": "credit",
30 | "generated_from": null,
31 | "last4": "3184",
32 | "networks": {
33 | "available": [
34 | "visa"
35 | ],
36 | "preferred": null
37 | },
38 | "three_d_secure_usage": {
39 | "supported": true
40 | },
41 | "wallet": null
42 | },
43 | "created": 1628645643,
44 | "customer": "cus_1234",
45 | "livemode": false,
46 | "metadata": {
47 | },
48 | "type": "card"
49 | },
50 | "previous_attributes": {
51 | "card": {
52 | "exp_year": 2034
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/lib/pay/braintree/charge.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Braintree
3 | class Charge
4 | attr_reader :pay_charge
5 |
6 | delegate :processor_id, to: :pay_charge
7 |
8 | def self.sync(charge_id, object: nil, try: 0, retries: 1)
9 | object ||= Pay.braintree_gateway.transaction.find(charge_id)
10 |
11 | pay_customer = Pay::Customer.find_by(processor: :braintree, processor_id: object.customer_details.id)
12 | return unless pay_customer
13 |
14 | pay_customer.save_transaction(object)
15 | rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
16 | try += 1
17 | if try <= retries
18 | sleep 0.1
19 | retry
20 | else
21 | raise
22 | end
23 | end
24 |
25 | def initialize(pay_charge)
26 | @pay_charge = pay_charge
27 | end
28 |
29 | def charge
30 | Pay.braintree_gateway.transaction.find(processor_id)
31 | rescue ::Braintree::Braintree::Error => e
32 | raise Pay::Braintree::Error, e
33 | end
34 |
35 | def refund!(amount_to_refund)
36 | Pay.braintree_gateway.transaction.refund(processor_id, amount_to_refund / 100.0)
37 | pay_charge.update(amount_refunded: amount_to_refund)
38 | rescue ::Braintree::BraintreeError => e
39 | raise Pay::Braintree::Error, e
40 | end
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" }
3 |
4 | # Declare your gem's dependencies in pay.gemspec.
5 | # Bundler will treat runtime dependencies like base dependencies, and
6 | # development dependencies will be added by default to the :development group.
7 | gemspec
8 |
9 | # Declare any dependencies that are still in development here instead of in
10 | # your gemspec. These might include edge Rails or gems from your path or
11 | # Git. Remember to move these dependencies to your gemspec before releasing
12 | # your gem to rubygems.org.
13 |
14 | gem "byebug"
15 | gem "appraisal", github: "thoughtbot/appraisal"
16 | gem "overcommit"
17 | gem "standard"
18 | gem "mocha"
19 | gem "vcr"
20 | gem "webmock"
21 |
22 | gem "braintree", ">= 2.92.0"
23 | gem "stripe", "~> 8.0"
24 | gem "paddle_pay", "~> 0.2"
25 |
26 | gem "receipts"
27 | gem "prawn", github: "prawnpdf/prawn"
28 |
29 | # Test against different databases
30 | gem "pg"
31 | gem "mysql2"
32 | gem "sqlite3", "~> 1.6.0.rc2"
33 |
34 | # Used for the dummy Rails app integration
35 | gem "puma"
36 | gem "web-console", group: :development
37 |
38 | gem "sprockets-rails"
39 | gem "importmap-rails"
40 | gem "turbo-rails"
41 | gem "stimulus-rails"
42 |
43 | # Ruby 3.1+ drops these built-in gems
44 | gem "net-imap", require: false
45 | gem "net-pop", require: false
46 | gem "net-smtp", require: false
47 |
--------------------------------------------------------------------------------
/test/pay/billable/sync_customer_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Billable::SyncCustomer::Test < ActiveSupport::TestCase
4 | include ActiveJob::TestHelper
5 |
6 | test "customer sync only on updating customer email" do
7 | assert_no_enqueued_jobs do
8 | User.create(email: "test@example.com")
9 | end
10 |
11 | assert_enqueued_with(job: Pay::CustomerSyncJob, args: [users(:stripe).payment_processor.id]) do
12 | users(:stripe).update(email: "test@test.com")
13 | end
14 | end
15 |
16 | test "customer sync on updating with pay_should_sync_customer? overriden" do
17 | assert_no_enqueued_jobs do
18 | User.create(email: "test@example.com")
19 | end
20 |
21 | assert_enqueued_with(job: Pay::CustomerSyncJob, args: [users(:stripe).payment_processor.id]) do
22 | user = users(:stripe)
23 | def user.pay_should_sync_customer?
24 | true
25 | end
26 |
27 | users(:stripe).update(first_name: "whatever")
28 | end
29 | end
30 |
31 | test "email sync should be ignored for billable that delegates email" do
32 | assert_no_enqueued_jobs do
33 | Team.create(name: "Team 1")
34 | end
35 | end
36 |
37 | test "queues multiple jobs if a user has multiple payment processors" do
38 | user = users(:multiple)
39 | assert_enqueued_jobs 2 do
40 | user.update(email: "test@test.com")
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/paddle/subscriptions_controller.rb:
--------------------------------------------------------------------------------
1 | class Paddle::SubscriptionsController < ApplicationController
2 | before_action :set_subscription, only: [:show, :edit, :update, :destroy, :cancel, :resume]
3 |
4 | def index
5 | @subscriptions = Pay::Subscription.joins(:customer).where(pay_customers: {processor: :paddle}).order(created_at: :desc)
6 | end
7 |
8 | def show
9 | end
10 |
11 | def new
12 | end
13 |
14 | def create
15 | current_user.set_payment_processor params[:processor]
16 | current_user.payment_processor.payment_method_token = params[:card_token]
17 | subscription = current_user.payment_processor.subscribe(plan: params[:plan_id])
18 | redirect_to paddle_subscription_path(subscription)
19 | rescue Pay::Error => e
20 | flash[:alert] = e.message
21 | redirect_to new_paddle_subscription_path
22 | end
23 |
24 | def edit
25 | end
26 |
27 | def update
28 | end
29 |
30 | def destroy
31 | @subscription.cancel_now!
32 | redirect_to paddle_subscription_path(@subscription)
33 | end
34 |
35 | def cancel
36 | @subscription.cancel
37 | redirect_to paddle_subscription_path(@subscription)
38 | end
39 |
40 | def resume
41 | @subscription.resume
42 | redirect_to paddle_subscription_path(@subscription)
43 | end
44 |
45 | private
46 |
47 | def set_subscription
48 | @subscription = Pay::Subscription.find(params[:id])
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/test/mailers/previews/pay/user_mailer_preview.rb:
--------------------------------------------------------------------------------
1 | class Pay::UserMailerPreview < ActionMailer::Preview
2 | def payment_action_required
3 | Pay::UserMailer.with(
4 | pay_customer: Pay::Customer.first,
5 | payment_intent_id: "fake"
6 | ).payment_action_required
7 | end
8 |
9 | def payment_failed
10 | Pay::UserMailer.with(
11 | pay_customer: Pay::Customer.first
12 | ).payment_failed
13 | end
14 |
15 | def receipt
16 | Pay::UserMailer.with(
17 | pay_customer: Pay::Customer.first,
18 | pay_charge: Pay::Charge.first
19 | ).receipt
20 | end
21 |
22 | def refund
23 | Pay::UserMailer.with(
24 | pay_customer: Pay::Customer.first,
25 | pay_charge: Pay::Charge.first
26 | ).receipt
27 | end
28 |
29 | def subscription_renewing
30 | Pay::UserMailer.with(
31 | pay_customer: Pay::Customer.first,
32 | pay_subscription: Pay::Subscription.first,
33 | date: Date.today
34 | ).subscription_renewing
35 | end
36 |
37 | def subscription_trial_ended
38 | Pay::UserMailer.with(
39 | pay_customer: Pay::Customer.first,
40 | pay_subscription: Pay::Subscription.first
41 | ).subscription_trial_ended
42 | end
43 |
44 | def subscription_trial_will_end
45 | Pay::UserMailer.with(
46 | pay_customer: Pay::Customer.first,
47 | pay_subscription: Pay::Subscription.first
48 | ).subscription_trial_will_end
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/app/models/pay/payment_method.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | class PaymentMethod < Pay::ApplicationRecord
3 | self.inheritance_column = nil
4 |
5 | belongs_to :customer
6 |
7 | store_accessor :data, :stripe_account
8 | store_accessor :data, :brand # Visa, Mastercard, Discover, PayPal
9 | store_accessor :data, :last4
10 | store_accessor :data, :exp_month
11 | store_accessor :data, :exp_year
12 | store_accessor :data, :email # PayPal email, etc
13 | store_accessor :data, :username
14 | store_accessor :data, :bank
15 |
16 | # Aliases to share PaymentMethodAttributes
17 | alias_attribute :payment_method_type, :type
18 |
19 | validates :processor_id, presence: true, uniqueness: {scope: :customer_id, case_sensitive: true}
20 |
21 | def self.find_by_processor_and_id(processor, processor_id)
22 | joins(:customer).find_by(processor_id: processor_id, pay_customers: {processor: processor})
23 | end
24 |
25 | def self.pay_processor_for(name)
26 | "Pay::#{name.to_s.classify}::PaymentMethod".constantize
27 | end
28 |
29 | def payment_processor
30 | @payment_processor ||= self.class.pay_processor_for(customer.processor).new(self)
31 | end
32 |
33 | def make_default!
34 | return if default?
35 |
36 | payment_processor.make_default!
37 |
38 | customer.payment_methods.update_all(default: false)
39 | update!(default: true)
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/braintree/subscriptions_controller.rb:
--------------------------------------------------------------------------------
1 | class Braintree::SubscriptionsController < ApplicationController
2 | before_action :set_subscription, only: [:show, :edit, :update, :destroy, :cancel, :resume]
3 |
4 | def index
5 | @subscriptions = Pay::Subscription.joins(:customer).where(pay_customers: {processor: :braintree}).order(created_at: :desc)
6 | end
7 |
8 | def show
9 | end
10 |
11 | def new
12 | end
13 |
14 | def create
15 | current_user.set_payment_processor params[:processor]
16 | current_user.payment_processor.payment_method_token = params[:card_token]
17 | subscription = current_user.payment_processor.subscribe(plan: params[:plan_id])
18 | redirect_to braintree_subscription_path(subscription)
19 | rescue Pay::Error => e
20 | flash[:alert] = e.message
21 | redirect_to new_braintree_subscription_path
22 | end
23 |
24 | def edit
25 | end
26 |
27 | def update
28 | end
29 |
30 | def destroy
31 | @subscription.cancel_now!
32 | redirect_to braintree_subscription_path(@subscription)
33 | end
34 |
35 | def cancel
36 | @subscription.cancel
37 | redirect_to braintree_subscription_path(@subscription)
38 | end
39 |
40 | def resume
41 | @subscription.resume
42 | redirect_to braintree_subscription_path(@subscription)
43 | end
44 |
45 | private
46 |
47 | def set_subscription
48 | @subscription = Pay::Subscription.find(params[:id])
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/docs/stripe/6_metered_billing.md:
--------------------------------------------------------------------------------
1 | # Stripe Metered Billing
2 |
3 | Metered billing are subscriptions where the price fluctuates monthly. For example, you may spin up servers on DigitalOcean, shut some down, and keep others running. Metered billing allows you to report usage of these servers and charge according to what was used.
4 |
5 | ```ruby
6 | @user.payment_processor.subscribe(plan: "price_metered_billing_id")
7 | ```
8 |
9 | This will create a new metered billing subscription.
10 |
11 | To report usage, you will need to create usage records for the `SubscriptionItem`. You can do that using the Pay helper:
12 |
13 | ```ruby
14 | pay_subscription.create_usage_record(quantity: 99)
15 | ```
16 |
17 | If your subscription has multiple SubscriptionItems, you can specify the `subscription_item_id` to be used:
18 |
19 | ```ruby
20 | pay_subscription.create_usage_record(subscription_item_id: "si_1234", quantity: 99)
21 | ```
22 |
23 | ## Failed Payments
24 |
25 | If a metered billing subscription fails, it will fall into a `past_due` state.
26 |
27 | After payment attempts fail, Stripe will either leave the subscription alone, cancel it, or mark it as `unpaid` depending on the settings in your Stripe account.
28 | We recommend marking the subscription as `unpaid`.
29 |
30 | You can notify your user to update their payment method. Once they do, you can retry the open payment to bring their subscription back into the active state.
31 |
32 | ## Next
33 |
34 | See [Stripe Tax](7_stripe_tax.md)
35 |
--------------------------------------------------------------------------------
/test/dummy/app/views/stripe/payment_methods/edit.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= form_with url: stripe_payment_method_path, method: :patch, local: true, id: "payment-form", data: { setup_intent: @setup_intent.client_secret } do |form| %>
4 |
5 |
6 | Name on Card
7 |
8 |
9 |
10 |
11 |
12 |
23 |
24 | <%= form.submit "Save Payment Method", class: "btn btn-primary" %>
25 | <% end %>
26 |
27 |
28 |
29 |
Test Cards
30 |
31 | 4242 4242 4242 4242
32 | No Authentication
33 |
34 |
35 |
36 | 4000 0027 6000 3184
37 | Requires Authentication
38 |
39 |
40 | <%= link_to "All Test Cards", "https://stripe.com/docs/testing", target: :_blank, class: "btn btn-outline-dark btn-sm mt-3" %>
41 |
42 |
43 |
--------------------------------------------------------------------------------
/test/support/fixtures/paddle/subscription_cancelled.json:
--------------------------------------------------------------------------------
1 | {
2 | "alert_id": "1440248908",
3 | "alert_name": "subscription_cancelled",
4 | "cancellation_effective_date": "2020-11-18 11:18:16",
5 | "checkout_id": "7-1bfa34cf3136556-972299a6b8",
6 | "currency": "USD",
7 | "email": "wilkinson.cicero@example.net",
8 | "event_time": "2020-11-16 16:45:09",
9 | "linked_subscriptions": "6, 1, 6",
10 | "marketing_consent": "",
11 | "passthrough": "{\"owner_sgid\": \"BAh7CEkiCGdpZAY6BkVUSSIiZ2lkOi8vZHVtbXkvVXNlci8xP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIhJwYWRkbGVfMTIzNDU2BjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--0ee181d81896a7e0ef25d322c6081df1ec534cdd\"}",
12 | "quantity": "49",
13 | "status": "deleted",
14 | "subscription_id": "7",
15 | "subscription_plan_id": "9",
16 | "unit_price": "unit_price",
17 | "user_id": "9",
18 | "p_signature": "17ffO2bIL20TjljrhhETh3PfpNyDi8WLvx+cK1DVy1EEDUPPCT3CCAITuUraVKJjWD3hWsOx1/KVNUaqzqMWAA5GWa4cPf/iaAXDUGXCpsNwZQR7XeZXmQnD8xXHH2/BaL7BStGJ4iyGqbZynzJM0gIV+2j6ZXa3uvkReZMVlU9AUZoF1I4su3U15509bsjcPw/2UOhj6GFKH0WNwcsPkhzd9Xzfxcof5NWDlmIpiEnHgnBUGbdqpHp46ey2uavA5hpIFTs8chJgAQGh/suDuuK+GKE0CyIRCnuJP7Jd57qqCUVIWPgucMErKJWeiO3QOyQPRegIF0RlfJhT/i87hg1iJ5loxpc+a+ljNvQcrPpi6fUE6EGiaqOhaSmAD2/+VDy+y5UGwl0E9GU4fPND9lmS9WISbdO9L1Z0tVpb3/LJOXRk/rxkpp3ss4/ZoC91Fak0TD/bKmS48K8KWsb7s3DiMPONGuSJCKtJzAnDHFrr6XN/n+5HZS4xor6AFkH8UtZlBZD03o76i8dCORc/UBhuVfzmjICQbLUAzvu5M8I/4hR21ev9wmaShTNSe8FAjMWV/k2nAmLfDJGNRvoYAqOdGwxij3CviU2ArnIboqbrgu9lA1TNTM55OgAKFOphlHp0tGlfa0WOPtMdB5oYSvpIdnJ1O7+WrnNRcaAl374="
19 | }
20 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/subscription_renewing_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::SubscriptionRenewingTest < ActiveSupport::TestCase
4 | setup do
5 | @event = stripe_event("invoice.upcoming")
6 | @pay_customer = pay_customers(:stripe)
7 | @pay_customer.update(processor_id: @event.data.object.customer)
8 | end
9 |
10 | test "yearly subscription should receive renewal email" do
11 | @event.data.object.lines.data.first.price.recurring.interval = "year"
12 |
13 | create_subscription(processor_id: @event.data.object.subscription)
14 | Pay::Stripe::Webhooks::SubscriptionRenewing.new.call(@event)
15 | assert_enqueued_emails 1
16 | end
17 |
18 | test "monthly subscription should not receive renewal email" do
19 | @event.data.object.lines.data.first.price.recurring.interval = "month"
20 |
21 | create_subscription(processor_id: @event.data.object.subscription)
22 | assert_no_enqueued_emails do
23 | Pay::Stripe::Webhooks::SubscriptionRenewing.new.call(@event)
24 | end
25 | end
26 |
27 | test "missing subscription should not receive renewal email" do
28 | assert_no_enqueued_emails do
29 | create_subscription(processor_id: "does-not-exist")
30 | Pay::Stripe::Webhooks::SubscriptionRenewing.new.call(@event)
31 | end
32 | end
33 |
34 | private
35 |
36 | def create_subscription(processor_id:)
37 | @pay_customer.subscriptions.create!(processor_id: processor_id, name: "default", processor_plan: "some-plan", status: "active")
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/test/dummy/app/views/stripe/charges/new.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= form_with url: stripe_charges_path, local: true, id: "payment-form" do |form| %>
4 |
5 | <%= form.label :amount, "Amount in cents" %>
6 | <%= form.text_field :amount, value: 1500, class: "form-control" %>
7 |
8 |
9 |
10 |
11 | Name on Card
12 |
13 |
14 |
15 |
16 |
17 |
28 |
29 | <%= form.submit "Checkout", class: "btn btn-primary" %>
30 | <% end %>
31 |
32 |
33 |
34 |
Test Cards
35 |
36 | 4242 4242 4242 4242
37 | No Authentication
38 |
39 |
40 |
41 | 4000 0027 6000 3184
42 | Requires Authentication
43 |
44 |
45 | <%= link_to "All Test Cards", "https://stripe.com/docs/testing", target: :_blank, class: "btn btn-outline-dark btn-sm mt-3" %>
46 |
47 |
48 |
--------------------------------------------------------------------------------
/test/pay/paddle/charge_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Paddle::Charge::Test < ActiveSupport::TestCase
4 | setup do
5 | @pay_customer = pay_customers(:paddle)
6 | end
7 |
8 | test "paddle can get paddle charge" do
9 | charge = @pay_customer.charges.create!(
10 | processor_id: "11018517",
11 | amount: 119,
12 | payment_method_type: "card",
13 | paddle_receipt_url: "https://my.paddle.com/receipt/15124577-11018517/57042319-chre8cc6b3d11d5-1696e10c7c",
14 | created_at: Time.zone.now
15 | )
16 | paddle_charge = charge.processor_charge
17 | assert_equal charge.processor_id, paddle_charge[:id].to_s
18 | end
19 |
20 | test "paddle can fully refund a transaction" do
21 | charge = @pay_customer.charges.create!(
22 | processor_id: "11018517",
23 | amount: 119,
24 | payment_method_type: "card",
25 | paddle_receipt_url: "https://my.paddle.com/receipt/15124577-11018517/57042319-chre8cc6b3d11d5-1696e10c7c",
26 | created_at: Time.zone.now
27 | )
28 |
29 | charge.refund!
30 | assert_equal 119, charge.amount_refunded
31 | end
32 |
33 | test "paddle cannot refund a transaction without payment" do
34 | charge = @pay_customer.charges.create!(
35 | processor_id: "does-not-exist",
36 | amount: 119,
37 | payment_method_type: "card",
38 | paddle_receipt_url: "https://my.paddle.com/receipt/15124577-11018517/57042319-chre8cc6b3d11d5-1696e10c7c",
39 | created_at: Time.zone.now
40 | )
41 |
42 | assert_raises(Pay::Error) { charge.refund! }
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/test/dummy/app/views/stripe/subscriptions/new.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= form_with url: stripe_subscriptions_path, local: true, id: "payment-form" do |form| %>
4 |
5 | <%= form.label :price_id, "Price or Plan ID" %>
6 | <%= form.text_field :price_id, value: "default", class: "form-control" %>
7 |
8 |
9 |
10 |
11 | Name on Card
12 |
13 |
14 |
15 |
16 |
17 |
28 |
29 | <%= form.submit "Checkout", class: "btn btn-primary" %>
30 | <% end %>
31 |
32 |
33 |
34 |
Test Cards
35 |
36 | 4242 4242 4242 4242
37 | No Authentication
38 |
39 |
40 |
41 | 4000 0027 6000 3184
42 | Requires Authentication
43 |
44 |
45 | <%= link_to "All Test Cards", "https://stripe.com/docs/testing", target: :_blank, class: "btn btn-outline-dark btn-sm mt-3" %>
46 |
47 |
48 |
--------------------------------------------------------------------------------
/test/pay/stripe/webhooks/checkout_session_completed_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::Webhooks::CheckoutSessionCompletedTest < ActiveSupport::TestCase
4 | test "creates Pay::Customer if client_reference_id present and valid" do
5 | client_reference_id = Pay::Stripe.to_client_reference_id(users(:none))
6 | event = stripe_event("checkout.session.completed", overrides: {"object" => {"client_reference_id" => client_reference_id}})
7 | Pay::Stripe::Subscription.expects(:sync)
8 | assert_difference "Pay::Customer.count" do
9 | Pay::Stripe::Webhooks::CheckoutSessionCompleted.new.call(event)
10 | end
11 | end
12 |
13 | test "handles client_reference_id if present but not valid" do
14 | event = stripe_event("checkout.session.completed", overrides: {"object" => {"client_reference_id" => "invalid"}})
15 | Pay::Stripe::Subscription.expects(:sync)
16 | assert_no_difference "Pay::Customer.count" do
17 | Pay::Stripe::Webhooks::CheckoutSessionCompleted.new.call(event)
18 | end
19 | end
20 |
21 | test "checkout session completed syncs latest charge" do
22 | event = stripe_event("checkout.session.completed", overrides: {"object" => {"payment_intent" => "pi_1234", "latest_charge" => "ch_1234", "subscription" => nil}})
23 | ::Stripe::PaymentIntent.expects(:retrieve).returns(OpenStruct.new(id: "pi_1234", latest_charge: OpenStruct.new(id: "ch_1234")))
24 | Pay::Stripe::Charge.expects(:sync)
25 | assert_no_difference "Pay::Customer.count" do
26 | Pay::Stripe::Webhooks::CheckoutSessionCompleted.new.call(event)
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/pay/paddle/webhooks/subscription_updated.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Paddle
3 | module Webhooks
4 | class SubscriptionUpdated
5 | def call(event)
6 | pay_subscription = Pay::Subscription.find_by_processor_and_id(:paddle, event["subscription_id"])
7 |
8 | return if pay_subscription.nil?
9 |
10 | case event["status"]
11 | when "deleted"
12 | pay_subscription.status = "canceled"
13 | pay_subscription.ends_at = Time.zone.parse(event["next_bill_date"]) || Time.current if pay_subscription.ends_at.blank?
14 | when "trialing"
15 | pay_subscription.status = "trialing"
16 | pay_subscription.trial_ends_at = Time.zone.parse(event["next_bill_date"])
17 | when "active"
18 | pay_subscription.status = "active"
19 | pay_subscription.pause_starts_at = Time.zone.parse(event["paused_from"]) if event["paused_from"].present?
20 | else
21 | pay_subscription.status = event["status"]
22 | end
23 |
24 | pay_subscription.quantity = event["new_quantity"]
25 | pay_subscription.processor_plan = event["subscription_plan_id"]
26 | pay_subscription.paddle_update_url = event["update_url"]
27 | pay_subscription.paddle_cancel_url = event["cancel_url"]
28 |
29 | # If user was on trial, their subscription ends at the end of the trial
30 | pay_subscription.ends_at = pay_subscription.trial_ends_at if pay_subscription.on_trial?
31 |
32 | pay_subscription.save!
33 | end
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/test/dummy/app/controllers/stripe/subscriptions_controller.rb:
--------------------------------------------------------------------------------
1 | module Stripe
2 | class SubscriptionsController < ApplicationController
3 | before_action :set_subscription, only: [:show, :edit, :update, :destroy, :cancel, :resume]
4 |
5 | def index
6 | @subscriptions = Pay::Subscription.joins(:customer).where(pay_customers: {processor: :stripe}).order(created_at: :desc)
7 | end
8 |
9 | def show
10 | end
11 |
12 | def new
13 | end
14 |
15 | def create
16 | current_user.set_payment_processor params[:processor]
17 | current_user.payment_processor.payment_method_token = params[:card_token]
18 | subscription = current_user.payment_processor.subscribe(plan: params[:price_id])
19 | redirect_to stripe_subscription_path(subscription)
20 | rescue Pay::ActionRequired => e
21 | redirect_to pay.payment_path(e.payment.id)
22 | rescue Pay::Error => e
23 | flash[:alert] = e.message
24 | render :new, status: :unprocessable_entity
25 | end
26 |
27 | def edit
28 | end
29 |
30 | def update
31 | end
32 |
33 | def destroy
34 | @subscription.cancel_now!
35 | redirect_to stripe_subscription_path(@subscription)
36 | end
37 |
38 | def cancel
39 | @subscription.cancel
40 | redirect_to stripe_subscription_path(@subscription)
41 | end
42 |
43 | def resume
44 | @subscription.resume
45 | redirect_to stripe_subscription_path(@subscription)
46 | end
47 |
48 | private
49 |
50 | def set_subscription
51 | @subscription = Pay::Subscription.find(params[:id])
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/test/pay/stripe/checkout_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Stripe::CheckoutTest < ActiveSupport::TestCase
4 | setup do
5 | @pay_customer = pay_customers(:stripe)
6 | @pay_customer.update(processor_id: nil)
7 | @pay_customer.customer
8 | end
9 |
10 | test "checkout success_url includes session_id" do
11 | session = @pay_customer.checkout(mode: "setup")
12 | assert_equal "http://localhost:3000/?session_id={CHECKOUT_SESSION_ID}", session.success_url
13 | end
14 |
15 | test "checkout setup session" do
16 | session = @pay_customer.checkout(mode: "setup")
17 | assert_equal "setup", session.mode
18 | end
19 |
20 | test "checkout payment session" do
21 | session = @pay_customer.checkout(mode: "payment", line_items: "price_1ILVZaKXBGcbgpbZQ26kgXWG")
22 | assert_equal "payment", session.mode
23 | end
24 |
25 | test "checkout subscription session" do
26 | session = @pay_customer.checkout(mode: "subscription", line_items: "default")
27 | assert_equal "subscription", session.mode
28 | end
29 |
30 | test "billing portal session" do
31 | session = @pay_customer.billing_portal
32 | assert_not_nil session.url
33 | end
34 |
35 | test "raises an error with empty default_url_options" do
36 | # This should raise:
37 | # ArgumentError: Missing host to link to! Please provide the :host parameter, set default_url_options[:host], or set :only_path to true
38 |
39 | Rails.application.config.action_mailer.stub :default_url_options, nil do
40 | assert_raises ArgumentError do
41 | @pay_customer.checkout(mode: "setup")
42 | end
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/test/dummy/app/javascript/controllers/braintree_controller.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "@hotwired/stimulus"
2 | import Rails from "@rails/ujs"
3 |
4 | export default class extends Controller {
5 | static targets = [ "dropin", "form" ]
6 |
7 | connect() {
8 | braintree.dropin.create({
9 | authorization: this.data.get("clientToken"),
10 | container: this.dropinTarget,
11 | //threeDSecure: true,
12 | paypal: {
13 | flow: "vault"
14 | },
15 | // Uncomment this to only display PayPal in the Drop-in UI
16 | //paymentOptionPriority: ['paypal']
17 | },
18 | this.clientCreated.bind(this)
19 | )
20 | }
21 |
22 | clientCreated(error, instance) {
23 | if (error) {
24 | console.error("Error setting up Braintree dropin:", error)
25 | return
26 | }
27 |
28 | this.instance = instance
29 | }
30 |
31 | submit(event) {
32 | event.preventDefault()
33 | this.instance.requestPaymentMethod(this.paymentMethod.bind(this))
34 | }
35 |
36 | paymentMethod(error, payload) {
37 | if (error) {
38 | console.error("Error with payment method:", error)
39 | return
40 | }
41 |
42 | this.addHiddenField("processor", "braintree")
43 | this.addHiddenField("card_token", payload.nonce)
44 |
45 | Rails.fire(this.formTarget, "submit")
46 | }
47 |
48 | addHiddenField(name, value) {
49 | let hiddenInput = document.createElement("input")
50 | hiddenInput.setAttribute("type", "hidden")
51 | hiddenInput.setAttribute("name", name)
52 | hiddenInput.setAttribute("value", value)
53 | this.formTarget.appendChild(hiddenInput)
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/test/pay/braintree/charge_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 | require "minitest/mock"
3 |
4 | class Pay::Braintree::Charge::Test < ActiveSupport::TestCase
5 | setup do
6 | @pay_customer = pay_customers(:braintree)
7 | @pay_customer.update(processor_id: nil)
8 | end
9 |
10 | test "can partially refund a transaction" do
11 | @pay_customer.payment_method_token = "fake-valid-visa-nonce"
12 |
13 | charge = @pay_customer.charge(29_00)
14 | assert charge.present?
15 |
16 | charge.refund!(10_00)
17 | assert_equal 10_00, charge.amount_refunded
18 | end
19 |
20 | test "can fully refund a transaction" do
21 | @pay_customer.payment_method_token = "fake-valid-visa-nonce"
22 |
23 | charge = @pay_customer.charge(37_00)
24 | assert charge.present?
25 |
26 | charge.refund!
27 | assert_equal 37_00, charge.amount_refunded
28 | end
29 |
30 | test "you can ask the charge for the type" do
31 | assert pay_customers(:stripe).charges.new.stripe?
32 | refute pay_customers(:braintree).charges.new.stripe?
33 |
34 | assert pay_customers(:braintree).charges.new.braintree?
35 | refute pay_customers(:braintree).charges.new.stripe?
36 |
37 | assert pay_customers(:paddle).charges.new.paddle?
38 | refute pay_customers(:paddle).charges.new.stripe?
39 |
40 | assert pay_customers(:fake).charges.new.fake_processor?
41 | refute pay_customers(:fake).charges.new.stripe?
42 | end
43 |
44 | test "braintree saves currency on charge" do
45 | @pay_customer.payment_method_token = "fake-valid-visa-nonce"
46 | charge = @pay_customer.charge(29_00)
47 | assert_equal "USD", charge.currency
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/test/pay/paddle/webhooks/subscription_created_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Paddle::Webhooks::SubscriptionCreatedTest < ActiveSupport::TestCase
4 | setup do
5 | @data = OpenStruct.new JSON.parse(File.read("test/support/fixtures/paddle/subscription_created.json"))
6 | @user = users(:paddle)
7 | end
8 |
9 | test "paddle passthrough" do
10 | json = JSON.parse Pay::Paddle.passthrough(owner: @user, foo: :bar)
11 | assert_equal "bar", json["foo"]
12 | assert_equal @user, GlobalID::Locator.locate_signed(json["owner_sgid"])
13 | end
14 |
15 | test "a subscription is created" do
16 | assert_difference "Pay::Subscription.count" do
17 | @data.passthrough = Pay::Paddle.passthrough(owner: @user)
18 | Pay::Paddle::Webhooks::SubscriptionCreated.new.call(@data)
19 | end
20 |
21 | @user.reload
22 |
23 | assert_equal "paddle", @user.payment_processor.processor
24 | assert_equal @data.user_id, @user.payment_processor.processor_id
25 |
26 | subscription = Pay::Subscription.last
27 | assert_equal @data.quantity.to_i, subscription.quantity
28 | assert_equal @data.subscription_plan_id, subscription.processor_plan
29 | assert_equal @data.update_url, subscription.paddle_update_url
30 | assert_equal @data.cancel_url, subscription.paddle_cancel_url
31 | assert_equal Time.zone.parse(@data.next_bill_date), subscription.trial_ends_at
32 | assert_nil subscription.ends_at
33 | end
34 |
35 | test "a subscription isn't created if no corresponding owner can be found" do
36 | @data.passthrough = "does-not-exist"
37 |
38 | assert_no_difference "Pay::Subscription.count" do
39 | Pay::Paddle::Webhooks::SubscriptionCreated.new.call(@data)
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/test/dummy/public/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | We're sorry, but something went wrong (500)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
We're sorry, but something went wrong.
62 |
63 |
If you are the application owner check the logs for more information.
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/test/controllers/pay/webhooks/stripe_controller_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | module Pay
4 | class StripeWebhooksControllerTest < ActionDispatch::IntegrationTest
5 | include Engine.routes.url_helpers
6 |
7 | setup do
8 | @routes = Engine.routes
9 | end
10 |
11 | test "should handle stripe post requests" do
12 | post webhooks_stripe_path
13 | assert_response :bad_request
14 | end
15 |
16 | test "should parse a stripe webhook" do
17 | params = {
18 | "id" => "evt_3JMPQbQK2ZHS99Rk0zZhIl7y",
19 | "object" => "event",
20 | "api_version" => "2020-08-27",
21 | "created" => 1628480731,
22 | "data" => fake_event("stripe/charge.succeeded"),
23 | "livemode" => false,
24 | "pending_webhooks" => 3,
25 | "request" => {
26 | "id" => nil,
27 | "idempotency_key" => "in_1JMOTyQK2ZHS99Rk3k06zB02-initial_attempt-0dee959767cdedcc1"
28 | },
29 | "type" => "charge.succeeded"
30 | }
31 |
32 | stripe_event = ::Stripe::Event.construct_from(params)
33 | Pay::Webhooks::StripeController.any_instance.expects(:verified_event).returns(stripe_event)
34 | ::Stripe::Charge.expects(:retrieve).returns(stripe_event.data.object)
35 |
36 | pay_customer = pay_customers(:stripe)
37 | pay_customer.update(processor_id: stripe_event.data.object.customer)
38 |
39 | assert_difference "Pay::Webhook.count" do
40 | assert_enqueued_with(job: Pay::Webhooks::ProcessJob) do
41 | post webhooks_stripe_path, params: params
42 | assert_response :success
43 | end
44 | end
45 |
46 | assert_difference "Pay::Charge.count" do
47 | perform_enqueued_jobs
48 | end
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/test/support/fixtures/paddle/subscription_created.json:
--------------------------------------------------------------------------------
1 | {
2 | "alert_id": "585171988",
3 | "alert_name": "subscription_created",
4 | "cancel_url": "https://checkout.paddle.com/subscription/cancel?user=5&subscription=1&hash=a1f8c289b995bba3df34d38a0671d714d439d017",
5 | "checkout_id": "4-779e7566d0d342e-42367a752f",
6 | "currency": "GBP",
7 | "email": "kjakubowski@example.net",
8 | "event_time": "2020-11-16 16:40:31",
9 | "linked_subscriptions": "5, 3, 6",
10 | "marketing_consent": "1",
11 | "next_bill_date": "2020-12-08",
12 | "passthrough": "{\"owner_sgid\": \"BAh7CEkiCGdpZAY6BkVUSSIiZ2lkOi8vZHVtbXkvVXNlci8xP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIhJwYWRkbGVfMTIzNDU2BjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--0ee181d81896a7e0ef25d322c6081df1ec534cdd\"}",
13 | "quantity": "97",
14 | "source": "Trial",
15 | "status": "trialing",
16 | "subscription_id": "1",
17 | "subscription_plan_id": "4",
18 | "unit_price": "unit_price",
19 | "update_url": "https://checkout.paddle.com/subscription/update?user=6&subscription=1&hash=346cadb27c6146bd13d51228d1c4f34e3c6d954c",
20 | "user_id": "4",
21 | "p_signature": "SgjMh3AjXvcktlN016cOpfwsyFjzXb2e+BKwAxCg5zsgiC7posU0KqMEztS4h/nyTmBvnnhjzoQR4BxREktrsYx9QZ0goTHoWMpFhkYiA5ErfKw43pE0X7br7WZlEngs0plMZmXguVDLJoIaasj0w7FI9rlDRjXzdlpO/AAj2leT7byBXPpwxe1iv2boyRaGoYN81qIHAdRZOgjXAGA28CBBKaE8mODmNvizyteZDLOwjEeumzX+FWTY5TpjIxRAVjpwEHj48YtyJNbT7GGsLnW7c2wLkkRja7roENR4B28dr2I0V4gxWphPEKcYviiDG1jTum2RrKu76CbpiWxrkdZ3OzrdXjqGh6tQuorkMRdXIFVnyhL+sNP6LHxSbLSG6TYt6PxvWE4GUx9FoIftiQI0iFcUfkizc1DnZYm5lnzcxSUkNHrMNHItL6TJV9IXALnaTXvqXlly6LMN8wZKrzzWP1Ca2luu9w8IOVXWJ4NGU36pRGrfRL9MxykVx6hAL020oo9IWKDJc+udSP8lQDd0QyDCHWe0sX6Da2NHGXhlb1n7svNBQ1W7m5rr76Xk9c4mkddYtfJzEkg15n/EwCm2uqAAAq75PSSvKwmCTtT1pwNStBTklrUOBMBokfp2t2q4fcQbQQ3OpT325bstTj+ES8A6wpZCtHMoH/+nD/I="
22 | }
23 |
--------------------------------------------------------------------------------
/test/pay/paddle/webhooks/subscription_cancelled_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Paddle::Webhooks::SubscriptionCancelledTest < ActiveSupport::TestCase
4 | setup do
5 | @data = OpenStruct.new JSON.parse(File.read("test/support/fixtures/paddle/subscription_cancelled.json"))
6 | @pay_customer = pay_customers(:paddle)
7 | @pay_customer.update(processor_id: @data.user_id)
8 | end
9 |
10 | test "it sets ends_at on the subscription" do
11 | @pay_customer.subscription.update!(processor_id: @data["subscription_id"])
12 | Pay::Subscription.any_instance.expects(:update!).with(
13 | status: :canceled,
14 | trial_ends_at: nil,
15 | ends_at: Time.zone.parse(@data["cancellation_effective_date"])
16 | )
17 | Pay::Paddle::Webhooks::SubscriptionCancelled.new.call(@data)
18 | end
19 |
20 | test "it sets trial_ends_at on subscription with trial" do
21 | @pay_customer.subscription.update!(processor_id: @data["subscription_id"], trial_ends_at: 1.month.ago)
22 | Pay::Subscription.any_instance.expects(:update!).with(
23 | status: :canceled,
24 | trial_ends_at: Time.zone.parse(@data["cancellation_effective_date"]),
25 | ends_at: Time.zone.parse(@data["cancellation_effective_date"])
26 | )
27 | Pay::Paddle::Webhooks::SubscriptionCancelled.new.call(@data)
28 | end
29 |
30 | test "it doesn't set ends_at on the subscription if it can't find the subscription" do
31 | @pay_customer.subscription.update!(processor_id: "does-not-exist")
32 | Pay::Subscription.any_instance.expects(:update!).with(
33 | status: :canceled,
34 | trial_ends_at: nil,
35 | ends_at: Time.zone.parse(@data["cancellation_effective_date"])
36 | ).never
37 | Pay::Paddle::Webhooks::SubscriptionCancelled.new.call(@data)
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/app/controllers/pay/webhooks/stripe_controller.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Webhooks
3 | class StripeController < Pay::ApplicationController
4 | if Rails.application.config.action_controller.default_protect_from_forgery
5 | skip_before_action :verify_authenticity_token
6 | end
7 |
8 | def create
9 | queue_event(verified_event)
10 | head :ok
11 | rescue ::Stripe::SignatureVerificationError => e
12 | log_error(e)
13 | head :bad_request
14 | end
15 |
16 | private
17 |
18 | def queue_event(event)
19 | return unless Pay::Webhooks.delegator.listening?("stripe.#{event.type}")
20 |
21 | record = Pay::Webhook.create!(processor: :stripe, event_type: event.type, event: event)
22 | Pay::Webhooks::ProcessJob.perform_later(record)
23 | end
24 |
25 | def verified_event
26 | payload = request.body.read
27 | signature = request.headers["Stripe-Signature"]
28 | possible_secrets = secrets(payload, signature)
29 |
30 | possible_secrets.each_with_index do |secret, i|
31 | return ::Stripe::Webhook.construct_event(payload, signature, secret.to_s)
32 | rescue ::Stripe::SignatureVerificationError
33 | raise if i == possible_secrets.length - 1
34 | next
35 | end
36 | end
37 |
38 | def secrets(payload, signature)
39 | secret = Pay::Stripe.signing_secret
40 | return Array.wrap(secret) if secret
41 | raise ::Stripe::SignatureVerificationError.new("Cannot verify signature without a Stripe signing secret", signature, http_body: payload)
42 | end
43 |
44 | def log_error(e)
45 | logger.error e.message
46 | e.backtrace.each { |line| logger.error " #{line}" }
47 | end
48 | end
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/lib/pay/webhooks/delegator.rb:
--------------------------------------------------------------------------------
1 | module Pay
2 | module Webhooks
3 | class Delegator
4 | attr_reader :backend
5 |
6 | def initialize
7 | @backend = ActiveSupport::Notifications
8 | end
9 |
10 | # Configure DSL
11 | def configure(&block)
12 | raise ArgumentError, "must provide a block" unless block
13 | block.arity.zero? ? instance_eval(&block) : yield(self)
14 | end
15 |
16 | # Subscribe to specific events
17 | def subscribe(name, callable = nil, &block)
18 | callable ||= block
19 | backend.subscribe to_regexp(name), NotificationAdapter.new(callable)
20 | end
21 |
22 | # Listen to all events
23 | def all(callable = nil, &block)
24 | callable ||= block
25 | subscribe nil, callable
26 | end
27 |
28 | # Unsubscribe
29 | def unsubscribe(name)
30 | backend.unsubscribe name_with_namespace(name)
31 | end
32 |
33 | # Called to process an event
34 | def instrument(event:, type:)
35 | backend.instrument name_with_namespace(type), event
36 | end
37 |
38 | def listening?(type)
39 | backend.notifier.listening? name_with_namespace(type)
40 | end
41 |
42 | # Strips down to event data only
43 | class NotificationAdapter
44 | def initialize(subscriber)
45 | @subscriber = subscriber
46 | end
47 |
48 | def call(*args)
49 | payload = args.last
50 | @subscriber.call(payload)
51 | end
52 | end
53 |
54 | private
55 |
56 | def to_regexp(name)
57 | %r{^#{Regexp.escape name_with_namespace(name)}}
58 | end
59 |
60 | def name_with_namespace(name, delimiter: ".")
61 | [:pay, name].join(delimiter)
62 | end
63 | end
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/lib/pay/engine.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Pay
4 | class Engine < ::Rails::Engine
5 | engine_name "pay"
6 |
7 | initializer "pay.processors" do |app|
8 | if Pay.automount_routes
9 | app.routes.append do
10 | mount Pay::Engine, at: Pay.routes_path, as: "pay"
11 | end
12 | end
13 |
14 | # Include the pay attributes for ActiveRecord models
15 | ActiveSupport.on_load(:active_record) do
16 | include Pay::Attributes
17 | end
18 | end
19 |
20 | config.after_initialize do
21 | ActiveSupport.run_load_hooks(:pay, Pay)
22 | end
23 |
24 | # Add webhook subscribers before app initializers define extras
25 | # This keeps the processing in order so that changes have happened before user-defined webhook processors
26 | config.before_initialize do
27 | Pay::Stripe.configure_webhooks if Pay::Stripe.enabled?
28 | Pay::Braintree.configure_webhooks if Pay::Braintree.enabled?
29 | Pay::Paddle.configure_webhooks if Pay::Paddle.enabled?
30 | end
31 |
32 | config.to_prepare do
33 | Pay::Stripe.setup if Pay::Stripe.enabled?
34 | Pay::Braintree.setup if Pay::Braintree.enabled?
35 | Pay::Paddle.setup if Pay::Paddle.enabled?
36 |
37 | if defined?(::Receipts::VERSION)
38 | if Pay::Engine.version_matches?(required: "~> 2", current: ::Receipts::VERSION)
39 | Pay::Charge.include Pay::Receipts
40 | else
41 | raise "[Pay] receipts gem must be version ~> 2"
42 | end
43 | end
44 | end
45 |
46 | # Determines if a gem version matches requirements
47 | # Used for verifying that dependencies are correct
48 | def version_matches?(current:, required:)
49 | Gem::Dependency.new("gem", required).match? "gem", current
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/test/pay/currency_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class Pay::Currency::Test < ActiveSupport::TestCase
4 | test "formats amounts in different currencies" do
5 | assert_equal "$15.39", Pay::Currency.format(15_39, currency: :usd)
6 | assert_equal "1 539 Ft", Pay::Currency.format(15_39, currency: :huf)
7 | assert_equal "€15,39", Pay::Currency.format(15_39, currency: :eur)
8 | assert_equal "¥1,539", Pay::Currency.format(15_39, currency: :jpy)
9 | assert_equal "¥15.39", Pay::Currency.format(15_39, currency: :cny)
10 | assert_equal "£15.39", Pay::Currency.format(15_39, currency: :gbp)
11 | assert_equal "1.539 ع.د", Pay::Currency.format(15_39, currency: :iqd)
12 | end
13 |
14 | test "defaults to :usd if currency nil" do
15 | assert_equal "$15.39", Pay::Currency.format(15_39, currency: nil)
16 | end
17 |
18 | test "options" do
19 | assert_equal "$15", Pay::Currency.format(15_39, currency: nil, precision: 0)
20 | end
21 |
22 | test "additional precision" do
23 | assert_equal "$0.008", Pay::Currency.format(0.8, currency: nil)
24 | end
25 |
26 | test "formats amounts from strings in different currencies" do
27 | assert_equal "$15.39", Pay::Currency.format("1539", currency: :usd)
28 | assert_equal "1 539 Ft", Pay::Currency.format("1539", currency: :huf)
29 | assert_equal "€15,39", Pay::Currency.format("1539", currency: :eur)
30 | assert_equal "¥1,539", Pay::Currency.format("1539", currency: :jpy)
31 | assert_equal "¥15.39", Pay::Currency.format("1539", currency: :cny)
32 | assert_equal "£15.39", Pay::Currency.format("1539", currency: :gbp)
33 | assert_equal "1.539 ع.د", Pay::Currency.format("1539", currency: :iqd)
34 | end
35 |
36 | test "additional precision from string" do
37 | assert_equal "$0.008", Pay::Currency.format("0.8", currency: nil)
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/test/dummy/public/422.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The change you wanted was rejected (422)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The change you wanted was rejected.
62 |
Maybe you tried to change something you didn't have access to.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/test/dummy/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The page you were looking for doesn't exist (404)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The page you were looking for doesn't exist.
62 |
You may have mistyped the address or the page may have moved.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/docs/stripe/7_stripe_tax.md:
--------------------------------------------------------------------------------
1 | # Stripe Tax
2 |
3 | Collecting tax is easy with Stripe and Pay. You'll need to enable Stripe Tax in the dashboard and configure your Tax registrations where you're required to collect tax.
4 |
5 | ### Set Address on Customer
6 |
7 | An address is required on the Customer for tax calculations.
8 |
9 | ```ruby
10 | class User < ApplicationRecord
11 | pay_customer stripe_attributes: :stripe_attributes
12 |
13 | def stripe_attributes(pay_customer)
14 | {
15 | address: {
16 | country: "US",
17 | postal_code: "90210"
18 | }
19 | }
20 | end
21 | end
22 | ```
23 |
24 | To update the customer address anytime it's changed, call the following method:
25 |
26 | ```ruby
27 | @user.payment_processor.update_customer!
28 | ```
29 |
30 | This will make an API request to update the Stripe::Customer with the current `stripe_attributes`.
31 |
32 | See the Stripe Docs for more information about update tax addresses on a customer.
33 | https://stripe.com/docs/api/customers/update#update_customer-tax-ip_address
34 |
35 | ### Subscribe with Automatic Tax
36 |
37 | To enable tax for a subscription, you can pass in `automatic_tax`:
38 |
39 | ```ruby
40 | @user.payment_processor.subscribe(plan: "growth", automatic_tax: { enabled: true })
41 | ```
42 |
43 | For Stripe Checkout, you can do the same thing:
44 |
45 | ```ruby
46 | @user.payment_processor.checkout(mode: "payment", line_items: "price_1234", automatic_tax: { enabled: true })
47 | @user.payment_processor.checkout(mode: "subscription", line_items: "price_1234", automatic_tax: { enabled: true })
48 | ```
49 |
50 | ### Pay::Charges
51 |
52 | Taxes are saved on the `Pay::Charge` model.
53 |
54 | * `tax` - the total tax charged
55 | * `total_tax_amounts` - The tax rates for each jurisidction on the charge
56 |
57 | ## Next
58 |
59 | See [Stripe Checkout & Billing Portal](8_stripe_checkout.md)
60 |
--------------------------------------------------------------------------------
/test/support/fixtures/paddle/subscription_payment_refunded.json:
--------------------------------------------------------------------------------
1 | {
2 | "alert_id": "39297549",
3 | "alert_name": "subscription_payment_refunded",
4 | "amount": "671.87",
5 | "balance_currency": "EUR",
6 | "balance_earnings_decrease": "0",
7 | "balance_fee_refund": "0.35",
8 | "balance_gross_refund": "0.68",
9 | "balance_tax_refund": "0.52",
10 | "checkout_id": "9-3eafeae89135527-aca34a910b",
11 | "currency": "USD",
12 | "earnings_decrease": "0.83",
13 | "email": "glindgren@example.org",
14 | "event_time": "2020-11-16 16:52:45",
15 | "fee_refund": "0.76",
16 | "gross_refund": "0.17",
17 | "initial_payment": "true",
18 | "instalments": "2",
19 | "marketing_consent": "",
20 | "order_id": "4",
21 | "passthrough": "{\"owner_sgid\": \"BAh7CEkiCGdpZAY6BkVUSSIiZ2lkOi8vZHVtbXkvVXNlci8xP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIhJwYWRkbGVfMTIzNDU2BjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--0ee181d81896a7e0ef25d322c6081df1ec534cdd\"}",
22 | "quantity": "28",
23 | "refund_reason": "refund_reason",
24 | "refund_type": "partial",
25 | "status": "active",
26 | "subscription_id": "7",
27 | "subscription_payment_id": "1",
28 | "subscription_plan_id": "6",
29 | "tax_refund": "0.04",
30 | "unit_price": "unit_price",
31 | "user_id": "7",
32 | "p_signature": "rGXTK+5AM9/MXo0/YQUPKTxT1UfPOdlmTeBfRu5KFFdmXgTAKs6lkiFujnGUdXNr9n58gQ87BhITSnzAYxrHQZPStzQ810S8r/ES0xKeObueDLt/0Pf8NrV5w9C6m26ZMVo/2NqkH2qx23DJRfJ02c7Bm8LIEuT3bLvJsgIbxHyOh50GooAyg9vn/k6XAelbbt8uOoSEte5kU12THautegClljemjl7iASk1QzuAGc7kYFvoj92zZp+hed/5xhkEveKNR7PAh8zhq6u+yyXaySg4M/41S6dFLrfyAf+tYiClewGN1B3o0ZsXK6fqNPQ4ObCO8QAOm9a69oDAQHkm14SuhNuVYZiMzxwaahGfT0sUs0zT3cFsSjZSChNt+LxN30F51FKYvwz+NF1edQWH6pFKC0RXRasKUhbY5/4lUNpxWt/fF9PCQTqNDnXIOl7NlTW4v+SZ5WkjMKkzDk9nM97jQHeowCdCwLxgaraITyjpY8bUf5KEAWZK890BwQTM/gUrv/IhBTgtZdU9AF4C9Ql/p0I4KW7kHQIpoll1ijEScS6PELNftxEmsB54UK2G8N5YudGaZAzIPDDPaG/HEu4JTVAy5dlxi8/LkkRQ02UAzwIOQ28e8YdUSwwUSTFE976coT9AK+OyYZimdsfDCZgKq2Hqc2IuZRTVBMc1wsg="
33 | }
34 |
--------------------------------------------------------------------------------