├── 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 |
2 |
3 | Update Payment Information 9 |
10 | 11 |
12 |
13 |
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 |
2 |
3 | Subscribe 4 | 5 | 15 |
16 |
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 |
2 |
3 | Buy now 4 | 5 | 15 |
16 | 17 |
18 |
19 |
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 | 8 | 9 | 10 |
11 | 12 |
13 | 16 |
17 | 18 |
19 | 20 | 21 | 22 |
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 | 13 | 14 | 15 |
16 | 17 |
18 | 21 |
22 | 23 |
24 | 25 | 26 | 27 |
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 | 13 | 14 | 15 |
16 | 17 |
18 | 21 |
22 | 23 |
24 | 25 | 26 | 27 |
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 | --------------------------------------------------------------------------------